フロント「Vue + gRPC-Web」、サーバー「Python + gRPC」構成でタイマーアプリを作る。


概要

REST APIより良いAPIの通信方法はないものか調べていたら、gRPCというものが見つかりました。なかなか良さそうなので簡単なアプリを作りながら、使った感じをメモします。

結論を先に言うと、Pythonでサーバー作るのちょっときついかも。。でした。

期待していた、messageの補完が効かないからです。


gRPCってなに

gRPCはRPC実装の一つです。

RPCとはなにかといういうと、REST APIの弱点を克服する通信規格のひとつです。

RESTの辛みを解消した、代替候補の1つという認識です。

REST APIとの大きな違いとしては、引数や返り値のデータ型をしっかり決める必要があることかな、と。

これの何が嬉しいかというと、APIの引数、返り値を編集したり使用するときに補完が効く(はず)ということ。

APIの構造を決めればデータ型は自動生成する機能もついています。

なので、仕様書を見ながらこの項目は文字型だ、数字型だということを気にしなくてもよく、より実装=仕様な感じになるのではないかなと。

あと、サーバーとの双方向通信(プッシュ型)の処理もできるのは期待大です。

もうポーリングする必要ないんです!

RPC(Remote Procedure Call)の名前が示す通り、REST APIよりも、関数を呼ぶ感覚で使える感じですね。


やってみる


作るもの

タイマーを作ってみます。

フロントVue, サーバーPythonの構成でgRPC通信します。

別にサーバーなくても作れる内容なんですが、とにかくgRPCを無理やり使うんです。


それがサンプルプログラムってもんです。


仕様


  • タイマー機能はサーバーに持つ。

  • フロントからタイマー開始の命令をgRPC経由で送る。

  • サーバーからは1秒おきに残り時間をgRPC経由でフロントに送る。

  • フロントはサーバーから送られてきた残り時間を画面に表示する。

作ったものをGitHubに上げています。


試した環境(前提)


  • Windows 10

  • Python 3.6

  • Node.js 12.5.0

  • npm 6.9.0

  • yarn 1.9.4

  • Docker for Windows 2.0.0.3 (envoy用)

(余談)Docker初めて使いましたが、死ぬほど便利ですね。


環境構築

必要なツールを入れていきます。


Vueをインストール

フロントはVueで作るので、Vueのツールをインストールします。

npm install -g @vue/cli

ここを参考にさせていただきました。


Protocol Buffer のコンパイラの準備

gRPCでは、APIの仕様の記述をProtocol Buffersというインターフェース定義用言語を使います。

Protocol Buffersは、雑な例えをすると、SQLのCREATE TABLE構文だけ抜き出したもの+関数インターフェース定義ができる言語、みたいな印象です。

Protocol Buffersで書いたデータ定義や関数定義を各言語にコンパイルするため,言語別のコンパイラをインストールします。

今回はjavascript用とPython用です。


javascript用(gRPC-web)コンパイラのインストール


手動で入れます。

コンパイラ本体javascript用プラグインをダウンロードして、中に入っている実行ファイル(protoc.exeprotoc-gen-grpc-web.exe)にそれぞれパスを通す。

インストールされたか確認するには、

protoc --version

libprotoc 3.8.0

となればとりあえずOK。

javascript用ですが、Typescript定義ファイルも一緒に吐き出せます。やった。

ここの手順は公式チュートリアルのここらへんを参考にしています。


Python用コンパイラのインストール

pip install grpcio-tools

インストールされたか確認するには、

python -m grpc_tools.protoc --version

libprotoc 3.8.0

となればOK。


疑問だった点。

何でweb用とpython用でコンパイラの入れ方が違うのだろう。。

個人的には、python用もprotoc-gen-python.exe みたいなプラグインにして、コンパイラ本体は共通にしてほしかったです。

どうやらpipでインストールしたほうもprotocコンパイラの本体(をdllにしたもの)が別途ダウンロードされているようなので、クライアントとサーバーでバージョンが異なったりしないのかモヤモヤします。


APIの実装

Protocol Buffersを実装していきます。


timer.proto

syntax = "proto3";

package timer_with_grpc;

service Timer {
rpc StartTimer(StartRequest) returns (stream TimerState); // タイマー開始
rpc StopTimer(Empty) returns (TimerState); // タイマー停止
}

