LoginSignup
1

posted at

gRPC の Node.js(TypeScript) 用スタブを自動生成する

最近 gRPC というものが気になり始めたので、少し触れてみようと思った。
gRPC は元々 Google によって開発されたサービス間通信技術(プロトコル)であり、現在はオープンソース化している。
関数を呼び出すようにクライアントからサーバーにリクエストすることができ、Protocol Buffers と呼ばれる技術によってデータのやり取りがなされる。
最近では、マイクロサービス間通信によく用いられるらしい。
詳しい背景や仕様などは専門書や web を参照していただきたい。

やりたかったこと

なんとなく gRPC のイメージがついたところで、実際の開発現場においてどのように導入し、サービスを作っていくのか の最初の部分を見ていきたいと思う。

初めに

gRPC では、上述のように Protocol Buffers と言う技術が用いられるが、具体的には .proto という拡張子のファイルにサービス(インターフェース)とメッセージ(データ)を定義して、それに基づいて通信に必要なメソッドなどを記述していく。
この時 gRPC 環境を構築するアプローチとしては2パターンあり、①動的に .proto ファイルをロードする方法と、②静的に .proto ファイルをコンパイルする方法である。
今回は、TypeScript の型情報も併せて生成できる②の方法でやってみた。

アーキテクチャ

architecture1.png

クライアント(Postman) から Express で立てた gRPC クライアントに対して REST API でリクエストを投げる。
そのリクエストをもとに gRPC サーバーに gRPC 通信でリクエストを投げ、レスポンスを得た後、gRPC クライアントは Postman に返却する。
仮にマイクロサービス間通信を想定した時、Postman(REST API 通信) に当たる部分はなく、gRPC サーバー/クライアント による構成になると思われるが、今回は外部から入力を行うためこのような構成になっている。

作業

ライブラリインストール

gRPC サーバーに通信するためのクライアント構築用

npm install express

gRPC for TypeScript のオートジェネレーション用

npm install grpc-tools @grpc/grpc-js ts-protoc-gen @improbable-eng/grpc-web

例として、このような構成になる。

package.json
{
  "dependencies": {
    "express": "^4.18.2",
    "fs": "^0.0.1-security",// gRPC サーバーが返すデータを json ファイルから引っ張ってくる構成にしており、それをロードするために使用
    "@grpc/grpc-js": "^1.8.0",
    "@improbable-eng/grpc-web": "^0.15.0",
    "grpc-tools": "^1.12.3",
    "ts-protoc-gen": "^0.15.0"
  },
  "devDependencies": {
    "@types/express": "^4.17.15",
    "ts-node": "^10.9.1",
    "tsconfig-paths": "^4.1.2"
  },
  "scripts": {
    "dev:server": "ts-node -r tsconfig-paths/register src/server.ts",
    "dev:client": "ts-node -r tsconfig-paths/register src/client.ts"
  }
}

サービス定義

プロジェクトルートで .proto ファイルを作成し、gRPC 通信に必要な、サービスとメッセージを定義する。

touch user.proto
user.proto
syntax = "proto3";

package user;

import "google/protobuf/empty.proto";
import "google/protobuf/wrappers.proto";

service User {
  rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
  rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {}
  rpc AllUsers(google.protobuf.Empty) returns (ListUsersResponse) {}
}

message GetUserRequest {
  int32 id = 1;
}

message GetUserResponse {
  UserInfo user = 1;
}

message ListUsersRequest {
  google.protobuf.Int32Value limit = 1;
  google.protobuf.Int32Value offset = 2;
}

message ListUsersResponse {
  int32 total = 1;
  repeated UserInfo users = 2;
}

message UserInfo {
  int32 id = 1;
  string email = 2;
  string full_name = 3;
  int64 created_at = 4;
  int64 updated_at = 5;
}

詳細は割愛するが、ポイントは以下になる。

  • syntax = "proto3"; で使用する Protocol Buffers のバージョンを指定する。
  • service User {} でサービスを定義する。
    • 例) GetUser が関数名で、GetUserRequest がその関数に渡すパラメータの引数の型、GetUserResponse がその関数が返すメッセージの型を指定する。
  • message xxx {} でメッセージの型を定義する。
    • 例) GetUserRequest は int32 型の id というデータを受けとる。
    • message はネストできる。例) GetUserResponse の中に UserInfo がある。

コンパイル

プロジェクトルートに、下記のようなシェルスクリプトファイルを作成する。

sh/ts-gen-service.sh
# protoc(proto ファイルから各種ファイルを生成するコンパイラ)のパス
NODE_PROTOC="./node_modules/.bin/grpc_tools_node_protoc"

# TypeScript 用のファイルを生成するためのプラグインのパス
PROTOC_GEN_TS_PATH="./node_modules/.bin/protoc-gen-ts"

