PerlでもgRPCで通信したい

まずはじめに、2021/2時点でgRPCがサポートされている言語にはPerlは含まれていなく、公式にはサポートされていません。 現時点でと言ったものの将来的にもサポートされることがないだろうことからPerlでgRPCを扱うのは茨の道といえるでしょう。

おとなしくgRPC transcodingしてHTTP REST APIで叩きましょう、というのがほぼ答えなのですがCPANに公開されているライブラリを使ってどこまでできるのかを検証するのがこの記事の目的です。

題材

gRPCで通信といっても、サーバとクライアントのどちらをPerlで実装するかという話になりますが、今回実装するのはクライアントです。 他の言語で書かれたマイクロサービスからPerlと通信することを想定して、手軽な例としてGAPIC Showcaseのサーバと通信することにしてみます。

github.com

google.showcase.v1beta1 packageにはいくつかのserviceが提供されていますが、その中でもEcho serviceの各メソッドを呼び出してみることを題材とします。 protoファイルに定義されたスキーマには、単純にリクエストを投げてレスポンスが返ってくるだけのEchoメソッドやサーバストリーミング、クライアントストリーミング、双方向ストリーミングなど形式で通信を行うメソッドが用意されています。

ちなみにIdentity serviceなどでも試したかったのですがproto3のoptional fieldが使われているため見送りました。

PerlからProtocol Buffersを扱う

protoファイルを元にメッセージのエンコード/デコードを行うためにGoogle::ProtocolBuffer::Dynamicを使います。 ほかにもモジュールは世に存在しているのですが、proto2にしか対応していない、メンテナンスされていないことから選択肢としては実質このモジュールしかありません。

metacpan.org

Google::ProtocolBuffer::Dynamicはその名前の通り、スタブコードを事前に生成しておくのではなく、protoファイルを読み込んで動的にインタフェースを生成します。

gRPCでクライアント通信をする場合はオプションを渡すことでGrpc::XSが内部で使われるようになります。Grpc::XSはCPANTSの結果を見るとMETA.ymlが存在しなくDevel::CheckLibの依存が漏れていたりなどと少し不安ではありますがGoogle::ProtocolBuffers::Dynamicからはこのモジュールを使うしかありません。

試してみる

PerlからgRPCで通信はできそうということがわかったので、実際にコードを書いて試してみます。今回書いたコードの全体は以下のリポジトリで公開しています。

github.com

まずは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-protosgoogle.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してレスポンスを返すというサーバ側の実装になっていますが、ここではあまり関係ないのでエラーを返すときの例として紹介します。

実は->waitwantarrayでコンテキストに応じて返り値が変わるようになっており、リストコンテキストで受け取る場合にはレスポンスと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で通信するのはやっぱり辛いけどなんとかできる状態にはなっています。(プロダクションで使っている事例があれば教えてください)