Help us understand the problem. What is going on with this article?

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

More than 1 year has passed since last update.

はじめに

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:50051', 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:50051`,
  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や例外処理などが考慮されていないため
そちらに関しては別途機会を見つけて記事を書こうと思います。

ohs30359-nobuhara
WEB系エンジニア k8s/nodejs(ts)/php/java
Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away