🆙

Android へ Connect RPC を導入

に公開

はじめに

こんにちは。ビジネス映像メディア「PIVOT」で、モバイルアプリのテックリードをしている さきさん です。

この記事では、PIVOTのAndroidアプリに Connect RPC を導入した背景と、実際の導入方法について紹介していきます。

Connect RPC導入の背景

PIVOTアプリではもともと、REST API のレスポンスデータを Protocol Buffers(以下 Proto)でシリアライズして返却する方式を採用していました。これ自体は各プラットフォーム間で API レスポンスを効率的にやり取りできるメリットがあったのですが、開発を進めていくなかでいくつか課題が見えてきました。

  • レスポンス側のみでの Proto 利用
    通信の片側(レスポンス)だけで Proto を使っていて、リクエストも含めた完全なスキーマ駆動設計にはなっていなかった。

  • RPC 化されていない
    gRPC のようなリクエスト/レスポンスの型安全な通信ではなく、あくまでデータ効率を高めるための部分利用にとどまっていた。

  • スキーマからのコード生成が未活用
    .proto ファイルを元に通信層のコードを自動生成する仕組みがなく、開発・保守の効率化が十分に図れていなかった。

こうした課題を解決するために導入したのが Connect RPC です。

なぜ gRPC ではなく Connect RPC なのか

https://connectrpc.com/

Androidアプリで gRPC を使おうとすると、通信ライブラリの依存や HTTP/2 環境の制約がネックになりがちだったりします。バイナリ形式なのでデバッグもしづらく、通信ログの解析もなかなか大変です。

Connect RPC はこういった課題を解決するために設計された RPC フレームワークで、Protobuf スキーマを共有しつつ HTTP/1.1・HTTP/2 の両方に対応し、JSON 通信も可能なので、通常の HTTP API と同じ感覚で扱えるのがポイントです。

つまり「gRPC の型安全性」と「REST の扱いやすさ」を両立できる仕組みというイメージですね。

Android 側では OkHttp や Retrofit といった既存の通信基盤にそのまま乗せられるので、導入コストが低いのも地味にありがたいところでした。

PIVOTでの Connect RPC 導入の経緯や全体像については、チームメンバーのたわちゃんが以下の記事で詳しくまとめてくれているので、あわせてご覧ください。

https://zenn.dev/pivotmedia/articles/pivot-connect-rpc-adoption

Before / After:REST と Connect RPC の比較

実際にコードがどう変わったかを見てみます。ここでは同じ「アイテム取得」の操作で比較します。

Before:Retrofit によるREST API呼び出し

// Retrofitのエンドポイント定義(手動で作成)
interface MyAppEndpoint {
    @GET("item/{id}")
    suspend fun getItem(@Path("id") id: Int): ItemDetailView
}

// RemoteDataSource での呼び出し
class SampleLegacyRemoteDataSource @Inject constructor(
    private val api: Api,
) {
    suspend fun getItem(itemId: Int): ItemDetailView =
        api.getItem(itemId)
}

この方式だと、エンドポイントの定義を手動で書く必要があって、リクエスト・レスポンスの型もサーバー側と手動で合わせないといけません。APIの仕様が変わったときに気付きづらいのが地味につらいポイントでした。

After:Connect RPC による呼び出し

// ServiceClient は .proto ファイルから自動生成される(手書き不要)

// RemoteDataSource での呼び出し
class SampleRemoteDataSource @Inject constructor(
    private val sampleServiceClient: SampleServiceClient,
) {
    suspend fun getItem(itemId: Int): ResponseMessage<GetItemResponse> =
        sampleServiceClient.getItem(
            request = GetItemRequest.newBuilder()
                .setContentId(itemId)
                .build(),
        )
}

大きな違いとして、

  • エンドポイント定義が不要: .proto ファイルから SampleServiceClient が自動生成されるので、HTTPメソッドやパスの定義を手書きする必要がない
  • リクエスト・レスポンスの型が保証される: Protobuf のスキーマから生成された型を使うので、サーバーとクライアントで型の不整合が起きない
  • ResponseMessage<T> による統一的なレスポンスハンドリング: 成功・失敗のハンドリングが一貫する

比較まとめ

