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

NestJSには@nestjs/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のバージョン管理を行うことが必要になる。
つまり、以下のようにパッケージ名にバージョン情報を含め、
破壊的変更や新たなAPIを提供する場合はv2
として提供していくことになる。
syntax = "proto3";
package hello.v1;
service HelloService {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
}
message SayHelloRequest {
string name = 1;
}
message SayHelloResponse {
string message = 1;
}

もちろんこのときにv1
の挙動を変えてはいけないので、v1
とv2
のロジックを分離する必要が出てくる。
NestJSではバージョニングを考慮した設計をサポートするAPIが提供されている。
が、enableVersioning
はNestFactory.create
した場合にしか使えず、
gRPCサーバーを起動する際のNestFactory.createMicroservice
には生えていない。
型定義で言うとINestApplication
にはあるがINestMicroservice
には存在しないということ。
そうなると自力で処理の分岐を組む必要があるので、
どのように実装するのがよいかコードを書きながら考える。

環境構築
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経由でもいいが、今回はバイナリをインストールする。

protoファイル準備
プロジェクト直下にproto
ディレクトリを用意し、バージョンごとにディレクトリを分ける。
├── package.json
├── pnpm-lock.yaml
├── proto
│ └── hello
│ ├── v1
│ │ └── hello.proto
│ └── v2
│ └── hello.proto
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;
}
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 generate
でsrc
以下に、protoディレクトリの階層にしたがってファイルが生成される。
├── src
│ ├── app.module.ts
│ ├── hello
│ │ ├── v1
│ │ │ └── hello_pb.ts
│ │ └── v2
│ │ └── hello_pb.ts
│ └── main.ts

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
以下、いくつか抜粋。
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();
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 {}
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',
});
}
}
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',
});
}
}

リクエストを送る
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"
}

呼び出されているのは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ファイルの読み込み部分を見てみる。
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
を読み込むオプションになっている。
オブジェクトの構造だけ見ると、
パッケージに対して定義ファイルを紐づけるような作りではなく、
読み込んだもの全てがミックスされているのでは?という印象を受ける。(後ほど実装を確認)

パッケージ定義をしない場合
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"
}

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ファイルは必要で、
起動時にチェックが行われている。

v1とv2の読み込み順
上記の挙動から、どちらのprotoファイル・パッケージも正しく読み込みは行われてそう。
Version
メソッドに何度リクエストを送ってもv2
のレスポンスが返ってくるが、
これはコントローラーの読み込み順に依存している。
@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に新規メソッドを足してみる
service HelloService {
rpc SayHello (SayHelloRequest) returns (SayHelloResponse) {}
rpc Version (VersionRequest) returns (VersionResponse) {}
+ rpc NewMethod (NewMethodRequest) returns (NewMethodResponse) {}
}
+message NewMethodRequest {}
+message NewMethodResponse {
+ string message = 1;
+}
@GrpcMethod('HelloService', 'NewMethod')
newMethod(): NewMethodResponse {
return new NewMethodResponse({
message: 'only callable v2',
});
}
この状態で、v1
のNewMethod
を呼び出そうとするとどうなるのか。
❯ 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"
}
つまり、読み込んだパッケージがミックスされてなんでも呼び出せるわけではなく、
対象のパッケージに定義されているメソッドのみ実行できるという正しいメカニズムになっている。

フィールドの型を変えてみる
v1・v2の差分としてVersion
メソッドの実装内容に差を持たせていたが、
シグネチャを変えるとどういった挙動になるのか確認する。
v2のVersionResponse
で数字を返すようにしてみる。
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
}
レスポンスの型を変えてみる
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
}

上記から
- パッケージの読み込み自体は正しく行えている
- 該当のパッケージに存在していないメソッドは呼び出し時にエラーになる
- 複数のパッケージ間で同名のメソッドを実装している場合、正しくルーティングが行われずに最後に読み込んだコントローラーの実装が呼び出される
という挙動だと推測できる。
次に、リクエストを受け付けてルーティングされる際の挙動を見ていく

protoの読み込み処理
@nestjs/microservices
の処理において、以下のコードで読み込みが行われている。
ログを仕込んで確認する。
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' ]

もう少し深追いすると、内部で@grpc/grpc-js
を使ってサーバーを動かす作りになっていることがわかる。
@grpc/grpc-js
でパッケージがサービスとして登録される箇所は以下。
引数をログに出してみると、以下のような情報が得られる。
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'
というフィールドがルーティングに関係してそうに見える。

このパスを登録しているのが以下。
path
をキーにしてハンドラーとして登録され、
実行時に取り出して処理されるという作りになっている。

@grpc/grpc-js
は渡された関数を登録して実行するだけなので、
NestJS側でどんな関数が渡されるかを確認する。
addService
しているのは先ほどの箇所。
createService
はここ。
この中で、
methodHandler = this.messageHandlers.get(pattern);
にて実行する関数を取り出している。
messageHandlersは以下のaddHandler
関数の中でセットされる。

addHandler
の引数をログで確認する。
console.log(pattern, callback);
// { service: 'HelloService', rpc: 'SayHello', streaming: 'no_stream' } [AsyncFunction (anonymous)]
エンドポイントと関数が入ってそうに見える。
このaddHandler
は少し追いづらいが、
以下のハンドラーを登録する処理の中で実行される。
patternHandlers
でという変数の数分ループする作りになっており、
patternHandlers
には関数デコレーターから取得した情報が入るようになっている。
ここで探索条件になっているPATTERN_HANDLER_METADATA
の定義はこちら。
export const PATTERN_HANDLER_METADATA = 'microservices:handler_type';
このPATTERN_HANDLER_METADATA
が設定される箇所を追いかけると、
GrpcMethod
という関数デコレーターの中で設定されることがわかる。
コードを追わなくても容易に想像はつくが、
つまるところ、 @GrpcMethod() をつけた関数の情報をハンドラーとして登録し、
リクエスト時に取り出して実行しているということがわかった。

問題の箇所
ハンドラーを登録するaddHandler
をもう一度みる。
messageHandlers
というMap<string, MessageHandler>()
なオブジェクトでハンドラー管理しているが、
このときキーとして登録されるのはpattern
を文字列化したものであり、
具体的には以下のような文字列になる。
"{ service: 'HelloService', rpc: 'SayHello', streaming: 'no_stream' }"
サービス名, メソッド名, Streamingかの情報しかなくここにパッケージ名は入ってこない。
つまり、異なるパッケージで同名のサービスかつ同名のメソッドを登録した場合、
後から登録されたもので上書きされてしまうという挙動になるため、ルーティングが正しく行えない。

どうするか?
案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 {

案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の名前が変わったことに気づかずにリクエストを送ることはないのでそこまで問題はなさそう。

そもそも'/hello.v1.HelloService/SayHello'
のようなpathはproto-loader
でprotoファイルを読み込んで設定するので、protoからコード生成できるはず。
定数を吐き出してキーとして登録するメカニズムはそこまで難しくなさそうに思える
proto-loader
のpath組み立て場所はここ。

@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とかを使ってコントローラーの処理で分岐させるのはありかもしれない

まとめ
- NestJSでのgRPCサーバーにおいて、パッケージのバージョニングはできない
- サービス名をバージョン毎に固有なものにするか、
リクエストパスを見て分岐処理を書く方法の2つで回避できるかもしれない
↓検証に使ったコード。