突然ですが、同人即売会 用のレジアプリを開発しています。2
レジ画面
こんにちは,きら同技術部員4こと🌱🌿☘️🍀です(@cm_ayf , @cm-ayf ).名前の読み方について公式見解を示す予定はありません.
まず初めに,先代がありました.
同人即売会用レジアプリを開発している話
先代のアプリは素晴らしいもので,きらら同好会の即売会では,これによって在庫管理が容易になり,計算ミスは撲滅され,世界には平和が訪れました.
即売会中のUXは大幅に向上しましたが,一方で,主に準備段階において不便な点もいくつかありました:
特定のデプロイ先がない
先代はWebSocketを利用していたため,Vercelが使えませんでした
Herokuは無料では使えなくなりました
その都度EC2インスタンス を立てるなりして対処していました
イベントと商品の情報を登録する手順が難しかった
DBにSQLite を使っていましたが,SQL クエリを直接書いて設定していました
また,セット価格等の計算のため,計算機部分はTypeScriptを書いてデプロイ時に混ぜるという対応をしていました
認証機能が無かった
なぜでしょうね……
デプロイして常時利用可能にするためには必須です
ついでに会員の中でも使える・使えないを制御できた方が嬉しいですね
これを解決すべく(というのは建前で単純にプログラミングの題材に飢えていたので),同人即売会 用のレジアプリを開発しています.
要件
先代から引き継ぐ要件も含めて列挙します:
タブレット ・スマートフォン で簡単に利用可能にする
サーバーを確保して常時利用可能にする
イベントと商品の情報を簡単に登録できるようにする
認証を追加する
レジ画面について
先代と同等以上のUXを(とくにスマホ ・タブレット で)担保する
先代と同等のネットワーク障害耐性を担保する
集計画面について
先代と同様の情報を表示できるようにする
CSV をエクスポートできるようにする
新機能
イベントと商品の情報登録
イベントと商品の情報を登録する画面を作りました.ここでは例としてイベント編集画面を紹介します.
イベント編集画面
左上の白いカードをクリックすると,基本情報を更新できます(作成UIは当然別にあります).実装はMUIのDialogを使うだけです.簡単ですね.
イベントは複数の商品を持つようになっています(商品は複数のイベントに登録できます;n:mです).データベース的には,explicitなmany-to-many relationのテーブルDisplay
(「お品書き」)があります.「お品書き」の右のボタンから編集できるようになっています.
計算機については,Display
に単価を持たせることも考えましたが,結局JavaScript を書いてもらうことにしました.セット価格などを表現するのに十分な構造を定義するコストが高いと考えたためです.
代わりと言ってはなんですが,TypeScript Playgroundのリンクを置いておくことで,計算機を実装しやすくしています.
認証
環境変数 からDiscordのサーバーID(とoptionalでロールID)を登録することで,そのサーバーの(そのロールを持つ)ユーザーだけが利用できるようになります.
ユーザーを示すUI
技術選定
前提として,先代のアーキテクチャ を踏襲しています.先代の記事の「アーキテクチャ の検討」の項もご参照ください.
Webアプリ
私はWebアプリしか作れません. PWAとして使えるように設定することでオフライン動作を可能にします.
オタクはTypeScriptが好き.
PWA対応のため,next-pwa
を利用しています.
レジ画面に必要なpropsを得るAPI ルートとフロントエンドをキャッシュさせ,レジ画面がmutationなしで動作するようにすることで,完全オフラインでの動作を実現しています.
React用のUIライブラリです.私はCSS が書けず,MUIコンポーネント のsx
propsしか使えないため,head内を除くすべてのJSX要素はMUIで書かれています.
React用のデータフェッチ・キャッシュライブラリです.最近v2が出てmutationも扱えるようになり,またisLoading
などの便利な機能も追加されました.
JSON Schemaビルダーです.中身のJSON Schemaでvalidationを提供しつつ,型定義と型レベルでの検査も提供します.API 定義などに利用しています.
オタクはTypeScriptが好き.
DBとしてPlanetScaleを使うにあたり,外部キー制約の確認をPrisma 側で行ってくれる設定があるのは思わぬ幸運でした.
IndexedDB
先代と同様にidb
越しに利用しています.オタクはTypeScriptが好き.
Next.js API Route
Vercelへのデプロイを念頭に置いて開発したため,Next.jsのAPI ルートを利用しています.他のフレームワーク を利用する余地はありませんし,必要もそこまでありません.
機能面において,WebSocketがないことによってサーバーとの接続が利用可能かが直ちにはわからなくなってしまいます.
今回は,SWRの定期的にrevalidateする機能を利用することで(つまりポーリングを行うことで)代用しています.
Discordでの認証などを提供するAPI です.Discordのユーザーに対応する情報だけでなく,サーバーID(Guild ID)を渡すと対応するMemberの情報も得ることができます;すなわち,そのサーバーにいるかどうかだったり,そのサーバーでどんなロールがついているかなどの情報も得ることができます.
また,Node.js向けのラッパーdiscord-oauth2
も利用しています.
JWT
署名によってstatelessに利用可能な「鍵」です.さすがにAPI ルートを叩くごとにDiscord API を叩いてアクセストーク ンを検証するのは忍びないので,補助的に利用しています.
ライブラリにはjsonwebtoken
を利用しています.また,アルゴリズム は共通鍵系のHS256を利用しています(まあテキトーでいいかなって).
実装の話に移ります.この手のものの実装はプログラムを快適に書けるようにするために形式的な部分と型パズルから始めるのがモチベーション上優れています.
バックエンドに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 をご覧ください.
ギャラリー
Vercelにデプロイ
レジ画面
イベントでの購入情報を表示する画面
イベントの購入履歴一覧
あとがき
そんなわけで,すでにあるものの個人的に不満な点を改善するためだけにフルスクラッチ ですべて書き換えた話でした.こちらのアプリもGitHubでソースコードを公開しております ので,よろしければPull Requestなどお寄せください.
東京大学 きらら同好会は5/13-14(土-日) の第96回五月祭 で開催されるコミックアカデミー22 にて「結ぶ、束ねる。 」等の既刊を揃えてお待ちしております.私は当日スペースにいる可能性は低いですが,どうぞよろしくお願いいたします.