OK Google, Protocol Buffers から生成したコードを使って Node.js で gRPC 通信して

Mercari Advent Calendar 2020 の17日目は、メルカリ WebUX チーム でテックリードマネージャをしている @vwxyutarooo がお送りします。普段はエンジニアリングマネージャやテックリードをしたりフロントエンドエンジニアをしています。今日は Node.js で gRPC 通信します。なぜなら Node.js で gRPC 通信したいからです。

Mercari ではバックエンドに microservices アーキテクチャを採用していて、gRPC も採用されています。多くの microservices は go 言語によって実装されていますが、一部のサービスでフロントエンドエンジニアとのコミュニケーションや on-call 対応の受け入れ体制を広く柔軟にするために Node.js が採用されたものがあります。 ライブラリや実装方法には幾つかの方法がありますが、特に DX 向上に大きく寄与する Protocol Buffers のコード生成を利用する方法を紹介します。

gRPC とは

gRPC (gRPC Remote Procedure Calls) は、Googleが開発を開始したオープンソースのリモートプロシージャコール (RPC) システムである。gRPCは、HTTP/2をトランスポートとして利用し、Protocol Bufferssをインタフェース記述言語として利用する。gRPCは認証や双方向のストリーミング、フロー制御、ブロッキングとノンブロッキングのバインディング、取り消しとタイムアウトといった機能を提供している。多くの言語でクロスプラットフォームのクライアントとサーバーのバインディングが利用できる。最も一般的な利用シナリオとしては、マイクロサービス型のアーキテクチャーにおける接続サービスや、モバイルデバイスの接続、バックエンドサービスへのブラウザーからの接続がある。

https://ja.wikipedia.org/wiki/GRPC

Protocol Buffers とは

Protocol Bufferss(プロトコルバッファー)はインタフェース定義言語 (IDL) で構造を定義する通信や永続化での利用を目的としたシリアライズフォーマットであり、Googleにより開発されている。オリジナルのGoogle実装はC++、Java、Pythonによるものであり、フリーソフトウェアとしてオープンソースライセンスで公開されている

https://ja.wikipedia.org/wiki/Protocol_Buffers

gRPC 通信を試す

今回紹介するコードは vwxyutarooo/grpc-node-demo で公開しているので中身だけ気になる方はこちらをどうぞ。

1. Protocol Buffers を記述

最もプリミティブな方法は @grpc/proto-loader を使い Rest API 同様リクエストとレスポンスのコードを自ら記述する方法ですが、非常に冗長かつ型の恩恵も受けることが出来ません。Protocol Buffers ははインタフェースを定義する言語であり、様々な言語に対しコードを生成するコンパイラを提供しているためこれを使わない手はありません。これにより Protocol Buffers を元にサーバやサーバとの通信の実装、及び型定義を生成することができます。 サポートしている言語は Official Support から確認でき、Node.js もその対象です。

ということでまずは Protocol Buffers を用意します。今回は gRPC のリポジトリにある Hello World のサンプルをベースにします。 適当にワーキングスペースを用意して .proto ファイルを作りましょう。

$ mkdir grpc-node-demo
$ cd grpc-node-demo
$ mkdir src src/proto
$ touch src/proto/helloworld.proto

helloworld.proto の中身はこんな感じになります。 Greeter という Service を定義し、リクエストパラメータとレスポンスを message タイプで定義します。

// Copyright 2015 gRPC authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
//     http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

syntax = "proto3";

option java_multiple_files = true;
option java_package = "io.grpc.examples.helloworld";
option java_outer_classname = "HelloWorldProto";
option objc_class_prefix = "HLW";

package helloworld;

// The greeting service definition.
service Greeter {
  // Sends a greeting
  rpc SayHello (HelloRequest) returns (HelloReply) {}
}

// The request message containing the user's name.
message HelloRequest {
  string name = 1;
}

// The response message containing the greetings
message HelloReply {
  string message = 1;
}

Protocol Buffers からコードを生成

