その他

【NodeJS+gRPC】NextJS内のBFFとgRPCサーバを繋ぐには

この記事は約15分で読めます。

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でも生成してみたいところです。

みなさまの開発の一助となれば幸いです!