🌐

gRPC を Flutter クライアントで使ってみた 〜使うべきではない〜

2024/12/02に公開

GDGoC Japan Advent Calender 2日目

こんにちは、ゆにねこです。普段は情報系の学生をやりつつ、空いた時間でシステム開発や GDG on Campus University of Osaka (GDGoC Osaka) という技術コミュニティの運営を行っています。

https://gdsc-osaka.jp

本記事では、とあるモバイルアプリのクライアントサイドに gRPC を採用し、辛い思いをした話をしようと思います。

gRPC とは

Remote Procedure Call の一種です。API を定義した .proto ファイルを元に、クライアント及びサーバー用のコードを自動生成し、メソッドを呼び出すように通信できるシロモノです。

user_service.proto
service User {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}

message GetUserRequest {
  string id = 1;
}

message GetUserResponse {
  User user = 1;
}

リクエストとレスポンスが一対一であるような "普通の" Web API に加え、ストリーミングや双方向通信にも対応しています。マイクロサービス間の通信に使用されることが多いですが、クライアントサイドで使用することもできます。

grpc
https://grpc.io/docs/what-is-grpc/introduction/

gRPC on Flutter の後悔

新しいもの (gRPC 自体は新しくないものの) 好きな私は、そんな gRPC を Flutter クライアントサイドに採用してしまったのです!

勿論考え無しに採用したわけではなく、チャット等のストリーミングと相性の良い機能があった他、Open API の Dart クライアントを生成する openapi_generator にバグがあった等、それなりの理由はありました。

後悔1. ファイルアップロードが辛い

さて、Web API でファイルアップロードを行う方法はいくつか存在します。multipart/form-data で送るとか、body に base64 エンコードしたバイナリを入れてしまうとか。前者は多くの言語でライブラリが存在しますし、ファイルのメタデータも送信できていいですよね。

Future<void> uploadFile(String filePath, String url) async {
  final dio = Dio();

  final file = File(filePath);
  final fileName = basename(file.path);
  final formData = FormData.fromMap({
    "file": await MultipartFile.fromFile(file.path, filename: fileName),
  });

  fInal response = await dio.post(url, data: formData);
}

では gRPC はどうでしょうか?
gRPC はリクエストとレスポンスのデータサイズに上限値 (既定値は 4MB) が存在するため、大きなデータはストリームで送信することが推奨されています。これは辛いです。multipart/form-data では既存の非同期関数を一つ呼べばよかったものが、gRPC ではメタデータとファイルデータチャンクの型定義を行った上で、クライアントからファイルを細切れにしてアップロードし、サーバー側で細切れのファイルを再構築するロジックを自作する必要があります!

クライアントサイド
final fileService = await ref.read(fileServiceProvider.future);
for (final file in files) {
 uploadFutures.add(fileService.uploadFile(_uploadFileStream(File(file.path)),
    options: uploadFileCallOptions(
      mimeType: lookupMimeType(file.path),
      filename: file.name,
    )));
}

Stream<UploadFileRequest> _uploadFileStream(File file) async* {
  await for (final chunk in file.openRead()) {
    yield UploadFileRequest()..data = chunk;
  }
}

後悔2. カスタムドメインの接続が辛い

クラウドにしろオンプレにしろ、API サーバーにはカスタムドメインを割り当てたいですよね。Google Cloud でカスタムドメインを割り当てる場合は大体次の方法があります:

  1. ロードバランサーでプロキシする
  2. Firebase Hosting で rewrite する
  3. Cloud Run Integration ドメインマッピング (preview)

1 は金銭的なコストのため、3 は region が非対応だったため却下となり、当システムでは 2 の Firebase Hosting でカスタムドメインをマッピングすることにしました。

firebase.json
{
  "hosting": {
    "rewrites": [
      {
        "source": "**",
        "run": {
          "serviceId": "my-project-id",
          "region": "asia-northeast2"
        }
      }
    ]
  }
}

けれども、一連の設定が終わってもカスタムドメインから Cloud Run の gRPC に接続できません。どうやら Firebase Hosting は HTTP/1.1 しかサポートしていないらしく、HTTP/2 を用いる gRPC はマッピングできないようでした。

後悔3. Cloud Run 上で REST と同居できないのが辛い

サーバーで Webhook を受け付けて gRPC のイベントを発火する等、ユースケースによっては同一コンテナで gRPC サーバーと REST API サーバーを同居させたい場合があるかと思います。一方、Cloud Run は単一ポートしか開放できないため、プロトコルの異なる gRPC (HTTP/2) と REST API (HTTP/1.1) は同居できません

サイドカーやプロキシを用い方法もありますが、今回はコストとの兼ね合いで REST API 受付専用の Cloud Run サービスを作成し、gRPC サーバーに HTTP リクエストの中身を gRPC で送信することにしました。

さいごに

ここまで gRPC を批判してきましたが、私は gRPC は好きです。RESTful API と比較して型安全性や変更容易性で優れていますし、proto ファイルの書き心地も気に入っています。

とは言え、適材適所ですね。オーケストレーション層を作るなどして、クライアントサイドは素直に REST を用いたほうが良いでしょう。

Discussion