MySQLの一貫性読み取りについて調べてみました

ロックあたりを調べていたら気になったので、ついでに調べました。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.4 一貫性非ロック読み取り

ポイント

  • ある時点でのデータベースのスナップショットをクエリーに提供
  • トランザクション分離レベルが REPEATABLE READ の場合は、同じトランザクション内すべてのが一貫性読み取り
  • 一部のステートメントではデフォルトの設定において一貫性読み取りが提供されない

一貫性読み取り

一貫性読み取りとは、データベースのある時点での状態をスナップショットとして保持し、トランザクション内で一貫性のあるクエリを実現する事です。公式のドキュメントには以下のような記述があります。

一貫性読み取りとは、InnoDB がマルチバージョンを使用して、ある時点でのデータベースのスナップショットをクエリーに提供することを意味します。クエリーには、その時点よりも前にコミットされたトランザクションによる変更のみが表示され、その時点よりもあとのトランザクションまたはコミットされていないトランザクションによる変更は表示されません。

実際に試してみましょう。

REPEATABLE READで一貫性読み取りを試す

トランザクションの分離レベルを REPEATABLE READ (デフォルト)にした状態で一貫性読み取りの動きを確認してみたいと思います。ちなみにトランザクション分離レベルは以下のQiitaの記事で調べたことをまとめています。

MySQLのトランザクションと分離レベル - Qiita

以下で使用する books テーブルはこのようなテーブルです。

