生きることは忘れること

同人即売会用レジアプリを開発している話

突然ですが、同人即売会用のレジアプリを開発しています。

同人即売会にサークル出展するときの悩みの種の一つが、会計です。もちろん壁やシャッター1といった大手サークルとは比べものになりませんが、普通のサークルでも、来場者が訪れるごとに、注文を聞き取り、在庫を取り出し、お釣りの計算をして、頒布数を記録して、と狭いサークルスペースでたくさんのことをやらねばならず、てんてこ舞いになります。

おまけにきら同では、おかげさまでなぜかやたらと好評をいただき頒布部数が多めとなっている上に、次々に本を出して扱う種類数も増加してきており、こうした負担は増大する一方です。さらに、きたるコミックマーケット99では、サークル通行証の枚数が2枚、一般参加も大きく制限されることから、限られた人数でサークルスペースを運営する必要があります。加えて、会員個人の作品を委託頒布するという計画も持ち上がっており、これまで以上に頒布部数を正確に記録することが求められています。

以上のようなモチベーションで (というのは建前で単純にプログラミングの題材に飢えてたので) 、同人即売会用のレジアプリを開発しています。

また、独自に開発したアプリで記録を行うことにより、スムーズな処理が可能なユーザインタフェースとリアルタイムな記録の両立が可能になり、今後のサークル出展に向けたデータ(どの時間に混雑するか等)の収集にも役立つと思われます2

要件

ここまで述べてきたことのほか、同人即売会特有の事情なども踏まえ、簡単に要件をまとめると次のようになります。

アーキテクチャの検討

Webアプリ

タブレット・スマートフォン上でアプリを動かすには、アプリストア(Google PlayとApp Store)経由で配布するか、Webアプリとして動かすかの実質的に2択です。アプリストアにアプリを登録するには費用がかかりますし、利便性の面でもURLにアクセスするだけで使えるWebアプリの方に軍配が上がります。

問題はオフラインでの利用ですが、近年Webプラットフォームに導入されているProgressive Web App (PWA)と呼ばれる技術(より正確には、そのうちのService Workerと呼ばれるもの)を使えば、ネットワークに接続していないときでもWebアプリを動作させられます。

というわけで、Webアプリとして作成することに決まりです。

TypeScript + React + Next.js

根幹のフレームワークには、React + Next.jsを選定しました。オタクはReactが好き。Next.jsはそんなに好きでもありませんが、サーバ側でデータベースアクセスをするとか可変なURLを扱う(dynamic routingをする)とかになると生のReactではちょっと力不足です。それから当然TypeScriptです。オタクはTypeScriptが好き。

あと、UIライブラリとして(ついこの間Material UIから改称された)MUIも使っています。これはあまり本質的ではありませんが。

Prisma

いわゆるORM (Object-Relational Mapping) です。Node.jsのORMにはいくつか定番があるようで悩みましたが、TypeScriptによる静的型付けが優れているという噂でPrismaを採用しました。実際、(大したコードは書いていませんが)しっかり型が付いて楽しくコードが書けています4。オタクはTypeScriptが好き。

IndexedDB

ここからクライアントサイドも絡む話です。オフラインで動作させるという要件から、データを一時的にブラウザに保存させる必要があります。そのようなAPIにはCookielocalStorageやsessionStorageなどもありますが、ここではIndexedDBを使います。あまり見慣れないかもしれませんが、JavaScriptオブジェクトを文字列化せずにオブジェクトとして保存できたり、データの一部分だけを追加・削除するのも簡単だったり、今回の用途には向いています。ただ、単なる文字列の出し入れではなくさまざまな処理が可能なように仕様が複雑である上、データベースとして一般的であるところのSQLを使う関係データベース(リレーショナルデータベース、RDB)とも異なる作りであるため、特有なAPI体系でちょっと学習曲線は急かなという印象もあります。

また、IndexedDBは策定・普及がPromise時代に間に合わなかったためにイベントベースのAPIになっており大変使いづらいのですが、これをPromiseでラップしてくれるidbというライブラリがあり、併せて導入しています。さらにこのidb、TypeScriptサポートまでついてきて、自由度が高めなIndexedDBのAPIに対して(もちろん限界はありますが)型を書くだけである程度の一貫性を保証してくれます。オタクはTypeScriptが好き。

WebSocket

次にクライアントとサーバの間の通信です。普通にFetch APIでHTTPリクエストを飛ばしても良いのですが、せっかくなので(?)WebSocketを使ってみます。一応、次のような理由で正当化できると考えています。

