こんにちは、エムスリーエンジニアリンググループの福林 (@fukubaya) です。
今回は「新型コロナワクチン余剰通知システム」の実装、構築をAI・機械学習チームの丸尾 (@snowhork)と担当したので紹介します。 いろいろあって今のところリリースの予定はありませんが、せっかく作って記事も用意していたので公開することにしました。
どんなサービスなのか?
急な予約のキャンセルなどにより余剰が発生してしまった新型コロナウイルスのワクチンを接種会場の近隣で接種を希望する人とマッチングさせるサービスです。自治体でも医院でも職域接種であっても余剰の扱いは課題になっているので、このサービスで少しでも課題の解消に貢献できればと思いながら構築しました。主な流れは図のとおりです。
(1)接種を希望するユーザは事前に、生年月日、携帯電話番号、居住地や勤務先などの郵便番号(2つまで)を登録します。
(2)接種会場でワクチンに余剰が発生した場合は、会場に近い希望者を選んでSMSで通知します。通知時には来場期限も指定します。なお、この段階で接種会場側が見られるのは希望者の年齢と市区町村だけです。
(3)通知を受けたユーザは、会場の場所、来場期限、ワクチンの種類を見て行ける場合は来場可能であることを回答します。なお来場可能の回答をしても必ずしも連絡が来る訳ではありません。
(4)接種会場は来場可能の回答があったユーザに直接電話で連絡し、本当に来場可能か、事前の注意事項など確認してから来場してもらい、実際に接種を実施します。
デザイナーさんにせっかく作ってもらったのでトップページのキャプチャを貼っておきます。
開発までのいきさつ
計画を聞いたのが2021年06月10日くらいでした。そこからおおまかな仕様を議論して、2021年06月16日から開発をスタートしました。実装は福林と丸尾の2人だけなので、共有するDBとSQSの仕様だけ先に決めて、あとはそれぞれで実装を進めました。インフラ構築はAWSへのクラウド移行の経験上、それほど難しくないと思ったので福林が1人で担当しました。7月下旬には実装、QAが完了してリリースできる状態になりました。
構成
機能ごとに分割した3つのECSコンテナをFargateで実行しています。接種希望者向けのアプリケーション、接種施設向けのアプリケーション、SMS通知を処理するアプリケーションの3つです。DBはAurora(PostgreSQL)、SMS通知のジョブキューにSQSを使いました。SMS通知にはtwilioを使っています。
希望者向けのアプリケーションは丸尾が担当しました。バックエンドはRailsで、フロントはVue.jsです。施設向けのアプリケーションは福林が担当しました。以前担当したDocpediaの資産を流用するのが早かったので、SpringBoot(Kotlin)とVue.jsの組み合わせです。SMS通知も福林が担当しました。これは、queueを処理してTwilioのAPIを叩くだけで画面はないのと、処理数が増えた時に並行実行がしやすい方がよかったのでGoで作りました。
今回の場合は、長期的な保守や改修のしやすさよりも、短期間で完成させることの方が大事なので、実装担当者が一番やりやすい言語やフレームワークを選択しています。と言っても個人情報も扱うので、品質を犠牲にしている訳ではありません。テストやQAは通常通り実施しています。
技術的なチャレンジ
とにかく早く完成させる必要があるサービスですが、それでも既知のやり方で漫然と作るのは技術者として楽しくありません。開発スピードを落とさない範囲でやってみたいことを入れてみました。以下は福林の担当範囲でやってみたことを紹介します。
どうやって接種施設から近い順に希望者をソートするか
接種会場側の画面では、どの希望者に通知するか選択するため、接種会場に近い順に希望者をソートして表示する必要があります。最初にサービスの概要を聞いた時に、他の部分はだいたい具体的な実装までイメージできましたが、このソート部分をどう実装するかで悩みました。日本郵政の郵便番号データだけでは、位置情報までは分からないので、都道府県、市区町村が同じかどうかくらいしか判断できません。神奈川県川崎市と東京都大田区が隣接していることはおそらくこのデータでは分かりません。
仮に郵便番号ごとに詳細な位置情報があったとしても、全ての登録希望者の居住地と施設の距離を計算して近い順に並べるのは計算量が多すぎて実用的な時間内に結果を得られないと思いました。専用のソフトやサービスを使えば解決できるのかもしれませんが、それらを調査して導入する時間はもったいないので、できそうな範囲で実装を考えました*1。
調べてみると国土交通省が位置参照情報を提供していることが分かったのでこれを使うことにしました。
ただし、このデータでは大字・町丁目レベルで緯度・経度が得られますが、郵便番号データ1つずつと結合させるのは難しいです。そこで、市区町村(全国地方公共団体コード(JIS X0401、X0402))が同じデータをまとめて平均した結果を、その市区町村の緯度・経度としました。これにより全国の市区町村の位置データが得られました*2。郵便番号データにも全国地方公共団体コードが付与されているので、これで郵便番号から位置情報を取得できます。
さらに、接種希望者をSQLで取得してソートする際に毎回距離を計算をするのはコストがかかるので、予め任意の2点の市区町村間の距離を計算したテーブルを用意しました。これで距離計算のコストはO(1)になります。ちなみに一番遠いのは北海道羅臼町(01694)と沖縄県与那国町(47382)で2968kmでした。
db=# SELECT lgcode1, lgcode2, distance FROM lg_distance ORDER BY distance DESC LIMIT 2; lgcode1 | lgcode2 | distance ---------+---------+----------- 01694 | 47382 | 2968.9326 47382 | 01694 | 2968.9326 (2 rows)
今回の要件では、だいたいでソートできればよく、距離自体は不要なので緯度・経度でユークリッド距離を算出しても支障はなかったのですが、GeoPyで簡単に計算できそうだったのでこれで算出しました。
このデータを使って、エムスリーがある東京都港区(13103)との距離の近い順で郵便番号を並べてみました。
db=# SELECT z.zipcode, z.prefecture, z.city, d.distance FROM zipcode z JOIN lg_distance d ON z.lgcode = d.lgcode1 WHERE d.lgcode2 = '13103' ORDER BY distance ASC, z.zipcode ASC; zipcode | prefecture | city | distance ---------+------------+------------------+------------ 1008602 | 東京都 | 港区 | 0 ... 1500000 | 東京都 | 渋谷区 | 3.9983912 ... 1008606 | 東京都 | 中央区 | 4.0966954 ... 9071800 | 沖縄県 | 八重山郡与那国町 | 2032.125
SQLだけで希望者を近い順にソートするデータを準備できました。
Vue3の導入
Docpediaの頃はまだVue2でしたが、Vue3が出てからある程度経っていてどこかで実践投入したいと思っていたので、ここでやってみることにしました。
当初はメジャーバージョンアップと言っても、複雑なことはやってないし、微修正でいけるだろうと思っていましたが、それほど楽じゃなくてちょっと焦りました。
急いで作らないといけないのにVue3に挑戦してみたりして時間をかけてしまった。
— Yuichiro Fukubayashi (@fukubaya) June 17, 2021
Composition APIに合わせて既存実装を直していくのがちょっと大変でしたが、慣れれば機械的に直していけると思います。
PCとスマホで共通コンポーネントを実現するプラグイン
以前記事で書いたPCとスマホで共通コンポーネントを実現するプラグインを生かしてみたくて、実装してみました。
ただ、Vue3でプラグインの書き方がちょっと変わっていて、そのままでは動かなくてドキュメントを見ながら修正が必要でした。
以前書いたものは以下のように変更しました。
/** * プラグイン本体 */ export const AppModePlugin = { install(app: any, options?: AppModeOptions) { // eslint-disable-line const mode: AppMode = options ? options.mode : AppMode.PC; app.config.globalProperties.$appMode = mode; app.provide("$appMode", mode); app.config.globalProperties.$isPc = mode === AppMode.PC; app.provide("$isPc", mode === AppMode.PC); app.config.globalProperties.$isSp = mode === AppMode.SP; app.provide("$isSp", mode === AppMode.SP); app.mixin(generateMixin(mode)); }, };
なお、時間かけてがんばってしまいましたが、接種施設側は今のところPCしか想定していないので、必須ではない変更でした。施設から希望者に対して、最終的には電話をかける必要があり、スマホ対応する可能性もあるので完全には無駄ではないと思っています。
学びと反省
MVP(Minimum Viable Product)はみんなで作る
本プロジェクトは、社会貢献の側面が強く、高い利益が期待できるものではありません。したがって、少ない人数で、短い期間で作る必要がありました。そうでなかったとしても、価値のあるサービスを少ないコストで短期間にリリースするためには、サービスの価値を見極めて仕様を決める必要があります。
最初の想定では、希望者から生年月日(年齢)の他に性別や既往症も回答してもらって、ワクチンが余った施設側が対象者を決める参考情報として利用させる仕様でした*3。しかし、年齢と居住地に比べて性別は施設側の判断要因としては影響がほぼないだろうと考え、性別は取得しないことにしました。さらに、既往症が重大な個人情報である割には「同じ居住地で同年齢が2人いてどっちを選んだらよいか?」といった状況はそう多くは生まれないだろうと判断し、やはり取得しないことになりました。
連絡先に関しても、当初はSMS以外にLINEが選択肢として挙がっていましたが、最終的に施設側から希望者に対して確認の電話連絡が必要*4で、登録の時点で有効な電話番号の取得は必須だったので通知もSMSで実施することにしました。twilioのSMS通知の実装もすぐできそうだったのも理由のひとつです。
このような仕様が決定には、新型コロナの重症化要因も、個人情報に対するリスクの大小も、LINEやSMSで通知実装の難易度も考えなければならず、PdMやエンジニアがひとりでは最適な構成を考え出すのは困難です。知識もバックグラウンドも違うメンバー(弊社には医師の社員もいます)が一緒に考えることが最適なMVPを作り出すのには必要だと思いました。
UI部品のコンポーネント化
m3.comではフォームのコンポーネントを含めたスタイルが定義されたCSSが用意されてはいるのですが、あくまでCSSなのでVue.jsに組込むためには、毎回ボタンなどのコンポーネントは実装しなければいけません。もっとプロトタイプ開発の速度を上げるためには、コンポーネントとして使えるものを準備しておき、ロジックの実装に集中できるようにしておく必要があると感じました。暇を見つけてコンポーネント化をやっておきたいですね。
OpenAPIの活用とクリーンアーキテクチャ
施設側のバックエンドの実装では、OpenAPIとクリーンアーキテクチャが速い実装を可能にしてくれました。
yamlからサーバとクライアント側のコードを自動で生成できる仕組みがあると、仕様の変更にもyamlを変更するだけでコードに反映できてしまうので、仕様を作りながら実装を進めるようなサービスの立ち上げ時にはとても役立ちます。
クリーンアーキテクチャに関しては、コード上の役割り分担がパターン化されるので、機能追加、変更時にも設計上で迷ったり考えたりする必要がほぼなく、OpenAPIと同じく変更への強さを実証できたと思いました。なお、Goで書いたSMS通知処理もクリーンアーキテクチャを採用しました。
We are hiring!
今回のサービスのように、弊社では医療分野で社会に貢献できるチャンスがたくさんあります。一緒に参加してくれる仲間を募集中です。お気軽にお問い合わせください。
*1:PostGISとか使えそうですが、一から調べたりAuroraに組込んだりするのに時間がかかりそうだったのでやめました。
*2:実際には東京都利島村(13362)と長野県上伊那郡南箕輪村(20385)と熊本県球磨郡湯前町(43506)のデータがなかったのでGoogle Mapsで調べて手動で追加しました
*3:年齢ほどではないですが性別も重症化具合に影響があるようです https://news.yahoo.co.jp/byline/kutsunasatoshi/20210516-00236991
*4:希望者から施設への電話連絡が殺到しないようにするため、希望者側には施設の連絡先は提示しません