Google から protoc という CLI のツールが提供されていて、Node.js の場合 grpc-tools という npm パッケージに同胞されています (Mac の場合 $ brew install protobuf でもインストール可能です)。 TypeScript で記述したいので、型定義ファイルを生成するために grpc_tools_node_protoc_ts というプラグインも併用します。 @grpc/grpc-js と google-protobuf は生成されるコードから参照される peer dependencies になります。 npm のセットアップをしましょう。

依存関係:

$ npm init -y
$ npm install grpc-tools grpc_tools_node_protoc_ts --save-dev
$ npm install @grpc/grpc-js google-protobuf

依存関係が整ったらコードを生成するためのコマンドを用意します。 src/proto/ 配下にある全ての .proto ファイルから gen フォルダにコンパイル後のコードを吐き出します。 grpc_tools_node_protocprotoc コマンドが受け付ける全てのオプションを指定可能です。また、--grpc_out=grpc_js:/path を指定することで grpc パッケージの代わりに @grpc/grpc-js を指定できます。

gprc は C 言語ベースの Node.js アドオンなのに対し、@grpc/grpc-js は 100% JS で書かれたパッケージです。grpc-js が後発でしたが、幾つかの機能を除き実装が進められています。対応する機能は Feature comparison of grpc and @grpc/grpc-js packages で比較することができ、モニタリングやロードバランシングなど幾つかの機能が未対応/限定的となっています。 grpc の開発はバグフィクスを除いて終了しており、2021年の春頃に deprecated となる計画です。まだ全ての機能がサポートされているわけではなく、production であれば grpc を使いたいところですが、デモなので今回は @grpc/grpc-js を使います。 grpc から @grpc/grpc-js に移行した場合の実装の差分がこれです。 型定義で用意される I{ServiceName}Server は利用できなくなります (@ts-ignore すれば動く)。また、server#bind が無くなり server#bindAsync を使う必要があります。それ以外の変更は named export に起因するものです。

オプションが多くて複雑になりがちなので Makefile を使います。

# Makefile
OUTPUT=gen
NPM_BIN=$(shell npm bin)

GRPC_TOOL=$(NPM_BIN)/grpc_tools_node_protoc
TYPESCRIPT_PLUGIN=protoc-gen-ts=$(NPM_BIN)/protoc-gen-ts

