TypeScriptにおけるgRPC関連ライブラリの比較とプロダクト開発で採用した方法の紹介

こんにちは、テクノロジー本部バックエンド開発グループの山田です。

弊社のプロダクト開発では、以下の図のようにフロントエンド <-> BFF <-> バックエンドの構成をとっており、Node.js上で稼働しているBFFと、Rustで作成しているバックエンドの間をgRPCで通信しています。

技術スタック

そこで今回は、TypeScriptにおけるgRPCの関連ライブラリについて、以下を紹介していきます。

【1】 公式チュートリアルに沿った2種類の実装サンプルに、アプリケーション開発中に認証や分散トレーシング等で利用するMetadataの実装を追加したコード

【2】 2種類の方法をライブラリの実装も見つつ比較

【3】 直近の開発で採用している方法の紹介

お急ぎの方は下部にまとめを記述しているのでそちらを参照ください。

また、説明の都合で記事中のサンプルコードは一部を抜粋して記述していくため、完全なサンプルコードは↓のリポジトリを参照ください。 GitHub - kei711/ts-grpc-example

※ライブラリ使用方法の比較にフォーカスするため、gRPC自体とNode.jsやTypeScriptに関しては説明を省略します ※gRPC-Webについては、採用を決めたときに記事にできたらと思っています


[toc]

事前準備

Node.js v12.xとyarnがインストールされている環境を前提に進めていきます。 npmを使う方は適宜読み替えてください。

TypeScript等の開発に必要なライブラリとgRPCをインストール

yarn add -D typescript @types/node ts-node
yarn add grpc

サービスの定義

gRPCで通信するためにはサービスを定義する必要があります。 今回はサーバー・クライアント間でPingとPongのメッセージをやり取りするサービスを定義します。

pingpong.proto
syntax = "proto3";
package pingpong;

service PingPong {
  rpc SendPing (Ping) returns (Pong) {}
}
message Ping {
  string type = 1;
  string payload = 2;
}
message Pong {
  string payload = 1;
}

【1】 gRPC Getting Started

公式のチュートリアルでも紹介されている、以下2パターンの実装をしてみます。

① protocコマンドにより静的コードを生成する方法(以下公式に合わせてstatic_codegenと記述する) ② プログラム実行時に.protoファイルを直接読み込む方法(以下公式に合わせてdynamic_codegenと記述する)

① static_codegen

protocコマンドにより静的コードを生成する方法です。 必要なライブラリをインストールして、gRPCのサーバー・クライアントの処理を書いていきます。

yarn add google-protobuf
 yarn add -D @types/google-protobuf grpc-tools grpc_tools_node_protoc_ts

コード生成時のコマンドが大変なので、grpc_tools_node_protoc_tsのREADMEを参考にshellを用意して実行します。

static/proto_generate.sh
"`yarn bin`"/grpc_tools_node_protoc \
  --plugin=protoc-gen-grpc="`yarn bin`"/grpc_tools_node_protoc_plugin \
  --js_out=import_style=commonjs,binary:./generated \
  --grpc_out=./generated \
  -I ../../ ../../pingpong.proto

"`yarn bin`"/grpc_tools_node_protoc \
  --plugin=protoc-gen-ts="`yarn bin`"/protoc-gen-ts \
  --ts_out=./generated \
  -I ../../ ../../pingpong.proto

生成されたコードをもとにサーバー・クライアントを実装 (抜粋)

static/server.ts
const server = new grpc.Server();
server.addService<IPingPongServer>(PingPongService, {
  sendPing: (call, callback) => {
    let pong = new Pong();
    pong.setPayload('pong');
    callback(null, pong);
  },
});
server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
server.start();
static/client.ts
const meta = new grpc.Metadata();
meta.set('identifier', 'test@example.com');
const client = new PingPongClient('localhost:50051', grpc.credentials.createInsecure());
const ping = new Ping();
ping.setType('sample');
ping.setPayload('ping');
client.sendPing(ping, meta, (error, value) => {
  client.close();
});

ここまでがprotocコマンドにより生成された静的コードを利用したgRPCサーバー・クライアントの実装でした。 次はprotoファイルを動的に読み込んでgRPCサーバー・クライアントを実装する方法です。

② dynamic_codegen

