MENU

RustでGraphQLやってみるその2(更新編)

こんにちは、とらラボのY.Fです。

3月に以下の通り、RustとGraphQLについての記事を書きました。 toranoana-lab.hatenablog.com

今回は、続きとしてRustを利用したGraphQLのデータ更新について書いてみたいと思います。

環境

基本的には前回と同じ構成です。

  • rustup 1.12.1
  • Rust 1.42.0
  • actix-web 2.0系
  • juniper 0.14.2

上記に加えて以下を利用します。

  • PostgreSQL 12.1
    • 保存先にはPostgreSQLを利用します
  • diesel 1.4.4
    • Rust用のORMです
  • diesel-cli 1.4.0
    • cliのマイグレーションツールです
  • r2d2 v0.8.8
    • Rust用のコネクションプーリングライブラリです
  • chrono 0.4.10
    • Rustで時間の概念をよしなに扱えるライブラリです。dieselなどとバージョンを合わせるためにバージョンを指定しています。

準備

PCへインストールするものと、プロジェクトの Cargo.toml へ追加するものがあります。

PCへのインストール

PostgreSQLは以下を参照にインストールして下さい。
psql コマンドを使って postgres ユーザーで接続できればOKです。 qiita.com

diesel-cliは以下でインストールします。

$ cargo install diesel_cli

コマンド自体は記事の後の方でも利用しますが、先に詳細を知りたい方は以下参照下さい。 github.com

Cargo.tomlへの追記

r2d2 , r2d2_postgres, diesel, chrono を追加します。

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

  [dependencies]
  actix-web = "2"
  actix-rt = "1"
  juniper = "0.14"
  dotenv = "*"
  env_logger = "*"
  log = "*"
  serde = { version = "*", features = ["derive"] }
  serde_json = "*"
+ r2d2 = "*"
+ r2d2_postgres = "*"
+ diesel = { version = "1.0.0", features = ["postgres", "r2d2", "chrono"] }
+ chrono = "0.4.10"

書き換えたら cargo build でビルドしておきましょう。

概要

今回は前回の記事で作成した Photo を更新してみようと思います。
Photo テーブルを diesel-cil を使って作成したあと、GraphQLで更新データを扱えるようにしていきたいと思います。

diesel-cliでDB管理

diesel-cliを使うことでDBの作成から各テーブルの管理までできます。
Ruby on Rails などを使ったことがある方は馴染み深いとは思います。

DB作成

まずは以下に従って .env ファイルを作成します。 diesel.rs

$ echo DATABASE_URL=postgres://postgres@localhost/graphql_sample > .env

書き込みが完了したら以下コマンドでDBを作成します。

$ disel setup

コマンドの実行が完了すると、プロジェクトディレクトリ直下に migrations フォルダと、DBクリエイト用のSQLが生成されていることがわかると思います。
なお、SQLに関しては up.sql がマイグレーション用のファイル、 down.sql がロールバック用のSQLとなります。

(フォルダ)

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

テーブル作成

データベースができたので、テーブル作成用のマイグレーションを実行します。

$ diesel migration generate create_photos

上記実行すると、setup とは異なり、空の up.sqldown.sql が生成されるので中身を記述していきます。

up.sql

-- Your SQL goes here
CREATE TABLE photos (
  id SERIAL NOT NULL,
  name VARCHAR(255) NOT NULL,
  description TEXT,
  created_at timestamp with time zone NOT NULL default CURRENT_TIMESTAMP,
  updated_at timestamp with time zone NOT NULL default CURRENT_TIMESTAMP
);

down.sql

-- This file should undo anything in `up.sql`
DROP TABLE photos;

dieselのコマンドでマイグレーションを実行します。

$ diesel migration run

これでデータベースの作成は完了です。
同時に、srcディレクトリ直下に schema.rs ファイルができているのが確認できると思います。

table! {
    photos (id) {
        id -> Int4,
        name -> Varchar,
        description -> Nullable<Text>,
        created_at -> Timestamptz,
        updated_at -> Timestamptz,
    }
}

実装

データベースにアクセスできるようになったので実装していきます。
DBアクセスでもdieselに活躍してもらいます。

