😇

マッチングアプリ開発でFlutter x Go x AWSの組み合わせは失敗だった

に公開

Flutter × Go × AWSで友人とマッチングアプリを開発したけれど、「次はこの構成ではやらないな」と感じたポイントがありました。
今回は、失敗したポイントと、もし1から作り直すならどんな技術選定にするかを共有します。

ちなみに開発したマッチングアプリはApple審査の却下により日の目を浴びることはありませんでした。アプリ審査で地獄を見た記事はこちら

作ったアプリのデモ動画

https://youtube.com/shorts/TMUbs2omn7c?feature=share

インフラアーキテクチャ

使用技術と選定理由

アプリ Flutter

まず人手が少ないのでマルチプラットフォームを同時開発できる要件を満たしていて欲しかった。アプリ開発経験者がいなかったため、事例が豊富そうなものが良いと思っていたが、日本国内の個人アプリ開発者はFlutterの割合が高そうだったから。

結果、Flutterは良かったと思う。1から開発するとしても選定する。

バックエンド Go

友人も私もGoをメイン言語としていたため。

DB操作 sqlc

DB操作にはsqlcでGoコードを生成していた。

項目 内容
ツール名 sqlc
用途 SQLクエリから型安全なGoコードを自動生成する
対象言語 Go(他にもRustやPythonなどのサポートが進行中)
入力 SQLファイル(.sql)とDBスキーマ情報(.sqlまたは.yamlなど)
出力 Goのコード(DBアクセス関数や構造体)

バックエンドは私がメインで開発していたがアプリ開発前にLeetCodeでSQL問題を勉強がてら解いていたらSQLを書くのが楽しくなり、SQLを書いてそこからGoコードを生成できるsqlcにした。私としては大方使い心地良かった。

sqlcの気になった点

1対多のレコードをJOINして取得する時に生成される構造体が扱いにくいところがあった。

具体例で言うと、ユーザが会える都道府県を複数設定できるような1対多のテーブル設計を想定した場合、

-- usersテーブル(1)
CREATE TABLE users (
  id UUID NOT NULL PRIMARY KEY,
  nickname VARCHAR(255) NOT NULL
);

-- meeting_prefecturesテーブル(多)
CREATE TABLE meeting_prefectures (
    user_id UUID NOT NULL,
    prefecture INT NOT NULL,
    PRIMARY KEY (user_id, prefecture),
    FOREIGN KEY (user_id) REFERENCES users (id) ON DELETE CASCADE
);

ユーザIDを指定してユーザ情報と会える都道府県を取得したい場合以下のようなクエリを書くことになるが、

-- name: GetProfile :many
SELECT
    users.id,
    users.nickname,
    meeting_prefectures.prefecture AS meeting_prefecture
FROM users
    LEFT JOIN meeting_prefectures ON users.id = meeting_prefectures.user_id
WHERE
    users.id = $1;

理想的には、配列として構造体に格納されていて欲しいが、実際に生成される構造体はSQLで帰ってくる行をそのまま構造体にした感じなので、クライアントに返す時はここら辺を適切な構造体に詰め直す必要がある。

# 理想
type GetProfileRow struct {
	ID                    pgtype.UUID
	Nickname              string
	MeetingPrefecture     *[]int32
}

# 実際に生成される構造体
type GetProfileRow struct {
	ID                    pgtype.UUID
	Nickname              string
	MeetingPrefecture     *int32
}

このようにJOINしても、sqlcは1対多の結果をマージしない

DBマイグレーションツール dbmate

dbmateを使用した。CLIとしての利用でmigrationファイルがある場所を指定してup/downコマンドを使用してマイグレーションが行える。開発時には特に不満なかった。

API Connect

メッセージ機能をサーバーサイドストリーミングで実装したかったため、gRPCを採用した。その後、ブラウザから利用できる管理画面を作成する際にgrpc-webなどのプロキシサーバが無くても通信できるgRPC互換のConnectに切り替えた。gRPCの自動生成の開発体験はとても良かった。

インフラ AWS

友人も私もAWSに知見があったため選定した。インフラはTerraformで管理していた。

サービス名 用途 選定理由
Fargate Go製APIサーバのホスティング(Dockerコンテナを実行) gRPCストリーミング対応
ALB (Load Balancer) APIリクエストのルーティング Fargateとの連携が容易でHTTPS対応も可能。gRPC対応
S3 Bucket アプリ画像保存用 S3一択
DynamoDB メッセージ情報の保存 メッセージの容量スケールに安価に対応できると考えたから。The DynamoDB Bookを読んでDynamoDBのポテンシャルに惹かれたから
Aurora PostgreSQL マッチ情報/ユーザ情報などん保存 DynamoDBだけで完結されられるか考えたが検索がRDBでないと厳しかった。料金が高かったため、途中から開発時はNeonを使っていた。
ECR (Elastic Container Registry) Fargateで実行するDockerイメージの保管 AWS内で完結する安全なDockerレジストリ。Fargateとの統合が容易。
CloudWatch Logs Fargateからのログ収集・アラート設定 CloudWatch一択
Amazon Pinpoint プッシュ通知の配信(APNs/FCM経由) pinpoint一択
Cognito Flutterアプリのユーザー認証・認可 Cognito一択
Amplify FlutterアプリからCognito, Pinpointに連携する用 使ったほうが認証・プッシュ通知周りの開発を省けたから