後者は、HTTP/2とかHTTP/3とかでHTTPのオーバーヘッドも改善されつつありますが、WebSocketはペイロードが125バイトまでならオーバーヘッドであるフレームヘッダは48ビットなので、リクエストごとに必然的にヘッダやエンドポイント (URL) を伴うHTTPよりは小さくなることが期待できます5

実装としては、Socket.ioというライブラリがポピュラーですが、やや多機能で、オフラインどうこうを自分でハンドリングするには向いていないかなーという感じだったので、WebSocketだけのシンプルなライブラリを選びました(それもなんか複数あって困ったのですが……)。ちなみにクライアント側はブラウザの機能として組み込まれているのでそれをそのまま使います。

ユーザインタフェース

実装の話に移ります。この手のものの実装は目に見えやすいユーザインタフェースから取り掛かるのがモチベーション上優れています。前述のようにMUIを採用したので、リファレンスと睨めっこしながら使えそうなUI部品を選び出してきて組み合わせてやると一丁あがりです。

ポイントとしては、アプリの目的からしてとにかく操作性を優先することでしょうか。もとよりそんなに機能の多くないシンプルなアプリですが、入力時に使う画面は一つ、画面遷移もなく右下のボタンを押せばそれだけで1件分の入力が完了します。一方、同人即売会の売り子という過酷な運用を可能な限り支援できるよう、合計金額の自動計算に加え、お釣りの自動計算なども実装できればと考えています(「新刊1冊ごとに既刊各種をそれぞれ1冊まで500円に割引」などという邪悪な価格設定がなされ、合計金額の自動計算をするためにそれなりのデータ構造を考案しなければならなくなりました。実はここがまだできていないので困っています。冬コミはその場しのぎの実装で逃げようかな……)。

また、開くだけでいきなり使え始めるように、ユーザ登録などの事前作業も省けるようにしています。一方で誰が入力したデータかは分かったほうがいいので、きららファンタジアの参戦作品とキャラクター一覧 (きららふぁんたじあのさんせんさくひんときゃらくたーいちらん)とは【ピクシブ百科事典】から抽出した名前を各クライアントに自動的に割り当てるようにしています。 この記事唯一のきらら要素

非同期処理と状態機械

次に中身の実装です。単純にデータの保存と送信をするだけならばそんなに苦労はありませんが、アプリの目的からして、突然オフラインになってもデータを絶対に失うわけにはいかないという要求を満たす必要があるので、丁寧に考えてやる必要があります。

そして、利用するAPIはすべて、いわゆる「非同期」のモデルになっています。IndexedDBは上で述べたとおりですし、WebSocketはそもそもリクエスト・レスポンスモデルではありませんからサーバへのデータ保存が完了したことは「メッセージの受信」というイベントで表現されることになります6。そんなわけで、登録ボタンが押されたあと、それが実際にIndexedDBに保存されるまでにかかる時間は「一瞬」ではありません。WebSocketの方はもっと酷く、その瞬間に接続が切断されたら「完了」しない可能性もあります。

さらにいうと、IndexedDBはそのAPI上データベースをまず「開く」必要があり、それが成功するまでは登録を受け付けてはなりません7。また、WebSocketは当然ながら最初に接続を開始してユーザ情報などの必要な情報をやり取りする必要があります。接続が切れてしまった場合の再接続試行もしなければなりません8

そして、これらを踏まえてユーザインタフェースの表示をおこなう必要があります。エラーならエラーを出し、WebSocketの接続状況を(どう役に立つか分かりませんが一応)出し、サーバに保存できていないデータがあるなら分かるようにし……。

そんなこんなをつらつら考えながら手元の紙に落書きをしていたら、謎の状態機械ができていました。実際の実装もこれをベースにやっておりますが、非同期は気を付けないとコードがすぐ崩壊していって大変ですね。

Service Worker

