💬

RustでGraphQLサーバの実装を試してみる

2021/12/04に公開

この記事はRustアドベントカレンダー(その2)の4日目の記事です。

はじめに

GraphQLは、フロントエンドからみたときにMicroservice間のやり取りを簡易化するGatewayとしての役割(いわゆるGraphQL GatewayやBFF)として使うはよく知られていると思います。

今回は、GraphQLをGraphQL Gatewayとして使うのではなく、単一のAPIサーバ(いわゆるGraphQL Monolith Architecture)をRustで試してみました。

背景

個人的に新規でAPIサーバの構築を考える機会がありました。今だとRESTでOpenAPIで...と漠然と考えて実装を進めようかなと思っていたのですが、エンドポイント設計やOpenAPIのディレクトリ構成どうするのだとか、レスポンスの形式変わるとあっちこっち変更する必要があったりと考えるだけでうーんとなりどうしよかなと悩んでました。その際にたまたまGraphQL Monolith Architectureというのを知りました。
それまでGraphQLはGatewayやBFFとして使用するものという印象があったのですが、単一のGraphQLサーバだとエンドポイントの構成考えなくていいし、クライアント都合でいろいろレスポンスの形式変えれたりするからあとあと楽かもと思い、試してみようかなと思い今回の記事をかきました。

やること

作るものは、「初めてのGraphQL」内で紹介されている写真共有アプリのサーバサイド側をRustで実装します。

「はじめてのGraphQL」内で紹介されている写真共有アプリは以下の要素をもっています。

  • 写真を投稿する
  • 投稿されているすべての写真の数を取得する
  • ユーザが投稿した写真一覧を取得する
  • 写真から投稿したユーザ情報を取得する
  • ユーザが写っている写真一覧を取得する
  • 写真に写っているユーザ一覧を取得する

実装のwebフレームワークとしactix-webを、GraphQLライブラリとしてはasync-graphqlを使用します。
RustのGraphQLライブラリはjuniperがスター数が多いのですがApollo Federationをサポートしてなかったり、他の方がすでに記事書かれてたりしたので今回はasync-graphqlを使うことにしました。

セットアップ

アプリディレクトリの作成、及び関連するライブラリ郡の準備をします。

$ cargo init --bin photo-share-api
     Created binary (application) package
$ cd photo-share-api
Cargo.toml
[dependencies]
async-graphql = "3.0"
async-graphql-actix-web = "3.0"
actix-web = { version = "4.0.0-beta.13", default-features = false }
once_cell = "1.8.0"

静的な値を返すリゾルバの実装

「初めてのGraphQL」の内容通り、静的な値42を返すtotal_photosリゾルバ関数を実装します。

サーバとして動かすためにexampleを参考に実装します。

use actix_web::web::Data;
use actix_web::{guard, web, App, HttpResponse, HttpServer, Result};
use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};
use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};

struct Query;

#[Object]
impl Query {
    async fn total_photos(&self) -> usize {
        42
    }
}

type ApiSchema = Schema<Query, EmptyMutation, EmptySubscription>;

async fn index(schema: web::Data<ApiSchema>, req: GraphQLRequest) -> GraphQLResponse {
    schema.execute(req.into_inner()).await.into()
}

async fn index_playground() -> Result<HttpResponse> {
    let source = playground_source(GraphQLPlaygroundConfig::new("/").subscription_endpoint("/"));
    Ok(HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(source))
}

#[actix_web::main]
async fn main() -> std::io::Result<()> {
    let schema = Schema::build(Query, EmptyMutation, EmptySubscription).finish();

    println!("Playground: http://localhost:8000");

    HttpServer::new(move || {
        App::new()
            .app_data(Data::new(schema.clone()))
            .service(web::resource("/").guard(guard::Post()).to(index))
            .service(web::resource("/").guard(guard::Get()).to(index_playground))
    })
    .bind("127.0.0.1:8000")?
    .run()
    .await
}

cargo runでGraphQL Playgroundのサーバが立ち上がります。クエリを投げると正しく動作することがわかります。

query
{
    totalPhotos
}
response
{
  "data": {
    "totalPhotos": 42
  }
}

写真投稿のためのリゾルバの実装

次に写真投稿のためのリゾルバ関数post_photoを実装します。
nameとdescriptionを持つPhotoをミューテーションで追加できるようにします。

static PHOTOS: Lazy<Mutex<Vec<Photo>>> = Lazy::new(|| Mutex::new(vec![]));

struct Photo {
    name: String,
    description: String,
}

struct Mutation;

