概要
かねてよりgRPCおよびgRPC-Webに興味があり、これを用いてシンプルなリアルタイムチャットサービスを制作し、公開した。
本稿では、その開発工程について解説する。
ゴール
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ファイルを作る。
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
そして、以下のスクリプトを書く。
#!/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) が自動生成されるので、このクラスを実装する。
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サービスの実装ができたので、これをサーバ上で動かす。
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に設定を記述する。
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を記述する。
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を用意する。
% #!/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を作る。
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を実行する。
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が得られるたび随時更新するよう、ハンドラを登録しておく。
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も適当に作っておく。
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も書き換えよう。
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対応のための設定について紹介する。