【最新版】gRPC × Node.jsでのマイクロサービス開発のベストプラクティス
はじめに
弊社では、ある程度の規模のあるバックエンドを開発する際、gRPCを使ってマイクロサービスを構築する設計を取ることがあります。
今回は、Protobuf周辺のエコシステムであるBufのJavaScript版であるProtobuf-ESと、つい先日v2がリリースされた、gRPC互換のAPI構築に強いConnectを使って、簡単なアプリケーションを例にマイクロサービスでのgRPC × Node.jsのベストプラクティスを整理していきます。
想定読者
- gRPCに興味がある
- Node.js × TypeScriptで開発を行っている
- マイクロサービスアーキテクチャに興味がある
成果物
まずは、本記事で作成する成果物を確認します。
今回開発するアプリケーションはタスク管理サービスのようなものを想定しており、以下の2つのマイクロサービスから構成されます。
- user
- ユーザ情報を扱うサービス
- ユーザ情報の取得
- ユーザ情報を扱うサービス
- task
- タスク情報を扱うサービス
- タスクの作成
- タスクの削除
- タスクの一覧取得
- タスク情報を扱うサービス
成果物は以下のリポジトリにまとめられています。
今回は、バックエンドコードだけでなく、APIを利用しやすいように、gRPCクライアントも含めて開発を行っています。
ディレクトリ構成
ディレクトリ構成は以下のようになっています。(一部省略)
ディレクトリ構成
.
├── package.json
├── proto
│ ├── buf.yaml
│ ├── task
│ │ ├── service
│ │ │ └── task
│ │ │ └── v1
│ │ │ ├── create_task.proto
│ │ │ ├── delete_task.proto
│ │ │ ├── list_tasks.proto
│ │ │ └── service.proto
│ │ └── type
│ │ └── task.proto
│ └── user
│ ├── service
│ │ └── user
│ │ └── v1
│ │ ├── get_user.proto
│ │ └── service.proto
│ └── type
│ └── user.proto
└── service
├── task
│ ├── buf.gen.yaml
│ ├── client # gRPCクライアントのコード
│ │ ├── package.json
│ │ └── tsconfig.json
│ ├── package.json
│ └── server # gRPCサーバのコード
│ ├── package.json
│ └── tsconfig.json
└── user
├── buf.gen.yaml
├── client # gRPCクライアントのコード
│ ├── package.json
│ └── tsconfig.json
├── package.json
└── server # gRPCサーバのコード
├── package.json
└── tsconfig.json
パッケージ管理には yarn を使用しており、マイクロサービスと相性の良い yarn workspaces を採用しています。
gRPC設計
gRPCの設計は、Googleが提供しているAPI設計ガイドに従っています。
以下に、taskサービスのgRPCの設計を示します。
taskサービスのgRPC設計
syntax = "proto3";
package task.type;
import "google/protobuf/timestamp.proto";
import "google/api/field_behavior.proto";
import "user/type/user.proto";
message Task {
string name = 1;
string display_name = 2;
string assignee_name = 3;
user.type.User assignee = 4
[(google.api.field_behavior) = OUTPUT_ONLY];
google.protobuf.Timestamp due = 5;
}
syntax = "proto3";
package task.service.task.v1;
import "google/api/field_behavior.proto";
import "task/type/task.proto";
message CreateTaskRequest {
string parent = 1 [(google.api.field_behavior) = REQUIRED];
type.Task task = 2 [(google.api.field_behavior) = REQUIRED];
}
syntax = "proto3";
package task.service.task.v1;
import "google/api/field_behavior.proto";
message DeleteTaskRequest {
string name = 1 [(google.api.field_behavior) = REQUIRED];
}
syntax = "proto3";
package task.service.task.v1;
import "google/api/field_behavior.proto";
import "task/type/task.proto";
message ListTasksRequest {
string parent = 1 [(google.api.field_behavior) = REQUIRED];
int32 page_size = 2;
string page_token = 3;
}
message ListTasksResponse {
repeated type.Task tasks = 1;
string next_page_token = 2;
int32 total_size = 3;
}
syntax = "proto3";
package task.service.task.v1;
import "google/api/annotations.proto";
import "google/protobuf/empty.proto";
import "task/type/task.proto";
import "task/service/task/v1/list_tasks.proto";
import "task/service/task/v1/create_task.proto";
import "task/service/task/v1/delete_task.proto";
service TaskService {
rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) {
option (google.api.http) = {
get: "/v1/task"
};
}
rpc CreateTask(CreateTaskRequest) returns (type.Task) {
option (google.api.http) = {
post: "/v1/task"
body: "task"
};
}
rpc DeleteTask(DeleteTaskRequest) returns (google.protobuf.Empty) {
option (google.api.http) = {
delete: "/v1/{task/*}"
};
}
}
Task の assignee のように、マイクロサービス間を跨いでスキーマを共有することも可能です。
message Task {
...
user.type.User assignee = 4
[(google.api.field_behavior) = OUTPUT_ONLY];
...
}
Bufによるコード生成
Bufの導入
Bufを使って、Typescriptの型定義を自動生成するために、まず必要なライブラリをインストールします。
yarn add -D @bufbuild/buf @bufbuild/protoc-gen-es
Bufの設定
次に、buf.yaml を作成します。
version: v2
lint:
use:
- STANDARD
except:
- RPC_REQUEST_RESPONSE_UNIQUE
- RPC_REQUEST_STANDARD_NAME
- RPC_RESPONSE_STANDARD_NAME
- PACKAGE_VERSION_SUFFIX
breaking:
use:
- FILE
deps:
- buf.build/googleapis/googleapis
一部の不要なLintルールを無効化していますが、必要に応じて変更してください。
また、Googleの公式のAPI定義を利用するため、googleapis/googleapis を deps に追加しています。
以下のコマンドにより、dep を解決します。
npx buf dep update proto
また、Proto定義のLintチェックは以下のコマンドで行います。
npx buf lint proto
Typescriptの型定義の生成
Lintチェックが通ったら、Typescriptの型定義の生成に移ります。
各種サービスのディレクトリにbuf.gen.yaml
を作成します。
version: v2
clean: true
managed:
enabled: true
plugins:
- local: protoc-gen-es
out: client/gen
opt: [target=ts, import_extension=none]
- local: protoc-gen-es
out: server/gen
opt: [target=ts, import_extension=none]
inputs:
- directory: ../../proto
paths:
- ../../proto/user
- module: buf.build/googleapis/googleapis
paths:
- google/api/annotations.proto
- google/api/field_behavior.proto
- google/api/http.proto
version: v2
clean: true
managed:
enabled: true
plugins:
- local: protoc-gen-es
out: client/gen
opt: [target=ts, import_extension=none]
- local: protoc-gen-es
out: server/gen
opt: [target=ts, import_extension=none]
inputs:
- directory: ../../proto
paths:
- ../../proto/task
- proto_file: ../../proto/user/type/user.proto
- module: buf.build/googleapis/googleapis
paths:
- google/api/annotations.proto
- google/api/field_behavior.proto
- google/api/http.proto
ここでは、クライアント用とサーバ用のコードをそれぞれ生成するように設定しています。
また、googleapis の型定義を利用するために、module
を指定しています。これを指定することにより、生成時にremoteのgoogleapisのProto定義も一緒に読み込んでTypescriptの型を生成してくれます。
さらに、taskサービスでは、userサービスの型定義を利用するために、proto_file
にuser.proto
を含めるようにしています。
各種のpackage.json
には以下のようにスクリプトを書いています。
{
"name": "user-service",
"volta": {
"extends": "../../package.json"
},
"workspaces": ["server", "client"],
"scripts": {
"buf:gen": "npx buf generate --template buf.gen.yaml && yarn check:fix",
"check:fix": "npx biome check . --fix"
}
}
以下のコマンドによって、全てのマイクロサービスの型定義を生成します。
yarn workspaces foreach -A run buf:gen
生成されたコード
生成されたコードの一部を以下に示しておきます。
生成されたコードの一部
// @generated by protoc-gen-es v2.2.2 with parameter "target=ts,import_extension=none"
// @generated from file user/type/user.proto (package user.type, syntax proto3)
/* eslint-disable */
import type { Message } from '@bufbuild/protobuf';
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv1';
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv1';
/**
* Describes the file user/type/user.proto.
*/
export const file_user_type_user: GenFile =
/*@__PURE__*/
fileDesc(
'ChR1c2VyL3R5cGUvdXNlci5wcm90bxIJdXNlci50eXBlIioKBFVzZXISDAoEbmFtZRgBIAEoCRIUCgxkaXNwbGF5X25hbWUYAiABKAlCXwoNY29tLnVzZXIudHlwZUIJVXNlclByb3RvUAGiAgNVVFiqAglVc2VyLlR5cGXKAglVc2VyXFR5cGXiAhVVc2VyXFR5cGVcR1BCTWV0YWRhdGHqAgpVc2VyOjpUeXBlYgZwcm90bzM',
);
/**
* @generated from message user.type.User
*/
export type User = Message<'user.type.User'> & {
/**
* @generated from field: string name = 1;
*/
name: string;
/**
* @generated from field: string display_name = 2;
*/
displayName: string;
};
/**
* Describes the message user.type.User.
* Use `create(UserSchema)` to create a new message.
*/
export const UserSchema: GenMessage<User> =
/*@__PURE__*/
messageDesc(file_user_type_user, 0);
// @generated by protoc-gen-es v2.2.2 with parameter "target=ts,import_extension=none"
// @generated from file user/service/user/v1/get_user.proto (package user.service.user.v1, syntax proto3)
/* eslint-disable */
import type { Message } from '@bufbuild/protobuf';
import type { GenFile, GenMessage } from '@bufbuild/protobuf/codegenv1';
import { fileDesc, messageDesc } from '@bufbuild/protobuf/codegenv1';
import { file_google_api_field_behavior } from '../../../../google/api/field_behavior_pb';
/**
* Describes the file user/service/user/v1/get_user.proto.
*/
export const file_user_service_user_v1_get_user: GenFile =
/*@__PURE__*/
fileDesc(
'CiN1c2VyL3NlcnZpY2UvdXNlci92MS9nZXRfdXNlci5wcm90bxIUdXNlci5zZXJ2aWNlLnVzZXIudjEiIwoOR2V0VXNlclJlcXVlc3QSEQoEbmFtZRgBIAEoCUID4EECQpsBChhjb20udXNlci5zZXJ2aWNlLnVzZXIudjFCDEdldFVzZXJQcm90b1ABogIDVVNVqgIUVXNlci5TZXJ2aWNlLlVzZXIuVjHKAhRVc2VyXFNlcnZpY2VcVXNlclxWMeICIFVzZXJcU2VydmljZVxVc2VyXFYxXEdQQk1ldGFkYXRh6gIXVXNlcjo6U2VydmljZTo6VXNlcjo6VjFiBnByb3RvMw',
[file_google_api_field_behavior],
);
/**
* @generated from message user.service.user.v1.GetUserRequest
*/
export type GetUserRequest = Message<'user.service.user.v1.GetUserRequest'> & {
/**
* @generated from field: string name = 1;
*/
name: string;
};
/**
* Describes the message user.service.user.v1.GetUserRequest.
* Use `create(GetUserRequestSchema)` to create a new message.
*/
export const GetUserRequestSchema: GenMessage<GetUserRequest> =
/*@__PURE__*/
messageDesc(file_user_service_user_v1_get_user, 0);
// @generated by protoc-gen-es v2.2.2 with parameter "target=ts,import_extension=none"
// @generated from file user/service/user/v1/service.proto (package user.service.user.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenService } from '@bufbuild/protobuf/codegenv1';
import { fileDesc, serviceDesc } from '@bufbuild/protobuf/codegenv1';
import { file_google_api_annotations } from '../../../../google/api/annotations_pb';
import type { UserSchema } from '../../../type/user_pb';
import { file_user_type_user } from '../../../type/user_pb';
import type { GetUserRequestSchema } from './get_user_pb';
import { file_user_service_user_v1_get_user } from './get_user_pb';
/**
* Describes the file user/service/user/v1/service.proto.
*/
export const file_user_service_user_v1_service: GenFile =
/*@__PURE__*/
fileDesc(
'CiJ1c2VyL3NlcnZpY2UvdXNlci92MS9zZXJ2aWNlLnByb3RvEhR1c2VyLnNlcnZpY2UudXNlci52MTJlCgtVc2VyU2VydmljZRJWCgdHZXRVc2VyEiQudXNlci5zZXJ2aWNlLnVzZXIudjEuR2V0VXNlclJlcXVlc3QaDy51c2VyLnR5cGUuVXNlciIUgtPkkwIOEgwvdjEve3VzZXIvKn1CmwEKGGNvbS51c2VyLnNlcnZpY2UudXNlci52MUIMU2VydmljZVByb3RvUAGiAgNVU1WqAhRVc2VyLlNlcnZpY2UuVXNlci5WMcoCFFVzZXJcU2VydmljZVxVc2VyXFYx4gIgVXNlclxTZXJ2aWNlXFVzZXJcVjFcR1BCTWV0YWRhdGHqAhdVc2VyOjpTZXJ2aWNlOjpVc2VyOjpWMWIGcHJvdG8z',
[
file_google_api_annotations,
file_user_type_user,
file_user_service_user_v1_get_user,
],
);
/**
* @generated from service user.service.user.v1.UserService
*/
export const UserService: GenService<{
/**
* @generated from rpc user.service.user.v1.UserService.GetUser
*/
getUser: {
methodKind: 'unary';
input: typeof GetUserRequestSchema;
output: typeof UserSchema;
};
}> = /*@__PURE__*/ serviceDesc(file_user_service_user_v1_service, 0);
googleapisの型はservice/user/server/gen/google/api/annotations_pb.ts
のように生成されています。
gRPCサーバの実装
APIの構築部分は Connect と Fastify の Connect 互換プラグインを使って実装します。
ライブラリのインストール
yarn workspace user-service-server add @bufbuild/protobuf @connectrpc/connect @connectrpc/connect-node fastify @connectrpc/connect-fastify
yarn workspace task-service-server add @bufbuild/protobuf @connectrpc/connect @connectrpc/connect-node fastify @connectrpc/connect-fastify
APIの実装
以下に、userサービスのgRPCサーバの実装例を示します。
import { create } from '@bufbuild/protobuf';
import { fastifyConnectPlugin } from '@connectrpc/connect-fastify';
import fastify from 'fastify';
import { UserService } from 'gen/user/service/user/v1/service_pb';
import { UserSchema } from 'gen/user/type/user_pb';
(async () => {
const server = fastify({
http2: true,
});
await server.register(fastifyConnectPlugin, {
routes: (router) =>
router.service(UserService, {
getUser: (_req, _ctx) => {
// implement your getUser logic here
return create(UserSchema, {
name: '/user/1',
displayName: 'User 1',
});
},
}),
});
await server.listen({
host: 'localhost',
port: 8080,
});
console.info('server is listening at', server.addresses());
})();
生成された型を使って型安全にAPIの開発が行えていることがわかります。
gRPCクライアントの実装
クライアントパッケージが独立してランタイムで動くように、必要なライブラリをインストールしていきます。
ライブラリのインストール
yarn workspace user-service-client add @bufbuild/protobuf
yarn workspace task-service-client add @bufbuild/protobuf
クライアントコードの実装
gRPCクライアントの方は、基本的に生成された型をexport
するだけで十分です。
以下に、userサービスのgRPCクライアントの実装例を示します。
export { UserService } from './gen/user/service/user/v1/service_pb';
export { User } from './gen/user/type/user_pb';
クライアントの利用
taskサービスのassignee
にuserサービスから取得したユーザ情報を付与する例を示します。
まず、userサービスのクライアントをパッケージとして利用できるようにビルドします。
{
"name": "user-service-client",
"volta": {
"extends": "../package.json"
},
"scripts": {
"build": "npx tsc -p tsconfig.json"
}
}
yarn workspace user-service-client run build
続いて、taskサービスのサーバーでuserサービスのクライアントをインストールします。
今回は、便宜的にファイルの相対パスで指定しています。
{
"name": "task-service-server",
"volta": {
"extends": "../package.json"
},
"dependencies": {
"@bufbuild/protobuf": "^2.2.2",
"@connectrpc/connect": "^2.0.0",
"@connectrpc/connect-fastify": "^2.0.0",
"@connectrpc/connect-node": "^2.0.0",
"fastify": "^5.1.0",
"user-service-client": "workspace:*"
}
}
yarn workspace task-service-server install
これで、以下のようにtaskサービスのサーバーでuserサービスのクライアントを利用できます。
import { createClient } from '@connectrpc/connect';
import { createConnectTransport } from '@connectrpc/connect-node';
import { UserService } from 'user-service-client';
const transport = createConnectTransport({
httpVersion: '2',
// TODO: Replace with your own base URL
baseUrl: 'http://demo.connectrpc.com',
});
export const userClient = createClient(UserService, transport);
これを用いて、taskサービスのlistTasks APIの実装を行います。
import { create } from '@bufbuild/protobuf';
import { timestampFromDate } from '@bufbuild/protobuf/wkt';
import { fastifyConnectPlugin } from '@connectrpc/connect-fastify';
import fastify from 'fastify';
import { ListTasksResponseSchema } from 'gen/task/service/task/v1/list_tasks_pb';
import { TaskService } from 'gen/task/service/task/v1/service_pb';
import { TaskSchema } from 'gen/task/type/task_pb';
import { userClient } from 'user-service';
(async () => {
const server = fastify({
http2: true,
});
await server.register(fastifyConnectPlugin, {
routes: (router) =>
router.service(TaskService, {
listTasks: async (_req, _ctx) => {
const assignee = await userClient.getUser({
name: '/user/1',
});
const task = create(TaskSchema, {
name: '/task/1',
displayName: 'Task 1',
assigneeName: assignee.name,
assignee,
due: timestampFromDate(new Date()),
});
return create(ListTasksResponseSchema, {
tasks: [task],
nextPageToken: '',
totalSize: 1,
});
},
createTask: (_req, _ctx) => {
throw new Error('not implemented');
},
deleteTask: (_req, _ctx) => {
throw new Error('not implemented');
},
}),
});
await server.listen({
host: 'localhost',
port: 8080,
});
console.info('server is listening at', server.addresses());
})();
まとめ
いかがだったでしょうか。
マイクロサービス化で責務分離をしつつ、サービス間でスキーマを共有することで、開発効率を向上させることができます。
また、Bufを用いて型定義を自動生成することで、型安全な開発を行うことができます。
今後のマイグレーションで、googleapisのfield_behavior
などにもTypescriptのコード生成時に対応するなど、さらなる快適なgRPC × Node.jsの開発ライフを送れることを期待しています。
Discussion