TypeScriptを用いてgRPCサーバとクライアントを実装するときにつまづいたので、アウトプットとして示します。
はじめに
gRPCを使ってサーバ・クライアントの通信を行う際、Go言語を用いた情報がヒットしやすく、適切な情報が得にくいと思います。さらに、TypeScriptでgPRCを実現しようとすると、巷では多くのptoroプラグインやライブラリがあるため、その実装方法を描きにくいと思います。
ここでは、私が実際にgRPC通信を実装したサーバとクライアントの実装例をまとめます。
実現するシステム構成
ここで実現するシステムは、マイクロサービスを想定して、以下と想定します。言語はすべてTypeScriptとします。
- アプリケーションサーバ:NextJS(フロント+バックエンド)
- APIサーバ:NestJS
通信のイメージとしては以下です。
React→(HTTP)→BFF→(gRPC)→NestJS
なお、以下では、ProtoBufferやBFFなどの用語の説明を行わず、gRPCのサーバとクライアントの実装にのみフォーカスして解説します。
Proto
簡単なため、以下のprotoファイルを想定します。protoファイルのコンパイラとして、2024年9月現在はBufとprotocの2つの選択肢がありますが、以下ではprotocを採用して話を進めます。
// shared_api.proto syntax = "proto3"; package sharedapi; service AccountService { rpc FindAccountById (FindAccountByIdRequest) returns (FindAccountByIdResponse) {} } message Account { int32 id = 1; string name = 2; } message FindAccountByIdRequest { int32 id = 1; } message FindAccountByIdResponse { Account account = 1; }
サーバの実装
NestJSにてgRPCを実現する際、protoファイルのプラグインとしてはts-protoが便利です。ts-protoは、他のプラグインとはことなり、NestJSで使用するアノテーションを自動生成してくれます。NestJS の公式ページにも使用法が説明されているので安心感があります。
生成時のコマンドは以下です。protocを使用する際は、出力したい言語に合わせて、プラグイン(ここでは、protoc-gen-ts_proto)を呼び出すという形でprotocコマンドを実行します。
protoc --proto_path=./ --ts_proto_opt=nestJs=true,addGrpcMetadata=true --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./ ./shared_api.proto
すると、以下のTSファイルが生成されます。AccountServiceControllerMethods がアノテーションとなります。
import { Metadata } from "@grpc/grpc-js"; import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices"; import { Observable } from "rxjs"; export const protobufPackage = "sharedapi"; /** shared_api.proto */ export interface Account { id: number; name: string; } export interface FindAccountByIdRequest { id: number; } export interface FindAccountByIdResponse { account: Account | undefined; } export const SHAREDAPI_PACKAGE_NAME = "sharedapi"; export interface AccountServiceClient { findAccountById(request: FindAccountByIdRequest, metadata?: Metadata): Observable<FindAccountByIdResponse>; } export interface AccountServiceController { findAccountById( request: FindAccountByIdRequest, metadata?: Metadata, ): Promise<FindAccountByIdResponse> | Observable<FindAccountByIdResponse> | FindAccountByIdResponse; } export function AccountServiceControllerMethods() { return function (constructor: Function) { const grpcMethods: string[] = ["findAccountById"]; for (const method of grpcMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); GrpcMethod("AccountService", method)(constructor.prototype[method], method, descriptor); } const grpcStreamMethods: string[] = []; for (const method of grpcStreamMethods) { const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method); GrpcStreamMethod("AccountService", method)(constructor.prototype[method], method, descriptor); } }; } export const ACCOUNT_SERVICE_NAME = "AccountService";
あとは、このTSファイルを用いてサーバを起動させます。必要なライブラリは適宜importしてください。
// main.ts const grpcOptions: GrpcOptions = { transport: Transport.GRPC, options: { /* Dockerコンテナでの使用を想定 */ url: '0.0.0.0:3000', package: 'sharedapi', protoPath: join(__dirname, 'shared_api.proto'), /* サーバリフレクションのための設定 */ onLoadPackageDefinition: (pkg, server) => { new ReflectionService(pkg).addToServer(server); }, }, }; async function bootstrap() { const app = await NestFactory.createMicroservice<GrpcOptions>( AppModule, grpcOptions, ); await app.listen(); } bootstrap();
すると、PostmanやevansのようなクライアントツールでgRPC通信が確認できます。
クライアント(BFF)の実装
今回、NestJSで実装されたAPIの内部から、gRPCのAPIサーバにリクエストを送ることになります。
イメージとしては以下になります。
// src/pages/api/account/find.ts export default function handler(apiReq: NextApiRequest, apiRes: NextApiResponse) { const { id } = apiReq.query as { id: string }; const request: FindAccountByIdRequest = { id }; let response: FindAccountByIdResponse; // 1. gRPCクライアントをインスタンス化 // 2. sharedapi.AccountService.GetAccountById を呼び出しresponseに代入 apiRes.status(200).json(respose); }
まず、gRPCクライアントを実装するため、shared_api.protoからTSファイルを生成します。先ほどサーバ実装時に使ったTSファイルはNestJS用に出力されたものなので、NestJSを使用しているクライアントでは使用できません。そのため別にprotocを使用してTSファイルを作る必要があります。
gRPCクライアントを実装可能なライブラリは現状、3つ存在します。
これらのうち、上二つはTypeScriptのサポートが実験的な状況であるため、grpc-jsを使用することにします。ts-protoは、grpc-js用のクライアントを生成することもできるため、以下のprotocコマンドでコンパイルを行います。
protoc --proto_path=./ --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=./ --ts_proto_opt=outputServices=grpc-js,addGrpcMetadata=true ./shared_api.proto
すると、以下のTSファイルが生成されます。
import { BinaryReader, BinaryWriter } from "@bufbuild/protobuf/wire"; import { /* 略 */ } from "@grpc/grpc-js"; export const protobufPackage = "sharedapi"; /** shared_api.proto */ export interface Account { id: number; name: string; } export interface FindAccountByIdRequest { id: number; } export interface FindAccountByIdResponse { account: Account | undefined; } function createBaseAccount(): Account { return { id: 0, name: "" }; } export const Account: MessageFns<Account> = { // 略 }; function createBaseFindAccountByIdRequest(): FindAccountByIdRequest { return { id: 0 }; } export const FindAccountByIdRequest: MessageFns<FindAccountByIdRequest> = { // 略 }; function createBaseFindAccountByIdResponse(): FindAccountByIdResponse { return { account: undefined }; } export const FindAccountByIdResponse: MessageFns<FindAccountByIdResponse> = { // 略 }; export type AccountServiceService = typeof AccountServiceService; export const AccountServiceService = { findAccountById: { path: "/sharedapi.AccountService/FindAccountById", requestStream: false, responseStream: false, requestSerialize: (value: FindAccountByIdRequest) => Buffer.from(FindAccountByIdRequest.encode(value).finish()), requestDeserialize: (value: Buffer) => FindAccountByIdRequest.decode(value), responseSerialize: (value: FindAccountByIdResponse) => Buffer.from(FindAccountByIdResponse.encode(value).finish()), responseDeserialize: (value: Buffer) => FindAccountByIdResponse.decode(value), }, } as const; export interface AccountServiceServer extends UntypedServiceImplementation { findAccountById: handleUnaryCall<FindAccountByIdRequest, FindAccountByIdResponse>; } export interface AccountServiceClient extends Client { findAccountById( request: FindAccountByIdRequest, callback: (error: ServiceError | null, response: FindAccountByIdResponse) => void, ): ClientUnaryCall; findAccountById( request: FindAccountByIdRequest, metadata: Metadata, callback: (error: ServiceError | null, response: FindAccountByIdResponse) => void, ): ClientUnaryCall; findAccountById( request: FindAccountByIdRequest, metadata: Metadata, options: Partial<CallOptions>, callback: (error: ServiceError | null, response: FindAccountByIdResponse) => void, ): ClientUnaryCall; } export const AccountServiceClient = makeGenericClientConstructor( AccountServiceService, "sharedapi.AccountService", ) as unknown as { new (address: string, credentials: ChannelCredentials, options?: Partial<ClientOptions>): AccountServiceClient; service: typeof AccountServiceService; serviceName: string; }; // 略
ここで特筆すべきは、クライアント定義AccountServiceClientが生成されていることです。こちらを使用することで、クライアントを実装できます。
// src/pages/api/account/find.ts export default function handler(apiReq: NextApiRequest<FindAccountByIdRequest>, apiRes: NextApiResponse<FindAccountByIdResponse>) { const { id } = apiReq.query as { id: string }; const request: FindAccountByIdRequest = { id }; let response: FindAccountByIdResponse; // 1. gRPCクライアントをインスタンス化 const host = 'host.docker.internal:3000'; // APIサーバがDockerコンテナにある想定 const client = new AccountServiceClient(host, credentials.createInsecure()); // 2. sharedapi.AccountService.GetAccountById を呼び出しresponseに代入 client.FindAccountById(req, (err, res) => { if (err !== null) { apiRes.status(500).json(response); return; } response = res; apiRes.status(200).json(response); }); }
なお、今回は、ブラウザから直接gRPC通信を行わないためgrpc-jsを使用しましたが、ブラウザからgRPC通信を行うには、プロキシを使用することが必要です。しかし、先ほど説明した、grpc-web2つは、このプロキシを内部に持っているため、WEBブラウザからgRPC接続が可能です。grpc-web2つのうちのどちらかを使用する場合は、そのライブラリが使用可能なTSファイルを出力可能なprotocプラグインを選択することになります。
さいごに
本記事では、サーバ・クライアント双方がTypeScriptで実装されたgRPC通信を実現しました。今回は、protocによってコンパイルを行いましたが、Bufでも生成してみたいところです。
みなさまの開発の一助となれば幸いです!