マイクロサービス構成における NestJS での gRPC クライアントの運用戦略

はじめに

はじめまして、バックエンドセクションの yukimochi です。

現在、N予備校ではバックエンドのアプリケーションの移行計画が進んでいます。 その一環で、一部のマイクロサービス間通信についても REST API + OpenAPI の現状から gRPC へと移行することになりました。

私の参画しているプロジェクトである教材入稿ツールでは TypeScript + NestJS を採用しており、結合している他マイクロサービスとの通信でgRPCを利用する際の gRPC クライアントと、そのスキーマ定義を担う proto の運用戦略、実現方法について記します。

proto ファイルと型定義パッケージの取り回しについて考える

スキーマ定義である proto をどこに保存するか

スキーマ定義である proto をどこに保存しておくかは、 proto のバージョン管理の観点で重要です。今回では以下のような候補を考えました。

  • エンドポイントを持つアプリケーションのリポジトリに proto のディレクトリを用意する。
  • proto だけのリポジトリを用意する。

通信先のアプリケーション側の都合により、 proto だけのリポジトリを用意することがすでに決定していましたが、以下のような点が考慮事項となりました。

エンドポイントを持つアプリケーションのリポジトリに proto のディレクトリを用意する場合

この場合のメリットは、アプリケーションのコードがサポートしている機能と proto が一致していることを期待できるところです。(本当に一致しているかは、運用に依ります)

他方、開発中の機能についてクライアントを実装したいとき、開発中のブランチなどに存在する proto を参照しないといけないこととなります。

proto だけのリポジトリを用意する場合

この場合のメリットは、アプリケーションが実装しているかにかかわらず proto を確定できることです。サーバーとクライアントが同時に開発されるとしても特定のバージョンの proto に対応するように開発すればよくなります。

今回のプロジェクトでは、サーバーとクライアントがそれぞれ同時に開発されることが確定していたことから、このように管理できる点は決め手となりました。

ただし、クライアントがサーバーより先のバージョンを実装している状況で結合してしまうと、実装されていない API をリクエストしてしまう可能性がある点に注意する必要があります。

gRPC クライアントのための TypeScript の型定義パッケージをどこに保存するか

続いて、 TypeScript で利用する proto から生成する型定義パッケージをどこに保存していくかを考えます。

  • proto をクライアントコードに submodule で導入し、更新するたびに型定義パッケージを生成する。
  • proto のバージョンごとに node モジュールとして、型定義パッケージを生成し、 npm レジストリー(社内)へリリースする。

proto をクライアントコードに submodule で導入する場合

この場合のメリットは、型定義パッケージのためにリポジトリを用意しなくてよいことです。また、proto のリポジトリに対して CI/CD を設定しなくても運用することができるので proto の開発チームと融通を利かせることが厳しい場合でもすぐに採用できる点もメリットになります。

他方、git の submodule を使ったバージョンの管理は npm などのパッケージマネージャーに比べて扱いづらいことや、 proto を更新したにもかかわらず型定義パッケージの更新を忘れてしまうことが起こりうる点に注意する必要が出てきます。

proto のバージョンごとに npm レジストリー(社内)へリリースする場合

この場合のメリットは、 npm でバージョン管理できることです。また、 proto を型定義パッケージに同梱すれば、常に proto と型定義の対応が取れている状態を保つこともできます。

他方、 proto のリポジトリでリリースが行われると同時に型定義パッケージがリリースされるような CI/CD 環境を構築できないと、最新の proto への追従がしにくくなってしまいます。他の言語・フレームワークで proto を利用する場合、それに合わせて同様の仕組みを用意しなくてはならない点も留意する必要があります。

今回の採用した方法とその決め手

今回のプロジェクトでは、後者の方法を採用することにしました。その決め手となったのは以下の2点です。

  • 同じ部署で proto のリポジトリを管理しているため、 TypeScript 向けの CI/CD を設定してもらえる余地があった。
  • 社内用の npm レジストリーが存在していたため、すぐに node モジュールのリリースが可能であった。

proto を利用するクライアントの種類があまりにも増える見込みがある場合では、 CI/CD の管理責任などについて、検討する必要があると思います。

proto から NestJS で使う型定義パッケージの作成方法

実際に proto のリポジトリから NestJS で使う型定義パッケージを生成していくやり方をまとめていきます。今回は例として、以下のような proto を利用することとします。(実際のN予備校のコードとは異なります)

1. zane_service/services/lesson_service.proto

syntax = "proto3";

package zane.zane_service;

import "zane_service/messages/lesson.proto";

