Closed25

NestJSで実装するgRPCサーバーでバージョニングを考える

mishiomishio

NestJSには@nestjs/microservicesというパッケージがあり、gRPCに対応するサービスを立ち上げることができる。

https://docs.nestjs.com/microservices/grpc

例えばこの定義ファイルを使って、

syntax = "proto3";

package hello;

service HelloService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
}

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}

こう実装すると動くようになる。

@Controller()
export class HelloController {
  @GrpcMethod('HelloService', 'SayHello')
  sayHello(data: { name: string }): { message: string } {
    return new SayHelloResponse({
      message: `Hello, ${data.name}`,
    });
  }

ここまではシンプル。
実際に業務で使う際はgRPCもREST APIと同じように、
APIに破壊的変更があった際に後方互換性を保つべく、APIのバージョン管理を行うことが必要になる。

https://learn.microsoft.com/ja-jp/aspnet/core/grpc/versioning?view=aspnetcore-8.0

つまり、以下のようにパッケージ名にバージョン情報を含め、
破壊的変更や新たなAPIを提供する場合はv2として提供していくことになる。

syntax = "proto3";

package hello.v1;

service HelloService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
}

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}
mishiomishio

もちろんこのときにv1の挙動を変えてはいけないので、v1v2のロジックを分離する必要が出てくる。

NestJSではバージョニングを考慮した設計をサポートするAPIが提供されている。

https://docs.nestjs.com/techniques/versioning

が、enableVersioningNestFactory.createした場合にしか使えず、
gRPCサーバーを起動する際のNestFactory.createMicroserviceには生えていない。
型定義で言うとINestApplicationにはあるがINestMicroserviceには存在しないということ。

https://github.com/nestjs/nest/blob/master/packages/common/interfaces/nest-application.interface.ts#L48

https://github.com/nestjs/nest/blob/master/packages/common/interfaces/nest-microservice.interface.ts#L13-L64

そうなると自力で処理の分岐を組む必要があるので、
どのように実装するのがよいかコードを書きながら考える。

mishiomishio

環境構築

NestJSはv10系を使う。

# パッケージマネージャーはpnpmを選んだ
❯ npx @nestjs/cli new nest-grpc-multi-versioning

今回はprotoファイルからのコード生成はbufを使うので、
他のgRPCライブラリと合わせてインストールする。

pnpm add @bufbuild/protobuf @grpc/grpc-js @grpc/proto-loader @nestjs/microservices

buf cliはnpm経由でもいいが、今回はバイナリをインストールする。

https://buf.build/docs/installation

mishiomishio

protoファイル準備

プロジェクト直下にprotoディレクトリを用意し、バージョンごとにディレクトリを分ける。

├── package.json
├── pnpm-lock.yaml
├── proto
│   └── hello
│       ├── v1
│       │   └── hello.proto
│       └── v2
│           └── hello.proto
v1
syntax = "proto3";

package hello.v1;

service HelloService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
  rpc Version (VersionRequest) returns (VersionResponse) {}
}

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}

message VersionRequest {}

message VersionResponse {
  string version = 1;
}
v2
syntax = "proto3";

package hello.v2;

service HelloService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
  rpc Version (VersionRequest) returns (VersionResponse) {}
}

message SayHelloRequest {
  string name = 1;
}

message SayHelloResponse {
  string message = 1;
}

message VersionRequest {}

message VersionResponse {
  string version = 1;
}

buf.yaml

version: v2
modules:
  - path: proto

lint:
  use:
    - DEFAULT
breaking:
  use:
    - PACKAGE

buf.gen.yaml

version: v2
managed:
  enabled: true
plugins:
  - remote: buf.build/bufbuild/es:v1.10.0
    opt: target=ts
    out: src

buf generatesrc以下に、protoディレクトリの階層にしたがってファイルが生成される。

├── src
│   ├── app.module.ts
│   ├── hello
│   │   ├── v1
│   │   │   └── hello_pb.ts
│   │   └── v2
│   │       └── hello_pb.ts
│   └── main.ts
mishiomishio

Controllerを書く

buf generateでいい感じにディレクトリが切られるので、
その中にNestJSのコントローラー・モジュールもこれに倣う形で配置する。