最後にService Workerですが、これはNext.jsと致命的に相性が悪くかなり苦労しています。根本的にWebpackを通るのでどのファイルにどんなリクエストが飛ぶかは非自明というところはworkbox-webpack-pluginでまだなんとかなりますが、そもそもNext.jsの思想としてもどのファイルにどんなリクエストが飛ぶかは実装の詳細に属する部類の話なのではないかという気がします。例としては、SPAを使っておりますと、つまりページ遷移をJavaScriptでおこなうようにしておりますと、URLに直接アクセスした場合はサーバサイドで生成されたHTMLが返されるのに対して、リンクで遷移した場合は必要なデータ(具体的にはgetServerSideProps)のJSON(とスクリプト)だけを取りに行くようになっており、同一のデータについてサーバからブラウザに送られる経路が異なっているという事態が生じます9。もちろん自分でService Workerのコードをフルスクラッチで書けばコントロールできますが、Webpackの出力結果も取り扱う必要があるとなるとそういうわけにもいきませんので、真面目に取り組もうとするとかなりしんどそうな印象です。今回はとりあえず動けばいいやの精神でそれっぽい設定だけ入れて誤魔化してありますが……。

あとがき

そんなわけで、ちょっとしたアプリでも変な(?)要件を盛るとけっこう考えることが多いという話でした(?)。プログラミングをするときに考えていることを文字に起こすということが(この記事が十分にできているかは疑わしいですがともかく)何かの糧に繋がるといいなと思っております。また、本アプリはGitHubでソースコードを公開しておりますので、よろしければpull requestなどお寄せください。

C99A当日も差し迫っていますが、なんとか残りの実装を終えて、スムーズなスペース運営に貢献できるよう努めてまいります。「東京大学きらら同好会」は、コミックマーケット99 1日目(12月30日木曜日)東5ホール ノ09aで新刊・既刊とりそろえてお待ちしております10。私は当日スペースにはおりませんが、どうぞよろしくお願いいたします。

おはようございます!きらら同好会です。
冬コミでの頒布物のおしながきを公開いたします!
会場頒布だけでなくメロンブックス様への委託も行いますので、当日参戦できない方は是非ご利用ください。よろしくお願いします! pic.twitter.com/66DjN06DSn

— 東大きらら同好会@1日目東ノ09a (@UTKiraraCircle) December 22, 2021

  1. 1. 行列に対応しやすいために混雑が予想されるサークルが配置される場所、およびそこに配置されるサークルのこと。
  2. 2. 6月のキラキラアーカイブ(俺達の動画工房)では、Googleフォームでリアルタイムに記録していました。一方、それでは頒布物の種類数が多い場合には操作性に難があるとして、10月のよんこま文化祭2021では紙ベースの記録表に記入する運用としており、リアルタイム性の高い情報を残すことができませんでした。
  3. 3. これにより、サークルスペースにいなくても完売ツイートができるといった御利益も得られます。普通はそんなことしませんが。
  4. 4. ちなみにどうやって型を付けているかというと、独自フォーマットのスキーマ定義から型定義ファイルを自動的に生成するという仕組みになっています。生成された型定義ファイルをちょっと覗いてみたら大変ゲテモノで愉快な気持ちになりました。デコレータとかを駆使してORMインタフェースそのものをTypeScriptで書く流儀もあり得ると思うのですが、あえてそうしないところがロックです(もちろんそのおかげでハイレベルなTypeScriptサポートを得られているのでしょうが)。
  5. 5. といいつつ速攻で手のひらを返すと、ヘッダやエンドポイント (URL) がHTTPリクエストに必然的に伴うものであるのは確かですが、HTTP/2やHTTP/3にはこれらをいわば「キャッシュ」する仕組みもあるので、そのオーバーヘッドが常に現実的なインパクトを有しているとまでは実は断言できません。ちなみに、HTTP/2では1リクエストでヘッダフレームとデータフレームの2個のフレームを送る必要があり、1フレームごとに72ビットのメタデータを伴うので、WebSocketの方がまだ有利でしょう(知らんけど)。HTTP/3のフレームはもっと複雑なので調査しきれませんでした(それに、そもそもHTTP/3の仕様はまだ固まっていませんし)。
  6. 6. ここはFetch APIに劣るところで、FetchならPromiseで処理できるわけです。まあ、非同期なこと自体は一緒なのであまり変わらない気もしていますが。
  7. 7. 実際、FirefoxではプライベートウィンドウでIndexedDBが使えないので、これに関するエラーをきちんと処理しなければならない。
  8. 8. 再接続試行は、一定の時間間隔(より正確には指数関数的バックオフ)、onlineイベント、登録ボタンの押下、あたりでトリガすることになる。
  9. 9. そもそもgetServerSidePropsのようなものをオフラインキャッシュしようとするのが変な話だと言ってしまえばそれまでかもしれませんが。
  10. 10. 折からの大雪で新刊が無事に届くか危ぶまれる状況となっていますが、それは別の話。