MENU

RustでGraphQLやってみるその3(関連付けされたデータを扱う)

皆さんこんにちは、虎の穴ラボのY.Fです。

GraphQLシリーズとして連載してる記事の4つめの記事になります。(本エントリー2回+番外編1回) 前回までの記事はこちら

(前回の記事) toranoana-lab.hatenablog.com

(番外編) toranoana-lab.hatenablog.com

さて、今回はGraphQLを使って関連するデータを扱ってみたいと思います。

データの関連付けを行う

前回記事までで作成していた Photo テーブルの親テーブルとして Post テーブルを作ることにします。

マイグレーション方法などは前回記事を参照してください。ここではテーブル作成、カラム追加のSQLのみ記載します。

Postsテーブル作成

--- Your SQL goes here
CREATE TABLE posts (
  id SERIAL NOT NULL PRIMARY KEY,
  title VARCHAR(255) NOT NULL,
  content TEXT,
  created_at timestamp with time zone NOT NULL default CURRENT_TIMESTAMP,
  updated_at timestamp with time zone NOT NULL default CURRENT_TIMESTAMP
);

Photosテーブルにカラム追加

ALTER TABLE photos ADD COLUMN post_id int references posts (id) NOT NULL;

f:id:toranoana-lab:20200730113403p:plain

前回までの記事と似たような形でPostを扱うための実装を追加します。

(src/db/post.rs)

use crate::schema::posts;
use chrono::NaiveDateTime;

// DBからのデータ取得用構造体
#[derive(Eq, PartialEq, Debug, Queryable, Identifiable)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub content: Option<String>,
    pub created_at: NaiveDateTime,
    pub updated_at: NaiveDateTime,
}

// データ挿入用構造体
#[derive(Insertable)]
#[table_name = "posts"]
pub struct PostNewForm<'a> {
    pub title: &'a str,
    pub content: Option<&'a str>,
}

// データ更新用構造体
#[derive(AsChangeset)]
#[table_name = "posts"]
pub struct PostUpdateForm<'a> {
    pub title: Option<&'a str>,
    pub content: Option<&'a str>,
}

(src/db/post_repository.rs)

use self::diesel::prelude::*;
use crate::graphql::schema::{Context, NewPost, UpdatePost};
use diesel::result::Error;
use crate::db::post::{Post, PostNewForm, PostUpdateForm};

extern crate diesel;

pub struct PostRepository;

impl PostRepository {
    pub fn all_posts(context: &Context) -> Result<Vec<Post>, Error> {
        use crate::schema::posts::dsl::*;
        let conn = &context.pool;
        posts.load(conn)
    }

    pub fn find_post(context: &Context, pkey: i32) -> Result<Post, Error> {
        use crate::schema::posts::dsl::*;
        let conn = &context.pool;
        let select_query = posts.filter(id.eq(pkey));
        select_query.get_result::<Post>(conn)
    }

    pub fn insert_post(context: &Context, new_post: NewPost) -> Result<Vec<Post>, Error> {
        use crate::schema::posts::dsl::*;
        use diesel::dsl::insert_into;

        let conn = &context.pool;
        let post_form: PostNewForm = (&new_post).into();
        insert_into(posts).values(&post_form).get_result(conn)
            .and_then(|_: Post| PostRepository::all_posts(context))
    }

    pub fn update_photo(
        context: &Context,
        pkey: i32,
        update_post: UpdatePost,
    ) -> Result<Post, Error> {
        use crate::schema::posts::dsl::*;
        use diesel::dsl::update;

        let conn = &context.pool;
        let post_form: PostUpdateForm = (&update_post).into();
        let rows_inserted = update(posts.filter(id.eq(pkey)))
            .set(&post_form)
            .get_result(conn)?;
        Ok(rows_inserted)
    }
}

また、既存のPhotoにも関連付けを表現するために以下の様な変更を加えます。

(src/db/photo.rs)