プログラム実行時に.protoファイルを直接読み込み、動的に処理が追加される実装方法です。 必要なライブラリをインストールして、gRPCのサーバー・クライアントの処理を書いていきます。

yarn add @grpc/proto-loader

static_codegenとは異なり、実行時に動的にメソッド定義されるため、TypeScriptの型チェックの恩恵を得るためには自分で記述する必要があるため記述していきます。

export interface PingPongServer {
  sendPing(call: grpc.ServerUnaryCall<Ping>, callback: grpc.sendUnaryData<Pong>): void;
}
export interface PingPongClient extends grpc.Client {
  sendPing(call: Ping, metadata: grpc.Metadata, callback: grpc.sendUnaryData<Pong>): void;
}
export interface Ping {
  type: string;
  payload: string;
}
export interface Pong {
  payload: string;
}

このようにgrpcライブラリの値を使いながら定義しておくと、正しく型チェックできるようになります。

作成した型定義を使いつつサーバー・クライアントを実装 (抜粋)

dynamic/server.ts
const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options);
const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong;

const server = new grpc.Server();
server.addService<PingPongServer>(packageObject['PingPong'].service, {
  sendPing: (call, callback) => {
    callback(null, { payload: 'pong' });
  },
});
server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
server.start();
dynamic/client.ts
const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options);
const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong;

const meta = new grpc.Metadata();
meta.set('identifier', 'test@example.com');
const client: PingPongClient = new packageObject['PingPong']('localhost:50051', grpc.credentials.createInsecure());
client.sendPing({ type: 'sample', payload: 'ping' }, meta, (error, value) => {
  client.close();
});

以上がprotoファイルを動的に読み込んでgRPCサーバー・クライアントを実装する方法です。

ここまでで、公式チュートリアルに沿って2種類の実装方法をサンプルとともに紹介してきました。 次はこれらの実装方法を比較し、より開発体験の良い方法を模索していきます。

【2】 各実装の比較

static_codegenとdynamic_codegenのいずれにしても実現できることは変わりません。 しかし、Protocol Buffers向けにSerialize/Deserializeの実装を行っている処理が以下のように依存する実装ライブラリが異なるため、記述の仕方も異なっています。

  • static_codegenのライブラリ依存関係
    • grpc + grpc_tools_/node_protoc_ts + google-protobuf
  • dynamic_codegenのライブラリ依存関係
    • grpc + @grpc/proto-loader + protobuf.js

これから、この差分を見て比較していきたいところですが、複数の観点から比較するためにも、もう少し深堀りして判断材料を増やしたいとおもいます。

各実装のライブラリ内実装

次の2点は、ライブラリの実装を見ていないと気づきにくい箇所でもあったので紹介していきます。

① static_codegenで型定義から隠されている実装 ② dynamic_codegenで追加されるメソッド・処理

① static_codegenで型定義から隠されている実装

まずstatic_codegenに関してです。 生成された型定義を見ていると一見setterによる値指定しかできないように見えますが、JavaScriptのコードを見てみるとコンストラクタからも値を指定することが可能になっています。

static/generated/pingpong_pb.js
proto.pingpong.Ping = function(opt_data) {
  jspb.Message.initialize(this, opt_data, 0, -1, null, null);
};

コンストラクタとして実行されるgoogle-protobuf/jspb.Message.initializeの実装を見てみると、内部で使用しているjspb.Message.arrayに第2引数の配列を直接代入するようになっています。

また、関連するjspb.Message.setFieldの処理を見ていると、どうやらprotoの定義上のナンバリングと配列の数値インデックスを一致させる必要がありそうです。

一見コンストラクタで値を注入できて便利そうに見えましたが、運用していくにつれてprotoのナンバリングは歯抜けになったり、予約されていたりするので、配列のインデックスを一致させるのが難しくなっていきそうです。

関連コードを読み切れていないので想像ですが、カジュアルに使うと分かりにくいバグの原因になるのと、順序の制約もつけにくいため、型として公開されていないのではないか、と思っています。 (Tupleを使えば解決できそうでもありますが、一旦考えないこととします)

② dynamic_codegenで追加されるメソッド・処理

ここからはstatic_codegenと比較するためにdynamic_codegenで、以下の2つの処理がどのような形で動的に追加されているのか、ライブラリの実装を追っていきたいと思います。

  • Serialize/Deserializeの処理
  • UnaryRequestとして単一のリクエストを実行する処理

