🦮

NestJSでgRPCを使ってみた

2022/11/02に公開約9,400字

はじめに

NestJS が気になって調査中です。
NestJS で gRPC 使うとどうなるのかなと思い試してみました。

下記サイトを参考にさせていただきました。
基本的には、1-2 のブログをマネさせてもらって、NestJS のバージョンアップで動かなかった部分や、proto から protoc を使ってコード自動生成する部分を追加修正してます。

  1. 【NestJS】NestJS で gRPC を使ってみた - server 編 - 開発覚書はてな版
  2. 【NestJS】NestJS で gRPC を使ってみた - client 編 - 開発覚書はてな版
  3. nest/sample/04-grpc at master · nestjs/nest
  4. gRPC - Microservices | NestJS - A progressive Node.js framework
  5. NestJS で gRPC API 作るならコード生成は ts-proto で決まり - Qiita

環境準備

NestJS

下記コマンドで、NestJS をインストールして、環境を構築します。

npm i -g @nestjs/cli
nest new project-name

Documentation | NestJS - A progressive Node.js framework

proto ファイルからのコード生成

下記コマンドで、Protocol Buffer Compilerであるprotobufと TypeScript の型ファイルを作るts-protoをインストールします。

brew install protobuf
npm install ts-proto

Protocol Buffer Compiler Installation | gRPC
stephenh/ts-proto: An idiomatic protobuf generator for TypeScript

コード

proto と型コード生成

スキーマ駆動開発ということで、まずは、proto。
proto はこんな感じです。rpc はパスカルケース(Language Guide)になってます。

proto/sample.proto
syntax = "proto3";

package sample;

service AppService {
  rpc FindOne (SampleDataById) returns (SampleData) {}
}

message SampleDataById {
  int32 id = 1;
}

message SampleData {
  int32 id = 1;
  string name = 2;
}

下記コマンドで、型が書かれたコードを生成します。

protoc --ts_proto_opt=nestJs=true --plugin=./node_modules/.bin/protoc-gen-ts_proto --ts_proto_out=.. ./src/proto/sample.proto

ts-proto/NESTJS.markdown at main · stephenh/ts-proto

生成されたコードは下記の通り。interface 等が定義されてます。
このコードはサーバー側でもクライアント側でも使用するので、両方のフォルダにコピーしておきます。

sample.ts
/* eslint-disable */
import { GrpcMethod, GrpcStreamMethod } from "@nestjs/microservices";
import { Observable } from "rxjs";

export const protobufPackage = "sample";

export interface SampleDataById {
  id: number;
}

export interface SampleData {
  id: number;
  name: string;
}

export const SAMPLE_PACKAGE_NAME = "sample";

export interface AppServiceClient {
  findOne(request: SampleDataById): Observable<SampleData>;
}

export interface AppServiceController {
  findOne(
    request: SampleDataById
  ): Promise<SampleData> | Observable<SampleData> | SampleData;
}

export function AppServiceControllerMethods() {
  return function (constructor: Function) {
    const grpcMethods: string[] = ["findOne"];
    for (const method of grpcMethods) {
      const descriptor: any = Reflect.getOwnPropertyDescriptor(
        constructor.prototype,
        method
      );
      GrpcMethod("AppService", method)(
        constructor.prototype[method],
        method,
        descriptor
      );
    }
    const grpcStreamMethods: string[] = [];
    for (const method of grpcStreamMethods) {
      const descriptor: any = Reflect.getOwnPropertyDescriptor(
        constructor.prototype,
        method
      );
      GrpcStreamMethod("AppService", method)(
        constructor.prototype[method],
        method,
        descriptor
      );
    }
  };
}

export const APP_SERVICE_NAME = "AppService";

サーバー

まず、下記コマンドで、依存してるパッケージをインストールしておきます。

npm install @grpc/grpc-js
npm install @nestjs/microservices

メインでは、INestMicroserviceのインターフェースを持つappを作って、listen()します。

main.ts
import { NestFactory } from '@nestjs/core';
import { Transport, GrpcOptions } from '@nestjs/microservices';
import { join } from 'path';

import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<GrpcOptions>(AppModule, {
    transport: Transport.GRPC,
    options: {
      url: 'localhost:5000',
      package: 'sample',
      protoPath: join(__dirname, 'proto/sample.proto'),
    },
  });
  await app.listen();
}
bootstrap();

参考サイトに書かれていたlistenAsync()は deprecated していて、それっぽいものとしてlisten()を使ったら期待通り動きました。

Migration guide - FAQ | NestJS - A progressive Node.js framework

コントローラーはこんな感じです。AppServiceController等は proto から生成したインターフェースを使ってます。

app.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';

import { AppServiceController, SampleData, SampleDataById } from './sample';