use crate::schema::photos;
use crate::db::post::Post;
use chrono::NaiveDateTime;

// 関連付け用にAssociations、Identifiable、belongs_toを追加
#[derive(Eq, PartialEq, Debug, Queryable, Associations, Identifiable)]
#[belongs_to(parent = "Post")]
pub struct Photo {
    pub id: i32,
    pub post_id: i32,
    pub name: String,
    pub description: Option<String>,
    pub created_at: NaiveDateTime,
    pub updated_at: NaiveDateTime,
}
// 以下略

(src/db/photo_repository.rs)

use self::diesel::prelude::*;
use crate::db::photo::{Photo, PhotoNewForm, PhotoUpdateForm};
use crate::graphql::schema::{Context, NewPhoto, UpdatePhoto};
use diesel::result::Error;

extern crate diesel;

pub struct PhotoRepository;

impl PhotoRepository {
    // 略

    // Postに関連するPhotoを取得するように追加
    pub fn post_photos(
        context: &Context,
        post_pkey: i32,
    ) -> Result<Vec<Photo>, Error> {
        use crate::schema::photos::dsl::*;

        let conn = &context.pool;
        let rows_inserted = photos.filter(post_id.eq(post_pkey)).load::<Photo>(conn)?;
        Ok(rows_inserted)
    }
}

これでDB周りの準備は出来ました。

GraphQLで関連データをあつかう

DBに近い部分での関連付けは出来たので、お次はGraphQLで関連付けを扱います。

少し準備をします。今後のために juniper を最新ブランチのものにしておきます。

[package]
name = "graphql-sample"
version = "0.1.0"
authors = ["内緒だよ"]
edition = "2018"

[dependencies]
actix-web = "2"
actix-rt = "1"
+ juniper = { git = "https://github.com/graphql-rust/juniper" }
- juniper = "0.14"
dotenv = "*"
env_logger = "*"
log = "*"
serde = { version = "*", features = ["derive"] }
serde_json = "*"
actix-cors = "0.2.0"
r2d2 = "*"
r2d2_postgres = "*"
diesel = { version = "1.0.0", features = ["postgres", "r2d2", "chrono"] }
chrono = "0.4.10"

関連付けするとは言っても単に juniper::object などの部分で外部キーに紐づくオブジェクトを返すようなメソッドを追加するだけです。

(graphql/schema.rs)

// 略

/// GraphQLの型の素になるPhoto構造体
#[derive(Clone, Debug)]
pub struct Photo {
    pub id: i32,
    pub post_id: i32,  // 追加
    pub name: String,
    pub description: String,
}

#[derive(juniper::GraphQLInputObject)]
#[graphql(description = "A Photo insert struct")]
pub struct NewPhoto {
    pub name: String,
    pub post_id: i32,  // 追加
    pub description: Option<String>,
}

// 略

/// GraphQLの型の素になるPost構造体
#[derive(Clone, Debug)]
pub struct Post {
    pub id: i32,
    pub title: String,
    pub content: String,
}

#[derive(juniper::GraphQLInputObject)]
#[graphql(description = "A Post insert struct")]
pub struct NewPost {
    pub title: String,
    pub content: Option<String>,
}

#[derive(juniper::GraphQLInputObject)]
#[graphql(description = "A Post update struct")]
pub struct UpdatePost {
    pub title: Option<String>,
    pub content: Option<String>,
}

/// 実際にGraphQLとしての型になるのは以下アトリビュートがついているこちら
/// object→graphql_objectへ変更(今後のため)
#[juniper::graphql_object(Context = Context)]
#[graphql(description = "A Photo returns struct")]
impl Photo {
    // 略

    // 追加: Photoの親であるPostを返す
    pub fn post(&self, context: &Context) -> FieldResult<Post> {
        let post = PostRepository::find_post(context, self.post_id)?;
        Ok(post.into())
    }
}

// 略

