はじめに
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を作成
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を作成
{
"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"
]
}
コンパイル用のスクリプトを用意
"scripts": {
"build": "webpack"
},
Typescriptのコンパイルが出来るかを確認するため、仮のコードを実装しておきます。
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ファイルに定義していきます。
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で実行しようとすると色々大変なので シェルを用意しておきます。
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から取得したユーザーのリストを返しています。
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配下に用意されているので、
こちらを利用して変換処理を実装していきます。
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のリストを返却しています。
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の実体です。
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:50051', ServerCredentials.createInsecure());
grpcServer.start();
addProtoServiceに登録されたfunctionはclientからの要求に応じて
gRPCサーバーからcallインスタンスとcallbackを引数に実行されます。
grpcServer.addProtoService(UserDomainService, {
getUsers
});
この時、登録するサービス名はprotoで定義したrpc名と一致する必要があるので注意してください。
service UserDomain {
rpc getUsers (UsersRequest) returns (UsersReply);
}
最後に起動用スクリプトの追記しておきます。
"scripts": {
"server": "npm run build && node dist/server.js",
"build": "webpack"
},
これで実行すればgRPCサーバーが立ち上がるはずです。
$ npm run server
ですが、このままではclientが存在しないためリクエストをすることが出来ません。
なので、動作確認用のコードを実装します。
クライント側の実装
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:50051`,
credentials.createInsecure()
);
こちらは サーバーとは無関係で コンパイル対象にするべきではないため
ts-nodeで直接実行してしまいましょう。
$ npm i -D ts-node
クライアント用のスクリプトを追記。
"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や例外処理などが考慮されていないため
そちらに関しては別途機会を見つけて記事を書こうと思います。