4
Help us understand the problem. What are the problem?

posted at

updated at

【Rust】GraphQL API Serverを作ってみる話。(1/?) ~導入編~

はじめに

こんにちは、久し振りのシリーズものです。
最近Rustでサーバーを建てる機会があり、じゃぁ折角だからGraphQLに挑戦してみようかなって軽い気持ちで始めたら意外と苦戦したので、記事にのこしておこうと思いました。

このシリーズでは簡単なGraphQL API Serverを建てることを目標に、

  • RustのGraphQL Frameworkってどんなのがあるん?
  • RustのORMってどんなのがあるん?
  • N+1問題ってなに?Rustではどうやって解決するの?

ということなどについて言及していきます。

また事前知識として、以下のようなものがあると理解がしやすいかなと思います。
もし本記事で分からないところがあれば、参考文献として見てみてください。

また、このシリーズではRustのGraphQL FrameworkとしてJuniperを採用しています。
Juniperには公式が提供するBookもあり、ドキュメント周りがとても整備されているという印象です。
ただやはり英語ということもあり、英語弱者である自分はめっさ読む気を削がれました...。
なので、以下に自分が公式のBookを翻訳したものを貼っておきます。
読む際には、非公式なので常に最新版であるとは限らないということと、DeepL翻訳を多用したのでガバガバだということに注意してください。
ただ公式も公式で、本稿執筆時点ではBook内で使用されているJuniperのバージョンが0.10であるのに対し、最新安定版が0.15.9であることに注意してください...。
やっぱりガバガバじゃないか...(困惑

環境

  • OS: Windows 10
  • Rust: 1.60.0-nightly
    • 各依存クレートの詳細は後述のCargo.tomlを参照されたし。

Rust・GraphQLとは

この記事をご覧になっている人のほとんどはRustとGraphQLに興味がある方だと思います。なので今更説明なんてフヨウラ!と思うかもしれませんが、思わない人のために私の言葉で簡単に説明させていただきます。

Rustとは

Rustはその強い型付けにより、高速かつ安全なプログラムを提供します。
その速度はC/C++を抜いたり抜かなかったりするレベルです。
また拡張性にも優れ、豊富な言語機能によってゼロコスト抽象化を実現しています。
つまるところ、今イケイケなナウい言語です。

GraphQLとは

GraphQLとは、APIの速度、柔軟性、開発者にとってのユーザビリティを向上させるために設計された、クエリ言語またはそのアーキテクチャです。
GraphQLではスキーマに重きを置き、これによってフロントエンド開発とバックエンド開発の衝突を軽減します。
またクライアントのリクエストに完全にマッチしたレスポンスが送られてくることも、GraphQLの大きな特徴の一つですね。

ここでピンときた方がいると思います。
そう、RustとGraphQLって思想が結構似通ってるんですね。
型安全で、抽象的で、柔軟...。
最高にそりっどですぴーでぃーなサーバーがびるどできそうな気がしてきましたね!

準備

開発を始めるには、まずワークスペースを作る必要があります。
ということで、Rustのプロジェクトを†領域展開†します。

cargo new rust_graphql --bin

これで無量空処Rustのプロジェクトが立ち上がりました。

次に今回のシリーズを通して使用するクレート達を、Cargo.tomlのdependenciesに追加します。

Cargo.toml
[package]
name = "rust_graphql"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
+ actix-cors = "0.6.0"
+ actix-rt = "2.6.0"
+ actix-web = "4.0.1"
+ anyhow = "1.0.55"
+ dotenv = "0.15.0"
+ env_logger = "0.9.0"

そうしたら、今作った「rust_graphql」というバイナリクレートの中に「graphql」というライブラリクレートを作ります。
次のコマンドを実行してください。

cargo new graphql --lib

次にこれをワークスペースに追加します。
Cargo.tomlを修正しましょう。

Cargo.toml
[package]
name = "rust_graphql"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
actix-cors = "0.6.0"
actix-rt = "2.6.0"
actix-web = "4.0.1"
anyhow = "1.0.55"
dotenv = "0.15.0"
env_logger = "0.9.0"
+ graphql = { path = "./graphql" }

+ [workspace]
+ members = [
+     "graphql",
+ ]

これで二つのクレートに一方向の繋がりが生まれました。
これもGraphQLで言うところの、二つのノードと単一のエッジから成る有向グラフですね。

そうしたら最後に、「graphql」のクレートに依存関係を追加します。

graphql/Cargo.toml
[package]
name = "graphql"
version = "0.1.0"
edition = "2021"

# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html

[dependencies]
+ actix-web = "4.0.1"
+ anyhow = "1.0.55"
+ async-trait = "0.1.52"
+ chrono = "0.4.19"
+ dataloader = "0.14.0"
+ diesel = { version = "1.4.8", features = ["postgres", "r2d2", "chrono"] }
+ juniper = "0.15.9"
+ juniper_actix = "0.4.0"
+ log = "0.4.14"
+ r2d2 = "0.8.9"

はい、これで準備は完了です。
ソースコードは何も書いていませんが、ここまで正しく手順を踏めているかを確認するためにビルドしましょう。

cargo build

もしこの状態でビルドに失敗するなら、それは多分大体RustのORMであるdieselのせいです。
このシリーズでは後々PostgreSQLを使ってDBを操作するので、現時点でdieselをpostgreSQL用のものとして依存関係に記述しています。
もしPostgreSQLをインストールしていない方はインストールしておきましょう。

では最後に、現時点でのディレクトリ構成を載せておきます。

rust_graphql
|
│  .gitignore
│  Cargo.lock
│  Cargo.toml
│
├─graphql
│  │  .gitignore
│  │  Cargo.toml
│  │
│  ├─src
│  │  lib.rs
│  │
│  └─target
│
├─src
|  main.rs
|
└─target

実装

今回記事を書くにあたり、全体のソースコードを見れると便利だなぁ...と思ったりしました。
ということで、GitHubのリポジトリを作成しました。
ディレクトリ構成が分からなくなったりしたら、ぜひ参考にしていただければなと思います。
ちなみにブランチは記事ごとに分ける予定で、今回は「1.-導入編」というブランチになります。

サーバーを建てる

ではいよいよコードを書いていきます。

まずは何の機能もない空のサーバーを建てます。
main.rsを以下のようにまるっと修正してください。

src/main.rs
use actix_cors::Cors;
use actix_web::{
    App,
    http::header,
    HttpServer,
    middleware::{
        Compress,
        Logger,
    },
};
use anyhow::Result;
use dotenv::dotenv;
use std::env;

// 今回サーバーの実装にActix Webを使用しているので、非同期ランタイムはactix-rtを採用.
#[actix_rt::main]
async fn main() -> Result<()> {
    // .envに記述された環境変数の読み込み.
    dotenv().ok();

    // debugと同等以上の重要度を持つログを表示するように設定し、ログを開始する.
    env::set_var("RUST_LOG", "debug");
    env_logger::init();

    // サーバーの色んな設定.
    let mut server = HttpServer::new(move || {
        App::new()
            .wrap(
                Cors::default()
                    .allow_any_origin()
                    .allowed_methods(vec!["GET", "POST"])
                    .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
                    .allowed_header(header::CONTENT_TYPE)
                    .supports_credentials()
                    .max_age(3600),
            )
            .wrap(Compress::default())
            .wrap(Logger::default())
    });

    // Herokuとかにデプロイすることを考えて、HOSTやPORTの環境変数を優先する.
    let host = match env::var("HOST") {
        Ok(ok) => ok,
        Err(_) => env::var("LOCAL_HOST")?,
    };
    let port = match env::var("PORT") {
        Ok(ok) => ok,
        Err(_) => env::var("LOCAL_PORT")?,
    };
    let address = format!("{}:{}", host, port);
    server = server.bind(address)?;
    server.run().await?;

    Ok(())
}

上のコードで環境変数を扱っているので、次に.envファイルを記述していきます。
プロジェクトルートに.envを作成し、以下のように記述してください。

.env
LOCAL_HOST='localhost'
LOCAL_PORT='8000'

解説

ここでポイントとなるのはログの存在です。
Rustはもともとエラーハンドリングがよく整備されているなぁと思う言語なのですが、まれにエラー出力やRUST_BACKTRACEだけでは問題の箇所を特定できないようなことがあります。
今回のActix Webを使ったサーバー実装もそうです。
なので、Rustではlogやenv_loggerといったクレートを用いてログを確認するのが一般的です。
たった二行を追加するだけなので、めんどうだなと思って省いた人は思いとどまってください。
ちなみに私は思いとどまれませんでした(大敗

あと言い忘れていましたが、このシリーズではanyhow::Resultを使えるところは使っていきます。
なので至る所で?を使っていますが、ちゃんとエラーメッセージを書いたりするととてもめんどうなのでご了承くだしあ...。

スキーマを作る

サ-バーが建ったので、GraphQLの開発に着手しましょう。
まずは、GraphQL APIの核となるルート型についてのスキーマを作成します。

rust_graphql/graphql/srcにschemas/mod.rsを作成しましょう。
mod.rsを作成したので、これをプロジェクトのモジュールであると宣言する必要があります。
lib.rsを以下のようにまるっと修正しましょう。

graphql/src/lib.rs
pub mod schemas;

これでschemas/mod.rsを使えるようになりました。

そうしたら、schemasディレクトリの中にroot.rsファイルを作成しましょう。
ここにルート型のスキーマを記述していくことになります。
root.rsを有効化するために、mod.rsを変更します。

graphql/src/schemas/mod.rs
pub mod root;

これでスキーマを書くための準備は完了です。

では、GraphQLのルート型を担う3+1つの型、Query, Mutation, Subscription, Contextを記述していきます。
といっても、このファイルでは型のフィールド(リゾルバ)までは記述しないので、宣言のみを記述していきます。

root.rsに以下の記述を加えましょう。

graphql/src/schemas/root.rs
use juniper::{
    // 今回はSubscriptionを使わないので、ダミーの型を使う必要がある.
    EmptySubscription,
    RootNode,
};

// 後々ジェネリクスの引数とかに使うので、型をまとめておく.
pub type Schema = RootNode<'static, Query, Mutation, EmptySubscription<Context>>;

pub struct Context {
    // 今回のシリーズではなんの括約もしないtokenニキ.
    // しょうがないね.
    pub token: Option<String>,
}

// 「GraphQLのコンテキスト」という特徴を付与する.
impl juniper::Context for Context {}

pub struct Query;

pub struct Mutation;

解説

冒頭でも述べましたが、今回RustでGraphQLを扱うためにJuniperというクレートを使っています。
async-graphqlというクレートもあるのですが、本稿執筆時点ではJuniperの方がGitHubのスター数が多かったのと、ブログとかに日本語記事があったりしたっていう理由でJuniperの方を採用しました。

リゾルバを作る

型ができればそれに関する処理が必要になります。
ということで、ルート型についてのリゾルバを作りましょう。

スキーマを作ったときと同様に、rust_graphql/graphql/srcにresolvers/mod.rsを作成し、lib.rsに以下を追加します。

graphql/src/lib.rs
+ pub mod resolvers;
pub mod schemas;

そうしたら、resolversディレクトリの中にroot.rsファイルを作成しましょう。
ここにルート型のリゾルバを記述していくことになります。
root.rsを有効化するために、mod.rsを変更します。

graphql/src/resolvers/mod.rs
mod root;

これでリゾルバを書くための準備は完了です。

では、実際にリゾルバを記述していきましょう。
root.rsに以下を記入してください。

graphql/src/resolvers/root.rs
use crate::{
    schemas::{
        root::{
            Context,
            Mutation,
            Query,
        },
    },
};
use juniper::{
    graphql_object,
};

// 「GraphQLのオブジェクト型」という特徴を付与する.
#[graphql_object(context=Context)]
impl Query {
    // 今回は導入編なので、リゾルバも簡易的な感じで.
    fn dummy_query() -> String {
        String::from("It is dummy query.")
    }
}

#[graphql_object(context=Context)]
impl Mutation {
    fn dummy_mutation() -> String {
        String::from("It is dummy mutation.")
    }
}

解説

他のクレートがどうかはわかりませんが、少なくともJuniperではこのリゾルバがそのままその型のフィールドになります。
また、#[graphql_object(context=Context)]という手続き型マクロですが、これを構造体(列挙体は未検証)のimplブロックの頭につけることで、その構造体をGraphQLのオブジェクト型とすることができます。
またもうひとつ注意したいのが、各リゾルバの返り値の型です。
今回はどれもString型を返しますが、これがi64型だったりf32型を返そうとするとこわいおじさんに怒られます。
理由は「Juniperがその型を、組み込みスカラー型としてサポートしていないから」です。
以下にJuniperが組み込みスカラー型またはカスタムスカラー型として、デフォルトでサポートしている型のリストを載せておきます。

Rust GraphQL
i32 Int!
f64 Float!
String, &str String!
bool Boolean!
juniper::ID ID!
Vec<T> [T!]!
Option<T> T
uuid::* uuid::*!
chrono::* chrono::*!
chrono-tz chrono-tz::*!
time::* time::*!
url::* url::*!
bson::* bson::*!

もちろん上記以外のものをカスタムスカラー型として定義することは可能です。
とはいってもそれが結構めんどうなので、基本的には組み込みサポートされているものを使う感じでいいんじゃないかなぁと思います。

サーバーにGraphQLを実装する

ここまでで、ルート型についてのスキーマとリゾルバを作成しました。
実をいうと、この時点でGraphQLとしては完成しています。
やったね!
あとはこれをサーバーとくっつけるだけなので、ラストスパート張り切っていきましょう!

Actix WebからGraphQLにアクセスできるように、lib.rsに以下の記述を追加してください。

graphql/src/lib.rs
+ use actix_web::{
+     Error,
+     HttpResponse,
+     web::{
+         Data,
+         Payload,
+     },
+ };
+ use juniper_actix::{
+     graphiql_handler,
+     graphql_handler,
+     playground_handler,
+ };

pub mod resolvers;
pub mod schemas;
+ use crate::schemas::root::{
+     Context,
+     Schema,
+ };

// Actix WebからGraphQLにアクセスするためのハンドラメソッド.
+ pub async fn graphql(req: actix_web::HttpRequest, payload: Payload, schema: Data<Schema>) -> Result<HttpResponse, Error> {
+     // tokenがリクエストヘッダに添付されている場合はSomeを、なければNoneを格納する.
+     let token = req
+         .headers()
+         .get("token")
+         .map(|t| t.to_str().unwrap().to_string());
+
+     let context = Context {
+         token,
+     };
+
+     graphql_handler(&schema, &context, req, payload).await
+ }

// Actix WebからGraphiQLにアクセスするためのハンドラメソッド.
+ pub async fn graphiql() -> Result<HttpResponse, Error> {
+     graphiql_handler("/graphql", None).await
+ }

// Actix WebからGraphQL Playgroundにアクセスするためのハンドラメソッド.
+ pub async fn playground() -> Result<HttpResponse, Error> {
+     playground_handler("/graphql", None).await
+ }

次に「スキーマを作る」で作成したスキーマの型を返す関数を作成します。
schemas/mod.rsを以下のように修正してください。

graphql/src/schemas/mod.rs
+ use juniper::EmptySubscription;

pub mod root;
+ use root::{
+     Context,
+     Mutation,
+     Query,
+     Schema,
+ };

+ pub fn create_schema() -> Schema {
+     // Schemaオブジェクトを新規に作成する関数.
+     Schema::new(
+         Query {},
+         Mutation {},
+         EmptySubscription::<Context>::new()
+     )
+ }

これでActix WebとGraphQLを繋ぐために必要なものは全て揃いました。
いよいよ次が最後の変更です。
main.rsを以下のように修正してください。

src/main.rs
use actix_cors::Cors;
use actix_web::{
    App,
    http::header,
    HttpServer,
    middleware::{
        Compress,
        Logger,
    },
+     web::{
+         self,
+         Data,
+     },
};
use anyhow::Result;
use dotenv::dotenv;
+ use graphql::{
+     graphiql,
+     graphql,
+     playground,
+     schemas::create_schema,
+ };
- use std::env;
+ use std::{
+     env,
+     sync::Arc,
+ };

// 今回サーバーの実装にActix Webを使用しているので、非同期ランタイムはactix-rtを採用.
#[actix_rt::main]
async fn main() -> Result<()> {
    // .envに記述された環境変数の読み込み.
    dotenv().ok();

    // debugと同等以上の重要度を持つログを表示するように設定し、ログを開始する.
    env::set_var("RUST_LOG", "debug");
    env_logger::init();

    // Schemaオブジェクトをスレッドセーフな型でホランラップする.
+     let schema = Arc::new(create_schema());

    // サーバーの色んな設定.
    let mut server = HttpServer::new(move || {
        App::new()
            // SchemaオブジェクトをActix Webのハンドラメソッドの引数として使えるようにする.
+             .app_data(Data::from(schema.clone()))
            .wrap(
                Cors::default()
                    .allow_any_origin()
                    .allowed_methods(vec!["GET", "POST"])
                    .allowed_headers(vec![header::AUTHORIZATION, header::ACCEPT])
                    .allowed_header(header::CONTENT_TYPE)
                    .supports_credentials()
                    .max_age(3600),
            )
            .wrap(Compress::default())
            .wrap(Logger::default())
            // /graphqlエンドポイントにgraphql()をセットする.
+             .service(
+                 web::resource("/graphql")
+                     .route(web::get().to(graphql))
+                     .route(web::post().to(graphql))
+             )
+             // /graphiqlエンドポイントにgraphiql()をセットする.
+             .service(web::resource("/graphiql").route(web::get().to(graphiql)))
+             // /playgroundエンドポイントにplayground()をセットする.
+             .service(web::resource("/playground").route(web::get().to(playground)))
+     });

    // Herokuとかにデプロイすることを考えて、HOSTやPORTの環境変数を優先する.
    let host = match env::var("HOST") {
        Ok(ok) => ok,
        Err(_) => env::var("LOCAL_HOST")?,
    };
    let port = match env::var("PORT") {
        Ok(ok) => ok,
        Err(_) => env::var("LOCAL_PORT")?,
    };
    let address = format!("{}:{}", host, port);
    server = server.bind(address)?;
    server.run().await?;

    Ok(())
}

おめでとうございます!
ここまでの全てが上手くいっていれば、以下のコマンドでGraphQL API Serverが立ち上がるはずです。

cargo run

juniper_actixという便利なクレートのおかげで、今回このAPIサーバーにGraphiQLとGraphQL Playgroundの二つのIDEを実装することができました。
実際にクエリを投げて、正常にレスポンスが返ってくることを確認してみてください。

解説

ここでポイントとなるのは、以下の二行です。

src/main.rs
// --snip--
let schema = Arc::new(create_schema());
// --snip--
        .app_data(Data::from(schema.clone()))

まず一行目では、SchemaオブジェクトをArc型でラップすることで、スレッドセーフな値にしています。
そして二行目、これをさらにData型で包み.app_data()に渡すことで、graphql/src/lib.rsのgraphql()のようにSchemaオブジェクトを関数の引数として受け取ることができます。
これをどれかひとつでも怠っていると、大体こわおじに怒られます。
Rustってこわいね。

とりあえず完成!

お疲れ様です!
そしてここまで読んでくださりありがとうございました!
先生の次回作にご期待ください!

というのは冗談で、今回はまだ中身の薄いルート型を作っただけにすぎません。
最終的にN+1問題についても言及しなければすっきりしないので、記事的に言うと多分1/4か1/5が終わったところです。
まだまだ先は長そうですね。
よろしければ、もう少しだけお付き合いください。

最後に、最終的なディレクトリ構成を載せておきます。

rust_graphql
|
|  .env
│  .gitignore
│  Cargo.lock
│  Cargo.toml
│
├─graphql
│  │  .gitignore
│  │  Cargo.toml
│  │
│  ├─src
|  |  |  lib.rs
|  |  |
|  |  ├─resolvers
|  |  |  mod.rs
|  |  |  root.rs
|  |  |
|  |  └─schemas
|  |     mod.rs
|  |     root.rs
│  │
│  └─target
│
├─src
|  main.rs
|
└─target

おわりに

今回はRustのJuniperというGraphQL Frameworkを用いて、簡易的なGraphQL API Serverを作成しました。
次回Userオブジェクトを作成し、それをDBに保存したり更新したり...というところまでやりたいなぁと思っています。

またね。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Sign upLogin
4
Help us understand the problem. What are the problem?