観点 REST(Retrofit) Connect RPC
エンドポイント定義 手動(@POST, @GET 等) .proto から自動生成
リクエスト構築 データクラス直接生成 .newBuilder() パターン
型安全性 サーバーと手動で合わせる スキーマから保証
レスポンス型 直接 T ResponseMessage<T>
デバッグ HTTP リクエストとして確認可能 HTTP/1.1 + JSON でも通信可能

Androidでの導入方法

ここからは実際の導入手順を紹介していきます。

依存関係の追加

// app/build.gradle.kts
dependencies {
    // Protobuf Lite
    implementation(libs.protobufJavalite)
    // Connect RPC
    implementation(libs.connect.kotlin)
    implementation(libs.connect.kotlin.okhttp)
    implementation(libs.connect.kotlin.google.javalite.ext)
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:${libs.versions.protobuf.get()}"
    }
    generateProtoTasks {
        all().forEach { task ->
            task.builtins { id("java") { option("lite") } }
        }
    }
}

Android では lite オプションを指定して protobuf-javalite を使うことで、APK サイズへの影響を抑えています。ここはけっこう大事なポイントで、フルの protobuf-java だとサイズが大きくなりがちなので、モバイルでは lite 一択かなと思います。

Connect クライアントの生成

OkHttp に ConnectOkHttpClient をラップし、Connect プロトコルと Protobuf Lite を指定して ProtocolClient を生成します。認証トークンやアプリバージョン等のヘッダは OkHttp のインターセプタで付与するイメージです。

private fun createProtocolClient(host: String, cache: Cache): ProtocolClient =
    ProtocolClient(
        httpClient = ConnectOkHttpClient(createOkHttpClient(cache)),
        config = ProtocolClientConfig(
            host = host,
            serializationStrategy = GoogleJavaLiteProtobufStrategy(),
            networkProtocol = NetworkProtocol.CONNECT,
        ),
    )

private fun createOkHttpClient(cache: Cache): OkHttpClient =
    OkHttpClient.Builder()
        .cache(cache)
        .addInterceptor(AppAuthInterceptor())       // 認証・メタ情報
        .addNetworkInterceptor(AppLogInterceptor())  // ログ
        .build()

既存の OkHttp クライアントと同じ構成で組めるので、Retrofit を使い慣れているチームであれば違和感なく導入できると思います。

サービスクライアントの初期化

各サービスのクライアントは Hilt で DI しています。

@Provides
@Singleton
fun provideSampleServiceClient(
    protocolClient: ProtocolClient,
): SampleServiceClient = SampleServiceClient(protocolClient)

API の呼び出し

// リクエスト・レスポンスのクラスは .proto から自動生成される
suspend fun getItem(itemId: Int): ResponseMessage<GetItemResponse> =
    sampleServiceClient.getItem(
        request = GetItemRequest.newBuilder()
            .setContentId(itemId)
            .build(),
    )

API ごとにリクエスト・レスポンスクラスが自動生成されるので、型やパラメータの不一致をコンパイル時に検知できます。手動で型を合わせていた頃と比べると、安心感がだいぶ違いますね。

導入して変わったこと

Connect RPC を導入してから実感している変化をまとめます。

  • API 追加時の工数が減った: エンドポイント定義を手書きする必要がなくなり、.proto にサービスを追加すれば Android 側のクライアントコードが自動生成されるので、新しいAPIの対応がかなり楽になりました
  • 型不整合によるバグがなくなった: サーバーとクライアントが同じ .proto を参照しているので、「APIの仕様変更に気付かず実行時エラー」というケースがなくなったのは地味にありがたいです
  • レガシーコードとの共存が容易: 既存の Retrofit ベースの API と新しい Connect RPC ベースの API を並行して運用できるので、段階的に移行を進められています
  • デバッグのしやすさは維持: Connect プロトコルは HTTP/1.1 + JSON でも通信できるので、従来と同じように通信ログを確認できるのもポイントです

まとめ

Connect RPC の導入により、「gRPC の型安全性」と「REST の扱いやすさ」を両立した通信基盤を構築できました。

とくに Android 開発においては、OkHttp との親和性が高く導入コストが低い点と、Protobuf Lite によって APK サイズへの影響を最小限に抑えられる点が大きなメリットだと感じています。

現在は REST API から Connect RPC への段階的な移行を進めていて、新規 API は Connect RPC、既存 API は順次移行という方針で運用しています。サーバーとクライアント間の距離がぐっと近くなったことを実感していて、これからの開発がさらに楽しみです。

PIVOT Tech Blog

Discussion