gRPCを分かりやすくまとめてみた

はじめに

gRPCという言葉自体はよく聞いていたのですが、「RESTと同じような立ち位置なんだよね?何が違うの?」という状況だったので調べてまとめてみました。
モダンな技術を採用している企業では、既にサービスで当たり前のように活用されている技術ですので、gRPCの基本レベルで自信無い方は目を通してみてください。

スクリーンショット 2019-05-24 7.53.10.png

gRPCとは

gRPCはGoogle謹製のHTTP/2を利用したRPCフレームワークです。
Protocol Buffersを利用し、データをシリアライズして高速なRPCを実現します。
(Protocol Buffers以外も利用可能ですが、デファクトスタンダードとなっているため、本記事ではProtocol Buffersを前提に説明します。)

protoファイルと呼ばれるIDL(Interface Definition Language)にAPI仕様を記述します。
また、IDLからサーバーサイドとクライアントサイドの雛形コードを自動生成できます。
自動生成コードは多言語対応で、サーバーサイドとクライアンサイドが異なる言語でも問題ありません。

そもそもRPCって何ですか?

RPCはRemote Procedure Callの略です。
バラしてみるとこんな感じです。

  • Remote = リモートサーバの。
  • Procedure = 手続き(メソッド)を。
  • Call = 呼び出す(実行する)。

つまり、「ローカルのメソッドを実行するのと同じような感覚でリモートサーバのメソッドを実行」できます。
gRPC以外にもJSON-RPCなどが有名です。
REST-APIのようにパスやメソッドなどを指定する必要がありません。単に関数と引数を指定するだけです。
REST-APIの替わりとして注目を集めています。

gRPCのメリット

シリアライズで高速化

送信データをProtocol Buffersでシリアライズしてバイナリに変換させるため、送信データ量が少なくなり高速になります。

HTTP/2で高速化

gRPCを使えば、プログラマは意識することなくHTTP/2を利用できます。
これまでの主流であったHTTP1.1(REST-API)とHTTP/2の比較は下記資料で詳細かつ分かりやすくまとめられています。

そろそろ知っておきたいHTTP/2の話
HTTP/2における双方向通信とgRPCとこれから

以下に、ポイントをまとめておきます。

ヘッダ部の削減

  • HTTP1.1は、ヘッダ部が大きい。その分ボディ部が小さくなってしまう。
    • →HTTP2は、HPACK(辞書を使った圧縮方法)でヘッダ部を圧縮する。

ステートフル前提

  • HTTP1.1は、ステートレスなプロトコトル上でステートフルを振る舞うために、ステート(Cookieや独自トークンなど)のやりとりを毎回やりとりする必要がある。
    • →HTTP2は、ステートフルを前提としたプロトコル。HPACK圧縮によるコンテキスト(HEADERSフレームを送信)を接続全体で共有し、初回以降はヘッダ差分のみを送信をする。

バイナリベース

  • HTTP1.1は、テキストベースのためヘッダ部の解析コストもかかるうえに、そのヘッダは初回通信確立時を除いて不要となるため、毎回送られても無駄になる。
    • →HTTP2は、バイナリベースのため煩雑なテキスト解析処理は不要になり、初回以降はヘッダ差分のみを送信をする。

Streaming

  • HTTP1.1は、原則1つのTCPコネクション上で複数のHTTPリクエスト/レスポンスを並列に処理することはできない。TCPコネクションの数を増やしてもよいが、その分サーバの負担は大きくなる。
    • →HTTP2は、Streamingにより1つのTCPコネクションで複数のHTTPリクエスト/レスポンスを並列にやり取りできる。(Streamingのタイプ一覧はこちら)TCP接続が1つになったことにより、サーバーの負荷も低減するし、TCPの3ウェイハンドシェイクも一度だけで済む。

つまり、すごく雑に一言でいうとHTTP/2は高速になったのである(笑)!

コードの自動生成による実装コスト削減

