Help us understand the problem. What is going on with this article?

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対応のための設定について紹介する。

参考文献

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
No comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
ユーザーは見つかりませんでした