dieselを使ったDBアクセス

まずは、dieselを使うために構造体などを定義していきます。
ただし、前回の記事で作成したgraphqlディレクトリをそのまま使うとごちゃごちゃしてしまうのでsrcディレクトリ以下にdbディレクトリを作成します。
dbディレクトリの中に photo.rs 及び、 photo_repository.rs を作成します。特に複雑なロジックとかは無いのでservice層などは作成しないことにします。
さらに、コネクションプーリング用の構造体やタイプエイリアスを作成します。
まとめると、ここで作るのは以下のファイルになります。

  • src/db/photo.rs
    • ORM用の構造体などを記述するファイル
  • src/db/photo_repository.rs
    • 具体的なDBインサートなどを書くファイル
  • src/db/manager.rs
    • DBへのコネクション情報を書くファイル

各ソースファイルは以下のようにしました。

(src/db/photo.rs)

use crate::schema::photos;
use chrono::NaiveDateTime;

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

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

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

derive アトリビュートや table_name アトリビュートでその構造体がどのテーブルに対して何の操作をする構造体かを記述しています。

(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 {
    pub fn all_photos(context: &Context) -> Result<Vec<Photo>, Error> {
        use crate::schema::photos::dsl::*;
        let conn = &context.pool;
        photos.load(conn)
    }

    pub fn insert_photo(context: &Context, new_photo: NewPhoto) -> Result<Photo, Error> {
        use crate::schema::photos::dsl::*;
        use diesel::dsl::insert_into;

        let conn = &context.pool;
        // PhotoFormのメンバは&strで参照値なのでintoかつライフタイムに注意
        // new_projectのライフタイムよりphoto_formのライフタイムが長いとエラーになる
        let photo_form: PhotoNewForm = (&new_photo).into();
        let rows_inserted = insert_into(photos).values(&photo_form).get_result(conn)?;
        Ok(rows_inserted)
    }

    pub fn update_photo(
        context: &Context,
        pkey: i32,
        update_photo: UpdatePhoto,
    ) -> Result<Photo, Error> {
        use crate::schema::photos::dsl::*;
        use diesel::dsl::update;

        let conn = &context.pool;
        let photo_form: PhotoUpdateForm = (&update_photo).into();
        let rows_inserted = update(photos.filter(id.eq(pkey)))
            .set(&photo_form)
            .get_result(conn)?;
        Ok(rows_inserted)
    }
}

先程自動生成された schema.rs をuseすることによってORM用の各メソッドなどが使えるようになっています。

(src/db/manager.rs)

use diesel::r2d2::ConnectionManager;
use diesel::PgConnection;
use r2d2::{Error, Pool, PooledConnection};
use std::env;

pub type PgPool = Pool<ConnectionManager<PgConnection>>;
pub type PgPooled = PooledConnection<ConnectionManager<PgConnection>>;

pub fn new_pool() -> Result<PgPool, Error> {
    let database_url = env::var("DATABASE_URL").expect("DATABASE_URL must be set");

    let manager = ConnectionManager::<PgConnection>::new(database_url);
    Pool::builder().max_size(15).build(manager)
}

r2d2を使ってdieseのコネクションをラップするような見た目になります。これで、アクセスのたびにコネクションを取り直す必要がなくなります。

GraphQLを実装する

さて、DBへのアクセスはできるようになったのでGraphQLの更新処理である Mutation を実装します。
Query の処理も同様にDBから情報を取るように変更します。
また、各 MutationQuery へは Context を通してプーリングされているコネクションを渡したいので同時に実装していきます。

(src/graphql/schema.rs)

use crate::db::manager::PgPooled;
use crate::db::photo;
use crate::db::photo_repository::PhotoRepository;
use juniper::{FieldError, FieldResult, ID};

#[derive(Clone, Debug)]
pub struct Photo {
    pub id: i32,
    pub name: String,
    pub description: String,
}

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

#[derive(juniper::GraphQLInputObject)]
#[graphql(description = "A Photo update struct")]
pub struct UpdatePhoto {
    pub name: Option<String>,
    pub description: Option<String>,
}

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

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

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

    pub fn url(&self) -> String {
        format!("http://hogehoge/{}", self.id)
    }
}

