gRPC-Webでブラウザからgoで書いたサーバにgRPCで通信してみた

次やった時に絶対忘れてるくらいには手数が多かったので、作業メモを思い出しながら書いておこうかと。思い出しながらなので、なにか抜けがあるかも。

とりあえず、こんな構成です
ubuntu 18.04 LTS
go 1.11.2
node v10.15.1 LTS
docker 18.06.1
protoc 3.7.0
Firefox 65

# go言語向けProtocolBuffersの環境構築

公式にインストールの仕方が書いてあります
https://github.com/golang/protobuf

で、わかる人は下を飛ばしてください。protocコマンドで.pb.goファイルが生成できればOKです。

```bash
protoc --go_out=plugins=grpc:./ .protoファイル
```

## protocのインストール

公式に(ry
https://github.com/protocolbuffers/protobuf

今回の環境はUbuntuなので、 https://github.com/protocolbuffers/protobuf/releases よりLinux用binary(protoc-3.7.0-rc-2-linux-x86_64.zip)を拾ってきました。zipの中に/bin/protocというファイルがあるので、パスの通った場所に移動させておきます。

次のコマンドで、libprotocのバージョンが表示されたらOKです。

```bash
protoc --version
```

## protoc-gen-goのインストール

公式に(ry
https://github.com/golang/protobuf

```bash
GIT_TAG="v1.2.0" # change as needed
go get -d -u github.com/golang/protobuf/protoc-gen-go
git -C "$(go env GOPATH)"/src/github.com/golang/protobuf checkout $GIT_TAG
go install github.com/golang/protobuf/protoc-gen-go
```

が、後からブランチがどーのこーのと怒られ始めるので、あとから結局次のようにやり直しました。

```bash
cd "$(go env GOPATH)"/src/github.com/golang
rm -rf protobuf
go get -u github.com/golang/protobuf
go install github.com/golang/protobuf/protoc-gen-go
```

# .protoファイルを書いてgoファイルを出力してみる

名前を送ったら、hello! <名前>で返してくるエコーバックサーバを作ってみます。packageはなくても動きます。

```protobuf
syntax = "proto3";

service Say {
  rpc Hello (HelloRequest) returns (HelloResponse) {}
}

message HelloRequest {
  string your_name = 1;
}

message HelloResponse {
  string response = 1;
}
```

これをmyapi.protoとして保存しておきます。

go言語のプログラムは\$(GOPATH)/srcの中に書くのがお作法なので、"$(go env GOPATH)"が~/goとした場合、~/go/src/my_proj/grpc_test/などとディレクトリを掘って、その中で作業すると余計なトラブルを発生させずに済みます。

protocの結果はさらにpbというディレクトリを掘ってそこに入れておきます。自動生成されるファイルなので、pbディレクトリは.gitignoreとかに入れておけます。

```bash
cd ~/go/src/my_proj/grpc_test/
mkdir pb
protoc --go_out=plugins=grpc:pb myapi.proto
```
出力されたmyapi.pb.goファイルには、クライアントとサーバ両方のコードが入っています。

# サーバを立てる

server.goというファイルを作成して内容を記述します。あとでclient.goも作りますので、func mainがあるgoファイルが同一ディレクトリにいるとvscodeが怒り出します。適当にserver/とかディレクトリを掘って、そこに作成してください。

```bash
cd ~/go/src/my_proj/grpc_test/
mkdir server
code server/server.go
```

ファイルの内容は次の通りです

```go
package main

import (
	"fmt"
	pb "my_proj/grpc_test/pb"
	"net"

	"github.com/Sirupsen/logrus"

	"golang.org/x/net/context"
	"google.golang.org/grpc"
)

// SayService protocで生成されたHelloを実装するための、所属させるサービスstruct
type SayService struct {
}

// Hello protocで生成されたHelloの実装を記述する。
// interfaceで定義だけされているので、所属させるstructや引数の名前とかを追加して実装する
func (s *SayService) Hello(ctx context.Context, request *pb.HelloRequest) (*pb.HelloResponse, error) {
	logrus.Info(ctx)

	return &pb.HelloResponse{
		Response: "hello! " + request.GetYourName(),
	}, nil
}

func main() {
	// 使用するポート。:の前にlocalhostとか書くと、後でproxy通した時に泣くことになるので:portだけに
	listenPort, err := net.Listen("tcp", ":3000")
	if err != nil {
		panic(err)
	}

	server := grpc.NewServer()
	sayService := &SayService{}
	pb.RegisterSayServer(server, sayService)

	logrus.Info(fmt.Sprintf("start server: %#v", listenPort.Addr().String()))
	server.Serve(listenPort)
}
```

ロギング関係はlogrusとかお好みで。実行すると、::3000で待ち受けが始まります。

```bash
go run server/server.go
```

# goでクライアントを書いて動作確認

clientというディレクトリを掘って、そこでclient.goを作成します。

```bash
cd ~/go/src/my_proj/grpc_test/
mkdir client
code client/client.go
```

ファイルの内容は次の通りです

```go
package main

import (
	"context"
	pb "my_proj/grpc_test/pb"

	"github.com/Sirupsen/logrus"

	"google.golang.org/grpc"
)

func main() {
	// ポート3000につなぎに行く
	conn, err := grpc.Dial("localhost:3000", grpc.WithInsecure())
	if err != nil {
		panic(err)
	}
	defer conn.Close()

	// 自動生成で作られたクライアントを使用する
	client := pb.NewSayClient(conn)
	request := &pb.HelloRequest{YourName: "oja"} // 送信パラメータ作成

	// 送信
	res, err := client.Hello(context.TODO(), request)
	if err != nil {
		logrus.Error(err)
	}
	// 送り返された内容を表示
	logrus.Info(res)
}
```

myapi.protoではyour_nameだったものが、出力されたmyapi.pb.goファイルではYourNameになっているので注意です。生成された.pb.goファイルは一度眺めて、変数名とかがどうなってるか確認しておくのが良さそうです。

それでは実行してみましょう

```go
go run client/client.go
```

INFO[0000] response:"hello! oja"
と、clientから送ったojaにサーバがhello!と付けて送り返してきます。

# ブラウザで動くクライアントを作成する

公式
https://github.com/grpc/grpc-web

ブラウザでgRPCを動かす場合、gRPCそのものではなくてgRPC-Webというものを使用します。そのままgoのサーバにつながると嬉しいのですがそうも行かず、間にgRPCとgRPC-Webの翻訳proxyを挟む必要があります。

とりあえずproxyのことは置いておいてjavascriptを作成してしまいます。先程掘ったpbに一緒に出しておきましょう。

```bash
cd ~/go/src/my_proj/grpc_test/
protoc --js_out=import_style=commonjs:pb --grpc-web_out=import_style=commonjs,mode=grpcwebtext:pb myapi.proto
```
生成されたファイルはwebpack等で処理されることが前提になっています。また、webpack時にはgrpc-webとgoogle-protobufというmoduleも一緒に処理されますので、pb下にこれらのモジュールも拾っておきます。
 
```bash
npm install -g webpack
npm install -g webpack-cli
cd pb
npm install grpc-web
npm install google-protobuf
```
準備ができたらメインのjavascriptを作成します。こちらは今回はhtmlというディレクトリを掘って使用します。しばらくはこのhtmlディレクトリの中で作業します。

```bash
cd ~/go/src/my_proj/grpc_test/
mkdir html
cd html
code index.js
```

ファイルの内容は次の通りです


```javascript
// protocで出力したjsをimportする
import proto from '../pb/myapi_grpc_web_pb';

// 接続先。直接:3000にgRPCで接続できないのでproxyのアドレスを記述する。
// 後で8080でproxyを立てるので、ポートを8080で指定しておく
var sayService = new proto.SayClient('http://localhost:8080');

// 送信パラメータの作成
var request = new proto.HelloRequest();
request.setYourName("oja")

// 追加のヘッダデータを送れる。アクセストークンとか送れる。今回は不要
var metadata = {};

// callback型で実行。promise型も自動生成されているので好きな方を
sayService.hello(request, metadata, function(err, response) {
    if (err) {
        console.log(err.code);
        console.log(err.message);
    } else {
        console.log(response);
    }
});
```
webpackでファイルをコンパイルします

```bash
webpack index.js
```
dist/main.jsというファイルが出来上がっています。これを読むindex.htmlも作成しておきます。

```html
<html>
    <htead>
        <meta http-equiv="Content-Type" content="text/html; charset=UTF-8">
    </htead>

    <body>
        <script src="./dist/main.js"></script>
    </body>
</html>
```

これでjavascriptの準備は完了です。

# proxyを用意する

マニュアルどおりenvoy(docker版)を使用します。grpcwebproxyというのがあってそれを使うと簡単そうだったのですが、うまく動きませんでした(´・ω・｀)

proxyというディレクトリを掘ってその中で作業を進めていきます

```bash
cd ~/go/src/my_proj/grpc_test/
mkdir proxy
cd proxy
mkdir conf
code conf/envoy.yaml
```

公式から内容をまるっとコピペし、addressとportを書き換えます。

```yaml
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:
      # 待ち受けポートを8080に
      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: echo_service
                  max_grpc_timeout: 0s
              cors:
                allow_origin:
                - "*"
                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
                enabled: true
          http_filters:
          - name: envoy.grpc_web
          - name: envoy.cors
          - name: envoy.router
  clusters:
  - name: echo_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    # grpgのサーバはホスト(172.17.0.1)で動いている。環境に寄ってipが変わるので要確認(ifconfig docker0)
    # 後、コピペした時にずれることがあったので、インデントに注意
    hosts: [{ socket_address: { address: 172.17.0.1, port_value: 3000 }}]
```

envoy実行時の設定ファイルができたので、このファイルを見るようなenvoyのdockerイメージを作成します。

```bash
code Dockerfile
```

先程envoy.yamlを作ったconfディレクトリを、docker run時にボリューム割り当てで/etc/envoyとして見えるようにします。Dockerfileでは、/etc/envoy/envoy.yamlというファイルを読むようにしておきます。

```Dockerfile
FROM envoyproxy/envoy:latest
RUN apt-get update
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml
```

それでは、コンテナを立ち上げましょう。docker run時に-dを付けるとバックグラウンド動作しますが、エラーが出たときとか調べるのが面倒なので、まずは普通に立ち上げます。コンソールを別に立ち上げて実行するのがいいです。

```bash
cd ~/go/src/my_proj/grpc_test/
cd proxy
docker build -t envoy/hello .
docker run --rm -p 8080:8080 -p 9901:9901 -v ~/go/src/my_proj/grpc_test/proxy/conf:/etc/envoy --name envoy_hello envoy/hello
```

ポート9901でadminコンソールが待ち受けしているので、ブラウザでlocalhost:9901にアクセスするとadmin画面が見られます。

なにか問題があって、コンテナの中を見たい時は次のコマンドで中に入れます。ただし、ネットワーク系のコマンドやエディタ等はほぼ使えません。

```bash
docker exec -it envoy_hello /bin/bash
```

proxyを止めるときは、ctrl+Cでは止まらないので別コンソールから次のコマンドでコンテナを止めます。

```bash
docker stop envoy_hello
```

# htmlを表示(javascriptクライアントを実行)する

まずは確認。

goのサーバはポート3000で動いていますか？
envoyのproxyはポート8080で動いていますか？

```bash
netstat -na | grep LISTEN | grep ^tcp
```

で、ポート3000とポート8080が0.0.0.0:3000または:::3000のように、publicにポートを開いているのが確認できますか？

ここまできたら、後はindex.htmlをブラウザで開くだけです。ブラウザからhttpでアクセスできるようにファイルを公開します。node.jsが入っているはずなので、node-staticとか使うと簡単に公開できます。ポート8081辺りを使ってhtmlディレクトリを公開してみましょう。

```bash
cd ~/go/src/my_proj/grpc_test/
npm install -g node-static
static html -p 8081 -a
```

ブラウザでlocalhost:8081にアクセスすると、htmlディレクトリのindex.htmlが表示され、javasciptのコンソールにgRPCのレスポンスが表示されます。レスポンスの中にgoクライアントから繋いだ時と同様にhello! ojaという内容があれば成功です。
![image.png](https://qiita-image-store.s3.amazonaws.com/0/140718/47e8a18a-9d83-9a36-dfb4-b41e20db591a.png)

通信が成功しているのを確認したら、index.jsのcallback内のconsoel.log(response)の部分を書き換えてwebpackし直します。ブラウザをリロードするとレスポンスの内容だけ表示されます。

```javascript
// .protoのresponseという変数を拾うには_pb.jsではgetResponse()
// console.log(response)
console.log(response.getResponse())
```

CORSがどうのと表示されたら、、、その、頑張って(´・ω・｀)
(実際にCORSでエラーがでてるのではなく、通信経路に問題があるとか、ポート番号やIP打ち間違えたとか、その辺りが怪しいです)