async-graphql + sqlx + shakuを使ってGraphQL を試した

RustでGraphQLアプリを構築して、気づいたことや感想まとめ

作ったもの

https://github.com/vraisamis/graphql-rust-sample

  • Kanban風のデータを取得できるアプリ
  • GraphQLアプリ
    • Queryのみ実装済みで、Mutation, Subscriptionは未実装
    • QueryではDataLoaderを実装
  • 言語: Rust 1.71.0
  • ライブラリ(主要)
    • サーバー: axum 0.6.19
    • GraphQL: async-graphql 6.0.0
    • DI: shaku 0.6.1
    • SQL: sqlx 0.7.2
  • データストア
    • Postgres 16.0
    • dynamodb on localstack 2.3 (未使用)
  • オニオンアーキテクチャ
  • DDD(ドメイン駆動設計)
    • とはいえ、クエリしか作っていないのでIDくらいしか活用していない

ドメイン

だいたいこんな。未実装箇所もある https://github.com/vraisamis/graphql-rust-sample#domain

レイヤー

だいたいこんな。 https://github.com/vraisamis/graphql-rust-sample#layer

やっていないこと(完了していないこと)

  • テストの実装
  • Mutation, Subscription
  • ドメインの実装
  • dynamodb
  • CI構築

Tips

Rust: 言語機能

Rustの言語機能関連

Trait alias

何がしたかったか

shaku のモジュール MBox<dyn UsersQuery> をprovideできるとき、 M: HasProvider<dyn UsersQuery> を満たしている。 M の具体型は実装レイヤで決まるので、実装に関与しないレイヤでは抽象的に扱いたい。

しかしこのとき、 M の指定が大変なことになりやすい。

fn foo<M>(m: &M)
where
    M: HasProvider<dyn UsersQuery>,
    M: HasProvider<dyn BoardQuery>,
    M: HasProvider<dyn ColumnsQuery>,
    M: HasProvider<dyn CardsQuery>,
    // ...
{
    todo!()
}

これを解消するために直感的には typeエイリアスを定義したい。

// これはできない
type MyFuncTrait = HasProvider<dyn UsersQuery> +
    HasProvider<dyn BoardQuery> +
    HasProvider<dyn ColumnsQuery> +
    HasProvider<dyn CardsQuery>;

これはできない。

対策

SEE: https://zenn.dev/ttttkkkkk/articles/e213d91ef7b47a

上の記事に全部書いてあるが……以下をやる。

  • すべてを前提とするtrait定義
  • すべての前提traitを実装している型に対して全称実装
// trait定義
pub trait QueryProvider
where
    Self: HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>,
{
}

// すべての前提traitを実装している型に対して全称実装
impl<T> QueryProvider for T
where
    Self: HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>,
{
}

ちなみに、この用途だと FooQuery が増えるたびに2箇所に追記していくことになる。マクロで解決できないかと思ったが、構文エラーになって駄目だった。

// これはできない
macro_rules! queries {
    () => {
    HasProvider<dyn UsersQuery> +
        HasProvider<dyn BoardQuery> +
        HasProvider<dyn ColumnsQuery> +
        HasProvider<dyn CardsQuery>
    };
}

pub trait QueryProvider
where
    Self: queries!(),
{
}

impl<T> QueryProvider for T
where
    Self: queries!(),
{
}

pub(crate) とか re-exportとか

Rustのstruct, enum定義は縦に長くなりがちである。これは主に以下のような理由による。

  • メンバの定義とメソッド定義を別に書く必要があるため、構文要素が多くなる
    • ジェネリクスを持つ( Foo<T> )場合、 T が実装しているtraitによって実装するメソッドを増やしたりするともっと多くなる
  • traitを実装する記述を書くほど長くなる
    • メソッド本体よりも周りの impl FooTrait for Foo where ... がいっぱいになる
  • テストを同じファイルに書く習慣がある

また、DDDでは以下のようにしたくなる。

  • ユーザー情報に関連するモデルを複数作りたい
    • User
    • UserName
    • Email
  • 同じサブドメインに関連するモデルがわかりやすい構造で外部(上位レイヤ)から参照したい
    • use domain_kanban::user::{User, UserName, Email}

これをそのまま実現しようとすると、1ファイルに大量の定義や実装を書くことになる。対策として、複数ファイルに分ける方法を利用できる。 具体的には、以下のようにuser/email.rs, user/user_name.rs を作成する。

