5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

今回は公式のチュートリアルに則りつつ、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問題については次の記事で書きたいと思います。

最後まで精読いただきありがとうございました。

5
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
5
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?