# 生成ファイルを格納するフォルダパス
OUT_DIR="./generated"

# proto のコンパイル
$NODE_PROTOC \
    --plugin="protoc-gen-ts=${PROTOC_GEN_TS_PATH}" \
    --js_out="import_style=commonjs,binary:${OUT_DIR}" \
    --ts_out="service=grpc-node,mode=grpc-js:${OUT_DIR}" \
    --grpc_out="grpc_js:${OUT_DIR}" \
    user.proto

コンパイル時のポイントとしては、以下が挙げられる。

  • TypeScript 用のプラグインを指定する(--plugin=...)
  • TypeScript 用生成ファイルの設定を指定する(--ts-out=...)
    • 本家のドキュメントでは、service=grpc-web を指定しているが、これだと型定義ファイルが出力されないため、service=grpc-node を指定する必要がある。

生成ファイルを格納するフォルダを作成し、

mkdir generated

上記のシェルを実行する。

sh/ts-gen-service.sh 

すると、generated/ 配下にいくつかのファイルが生成される。

tree generated
generated
├── user_grpc_pb.d.ts
├── user_grpc_pb.js
├── user_pb.d.ts
└── user_pb.js

これらが本記事のコアとなる部分であり、TypeScript 用の自動生成された型情報などが含まれている。
ざっくりと解説すると、以下のような内容となる。

  • user_grpc_pb.js: gRPC サービスの内容が書かれたもの(user_grpc_pd.d.ts はその型定義)
  • user_pb.js: gRPC メッセージの内容が書かれたもの(user_pd.d.ts はその型定義)

この後の流れとしては、これらの自動生成されたファイルを用いて、サービスの実装を行なっていく。

サービスの実装

gRPC サーバー側

src/server.ts
import * as fs from 'fs';
import * as grpc from '@grpc/grpc-js';
import { UserService } from '../generated/user_grpc_pb';
import { UserInfo, GetUserResponse, ListUsersResponse } from '../generated/user_pb';

const dumyUsers = JSON.parse(fs.readFileSync('./db/user.json', 'utf8'));

const getUser = (call, callback) => {
  const user = dumyUsers.filter((dumyUser) => dumyUser.id === call.request.getId()).shift();

  if (user) {
    const userInfo = new UserInfo();
    userInfo.setId(user.id);
    userInfo.setEmail(user.email);
    userInfo.setFullName(user.fullName);
    userInfo.setCreatedAt(user.createdAt);
    userInfo.setUpdatedAt(user.updatedAt);

    const reply = new GetUserResponse();
    reply.setUser(userInfo);

    return callback(null, reply);
  }

  return callback({
    code: grpc.status.NOT_FOUND,
    message: 'user not found'
  });
};

const listUsers = (call, callback) => {
  const limit = call.request.hasLimit() ? call.request.getLimit() : 10;
  const offset = call.request.hasOffset() ? call.request.getOffset() : 0;

  const reply = new ListUsersResponse();
  reply.setTotal(dumyUsers.length);

  const users = dumyUsers.slice(offset).slice(0, limit);
  users.forEach((user, index) => {
    const userInfo = new UserInfo();
    userInfo.setId(user.id);
    userInfo.setEmail(user.email);
    userInfo.setFullName(user.fullName);
    userInfo.setCreatedAt(user.createdAt);
    userInfo.setUpdatedAt(user.updatedAt);
    reply.addUsers(userInfo, index);
  });

  callback(null, reply);
};

const allUsers = (call, callback) => {
  // 省略
};

const server = new grpc.Server();

server.addService(UserService, {
  getUser,
  listUsers,
  allUsers
});

server.bindAsync('0.0.0.0:50051', grpc.ServerCredentials.createInsecure(), (e, port) => {
  if (e) console.error(e);
  server.start();
  console.log(`server start listing on port ${port}`);
});

gRPC クライアント側

src/clients/user.ts
import * as grpc from '@grpc/grpc-js';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import { UserClient } from '../../generated/user_grpc_pb';
import { GetUserRequest, GetUserResponse, ListUsersRequest, ListUsersResponse } from '../../generated/user_pb';

const client = new UserClient('0.0.0.0:50051', grpc.credentials.createInsecure());

const getUser = (id): Promise<GetUserResponse> => {
  const request = new GetUserRequest();
  request.setId(id);

  return new Promise((resolve, reject) => {
    client.getUser(request, (err, response) => {
      if (err) reject(err);
      if (response === undefined) return;
      resolve(response);
    });
  });
};

const listUsers = (limit?: number, offset?: number): Promise<ListUsersResponse> => {
  const request = new ListUsersRequest();
  if (!!limit) request.setLimit(limit);
  if (!!offset) request.setOffset(offset);

  return new Promise((resolve, reject) => {
    client.listUsers(request, (err, response) => {
      if (err) reject(err);
      if (response === undefined) return;
      resolve(response);
    });
  });
};

