突然ですが、同人即売会用のレジアプリを開発しています。2
こんにちは,きら同技術部員4こと🌱🌿☘️🍀です(@cm_ayf, @cm-ayf).名前の読み方について公式見解を示す予定はありません.
まず初めに,先代がありました.
先代のアプリは素晴らしいもので,きらら同好会の即売会では,これによって在庫管理が容易になり,計算ミスは撲滅され,世界には平和が訪れました.
即売会中のUXは大幅に向上しましたが,一方で,主に準備段階において不便な点もいくつかありました:
- 特定のデプロイ先がない
- 先代はWebSocketを利用していたため,Vercelが使えませんでした
- Herokuは無料では使えなくなりました
- その都度EC2インスタンスを立てるなりして対処していました
- イベントと商品の情報を登録する手順が難しかった
- 認証機能が無かった
- なぜでしょうね……
- デプロイして常時利用可能にするためには必須です
- ついでに会員の中でも使える・使えないを制御できた方が嬉しいですね
これを解決すべく(というのは建前で単純にプログラミングの題材に飢えていたので),同人即売会用のレジアプリを開発しています.
要件
先代から引き継ぐ要件も含めて列挙します:
- タブレット・スマートフォンで簡単に利用可能にする
- サーバーを確保して常時利用可能にする
- イベントと商品の情報を簡単に登録できるようにする
- 認証を追加する
- レジ画面について
- 集計画面について
- 先代と同様の情報を表示できるようにする
- CSVをエクスポートできるようにする
新機能
イベントと商品の情報登録
イベントと商品の情報を登録する画面を作りました.ここでは例としてイベント編集画面を紹介します.
左上の白いカードをクリックすると,基本情報を更新できます(作成UIは当然別にあります).実装はMUIのDialogを使うだけです.簡単ですね.
イベントは複数の商品を持つようになっています(商品は複数のイベントに登録できます;n:mです).データベース的には,explicitなmany-to-many relationのテーブルDisplay
(「お品書き」)があります.「お品書き」の右のボタンから編集できるようになっています.
計算機については,Display
に単価を持たせることも考えましたが,結局JavaScriptを書いてもらうことにしました.セット価格などを表現するのに十分な構造を定義するコストが高いと考えたためです.
代わりと言ってはなんですが,TypeScript Playgroundのリンクを置いておくことで,計算機を実装しやすくしています.
認証
環境変数からDiscordのサーバーID(とoptionalでロールID)を登録することで,そのサーバーの(そのロールを持つ)ユーザーだけが利用できるようになります.
技術選定
前提として,先代のアーキテクチャを踏襲しています.先代の記事の「アーキテクチャの検討」の項もご参照ください.
Webアプリ
私はWebアプリしか作れません. PWAとして使えるように設定することでオフライン動作を可能にします.
TypeScript + React + Next.js
オタクはTypeScriptが好き.
next-pwa
PWA対応のため,next-pwa
を利用しています.
レジ画面に必要なpropsを得るAPIルートとフロントエンドをキャッシュさせ,レジ画面がmutationなしで動作するようにすることで,完全オフラインでの動作を実現しています.
MUI
React用のUIライブラリです.私はCSSが書けず,MUIコンポーネントのsx
propsしか使えないため,head内を除くすべてのJSX要素はMUIで書かれています.
SWR
React用のデータフェッチ・キャッシュライブラリです.最近v2が出てmutationも扱えるようになり,またisLoading
などの便利な機能も追加されました.
TypeBox
JSON Schemaビルダーです.中身のJSON Schemaでvalidationを提供しつつ,型定義と型レベルでの検査も提供します.API定義などに利用しています.
Prisma
オタクはTypeScriptが好き.
DBとしてPlanetScaleを使うにあたり,外部キー制約の確認をPrisma側で行ってくれる設定があるのは思わぬ幸運でした.
IndexedDB
先代と同様にidb
越しに利用しています.オタクはTypeScriptが好き.
Next.js API Route
Vercelへのデプロイを念頭に置いて開発したため,Next.jsのAPIルートを利用しています.他のフレームワークを利用する余地はありませんし,必要もそこまでありません.
機能面において,WebSocketがないことによってサーバーとの接続が利用可能かが直ちにはわからなくなってしまいます. 今回は,SWRの定期的にrevalidateする機能を利用することで(つまりポーリングを行うことで)代用しています.
Discord API - OAuth2
Discordでの認証などを提供するAPIです.Discordのユーザーに対応する情報だけでなく,サーバーID(Guild ID)を渡すと対応するMemberの情報も得ることができます;すなわち,そのサーバーにいるかどうかだったり,そのサーバーでどんなロールがついているかなどの情報も得ることができます.
また,Node.js向けのラッパーdiscord-oauth2
も利用しています.
JWT
署名によってstatelessに利用可能な「鍵」です.さすがにAPIルートを叩くごとにDiscord APIを叩いてアクセストークンを検証するのは忍びないので,補助的に利用しています.
ライブラリにはjsonwebtoken
を利用しています.また,アルゴリズムは共通鍵系のHS256を利用しています(まあテキトーでいいかなって).
API定義
実装の話に移ります.この手のものの実装はプログラムを快適に書けるようにするために形式的な部分と型パズルから始めるのがモチベーション上優れています.
バックエンドにTypeScriptを利用する意義の一つに,API定義が共通化できることがあります(それ以外はほとんどありません).そこで,TypeBoxを利用してそれぞれのルートを定義しています(GitHub):
export const readEvent = { method: "GET", path: "/api/events/[eventcode]", params: Type.Object({ eventcode: Code }), response: Event, // 一つのイベント情報に対応するJSONのスキーマ } satisfies Route;
これを利用して,APIルートの実装を行います(GitHub):
const readEventHandler = createHandler(readEvent, async (req, res) => { const token = verify(req); if (!token) { res.status(401).end(); return; } const event = await prisma.event.findUniqueOrThrow({ where: { code: req.query.eventcode }, include: eventInclude, }); res.status(200).json(toEvent(event)); });
そして,適切な型パズルを行ってクライアント側のSWRフックを定義します(GitHub):
export const useEvent = createUseRoute(readEvent);
すると, DBのデータが非常に簡単に利用できるようになります.eventcode
を渡す(当然型付き)とevent
には適切な型がついたデータが入り,isLoading
でロード中かどうかがわかります.また,fetch時に実行時型チェックも行っています.
const { data: event, isLoading } = useEvent({ eventcode });
PWA
先代では使われていませんでしたが,next-pwa
を利用すると,Service Worker関係はnext.config.js
をちょっといじるだけで実装できます(GitHub):
module.exports = nextPWA({ dest: "public", disable: process.env.NODE_ENV !== "production", cacheOnFrontEndNav: true, runtimeCaching: [ { urlPattern: "/api/users/me", method: "GET", handler: "NetworkOnly", }, ...nextPWACache, ], })({ reactStrictMode: true, });
フロントエンドについては,next-pwa
のデフォルト設定だと上で述べたポーリングによる通信確認が不可能になるため,ユーザー情報を表示するエンドポイントだけNetworkOnly
にしています.UIでも対応して,通信が確認できない場合はユーザー情報の部分にオフラインと表示されるようになっています.
認証
Discord APIでOAuth2フローを回すと,ユーザーの同意のもとでユーザーが入っているサーバー(Guild)とそのサーバーにおけるユーザーの情報(Member)を得ることができるアクセストークンを発行することができます.まずは,それとリフレッシュトークンをクッキーに焼きます.
しかしながら,このアクセストークンはただの一意識別子であり,JWT(JWS)のようにサーバーに問い合わせずにauthenticationに使えるものではありません.そこで,これとは独立に独自のJWTを発行して,それもクッキーに焼きます.
リフレッシュエンドポイントでは,まずDiscord APIのアクセストークンを使ってみて,使えればそれで更新し,使えなければリフレッシュトークンを利用してアクセストークンを更新したのち改めてアクセストークンで更新します.
デプロイ
Vercelを利用しました.
VercelはGitHubと接続すればよしなに自動デプロイを組んでくれます.Next.jsをそのまま使って作ったアプリですからデフォルト設定で動くため,本当にzero-configです.
Vercelはデータベースを提供してくれないため,Planetscaleを利用しています.PlanetscaleはサーバーレスなリレーショナルDBaaSで,容易にスケールすることができる代わりに外部キー制約を検証してくれません.Prismaは外部キー制約を自前で検証するように設定できるため,これを利用しています.
詳しくはREADMEをご覧ください.
ギャラリー
あとがき
そんなわけで,すでにあるものの個人的に不満な点を改善するためだけにフルスクラッチですべて書き換えた話でした.こちらのアプリもGitHubでソースコードを公開しておりますので,よろしければPull Requestなどお寄せください.
東京大学きらら同好会は5/13-14(土-日)の第96回五月祭で開催されるコミックアカデミー22にて「結ぶ、束ねる。」等の既刊を揃えてお待ちしております.私は当日スペースにいる可能性は低いですが,どうぞよろしくお願いいたします.