mysql> SHOW CREATE TABLE books;
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                             |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
  `id` int(11) NOT NULL,
  `name` varchar(50) NOT NULL,
  `publisher` varchar(50) NOT NULL,
  `author` varchar(50) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `books_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+

2つのトランザクションを開始し、一方で SELECT を、もう一方で INSERTbooks テーブルに行います。ステートメントの左側にトランザクション名を示しておきます。

T1> BEGIN;

T1> SELECT id FROM books;
+----+
| id |
+----+
|  3 |
|  1 |
|  2 |
+----+

                                                                T2> INSERT INTO books VALUES 
                                                                                    (4,
                                                                                     'Effective Java (3rd Edition)', 
                                                                                     'Addison-Wesley Professional',
                                                                                     'Joshua Bloch'
                                                                                    );

T1> SELECT id FROM books;
+----+
| id |
+----+
|  3 |
|  1 |
|  2 |
+----+

T1 トランザクションでのクエリ( SELECT )がトランザクション開始時点から変化していないことがわかりました。これが一貫性読み取りと言われるものですね。ちなみに、一貫性読み取りはトランザクション分離レベルによって動きが変わります。

REPEATABLE READ の場合には最初のクエリが実行されたタイミングでデータベースのスナップショットを取り、その後のクエリではそのスナップショットを使用したクエリ結果を返します。なので、上記の例にある INSERT のタイミングを変えると、異なる結果になります。

T1> BEGIN;

                                                                T2> INSERT INTO books VALUES 
                                                                                    (4,
                                                                                     'Effective Java (3rd Edition)', 
                                                                                     'Addison-Wesley Professional',
                                                                                     'Joshua Bloch'
                                                                                    );

T1> SELECT id FROM books;
+----+
| id |
+----+
|  3 |
|  4 |
|  1 |
|  2 |
+----+

T1 トランザクションの最初のクエリ前に INSERT が走ったので、 T1 トランザクションが使用するスナップショットは INSERT の内容を反映したものになっています。

一貫性読み取りが効かないステートメントもある

一部、一貫性読み取りが効かない場合もあります。例えば、 INSERT INTO ... SELECT 構文で読み取られる行はデフォルトでは一貫性読み取りにはならず、他のトランザクションで変更された内容を読み取ってしまいます。そのため、以下のような状況がありえます。

T1> begin;

T1> select * from plans_stocks;

                                                                    T2> begin;

                                                                    T2> insert into plans_stocks values (\
                                                                        -> 5,
                                                                        -> 'Practical VIM',
                                                                        -> 'Pragmatic Bookshelf',
                                                                        -> 'Drew Neil');

                                                                    T2> commit;

T1> insert into books \
        -> select * from plans_stocks;

T1> select id from books;
+----+
| id |
+----+
|  3 |
|  4 |
|  5 |
|  1 |
|  2 |
+----+

上の例では T1 トランザクションplans_stocks テーブルの読み込みを行っており、その時のスナップショットをつかった一貫性読み取りが有効になっています。その後、 INSERT INTO ... SELECT ステートメントを使って plans_stocks から値を書き込んでいますが、読み込むデータはスナップショットからではなく、 T2 トランザクションからの影響を受けています。つまり、 INSERT INTO ... SELECT 構文での SELECT には一貫性読み取りが有効になっていません。

このような場合で一貫性読み取りを有効にする方法もあるみたいです。(実際に試してはいませんが、このドキュメントの下部にその方法が書いてあります。MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.4 一貫性非ロック読み取り

MySQLのインデックスについて調べてみました

業務ではクエリパフォーマンスの最適化とかやらないのですが、知っておいたほうが絶対にいいので調べてみました。

ポイント

  • インデックス
  • Bツリー構造
  • SHOW INDEX
  • CREATE INDEX
  • EXPLAIN

インデックスとは

インデックスは特定のカラム値のある行を見つけるために使用されるデータ構造の事です。インデックスが設定されていないとすべての行を読み取って行かなくてはならないため、テーブルの行数が大きくなるほどパフォーマンスが低下してしまいます。

一般的に用いられている例を示すと、インデックスはまるで本の目次のようなものです。特定の章にたどり着くのに本を一枚づつめくっていくのは大変ですよね。データベースもそれは同じで特定の行を発見するのに上から順番になめていったら時間がかかってしまいます。そこで目次のように行を管理する機能が備わっているわけですね。

一般的にMySQLでは以下のような操作の時にインデックスを使用します。

  • WHERE 句に一致する行の検索
  • MIN() または MAX() 値の検索
  • 結合の実行時に他のテーブルから行を取得するため

注意しなければならないのは、すべてのカラムにインデックスを設定すればよいわけではないということです。目次のたとえ話で考えてみれば当然のことですね(すべてのページを目次に乗せても誰も喜ばないはずです)。MySQLに限った話ではありませんがドキュメントには以下のような記述があります。

クエリーで使用されている可能なすべてのカラムにインデックスを作成しようとしがちですが、不要なインデックスは領域を無駄にし、MySQL が使用するインデックスを判断するための時間を無駄にします。各インデックスを更新する必要があるため、インデックスは挿入、更新、削除のコストも追加します。最適なインデックスのセットを使用して、高速のクエリーを実現するために、適切なバランスを見つける必要があります。

InnoDBのインデックス

標準でインデックスをBツリー構造で持ちます。Bツリーとはツリーデータ構造の一種です。常にソートされ続け、正確な一致または範囲の高速な検索が得意です。ここで詳しい説明は省きますが、以下にWikipediaのリンクを示しておきます。

B木

MySQLでは常に、主キーを表すインデックスを持ちます(主キーインデックスと言う。InnoDB用語ではクラスタ化されたインデックスとも言う)。 SHOW INDEX 文で対象テーブルのインデックス情報を調査できるので実際にやってみました。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 13.7.5.23 SHOW INDEX 構文

mysql> SHOW CREATE TABLE books;
+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                |
+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
  `id` int(11) NOT NULL,
  `name` varchar(50) NOT NULL,
  `publisher` varchar(50) NOT NULL,
  `author` varchar(50) NOT NULL,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+-------+---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> SHOW INDEX FROM books;
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| books |          0 | PRIMARY  |            1 | id          | A         |           2 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
1 row in set (0.01 sec)

SHOW INDEX 文の結果で注目したいのは以下のカラムです。

  • Key_name ... インデックスの名前。インデックスが主キーである場合、常に PRIMARY になる。
  • Column_name ... カラム名
  • Index_type ... 使用されるインデックス方法。

books テーブルは id カラムが主キーのため、以下のような結果が取得できました。

  • Key_name ... PRIMARY
  • Column_name ... id
  • Index_type ... BTREE

主キー以外のカラムへのインデックス

インデックスは主キー以外のカラムにも設定することができます。Bツリーデータ構造によって、 WHERE 句内の比較を高速化でき、素早く対象行のルックアップができるようになります。

主キー以外のカラムにインデックスを設定するにはいくつか方法がありますが、既存のテーブルに設定するには CREATE INDEX 文が使えます。例えば、以下のように books テーブルに追加でインデックスを設定できます。

mysql> SHOW INDEX FROM books;
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| books |          0 | PRIMARY  |            1 | id          | A         |           2 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+-------+------------+----------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
1 row in set (0.00 sec)

mysql> CREATE INDEX books_name ON books (name);
Query OK, 0 rows affected (0.05 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> SHOW INDEX FROM books;
+-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| Table | Non_unique | Key_name   | Seq_in_index | Column_name | Collation | Cardinality | Sub_part | Packed | Null | Index_type | Comment | Index_comment | Visible | Expression |
+-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
| books |          0 | PRIMARY    |            1 | id          | A         |           2 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
| books |          1 | books_name |            1 | name        | A         |           2 |     NULL |   NULL |      | BTREE      |         |               | YES     | NULL       |
+-------+------------+------------+--------------+-------------+-----------+-------------+----------+--------+------+------------+---------+---------------+---------+------------+
2 rows in set (0.01 sec)

SHOW INDEX の結果行が増えており、インデックスが追加で設定できたことがわかりますね。

実行計画からインデックスが使われているか確認

実行計画と言われる、MySQLステートメントを実行する際の情報を元に、インデックスが使用されているかを確認することができます。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 8.8.1 EXPLAIN によるクエリーの最適化

実行計画を取得するには EXPLAIN 文を使います。例えば、books テーブルで id を元に検索した時の実行計画は以下のようになります。

mysql> SHOW CREATE TABLE books;
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| Table | Create Table                                                                                                                                                                                                                                                             |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
| books | CREATE TABLE `books` (
  `id` int(11) NOT NULL,
  `name` varchar(50) NOT NULL,
  `publisher` varchar(50) NOT NULL,
  `author` varchar(50) NOT NULL,
  PRIMARY KEY (`id`),
  KEY `books_name` (`name`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci |
+-------+--------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------+
1 row in set (0.00 sec)

mysql> EXPLAIN SELECT * FROM books WHERE id = 1;
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type  | possible_keys | key     | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | books | NULL       | const | PRIMARY       | PRIMARY | 4       | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+-------+---------------+---------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.00 sec)

EXPLAIN 文はその後ろに対象のステートメントを続けて書きます。各項目の詳細な説明は以下のリンク先のドキュメントに詳しく書かれています。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 8.8.2 EXPLAIN 出力フォーマット

特に着目したい項目は以下です。

  • possible_keys ... 選択可能なインデックスは PRIMARY だけになっている。
  • key ... 実際に選択されたインデックスは PRIMARY になっており、主キーインデックスが使用される事がわかる。
  • rows ... 調査される行の見積もりは1行。これは id によって行を一意に特定できるからだと思う。

上記の例では id を検索しましたが、 name を検索すると実行計画の内容が以下のように変化します。

mysql> EXPLAIN SELECT * FROM books WHERE name = 'Test Driven Development';
+----+-------------+-------+------------+------+---------------+------------+---------+-------+------+----------+-------+
| id | select_type | table | partitions | type | possible_keys | key        | key_len | ref   | rows | filtered | Extra |
+----+-------------+-------+------------+------+---------------+------------+---------+-------+------+----------+-------+
|  1 | SIMPLE      | books | NULL       | ref  | books_name    | books_name | 202     | const |    1 |   100.00 | NULL  |
+----+-------------+-------+------------+------+---------------+------------+---------+-------+------+----------+-------+
1 row in set, 1 warning (0.01 sec)

先程と同じ項目に着目してみましょう。

  • possible_keys ... 選択可能なインデックスが books_name になりました。
  • key ... 実際に選択されたインデックスは books_name になりました。
  • rows ... 調査される行の見積もりは変わりませんでした。 name カラムがインデックスになっているので各行を一意に特定できるからだと思います。

InnoDB(MySQL)のロックについて調べてみました

MySQLのロックについて調べてみました。MySQLのドキュメントは製品特有のことだけではなく、RDBに関する知識も豊富なので勉強しがいがあります。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.3 InnoDB のロックモード

ポイント

共有ロックと排他ロック

共有ロックと排他ロックは互いに影響を与えます。トランザクションAが共有ロックを行Rに対して獲得している場合、ほかトランザクションは行Rに対して共有ロックを獲得できますが排他ロックは獲得できません。一方で、トランザクションAが排他ロックを行Rに対して獲得している場合、他のトランザクションは行Rに対して共有ロックも排他ロックも獲得できません。

実演 共有ロック

共有ロックを獲得するには SELECT ... LOCK IN SHARE MODE 文を発行します。このSELECT文でマッチした行はすべて共有ロックを獲得します。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 14.2.5 ロック読み取り (SELECT ... FOR UPDATE および SELECT ... LOCK IN SHARE MODE)

次の例は、異なる2つのトランザクションが共有ロックを同じ行に対して獲得できる事を示します。なお、ステートメントを発行する左端にどのトランザクションかを示しています。

T1> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T1> SELECT name FROM books WHERE id = 1 LOCK IN SHARE MODE;
+-------------------------+
| name                    |
+-------------------------+
| Refactoring 2nd Edition |
+-------------------------+
1 row in set (0.00 sec)

T2> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T2> SELECT name FROM books WHERE id = 1 LOCK IN SHARE MODE;
+-------------------------+
| name                    |
+-------------------------+
| Refactoring 2nd Edition |
+-------------------------+
1 row in set (0.00 sec)

次の例は、共有ロックが獲得されている行に対して排他ロックは獲得できないことを示します。

T1> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T1> SELECT name FROM books WHERE id = 1 LOCK IN SHARE MODE;
+-------------------------+
| name                    |
+-------------------------+
| Refactoring 2nd Edition |
+-------------------------+
1 row in set (0.00 sec)

T2> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T2> UPDATE books SET name = 'Refactoring 2nd Edition(NEW)' WHERE id = 1;
...(待機)

実演 排他ロック

排他ロックは UPDATESELECT ... FOR UPDATE 文などで獲得できます。

次の例は、異なる2つのトランザクションで同じ行に対し排他ロックまたは共有ロックをかけることができない事を示します。

T1> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T1> SELECT * FROM books WHERE id = 1 FOR UPDATE;
+----+-------------------------+--------------------------+---------------+
| id | name                    | publisher                | author        |
+----+-------------------------+--------------------------+---------------+
|  1 | Refactoring 2nd Edition | OBJECT TECHNOLOGY SERIES | Martin Fowler |
+----+-------------------------+--------------------------+---------------+
1 row in set (0.00 sec)

T2> BEGIN;
Query OK, 0 rows affected (0.00 sec)

T2> SELECT * FROM books WHERE id = 1 LOCK IN SHARE MODE;
...(待機)

テーブルに対するロック

共有ロック、排他ロックをテーブルに対して明示的に獲得する事もできます。テーブルに対してロックを獲得するには LOCK TABLES を、ロックを開放するには UNLOCK TABLES を使います。

MySQL :: MySQL 5.6 リファレンスマニュアル :: 13.3.5 LOCK TABLES および UNLOCK TABLES 構文

インテンションロック

テーブルレベルに対するロック。トランザクションがテーブル内の各行でどのようなタイプ(S、X)を取ろうとしているのかを示すのに使われます。共有ロック、排他ロックは実際に行に対しロックを獲得するのに対し、インテンションロックは意図を示すので少々難しく感じます。

インテンションロックは実際にはテーブルに対するロック以外何もブロックしません。ドキュメントには以下のように記述されています。インテンションロックはテーブルに対するロックと、行に対するロックを共存させるための仕組みにすぎないため、テーブルロックを使用しない場合(ほとんどの場合だと思います)は意識する必要がないように感じます。

インテンションロックでは、完全なテーブルリクエスト (LOCK TABLES ... WRITE など) 以外は何もブロックされません。IX および IS ロックの主な目的は、だれかが行をロックしていることや、テーブル内の行をロックしようとしていることを示すことです。

インテンションとは ... 意図、意向、意図するもの、目的、(交際中の女性に対する男性の)結婚の意志

関連ドキュメント

qiita.com

graphql-java-toolsのResolverを使ってネストされたPOJOをクライアントに返してみる!

graphql-java-tools のResolverを使ってネストされたPOJOをクライアントに返却してみました。

Resolverについて

graphql-java-tools においてResolverとはGraphQLスキーマに対応したPOJOをクライアントに返却する際にデータを解決するインスタンスです。ライブラリ内ではResolverはインターフェイスとして定義されており、使う側はそれらのインターフェイスを実装することでResolverのインスタンスを作ります。ライブラリ内に定義されているインターフェイスの関係性をざっくり書いてみました。

f:id:bau1537:20200223214319p:plain

前回の記事では Query Resolver と Mutation Resolver を実装する例を載せました。一方、今回はネストされたPOJOのResolverの実装をしてみました。

ネストされたPOJOとは?

"ネストされたPOJO"という表現が適切なのかわかりませんが、あるオブジェクトAが別のオブジェクトBをID参照で関連を持っているときに、GraphQLでAオブジェクトとBオブジェクトを一度のQueryで取得するような場合の事を指します。例えば以下のようなGraphQLスキーマがあったときに...

type Query {
    bookMarks: [BookMark!]!
    categories: [Category!]!
}

type Mutation {
    registerBookMark(title: String, url: String): BookMark!
}

type Category {
    name: String!
    bookMarks: [BookMark!]!
}

type BookMark {
    title: String!
    url: String!
}

CategoryBookMark のクラス定義が以下のように定義され、IDによって参照を保持している場合です。

// CategoryはbookMarksフィールドによってBookMarkへの参照を持つ
data class Category(val id: Int, val name: String, val bookMarks: List<Int>)
data class BookMark(val id: Int, val title: String, val url: String)

このとき、クライアントは以下のようなクエリが発行できます。

query categories {
  categories {
    name
    bookMarks {
      title
      url
    }
  }
}

ここでは単純に Category だけを取得して返却することはできません。なぜならクライアントは Category が持っている BookMark のID値ではなく、 BookMark 自体の情報を求めているからです。graphql-java-tools ではこのような場合に CategoryBookMark の両方を合わせて返却する仕組みを提供しています。

Resolverの実装

このような場合、QueryのもととなるPOJOに対応づく GraphQLResolver を実装した独自のResolverインスタンスを作る必要があります。Category に対するResolverは以下のようになります。このResolverの bookMarks() メソッドは、GraphQLの bookMark フィールドに対応しています。

package demo.tools.graphql

import com.coxautodev.graphql.tools.GraphQLResolver
import org.springframework.stereotype.Component
import java.lang.IllegalStateException

@Component
class CategoryResolver(val bookMarkRepository: BookMarkRepository) : GraphQLResolver<Category> {

    // GraphQL の bookMark に対応
    fun bookMarks(category: Category): List<BookMark> {
        return category.bookMarks
            .map { bookMarkRepository.findById(it) ?: throw IllegalStateException() }
    }

}

Queryに対応しているResolverは前回の記事と同様の形で実装します。

package demo.tools.graphql

import com.coxautodev.graphql.tools.GraphQLQueryResolver
import org.springframework.stereotype.Component

@Component
class Query(
    val bookMarkRepository: BookMarkRepository,
    val categoryRepository: CategoryRepository
) : GraphQLQueryResolver {

    fun categories(): List<Category> {
        return categoryRepository.getAll()
    }

    fun bookMarks(): List<BookMark> {
        return bookMarkRepository.getAll()
    }

}

このような実装を行うことでネストされたPOJOを返却する事ができます。

エンドポイントを叩いてみる

実際にエンドポイントを叩いてみました。

// リクエスト
query categories {
  categories {
    name
    bookMarks {
      title
      url
    }
  }
}
// レスポンス
{
  "data": {
    "categories": [
      {
        "name": "Most Visit",
        "bookMarks": [
          {
            "title": "google",
            "url": "https://google.com"
          }
        ]
      }
    ]
  }
}

graphql-java-toolsを使ってGraphQLサーバーを作ってみる!

GraphQLサーバを作る際には graphql-java-tools を使うと便利らしいので使ってみました。

graphql-java-tools について

graphql-java だけでは冗長になりがちな仕組みを提供してくれるライブラリみたいです。すでにプロジェクトで独自のドメイン似特化したPOJOがある場合に、GraphQLとシームレスに統合できるようになっているんだとか。Javaとライブラリ名にありますが、JVM上なら動作するためKotlinでも使うことができるみたいですね。

GraphQLは関連するライブラリが多いように感じます。Qiitaのこの記事にそれぞれのライブラリの関連性が書かれています。この記事によれば graphql-java-tools はGraphQL関連に関して graphql-java のみに依存しているみたいです。

queryの実装

動作環境は以下の通りです。

> java -version
java version "1.8.0_191"
Java(TM) SE Runtime Environment (build 1.8.0_191-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.191-b12, mixed mode)

> gradle -version
------------------------------------------------------------
Gradle 6.0.1
------------------------------------------------------------

Build time:   2019-11-18 20:25:01 UTC
Revision:     fad121066a68c4701acd362daf4287a7c309a0f5

Kotlin:       1.3.50
Groovy:       2.5.8
Ant:          Apache Ant(TM) version 1.10.7 compiled on September 1 2019
JVM:          1.8.0_191 (Oracle Corporation 25.191-b12)
OS:           Mac OS X 10.15.3 x86_64

graphql-java-tools を試すに当たり、 graphql-spring-boot を使用することにしました。このライブラリは graphql-java-tools も含んでいるのでこちらのライブラリだけを依存先に追加するだけでよいみたいです。 build.gradle.kts の全文は最後の補足に乗せてあります。

GraphQLスキーマは以下になります。

type Query {
    bookMarks: [BookMark!]!
}

type BookMark {
    title: String!
    url: String!
}

上記のスキーマに合わせてQueryの実装をしていくのですが、graphql-java-tools では Resolver というクラスをGraphQLスキーマに対応付けします。 簡単に Resolver の立ち位置をスケッチしてみました。

f:id:bau1537:20200221212124p:plain

Queryでは GraphQLQueryResolver インターフェイスを継承したResolverクラスをGraphQLスキーマに対応付けます。bookMarks() メソッドはGraphQLスキーマbookMarks: [BookMark!]! に対応しています。この仕組があることで、GraphQLスキーマとそれを処理する実装の紐づきが明確になっているように感じます。

package demo.tools.graphql

import com.coxautodev.graphql.tools.GraphQLQueryResolver
import org.springframework.stereotype.Component

@Component
class Query(val bookMarkRepository: BookMarkRepository) : GraphQLQueryResolver {

    fun bookMarks(): List<BookMark> {
        return bookMarkRepository.getAll()
    }

}

この Query クラスを使ってGraphQLインスタンスを生成すれば実装は終わりです。 以下のコードではSchemaParser.newParser() によってGraphQLインスタンスを生成しています。これも graphql-java-tools による機能です。 上記の Query クラスは resolvers() メソッドで渡します。

package demo.tools.graphql

import com.coxautodev.graphql.tools.GraphQLMutationResolver
import com.coxautodev.graphql.tools.GraphQLQueryResolver
import com.coxautodev.graphql.tools.SchemaParser
import graphql.GraphQL
import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication
import org.springframework.context.annotation.Bean

@SpringBootApplication
class DemoApplication {

    @Bean
    fun graphQL(query: GraphQLQueryResolver): GraphQL {
        val graphQLSchema = SchemaParser.newParser()
            .file("schema.graphqls")
            .resolvers(query)
            .build()
            .makeExecutableSchema()
        return GraphQL.newGraphQL(graphQLSchema).build()
    }

}

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

mutationの実装

mutationも上記のやり方とほとんど同じです。 Query の代わりに Mutation クラスを作り、登録します。

まずはGraphQLSchemaにMutationを追加します。

type Query {
    bookMarks: [BookMark!]!
}

type Mutation {
    registerBookMark(title: String, url: String): BookMark!
}

type BookMark {
    title: String!
    url: String!
}

次に対応する Mutation クラスを実装します。

package demo.tools.graphql

import com.coxautodev.graphql.tools.GraphQLMutationResolver
import org.springframework.stereotype.Component

@Component
class Mutation(val bookMarkRepository: BookMarkRepository) : GraphQLMutationResolver {

    fun registerBookMark(title: String, url: String): BookMark {
        val aNewBookMark = BookMark(title, url)
        bookMarkRepository.save(aNewBookMark)
        return aNewBookMark
    }

}

最後に Query を作成したときと同様、 GraphQL インスタンスMutation インスタンスを登録します。

エンドポイントを叩いてみる

実際にサーバーに対してリクエストを投げてみました。リクエスト・レスポンスの送受信には GraphQL IDE を使いました。

では、Mutationによって BookMark を登録してみます。

// リクエスト
mutation registerBookMark($title: String, $url: String) {
  registerBookMark(title: $title, url: $url) {
    title
    url
  }
}

{
  "title": "demoo",
  "url": "http://demo.com"
}
// レスポンス
{
  "data": {
    "registerBookMark": {
      "title": "demoo",
      "url": "http://demo.com"
    }
  }
}

Query で全件取得をしてみます。

// リクエスト
{
  bookMarks {
    title
    url
  }
}
// レスポンス
{
  "data": {
    "bookMarks": [
      {
        "title": "google",
        "url": "https://google.com"
      },
      {
        "title": "demoo",
        "url": "http://demo.com"
      }
    ]
  }
}

補足

今回使用したプロジェクトの build.gradle.kts は以下になります。

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.2.4.RELEASE"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    kotlin("jvm") version "1.3.61"
    kotlin("plugin.spring") version "1.3.61"
}

group = "com.graphql-java.tutorial"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.graphql-java-kickstart:graphql-spring-boot-starter:6.0.0")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

GraphQLで独自のScalarTypeを作ってみる!

GraphQLはSchemaに型を記述します。その型には2種類あります。

  • Object Type ... 取得できるデータの種類とそのフィールドを定義する型
  • Scalar Type ... Object Type のフィールドが最終的に変換される、それ以上分割できない型

イメージとしては Scalar Type が集まって Object Type を形成する感じだと思います。

Scalar Type は事前に用意されているものもありますが、独自に定義する事もできるみたいなので試してみました。

独自の Scalar Type を宣言する

Schemaでの宣言の方法は簡単で scalar {型名} と書くだけです。作ったSchemaの一部はこんな感じ。DataEmailを定義してQuerAuthorで使っています。

scalar Date
scalar Email

type Query {
    authorByEmail(email: Email): Author
}

type Author {
    id: ID
    firstName: String
    lastName: String
    age: Int
    birthDay: Date
    email: Email
}

サーバー側で処理する

では Email という Scalar Type のサーバー側での処理を実装してみます。

Scalar Type ごとにGraphQLScalarTypeインスタンスが必要なので作ります。GraphQLScalarTypenewScalarメソッドが用意されていて、メソッドチェーンをつかって新しいインスタンスを作れるようになっています。そのメソッドチェーンにはcoercingメソッドがあるのですが、引数に具体的な Scalar Type の変換処理クラスを渡します。

こんな感じですね。

val emailType: GraphQLScalarType = GraphQLScalarType.newScalar().name("Email").coercing(emailCoercing).build()

Scalar Type の具体的な変換処理はCoercingインターフェイスを実装したクラスで行います。上記の例ではemailCoercingというインスタンスがそのクラスに該当します。インターフェイスが実装するメソッドは以下の3つ。

  • parseValue ... クライアントが送ってきた変数をJavaの型に変換
  • parseLiteral ... クライアントが送ってきたqueryの引数をJavaの型に変換
  • serialize ... レスポンスに含める値をシリアライズ

全体のコードはこんな感じ。

package com.graphqljava.tutorial.bookdetails

import graphql.language.StringValue
import graphql.schema.Coercing
import graphql.schema.CoercingParseLiteralException
import graphql.schema.CoercingParseValueException
import graphql.schema.GraphQLScalarType
import org.slf4j.LoggerFactory

data class EmailScalarType(val rawValue: String)

val emailCoercing = object : Coercing<EmailScalarType, String> {

    private val logger = LoggerFactory.getLogger("emailCoercing")

    // Client to Server で値を受け取るときに呼び出される
    // 変数を変換する
    override fun parseValue(input: Any): EmailScalarType {
        logger.info("parseValue {}", input.toString())

        when (input) {
            // 入力された値はダウンキャストして使用する
            is String -> return EmailScalarType(input)
            // Coercing用の例外クラスを用いることで、適切なエラーレスポンスになる
            else -> throw CoercingParseValueException("invalid email value")
        }
    }

    // Client to Server で値を受け取るときに呼び出される
    // queryの引数を変換する
    override fun parseLiteral(input: Any): EmailScalarType {
        logger.info("parseLiteral {}", input.toString())

        when (input) {
            // 入力された値はダウンキャストして使用する
            is StringValue -> return EmailScalarType(input.value)
            // Coercing用の例外クラスを用いることで、適切なエラーレスポンスになる
            else -> throw CoercingParseLiteralException("invalid email value")
        }
    }

    // Server to Client で値を渡すときに呼び出される
    override fun serialize(dataFetcherResult: Any): String {
        logger.info("serialize {}", dataFetcherResult.toString())

        return when (dataFetcherResult) {
            is String -> dataFetcherResult
            is EmailScalarType -> dataFetcherResult.rawValue
            else -> dataFetcherResult.toString()
        }
    }

}

val emailType: GraphQLScalarType = GraphQLScalarType.newScalar().name("Email").coercing(emailCoercing).build()

あとはemailTypeRuntimeWiringにセットします。

    // 一部のコードは省略
    private fun buildWiring(): RuntimeWiring {
        return RuntimeWiring.newRuntimeWiring()
                .scalar(emailType)
                .build()
    }

以上の実装で Email Scalar Type が使えるようになりました!

このように Scalar Type を独自に作ることができます。また、一般的に使われそうなものは以下のGitHubリポジトリにアップされています。

今回はこの中から日付を表す Date 型をつかってみました。

実際に動かしてみる

実際に通信してみます。DataFetcherの実装は割愛します。

リクエス

{
  authorByEmail(email: "sample@demo.co.jp") {
    id
    birthDay
  }
}

レスポンス

{
  "data": {
    "authorByEmail": {
      "id": "author-1",
      "birthDay": "1990-01-01"
    }
  }
}

独自に定義された Scalar Type を使って通信することができました。

GraphQLを試す!(enumとPOJO)

前回はGraphQLを簡単に試してみました。

試してみたコードはチュートリアルに則っているので、非常に簡単でわかりやすいレベルになっています。その中で、もう少し実用的な内容にしてみようと以下の2つを試してみました。

  • Schemaにてenumを使用する
  • MapではなくPOJOをレスポンスに指定する

queryの引数にenumを使用する

GraphQLのSchema定義をざっと読んでみたのですが、色々とできることがあるみたいです。その中で、とりあえずenumを使ってみようと思います。enumを使うことのメリットは値の種類を制限することができる点です。種類に該当しない値がやりとりされると、GraphQL側で自動的にエラーとして認識してくれます。

というわけで、以下のSchema定義を記述してみました。

type Query {
    hero(episode: Episode): Character
}

type Character {
    name: String!
    appearsIn: [Episode!]!
}

enum Episode {
    NEW_HOPE
    EMPIRE
    JEDI
}

enumの定義は enum にて行います。あとはその種類を列挙するだけです。

実際にリクエストを送る場合はこのようなリクエストになります。

{
  hero(episode: JEDI) {
    name
    appearsIn
  }
}

MapではなくPOJOをレスポンスに指定する

前回はレスポンスをMapを使って処理していました。しかし、普通だったらPOJOを使いたいですよね。というわけで、上記のSchema定義の Character を以下のように書いてみました。

    data class Character(val name: String, val appearsIn: List<String>)

    fun getHeroDataFetcher(): DataFetcher<Character> {
        return DataFetcher { dataFetchingEnvironment ->
            logger.info("argument is {}", dataFetchingEnvironment.getArgument("episode") as String)
            Character("Anakin Skywalker", listOf("NEW_HOPE", "EMPIRE", "JEDI"))
        }
    }

指定された引数は処理に使っていないのですが、実際になんの値が来たのかを知るためにログ出力しています。ここでスター・ウォーズの例を使っているのは私が好きだからではなく、公式のドキュメントがスター・ウォーズを題材にしているからです。

POJOをレスポンスに使う場合の詳細についてはドキュメントに記載があります。POJOはデフォルトの PropertyDataFetcher がよしなに処理してくれるみたいですね。

動かしてみた

実際に動かしてみました。リクエストとレスポンスはこんな感じになりました。

リクエス

{
  hero(episode: JEDI) {
    name
    appearsIn
  }
}

レスポンス

{
  "data": {
    "hero": {
      "name": "Anakin Skywalker",
      "appearsIn": [
        "NEW_HOPE",
        "EMPIRE",
        "JEDI"
      ]
    }
  }
}

きちんとPOJOクラスが処理されているのがわかりますね。

ここでenumの種類以外の値をリクエストに含めると以下のようにエラーになりました。

リクエス

{
  hero(episode: XXX) {
    name
    appearsIn
  }
}

レスポンス

{
  "errors": [
    {
      "message": "Validation error of type WrongType: argument 'episode' with value 'EnumValue{name='XXX'}' is not a valid 'Episode' @ 'hero'",
      "locations": [
        {
          "line": 2,
          "column": 8
        }
      ]
    }
  ]
}

レスポンスの内容に丁寧にどこかどう間違っているのか書いてあります。