GoサーバとDartクライアントではじめるgRPC

5 min読了の目安(約5000字TECH技術記事

はじめに

Go製のAPIサーバとDartクライアント(Flutterを想定)間の通信をgRPCで行う方法について書きます

日比谷音楽祭おさんぽアプリ2020 開発の裏側を語る / サーバー編というDeNAの20新卒、21卒内定者の方の記事を参考にしたので共有しておきます。

また、なぜgRPCを使うと嬉しいのかは、Cloud Native Days Tokyo 2020の南 直さんのセッション "Real World Migration from HTTP to gRPC"を聞くとわかりやすいのでこちらも共有しておきます。リンクからアーカイブ動画に飛べます。

準備

.protoファイルから自動生成するにはprotocコマンドと周辺プラグインを使用する必要がありますがローカルにいろいろインストールするのは嫌なので、Dockerコンテナを立ち上げて各言語のコードを生成します。現在のProtocolBuffersの最新バージョンはGitHubのリポジトリから確認できます

Dockerfile


FROM golang:1.15.0

ENV DEBIAN_FRONTEND=noninteractive

ARG PROTO_VERSION=3.13.0

WORKDIR /proto

COPY ./proto .

RUN mkdir /output /output/server /output/client

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

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

# Dart
RUN apt-get install apt-transport-https
RUN sh -c 'curl https://dl-ssl.google.com/linux/linux_signing_key.pub | apt-key add -'
RUN sh -c 'curl https://storage.googleapis.com/download.dartlang.org/linux/debian/dart_stable.list > /etc/apt/sources.list.d/dart_stable.list'
RUN apt-get update
RUN apt-get install dart -y
ENV PATH="${PATH}:/usr/lib/dart/bin/"
ENV PATH="${PATH}:/root/.pub-cache/bin"
RUN pub global activate protoc_plugin

このコンテナ内で以下のスクリプトを実行します

protoc.sh

#!/bin/sh

set -xe

SERVER_OUTPUT_DIR=/output/server
CLIENT_OUTPUT_DIR=/output/client

protoc --version
protoc -I=/proto/protos hoge.proto fuga.proto\
  --go_out=plugins="grpc:${SERVER_OUTPUT_DIR}" \
  --dart_out="grpc:${CLIENT_OUTPUT_DIR}"

# protoファイル内でtimestamp.protoなどをimportしたときに必要
protoc -I=/proto/protos timestamp.proto wrappers.proto\
  --dart_out="grpc:${CLIENT_OUTPUT_DIR}"

docker-composeを使用する際はcommandで、先ほどのprotoc.shを実行するように設定しておけば立ち上げ時に自動でコードが生成されるようになります。volumesでprotoファイルとprotoc.shが入ったディレクトリ、サーバ側の出力ディレクトリ、クライアント側の出力ディレクトリを同期させます。

docker-compose.yml

version: '3.8'

services:
  proto:
    build:
      context: .
      dockerfile: docker/proto/Dockerfile
    command: ./protoc.sh
    volumes:
      - ./proto:/proto
      - ./client:/output/client
      - ./server:/output/server

ただし、protoファイル側でgoパッケージを指定する場合、例えば

hoge.proto

syntax = "proto3";

package hoge.hoge;

option go_package = "hoge/fuga/foo/bar";

service Hoge{
}

だとすると、コンテナ側の/output/server/hoge/fuga/fooに出力されます。

サーバサイド(Go)

docker-compose.ymlで指定したvolumesにhoge.pb.goが生成されます。その中にClinetのInerface定義があるので(ctxでファイル内検索すると見つかりやすい)、その部分をみてメソッドを実装していきます。

hoge.pb.go

// HogeClient is the client API for Hoge service.
//
// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://godoc.org/google.golang.org/grpc#ClientConn.NewStream.
type HogeClient interface {
	CreateHoge(ctx context.Context, in *CreateHogeMessage, opts ...grpc.CallOption) (*CreateHogeResponse, error)
}

HogeClientを実装するクラスとしてHogeControllerをつくります。ここで注意なのが、第3引数以降で可変長引数を受け取るようになっていますが、実装側のメソッドではoptsは書かなくていいです。

hoge_controller.go

type HogeController struct{
}

func (ctrl HogeController) CreateHoge(ctx context.Context, in *CreateHogeMessage) (*CreateHogeResponse, error){
	// TODO: return *CreateHogeResponse, error
}

上で作ったHogeControllerのインスタンスをRegisterHogeServer()でgRPCサーバインスタンスに登録します。

main.go

func main() {
	listenPort, err := net.Listen("tcp", ":8000")
	if err != nil {
		log.Fatalln(err)
	}
	server := grpc.NewServer()

	hogeCtrl := NewHogeController()
	pb.RegisterHogeServer(server, &hogeCtrl)

	if err := server.Serve(listenPort); err != nil {
		log.Fatalf("failed to serve: %v", err)
	}
}

クライアントサイド(Dart)

クライアントはhoge.pb.dart, hoge.pbgrpc.dart, hoge.pbjson.dart, enumを使っている場合はhoge.pbenum.dartが生成されます。

protoファイル側でtimestamp.dartを使用した場合は生成されたパスではエラーになるので単純にhoge.pb.dart内のimport文をimport 'timestamp.pb.dart' as $1;のように変更すると使用できます。Unary Callの場合は以下のような関数で通信できます。

hoge.dart

Future<void> createHoge(dartSideParam1, dartSideParam2, dartSideParam3) async {
    final channel = ClientChannel('localhost',
        port: 8000,
        options:
            const ChannelOptions(credentials: ChannelCredentials.insecure()));
    final grpcClient = KitchenClient(channel,
        options: CallOptions(timeout: Duration(seconds: 10)));
    try {
      await grpcClient.createHoge(CreateHogerMessage()
        ..param1 = dartSideParam1
        ..param2 = dartSideParam2
        ..param3 = dartSideParam3;
      await channel.shutdown();
    } catch (error) {
      developer.log('Caught error: $error');
      await channel.shutdown();
      return Future.error(error);
    }
}

まとめ

gRPCを使用してGoとDart間を通信する方法でした。業務ではServer Streaming方式の通信を使用して実装をしているところもあるので、後日その情報も共有していきたいです。