Go言語で簡単なgRPCサーバーを作成

この記事はGo言語でgRPCサーバーを作る上で学んだことをまとめています。サンプルプロジェクトとしてシンプルな単行RPC(Unary RPC)サーバーとそれを呼び出すコマンドラインのクライアントを作成していきます。
2021.09.28

はじめに

この記事はGo言語でgRPCサーバーを作る上で学んだことをまとめています。
サンプルプロジェクトとしてシンプルな単行RPC(Unary RPC)サーバーとそれを呼び出すコマンドラインのクライアントを作成していきます。
※この記事は既にGo言語の開発環境をセットアップ済みで基本的な文法を学習済みの方を想定しています。

動作環境

今回使用した動作環境は以下のとおりです。

  • PC : Mac M1(Apple Silicon)チップ
  • OS : macOS Big Sir 11.5.2
  • Go : 1.17.1

gRPCとは

gRPCはGoogleの作ったRPCのフレームワークです。 以下の3つの利点があります。

  • HTTP/2による高速な通信が可能。
  • Protocol Buffersによるスキーマファーストの開発。protoファイルというIDLからコードの自動生成が可能。
  • 様々なストリーミング方式の通信が可能。

以下の4つの通信方式があります。

  • 単行RPC(Unary RPC): 1つのリクエストに対して1つのレスポンスを返却する、シンプルな通信方式。
  • サーバーストリーミングRPC: 1つのリクエストに対してサーバー側から複数のレスポンスを返却する通信方式。
  • クライアントストリーミングRPC: クライアントがサーバーに複数のリクエストを送る通信方式。サイズの大きいファイルのアップロードなどに利用される。
  • 双方向ストリーミングRPC: クライアントとサーバーの双方がデータを送り合う通信方式。チャットなどのリアルタイム処理に利用される。

サンプルプロジェクト

このサンプルプロジェクトはシンプルな単行RPC(Unary RPC)です。
ジャンケンで遊べる簡単なサーバーとそれを呼び出すコマンドラインのクライアントの2つを作成します。
サーバーは、ジャンケンを行うAPIと対戦結果の履歴が取得できるAPIの2つのAPIを提供します。
ここでは一部コードを抜粋して説明していきます。全ファイルは私のGithubリポジトリをご参照ください。

プロジェクト構成

このサンプルプロジェクトの最終的なプロジェクト構成は下記になります。

一部抜粋

rock-paper-scissors/  ルートディレクトリ
  ┣ cmd/
  ┃  ┣ api/
  ┃  ┃  ┗ main.go ・・・ サーバー側メイン処理
  ┃    ┗ cli/
  ┃      ┗ main.go ・・・ クライアント側メイン処理
  ┣  pb/
  ┃  ┣ rock-paper-scissors.pb.go ・・・ protoファイルから自動生成されたリクエスト/レスポンス部分のコード
  ┃ ┗ rock-paper-scissors_grpc.pb.go ・・・ protoファイルから自動生成されたサービス部分のコード 
  ┣  proto/
  ┃ ┗ rock-paper-scissors.proto ・・・ このサンプルプロジェクトのprotoファイル 
  ┣ service/
  ┃  ┣ client.go ・・・ gRPCでサーバー側のAPIを呼び出す
  ┃  ┗ server.go ・・・ サービスの各メソッドを実装しgRPCを処理する
  ┣ go.mod
  ┗ go.sum

protoファイル

protoファイルはgRPCのAPI定義を定義するファイルです。
このファイルをもとに、各プログラミング言語用のコードが自動生成されます。
protoファイルは仕様書の役目を果し、自動生成されたコードは静的型付けによりAPIの提供元と呼び出し側の両方の生産性を向上させます。

下記がこのサンプルプロジェクトのprotoファイルです。/protoフォルダ配下に作成してください。

rock-paper-scissors.proto

// protoのバージョンです。
syntax = "proto3";

// メッセージ型などの名前の衝突を避けるためにパッケージ名を指定します。
package game;

// コードが自動生成されるディレクトリを指定しています。
option go_package = "pb/";

// 他のパッケージのメッセージ型をインポートできます。
// ここではWell Known Typesと呼ばれるGoogle提供のメッセージ型を使用します。
import "google/protobuf/timestamp.proto";

