Developer's Blog

gRPC(Go) で API を実装する

gRPC logo

こんにちは。BoltzEngine を担当している門多です。REST な API を設計していると、たまにリソースと機能をマッピングするところで困ることはないでしょうか。または、サーバーとクライアントのコードで同じモデルを定義したりと面倒だなと思ったことはないでしょうか。今日は BoltzEngine で使っている gRPC フレームワークをご紹介します。

gRPC とは

gRPC は Google によって作られた新しい RPC フレームワークです。Protocol Buffers でエンコードされたデータを HTTP/2 でやり取りします。RPC メソッドを提供する gRPC サーバーと、メソッドを呼び出す gRPC クライアントといった 2 つの役割があります。

gRPC を使った開発は、メソッドとメッセージ型 (オブジェクト) をプログラミング言語に依存しない Protocol Buffers(.proto) で定義しておき、上記の定義ファイルをもとに、各種プログラミング言語のコードを生成します。2016 年 10 月現在、gRPC は以下の言語に対応しています。

  • C++
  • Java
  • Python (3系)
  • Go
  • Ruby
  • C#
  • Node.js
  • Android Java
  • Objective-C
  • PHP

gRPC サーバーと gRPC クライアントは別の言語で実装しても構いません。例えばサーバー側は効率のために Go で書いて、Python で実装したクライアントからサーバーへアクセスする場合でも、RPC で交換するメッセージのモデルは自動生成されるため、実装や維持のコストは同じ言語を使う場合とそれほど変わりません。

Protocol Buffers コンパイラの準備

Protocol Buffers を各種言語にコンパイルするため、Protocol Buffers コンパイラと、ターゲット言語用のランタイムが必要です。公式情報の通りにコマンドを実行すればインストールできます。Go の場合は以下のようになります。

# protocのインストール(以下どちらか)
$ brew install protobuf		# Homebrew を使っている場合
$ nix-env -i protobuf-3.0.0	# Nixpkgs を使っている場合

# Go 用の Protocol Buffers ランタイムをインストール
$ go get -u github.com/golang/protobuf/protoc-gen-go

protoc は、必ず 3.0 以上をインストールしてください。2.x では、gRPC 用の proto ファイルがコンパイルできません。

プロトコル定義ファイルを書く

上記の通り gRPC を使った開発では、メソッドとメッセージを Protocol Buffers で用意する必要があります。

syntax = "proto3";

package mbox;

service Mail {
	// メールを送信する。
	rpc Send (Message) returns (Result);

	// 新着メールを受信する。
	rpc Receive (Folder) returns (stream Message);
}

1 行目の syntax = "proto3" は、Protocol Buffers のバージョンを指定します。現在は proto3 を指定しておけば良いでしょう。

service ブロックは gRPC が提供するサービスを定義します。このブロックの中で、サービスに必要なメソッドを記述すると、それが gRPC のメソッドとして扱われるようになります。例えば rpc Send (Message) returns (Result); が一つのメソッドです。上記の例では、SendReceive の 2 つを定義しています。

メソッド名すぐ後ろの括弧は、リクエストメッセージの型を表します。returns の後ろにある括弧が、レスポンスメッセージの型です。

gRPC のメソッドは、リクエストとレスポンスそれぞれにおいて、シンプルなメッセージなのか、ストリームなのかを選ぶことができ、その組み合わせは以下の 4 つに分類されます。

リクエストとレスポンスは 1 回ずつ (Simple RPC)

シンプルな、リクエスト 1 回に対してレスポンスが 1 回のパターンです。このタイプのメソッドは、定義はどちらも stream が付きません。

rpc MethodName (Message) returns (Reply);

リクエスト 1 回につき複数のレスポンス (Server-side streaming RPC)

クライアントからのリクエストをきっかけにして、サーバーから複数のレスポンスを送信するパターンです。任意の数だけレスポンスを送り切ったら、サーバー側からクライアントへレスポンスの終わりを伝えます。このタイプは、レスポンス側を stream とします。

rpc MethodName (Message) returns (stream Reply);

複数のリクエストと 1 回のレスポンス (Client-side streaming RPC)

上記とは逆に、クライアントから複数回のリクエストを送信し、全て送り終わった後にサーバーからのレスポンスが 1 度送られてくるパターンです。リクエストの側が stream となり、レスポンスには付きません。

rpc MethodName (stream Message) returns (Reply);

複数のリクエストと複数のレスポンス (Bidirectional streaming RPC)

クライアントもサーバーも、複数回データを送受信するパターンです。リクエスト、レスポンス共に stream を付けると、これになります。

rpc MethodName (stream Message) returns (stream Reply);

メッセージ型の定義

gRPC メソッドで交換されるメッセージ型も、Protocol Buffers で定義しておく必要があります。

message Message {
	string from = 1;
	repeated string to = 2;
	string subject = 3;
	oneof body {
		string text = 4;
		string html = 5;
	}
}

message Result {
}

message Folder {
	string name = 1;
}

それぞれのメッセージ型は message ブロックの中に記述します。ひとつのフィールドは、型とフィールド名とタグ (1 以上の整数) で構成されますが、= の後にあるタグはとても重要な意味を持っています。

タグは、重複させることはできませんし、一度リリースしたフィールドのタグも変更してはいけません。たとえフィールドが削除された場合でも、必ず、過去に使ったことのある値は再利用してはいけません。

フィールドの型 (type) は、bool, int32, uint64, double, string など、よくあるプログラミング言語の型がサポートされています。ただし、int などのような、サイズの無い整数はありません。サポートされている型の詳細は Scalar Value Types を参照ください。

