🔥

2022年はネイティブ×gRPCが激アツかもしれん

2022/05/15に公開

CyberAgent AI事業本部 DXエンジニアの EFEXP です。

先日久しぶりにAndroidでgRPCを触りました、3年前に触ったときと比べて劇的に開発者体験が改善されており、みなさんにもgRPCを使ってほしくなりました。
しばらく触っていて、もしかして2022年はモバイルエンジニアにとってgRPCが激アツかもと思ったので共有させてください。
gRPC Kotlinしか触っていないのでgRPC Swiftの話は参考程度に聞いてください。

gRPCがモバイル領域でいま激アツな理由

2022年、私はモバイル開発のクライアント・サーバー間通信でgRPCを用いるタイミングが来たと考えています。
もともとモバイルネイティブアプリとgRPCの相性はよいはずですが、2020年ごろまではバージョン更新によってすぐ互換性が壊れるほどエコシステム全体が不安定な状況でした。
この安定性の欠如はモバイル領域におけるgRPC普及の最後の壁となっていました。そのような背景のなか、2020年から2022年にかけて複数のコアライブラリのメジャーリリースが行われました。メジャーリリースをきっかけとしてコアライブラリを取り巻く、さまざまなプラグインの仕様も安定してきています。
このことから私は2022年がモバイル開発においてgRPCを始める最適なタイミングだと考えています。

モバイル領域においてgRPCのエコシステムがやっと安定してきた

gRPCは2020年の終わりまでモバイルサポートが貧弱で、頻繁に破壊的な変更が行われていました。
しかし、2020年12月gRPC Kotlin の1.0.0リリースに続き、2021年2月gRPC Swift1.0.0がリリースされたことによって、破壊的な変更は劇的に減少しました。
また、2021年7月にはAndroid Developers公式サイトに gRPCの解説ページ ができました。
このように、近年エコシステムが安定してきたことで採用事例や情報量が増え、公式による正しいgRPCの実装方法やノウハウが参照できるようになりました。

モバイル×gRPCはもともと相性バツグンだった

私はモバイルネイティブアプリと、gRPCの相性は非常によいと考えています。それはgRPCが次のようなモバイルアプリ特有の制限とよく噛み合うからです。

  • Kotlin/Swiftなどの静的型付け言語を用いる
  • バッテリー消費を抑えるため、余計な処理をしてはならない
  • 通信量を最小限に抑えなくてはならない
  • 画面が小さいためフリーズしたように見えてはならない
  • 圏外などでネットワークが突然途切れる

モバイルの制限された状況下でgRPCを用いることには、次のようなメリットがあります。

  • gRPCとProtocol Buffersによる強い型付けは、Kotlin,Swiftのような静的型付き言語と相性がよい
  • gRPCのHTTP/2は、同時に複数のTCPコネクションを用いて効率よく通信する
  • gRPCのHTTP/2,Protocol Buffersは、バイナリを用いて通信を圧縮し通信量を削減できる
  • gRPCによるコード生成は、動画のストリーミング配信などを簡単に実装できる(動画すべてのダウンロードを待たない)
  • gRPCのAndroid実装は、OSのネットワークコネクションを監視し自動で再接続をハンドリングする

私はこれらの理由から、モバイルがgRPCの次の主戦場になるのではないかと考えています。

gRPCを用いるデメリット

もちろんgRPCをモバイルネイティブ開発で用いることには、デメリットもあります。
まず、アプリとサーバーの双方を自社で管理下に置けるサービスの開発である必要があります。gRPCはアプリ、サーバー双方の対応が必要だからです。
また、ブラウザからの接続はリバースプロキシを挟む必要があります。アプリのバックエンドとまったく同じサーバー構成を使い回すことはできません。ブラウザがgRPCに対応していない ためです。

そのほかにも、RESTと比較してさまざまな 一長一短 はありますが、ビジネス要件に適合さえすれば技術成熟度的には使ってよい時期に来たと考えています。

gRPCはサーバーのコードをローカルの関数のように呼ぶ

まずは全体像を把握するために、使用イメージを見てください。
サーバーサイドストリーミングの例のみを出しますが、クライアントサイドストリーミング、双方向ストリーミングもほとんど同じです。

ストリーミング通信の例

通知の送信、動画の再生や、時間のかかる処理について終わった分だけ徐々に返すなどのユースケースで用いるサーバーサイドストリーミングの実装例です。
サンプルコードのため、実際には動きません。

Server.kt
val itemStreamFlow =
        MutableSharedFlow<Item>(replay = 1, extraBufferCapacity = 1)
....
override fun feedItem(request: ExampleRequest): Flow<ExampleResponse> {
    return itemStreamFlow
        .map{
            exampleResponse { //Kotlin DSLでExampleResponseを作る
            item = Flow
            }
        }
    }
}
Client.kt
val stubItem:Flow<ExampleResponse> =stub.feedItem(ExampleRequest)
stubItem.onEach {
    print(it.item.toString())
}