├── src
│   ├── app.module.ts
│   ├── hello
│   │   ├── hello.module.ts
│   │   ├── v1
│   │   │   ├── hello.v1.controller.ts
│   │   │   └── hello_pb.ts
│   │   └── v2
│   │       ├── hello.v2.controller.ts
│   │       └── hello_pb.ts
│   └── main.ts

以下、いくつか抜粋。

main.ts
import { join } from 'node:path';
import { NestFactory } from '@nestjs/core';
import { type MicroserviceOptions, Transport } from '@nestjs/microservices';
import { AppModule } from './app.module';

async function bootstrap() {
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '0.0.0.0:3000',
        package: ['hello.v1', 'hello.v2'],
        protoPath: [
          join(__dirname, '../proto/hello/v1/hello.proto'),
          join(__dirname, '../proto/hello/v2/hello.proto'),
        ],
      },
    },
  );

  app.listen();
}
bootstrap();
hello.module.ts
import { Module } from '@nestjs/common';
import { HelloV1Controller } from './v1/hello.v1.controller';
import { HelloV2Controller } from './v2/hello.v2.controller';

@Module({
  imports: [],
  controllers: [HelloV1Controller, HelloV2Controller],
})
export class HelloModule {}
hello.v1.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import {
  type SayHelloRequest,
  SayHelloResponse,
  VersionResponse,
} from './hello_pb';

@Controller()
export class HelloV1Controller {
  @GrpcMethod('HelloService', 'SayHello')
  sayHello(data: SayHelloRequest): SayHelloResponse {
    return new SayHelloResponse({
      message: `Hello, ${data.name}`,
    });
  }

  @GrpcMethod('HelloService', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      version: 'v1',
    });
  }
}
hello.v2.controller.ts
import { Controller } from '@nestjs/common';
import { GrpcMethod } from '@nestjs/microservices';
import {
  type SayHelloRequest,
  SayHelloResponse,
  VersionResponse,
} from './hello_pb';

@Controller()
export class HelloV2Controller {
  @GrpcMethod('HelloService', 'SayHello')
  sayHello(data: SayHelloRequest): SayHelloResponse {
    return new SayHelloResponse({
      message: `Hello, ${data.name}`,
    });
  }

  @GrpcMethod('HelloService', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      version: 'v2',
    });
  }
}
mishiomishio

リクエストを送る

Buf CLIにコマンドラインでリクエストを送信するbuf curlがあるのでこれを使う。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/SayHello -d '{"name": "hoge"}'

{
  "message": "Hello, hoge"
}
mishiomishio

呼び出されているのはv1かv2のどちらなのか?

http://localhost:3000/hello.v1.HelloService/SayHelloに対してのリクエストなので
v1のコントローラーが呼び出されているように思うが、SayHelloの実装は共通なので見分けがつかない。

Versionメソッドを呼び出してみる。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/Version

{
  "version": "v2"
}

レスポンスの内容から、v2のコントローラーが呼び出されたことがわかる。

期待値と結果

まとめるとこんな感じ。

呼び出し先 期待値 結果
v1 "v1" "v2"
v2 "v2" "v2"

どういうことなのか?

パッケージとprotoファイルの読み込み部分を見てみる。

main.ts
  const app = await NestFactory.createMicroservice<MicroserviceOptions>(
    AppModule,
    {
      transport: Transport.GRPC,
      options: {
        url: '0.0.0.0:3000',
        package: ['hello.v1', 'hello.v2'],
        protoPath: [
          join(__dirname, '../proto/hello/v1/hello.proto'),
          join(__dirname, '../proto/hello/v2/hello.proto'),
        ],
      },
    },
  );

パッケージhello.v1, hello.v2を読み込み、
定義ファイルとしてv1, v2のhello.protoを読み込むオプションになっている。

オブジェクトの構造だけ見ると、
パッケージに対して定義ファイルを紐づけるような作りではなく、
読み込んだもの全てがミックスされているのでは?という印象を受ける。(後ほど実装を確認)

mishiomishio