// APIにおけるサービスを定義
service RockPaperScissorsService {
  // ジャンケンを行います。
  rpc PlayGame (PlayRequest) returns (PlayResponse) {}
  // 対戦結果の履歴を確認します。
  rpc ReportMatchResults (ReportRequest) returns (ReportResponse) {}
}

// enumでグー、チョキ、パーを定義。
enum HandShapes {
  HAND_SHAPES_UNKNOWN  = 0;
  ROCK  = 1;
  PAPER  = 2;
  SCISSORS  = 3;
}

// enumで勝敗とあいこを定義
enum Result {
  RESULT_UNKNOWN  = 0;
  WIN  = 1;
  LOSE  = 2;
  DRAW  = 3;
}

// 型にはスカラー型とメッセージ型の2つがあります。
// スカラー型は数値、文字列、真偽値などがあり、メッセージ型は複数のフィールドを持つことができます。

// 対戦結果のメッセージ型です。
message MatchResult {
  HandShapes yourHandShapes = 1;
  HandShapes opponentHandShapes = 2;
  Result result = 3;
  google.protobuf.Timestamp create_time = 4;
}

// 今までの試合数、勝敗と対戦結果の履歴を持つメッセージ型です。
message Report {
  int32 numberOfGames = 1;
  int32 numberOfWins = 2;
  // `repeated`を付けることで配列を表現できます。
  repeated MatchResult matchResults = 3;
}

// PlayGameメソッドのリクエスト用のメッセージ型
message PlayRequest {
  HandShapes handShapes = 1;
}

// PlayGameメソッドのレスポンス用のメッセージ型
message PlayResponse {
  MatchResult matchResult = 1;
}

// ReportMatchResultsメソッドのリクエスト用のメッセージ型
message ReportRequest {}

// ReportMatchResultsメソッドのレスポンス用のメッセージ型
message ReportResponse {
    Report report = 1;
}

その他にprotoファイルに関してはProtocol Buffers Language Guideをご参照ください。

プロジェクトの作成とコード自動生成手順

1. ルートディレクトリ直下で下記コマンドを実行しプロジェクトを作成します。

% mkdir rock-paper-scissors
% cd ./rock-paper-scissors
% go mod init {Repository FQDN}/{Repository Path}/rock-paper-scissors

2. Go言語のgRPCライブラリを追加します。

% go get -u google.golang.org/grpc

3. protoファイルのコンパイルコマンドをインストールします。

% brew install protobuf

4. Go言語のコード自動生成用プラグインをインストールします。

% go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.26
% go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.1

私はここで少し詰まったので補足します。
Go言語のProtocol Buffers用のAPIはv1とv2で動きが違うようです。※詳しくはProtocol Buffers用 Go言語APIの APIv1 と APIv2 の差異の記事を参照ください。
サービスのコードが自動生成されずに迷いましたが、google.golang.org/grpc/cmd/protoc-gen-go-grpcをインストールすることで解決しました。

5. protoフォルダ配下にrock-paper-scissors.protoを配置後、下記コマンドを実行してください。/pb配下にコードが自動生成されます。

% protoc --go_out=. --go-grpc_out=require_unimplemented_servers=false:. ./proto/rock-paper-scissors.proto

--go_outオプションはリクエスト/レスポンス部分のコードの出力先を、--go-grpc_outオプションはサービス部分のコードの出力先を指定しています。
require_unimplemented_servers=falsemustEmbedUnimplemented*** methodというメソッドが自動生成されないための指定です。

サーバー側ソースコードの説明

cmd/api/main.go

package main

import (
	"fmt"
	"log"
	"net"

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

	"github.com/hoge/rock-paper-scissors/pb"
	"github.com/hoge/rock-paper-scissors/service"
)

func main() {
	// 起動するポート番号を指定しています。
	port := 50051
	listenPort, err := net.Listen("tcp", fmt.Sprintf(":%d", port))
	if err != nil {
		log.Fatalf("failed to listen: %v", err)
	}

	// gRPCサーバーの生成
	server := grpc.NewServer()
	// 自動生成された関数に、サーバと実際に処理を行うメソッドを実装したハンドラを設定します。
	// protoファイルで定義した`RockPaperScissorsService`に対応しています。
	pb.RegisterRockPaperScissorsServiceServer(server, service.NewRockPaperScissorsService())

	// サーバーリフレクションを有効にしています。
	// 有効にすることでシリアライズせずとも後述する`grpc_cli`で動作確認ができるようになります。
	reflection.Register(server)
	// サーバーを起動
	server.Serve(listenPort)
}

