Tags: gRPC Go Golang

Go 言語で gRPC をはじめてみる - 環境構築編

かなり前に gRPC-web を試してみた覚えがあるのだが、そもそも基本の gRPC がよくわかっていないので基本の gRPC をやってみる。
そのための環境構築を Docker Compose で行う。

開発環境

  • Windows 10 Pro バージョン20H2
  • docker desktop 3.3.1
  • WSL2 のディストリビューションは Ubuntu 20.04

gRPC とは?

何はともあれ公式サイトを見てみる

https://grpc.io/

が、さくらインターネットさんが日本語で解説記事を出しているのでそれを見るほうが楽かもしれない。

サービス間通信のための新技術「gRPC」入門

要するに「サーバー側の関数をクライアントから叩く」ために色々方式があり、その中の一つが gRPC である。
という程度の理解でもとりあえずやってみることに支障はないので、とりあえずやってみる。

一応公式サイトにクイックスタートがあるのだが

https://grpc.io/docs/languages/go/quickstart/

残念ながら最新の Go 言語のやり方でない部分が残っている。具体的には export GO111MODULE=on はもういらないはずである。
あと私は全部 Docker 上で構築したいので、このクイックスタートではよろしくない。
ということで環境構築からやっていく。

環境構築

何のために何を使うのか

世間の gRPC をやってみる系記事を散々見たのだがよくわからなかった原因の一つに「何のために何を使うかがわからない」というのがある。
自分のローカル環境上で全部やろうとすると、全部のパッケージをローカル環境に入れることになるので、何をどれに使ってるのかがよくわからないのだ。
私は Docker で環境構築するにあたってここで詰まったので、最初にそれを説明する。

まず今回、サーバー環境とクライアント環境は両方 Go 言語で書くとして必要な環境は 3 つある。

(1) Protocol Buffers をコンパイルしてGo 言語用のコードを生成する環境
必要なもの

  • Go 実行環境
  • Protocol buffer compiler, protoc, version 3
  • Go plugins for the protocol compiler (protoc-gen-go, protoc-gen-go-grpc)

(2) gRPC のサーバー環境
必要なもの

  • Go 実行環境

(3) gRPC のクライアント環境
必要なもの

  • Go 実行環境

以上。環境 (2) (3) では Protocol Buffers をコンパイルできる必要はない。

上記 3 つの環境をそれぞれ Docker で構築し、Docker Compose でまとめて起動することにする。

今回のディレクトリ構成は完成品が gitlab にあるのでそちらを参照。
今回は上記完成品が ~/grpc_test の下にあるとして説明を書く。

環境 (1) 構築

環境 (1) では下記を実施する。

  1. Protocol Buffers のスキーマ定義 (.proto) から Go 言語用のコードを生成する
  2. 手順1. で作成したコードをサーバー環境とクライアント環境の所定の位置に配置する

手順1. で作成したコードを GitHub とかにあげておくならサーバー環境・クライアント環境からは普通にパッケージを import すればいいと思うが、今回はそうしない。
.proto をコンパイルした結果のファイルはサーバー環境・クライアント環境のローカルパッケージとして import したい。
だが .proto ファイルはサーバー環境・クライアント環境で共通なので、サーバー環境・クライアント環境でそれぞれ同一の .proto を持って個別にコンパイルするのは無駄である。
よって環境 (1) でコンパイルしたファイルをサーバー環境・クライアント環境へコピーすることにした。

ということでまずは .proto をコンパイルする環境のための Dockerfile を ~/grpc_test/proto/build/package/Dockerfile に生成する。
中身は下記の通り。

# 2021/04/25 現在の最新バージョン
# alpine だとapt-getが使えなくて面倒なので buster を使う
FROM golang:1.16.3-buster

RUN apt-get update && apt-get install -y protobuf-compiler
RUN go get -u google.golang.org/protobuf/cmd/protoc-gen-go
RUN go get -u google.golang.org/grpc/cmd/protoc-gen-go-grpc

WORKDIR /go/src

ポイントとしては alpine ベースではなく buster ベースだということである。
alpine は apt-get が使えない。
なので protobuf-compiler をインストールするためにソースからビルドしないといけないのだが、正直面倒だし、実際に商業的にやるとしたらそんなことしないと思う。
よって大人しく apt-get が使える buster ベースにして apt-get で入れることにした。

protobuf-compiler を入れたら、Go 言語用のプラグインである protoc-gen-go と protoc-gen-go-grpc も入れておく。

次にコンパイル対象を ~/grpc_test/proto/api/helloworld.proto に作成する。
中身は公式サイトの クイックスタート にちょっと手を入れたもので、下記の通り。

syntax = "proto3";

package api;

option go_package = "github.com/k1350/proto/api";

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

手を入れたのは packageoption go_package である。
Go 言語用にコンパイルするにあたり option go_package をファイル内で指定するかコンパイル時に引数で指定しないとエラーになる。

今回コンパイル後のファイルは
サーバー環境では “github.com/k1350/server/api”
クライアント環境では “github.com/k1350/client/api”
として import したいのだが、どうやって実現しようかと試した結果、たぶん packageoption go_package の末尾が合ってればなんでもよさそうという結論に至った。

そのため
package api;
option go_package = "github.com/k1350/proto/api";
と指定している。

次に .proto をコンパイルしてからファイルをコピーするコマンドを毎回打ちたくないのでシェルを作った。

