gRPC-WebとGoとVue.jsで簡素なチャット

# はじめに
何だか良くわからないけどよく聞くgRPC-Webなるものを触りだけでも理解すべく辛うじてチャット呼べそうなものを作ってみました。

![2020-12-18_02:05:20.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/163210/43aa4c6c-7bfc-e9dd-6a70-4fef3dd82690.png)

# 概要
## gRPCとは
https://grpc.io/
Protocol BuffersやHTTP2などを利用した環境に依存せず実行できる高パフォーマンスのRPCフレームワーク。

## Protocol Buffersとは
https://developers.google.com/protocol-buffers
言語やプラットフォームに依存しない構造データを定義できる。
コンパイルして指定の言語のコードを生成できる。

### proto
test.proto

```proto
service TestService {
  rpc Login(User) returns (User) {}
}

message User {
  string name = 1;
  string token = 2;
}
```

### Go
protoファイルからコンパイルしてGoのコードを生成。
test.pb.go

```go
func (t *testServiceClient) Login(ctx context.Context, in *User, opts ...grpc.CallOption) (*User, error) {
	out := new(User)
	err := t.cc.Invoke(ctx, "/test.TestService/Login", in, out, opts...)
	if err != nil {
		return nil, err
	}
	return out, nil
}

type User struct {
	state         protoimpl.MessageState
	sizeCache     protoimpl.SizeCache
	unknownFields protoimpl.UnknownFields

	Name  string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"`
	Token string `protobuf:"bytes,2,opt,name=token,proto3" json:"token,omitempty"`
}
```

## HTTP2とは
https://http2.info/

HTTP1からの大まかな変更

- テキストからバイナリに。プログラムで解釈しやすくなった。
- ステートレスからステートフル。ヘッダーなどを毎回送らなくても良くなった。
- 1つのTCPコネクションの中で複数のHTTP Requestと複数のHTTP Response。一気に画像を取得できたり。

## gRPCのメリット
JSONではなくProtocol Buffersを利用することによってコンピュータ間でやりとりするデータを厳密に表現できる。
HTTP2によって高速。

## gRPC-Webとは
https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md
https://grpc.io/docs/platforms/web/basics/
一般的にgRPCはマイクロサービス間で通信を行う際に使われ、クライアントをブラウザにしたい場合はgRPC-Webを利用する。
ブラウザの制限によりネイティブのgRPCとは違う実装。

## envoyとは
https://www.envoyproxy.io/docs/envoy/latest/
gRCPとgRCP-Webを接続するためには特別なプロキシが必要でデフォルトがenvoy。

# コードの説明
https://github.com/atsuya0/grpc-web-simple-chat

## protoファイル定義
ひとまず、APIを仕様を定義します。

```proto
syntax = "proto3";
package chat;

option go_package = "server/proto";

// よくあるデータ型は定義してあるので読み込む
import "google/protobuf/empty.proto";
import "google/protobuf/timestamp.proto";

// やりとりを定義
service ChatService {
  rpc Login(User) returns (User) {}
  rpc Logout(User) returns (google.protobuf.Empty) {}
  rpc SendMessage(Message) returns (Message) {}
  // 複数のレスポンスの場合、stream使う。
  // Userを渡して複数のMessageを受け取る。
  rpc GetMessage(User) returns (stream Message) {}
}

// やりとりするデータを定義
message Message {
  // 番号はただの順番
  string content = 1;
  // 自分で定義した型を使える。
  User user = 2;
  google.protobuf.Timestamp created_at = 3;
}

message User {
  string name = 1;
  string token = 2;
}
```
上記以外にもいろんな書式があって表現力高い。

### コンパイルしてコード生成
https://github.com/protocolbuffers/protobuf
からコンパイラをダウンロード。
パッケージマネージャーからインストールもできる。
Arch Linuxなら
`$ sudo paman -S protobuf`
Goのコードを生成するときは
`$ go get -u github.com/golang/protobuf/protoc-gen-go`
gRPC-Webのコードを生成するときは
`$ npm install -g protoc-gen-grpc-web`
言語、出力先をを指定してコンパイル

```shell
$ protoc chat.proto \
  --go_out=plugins="grpc:." \
  --js_out=import_style=commonjs:client/src/proto \
  --grpc-web_out=import_style=commonjs,mode=grpcwebtext:client/src/proto