service/server.go

package service

import (
	"context"
	"math/rand"
	"time"

	"github.com/golang/protobuf/ptypes/timestamp"
	"google.golang.org/grpc/codes"
	"google.golang.org/grpc/status"

	"github.com/hoge/rock-paper-scissors/pb"
	"github.com/hoge/rock-paper-scissors/pkg"
)

func init() {
	rand.Seed(time.Now().UnixNano())
}

// DBを使わずに対戦結果の履歴を表示できるように構造体にデータを保持しています。
type RockPaperScissorsService struct {
	numberOfGames int32
	numberOfWins  int32
	matchResults  []*pb.MatchResult
}

// `RockPaperScissorsService`を生成し返却するコンストラクタです。
// `RockPaperScissorsService`は`PlayGame`と`ReportMatchResults`メソッドを実装しています。
func NewRockPaperScissorsService() *RockPaperScissorsService {
	return &RockPaperScissorsService{
		numberOfGames: 0,
		numberOfWins:  0,
		matchResults:  make([]*pb.MatchResult, 0),
	}
}

// 自動生成された`rock-paper-scissors_grpc.pb.go`の
// `RockPaperScissorsServiceServer`インターフェースを実装しています。
func (s *RockPaperScissorsService) PlayGame(ctx context.Context, req *pb.PlayRequest) (*pb.PlayResponse, error) {
	if req.HandShapes == pb.HandShapes_HAND_SHAPES_UNKNOWN {
		return nil, status.Errorf(codes.InvalidArgument, "Choose Rock, Paper, or Scissors.")
	}

	// ランダムに1~3の数値を生成し相手の手とし、`HandShapes`のenumに変換しています。
	opponentHandShapes := pkg.EncodeHandShapes(int32(rand.Intn(3) + 1))

	// ジャンケンの勝敗を決めています。
	var result pb.Result
	if req.HandShapes == opponentHandShapes {
		result = pb.Result_DRAW
	} else if (req.HandShapes.Number()-opponentHandShapes.Number()+3)%3 == 1 {
		result = pb.Result_WIN
	} else {
		result = pb.Result_LOSE
	}

	
	now := time.Now()
	// 自動生成された型を元に対戦結果を生成
	matchResult := &pb.MatchResult{
		YourHandShapes:     req.HandShapes,
		OpponentHandShapes: opponentHandShapes,
		Result:             result,
		CreateTime: ×tamp.Timestamp{
			Seconds: now.Unix(),
			Nanos:   int32(now.Nanosecond()),
		},
	}

	// 試合数を1増やし、プレイヤーが勝利した場合は勝利数も1増やします。
	s.numberOfGames = s.numberOfGames + 1
	if result == pb.Result_WIN {
		s.numberOfWins = s.numberOfWins + 1
	}
	s.matchResults = append(s.matchResults, matchResult)

	// 自動生成されたレスポンス用のコードを使ってレスポンスを作り返却しています。
	return &pb.PlayResponse{
		MatchResult: matchResult,
	}, nil
}

// 自動生成された`rock-paper-scissors_grpc.pb.go`の
// `RockPaperScissorsServiceServer`インターフェースを実装しています。
func (s *RockPaperScissorsService) ReportMatchResults(ctx context.Context, req *pb.ReportRequest) (*pb.ReportResponse, error) {
	// 自動生成されたレスポンス用のコードを使ってレスポンスを作り返却しています。
	return &pb.ReportResponse{
		Report: &pb.Report{
			NumberOfGames: s.numberOfGames,
			NumberOfWins:  s.numberOfWins,
			MatchResults:  s.matchResults,
		},
	}, nil
}

pkg/enum_converter.go

package pkg

import "github.com/hoge/rock-paper-scissors/pb"

func EncodeHandShapes(n int32) pb.HandShapes {
	switch n {
	case 1:
		return pb.HandShapes_ROCK
	case 2:
		return pb.HandShapes_PAPER
	case 3:
		return pb.HandShapes_SCISSORS
	default:
		return pb.HandShapes_HAND_SHAPES_UNKNOWN
	}
}

