この投稿は、Rustのtonicを用いてEnvoyなしでもgRPC-webに対応したgRPCサーバーを作り、Next.jsからgRPCのエンドポイントを呼び出すまでの方法をステップ・バイ・ステップで解説するチュートリアル記事です。
gRPC-webを実現しようとすると、クライアントサイドとサーバーサイドの間に、Envoyなどのリバースプロキシが必要となります。なぜこれが必要かというと、gRPCサーバーはHTTP2なのに対し、ブラウザがHTTP2をサポートしているとは限らないというギャップを埋めるためです。EnvoyがHTTP1でgRPCリクエストを受け付け、HTTP2に変換したものをサーバーサイドに送るという仲介役を引き受けてくれます。
しかし、RustのgRPCサーバーtonicは、それ自体HTTP1とgRPC-webをサポートしているので、Envoyなどのミドルウェアを導入する必要がありません。この投稿では、tonicのgRPC-webを用いることで、EnvoyないしにgRPC-webを実現する方法を解説していきます。
なお、このチュートリアルで作ったアプリケーションのコードはGitHubにて公開しています。完成形のコードを確認したい方は、こちらもご覧ください。
プロジェクトを作る
まず、このチュートリアルの置き場を作ります。
mkdir grpc
cd grpc
次に、その中に
- Rust製のサーバーサイドプログラムを置くディレクトリ
- Next.js製のクライアントサイドプログラムを置くディレクトリ
- Protocol Buffersのスキーマを置くディレクトリ
の3つをそれぞれ作ります。
mkdir server proto client
この2つを作るとプロジェクトのファイル構成は次のようになります。
.
├── client
├── proto
└── server
Protocol Buffersのスキーマを作る
APIを定義するために、まずProtocol Buffersのファイルを作ります。今回作るAPIは、人(person)の情報を送ったら、その人の名前(name)を含んだ挨拶メッセージ(greeting message)を生成して返すシンプルなAPIです。
まず、proto
ディレクトリを作り、その中にgreeting.proto
ファイルを作ります。
touch proto/greeting.proto
greeting.proto
ファイルに書くスキーマは次のようになります。
syntax = "proto3";
package greeting;
service Greeter {
rpc SayHello (Person) returns (GreetingMessage) {}
}
message Person {
string name = 1;
}
message GreetingMessage {
string text = 1;
}
gRPC-webサーバーを作る
ここからは、gRPC-webサーバーをRustのtonicを使って組んできます。
サーバーサイドプロジェクトをセットアップする
server
ディレクトリに移動して、cargo init
でプロジェクトの雛形を作ります。
cd server
cargo init .
tonicをインストールする
tonicとtonic関連のパッケージをインストールします。
cargo add tonic
cargo add tonic-web
cargo add --build tonic-build
cargo add prost
cargo add tokio -F rt-multi-thread -F rt -F macros
- tonic ─ 本体。gRPCサーバーを実装するためのライブラリ。
- tonic-web ─ tonicをgRPC-webに対応するためのライブラリ。
- tonic-build ─ Protocol BuffersからRustコード(prostコード)を生成するツール。
- prost ─ Protocol BuffersのRust実装。
tonicに付随して必要なものをインストール
tonicはprotobuf
が必要なのでそれをHomebrewでインストールしておきます。
brew install protobuf
デバッグ用ツールをインストール
gRPC-webのサーバーをデバッグするときにGUIのgRPCクライアントがあると便利です。BloomRPCはgRPCのGUIクライアントで、これもHomebrewでインストールしておきます。
brew install --cask bloomrpc
Rustのビルドスクリプトを作る
上で作ったgreeting.proto
をRustのコンパイル時にRustコードに変換できるように、Rustのビルドスクリプトbuild.rs
を作っておきます。
touch build.rs
build.rs
の内容は次のようにします。
fn main() -> Result<(), Box<dyn std::error::Error>> {
tonic_build::compile_protos("../proto/greeting.proto")?;
Ok(())
}
この設定の内容は、greeting.proto
をRustコードに変換するものですが、生成されるRustコードはtarget
ディレクトリに格納されます。
gRPC-webサーバーを実装する
src/main.rs
にgRPC-webサーバーを実装するために、main.rs
の内容を次のように変更します。
use greeting::greeter_server::{Greeter, GreeterServer};
use greeting::{GreetingMessage, Person};
use tonic::{transport::Server, Request, Response, Status};
use tonic_web::GrpcWebLayer;
use tower_http::cors::CorsLayer;
mod greeting {
// greeting.protoから生成したRustコードを展開するマクロ
tonic::include_proto!("greeting");
}
#[derive(Default)]
struct MyGreeter {}
#[tonic::async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<Person>,
) -> Result<Response<GreetingMessage>, Status> {
// gRPCリクエストから入力値を参照する
let name = request.into_inner().name;
println!("Creating a greeting message for {:?}", name);
// レスポンスの内容を作成する
let greeting_message = GreetingMessage {
text: format!("Hello {}!", name),
};
// gRPCレスポンスを作成する
let response = Response::new(greeting_message);
// gRPCレスポンスを返す
Ok(response)
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr = "127.0.0.1:50051".parse().unwrap();
let greeter = MyGreeter::default();
let allow_cors = CorsLayer::new()
.allow_origin(tower_http::cors::Any)
.allow_headers(tower_http::cors::Any)
.allow_methods(tower_http::cors::Any);
println!("GreeterServer listening on {}", addr);
Server::builder()
.accept_http1(true) // gRPC-webに対応するために必要
.layer(allow_cors) // CORSに対応するために必要
.layer(GrpcWebLayer::new()) // gRPC-webに対応するために必要
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
gRPC-webサーバーを起動する
cargo run
を実行してgRPC-webサーバーを起動します。
cargo run
これを実行すると、ビルドとサーバーの起動が行われます。しばらくして次のようなメッセージがターミナルに表示されたら、サーバーの起動が成功しています。
GreeterServer listening on 127.0.0.1:50051
BloomRPCでサーバーをテストする
サーバーが起動したら、greeting APIが使えるかgRPCのGUIクライアントでリクエストを出してみてテストします。
- BloomRPCが起動する。
- プラスボタンを押してProtocol Buffersファイル
greeting.proto
を読み込みます(1)。 - 左メニューにAPIリストが表示されるので、その中から「SayHello」メソッドをクリックします(2)。
- アドレス入力欄に「127.0.0.1:50051」と入力 (3)。
- 「WEB」のトグルをonにします。(4)
- 「Editor」のJSONを変更して、
name
プロパティにtanaka
など好きな値にしてください。(5) - 実行ボタンを押す。(6)
- 「Response」にレスポンスの内容が表示されます。(7)
BloomRPCでリクエストを出して、レスポンスに次のような内容が表示されれば、サーバーは正しく動作していることになります。
{
"text": "Hello tanaka!"
}
これと同時に、ターミナルの標準出力に表示されるサーバーのログには次のようなログが表示されます。
Creating a greeting message for "tanaka"
クライアントを作る
ここからはNext.jsから上で作ったサーバーのAPIを呼び出せるように作っていきます。
必要なもの
- Yarn (このチュートリアルではv1.22.19を使用)
クライアントサイドプロジェクトのセットアップ
ターミナルのタブを開いて、最初に作っておいたclient
ディレクトリに移動します。
cd client
次のコマンドを実行して、Next.jsの雛形プロジェクトを生成します。
yarn create next-app . --typescript
ここではインタラクティブにいろいろ聞かれますが、全部空欄で大丈夫です。これが実行され終わると、次のようなファイルが生成されます。
client/
├── README.md
├── next-env.d.ts
├── next.config.js
├── node_modules
├── package.json
├── pages
├── public
├── styles
├── tsconfig.json
└── yarn.lock
Next.jsを起動する
次のコマンドを実行して、Next.jsを起動します。
yarn dev
起動したら、http://localhost:3000をブラウザで開いてNext.jsが起動しているか確認します。
Connect-webをインストールする
gRPC-webをクライアントサイドで手軽に使いたいので、各種ライブラリと開発ツールをインストールします。
yarn add @bufbuild/connect-web @bufbuild/protobuf
yarn add -D @bufbuild/protoc-gen-connect-web @bufbuild/protoc-gen-es
ここでインストールするものの内容は次のとおりです。
- ライブラリ
- @bufbuild/connect-web ─ ConnectプロトコルとgRPC-webプロトコルに対応したクライアントを提供する
- @bufbuild/protobuf ─ シリアライゼーションと基本的な型のいろいろを提供する
- 開発ツール
- @bufbuild/protoc-gen-connect-web ─ Protocol Buffersスキーマからサービスを生成する
- @bufbuild/protoc-gen-es ─ リクエストやレスポンスといった基本的な型を生成する。
bufの設定ファイルを作る
上でインストールした開発ツールを使って、Protocol BuffersのスキーマファイルからTypeScriptのコードを生成するには、buf.gen.yaml
という設定ファイルを作る必要があります。
touch buf.gen.yaml
buf.gen.yaml
の内容は次のようにします。
version: v1
plugins:
# Protocol Buffersのmessageに対応するTypeScriptコードを生成する (_pb.tsで終わるファイル名のもの)
- name: es
out: services
opt: target=ts
# Protocol Buffersのserviceに対応するTypeScriptコードを生成する (_connectweb.tsで終わるファイル名のもの)
- name: connect-web
out: services
opt:
- target=ts
- import_extension=none
genはプラグインを足すことで、Protocol Buffersのスキーマからさまざまな言語のコードを生成できる仕組みになっています。上の例では、es
プラグインとconnect-web
プラグインを使っていて、services
ディレクトリにTypeScriptファイルを生成します。
ちなみに、es
プラグイン、connect-web
プラグインともに、TypeScriptコードではなくJavaScriptコードやTypeScriptの型定義ファイルのみを生成することもできます。その場合は、target=js
やtarget=js+dts
にします。
Protocol BuffersからTypeScriptファイルを生成する
生成するにはbuf
コマンドが必要になるので、Homebrewでインストールしておきます。
brew install bufbuild/buf/buf
buf generate
を実行すると、TypeScriptファイルが生成できるわけですが、bufのes
プラグインやconnect-web
プラグインのパスがPATH
環境変数に追加された環境で実行する必要があります。その環境を設ける方法はいくつか考えられますが、NPMスクリプトにするのが手軽です。NPMスクリプトにするには、次のようにpackage.json
のscripts
フィールドにbuf generate
コマンドを足します。
{
// 中略
"scripts": {
// 中略
"gen:services": "buf generate ../proto/greeting.proto"
},
// 中略
}
NPMスクリプトを追加したら、yarn
でこれを実行します。
yarn gen:services
このコマンドを実行すると、buf.gen.yaml
で指定したservices
ディレクトリに、次のTypeScriptファイルが生成されます。
- greeting_connectweb.ts ─ 内容はProtocol Buffersのserviceに対応したオブジェクト
- greeting_pb.ts ─ 内容はProtocol Buffersのmessageに対応したクラス
生成されたTypeScriptファイルを少し眺めてみましょう。次は、greeting_pb.tsの中身です。
// 中略
import { Message } from "@bufbuild/protobuf";
export class Person extends Message<Person> {
name = "";
// 中略
}
export class GreetingMessage extends Message<GreetingMessage> {
text = "";
// 中略
}
ご覧のとおり、Protocol Buffersのmessageに対応したクラスが生成されています。
次は、greeting_connectweb.tsの内容です。
import { GreetingMessage, Person } from "./greeting_pb";
import { MethodKind } from "@bufbuild/protobuf";
export const Greeter = {
typeName: "greeting.Greeter",
methods: {
sayHello: {
name: "SayHello",
I: Person,
O: GreetingMessage,
kind: MethodKind.Unary,
},
}
} as const;
これはProtocol Buffersのserviceに対応したオブジェクトが生成されています。
Next.jsからtonicのgRPCを呼び出すには、これらの生成されたTypeScriptコードを用いていきます。
Next.jsとtonicを接続する
ここからは、Next.jsからtonicのgRPCを呼び出す処理を実装していきます。pages/index.tsx
の内容を次のように書き換えます。コードの部分部分の解説は、インラインコメントで説明します。
import {
createGrpcWebTransport,
createPromiseClient,
} from "@bufbuild/connect-web";
import type { PartialMessage } from "@bufbuild/protobuf";
import { type FormEvent, useState } from "react";
import { Greeter } from "../services/greeting_connectweb";
// messageスキーマの型情報もimportすることで利用可能
import type { GreetingMessage, Person } from "../services/greeting_pb";
// gRPCクライアントの初期化
const transport = createGrpcWebTransport({
baseUrl: "http://localhost:50051",
});
const client = createPromiseClient(Greeter, transport);
export default function Home() {
const [name, setName] = useState("");
const [text, setText] = useState("");
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
// リクエストメッセージのオブジェクトはPartialMessageを使うと取れます
const person: PartialMessage<Person> = { name };
// gRPCメソッドを呼び出す
const greetingMessage: GreetingMessage = await client.sayHello(person);
console.log("greetingMessage: ", greetingMessage);
setText(greetingMessage.text);
};
return (
<>
<form onSubmit={handleSubmit}>
<input
placeholder="name"
value={name}
onChange={(e) => setName(e.target.value)}
/>
<button type="submit">submit</button>
</form>
<div>{text}</div>
</>
);
}
書き換えたら、ブラウザでhttp://localhost:3000/を開いてください。name
の入力欄があるので、そこに名前を入力して、「submit」ボタンを押してみてください。すると、その下に「Hello」で始まるテキストが結果として表示されます。この処理の裏ではgRPC-webの通信が行われていて、開発ツールのネットワークでHTTPリクエスト・レスポンスを確認すると、その内容も確認できます。
おわり
以上で、Rustのtonicを用いてEnvoyなしでもgRPC-webに対応したgRPCサーバーを作り、Next.jsからgRPCのエンドポイントを呼び出すまでのチュートリアルは終わりです。意外とシンプルな構成でgRPC-webが実現できることがおわかりいただけたのではないでしょうか。
まとめ
- Rustのtonicを使うと、EnvoyなしにgRPC-webが使える
- bufを使うと、Protocol BuffersのスキーマからTypeScriptコードが生成できる
- このチュートリアルで作ったアプリのソースコード: GitHub