Kotlinに馴染みのない方は読みづらいですが、コード自体の理解は不要です。
重要なのは、クライアントが呼び出している関数の名前、引数、戻り値の型のセット(シグネチャと呼びます)とサーバーの関数のシグネチャが一致するということです。

クライアントがfeedItem(ExampleRequest):Flow<ExampleResponse>というローカルの関数を呼ぶとネットワークを介してサーバー上の同じシグネチャをもつ関数が呼ばれ、クライアントはサーバーの関数によって返されたFlowインスタンスを手に入れることができます。(厳密には違うがあたかもそういうふうに見えます)
これがRPCRemote Procedure Call)と呼ばれる理由です。
gRPCのストリーミング通信では確立されたコネクションが続く限り、サーバーでitemStreamFlow.tryEmit(Item)が呼ばれると同時にクライアントでもprint(it.item.toString)が呼ばれ続けます。

gRPCのStubとは

a.png

gRPCではStubというコンポーネントがサーバーとクライアントの仲介をします。同じ言葉がテストの文脈でよく出てくるため気を付けてください。Stubとは代用品のことで「呼び出し側からみて通信相手がそこにいるようなフリをする」ためのローカルなコンポーネントです。

  1. Client Stubはクライアントにライブラリとして組み込まれ、サーバーのフリをしてローカルで関数呼び出しを受け付けます。

  2. ClientStubはHTTP/2とProtocol Buffersを用いてHTTPリクエストを生成します。

  3. ClientStubは初期化時に指定されたサーバーへHTTPリクエストを投げます。

  4. リクエストを受け取ったホストは、ServerStubの関数を呼び出します。

  5. サーバーの実装の中にoverrideされた関数の実装が呼び出されて、クライアントに値が返されます。

ClientStub呼び出し側は、普通にKotlinの関数を呼んでいるだけです。中身がネットワークを介して帰ってきていることは意識する必要がありません。

この仲介を果たすStubはprotoというサービス定義ファイルから生成されます。
gRPCにおけるサービスとは、構造化されたモデルの定義とそれらを引数返り値にもつことができる関数のセットのことです。OpenAPIスキーマと役割としては似ています。

example.proto
service ExampleService {
    rpc FeedItem (ExampleRequest) returns (stream ExampleResponse);
    ...
}

message ExampleRequest {
    repeated string itemIds = 1;
}

message ExampleResponse {
    string connectionId = 1;
    Item item = 2;
}

message Item {
    string id = 1;
    ...
}

このprotoをprotocコマンドでコンパイルしStubを生成します。Stubはサーバー、クライアントそれぞれにライブラリとして組み込んで使用します。

Android×gRPCの要点

全体の大まかな流れが把握できたところで、次はAndroidで使う際の要点を解説します。

stubを生成するためのプロジェクト構造

次にprotoからKotlin ServerとAndroidで使うStubを生成するプロジェクトの構成例を示しました。この構成にはClient、Serverなど生成されたStub利用者側のコードは含まれていません。

app
|-build.gradle.kts
|-client_stub
| |-build(自動生成)
| |-build.gradle.kts
|-server_stub
| |-build(自動生成)
| |-build.gradle.kts
|-proto
| |-build.gradle.kts
| |-src/main/proto
| | |-example.proto

appではプロジェクト全体で使うライブラリやプラグインのバージョン定義などを行っています。
clientがAndroidなど、制限されたJVMランタイム環境である場合protobufライブラリは Protobuf Lite を用います。

app/build.gradle.kts
plugins {
    kotlin("jvm") version "1.6.21" apply false
    id("com.google.protobuf") version "0.8.18" apply false
}
ext["grpcVersion"] = "1.46.0"
ext["grpcKotlinVersion"] = "1.2.1"
ext["protobufVersion"] = "3.20.0"
ext["coroutinesVersion"] = "1.6.1"
client_stub/build.gradle.kts
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.plugins
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc
plugins {
    kotlin("jvm")
    id("com.google.protobuf")
}

dependencies {
    protobuf(project(":proto"))
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${rootProject.ext["coroutinesVersion"]}")
    api("io.grpc:grpc-stub:${rootProject.ext["grpcVersion"]}")
    api("io.grpc:grpc-protobuf-lite:${rootProject.ext["grpcVersion"]}")
    api("io.grpc:grpc-kotlin-stub:${rootProject.ext["grpcKotlinVersion"]}")
    api("com.google.protobuf:protobuf-kotlin-lite:${rootProject.ext["protobufVersion"]}")
}

java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
    kotlinOptions {
        freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn")
    }
}
protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:${rootProject.ext["protobufVersion"]}"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:${rootProject.ext["grpcVersion"]}"
        }
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:${rootProject.ext["grpcKotlinVersion"]}:jdk7@jar"
        }
    }
    generateProtoTasks {
        all().forEach {
            it.builtins {
                named("java") {
                    option("lite")
                }
                id("kotlin") {
                    option("lite")
                }
            }
            it.plugins {
                id("grpc") {
                    option("lite")
                }
                id("grpckt") {
                    option("lite")
                }
            }
        }
    }
}

