はじめに
Rust で GraphQL サーバーを書くとき、事実上の定番である async-graphql は code-first を採用しています。Rust の型と impl にアトリビュートを付けるだけで、GraphQL schema が自動的に組み上がる仕組みです。
これは書き味が最高な一方で、frontend 側にチェックインしてある schema.graphqls が手書きで更新されないと、気づかないうちに嘘をつき始めます。
自分の個人プロジェクト(nba-iso-flow.com)でも、backend では tradeNews にリネームしていたのに frontend の schema.graphqls には newsItems という古い名前が残っていた、という事案がありました。Ktor で手書きクエリしていたので、ビルド時にも実行時にも誰も気づかない、という地味に怖い状態です。
この記事では、
- DB 接続なしでスキーマを SDL にエクスポートする小さなバイナリを追加して、
- CI で
diffを取って drift(乖離)を fail させる
までをやったので、実コードと設計判断を残しておきます。
環境
- Rust: stable(執筆時 1.85)
- async-graphql: 7.x
- sqlx: 0.8(
SQLX_OFFLINEを活用) - GitHub Actions(ubuntu-latest)
frontend 側(Kotlin/JS + Apollo Kotlin)の話は別記事にするので、ここでは SDL を生成する backend 側と CI に絞ります。
実装
1. print_schema バイナリを追加する
backend/src/bin/print_schema.rs に、SDL を stdout に吐くだけの薄いバイナリを置きます。
// backend/src/bin/print_schema.rs
use async_graphql::{EmptySubscription, Schema};
use nba_trade_scraper::graphql::{Mutation, Query};
fn main() {
let schema = Schema::build(Query, Mutation, EmptySubscription).finish();
print!("{}", schema.sdl());
}
async-graphql の Schema::build は Query / Mutation / Subscription の 型だけを要求します。PgPool や SqlitePool などの状態を渡さなくても Schema は組み立てられるので、このバイナリは DB も環境変数もなしで動くのがポイントです。
あとは生成するだけ。
cd backend
cargo run --bin print_schema > ../frontend/src/jsMain/graphql/schema.graphqls
これで frontend の schema.graphqls が常に backend の実装と一致した状態で commit されます。Rust のドキュメントコメント(///)は GraphQL の description にそのまま流れるので、日本語コメントも SDL に反映されます。
# 生成される schema.graphqls(抜粋)
type Query {
"""
全てのトレードニュースを取得します(データベースから)
最新100件のニュースを返します
"""
tradeNews: [TradeNews!]!
tradeNewsByCategory(category: String!): [TradeNews!]!
# ...
}
2. CI で drift を検知する
.github/workflows/schema-check.yml を追加します。
name: GraphQL Schema Drift Check
on:
pull_request:
types: [opened, synchronize, reopened]
paths:
- 'backend/src/graphql.rs'
- 'backend/src/bin/print_schema.rs'
- 'backend/Cargo.toml'
- 'backend/Cargo.lock'
- 'frontend/src/jsMain/graphql/schema.graphqls'
- '.github/workflows/schema-check.yml'
concurrency:
group: schema-check-${{ github.event.pull_request.number }}
cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
SQLX_OFFLINE: true
jobs:
schema-drift:
name: Verify SDL matches backend code-first schema
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Rust toolchain
uses: dtolnay/rust-toolchain@stable
- name: Cache cargo registry & target
uses: Swatinem/rust-cache@v2
with:
workspaces: backend
- name: Regenerate SDL from backend
working-directory: backend
run: |
cargo run --bin print_schema --quiet > /tmp/schema.graphqls
- name: Diff against committed schema.graphqls
run: |
if ! diff -u frontend/src/jsMain/graphql/schema.graphqls /tmp/schema.graphqls; then
echo ""
echo "❌ frontend/src/jsMain/graphql/schema.graphqls が backend のスキーマとズレてる"
echo " ローカルで以下を実行してコミットしてくれ:"
echo ""
echo " cd backend && cargo run --bin print_schema > ../frontend/src/jsMain/graphql/schema.graphqls"
echo ""
exit 1
fi
echo "✅ SDL is in sync with backend code-first schema"
やってることは素朴で:
-
cargo run --bin print_schemaで SDL を/tmp/schema.graphqlsに出す - commit 済みの
frontend/src/jsMain/graphql/schema.graphqlsとdiff -u - 差分があれば
exit 1で PR を fail させる
ワークフローを分けた理由は、既存の backend CI(テスト・clippy 等)とライフサイクルが違うからです。スキーマに無関係な変更のたびに走らせたくないので paths でフィルタしています。
ハマったポイント
1. SQLX_OFFLINE=true を忘れると CI で build が死ぬ
sqlx の query! マクロを使っている場合、デフォルトでは コンパイル時に DB へ接続して schema を確認しに行きます。CI で cargo run --bin print_schema を叩いたとき、対象の bin が DB を触らないのに、workspace 内の他のコードの compile-time check のせいで DB 接続を要求される、というパターンで死にます。
対策は env: SQLX_OFFLINE: true を workflow に入れておくこと。これで、事前に cargo sqlx prepare で生成しておいた query cache を見に行くモードになり、DB なしでもビルドが通ります。
env:
SQLX_OFFLINE: true
2. schema.sdl() の出力順は実装順依存
async-graphql の Schema::sdl() は、型やフィールドの 定義順をそのまま出力します。Rust 側で impl の順番を入れ替えると diff が出て、本質的でない差分で PR がレッドになることがあります。
選択肢としては:
- そのまま受け入れる(iso-flow はこれ)。実装順を動かさない運用に倒す
- ソートを噛ませる。SDL を line ベースでソートする、または AST を舐めてカノニカル化するスクリプトを追加する
個人開発では前者で十分でした。チームが増えたら後者に倒すかもしれません。
3. cargo run でうっかり他の bin をビルドさせない
src/bin/ に DB を触る別のバイナリがあると、cargo run --bin print_schema でもリンク時に引っかかって build 時間がドカっと伸びる、ということはありませんが、将来 workspace が育ったら --package や -p で範囲を絞るのが安全です。
実際には --bin print_schema でビルドされるのは依存関係のグラフに必要な分だけなので、SQLX_OFFLINE さえ通せば print_schema はサクッと動きます。
4. エラーメッセージに修正コマンドを書いておく
最初は diff -u の結果だけを出していたんですが、drift を起こした PR の作者(未来の自分)は「で、どうやって直すの?」となります。
echo "❌ frontend/src/jsMain/graphql/schema.graphqls が backend のスキーマとズレてる"
echo " ローカルで以下を実行してコミットしてくれ:"
echo ""
echo " cd backend && cargo run --bin print_schema > ../frontend/src/jsMain/graphql/schema.graphqls"
Actions の失敗ログから コピペして直せるようにしておくと、体験がだいぶ変わります。
まとめ
- async-graphql は code-first だが、frontend に SDL を配る需要はあるので
schema.graphqlsは commit する - commit する以上は drift を機械的に検出する仕組みがほしい
-
Schema::sdl()を stdout に吐くだけの小さなバイナリをsrc/bin/に置き、CI でdiffを取るのが最小コストの解 -
SQLX_OFFLINE=trueとpathsフィルタで CI コストは十分に抑えられる
次回は、この SDL を入力にして Kotlin/JS + Apollo Kotlin で型安全 GraphQL クライアントを入れた話を書きます。Apollo Kotlin 4 が Kotlin 2.0 必須だったり、apollo-mockserver が死んでたり、地味に罠があるので共有する予定です。
サンプルリポジトリ
👉 toguri/rust-async-graphql-schema-ci
この記事の構成を最小サンプルとして切り出したリポです。cargo run --bin print_schema > schema.graphqls と schema-check.yml がそのまま動く形になっています。
参考
- async-graphql book — SDL export
- async-graphql リポジトリ
- この仕組みが動いている本番サイト: nba-iso-flow.com