パッケージ定義をしない場合

      options: {
        url: '0.0.0.0:3000',
        package: ['hello.v2'],
        protoPath: [
          join(__dirname, '../proto/hello/v1/hello.proto'),
          join(__dirname, '../proto/hello/v2/hello.proto'),
        ],
      },

これは失敗する

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/Version
{
   "code": "unimplemented",
   "message": "The server does not implement the method /hello.v1.HelloService/Version"
}
mishiomishio

protoファイルを提供しない場合

v2のprotoファイルだけ読み込む形にしてみる。

      options: {
        url: '0.0.0.0:3000',
        package: ['hello.v1', 'hello.v2'],
        protoPath: [
          // join(__dirname, '../proto/hello/v1/hello.proto'),
          join(__dirname, '../proto/hello/v2/hello.proto'),
        ],
      },

サーバー起動時にエラーになる。

[Nest] 70370  - 06/22/2024, 2:14:42 PM   ERROR [Server] The invalid gRPC package (package "hello.v1" not found)

つまりパッケージ名に対応するprotoファイルは必要で、
起動時にチェックが行われている。

mishiomishio

v1とv2の読み込み順

上記の挙動から、どちらのprotoファイル・パッケージも正しく読み込みは行われてそう。
Versionメソッドに何度リクエストを送ってもv2のレスポンスが返ってくるが、
これはコントローラーの読み込み順に依存している。

hello.module.ts
@Module({
  imports: [],
  controllers: [HelloV1Controller, HelloV2Controller],
})
export class HelloModule {}

試しにcontrollers: [HelloV2Controller, HelloV1Controller],のように順番を入れ替えてみると、
v1のレスポンスが返ってくるようになる。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/Version
{
  "version": "v1"
}

この挙動から、同じメソッドを持ったコントローラー同士の定義がぶつかってあと勝ちになっていると考えられる。

v2に新規メソッドを足してみる

proto
service HelloService {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
  rpc Version (VersionRequest) returns (VersionResponse) {}
+ rpc NewMethod (NewMethodRequest) returns (NewMethodResponse) {}
}

+message NewMethodRequest {}

+message NewMethodResponse {
+ string message = 1;
+}
hello.v2.controller.ts
  @GrpcMethod('HelloService', 'NewMethod')
  newMethod(): NewMethodResponse {
    return new NewMethodResponse({
      message: 'only callable v2',
    });
  }

この状態で、v1NewMethodを呼び出そうとするとどうなるのか。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/NewMethod
Failure: URL indicates method name "NewMethod", but service "hello.v1.HelloService" contains no such method

不可能。v2のみ呼び出せる期待通りの動きになる。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v2.HelloService/NewMethod
{
  "message": "only callable v2"
}

つまり、読み込んだパッケージがミックスされてなんでも呼び出せるわけではなく、
対象のパッケージに定義されているメソッドのみ実行できるという正しいメカニズムになっている。

mishiomishio

フィールドの型を変えてみる

v1・v2の差分としてVersionメソッドの実装内容に差を持たせていたが、
シグネチャを変えるとどういった挙動になるのか確認する。

v2のVersionResponseで数字を返すようにしてみる。

v2
message VersionResponse {
  int32 version = 1;
}
  @GrpcMethod('HelloService', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      version: 2,
    });
  }

v1の呼び出し結果がこちら。
2が返ってくるが、int32ではなくstringになっている。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/Version  
{
  "version": "2"
}

v2として呼び出すとint32、数字で返ってくることが確認できる。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v2.HelloService/Version  
{
  "version": 2
}

レスポンスの型を変えてみる

v2
message VersionResponse {
  int32 new_version = 1;
}
  @GrpcMethod('HelloService', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      newVersion: 2,
    });
  }

versionというフィールドを別のものに変えてみる。

*本来別のフィールドに変える場合は、既存のフィールドに手を加えるのではなく追加するのが望ましいと考える

v1としての呼び出し結果がこちら。
v1で定義されているversionが存在しないので、何も返ってこない。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v1.HelloService/Version
{}

v2は正しく返ってくる。

❯ buf curl --schema proto --protocol grpc --http2-prior-knowledge http://localhost:3000/hello.v2.HelloService/Version
{
  "newVersion": 2
}
mishiomishio