const allUsers = () => {
  // 省略
};

export { getUser, listUsers, allUsers };

また、クライアント(Postman)からのリクエストを受ける Express サーバーを立てる。

src/client.ts
import * as grpc from '@grpc/grpc-js';
import { Empty } from 'google-protobuf/google/protobuf/empty_pb';
import { UserClient } from '../../generated/user_grpc_pb';
import { GetUserRequest, GetUserResponse, ListUsersRequest, ListUsersResponse } from '../../generated/user_pb';

const client = new UserClient('0.0.0.0:50051', grpc.credentials.createInsecure());

const getUser = (id): Promise<GetUserResponse> => {
  const request = new GetUserRequest();
  request.setId(id);

  return new Promise((resolve, reject) => {
    client.getUser(request, (err, response) => {
      if (err) reject(err);
      if (response === undefined) return;
      resolve(response);
    });
  });
};

const listUsers = (limit?: number, offset?: number): Promise<ListUsersResponse> => {
  const request = new ListUsersRequest();
  if (!!limit) request.setLimit(limit);
  if (!!offset) request.setOffset(offset);

  return new Promise((resolve, reject) => {
    client.listUsers(request, (err, response) => {
      if (err) reject(err);
      if (response === undefined) return;
      resolve(response);
    });
  });
};

const allUsers = () => {
  // 省略
};

export { getUser, listUsers, allUsers };

dummy DB の準備

gRPC サーバーから接続するであろう DB を json ファイルの内容で代替する。
src/server.tsconst dumyUsers = JSON.parse(fs.readFileSync('./db/user.json', 'utf8')); の部分でロードしている。

mkdir db
touch db/user.json

プロパティを一致させさえすれば、内容はお好みでカスタマイズしても構わない。

user.json
[
  {
    "id": 1,
    "email": "xxx@test1.com",
    "fullName": "Michael",
    "createdAt": 1672760237,
    "updatedAt": 1672760237
  },
  {
    "id": 2,
    "email": "yyy@test2.com",
    "fullName": "Jonathan",
    "createdAt": 1672760237,
    "updatedAt": 1672760237
  }
]

実行してみる

サーバーの起動

# gRPC サーバーの起動
npm run dev:server
# server start listing on port 50051

# gRPC クライアントの起動
npm run dev:client
# Start on port 3003.

gRPC サーバー/クライアントを起動すると、それぞれ 50051/3003 ポートでリッスンするようにしている。

Postman 上での確認

  • localhost:3003/users/1 へのリクエスト結果:
{
    "user": {
        "id": 1,
        "email": "xxx@test.com",
        "fullName": "Michael",
        "createdAt": 1672760237,
        "updatedAt": 1672760237
    }
}
  • localhost:3003/users へのリクエスト結果:
{
    "total": 2,
    "usersList": [
        {
            "id": 1,
            "email": "xxx@test.com",
            "fullName": "Michael",
            "createdAt": 1672760237,
            "updatedAt": 1672760237
        },
        {
            "id": 2,
            "email": "yyy@test.com",
            "fullName": "Yasukitie",
            "createdAt": 1672760237,
            "updatedAt": 1672760237
        }
    ]
}

このように、Postman から gRPC クライアントへは REST API により通信し、gRPC クライアント から gPRC サーバーへは gRPC 通信することでリクエストおよびレスポンスのやりとりを実現することで、無事情報を取得することができた。

(おまけ)gRPC サーバーのみの動作確認を行う

上記実装では、エンドユーザー(Postman)からのリクエストと言うわかりやすい形で一連の通信を確認した。
マイクロサービス間の通信を考える場合、gRPC サーバーのみの動作確認を行う場合もあると思うが、そのような時にお手軽にチェックする方法として、BloomRPC という GUI ツールを使用するものがある。

インストール

# install BloomRPC for Mac
brew install --cask bloomrpc

実行

image.png

BloomRPC を起動し、以下の手順で gRPC サーバーへのリクエストを行う。

  1. (+) ボタンをクリックし、proto ファイルを選択する。
  2. サービスを選択する。
  3. リクエスト内容を記述する。
  4. 接続先を指定する。
  5. (▶️) をクリックし、コールを行う。
  6. レスポンスを確認する。

先ほどの Postman 上での確認と同様な結果になっているのがわかる。
(厳密には異り得る。gRPC サーバーが返すレスポンスを受けて、gRPC クライアント側がどのように Postman に返却するかに依るため)

今後の予定

gRPC はストリーミングにも対応している。(ちなみに今回の通信は Unary RPC と呼ばれるもの)
そのあたりを勉強していきたい。

:book: 参考にさせていただいた記事

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
1