オルトプラスエンジニアの日常をお伝えします!

今ほどgRPCが必要とされている時代はない

こんにちは id:kotamat です。

最近携わっているプロジェクトでgRPCを使った通信を行っているのですが、マイクロサービスを作る上で非常に使い勝手がいいので、使い方含めて紹介しようと思います。

gRPCとは

HTTP/2を標準でサポートしたRPCフレームワークで、2015年にGoogleが発表しました。 デフォルトで対応しているProtocolBufferをgRPC用に書いた上で、サポートしている言語に書き出しを行うと、異なる言語のサーバー間でもある程度型堅牢に通信を行うことができます。 GoogleではプロトコルをProtoclBufferで書いているそうです。

f:id:kota-matsumoto:20170214223055p:plain

このようにサーバーをC++, クライアントをRubyやAndroidJavaといった形で通信できます。 携わっているプロジェクトでは、ScalaからGoのプロジェクトに対してgRPCのリクエストを送り、レスポンスを取得したかったので、Scalaをクライアント、Goをサーバーとして実装しました。

ProtocolBuffer

こちらは構造化されたデータ・フォーマットであり、構造化の仕方はGolangに似ています。 gRPCではmessageという構造体と、serviceというRPC部分の実装を記述します。 Proto3(version3)からはJSONフォーマットへのデコードもサポートするようになったので、もしgRPCではなく通常のREST Apiで送信したい場合はそのようにフォーマットするといいかと思います。

実装手順

実装手順は簡単にいうと下記の通りになります。

  1. Protocol bufferから各言語への書き出し実行ファイルをインストールする
  2. Protocol Bufferの定義ファイルを作成する
  3. 各言語ごとに書き出しを行う
  4. サーバーサイド、クライアントサイドで実際の処理を書く
  5. サーバーサイドを実行する
  6. クライアントサイドからRPCの関数を実行する

今回はgrpc-gachaという、以前個人的に作成したプロジェクトで作成してみようと思います。 https://github.com/kotamat/grpc-gacha cardという構造体を配列にして引数に渡すと、ランダムで一つのcardを返すという非常に簡単な処理です。

書き出し実行ファイルのインストール

今回はScala用とGolang用が必要なので、それぞれインストールします。

共通のランタイムprotocのインストール

protobufのGithubからprotoc-<version>-<OS name>.zipをダウンロードし、解凍します。 その中にbinディレクトリがあるので、実行可能なディレクトリにbin内のファイルをコピーします。

Golang用の書き出しプラグインのインストール

下記のコマンドを実行し、各種プラグインをインストールします。実行すると$GOPATH/binに実行ファイルがインストールされるので、PATHを通しておいてください。

go get -u github.com/golang/protobuf/{proto,protoc-gen-go}
go get -u google.golang.org/grpc

Scala用の書き出しプラグインのインストール

公式ではScalaをサポートしていないのでJava gRPCを使います。 ただ、ScalaPBというProtocolBuffer対応のOSSが、gRPCにも対応しているので、こちらのjarファイルのみsbtプロジェクト無いに入れてあげればいいです。

addSbtPlugin("com.thesamet" % "sbt-protoc" % "0.99.3")

libraryDependencies += "com.trueaccord.scalapb" %% "compilerplugin" % "0.5.47"
PB.targets in Compile := Seq(
  scalapb.gen() -> (sourceManaged in Compile).value
)

// If you need scalapb/scalapb.proto or anything from
// google/protobuf/*.proto
libraryDependencies += "com.trueaccord.scalapb" %% "scalapb-runtime" % com.trueaccord.scalapb.compiler.Version.scalapbVersion % "protobuf"

ただ、こちら、NettyServerを使っているのですが、バージョン的にPlay2.5とは共存できないので、もしPlayFrameworkで使いたい場合は2.4に下げるか別プロジェクトにしてあげる必要があります。

ProtocolBufferの定義ファイルを作成する。

syntax = "proto3";

package gacha;

service Gacha{
    rpc Lottery (Request) returns (Response) {}
}

