Wantedlyでインターンをしている遠藤です。インターンのタスクとして「RustでProtocol BuffersからGraphQL BFFを自動生成する」という技術検証を行ったので、その知見を共有したいと思います。
今回の技術検証で作成したツールのソースコードはここにあります:
前提知識(GraphQL, gRPC, BFF, etc.)
本セクションでは、本稿に登場する用語について簡単に説明します。
GraphQL
GraphQLはFacebookによって開発されたAPI向けのクエリ言語およびランタイムです。
同じ用途で使用されてきたREST(Representational State Transfer)と比較して、
- 必要なデータのみを取得できる、複数のデータソースから1つのAPI呼び出しでデータを取得できる、などのクエリの効率性と柔軟性。
- 型安全性。GraphQLをIDL(Interface Definition Language)としてスキーマを定義できる。
などの特徴があります。
Wantedlyではバックエンドのマイクロサービスを集約してフロントエンドと通信するために使用されています。
gRPC
gRPCはGoogleによって開発されたRPC(Remote Procedure Call)フレームワークです。
- Protocol BuffersによるシリアライズおよびHTTP/2による通信に由来する高いパフォーマンス。
- 型安全性。Protocol BuffersをIDL(Interface Definition Language)としてスキーマを定義できる。
などの特徴があります。
Wantedlyではマイクロサービス間通信で広く使用されています。
Protocol Buffers
Protocol BuffersGoogleによって開発されたシリアライズフォーマットおよびIDL(Interface Definition Language)です。
本稿で「Protocol Buffers」という場合は主に後者の意味になります。
BFF
BFF(Backends For Frontends)はバックエンドのマイクロサービスを集約してフロントエンドへのエントリーポイントとして機能するようなサーバです。
RustのProcedural macros
Rustのマクロはビルド時にソースコードの一部を置き換える仕組みです。大きく分けるとDeclarative macros(宣言マクロ。「Macros By Example」とも呼ばれます)とProcedural macros(手続きマクロ)の2種類があります。
Procedural macrosはDeclarative macrosと比較すると書くのが比較的難しいと言われていますが、その代わりに自由度が高く、「なんでもできる」と表現されることもあります。
Rustのビルドスクリプト
Rustのビルドスクリプトはビルド時にコードを実行するする仕組みです。マクロと異なり、Rustコード内の特定のアイテムと関連付けることができないため、ソースコードの置き換えには向きませんが、ビルド時にコンパイラのバージョンを特定したり、コードを生成したりするために使用されます。
本稿ではProtocol Buffersをビルド時に読み込んで、gRPCサービスおよびGraphQL GatewayのRustコードを生成するためにこの仕組みを使用します。
動機と経緯
Wantedlyには本番環境で使用しているBFFサーバがあり、その開発、保守に関して以下のような問題がありました。
- gRPCとGraphQL間のプロトコル変換のための処理を手動で書く必要があり面倒。
- スキーマの2重管理による開発、保守コストの増大。GraphQLスキーマを手動でProtocol Buffersの変更に追従させているという状況。
- BFFサーバを開発、保守するにあたって、gRPC/Protocol Buffersの知識とGraphQL/フロントエンドの知識の両方が必要になるなどの、学習、レビュー、設計のコストの増大。
これらの問題に対処するため、Node.jsとGoでそれぞれPOC(Proof of Concept)が書かれており、技術検証中の段階でした。
この技術検証における他の言語の候補の一つとしてRustが挙がっており、インターンのタスクとしてRustでの技術検証に取り組むことになりました。
成果物: proto-graphql-rust
本技術検証の成果物であるproto-graphql-rustは、「Protocol Buffersによって定義されたスキーマを解析して、GraphQLのGatewayコードを生成する」というコード生成ツールです。
proto-graphql-rustはライブラリですがコードジェネレータとしての側面が非常に強いです。これはWantedlyで使用されていた既存のBFFサーバ、およびその置き換えとして開発されていたPOCにおけるペインポイントの多くは自動化・機械化されていない処理に関することだった、ということに由来します。
また、Rust以外でも利用可能な成果物として、proto-graphql-rustでProtocol BuffersからGraphQLへのスキーマ変換時に発生した問題やその解決策・回避策を文書化(英語)しました。このドキュメントはproto-graphql-rustのリポジトリに含まれています。
proto-graphql-rustは、同じようにIDLでスキーマを定義できるGraphQLではなく、Protocol Buffersによって定義されたスキーマに基づいてコードを生成します。これには主に以下のような理由があります。
- WantedlyではマイクロサービスのAPIを基本的にProtocol Buffersで定義、管理していた。
- gRPCのエコシステムにはProtocol BuffersからgRPCのサーバとクライアントの雛形を生成するようなライブラリが多くあり、それらの利用することができた。
- RustのメジャーなGraphQLサーバライブラリはどれもRust code firstで、スキーマからRustコードを生成するのではなく、Rustコードからスキーマを組み立てるような実装になっており、スキーマからRustコードを生成する場合は多くの要素を自力で実装する必要があった。
何を生成するのか
前のセクションで述べたように、gRPC・Protocol Buffers用のコード生成にはすでに既存のライブラリがあるため、それらを使用します。
具体的には以下のコードが既存のライブラリを使用して生成されます。
- Protocol Buffersオブジェクトに対応するRustコード。RustのProtocol Buffers実装であるprostを使用して生成。
- gRPCサービス。RustのgRPC実装であるtonicを使用して生成。
そして、proto-graphql-rust自身は以下のコードを生成します。
- GraphQLオブジェクトに対応するRustコード。
- Protocol BuffersオブジェクトとGraphQLオブジェクトの変換コードの実装。
- GraphQLクエリをgRPCリクエストに変換するGraphQLクエリ。
proto-graphql-rustによって生成されたコードには、RustのGraphQLサーバ実装であるasync-graphqlのProcedural macrosの呼び出しが含まれており、GraphQLのリゾルバの実際の実装はそれらのProcedural macrosによって生成されます。
使用例
- Protocol Buffersでサービスの定義を作成します。
// proto/helloworld.proto
syntax = "proto3";
package helloworld;
service Greeter {
rpc SayHello(HelloRequest) returns (HelloReply) {}
}
message HelloRequest {
string name = 1;
}
message HelloReply {
string message = 1;
}
2. ビルド時にprotoファイルを読み込んで、gRPCサービスおよびGraphQL GatewayのRustコードを生成するために、ビルドスクリプトを作成します。
// build.rs
fn main() {
proto_graphql_build::compile_protos("proto/helloworld.proto").unwrap();
}
3. 次に、GraphQL Gatewayサーバを作成します。
// src/bin/graphql-gateway.rs
use std::{convert::Infallible, env, net::SocketAddr};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql_warp::{graphql, BadRequest, Response};
use tonic::transport::Channel;
use warp::{http::Response as HttpResponse, Filter, Rejection};
use pb::{
greeter_client::GreeterClient,
greeter_graphql::{build_graphql_schema, GreeterSchema},
};
mod pb {
// protoから生成されたRustコードをインポート
tonic::include_proto!("helloworld");
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = ([0, 0, 0, 0], 4000).into();
println!("listening on http://localhost:4000");
// gRPC clientに接続
let grpc_client = GreeterClient::connect("http://localhost:4001").await?;
// GraphQL schemaをビルドし、gRPC clientをデータとして渡す。
let schema = build_graphql_schema::<Channel>()
.data(grpc_client)
.finish();
// GraphQLサービスの作成
let graphql_post = graphql(schema).and_then(
move |(schema, request): (GreeterSchema<_>, async_graphql::Request)| async move {
let response = schema.execute(request).await;
Ok::<_, Infallible>(Response::from(response))
},
);
// GraphQL playgroundで使用できるようにする
let graphql_playground = warp::path::end().and(warp::get()).map(|| {
HttpResponse::builder()
.header("content-type", "text/html")
.body(playground_source(GraphQLPlaygroundConfig::new("/")))
});
// webサーバを実行
let routes = graphql_playground.or(graphql_post);
warp::serve(routes).run(addr).await;
Ok(())
}
4. 最後に、gRPCサーバを作成します。gRPCサーバについてはtonicを使用して書かれた通常のgRPCサーバと同じです。
use std::{env, net::SocketAddr};
use tonic::{async_trait, transport::Server, Request, Response, Status};
use pb::{
greeter_server::{Greeter, GreeterServer},
HelloReply, HelloRequest,
};
mod pb {
tonic::include_proto!("helloworld");
}
#[derive(Default)]
struct MyGreeter {}
// gRPCサービスの実装
#[async_trait]
impl Greeter for MyGreeter {
async fn say_hello(
&self,
request: Request<HelloRequest>,
) -> Result<Response<HelloReply>, Status> {
println!("Got a request from {:?}", request.remote_addr());
let reply = HelloReply {
message: format!("Hello {}!", request.into_inner().name),
};
Ok(Response::new(reply))
}
}
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
let addr: SocketAddr = ([0, 0, 0, 0], 4001).into();
let greeter = MyGreeter::default();
// gRPCサーバを実行
Server::builder()
.add_service(GreeterServer::new(greeter))
.serve(addr)
.await?;
Ok(())
}
リポジトリには他にもいくつかの例があります。
https://github.com/wantedly/proto-graphql-rust#examples
最後に
Rustで書いていて良かった点としては
- コンパイル時の型安全性。RustのGraphQLサーバ実装であるasync-graphqlがRustの型システムを利用して間違ったGraphQLオブジェクト定義をコンパイル時に検出できるように設計されていたため、コード生成のミスを即座に検出できることが多かった。
- コードジェネレータを作成するためのツールが強力。今回はProcedural macrosを書く際に使用するライブラリ群をそのまま使用できた。
という2点が大きかったと思います。Rustの強みの一つであるパフォーマンスについては今回は測定していないのでわからないです。
また、RustのGraphQLサーバ実装であるasync-graphqlが非常にアクティブに開発されていたことも良い点の一つでした。本インターン中にasync-graphqlのバグやドキュメントの間違いをいくつか見つけてPRを送りましたが、いずれもすぐにレビューを受けることができました。
Rustで書いていて悪かった点としては
というのが最大の問題でした。Wantedlyではマイクロサービスで使用されているprotoファイルが集中管理されているのですが、それらに対応するgRPCサービスおよびGraphQL GatewayのRustコードを生成すると、10-20分のビルド時間に加えて、公式のlinterがハングするなどのトラブルが起こりました。
ちなみに、元の技術検証の結果としてはNode.jsの実装を採用することになったそうです。