👀

【最新版】gRPC × Node.jsでのマイクロサービス開発のベストプラクティス

2024/11/25に公開

はじめに

弊社では、ある程度の規模のあるバックエンドを開発する際、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設計
proto/task/type/task.proto
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;
}
proto/task/service/task/v1/create_task.proto
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];
}
proto/task/service/task/v1/delete_task.proto
syntax = "proto3";

package task.service.task.v1;

import "google/api/field_behavior.proto";

message DeleteTaskRequest {
  string name = 1 [(google.api.field_behavior) = REQUIRED];
}
proto/task/service/task/v1/list_tasks.proto
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;
}
proto/task/service/task/v1/service.proto
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 のように、マイクロサービス間を跨いでスキーマを共有することも可能です。

proto/task/type/task.proto
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 を作成します。

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を作成します。

service/user/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
service/task/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/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_fileuser.protoを含めるようにしています。

各種のpackage.json には以下のようにスクリプトを書いています。

service/user/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

生成されたコード

生成されたコードの一部を以下に示しておきます。

生成されたコードの一部
service/user/server/gen/user/type/user_pb.ts
// @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);
service/user/server/gen/user/service/user/v1/get_user_pb.ts
// @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);
service/user/server/gen/user/service/user/v1/service_pb.ts
// @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サーバの実装例を示します。

service/user/server/index.ts
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クライアントの実装例を示します。

service/user/client/index.ts
export { UserService } from './gen/user/service/user/v1/service_pb';

export { User } from './gen/user/type/user_pb';

クライアントの利用

taskサービスのassigneeにuserサービスから取得したユーザ情報を付与する例を示します。

まず、userサービスのクライアントをパッケージとして利用できるようにビルドします。

service/user/client/package.json
{
  "name": "user-service-client",
  "volta": {
    "extends": "../package.json"
  },
  "scripts": {
    "build": "npx tsc -p tsconfig.json"
  }
}
yarn workspace user-service-client run build

続いて、taskサービスのサーバーでuserサービスのクライアントをインストールします。
今回は、便宜的にファイルの相対パスで指定しています。

service/task/server/package.json
{
  "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サービスのクライアントを利用できます。

service/task/server/user-service.ts
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の実装を行います。

service/task/server/index.ts
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の開発ライフを送れることを期待しています。

mutex Official Tech Blog

Discussion