IDLに記述した内容からサーバコードとクライアントコードの雛形を自動生成できるため、実装コストを削減できます。
このメリットはOpenAPI(Swagger)でも享受できるメリットではあります。
OpenAPIに関するまとめ記事は過去に書いたので、よろしければ見てください。
OpenAPI3を使ってみよう!Go言語を例にクライアントとスタブの自動生成までをまとめます

IF仕様書の最新化を強制できる

IF仕様を変更する場合はIDLを修正しないとコードの自動生成に反映されないため、必然的に最新の仕様がIDLに記述されていきます。
日々の忙しい開発業務の中で起こりがちな、「ソースコードとAPI仕様書の乖離」が発生することを防ぐことができます。
このメリットはOpenAPIでも享受できるメリットではあります。しかしながら、OpenAPIの場合はコードの自動生成までは開発プロセスに取り込んでいないケースも多く、言語によっては意図通りにうまく生成されないケースもあるので、その場合はgRPCを試してみてもいいかもしれません。

特定のプログラミング言語に依存しない

IDLは特定の言語に依存しない形式で記述できます。(といいつつ、若干Goっぽいですが。)
C++, Java (incl. support for Android), Objective-C (for iOS), Python, Ruby, Go, C#, Node.jsの言語をサポートしています。
KotlinとSwiftも公式サポートはないですがいけそう(?)。すいません、スマホアプリ全然詳しくないです。
また、自動生成するコードもクライアントとサーバが異なる言語でも問題ありません。

gRPCのデメリット(?)

  • protocがJavaScriptに対応していないためフロントエンドから直接gRPCを実行できない できるみたいです!
    • 後述のgrpc-gatewayでREST-API対応可
  • HTTP/2のみしか対応していない
    • 後述のgrpc-gatewayでHTTP1.1対応可
  • Swagger EditorのようなIF仕様を分かりやすく表示するツールがない
  • gRPC関連のパッケージを複数インストールする必要がある
  • 受信側はデータをデシリアライズする必要がある
  • パケットをキャプチャしてもデータはシリアライズされているため通信内容の監視がしにくい。つまり、デバッグ辛い。

gRPCが向いているケース

  • Microservice間の通信
    • フロントとのやりとりではHTTP1.1になるため旨味が少なくなるため。対応言語であればシステム間通信は問題無い。
  • モバイルユーザが利用するサービス
    • シリアライズやHTTP/2によりデータ量が減るためモバイル端末の通信量制限にかかりにくいため
  • 通信速度が求められるケース
    • gRPCは速いから
  • Microservicesで色々な言語を使いたいケース
    • 複数言語対応可だから

gRPCの4つの通信方式

  • Unary RPCs(1リクエスト1レスポンス)
  • Server Streaming RPCs(1リクエスト複数レスポンス)
  • Client Streaming RPCs(複数リクエスト1レスポンス)
  • Duplex Streaming RPCs(複数リクエスト複数レスポンス)

こちらの記事に図解でシンプルにわかりやすく説明があります。

gRPCをGoで動かしてみる

他言語の方は こちら

インストール作業

Goのバージョン確認

1.6以上のバージョンである必要があります。

$ go version
go version go1.11.4 darwin/amd64

gRPCのインストール

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

Protocol Buffers v3のインストール

Macの場合は簡単です。

$ brew install protobuf

それ以外のOSの場合は、下記からzipを取得して解凍してパスを設定してください。
https://github.com/protocolbuffers/protobuf/releases

Go用のprotocプラグインのインストール

$ go get -u github.com/golang/protobuf/protoc-gen-go

公式サンプルを触ってみる

サンプルのディレクトリへ移動

さきほどインストールした資材の中にサンプルが含まれています。

$ cd $GOPATH/src/google.golang.org/grpc/examples/helloworld

protoファイルの確認

helloworld.proto

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

解説していきます。

syntax = "proto3";

proto3のシンタックスを用いることを明記します。これを記述しない場合はproto2として扱われます。

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";

なぜかJava用のoptionが記述されているのはサンプルの誤植でしょうか?

package helloworld;