```
生成したコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/proto/chat.pb.go
https://github.com/atsuya0/grpc-web-simple-chat/tree/master/client/src/proto

## Docker
簡単に試せるようにDockerで環境を構築しています。

### Go
```dockerfile
FROM golang:latest

WORKDIR /server
COPY . .
RUN go mod download
RUN go build -o app
CMD ./app
```

### JavaScript
```dockerfile
FROM  node:lts-slim

WORKDIR /client

COPY . .
RUN npm install
```

### docker-compose.yml
GoのサーバーとJSのサーバーとプロキシサーバーの３つを定義してます。

```yaml
version: '3'
services:
  envoy:
    image: envoyproxy/envoy:v1.14.1
    command: /usr/local/bin/envoy -c /etc/envoy/envoy.yaml -l debug
    volumes:
      - ./envoy:/etc/envoy
    ports:
      - '10000:10000'
    links:
      - 'server'
    container_name: 'envoy'

  server:
    build:
      context: ./server
      dockerfile: Dockerfile
    command: /server/app
    ports:
      - '50051:50051'
    volumes:
      - ./server:/go/src/server
    container_name: 'server'

  client:
    build:
      context: ./client
      dockerfile: Dockerfile
    command: npm run serve
    ports:
      - '8080:8080'
    volumes:
      - ./client:/client
    links:
      - 'envoy'
    container_name: 'client'
```

## Envoy
動かすにはenvoy.yamlが必要です。
`$ docker run --rm -it envoyproxy/envoy:v1.14.1 bash`
で/etc/envoy/envoy.yamlをコピーして来てポートなどを書き換えて利用します。
.ymlにするとエラーになり時間が消えてなくなります。

```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: 10000 }
    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: chat_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: chat_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: server, port_value: 50051 }}]
```

## Go
Goの実際のコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/main.go
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/server.go
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/server/signal.go


生成されたインターフェイス

```go
type ChatServiceServer interface {
	Login(context.Context, *User) (*User, error)
	Logout(context.Context, *User) (*empty.Empty, error)
	SendMessage(context.Context, *Message) (*Message, error)
  // 複数のレスポンスの場合、戻り値がない。引数にレスポンスのためのコネクション。
	GetMessage(*User, ChatService_GetMessageServer) error
}
```
コンパイルして生成されたGoのインターフェイスに合わせてメソッドを定義していく。

### 1つのリクエストで1つのレスポンス
protoファイルで定義したmessageのカラムにはゲッターとセッターが定義されています。
以下のGetName()、GetToken()のような。

```go
func (s *server) Login(ctx context.Context, user *pb.User) (*pb.User, error) {
	log.Println("Try to logged in.")

	clientExists := false
	s.clients.Range(func(_, client interface{}) bool {
		if value, ok := client.(string); ok && value == user.GetName() {
			clientExists = true
			return false
		}
		return true
	})
	if clientExists {
		return &pb.User{}, fmt.Errorf("\"%s\" is already in use.", user.GetName())
	}

	user.Token = genToken()
	s.clients.Store(user.GetToken(), user.GetName())

	log.Printf("%s logged in.\n", user.GetName())
	return user, nil
}
```

### 1つのリクエストで複数のレスポンス
```go
func (s *server) GetMessage(user *pb.User, stream pb.ChatService_GetMessageServer) error {
	s.wg.Add(1)
	defer s.wg.Done()
	streamCh := s.createStreamCh(user.GetToken())
	defer s.deleteStreamCh(user.GetToken())

	for {
		select {
		case msg, ok := <-streamCh:
			if !ok {
				return nil
			}
            // ここでレスポンスしてる。メソッドは終了しない。
			if err := stream.Send(msg); err != nil {
				log.Println("Sending error.")
				return err
			}
		case <-s.exitCh:
			log.Printf("%s exit.\n", user.GetName())
			return nil
		}
	}
}
```

## JavaScript
JSの実際のコード
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/client/src/api/client.js
https://github.com/atsuya0/grpc-web-simple-chat/blob/master/client/src/components/Chat.vue

コンパイルして生成したクライアント

```javascript

