文化祭で滞在状況記録システムを運用しました
文化祭からはや 3 ヶ月。ずっと書きたいとは思っていたんですが、すぐ定期試験がやってきたり修学旅行に行ったりしてるうちにズルズル来てしまいました。このまま年を越すわけには行かないので、重い腰を上げて書き上げてしまおうと思います。
文章力が皆無なので読みづらい箇所があったらごめんなさい。質問等ございましたらお気軽にどうぞ!
1. システム概要
感染症対策の一環として、主に各展示の同時滞在者数の抑制を目的として導入したシステムです。
文化祭への来場者全員にリストバンドを配布します。各リストバンドには個別の QR コードがプリントされており、各展示の入室時及び退室時に、展示のスタッフが Web アプリ上でスキャンを行い、来場者の入退室時間を記録します。
主な機能として以下が挙げられます。
- 同じ時間に同じ教室にいたのがどのリストバンドをつけていた来場者であるかが分かるため、万が一新型コロナウイルスの陽性者が発生した際に濃厚接触者の特定をスムーズに行うことができます。
- 各展示に滞在中の人数をリアルタイムで把握することが可能です。
- 上限を設定しておくことで、人数超過時に入口で入室制限を掛けることができます。
- ピロティに大型ディスプレイを設置し、来場者が各展示教室の混雑状況を把握できるようにすることで、特定の展示教室への人流の緩和が期待できます。
- どの展示の平均滞在時間が長いのか、ある展示に行った人が次にどの展示に行くことが多いか等が可視化できるため、収集したデータを分析することで次年度以降の文化祭運営の参考指標とすることができます。
インフォメーションセンター横での展示別滞在人数の表示
2. 導入決定までの背景
コロナが始まってもう 3 年ですが、僕の学校では 1 年目は小規模なオンライン企画のみ、2 年目もオンラインでの開催にとどまり、今年の文化祭も平年通り開催されるという望みは薄い状況でした。
僕は現在高 2 で、文化祭実行委員会には昨年から所属していましたが、当時は高 1 だった上にオンライン開催ということもあり、仕事は殆ど回ってきませんでした。サイト自体は 10 月頃に公開され、全クラスが出し物を出すことになっていたこともあり、ある程度は盛り上がっていたと思います[1]。その後の引き継ぎで、僕が次年度の技術部門のチーフを務めることになりました。
11 月頃、 Qiita と Zenn で以下の 2 記事を目にしました。
このようなシステムを自分の学校でも導入できないかと思い、自分の学校に合った形で「滞在状況記録システム」の導入案を作成して担当教員に相談しました。提案にあたって特に麻布の方の記事には当日起こったトラブルを含め詳細な記述があり、とても参考にさせていただきました。本当にありがとうございます。
提案した当時は、まぁまず採用されないだろうと考えていましたが、思いの外興味を持っていただくことができ、なんと 12 月にはシステムを導入する方向で進めていくことが決定しました。
3. 技術的な構成
前年度より学校で文化祭用に AWS を契約していたため、フロント、バックとも AWS のプラットフォーム上に構築しました。料金は 5000 円は超えていなかったと思います。
3-1. フロントエンド
TypeScript + React + MUI で開発しました。状態管理はそこまで複雑にならなかったため Jotai を使っています。ホスティングサービスは Amplify を利用しました。
学校のネットワークはかなり貧弱[2]なので、すこしでも負荷を下げようと PWA にも対応させました。ただ、こちらが積極的に案内をしなかったこともありますが、インストールして使っている人はあまりいなかったです。
認証
文化祭実行委員、教室展示ごとにアカウントを発行しました。教室展示用のアカウントではその教室展示のスキャンと滞在状況の確認が、文化祭実行委員用のアカウントではステージ会場などの入退室スキャンができるようになっています。
Firebase Authentication や Supabase Auth を使ってみたかったですが、ユーザー数的に無料枠で収まらない可能性があった(普通に使えば問題ない量だが万が一足りなくなったときが怖い)ため、MySQL と連携がしやすかった JWT で実装しました。
QR コードスキャン
Web アプリという性質上性能が微妙なのは仕方のないことですが、各展示のスキャン担当の人たちはネイティブアプリのスキャン性能に慣れているわけなので、そこの文句は出ないか不安でした。個人のスマホの使用許可が下りなかったため、よりスキャンしづらかったと思います。スキャン端末のレンタルも考えたのですが、展示数 40 x 2 + 予備で少なくとも 100 台は必要で、校内の WiFi につなげるために MAC の設定をしたり、万が一破損したときの補償は、などを考えていると予算的にも時間的にも現実的ではなかったため学校配布の Chromebook を使うことになりました。
QR コードの読み取りには react-qr-reader を利用しました。React 17 に対応していなかったため Issue で言及されていたこのプルリクを使っています。ただ、このままだと constraints
を使おうとすると型定義が見つからないというエラーが出てしまうので、オリジナルの型定義ファイルを作って対応しています。
筑駒でも麻布でも報告されていたメモリリークに起因する読み取り不良の不具合については、麻布ではコンポーネントのリフレッシュを 5 分おきに行うことで対応していましたが、 Chromebook やいくつかの Android 端末で検証したところ 5 分でも耐えられなかったため、本システムでは Android であれば 30 秒おき、それ以外の機種は 2 分おきにリフレッシュを行うようにしました。
UI / UX
本来は Figma 等でモックアップを作ってから実装するべきなのでしょうがいつもうまく作れず...。結局レイアウトはコードを書きながら考えています。
UI コンポーネントライブラリを今回はじめて使ったのですが、その楽しさにハマってしまいました。ドキュメントのサンプルをぽちぽち弄ったり changelog を読んでいるだけで楽しい。今回はコンポーネント数もスター数も多かった MUI を採用しましたが、最近作っている別のサービスでは Chakra UI や Mantine も使っています。
カメラの切り替えボタンは、端末に搭載されているカメラが 2 つの場合はワンクリックで切り替わります。またスマホと Chromebook ではデフォルトが外カメラ / 内カメラと異なるため、カメラのプレビューエリアは左右を反転させられるようにしています。
エントランス入場処理
当日はアクセス集中によるレスポンス速度の低下が予想されたため、バックエンドとの通信が発生するすべての場所でローディングスピナーを表示し、ボタンは連打防止のため disable になるようにしました。
レイアウトは学校で配布されている Chromebook と iPhone SE のサイズを基本に合わせています。スマホ表示ではよく使うボタンはなるべく下側に置くようにしました。
スマホでの表示
ちなみにですが、ユーザーアイコンは boring-avatars というライブラリを使っています。画像にすると、保存場所を用意する必要があったり著作権も不安だったりと色々考える必要がありますが、このライブラリを使えば簡単にかわいいアイコンを用意できるので気に入っています。
Safari 対応
フロントエンド界隈では周知の事実ですがやはり Safari はクソでした。iOS 端末が手元になかったので初めてデバッグをしたのが 8 月の下旬だった[3]のですが、使い物にならないレベルで焦りました。
まず PWA ですが、standalone モードで起動するとカメラを開くことが出来ません。画面を広く使えるというのも PWA のメリットの一つだと思っていたのですが、仕方がないので以下の記事を参考に OS ごとにマニフェストファイルを分けました。
また、スマホ表示では Bottom Navigation を使っていたのですが、これの z-index
が効いておらず、 Card コンポーネントが上に乗っかってしまって一切押すことができなくなっていました。これも以下の記事を参考に、 transform: translateZ(1px);
をつけて対応しました。
3-2. バックエンド
Go 言語を使い echo というフレームワークを使って開発しました。EC2 インスタンス上に MySQL をインストールし、デーモン化ツールの pm2 を使ってプログラムの永続化を実現[4]しています。
当初は TypeScript + Express で開発していたのですが、レスポンス速度への懸念から Rust か Go の導入を検討しました。パフォーマンス最強は Rust だと思いますが、最終的に Rust は学習コストが高すぎると判断して Go で書き直しました。初めて触った言語でしたが Rust に比べると断然書きやすかったです。
gorm
データベースとの接続には gorm を使いました。ORM を使うのも初めてだった(一応 Python ではほんの少しだけある)ので、多くのエンドポイントで SQL 文を直接書いてしまっていて、うまく ORM として活用できなかった気がしてます...。
MySQL
データベースは特にパフォーマンスのチューニングに苦戦しました。事前に行った操作方法の説明会では 70 人程度が同時にアクセスしただけで詰まっていましたが、コネクションのタイムアウトの時間を短くしたり、インデックスを貼ったりすることで改善しました。
データベース構成
- user
key | type | description |
---|---|---|
user_id | varchar(10) | primary key, not null |
display_name | varchar(20) | not null |
user_type | varchar(10) | not null |
available | tinyint | not null |
created_by | varchar(10) | not null |
- activity
key | type | description |
---|---|---|
activity_id | bigint | primary key, not null |
guest_id | varchar(10) | not null |
exhibit_id | varchar(10) | not null |
user_id | varchar(10) | not null |
activity_type | varchar(5) | not null |
timestamp | timestamp | not null |
available | tinyint | not null |
- guest
key | type | description |
---|---|---|
guest_id | varchar(10) | primary key, not null |
guest_type | varchar(10) | not null |
reservation_id | varchar(10) | not null |
exhibit_id | varchar(10) | not null |
part | tinyint | not null |
user_id | varchar(10) | not null |
available | tinyint | not null |
register_at | timestamp | |
revoke_at | timestamp | |
is_spare | tinyint | not null |
- exhibit
key | type | description |
---|---|---|
exhibit_id | varchar(10) | primary key, not null |
exhibit_name | varchar(15) | not null |
group_name | varchar(10) | not null |
room_name | varchar(15) | not null |
exhibit_type | varchar(10) | not null |
status | tinyint | not null |
capacity | smallint | not null |
- reservation
key | type | description |
---|---|---|
reservation_id | varchar(10) | primary key, not null |
guest_type | varchar(10) | not null |
count | tinyint | not null |
available | tinyint | not null |
part | tinyint | not null |
EC2
普段はずっとフロントで開発している人間なので、サーバーについての知識は皆無でした。まず外部と通信できるようにした上でドメインを接続し HTTPS 化する、という流れを Tera Term と延々と向き合いながら手探りで進めました。サーバーを建てたのが 5 月の頭頃で、ゴールデンウィーク期間は無限にこのセットアップ作業をしていました。
3-3. 予約システム
本年度は保護者 + 一部の招待された方のみの完全事前予約制での開催だったため、予約システムの開発も行いました。とは言っても Google フォームに GAS で処理を加えただけのものです。ただ、予約システムについては開始直後のアクセス集中を考慮すると Google フォームが一番安心感があるので個人的にはここは変えるべきではないと思っています。
メール送信は SendGrid の利用を検討しましたが、 Google for Education では 1 日に 1500 通まで送信できるため、この数を超えることはないだろうと判断しすべて Google Workspace で完結させています。
予約フォームで工夫した点については以下の記事で簡単に纏めています。
4. システムの流れ
4-1. 事前予約
Google フォーム送信時に希望の時間枠ごとに予約 ID を発行、人数情報などとともにスプレッドシートへ記録し、入場時に必要となる QR コードを添付して予約完了メールを送信していました。各時間枠ごとに上限人数を設定しておき、予約がいっぱいになったら選択肢を消す処理も組みました。
個人情報(メールアドレス)が含まれるため、プログラムはこちらで組み、受付開始後は教員側でデータを管理してもらいました。予約受付期間の終了後、予約 ID / 時間枠 / 人数の情報を受け取り、データベースに流し込みました。
ステージに出演する生徒の保護者は、生徒が出演している時間枠に確実に入れるよう先行して予約を受け付けていました。この際、メールアドレスの入力ミスが非常に多かったため、一般予約では正規表現を使ってドメインを主要なもの[5]に制限しました。
4-2. 受付
- 予約完了メールに添付された QR コードを提示してもらいスキャンします。QR コードを忘れた来場者とそもそも予約をしていない来場者は特別レーンへ誘導し、来場者の氏名と渡したリストバンドのゲスト ID を控え、事後データベースに手動で入力しました。
- 予約がすでに使用されたものでなければ未使用のリストバンドを取り出し人数分スキャンします。
- バックエンドで、該当するゲスト ID に予約 ID を記録し予約とリストバンドの情報を紐づけます。
受付の様子
4-3. 生徒・教員の入場
生徒や教員はその多くが受付の開始時刻より前に校舎内に入るため、受付処理は通りません。当初は両日とも朝ホームルームが行われる予定だったので、一括で入場記録を付けるつもりだったのですが、諸事情でホームルームが行われないことに。その結果朝から校内にいるのが誰か分からなくなってしまったので、朝一旦生徒全員に入場記録をつけ、その後もう一度入場処理が行われれば朝のレコードを削除、また一日を通じてどの展示へも入退室記録がない生徒は登校しなかったものとみなし朝のレコードを削除、とすることで対応しました。
4-4. 展示への入退室
- 来場者にリストバンドをかざしてもらい、各展示の担当者がスキャンします。
- バックエンドにゲスト情報を問い合わせ、展示に入室中でないことが確認できたら「入室記録」ボタンを表示します。
- ボタンが押されたら、バックエンドですべての入退室記録を管理している
activity
テーブルへ、ゲスト ID・展示 ID・タイムスタンプ・アクティビティタイプ(enter
)の情報を追加します。 - 退室記録も同様に、アクティビティタイプを
exit
にして追加します。
4-5. ステージ会場
展示は通常の教室展示とは別に、バンドが出たり部活動の発表が行われるステージ会場があり、こちらは文化祭実行委員が入退室を記録していました。ステージ企画については出し物が変わるタイミングで多くの人が出入りし、一人ひとりスキャンをしていると退室口が詰まって密になることが予想されたため、管理者用のアカウントから「一括退室」処理を実行できるようになっています。
展示別滞在状況画面
5. その他
5-1. 予約 ID
メールに添付される QR コードは、流石にそのまま予約 ID にするのはよろしくないと思い crypto-js を使ってお気持ち程度に暗号化してあります。
なお予約 ID は RABXXXY
という形式でナンバリングしました。
-
R
...全員共通 -
A
...1 土曜, 2 日曜 -
B
...1 ~ 5 の数字を使って時間帯を表す -
XXX
...申込順で連番。先頭 0 埋め -
Y
...乱数 - 先頭の
R
以外はすべて数字。7 桁固定 - 特別対応は
A
とB
を 0 にして対応
5-2. リストバンド
一番おかねがかかっています。これは文化祭後の反省にも書いたんですが、正直リストバンドであることのメリットは「紛失が起きにくい」ことと「記念品になるかも?」ぐらいで、最大の欠点である「スキャンしづらい」にはどう頑張っても勝てないので、来年以降また導入するのであれば名刺サイズの厚紙カードみたいなものがいいと思いました。
カラーは「生徒」「教員」「保護者」「その他招待客」の 4 色で分けました。生徒と教員は 2 本ずつ用意し、1 日目 / 2 日目で分けて使えるようにしました。ゲスト ID の数字は手入力するときに認識しやすいよう 3 桁ごとに区切っています。
リストバンド
ゲスト ID は GABXXXXXYZG
という形式でナンバリングしました。
-
A
...0 両日、1 土曜、2 日曜 -
B
...ゲストタイプ。0 生徒、1 教員、2 保護者、3 その他 -
XXXXX
...生徒は学年(1 桁、1 ~ 6) - クラス(2 桁) - 番号(2 桁)、教員は教員番号(先頭 0 埋め)、保護者は時間帯( 1 桁、 1 ~ 5) - 連番(2 桁目以降)、その他は全桁連番 -
Y
...乱数 -
Z
...2 ~ 9 桁の合計の 1 の位 - 先頭の
G
以外はすべて数字。10 桁固定
ゲスト ID を見てゲストの区分が判別できるようにしようとしたら桁数が増えてしまいました。 ただ、Z
のチェックサムは手入力を間違えたときの不必要なサーバーとの通信を減らすことに繋がったので、入れておいて良かったです。
5-3. デジタルリストバンド
(こうせざるを得なくなった事情は様々あるのですが)「スキャン端末が Chromebook 」&「スキャンされる側は腕に巻いている」という最悪の組み合わせで、文句が出ることは想像に難くなかったため、急遽「デジタルリストバンド」というものを作りました。
受付でリストバンドをもらった後、このサイトで QR コードをスキャンすると QR コードが大きく表示されるというだけのものです。スマホで写真を撮るのと何も変わらない気もしますが、担当者にとってはリストバンドを提示していることが少なからずわかりやすくなっていたと思います。
リストバンド紛失時用の機能も用意はしていましたが、多くのゲスト(というか生徒)はゲスト ID が分かったためこちらで事足りました。
デジタルリストバンドの画面
5-4. 気をつけたこと
早く完成させる
文化祭は 9 月開催でしたが、7 月には一通りの機能の実装を終わらせてありました。これは次の「各所への周知」と重なりますが、早く完成させないと実際に使う人へ説明する時間がなくなります。実際に使ってもらうことで機能についての認識の齟齬を埋められますし、何よりデバッグにもなります。夏休みの間はほとんどが不具合の修正でした。今回大きなトラブルなく終われたのは、デバッグに 2 ヶ月近く時間をかけられたことも大きかったと思います。
各所への周知
個人的にこれは最も重要だと思っています。開発当初から、このシステムを円滑に運用するためは生徒への周知の徹底が欠かせないと考えていました。文化祭実行委員の担当部署の生徒や各展示団体の代表者を集め、操作方法についての説明会を複数回実施したほか、システムについて簡単に説明したビラを作成し、教室掲示だけでなく全生徒へ 1 人 1 枚配ってもらいました。前日準備の日には文実の担当部署がすべての展示を回り、「操作方法を理解しているか」「スキャン担当のシフトは用意されているか」などを確認しました。
むやみにエラーを表示しない
ゲストが展示に入室するとき、入室スキャン担当者の画面に「前の展示の退室記録がありません」と表示してもその人にはどうすることもできません。この場合はその場で自動で前の展示の退室処理が行われるように設計しました。
また、各展示には上限人数が設定されていますが、この人数を超えても入室処理はできるようになっています(警告は表示されますが)。普通は入室できないようにするべきでしょうが、展示の中より廊下のほうが人が密集する可能性もありますし、システムで入室できないようにしたところで、もし廊下に行列が出来ていたとき、担当者は果たして本当に入室させないでしょうか?分かりませんが、おそらくシステムを通さないで客を入れ始めると思います。それをされるぐらいであれば、多少人数は超えても行動履歴を記録し濃厚接触者を追跡できたほうがいいのではないか、ということです。
というかそもそも、ユーザーは基本エラーメッセージなんて読みません。エラーメッセージに限らずメッセージをほぼ読んでないな、というのは実際に使ってもらっているところを見ながら肌で感じました。これからサービスを作るときはそういったことにも気をつけながら作っていきたいと思います。
可用性の重視
万全の準備はしていましたが、初めての試みが多かったため不安もありました。障害発生時は文化祭全体に大きな影響が出てしまうため、システムが安定して動作することを最重要視して設計しました。本来はスタンプラリーの機能や一番良かった展示に投票する機能、各展示のリアルタイムの人数をインフォメーションセンターだけでなく各自の端末から確認できる機能等も作りたかったのですが、システムへの負荷が大きく上がるので見送りました。
リクエストの集中に伴う不具合の場合はスキャン処理をステージ会場のみに絞る等、文化祭の運営に極力影響しないように障害発生時用のマニュアルの作成も行いました。またデータベースでアカウントの権限を書き換えることで一時的にスキャン処理を出来なくするメンテナンスモードも実装しました。
5-5. ちょっとしたデータ集
文化祭の週のアプリへのアクセス人数
今まで作ってきた毎日 0 と 1 とを行ったり来たりしているようなサイトではとても見られないような数字です。 ところで、Google アナリティクスを見てるとよく海外の謎の国からのアクセスがあるんですがこれは VPN を使っているってことなんでしょうか?明らかに関係無さそうな属性の人がアクセスしているのを見ると少し不安になります。
イベント数の上位
exhibit_info_request
は展示情報(現在の人数等)がリクエストされた回数です。exhibit_use_numpad
は QR コードスキャンの代わりに直接入力が行われた回数ですが、これを見てもやはり直接入力の機能は整えておいてよかったと思います。展示内の人数が上限人数に達した回数である reach_capacity
の数が異様に多いですが、これは 30 秒おきの展示情報の取得時に上限人数に達しているとその度に記録されてしまっていたことが原因だと思います。
イベントはよく考えずに記録していましたが、こうして見るとやはり大規模なシステムではログの収集が大事になってくるなと感じました。
6. さいごに
最初の提案から 9 ヶ月間、ひたすらこのシステムを作ってきました。この期間の ManicTime の統計を見てみると、VSCode だけで 279 時間とあります[6]。時間をかけて作ったものだからこそ、実際にちゃんと使った上で言ってもらえた「凄い!」という言葉を聞いたときは本当に嬉しかったです。 2 日間での入退室レコードは 8 万件を超え、ピーク時は秒間で 10 以上はリクエストが来ていました。自分の中でも、これほど大きなシステムをすべて一人で開発し、大きな不具合も出さずに終えることが出来たことは自信に繋がりました。システムの導入にゴーサインを出してくださった先生、当日システムを円滑に運用するために準備してくださった先生、そして実際運用に協力してくれた全校生徒のみなさんには感謝でいっぱいです。本当にありがとうございました!
写真がなさすぎる
文実で技術部門の担当者の方へ、この記事が少しでも何かしらのシステムの導入の手助けとなれば幸いです。最後までお読みいただきありがとうございました。
-
昨年のオンライン文化祭では有志企画として校内の様々な場所を 360° カメラで撮影するという企画を行いました。ひっそりと公開したままにしてあるのでよろしければこちらもご覧ください! → https://look-inside-view.web.app/ ↩︎
-
バックエンドとの通信エラーはすべて Discord に通知されるようにしていたのですが、殆どのエラーはこれに起因するものでした。ステージなど主要な場所は事前に調査を行いポケット WiFi を置いていましたが隅にある展示までは手が回らず、エラーが多発していました ↩︎
-
この頃はパンフレット周りのことを中心に、想定外の事態が連続していてわりと病んでいました。特に 9 月に入ってからは様々な調整に追われ、とてもコードを書いている時間はありませんでした ↩︎
-
確か最初は systemctl でやろうとしたんですがうまくいかなかったです(理由はよく覚えていない) ↩︎
-
検索したら一番上に出てきたコカ・コーラの会員サイトの登録に使えるドメイン一覧 と、学校で配布されているメールアドレスのドメイン ↩︎
-
他の部署との打ち合わせに加え、初期は職員室で何時間も教員と話し合ったりしていたので、非開発時間が占める割合もかなり大きいです ↩︎
Discussion
おつかれさまです、OBとしてはまさかあの学校がこういう取り組み許すとは、と驚きました笑
校内360°写真は懐かしい気持ちになりました。残しておいてくれてありがとうございます!
OBの方...!コメント&バッジありがとうございます!昨年の企画の方も楽しんでいただけたようでとても嬉しいです!
お疲れ様です!とても素晴らしいなと思いました!
質問なのですが、数字の入力画面はどのように実装しているのでしょうか?お手隙であれば回答して頂けると幸いです。
ありがとうございます。
数字入力のボタンはMUIのボタンコンポーネントをベースにスタイルを追加してカスタマイズしています。
以下が該当コードになります。
回答していただきありがとうございます!
参考にさせていただきます!