コンパイルしてファイルをコピーする ~/grpc_test/proto/scripts/make_proto.sh

#!/bin/sh
protoc --go_out=. --go_opt=paths=source_relative \
    --go-grpc_out=. --go-grpc_opt=paths=source_relative \
    proto/api/helloworld.proto

cp ./proto/api/* ./server/api
cp ./proto/api/* ./client/api

あと念のため毎回コードを消すための ~/grpc_test/proto/scripts/clean_proto.sh

#!/bin/sh
rm -f ./proto/api/*.pb.go
rm -f ./proto/api/*.go

rm -f ./server/api/*

rm -f ./client/api/*

続けてこれらを起動する Compose ファイルだが、次のようにした(抜粋)。

proto:
    container_name: proto
    image: grpc_proto:1.0
    build: ./proto/build/package
    volumes:
        - ./:/go/src/
    tty: true
    command:
        sh -c "chmod -R +x proto/scripts/ && proto/scripts/clean_proto.sh && proto/scripts/make_proto.sh && /bin/sh"

注意点は volumes で ~/grpc_test/proto 配下ではなく ~/grpc_test 配下全体を共有していること。
こうしないと .proto ファイルをコンパイルした結果のファイルをサーバー環境(~/grpc_test/server)・クライアント環境(~/grpc_test/client)に配置できない。
あと command でシェルを動かすが、一番最後に && /bin/sh と書いておかないと tty: true を指定していてもコンテナが終了してしまう。

環境 (2) 構築

サーバー環境については、まず Dockerfile は protobuf-compiler をインストールしなくていいので alpine ベースでいい。

# 2021/04/25 現在の最新バージョン
FROM golang:1.16.3-alpine

WORKDIR /go/src/server

EXPOSE 50051

今回は 50051 ポートを使うので開けておく必要がある。

~/grpc_test/server/cmd/main.go の中身は公式サイトの クイックスタート にちょっと手を入れたもので、下記の通り。

package main

import (
	"context"
	"log"
	"net"

	"google.golang.org/grpc"
	pb "github.com/k1350/server/api"
)

const (
	port = ":50051"
)

// server is used to implement helloworld.GreeterServer.
type server struct {
	pb.UnimplementedGreeterServer
}

// SayHello implements helloworld.GreeterServer
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
	log.Printf("Received: %v", in.GetName())
	return &pb.HelloReply{Message: "Hello " + in.GetName()}, nil
}

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

続けて Compose ファイルだが次のようにした(抜粋)。

server:
    container_name: server
    image: grpc_server:1.0
    build: ./server/build/package
    volumes:
        - ./server:/go/src/server
    ports:
        - "50051:50051"
    tty: true
    depends_on:
        - proto
    command:
        sh -c "rm -f go.mod go.sum && go mod init github.com/k1350/server && go mod tidy && /bin/sh"

注意点は ports でホスト側の 50051 とコンテナの 50051 をつなげている点と、depends_on で サービス名:proto の後に起動するようにしている点と、command で毎回 go mod initgo mod tidy をやっている点である。
go mod init とかは別に毎回やらなくていいが、今回 gitlab へ完成品を置くにあたって、誰でも間違いなく起動できるようにこうした。

環境 (3) 構築

Dockerfile は protobuf-compiler をインストールしなくていいので alpine ベースでいい。

# 2021/04/25 現在の最新バージョン
FROM golang:1.16.3-alpine

WORKDIR /go/src/client

~/grpc_test/client/cmd/main.go の中身は公式サイトの クイックスタート にちょっと手を入れたもので、下記の通り。

package main

import (
	"context"
	"log"
	"os"
	"time"

	"google.golang.org/grpc"
	pb "github.com/k1350/client/api"
)

const (
	address     = "server:50051"
	defaultName = "world"
)

func main() {
	// Set up a connection to the server.
	conn, err := grpc.Dial(address, grpc.WithInsecure(), grpc.WithBlock())
	if err != nil {
		log.Fatalf("did not connect: %v", err)
	}
	defer conn.Close()
	c := pb.NewGreeterClient(conn)

	// Contact the server and print out its response.
	name := defaultName
	if len(os.Args) > 1 {
		name = os.Args[1]
	}
	ctx, cancel := context.WithTimeout(context.Background(), time.Second)
	defer cancel()
	r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
	if err != nil {
		log.Fatalf("could not greet: %v", err)
	}
	log.Printf("Greeting: %s", r.GetMessage())
}

注意点は address = "server:50051" の部分。
クイックスタートのサンプルでは address = "localhost:50051" となっているが、今回は localhost:50051 ではサーバー環境にアクセスできない。
Docker Compose のデフォルトのネットワークではサービス名で名前解決できるようになっているので、“server:50051” とすればサーバー環境の 50051 ポートにアクセス可能である。

続けて Compose ファイルだが次のようにした(抜粋)。

client:
    container_name: client
    image: grpc_client:1.0
    build: ./client/build/package
    volumes:
        - ./client:/go/src/client
    tty: true
    depends_on:
        - proto
    command:
        sh -c "rm -f go.mod go.sum && go mod init github.com/k1350/client && go mod tidy && /bin/sh"

注意点はポートが開いていない以外はサーバー環境と同じである。

起動

完成品 の README.md に起動方法が書いてある。

完成品を clone し、README.md の通りに実行するとサーバー環境とクライアント環境で通信できるはず。

今回は環境構築のみなのでこれで終了。