Serialize/Deserializeの処理

@grpc/proto-loaderでprotoを読み込んだ際に、以下のようにprotobuf.jsによるJSONを利用した実装がオブジェクトに追加されます。 これによりsetter/getterの実装をしなくてもメッセージの作成が行えます。

@grpc/proto-loader/index.js
function createDeserializer(cls, options) {
  return function deserialize(argBuf) {
    // protobuf.jsのtoObject()とdecode()が利用される
    return cls.toObject(cls.decode(argBuf), options);
  };
}
function createSerializer(cls) {
  return function serialize(arg) {
    // protobuf.jsのfromObject()とencode()が利用される
    var message = cls.fromObject(arg);
    return cls.encode(message).finish();
  };
}

UnaryRequestとして単一のリクエストを実行する処理

protoロード時にRPCとして追加されるメソッドの実態を確認していきます。

@grpc/proto-loaderで読み込み時に呼ばれているgrpc.loadPackageDefintionをもとに、grpc/src/client.jsの定義を以下の順にたどっていくと処理が見えてきます。

  • client.makeClientConstructor
    • protoの定義をもとにメソッド等を生成している
  • Client.prototype.makeUnaryRequest
    • 第3引数までは内部で生成された値が利用されている
    • 第4引数以降のargument, metadata, options, callbackが動的生成されているRPCのメソッドが受け付けるものとして利用されている

結果として、動的に生成されるRPCのメソッドが受け付ける引数は以下3パターンであることがわかります。

rpcName(argument, callback);
rpcName(argument, metadata, callback);
rpcName(argument, metadata, options, callback);

static_codegenとdynamic_codegenの比較

以上のライブラリ実装をもとに比較してみると以下のことがわかりました。

  • static_codegen
    • Serialize/Deserializeの処理はgoogle-protobufjspb.Messageを利用
    • 値の指定は、Messageごとに生成されるsetterを使用する
      • コンストラクタからも指定できるが使いにくい
  • dynamic_codegen
    • Serialize/Deserializeの処理はprotobuf.jsMessageを利用
    • 値の指定は、Messageのコンストラクタから値を渡す or 直接指定する
      • 内部的にJSONからMessageを作成するprotobuf.jsfromObjectが使われる
      • ただし、値がすべて入ることが保証できないので、 protobuf.jsのREADME に記載があるようにverifyメソッドでチェックする必要がある

【3】 直近の開発で採用している方法の紹介

さて、ここからは直近の開発で採用した方法について、紹介します。

開発時の悩みと検討したこと

BFFにgRPCのクライアントを実装するなかで、以下の要件が見えてきました。

  1. フロントとはGraphQL、バックエンドとはgRPCで通信を行うため、型変換を大量に行う必要がある
    • モデルごとにsetter/getterを書くのは非常につらいので楽をしたい
  2. オブジェクト数が増えたときを考えると、できるだけ高速であるといい
  3. 入力値等はTypeScriptの型で縛りたいが、すべてのRPCの型定義するのは非常に大変なので行いたくない

上記までの比較から、dynamic_codegenであれば、1.はJSONをDTOとして扱うことで解消でき、2.についてもprotobuf.jsのほうが早いとのベンチマークが protobuf.jsのREADME.md に掲示されているため、だいぶフィットしそうなことがわかりました。

問題は3.の型で縛るという点です。 ライブラリの導入だけではどうしようもないので、この問題を解決していきます。

dynamic_codegenのモデルへの型づけで楽をしたい

最初のdynamic_codegenの実装方法でだと、TypeScriptの型チェックを行うために自分でtypeの定義をしていかなければいけませんでした。 ただ、これを毎回行うのは非常に面倒なので、もっと楽できる方法はないかと考えました。

まず、モデルの型については、protobuf.jsがpbjs/pbtsというCLIを提供してくれています。 こちらもstatic_codegenと同様に毎回指定するのが大変なので protobuf.js/README を参考にshellを用意して実行することにします。

dynamic_with_protobufjs/proto_generate.sh
"`yarn bin`"/pbjs \
  --target static-module \
  --no-encode \
  --no-decode \
  --path ../../ \
  --out ./generated/index.js \
  ../../pingpong.proto