@Controller()
export class AppController implements AppServiceController {
  @GrpcMethod('AppService')
  findOne(data: SampleDataById): SampleData {
    const items = [
      { id: 1, name: 'John' },
      { id: 2, name: 'Doe' },
    ] as SampleData[];
    const filteredItems = items.filter((item) => item.id === data.id);
    return filteredItems.length > 0 ? filteredItems[0] : ({} as SampleData);
  }
}

module はこんな感じ。

app.module.ts
import { Module } from '@nestjs/common';

import { AppController } from './app.controller';

@Module({
  controllers: [AppController],
})
export class AppModule {}

NestJS の設定はこんな感じです。

nest-cli.json
{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "proto/**/*"
    ]
  }
}

実行はこんな感じです。

npm start

CLI クライアントで動作確認

ktr0731/evansを使って動作確認してみました。下記コマンドで期待通り動いていることを確認できます。

 echo '{"id": 2}' | evans --proto server/src/proto/sample.proto -p 5000 cli call sample.AppService.FindOne

Mac の場合インストールは brew で実行できます。

brew install evans

ktr0731/evans: Evans: more expressive universal gRPC client

クライアント

下記コマンドで依存してるパッケージをインストールします。サーバー側と一緒です。

npm install @grpc/grpc-js
npm install @nestjs/microservices

メインは、BFF (Backend For Frontend)を想定して port 3000 で API サーバーを立ち上げます。

main.ts
import { NestFactory } from "@nestjs/core";
import { AppModule } from "./app.module";

async function bootstrap() {
  const app = await NestFactory.create(AppModule);
  await app.listen(3000);
}
bootstrap();

BFF(Backends For Frontends)超入門――Netflix、Twitter、リクルートテクノロジーズが採用する理由:マイクロサービス/API 時代のフロントエンド開発(1)(1/2 ページ) - @IT

サービスでは、proto から生成したAppServiceClientを取得してfindOne()を呼び出しています。

app.service.ts
import { Injectable, OnModuleInit, Inject } from '@nestjs/common';
import { ClientGrpc } from '@nestjs/microservices';
import { Observable } from 'rxjs';
import { AppServiceClient, SampleData, SampleDataById } from './sample';

@Injectable()
export class AppService implements OnModuleInit {
  private sampleService: AppServiceClient;

  constructor(@Inject('SAMPLE_PACKAGE') private client: ClientGrpc) {}

  onModuleInit() {
    this.sampleService = this.client.getService<AppServiceClient>('AppService');
  }

  getSampleData(): Observable<SampleData> {
    return this.sampleService.findOne({ id: 1 } as SampleDataById);
  }
}

コントローラーでは、上記サービスにルーティングしてます。

app.controller.ts
import { Controller, Get } from '@nestjs/common';
import { Observable } from 'rxjs';

import { AppService } from './app.service';

@Controller()
export class AppController {
  constructor(private readonly appService: AppService) {}

  @Get()
  call(): Observable<any> {
    return this.appService.getSampleData();
  }
}

モジュールでは、依存関係や gRPC の設定を記載しています。

app.module.ts
import { Module } from '@nestjs/common';
import { Transport, ClientsModule } from '@nestjs/microservices';
import { join } from 'path';

import { AppController } from './app.controller';
import { AppService } from './app.service';

@Module({
  imports: [
    ClientsModule.register([
      {
        name: 'SAMPLE_PACKAGE',
        transport: Transport.GRPC,
        options: {
          url: 'localhost:5000',
          package: 'sample',
          protoPath: join(__dirname, 'proto/sample.proto'),
        },
      },
    ]),
  ],
  controllers: [AppController],
  providers: [AppService],
})
export class AppModule {}

NestJS の設定はこんな感じです。

nest-cli.json
{
  "collection": "@nestjs/schematics",
  "sourceRoot": "src",
  "compilerOptions": {
    "assets": [
      "proto/**/*"
    ]
  }
}

実行はこんな感じです。

npm start

最後に、サーバーとクライアントのプログラムを実行することで、ブラウザで、http://localhost:3000にアクセスすることで、期待通り json が返ってきていることを確認できます。

おわりに

以前、メルカリのブログを参考に TypeScript で gRPC を使ってみましたが、今回 NestJS 上で違うライブラリを使って動作確認しました。gRPC のツールが複数あって、何が違うのか、まだいまいちわかってませんが、NestJS 使った場合は今回のやり方がシンプルで良さそうな気がしました。今後、Meta データをやりとりする方法など、もうちょい調べてみようと思います。

OK Google, Protocol Buffers から生成したコードを使って Node.js で gRPC 通信して | メルカリエンジニアリング
gRPC x TypeScript を Docker で動かしてみた
gRPC x TypeScript で メタデータの送受信

GitHubで編集を提案

Discussion

ログインするとコメントできます