動的に.protoファイルを読み込む@grpc/proto-loaderを利用して、Node.jsでgRPCを動かしてみた
はじめに
こんにちは、SHIFTの開発部門に所属している Katayama です。
マイクロサービス間の内部通信には gRPC を利用することがあると思う。今回は Node.js で gRPC 通信を.proto ファイル(Protocol Buffers)を動的に読み込む@grpc/proto-loaderを利用してやってみた。
※Node.js のプロジェクトにおける.proto ファイル(Protocol Buffers)の環境設定に関しては、Node.js で gRPC を実装する前準備 .proto ファイルの自動整形するための設定をやってみたなど他の記事を参照ください。
※gRPCを利用する理由や、RESTとの比較などについては他の記事(gRPCと従来のREST APIの比較やgRPC vs REST: comparing APIs architectural stylesなど)を参照ください。
※Node.js で import・export(ES6 の構文)を使えるように webpack × Babel の設定をやってみたに書かれているように Babel を利用してトランスパイルを行うプロジェクトである事が、本記事に書かれている内容を実践するための前提条件になっています。
Node.js で gRPC 通信の全体像
今回は以下のような構成を想定して、gRPC 通信をやってみようと思う(ただし、DBへのアクセスは簡易的にモックにした)。
※gRPC サーバーを実装して、ちょっとした動作確認(REST API でいう所の Postman で確認みたいなイメージ)をしたい場合には、「おまけ」の「REST API でいう所の Postman の gRPC 版を使ってみる」で取り上げているBloomRPCなどを利用するのも便利だろう。
.proto ファイル(Protocol Buffers)について
今回は簡単にユーザー情報だけをやり取りする Protocol Buffers を定義する。中身としては以下のような感じ。指定されたユーザー ID のユーザー情報を返す GetUser と、存在するユーザーの情報を全て返す ListUsers の 2 つをサービスとして定義している。
syntax = "proto3";
package user;
service UserService {
rpc GetUser(GetUserRequest) returns (GetUserResponse) {}
rpc ListUsers(ListUsersRequest) returns (ListUsersResponse) {}
}
message GetUserRequest {
int32 id = 1;
}
message GetUserResponse {
User user = 1;
}
message ListUsersRequest {
int32 limit = 1;
int32 offset = 2;
}
message ListUsersResponse {
int32 total = 1;
repeated User users = 2;
}
message User {
int32 id = 1;
string email = 2;
string full_name = 3;
int64 created_at = 4;
int64 updated_at = 5;
}
上記の.proto ファイル(Protocol Buffers)で利用できる型の内、標準で備わっているものはScalar Value Typesに記載がある。また、Google が用意しているよく使いそうな型というのもある(Package google.protobuf)。これも以下のようにして利用できる(ちなみに、timestamp.proto はGitHubからその中身を見る事ができる)。
import "google/protobuf/timestamp.proto";
message User {
int32 id = 1;
string email = 2;
string full_name = 3;
google.protobuf.Timestamp created_at = 4;
google.protobuf.Timestamp updated_at = 5;
}
gRPC のサーバー側の実装
サーバー側の実装については gRPC の公式のQuick startなどが参考になる。
実装する内容としては、上記の「.proto ファイル(Protocol Buffers)について」で取り上げた Protocol Buffers の読み込みと、Protocol Buffers に定義されたサービスの実装の 2 つになる(あくまで Protocol Buffers はインターフェースを定義しているだけなので、その中身の実装が必要)。
今回は簡略化した実装として、JSON データを読み取りそれを DB のデータだと仮定して実装してみる。実装としては以下のようになる(以下はNode.js で import・export(ES6 の構文)を使えるように webpack × Babel の設定をやってみたで取り上げたような方法で Babel によるトランスパイルしている)。
import fs from "fs";
import camelcaseKeys from "camelcase-keys";
import path from "path";
import * as grpc from "@grpc/grpc-js";
import * as protoLoader from "@grpc/proto-loader";
// read json for local
const dumyUsers = camelcaseKeys(
JSON.parse(fs.readFileSync("./db/user.json", "utf8")),
{ deep: true }
);
const PROTO_PATH = path.resolve(__dirname, "../src/protos/user.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: false,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
const getUser = (call, callback) => {
const user = dumyUsers
.filter((dumyUser) => dumyUser.id === call.request.id)
.shift();
if (user) return callback(null, { user });
return callback({
code: grpc.status.NOT_FOUND,
message: "user not found",
});
};
const listUsers = (call, callback) => {
const { limit, offset } = call.request;
callback(null, {
total: dumyUsers.length,
users: dumyUsers.slice(offset).slice(0, limit),
});
};
const server = new grpc.Server();
server.addService(userProto.UserService.service, {
getUser,
listUsers,
});
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}`);
}
);
上記のコードに関して少し補足をする。
protoLoader
protoLoader についての詳細はgRPC Protobuf Loaderに記載がある。オプションに設定できる項目はUsageにまとめられている。
今回は、以下のような設定を行っている。
keepCase
gRPC の世界では snake_case が推奨されているが、JavaScript の世界では camelCase なのでそのケース変換を暗黙的に行うかどうかの設定で、今回は keep しない=暗黙的にケース変換する、という設定にしている。
longs/enums
long 値・enum 値を表現するために使用する型を設定するためのオプションで、今回は String(文字列)として扱うように設定している。
defaults
Protocol Buffers の定義にあるフィールドが省略された場合、そのレスポンスをDefault Valuesに書かれているデフォルト値にするかどうか?を設定するためのオプション。今回は true にしているので、例えば以下のような Protocol Buffers でサービスの実装が"callback(null, {});"の場合、レスポンスは数値型のデフォルト値である 0 になる(以下、公式からの引用)。
message GetUserResponse {
int32 id = 1;
}
ちなみに、上記の Protocol Buffers のようにカスタムの message である場合、各フィールドの型に合わせてデフォルト値が設定される(以下は、callback(null, { user: {} });と実装した場合の実行結果)。
oneofs
複数の型のどれか 1 つを値として持つフィールドを定義する時に使う oneof の設定を行うオプションで、今回は true にしているので、例えば、以下のような Protocol Buffers の書き方ができる。
message GetUserResponse {
oneof result {
User user = 1;
Error error = 2;
}
}
getUser/listUsers
この関数が実際の Protocol Buffers に定義されたサービスの実装であり、今回はダミーのユーザー情報をリクエストに応じて返す実装をしている。
サービスの実装については、今回の実装が、クライアントが単一のリクエストを送信し、単一のレスポンスを取得する最も単純なタイプ(Unary RPC)の実装になるので、handleCallの中のhandleUnaryCall(call, callback)を実装している。callback のメソッドの仕様はsendUnaryData(error, value [, trailer] [, flags])に書かれている通りで、第一引数がエラーオブジェクトで、第二引数がレスポンスの値になる(第三、第四についてはここでは割愛する)。
エラーハンドリング
Sample codeの章に書かれているサンプルのリポジトリのコードが参考になる。今回の実装では、ユーザーが見つからない場合に、REST API で言う所の 404 を返すような実装になっている。
const getUser = (call, callback) => {
const user = dumyUsers
.filter((dumyUser) => dumyUser.id === call.request.id)
.shift();
if (user) return callback(null, { user });
return callback({
code: grpc.status.NOT_FOUND,
message: "user not found",
});
};
エラーについては、リファレンスのhandleUnaryCall(call, callback)の callback(sendUnaryData(error, value [, trailer] [, flags]))の部分に定義がある。ServiceErrorの記述通り、エラーオブジェクトは code と message、metadata の 3 つのフィールドのみで定義される。
※ちなみに、リクエストが以下のように Protocol Buffers の定義とズレている場合のリクエストがどうなるか?だが、どうやら配列でない場合はデフォルトの値が設定され、配列の場合は空の配列になるようである。
grpc.Server()/server.addService()/server.bindAsync()
公式のgrpc. Serverに詳細が書かれているが、ここでやっている事としては、gRPC のサーバーを作成し、そのサーバーに Protocol Buffers に定義されたサービスの実装を追加。そして、bindAsyncで与えられたポートにサーバーをバインディングし、それが完了し callback 関数が呼ばれエラーが発生していなければそのままサーバーをスタートする、という実装になっている。
1 点、API リファレンスの方では callback 関数がないように見えるが、実際のコードを見ると callback 関数が第 3 引数に定義できる仕様になっている事が確認できる。
上記のように実装したサーバーは webpack × Babel によりビルドした後、以下のように起動できる。
[study@localhost node-grpc]$ node dist/server.js
server start listing on port 50051
gRPC のクライアント側の実装
続いて、Node.js Express サーバー(クライアント)の方を実装してみる。実装としては、ユーザーから REST API で Express にリクエストが来て、そのリクエストに基づいて Express から gRPC 通信で gRPC サーバーからデータを取得し、それを最終的にユーザーに返すというもの。
実装としては以下のようになった。1 点、公式のrequestCallback(error, value)に書かれている通り、gRPC クライアントでリクエストを送った後のレスポンスはコールバック関数で受け取る事になるので、そこは async/await を利用して実装できるように Promise にしている。
// src/client/index.js
import express, { Router } from "express";
import { getUser, listUsers } from "./lib/user";
const app = express();
const router = Router();
app.use(express.json());
app.use("/api/v1", router);
router.get("/user/:id", async (req, res) => {
try {
const { user } = await getUser(req.params.id);
res.status(200).json(user);
} catch (error) {
console.error(error);
res.status(500).json({ message: error.message });
}
});
router.get("/users", async (req, res) => {
const { limit, offset } = req.query;
try {
const { total, users } = await listUsers(limit, offset);
res.status(200).json({ total, users });
} catch (error) {
console.error(error);
res.status(500).json({ message: error.message });
}
});
app.listen(3000, () => console.log("listening on port 3000!"));
// src/client/lib/user.js
// 省略
const PROTO_PATH = path.resolve(__dirname, "../src/protos/user.proto");
const packageDefinition = protoLoader.loadSync(PROTO_PATH, {
keepCase: true,
longs: String,
enums: String,
defaults: true,
oneofs: true,
});
const userProto = grpc.loadPackageDefinition(packageDefinition).user;
const client = new userProto.UserService(
"0.0.0.0:50051",
grpc.credentials.createInsecure()
);
const getUser = (id) =>
new Promise((resolve, reject) => {
client.getUser({ id }, (err, response) => {
if (err) reject(err);
resolve(response);
});
});
const listUsers = (limit, offset) =>
new Promise((resolve, reject) => {
client.listUsers({ limit, offset }, (err, response) => {
if (err) reject(err);
resolve(response);
});
});
export { getUser, listUsers };
上記のように実装したクライアント(Expressのサーバー)は webpack × Babel によりビルドした後、以下のように起動できる。
[study@localhost node-grpc]$ node dist/client.js
listening on port 3000!
また、実際に Express に対して REST API を呼び出すと、Express 内部で gRPC 通信を行い、ユーザー(REST API を呼び出した人)にレスポンスが返ってくる事が確認できる。
[study@localhost node-grpc]$ curl localhost:3000/api/v1/user/1
{"id":1,"email":"","full_name":"","created_at":"0","updated_at":"0"}
[study@localhost node-grpc]$ curl 'localhost:3000/api/v1/users?limit=3&offset=0'
{"total":3,"users":[{"id":1,"email":"yamada@example.com","full_name":"hanako yamada","created_at":"1655700591","updated_at":"1655700591"},{"id":2,"email":"suzuki@example.com","full_name":"tarou suzuki","created_at":"1655700591","updated_at":"1655700591"},{"id":3,"email":"tarou@example.com","full_name":"tarou tanaka","created_at":"1655700591","updated_at":"1655700591"}]}
まとめとして
今回は@grpc/proto-loader を利用して、動的に.proto ファイル(Protocol Buffers)を読み込む方式で gRPC の実装をやってみた。gRPC の実装はやった事がなかったが、Protocol Buffers を記述してサービスの実装をするだけで、簡単な gRPC サーバーとクライアントを実装する事ができ、思ったよりも最初の 1 歩のハードルは低かったと感じた。今後は今回実装したUnary RPC以外の通信もやってみたいと思った。また、今回はバックエンドのサーバ間通信だったが、ブラウザ(Web)との通信をするというのも合わせて試してみたいと思った。
※今回は@grpc/proto-loader を利用していたが、この方法以外にもプロトコル定義ファイルからコードを生成する方式もあり、.proto ファイル(Protocol Buffers)からgrpc-toolsを利用して必要なファイルを生成するというもの。今後、静的な実装パターンについてもやってみたいと思った。
おまけ
REST API でいうところの Postman の gRPC 版を使ってみる
gRPC サーバーの実装だけをして、軽く動作確認をしたい時には、BloomRPCなどを利用できる。使い方を解説している記事は他にあるのでここでは割愛するが、実際に使ってみると、以下の動画の通り簡単にリクエストを送り、そのレスポンスを確認する事ができる。
参考:デバックに関して
参考文献
《この公式ブロガーの記事一覧》
お問合せはお気軽に
https://service.shiftinc.jp/contact/
SHIFTについて(コーポレートサイト)
https://www.shiftinc.jp/
SHIFTのサービスについて(サービスサイト)
https://service.shiftinc.jp/
SHIFTの導入事例
https://service.shiftinc.jp/case/
お役立ち資料はこちら
https://service.shiftinc.jp/resources/
SHIFTの採用情報はこちら
https://recruit.shiftinc.jp/career/