gRPCに入門してみる
研修資料をやってみる
サーバーの実装は、Kotlin でやってみる (Kotlin 経験はない)
環境構築
Java のインストール
$ asdf plugin-add java https://github.com/halcyon/asdf-java.git
$ asdf install java adoptopenjdk-17.0.1+12
$ asdf local java adoptopenjdk-17.0.1+12
kotlinのインストール
$ asdf plugin-add kotlin https://github.com/asdf-community/asdf-kotlin.git
$ asdf install kotlin 1.5.31
$ asdf local kotlin 1.5.31
gradleのインストール
$ asdf plugin-add gradle https://github.com/rfrancis/asdf-gradle.git
$ asdf install gradle 7.2
$ asdf local gradle 7.2
protoc のインストール
https://github.com/paxosglobal/asdf-protoc がM1対応してない & メンテされてないから、asdf pluginを作ってみる練習としてasdf-template-pluginをもとに作った。asdf-template-pluginからのプラグイン作る体験、めっちゃ良い。
$ asdf plugin-add java https://github.com/nissy-dev/asdf-protoc.git
$ asdf install protoc 3.19.1
$ asdf local protoc 3.19.1
ただprotoc関連のツールは、そもそもローカルにインストールするよりはdockerコンテナを作るケースが多そう....
Kotlinで生成できるところまではできたが、時間がかかり過ぎているので一旦Goでやる。
参考にした資料
雛形を作成する
$ gradle init --dsl kotlin
build.gradle.kts
を編集する
以下の設定で一応動いた。設定の細かいところまでは完全に理解できていない。
コードについては、build/generated/source/proto/main
に生成されている。
ここからの進み方がわからなすぎるので、やっぱりGoでやることにする。
/*
* This file was generated by the Gradle 'init' task.
*
* This is a general purpose Gradle build.
* Learn more about Gradle by exploring our samples at https://docs.gradle.org/7.2/samples
*/
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
// 定数の定義
val grpcVersion = "1.41.1"
val grpcKotlinVersion = "1.2.0"
val protobufVersion = "3.19.1"
val coroutinesVersion = "1.5.2"
// gradle-pluginの指定
plugins {
// アプリケーションに関する一般的な機能を提供するプラグイン
application
// kotlinのプラグイン
// TODO: なぜkotlinだけkotlin()なのか...?
kotlin("jvm") version "1.5.31"
// protobufのプラグイン
id("com.google.protobuf") version "0.8.17"
// kotlinのlintのプラグイン
id("org.jlleitschuh.gradle.ktlint") version "10.2.0"
}
// 依存パッケージを取得しにいくリポジトリ・順番を指定 (上から見に行く)
repositories {
google()
mavenCentral()
}
// 依存パッケージ
// TODO: implementation()、api()、runtimeOnly()などいくつかの依存解決の方法にも指示を追加できる (ここでは深追いしない)
// https://developer.android.com/studio/build/dependencies?hl=ja#dependency_configurations
dependencies {
// kotlin関連の依存パッケージ
implementation(kotlin("stdlib"))
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutinesVersion")
// gRPC関連の依存パッケージ
implementation("com.google.protobuf:protobuf-kotlin:$protobufVersion")
implementation("com.google.protobuf:protobuf-java-util:$protobufVersion")
implementation("io.grpc:grpc-protobuf:$grpcVersion")
// see: https://github.com/grpc/grpc-java/issues/8523
implementation("io.grpc:grpc-stub:$grpcVersion")
implementation("io.grpc:grpc-kotlin-stub:$grpcKotlinVersion")
}
java {
sourceCompatibility = JavaVersion.VERSION_1_8
}
tasks.withType<org.jetbrains.kotlin.gradle.tasks.KotlinCompile>().all {
kotlinOptions {
freeCompilerArgs = listOf("-Xopt-in=kotlin.RequiresOptIn")
}
}
protobuf {
protoc {
artifact = "com.google.protobuf:protoc:$protobufVersion"
}
plugins {
id("grpc") {
// see: https://github.com/grpc/grpc-kotlin/issues/273#issuecomment-872682803
artifact = "io.grpc:protoc-gen-grpc-java:$grpcVersion:osx-x86_64"
}
id("grpckt") {
artifact = "io.grpc:protoc-gen-grpc-kotlin:$grpcKotlinVersion:jdk7@jar"
}
}
generateProtoTasks {
all().forEach {
it.plugins {
id("grpc")
id("grpckt")
}
it.builtins {
id("kotlin")
}
}
}
}
Goの設定は、チュートリアル通りやればOK
サーバーを実装してみよう
type Server struct {
deepthought.UnimplementedComputeServer
}
// 生成されたインタフェース (deepthought.ComputeServer) をアノテーションした構造体を定義し、
// 型エラーが出ないように実装の詳細を実装する
var _ deepthought.ComputeServer = &Server{}
// メソッドの実装
func (s *Server) Boot(req *deepthought.BootRequest, stream deepthought.Compute_BootServer) error {
......
}
....
サーバー側でエラーを投げるときには、基本は定義済みのエラーコードを利用する
クライアントを実装してみよう
// コネクションを貼る
conn, err := grpc.Dial(addr, grpc.WithInsecure())
if err != nil {
return err
}
// 使い終わったら Close しないとコネクションがリークします
defer conn.Close()
// 自動生成された RPC クライアントを conn から作成
cc := deepthought.NewComputeClient(conn)
// A Context carries a deadline, a cancellation signal, and other values across API boundaries.
ctx := context.Background()
// 自動生成された Infer RPC 呼び出しコードを実行
stream, err := cc.Infer(ctx, &deepthought.InferRequest{Query: "dummy"})
関数書くときに、ポインタや構造体の概念が出てきたけどRustやったおかげでなんとかなった
発展問題
プロトコルを拡張してみよう
プロトコルに前方および後方互換性を保つ仕組みがあることです。仕組みは単純で、メッセージのすべてのフィールドがオプショナル、つまり指定してもしなくても良いということになっているのです。
互換性は確かに保たれそうだけど、サーバーの実装でちゃんとフィールドをバリデーションする必要はありそう。
注意点は、削除したフィールドの ID は他のフィールドに使いまわさないことです。 使いまわしを防止するため、Protocol Buffers には Reserved Fields という仕組みがあります。
使えなくなったフィールドが型エラーになって気づきやすくてよかった。
keep alive
keep alive については知らなかった
TCP の場合、接続先のサーバーが落ちると場合によっては長時間そのことに気付けない問題があります。 詳しくは TCP とタイムアウトと私を読んでみてください。
この問題に対応するため、gRPC は HTTP/2 の PING フレームを送信することで接続先がまだ存在していることを確認できます。
同様にログもセットしたけど、情報も多くて雑に設定するなら簡単にできた。
最後にクライアントのコードは、実際Webからのリクエストが多そうだからgrpc-webを使ってみる。
なぜそもそもgRPC-Web が生まれたのか....?
gRPC-Webのデザインゴール
adopt the same framing as “application/grpc” whenever possible
本家gRPCのプロトコルと可能な限り同じフレーミング (データの分割方法?) を適応する
フレーム: HTTP/2 での通信の最小単位。それぞれフレーム ヘッダーが含まれ、少なくともフレームが属するストリームを識別します。
decouple from HTTP/2 framing which is not, and will never be, directly exposed by browsers
ブラウザが直接提供していない(JSでコントロールできない)、そして今後も提供されることのないHTTP/2 特有(HTTP/1で代替できない)のフレーミングから切り離す。
これは、HTTP2で通信できる場合はHTTP2を使用するが、そうでない場合はHTTP/1.1にフォールバックすることと関係がある。ブラウザは、レイヤー的には最も外側のレイヤーなので、HTTP2 or HTTP/1.1のどちらも透過的に扱う必要がある。
Web application must not know whether HTTP2 or HTTP/1.1 is used under the hood. This means HTTP/1.1 and HTTP2 must be able to be handled transparently so that browser will never provide a way to control a feature only available in HTTP2 by JavaScript.
(ちなみに、提供されてないフレーミングは具体的にどんなものなんだろう...?)
support text streams (e.g. base64) in order to provide cross-browser support (e.g. IE-10)
IEサポートのためにテキストストリーミングを可能にする。(IE10ではHTTP2は使えない https://caniuse.com/http2) gRPC-Webでは、Base64 をデフォルトのエンコーダ / デコーダとして規定している。
こんな話もあった
When you say "browser supports http/2", it is in the context of transparently converting a regular HTTP 1.1 request and "upgrades" the connection to a http/2 one
gRPC-Webでのプロキシ
昔はバックエンドとクライアントとの間にはREST APIをおいて、ブラウザからのリクエストをgRPCで実装されたサーバにつなげていた。以下のブログの図がわかりやすい。
client wants to authenticate using a gRPC backend server by POSTing JSON to the HTTP server’s /auth endpoint. The HTTP server translates that POST request into an AuthRequest Protobuf message, sends that message to the backend gRPC auth server, and finally translates the AuthResponse message from the auth server into a JSON payload for the web client.
クライアントは、HTTPサーバーの/authエンドポイントにJSONをPOSTすることで、gRPCバックエンドサーバーを使用して認証を行いたいと考えています。HTTPサーバーは、そのPOSTリクエストをAuthRequest Protobufメッセージに変換し、そのメッセージをバックエンドのgRPC authサーバーに送信し、最後にauthサーバーからのAuthResponseメッセージをWebクライアント用のJSONペイロードに変換します。
↑のようなREST APIサーバーを介したクライアントとサーバー間の通信とは対照的に、gRPC-WebはEnd to EndのgRPC通信を実現する
That would mean no HTTP status codes, no JSON SerDe, and no deployment and management burden for the HTTP server itself.
HTTPステータスコードやJSON SerDe(シリアライザ/デシリアライザ)が不要になり、HTTPサーバー自体の導入や管理の負担もなくなります。
ただ、End to Endではあるんだけど、クライントリクエストはHTTP/1を含むのでサーバーのgRPCサーバーと通信する前に、HTTP2のリクエストにProxyしてあげる必要がある。公式だと、Envoyをプロキシサーバーとしておすすめしている。
gRPC-WebがM1バイナリに対応してなくてつらい... Dockerでなんとか実行できるようになった...
envoyの設定は、以下の設定を完全に真似した。設定が多すぎてわからなすぎる...
アドベントカレンダー、 gRPCで刷新されていたのか
2021年にこれはつらすぎる....
gRPC-Web の自動生成したクライアントは完全にブラウザ用になっていて、Node.js では利用できません。何が困るかというと、SSR する際にブラウザとサーバーで同一のコードが使えないので詰んでしまいます。
XMLHttpRequestを使うように、みんなPolyfillや代替ライブラリを入れているみたいだ....
ちなみにunofficialなgrpc-webのライブラリはnodejsでも動くようになっている
そもそも gRPC-Webは、closureで書かれていて今後のメンテどうなって行くんだ感ある
Open APIのほうが安定感はある
両方で上岡さんのサンプルアプリケーションを参考にしてテンプレート作るのが良さそう
Envoyの設定は、上岡さんの解説がとても良かった
コードはSandboxの中に移した
gRPC-Webが必要な理由を再度まとめる
- gRPCがブラウザでそのまま使えないために作られた
- セキュリティの問題でHTTPSじゃないとHTTP/2は使えない (開発が辛い)
- そもそもレガシーブラウザではHTTP/2が使えない
- → バイナリからテキストストリーミングに
-
HTTP/1.1もHTTP/2もどっちも扱えるようなプロトコルに修正する
- プロキシがいる理由もここにある
- gRPC-Webが送るリクエストはHTTP/1.1もHTTP/2どっちもあるから、それをすべてHTTP/2に変換するプロキシが必要
これって、ブラウザがもろもろ対応してきたら不要になるってこと...?
開発でもHTTP2が必要なのはさすがに治らないか...ブラウザのHTTP2対応は進んでいるけど
become optional (in 1-2 years) when browsers are able to speak the native gRPC protocol via the new whatwg streams API
Stream APIで直接ブラウザがgRPCと話せるようになったら、オプショナルになりそう
以下のPDFも良い資料