⚙️

RustでGraphQLサーバーを作る

2023/09/27に公開

はじめに

この記事は,RustによるGraphQLサーバーのテンプレートとコードの説明を個人的な備忘録としてまとめたものです.
Rustや各Crateその他のアップデートおよび仕様変更によって将来的にこの記事の内容が使用できなくなる可能性があります.(記.2023/9/27)

使用Crate

といった感じで比較的情報がたくさんありそうな組み合わせになります.なかったけど.最近はAPIサーバーのフレームワークだとAxumとかよく聞きますね.

全体概要

レポジトリはここにあります.READMEに書いてある通り,.envを用意してdocker compose upでサーバーが立ち上がります.
筆者は個人開発において生のSQLよりもORMを好むためDB接続の際にdiesel::r2d2を用いていますが,適当なCrateを使用してDBに接続するようdb.rsを書き換えれば非ORMも使えると思います.
また,スキーマを書くのが面倒だったのでこのテンプレートではDBのマイグレーションおよび読み書きの処理は実装していません.このテンプレートを使用する場合は必要に応じて各自で実装してください.
また,この記事ではドキュメントに載っていることの詳細な説明は行いません.実際にGraphQLサーバーの実装をするにあたって情報が無く,特に詰まったところを重点的に扱います.

DBのコネクションプール

このテンプレートではdiesel::r2d2を用いてDBへ接続していますが,ここは普通のRESTAPIの場合と変わらないと思います.

use diesel::{PgConnection, r2d2};
use diesel::r2d2::ConnectionManager;
use dotenv::dotenv;
use std::env;

pub type Pool = r2d2::Pool<ConnectionManager<PgConnection>>;

pub fn establish_connection() -> Pool {
  dotenv().ok();
  let url = env::var("DATABASE_URL").expect("Failed to find DATABASE_URL in .env");

  // create db connection pool
  let manager = ConnectionManager::<PgConnection>::new(url);
  let pool: Pool = r2d2::Pool::builder()
    .build(manager)
    .expect("Failed to create pool.");
  pool
}

おそらく詰まるところはpoolをどうやって持たせるかになると思いますが,どうやらActix-WebのApp::newではなくAsync-GraphQLのSchema::buildに持たせることになるようです.多少省略していますが,このような感じで.data()を用いてpoolのクローンを持たせています.

  // DB connection
  let pool: db::Pool = db::establish_connection();

  // schema setup
  let schema = Schema::build(Query, Mutation, Subscription)
    .data(pool.clone())
    .finish();

そしてこのpoolをQueryやMutationの実装時にasync_graphql::Contextから取り出し,データベースへの読み書きを行います.ここの実装例が本当に全然見つからなかったです.

async fn post<'ctx>(&self, ctx: &Context<'ctx>, message: String) -> bool {
    // select or delete database with this pool
    let conn = &mut ctx.data_unchecked::<db::Pool>().get().unwrap();
    // sql or orm code here
    ...
  }

Query/Mutation

QueryとMutationについてはドキュメントのこのページだけで実装には事足りると思います.テンプレートでは返り値がResult型になっていませんが,本当はドキュメントのようにResult型の方が良いようです.

Subscription

Subscriptionについてはこのページに独立した実装例がありますが,Mutationなどをトリガーにして配信したい場合がほとんどだと思います.
QueryやMutationと異なりSubscriptionはStream型を返すので,そこだけ注意する必要があります.このテンプレートではfutures_util::Streamを使用しています.ここもResult<Stream>型にしてあげた方がいいんですかね?

static RECEIVER: OnceCell<Mutex<UnboundedReceiver<String>>> = OnceCell::new();
---
  // subscription reciever
  let (cmt, rx) = tokio::sync::mpsc::unbounded_channel::<String>();
  RECEIVER.set(Mutex::new(rx)).unwrap();

  // schema setup
  let schema = Schema::build(Query, Mutation, Subscription)
    .data(cmt)
    .finish();

まずは上のようにOnceCellを用いてSubscriptionのレシーバーを用意し,DBのコネクションプールと同様にschemaに持たせています.そしてSubscriptionの実装については,

#[Subscription]
impl Subscription {
  async fn subscribe(&self) -> impl Stream<Item = String> {
    // return item if reciever has new push
    async_stream::stream! {
      loop {
        let mut rx = RECEIVER.get().unwrap().lock().await;
        if let Some(item) = (*rx).recv().await {
          yield item;
        }
      }
    }
  }
}

のようにloopを用いて監視し,RECIEVERに更新が入るとその内容を取得して返すという実装になっています.
また,Mutationなど他の操作をトリガーにして配信したい場合はその関数の中でRECIEVERに送る必要があります.以下はMutationで受け取ったmessageをsubscriptionへ送る例です.

    // push to subscription reciever
    let cmt = ctx.data_unchecked::<UnboundedSender<String>>();
    cmt.send(message.clone()).unwrap();

ちなみに,この実装だとSubscriptionに複数接続された場合,どれか1つの接続にしか送られないというバグがあります.RECIEVERかStreamの中の何かが引き起こしてそうな雰囲気を感じますがよく分かっていません.有識者による修正をお待ちしております.

おわりに

筆者自身Rustについて詳しいわけでもなく,このテンプレート自体がハッカソンで作成したものを抜粋したものであり,全くベストプラクティスではないと思います.もっと良い実装,綺麗なコードがありましたら,是非コメントやプルリクなどで教えてください.
また,この記事ではほとんどのコードを部分的に抜き出しているので,全体の実装についてはGitHubのレポジトリを見ていただければと思います.

Discussion