COMMAND=$(GRPC_TOOL) --plugin=${TYPESCRIPT_PLUGIN} --js_out=import_style=commonjs,binary:$(OUTPUT) --grpc_out=grpc_js:$(OUTPUT) --ts_out=grpc_js:$(OUTPUT) -I ./src ./src/proto/*.proto

.PHONY: protogen
protogen:
    rm -rf $(OUTPUT) && mkdir -p $(OUTPUT)
    # generate js and .d.ts codes via grpc-tools 
    $(COMMAND)

$ make protogen すると以下のファイルが出力されます。*_grpc_pd.js は名前の通り gRPC に対応する Server/Client を提供するサービスです。*_pb.js は proto に定義されたメッセージに対応するミドルウェアのようなものです。

  • helloworld_grpc_pb.d.ts
  • helloworld_grpc_pb.js
  • helloworld_pb.d.ts
  • helloworld_pb.js

2. gRPC サーバを用意

早速サーバを実装します。適当な場所に生成したコードからサーバとエンドポイントを設定します。

依存関係: npm install typescript ts-node nodemon --save-dev

現時点ではまだ使いませんが、開発に使う ts-node と nodemon のインストールとスクリプトも併せて用意します。

{
  "scripts": {
    "dev": "npm run dev:server & npm run dev:bff",
    "dev:server": "nodemon src/server/index.ts",
    "dev:bff": "nodemon src/client/index.ts",
    "test": "echo \"Error: no test specified\" && exit 1"
  }
}
// src/server/index.ts
import {
  sendUnaryData,
  Server,
  ServerCredentials,
  ServerUnaryCall,
} from "@grpc/grpc-js";
import { GreeterService } from "../../gen/proto/helloworld_grpc_pb";
import { HelloReply, HelloRequest } from "../../gen/proto/helloworld_pb";
import { port } from "../config";

function sayHello(
  call: ServerUnaryCall<HelloRequest, HelloReply>,
  callback: sendUnaryData<HelloReply>
) {
  const greeter = new HelloReply();
  const name = call.request.getName();
  const message = `Hello ${name}`;

  greeter.setMessage(message);
  callback(null, greeter);
}

function startServer() {
  const server = new Server();
  server.addService(GreeterService, { sayHello });
  server.bindAsync(
    `0.0.0.0:${port}`,
    ServerCredentials.createInsecure(),
    (error, port) => {
      if (error) {
        console.error(error);
      }

      server.start();
      console.log(`server start listing on port ${port}`);
    }
  );
}

startServer();

3. gRPC クライアント側のサーバを用意

クライアントになるサーバの実装です。今回は express server を使った BFF を想定し、ブラウザに対し HTTP の API を提供します。 まずは Hello Service に対する実装から用意します。

依存関係:

$ npm install @types/express
$ npm install express 
// src/client/helloClient.ts
import { HelloRequest } from "../../gen/proto/helloworld_pb";
import { GreeterClient } from "../../gen/proto/helloworld_grpc_pb";
import { credentials } from "@grpc/grpc-js";
import { grpcClientOptions, port } from "../config";

const serverURL = `localhost:${port}`;

export type RequestParams = {
  name?: string;
};

export function sayHello({ name = "World" }: RequestParams) {
  const Request = new HelloRequest();
  const Client = new GreeterClient(
    serverURL,
    credentials.createInsecure(),
    grpcClientOptions
  );
  Request.setName(name);

  return new Promise((resolve, reject) => {
    Client.sayHello(Request, (error, response) => {
      if (error) {
        console.error(error);
        reject({
          code: error?.code || 500,
          message: error?.message || "something went wrong",
        });
      }

      return resolve(response.toObject());
    });
  });
}

サーバにエンドポイントを生やして先程の sayHello とつなぎます。

// src/client/index.ts
import express from "express";
import { ParamsDictionary } from "express-serve-static-core";
import { BFFPort } from "../config";
import { RequestParams, sayHello } from "./helloClient";

const app = express();

app.get("/", ({}, res) => {
  res.json({ health: "ok" });
});

app.get<ParamsDictionary, any, any, RequestParams>(
  "/hello-world",
  async (request, response) => {
    const { name } = request.query;

    try {
      const result = await sayHello({ name });
      response.json({ result });
    } catch (error) {
      response.status(500).json({ error });
    }
  }
);

app.listen(BFFPort, () =>
  console.log(`Express server listening on port ${BFFPort}`)
);

サーバを起動

先程用意した npm script を使ってサーバを起動してみましょう。先程準備したスクリプトから npm run dev を起動した後 http://localhost:9000/hello-world にアクセスすると {"result":{"message":"Hello World"}} というレスポンスが得られると思います。 また、クエリとして name を指定し http://localhost:9000/hello-world?name=Mercari とすれば {"result":{"message":"Hello Mercari"}} となるのが確認できると思います。

まとめ

ハイパフォーマンス RPC を謳うプロトコルですが、Protocol Buffers との組み合わせにより開発体験にもベネフィットがあります。 特にサーバー間での通信において型安全が保証されるのはとても大きいです。 Node.js で実装する事による明確なアドバンテージは正直見えませんが、私達のように BFF 開発とメンテナンスにフロントエンドエンジニアも参加できるようになるのはいいことでした。

grpc-web を使ってブラウザから gRPC 通信をすることも可能ですが、クライアントのバンドルサイズがすごいことになるので利用したことはありません。 ブラウザに対しては BFF に GraphQL を採用すると Protocol Buffers を origin に一貫した型定義の DX が得られるためとても幸せになるということをお伝えします。