/// 実際にGraphQLとしての型になるのは以下アトリビュートがついているこちら
/// object→graphql_objectへ変更(今後のため)
#[juniper::graphql_object(Context = Context)]
#[graphql(description = "A Post returns struct")]
impl Post {
    pub fn id(&self) -> ID {
        ID::new(self.id.to_string())
    }

    pub fn title(&self) -> String {
        self.title.clone()
    }

    pub fn content(&self) -> String {
        self.content.clone()
    }

    // Postに紐づくPhotoをすべて取得する
    pub fn photos(&self, context: &Context) -> FieldResult<Vec<Photo>> {
        let photos = PhotoRepository::post_photos(context, self.id)?;
        Ok(photos.into_iter().map(|t| t.into()).collect())
    }
}

// dieselのPostをGraphQLのPostに変換するFromトレイト実装
impl From<post::Post> for Post {
    fn from(post: post::Post) -> Self {
        Self {
            id: post.id,
            title: post.title,
            content: post.content.expect("")
        }
    }
}

/// GraphQLの構造体NewPhotoをdieselの構造体PhotoNewFormに変換するFromトレイト実装
impl<'a> From<&'a NewPost> for post::PostNewForm<'a> {
    fn from(new_post: &'a NewPost) -> Self {
        Self {
            title: &new_post.title,
            content: new_post.content.as_ref().map(AsRef::as_ref),
        }
    }
}

/// GraphQLの構造体UpdatePostをdieselの構造体PostUpdateFormに変換するFromトレイト実装
impl<'a> From<&'a UpdatePost> for post::PostUpdateForm<'a> {
    fn from(update_post: &'a UpdatePost) -> Self {
        Self {
            title: update_post.title.as_ref().map(AsRef::as_ref),
            content: update_post.content.as_ref().map(AsRef::as_ref),
        }
    }
}

// 略

pub struct Context {
    pub pool: DataPgPool,  // object→graphql_objectに変えたのでスレッドセーフな値に変更
}
impl juniper::Context for Context {}

/// GraphQLのクエリ系リゾルバ
/// object→graphql_objectへ変更(今後のため)
#[juniper::graphql_object(Context = Context)]
impl Query {
    // 略

    fn all_posts(&self, context: &Context) -> FieldResult<Vec<Post>> {
        PostRepository::all_posts(context)
            .and_then(|posts| Ok(posts.into_iter().map(|t| t.into()).collect()))
            .map_err(Into::into)
    }
}

/// GraphQLのミューテーション系(更新系)リゾルバ
/// object→graphql_objectへ変更(今後のため)
#[juniper::graphql_object(Context = Context)]
impl Mutation {
    // 略

    fn create_post(
        &self,
        context: &Context,
        new_post: NewPost,
    ) -> Result<Vec<Post>, FieldError> {
        PostRepository::insert_post(context, new_post)
            .and_then(|posts| Ok(posts.into_iter().map(|t| t.into()).collect()))
            .map_err(Into::into)
    }

    fn update_post(
        &self,
        context: &Context,
        id: i32,
        update_post: UpdatePost,
    ) -> Result<Post, FieldError> {
        let result = PostRepository::update_post(context, id, update_post)?;
        Ok(result.into())
    }
}


// こちらも object→graphql_objectに変えた影響で型引数を追加
pub type Schema = juniper::RootNode<'static, Query, Mutation, EmptySubscription<Context>>;

pub fn create_schema() -> Schema {
    Schema::new(Query {}, Mutation {}, EmptySubscription::<Context>::new())
}

また、色々変更した影響で main.rsgraphql ハンドラーも少し変更します。

