はじめに
Nest.jsはTypescriptで記述可能なNode.jsのAPIフレームワークで、gRPCをサポートしています。
そんなNest.jsでgRPC APIを実装しようとすると、static_codegen
とdynamic_codegen
を組み合わせて実装することになると思います。
static_codegen
とdynamic_codegen
については以下のブログが参考になります。
https://caddi.tech/archives/455#3
上記の記事にもあるように、static_codegen
にはいくつか選択肢があります。
そして、記事と同じ議論でprotobuf.jsに行き着くのかなと思います。
しかし、protobuf.jsで生成されるサービスのコードは、アノテーションベースで実装するNest.jsとは相性が悪いです。
そのため、protobuf.jsを使用する場合、メッセージのインターフェースのみを使用し、サービス部分はスクラッチで実装していました。
そんなときに、Nest.jsのサポートを謳うツール、ts-protoを知りました。
ということで、ts-protoとNest.js + ts-protoでのgRPC APIの実装方法を紹介していきます。
ts-proto
ts-protoとは
ts-proto transforms your .proto files into strong typed typescript files!
とあるとおり、ProtocolBuffersファイルからTypescriptファイルを生成するツールの一つです。
上でも述べたように、ts-protoではNest.jsのサポートを謳っています。激アツです。
https://github.com/stephenh/ts-proto/blob/master/NESTJS.markdown
使ってみる
ソースは以下です。
https://github.com/vol1003/nestjs-grpc-sample
src
ディレクトリは、以下のような構成になっています。
src
├── app.module.ts
├── main.ts
├── hero
│ ├── hero.controller.spec.ts
│ ├── hero.controller.ts
│ └── hero.module.ts
└── protos
└── hero.proto
準備
今回は、公式ドキュメントを基に、以下のProtocolBuffersファイルを例としてNest.jsでgRPC APIを実装していきます。
https://docs.nestjs.com/microservices/grpc
syntax = "proto3";
package hero;
service HeroesService {
rpc FindOne (HeroById) returns (Hero) {}
}
message HeroById {
int32 id = 1;
}
message Hero {
int32 id = 1;
string name = 2;
}
Nest.jsでgRPCを実装する準備もしておきます。
yarn add @nestjs/microservices grpc @grpc/proto-loader
ts-protoでコードを生成する
まずは、ts-protoをインストールします。
yarn add ts-proto -D
ts-protoは、protoc
のプラグインとして動作します。
以下コマンドで、src/hero/hero.ts
にTypescriptのコードが生成されます。
protoc --plugin=(yarn bin)/protoc-gen-ts_proto \
--ts_proto_out=src/hero \
--ts_proto_opt=nestJs=true \ # Nest.js向けサービスインターフェースを出力
--ts_proto_opt=outputClientImple=false \
--ts_proto_opt=addGrpcMetadata=true \ # gRPCのメタデータをサービスの引数に追加
-Isrc/protos \
src/protos/hero.proto
生成されたコードは以下のようになっています。
/* eslint-disable */
import { Metadata } from 'grpc';
import { Observable } from 'rxjs';
import { GrpcMethod, GrpcStreamMethod } from '@nestjs/microservices';
export interface HeroById {
id: number;
}
export interface Hero {
id: number;
name: string;
}
export interface HeroesServiceController {
findOne(request: HeroById, metadata?: Metadata): Promise<Hero> | Observable<Hero> | Hero;
}
export interface HeroesServiceClient {
findOne(request: HeroById, metadata?: Metadata): Observable<Hero>;
}
export function HeroesServiceControllerMethods() {
return function (constructor: Function) {
const grpcMethods: string[] = ['findOne'];
for (const method of grpcMethods) {
const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method);
GrpcMethod('HeroesService', method)(constructor.prototype[method], method, descriptor);
}
const grpcStreamMethods: string[] = [];
for (const method of grpcStreamMethods) {
const descriptor: any = Reflect.getOwnPropertyDescriptor(constructor.prototype, method);
GrpcStreamMethod('HeroesService', method)(constructor.prototype[method], method, descriptor);
}
}
}
export const HERO_PACKAGE_NAME = 'hero'
export const HEROES_SERVICE_NAME = 'HeroesService';
HeroesServiceController
というコントローラーのインターフェースが生成されています。
この例では、--ts_proto_opt=addGrpcMetadata=true
を付与しているため、引数にmetadata
が追加されています。
また、HeroesServiceControllerMethods
というアノテーションが生成されています。
これらを活用して、HeroController
を実装していきます。
HeroController
を実装する
実装は、以下のようになります。
@Controller('hero')
@HeroesServiceControllerMethods() // ①
export class HeroController implements HeroesServiceController { // ②
findOne(request: HeroById, metadata?: Metadata): Promise<Hero> | Hero {
if (metadata) {
console.log(metadata);
}
return {
id: request.id,
name: 'superman',
} as Hero;
}
}
1. @HeroesServiceControllerMethods()
を付与する
このアノテーションは、メソッド名から適切なgRPC関連のアノテーション(@GrpcMethod
, @GrpcStreamMethod
)を付与します。
サービス定義が膨らめば膨らむほど楽ができて嬉しいですね。
2. HeroesServiceController
を実装する
HeroesServiceController
は、当該サービスのコントローラーで実装すべきメソッドが定義されているインターフェースです。
このインターフェースの存在のおかげで、proto定義とコントローラーの実装の不一致をコンパイル時点で発見できます。
これは、protobuf.jsでは受けられなかった恩恵かと思います。
また、IDEによってはインターフェースから実装すべきメソッドを自動生成する機能があります。
こういった機能を利用すれば、アノテーションの自動付与と合わせて、開発者は本当にロジックの実装に集中できますね。
grpcurlで動作確認
grpcurlで動作確認をします。確認ポイントは、3点です。
- 起動できるか
- gRPC APIとして機能するか
- metadataは受け取ることができるか
1. 起動できるか
ちゃんと起動できました。
yarn start
yarn run v1.22.4
$ nest start
[Nest] 42855 - 08/01/2020, 6:40:10 PM [NestFactory] Starting Nest application...
[Nest] 42855 - 08/01/2020, 6:40:10 PM [InstanceLoader] AppModule dependencies initialized +9ms
[Nest] 42855 - 08/01/2020, 6:40:10 PM [InstanceLoader] HeroModule dependencies initialized +0ms
[Nest] 42855 - 08/01/2020, 6:40:10 PM [NestMicroservice] Nest microservice successfully started +63ms
2. gRPC APIとして機能するか
期待通りの振る舞いです。
grpcurl -d '{"id": 5}' -plaintext \
-proto hero.proto \
-import-path ./src/protos/ \
-rpc-header 'credentials: hogehoge' \
localhost:5000 \
hero.HeroesService/FindOne
{
"id": 5,
"name": "superman"
}
3. metadataは受け取ることができるか
こちらも受け取っている様子が確認できます。
Metadata {
_internal_repr: {
'user-agent': [ 'grpc-go/1.30.0-dev' ],
credentials: [ 'hogehoge' ]
},
flags: 0
}
まとめ
ということで、Nest.js + ts-protoの組み合わせについて紹介しました。
初期実装のコストも、変更時のコストも下がるのは、大きなメリットですね。
今のところNest.jsでgRPCを実装するならts-protoが一番適しているかと思っています。
余談
proto3
では、optional
が削除されました。
駆け出しgRPCerとしては、optional
なくすとか正気か?という気分だったのですが、v3.12.0でoptionalがexperimental機能として電撃復活しました。
(内部的には、oneof
でラップしているようです)
https://github.com/protocolbuffers/protobuf/blob/v3.12.0/docs/implementing_proto3_presence.md
https://github.com/protocolbuffers/protobuf/blob/v3.12.0/docs/field_presence.md
ts-protoでは、optional
サポートの議論も行われており、期待が高まります。
https://github.com/stephenh/ts-proto/issues/73