サーバー動作確認

デバック用のツールgrpc_cliをインストールします。

% brew tap grpc/grpc
% brew install grpc

ここまでのファイルを配置してもらうとルートディレクトリ直下で下記コマンドを実行するとサーバーが起動します。

% go run ./cmd/api

別のターミナルを立ち上げ、下記コマンドでgRPCサーバーが起動しているか確認します。起動に成功しているとサービスのメソッドがサーバーから返却されます。

% grpc_cli ls localhost:50051 game.RockPaperScissorsService
PlayGame
ReportMatchResults

実際にジャンケンをやってみます。1:グーを指定してPlayGameメソッドを呼び出します。

% grpc_cli call localhost:50051 game.RockPaperScissorsService.PlayGame 'handShapes: 1'
connecting to localhost:50051
matchResult {
  yourHandShapes: ROCK
  opponentHandShapes: SCISSORS
  result: WIN
  create_time {
    seconds: 1632749016
    nanos: 862928000
  }
}
Rpc succeeded with OK status

ReportMatchResultsメソッドを呼び出し対戦結果を確認します。

%  grpc_cli call localhost:50051 game.RockPaperScissorsService.ReportMatchResults ''
connecting to localhost:50051
report {
  numberOfGames: 1
  numberOfWins: 1
  matchResults {
    yourHandShapes: ROCK
    opponentHandShapes: SCISSORS
    result: WIN
    create_time {
      seconds: 1632749016
      nanos: 862928000
    }
  }
}
Rpc succeeded with OK status

クライアント側ソースコードの説明

次に実際にgRPCのAPIを呼び出すコマンドラインのクライアント側のソースコードを説明します。

cmd/cli/main.go

package main

import (
	"bufio"
	"fmt"
	"os"
	"strconv"

	"github.com/hoge/rock-paper-scissors/service"
)

func main() {
	fmt.Println("start Rock-paper-scissors game.")
	scanner := bufio.NewScanner(os.Stdin)

	for {
		fmt.Println("1: play game")
		fmt.Println("2: show match results")
		fmt.Println("3: exit")
		fmt.Print("please enter >")

		scanner.Scan()
		in := scanner.Text()

		switch in {
		case "1":
			fmt.Println("Please enter Rock, Paper, or Scissors.")
			fmt.Println("1: Rock")
			fmt.Println("2: Paper")
			fmt.Println("3: Scissors")
			fmt.Print("please enter >")

			scanner.Scan()
			in = scanner.Text()
			switch in {
			case "1", "2", "3":
				handShapes, _ := strconv.Atoi(in)
				// この関数内で`PlayGame`メソッドを呼び出します。
				service.PlayGame(int32(handShapes))
			default:
				fmt.Println("Invalid command.")
				continue
			}
			continue
		case "2":
			fmt.Println("Here are your match results.")
			// この関数内で`ReportMatchResults`メソッドを呼び出します。
			service.ReportMatchResults()
			continue
		case "3":
			fmt.Println("bye.")
			goto M
		default:
			fmt.Println("Invalid command.")
			continue
		}
	}
M:
}

service/client.go

package service

import (
	"context"
	"fmt"
	"log"
	"time"

	"google.golang.org/grpc"

	"github.com/hoge/rock-paper-scissors/pb"
	"github.com/hoge/rock-paper-scissors/pkg"
)

func PlayGame(handShapes int32) {
	address := "localhost:50051"
	conn, err := grpc.Dial(
		address,
		
		grpc.WithInsecure(),
		grpc.WithBlock(),
	)
	if err != nil {
		log.Fatal("Connection failed.")
		return
	}
	defer conn.Close()

	ctx, cancel := context.WithTimeout(
		context.Background(),
		time.Second,
	)
	defer cancel()

	// 自動生成されたコードからgRPCのクライアントとリクエストを生成します。
	client := pb.NewRockPaperScissorsServiceClient(conn)
	playRequest := pb.PlayRequest{
		HandShapes: pkg.EncodeHandShapes(handShapes),
	}

	// gRPCサーバーの`PlayGame`メソッドを呼び出します。
	reply, err := client.PlayGame(ctx, &playRequest)
	if err != nil {
		log.Fatal("Request failed.")
		return
	}

	// レスポンスを標準出力に表示します。
	marchResult := reply.GetMatchResult()
	fmt.Println("***********************************")
	fmt.Printf("Your hand shapes: %s \n", marchResult.YourHandShapes.String())
	fmt.Printf("Opponent hand shapes: %s \n", marchResult.OpponentHandShapes.String())
	fmt.Printf("Result: %s \n", marchResult.Result.String())
	fmt.Println("***********************************")
	fmt.Println()
}

