Vue.js×Go×EvansでgRPC-Webの動作検証

公開:2020/11/21
更新:2020/11/21
10 min読了の目安(約9200字TECH技術記事

仕事上gRPC-Webの学習が必要になったため、
Evansで各段階のテストを行いながら動作検証してみることにしました。

クライアント側はほとんどこちらの記事
参考にさせていただきました。


※本記事はブログからの移行記事となります。

今回の検証に利用したサンプルコードは以下で公開中です。

https://github.com/gosarami/grpc_sandbox

対象者

  • 基本的なgRPCの概念は理解している
  • protocなどのgRPC用の環境構築を既に終えている

今回のゴール

gRPC-Webの検証として、以下の繋ぎ込みを行っていきます。

クライアント(JavaScript) -> リバースプロキシ(Envoy) -> サーバー(Go)

なお、中間にリバースプロキシを挟んでいる理由は、2020年11月現在gRPC-Webクライアントから、直接バックエンドサービスに対してgRPCコールを呼び出すことができないためです。
将来的には、ブラウザの対応が進むことによって中間のプロキシーは不要になっていくようです。

環境

  • ディレクトリ構成
$ tree -d -L 2
.
├── client
│   ├── vanilla //生js
│   └── vue //Vue.js
├── protobuf
├── proxy
└── server
  • MacOS Mojave
  • Go: 1.12.3
  • NPM: 6.7.0
  • Vue Cli: 3.4.1
  • Docker: 19.03.1
  • docker-compose: 1.24.1
  • protoc: 3.7.1
  • protoc-gen-gRPC-Web: 1.0.6
  • evans: 0.8.2

大まかな手順

  1. protobufの定義ファイルを作成
  2. protobufの定義ファイルからサーバー・クライアント両方のスタブを生成
  3. サーバー側の実装
  4. gRPC-Web対応のリバースプロキシを構築
  5. クライアント側の実装

protobufの定義ファイルを作成

helloworld.proto
syntax = "proto3";

package helloworld;

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

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

protobufの定義ファイルかサーバー・クライアント両方のスタブを生成

# サーバー
$ protoc -I=. --go_out=plugins=grpc:. helloworld.proto

# クライアント
$ protoc -I=. helloworld.proto \
  --js_out=import_style=commonjs:. \
  --gRPC-Web_out=import_style=commonjs,mode=grpcwebtext:.

protoファイルを更新するたびにサーバー・クライアント双方の変更が必要になるので、実運用では何らかの自動更新フローを確立する必要があります。
具体的な対応としては、protoファイルの更新を起点にした専用のCIを組むか、protobufをvendoringする以下のようなツールを利用するのがポイントになります。

https://github.com/stormcat24/protodep

サーバー側の実装

  • スタブ生成時に以下のファイルが生成されたことを確認
helloworld.pb.go 
  • インターフェースを参考に以下のように実装
main.go
package main

import (
        "context"
        "log"
        "net"
        pb "grpc_sandbox/protobuf"
        "google.golang.org/grpc"
)

func main() {
        listener,err := net.Listen("tcp", ":5300")
        if err != nil {
                log.Fatalf("failed to listen %v¥n",err)
                return
        }
        grpcSrv := grpc.NewServer()
        pb.RegisterGreeterServer(grpcSrv,&server{})
        log.Printf("hello service is running!")
        grpcSrv.Serve(listener)
}

type server struct{}

func (s *server) SayHello(ctx context.Context, request *pb.HelloRequest) (*pb.HelloReply, error){
        return &pb.HelloReply{
                Message: "hello! " + request.GetName(),
        },nil
}
  • サーバー起動
$ go run server/main.go 
  • サーバーの動作確認
$ evans --host localhost -p 5300 helloworld.proto
helloworld.Greeter@localhost:5300> call SayHello
name (TYPE_STRING) => server test
{
  "message": "hello! server test"
}

gRPC-Web対応のproxyを構築

gRPC対応のリバースプロキシとしてはNginx or Envoyを選択可能ですが、
今回は公式チュートリアルでも利用されているEnvoyを利用します。

  • envoy.yamlを用意

※テスト用にCORSの設定を緩めていますが、本番の利用の際は適切な設定を行ってください

envoy.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:
      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:
                - "*"
                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: greeter_service
    connect_timeout: 0.25s
    type: logical_dns
    http2_protocol_options: {}
    lb_policy: round_robin
    # win/mac hosts: Use address: host.docker.internal instead of address: localhost in the line below
    hosts: [{ socket_address: { address: host.docker.internal, port_value: 5300 }}]
  • Envoyのイメージ作成用のDockerfileを書く
Dockerfile
FROM envoyproxy/envoy:latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml
  • docker-compose.ymlを書く
docker-compose.yml
version: "3"
services:
  proxy:
    build: .
    ports: 
      - "8081:8080"
  • docker-compose up
$ docker-compose up -d
  • 動作確認
# --webオプションを足すとgRPC-Webのテストができる
$ evans --web --host localhost -p 8081 helloworld.proto
helloworld.Greeter@localhost:8081> call SayHello
name (TYPE_STRING) => proxy test
{
  "message": "hello! proxy test"
}

クライアント側の実装

vanilla.js

  • 先述のprotocコマンドで以下のファイルが生成されたことを確認
helloworld_pb.js
helloworld_grpc_web_pb.js
  • client.jsを作成
client.js
const {HelloRequest, HelloReply} = require('./helloworld_pb.js');
const {GreeterClient} = require('./helloworld_grpc_web_pb.js');

# 以下の記述はChromeのgRPC-Web拡張用のものなので、なくても動く
# https://chrome.google.com/webstore/detail/gRPC-Web-developer-tools/ddamlpimmiapbcopeoifjfmoabdbfbjj
const enableDevTools = window.__GRPCWEB_DEVTOOLS__ || (() => {});
const client = new GreeterClient('http://localhost:8081');
enableDevTools([
    client,
]);

const request = new HelloRequest();
request.setName('World!');

client.sayHello(request, {}, (err, response) => {
  console.log(response.getMessage());
});
  • gRPC用ライブラリ、Webpackをインストールするためpackage.jsonを用意
package.json
{
  "name": "gRPC-Web-simple-example",
  "version": "0.1.0",
  "description": "gRPC-Web simple example",
  "devDependencies": {
    "@grpc/proto-loader": "^0.3.0",
    "google-protobuf": "^3.6.1",
    "grpc": "^1.15.0",
    "gRPC-Web": "^1.0.0",
    "webpack": "^4.16.5",
    "webpack-cli": "^3.1.0"
  }
}
  • npm install
$ npm i
  • webpackでバンドル

以下のコマンドを実行した後、dist/main.jsが出力されていることを確認します。

$ npx webpack client.js --mode development
  • バンドルされたJavaScriptを読むindex.html作成
index.html
<meta charset="UTF-8">
<title>gRPC-Web Example</title>
<script src="dist/main.js"></script>
  • 簡易webサーバを立ち上げる

今回はPython経由でビルトインサーバーを起動していますが、
なんでもOKです。

$ python3 -m http.server 8080
  • localhost:8080にChromeでアクセス
  • consoleに<hello!World>が出力されていればOK

Vue.js

  • Vue CLIを利用して新規プロジェクトを作成
$ cd client/
$ vue create vue
  • 一旦動作確認
$ npm run serve
  • 必要なライブラリをインストール
$ npm i --save google-protobuf gRPC-Web babel-plugin-add-module-export
  • 吐いたcommonjsの構文とesの構文が混在するのを解決するために、babelで追加設定を行う
babel.config.js
module.exports = {
  presets: [
    '@vue/app',
  ],
  plugins: ["add-module-exports"] //←この一行を追加
}
  • console.log()したい場合は、package.jsonに以下追記しておく

Vue CLI3系から.eslintrcからpackage.jsonに記入する形に変わりました。

package.json
  "eslintConfig": {
    "root": true,
    "env": {
~~~      
~~~
    "rules": {
      "no-console": 0
    },

  • スタブをVueプロジェクト内に配置
$ mkdir -p client/vue/src/components/assets/protobuf
$ cp protobuf/*.js src/components/assets/protobuf
  • 生成されたスタブがESLintに怒られないようにIgnoreする
src/assets/protobuf/*.js
  • 以下のようなVueコンポーネントを追加
TestGrpc.vue
<template>
  <div class="hello">
    <h1>{{ msg }}</h1>
    <button @click="helloWorld">HelloButton</button>
  </div>
</template>

<script>
// eslint-disable-next-line 
const {HelloRequest, HelloReply} = require('../assets/protobuf/helloworld_pb.js');
const {GreeterClient} = require('../assets/protobuf/helloworld_grpc_web_pb.js');

// for gRPC-Web debug
const enableDevTools = window.__GRPCWEB_DEVTOOLS__ || (() => {});
const client = new GreeterClient('http://localhost:8081');
enableDevTools([
    client,
]);

export default {
  name: 'HelloWorld',
  data: function() {
    return {
      msg: ""      
    }
  },
  methods: {
    helloWorld: function(){
      // eslint-disable-next-line
      const request = new HelloRequest();
      request.setName('gRPC-Web with vue');
      client.sayHello(request, {}, (err, response) => {
        //console.log(response.getMessage());
        this.msg = response.getMessage();
      });
    }
  }
}
</script>

<!-- Add "scoped" attribute to limit CSS to this component only -->
<style scoped>
</style>

  • App.vueでインポート先を変更
App.vue
//import HelloWorld from './components/HelloWorld.vue'
import HelloWorld from './components/TestGrpc.vue'

...
  • もう一度ビルトインサーバを起動
$ npm run serve
  • ブラウザからlocalhost:8080にアクセスし、<hello! grpc-web with vue>というメッセージが表示されたらOK

参考

https://qiita.com/okumurakengo/items/cc696cdf28850d54775d
https://techblog.ap-com.co.jp/entry/2019/07/31/165309
https://qiita.com/mstssk/items/8759c71f328cab802670