gRPC-Web + React + Node.js + TypeScriptでシンプルなチャットサービスを作る

## 概要

かねてよりgRPCおよびgRPC-Webに興味があり、これを用いてシンプルな[リアルタイムチャットサービス](https://waiwai-chat-2019.aanrii.com/)を制作し、公開した。
本稿では、その開発工程について解説する。

### ゴール
gRPC-Webを用いて「わいわいチャット」を作る。

~~https://waiwai-chat-2019.aanrii.com/~~
※2020年9月現在、公開停止しました。

内容はシンプルなチャットアプリケーションだ。サイトを開くとまず過去ログが表示され、ほかの入室者の投稿が随時流れてくる。任意の名前で入室すると投稿欄が出現し、発言ができる。発言した内容はサイトにアクセスしている全員に、即座に共有される。過去ログは無限スクロールで遡ることができる。

フロントエンドはReactを用いたSPAとし、Netlifyを使って静的サイト生成・配信する。また、バックエンドはGKE上で動くNode.jsアプリケーションとし、かつenvoyをプロキシとして挟んで外部と通信させる。そして、フロントエンド-バックエンド間はgRPC-Web (HTTPS) で通信する。

なお、コードはここで公開している。
https://github.com/aanrii/waiwai-chat-2019

### やること
完成に到るまで、主に以下のことに取り組む。

- バックエンド開発
  - gRPC-Web + Node.js + TypeScriptによるアプリケーション開発 （+ grpcurlによるデバッグ)
  - envoy-proxyを通したフロントエンドとの接続 
  - GCPでの実行 (GKEへのデプロイ、およびCloud SQL + Cloud Pub/Subの導入。別稿にて解説)
  - SSL有効化 (cert-managerによる証明書自動取得。別稿にて解説)
- フロントエンド開発 
  - gRPC-Web + React + TypeScriptによるアプリケーション開発
  - Netlifyを用いた静的コンテンツ配信 (別稿にて解説)

本稿では、バックエンド・フロントエンドアプリケーションが**ローカル上で**動くことを目標として解説を進める。
GCP上での実行とgRPCサーバの冗長化、SSL有効化などについては、別稿にて説明する。

## 開発
### Service (protobuf) の定義
gRPCアプリケーションを作るため、まずgRPCサーバ (バックエンド) のI/Fを定義する.protoファイルを作る。

```protobuf:proto/MessageService.proto
syntax = "proto3";

import "google/protobuf/empty.proto";

service MessageService {
  rpc GetMessageStream(google.protobuf.Empty) returns (stream Message);
  rpc PostMessage(Message) returns (PostMessageResponse);
}

message PostMessageResponse {
  string status = 1; // メッセージの処理結果 
}

message Message {
  string text = 1; // 発言内容
  int64 create_time = 2; // 発言日時
  string author_name = 3; // 投稿者名
}
```
MessageServiceはGetMessageStreamとPostMessageという、ふたつのメソッドをもつ。PostMessageはフロントエンドからバックエンドへのメッセージ送信に用いる。バックエンドはMessageを受け取り、PostMessageResponse (受信に成功したらOK、失敗したらError、等) を返す。このように、1回のリクエストに1回のレスポンスを返すようなRPCは、Unary RPCと呼ばれる。