func ReportMatchResults() {
	address := "localhost:50051"
	conn, err := grpc.Dial(
		address,
		grpc.WithInsecure(),
		grpc.WithBlock(),
	)
	if err != nil {
		log.Fatal("Connection failed.")
		return
	}
	defer conn.Close()

	ctx, cancel := context.WithTimeout(
		context.Background(),
		time.Second,
	)
	defer cancel()

	// 自動生成されたコードからgRPCのクライアントとリクエストを生成します。
	client := pb.NewRockPaperScissorsServiceClient(conn)
	reportRequest := pb.ReportRequest{}

	// gRPCサーバーの`ReportMatchResults`メソッドを呼び出します。
	reply, err := client.ReportMatchResults(ctx, &reportRequest)
	if err != nil {
		log.Fatal("Request failed.")
		return
	}

	// レスポンスを標準出力に表示します。
	report := reply.GetReport()
	if len(report.MatchResults) == 0 {
		fmt.Println("***********************************")
		fmt.Println("There are no match results.")
		fmt.Println("***********************************")
		fmt.Println()
		return
	}

	fmt.Println("***********************************")
	for k, v := range report.MatchResults {
		fmt.Println(k + 1)
		fmt.Printf("Your hand shapes: %s \n", v.YourHandShapes.String())
		fmt.Printf("Opponent hand shapes: %s \n", v.OpponentHandShapes.String())
		fmt.Printf("Result: %s \n", v.Result.String())
		fmt.Printf("Datetime of match: %s \n", v.CreateTime.AsTime().In(time.FixedZone("Asia/Tokyo", 9*60*60)).Format(time.ANSIC))
		fmt.Println()
	}

	fmt.Printf("Number of games: %d \n", reply.GetReport().NumberOfGames)
	fmt.Printf("Number of wins: %d \n", reply.GetReport().NumberOfWins)
	fmt.Println("***********************************")
	fmt.Println()
}

その他google.golang.org/grpcに関してはgRPC-Goをご参照ください。

クライアント動作確認

ルートディレクトリ直下で下記コマンドを実行するとクライアントが起動します。

% go run ./cmd/cli
start Rock-paper-scissors game.
1: play game
2: show match results
3: exit

1を入力するとジャンケンが行なえます。

please enter >1
Please enter Rock, Paper, or Scissors.
1: Rock
2: Paper
3: Scissors

ジャンケンの手を選ぶと対戦結果が表示されます。

please enter >1
***********************************
Your hand shapes: ROCK
Opponent hand shapes: PAPER
Result: LOSE
***********************************

2を入力すると対戦結果の履歴が表示されます。

1: play game
2: show match results
3: exit
please enter >2
Here are your match results.
***********************************
1
Your hand shapes: ROCK
Opponent hand shapes: PAPER
Result: LOSE
Datetime of match: Tue Sep 28 02:56:47 2021

2
Your hand shapes: PAPER
Opponent hand shapes: ROCK
Result: WIN
Datetime of match: Tue Sep 28 02:56:52 2021

3
Your hand shapes: SCISSORS
Opponent hand shapes: SCISSORS
Result: DRAW
Datetime of match: Tue Sep 28 02:56:55 2021

Number of games: 3
Number of wins: 1
***********************************

3を入力すると終了します。

1: play game
2: show match results
3: exit
please enter >3                  
bye.

最後に

gRPCは高速な通信ができるというイメージが先行していましたが、実際に勉強してみるとprotoファイルによるAPI定義とコードの自動生成の方に利点を感じました。
今回はシンプルな単行RPC(Unary RPC)でしたが、次回以降ストリーミング方式のgRPCのサンプルプロジェクトも作成したいと思います。