上記から

  • パッケージの読み込み自体は正しく行えている
  • 該当のパッケージに存在していないメソッドは呼び出し時にエラーになる
  • 複数のパッケージ間で同名のメソッドを実装している場合、正しくルーティングが行われずに最後に読み込んだコントローラーの実装が呼び出される

という挙動だと推測できる。

次に、リクエストを受け付けてルーティングされる際の挙動を見ていく

mishiomishio

protoの読み込み処理

@nestjs/microservicesの処理において、以下のコードで読み込みが行われている。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L91-L102

ログを仕込んで確認する。

    async bindEvents() {
        const grpcContext = this.loadProto();
        console.log('grpcContext', grpcContext);
        const packageOption = this.getOptionsProp(this.options, 'package');
        const packageNames = Array.isArray(packageOption)
            ? packageOption
            : [packageOption];
        console.log('packageNames', packageNames);
        for (const packageName of packageNames) {
            const grpcPkg = this.lookupPackage(grpcContext, packageName);
            await this.createServices(grpcPkg, packageName);
        }
    }

読み込み結果をパッケージごとに階層構造で持っているので、
正しくハンドリングできる実装になってそうなことが確認できる。

grpcContext {
  hello: {
    v1: {
      HelloService: [Function],
      SayHelloRequest: [Object],
      SayHelloResponse: [Object],
      VersionRequest: [Object],
      VersionResponse: [Object]
    },
    v2: {
      HelloService: [Function],
      SayHelloRequest: [Object],
      SayHelloResponse: [Object],
      VersionRequest: [Object],
      VersionResponse: [Object],
      NewMethodRequest: [Object],
      NewMethodResponse: [Object]
    }
  }
}
packageNames [ 'hello.v1', 'hello.v2' ]
mishiomishio

もう少し深追いすると、内部で@grpc/grpc-jsを使ってサーバーを動かす作りになっていることがわかる。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L61-L63

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L473C24-L473C35

@grpc/grpc-jsでパッケージがサービスとして登録される箇所は以下。

https://github.com/grpc/grpc-node/blob/master/packages/grpc-js/src/server.ts#L450

引数をログに出してみると、以下のような情報が得られる。

