東京大学きらら同好会

東京大学きらら同好会の公式ブログ

【新歓ではない】同人即売会用レジアプリを開発している話2

突然ですが、同人即売会用のレジアプリを開発しています。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 + React + Next.js

オタクはTypeScriptが好き.

next-pwa

PWA対応のため,next-pwaを利用しています. レジ画面に必要なpropsを得るAPIルートとフロントエンドをキャッシュさせ,レジ画面がmutationなしで動作するようにすることで,完全オフラインでの動作を実現しています.

MUI

React用のUIライブラリです.私はCSSが書けず,MUIコンポーネントsxpropsしか使えないため,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をご覧ください.

ギャラリー

Vercelにデプロイ
レジ画面
イベントでの購入情報を表示する画面
イベントの購入履歴一覧

あとがき

そんなわけで,すでにあるものの個人的に不満な点を改善するためだけにフルスクラッチですべて書き換えた話でした.こちらのアプリもGitHubでソースコードを公開しておりますので,よろしければPull Requestなどお寄せください.

東京大学きらら同好会は5/13-14(土-日)第96回五月祭で開催されるコミックアカデミー22にて「結ぶ、束ねる。」等の既刊を揃えてお待ちしております.私は当日スペースにいる可能性は低いですが,どうぞよろしくお願いいたします.