src/
|-- lib.rs
|-- user/
|   |-- email.rs
|   `-- user_name.rs
`-- user.rs

そして、以下のようにする。

// -------- user/email.rs --------
// `pub(crate)` で(直接)参照可能範囲を制限する
pub(crate) struct Email {
    // ...
}

impl Email {
    // ...
}

// -------- user/user_name.rs --------
pub(crate) struct UserName {
    // ...
}

impl Email {
    // ...
}

// -------- user.rs --------
// mod にはpubをつけない( `domain_kanban::user::email::Email` というふうには参照させない)
mod email;
mod user_name;

// pub use (=re-export) で `domain_kanban::{Email, UserName}` で参照できるようにする
pub use email::Email;
pub use user_name::UserName;

pub struct User {
    // ...
}
// ...

こうすることで、ファイルが長くなるのを抑えつつ、外部への見せ方を変える事ができる。 ただし、こうするとファイル数が多くなるので、やり過ぎは禁物。

Vec<Result<T, E>> to Result<Vec, E>

SEE: https://qnighy.hatenablog.com/entry/2017/06/14/220000

Vec<T>map していると、軽率に Vec<Result<T, E>> になる。

これだと扱いにくいのだが、 collect でどうにかできる。

let vec_of_results = vec![Some(1), Some(2)];
// collectは複数の型に変換できるので、要求する型を束縛する変数側に指定する
// `collect::<Result<Vec<_>, _>()` でもいいが、個人的にはこちらのほうが書きやすい
let result_of_vec: Result<Vec<_>, _> = vec_of_results.into_iter().collect();
// `?` が使える文脈なら、それを使って処理を続ける
let v = result_of_vec?;
// `?` を使うなら、collect側に書いたほうが1行で完結できる
let v = vec_of_results.into_iter().collect::<Result<Vec<_>, _>>()?;

Vec<Future> to Vec

dataloader実装(後述)をやっていても、複数回クエリを回したいときもある。

let result_futures: Vec<impl Future<Output=Foo>> = keys
    .into_iter()
    // some_queryがFutureを返す
    .map(|k| some_query(/* ... */))
    .collect();

このあとどうやって処理するかというと、futures-util join_all を使う。

let result_vec: Vec<Foo> = join_all(result_futures).await;

Rust: (Virtual) Workspaceの活用

オニオンアーキテクチャにおけるレイヤー分けを見据えて、リポジトリ全体はVirtual Workspaceにした。 ワークスペースのメンバcrateは、 crates/ ディレクトリ配下に置いた。rootディレクトリがゴチャつかないようにするためで、好みの問題かもしれない。

メンバcrateの指定は Cargo.toml で以下のようにした。

[workspace]
members = [ 'crates/*' ]

bin crateが crates/main だけで他がlib crateであれば、 cargo run で問題なくmainが起動できた。

Workspace Dependencies

https://doc.rust-lang.org/cargo/reference/workspaces.html#the-dependencies-table

Workspaceでこれが一番便利だったかもしれない。