/// actixからGraphQLにアクセスするためのハンドラメソッド
pub async fn graphql(
    st: web::Data<Arc<Schema>>,
    data: web::Json<GraphQLRequest>,
    pool: web::Data<r2d2::Pool<ConnectionManager<PgConnection>>>,
) -> Result<HttpResponse, Error> {
    let user = web::block(move || {
        let mut rt = futures::executor::LocalPool::new();
        let ctx = &Context { pool: pool.clone() };
        let graphql_res = data.execute(&st, ctx);
        let res = rt.run_until(graphql_res);
        Ok::<_, serde_json::error::Error>(serde_json::to_string(&res)?)
    })
    .await?;
    Ok(HttpResponse::Ok()
        .content_type("application/json")
        .body(user))
}

/// actixからGraphiQLにアクセスするためのハンドラメソッド
pub async fn graphiql() -> HttpResponse {
    let html = graphiql_source("http://127.0.0.1:3000/graphql", None);
    HttpResponse::Ok()
        .content_type("text/html; charset=utf-8")
        .body(html)
}

動かしてみる

では実際動かしてみましょう。以下のような、Postを作成する mutation を送ってみます

mutation {
  createPost(newPost: {title: "test", content: "test"}) {
    id
    title
    content
    photos {
      id
      name
      description
    }
  }
}

すると以下のようなJSONが返ってきます。親レコード作成なので子レコードである photos は空で返ってきます。

{
  "data": {
    "createPost": [
      {
        "id": "1",
        "title": "test",
        "content": "test",
        "photos": []
      }
    ]
  }
}

photoも作ってみましょう

mutation {
  createPhoto(newPhoto: {name: "test", description: "test", postId: 1}) {
    id
    name
    description
    url
    post {
      id
      title
      content
    }
  }
}

今回は親レコードであるPostが一緒に返されてきます。

{
  "data": {
    "createPhoto": [
      {
        "id": "58",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/58",
        "post": {
          "id": "1",
          "title": "test",
          "content": "test"
        }
      }
    ]
  }
}

また、queryも同様な動きをします。試しにPostだけ取得して見ます。

query {
  allPosts {
    id
    title
    content
    photos {
      id
      name
      description
      url
    }
  }
}

レスポンス

{
  "data": {
    "allPosts": [
      {
        "id": "1",
        "title": "test",
        "content": "test",
        "photos": [
          {
            "id": "58",
            "name": "test",
            "description": "test",
            "url": "http://hogehoge/58"
          }
        ]
      }
    ]
  }
}

課題

ここまでの作業で一見うまく行っているように見えるので、どんなSQLが吐かれているのか見てみるとことにします。

repository系のソースに以下を追記します。また、環境変数に RUST_LOG=debug,actix_web=debug を追加します。(.envへの追加でもOKです)

(src/db/post_repository.rs)

// 3つ追加
use log::debug;
use diesel::debug_query;
use diesel::pg::Pg;

// 略

    pub fn find_post(context: &Context, pkey: i32) -> Result<Post, Error> {
        use crate::schema::posts::dsl::*;
        let conn = &context.pool.get().unwrap();
        let select_query = posts.filter(id.eq(pkey));
        // 以下二行追加
        let sql = debug_query::<Pg, _>(&select_query).to_string();
        debug!("{:?}", sql);
        select_query.get_result::<Post>(conn)
    }

この状態で、以下のqueryを投げてみます

query {
  allPhotos {
    id
    name
    description
    url
    post {
      id 
      title
      content
    }
  }
}

レスポンスはこんな感じになります。レコードはいくつか追加済みな状態です。