"`yarn bin`"/pbts \
  --out ./generated/index.d.ts \
  ./generated/index.js

このshellを実行することで、一通りの型定義を自動生成してくれます。 しかし、残念なことにprotobuf.jsのpbtsで生成される型は、gRPCには対応していません。

GitHubのissueでも議論されていましたが、gRPCはGoogleが提唱したProtocol Buffersを利用した規格であり、Protocol Buffers向けのライブラリであるprotobuf.jsには現状だと取り込まれることが無いためです。

gRPCに合うように、pbtsで生成された型を拡張する

実装方法の比較をする際に深堀りをした@grpc/proto-loaderでは、protobuf.jsの一部を利用しつつgrpcライブラリに値を渡すことで、metadetaやoptionsの指定ができるようになっていました。 そこで、protobuf.jsのpbtsで生成された型を活用し、metadataやoptionsの指定ができるように型の拡張を行う定義をしていきます。

dynamic_with_protobufjs/types.ts
import * as grpc from 'grpc';
import * as protobuf from 'protobufjs';

type FilteredKeys<T, U> = {
  [P in keyof T]: T[P] extends U ? P : never;
}[keyof T];

type ProtobufFn = (request: {}) => PromiseLike<{}>;
type RequestArgType<T extends ProtobufFn> = T extends (request: infer U) => PromiseLike<any>
  ? U
  : never;
type ResponseType<T extends ProtobufFn> = T extends (request) => PromiseLike<infer U> ? U : never;

export type GrpcServer<T extends protobuf.rpc.Service> = {
  [K in FilteredKeys<T, ProtobufFn>]: grpc.handleUnaryCall<
    RequestArgType<T[K]>,
    ResponseType<T[K]>
  >;
};

export type GrpcClient<T extends protobuf.rpc.Service> = grpc.Client &
  {
    [K in FilteredKeys<T, ProtobufFn>]: (
      req: RequestArgType<T[K]>,
      metadata: grpc.Metadata,
      callback: grpc.requestCallback<ResponseType<T[K]>>,
    ) => void;
  };

この定義した型とpbjs/pbtsで作成される型を活用してサーバー・クライアントのコードを書いてみます。

dynamic_with_protobufjs/server.ts
const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options);
const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong;

const server = new grpc.Server();
server.addService<GrpcServer<pingpong.PingPong>>(packageObject['PingPong'].service, {
  sendPing(call, callback): void {
    callback(null, new pingpong.Pong({ payload: 'pong' }));
  },
});
server.bind('0.0.0.0:50051', grpc.ServerCredentials.createInsecure());
server.start();
dynamic_with_protobufjs/client.ts
const PROTO_PATH = path.resolve(__dirname, '../../pingpong.proto');
const packageDefinition = protoLoader.loadSync(PROTO_PATH, options);
const packageObject = grpc.loadPackageDefinition(packageDefinition).pingpong;

const meta = new grpc.Metadata();
meta.set('identifier', 'test@example.com');
const client: GrpcClient<pingpong.PingPong> = new packageObject['PingPong']('localhost:50051', grpc.credentials.createInsecure());
client.sendPing(new pingpong.Ping({ type: 'sample', payload: 'ping' }), meta, (error, value) => {
  client.close();
});

上記のようにprotobuf.jsで生成された型をさらに拡張することにより、メンテ不要でサービス定義が増えてもmetadataの指定を行える型が出来上がり、モデルについてもコマンドで生成された型で保護されるコードになりました。

まとめ

  • TypeScriptでgRPCの実装をする方法はstatic_codegenとdynamic_codegenの2種類ある
  • dynamic_codegenのほうがprotobuf.jsのSerialize/Deserializeにより、JSONを使ってmessageのインスタンスを作成できるため、型変換に柔軟に対応できる
  • 今回作成したdynamic_with_protobufjs/types.tsGrpcServerGrpcClient 、protobuf.jsのpbtsで生成される型を組み合わせることでgRPCに対応した型を楽に指定できる

最後に

いかがでしたでしょうか。

BFFの開発をする中でGraphQLとgRPCの型変換に悩まされた結果、最後のprotobuf.jsを活用しつつ独自の型を定義する形が一つの答えかなと考えています。

もしより良いアイデア・実装方法がありましたら、より洗練させられると良いなと思っていますので、ぜひご連絡ください。