service LessonService {
    // 授業を取得する
    rpc GetLesson(GetLessonRequest) returns (GetLessonResponse);
    // 授業を更新する
    rpc UpdateLesson(UpdateLessonRequest) returns (UpdateLessonResponse);
    // 授業を削除する
    rpc DeleteLesson(DeleteLessonRequest) returns (DeleteLessonResponse);
}

// GetLesson

message GetLessonRequest {
    // 取得対象の授業ID
    uint64 lesson_id = 1;
}

message GetLessonResponse {
    // 取得結果
    LessonDetail lesson = 1;
}

// UpdateLesson, DeleteLesson は省略

2. zane_service/messages/lesson.proto

syntax = "proto3";

package zane.zane_service;

message LessonDetail {
    // 授業ID
    uint32 lesson_id = 1;
    // 授業タイトル
    string title = 2;
    // レコード作成日時。ISO8601形式
    string created_at = 3;
}

NestJS 向けの型定義パッケージの作成

TypeScript 向けの proto のコンパイラーはさまざま存在しますが、今回は NestJS での利用に特化したコンパイラーを探しました。 そこで、 NestJS 向けの出力オプションを持っている stephenh/ts-proto を利用することにしました。

1. ts-proto での型定義生成を行う。

mkdir output などで、あらかじめ出力先のディレクトリを作成しておき、

$ protoc --ts_proto_opt=nestJs=true,outputIndex=true \
    --plugin="npx protoc-gen-ts_proto" \
    --ts_proto_out=./output \
    --experimental_allow_proto3_optional \
    zane_service/services/**.proto

のようにして生成します。

ts_proto_opt に与えている nestJs=true が NestJS 向けの出力をする設定です。また、 outputIndex=true が複数の proto ファイルを束ねて1つのモジュールとして出力するために必要です。

実行すると以下のようなファイル群が生成されます。

./output/index.ts
./output/index.zane.ts
./output/index.zane.zane_service.ts
./output/zane_service
./output/zane_service/messages
./output/zane_service/messages/lesson.ts
./output/zane_service/services
./output/zane_service/services/lesson_service.ts

2. ./output/index.zane.zane_service.ts でパッケージ名の重複定義を解消する。

ts-proto では、複数の proto を束ねた場合に package 名の定義が衝突してしまう問題があります。今回の例では、

export const ZANE_ZANE_SERVICE_PACKAGE_NAME = "zane.zane_service";

が、 ./output/zane_service/messages/lesson.ts./output/zane_service/services/lesson_service.ts の両方に含まれています。

これを解決するために、それぞれを export している ./output/index.zane.zane_service.ts で定義を上書きして解消します。

$ sed -e '$a export const ZANE_ZANE_SERVICE_PACKAGE_NAME = "zane.zane_service";' -i output/index.zane.zane_service.ts

3. package.json, tsconfig.json を用意し TypeScript をトランスパイルする。

package.json の version 値はベースとなる proto のバージョンに一致するように CI/CD の中でうまく設定することをお勧めします。

  • package.json
{
    "name": "@zane/zane_service",
    "version": "0.0.1",
    "main": "dist/index.js",
    "files": [
        "dist"
    ],
    "dependencies": {
        "@nestjs/microservices": "^10.1.3",
        "rxjs": "^7.8.1"
    },
    "devDependencies": {
        "@types/node": "^20.5.0",
        "ts-proto": "^1.156.7",
        "typescript": "^5.1.6"
    }
}
  • tsconfig.json
{
    "compilerOptions": {
    "target": "es6",
    "module": "commonjs",
    "strict": true,
    "esModuleInterop": true,
    "declaration": true,
    "outDir": "dist",
    "baseUrl": "./output"
    },
    "include": ["output"]
}

それぞれのファイルを作成後、

$ npm install
$ npm exec tsc

によって、 TypeScript をトランスパイルします。

4. proto を型定義パッケージに同梱して publish する

NestJS で gRPC クライアントを機能させるためには proto ファイルも必要になります。そこで、型定義パッケージに proto ファイルを同梱することで、型定義パッケージに対応した proto を利用できるようにします。

$ mkdir dist/proto
$ cp --parents zane_service/**/* dist/proto

そして、 npm リポジトリーへ pulish して完了です。

$ npm publish

NestJS で gRPC クライアントモジュールの作成

ここからは、 gRPC を使って接続をしたいクライアントの NestJS プロジェクトでの利用方法についてまとめていきます。

1. npm install で先ほど作成したパッケージをインストールする。

$ npm install @zane/zane_service@0.0.1

2. gRPC クライアントを利用するモジュールを定義する。

公式ドキュメントの client の節 を参考に gRPC クライアントをインポートするモジュールを定義します。

const PROTO_BASE_PATH = join(
        dirname(require.resolve('@zane/zane_service')),
        '/proto',
    );