{
  "data": {
    "allPhotos": [
      {
        "id": "58",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/58",
        "post": {
          "id": "1",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "59",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/59",
        "post": {
          "id": "1",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "60",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/60",
        "post": {
          "id": "1",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "61",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/61",
        "post": {
          "id": "2",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "62",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/62",
        "post": {
          "id": "2",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "63",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/63",
        "post": {
          "id": "2",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "64",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/64",
        "post": {
          "id": "3",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "65",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/65",
        "post": {
          "id": "3",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "66",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/66",
        "post": {
          "id": "3",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "67",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/67",
        "post": {
          "id": "4",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "68",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/68",
        "post": {
          "id": "4",
          "title": "test",
          "content": "test"
        }
      },
      {
        "id": "69",
        "name": "test",
        "description": "test",
        "url": "http://hogehoge/69",
        "post": {
          "id": "4",
          "title": "test",
          "content": "test"
        }
      }
    ]
  }
}

ログも見てみます。

[2020-07-30T07:15:45Z INFO  actix_server::builder] Starting 4 workers
[2020-07-30T07:15:45Z INFO  actix_server::builder] Starting "actix-web-service-127.0.0.1:3000" service on 127.0.0.1:3000
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [1]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [1]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [1]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [2]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [2]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [2]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [3]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [3]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [3]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [4]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [4]"
[2020-07-30T07:16:04Z DEBUG graphql_sample::db::post_repository] "SELECT \"posts\".\"id\", \"posts\".\"title\", \"posts\".\"content\", \"posts\".\"created_at\", \"posts\".\"updated_at\" FROM \"posts\" WHERE \"posts\".\"id\" = $1 -- binds: [4]"
[2020-07-30T07:16:04Z INFO  actix_web::middleware::logger] 127.0.0.1:62291 "POST /graphql HTTP/1.1" 200 1512 "http://localhost:3000/graphiql" "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_5) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/84.0.4147.89 Safari/537.36" 0.040156

典型的なN+1クエリが発行されてしまっています。しかも、一度取得したレコードのキャッシュなどもされないため、全く同じクエリが何度も吐き出される事になってしまっています。
当然、このままではDB負荷などの問題が発生するので使えません。一方で、これを解消するのは以下の理由から簡単ではありません。

  • 通常のREST APIの場合はユースケースが予め決まっているのでin句などで一括取得できるが、GraphQLはスキーマのみ存在し、どのようなSQLが必要になるかはフロント側からのGraphQLクエリによって決まる。
    • 常に関連データを取るようにしてしまうと無駄になるケースが多くなる
  • フレームワークによってGraphQLのクエリから自動でリゾルバが選択され実行されるので、一度取得したクエリかどうか判別することは難しい

これを解決するために、Facebookが参照実装を公開している dataloader と呼ばれる仕組みがあります。

github.com

また、 dataloder のRust版も存在します。

github.com

次回の記事ではこの dataloader-rs を使ってN+1問題を解消してみたいと思います。

まとめ

今回の記事手はGraphQLでの関連付けされたデータの扱いについて説明してきました。かなり直感的な実装で関連レコードを取得するAPIが作れることがわかったのではないでしょうか? 一方で、通常の実装ではN+1問題が発生してしまうことも説明しました。次回はこの問題について書いてみたいと思います。

P.S

8月8日には定例開催している会社説明会をオンライン開催します。
どなたでも参加できるので、とらラボがどんなところか聞いてみたいという人は是非ご参加ください。

yumenosora.connpass.com

虎の穴ラボでの開発に少しでも興味を持っていただけた方は、採用説明会やカジュアル面談という場でもっと深くお話しすることもできます。ぜひお気軽に申し込みいただければ幸いです。
カジュアル面談では虎の穴ラボのエンジニアが、開発プロセスの内容であったり、「今期何見ました?」といったオタクトークから業務の話まで何でもお応えします。

カジュアル面談や採用情報はこちらをご確認ください。
yumenosora.co.jp

また、毎週火曜、木曜にはTora-Lab Meetup!と称して虎の穴ラボのエンジニア・採用担当とお話できる機会を設けさせていただくことになりました。
虎の穴ラボに興味がある、エンジニアや採用担当に質問したいことがある、などどなたでもご参加下さい。
news.toranoana.jp

さらに、弊社では新型コロナウイルス感染症終息後もフルリモートを継続導入することになりました!
地方在住のまま働きたい人など、上記Meetupやカジュアル面談、面接すべてリモート対応していますので、ご興味のある方はぜひいずれか応募してみてください! prtimes.jp