🌟

TypeScriptにてgRPC Clientのメソッドへpromisifyを適用する

2024/07/23に公開

こんにちは。エンジニアのYです。

データ連携(バッチ取り込み)機能のGA版ではバッチの設定を管理するWeb APIを開発しました。Web APIはgRPCサーバーをバックエンドとするBFFサーバーにより構成されています。BFFサーバーはTypeScriptにより実装されており、スキーマから自動生成されたgRPC Clientを通じてgRPCメソッドを呼び出しています。gRPC Clientのメソッドコールはコールバックによる非同期処理が実装されているため、そのまま使用すると可読性の低いコードになってしまいます。

今回はutil.promisifyを用いてasync/awaitを用いたgRPC Clientのメソッドコールを実現する方法をご紹介します。

スキーマからgRPC Clientを生成

TypeScriptのgRPC Clientコードはgrpc_tools_node_protoc_tsを用いて定義されたgRPCスキーマから自動生成します。grpc_tools_node_protoc_tsを使用することでClientが実装されたJavaScriptのコードと共に、型定義ファイルが生成されます。
次のスキーマからgRPC Clientを生成することを考えます。

service Example {
  rpc FetchData (Request) returns (Response) {}
}

message Request {}

message Response {}

grpc_tools_node_protoc_tsにより生成されたTypeScript型定義ファイルは次のようになります。

import * as grpc from "@grpc/grpc-js";

export interface IExampleClient {
    // 省略
}

export class ExampleClient extends grpc.Client implements IExampleClient {
    constructor(address: string, credentials: grpc.ChannelCredentials, options?: Partial<grpc.ClientOptions>);
    public fetchData(request: example_package_pb.Request, callback: (error: grpc.ServiceError | null, response: example_package_pb.Response) => void): grpc.ClientUnaryCall;
    public fetchData(request: example_package_pb.Request, metadata: grpc.Metadata, callback: (error: grpc.ServiceError | null, response: example_package_pb.Response) => void): grpc.ClientUnaryCall;
    public fetchData(request: example_package_pb.Request, metadata: grpc.Metadata, options: Partial<grpc.CallOptions>, callback: (error: grpc.ServiceError | null, response: example_package_pb.Response) => void): grpc.ClientUnaryCall;
}

async/awaitを用いたメソッドコールの実現

fetchDataメソッドはコールバックを使用した非同期処理となっています。このままではasync/awaitが使用できないため、util.promisifyを使用してPromiseを返すメソッドに変換することを考えます。fetchDataメソッドにpromisifyを適用するコードは次のように書けます。

import * as grpc from "@grpc/grpc-js";
import { serverAddresses as grpcServer } from '@/grpc/server';
import { promisify } from 'util';

const client = new ExampleClient(
    grpcServer.clientManager,
    grpc.credentials.createInsecure()
);

const promisedFetchData = promisify<Request, Response>(
    client.fetchData.bind(client)
);

これでコールバック関数の登録が必要なfetchDataメソッドからPromiseを返すpromisifiedFetchDataメソッドへの変換ができます。しかし、この方法ではgRPC Clientの数が増えた場合、bindメソッドの呼び出しが漏れてしまう可能性があります。そこでgRPC Clientを引数とする次の高階関数により、gRPC Clientが持つメソッドを全てpromisifyできます。

import * as jspb from 'google-protobuf';
import * as grpc from "@grpc/grpc-js";
import { promisify } from 'util';

export const createPromisifiedClient = (client: grpc.Client) => <
  T extends jspb.Message,
  U extends jspb.Message
>(
  grpcMethods: GrpcMethods<T, U>
) => (request: T): Promise<U> =>
  promisify<T, U>(grpcMethods.bind(client))(request);

gRPC Clientの各メソッドにおいて引数と戻り値の型がそれぞれ異るため、createPromisifiedClient関数のジェネリクスとして指定する必要があります。gRPCのメッセージはgoogle-protobufパッケージに定義されているMessage型を継承しているため、それをジェネリクスに制約として反映すれば良いでしょう。

次にgRPC Clientが持つメソッドはオーバーロードにより異なる引数、異なる型にてそれぞれ定義されています。そのため、GrpcMethods型を次のように定義する必要があります。

type GrpcCallback<T> = (err: grpc.ServiceError | null, response: T) => void;

type GrpcMethods<T extends jspb.Message, U extends jspb.Message> = {
  (request: T, callback: GrpcCallback<U>): void;
  (request: T, metadata: grpc.Metadata, callback: GrpcCallback<U>): void;
  (
    request: T,
    metadata: grpc.Metadata,
    options: Partial<grpc.CallOptions>,
    callback: GrpcCallback<U>
  ): void;
}

オーバーロードされたメソッドの型定義は同じ名前のメソッドの型を全て列挙することにより、型の定義が可能です。完成したcreatePromisifiedClientを用いてfetchDataをpromisifyする場合は次のようになります。

const client = new ExampleClient(
    grpcServer.clientManager,
    grpc.credentials.createInsecure()
);

const promisedFetchData = createPromisifiedClient(client)(client.fetchData)

GrpcMethodsはgRPC Clientのメソッドを包括的に型定義しているため、createPromisifiedClientはExampleサービス以外にも適用が可能です。

まとめ

今回はJavaScriptとTypeScriptにより記述されたgRPC Clientのメソッドをpromisifyする方法をご紹介しました。

Sprocketで働きませんか?

弊社ではカジュアル面談を実施しております。
ご興味を持たれましたら、こちらからご応募お待ちしております。

Sprocketテックブログ

Discussion