gRPC-Web + React Hooks + Go でリアルタイムチャットをつくる

## 概要

分散システムを学ぶうちにgRPCに興味を持った。きくところによると、gRPC-Webというものもあるらしい。

この記事では、gRPC-Web + React Hooks + Goを用いてリアルタイムチャットを作りながら、実装の流れを書いてみようと思う。

コードだけ見たいという方は↓へ、

- [okmttdhr/grpc-web-react-hooks](https://github.com/okmttdhr/grpc-web-react-hooks)

gRPC-Webってなんやねん？という方は↓へどうぞ！

- [3分でわかるgRPC-Web - Qiita](https://qiita.com/okmttdhr/items/fb7a8bc0c8afd4e2f4c1)

## 全体像

サービスの全体像は以下のようになる。

![Untitled Diagram.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/22875/ac11dd0a-92c0-9c23-e17c-5179634c0650.png)

タイトルのとおり、ReactクライアントからgRPC-WebでGoサーバーと通信するチャットサービスだ。

デモはこんな感じ↓
(リアルタイムですべてのクライアントにメッセージが配信される)

[![Image from Gyazo](https://i.gyazo.com/b75fbfb46e06b406b1cc9bfd8001013b.gif)](https://gyazo.com/b75fbfb46e06b406b1cc9bfd8001013b)

## 開発

### Protocol Buffersの定義

まずはProtocol Buffersのインターフェイスを定義する。

前述のチャットサービスをつくるにあたって、以下のようなインターフェイスを作成する。

```proto
syntax = "proto3";

import "google/protobuf/empty.proto";

package messenger;

service Messenger {
  rpc GetMessages (google.protobuf.Empty) returns (stream MessageResponse) {}
  rpc CreateMessage (MessageRequest) returns (MessageResponse) {}
}

message MessageRequest {
  string message = 1;
}

message MessageResponse {
  string message = 1;
}
```

- `CreateMessage`はメッセージの投稿で、リクエストとレスポンスの型を定義している。
- `GetMessages`でメッセージの受信をする。`returns (stream MessageResponse)`とすることでストリームを返すコードを生成できる。

### gRPCのコードを自動生成

ここからgRPCのコードを生成する。

GoバックエンドとTypeScriptフロントエンドのコードを生成するために、`protoc`に加え、`protoc-gen-go`、`protoc-gen-grpc-web`をインストールする。

もちろんローカル環境には入れたくないのでコンテナをつくっていく。

```Dockerfile
FROM golang:1.14.0

ENV DEBIAN_FRONTEND=noninteractive

ARG PROTO_VERSION=3.11.4
ARG GRPCWEB_VERSION=1.0.7

WORKDIR /proto

RUN apt-get -qq update && apt-get -qq install -y \
  unzip

RUN curl -sSL https://github.com/protocolbuffers/protobuf/releases/download/v${PROTO_VERSION}/\
  protoc-${PROTO_VERSION}-linux-x86_64.zip -o protoc.zip && \
  unzip -qq protoc.zip && \
  cp ./bin/protoc /usr/local/bin/protoc && \
  cp -r ./include /usr/local

RUN curl -sSL https://github.com/grpc/grpc-web/releases/download/${GRPCWEB_VERSION}/\
  protoc-gen-grpc-web-${GRPCWEB_VERSION}-linux-x86_64 -o /usr/local/bin/protoc-gen-grpc-web && \
  chmod +x /usr/local/bin/protoc-gen-grpc-web

RUN go get -u github.com/golang/protobuf/protoc-gen-go
```

```yml:docker-compose.yml
version: '3'
services:
  proto:
    command: ./proto/scripts/protoc.sh
    build:
      context: .
      dockerfile: DockerfileProto
    volumes:
      - .:/proto
```

```sh:protoc.sh
#!/bin/sh

set -xe

SERVER_OUTPUT_DIR=server/messenger
CLIENT_OUTPUT_DIR=client/src/messenger

protoc --version
protoc --proto_path=proto messenger.proto \
  --go_out=plugins="grpc:${SERVER_OUTPUT_DIR}" \
  --js_out=import_style=commonjs:${CLIENT_OUTPUT_DIR} \
  --grpc-web_out=import_style=typescript,mode=grpcwebtext:${CLIENT_OUTPUT_DIR}
```

これで`docker-compose up`するとコードが自動生成される。

### バックエンドの実装

バックエンドの実装をする。

ひとつ前のステップで、以下のようなインターフェイスが自動生成されているので、これを組み合わせて実装をしてゆく。

```go
// MessengerServer is the server API for Messenger service.
type MessengerServer interface {
	GetMessages(*empty.Empty, Messenger_GetMessagesServer) error
	CreateMessage(context.Context, *MessageRequest) (*MessageResponse, error)
}
```

まずはサーバーの雛形を書いてみる。下記の`TODO`を埋めていくような流れだ。

```go
package main

import (
	"context"
	"log"
	"net"

	"github.com/golang/protobuf/ptypes/empty"
	pb "github.com/okmttdhr/grpc-web-react-hooks/messenger"

	"google.golang.org/grpc"
	"google.golang.org/grpc/reflection"
)

const (
	port = ":9090"
)

type server struct {
	pb.UnimplementedMessengerServer
	requests []*pb.MessageRequest
}

func (s *server) GetMessages(_ *empty.Empty, stream pb.Messenger_GetMessagesServer) error {
  // TODO: 実装
}

func (s *server) CreateMessage(ctx context.Context, r *pb.MessageRequest) (*pb.MessageResponse, error) {
  // TODO: 実装
}

func main() {
	lis, err := net.Listen("tcp", port)
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}
	s := grpc.NewServer()
	pb.RegisterMessengerServer(s, &server{})
	reflection.Register(s)
	if err := s.Serve(lis); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}
```

まずはメッセージの投稿だが、シンプルに、配列に時刻付きのメッセージを詰め込んでゆく形にした。

```go
func (s *server) CreateMessage(ctx context.Context, r *pb.MessageRequest) (*pb.MessageResponse, error) {
	log.Printf("Received: %v", r.GetMessage())
	newR := &pb.MessageRequest{Message: r.GetMessage() + ": " + time.Now().Format("2006-01-02 15:04:05")}
	s.requests = append(s.requests, newR)
	return &pb.MessageResponse{Message: r.GetMessage()}, nil
}
```

次にメッセージの取得だ。一度目のアクセスで保持しているメッセージを流し、それ以降は、新しいメッセージを検知したときのみデータを送るようにしている。

```go
func (s *server) GetMessages(_ *empty.Empty, stream pb.Messenger_GetMessagesServer) error {
	for _, r := range s.requests {
		if err := stream.Send(&pb.MessageResponse{Message: r.GetMessage()}); err != nil {
			return err
		}
	}

	previousCount := len(s.requests)

	for {
		currentCount := len(s.requests)
		if previousCount < currentCount {
			r := s.requests[currentCount-1]
			log.Printf("Sent: %v", r.GetMessage())
			if err := stream.Send(&pb.MessageResponse{Message: r.GetMessage()}); err != nil {
				return err
			}
		}
		previousCount = currentCount
	}
}
```

これでバックエンドの実装ができた。

### フロントエンドの実装

次に、Web側の実装を行う。

まずはgRPCと通信を行うためのクライアントをつくる。`MessengerClient`が自動生成されているので、以下のように使うことができる。(`messenger/*`が自動生成)。

```ts
import { MessengerClient } from "messenger/MessengerServiceClientPb";

export type GRPCClients = {
  messengerClient: MessengerClient;
};

export const gRPCClients = {
  messengerClient: new MessengerClient(`http://localhost:8080`)
};
```

これを以下のように使うと、メッセージの受信ができる。

```ts
import { Empty } from "google-protobuf/google/protobuf/empty_pb";

const stream$ = client.getMessages(new Empty());
// イベントは`data`以外にも、`error`、`status`、`end`が生成される。
stream$.on("data", m => {
  console.log(m)
});
```

実際はhooksの中で使うので、以下のようなコードとなる。

```ts
import { Empty } from "google-protobuf/google/protobuf/empty_pb";
import { useState, useEffect } from "react";
import { MessengerClient } from "messenger/MessengerServiceClientPb";

export const useMessages = (client: MessengerClient) => {
  const [messages, setMessages] = useState<string[]>([]);

  useEffect(() => {
    const stream$ = client.getMessages(new Empty());
    stream$.on("data", m => {
      setMessages(state => [...state, m.getMessage()]);
    });
  }, [client]);

  return {
    messages
  };
};
```

`messages`をstateとして持ち、ストリームからデータを受信するたびに`messages`を更新している。

これを表示するコンポーネントは以下のようになる。

```tsx
import React from "react";

type Props = {
  messages: string[];
};

export const Messages: React.FC<Props> = ({ messages }) => {
  return (
    <div>
      {messages.map(m => (
        <div key={m}>{m}</div>
      ))}
    </div>
  );
};
```

コンポーネントからは`messages`だけを見ることで、gRPCのロジックを切り離すことができる。(hooksを呼び出す箇所は後述)。

メッセージの投稿は以下のようにgRPCのコードを利用できる。

```ts
import { MessageRequest } from "messenger/messenger_pb";

const req = new MessageRequest();
req.setMessage(message);
client.createMessage(req, null, res => console.log(res));
```

同じようにhooksで使ってゆく。

```ts
import { MessageRequest } from "messenger/messenger_pb";
import { useState, useCallback, SyntheticEvent } from "react";
import { MessengerClient } from "messenger/MessengerServiceClientPb";

export const useMessageForm = (client: MessengerClient) => {
  const [message, setMessage] = useState<string>("");

  // メッセージ入力欄
  const onChange = useCallback(
    (event: SyntheticEvent) => {
      const target = event.target as HTMLInputElement;
      setMessage(target.value);
    },
    [setMessage]
  );

  // メッセージ投稿
  const onSubmit = useCallback(
    (event: SyntheticEvent) => {
      event.preventDefault();
      const req = new MessageRequest();
      req.setMessage(message);
      client.createMessage(req, null, res => console.log(res));
      setMessage("");
    },
    [client, message]
  );

  return {
    message,
    onChange,
    onSubmit
  };
};
```

フォームのコンポーネント

```tsx
import React from "react";
import { useMessageForm } from "containers/Messages/hooks/useMessageForm";

type Props = ReturnType<typeof useMessageForm>;

export const MessageForm: React.FC<Props> = ({
  message,
  onChange,
  onSubmit
}) => {
  return (
    <form onSubmit={onSubmit}>
      <input type="text" value={message} onChange={onChange} />
    </form>
  );
};
```

hooksを使う側は以下のようになる。

```tsx
import React from "react";
import { Messages } from "components/Messages";
import { MessageForm } from "components/MessageForm";
import { GRPCClients } from "gRPCClients";
import { useMessages } from "./hooks/useMessages";
import { useMessageForm } from "./hooks/useMessageForm";

type Props = {
  clients: GRPCClients;
};

export const MessagesContainer: React.FC<Props> = ({ clients }) => {
  const messengerClient = clients.messengerClient;
  const messagesState = useMessages(messengerClient);
  const messageFormState = useMessageForm(messengerClient);
  return (
    <div>
      <MessageForm {...messageFormState} />
      <Messages {...messagesState} />
    </div>
  );
};
```

### プロキシの設定

現時点でgRPC-Webを使うには、プロトコル間の微調整を行うためのプロキシが必要で、公式ではEnvoyを推奨していたりする。

Dockerイメージがいい感じに用意されているので、フロントエンドからはプロキシにリクエスト、プロキシコンテナはサーバーコンテナにlinkするだけである。

詳しく見たい方は以下へどうぞ。

- [Dockerfile](https://github.com/okmttdhr/grpc-web-react-hooks/blob/master/DockerfileEnvoy)
- [docker-compose.yml](https://github.com/okmttdhr/grpc-web-react-hooks/blob/master/docker-compose.yml#L21-L28)
- [envoy.yaml](https://github.com/okmttdhr/grpc-web-react-hooks/blob/master/envoy.yaml)

これで一通りの実装が完了し、`docker-compose up`でアプリケーションが起動できるようになった。

コードの全貌は[GitHub](https://github.com/okmttdhr/grpc-web-react-hooks)に。


## おわりに

gRPC-Web + React Hooks + Goを用いてリアルタイムチャットを作成してみた。

まだ制限もあるが、少なくともRESTの置き換えとしては十分候補に入れてよいのではないだろうか。また、領域を問わずコンテナベースでの開発がスタンダードになっていることを改めて実感できた。

## 参考

- [gRPC-Web + React + Node.js + TypeScriptでシンプルなチャットサービスを作る - Qiita](https://qiita.com/aanrii/items/699b4cda0babb3f47a2f)
- [3分でわかるgRPC-Web - Qiita](https://qiita.com/okmttdhr/items/fb7a8bc0c8afd4e2f4c1#%E5%8F%82%E8%80%83)