option go_package で上書きしない限り、ここで指定したパッケージの値はGoのパッケージ名として扱われます。

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}
  • Greeterというサービスがある
  • GreeterサービスはSayHelloメソッドを持つ
  • SayHelloメソッドはHelloRequestを引数にHelloReplyを返す
message HelloRequest {
  string name = 1;
}
  • HelloRequestメッセージはnameというフィールドを持つ
  • nameフィールドはstring型でタグは1

タグについて補足します。
タグはメッセージ内でユニークであり、1~536,870,911の間で19000~19999以外の値である必要があります。また、1~15は1byteでエンコードできるため、使用頻度が高いフィールドに1~15を与えるべきです。

また、protoファイルでの命名規則には以下の決まりがあります。

  • サービス名:キャメルケース
  • メソッド名:キャメルケース
  • メッセージ名: キャメルケース
  • フィールド名: スネークケース

詳細なprotoファイルの書き方はこちらの記事を参照ください。

コードの自動生成

protocコマンドで pb.goファイル を自動生成します。
(※サンプルでは既に生成済みです。)

 protoc -I helloworld/ helloworld/helloworld.proto --go_out=plugins=grpc:helloworld

pb.goファイル

長文のためファイルの掲載は省略。リンク貼りました。

このファイルはプログラマが編集しません。
長々と書いてありますが、基本的にはprotoファイルで定義された内容に沿って、構造体・インターフェース・メソッドなどが宣言されているだけです。サーバコードやクライアントコードでは、pb.goファイルで抽象的に宣言されたものを利用して実装を進めていきます。

今回のpb.goファイルで特に重要なのは下記の部分です。サーバー側インターフェース

// GreeterServer is the server API for Greeter service.
type GreeterServer interface {
    // Sends a greeting
    SayHello(context.Context, *HelloRequest) (*HelloReply, error)
}

クライアント側インターフェース

type GreeterClient interface {
    // Sends a greeting
    SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error)
}

クライアント側メソッド

func (c *greeterClient) SayHello(ctx context.Context, in *HelloRequest, opts ...grpc.CallOption) (*HelloReply, error) {
    out := new(HelloReply)
    err := c.cc.Invoke(ctx, "/helloworld.Greeter/SayHello", in, out, opts...)
    if err != nil {
        return nil, err
    }
    return out, nil
}

サーバーコード

サーバーコードでは、以下のことを実装しています。 (※サンプルでは既に生成済みです。)

  • リッスン
  • サーバー起動
  • helloworld.pb.go で宣言されていた GreeterServerインターフェース の SayHelloメソッド を実装

greeter_server/main.go

//go:generate protoc -I ../helloworld --go_out=plugins=grpc:../helloworld ../helloworld/helloworld.proto

// Package main implements a server for Greeter service.
package main

import (
    "context"
    "log"
    "net"

    "google.golang.org/grpc"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

const (
    port = ":50051"
)

type server struct{}

// SayHelloメソッドを実装
func (s *server) SayHello(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
    log.Printf("Received: %v", in.Name)
    return &pb.HelloReply{Message: "Hello " + in.Name}, 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)
    }
}

クライアントコード

クライアントコードでは、 以下のことを実装しています。 (※サンプルでは既に生成済みです。)

  • gRPCコネクションの作成
  • 関数に与える引数の準備
  • contextの準備
  • helloworld.pb.go で宣言されていた GreeterClientインターフェース の SayHelloメソッド を呼び出し

greeter_client/main.go

// Package main implements a client for Greeter service.
package main

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

    "google.golang.org/grpc"
    pb "google.golang.org/grpc/examples/helloworld/helloworld"
)

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

func main() {
    // gRPCコネクションの作成
    conn, err := grpc.Dial(address, grpc.WithInsecure())
    if err != nil {
        log.Fatalf("did not connect: %v", err)
    }
    defer conn.Close()
    c := pb.NewGreeterClient(conn)

    // 引数の準備
    name := defaultName
    if len(os.Args) > 1 {
        name = os.Args[1]
    }

        // contextの準備
    ctx, cancel := context.WithTimeout(context.Background(), time.Second)
    defer cancel()

        // SayHelloメソッドの呼び出し
    r, err := c.SayHello(ctx, &pb.HelloRequest{Name: name})
    if err != nil {
        log.Fatalf("could not greet: %v", err)
    }
    log.Printf("Greeting: %s", r.Message)
}

