概要
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][github-url]に上げています。
[github-url]:https://github.com/mutsuyuki/TimerApp_with_gRPC
試した環境(前提)
- 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
[ここ][url]を参考にさせていただきました。
[url]:https://qiita.com/567000/items/dde495d6a8ad1c25fa43
Protocol Buffer のコンパイラの準備
gRPCでは、APIの仕様の記述をProtocol Buffersというインターフェース定義用言語を使います。
Protocol Buffersは、雑な例えをすると、SQLのCREATE TABLE構文だけ抜き出したもの+関数インターフェース定義ができる言語、みたいな印象です。
Protocol Buffersで書いたデータ定義や関数定義を各言語にコンパイルするため,言語別のコンパイラをインストールします。
今回はjavascript用とPython用です。
javascript用(gRPC-web)コンパイラのインストール
######手動で入れます。
[コンパイラ本体][url1]と[javascript用プラグイン][url2]をダウンロードして、中に入っている実行ファイル(protoc.exe
、protoc-gen-grpc-web.exe
)にそれぞれパスを通す。
[url1]:https://github.com/protocolbuffers/protobuf/releases
[url2]:https://github.com/grpc/grpc-web/releases
インストールされたか確認するには、
protoc --version
libprotoc 3.8.0
となればとりあえずOK。
javascript用ですが、Typescript定義ファイルも一緒に吐き出せます。やった。
ここの手順は[公式チュートリアルのここらへん][url3]を参考にしています。
[url3]:https://github.com/grpc/grpc-web/tree/master/net/grpc/gateway/examples/helloworld#generate-protobuf-messages-and-client-service-stub
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を実装していきます。
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
で引数や返却値のデータ構造を定義しています。
ここではStartTimer
とStopTimer
の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.py
、timer_pb2_grpc.py
という2つのファイルが出力されました。
timer_pb2_grpc.py
の方にAPI実装のひな型があるので、継承して機能を実装します。
サーバー側APIの実装
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-protobuf
とgrpc-web
がないと怒られたので、素直にインストールします。
yarn add google-protobuf @types/google-protobuf grpc-web
あたらめてコンパイルすると、今度は通りました。
実装
<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のリクエストをダイレクトにサーバーに伝えられないらしいです。(なぜだ。ポートの問題?)
[公式のチュートリアル][url4]から、
[url4]:https://github.com/grpc/grpc-web/tree/master/net/grpc/gateway/examples/helloworld#generate-protobuf-messages-and-
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/
と表示されましたら、上記アドレスでアクセスできるはずです。
感想
- REST APIしか触ったことない勢としては、サーバーからのプッシュ型のAPIは触ってて面白かったです。
- streamでつなぎ続けると再度実行されたときに前のものをキャンセルしたりしたいケースもありそうだなと思いました。
- Python版は情報が少ない。DBとのつなぎをDjangoにすることをもくろんでPythonを選んだけれど、Goで書いた方がよいのかなと悩む。
- Typescriptの方はmessageの変数名の補完が効いた。Pythonの方はほとんど効かなかった。Pythonだめなのかなー。。
こちらからは以上です。