SpringBoot + GraphQLでAPI構築

今回は公式の[チュートリアル](https://spring.pleiades.io/guides/gs/graphql-server/)に則りつつ、CRUD処理を追加しながらSpringBootでGraphQLAPIを作ってみたいと思います。

### 準備

チュートリアルの記載通り、[initializr](https://start.spring.io/)から「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](http://localhost:8080/graphiql?path=/graphql)から確認可能です。
下記のクエリを画面入力し、実行ボタン(ピンクの矢印)を押します。
一応説明すると`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＋１問題
次に`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問題で、このサンプルだけでも１回のSQL発行に見えて実は`book`の検索で1回、関連する`author`の検索で6回の計7回のSQL発行されます。
関連するレコード件数に比例してDB負荷も上がり、パフォーマンスも劣化するため、好ましい実装にはなっていません。
この辺は実装を見直す必要がありますが、N+1問題については次の記事で書きたいと思います。

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