Closed15

gRPCに入門してみる

nissy-devnissy-dev

環境構築

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からのプラグイン作る体験、めっちゃ良い。

https://github.com/nissy-dev/asdf-protoc

$ 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コンテナを作るケースが多そう....

nissy-devnissy-dev

雛形を作成する

$ 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")
            }
        }
    }
}

nissy-devnissy-dev

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 {
	......
}

....

サーバー側でエラーを投げるときには、基本は定義済みのエラーコードを利用する
https://grpc.github.io/grpc/core/md_doc_statuscodes.html

クライアントを実装してみよう

// コネクションを貼る
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やったおかげでなんとかなった

nissy-devnissy-dev

発展問題

プロトコルを拡張してみよう

プロトコルに前方および後方互換性を保つ仕組みがあることです。仕組みは単純で、メッセージのすべてのフィールドがオプショナル、つまり指定してもしなくても良いということになっているのです。

互換性は確かに保たれそうだけど、サーバーの実装でちゃんとフィールドをバリデーションする必要はありそう。

注意点は、削除したフィールドの ID は他のフィールドに使いまわさないことです。 使いまわしを防止するため、Protocol Buffers には Reserved Fields という仕組みがあります。

使えなくなったフィールドが型エラーになって気づきやすくてよかった。

keep alive

keep alive については知らなかった

TCP の場合、接続先のサーバーが落ちると場合によっては長時間そのことに気付けない問題があります。 詳しくは TCP とタイムアウトと私を読んでみてください。

この問題に対応するため、gRPC は HTTP/2 の PING フレームを送信することで接続先がまだ存在していることを確認できます。

同様にログもセットしたけど、情報も多くて雑に設定するなら簡単にできた。

nissy-devnissy-dev

なぜそもそもgRPC-Web が生まれたのか....?

gRPC-Webのデザインゴール

adopt the same framing as “application/grpc” whenever possible

本家gRPCのプロトコルと可能な限り同じフレーミング (データの分割方法?) を適応する

フレーム: HTTP/2 での通信の最小単位。それぞれフレーム ヘッダーが含まれ、少なくともフレームが属するストリームを識別します。

https://developers.google.com/web/fundamentals/performance/http2?hl=ja

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 をデフォルトのエンコーダ / デコーダとして規定している。

https://github.com/grpc/grpc/blob/f0bfcd864c7a2395ad82ff9db8e39d0c51d49ee0/doc/PROTOCOL-WEB.md
https://syfm.hatenablog.com/entry/2018/12/02/000000
https://yuku.takahashi.coffee/blog/2019/01/grpc-proxy-for-grpc-web

こんな話もあった

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

https://github.com/grpc/grpc-web/issues/347#issuecomment-434172518

gRPC-Webでのプロキシ

昔はバックエンドとクライアントとの間にはREST APIをおいて、ブラウザからのリクエストをgRPCで実装されたサーバにつなげていた。以下のブログの図がわかりやすい。

https://grpc.io/blog/grpc-web-ga/

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をプロキシサーバーとしておすすめしている。

https://blog.envoyproxy.io/envoy-and-grpc-web-a-fresh-new-alternative-to-rest-6504ce7eb880

nissy-devnissy-dev

アドベントカレンダー、 gRPCで刷新されていたのか
https://hokaccha.hatenablog.com/entry/2019/12/05/131453

2021年にこれはつらすぎる....

gRPC-Web の自動生成したクライアントは完全にブラウザ用になっていて、Node.js では利用できません。何が困るかというと、SSR する際にブラウザとサーバーで同一のコードが使えないので詰んでしまいます。

XMLHttpRequestを使うように、みんなPolyfillや代替ライブラリを入れているみたいだ....

https://github.com/grpc/grpc-web/issues/453
https://github.com/grpc/grpc-web/issues/1134

ちなみにunofficialなgrpc-webのライブラリはnodejsでも動くようになっている
https://github.com/improbable-eng/grpc-web/tree/master/client/grpc-web

そもそも gRPC-Webは、closureで書かれていて今後のメンテどうなって行くんだ感ある

nissy-devnissy-dev

gRPC-Webが必要な理由を再度まとめる

https://github.com/grpc/grpc/blob/f0bfcd864c7a2395ad82ff9db8e39d0c51d49ee0/doc/PROTOCOL-WEB.md

  • 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と話せるようになったら、オプショナルになりそう

https://developer.mozilla.org/ja/docs/Web/API/Streams_API

以下のPDFも良い資料
https://github.com/ktr0731/techbookfest-5-grpc-web/blob/master/grpc-web.pdf

このスクラップは2021/11/30にクローズされました