電通総研 テックブログ

電通総研が運営する技術ブログ

TypeScriptでgRPCを使ったアプリケーション開発をしてみた

こんにちは。X(クロス)イノベーション本部 ソフトウェアデザインセンター の山下です。 今回は、TypeScriptを使って、gRPCのアプリケーションを開発する際の方法について紹介します。

gRPCとProtocol Buffers

gRPCはGoogleの開発した様々な環境で動作するオープンソースのRPCフレームワークです。 gRPCは負荷分散、トレース、ヘルスチェック、認証などの機能をサポートし、効率的な通信の仕組みを提供しています。

gRPCは、Protocol Buffersというインターフェース記述言語(IDL)を用いてサービスを定義して利用します。このProtocol BuffersもgRPCと同じくGoogleが開発しています。gRPCでアプリケーションを開発する際は、まずProtocol Buffersから通信に関するプログラムを自動的に構築して開発することになります。

Protocol Buffersは多くの言語をサポートしています。今回利用するTypeScriptを直接サポートはしていませんが、Node.jsをサポートしているので問題なく利用できます。

詳細な情報はgRPCの公式サイトを参照してください。 https://grpc.io/

Protocol Buffersについての詳細な情報は以下のURLを参照してください。 https://developers.google.com/protocol-buffers

この記事では、Node.jsのサポートを利用してTypeScriptのアプリケーション開発の手順について解説します。

gRPCの通信方式

gRPCでは4種類の通信方式がある。

Unary RPC 1つのリクエストに対して一つのレスポンスを返す一般的な通信です。

Server streaming RPC クライアントから送られてきた一つのリクエストに対して、サーバは複数回に分けてレスポンスを返す通信方式です。

Client streaming RPC クライアントからリクエストを分割して送る方式でサーバーはすべてのリクエストを受け取ってからレスポンスを返す方式です。

Bidirectional streaming RPC サーバーとクライアントが一つのコネクションを確立しお互いに任意のタイミングでリクエストとレスポンスを送りあう通信方式です。

また、gRPCでは、ブラウザで利用するgRPC WebとHTTP/2を利用して通信を行うgRPC over HTTP/2の2種類の通信方式が存在しています。 gRPC over WEBはブラウザを中心に策定された仕様となっています。このため現在は、Client streaming RPC、Bidirectional streaming RPCを行うことが出来ません。

この記事では、このうちHTTP/2を▼使ったUnary RPCの開発手順について紹介します。

TypeScriptを用いたgRPCの開発手順

TypeScriptを用いたgRPCアプリケーションは以下のような流れで開発します。

  1. Protocol Buffersの定義をprotoファイルで行う
  2. protocプログラムを用いてprotoファイルからソースコードを生成する
  3. 生成したプログラムにロジックを追加して完成させる

という流れになります。 ここでは、その流れを順番に追ってみます。

Protocol Buffers でサービスとメッセージを記述する

Protocol Buffersではサービスとメソッド(rpc名と呼ぶ方が適切かもしれませんが、本記事ではメソッドで統一します)を定義します。メソッドがAPIの概念に近いものとなり、メソッドが通信でやりとりする内容をメッセージとして定義することとなります。 Protocol Buffersの例を以下に示します。なおProtocol Buffersにはバージョンがあり本記事ではバージョン3を想定しています。

syntax = "proto3";

message HelloRequest {
  string name = 1;
}

message HelloResponse {
  string result =1;
}

service Hello {
  rpc hello(HelloRequest) returns (HelloResponse);
}

上記の定義では、Helloというサービスと helloメソッド、HelloRequestHelloResponse というメッセージが定義されています。 。helloを呼びだす場合には、HelloRequestを引数として呼び出し、その返り値としてはHelloResponseというメッセージが返ってくることを表しています。

なお、ここではProtocol Buffersの文法の詳細を述べません。詳細については、公式のドキュメントを参照してください。 https://developers.google.com/protocol-buffers/docs/proto3

記載する際は以下のスタイルガイドが参考になります。 https://developers.google.com/protocol-buffers/docs/style

スタイルガイドには以下のようにサービス名、メッセージ名とフィールド名についての命名の規則が記載されています。

Use CamelCase (with an initial capital) for message names – for example, SongServerRequest. Use underscore_separated_names for field names (including oneof field and extension names) – for example, song_name.

