Edited at

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


概要

かねてよりgRPCおよびgRPC-Webに興味があり、これを用いてシンプルなリアルタイムチャットサービスを制作し、公開した。

本稿では、その開発工程について解説する。


ゴール

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

https://waiwai-chat-2019.aanrii.com/

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

フロントエンドは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ファイルを作る。


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 (void型のようなもの) を受け取り、Messageのstreamを返す。ようは、フロントエンドは初期化時に一回だけGetMessageStreamRequestをバックエンドに送り、その後はMessageがどこかでPostされるたびに、それをstreamで随時受け取ることができる。このように、1回のリクエストに対し複数個のレスポンスを含むストリームを返却するRPCは、Server streaming RPCと呼ばれる。詳細は、公式ガイドを参照のこと。


バックエンド (gRPCサーバ) の開発


パッケージのインストール

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

% yarn add typescript ts-node @types/node 

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

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

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

% yarn add grpc-tools grpc_tools_node_protoc_ts --dev

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


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) が自動生成されるので、このクラスを実装する。


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サービスの実装ができたので、これをサーバ上で動かす。


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) でサーバを起動できる。


% yarn ts-node src/index.ts

今回は動作確認のため、grpcurlを使う。

% brew install grpcurl

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


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

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

% 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が表示されている。



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


proxyの準備・実行

現状のgRPC-Webの仕様だと、ブラウザから直接gRPCサーバに接続することはできず、プロキシを挟む必要がある (詳細) 。

ここでは、公式の例に倣って、envoyを利用する。まず、envoy.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のL45をhosts: [{ socket_address: { address: host.docker.internal, port_value: 9090 }}]と書き換える必要がある)

そして、envoyを実行するための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イメージのビルド、起動を行う。

% 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にポートを変更して実行してみると、プロキシが機能していることがわかる。

% 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のボイラープレートから開発を始める。


% yarn create react-app frontend --typescript

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


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

フロントエンドではts-protoc-genを用いて.protoファイルをjsファイルに変換する。ここでも、バックエンド同様protoc.shを用意する。


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のインストールが必要となる。


% brew install protoc


実装

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


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を実行する。


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が得られるたび随時更新するよう、ハンドラを登録しておく。


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も適当に作っておく。


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も書き換えよう。


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のチェックから除外する方法が有効だ (参考)。ちょっとダサいが。

/* eslint-disable */

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


まとめ

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


参考文献