message Card {
    string name = 1;
}

message Request {
    repeated Card cards = 1;
}

message Response {
    Card card = 1;
    int32 ret_code = 2;
}
  1. RequestにCardを複数指定
  2. Lottery関数を実行
  3. Responseを返却、そこにはcardという変数と、ret_codeという変数が存在する

という感じです。

各言語ごとに書き出しを行う

Scalaの場合

client/scalaのディレクトリでsbt compileと実行するだけです。 生成されたscalaファイルはtarget/scala-2.11/src_managed/mainの中に生成されます。(scala 2.11を使っている場合) IntelliJ IDEAで使用する場合はこのディレクトリを読み取り可能になるように指定してあげてください。

Goの場合

protoディレクトリで下記コマンドを実行します

$ protoc --go_out=plugins=grpc:../lib/gacha *.proto

go_outで指定したディレクトリにpb.goファイルを書き出します。

実際の処理を書く

基本的な考え方は通常のRPCと一緒かと思います。

サーバーサイドの場合

Golangで書く場合は、structにRPCの関数を生やし、生成されたパッケージの中にあるRegisterXXXServerという関数の第二引数にポインターつきで指定します。

実際の通信は、grpc.NewServerで生成された構造体を使いまわします。

今回はpb.Requestの中に

const (
    port = ":50055"
)

type server struct{}

func (s *server) Lottery(ctx context.Context, in *pb.Request) (*pb.Response, error) {
    if len(in.Cards) < 1 {
        return &pb.Response{Card: nil, RetCode: 0}, errors.New("empty cards")
    }
    rand.Seed(time.Now().UnixNano())
    chosenKey := rand.Intn(len(in.Cards))
    return &pb.Response{Card: in.Cards[chosenKey], RetCode: 1}, nil

}

func main() {
    lis, err := net.Listen("tcp", port)
    if err != nil {
        log.Fatalf("failed to listen: %v", err)
    }

    s := grpc.NewServer()
    pb.RegisterGachaServer(s, &server{})
    s.Serve(lis)
}

Scalaなどのオブジェクト指向の言語は、生成されたパッケージの中にあるクラスをnewして同様に使いまわします。

クライアントサイドの場合

クライアントからは、サーバーのエンドポイントを指定したオブジェクトを作成し、そのオブジェクトを元にStubを作成し、stubからRPCの関数を呼び出すという形になります。

Scalaだと下記のようになります。

package com.github.kotamat.grpc_gacha

import gacha._
import io.grpc.ManagedChannelBuilder

class Client {
  def main(args: Array[String]): Unit = {
    val host = "localhost"
    val port = "50055"

    // チャンネルの作成(BuilderはNetty等も使用可能)
    val channel = ManagedChannelBuilder.forAddress(host,port).usePlaintext(true).build()

    // 同期的に取得するコネクションの作成
    val connection =gacha.GachaGrpc.blockingStub(channel)

    // 引数の定義
    val requestSpec = gacha.Request(Array(
      gacha.Card("card1"),
      gacha.Card("card2")
    ))

    // RPCの結果を取得
    val response = connection.lottery(requestSpec)

    printf("gain card: %s",response.card.map(_.name).getOrElse(""))
    // Output: card1
  }
}

Rubyだとこんな感じ

def main
  # stubの生成(チャンネルの生成も同時に行う)
  stub = Gacha::Gacha::Stub.new('localhost:50055', :this_channel_is_insecure)
  
  # 引数の定義
  cards = [
      Gacha::Card.new(name: "card1"),
      Gacha::Card.new(name: "card2"),
  ]

  begin
  res = stub.lottery(Gacha::Request.new(cards: cards))
  rescue => e
    p e
  end
  if res.ret_code == 1
    p "gained: #{res.card.name}"
    # Output: card1
  else
    p "error"
  end
end

main

まとめ

いろんな言語間での高速で堅牢な通信をgRPCを使えば簡単に実現してくれます。 開発中の結合テストで消耗するのはもうやめよう!