今回は公式のチュートリアルに則りつつ、CRUD処理を追加しながらSpringBootでGraphQLAPIを作ってみたいと思います。
準備
チュートリアルの記載通り、initializrから「Spring for GraphQL」 と「Spring Web」を依存関係に設定します。
DBはライトに管理したいため、インメモリデータベースの「H2」と「Spring Data JPA」の依存関係を追加し、SpringBoot起動時にDBを自動構成する方式としたいと思います。
pom.xmlの記載は割愛します。
application.properties
は下記の記載となります。
spring.graphql.graphiql.enabled=true
spring.sql.init.mode=always
# SQLログ
logging.level.org.springframework=WARN
logging.level.com.example.api_sample.BookMapper=DEBUG
logging.level.com.example.api_sample.AuthorMapper=DEBUG
# カラム名をCamel式に変換
mybatis.configuration.map-underscore-to-camel-case=true
次はDB自動構成のためのSQLです。
book
テーブルとauthor
テーブルがあって、book
テーブルからauthor_id
をキーにauthor
テーブルを参照しています。
それぞれsrc/main/resources
配下に配置してください。
schema.sql
create table if not EXISTS author(
id SERIAL primary key,
first_name varchar(255),
last_name varchar(255)
);
create table if not EXISTS book(
id SERIAL primary key,
name varchar(255),
page_count INT,
author_id INT,
FOREIGN KEY(author_id) REFERENCES author(id)
);
datq.sql
INSERT INTO author(first_name, last_name) VALUES('Joshua', 'Bloch');
INSERT INTO author(first_name, last_name) VALUES('Douglas', 'Adams');
INSERT INTO author(first_name, last_name) VALUES('Bill', 'Bryson');
INSERT INTO book(name, page_count, author_id) VALUES('Effective Java', 416, 1);
INSERT INTO book(name, page_count, author_id) VALUES('Hitchhiker''s Guide to the Galaxy', 208, 2);
INSERT INTO book(name, page_count, author_id) VALUES('Down Under', 436, 3);
INSERT INTO book(name, page_count, author_id) VALUES('Java Puzzlers: Traps, Pitfalls, and Corner Cases', 314, 1);
INSERT INTO book(name, page_count, author_id) VALUES('Mostly Harmless', 118, 2);
INSERT INTO book(name, page_count, author_id) VALUES('Notes from a Small Island ', 363, 3);
schema.graphqls
にクエリとタイプを定義していきます。
チュートリアル通りの設定をしていればresources/graphql
配下に作成されています。
クエリにはクエリのパラメータと戻り値、タイプには戻り値やパラメータで使用する構造体と属性を定義します。
Book
は関連するAuthor
も同時取得したいため、属性にauthor
を持たせます。
type Query {
bookById(id: ID): Book
bookAll: [Book]
}
type Book {
id: ID
name: String
pageCount: Int
author: Author
}
type Author {
id: ID
firstName: String
lastName: String
}
Book
クラスとAuthor
クラスを定義していきます。
チュートリアルではRECORDクラスとして定義されてますが、通常のエンティティクラスとして定義したいと思います。
Book.java
public class Book {
private Integer id;
private String name;
private int pageCount;
private Integer authorId;
// setter,getterは省略
}
Author.java
public class Author {
private Integer id;
private String firstName;
private String lastName;
// setter,getterは省略
}
O/Rマッパーは「MyBatis」を使用します。
Mapperクラスを定義します。
今回は説明の便宜上、Mapper XMLは使わず、@Select
アノテーションに直接SQLを記述します。
BookMapper.java
import java.util.List;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface BookMapper {
@Select("SELECT id, name, page_count, author_id FROM book WHERE id=#{id}")
Book getById(Integer id);
@Select("SELECT id, name, page_count, author_id FROM book")
List<Book> findAll();
}
AuthorMapper.java
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Select;
@Mapper
public interface AuthorMapper {
@Select("SELECT id, first_name, last_name FROM author WHERE id=#{id}")
Author getById(Integer id);
}
最後にControllerクラスを定義します。
schema.graphqls
で定義したクエリと対応するメソッドを@QueryMapping
アノテーションを付加して定義し、必要なMapperクラスのメソッドを呼び出します。
また、Book
のインスタンスを受け取るauthor
メソッドに@SchemaMapping
アノテーションを付加し、author
レコードの取得処理を追加することでbook
レコード取得時に同時にauthor
レコードも取得することができます。
BookController.java
import java.util.List;
import org.springframework.graphql.data.method.annotation.Argument;
import org.springframework.graphql.data.method.annotation.QueryMapping;
import org.springframework.graphql.data.method.annotation.SchemaMapping;
import org.springframework.stereotype.Controller;
@Controller
public class BookController {
private final BookMapper bookMapper;
private final AuthorMapper authorMapper;
public BookController(BookMapper bookMapper, AuthorMapper authorMapper) {
this.bookMapper = bookMapper;
this.authorMapper = authorMapper;
}
@QueryMapping
public Book bookById(@Argument Integer id) {
return bookMapper.getById(id);
}
@QueryMapping
public List<Book> bookAll() {
return bookMapper.findAll();
}
// N+1問題
@SchemaMapping
public Author author(Book book) {
return authorMapper.getById(book.getAuthorId());
}
}
実行
準備ができたらSpringBootを立ち上げてみましょう。
Playgroundから確認可能です。
下記のクエリを画面入力し、実行ボタン(ピンクの矢印)を押します。
一応説明するとid=1
に紐づくbook
レコードと関連するauthor
レコードを取得するクエリになります。
query {
bookById(id: 1) {
id
name
pageCount
author {
id
firstName
lastName
}
}
}
すると、下記のようなレスポンスが得られると思います。
{
"data": {
"bookById": {
"id": "1",
"name": "Effective Java",
"pageCount": 416,
"author": {
"id": "1",
"firstName": "Joshua",
"lastName": "Bloch"
}
}
}
}
book
レコードと同時にそれに紐づくauthor
レコードも取得されていることがわかります。
N+1問題
次にbook
レコード全件を取得するクエリを実行します。
query {
bookAll {
id
name
pageCount
author {
id
firstName
lastName
}
}
}
すると結果は下記の通り、全件取得され、特に問題が無いように見えます。
{
"data": {
"bookAll": [
{
"id": "1",
"name": "Effective Java",
"pageCount": 416,
"author": {
"id": "1",
"firstName": "Joshua",
"lastName": "Bloch"
}
},
// 省略
{
"id": "6",
"name": "Notes from a Small Island ",
"pageCount": 363,
"author": {
"id": "3",
"firstName": "Bill",
"lastName": "Bryson"
}
}
]
}
}
ですが、BookController
クラスのauthor
メソッドに仕込んだログの内容を確認すると、
[BookMapper.findAll ==> Preparing: SELECT id, name, page_count, author_id FROM book
[BookMapper.findAll ==> Parameters:
[BookMapper.findAll <== Total: 6
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 1(Integer)
[AuthorMapper.getById <== Total: 1
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 2(Integer)
[AuthorMapper.getById <== Total: 1
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 3(Integer)
[AuthorMapper.getById <== Total: 1
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 1(Integer)
[AuthorMapper.getById <== Total: 1
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 2(Integer)
[AuthorMapper.getById <== Total: 1
[AuthorMapper.getById ==> Preparing: SELECT id, first_name, last_name FROM author WHERE id=?
[AuthorMapper.getById ==> Parameters: 3(Integer)
[AuthorMapper.getById <== Total: 1
となり、AuthorMapper
クラスのgetById
がレコード件数分、計6回呼ばれていることがわかります。
これが俗に言うN+1問題で、このサンプルだけでも1回のSQL発行に見えて実はbook
の検索で1回、関連するauthor
の検索で6回の計7回のSQL発行されます。
関連するレコード件数に比例してDB負荷も上がり、パフォーマンスも劣化するため、好ましい実装にはなっていません。
この辺は実装を見直す必要がありますが、N+1問題については次の記事で書きたいと思います。
最後まで精読いただきありがとうございました。