`

server_stub/build.gradle.kts
import com.google.protobuf.gradle.generateProtoTasks
import com.google.protobuf.gradle.id
import com.google.protobuf.gradle.plugins
import com.google.protobuf.gradle.protobuf
import com.google.protobuf.gradle.protoc

plugins {
    kotlin("jvm")
    id("com.google.protobuf")
}

dependencies {
    protobuf(project(":protos"))
    api(kotlin("stdlib"))
    api("org.jetbrains.kotlinx:kotlinx-coroutines-core:${rootProject.ext["coroutinesVersion"]}")
    api("io.grpc:grpc-stub:${rootProject.ext["grpcVersion"]}")
    api("io.grpc:grpc-protobuf:${rootProject.ext["grpcVersion"]}")
    api("com.google.protobuf:protobuf-java-util:${rootProject.ext["protobufVersion"]}")
    api("com.google.protobuf:protobuf-kotlin:${rootProject.ext["protobufVersion"]}")
    api("io.grpc:grpc-kotlin-stub:${rootProject.ext["grpcKotlinVersion"]}")
}
java {
    sourceCompatibility = JavaVersion.VERSION_11
    targetCompatibility = JavaVersion.VERSION_11
}

tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
    kotlinOptions {
        freeCompilerArgs = listOf("-opt-in=kotlin.RequiresOptIn")
    }
}

protobuf {
    protoc {
        artifact = "com.google.protobuf:protoc:${rootProject.ext["protobufVersion"]}"
    }
    plugins {
        id("grpc") {
            artifact = "io.grpc:protoc-gen-grpc-java:${rootProject.ext["grpcVersion"]}"
        }
        id("grpckt") {
            artifact = "io.grpc:protoc-gen-grpc-kotlin:${rootProject.ext["grpcKotlinVersion"]}:jdk7@jar"
        }
    }
    generateProtoTasks {
        all().forEach {
            it.plugins {
                id("grpc")
                id("grpckt")
            }
            it.builtins {
                id("kotlin")
            }
        }
    }
}

/app以下でgenerateProtoタスクを実行するとclient_stub/buildserver_stub/build次にStubの入ったjarが生成されます。

ササッと試す程度なら、jarを生成した後、手作業でインポートするだけで十分です。
Androidアプリプロジェクトの/app/libs 次に、生成されたjarを手でコピーして、build.gradle.ktsimplementation(files("libs/client_stub-1.0-SNAPSHOT.jar")) とすればすぐ試せます。

本番環境でちゃんとやるなら、protoだけのリポジトリを作り、ClientとServerのmasterにprotoのmaster最新版が使われていることを保証するような仕組みを作る必要があります。
たとえ、Github ActionsとGitHubのタグを用いて、protoに変更があれば再ビルド、stubをアーティファクトとして公開、利用側はビルドツールで依存性解決するなどが考えられます。

ネットワーク再接続の自動ハンドリング

また、gRPC Androidライブラリ を用いると、もしネットワークが途切れても効率よくネットワークに再接続できます。gRPC AndroidライブラリにはChannelが構築される際に自動でネットワークイベントリスナーを登録するAndroidChannelBuilderが含まれています。

ネットワークイベントリスナーがAndroidデバイスのネットワーク状態を監視し、AndroidChannelBuilderがネットワーク切断後の再接続処理を自動で行います。
ネットワーク切断後OSからネットワーク回復のイベントが飛んできたときだけ再接続処理をすることで、効率的にリトライを行います。
素のgRPC自体にも再接続の仕組みはありますが、アルゴリズムで決められた秒数ごとにリトライといった単純なものです。
Stubを利用するAndroid側でimplementations("io.grpc:grpc-android:1.45.1")の依存性を追加し、ManagedChannelの代わりにAndroidChannelを指定します

AndroidChannelBuilder.forAddress(URL,PORT).usePlaintext().build()

まとめ

gRPCは今回紹介したStreamingやネットワークの監視機構以外にも、プラグインによってさらに機能を付加できます。プラグインにはauth、ドキュメントの自動生成、ロギングやアクセス制御、リフレクションなど非常に多くの種類が存在します。
これらをビジネス要件に沿って組み合わせることで、従来のjson+RESTを用いたAPI開発と比較して人間が記述する部分を減らし、より安全に、より高速に開発できます。

プラグインなど周辺のエコシステムも近年になって充実してきたため、やはりいまモバイルネイティブエンジニアにとってgRPCがアツいです。
関わるビジネスの要件に合わせて新規プロダクトでの全面採用や、既存のプロダクトへのgRPCの部分的導入を考えてみてはいかがでしょうか。

参考文献

gRPC周りは変化が速いので、古い記事がメッチャクチャ多いです。公式でも protobuf-gradle-plugin readmeなどは古い情報も混ざっており、取捨選択が難しいです。
今回の記事は次の公式例を参考にしてまとめました。

Discussion