Node.js
TypeScript
gRPC
Node.jsDay 24

gRPC × Typescript を始めるための一歩


はじめに

gRPC-WebがGAされた事で 今後gRPCを採用するケースが増えると思い、

Typescriptを使って実装をしようとしたのですが、

意外と資料が少なく苦戦したのでチュートリアルとして纏めておきます。

( 一応 公式にサンプルが載っているのですが 残念ながら Typescriptのサンプルは存在しません。 )


今回作るもの

今回実装するアプリケーションはテスト用のユーザーを要求人数分返すAPIになります。

テストユーザーを取得するAPIはこちらを利用させて頂きます。

Random User Generator | Documentation

最終的なファイル構成は以下になります

./

├── README.md
├── client.ts
├── dist
│   └── server.js
├── generate.sh
├── package-lock.json
├── package.json
├── protos
│   └── service.proto
├── src
│   ├── api.ts
│   ├── protos
│   │   ├── service_grpc_pb.d.ts
│   │   ├── service_grpc_pb.js
│   │   ├── service_pb.d.ts
│   │   └── service_pb.js
│   ├── repository.ts
│   ├── server.ts
│   └── service.ts
├── tsconfig.json
└── webpack.config.js

src以下の各ファイルごとの処理は以下になります。

file
処理

server
gRPCサーバーの実体。

service
リクエストに対してレスポンスを返す。

repository
レスポンスの取得 及び 整形。

api
apiを叩く実体。

また、今回 実装したコードはこちらに上げています。

https://github.com/ohs30359-nobuhara/grpc-sample


開発環境構築編

まずはTypescriptのコンパイル環境を構築していきます。

ここに関しては本題ではないので さくっと流していきます。


Typescript環境の構築

Typescriptのインストール

$ npm i -D ts-loader awesome-typescript-loader typescript

webpackのインストール

$ npm i -D webpack webpack-cli webpack-node-externals

webpack.confを作成


webpack.config.js

const path = require('path');

const nodeExternals = require('webpack-node-externals');
const {TsConfigPathsPlugin} = require('awesome-typescript-loader');

module.exports = {
mode: 'development',
entry: './src/server.ts',
target: 'node',
externals: [nodeExternals()],
devtool: 'inline-source-map',
node: {
__filename: true,
__dirname: true
},
module: {
rules: [
{
loader: 'ts-loader',
test: /\.ts$/,
exclude: [
/node_modules/
],
options: {
configFile: 'tsconfig.json'
}
}
]
},
resolve: {
plugins: [
new TsConfigPathsPlugin()
],
extensions: ['.ts', '.js']
},
output: {
filename: 'server.js',
path: path.resolve(__dirname, './dist')
}
}


ts-configを作成


ts-config.json

{

"compilerOptions": {
"sourceMap": true,
"noImplicitAny": true,
"module": "commonjs",
"target": "es6",
"moduleResolution": "node",
"removeComments": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": false,
"noImplicitReturns": true,
"noFallthroughCasesInSwitch": true,
"allowJs": true,
"strictFunctionTypes": false
},
"lib": [
"es5",
"es2015",
"es2016",
"es2017",
],
"exclude": [
"node_modules",
".idea"
]
}

コンパイル用のスクリプトを用意


package.json

 "scripts": {

"build": "webpack"
},

Typescriptのコンパイルが出来るかを確認するため、仮のコードを実装しておきます。


src/server.ts

function sample(message: string) :void {

console.log(`${message}を出力`);
}

コンパイルを実行してjsファイルがdist配下に生成されているかを確認。

$ npm run build

以上でTypescriptでの開発環境構築は完了です。


gRPC導入

本題のgRPCを導入していきます。

前提としてProtocol Buffersをコンパイルするprotobuf cliが必要になります。

今回は homebrewを使ってインストールしておきます。

$ brew install protobuf

続いてnode側にも必要なパッケージをインストールします。

$ npm i -D grpc-tools grpc_tools_node_protoc_ts

package
用途

grpc-tools
nodeでgRPCを利用するために必要なパッケージ

grpc_tools_node_protoc_ts
protobufで.tsを生成するためのパッケージ


protoファイルの作成

続いて今回のインターフェイスを.protoファイルに定義していきます。


protos/service.proto

syntax = "proto3";

package test_user;

service UserDomain {
rpc getUsers (UsersRequest) returns (UsersReply);
}

// user info
message User {
string firstName = 1;
string lastName = 2;
string sex = 3;
string email = 4;
Location location = 5;

message Location {
string state = 1;
string ciry = 2;
string street = 3;
}
}

