LoginSignup
94
69

More than 3 years have passed since last update.

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

Last updated at Posted at 2020-03-14

概要

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

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

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

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

全体像

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

Untitled Diagram.png

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

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

Image from Gyazo

開発

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-goprotoc-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
docker-compose.yml
version: '3'
services:
  proto:
    command: ./proto/scripts/protoc.sh
    build:
      context: .
      dockerfile: DockerfileProto
    volumes:
      - .:/proto
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するとコードが自動生成される。

バックエンドの実装

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

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

// 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の置き換えとしては十分候補に入れてよいのではないだろうか。また、領域を問わずコンテナベースでの開発がスタンダードになっていることを改めて実感できた。

参考

94
69
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
94
69