以下の技術スタックでhello worldをするようなやつを作りました。
-
フロント
gRPC-web、TypeScript -
サーバー
gRPC、rust、docker -
プロキシ
envoy、docker
コードはこちらにあります。
こんな感じのディレクトリ構成になっており、フロント以外はdocker-composeで動かすようにしました。
.
├── README.md
├── docker-compose.yml
├── front
│ ├── README.md
│ ├── gen.sh
│ ├── node_modules
│ ├── package-lock.json
│ ├── package.json
│ ├── public
│ ├── src
│ └── tsconfig.json
├── proxy
│ ├── Dockerfile
│ └── envoy.yaml
└── server
├── Cargo.lock
├── Cargo.toml
├── Dockerfile
├── README.md
├── build.rs
├── proto
├── src
└── target
gRPCについて
gRPCサーバーを実装していく際の流れのイメージはこんな感じです。
1. プロトコル定義ファイル(Protocol Buffers)を作成
Protocol Buffersは型が書けるスキーマ言語です。各言語で共通して扱うことができるインタフェースのような感じです。
今回のただhello worldするだけのprotoファイルはこちらです。SayHelloというリモートプロシージャ(関数)はHelloRequestを引数に取り、HelloReplyを返すというインタフェースを定義しています。
syntax = "proto3";
package helloworld;
service Greeter {
rpc SayHello(HelloRequest) returns (HelloReply) {}
}
message HelloRequest { string name = 1; }
message HelloReply { string message = 1; }
= 1
はフィールド番号と呼ばれるものです。Protocol Buffersでは、フィールド名ではなくフィールド番号を使ってそのメッセージの構造を保存します。そのためこの例だと、HelloRequest、HelloReplyともに、フィールドが一つしかないので = 1
のみですが、フィールドが増えていけば、 = 2
, = 3
と増えていきます。
2. プロトコル定義ファイルから各言語のインタフェースとなるコードを自動生成
上記のprotoファイルはそのままだと、どの言語もインタフェースとして利用することはできません。
protocというツールを使うことで上記のprotoファイルを元に、各言語でのインタフェースのコードを自動生成することができます。
3. 実装を書く
2で作成したインタフェースにしたがって実装を書いていきます。
この インタフェースにしたがって
という部分は言語によって実現の仕方が異なっており、
- pythonであれば自動生成されたコードを継承する形でクラスを実装する
- nodeであれば、自動生成されたコードに実装の関数を引数として渡す
みたいな感じになってるみたいです。
rustでのgRPCについて
今回、rustでgRPCサーバーを作るにあたって、tonicを利用しました。
tonicの特徴として、protocコマンドを直接使う必要がないということが挙げられます。
他の言語でgRPCサーバーを実装する際はprotocコマンドを使ってコードを自動生成しますが、tonicではtonic-buildを用いてコードを自動生成します。そのため、protocコマンドのインストールも不要です。
具体的にはこんな感じです。
定義したprotoファイルを読み込むビルドスクリプトを追加します。これで、cargo buildを行った時にrustのコードが自動生成されます。コードはtargetディレクトリ配下に生成されます。
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("proto/helloworld.proto")?;
Ok(())
}
このtargetディレクトリ配下に生成されたコードは、こんな感じで参照できます。
use tonic::{transport::Server, Request, Response, Status};
pub mod hello_world {
tonic::include_proto!("helloworld");
}
use hello_world::{
greeter_server::{Greeter, GreeterServer},
HelloReply, HelloRequest,
};
あとはこのインタフェースや型にしたがって、実装を書きます。
引数として、何か名前が渡されていれば Hello <渡された名前>!
、何も名前が渡されていない場合は Hello World!
と返すような実装にしました。
# [derive(Default)]
pub struct MyGreeter {}
# [tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
let mut name = request.into_inner().name;
if name.len() == 0 {
name = "World".to_string();
}
let reply = hello_world::HelloReply {
message: format!("Hello {}!", name),
};
Ok(Response::new(reply))
}
}
cargo run --bin helloworld-server
でgRPCサーバーを起動したうえで、grpcurlを使えばちゃんとレスポンスが返ってくることが確認できます。
gRPC-webについて
フロントの土台となるコードはCRAを使っています。
$ npx create-react-app front --template redux-typescript
gRPC-webの実装は、公式のhellowWorldのチュートリアルを参考に実装しました。
gRPC-webもサーバーのときと同様に、protoファイルを元にインタフェースのコードを自動生成し、それを活用します。
# !/bin/sh
parentdir=$(dirname `pwd`)
protodir="${parentdir}/server/proto"
outputpath="${parentdir}/front/src/proto"
protoc -I=${protodir} ${protodir}/helloworld.proto \
--js_out=import_style=commonjs:${outputpath} \
--grpc-web_out=import_style=typescript,mode=grpcwebtext:${outputpath}
protocコマンドを用いて、helloworld.proto を元にしたjsのファイルを自動生成するシェルです。
protocコマンドは brew install protobuf
でインストールできます。また、--grpc-web_out
でgrpc-web用のコードを生成しています。このオプションを利用するには事前にprotoc-gen-grpc-webをダウンロードしておく必要があります。
このシェルを実行すると、outputpathに指定したディレクトリ配下にファイルが生成されます。
上記ファイルを用いた、gRPCサーバーへリクエストを投げるコードがこんな感じです。
import { ClientReadableStream } from "grpc-web";
import { HelloRequest, HelloReply } from "./proto/helloworld_pb";
import { GreeterClient } from "./proto/HelloworldServiceClientPb";
const sayHello = (name: string): ClientReadableStream<HelloReply> => {
const request = new HelloRequest();
request.setName(name);
const client = new GreeterClient("http://localhost:8080", {}, {});
return client.sayHello(request, {}, (err, res) => {
if (err || !res) {
throw err;
}
});
};
プロキシについて
ブラウザから直接gRPCを叩くことは現状ではできないため、envoyを使ってプロキシしています。
フロント側のコードで http://localhost:8080
宛てにリクエストを投げていますが、このリクエストをenvoyが受け取って、gRPCサーバー(http://server:50051
)へ転送するようにしてます。
admin:
access_log_path: /tmp/admin_access.log
address:
socket_address: { address: 0.0.0.0, port_value: 9901 }
static_resources:
listeners:
- name: listener_0
address:
socket_address: { address: 0.0.0.0, port_value: 8080 }
filter_chains:
- filters:
- name: envoy.http_connection_manager
config:
codec_type: auto
stat_prefix: ingress_http
route_config:
name: local_route
virtual_hosts:
- name: local_service
domains: ["*"]
routes:
- match: { prefix: "/" }
route:
cluster: greeter_service
max_grpc_timeout: 0s
cors:
allow_origin_string_match:
- prefix: "*"
allow_methods: GET, PUT, DELETE, POST, OPTIONS
allow_headers: keep-alive,user-agent,cache-control,content-type,content-transfer-encoding,custom-header-1,x-accept-content-transfer-encoding,x-accept-response-streaming,x-user-agent,x-grpc-web,grpc-timeout
max_age: "1728000"
expose_headers: custom-header-1,grpc-status,grpc-message
http_filters:
- name: envoy.grpc_web
- name: envoy.cors
- name: envoy.router
clusters:
- name: greeter_service
connect_timeout: 0.25s
type: logical_dns
http2_protocol_options: {}
lb_policy: round_robin
hosts: [{ socket_address: { address: server, port_value: 50051 }}]
いろいろ書いてますが、ほぼ公式のチュートリアルのコピペです。変えたところは転送先のgRPCサーバーのaddressとport_valueのみです。
起動
docker-compose upでgRPCサーバーとプロキシを起動し、npm startでフロントを起動すれば、この記事の冒頭のgifを確認できます。
$ docker-compose up -d
$ cd front && npm i && npm start
ハマったところ
フロントでnpm startすると自動生成したコードの読み込みでこんなエラーが。
Failed to compile.
./src/proto/helloworld_pb.js
Line 27:1: 'proto' is not defined no-undef
Line 30:15: 'proto' is not defined no-undef
Line 31:20: 'COMPILED' is not defined no-undef
Line 36:3: 'proto' is not defined no-undef
Line 48:1: 'proto' is not defined no-undef
Line 51:15: 'proto' is not defined no-undef
Line 52:20: 'COMPILED' is not defined no-undef
Line 57:3: 'proto' is not defined no-undef
Line 75:1: 'proto' is not defined no-undef
Line 76:10: 'proto' is not defined no-undef
Line 89:1: 'proto' is not defined no-undef
Line 107:1: 'proto' is not defined no-undef
Line 109:17: 'proto' is not defined no-undef
Line 110:10: 'proto' is not defined no-undef
Line 121:1: 'proto' is not defined no-undef
Line 145:1: 'proto' is not defined no-undef
Line 147:3: 'proto' is not defined no-undef
Line 159:1: 'proto' is not defined no-undef
Line 175:1: 'proto' is not defined no-undef
Line 184:1: 'proto' is not defined no-undef
Line 205:1: 'proto' is not defined no-undef
Line 206:10: 'proto' is not defined no-undef
Line 219:1: 'proto' is not defined no-undef
Line 237:1: 'proto' is not defined no-undef
Line 239:17: 'proto' is not defined no-undef
Line 240:10: 'proto' is not defined no-undef
Line 251:1: 'proto' is not defined no-undef
Line 275:1: 'proto' is not defined no-undef
Line 277:3: 'proto' is not defined no-undef
Line 289:1: 'proto' is not defined no-undef
Line 305:1: 'proto' is not defined no-undef
Line 314:1: 'proto' is not defined no-undef
Line 319:29: 'proto' is not defined no-undef
Search for the keywords to learn more about each error.
issueが上がっており、そこに解決策もありました。
自動生成された helloworld_pb.js の頭に /* eslint-disable */
をつけるというパワーソリューション。
所感
gRPC-webを試しといてあれですが、ブラウザから直接gRPCを叩きたい!っていうユースケースが現状ではあまり思いつきません。こちらの記事のようにフロントとサーバーの間にBFFを挟んで、サーバー間をgRPCでやりとりするっていうのは良さそうだなと感じています。
参考
サービス間通信のための新技術「gRPC」入門
https://knowledge.sakura.ad.jp/24059/
protocの使い方
https://christina04.hatenablog.com/entry/protoc-usage