gRPC 初心者が AWS 環境で Connect を試した

本記事は、gRPC 素人の筆者（一応、IT技術者）が一念発起、AWS で [Connect](https://connect.build/) を実行するまでの記録です。Connect は Node.js や Kotlin などもサポートしますが今回は、Go を用います、シリアライズから始めて一歩ずつ進めていき最後に AWS の ALB（Application Load Balancer）のバックエンドで動作を確認します。参考程度に処理速度も計測します。<font color="gray">AWS の環境構築手順には触れません。</font>

gRPC については、次の素晴らしい解説がとても参考になりました。

https://zenn.dev/hsaki/books/golang-grpc-starting


# Protocol Buffers でシリアライズ
プログラミングでは、名前や年齢、電話番号といったデータを構造体やクラス、連想配列などでメモリ上に展開するが、それをファイルに保存したり他のアプリケーションへ送るには、シリアライズ（直列化）が必要。シリアライズとは、連続するバイト列でデータを表現すること。

```mermaid
flowchart LR
  subgraph p0[アプリケーション]
    d0[データ]
    s0[シリアライズ]
  end
  subgraph p1[アプリケーション]
    d1[データ]
    s1[シリアライズ]
  end
  subgraph p2[アプリケーション]
    s2[デシリアライズ]
    d2[データ]
  end
  d0 --> s0
  s0 -- 保存 --> ファイル
  d1 --> s1
  s1 -- 送信 --> s2
  s2 --> d2
```


シリアライズには、大きくテキスト型とバイナリ型の二種類ある。

## テキスト型のシリアライズ
テキスト型のシリアライズは、人間が意味を読み取り易くかつ互換性が高い一方で、サイズが大きくなりがちで、一般論としてあまり効率的ではない。

テキスト型のシリアライズには、CSV（Comma Separated Values）や XML（Extensible Markup Language）、JSON（JavaScript Object Notation）などがある。

<details><summary>CSV の例</summary>

```csvs:users.csv
name,age,phoneNumber
Yamada,20,090-123-4567
```
</details>

<details><summary>XML の例</summary>

```xml: users.xml
<Users>
  <User>
    <Name>Yamada</Name>
    <Age>20</Age>
    <PhoneNumber>090-123-4567</PhoneNumber>
  </User>
</Users>
```
</details>

<details><summary>JSON の例</summary>

```json: users.json
[
  {
    "name": "Yamada",
    "age": "20",
    "phoneNumber": "090-123-4567"
  }
]
```
</details>


## バイナリ型のシリアライズ Protocol Buffers
バイナリ型のシリアライズは、効率的な一方で、32ビットと64ビットの違いやエンディアン（バイトを並べる順序）の違いなど、互換性に課題がある。このためバイナリ型のシリアライズは固有の実装となりがちだが、プログラム言語やプラットフォームに依存しない方法の１つに **[Protocol Buffers](https://protobuf.dev/)** （略して Protobuf）がある。

Protobuf は、最初にデータ構造（プロト）を定義する。

プロト定義の例
```proto:user/v1/user.proto
syntax = "proto3";

package user.v1;

message User {
  string name = 1;
  int32  age = 2;
  string phone_number = 3;
}
```
:::note info
`syntax` に Protocol Buffers Languageバージョン（`proto2` または `proto3`）を指定する。省略すると `ptoro2` 
:::

:::note info
項目名の後ろの `=1`, `=2`, `=3` は、代入ではなく項目の一意な識別番号
:::

プロトを定義すると各種言語向けにバイナリデータにアクセスするためのインターフェイスコードを生成できる。

Python 用のインターフェイスコードを生成する例
```shell-session
$ mkdir gen/

$ protoc --proto_path=. --python_out=gen user/v1/user.proto

$ ls gen/user/v1
user_pb2.py
```

生成されたインターフェイスコードを用いてシリアライズできる。

シリアライズでファイルを読み書きする Python のコード例
```py:main.py
from gen.user.v1 import user_pb2  # インターフェイスコードをインポート

# データ作成
u1 = user_pb2.User()
u1.name = "John Doe"
u1.age = 20
u1.phone_number = "555-4321"

# ファイルへ書き込み
with open("user.dat", "wb") as f:
    f.write(u1.SerializeToString())

# ファイル読み込み
u2 = user_pb2.User()
with open("user.dat", "rb") as f:
    u2.ParseFromString(f.read())

print(u2)
```

実行
```shell-session
$ python main.py
name: "John Doe"
age: 20
phone_number: "555-4321"
```

JSON と比るとファイルサイズは半分以下に
```shell-session
$ ls -l
58  user.json
22  user.dat
```


# HTTP/2
HTTP/2 の大きな特徴の１つはストリーム。単一のTCPコネクション上で複数の HTTPリクエストと HTTPレスポンスをやりとり（多重化）できる。

![qt_2_2.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/cf10028e-a2b3-f638-6c9e-a3c5a24ae9a3.png)


# gRPC（gRPC Remote Procedure Calls）
gRPC は、バイナリ型のシリアライズ Protobuf と HTTP/2 を組合わせることができ、効率的かつ高度な通信を実現できる。

基本の関数呼び出し（１つのリクエストに１つのレスポンスを返す **Unary RPC**）はもちろんのこと

```mermaid
flowchart LR
  p1[アプリケーション]
  p2[アプリケーション]
  p1 -->|"Request"| p2
  p2 -.->|"Response"| p1
```


こんなことや（１つのリクエストに複数のレスポンスを返す **Server Streaming RPC**）

```mermaid
flowchart LR
  p1[アプリケーション]
  p2[アプリケーション]
  p1 -->|"Request"| p2
  p2 -.->|"Response"| p1
  p2 -.-> p1
  p2 -.-> p1
```

あんなこと（複数のリクエストに１つのレスポンスを返す **Client Streaming RPC**）

```mermaid
flowchart LR
  p1[アプリケーション]
  p2[アプリケーション]
  p1 -->|"Request"| p2
  p1 --> p2
  p1 --> p2
  p2 -.->|"Response"| p1
```

そんなこと（n 対 m の **Bidirectional Streaming RPC**）までできる。

```mermaid
flowchart LR
  p1[アプリケーション]
  p2[アプリケーション]
  p1 -->|"Request"| p2
  p1 --> p2
  p2 -.->|"Response"| p1
  p1 --> p2
  p2 -.-> p1
  p2 -.-> p1
```

優れた gRPC だが、あまり**人に易しくない**という側面がある。

バイナリ型のシリアライズはテキストのように編集できず、Curl コマンドや WEBブラウザの JavaScript からも手軽に呼べない。Protobuf のプロト定義はシンプルで分かり易いが、その代わりインターフェイスコードの管理が必要。


# Connect
2022 年10月にバージョン 1.0 がリリースされた [Connect](https://connect.build/) なら、そんな gRPC の煩わしさから解放してくれるかもしれない。

# ローカルPC 検証環境
```mermaid
flowchart
  subgraph pc[ローカルPC]
    subgraph WSL2[Ubuntu / WSL2]
      direction TB
      c1[connect-go クライアント]
      c2[Curl]
      s1[connect-go サーバ]
    end
  end
  c1 --> s1
  c2 --> s1
```
- Ubuntu 20.04.5 LTS / WSL2
- go 1.20.1
- curl 7.68.0


以下、[Connect ドキュメントの Getting Started](https://connect.build/docs/go/getting-started) を参考に進める。

大まかな作業の流れは次図の通り。 


```mermaid
flowchart LR
  proto["プロト定義\nuser.proto"]
  subgraph Buf[Buf CLI]
    gen[I/Fコード]
    subgraph buf.yaml
      Linter
    end
    subgraph buf.gen.yaml
      Plugins[プラグイン]
    end
  end
  c[connect-go クライアント]
  s[connect-go サーバ]
  proto --> Linter
  proto --> Plugins -->|コード生成| gen
  gen -->|import| c
  gen -->|import| s
```

まずプロト定義を作成し Linter でチェック。問題なければ Bufプラグインでインタフェースコードを生成。そのコードをインポートし connect-goサーバと connect-go クライアントを実装。コード生成は、ツール protoc ではなく Protobuf API 管理ツールの [Buf CLI](https://buf.build/) を用いる。


## Buf CLI インストール
[GitHub リリースページ](https://github.com/bufbuild/buf/releases)からバイナリをダウンロードし `/usr/local/bin/` に配置
```shell-session
$ curl -sSL https://github.com/bufbuild/buf/releases/download/v1.17.0/buf-Linux-x86_64 -o buf

$ chmod +x buf

$ sudo mv buf /usr/local/bin/

$ buf --version
1.17.0
```

## プロト定義
プロト定義を配置するフォルダ `proto/user/v1` を作成
```shell-session
$ mkdir -p proto/user/v1
```

プロト定義を記述
```proto:proto/user/v1/user.proto
syntax = "proto3";

package user.v1;

option go_package = "example/gen/user/v1;userv1";

message User {
  string name = 1;
  int32  age = 2;
  string phone_number = 3;
}

message GetUserRequest {
  string user_name = 1;
}

message GetUserResponse {
  User user = 1;
}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
}
```
上記は、メッセージの型 `User` を扱うサービス `UserService` に `User` を返すメソッド `GetUser()` を定義している。


## buf.yaml ファイル作成
プロト定義のルート階層に `proto/buf.yaml` を作成

```shell-session
$ buf mod init -o proto/
```

```yaml:proto/buf.yaml
version: v1
breaking:
  use:
    - FILE
lint:
  use:
    - DEFAULT
```

## Linter
プロト定義を Linter でチェック。指摘がなければ何も表示されない
```shell-session
$ buf lint proto/
```

## コード生成
コード生成プラグインを制御する `buf.gen.yaml` を記述。

次の  `buf.gen.yaml` は３つのプライグインを実行する。
- Python のインターフェイスコード生成
- Go のインターフェイスコード生成
- connect-goのクライアントスタブ、サーバースタブ生成
```yaml:buf.gen.yaml
version: v1
plugins:
  - plugin: buf.build/protocolbuffers/python
    out: gen
  - plugin: buf.build/protocolbuffers/go
    out: gen
    opt: paths=source_relative
  - plugin: buf.build/bufbuild/connect-go
    out: gen
    opt: paths=source_relative
```

:::note info
Buf リモートプラグインのリストは、[Buf Schema Registry, BSR](https://buf.build/plugins) を参照
:::


この時点でのフォルダ構成は次の通り
```
.
├── buf.gen.yaml
└── proto/
    ├── buf.yaml
    └── user/
        └── v1/
            └── user.proto
```

プラグインを実行しコード生成
```shell-session
$ buf generate proto/
```

フォルダ `gen/` 以下にコードが生成される。
フォルダ構成は次のようになった。
```
.
├── buf.gen.yaml
├── gen/
│   └── user/
│       └── v1/
│           ├── user.pb.go
│           ├── user_pb2.py
│           └── userv1connect/
│               └── user.connect.go
└── proto/
    ├── buf.yaml
    └── user/
        └── v1/
            └── user.proto
```
- `gen/user/v1/user.pb.go` には、gRPC メソッド `GetUser()` の引数と戻り値に使用する構造体 `GetUserRequest` と `GetUserResponse` が定義されている
- `gen/user/v1/userv1connect/user.connect.go` には、サーバの HTTPハンドラ インタフェース `UserServiceHandler` とクライアントのコンストラクタ `NewUserServiceClient` が定義されている

# connect-go を実装
## connect-go サーバ
生成されたコードを `import` し connect-go サーバを実装
```go:cmd/server/main.go
package main

import (
	"context"
	"log"
	"net/http"

	"github.com/bufbuild/connect-go"
	"golang.org/x/net/http2"
	"golang.org/x/net/http2/h2c"

	userv1 "example/gen/user/v1"        // generated by protoc-gen-go
	"example/gen/user/v1/userv1connect" // generated by protoc-gen-connect-go
)

type UserServer struct{}

func (s *UserServer) GetUser(
	ctx context.Context,
	req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
	res := connect.NewResponse(&userv1.GetUserResponse{
		User: &userv1.User{
			Name:        req.Msg.Name,
			Age:         21,
			PhoneNumber: "090-123-4567",
		},
	})
	return res, nil
}

func main() {
	svr := &UserServer{}
	mux := http.NewServeMux()
	path, handler := userv1connect.NewUserServiceHandler(svr)
	mux.Handle(path, handler)
	http.ListenAndServe(
		"0.0.0.0:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	)
}
```
上記は、gRPC のメソッド `GetUser()` を実装したもの。構造体 `UserServer` に HTTPハンドラ インタフェース `UserServiceHandler` （この場合、`GetUser()`）を実装する。

HTTPハンドラ インタフェース `UserServiceHandler` 
```go:gen/user/v1/userv1connect/user.connect.go
...
type UserServiceHandler interface {
	GetUser(context.Context, *connect_go.Request[v1.GetUserRequest]) (*connect_go.Response[v1.GetUserResponse], error)
}
...
```


## connect-go クライアント
生成されたコードを `import` し connect-go クライアントを実装
```go:cmd/client/main.go
package main

import (
	"context"
	"log"
	"net/http"

	userv1 "example/gen/user/v1"
	"example/gen/user/v1/userv1connect"

	"github.com/bufbuild/connect-go"
)

func main() {
	httpClient := http.DefaultClient

	client := userv1connect.NewUserServiceClient(
		httpClient,
		"http://localhost:8080",
	)
	res, err := client.GetUser(
		context.Background(),
		connect.NewRequest(&userv1.GetUserRequest{Name: "Mike"}),
	)
	if err != nil {
        panic(err)
	}
	log.Println(res.Msg.User)
}
```
上記は、gRPC のメソッド `GetUser()` を呼び出している。


# 動作確認
Go プロジェクトを初期化
```shell-session
$ go mod init example

$ go mod tidy
```

connect-go サーバを起動
```shell-session
$ go run ./cmd/server/main.go
```

connect-go クライアントを実行。GetUser() を呼び出せることを確認
```shell-session
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"
```


Curlコマンドでも GetUser() を呼び出せることを確認
```shell-session
$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/GetUser
{"user":{"name":"Jane", "age":21, "phoneNumber":"090-123-4567"}}
```


#  パッケージ net/http との互換性
connect-go は、Golang 標準パッケージ net/http と互換があり、サードパーティのライブラリを駆使しやすいとのことなので試します。

connect-goサーバに次を実装し呼び出せることを確認する。
- JSON を返す HTTPヘルスチェックエンドポイント
- 静的ファイルを返すエンドポイント
- サードパーティの [chi ルーター](https://github.com/go-chi/chi) に差し替え


```mermaid
flowchart LR
  curl[Curl]
  subgraph cgs[connect-go サーバ]
    
    chi[chiルーター]
    ep1["gRPCエンドポイント\n/user.v1.UserService/GetUser"]
    ep2["HTTPヘルスチェック\n/health"]
    ep3["静的ファイル\n/（実体は index.html）"]
  end
  curl --> chi
  chi --> ep1
  chi --> ep2
  chi --> ep3
```

```diff_go:cmd/server/main.go
package main

import (
    ...
+ 	"github.com/go-chi/chi/v5"
    ...
)
...
func main() {
	svr := &UserServer{}

    // chiルーター
-	mux := http.NewServeMux()
+	mux := chi.NewRouter()

    // gRPCエンドポイント
	path, handler := userv1connect.NewUserServiceHandler(svr)
-	mux.Handle(path, handler)
+	mux.Handle(path+"*", handler)

    // HTTPヘルスチェックエンドポイント
+	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
+		w.Header().Set("Content-Type", "application/json; charset=utf-8")
+		_, _ = w.Write([]byte(`{"status": "ok"}`))
+	})

    // 静的ファイルを返すエンドポイント
+   mux.Handle("/", http.FileServer(http.Dir("static")))

	http.ListenAndServe( ... )
```

`static/index.html` を作成
```html:static/index.html
Welcome to connect-go demo!
```

connect-go サーバ実行
```shell-session
$ go mod tidy

$ go run ./cmd/server/main.go
```

次を確認
1. gRPCエンドポイントを connect-goクライアントで呼び出せる
1. gRPCエンドポイントを Curl で呼び出せる
1. HTTPヘルスチェックエンドポイントを Curl で呼び出せる
1. 静的ファイル `index.html` を Curl で取得できる
```shell
# gRPCエンドポイント
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"

# gRPCエンドポイント
$ curl --header "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/GetUser
{"user":{"name":"Jane","age":21,"phoneNumber":"090-123-4567"}}

# ヘルスチェックエンドポイント
$ curl http://localhost:8080/health
{"status": "ok"}

# 静的ファイル
$ curl http://localhost:8080/
Welcome to connect-go demo!
```

いずれも問題なく呼び出せた。
なお、以後の検証では chi ルーターは用いない。



# HTTP/1.1 と HTTP/2
先へ進む前に HTTP/2 が使われているかどうかを調べる方法と、HTTP/2 を強制する方法を確認しておきます。

## Go と HTTP/2
Go の `net/http` パッケージは、環境変数 `GODEBUG` の設定で HTTP/2 サポートを無効化したり、HTTP/2 が使われている場合に（のみ）、**http2 デバッグメッセージ** をログに出力できる。

:::note info
GODEBUG 環境変数サポート

GODEBUG=http2client=0 # HTTP/2 クライアントのサポートを無効にする
GODEBUG=http2server=0 # HTTP/2 サーバのサポートを無効にする
GODEBUG=http2debug=1 # 詳細な HTTP/2 デバッグ ログを有効にする
GODEBUG=http2debug=2 # ... フレームダンプでさらに詳細に

参照：「[Golang net/http ドキュメント](https://pkg.go.dev/net/http)」

:::

**http2 デバッグメッセージ**を有効化し connect-go サーバを実行
```shell-session
$ GODEBUG=http2debug=1 go run ./cmd/server/main.go
```

HTTP/2 が使われていれば次のような **http2 デバッグメッセージ** が出力される
```
http2: server processing setting [MAX_CONCURRENT_STREAMS = 100]
http2: server processing setting [INITIAL_WINDOW_SIZE = 1073741824]
http2: server processing setting [ENABLE_PUSH = 0]
... <以下略> ...
```

connect-goクライアントから gRPC 呼び出すと **http2 デバッグメッセージ** は出力されなかったので HTTP/2 は使われていない模様。

connect-goサーバは `h2c` パッケージを用いている。`h2c` パッケージとは、非TLS（httpスキーム）での HTTP/2 サポートを可能にするパッケージ。
```go
	http.ListenAndServe(
		"0.0.0.0:8080",
		// Use h2c so we can serve HTTP/2 without TLS.
		h2c.NewHandler(mux, &http2.Server{}),
	)
```

:::note infor
h2c（HTTP/2 Cleartext）とは
`h2` や `h2c` は、HTTP/2 バージョンの識別子
- `h2` は**暗号化された** Transport Layer Security (TLS) を使用する HTTP/2 プロトコルを識別する。つまり **https** の HTTP/2
- `h2c` は**平文**の TCP 上での HTTP/2 プロトコルを識別する。つまり **http** の HTTP/2

参照：「[RFC7540 - 3.1.  HTTP/2 Version Identification](https://datatracker.ietf.org/doc/html/rfc7540#section-3.1)」
:::

だとすれば HTTP/2 が使われてもよさそうだがそうならないのは、connect-goクライアントが非TLS での HTTP/2 を無効にしているから。

環境変数 `GODEBUG` は、HTTP/2 を無効にできるが有効化を強制することはできないようなので、非TLS の HTTP/2 サポートを強制するにはコードを改変する必要がある。

:::note info
HTTP/2 を強制する connect-goクライアントコード例
```diff_go:cmd/client/main.go
package main

import (
	"context"
+	"crypto/tls"
	"log"
+	"net"
	"net/http"

	userv1 "example/gen/user/v1"
	"example/gen/user/v1/userv1connect"

	"github.com/bufbuild/connect-go"
+	"golang.org/x/net/http2"
)

func main() {
-	httpClient := http.DefaultClient
+	httpClient := &http.Client{
+		Transport: &http2.Transport{
+			AllowHTTP: true,
+			DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
+				return net.Dial(network, addr)
+			},
+		},
+	}

	client := userv1connect.NewUserServiceClient(
		httpClient,
		"http://localhost:8080",
	)
    ...
```

- HTTPクライアントのトランスポートに [`http2.Transport`](https://pkg.go.dev/golang.org/x/net/http2#Transport) を指定
  - `AllowHTTP: true` とし httpスキーム（平文）を許可
  - `DialTLS` にTLS証明書を必要としないダイアル関数を指定。デフォルトは TLS証明書が必要な `tls.Dial` を使用するため

参照：「[Deployment & h2c - HTTP/2 without TLS](https://connect.build/docs/go/deployment#h2c)」
:::

## Curl と HTTP/2
Curl は、オプション `--http2` で HTTP/2 を強制できる。HTTP/1.1 を強制したければ `--http1.1` を指定。
```shell-session
$ curl --http2 -H "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/GetUser
```

オプション `-v` で詳細を確認できる。
```shell-session
$ curl -v --http2 --header "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/GetUser
...
> Connection: Upgrade, HTTP2-Settings
> Upgrade: h2c
...
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
...
< HTTP/2 200
...
```
上記の場合、HTTP/1.1 から HTTP/2（h2c）へ **Upgrade** している。


:::note info
HTTP/1.1 と HTTP/2 に互換性はない。このため HTTP/2 通信を開始するには相手と HTTP/2 の使用を合意する必要があり、いくつかの方法が仕様に定義されている。
1. ALPNを使用する
1. HTTP/1.1からアップグレードする
1. ダイレクトで開始する

ALPN は TLS（https）が前提。TLS を用いない場合は二つめのアップグレードが行われる。その場合、最初にHTTP/1.1で通信を開始した後、そのコネクションをHTTP/2 にアップグレードする。 

参照：「[HTTP/2とは - JPNIC](https://www.nic.ad.jp/ja/newsletter/No68/0800.html)」
:::

:::note info
Curl にオプション `--http2-prior-knowledge` を指定するとアップグレードではなくダイレクトに HTTP/2 を開始できる

```shell-session
$ curl -v --http2-prior-knowledge --header "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/GetUser
...
* Using HTTP2, server supports multi-use
* Connection state changed (HTTP/2 confirmed)
...
* Using Stream ID: 1 (easy handle 0x557e00ad28e0)
> POST /user.v1.UserService/GetUser HTTP/2
...
< HTTP/2 200
...
```
:::


これらの設定を駆使し確認したところ、非TLS における HTTP/1.1 と HTTP/2 のいずれも gRPC 呼出し（Unary RPC）が動作することを確認できた。

| | RPC | Protocol| 動作 |
|---|:--|:-:|:-:|
| Curl | Unary | HTTP/2（非TLS） | Success | 
| Curl | Unary | HTTP/1.1（非TLS） | Success | 
| connect-goクライアント | Unary | HTTP/2（非TLS） | Success | 
| connect-goクライアント | Unary | HTTP/1.1（非TLS） | Success | 


# Connect で Server Streaming RPC
そうなると気になるのは gRPC のストリーミング。
**Server Streaming RPC** を試します。

```mermaid
flowchart
  subgraph rpc[Server Streaming RPC]
    p1[connect-go クライアント]
    p2["connect-go サーバ\nPingUser()"]
  end
  p1 -->|"Request"| p2
  p2 -.->|"Response"| p1
  p2 -.-> p1
  p2 -.-> p1
```

## プロト定義を追加
プロト定義に **Server Streaming RPC** のサービスメソッド `PingUser()` を追加
```diff_protobuf:proto/user/v1/user.proto
syntax = "proto3";

package user.v1;

option go_package = "example/gen/user/v1;userv1";

message User {
  string name = 1;
  int32  age = 2;
  string phone_number = 3;
}

message GetUserRequest {
  string name = 1;
}

message GetUserResponse {
  User user = 1;
}

+message PingUserRequest {
+    string name = 1;
+  }

+message PingUserResponse {
+User user = 1;
+}

service UserService {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
+  rpc PingUser(PingUserRequest) returns (stream PingUserResponse) {}
}
```
:::note warn
メソッド `PingUser()` の `returns` に `stream` が指定されていることに注意
:::

インタフェースコードを再生成
```shell-session
$ buf generate proto/
```

connect-goサーバに `PingUser()` ハンドラを追加
```diff_go:cmd/server/maing.go
package main

import ( ... )

type UserServer struct{}

func (s *UserServer) GetUser( ... ) { ... }

+func (s *UserServer) PingUser(
+	ctx context.Context,
+	req *connect.Request[userv1.PingUserRequest],
+	stream *connect.ServerStream[userv1.PingUserResponse],
+) error {
+	msg := &userv1.PingUserResponse{
+		User: &userv1.User{
+			Name:        req.Msg.Name,
+			Age:         21,
+			PhoneNumber: "090-123-4567",
+		},
+	}
+	var i time.Duration
+	for i = 1; i < 3600; i = i * 2 {
+		err := stream.Send(msg)
+		if err != nil {
+			return err
+		}
+		time.Sleep(i * time.Second)
+	}
+	return nil
+}

func main() { ... }
```

`PingUser()` の引数で渡される `connect.ServerStream` を用いて繰り返しメッセージ `User` を送信する。なお、送信の度にインターバルを倍増しているため徐々に送信間隔が長くなることに留意。

Server Streaming RPC を呼び出す connect-goクライアントを新たに実装

```go: cmd/stream/main.go
package main

import (
	"context"
	"crypto/tls"
	"log"
	"net"
	"net/http"

	userv1 "example/gen/user/v1"
	"example/gen/user/v1/userv1connect"

	"github.com/bufbuild/connect-go"
	"golang.org/x/net/http2"
)

// Server Streaming RPC
func main() {
	httpClient := &http.Client{
		Transport: &http2.Transport{
			AllowHTTP: true,
			DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
				return net.Dial(network, addr)
			},
		},
	}

	client := userv1connect.NewUserServiceClient(
		httpClient,
		"http://localhost:8080",
	)

	stream, err := client.PingUser(
		context.Background(),
		connect.NewRequest(&userv1.PingUserRequest{Name: "Mike"}),
	)

	for stream.Receive() {
		msg := stream.Msg()
		log.Println(msg.User)
	}
	log.Println("Stopped")
	if err != nil {
		panic(err)
	}
}
```
`for` ループで `client.PingUser()` の戻り値 `stream` からメッセージを読み込むところが `Unary RPC` と大きく異なる。なお、上記は HTTP/2 を強制していることに留意。


connect-go サーバを実行
```shell-session
$ go run ./cmd/server/main.go
```

connect-go クライアントを実行。繰り返しメッセージ `User` が届くことを確認。メッセージが届く間隔が徐々に長くなっている。
```shell-session
$ go run ./cmd/stream/main.go
15:32:59 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:00 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:02 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:06 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:14 name:"Mike" age:21 phone_number:"090-123-4567"
15:33:30 name:"Mike" age:21 phone_number:"090-123-4567"
15:34:02 name:"Mike" age:21 phone_number:"090-123-4567"
15:35:06 name:"Mike" age:21 phone_number:"090-123-4567"
15:37:14 name:"Mike" age:21 phone_number:"090-123-4567"
15:41:30 name:"Mike" age:21 phone_number:"090-123-4567"
15:50:02 name:"Mike" age:21 phone_number:"090-123-4567"
```

HTTP/2 で動くことを確認できたので次は HTTP/1.1
connect-go クライアントコードを改変し HTTP/2 の強制を解除
```diff_go:cmd/stream/main.go
package main

import ( ... )

// Server Streaming RPC
func main() {
+	httpClient := http.DefaultClient
-	httpClient := &http.Client{
-		Transport: &http2.Transport{
-			AllowHTTP: true,
-			DialTLS: func(network, addr string, cfg *tls.Config) (net.Conn, error) {
-				return net.Dial(network, addr)
-			},
-		},
-	}

	client := userv1connect.NewUserServiceClient(
		httpClient,
		"http://localhost:8080",
	)
...
```

環境変数に `GODEBUG=http2client=0` を指定（HTTP/1.1 を強制）し connect-go クライアントを実行
```shell-session
$ GODEBUG=http2client=0 go run cmd/stream/main.go
15:53:42 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:43 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:45 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:49 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:57 name:"Mike"  age:21  phone_number:"090-123-4567"
15:54:13 name:"Mike"  age:21  phone_number:"090-123-4567"
15:54:45 name:"Mike"  age:21  phone_number:"090-123-4567"
15:55:49 name:"Mike"  age:21  phone_number:"090-123-4567"
15:57:57 name:"Mike"  age:21  phone_number:"090-123-4567"
16:02:13 name:"Mike"  age:21  phone_number:"090-123-4567"
```

HTTP/1.1 を強制してもストリーミングできた。素晴らしいけどどんな実装なのか気になる。

tcpdump で確認するとチャンク形式でレスポンスを返していた。

:::note info
チャンク形式とは、チャンク（小さなかたまり）に小分けし返送する方式

次は、tcpdump の出力を見易いように成形したもの
```shell-session
$ sudo tcpdump -A -n -i lo
...
14:27:18.034781 IP 127.0.0.1.43848 > 127.0.0.1.8080: Flags [P.], seq 1:267, ack 1, win 512, options [nop,nop,TS val 3794005145 ecr 3794005145], length 266: HTTP: POST 
POST /user.v1.UserService/PingUser HTTP/1.1
Host: localhost:8080
User-Agent: connect-go/1.6.0 (go1.20.1)
Transfer-Encoding: chunked
Accept-Encoding: identity
Connect-Accept-Encoding: gzip
Connect-Protocol-Version: 1
Content-Type: application/connect+proto

<...リクエストボディ...>

14:27:18.035166 IP 127.0.0.1.8080 > 127.0.0.1.43848: Flags [P.], seq 1:248, ack 293, win 512, options [nop,nop,TS val 3794005145 ecr 3794005145], length 247: HTTP: HTTP/1.1 200 OK
HTTP/1.1 200 OK
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
Content-Type: application/connect+proto
Date: Tue, 18 Apr 2023 05:27:18 GMT
Transfer-Encoding: chunked

35
<...chunk-data...>35
<...chunk-data...>35
<...chunk-data...>0

```

HTTPリクエストヘッダに `Transfer-Encoding: chunked` が指定されている。HTTPレスポンスボディのチャンクは、16進数のチャンクサイズ `35` で始まりチャンクデータが続く。そのようなチャンクを何度か繰り返した後、サイズ `0` のチャンクで終了。
:::

Curl は動作せず。
```shell-session
 curl -v --http2 -H "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/PingUser
...
< HTTP/2 415
...
```

```shell-session
 curl -v --http1.1 -H "Content-Type: application/json" --data '{"name": "Jane"}'  http://localhost:8080/user.v1.UserService/PingUser
...
< HTTP/1.1 415 Unsupported Media Type
...
```

結果
| | RPC | Protocol | 動作 |
|---|:-:|:-:|:-:|
| Curl | Server Streaming | HTTP/2（非TLS） | Error | 
| Curl | Server Streaming | HTTP/1.1（非TLS） | Error | 
| connect-goクライアント | Server Streaming | HTTP/2（非TLS） | Success | 
| connect-goクライアント | Server Streaming | HTTP/1.1（非TLS） | Success | 


# AWS 検証環境
connect-go サーバを AWS ALB（Application Load Balancer）のバックエンドで実行し、ローカルPCのブラウザや Curl コマンド、connect-goクライアントから呼び出せることを確認する。ALB とバックエンド間は HTTP/2（h2c）。

バックエンドは、EC2 に Docker を立ち上げ connect-go サーバをコンテナで実行する。ローカルPC の Docker CLI から接続できるよう SSH 接続のコンテキストを作成しておく。


```mermaid
flowchart LR

  subgraph Local["ローカルPC"]
    direction LR
    c1["ブラウザ"]
    c2["Curl"]
    c3["connect-goクライアント"]
    Shell
  end
  c1 -- HTTPS --> ALB
  c2 -- HTTPS --> ALB
  c3 -- HTTPS --> ALB
  Shell(Docker CLI) -- SSH --> NAT
  ALB -- HTTP/2 --> Docker0
  NAT -- SSH --> Docker0

  subgraph AWS
    subgraph Public Subnet
      direction LR
      ALB
      NAT[Bastion/EC2]
    end
    subgraph Private Subnet
      subgraph Docker0[Docker/EC2]
      direction LR
      cgs["connect-goサーバ"]
      end
    end
  end
  style NAT fill:#fff
  style Shell fill:#fff
```
本筋から外れるため構築手順は割愛。


## connect-goサーバをデプロイ
connect-go サーバをビルド
```shell-session
$ go build -trimpath -ldflags "-w -s" -o bin/server cmd/server/main.go
```

コンテナに connect-go 実行イメージ `bin/server` と静的ファイル `static/` を含める
```dockerfile:Dockerfile
FROM debian:bullseye-slim
WORKDIR /app
COPY bin/server /app/server
COPY static /app/static/
ENTRYPOINT [ "/app/server"]
```

docker-compose.yml を記述
```yaml:docker-compose.yml
version: "3.9"
services:
  grpc:
    image: connect/server:latest
    container_name: grpc
    build:
      context: ./
      dockerfile: Dockerfile
    ports:
      - 0.0.0.0:80:8080
```

EC2インスタンス上の Docker で connect-goサーバ コンテナを開始
```shell-session
$ docker context use ec2-docker

$ docker compose build

$ docker compose up -d

$ docker compose ps
NAME   IMAGE                   COMMAND         SERVICE  ...  PORTS
grpc   connect/server:latest   "/app/server"   grpc     ...  0.0.0.0:80->8080/tcp
```

## 動作確認 Unary RPC
Curlコマンドを実行。`GetUser()` を呼び出せることを確認
```shell-session
$ curl -H "Content-Type: application/json" --data '{"name": "Jane"}'  https://<Public-DNS-Name>/user.v1.UserService/GetUser
{"user":{"name":"Jane", "age":21, "phoneNumber":"090-123-4567"}}
```

Curl の `-v` オプションで HTTP/2（h2）が使われていることを確認
```shell-session
$ curl -v --header "Content-Type: application/json" --data '{"name": "Jane"}'  https://<Public-DNS-Name>/user.v1.UserService/GetUser
...
* ALPN, offering h2
* ALPN, offering http/1.1
...
* ALPN, server accepted to use h2
...
```

ブラウザ Fetch API から `GetUser()` を呼び出せることを確認
![qt_2_1.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/7d3574ce-3100-e21f-f441-ff3d55b11966.png)

```html:static/index.html
<!DOCTYPE html>
<html>
    <body>
        <h1>Welcome to connect-go demo!</h1>
        <input type="button" value="実行" onclick="postData()">
        <script language="javascript" type="text/javascript">
            async function postData() {
                let data = {name: "Foo"};
                fetch("/user.v1.UserService/GetUser", {
                    method: 'POST',
                    headers: {'Content-Type': 'application/json'},
                    body: JSON.stringify(data),
                })
                .then((response) => response.json())
                .then((data) => console.log(data));
            }
        </script>
    </body>
</html>

```


connect-goクライアントを実行。`GetUser()` を呼び出せることを確認
```shell-session
$ go run cmd/client/main.go
name:"Mike" age:21 phone_number:"090-123-4567"
```

環境変数 `GODEBUG=http2debug=1` を設定。HTTP/2 が使われている
```shell-session
$ GODEBUG=http2debug=1 go run cmd/client/main.go
http2: Transport failed to get client conn for xx.xx.xx:443: http2: no cached connection was available
http2: Transport creating client conn 0xc000208180 to 99.99.99.99:443
http2: Transport encoding header ":authority" = "xx.xx.xx"
http2: Transport encoding header ":method" = "POST"
...
```

結果
| | RPC | Request protocol<br>--> ALB | Backend protocol<br>ALB --> | Result |
|---|:-:|:-:|:-:|:-:|
| Curl | Unary | HTTPs/2 | HTTP/2 | Success | 
| ブラウザ Fetch API | Unary | HTTPs/2 | HTTP/2 | Success | 
| connect-goクライアント | Unary | HTTPs/2 | HTTP/2 | Success | 


## 動作確認 Server Streaming RPC
インターバルが１分を超えたあたりで通信が切断される現象が発生。

```shell-session
$ go run cmd/stream/main.go
15:52:33 name:"Mike"  age:21  phone_number:"090-123-4567"
15:52:34 name:"Mike"  age:21  phone_number:"090-123-4567"
15:52:36 name:"Mike"  age:21  phone_number:"090-123-4567"
15:52:40 name:"Mike"  age:21  phone_number:"090-123-4567"
15:52:48 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:04 name:"Mike"  age:21  phone_number:"090-123-4567"
15:53:36 name:"Mike"  age:21  phone_number:"090-123-4567"
15:54:36 Stopped
```

原因は、AWS ALB の設定 `Idle timeout`（デフォルト 60秒）がタイムアウトしたことによるセッション切断。

connect-go クライアントから HTTP/2 PING を定期的に送信してみたが、ALB は PING には応答するものの問題は解消せず。HTTP/2 PING ではタイムアウトはリセットされない模様。


`ReadIdleTimeout` を設定し 10秒毎に HTTP/2 PING を送信する例
```go:cmd/stream/main.go
...
	httpClient := &http.Client{
		Transport: &http2.Transport{
			ReadIdleTimeout: 10 * time.Second,
		},
	}
...
```
HTTP/2 PING の送受信ログ。PING には応答するが...
```shell-session
$ GODEBUG="http2debug=2" go run cmd/stream/main.go
...
16:00:58 http2: Transport sending health check
16:00:58 http2: Framer 0xc0001aa0e0: wrote PING len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Framer 0xc0001aa0e0: read PING flags=ACK len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Transport received PING flags=ACK len=8 ping="QR\xb29\x00y\xff\xbf"
16:00:58 http2: Transport health check success
...
```

結果
| | RPC | Request protocol<br>--> ALB | Backend protocol<br>ALB --> | Result |
|---|:-:|:-:|:-:|:-:|
| connect-goクライアント | Server Streaming | HTTPs/2 | HTTP/2 | Success<br>ただし、ALB `Idle timeout` の制限あり  | 

# バックエンドのプロトコルバージョン
ALB は、[プロトコル バージョン](https://docs.aws.amazon.com/elasticloadbalancing/latest/application/load-balancer-target-groups.html#target-group-protocol-version)を使用して、HTTP/2 または gRPC を使用してターゲットにリクエストを送信できる。

ALB と connect-goサーバ間のプロトコルバージョンを HTTP/2 から gRPC に変更したが ALB の `Idle timeout` の制限は解消せず。


```mermaid
flowchart LR

  subgraph Local["ローカルPC"]
    direction LR
    c3["connect-goクライアント"]
  end
  c3 -- HTTPS --> ALB
  ALB -- "プロトコルバージョン gRPC" --> Docker0

  subgraph AWS
    ALB
    subgraph Docker0[Docker/EC2]
      direction LR
      cgs["connect-goサーバ"]
    end
  end
```

# gRPCスタイルのヘルスチェック
ALB と connect-goサーバ間のプロトコルバージョンを gRPC とする場合、connect-goサーバに gRPCスタイルのヘルスチェック エンドポイントを実装する必要がある。でないと ALB からヘルスチェックできない。

詳しくは ↓

https://zenn.dev/hsaki/books/golang-grpc-starting/viewer/healthcheck


:::note info
[Go パッケージ connect-grpchealth-go](https://github.com/bufbuild/connect-grpchealth-go) で gRPCヘルスチェック エンドポイントを簡単に実装できる

connect-goサーバに gRPCヘルスチェックを実装した例
```diff_go:cmd/server/main.go
package main

import (
    ...
+	grpchealth "github.com/bufbuild/connect-grpchealth-go"
    ...
)
...
func main() {
	svr := &UserServer{}
	mux := http.NewServeMux()
	path, handler := userv1connect.NewUserServiceHandler(svr)
	mux.Handle(path, handler)

+	// gRPC ヘルスチェックエンドポイント
+	checker := grpchealth.NewStaticChecker(
+		userv1connect.UserServiceName, // サービス名
+	)
+	mux.Handle(grpchealth.NewHandler(checker))
    ...
}
```

Curl で gRPC ヘルスチェックエンドポイントを呼び出し
```shell
# サービス名を省略
$ curl -H "Content-Type: application/json" -d '{"service": ""}'  http://localhost:8080/grpc.health.v1.Health/Check
{"status":"SERVING_STATUS_SERVING"}

# サービス名を指定
$ curl -H "Content-Type: application/json" -d '{"service": "user.v1.UserService"}'  http://localhost:8080/grpc.health.v1.Health/Check
{"status":"SERVING_STATUS_SERVING"}
```
:::


# （参考）パフォーマンス計測
AWS 環境で簡単に速度を計測しました。各種要因の影響を分析できていないため参考まで。

```mermaid
flowchart LR
  subgraph Local["ローカルPC"]
    cgc["connect-goクライアント"]
    hgc["http-goクライアント"]
  end

  subgraph AWS
    ALB1[ALB]
    ALB2[ALB]
    subgraph Docker0[Docker/EC2]
      cgs["connect-goサーバ"]
      hgs["http-goサーバ"]
    end
  end

  cgc -- Protobuf --> ALB1
  ALB1 -- "HTTP/2" --> cgs

  hgc -- JSON --> ALB2
  ALB2 -- "HTTP/2" --> hgs
```

リクエスト -> レスポンスを**シーケンシャルに 100回繰り返したときの処理時間（ミリ秒）**

| | HTTP-JSON |　Connect-Protobuf |
|---|:-:|:-:|
| 最大値 | 2,373 | 2,672 |
| 中央値 | 2,063 | 2,076 |
| 最小値 | 1,935 | 1,969 |



![qiita2_4png.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/2629684/579e8716-aeed-80b1-c1d1-59acb0c6b403.png)

※ サンプル数は 32
※ HTTP-JSON のコードは、Go の net/http で別途作成したもの
※ Connect-Protobuf のコードは、上記で作成したものをほぼそのまま流用

計測用 connect-goサーバコード
```go:cmd/server/main.go
package main

import (
        "context"
        "log"
        "net/http"
        "time"

        "github.com/bufbuild/connect-go"
        grpchealth "github.com/bufbuild/connect-grpchealth-go"
        "golang.org/x/net/http2"
        "golang.org/x/net/http2/h2c"

        userv1 "example/gen/user/v1"        // generated by protoc-gen-go
        "example/gen/user/v1/userv1connect" // generated by protoc-gen-connect-go
)

type UserServer struct{}

func (s *UserServer) GetUser(
        ctx context.Context,
        req *connect.Request[userv1.GetUserRequest],
) (*connect.Response[userv1.GetUserResponse], error) {
        res := connect.NewResponse(&userv1.GetUserResponse{
                User: &userv1.User{
                        Name:        req.Msg.Name,
                        Age:         21,
                        PhoneNumber: "090-123-4567",
                },
        })
        return res, nil
}

func (s *UserServer) PingUser(
        ctx context.Context,
        req *connect.Request[userv1.PingUserRequest],
        stream *connect.ServerStream[userv1.PingUserResponse],
) error {
        msg := &userv1.PingUserResponse{
                User: &userv1.User{
                        Name:        req.Msg.Name,
                        Age:         21,
                        PhoneNumber: "090-123-4567",
                },
        }
        var i time.Duration
        for i = 1; i < 3600; i = i * 2 {
                err := stream.Send(msg)
                if err != nil {
                        return err
                }
                time.Sleep(i * time.Second)
        }
        return nil
}

func main() {
        svr := &UserServer{}
        mux := http.NewServeMux()
        path, handler := userv1connect.NewUserServiceHandler(svr)
        mux.Handle(path, handler)

        // gRPC ヘルスチェック
        checker := grpchealth.NewStaticChecker(
                userv1connect.UserServiceName,
        )
        mux.Handle(grpchealth.NewHandler(checker))

        // HTTPヘルスチェックエンドポイント
        mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
                w.Header().Set("Content-Type", "application/json; charset=utf-8")
                _, _ = w.Write([]byte(`{"status": "ok"}`))
        })

        // 静的ファイル index.html を返すエンドポイント
        mux.Handle("/", http.FileServer(http.Dir("static")))

        log.Fatal(http.ListenAndServe(
                "0.0.0.0:8080",
                h2c.NewHandler(mux, &http2.Server{}),
        ))
}
```

計測用 connect-goクライアントコード
```go:cmd/loop-grpc/main.go
package main

import (
        "context"
        "log"
        "net/http"
        "time"

        userv1 "example/gen/user/v1"
        "example/gen/user/v1/userv1connect"

        "github.com/bufbuild/connect-go"
)

func main() {
        httpClient := http.DefaultClient
        client := userv1connect.NewUserServiceClient(
                httpClient,
                "https://<Public-DNS-Name>",
        )
        req := connect.NewRequest(&userv1.GetUserRequest{Name: "Bar"})
        ctx := context.Background()
        nLoop := 100
        startAt := time.Now()
        for i := 0; i < nLoop; i++ {
                _, err := client.GetUser(ctx, req)
                if err != nil {
                        panic(err)
                }
        }
        duration := time.Since(startAt).Milliseconds()
        log.Printf("gRPC: Complete! %v msec, loop %d\n", duration, nLoop)
}
```

# 参考文献

https://connect.build/docs/go/getting-started

https://future-architect.github.io/articles/20220623a/

https://zenn.dev/hsaki/books/golang-grpc-starting

https://www.nic.ad.jp/ja/newsletter/No68/0800.html

https://datatracker.ietf.org/doc/html/rfc7540

https://github.com/thrawn01/h2c-golang-example