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でも生成してみたいところです。
みなさまの開発の一助となれば幸いです!