message StartRequest {
int32 time = 1;
}

message TimerState {
bool isRunning = 1;
int32 leftTime = 2;
}

// 引数空ができないようなので、空用の定義をしておく
message Empty {
}


シンプル。

基本的な定義はサンプルの書き換えで何とかなりそうな印象です。

serviceの中にAPIのインターフェースを定義し、messageで引数や返却値のデータ構造を定義しています。

ここではStartTimerStopTimerの2つのAPIを定義しています。

引数がいらないAPIもありそうですが、

rpc StopTimer() returns (TimerState);

としてみたらエラーになったので、何かしら引数はいるみたいです。

今回はEmptyという空のデータ型を定義してみました。

良い方法かどうかはわかりません。

また、returns に stream をつけるとサーバーからデータをプッシュ型でクライアントに送信できます。これがやりたかった。


Protocol Buffersのコンパイル


クライアント用コード生成(Javascript + Typescript定義ファイル)

protoc -I=. timer.proto --js_out=import_style=commonjs:. --grpc-web_out=import_style=commonjs+dts,mode=grpcwebtext:.

timer_pb.js

timer_pb.d.ts

timer_grpc_web_pb.js

timer_grpc_web_pb.d.ts

という4つのファイルが出力されました。

オプション次第でちゃんとTypescriptの定義ファイルまで出してくれるのでありがたいですね。(+dtsをつける)

内容結構ごつくてちゃんと読めてませんが、timer_grpc_web_pb.d.tsにAPIのクライアントインターフェースらしきものがあることが確認できます。


サーバ用コード生成(Python)

python -m grpc_tools.protoc -I. timer.proto --python_out=. --grpc_python_out=.

timer_pb2.pytimer_pb2_grpc.pyという2つのファイルが出力されました。

timer_pb2_grpc.pyの方にAPI実装のひな型があるので、継承して機能を実装します。


サーバー側APIの実装


TimerApi.py

import timer_pb2

import timer_pb2_grpc

from concurrent import futures
import time
import grpc

# APIのロジック
class TimerServicer(timer_pb2_grpc.TimerServicer):
leftTime = 0
isRunning = False

def StartTimer(self, request, context):
self.leftTime = request.time
self.isRunning = True

while self.leftTime > 0:
if self.isRunning: # 途中でStopTimerされてないかチェック
yield self.makeTimerState()
time.sleep(1)
self.leftTime -= 1
else:
return

self.isRunning = False
yield self.makeTimerState()

def StopTimer(self, request, context):
self.isRunning = False
return self.makeTimerState()

def makeTimerState(self):
return timer_pb2.TimerState(
isRunning=self.isRunning,
leftTime=self.leftTime
)

# サーバーの実行
def serve():
server = grpc.server(futures.ThreadPoolExecutor(max_workers=10))
timer_pb2_grpc.add_TimerServicer_to_server(TimerServicer(), server)
server.add_insecure_port('0.0.0.0:8082')
server.start()
print("Server Start!!")
try:
while True:
time.sleep(1)
except KeyboardInterrupt:
server.stop(0)

if __name__ == '__main__':
serve()


TimerServicerがprotocコンパイラが出力したAPIインターフェースを継承して

ロジックを追加した部分です。

StartTimerは、引数でもらった秒数だけカウントダウンしていきます。

StopTimerで止めます。


クライアント側実装


Vueでプロジェクトを作成

まずはVueのclientとう名前のプロジェクトを作成します。

$ vue create client

Vue CLI v3.8.4
┌───────────────────────────┐
│ Update available: 3.9.2 │
└───────────────────────────┘
? Please pick a preset: Manually select features
? Check the features needed for your project: TS, CSS Pre-processors
? Use class-style component syntax? Yes
? Use Babel alongside TypeScript (required for modern mode, auto-detected polyfills, transpiling JSX)? No
? Pick a CSS pre-processor (PostCSS, Autoprefixer and CSS Modules are supported by default): Sass/SCSS (with dart-sass)
? Where do you prefer placing config for Babel, PostCSS, ESLint, etc.? In dedicated config files
? Save this as a preset for future projects? No

Babelを使用しないで、Typescriptとscssを使うようにした以外はデフォルトで作りました。


コンパイルしてみる。

クライアント用のプロジェクトができたので、いったんコンパイルしてみましょう。