このガイドの内容に従わなくても問題はおきません。しかし、従っていない場合は生成されるソースコードが不自然なものとなってしまいます。 例えば、フィールド名をuserIdのような名前を付けた場合、これに対応するメソッドやプロパティの名前はgetUseridsetUserid といった形になってしまいます。 一方でuser_idというフィールド名を用いた場合はgetUserIdsetUserIdという名前で生成されます。コードの読みやすさなどの観点からフィールド名にはスネークケースを用いる方が良さそうですね。

Protocol Buffersの更新について

Protocol Buffersのprotoファイルを更新して、メッセージのフォーマットを変更する場合には注意が必要となります。

特に、フィールド番号は安易に変更、削除してしまうと古いプログラムと新しいプログラムでメッセージのフォーマットが一致しなくなり通信できなくなってしまうので注意が必要です。 極力新しいフィールドを追加していく形で更新していく事が望ましいです。 また削除も可能であれば避けOBSOLETE_といったプレフィックスをつけて残しておく事が望ましいです。 後々誤って削除したフィールド番号が再利用されてしまうといったトラブルを回避するためです。 また各種メッセージの種類には互換性があり、同じフィールド番号でもメッセージの種類の変更は可能な場合があります。

その他の注意点については公式のドキュメントを熟読し、慎重に更新を行っていく必要があるので注意してください。 https://developers.google.com/protocol-buffers/docs/proto3#updating

Protocol Buffersからソースコードを生成する

protocプログラムとそのオプションについて

Protocol Buffersからコードを生成するプログラム(protoc)は、オプションの記述方法に注意が必要です。 protocを利用する際は、xxx_out=.... というオプションが並ぶことになります。

このオプションは、以下のように解釈します。 --xxx_outプラグインと出力先の指定を意味します。これは、proto-gen-xxxというプラグイン名の場合は、xxx_outという対応関係になっています。 proto-gen-goプラグインなら--go_outproto-gen-grpc-gatewayプラグインなら--grpc-gateway_outという具合になっています。 プラグイン自体もオプションも同時に指定することが出来て、--xxx_out=プラグインのオプション:出力先という形になります。

例えば、--js_out=import_style=commonjs,binary:${PROTO_DEST}と書いてある場合を考えます。 これは、js_outプラグインimport_style=commonjs,binary(=true)という引数を渡すことになります。そして、その出力先は${PROTO_DEST}ということです。

TypeScript用のprotocについて

protocは Node.js に対応していますが、TypeScriptの型定義などを直接生成する機能は持っていません。 今回は、grpc_tools_node_protoc_tsを用いてprotocの生成した Node.js のソースコードに型定義を自動生成して利用します。これは、grpc-toolsというgRPC公式に含まれているgrpc_tools_node_protocというツールを拡張したものです。

以下が公式ドキュメントとその使い方です。 https://github.com/agreatfool/grpc_tools_node_protoc_ts#how-to-use

# ソースコードの出力先
PROTO_DEST=./src/proto
# protoファイルが置いてあるディレクトリ
PROTO_DIR=./proto