#[Object]
impl Mutation {
    async fn post_photo(&self, name: String, description: String) -> bool {
        let photo = Photo {
            name,
            description
        };
        PHOTOS.lock().unwrap().push(photo);
        true
    }
}

また、total_photosリゾルバ関数を拡張して、フォト一覧の長さを返すようにします。

    async fn total_photos(&self) -> usize {
        PHOTOS.lock().unwrap().len()
    }

最終的な変更としては以下になります

-use async_graphql::{EmptyMutation, EmptySubscription, Object, Schema};
+use async_graphql::{EmptySubscription, Object, Schema};
 use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
+use once_cell::sync::Lazy;
+use std::sync::Mutex;
+
+struct Photo {
+    name: String,
+    description: String,
+}
+
+static PHOTOS: Lazy<Mutex<Vec<Photo>>> = Lazy::new(|| Mutex::new(vec![]));

 struct Query;

 #[Object]
 impl Query {
     async fn total_photos(&self) -> usize {
-        42
+        PHOTOS.lock().unwrap().len()
+    }
+}
+
+struct Mutation;
+
+#[Object]
+impl Mutation {
+    async fn post_photo(&self, name: String, description: String) -> bool {
+        let photo = Photo { name, description };
+        PHOTOS.lock().unwrap().push(photo);
+        true
     }
 }

