概要
分散システムを学ぶうちにgRPCに興味を持った。きくところによると、gRPC-Webというものもあるらしい。
この記事では、gRPC-Web + React Hooks + Goを用いてリアルタイムチャットを作りながら、実装の流れを書いてみようと思う。
コードだけ見たいという方は↓へ、
gRPC-Webってなんやねん?という方は↓へどうぞ!
全体像
サービスの全体像は以下のようになる。
タイトルのとおり、ReactクライアントからgRPC-WebでGoサーバーと通信するチャットサービスだ。
デモはこんな感じ↓
(リアルタイムですべてのクライアントにメッセージが配信される)
開発
Protocol Buffersの定義
まずはProtocol Buffersのインターフェイスを定義する。
前述のチャットサービスをつくるにあたって、以下のようなインターフェイスを作成する。
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
をインストールする。
もちろんローカル環境には入れたくないのでコンテナをつくっていく。
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
version: '3'
services:
proto:
command: ./proto/scripts/protoc.sh
build:
context: .
dockerfile: DockerfileProto
volumes:
- .:/proto
#!/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
するとコードが自動生成される。
バックエンドの実装
バックエンドの実装をする。
ひとつ前のステップで、以下のようなインターフェイスが自動生成されているので、これを組み合わせて実装をしてゆく。
// MessengerServer is the server API for Messenger service.
type MessengerServer interface {
GetMessages(*empty.Empty, Messenger_GetMessagesServer) error
CreateMessage(context.Context, *MessageRequest) (*MessageResponse, error)
}
まずはサーバーの雛形を書いてみる。下記のTODO
を埋めていくような流れだ。
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)
}
}
まずはメッセージの投稿だが、シンプルに、配列に時刻付きのメッセージを詰め込んでゆく形にした。
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
}
次にメッセージの取得だ。一度目のアクセスで保持しているメッセージを流し、それ以降は、新しいメッセージを検知したときのみデータを送るようにしている。
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/*
が自動生成)。
import { MessengerClient } from "messenger/MessengerServiceClientPb";
export type GRPCClients = {
messengerClient: MessengerClient;
};
export const gRPCClients = {
messengerClient: new MessengerClient(`http://localhost:8080`)
};
これを以下のように使うと、メッセージの受信ができる。
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の中で使うので、以下のようなコードとなる。
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
を更新している。
これを表示するコンポーネントは以下のようになる。
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のコードを利用できる。
import { MessageRequest } from "messenger/messenger_pb";
const req = new MessageRequest();
req.setMessage(message);
client.createMessage(req, null, res => console.log(res));
同じようにhooksで使ってゆく。
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
};
};
フォームのコンポーネント
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を使う側は以下のようになる。
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するだけである。
詳しく見たい方は以下へどうぞ。
これで一通りの実装が完了し、docker-compose up
でアプリケーションが起動できるようになった。
コードの全貌はGitHubに。
おわりに
gRPC-Web + React Hooks + Goを用いてリアルタイムチャットを作成してみた。
まだ制限もあるが、少なくともRESTの置き換えとしては十分候補に入れてよいのではないだろうか。また、領域を問わずコンテナベースでの開発がスタンダードになっていることを改めて実感できた。