gRPC-webを触ってみた

※この記事は、 [CyberAgent PTA Advent Calendar](https://adventar.org/calendars/5741) 2020の21日目の記事です。

現在サイバーエージェントのメディア事業部でエンジニアをやっているwata3110です。


この記事は、[gRPC-Web Hello World Guide](https://github.com/grpc/grpc-web/tree/master/net/grpc/gateway/examples/helloworld)を参考に手を動かした時のことをまとめました。
はじめにざっくりとgRPC-webとは何かを説明したあと、触ってみてハマったところなどを書いていきたいと思います。
まだ理解できていないところも多いため訂正や補足ありましたら、コメントいただけると幸いです。


## gRPC-webとは

webクライアントがHTTPサーバーを経由することなく、gRPCバックエンドサービスと直接通信できるようにするJavaScriptクライアントライブラリです。

クライアント側とサーバー側のデータ型とサービスインターフェイスをProtocol Bufferで定義することができ、end-to-endのgRPCアプリケーションアーキテクチャを簡単に構築できることがgRPC-Webを使用することの大きな利点の一つになります。![1_mAkZWyRD9gKyBEOaqEFm-A.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/869586/fb9dff72-961c-bd16-93d5-9839fe69398b.png)
クライアントはバックエンドに対して直接呼び出すことはできず、クライアント呼び出しをgRPCに適した呼び出しに変換する必要があり、その役割はEnvoyがクライアントによって生成されたHTTP/1.1呼び出しをバックエンドのサービスで処理できるHTTP/2呼び出しに変換することで実現しています。

## 触ってみる

## Protocol Bufferを定義する

HelloRequestを渡すとHelloResponseを返すrpcメソッドSayHelloをもつサービスGreeterを定義します。

```proto:helloworld.proto
syntax = "proto3";

package helloworld;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply);
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}
```

##サービスを実装する
クライアントからリクエストを受け取り、
それを元にレスポンスを作成してクライアントに送り返します。

```javascript:server.js
var PROTO_PATH = __dirname + '/helloworld.proto';

var grpc = require('@grpc/grpc-js');
var protoLoader = require('@grpc/proto-loader');
var packageDefinition = protoLoader.loadSync(
    PROTO_PATH,
    {keepCase: true,
     longs: String,
     enums: String,
     defaults: true,
     oneofs: true
    });
var protoDescriptor = grpc.loadPackageDefinition(packageDefinition);
var helloworld = protoDescriptor.helloworld;

function doSayHello(call, callback) {
  callback(null, {
    message: 'Hello! ' + call.request.name
  });
}

function getServer() {
  var server = new grpc.Server();
  server.addService(helloworld.Greeter.service, {
    sayHello: doSayHello,
  });
  return server;
}

if (require.main === module) {
  var server = getServer();
  server.bindAsync(
    '0.0.0.0:9090', grpc.ServerCredentials.createInsecure(), (err, port) => {
      assert.ifError(err);
      server.start();
  });
}

exports.getServer = getServer;
```

##クライアントコードを書く

クライアント用のjsファイルを以下コマンドで生成します。
import_styleにtypescriptを指定すれば、typescriptで出力できます。

```
$protoc -I=. helloworld.proto \
  --js_out=import_style=commonjs:. \
  --grpc-web_out=import_style=commonjs,mode=grpcwebtext:.
```

生成したファイルを元にclient.jsを実装します。

```javascript:client.js
const {HelloRequest, HelloReply} = require('./helloworld_pb.js');
const {GreeterClient} = require('./helloworld_grpc_web_pb.js');

var client = new GreeterClient('http://localhost:8080');

var request = new HelloRequest();
request.setName('World');

client.sayHello(request, {}, (err, response) => {
  console.log(response.getMessage());
});
```

##package.json
package.jsonを作成します。

```json:package.json
{
  "name": "grpc-web-simple-example",
  "version": "0.1.0",
  "description": "gRPC-Web simple example",
  "main": "server.js",
  "devDependencies": {
    "@grpc/grpc-js": "~1.0.5",
    "@grpc/proto-loader": "~0.5.4",
    "async": "~1.5.2",
    "google-protobuf": "~3.14.0",
    "grpc-web": "~1.2.1",
    "lodash": "~4.17.0",
    "webpack": "~4.43.0",
    "webpack-cli": "~3.3.11"
  },
  "scripts": {
    "build": "webpack client.js --mode development"
  }
}
```

##index.html
簡単なHTMLを実装します。

```html:index.html
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>gRPC-Web Example</title>
<script src="./dist/main.js"></script>
</head>
<body>
  <p>Open up the developer console and see the logs for the output.</p>
</body>
</html>
```

##プロキシの設定ファイル
Envoyの設定ファイルを記述します。

```yaml:envoy.yml
admin:
  access_log_path: /tmp/admin_access.log
  address:
    socket_address: { address: 0.0.0.0, port_value: 9901 }

static_resources:
  listeners:
    - name: listener_0
      address:
        socket_address: { address: 0.0.0.0, port_value: 8080 }
      filter_chains:
        - filters:
            - name: envoy.filters.network.http_connection_manager
              typed_config:
                "@type": type.googleapis.com/envoy.config.filter.network.http_connection_manager.v2.HttpConnectionManager
                codec_type: auto
                stat_prefix: ingress_http
                route_config:
                  name: local_route
                  virtual_hosts:
                    - name: local_service
                      domains: ["*"]
                      routes:
                        - match: { prefix: "/" }
                          route:
                            cluster: greeter_service
                            max_grpc_timeout: 0s
                      cors:
                        allow_origin_string_match:
                          - prefix: "*"
                        allow_methods: GET, PUT, DELETE, POST, OPTIONS
                        allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,
                        custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                        max_age: "1728000"
                        expose_headers: custom-header-1,grpc-status,grpc-message
                http_filters:
                  - name: envoy.filters.http.grpc_web
                  - name: envoy.filters.http.cors
                  - name: envoy.filters.http.router
  clusters:
    - name: greeter_service
      connect_timeout: 0.25s
      type: logical_dns
      http2_protocol_options: {}
      lb_policy: round_robin
      load_assignment:
        cluster_name: cluster_0
        endpoints:
          - lb_endpoints:
              - endpoint:
                  address:
                    socket_address:
                      address: host.docker.internal
                      port_value: 9090
```

###クライアント用のjsコードをコンパイルする

```
npm install
npm run build
```


##動かしてみる

gRPCサービスを立ち上げます。

```
$ node server.js
```

次にEnvoyを立ち上げます。
上記の`envoy.yml`でポート:8080でブラウザからのリクエストを受け取り、ポート:9090に転送するようにEnvoyを構成しています。

```
docker run -d -v "$(pwd)"/envoy.yaml:/etc/envoy/envoy.yaml:ro \
    -p 8080:8080 -p 9901:9901 envoyproxy/envoy:v1.16.1
```

webサーバーを立ち上げます

```
python3 -m http.server 8081
```

全て正常に起動させた後、localhost:8081にアクセスすると、
devツールのconsoleに`hello world`が表示されます。

![スクリーンショット 2020-12-21 8.12.04.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/869586/8e160d42-5d22-9dcf-3469-628578400864.png)

##ハマったところ

gRPCサービス、Envoy、webサーバーそれぞれ起動はしたが、consoleに何も表示されない事象が発生した。

##原因

↓元のコード

```yaml:envoy.yml
static_resources:
  listeners:
  - name: listener_0
    address:
      socket_address: { address: 0.0.0.0, port_value: 8080 }
    filter_chains:
    - filters:
      - name: envoy.http_connection_manager
        config:
          codec_type: auto
          stat_prefix: ingress_http
          route_config:
            name: local_route
            virtual_hosts:
            - name: local_service
              domains: ["*"]
              routes:
              - match: { prefix: "/" }
                route:
                  cluster: greeter_service
                  max_grpc_timeout: 0s
              cors:
                allow_origin_string_match:
                - prefix: "*"
                allow_methods: GET, PUT, DELETE, POST, OPTIONS
                allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
                max_age: "1728000"
                expose_headers: custom-header-1,grpc-status,grpc-message
          http_filters:
          - name: envoy.grpc_web
          - name: envoy.cors
          - name: envoy.router
  clusters:
  - name: greeter_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    hosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}]　// ←ここを修正
```

ガイドに沿って書いた元の`envoy.yml`のコードはclustersのhostsフィールドが使われてました。
[Envoy documentation](https://www.envoyproxy.io/docs/envoy/latest/api-v2/api/v2/cluster.proto)を見ると、hostsは非推奨になっており、代わりにload_assginmentを使うようにと書いてあったのでその通りにしたら正常に`Hello Wolrd`が表示されるようになりました。



![スクリーンショット 2020-12-21 8.53.23.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/869586/079b98d5-411d-def0-bc6a-84f2426401d1.png)


##まとめ
ただHello Worldするだけでしたが、なかなかうまく行かない部分が多く苦労しました。
それでもなんとなくですがgRPC-webの概要は理解できるようになったと思います。
次は何か作ってより理解を深めたいと思います。




##参考文献
- [gRPC-Web is Generally Available](https://grpc.io/blog/grpc-web-ga/)
- [Envoy and gRPC-Web: a fresh new alternative to REST](https://blog.envoyproxy.io/envoy-and-grpc-web-a-fresh-new-alternative-to-rest-6504ce7eb880)
- [gRPC-Web Hello World Guide](https://github.com/grpc/grpc-web/tree/master/net/grpc/gateway/examples/helloworld)
- [Envoy documentation](https://www.envoyproxy.io/docs/envoy/latest/)
- [gRPC WebがGAになったのでそろそろ理解してみる](https://qiita.com/betchi/items/0e9040f21b1f8ecbf321)