以下では、少し変わった型の紹介をします。

repeated

repeated を型の前に付けると、そのフィールドは配列やリストのような扱いとなります。

repeated string urls = 1;

enum

enum が使えます。enum はデフォルトの値が 0 なので、必ずゼロ値を含めなければなりません。

enum Kind {
	INFO = 0;
	WARN = 1;
	ERROR = 2;
}
message Log {
	Kind kind = 1;
}

map

キーと値の型を特定したマップも定義できます。

map<string, string> dict = 1;

oneof

oneof は、複数のフィールドのうち、どれか 1 つだけが選ばれるフィールドです。

message Reply {
	oneof text {
		string plain = 1;
		string html = 2;
	}
}

required

required は、proto2 では提供されていたようですが、proto3 で削除されてしまいました。どこかで目にした記事によると、required なフィールドを追加しても全てのクライアントがすぐにアップデートをしてくれるわけではないし、クライアントが移行するまでの間は required にはできないので、あまり意味がないから外れたんじゃないか、とのことでした。

サーバーのコードを書く

まずは作成した proto ファイルを、実際にコードを書く言語にコンパイルします。ここでは Go を使いますが、他の言語でも似たコマンドでコンパイルできます。

protoc --go_out=plugins=grpc:. mbox.proto

--go_out=plugins=grpc:. オプションは必須です。--go_out=. としてもコンパイルは成功しますが、この場合は gRPC メソッド用のコードが生成されません。また、最後の . はどこにファイルを生成するかを指定します。

go_out が利用可能な他のオプションは公式情報の Parameters に書かれています。

正常に終われば、mbox.pb.go というファイルが生成されます。mbox.pb.go には、サーバーとクライアントの両方に必要なコードが記述されています。ざっと生成されたコードに目を通してから実装を始めましょう。

// svr.go

package main

import (
	"fmt"
	"log"
	"net"

	pb "./proto"
	"golang.org/x/net/context"
	"google.golang.org/grpc"
)

type Service struct{}

func (*Service) Send(ctx context.Context, msg *pb.Message) (*pb.Result, error) {
	fmt.Println("From:", msg.From)
	fmt.Println("To:", msg.To)
	body := msg.GetBody()
	if body == nil {
		return nil, fmt.Errorf("missing body")
	}
	switch v := body.(type) {
	case *pb.Message_Text:
		fmt.Println("Text:", v.Text)
	case *pb.Message_Html:
		fmt.Println("HTML:", v.Html)
	}
	return &pb.Result{}, nil
}

func (*Service) Receive(folder *pb.Folder, stream pb.Mail_ReceiveServer) error {
	replies := []*pb.Message{
		&pb.Message{
			From:    "from1",
			To:      []string{"to1", "to2"},
			Subject: "subject1",
			Body: &pb.Message_Text{
				Text: "plain text",
			},
		},
		&pb.Message{
			From:    "from2",
			To:      []string{"to3", "to4"},
			Subject: "subject2",
			Body: &pb.Message_Html{
				Html: "<div>attributed text</div>",
			},
		},
	}
	for _, r := range replies {
		if err := stream.Send(r); err != nil {
			return err
		}
	}
	return nil
}

func main() {
	l, err := net.Listen("tcp", ":13009")
	if err != nil {
		log.Fatalln(err)
	}
	s := grpc.NewServer()
	pb.RegisterMailServer(s, &Service{})
	s.Serve(l)
}

クライアントのコードを書く

次にクライアントです。proto ファイルのコンパイルはサーバーの時と同じなので省略します。

// cli.go

package main

import (
	"fmt"
	"io"
	"log"

	pb "./proto"
	"golang.org/x/net/context"
	"google.golang.org/grpc"
)

func main() {
	conn, err := grpc.Dial("localhost:13009", grpc.WithInsecure())
	if err != nil {
		log.Fatalln("Dial:", err)
	}
	defer conn.Close()
	c := pb.NewMailClient(conn)

	/*
	 * Send
	 */
	msg := &pb.Message{
		From:    "sender",
		To:      []string{"test@example.com"},
		Subject: "hello",
		Body: &pb.Message_Text{
			Text: "hello world",
		},
	}
	if _, err := c.Send(context.Background(), msg); err != nil {
		log.Fatalln("Send:", err)
	}

	/*
	 * Receive
	 */
	stream, err := c.Receive(context.Background(), &pb.Folder{})
	if err != nil {
		log.Fatalln("Receive:", err)
	}
	for {
		msg, err := stream.Recv()
		if err == io.EOF {
			break
		}
		if err != nil {
			log.Fatalln("Receive:", err)
		}
		fmt.Println(msg.Subject)
	}
}

サンプルなので面白くはありませんが、このサーバーを起動させた状態でクライアントを実行すると、固定メッセージが表示されます。

# Terminal A
$ go run svr.go

# Terminal B
$ go run cli.go

この記事では、Simple RPC と Server-side streaming RPC しか使っていませんが、Client-side streaming RPC など、他のスタイルについては公式のガイドを読んでください。

まとめ

可能なら REST API で実装するのが良いと思いますけど、RPC スタイルの方がふさわしいケースも存在していると思います。個人的に、RPC を実装する時は net/rpc パッケージを使っていましたが、これは他の言語から扱うことができませんでした。gRPC によって、人気のある言語から扱えて、ストリーミングも可能になったので使いやすいと思いました。

また、直接のメリットではありませんが、モデルコードの作成が自動生成になるので、面倒なモデルの作成がなくなるのはいいですね。

Copyright © 2019 Fenrir Inc. All rights reserved.