実行

サーバを起動します。

$ go run greeter_server/main.go

別のターミナル開いて、クライアント起動(リクエスト実行)します。
返り値が返ってきました。

$ go run greeter_client/main.go
2019/05/26 10:56:30 Greeting: Hello world

protoファイルにメソッド定義追加

これだけでは味気ないので、もう少し触ってみます。
Greeterサービス に SayHelloAgainメソッド を追加してみます。helloworld.proto

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";

package helloworld;

service Greeter {
  rpc SayHello (HelloRequest) returns (HelloReply) {}
  rpc SayHelloAgain (HelloRequest) returns (HelloReply) {} // これを追加!
}

message HelloRequest {
  string name = 1;
}

message HelloReply {
  string message = 1;
}

コード自動生成

$ protoc -I helloworld/ helloworld/helloworld.proto --go_out=plugins=grpc:helloworld

省略しますが、helloworld.pb.goにもSayHelloAgain関連の変更が反映されています。

サーバコードの修正

下記のメソッド実装を追加。
“Hello”でなく”Hello again”と返します。greeter_server/main.go

 func (s *server) SayHelloAgain(ctx context.Context, in *pb.HelloRequest) (*pb.HelloReply, error) {
        return &pb.HelloReply{Message: "Hello again " + in.Name}, nil
}

クライアントコードの修正

下記の SayHelloAgainメソッド 呼び出しをmain関数に追加。greeter_client/main.go

r, err = c.SayHelloAgain(ctx, &pb.HelloRequest{Name: name})
if err != nil {
        log.Fatalf("could not greet: %v", err)
}
log.Printf("Greeting: %s", r.Message)

実行(SayHelloAgain)

サーバを起動します。

$ go run greeter_server/main.go

別のターミナル開いて、クライアント起動(リクエスト実行)します。
“Hello again”もちゃんと返ってきました。

$ go run greeter_client/main.go
2019/05/26 12:13:47 Greeting: Hello world
2019/05/26 12:13:47 Greeting: Hello again world

grpc-gateway

grpc-gatewayは、gRPCのサービスをREST-APIとして実行できるようにするリバースプロキシを生成するプラグインです。
下図のように、protoファイルからリバースプロキシとgRPCサービスのコードを自動生成します。リバースプロキシの実体も、指定した言語で自動生成されたソースコードです。

スクリーンショット 2019-06-02 17.44.39.png

当然ながら、クライアントとリバースプロキサーバ間はREST-APIの通信になるため、gRPCの高速性などのメリットは享受できなくなります。

インストール

$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-grpc-gateway
$ go get -u github.com/grpc-ecosystem/grpc-gateway/protoc-gen-swagger
$ go get -u github.com/golang/protobuf/protoc-gen-go

触ってみる

protoファイルを作成

まずはgrpc-gateway関係ない普通のprotoファイルです。your_service.proto

syntax = "proto3";
package example;
message StringMessage {
  string value = 1;
}

service YourService {
  rpc Echo(StringMessage) returns (StringMessage) {}
}

protoファイルを編集

grpc-gatewayで必要な分を上記protoファイルの Echoサービス 部分に追記します。
REST-APIで実行できるようにリバースプロキシの設定をしています。

  • メソッドは POST
  • パスは /v1/example/echo
  • リクエストボディは StringMessageの全てのフィールド

your_service.proto

syntax = "proto3";
package example;

import "google/api/annotations.proto"; // 追加

message StringMessage {
  string value = 1;
}

service YourService {
  rpc Echo(StringMessage) returns (StringMessage) {
    option (google.api.http) = { // 追加
      post: "/v1/example/echo" // 追加
      body: "*" // 追加
    }; // 追加
  } // 追加
}