/Cargo.tomlworkspace.dependencies 配下にいつも通り依存関係を書く。 各 /crates/*/Cargo.tomldependencies で、 package-name.workspace = true とすると、そのcrateを利用できる。

例:

# -------- on /Cargo.toml --------
[workspace.dependencies]
anyhow = "1.0.72"

# -------- on /crates/main/Cargo.toml --------
[dependencies]
anyhow.workspace = true

# -------- on /crates/domain-util/Cargo.toml --------
[dependencies]
anyhow.workspace = true

実際に定義したものを列挙してコメントしておく。説明の都合sourceとは掲載順が違うことがある。

# -------- on /Cargo.toml --------
[workspace.dependencies]
# anyhow, thiserror: エラー処理はどの層でも行うので共通で入れた
# ちゃんとエラー処理するほうに倒すと、anyhowは減っていく可能性もある
anyhow = "1.0.72"
thiserror = "1.0.47"

# tracing, tracing-subscriber: ロギング。
# ログに使っているcrate versionが揃うように。
# tracing-subscriberはもしかしたらmainだけでいいかもしれない。ちゃんとログ実装したら除去できるかも。
# 各層から依存するlog crateでtracingを使っていることを隠蔽しようかとも思ったけど、
# マクロの構文が物によって違いそうなのでやめた。
tracing = "0.1.37"
tracing-subscriber = "0.3.17"

# async-trait: infrastructure層で外部リソースを使用することが必至なため、だいたいasyncにする。
# I/Fはquery層などに置くことになるので、trait methodとしてasyncにする必要がある。
async-trait = "0.1.72"

# futures-util, itertools: 一般的なutility
futures-util = "0.3.28"
itertools = "0.11.0"

# shaku: DI。I/F側と実装側でバージョン統一するため。
shaku = "0.6.1"

# sqlx: これはinfrastructure層でしか使わない……と見せかけて、migrateとかscriptとかで使っている(後述)
# ここではバージョンのみ定義していて、featuresは使う側のcrateで決めている。
sqlx = "0.7.2"

# ulid: これはdomain-utilで吸収しているのでここではいらないかも……
ulid = "1.0.1"

# 以下各レイヤの定義。利用側でpathを毎回書かなくてよくなる。
# 依存されないcrateはここには列挙していない
presentation-axum = { path = "./crates/presentation-axum" }
presentation-graphql = { path = "./crates/presentation-graphql" }
infrastructure-rdb = { path = "./crates/infrastructure-rdb" }
query-resolver = { path = "./crates/query-resolver" }
domain-kanban = { path = "./crates/domain-kanban" }
domain-util = { path = "./crates/domain-util" }

# tokio: main関数を使う場所で使っている。mainアプリケーションでは下層のspawnerにも使っている。
[workspace.dependencies.tokio]
version = "1.29.1"
features = ["macros", "rt-multi-thread"]
  • 便利ポイント
    • シンプルに同じバージョンや同じfeaturesを共有できるので便利
    • featuresは利用側で追加できる。共通のものだけ置いておくのが便利
    • レイヤー間の依存関係について、個別にpathで書くのがちょっとめんどくさかった。これが解消されるので便利。
  • イマイチポイント
    • cargo-add とかのツールのサポート(後述)

Workspace: cargo-(add|outdated)

それぞれ依存crate管理に関わるツール。ツール単体に気になるところはなく、普通に便利だった。

workspace dependencies を書くうえではコツがいりそうなので、ポイントに絞って書く。

cargo-add

端的に言うとworkspaceはサポートしていない。

issueはここにある: https://github.com/rust-lang/cargo/issues/10608

workspace dependenciesには自分で書くとして、そのヒントとなる情報は --dry-run オプションで得るのがちょっと便利だった。

# --package オプションの指定がめんどくさいので、移動しておく。
$ cd crates/main
$ cargo add frunk --dry-run
    Updating crates.io index
      Adding frunk v0.4.2 to dependencies.
             Features:
             + frunk_proc_macros
             + proc-macros
             + std
             + validated
             - serde
warning: aborting add due to dry run

この結果を見ると以下がわかる。Webで調べる場合との比較も書いておく。

  • 最新版は0.4.2っぽい
    • crates.io をみればわかるが、GitHubを見てもわからないことが多い
      • releasesを使ってリリースしているところもあればそうでないところもある。なんなら途中から切り替わったところもある
      • /Cargo.toml に書いてないこともある。Virtual Workspaceを使っていてmulti crateそれぞれpublishされている場合とか
      • デフォルトブランチの内容がリリースの最新とは限らない。大体のリポジトリには、リリースバージョンごとにタグをつけてあるけど、「大体」
    • crates.io はREADMEの幅が狭いので、説明文が読みにくいことがある。 feature flagsはREADMEにあることが多いので、読みにくいと困る
  • default featuresは frunc_proc_macros, proc-macros, std, validated で、optional featuresは serde らしい
    • これもどの Cargo.toml をみたらいいかわかりにくい。みてもわからないことも……
    • そもそもRust全体として、外部から指定するfeatureなのか内部のためにあるfeatureなのかがわからないという話がある。ここは変わらない
      • たとえばこのfrunkなら、実際は validated, proc-macros, serde が指定するもので、残りは依存されるものでおそらく外部から指定するものではない
      • ……が、実は serdeCargo.toml に載ってない。READMEにだけある

cargo-outdated

workspace supportはあるっぽい。

  • w, --workspace
    • なしだと workspace dependenciesをチェックする。というより、rootの Cargo.toml をみるみたい
    • ありだと、workspace memberのdependenciesをチェックする
  • R, --root-deps-only
    • 直接依存しているcrateのみを表示するようになる
    • デフォルトだと依存しているものの依存先も再帰的に辿っていくので、更新対象を確認する用途だとほぼ必須っぽい

上記を整理すると、以下のような使い分けになる。

  • workspace dependenciesを確認するとき: cargo outdated -Rw
  • 各crateのdependenciesを確認するとき: cargo outdated -R

Workspace: スクリプト

進むうちに、いくつかスクリプトが欲しくなった。

  • Rustコード系
    • GraphQLのスキーマを吐きたくなった。async-graphqlはcode firstなので、Rustコードからスキーマを生成する
    • migrateコード。migrate方法は深く追求せずsqlxのを使うことにしたが、sqlx cliをインストールせずにやろうと思うとscriptにする必要があった
    • テスト用のデータ生成。これをmigrateとまぜるときれいにしたいときとか面倒なので、別のbinにしたかった
  • シェルスクリプト
    • 開発用の cargo ... などのコマンド。オプションが多くて思い出すのがめんどくさくなってきた

それぞれ検討した方法は以下の通り。1番目に記載したものが採用したもの。

  • Rustコード系
    • スクリプト用の(multi bin) crateを用意
      • workspace内のコードを再利用したい部分もあったので、crateを追加で作成した
      • migrateだけ独立させた。マイグレーションSQLが別途必要で、どのbinがこれを使うのかを明確にしたかった
      • それ以外はmulti crate binにした。開発用スクリプトなので、binary sizeは気にしなくていい。
      • ここに記載があるように、 src/bin 配下に置いたファイルにmainを置けば新しいbinにできる
      • 実行は cargo run --bin $BIN_NAME のようにする。workspace rootからやるなら -package scripts を追加する
    • rust-script を使う
      • crateを作らなくても、 [rust-script](https://github.com/fornwall/rust-script) で個別のスクリプトを実行できる。
      • 個別にdependenciesを定義できる良さがあるが、これは rust-analyzer が効かない
        • 何か工夫すればできる可能性があるが、知らない&そのための工夫が必要なプロジェクトではない
      • scriptにしても結局workspace内のcrateに依存させるので、workspace内にcrate作ってもあまり変わらないなと思った
  • シェルスクリプト
    • READMEに書く
      • 特に説明することはない……READMEに1行説明とともに列挙するだけ
    • cargo-make にまとめる
      • cargo-make にまとめると cargo make migrate みたくすることができる
      • CIとかを見据えると、1箇所で管理できて名前付きにできるこの方法のほうがリッチだけど、 Makefile.toml を書くのがやや面倒
      • 多機能ゆえに機能を把握していないと触りにくい(性格の問題)

Workspace: default members

前述のように、bin crateを追加したことで実行時引数が増えた。

  • before: cargo run
  • after: cargo --project main run

ちょっと増えたぐらいだから別にいいんだけど、 default-members に指定することで引数不要にできるのでそうした。

[workspace]
members = [ 'crates/*' ]
default-members = [ 'crates/main' ]

cargo-watch

GraphQLサーバーのコードを更新するたびに再ビルドさせて実行させるのに、 cargo-watch が便利だった。

cargo watch -x run で実行できる。

  • watch はデフォルトで check をするので、 x で実行内容を指定する
  • default-members でないものは -package などを指定する必要があるが、 x 'check --package scripts' のようにquoteが必要
  • GitHub を見たら、 cargo watch -- cargo check --package scripts のようにもできるらしい

cargo-expand

マクロをそれなりに使うときは、 cargo-expand を使うとコンパイルエラー調査が便利である。 今回だと、async-trait, tokio, async-graphql, shaku, sqlxあたりがマクロをよく使う。

cargo-expand はデフォルトだと全部を展開しようとする。itemの指定ができるので、以下のように使うと便利。

# 該当のcrate rootまで移動しておく
cd crates/infrastructure-rdb

# mod単位でexpand
cargo expand query::card
  • 本当にstruct単位でexpandしようとすると、implが表示されないなど情報不足なことがある
    • かといってmoduleだと多いなーという場合は確認用にsubmoduleに切り出すといいかもしれない
  • ただ、期待しすぎない方がいい。普通にコードを読むより多くの知識を要求されることが多いので、ある程度「わからんものはわからん」という気持ちでいないとつらくなる
    • 見ても分からなければ強い人に聞いて、また強くなったら帰ってこよう

Rust utilityライブラリ

あまり書くことはないけど使ったもの。

itertools

VecをgroupingしてHashMapにするなどのときに便利だった。 でもびっくりするぐらいメソッドがあるので、iterator関連のほしいメソッドはググるのが賢明。

futures-util

future関連のメソッドが追加されていて便利。 どうしてもiteratorの要素ごとにFutureが生成されてしまったので、 join_all を使った。

GraphQL

GraphiQL UI

GraphiQLというGraphQLのUIがあり、 async-graphql でもすぐ利用できるようになっている。 cargo watch -x run で起動し、8000ポートにマップした。 GraphQLの要素が補完され、ドキュメントも出るのでクエリ組み立てに便利。

watch でサーバーが再起動しても、UIは残るので使いやすかった。 サーバー再起動時にはschemaを取り直さないので、 Refresh GraphQL schema ボタンを押すことで補完されるようになる。

dataloaderと相互参照

dataloaderを実装した(というより、schemaのresolverを分けた)ことで、相互参照が実現できた。

{
    usersAll {
        id
        name
        # userがオーナーのボードを参照
        ownedBoards {
            id
            title
            # ボードのオーナーを参照
            owner {
                id
                name
            }
        }
    }
    boardsAll {
        id
        title
        # ボードのオーナーを参照
        owner {
            id
            name
        }
    }
}

schemaのresolverを分けたりしなくても相互参照はできる。 しかし、dataloaderの実装に任せることで、実装をシンプルに保つことができた。

async-graphql

GraphQL実装はRust製のasync-graphqlを使った。一通りの機能があり、ドキュメントもあるのであまり困らず使えた。

Schema生成

ここにある通りに書いた。

実装上の考慮事項として、Schema型にはdataやextentionsなどのデータを紐づけているが、これはスキーマ生成だけを考えると不要である。そのため、以下のように考慮しておく(と楽)。

  • schema生成時はdataやextentionsを紐付けない
  • アプリとschema生成で同じbuildを通るように共通化

通化にあたって、SchemaやBuilderの型引数にquery, mutation, subscriptionがあって煩雑になったので、 type キーワードでエイリアスを作って対策した。

// 定義
type SchemaType = Schema<Query, EmptyMutation, EmptySubscription>;
type SchemaBuilderType = SchemaBuilder<Query, EmptyMutation, EmptySubscription>;

// 利用
fn schema_builder() -> SchemaBuilderType {
    Schema::build(Query, EmptyMutation, EmptySubscription)
}

fn schema() -> SchemaType {
    schema_builder().finish()
}

fn schema_with<F>(f: F) -> SchemaType
where
    F: FnOnce(SchemaBuilderType) -> SchemaBuilderType,
{
    f(schema_builder()).finish()
}

また、rust-scriptだと補完が効かなくて作りにくかったのでscripts crateにまとめた。

dataloader実装

dataloaderの実装方法自体はドキュメントにコードで記載があり、それをなぞった。以下が私にとってのポイントだった。

  • Trait実装として提供するので、同じキーのdataloaderを複数個同じ型に実装できない
    • たとえば Id<User> をキーにユーザー一覧をとりたいときとボード一覧をとりたいときとか
    • →未確認だが、 UserIdForBoard のようなstructにラップして利用すれば良さそう?もしくはdataloaderを分ける?
      • そもそもdataloaderを複数にするべきかどうかがわかっていない。一つでいいような気がする。
  • dataloaderはadapter層で、実行するクエリはquery層で定義している。そのため、IDの型があわない。せっかく keys: &[K] でもらっても変換するのでVecにしている。
    • →仕方ないと思って諦めた
    • ちなみに、返却時も再変換が必要
  • 戻り値が HashMap<K, V> であるので、この形に変換が必要
    • →あきらめてquery層をこちらに寄せてHashMapを返すようにした

Data 取得

async-graphqlは context から参照できるデータを持つ。このデータは格納時に Any にして、取り出すときにダウンキャストしている。

let modules: &Modules = context.data()?;
// or
let modules = context.data::<&Modules>()?;

そのままでも使えるが、型注釈が必須な点がやや面倒である。

個人的には前者のほうが読みやすいが、続けてメソッドを呼び出したい場合などでは後者の書き方になり、このとき型注釈を忘れるとエラーになってしまったり、 ::<> のように装飾が多かったりとあまり好きではない。

以下のようにcontextを拡張するメソッドをつくって、型注釈を不要にした。メソッド名から何が返るのかもわかりやすく、実装コストに対して効果的な印象。

// ContextにDataLoader, Modulesを取得するメソッドを作成する
// dataはAnyで型消去しているけど、このプロジェクト内ではSchemaを作っている箇所で必ず設定しているはずなので気にしないことにする
pub trait ContextExt {
    fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError>;
    fn modules(&self) -> Result<&Modules, GqlError> {
        Ok(self.data_loader()?.loader())
    }
}

impl<'ctx> ContextExt for Context<'ctx> {
    fn data_loader(&self) -> Result<&DataLoader<Modules>, GqlError> {
        self.data()
    }
}

sqlx

マクロ

sqlxはマクロで「コンパイル時にSQL結果の型をチェック」できる。

これの利用に向けてはちょっとコツがいる。

  • コンパイル時に参照できる場所にテーブルが存在するDBを立てる必要がある
    • dockerを使って立てるのが楽
      • docker内にテーブルを用意するのは、sqlxのmigrate機能等を使うといい
    • offlineモードというのもあるが、試していない
  • 環境変数で参照するDBを指定する必要がある
    • コマンドが長くなるので、スクリプトにまとめた方がいい
  • query!() マクロの場合)コンパイル時に型チェック=コンパイルしないと型が決まらない
    • rust-analyzerが補完してくれない
    • クエリを書いたあと、後述処理を書くまえにコードを保存(ないしは再コンパイル)するといい
  • コンパイル時にSQLでエラーになったとき、エラー箇所がわからない
    • Rustのコンパイルエラーメッセージにエラー内容が記載されていないことがある
    • dockerのPostgres(コンパイル時の接続先)のstdoutを見るのがいい
      • ただし、prepared statementのエラーかつ改行なしで出ていて、どこが間違っているのかすぐにわからないこともあった

感想としては、「すごいが、コンパイル時チェックなしの関数でもいいかな……」という感じだった。理由は以下。

  • クエリがstatic文字列でなくてはいけない
    • 動的に where 句のパターンを書き換えたいときにつらい
    • 似たような or 同じクエリを使い回せない
      • query_file!() マクロがあれば完全に同じクエリならいける
  • (前述)クエリ結果を束縛した状態でsaveしないとrust-analyzerが補完してくれない
  • 安全性は(テストをちゃんと書くなら)関数で書いてもあまり変わらない

poolとtransaction

sqlxの(postgres)クエリを実行するとき、 PgPoolTransaction<'a, Postgres> が必要になる。それぞれの使い方は以下の通り。

  • pool: &PgPool の場合
    • .execute(&pool) とする
  • mut transaction: Transaction<'a, Postgres> の場合
    • .execute(&mut transaction) とする
  • transaction: &mut Transaction<'a, Postgres> の場合
    • .execute(&mut **transaction) とする
    • 特にこれを忘れやすい
    • 関数にクエリをまとめていると引数に登場しがち

特にC言語の経験からすると3番目は謎が深いが、とりあえず覚えることにする。

derefして &Connection に、更にderefして '&mut を取っている感じで認識している。

migrate

sqlxがCLIを提供していて、それを通してmigrateした。

migrateファイルの作成も適用もCLIで問題なく行えた。

shaku: Providerのエラー型をasync-graphqlに合わせる

RustのDIライブラリ。async-graphqlのcontextと組み合わせて、以下のようにしてクエリを取得したい。

let user_query: Box<dyn UsersQuery> = context.modules()?.query().provide()?;

これは工夫しなくても実現できる……が、エラー型がちょっと扱いにくい。最終的には async_graphql::Error 型にしたいが、 shaku::HasProvider::provide() が返すエラーは Box<dyn std::error::Error> であり async_graphql::Error 型に変換できない。

そこで、この変換だけをラップするためのメソッドをtraitで拡張して作成した。

// shakuのErrorをasync-graphqlにあわせる
pub trait HasProviderGql<I: ?Sized>: HasProvider<I> {
    fn provide_gql_result(&self) -> Result<Box<I>, GqlError>;
}

impl<T, I: ?Sized> HasProviderGql<I> for T
where
    T: HasProvider<I> + ?Sized,
{
    fn provide_gql_result(&self) -> Result<Box<I>, GqlError> {
        self.provide().map_err(|e| {
            GqlError::new(format!(
                "providing <{}> failed: {}",
                type_name::<I>(),
                e
            ))
        })
    }
}

これを使うと、GraphQL クエリメソッド内でも ? でエラー型を変換できる。

開発体験: ChatGPT

使い込んでいないので3.5での結果。

ChatGPTにやらせたこと(1): ドメインモデル図を描かせる

ありきたりなkanbanアプリのドメインモデル図だったので、ChatGPTに書かせた。

Kanbanアプリケーションをドメイン駆動設計で作成します。ドメインモデル図をmermaid形式で示してください。

指示としては以上で、それ以降は「集約を小さくしてください」「操作を追加してください」などを追加指示した。

結果として取得できたコードは以下で、いくつか間違っている箇所がある。手動で直せばいい範囲なので、ChatGPTに再修正させず手で直した。

classDiagram
    class User {
        <<Entity>>
        +userId: int
        +name: string
        +email: string
        +boards: Board[]
    }

    class Board {
        <<Aggregate>>
        +boardId: int
        +title: string
        +owner: User
        +members: User[]
        +columns: Column[]
        +addColumn(title: string): Column
        +removeColumn(columnId: int): void
    }

    class Column {
        <<Aggregate>>
        +columnId: int
        +title: string
        +tasks: Task[]
        %% --------間違った結果の箇所: addColumnではなくaddTaskっぽい。--------
        +addColumn(taskTitle: string, taskDescription: string): Task
        +removeTask(taskId: int): void
    }

    class Task {
        <<Entity>>
        +taskId: int
        +title: string
        +description: string
        +status: string
    }

    User --> Board : owns
    User --> Board : member of
    Board --> Column : has
    Column --> Task : contains

ChatGPTにやらせたこと(2): レイヤー構造を描かせる

同じ理屈で、オニオンアーキテクチャのレイヤー構造を図にしてもらった。

mermaidの図の書き方を思い出すより早くできた。

ChatGPTにやらせたこと(3): 典型的な docker-compose.yml を書かせる

RDBを構築したくなったタイミングでChatGPTに docker-compose.yml も書かせてみた。

今回は典型的な(後で使うつもりのlocalstackと)Postgresを立てるだけだったので、ほとんどそのまま出力できた。

注意点は以下。

  • localstackは古いバージョンでのみ利用できる環境変数があったので除去した
  • ポート番号はそのままで割り当てられるので、それを 60000 から振るように追加指示した

ChatGPTにやらせたこと(4): RDBテーブル定義を作らせる

RDBのテーブル定義を作るのが面倒だったので、吐き出させた。

何を入力にしたかというと、GraphQL Schemaを入力にした。

以下graphqlのデータをPosgresで構築します。テーブル定義のDDLを示してください。

追加注文したのは以下の通り。

  • IdVARCHAR 型にしてください
  • テーブル間の関連を別テーブルに切り分けてください
    • これは2回くらい注文した。
  • テーブル名をsnake_caseに変更してください
  • テーブル名を複数形にしてください

ChatGPTにやらせたこと(5): Rustコードをサンプルデータ投入SQLに変換させる

infrastructure層の実装が終わって、RDBと接続して確認したかったが、データがない。

仮実装の段階でRustコードでデータをメモリ上に作る実装があったので、これを入力にINSERT文を吐かせてみたら、思ったより精度良く吐かせることができた。

以下Rustコードで作成するのと同等のデータをPostgreSQL DBに用意するINSERT文を示してください。
[Rustコード...]

なお、テーブル定義は以下のとおりです。
[テーブル定義...]

特に、配列参照を使ってIDがおなじになるようにしていた箇所が全体的に正しく解決されていたので驚いた。

気になっていること

frunkで変換が楽になる?

レイヤーの関係で変換が必要な presentation_graphql::scalar::Id<T>domain_util::Identifier<T> とか、クエリの返却値とgraphql responseとか、細かい変換をいくつも書いた。

frunkという関数型プログラミング系のライブラリがあって、この中のGenericという機能がこの辺りの変換をサポートしてくれていそう。活用したら楽になりそう。

infrastructureのテスト

互いのテストが影響しないようにするために、インメモリDBを使いたいが、sqlxでそのあたりうまくできるのかを調査する必要がある。

認証とか

まだアプリケーション内での認証処理とかについて調べていないので確認する必要がある。