まずはじめに、2021/2時点でgRPCがサポートされている言語にはPerlは含まれていなく、公式にはサポートされていません。 現時点でと言ったものの将来的にもサポートされることがないだろうことからPerlでgRPCを扱うのは茨の道といえるでしょう。
おとなしくgRPC transcodingしてHTTP REST APIで叩きましょう、というのがほぼ答えなのですがCPANに公開されているライブラリを使ってどこまでできるのかを検証するのがこの記事の目的です。
題材
gRPCで通信といっても、サーバとクライアントのどちらをPerlで実装するかという話になりますが、今回実装するのはクライアントです。 他の言語で書かれたマイクロサービスからPerlと通信することを想定して、手軽な例としてGAPIC Showcaseのサーバと通信することにしてみます。
google.showcase.v1beta1
packageにはいくつかのserviceが提供されていますが、その中でもEcho
serviceの各メソッドを呼び出してみることを題材とします。
protoファイルに定義されたスキーマには、単純にリクエストを投げてレスポンスが返ってくるだけのEcho
メソッドやサーバストリーミング、クライアントストリーミング、双方向ストリーミングなど形式で通信を行うメソッドが用意されています。
ちなみにIdentity
serviceなどでも試したかったのですがproto3のoptional fieldが使われているため見送りました。
PerlからProtocol Buffersを扱う
protoファイルを元にメッセージのエンコード/デコードを行うためにGoogle::ProtocolBuffer::Dynamicを使います。 ほかにもモジュールは世に存在しているのですが、proto2にしか対応していない、メンテナンスされていないことから選択肢としては実質このモジュールしかありません。
Google::ProtocolBuffer::Dynamicはその名前の通り、スタブコードを事前に生成しておくのではなく、protoファイルを読み込んで動的にインタフェースを生成します。
gRPCでクライアント通信をする場合はオプションを渡すことでGrpc::XSが内部で使われるようになります。Grpc::XSはCPANTSの結果を見るとMETA.ymlが存在しなくDevel::CheckLibの依存が漏れていたりなどと少し不安ではありますがGoogle::ProtocolBuffers::Dynamicからはこのモジュールを使うしかありません。
試してみる
PerlからgRPCで通信はできそうということがわかったので、実際にコードを書いて試してみます。今回書いたコードの全体は以下のリポジトリで公開しています。
まずはprotocコマンドを使ってスタブコードを生成します。このスタブコードというのはprotoファイルに定義されたメッセージの生成やメソッドの呼び出しを行えるようにするためのクライアント用に生成されたコードです。
以下のコマンドを実行するとGrpcSandbox::PB
というPerlのパッケージが作られます。
生成されたコードにはシリアライズされたデータとgRPCとPerlのパッケージの紐付けが含まれており、実際にserviceを呼び出す際にはGrpcSandbox::PB::Google::Showcase::V1beta1::Echo
パッケージを参照するといった形で行ないます。
% protoc \ -Ithird_party/gapic-showcase/schema/api-common-protos \ -Ithird_party/gapic-showcase/schema \ --perl-gpd_out=package=GrpcSandbox.PB:lib \ --perl-gpd_opt=client_services=grpc_xs \ third_party/gapic-showcase/schema/google/showcase/v1beta1/echo.proto \ $(find third_party/gapic-showcase/schema/api-common-protos/google -name '*.proto') \ $(find /usr/local/include/google -name '*.proto')
ここで注意する点としては、GAPIC Showcaseが依存しているapi-common-protosとgoogle.protobuf
packageのprotoファイルも読み込む必要があることです。必要に応じてprotoファイルのinclude pathも指定します。
余談ですがprotocの挙動としては--perl-gpd_xxx
というオプションが渡されることでGoogle::ProtocolBuffer::Dynamicの提供するproto-gen-perl-gpd
というコマンドが呼ばれるようになります。
このコマンド同士のやり取りにもProtocol Buffersが使われており、オプションやprotoファイルの一覧がCodeGeneratorRequestとして渡されていたりします。
Echoメソッドの実装
クライアントライブラリを提供するという形でgRPCで通信するメソッドを実装していきます。 適宜protoファイルを見ながら読んでもらえると理解しやすいと思います。
まずは以下のコードのようにしてEcho
serviceへのコネクションを作成します。
コード中にでてくる $self->service
はこれを指します。
my $service = GrpcSandbox::PB::Google::Showcase::V1beta1::Echo->new( 'gapic-showcase:7469', credentials => Grpc::XS::ChannelCredentials::createInsecure(), );
serviceにあるメソッドの呼び出しは->Echo
のように同じ名前で呼び出す形に対応します。
google.showcase.v1beta1
packageのEchoRequest
に対応するパッケージはGrpcSandbox::PB::Google::Showcase::V1beta1::EchoRequest
です。
Echoメソッドは単一(Unary)リクエストなので、呼び出し後は->wait
を使ってEchoResponse
に対応するオブジェクトを取得します。メッセージのフィールドの値はget_
というprefixをつけて取り出すことができます。
sub echo { my ($self, $content) = @_; my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::EchoRequest->new({ content => $content, }); my $call = $self->service->Echo(argument => $req); my $res = $call->wait; return $res->get_content; }
Expand, Collectメソッドの実装
Expandメソッドは複数のレスポンスを受け取り(サーバストリーミング)、Collectメソッドは複数のリクエストを送ります(クライアントストリーミング)。
sub expand { my ($self, $content) = @_; my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::ExpandRequest->new({ content => $content, }); my $call = $self->service->Expand(argument => $req); my @res = $call->responses; return [map { $_->get_content } @res]; } sub collect { my ($self, @contents) = @_; my $call = $self->service->Collect(); for my $content (@contents) { my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::EchoRequest->new({ content => $content, }); $call->write($req); } my $res = $call->wait; return $res->get_content; }
Chatメソッドの実装
Chatメソッドは双方向ストリーミングを行ないます。1つずつリクエストを送ってはレスポンスを受け取るという形にしてみました。
sub chat { my ($self, @contents) = @_; my @res; my $call = $self->service->Chat(); for my $content (@contents) { my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::EchoRequest->new({ content => $content, }); $call->write($req); my $res = $call->read; push @res, $res->get_content; } $call->writesDone; return \@res; }
Waitメソッドの実装
Waitメソッドは待ち時間を受け取りますが、即座にgoogle.longrunning.Operationを返します。google.longrunning.Operations
serviceが実装されているのでGetOperation
メソッドを定期的に呼び出し、そのoperationが終了したかどうかを確認するようにしてみました。
google.longrunning.Operation
のresponseフィールドはgoogle.protobuf.Any
なので自分でWaitResponse
にデコードする必要があります。
ちなみにgoogle.longrunning.Operation
の詳しい仕様に関してはGoogle AIPsのAIP-151: Long-running operationsにあります。
sub wait { my ($self, $content, $ttl) = @_; my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::WaitRequest->new({ success => { content => $content }, ttl => { seconds => $ttl }, }); my $call = $self->service->Wait(argument => $req); my $res = $call->wait; while (1) { my ($res, $done) = $self->_get_operation($res->get_name); if ($done) { my $wait_res = GrpcSandbox::PB::Google::Showcase::V1beta1::WaitResponse->decode($res->get_value); return $wait_res->get_content; } sleep 1; } } sub _get_operation { my ($self, $name) = @_; my $operations_service = GrpcSandbox::PB::Google::Longrunning::Operations->new( $self->{server}, credentials => $self->{credentials}, ); my $req = GrpcSandbox::PB::Google::Longrunning::GetOperationRequest->new({ name => $name, }); my $call = $operations_service->GetOperation(argument => $req); my $res = $call->wait; return $res->get_response, $res->get_done; }
Blockメソッドの実装
Blockメソッドは受け取った待ち時間分、実際にsleepしてレスポンスを返すというサーバ側の実装になっていますが、ここではあまり関係ないのでエラーを返すときの例として紹介します。
実は->wait
はwantarray
でコンテキストに応じて返り値が変わるようになっており、リストコンテキストで受け取る場合にはレスポンスとstatusを返します。
このstatusというのはGrpc::XSの実装によるとcode, details, metadataというキーを持つhashrefが返され、このキーの順番にgoogle.rpc.Statusのcode, message, detailsに対応します(details→messageなので注意)。
sub block_error { my ($self, $delay, $content) = @_; my $req = GrpcSandbox::PB::Google::Showcase::V1beta1::BlockRequest->new({ response_delay => { seconds => $delay }, error => { code => GrpcSandbox::PB::Google::Rpc::Code::UNKNOWN, message => 'unknown error', }, }); my $call = $self->service->Block(argument => $req); my ($res, $status) = $call->wait; return { code => $status->{code}, details => $status->{details}, }; }
最後にgRPCサーバと通信するテストを書きました。 動かすと裏で立ち上がっているGAPIC Showcaseのコンテナに対して通信します。
% docker-compose exec app bash root@c271acf8b99d:/app# perl t/echo_service.t # Subtest: echo ok 1 ok 2 1..2 ok 1 - echo # Subtest: expand ok 1 1..1 ok 2 - expand # Subtest: collect ok 1 1..1 ok 3 - collect # Subtest: chat ok 1 1..1 ok 4 - chat # Subtest: paged_expand ok 1 ok 2 ok 3 ok 4 1..4 ok 5 - paged_expand # Subtest: wait ok 1 1..1 ok 6 - wait # Subtest: block ok 1 ok 2 1..2 ok 7 - block 1..7
gapic-showcase_1 | 2021/02/06 19:36:44 Received Unary Request for Method: /google.showcase.v1beta1.Echo/Echo gapic-showcase_1 | 2021/02/06 19:36:44 Request: content:"hello" gapic-showcase_1 | 2021/02/06 19:36:44 Returning Response: content:"hello" gapic-showcase_1 | 2021/02/06 19:36:44 gapic-showcase_1 | 2021/02/06 19:36:44 Received Unary Request for Method: /google.showcase.v1beta1.Echo/Echo gapic-showcase_1 | 2021/02/06 19:36:44 Request: content:"world" gapic-showcase_1 | 2021/02/06 19:36:44 Returning Response: content:"world" gapic-showcase_1 | 2021/02/06 19:36:44 gapic-showcase_1 | 2021/02/06 19:36:44 Server Stream for Method: /google.showcase.v1beta1.Echo/Expand gapic-showcase_1 | 2021/02/06 19:36:44 Receiving Message: content:"hello world" gapic-showcase_1 | 2021/02/06 19:36:44 gapic-showcase_1 | 2021/02/06 19:36:44 Server Stream for Method: /google.showcase.v1beta1.Echo/Expand gapic-showcase_1 | 2021/02/06 19:36:44 Sending Message: content:"hello" gapic-showcase_1 | 2021/02/06 19:36:44 gapic-showcase_1 | 2021/02/06 19:36:44 Server Stream for Method: /google.showcase.v1beta1.Echo/Expand gapic-showcase_1 | 2021/02/06 19:36:44 Sending Message: content:"world" <snip>
まとめ
これでPerlでgRPCでの一通りの通信はできました。思った以上にGoogle::ProtocolBuffers::DynamicとGrpc::XSの出来は良く、gRPC Transcodingに頼らずにgRPCで通信するのも選択肢としてはありかもしれません。
ただし動的なスタブコードを使って実装していくのは大変で、protoファイルを見ながら対応しているパッケージをちまちまと書いていく必要がありました。
Goでのprotoc-gen-goを使ったスタブコード生成をしてエディタの補完が効く快適な開発体験と比べると、PerlでもgRPCで通信するのはやっぱり辛いけどなんとかできる状態にはなっています。(プロダクションで使っている事例があれば教えてください)