// get users
message UsersRequest {
// number of users requested
int32 resultCount = 1;
}

// return list of users
message UsersReply {
// list of users
repeated User users = 1;
}


今回はサンプルユーザーを指定した人数分用意するサービスを定義しています。

service UserDomain {

rpc getUsers (UsersRequest) returns (UsersReply);
}

こちらのrpcに定義された内容がエントリポイントとなり、

UsersRequestを渡すとUserReplyが返ってくるといった定義になっています。


protoファイルのコンパイル

ここは少々厄介な内容になります。

cli経由で先程のprotoファイルをコンパイルしてtsファイルを作成するのですが、

そのままcliで実行しようとすると色々大変なので シェルを用意しておきます。


generate.sh

PLUGIN_TS=./node_modules/.bin/protoc-gen-ts

PLUGIN_GRPC=./node_modules/.bin/grpc_tools_node_protoc_plugin
DIST_DIR=./src/protos

protoc \
--js_out=import_style=commonjs,binary:"${DIST_DIR}"/ \
--ts_out=import_style=commonjs,binary:"${DIST_DIR}"/ \
--grpc_out="${DIST_DIR}"/ \
--plugin=protoc-gen-grpc="${PLUGIN_GRPC}" \
--plugin=protoc-gen-ts="${PLUGIN_TS}" \
--proto_path=./protos/ \
-I $DIST_DIR \
./protos/*.proto


細かい説明は省略しますが、protobufでコンパイルする際に

npmでインストールしておいたパッケージ(grpc-tools)を直参照させています。

この状態でシェルを実行すればsrc/protos配下にファイルが作成されます。

$ sh generate

最終的にsrc/protos は以下のような状態になっているはずです。

src/protos

├── service_grpc_pb.d.ts
├── service_grpc_pb.js
├── service_pb.d.ts
└── service_pb.js


実装編

では早速出来上がったファイルを使って実装を進めていきます。

まずはサンプルユーザーを取得する処理を実装します。

今回はrequest-promiseを使ってAPIを叩くので パッケージをインストールします。

$ npm i request-promise request

$ npm i -D @typs/request-promise


apiの実装

apiの処理自体は何の変哲もない ただのget処理です。

特に説明する必要もありませんが APIから取得したユーザーのリストを返しています。


src/api.ts

import * as request from 'request-promise';

/**
* IResponse
* @interface
*/

export interface IResponse {
results: [{
gender: string,
name: {
first: string,
last: string
},
location: {
street: string,
city: string,
state: string
},
email: string
}]
}

/**
* randomUser
* @param {number} resultCount
* @return Promise<IResponse>
*/

export async function randomUser(resultCount: number): Promise<IResponse> {
return request(`https://randomuser.me/api/`, {
qs: {
results: resultCount
},
json: true
});
}



repositoryの実装

gRPCではclientへのレスポンスはprotoファイルで定義したオブジェクトにセットして返す必要があります。

そのため、先程のAPIからのレスポンスをgRPC用のオブジェクトに変換する処理を実装します。

gRPCオブジェクトは先程作成したproto配下に用意されているので、

こちらを利用して変換処理を実装していきます。


src/repository.ts

import { User } from './protos/service_pb';

import { randomUser, IResponse } from './api'

/**
* generateUser
* @param {IResponse} response
* @return User[]
*/

function generateUser(response: IResponse): User[] {
return response.results.map(result => {
const user: User = new User();
user.setFirstname(result.name.first);
user.setLastname(result.name.last);
user.setSex(result.gender);
user.setEmail(result.email);

const location: User.Location = new User.Location();
location.setState(result.location.street);
location.setCiry(result.location.city);
location.setStreet(result.location.state);

user.setLocation(location)

return user;
});
}

/**
* findUsers
* @param {number} resultCount
* @return Promise<User[]>
*/

export async function findUsers(resultCount: number): Promise<User[]> {
return generateUser(await randomUser(resultCount));
}


APIから返ってきたユーザー情報のJSONを

gRPCインスタンスのフィールドにアクセサーを経由して値をセットしていきます。

const user: User = new User();

user.setFirstname(result.name.first);
user.setLastname(result.name.last);
user.setSex(result.gender);
user.setEmail(result.email);

const location: User.Location = new User.Location();
location.setState(result.location.street);
location.setCiry(result.location.city);
location.setStreet(result.location.state);

user.setLocation(location)

return user;

これでclientに返すためのUserのリストは準備できました。


serviceの実装

リクエストを受け付ける実体の処理です。