# Protocol BuffersからnodeのgRPCコードを自動生成
grpc_tools_node_protoc \
  --js_out=import_style=commonjs,binary:${PROTO_DEST} \
  --grpc_out=${PROTO_DEST} \
  --plugin=protoc-gen-grpc=$(which grpc_tools_node_protoc_plugin) \
  -I ${PROTO_SRC} \
  ${PROTO_SRC}/*

# typescript用の型定義を作成
grpc_tools_node_protoc \
  --plugin=protoc-gen-ts=$(yarn bin)/protoc-gen-ts \
  --ts_out=${PROTO_DEST} \
  -I ${PROTO_SRC} \
  ${PROTO_SRC}/*

もしくは以下のように一括で生成する方法もあります。

# 定義と実装を同時に生成
yarn run grpc_tools_node_protoc \
    --plugin=protoc-gen-ts=$(yarn bin)/protoc-gen-ts \
    --ts_out=grpc_js:${PROTO_DEST} \
    --js_out=import_style=commonjs,binary:${PROTO_DEST} \
    --grpc_out=grpc_js:${PROTO_DEST} \
    -I ${PROTO_DIR} \
    ${PROTO_DIR}/*.proto

生成されたプログラムを利用してみる

Helloサービスの簡単なサーバ、クライアントの実装を以下に示します。 通信に関わる処理は全てgRPC側が行ってくれているため、実際に記述する部分はロジックに対応する部分だけとなります。通信に用いるメッセージを組み立てる関数も自動生成されているので、それを利用して構築できます。

// サーバプログラム
import * as grpc from '@grpc/grpc-js';
import { sendUnaryData } from '@grpc/grpc-js/build/src/server-call';
import { HelloRequest, HelloResponse } from '../proto/hello_pb';
import { HelloService } from '../proto/hello_grpc_pb';

const HelloServer = {
    hello: (call: grpc.ServerUnaryCall<HelloRequest, HelloResponse>, callback: sendUnaryData<HelloResponse>): void => {
        const request = call.request;
        const response = new HelloResponse();
        console.log("Message from client");
        response.setResult("Hello," + request.getName())
        callback(null, response);
    }
}

function serve(): void {
    const server = new grpc.Server();
    server.addService(HelloService, HelloServer);
    server.bindAsync(`localhost:6543`, grpc.ServerCredentials.createInsecure(), (err, port) => {
        if (err) {
            throw err;
        }
        console.log(`Listening on ${port}`);
        server.start();
    });
}

serve();
// クライアントプログラム
import * as grpc from '@grpc/grpc-js';
import { HelloClient } from '../proto/hello_grpc_pb';
import { HelloRequest, HelloResponse } from '../proto/hello_pb';

function hello(): Promise<HelloResponse> {
  const client = new HelloClient(
    `localhost:6543`,
    grpc.credentials.createInsecure(),
  );

  // HelloRequestを作るためのクラス、メソッドが用意されているのでそれを用いてメッセージを作成する
  const request = new HelloRequest();
  request.setName("ISID");
  // サーバに対してサービスの実行を要求する
  return new Promise<HelloResponse>((resolve, reject) => {
    console.log("Send Hello Message");
    client.hello(request, (err, response) => {
      if (err) {
        return reject(err);
      }
      // ここで結果を受け取っている
      console.log("Receive Message");
      return resolve(response);
    });
  });
}

(async () => {
  console.log("Client Start");
  const result = await hello();
  console.log(result.getResult());
})();

実際のプロジェクト構成と動作例

ここでは、今回のサンプルで利用したプロジェクトの構成を紹介します。

プロジェクト構成例

ディレクトリの構成は以下のような構成です。

.
├── package.json
├── proto
│   └── hello.proto
├── scripts
│   └── build-protos.sh
├── src
│   ├── client
│   │   └── index.ts
│   ├── proto
│   └── server
│       └── index.ts
└── tsconfig.json

また、package.jsonの中身は以下のような内容です。

{
  "name": "grpc-hello",
  "version": "1.0.0",
  "main": "index.js",
  "license": "MIT",
  "dependencies": {
  },
  "scripts": {
    "lint": "yarn run eslint --fix --ext .ts src",
    "clean": "rm -rf ./dist && rm -rf ./src/proto && mkdir -p ./src/proto ",
    "build": "sh ./scripts/build-protos.sh ./hello.proto ./src/proto && yarn run tsc; cp -r ./src/proto ./dist/src/proto",
  },
  "devDependencies": {
    "@grpc/grpc-js": "^1.4.2",
    "@types/eslint": "^7.28.2",
    "@types/google-protobuf": "^3.15.5",
    "@types/node": "^16.11.7",
    "@typescript-eslint/eslint-plugin": "^5.3.0",
    "@typescript-eslint/parser": "^5.3.0",
    "eslint": "^8.1.0",
    "grpc-tools": "^1.11.2",
    "grpc_tools_node_protoc_ts": "^5.3.2",
    "ts-node": "^10.4.0",
    "tsconfig-paths": "^3.11.0",
    "typescript": "^4.4.4"
  }
}

動作確認

まず yarn build で Protocol Buffers からコード生成、TypeScriptからJavaScriptへのトランスパイルを実行します。

$ yarn
$ yarn build

サーバの起動

$ node ./dist/src/server/
Listening on 6543

クライアントの実行

$ node ./dist/src/client
Client Start
Send Hello Message
Receive Message
Hello,ISID

サーバー側のログも確認すると

Message from client

というようなログが出ています。 gRPCを使った通信プログラムが実装できてそうです。

まとめ

今回は、gRPCを用いたアプリケーション開発の方法やその注意点について紹介しました。 gRPCは Protocol Buffers を定義するだけで高品質な通信プログラムが実装できる素晴しい技術ですね。 機会があれば積極的に利用していきたいと思います。


私たちは同じチームで働いてくれる仲間を探しています。今回のエントリで紹介したような仕事に興味のある方、ご応募お待ちしています。

執筆:@yamashita.tsuyoshi、レビュー:@handa.kentaShodoで執筆されました