addService {
  SayHello: {
    path: '/hello.v1.HelloService/SayHello',
    requestStream: false,
    responseStream: false,
    requestSerialize: [Function: serialize],
    requestDeserialize: [Function: deserialize],
    responseSerialize: [Function: serialize],
    responseDeserialize: [Function: deserialize],
    originalName: 'sayHello',
    requestType: {
      format: 'Protocol Buffer 3 DescriptorProto',
      type: [Object],
      fileDescriptorProtos: [Array]
    },
    responseType: {
      format: 'Protocol Buffer 3 DescriptorProto',
      type: [Object],
      fileDescriptorProtos: [Array]
    },
    options: {
      deprecated: false,
      idempotency_level: 'IDEMPOTENCY_UNKNOWN',
      uninterpreted_option: []
    }
  },

path: '/hello.v1.HelloService/SayHello'というフィールドがルーティングに関係してそうに見える。

mishiomishio

@grpc/grpc-jsは渡された関数を登録して実行するだけなので、
NestJS側でどんな関数が渡されるかを確認する。

addServiceしているのは先ほどの箇所。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L582-L599

createServiceはここ。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L124-L176

この中で、
methodHandler = this.messageHandlers.get(pattern);
にて実行する関数を取り出している。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L163

messageHandlersは以下のaddHandler関数の中でセットされる。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L447-L455

mishiomishio

addHandlerの引数をログで確認する。

console.log(pattern, callback);
//  { service: 'HelloService', rpc: 'SayHello', streaming: 'no_stream' } [AsyncFunction (anonymous)]

エンドポイントと関数が入ってそうに見える。

このaddHandlerは少し追いづらいが、
以下のハンドラーを登録する処理の中で実行される。

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/listeners-controller.ts#L134

patternHandlersでという変数の数分ループする作りになっており、
patternHandlers には関数デコレーターから取得した情報が入るようになっている。

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/listeners-controller.ts#L75

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/listener-metadata-explorer.ts#L38-L69

ここで探索条件になっているPATTERN_HANDLER_METADATAの定義はこちら。

export const PATTERN_HANDLER_METADATA = 'microservices:handler_type';

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/listener-metadata-explorer.ts#L52

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/constants.ts#L29

このPATTERN_HANDLER_METADATAが設定される箇所を追いかけると、
GrpcMethodという関数デコレーターの中で設定されることがわかる。

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/decorators/message-pattern.decorator.ts#L77-L81

https://github.com/nestjs/nest/blob/15cb568e40f42fb3c40c4cb2ad432b23a5ec7bcd/packages/microservices/decorators/message-pattern.decorator.ts#L103-L112

コードを追わなくても容易に想像はつくが、
つまるところ、 @GrpcMethod() をつけた関数の情報をハンドラーとして登録し、
リクエスト時に取り出して実行しているということがわかった。

mishiomishio

問題の箇所

ハンドラーを登録するaddHandlerをもう一度みる。

https://github.com/nestjs/nest/blob/master/packages/microservices/server/server-grpc.ts#L447-L455

messageHandlersというMap<string, MessageHandler>()なオブジェクトでハンドラー管理しているが、
このときキーとして登録されるのはpatternを文字列化したものであり、
具体的には以下のような文字列になる。

"{ service: 'HelloService', rpc: 'SayHello', streaming: 'no_stream' }"

サービス名, メソッド名, Streamingかの情報しかなくここにパッケージ名は入ってこない。
つまり、異なるパッケージで同名のサービスかつ同名のメソッドを登録した場合、
後から登録されたもので上書きされてしまうという挙動になるため、ルーティングが正しく行えない。

mishiomishio

どうするか?

案1

  • GrpcMethodでパッケージ名を指定する(あるいは他の方法で渡す)
  • ハンドラーのキーにパッケージ名を含める
  • キーから期待するパスを復元できるようになるはずなので、
    パスとマッピングしてハンドラーを取り出せるようにする
addService {
  SayHello: {
    path: '/hello.v1.HelloService/SayHello',

パスはパッケージ名+サービス名+メソッド名で取得できる。
このパスと、Streamかどうかの情報をつかえば問題なくハンドリングできるはず。

こんな感じ。

  @GrpcMethod('hello.v1', 'HelloService', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      version: 'v1',
    });
  }

そもそも、Controllerのデコレーターで分岐させてほしい。

@Controller('hello.v1')
export class HelloV1Controller {
mishiomishio

案2

Serviceの名前を変える。

HelloService -> HelloV2Service

syntax = "proto3";

package hello.v2;

service HelloV2Service {
  rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
  rpc Version (VersionRequest) returns (VersionResponse) {}
  rpc NewMethod (NewMethodRequest) returns (NewMethodResponse) {}
}

必然的にGrpcMethodのデコレーターに渡す引数も変わる。

  @GrpcMethod('HelloV2Service', 'Version')
  version(): VersionResponse {
    return new VersionResponse({
      newVersion: 2,
    });
  }

うまく動きはするものの、果たしてこれがバージョン管理なのか悩ましい。
幸いにもgRPCだとクライアント側が定義を知っている必要があるので、
Serviceの名前が変わったことに気づかずにリクエストを送ることはないのでそこまで問題はなさそう。

mishiomishio
@Injectable()
export class Interceptor implements NestInterceptor {
  intercept(context: ExecutionContext, next: CallHandler): Observable<any> {
    const path = context.getArgByIndex(2).path;
    console.log(path);

    return next.handle().pipe(tap((res) => console.log(res)));
  }
}

Interceptorでリクエスト毎にpathを確認できるので、
async-local-storageとかを使ってコントローラーの処理で分岐させるのはありかもしれない

mishiomishio

まとめ

  • NestJSでのgRPCサーバーにおいて、パッケージのバージョニングはできない
  • サービス名をバージョン毎に固有なものにするか、
    リクエストパスを見て分岐処理を書く方法の2つで回避できるかもしれない

↓検証に使ったコード。

https://github.com/mishio-n/nest-grpc-multi-versioning

このスクラップは2024/08/19にクローズされました