// dieselのPhotoをGraphQLのPhotoに変換するFromトレイト実装
impl From<photo::Photo> for Photo {
    fn from(photo: photo::Photo) -> Self {
        Self {
            id: photo.id,
            name: photo.name,
            description: photo.description.map_or("".to_string(), |d| d),
        }
    }
}

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

/// GraphQLの構造体UpdatePhotoをdieselの構造体PhotoUpdateFormに変換するFromトレイト実装
impl<'a> From<&'a UpdatePhoto> for photo::PhotoUpdateForm<'a> {
    fn from(update_photo: &'a UpdatePhoto) -> Self {
        Self {
            name: update_photo.name.as_ref().map(AsRef::as_ref),
            description: update_photo.description.as_ref().map(AsRef::as_ref),
        }
    }
}

pub struct Query;
pub struct Mutation;

pub struct Context {
    pub pool: PgPooled,
}
impl juniper::Context for Context {}

/// GraphQLのクエリ系リゾルバ
#[juniper::object(Context = Context)]
impl Query {
    fn all_photos(&self, context: &Context) -> FieldResult<Vec<Photo>> {
        PhotoRepository::all_photos(context)
            .and_then(|photos| Ok(photos.into_iter().map(|t| t.into()).collect()))
            .map_err(Into::into)
    }
}

/// GraphQLのミューテーション系(更新系)リゾルバ
#[juniper::object(Context = Context)]
impl Mutation {
    fn create_photo(&self, context: &Context, new_photo: NewPhoto) -> Result<Photo, FieldError> {
        let result = PhotoRepository::insert_photo(context, new_photo)?;
        Ok(result.into())
    }

    fn update_photo(
        &self,
        context: &Context,
        id: i32,
        update_photo: UpdatePhoto,
    ) -> Result<Photo, FieldError> {
        let result = PhotoRepository::update_photo(context, id, update_photo)?;
        Ok(result.into())
    }
}

pub type Schema = juniper::RootNode<'static, Query, Mutation>;

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

GraphQL用の構造体↔diesel用の構造体で相互変換したいので、必要に応じて From トレイトを実装している点もポイントです。

最後に、main.rs でのアプリケーション起動時にコネクションを作成して渡してやれば完成です。

(src/main.rs)

#[actix_rt::main]
async fn main() -> std::io::Result<()> {
    // 追加
    dotenv().ok();
    env_logger::init();

    // 追加
    let pool = match new_pool() {
        Ok(pool) => pool,
        Err(e) => panic!(e.to_string()),
    };

    let schema = std::sync::Arc::new(create_schema());

    let mut server = HttpServer::new(move || {
        App::new()
            .data(pool.clone()) // 追加
            .data(schema.clone())
            .wrap(middleware::Logger::default())
            .route("/", web::get().to(hello_world))
            .route("/graphiql", web::get().to(graphiql))
            .route("/graphql", web::post().to(graphql))
    });

    server = server.bind("127.0.0.1:3000").unwrap();
    server.run().await
}

これで cargo run でアプリケーションを起動し、 http://127.0.0.1:3000/graphiql にアクセスすると更新処理などが追加されていることが確認できます。

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

まとめ

今回はDBへアクセスし、データを取得したり更新したりする方法について紹介しました。
これだけの実装で、かなり自由な情報取得、更新ができるようになります。
DBとGraphQL側の構造体など完全分離したりしたため、一見記述量は多いように見えますが、パターンさえ掴んでしまえれば見た目よりは大変では無いと思います。
次回は、DB上での1対1や1対多の関連付け、グラフ理論では無向グラフと呼ばれるものの実装について紹介していきたいと思います。

P.S.

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

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

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

6月12日に「【オンライン開催】とらのあな採用説明会 6/12 オタク企業で働くエンジニアの魅力について」を開催します。
とらのあなラボのエンジニアの働き方など説明します。ぜひ奮ってご参加ください。 yumenosora.connpass.com

さらに、弊社では新型コロナウイルス感染症終息後もフルリモートを継続導入することになりました!
prtimes.jp