body は、メッセージのどのフィールドをリクエストボディとして使用するかを表しています。
全てを使う場合は、 * になります。

body: "*"

pb.goファイルの生成

$ protoc -I/usr/local/include -I. \
> -I$GOPATH/src \
> -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
> --go_out=plugins=grpc:. \ 
> your_service.proto

リバースプロキシの生成

$ protoc -I/usr/local/include -I. \
>   -I$GOPATH/src \
>   -I$GOPATH/src/github.com/grpc-ecosystem/grpc-gateway/third_party/googleapis \
>   --grpc-gateway_out=logtostderr=true:. \
>   your_service.proto

--go_out でなく --grpc-gateway_out になっているところがポイントですね。

パッケージ名をexampleにしたので、生成したコードはexampleディレクトリにでもまとめておきましょう。

$ mkdir example
$ mv your_service.pb.go example
$ mv your_service.pb.gw.go example

サーバーサイド実装

リクエストがきたら、”echo!!!”と返すだけです。app/main.go

package main

import (
    "context"
    "log"
    "net"

    pb "../example"
    "google.golang.org/grpc"
)

const (
    port = ":9090"
)

type server struct{}

func (s *server) Echo(ctx context.Context, in *pb.StringMessage) (*pb.StringMessage, error) {
    return &pb.StringMessage{
        Value: "echo!!!",
    }, nil
}

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

リバースプロキシのエントリーポイントを実装

リバースプロキシ起動用のコードを実装します。gw/main.go

package main

import (
  "flag"
  "net/http"

  "github.com/golang/glog"
  "golang.org/x/net/context"
  "github.com/grpc-ecosystem/grpc-gateway/runtime"
  "google.golang.org/grpc"

  gw "path/to/your_service_package"
)

var (
  echoEndpoint = flag.String("echo_endpoint", "localhost:9090", "endpoint of YourService")
)

func run() error {
  ctx := context.Background()
  ctx, cancel := context.WithCancel(ctx)
  defer cancel()

  mux := runtime.NewServeMux()
  opts := []grpc.DialOption{grpc.WithInsecure()}
  err := gw.RegisterYourServiceHandlerFromEndpoint(ctx, mux, *echoEndpoint, opts)
  if err != nil {
    return err
  }

  return http.ListenAndServe(":8080", mux)
}

func main() {
  flag.Parse()
  defer glog.Flush()

  if err := run(); err != nil {
    glog.Fatal(err)
  }
}

実行

appとgwのコードを起動し、curlします。
無事、”echo!!!”が返ってきました!コンソール1

$ go run app/main.go

コンソール2

$ go run gw/main.go

コンソール3

$ curl localhost:8080/v1/example/echo -X POST
{"value":"echo!!!"}

所感

  • gRPCとGoとMicroservicesがセットで語られることが多い気がするけど、なんとなく理由がわかった。
    • gRPCは複数言語対応可だからMicroservicesと相性いいから
    • protoファイルがGoっぽいし静的型付けと相性いいから
    • Microservices間のシステムコールでgRPC使いやすいから
    • gRPCもGoもGoogle謹製だから
  • 現在の担当案件で使えそうな匂いがした。
    • Go製クライアント(通信品質がよくない中国)とGo製APIサーバ間の通信はgRPCにする
    • SPAで作ったフロント(from日本)にはgrpc-gatewayを用意してあげる
    • とはいえ、既にOpenAPIで作ったAPIをわざわざ作り変える必要があるのか。OpenAPIのコード自動生成を活用しているからIF仕様書とソースコードの乖離も起きていないし。
  • とりあえず基礎知識は身についたので、実践してノウハウをためていきたい。

まとめ

  • REST-APIに比べてgRPCは速いうえにIF仕様とソースコードの乖離発生を防げるし考えることが少ない
  • フロントエンドからは直接実行できないためgrpc-gatewayを噛ませる必要がある
  • gRPCを使った主な開発の流れは以下。
    1. IDLを記述
    2. IDLから雛形コード自動生成
    3. 雛形コードの中身を実装
    4. 起動