@Module({
    imports: [
        ClientsModule.register([
            {
                name: 'ZANE_SERVICE',
                transport: Transport.GRPC,
                options: {
                    url: process.env['ZANE_GRPC_BASE_URL'] || '',
                    package: ZANE_ZANE_SERVICE_PACKAGE_NAME,
                    protoPath: [
                        'zane_service/services/lesson.proto', // service の proto ファイルのみ指定すればよい
                    ],
                    loader: {
                        includeDirs: [PROTO_BASE_PATH],
                    },
                },
            },
        ]),
    ],
    exports: [ZaneService],
    providers: [ZaneService],
})

export class ZaneServiceModule {}

protoPath には proto のファイルパスを指定し、 includeDirs にはパスの起点となるディレクトリを指定する必要があります。ここで、 dirname(require.resolve('@zane/zane_service')) のようにして node モジュールのパスを参照することで node モジュールに同梱されている proto ファイルを常に指定できます。

3. gRPC クライアントを利用するサービスを作成する。

ts-proto の NestJS 向けドキュメント を参考に gRPC クライアントを定義します。

@Injectable()
export class ZaneService implements OnModuleInit {
    private lessonServiceClient: ZaneServiceClient;
        
    constructor(@Inject(ZANE_ZANE_SERVICE_PACKAGE_NAME) private client: ClientGrpc) {}

    onModuleInit(): void {
        this.lessonServiceClient = this.client.getService<LessonService>(LESSON_SERVICE_NAME);
    }

    getLesson(lessonId: number): Observable<GetLessonResponse> {
        return this.lessonServiceClient.getLesson({ lesson_id: lessonId }); // getLesson の受け取る型は GetLessonRequest
    }
}

proto から生成した型定義パッケージより ZaneServiceClient が与えられているため、 メソッドや引数・返り値の型の補助を受けることができます。

4. Observable を Promise に変換する層を用意する。

NestJS ではあまり利用しない Observable を Promise へ変換する層を return する前に差し込むことでほかのモジュールから使いやすくします。 必要に応じて、 gRPC のエラーを REST API に準じたエラーコードに変換する処理なども adjustRpcResponse で行ってもよいと思います。

private async adjustRpcResponse<T>(response: Observable<T>): Promise<T> {
    try {
        return await lastValueFrom(response);
    } catch (e) {
        if (e?.code && e?.details) {
            // ZaneRpcException で gRPC のエラーを REST API のステータスコードへ変換する
            throw new ZaneRpcException(e, e.code, e.details);
        }
        throw e;
    }
}

adjustRpcResponse を利用することで、先ほどの getLesson を以下のように変更できました。

@Injectable()
export class ZaneService implements OnModuleInit {
    private lessonServiceClient: ZaneServiceClient;
        
    constructor(@Inject(ZANE_ZANE_SERVICE_PACKAGE_NAME) private client: ClientGrpc) {}

    onModuleInit(): void {
        this.lessonServiceClient = this.client.getService<LessonService>(LESSON_SERVICE_NAME);
    }

    async getLesson(lessonId: number): Promise<GetLessonResponse> {
        const rpcResponse = this.lessonServiceClient.getLesson({ lesson_id: lessonId }); // getLesson の受け取る型は GetLessonRequest
        return this.adjustRpcResponse<GetLessonResponse>(rpcResponse);
    }
    
    private async adjustRpcResponse<T>(response: Observable<T>): Promise<T> {
        try {
            return await lastValueFrom(response);
        } catch (e) {
            if (e?.code && e?.details) {
                // ZaneRpcException で gRPC のエラーを REST API のステータスコードへ変換する
                throw new ZaneRpcException(e, e.code, e.details);
            }
            throw e;
        }
    }
}

あとは、利用したいサービスから ZaneServiceModule を import してサービスを呼び出せば gRPC クライアントを利用できます。

まとめ

NestJS 向けの TypeScript 型定義パッケージを proto リポジトリとどのように連携して管理していくか、そしてどのように利用したかについて記しました。

結果として、 proto の変更を npm パッケージとして簡単に取り込むことができる体制を作ることができました。また、 NestJS のモジュールの仕組みにより、本体のビジネスロジックと gRPC クライアントを適度に分離して gRPC 特有の仕組みを ZaneServiceModule の内部に隠ぺいできました。

We are hiring!

株式会社ドワンゴの教育事業では、一緒に未来の当たり前の教育をつくるメンバーを募集しています。 カジュアル面談も行っています。 お気軽にご連絡ください!

カジュアル面談応募フォームはこちら

www.nnn.ed.nico

開発チームの取り組み、教育事業の今後については、他の記事や採用資料をご覧ください。

speakerdeck.com