コンパイルのコマンドはclientディレクトリに入ってから、

yarn serve

です

その際、Protocol Buffersのコンパイルした

timer_pb.js

timer_pb.d.ts

timer_grpc_web_pb.js

timer_grpc_web_pb.d.ts

も含めて(client/src以下に4ファイルを置いて)コンパイルしてみると、

以下のようなエラーがでてきました。

1:23 Cannot find module 'google-protobuf'.

> 1 | import * as jspb from "google-protobuf"
| ^
<<中略>>
10:26 Cannot find module 'grpc-web'.
> 10 | import * as grpcWeb from 'grpc-web';
| ^

google-protobufgrpc-webがないと怒られたので、素直にインストールします。

yarn add google-protobuf @types/google-protobuf grpc-web

あたらめてコンパイルすると、今度は通りました。


実装


App.vue

<template>

<div id="app">
<p>
- TIMER -
</p>
<br>
<p>
{{isTimerRunning ? "実行中" : "停止中"}}
</p>
<p class="time">
{{leftTime}}
</p>

<button @click="startTimer">start</button>
<button @click="stopTimer">stop</button>
</div>
</template>

<script lang="ts">
import {Component, Vue} from 'vue-property-decorator';
import {TimerClient} from "./timer_grpc_web_pb";
import {Empty, StartRequest, TimerState} from "./timer_pb";
import {ClientReadableStream} from "grpc-web";

@Component({})
export default class App extends Vue {
private timerClient: TimerClient;
private isTimerRunning: boolean = false;
private leftTime: number = 0;

constructor() {
super();
this.timerClient = new TimerClient('http://' + window.location.hostname + ':8081', null, null);
}

private startTimer(): void {
const request = new StartRequest();
request.setTime(10); // 10秒をセット
const stream: ClientReadableStream<TimerState> = this.timerClient.startTimer(request, {});
stream.on('data', (response: TimerState) => {
this.isTimerRunning = response.getIsrunning();
this.leftTime = response.getLefttime();
});
}

private stopTimer(): void {
this.timerClient.stopTimer(new Empty(), {}, (err, response: TimerState) => {
this.isTimerRunning = response.getIsrunning();
this.leftTime = response.getLefttime();
});
}

}
</script>

<style lang="scss">
#app {
font-family: 'Avenir', Helvetica, Arial, sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
text-align: center;
color: #2c3e50;
margin-top: 60px;
}
.time{
margin:0 0 40px 0;
font-size:148px;
color:#999;
}
</style>


Typescript側は、コンストラクタでAPIクライアントをインスタンス化し、開始ボタン or 停止ボタンで対応するAPIをコールします。

タイマー開始の方はサーバー側から時間が変わるたびにデータがプッシュされてくるので、その値に応じて秒数を更新する仕組みです。


プロキシサーバーを用意

ここに関しては公式のサンプル通りです。

正直よくわかってないです。

envoyというプロキシサーバー(本来はロードバランサらしい?)を使います。

現状gRPCのリクエストをダイレクトにサーバーに伝えられないらしいです。(なぜだ。ポートの問題?)

公式のチュートリアルから、

envoy.Dockerfile

envoy.yaml

をひろってきて、以下のコマンドで

docker build -t envoy_for_timer -f ./envoy.Dockerfile .


実行する


サーバーアプリ起動

python TimerApi.py 


リバースプロキシ起動

docker run -d -p 8081:8081 envoy_for_timer


フロントアプリ起動

yarn serve

フロントアプリを起動後に

  App running at:

- Local: http://localhost:8080/
- Network: http://192.168.1.22:8080/

と表示されましたら、上記アドレスでアクセスできるはずです。

tello_yolo_camera.gif


感想


  • REST APIしか触ったことない勢としては、サーバーからのプッシュ型のAPIは触ってて面白かったです。

  • streamでつなぎ続けると再度実行されたときに前のものをキャンセルしたりしたいケースもありそうだなと思いました。

  • Python版は情報が少ない。DBとのつなぎをDjangoにすることをもくろんでPythonを選んだけれど、Goで書いた方がよいのかなと悩む。

  • Typescriptの方はmessageの変数名の補完が効いた。Pythonの方はほとんど効かなかった。Pythonだめなのかなー。。

こちらからは以上です。