Flutter開発でAmplifyはもう使いたくない

AWS使うからFlutterアプリのユーザ管理やプッシュ通知部分はAmplifyを用いることにしたがこれが振り返ると失敗だった。Amplifyはまだ発展途上という感じでドキュメントが充実していなかったし、Amplifyならではの問題にいくつか遭遇した。また、既知の改善点がなかなか改善されることはなくコミュニティが活発でないように思えた。

Amplifyの微妙だった点

AWSの不満だった点

Amplify x Flutterの組み合わせは次で使うことはないと思うが、AWSについてもコスト面で不満が多かった。

  1. ALBを停止できないため、費用を抑えるためには削除する必要がある
    2. そして削除をして再度立ち上げるとホスト名が変わってしまうため、固定にするために独自ドメインの取得とRoute53の設定が必要 → Route53のレコード保持に月0.5ドルかかる
  2. Fargateは使っていない時に自動でインスタンス数を0にスケールインできないため、コストを発生させないためにはテストの度にインスタンス数をいじる必要がある
  3. AuroraDBが高額で手軽に立ち上げてテストできない

1から作り直すならFlutter x Go x Firebase x GCP

長々と書いてきたが1から作り直すならFlutter x Go x Firebase x GCPを採用する。つまり、FlutterとGoは据え置きでAmplifyとAWSを使っていたところをFirbaseとGCPに置き換える。実際にFirebaseとGCPへの載せ替えも行ってみたが、コストメリットがあり、開発体験も上がった。実際にリリースまで行けなかったこともあり、運用した場合にはまた新たな課題があるかもしれないが、少なくとも開発時点では圧倒的にこの構成が良かった。具体的に良かった点をまとめてみる。

新アーキテクチャ図


Firebase x GCPを用いたアーキテクチャ図

Amplify → Firebaseのメリット

Firebaseにしたら、Amplifyで起こっていた問題が全て解消された。ドキュメントの充実度も圧倒的にFirebaseだし、事例も豊富だった。ログイン・サインアップ画面の両方にOAuthの登録・ログインボタンを付与するのも非常に簡単にできたし、プッシュ通知を受信した時やタップした時の制御も簡単に行えた。Amplifyでどれだけ時間を無駄にしたことか...

AWS ALB → GCP API Gatewayのメリット

AWSのAPI GatewayはgRPC対応していなかったためgRPC対応しているALBを採用した。しかし、ALBは停止できず、費用を発生させないためには削除するしかなかった。結果、テストの度にALBを新規作成する必要があった。新規作成する度にアプリの接続ホスト名が変化するため、固定させるために独自ドメインを用意してCNAMEでALBに向けることで、アプリの接続先を固定する必要があった。

一方GCP API Gatewayではこれらのデメリットが発生しなかった

  • gRPC対応
  • API Gateway自体がサーバレスで、使っていないときに課金が発生しない
  • API Gatewayを都度再作成しなくて良いためGCPが自動で割り当てるホスト名をアプリのリクエスト先としておけばOK(独自ドメインの取得の必要なし)

AWS Fargate → GCP Cloud Runのメリット

Fargateでは自動でインスタンス数を0にできなかったが、Cloud Runでは使われていない時間が一定時間でインスタンス数を0にスケールインさせて費用を発生させないようにすることができた。それにより、いちいちインスタンス数をいじる手間が省かれた。

AWS DynamoDB → Firebase Firestoreのメリット

Firebase FirestoreにはFirebaseのSDKを使ってFlutterアプリから直接書き込み・読み込みができる。また、リアルタイムに自分がlistenしている変更を検知・反映できるため、Goのアプリケーションサーバを介したメッセージ機能のストリーミング通信の必要がなくなった。
Firestoreのread/write権限を厳密に定義しなければいけない手間はあるが、ストリーミング通信を適切に実装・テストする手間に比べたら圧倒的に楽。

また、これまではストリーミングのためにはユーザの接続情報をサーバに持たせたステートフルサーバーになっていた。そのため、今の構成のままだと、サーバーのスケールアウトができないような設計になっていた。だが、ストリーミングがなくなったことによりステートレスサーバーにすることができ、負荷に合わせた柔軟なスケールアウトが可能になった。

※ ストリーミングがなくなったので、Cloud Runではなく、Cloud Functionsを使ってもいいかもしれない。AWS LambdaはgRPC非対応だが、Cloud FunctionsはgRPC対応してる。

まとめ

  • Flutterでアプリ開発するならAmplifyよりFirebaseを使おう
  • Firebase使うならAWSよりGCPを使おう
  • gRPC使うならGCPの方が対応が進んでいる

Discussion