一方、GetMessageStreamはフロントエンドがバックエンドからメッセージを受信するのに使う。バックエンドは[google.protobuf.Empty](https://developers.google.com/protocol-buffers/docs/reference/google.protobuf#google.protobuf.Empty) (void型のようなもの) を受け取り、Messageのstreamを返す。ようは、フロントエンドは初期化時に一回だけGetMessageStreamRequestをバックエンドに送り、その後はMessageがどこかでPostされるたびに、それをstreamで随時受け取ることができる。このように、1回のリクエストに対し複数個のレスポンスを含むストリームを返却するRPCは、Server streaming RPCと呼ばれる。詳細は、[公式ガイド](https://www.grpc.io/docs/guides/concepts/)を参照のこと。

### バックエンド (gRPCサーバ) の開発
#### パッケージのインストール

バックエンドの開発に入る。必要なパッケージをインストールする。まず、TypeScriptとNode.js。

```zsh
% yarn add typescript ts-node @types/node 
```

続いて、gRPCを利用するためのパッケージを追加する。

```zsh
% yarn add grpc google-protobuf @types/google-protobuf
```

最後に、TypeScript用protobufコンパイラを導入する。これらは、protoファイルからTypeScript+Node.jsで使える型定義ファイルを生成するために用いる。

```zsh
% yarn add grpc-tools grpc_tools_node_protoc_ts --dev
```

そして、以下のスクリプトを書く。

```bash:backend/server/protoc.sh

#!/usr/bin/env bash

set -eu

export PATH="$PATH:$(yarn bin)"

# protoファイルがあるディレクトリへの相対パス
PROTO_SRC=../../proto

# 生成したjs、tsファイルを格納したいディレクトリへの相対パス
PROTO_DEST=./src/proto
mkdir -p ${PROTO_DEST}

grpc_tools_node_protoc \
  --js_out=import_style=commonjs,binary:${PROTO_DEST} \
  --grpc_out=${PROTO_DEST} \
  --plugin=protoc-gen-grpc=$(which grpc_tools_node_protoc_plugin) \
  -I ${PROTO_SRC} \
  ${PROTO_SRC}/*

grpc_tools_node_protoc \
  --plugin=protoc-gen-ts=$(yarn bin)/protoc-gen-ts \
  --ts_out=${PROTO_DEST} \
  -I ${PROTO_SRC} \
  ${PROTO_SRC}/*
```

これで、`bash protoc.sh`を実行することで、protoファイルをjs/tsコードにコンパイルすることが可能になる。

#### gRPCサーバの実装

生成されたファイル、サーバーサイドのinterface (IMessageServiceServer) が自動生成されるので、このクラスを実装する。

```TypeScript:backend/server/src/MessageService.ts
import { EventEmitter } from 'events';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import * as grpc from 'grpc';
import { IMessageServiceServer } from './proto/MessageService_grpc_pb';
import { Message, PostMessageResponse } from './proto/MessageService_pb';

class MessageService implements IMessageServiceServer {
  // PostMessageにより投稿されたメッセージをGetMessageStreamで返却するstreamに流すための中継器
  private readonly messageEventEmitter = new EventEmitter();

  // 過去ログを保存する配列
  private readonly pastMessageList: Message[] = [];

  public getMessageStream(call: grpc.ServerWriteableStream<Empty>) {
    // 過去ログをstreamに流し込む
    this.pastMessageList.forEach(message => call.write(message));

    // PostMessageが実行されるたびに、そのメッセージをstreamに流し込む
    const handler = (message: Message) => call.write(message);
    this.messageEventEmitter.on('post', handler);

    // streamが切断された時、上記Listenerを消去する
    call.on('close', () => {
      this.messageEventEmitter.removeListener('post', handler);
    });
  }

  public postMessage(call: grpc.ServerUnaryCall<Message>, callback: grpc.sendUnaryData<PostMessageResponse>) {
    // 受け取ったメッセージを過去ログに保存する
    const message = call.request;
    this.pastMessageList.push(message);

    // messageEventEmitter経由で、getMessageStreamで返却するstreamにメッセージを送る
    this.messageEventEmitter.emit('post', message);

    // レスポンスを返す
    const response = new PostMessageResponse();
    response.setStatus('ok');
    callback(null, response);
  }
}

export default MessageService;

```

一旦ローカルで動かすために、インメモリ上にメッセージを貯めることとする。
gRPCサービスの実装ができたので、これをサーバ上で動かす。


```TypeScript:backend/server/src/index.ts
import * as grpc from 'grpc';
import MessageService from './MessageService';
import { MessageServiceService } from './proto/MessageService_grpc_pb';

(() => {
  const server = new grpc.Server();
  server.bind(`0.0.0.0:9090`, grpc.ServerCredentials.createInsecure());
  server.addService(MessageServiceService, new MessageService());
  server.start();
})();

```

#### デバッグ

以下のコマンドにより、ローカル (0.0.0.0:9090) でサーバを起動できる。

```zsh

% yarn ts-node src/index.ts 
```

今回は動作確認のため、[grpcurl](https://github.com/fullstorydev/grpcurl)を使う。

```zsh
% brew install grpcurl
```

以下コマンドでGetMessageStreamを呼び出すと、待機状態に入る。

```zsh

% grpcurl -plaintext -import-path proto/ -proto MessageService.proto 0.0.0.0:9090 MessageService/GetMessageStream
```

この状態でターミナルを別に立ち上げ、Messageを投稿してみる。成功すればレスポンスが返ってくる。

```zsh
% grpcurl -d "{\"text\":\"hello\",\"create_time\":$(node -e 'console.log(Date.now())'),\"author_name\":\"aanrii\"}" -import-path proto/ -proto MessageService.proto -plaintext -v 0.0.0.0:9090 MessageService/PostMessage

Resolved method descriptor:
rpc PostMessage ( .Message ) returns ( .PostMessageResponse );

Request metadata to send:
(empty)

Response headers received:
accept-encoding: identity,gzip
content-type: application/grpc
grpc-accept-encoding: identity,deflate,gzip

Response contents:
{
  "status": "ok"
}

Response trailers received:
(empty)
Sent 1 request and received 1 response

```
GetMessageStreamを実行したウィンドウに戻ると、受信したMessageが表示されている。

```json

{
  "text": "hello",
  "createTime": "1570468135968",
  "authorName": "aanrii"
}
```

#### proxyの準備・実行
現状のgRPC-Webの仕様だと、ブラウザから直接gRPCサーバに接続することはできず、プロキシを挟む必要がある ([詳細](https://github.com/grpc/grpc/blob/master/doc/PROTOCOL-WEB.md)) 。
ここでは、[公式の例](https://github.com/grpc/grpc-web/tree/master/net/grpc/gateway/examples/helloworld)に倣って、envoyを利用する。まず、envoy.yamlに設定を記述する。

```yaml:backend/proxy/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: message_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
          http_filters:
          - name: envoy.grpc_web
          - name: envoy.cors
          - name: envoy.router
  clusters:
  - name: message_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 }}]
```

(Docker Desktop for Macで動かすためには、公式の[envoy.yaml](https://github.com/grpc/grpc-web/blob/master/net/grpc/gateway/examples/helloworld/envoy.yaml#L45)のL45を`hosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}]`と書き換える必要がある)

そして、envoyを実行するためのDockerfileを記述する。

```Dockerfile:backend/proxy/Dockerfile
FROM envoyproxy/envoy:latest
COPY ./envoy.yaml /etc/envoy/envoy.yaml
CMD /usr/local/bin/envoy -c /etc/envoy/envoy.yaml
EXPOSE 8080
```

そして、以下のコマンドでDockerイメージのビルド、起動を行う。

```zsh
% docker build -t waiwai-chat-grpc/envoy -f ./Dockerfile .
% docker run -d -p 8080:8080 -p 9901:9901 waiwai-chat-grpc/envoy:latest
```

前述のgrpcurlコマンドを、9090から8080にポートを変更して実行してみると、プロキシが機能していることがわかる。

```shell
% grpcurl -plaintext -import-path proto/ -proto MessageService.proto 0.0.0.0:8080 MessageService/GetMessageStream
{
  "text": "hello",
  "createTime": "1570468135968",
  "authorName": "aanrii"
}
```

これで、最低限それらしい挙動をするgRPCサーバが完成した。

### フロントエンドの開発
#### パッケージのインストール
今回はフロントエンドも、バックエンドと同じくTypeScriptで記述していく。
create-react-appを用いて、React + TypeScriptのボイラープレートから開発を始める。

```zsh

% yarn create react-app frontend --typescript
```

続いて、gRPCを利用するためのパッケージを追加する。

```zsh

% yarn add @improbable-eng/grpc-web ts-protoc-gen
```

フロントエンドでは[ts-protoc-gen](https://github.com/improbable-eng/ts-protoc-gen)を用いて.protoファイルをjsファイルに変換する。ここでも、バックエンド同様protoc.shを用意する。

```shell:frontend/protoc.sh
% #!/usr/bin/env bash

set -eu


# protoファイルがあるディレクトリへの相対パス
PROTO_SRC=../proto
# 生成したjs、tsファイルを格納したいディレクトリへの相対パス
PROTO_DEST=./src/proto

mkdir -p ${PROTO_DEST}

# protoc-gen-tsへのパス
PROTOC_GEN_TS_PATH="$(yarn bin)/protoc-gen-ts"
 
protoc \
    --plugin="protoc-gen-ts=${PROTOC_GEN_TS_PATH}" \
    --js_out="import_style=commonjs,binary:${PROTO_DEST}" \
    --ts_out="service=true:${PROTO_DEST}" \
    -I ${PROTO_SRC} $(find ${PROTO_SRC} -name "*.proto")
```

このスクリプトを動かすには別途protocのインストールが必要となる。

```zsh

% brew install protoc
```

#### 実装

まず、gRPCクライアントを生成し、あらゆるコンポーネントから利用できるようにするためのHOCを作る。

```tsx:frontend/src/attachMessageServiceClient.tsx
import React from 'react';
import { MessageServiceClient } from '../proto/MessageService_pb_service';

export type MessageServiceClientAttached = {
  client: MessageServiceClient;
};

const client = new MessageServiceClient(`http://0.0.0.0:8080`);

const attachMessageServiceClient = <P extends {}>(WrappedComponent: React.ComponentType<P & MessageServiceClientAttached>) =>
  class MessageServiceAttached extends React.Component<P> {
    render() {
      return <WrappedComponent {...this.props} client={client} />;
    }
  };

export default attachMessageServiceClient;
```

投稿フォームは次のようにする。フォームに入力された文字列をもとにMessageを生成し、postMessageを実行する。 

```Tsx:frontend/src/components/PostForm.tsx
import React, { useState, FormEvent } from 'react';
import { Message as ProtoMessage } from '../proto/MessageService_pb';
import attatchMessageServiceClient, { MessageServiceClientAttached } from './attatchMessageServiceClient';

const PostForm: React.FC<{ initialInputText?: string } & MessageServiceClientAttached> = ({
  initialInputText = '',
  client,
}) => {
  const [inputText, setInputText] = useState(initialInputText);
  const handleSubmit = (e: FormEvent<HTMLFormElement>) => {
    e.preventDefault();

    const currentDate = Date.now();
    const message = new ProtoMessage();

    message.setAuthorName('hoge'); // 一旦適当に埋める
    message.setCreateTime(currentDate);
    message.setText(inputText);
    client.postMessage(message, (error, response) => console.log(error == null ? error : response));

    setInputText('');
  };
  return (
    <div>
      <form onSubmit={handleSubmit}>
        <input type="text" name="inputText" value={inputText} onChange={e => setInputText(e.target.value)} />
        <input type="submit" value="Submit" />
      </form>
    </div>
  );
};

export default attatchMessageServiceClient(PostForm);

```
ログの表示に再しては、まずgetMessageStreamによってstreamを得て、Messageが得られるたび随時更新するよう、ハンドラを登録しておく。

```tsx:frontend/src/components/MessageList.tsx
import React from 'react';
import Message from './Message';
import { Message as ProtoMessage } from '../proto/MessageService_pb';
import attatchMessageServiceClient, { MessageServiceClientAttached } from './attatchMessageServiceClient';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';

interface MessageListState {
  protoMessageList: ProtoMessage.AsObject[];
}

class MessageList extends React.Component<void & MessageServiceClientAttached, MessageListState> {
  constructor(props: {} & MessageServiceClientAttached) {
    super(props);
    this.state = { protoMessageList: [] };
    // message streamの取得
    const messageStream = props.client.getMessageStream(new Empty());
    // streamからmessageを受け取るたび、それをprotoMessageListに格納するハンドラを登録する
    messageStream.on('data', message => {
      const newProtoMessageList = [message.toObject()].concat(this.state.protoMessageList);
      this.setState({ protoMessageList: newProtoMessageList });
    });
  }

  render() {
    return (
      <div>
        {this.state.protoMessageList.map(protoMessage => (
          <Message {...protoMessage} key={protoMessage.createTime} />
        ))}
      </div>
    );
  }
}

export default attatchMessageServiceClient(MessageList);

```
MessageのためのPresentational Componentも適当に作っておく。

```tsx:frontend/src/components/Message.tsx
import React from 'react';
import { Message as ProtoMessage } from '../proto/MessageService_pb';

const Message: React.SFC<ProtoMessage.AsObject> = protoMessage => (
  <div>
    {protoMessage.text} ({new Date(protoMessage.createTime).toString()})
  </div>
);

export default Message;
```
App,tsxも書き換えよう。

```tsx:frontend/src/App.tsx
import React from 'react';
import PostForm from './components/PostForm';
import MessageList from './components/MessageList';

const App: React.FC = () => {
  return (
    <div>
      <PostForm />
      <MessageList />
    </div>
  );
};

export default App;
```
ここで、`yarn start`を起動してみよう。ブラウザを確認すると、大量にエラーが出ているのがわかる。

```
./src/proto/MessageService_pb.js
  Line 27:    'proto' is not defined     no-undef
  Line 30:   'proto' is not defined     no-undef
  Line 31:   'COMPILED' is not defined  no-undef
  Line 36:    'proto' is not defined     no-undef
```
これについては、実際のところ、protocで生成されたjsファイルをeslintのチェックから除外する方法が有効だ ([参考](https://github.com/grpc/grpc-web/issues/447))。ちょっとダサいが。

```
/* eslint-disable */
```

再び`yarn start`を実行すると、問題なくフロントが表示されることがわかる。

## まとめ
ここまでで、gRPC-Web + React + Node.js + TypeScriptを用いて、少なくともローカルで動くチャットアプリケーションを作成した。続編 (今後書く予定) では、GCPへデプロイを行い、Kubernetes (GKE) 上でgRPCサーバを動かし、またその冗長化、負荷分散、SSL対応のための設定について紹介する。

## 参考文献
- [Node.jsとTypeScriptでgRPCを動かす](https://qiita.com/daisaru11/items/67366061f7244378639c)
- [gRPC × TypeScript を始めるための一歩](https://qiita.com/ohs30359-nobuhara/items/f11857d5d3d9dbc6637b)
- [gRPC-Web + Typescript + GoでHelloWorld](https://qiita.com/otanu/items/98d553d4b685a8419952)
- [gRPCをcurlのようにコンソールから呼び出せるgrpcurl](https://kiririmode.hatenablog.jp/entry/20181008/1538984412)