要約
Wifiは無いに等しいと考えること。
(来場者1万強/日 なんていう状況下でWifiが動くと想定するのが駄目でした)
進捗管理する第三者を設けること。
ソースコード
https://github.com/Na4Yu/EasyEats
(RTDBのURLやSquareの個別キーは抜いているのでそのままは使えないです)
はじめまして
はじめまして、高校2年のNaYuです。
今回は文化祭で派手に失敗した話をさせて頂きます。
血反吐を垂れ流しながら書いていましたが、もし皆さんが文化祭を経て「この人のしたことをしなくて良かった~」なんて言っていただければ幸いです。(人の不幸は蜜の味)
お願い
本記事は知見の共有を目的として個人が執筆したものであり、本記事の内容について学校、学校関係者への問い合わせはご遠慮頂けるようお願い申し上げます。
これを読んでいる後輩の方々へ
この記事が私からの引き継ぎになります。
来年行うか行わないかは皆さん次第ですが、いずれにせよ私は応援しています。頑張ってください。
NaYu 2023/6/20
背景
問題点
例年、弊校の文化祭で問題となっていたのが食品の待機列でした。
屋内と屋外に食品店舗があるのですが、混雑緩和のために屋外に人気店舗(焼きそば、たこ焼き、焼き鳥)を置いていました。にも関わらず、昼食時には屋外に大行列ができ、しまいにはイベント用のスペースにまで食い込む始末でした。加えて各店舗ごとの列だったので、来場者を一種類の食品を購入するのに最大で1~2時間待たせていました。
この問題の大きな要因は、食品の調理時間と決済にかかる時間の差です。
会計の部門員(我々の文化祭では部門という制度を導入しています。会計担当の店員ということです)の主な仕事は
注文を聞く -> 金額を要求(電子決済ならば端末等での読み取り)
だけです。遅くても2~3分で一団体終わります。
それに比べて、不可抗力ですが、食品の調理は時間がかかります。例えば焼きそばですが、会計と違って持続的に提供できるものではありません。当然まとめて調理するので断続的な提供になってしまいます。
調理のミスや、シフトをサボる店員だって出てきます。(シフトをサボった生徒を後日生徒集会で呼び出す案が出てたのは秘密です)
色々積み重なって、会計にかかる時間<<<<<<食品の調理時間 になっていました。
また、前述した一店舗からしか注文できない問題もあります。
空いている店舗が視覚的にわかったので全店舗満遍なく並んでもらえるという利点もあります。
が、やはり一種類の食品しか食べれないというのは来場者に申し訳ないですし、調理している生徒も可哀想でした。
冬休み
確か私が実行委員長にこのシステムの提案をしたのは冬休みでした。1月の上旬あたりに実行委員長とVALORANTをしながら真面目に会議していた時に私が言い出しました。
当初の名前は「食券システム」でした。
TwitterのTLとかで他校も入場管理システム等を作成しているのを見て、委員長が他校がやったことのないことをやりたいという思いがあったのも一因であると思います。
まあ私の場合は失敗したんですがね!(涙目)
EasyEats
ということで本題です。もちろん、当初思い描いていたシステムとはちょっと異なりますが、最終的に落ち着いたものを仕組みだけでなく具体的なコード等も含めてここに書きます。
ちなみにですがEasyEatsは私が「EasyEat」と提案したところ実行委員長がUb○rEatsみたいにs付けようぜ、と言い「EasyEats」になりました。名前は気に入っています。
EasyEatの由来は前述した問題点の解消を目的としたシステムだったので、「簡単、楽」に「食べれる」という意味を組み合わせました。
概要
EasyEatsは、簡単にいうとマ○ドです。(職員会議でもこの単語を使って説明しました)
某チェーン店も、実質同じ問題を解決しています。注文を終えた後、その注文と結びついている何か(マ○ドの場合は注文が書かれたレシート)を渡します。そして調理が完成したらその番号を注文者が見えるように表示し受け取りに来てもらいます。
これを再現すれば、待機列を解消できるのではないか、ということです。
EasyEatsの仕組み
まず、お客様には会計レジ(計6レーン)に並んで頂き、注文をしてもらいます。そして、その場で決済をし、代わりに食品引換券を受け取ります
上の写真のような引換券です。この引換券を持って、番号がテレビモニターに表示されるのを待ち、表示されたら食品の受け取り所で引換券と交換して頂きます。
(テレビモニターの見た目)
ちなみにですが、Crepe、Grilled Chicken、Sausage、Takoyaki、Yakisobaの頭文字です。今記事を読んでいる画面の前の貴方も思ったでしょう。分かりづら!
こればっかりは分かりづらかったと思います。会計の方には申し訳ないです。
また、店舗ごとに別にした理由は単純に調理時間の差と人気の差です。
例えばですが、クレープ等のデザートは一日中注文が見込まれます。それに比べて、焼きそばや焼き鳥は昼間の注文数が増えることが見込まれます。
昼間以外はいいのですが、昼間になるとクレープは受け取れるが焼きそば焼き鳥が完成していないので受け取れない、なんてことが起きてしまいます。
もし注文を一つにまとめてしまうと(そっちの方が開発は断然楽でしたが)注文した品が一つでも完成していないとお客様は受け取れません。流石に食品を放置するのは良くないので、店舗ごとに分けました。
食品側は、会計レジの奥にある各店舗がそれぞれiPadで注文を受け取り調理します。ただピーク時はフル回転で調理したとしても追いつかないのでこのiPadは気休めみたいな所がありました。
調理した食品は受け取り所に運ばれ、受け取り所にいた幹部がその注文の状態を変更します(受け取り可、受け取り済み)。受け取り可になるとテレビモニターに注文の番号が表示され、受け取り済みになると番号が表示されなくなります。
ただ、この表示に関してですが、何百人と溜まる未来が見えていたので、一番大きい番号(最新の注文)を5人分表示する方式にしました。
このシステムにより、理論的には(←ここ重要)お客様が列で待たずに調理中は展示やイベントに参加でき、複数の店舗からの注文が可能になります。
技術面
そもそも私はサーバーをいじった経験がない底辺エンジニアなので機能はほとんどアプリに載せることにしました。
言語ですが、生徒会が所有しているiPadが20台あり例年これで電子決済をしてきていたので今年もこれを使いました。なのでSwiftです。ちなみに1月の時点でSwift処女です。(ここまで全部事実です)
バックエンドはFirebaseに任せました。RTDB(Realtime Databaseの略)は文献が少ないですが慣れると汎用性が高く開発が簡単です。おすすめです。
遡ること12月
順序がぐちゃぐちゃですが12月に幹部の任命、集会等がありました。そうです、
《おやおや、NaYuの様子が....》です。
技術部門長になりました。当時は無邪気に喜んでいたNaYu(高1)ですが、技術部門長になる=予算審議、進捗管理、先生との交渉 です。なんで自分のシステムの進捗管理できないやつが技術部門長なってるんだよ、なんて言わないでください。そろそろ出血多量です。
解説
変な言い訳が入りましたがシステムの解説です。
GitHubを見れば一目瞭然ですが、このシステムは5つの部品でできています。
結構基本的なところもメモがてら書いているのでもし分かりきった内容でしたらスキップすることをおすすめします。
PosApplication
POSはPoint of Saleの略です。会計アプリのことです。
ぶっちゃけると、この会計アプリは決済だけなら必要はありません。Square純正のアプリでもメニューを作成して決済できるのでそれを使ってもいいです。が、メニューを店舗ごとに分けれないので焼きそばの会計端末にもクレープの商品が表示されてしまいます。もし我々のように食品店舗が多い場合はメニュー部分だけ個人開発してSquareApiを叩くのをおすすめします。SquareApiは値段と決済方法を指定すると純正アプリに飛んでそっちで決済してくれるので楽な上バグることもないので便利です。(決済アプリを個人で開発してバグったときの責任なんてとれるわけがありません)
さて、一番重いアプリなので結構解説が長めになります。
仕組み
今回のアプリは決済だけでなくお客様の注文情報を保存するDBも関係してくるので、FirebaseAuthをログインに使いました。ログインするとFirebaseのライブラリにユーザー情報を保存してくれるので、アプリ側の読み取り、書き込み関数の呼び出し時にユーザー情報に一切触れなくて大丈夫です。DBの認証ルールにAuthの識別キーを設定するだけで(後述)読み取り、書き込みを制限してくれます。Firebaseいつもありがとう。
ログイン後は、すぐにメニューに飛ばされます。このメニューからお客様の食品を選び、決済に進みます。
さて、ここからが面倒な所になります。
マ○ドは便利なことにレシートに表示される番号はレジ番号+注文番号なので、面倒な処理は必要ありません。レジごとにInt型を用意して注文ごとに+1するだけです。
しかし、今回は何百と注文が蓄積することが見込まれたので、テレビモニター全ての番号を表示はできません。なので、複雑な番号はできるだけ回避すべきです。単純に昇べきの順で注文番号を割り振る必要があります。そのため、サーバーに注文を送った上、返答を待つ必要がありました。
サーバー側の処理が多く絡んでくるので、ここらへんは後述するサーバー側の話に書こうと思います。今のところはサーバーに情報を送り、注文の番号が返ってくる、という認識でお願いします。
また、決済の前に発券(引換券の印刷)を行う理由ですが、単純にバグ防止です。当然注文の番号はサーバーから返ってくるものなので、番号が返ってきたら注文が正しく処理されたと確認することができます。決済のあとに行うと、発券時にサーバーとの通信で問題が起きて情報が送れなかったら、ただの金銭泥棒です。注文がなくなるよりは、注文の重複がマシです。
上にもあった引換券はブラザー社のラベルプリンターで行いました。このラベルプリンターは2個上の先輩方が2年前の文化祭で入場管理システムをやった時のものです。(コロナが下火だったのもそうですが、2年前に入場管理システムをやったので、今年は別のシステム作ろう、となりました)
このラベルプリンター(QL810W)はなんと海外製でした。安かったらしいです。が、流石に苦戦しました。文献も少なく、印刷が成功した時は感動しました。
印刷には同一ネットワークのIPに対してPDFを送り印刷します。PDFはPDFKitを使用して番号ごとに適宜作成、印刷しています。IPアドレスはそれぞれのプリンターに固定IPを設定して印刷しました。この印刷関係で相談に乗ってもらったS先輩(大1)には頭があがりません。受験終わってすぐだったのですが、いつでも相談に乗ってくださいました。ありがとうございます。
決済は前述の通りApiを叩くだけです。Square Reader(クレカやSuicaを読み取る機械)はiPadにBluetooth接続するので楽です(どこぞのBluetooth対応してなくてIP使わなければいけないプリンターと違ってね!)。
管理者設定もアプリに設けていて、メニューの変更や叩くプリンターのIPの変更などできるようにしました。
仕組みを一通り説明させて頂きました。以下は私が複雑だと思ったコードを部分的に解説します。
PDFKit
普通に訳が分からないです。
final class CreatePDF: PDFPage {
private let width = 620
private let height = 290
private var shopType = "C"
private var num1 = 0
private var num2 = 0
private var keyString = ""
private var ordernum = "0000"
init(shopType:String, num1:Int, num2:Int, keyString:String, ordernum :Int) {
self.shopType = shopType
self.num1 = num1
self.num2 = num2
self.keyString = keyString
self.ordernum = String(format: "%04d", ordernum)
super.init()
}
override func draw(with box: PDFDisplayBox, to context: CGContext) {
super.draw(with: box, to: context)
UIGraphicsPushContext(context)
context.translateBy(x: 0.0, y: CGFloat(height))
context.scaleBy(x: 1.0, y: -1.0)
let alignment = NSMutableParagraphStyle()
alignment.alignment = .center
"<EasyEats 食品整理券>".draw(in: CGRect(x: 0, y: 0, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans W6", size: 35) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
"\(shopType) \(ordernum)".draw(in: CGRect(x: 0, y: 45, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans W6", size: 120) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
"注文識別キー:\(keyString)".draw(in: CGRect(x: 10, y: 260, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans W6", size: 25) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
switch shopType {
case "C":
"食品名:\(num1)\n食品名:\(num2)".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
case "G":
"食品名:\(num1)".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
case "S":
"食品名:\(num1)".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
case "T":
"食品名:\(num1)".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
case "Y":
"食品名:\(num1)".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
default:
"印刷ミス\n印刷ミス".draw(in: CGRect(x: 0, y: 180, width: width, height: height), withAttributes: [NSAttributedString.Key.font: UIFont(name: "Hiragino Sans", size: 30) as Any, NSAttributedString.Key.foregroundColor: UIColor.black, NSAttributedString.Key.paragraphStyle: alignment])
}
}
override func bounds(for box: PDFDisplayBox) -> CGRect {
return CGRect(x: 0, y: 0, width: width, height: height)
}
}
func TicketPDFHandler(orderkey:String, c1:Int, c2:Int, cnum:Int, g1:Int, gnum:Int, s1:Int, s2:Int, snum:Int, t1:Int, tnum:Int, y1:Int, ynum:Int, completion: @escaping (URL?) -> Void){
let pdf = PDFDocument()
if (y1 != 0) {
pdf.insert(CreatePDF(shopType: "Y", num1: y1, num2: 0, keyString: orderkey, ordernum: ynum), at: 0)
}
if (t1 != 0) {
pdf.insert(CreatePDF(shopType: "T", num1: t1, num2: 0, keyString: orderkey, ordernum: tnum), at: 0)
}
if (s1+s2 != 0) {
pdf.insert(CreatePDF(shopType: "S", num1: s1, num2: s2, keyString: orderkey, ordernum: snum), at: 0)
}
if (g1 != 0) {
pdf.insert(CreatePDF(shopType: "G", num1: g1, num2: 0, keyString: orderkey, ordernum: gnum), at: 0)
}
if (c1+c2 != 0) {
pdf.insert(CreatePDF(shopType: "C", num1: c1, num2: c2, keyString: orderkey, ordernum: cnum), at: 0)
}
if let url = FileManager.default.urls(for: .documentDirectory, in: .userDomainMask).first {
let path = url.appendingPathComponent("output.pdf")
print(path)
pdf.write(to: path)
completion(path)
}
completion(nil)
}
まず一番上でPDFの設定と関数で使いたい変数を定義します。次に初期化。ここまでは理解ができます。
次に実際のPDFを作っていきます。こんな感じの定型文です。
"文字列".draw(in: CGRect(x: "x座標", y: "y座標", width: width, height: height(これは最初に定義した1ページの大きさ)), withAttributes: [NSAttributedString.Key.font: UIFont(name: "フォント名", size: "フォントサイズ") as Any, NSAttributedString.Key.foregroundColor:色, NSAttributedString.Key.paragraphStyle: 文字の配置(alignmentは上のコードで定義してあります)])
こればっかりは適宜PDFを出力して試してみるしかありません。地道な作業ですが頑張りましょう。
pdf.insert(CreatePDF())
このCreatePDFでPDFを作成します。必要な枚数分そのデータと一緒に呼び出しましょう。
店舗を逆の順で呼び出す理由はInsertがPDFの一番上に挿入するからです。こうすれば印刷時には一番上に最初の店舗の引換券が来ます。
あとは適当にPDFに名前をつけてファイルパスを返しましょう。
引換券の印刷
需要がない気がしますが一応置いておきます。
func printPDF(path: URL, ip:String) -> String{
let generateResult = BRLMPrinterDriverGenerator.open(BRLMChannel(wifiIPAddress: ip))
guard generateResult.error.code == .noError,let printerDriver = generateResult.driver else {
return "Error- Open Channel: \(generateResult.error.code)"
}
defer {
printerDriver.closeChannel()
print("Channel Closed")
}
guard let printSettings = BRLMQLPrintSettings(defaultPrintSettingsWith: .QL_810W)
else {
return "Error- Image file not found"
}
printSettings.labelSize = .dieCutW62H29
printSettings.autoCut = false
printSettings.cutAtEnd = true
printSettings.hAlignment = .center
printSettings.vAlignment = .center
printSettings.scaleMode = .fitPageAspect
let printError = printerDriver.printPDF(with: path, settings: printSettings)
if printError.code != .noError {
return "Error - Print Image: \(printError.code == .filepathURLError)"
} else {
return "Success"
}
}
実はこれだけです。コードはこれだけですがWifi接続は普通に53です。私の集中力並みに断続的でした。
let generateResult = BRLMPrinterDriverGenerator.open(BRLMChannel(wifiIPAddress: ip))
まずプリンターとの回線を確立します。私は固定Ipを利用しましたがブラザーの文献によると同一ネットワークのプリンターを探すこともできるらしいです。まあ面倒なのでしませんが。
勿論通信なのでエラー処理は丁寧に行いましょう。(とか言っておきながら意味ないです。後述)
次に印刷設定を決めます。
QL_810Wはプリンターの型番です。
LabelSizeは印刷するラベルの大きさです。色々な種類がありますが今回は62mm×29mmを使いました。WはWidth、HはHeightです。
AutoCutは一枚ごとに切るか、です。別に一枚ごとに切っても良かったですが台紙はまとめた方がゴミがまとまって片付けやすいです。一枚ごとだとゴミが増えます。
CutAtEndは一番最後に切るか、です。プリンターにカットボタンがあって手動で切れますが、いつ印刷が終わったのがわかりにくいので基本Trueを推奨します。
AlignmentはPDFデータの位置です。どこでもいいですが多分Centerが一番キレイに見えます。
ScaleModeはPDFデータの拡大縮小のことです。Swiftで定義した縦横は謎の単位を使っているので、実際に印刷した際に正しい大きさだとは限りません。そのためSwiftでPDF作成時には縦横比を正しく設定して、印刷時にブラザーに描画をしてもらうのが妥当です。
あとは印刷するだけです。
Square決済
Squareの決済のコードです。
import Foundation
import SquarePointOfSaleSDK
enum PaymentMethod {
case cash
case card
}
func payBySquare(price:Int,store:String,method:PaymentMethod,completion: @escaping (Bool?) -> Void){
SCCAPIRequest.setApplicationID(applicationID)
do {
let money = try SCCMoney(amountCents: price, currencyCode: "JPY")
let apiRequest =
try SCCAPIRequest(
callbackURL: callbackURL,
amount: money,
userInfoString: nil,
locationID: locationID,
notes: "\(store)",
customerID: nil,
supportedTenderTypes: (method == .card ? .card :.cash),
clearsDefaultFees: false,
returnsAutomaticallyAfterPayment: true,
disablesKeyedInCardEntry: true,
skipsReceipt: true)
try SCCAPIConnection.perform(apiRequest)
completion(true)
} catch let error as NSError {
print(error.localizedDescription)
completion(false)
}
}
import Foundation
let callbackURL = URL(string: "posapplication://")!
let applicationID = "AppId"
let locationID = "LocationID"
Squareのアカウントを作成するとデベロッパーページに行けます。そのデベロッパーページから自分のアプリを登録してIDを取得、それをぶち込みます。
CallbackUrlはアプリ遷移のためのUrlです。
LocationIDは国?のIDです。どうやら国によってSquareの手数料が違うそうです。
上の情報は結構センシティブな情報なのでPrivate.swiftとかに記述してgitignoreすることをおすすめします。
決済の作成はコードを見れば自明だと思います。唯一難しいと言えばSupportedTenderTypesでしょうか。これは決済手段を指定するもので、もしCardにした場合はSquareアプリでは電子マネーやクレジットデビット用の画面、Cashの場合はただの値段を表示する画面に飛ばされます。
じゃあ現金の場合はSquare使う必要ないじゃん、となりますがSquareは現金を含む全ての決済を記録して、あとでCSVとかで出力できるので文化祭後の売上計算が楽になります。
以上がPosアプリの説明になります。
バックエンド
これが二番目に重かったです。JS初心者でしたので、ありとあらゆる文献を読み漁り、なんとか一週間でサーバーのソースを書き終わりました。
サーバー側の処理ですが、まずは排他制御というものを解説したいと思います。
同時書き込みという問題点
DBに書き込みする関数ですが、主に2つに分けれると思います。
1.書き込み場所を指定するタイプ
2.DBの一番下にプッシュするタイプ
今回のシステムですが、各注文にInt型の番号注文を割り振る必要があります。
普通はDBにカウンターノードを設けて、その数字をノード毎に+1して新しいデータをその得た数値を注文の親ノード名にするだけでいいです。
ですが、ここで出現する問題が一つあります。
カウンターの加算ですが、当然ノードを一回取得した後にその数値をクライアント側で処理、再度DBに書き込む必要があります。では、この時に複数のクライアントが同時に値を取得した場合はどうなるでしょう。
別々の注文のために取得した注文番号が同じになる、ということが起きてしまいます。この情報でタイプ1の書き込みを行うと、同じノードに別々の注文を何度も上書きしてしまいます。すなわち、注文データが失われてしまいます。
これは大問題です。クライアント側からすれば無事に注文番号を取得できたので、サーバー側でデータが消去されているとも知らずにそのまま発券、決済に進んでしまいます。クライアント側は当然全ての注文を記録しているわけではないので、お客様の注文のデータが完全に消失します。
排他制御(RTDB)
排他制御とは、要約すると「他のトランズアクションを考慮したデータ保管機能」です。
排他制御には2種類、それぞれ「悲観ロック」と「楽観ロック」と呼ばれています。
噛み砕いていきます。
前述した問題点ですが、これはカウンターへのアクセスを制御すれば問題が解決します。一つのトランズアクションが起きている間、他のトランズアクションをブロックする。これが悲観ロックです。
(トランズアクションはサーバーとのデータのやり取りと考えてください。)
楽観ロックは名前の通り楽観するのでトランズアクションの重複が発生しないことを前提にトランズアクションを行います。読み込みを制御するのではなく、楽観ロックの場合はデータ更新時に元のデータ(取得したデータ)が現在も正しいか確認する方式です。詳しくは次の記事をお読みください。とても参考になりました。
楽観ロックと悲観ロックの違い
話を戻しますが、今回はカウンターに悲観ロックを用いました。
キューDB
さて、排他制御を実装できたのはいいですが、排他制御は一定回数上限を超えるとエラーを吐いてしまいます。Firebaseの場合は確か5回でした。要するに、読み込み、書き込み時に5回以上他のトランズアクションが動いていた場合(稀ですが起こります)はクライアントでエラーを吐いてしまいます。これではクライアント側の処理が多くなってしまいます。
ある程度Wifiが重いのは想定内でしたので、クライアント側の通信を軽くするためにキューDBを用いました。
前述の通り、DBにはプッシュという便利なものがあります。住所を指定せずにノードを追加できるスグレモノです。
なので、Posアプリに戻りますが、Posアプリは注文時にキューDBに注文内容をプッシュします。このプッシュした注文は勿論重複しないので、これで安全にサーバー側で排他制御を行えます。
皆さんはここで思うと思います。
何故そもそも排他制御の代わりにプッシュを使わないのか、と。カウンターなんて使わずにプッシュすれば注文は番号順になるだろ、と。
確かにプッシュは重複が起こりませんが、Firebaseのプッシュ関数は親ノード名をその時刻を元にした十数桁の英数字にしてDBに追加します。
利点としてクエリをかけた場合は時刻順に並ぶことですが注文番号には用いることができません。
このプッシュしたデータをもう一度カウンターにかけて排他制御を行う必要があります。
これが今回のシステムにおける注文番号の重複防止の面倒な部分です。
メインDB
最後にメインDBとなります。
ぶっちゃけDBを分ける必要はないですが、アプリごとに乗っけるDBを整理できたので分けました。
キューDBにプッシュされた注文はトランズアクションで注文番号を取得します。
取得された注文番号は、このメインDBに店舗ごとのノードにそれぞれの注文番号を親ノードとして追加されます。
main--c--1--フルーツクレープ:1, チョコクレープ:2
|
--2--フルーツクレープ:3, チョコクレープ:0
|
--g
|
·····
メインDBを視覚化した図になります。
実際は注文番号のノードにBool値や注文識別キーなどありますが、主な情報は上の通りです。
排他制御とキューDBのコード解説
キューDBの排他制御とかのコードはBackend/queueprocessにあると思います。
ここは実際にコードを用いて説明させて頂きます。
関数自体はFirebaseFunctionsに乗っけて使用しています。(有料だが実質無料)
function checkCounterTransaction(currentData) {
if (currentData === null || currentData === undefined) {
currentData = 0;
}
return currentData + 1;
}
これは単純にカウンターを+1するものです。
排他制御は、このカウンター関数をトランズアクションとして実行します。
async function checkTransaction() {
if (c1+c2 !== 0) {
let returnresult = null;
const counterTransactionPromise = counterc.transaction(checkCounterTransaction)
.then((result) => {
if (result.committed) {
return result.snapshot.val();
} else {
console.log(`C:Counter Transaction Aborted for ${orderKey}`);
return null;
}
});
returnresult = await counterTransactionPromise;
const cData = {
[returnresult]: {
1: c1,
2: c2,
"serveable": false,
"served": false
}
};
const ckey = {
[orderKey]: {
"keynum": returnresult
}
};
console.log(`C Logger Result = ${returnresult}`);
if (returnresult !== null) {
keyRefC.update(ckey)
.catch((error) => {
console.error(`C:Error Setting KeyLegend for ${orderKey}`)
})
ticketRefC.update(cData)
.then(() => {
console.log(`C:Data Updated Successfully for ${orderKey}`);
})
.catch((error) => {
console.error(`C:Error Setting Data for ${orderKey}`);
});
}
}
上のコードですが、
countercはC(クレープ)カウンターノードへのRefです。
このcountercに対してカウンター増加関数をトランズアクションとして行います。
これが
counterc.transaction()
です。
サーバー書き込みなので、当然Promiseを返します。
このPromiseをAwaitして、その後注文を作成します。
1,2は品目です。次に続くBool値は前述した「受け取り可能」と「受け取り済み」のBool値です。
これを操作することによりテレビの表示を操作できます。
もう一つデータを作成しているのが分かると思います。プッシュ関数の話ですが、プッシュ関数はその送信した時刻の英数字を返します。
なので、この英数字と注文番号を結びつける必要があります。これが今から作成するデータです。
このデータは親ノードが時刻の英数字、子ノードが注文番号というシンプルなものです。
Posアプリは注文番号を取得したいのであってわざわざ注文内容をもう一回取得する必要はありません。なので別のノードを作成してこの下にこの注文番号データを追加しています。Posアプリはこのノードを辞書のように使ってプッシュ時の文字列から実際の注文番号を取得しています。ちなみにですが、このプッシュ時の文字列に重複がないことを利用して注文識別キーとして使っています。万が一サーバー側の処理で重複が発生した場合、その整合性が取れやすいためです。
クエリについて
Backendファイルには上のqueueprocess以外にfilterという名前がついたファイルが15個ぐらいあると思います。
これらの関数はBool値(受け取り状態)によってサーバー側でクエリを常時実行(指定した範囲が更新されたときに)します。
ではなぜサーバー側かというと、単純にクライアントで行うのは重かったからです。これは後述します。
さて、クエリを実行したものを別のノードに書き込みます。このノードこそが「orders」,「serve」,「display」です。
テレビの場合はdisplayノードを、食品店舗の場合はordersノードを、とそれぞれ用途に分けてノードを表示するだけです。
この場合、
ordersノードは受け取り不可
serveノードは未受け取り
displayノードは受け取り可能、未受け取り
と分類分けしています。
具体的に解説すると、食品店舗ではまだ調理していない注文だけを表示します。そのため、もし受け取り可能になった場合はもう表示する必要がありません。このため、条件は受け取り不可のみになります。
食品受け取り所では受け取り状態を操作する必要があります。そのため、基本的に全て表示しますが、すでに受け取りされた注文は表示していると混乱します。なので、受け取り済み以外を表示します。
最後にテレビに表示するノードですが、これは単純に受け取り可能、未受け取りが条件になります。まあ自明ですね。
クエリの関数自体は簡単なものなので、解説は省きます。
あとめっちゃノードノードと言っていますが、Firebaseの米文献が「node」を多用しているため癖になってしまいました。日本語だと違和感を覚えますが実際にこの単語を使うのでしょうか?有識者お願いします。
DB所在地とFunctionサーバー所在地について
ちょっと深い話ですが、Firebaseはサーバーの所在地を指定できます。
基本的にはDBの所在地はシンガポール、そしてFunctionサーバーは東京になります。
計測したところ、シンガポールと東京間で行うと一回の関数の実行が平均50ms、ピークで500msを記録しました。
また、DBのサーバーはシンガポールしかないので、Functionsサーバーをシンガポールに変えたところ平均が20ms、ピークでも100msにとどまりました。
上にも繋がる話ですが、わざわざ東京(学校)からシンガポールのサーバーに対してクエリを行うより、シンガポールサーバーにFunctionsを乗っけて、自立型で動かす方が軽いよね、という考えです。
以上がサーバーの説明になります。
他のアプリ
ぶっちゃけ他のアプリは簡単なので軽く説明します。
Kitchenアプリ
調理場に置いて注文を見るアプリです。サーバーの所で記述したordersノードをリストに表示するだけです。
FoodDeliveryアプリ
食品受け取り所のアプリです。これを使って注文の状態を変更します。なのでリストにonPressedでトグルスイッチを2つ表示するだけです。これも簡単です。
Displayアプリ
最後にテレビに数字を表示する方法ですが、Wifiは当然遅いので、Chromecast等は使えません。なので、HDMIケーブルを買いました。150メートル分買いました。
このHDMIケーブルをスプリッターを経由させ、MacBookに繋げました。なのでこのアプリはMacOsで動きます。
このアプリはdisplayノードから後ろから5個分の注文番号を取得して表示するものです。
ちなみに150メートルは巻くのがとてつもなく大変です。筆者はウエストが大体63cmなので体に巻きつけて纏めさせられました(使役)。他幹部に巻きやすいと言われました。それ褒めてないよね
開発日記
アプリの説明が一通り終わったので、次に今回のシステムの開発日記兼酷すぎる進捗を共有させていただきます。
1月
このぐらいに企画が決まりました。まずは企画書を書いたりとかで1月は終わりました。
2月
まだ企画書のままです。やはり難しく企画なだけあってなかなか職員会議を通りません。
2月10日
企画が通りました。
即日備品購入しました。
3月
生徒へ調査フォーム送ったり先生との話し合いだったりと、部門長系の仕事が多くてまだ手つかずです。ちなみに期末試験は無残な結果となりなした。
春休み
やっと開発を始めます。Swift初心者JS初心者なので最初の二周間は普通に勉強です。
JSはやる気を出してなんとか一週間で終わらせました。未だに信じられません。
Swiftについては春休み中にKitchen、FoodDelivery、そしてDisplayアプリを完成させました。まあ楽だったので入門編ですね。
4月
いよいよ一ヶ月前で地獄を見始めます。高2なので授業を寝るわけにもいかず、毎日カフェイン漬けです。多分この月は体内のカフェイン濃度は血中のヘモグロビンの量より多かったのではないでしょうか。二度と経験したくありません。
じゃあいつPosアプリが完成したかというと、なんと文化祭前日です。アタオカです。
生徒の講習会に使ったアプリは開発途中のものでした。
前日
前日はアプリ開発もそうですが、機材の搬入とかも多く忙しかったです。
「バグ」が現れた!
Posアプリの項目で話したブラザーのエラー処理の話を覚えていますでしょうか。
なんと、ブラザーのプリンターはリスポンスのタイミングがおかしいのか正しく印刷できたかがキャッチできなかったです。仕方なく突貫工事で印刷ボタンと同じページに決済ボタンを置きました。
一日目
さて、迎えた当日。
開場後からiPadがタイムアウトし始めました。
もう最悪です。プリンターの故障だったりSquareの故障だったらシステムを維持したままでいいのですが、Wifiはもうお手上げです。すぐに先生の所に言ってシステムストップさせました。
この絶望感はなかなか味わえないものでした。
一日目夜
改善案を出します。
Wifiが必要な理由は、そもそも注文ごとの情報を共有しようとするからです。ならば、Wifiを使わなければいい、と思いつきました。
すなわち、食品1個につき引換券一枚です。
こう設定すれば非常に楽です。生徒会室はWifiが完備されていたので、生徒会室で何千枚と印刷して、それを会計レジの所に持っていく、という形です。
Displayアプリを少し改良して、数値部分を手動入力できるように作り替えました。
こうすれば、受け取り所で調理済みの食品の数を加算して表示すれば、その数は受け取れる引換券の番号に相当するようになります。
二日目
なんとか持ち直しました。
会計、食品、そして技術部門の幹部の皆さんの適応力が異常でした。
私が昨晩話した案を見事に再現してくださいました。本当に感謝です。
後日談
まず問題のWifiですが、先生と相談してもよくわからないままです。こればっかりはどうしようもないです。
もし来年行う場合は、屋内で販売するのもありかもしれません。
また、一切システムを動かさなかったわけではなく、10分程度動かしたのちにタイムアウトし始めて止めました。
その時はDBに記録できているのですが、早朝の時間帯で10分間の間に200注文程度処理していました。容量を見たところ、100Kbでした。もし本当に動かしていたら1Tbはいっていたかもしれません。少し恐ろしいです。
結果
打ち上げとかで他幹部と話したりしましたが、みなさんに労いの言葉をかけていただきました。
失敗したのは悔しいですが、結果同期と後輩の人達と一緒に働けて楽しかったです。
長くなりすぎるのも良くないので、ここら辺で話を切らせて頂きます。
最後に
最後までお読み頂きありがとうございます。
ただただ自傷してブラザーの批評をした記事でしたが、もし皆さんの文化祭や制作物の参考になれたのでしたら幸いです。
では、最後にですが、お世話になった方々へ一言。
実行委員長。共に考案して、そして私を信頼してGOサインを出してくれました。期待を裏切る形となってしまい申し訳ないです。感謝しても感謝しきれません。
執行、会計、食品部門幹部の方々。私が変なことを言い出したのにも関わらず賛同して頂き、その上一緒に計画、そして実行まで手伝ってくださいました。本当に感謝しています。
技術部門の幹部の方々。私がパンクしてて管理ができていない中、一人一人が動いて、そして私の無茶振りにも答えてくださいました。ありがとう、最高のメンバーでした。
文化祭、そして技術の顧問の先生方。私の無謀なアイデアを一蹴しても全然良かったのですが、それでも我々(最終的には技術ではなく幹部全体の企画書のようになっていました。)のアイデアに寄り添い、時には手厳しい意見を頂きましたが、最終的に信頼してもらい企画を通して頂きました。加えて技術の顧問の先生方は最終日まで機材だったりと多様な面でお世話になりました。本当にありがとうございます。
そして、最後になってしまいますが、迷惑をかけ、そしてお世話になった全幹部、全先生方、そして全生徒に感謝を申し上げて、本記事の締めとさせていただきます。
皆さん、ありがとうございました!
(終)
追記
たくさんの方に読んでいただいて光栄です。
少し後輩(高一)と話した内容を載せようと思います。
ローカル運用
何回かTwitterで見かけましたが、やはりローカルで動かすべきでした。
これを行わなかった理由が、
1.決済に用いるSquareはWifiに繋がって無ければいけない。
2.単純に手が回らなかった。
の2つです。
ルーターとの有線Lanも考えたのですが、これは学校の都合で行いませんでした。
生徒会が保有しているPCを利用しサーバーを建てて、iPadとケーブルで繋げて稼働するのがベストだったと思います。
電子決済はどうしてもWifi頼みになってしまうので、行わないのも手だったかもしれません。
プリンター
ブラザーのプリンターですが、これも魔物です。
有線Lanでも動かない時があり、本番では「印刷できない可能性があるから印刷できなかったら手書きで行こう」と話していました。
もし来年同じことを行う場合はプリンターの買い替え、手書き、もしくは事前印刷のどれかをしてもいいと思っています。
開発人数
来年はこの企画専用の幹部がいてもいいと思います。
流石に1人の開発は愚かでした。
締め
私はもうこれで文化祭は終わりになります。
来年、後輩がこれより遥かに優れたものを出すと思います。来年の文化祭を楽しみにしながら、そろそろ勉強に勤しみたいと思います。では。