clientのリクエストに対して 先程のrepositoryから取得したUserのリストを返却しています。


src/service.ts

import { ServerUnaryCall } from 'grpc';

import { UsersRequest, UsersReply } from './protos/service_pb';
import { findUsers } from './repository'

/**
* getUsers
* @param {any} call
* @param {ServerUnaryCall<UsersRequest>} callback
*/

export async function getUsers(call: ServerUnaryCall<UsersRequest>, callback: any): Promise<void> {
const request: UsersRequest = call.request;

const reply: UsersReply = new UsersReply();
reply.setUsersList(await findUsers(request.getResultcount()));
callback(null, reply);
}


clientからのリクエストはすべてcallインスタンスとして渡されます。

第二引数のcallbackに関してはclientへのレスポンス時に利用するためのものです。

function getUsers(call: ServerUnaryCall<UsersRequest>, callback: any): Promise<void>

clientへ値を返すには必ずcallbackに値を渡す必要があります

これを忘れると永遠とclientはレスポンスを待ち続けることになるので注意が必要です。

callbackにセットできるものはprotoのretunsに定義したオブジェクトのみになります。

つまり、今回のケースではUserReplyをセットする必要があります。

const reply: UsersReply = new UsersReply();

reply.setUsersList(await findUsers(request.getResultcount()));
callback(null, reply);

※ ただし、例外処理時のみ 以下のような構文で任意のオブジェクトをセットすることが出来ます。

callback({ message: xxxxxx })


Serverの実装

最後にgRPC serverの実体です。


src/server.ts

import { Server, ServerCredentials } from 'grpc';

import { UserDomainService } from './protos/service_grpc_pb'
import { getUsers } from './service'

const grpcServer: Server = new Server();
grpcServer.addProtoService(UserDomainService, {
getUsers
});

grpcServer.bind('localhost:8080', ServerCredentials.createInsecure());
grpcServer.start();


addProtoServiceに登録されたfunctionはclientからの要求に応じて

gRPCサーバーからcallインスタンスとcallbackを引数に実行されます。

grpcServer.addProtoService(UserDomainService, {

getUsers
});

この時、登録するサービス名はprotoで定義したrpc名と一致する必要があるので注意してください。

service UserDomain {

rpc getUsers (UsersRequest) returns (UsersReply);
}

最後に起動用スクリプトの追記しておきます。


package.json

"scripts": {

"server": "npm run build && node dist/server.js",
"build": "webpack"
},

これで実行すればgRPCサーバーが立ち上がるはずです。

$ npm run server

ですが、このままではclientが存在しないためリクエストをすることが出来ません。

なので、動作確認用のコードを実装します。


クライント側の実装


client.ts

import { UserDomainClient, IUserDomainClient } from './src/protos/service_grpc_pb'

import { credentials } from 'grpc';
import {UsersReply, UsersRequest} from './src/protos/service_pb'

const client: IUserDomainClient = new UserDomainClient(
`localhost:8080`,
credentials.createInsecure()
);

const request: UsersRequest = new UsersRequest();
request.setResultcount(2);

client.getUsers(request, (err: any, response: UsersReply) => {
response.getUsersList().forEach(user => {
console.log(user.toObject());
})
});


gRPCのclientを作成し、リクエストを渡せばcallbackでレスポンスが取得できます。

const client: IUserDomainClient = new UserDomainClient(

`localhost:8080`,
credentials.createInsecure()
);

こちらは サーバーとは無関係で コンパイル対象にするべきではないため

ts-nodeで直接実行してしまいましょう。

$ npm i -D ts-node

クライアント用のスクリプトを追記。


package.json

"scripts": {

"client": "ts-node ./client.ts",
"server": "npm run build && node dist/server.js",
"build": "webpack"
},

clientを実行すればAPIからのレスポンスが返ってくることが確認できます。

$ npm run client

{ firstname: 'matias',
lastname: 'ranta',
sex: 'male',
email: 'matias.ranta@example.com',
location:
{ state: '1924 bulevardi',
ciry: 'vihti',
street: 'ostrobothnia' } }
{ firstname: 'minttu',
lastname: 'korpi',
sex: 'female',
email: 'minttu.korpi@example.com',
location:
{ state: '1511 hatanpään valtatie',
ciry: 'ilomantsi',
street: 'ostrobothnia' } }

以上が基礎的なgRPCの構築に関する解説になります。

今回の実装ではTimeoutや例外処理などが考慮されていないため

そちらに関しては別途機会を見つけて記事を書こうと思います。