-type ApiSchema = Schema<Query, EmptyMutation, EmptySubscription>;
+type ApiSchema = Schema<Query, Mutation, EmptySubscription>;

 async fn index(schema: web::Data<ApiSchema>, req: GraphQLRequest) -> GraphQLResponse {
     schema.execute(req.into_inner()).await.into()
@@ -28,7 +48,7 @@ async fn index_playground() -> Result<HttpResponse> {

 #[actix_web::main]
 async fn main() -> std::io::Result<()> {
-    let schema = Schema::build(Query, EmptyMutation, EmptySubscription).finish();
+    let schema = Schema::build(Query, Mutation, EmptySubscription).finish();

     println!("Playground: http://localhost:8000");

クエリを投げると正しく動作することがわかります

Mutation
mutation newPhoto($name: String!, $description: String) {
      postPhoto(name: $name, description: $description)
}
Variables
{
      "name": "sample photo A",
      "description": "A sample photo for our dataset"
}
Response
{
  "data": {
    "postPhoto": true
  }
}

投稿された写真一覧を取得する

投稿された写真一覧を取得するためのall_photosリゾルバを実装します。変更点としては以下となります。また、

diff --git a/src/main.rs b/src/main.rs
index bbca017..3fd27d7 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,16 +1,19 @@
 use actix_web::web::Data;
 use actix_web::{guard, web, App, HttpResponse, HttpServer, Result};
 use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
-use async_graphql::{EmptySubscription, Object, Schema};
+use async_graphql::{EmptySubscription, Object, SimpleObject, Schema};
 use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
 use once_cell::sync::Lazy;
 use std::sync::Mutex;
 
+#[derive(SimpleObject, Clone)]
 struct Photo {
+    id: usize,
     name: String,
     description: String,
 }
 
+static SEQUENCE_ID: Lazy<Mutex<usize>> = Lazy::new(|| Mutex::new(0));
 static PHOTOS: Lazy<Mutex<Vec<Photo>>> = Lazy::new(|| Mutex::new(vec![]));
 
 struct Query;
@@ -20,16 +23,22 @@ impl Query {
     async fn total_photos(&self) -> usize {
         PHOTOS.lock().unwrap().len()
     }
+
+    async fn all_photos(&self) -> Vec<Photo> {
+        PHOTOS.lock().unwrap().clone()
+    }
 }
 
 struct Mutation;
 
 #[Object]
 impl Mutation {
-    async fn post_photo(&self, name: String, description: String) -> bool {
-        let photo = Photo { name, description };
-        PHOTOS.lock().unwrap().push(photo);
-        true
+    async fn post_photo(&self, name: String, description: String) ->Photo {
+        let mut id = SEQUENCE_ID.lock().unwrap();
+        *id += 1;
+        let photo = Photo { id: *id, name, description };
+        PHOTOS.lock().unwrap().push(photo.clone());
+        photo
     }
 }

post_photo のレスポンスにphoto obejctの値が帰ってくることがわかります。

Mutation
mutation newPhoto($name: String!, $description: String) {
  postPhoto(name: $name, description: $description) {
    id
    name
    description
  }
}
Variables
{
      "name": "sample photo A",
      "description": "A sample photo for our dataset"
}

Response
{
  "data": {
    "postPhoto": {
      "id": 2,
      "name": "sample photo A",
      "description": "A sample photo for our dataset"
    }
  }
}

all_photosを呼んだ場合のケースも確かめます。

query
query listPhotos {
  allPhotos {
    id
    name
    description
  }
}
response
{
  "data": {
    "allPhotos": [
      {
        "id": 1,
        "name": "sample photo A",
        "description": "A sample photo for our dataset"
      },
      {
        "id": 2,
        "name": "sample photo A",
        "description": "A sample photo for our dataset"
      }
    ]
  }
}

入力型及びEnumを試す

Rustでも入力型及び列挙型が使えるか検証します。
変更点としては以下のようになります。

 use actix_web::web::Data;
 use actix_web::{guard, web, App, HttpResponse, HttpServer, Result};
 use async_graphql::http::{playground_source, GraphQLPlaygroundConfig};
-use async_graphql::{EmptySubscription, Object, SimpleObject, Schema};
+use async_graphql::{EmptySubscription, Enum, InputObject, Object, Schema, SimpleObject};
 use async_graphql_actix_web::{GraphQLRequest, GraphQLResponse};
 use once_cell::sync::Lazy;
 use std::sync::Mutex;
@@ -11,6 +11,22 @@ struct Photo {
     id: usize,
     name: String,
     description: String,
+    category: PhotoCategory,
+}
+
+#[derive(Enum, Copy, Clone, Eq, PartialEq)]
+enum PhotoCategory {
+    Selfie,
+    Portrait,
+    Action,
+    Landscape,
+    Graphic,
+}
+
+impl Default for PhotoCategory {
+    fn default() -> Self {
+        PhotoCategory::Portrait
+    }
 }

 static SEQUENCE_ID: Lazy<Mutex<usize>> = Lazy::new(|| Mutex::new(0));
@@ -31,12 +47,25 @@ impl Query {

 struct Mutation;

+#[derive(InputObject)]
+struct PostPhotoInput {
+    name: String,
+    description: String,
+    #[graphql(default_with = "PhotoCategory::default()")]
+    category: PhotoCategory,
+}
+
 #[Object]
 impl Mutation {
-    async fn post_photo(&self, name: String, description: String) ->Photo {
+    async fn post_photo(&self, input: PostPhotoInput) -> Photo {
         let mut id = SEQUENCE_ID.lock().unwrap();
         *id += 1;
-        let photo = Photo { id: *id, name, description };
+        let photo = Photo {
+            id: *id,
+            name: input.name,
+            description: input.description,
+            category: input.category,
+        };
         PHOTOS.lock().unwrap().push(photo.clone());
         photo1

実際にMutationを試してみます。

Mutation
mutation newPhoto($input: PostPhotoInput!) {
  postPhoto(input: $input) {
    id
    name
    description
    category
  }
}
Variables
{
    "input": {
        "name": "sample photo A",
        "description": "A sample photo for our dataset"
    }
}
Response
{
  "data": {
    "postPhoto": {
      "id": 4,
      "name": "sample photo A",
      "description": "A sample photo for our dataset",
      "category": "PORTRAIT"
    }
  }
}

ユーザが投稿した写真一覧、および写真から投稿したユーザ情報を取得する

1対多の実装検証のため「ユーザが投稿した写真一覧を取得する」「写真から投稿したユーザ情報を取得する」を実装します。

Photoに加え、Userを作成します。Userは、以前投稿した写真の一覧を取得することができます(postedPhotos関数)。
また逆に、Photoは投稿したユーザを取得することができます(postedBy関数)。

これらをGraphQL SDL(schema definition language) で表すと下記のようになります。

SDL
type User {
	githubLogin: ID!
	name: String
	avatar: String
	postedPhotos: [Photo!]!
}
type Photo {
      id: ID!
      url: String!
      name: String!
      description: String
      category: PhotoCategory!
      postedBy: User!
}

「はじめてのGraphQL」だとtrivial resolverを使って下記のように実装していました。

const resolvers = {
      ......
      User: {
        postedPhotos: parent => {
          return photos.filter(p => p.githubUser === parent.githubLogin)
        }
      }
}

async-graphqlではtrivial resolverを使うのではなくObjectを使います。オブジェクトは、すべてのフィールドに対する関数を用意する必要があります。

struct User {
    github_login: String,
    name: String,
    avatar: String,
}

#[Object]
impl User {
    async fn github_login(&self) -> String {
        self.github_login.clone()
    }

    async fn name(&self) -> String {
        self.name.clone()
    }

    async fn avatar(&self) -> String {
        self.avatar.clone()
    }

    async fn posted_photos(&self) -> Vec<Photo> {
        let photos = PHOTOS.lock().unwrap().into_iter()
            .filter(|x| x.github_user == self.github_login)
            .iter();
        photos
    }
}

Photo側も修正します。Photo側はもともとSimple Objectを使っていたのですが、こちらもObjectとして書き直します。
github_userというフィールドも追加しています。

#[derive(Clone)]
struct Photo {
    id: usize,
    name: String,
    description: String,
    github_user: String,
    category: PhotoCategory,
}

#[Object]
impl Photo {
    async fn id(&self) -> usize {
        return self.id
    }

    async fn name(&self) -> String {
        return self.name.clone()
    }

    async fn description(&self) -> String {
        return self.description.clone()
    }

    async fn category(&self) -> PhotoCategory {
        return self.category
    }

    async fn posted_by(&self) -> User {
        let user = USERS.lock().unwrap().into_iter()
            .find(|user| user.github_login == self.github_user).unwrap();
        user
    }
}

また、User及びPhotoの初期値を設定します。

static USERS: Lazy<Mutex<Vec<User>>> = Lazy::new(|| Mutex::new(vec![
    User {
        github_login: "mHattrup".to_string(),
        name: "Mike Hattrup".to_string(),
        avatar: "".to_string()
    },
    User {
        github_login: "gPlake".to_string(),
        name: "Glen Plake".to_string(),
        avatar: "".to_string()
    },
    User {
        github_login: "sSchmidt".to_string(),
        name: "Scot Schmidt".to_string(),
        avatar: "".to_string()
    },
]));
static PHOTOS: Lazy<Mutex<Vec<Photo>>> = Lazy::new(|| Mutex::new(vec![
    Photo {
        id: 5,
        name: "Dropping the Heart Chute".to_string(),
        description: "The heart chute is one of my favorite chutes".to_string(),
        category: PhotoCategory::Action,
        github_user: "gPlake".to_string(),
    },
    Photo {
        id: 2,
        name: "Enjoying the sunshine".to_string(),
        description: "".to_string(),
        category: PhotoCategory::Selfie,
        github_user: "sSchmidt".to_string(),
    },
    Photo {
        id: 3,
        name: "Gunbarrel 25".to_string(),
        description: "25 laps on gunbarrel today".to_string(),
        category: PhotoCategory::Landscape,
        github_user: "sSchmidt".to_string(),
    },
]));

実際にQueryを投げ、試してみます。

Query
query photos {
  allPhotos {
    name
    postedBy {
      name
    }
  }
}
Response
{
  "data": {
    "allPhotos": [
      {
        "name": "Dropping the Heart Chute",
        "postedBy": {
          "name": "Glen Plake"
        }
      },
      {
        "name": "Enjoying the sunshine",
        "postedBy": {
          "name": "Scot Schmidt"
        }
      },
      {
        "name": "Gunbarrel 25",
        "postedBy": {
          "name": "Scot Schmidt"
        }
      }
    ]
  }
}

ユーザが写っている写真一覧、写真に写っているユーザ一覧を取得する

多対多の実装検証のため「ユーザが写っている写真一覧を取得する」「写真に写っているユーザ一一覧を取得する」を実装します。

写真に写っているユーザにタグ付けるタグを用意します。タグには、ユーザと写真が紐付かれています。これによって複数のユーザが写真にタグ付けされ、また逆に写真に複数のユーザをタグ付けすることができます。

これらをGraphQL SDLで表すと下記のようになります。

SDL
type User {
  ...
  inPhotos: [Photo!]!
}
type Photo {
  ...
  taggedUsers: [User!]!
}
Query
query listPhotos {
  allPhotos {
    taggedUsers {
      name
    }
  }
}
Response
{
  "data": {
    "allPhotos": [
      {
        "taggedUsers": []
      },
      {
        "taggedUsers": [
          {
            "name": "Scot Schmidt"
          },
          {
            "name": "Mike Hattrup"
          },
          {
            "name": "Glen Plake"
          }
        ]
      },
      {
        "taggedUsers": []
      }
    ]
  }
}

まとめ

Rustのasync-graphqlを使ってAPIサーバを構築しました。
Trivial resolversなど一部の機能を利用することはできなかったが「はじめてのGraphQL」に書かれているサーバ側の実装をしました。

感想

実際には、DBとの接続やunwrapで書かれている部分を処理する必要がありますが、個人的にはスムーズに書けて体験としてはよかったです。もう少しパフォーマンス面やDataLoader周りを深ぼってみたいと思いました。

その他

スキーマファーストにしたい場合、codegen-for-async-graphqlがあったりします。
またTracingまわりもApollo Tracingがあったりします。

Discussion