くりにっき

フルスタックキュアエンジニアです

プリマップというプリパラやプリ☆チャンが遊べるお店を探せる地図を作った

タイトルが全て

primap.web.app

f:id:sue445:20201013232154p:plain

モチベーション

以前 プリ☆チャンMAP を作ったことがあるのですが、下記のような不満がありました。

  • プリ☆チャンの公式店舗一覧スクレイピングしてスプレッドシートに入れるところまでは自動化できたが、そこからGoogleマイマップに取り込む作業が手作業
  • スプレッドシートに住所カラムを作っておけばGoogleマイマップがいい感じに緯度経度に変換してくれるのだが、公式の住所のフォーマットがおかしいと稀に緯度経度が取れないので手動で補正する必要がある
  • 公式のショップ一覧は定期的に更新されるのでそれに追従するのが大変
  • プリ☆チャンMAP作成後にプリパラオルフレンズの稼働が始まったのだがそれの地図を作るのも大変

そのため完全に作り直しました。

使い方

https://primap.web.app/ を開くといい感じに現在地周辺のプリ☆チャンやプリパラで遊べる店舗の一覧が出ます。

f:id:sue445:20201013233308p:plain

技術的なこと

リポジトリ

github.com

大雑把な仕組み

  1. PrismDB から店舗一覧を取得
  2. Geocoding APIで店舗の住所から緯度経度を取得してFirestoreに保存
  3. Firestoreに保存されている店舗をいい感じに地図上に表示

開発期間はトータルで1ヶ月くらいです

サーバサイド

俗に言うサーバサイドは最初はあったけど途中で捨てました

当初の構成だとCloud RunでGoで書いたAPIやジョブをホスティングする予定だったんですが、APIはFirebaseでいいことに気づいたので途中で方針転換して、その結果サーバサイドがなくなりました。(サーバレス)

ジョブ

採用技術は下記

  • Go
  • Cloud Functions
  • Cloud Scheduler
  • Cloud Pub/Sub
  • Secret Manager
  • Geocoding API
  • Serverless Framework

店舗が数千件あって1つのファンクションの中で全ての緯度経度の取得をするのは厳しいと判断して、PrismDBで取得した店舗の件数分だけPub/Subでqueueに積んで非同期で処理するようにしました

Geocoding APIを一度にたくさん叩きすぎるとReteLimitに引っかかるのでリトライも必要になるのですが、Pub/Subで店舗1件ずつ処理することでその辺のリトライを何も考えなくてよかったのは楽でした。(Pub/Subで呼んだファンクションがエラーになれば勝手にリトライされるので)

フロントエンド

採用技術は下記

  • TypeScript
  • React
    • Vue.js + TSは過去にやったことあるので今回は今まで使ったことのないReactを採用
    • React歴3週間くらい
  • Tailwind CSS
    • 社内でちょいちょい名前を聞いていたので採用
  • Firebase Hosting
  • Google Maps

その他使ったもの

  • GitHub Actions
  • Sentry
  • Terraform

あたり

こだわりポイント

Cloud RunからCloud Functionsに移行して変わったこと

メリット

  • デプロイフローがシンプルになった
    • Cloud Runでアプリを動かすためにはDockerイメージが必要なので必然的にCIでもdocker buildしてContainer Registryにpushする必要があるのだが(GitHub Actions上で1分弱)、Cloud Functions化することでそのフローが完全に不要になった
  • Cloud RunでCloud SchedulerやPub/Subのリクエストを受けるためにはhttpのエンドポイントが必要だったが、Cloud Functions化することでhttp周りの実装が不要になった
    • Cloud Functions側にPub/Sub専用のエンドポイントがある

デメリット

  • Cloud Run時代はデプロイが2分弱だったが*1、Cloud Functions移行後にデプロイに5〜6分かかるようになった
    • Serverless Frameworkのバックエンドで使ってるDeployment Managerの様子を見る感じ純粋にCloud Functionsへのデプロイが一番時間がかかってるのだが、具体的にどこで時間がかかってるのかが特定できないので謎。(おそらくgo getかビルドのどちらかだと思うのだが...)
    • 1つのバイナリに2つのファンクションが含まれているのでそれらを分割すれば早くなる可能性はありそう
  • Cloud Functionsだと最新のGoが1.13
    • 早く1.15使わせてほしい...

GoのstructからTypeScriptのclassを自動生成するようにした

FirestoreにはGoのstructで保存してるのですが、全く同じデータをフロントエンドで使うために tscriptify でTypeScriptのclassに変換しています。

Firestoreに緯度経度の型はあるのだが、実際にはそれだけだと検索で使えなくてハマった

現在地の緯度経度からFirestoreで検索しようとしたのですが当初うまくいきませんでした。(明らかに現在地から外れた店舗が検索にヒットする)

調べると https://stackoverflow.com/questions/46630507/how-to-run-a-geo-nearby-query-with-firestore

Firestore does not support actual GeoPoint queries at present so while the below query executes successfully, it only filters by latitude, not by longitude and thus will return many results that are not nearby.

とあるように、Firestoreは実際のGeoPointクエリをサポートしていないため、latlng型で検索しても緯度しか考慮されないということでした。

そのため緯度経度から算出したGeoHashも別途Firestoreに保存することでようやく現在地周辺の検索ができるようになりました。

ディレクトリ構成を色々変えた

開発中に何回かディレクトリ構成を作り直しました

  • 1段階目
    • 最初はオーソドックスなGoのアプリのディレクトリ構成
  • 2段階目
    • frontend実装直後
    • GoはそのままでTSとReact関係のソースだけfrontendに入れた形
  • 3段階目
    • serverとfrontendの2つのディレクトリ構成
    • この時点ではmakeやnpmのコマンド実行時にいちいちcdするのが嫌だという気持ちがあってこの構成でした
    • TSやReact関係のソースは全部frontendに格納できたのですが、Goはビルドの関係でトップに一部ソースが残ってます
  • 4段階目(今)
    • 「makeやnpmのコマンド実行時にいちいちcdするのが嫌だ」という気持ちはあったんですが、それ以上にトップの見通しが悪いのが嫌だったので完全にディレクトリを分けました(手の平クルー)

f:id:sue445:20201014000907p:plain

frontendとfunctionの接点がfirestoreだけだったのでここまで振り切れたかもしれないです。

本当ならfrontendとfunctionでリポジトリを分けてもよかったのですが、前述の通りGoのstructからTypeScriptのclassを生成してるためリポジトリを分けると逆に連携が難しくなるためモノリシックリポジトリにしました。

port 55301

せっかくなので(?)devServerは55301番ポートを使うようにしました。

副産物

プリマップでSecretManagerを使ってるのですがラッパとして途中でgcp-secretmanagerenvができました

sue445.hatenablog.com

*1:docker buildに1分半でgcloud run deployが30秒