import { ChatServiceClient } from '../proto/chat_grpc_web_pb'
export default new ChatServiceClient('http://localhost:10000', null, null)
```
Vueのscript

```javascript
// クライアント読み込む
import client from '../api/client.js'
// コンパイルして生成した型を読み込む
import { Message, User } from '../proto/chat_pb'
// googleが定義してる型を読み込む
// import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb';

export default {
  name: "Chat",
  data: () => ({
    userName: "",
    userToken: "",
    message: "",
    messages: [],
    stream: null,
  }),
  filters: {
    toLocaleString: (value) => {
      return (new Date(value.getSeconds() * 1000)).toLocaleString()
    }
  },
  methods: {
    login: async function(e) {
      e.preventDefault();
      if (!this.userName) {
        return;
      }
      await client
        .login(this.getUser(), {}, (err, user) => {
          if (err != null) {
            console.log(err);
          } else {
            this.userToken = user.getToken();
            this.stream = this.fetchMessageStream()
          }
        })
    },
    sendMessage: async function(e) {
      e.preventDefault();
      if (!this.message) {
        return;
      }
      // 生成した型に入れてく。
      // セッターが生えてるので利用する。
      const message = new Message();
      message.setContent(this.message);
      message.setUser(this.getUser());
      const timestamp = new Timestamp();
      // ここはどこにも書いてなくて、開発者コンソールで中身を全部読んだ。
      timestamp.fromDate(new Date());
      message.setCreatedAt(timestamp);

      await client
        .sendMessage(message, {}, (err, res) => {
          if (err != null) {
            console.log(err);
          }
          this.message = '';
        })
    },
    fetchMessageStream: function() {
      const stream = client.getMessage(this.getUser());
      // メッセージが来たら発火するイベント
      stream.on('data', message => {
        console.log(message);
        this.messages = [...this.messages, message];
      });
      return stream;
    },
    getUser: function() {
      const user = new User();
      user.setName(this.userName);
      user.setToken(this.userToken);
      return user;
    }
  }
};
```

## 参考
GoでgRPC使う際のクイックスタート
https://grpc.io/docs/languages/go/quickstart/
protocol buffersが生成するGoのコードの説明
https://developers.google.com/protocol-buffers/docs/reference/go-generated
gRCPのGo実装
https://github.com/grpc/grpc-go
ブラウザためのgRCPのJavaScript実装
https://github.com/grpc/grpc-web
Goのライブラリのドキュメント
https://godoc.org/google.golang.org/grpc
GCPのドキュメントにある構成例
https://cloud.google.com/endpoints/docs/grpc/grpc-service-config?hl=ja

# 試す
`$ git clone https://github.com/atsuya0/grpc-web-simple-chat.git`
`$ cd grpc-web-simple-chat`
`$ docker-compose up -d --build`
ブラウザでhttp://localhost:8080

## サーバーだけ試す
curlは使えないのでgrpc-cli

パッケージマネージャーからインストール
`$ sudo paman -S grpc-cli`

`$ grpc_cli ls localhost:50051 chat.ChatService -l`
`$ grpc_cli call localhost:50051 ChatService.Login 'name: "John"'`
`$ grpc_cli call localhost:50051 ChatService.SendMessage 'content: "Hey"'`

# 最後に
Protocol Buffersで送信・受信するデータをテキストファイルで定義できるのは仕様が分かりやすくてとても良いですね。1つのTCPコネクションの中で複数のHTTP通信ができるのも幅が広がって期待に胸いっぱいです。