Takehisa's tech blog

勉強したことの記録や解説を目的に記事を更新していきます。

Spring GraphQL を触ってみた

はじめに

2021 年 8 月 6 日に開催された JSUG勉強会 2021年その2 Spring GraphQLをとことん語る夕べ に参加し、Spring GraphQL について初めて学んできました。 新しく学べたことがたくさんあったので、復習も兼ねて、実際に手を動かして実装してみました。 このページで実装したリポジトリはこちらになります。

github.com

また勉強会で使用された資料やデモ実装はこちらの方の Github リポジトリにて公開されています。

github.com

Graph QL とは?

そもそも、この勉強会に参加してみて、初めて GraphQL というものを知りました。 GraphQL とは、API 用に設計されたクエリ言語で、API の速度、柔軟性、開発者にとっての使いやすさを向上させるために設計された1 ようです。 特徴2 としては、

  1. 型を定義して、それに対してクエリを実行する => データベースに依存しない
  2. 必要な型に対してのみクエリを実行できる => クライアントが必要なデータのみ取得可能
  3. 一度のリクエストで複数のリソースを取得できる => REST の場合はリソース毎にエンドポイントを用意して、それぞれにリクエストを投げる必要あり
  4. API をバージョンなしで進化させられる => クライアントは必要な型のみを定義してクエリを実行するため、新たなフィールドが型に追加されたとしても、クライアントにはなんの影響も出ない。もし、型のフィールドを削除する場合にも、deprecated アノテーションをつけるなど、対応策がある。

が挙げられます。

実装してみる

環境

  • Java: 11
  • Spring Boot: 2.5.3
  • graphql-spring-boot-starter: 5.0.2

プロジェクト作成

プロジェクトは、Spring Initializer を使用して作成します。 group や artifact などは好きに設定して OK です。 Depedency には以下を追加しました。

  • Lombok
  • Spring Web
  • Spring Data JPA
  • H2 Database
  • Spring Boot Actuator

これらに加えて、今回使用する Spring GraphQL の depedency を追加します。

<dependency>
    <groupId>org.springframework.experimental</groupId>
    <artifactId>graphql-spring-boot-starter</artifactId>
    <version>1.0.0-M1</version>
</dependency>

<repositories>
    <repository>
        <id>spring-milestones</id>
        <url>https://repo.spring.io/milestone</url>
    </repository>
    <repository>
        <id>spring-snapshots</id>
        <url>https://repo.spring.io/snapshot</url>
        <releases>
                <enabled>false</enabled>
        </releases>
    </repository>
</repositories>

型の定義

まずは今回扱う型の定義を行います。 SpringBoot では、クラスパス上の graphql ディレクトリに存在する

.graphqls
.graphql
.gpl
.gpls

を型定義の対象ファイルとして扱うので、src/main/resources/graphql 以下に型定義ファイルを作成します。 今回は、各ゲーム会社についてデータを作成しようと思うので、Company と Game という型を作っていきます。

type Game {
    id: ID!
    name: String!
    companyName: String
    kind: String
}

type Company {
    companyId: ID!
    name: String!
    games: [Game!]!
}

type Query {
    game(id: ID!): Game
    games: [Game!]!
    company(companyId: ID!): Company
    companies: [Company!]!
}

type Mutation {
    createCompany(companyId: ID!, name: String!): Company
    createGame(id: ID!, name: String!, companyName: String!, kind: String!): Game
}

これらは GraphQL の文法になるので、詳細や他の文法については公式ページを見ていただくとして、今回使用した type XXXtype Querytype Mutation について簡単に説明します。

type XXX: type を用いて宣言を行うと、その名前の型を定義することができます。Java でいう class XXX としているようなものです。今回の例では、id, name, companyName, kind というフィールドを持つ Game 型と companyId, name, games というフィールドを持つ Company 型を定義したことになります。 それぞれのフィールドは フィールド名: データタイプ という形式で記述します。データタイプは、公式ページより、以下が指定できるようです。

  • ID: The ID scalar type represents a unique identifier (= データを一意に特定するためのもの。SQL の主キー。)
  • Int: A signed 32‐bit integer
  • Float: A signed double-precision floating-point value
  • String: A UTF‐8 character sequence.
  • Boolean: true or false

type Query: これは特別な型宣言で、この Query type の中でデータを取得するときの query を宣言します。

type Mutation: これも特別な型宣言で、この Mutation type の中でデータを更新、作成、削除するときの query を宣言します。

データの挿入

今回はデモができれば良いだけなので、利便性を重視して、インメモリデータベースの h2 を使用します。 インメモリではありますが、設定次第で、作成したデータをファイルに保存して永続化ができるので、今回はそちらで対応します。

まずは、下記の SQL でテーブルを作成します。

-- GAME テーブルの作成
CREATE TABLE GAME(
    ID INT PRIMARY KEY,
    NAME VARCHAR(100) NOT NULL,
    COMPANY_NAME VARCHAR(100) NOT NULL,
    KIND VARCHAR(10)    ​
);

-- COMPANY テーブルの作成
CREATE TABLE COMPANY(
    COMPANY_ID INT PRIMARY KEY,
    NAME VARCHAR(100) NOT NULL
);

次にテストデータを作成します。

-- GAME data
INSERT INTO GAME (ID, NAME, COMPANY_NAME, KIND) VALUES(1, 'ドラゴンクエスト XI 過ぎ去りし時を求めて', 'SQUEA ENIX', 'RPG');
INSERT INTO GAME (ID, NAME, COMPANY_NAME, KIND) VALUES(2, 'FINAL FANTASY X', 'SQUEA ENIX', 'RPG');
INSERT INTO GAME (ID, NAME, COMPANY_NAME, KIND) VALUES(3, '桃太郎電鉄 ~昭和 平成 令和も定番!', 'ハドソン', 'ボードゲーム');
INSERT INTO GAME (ID, NAME, COMPANY_NAME, KIND) VALUES(4, 'マリオテニスエース', '任天堂', 'スポーツゲーム');

-- COMPANY data
INSERT INTO COMPANY (COMPANY_ID, NAME) VALUES (1, 'SQUEA ENIX');
INSERT INTO COMPANY (COMPANY_ID, NAME) VALUES (2, 'ハドソン');
INSERT INTO COMPANY (COMPANY_ID, NAME) VALUES (3, '任天堂');

データを確認します。

SELECT * FROM GAME;

f:id:awdrgyjilp10098:20210814221612p:plain
GAME テーブルのテストデータ

SELECT * FROM COMPANY;

f:id:awdrgyjilp10098:20210814221734p:plain
COMPANY テーブルのテストデータ

画像の通りテストデータを作成することができました。

次は、このデータを取得するまでの流れを作成します。

データフェッチ部分の実装

まずは各 type に対応する Entity クラスを作成します。

// Company
@Entity
public class Company {
    @Id
    Integer companyId;
    String name;

    public Company() {
    }

    public Company(Integer companyId, String name) {
        this.companyId = companyId;
        this.name = name;
    }

    public Integer getCompanyId() {
        return companyId;
    }

    public void setCompanyId(Integer companyId) {
        this.companyId = companyId;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }
}

// Game
@Entity
public class Game {
    @Id
    Integer id;
    String name;
    String companyName;
    String kind;

    public Game() {
    }

    public Game(Integer id, String name, String companyName, String kind) {
        this.id = id;
        this.name = name;
        this.companyName = companyName;
        this.kind = kind;
    }

    public Integer getId() {
        return id;
    }

    public void setId(Integer id) {
        this.id = id;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public String getCompanyName() {
        return companyName;
    }

    public void setCompanyName(String companyName) {
        this.companyName = companyName;
    }

    public String getKind() {
        return kind;
    }

    public void setKind(String kind) {
        this.kind = kind;
    }
}

次に、データをフェッチしてくる部分の実装です。 RuntimeWiringBuilderCustomizer クラスの customize メソッドをオーバーライドすることで実現できます。

@Component
public class GameDataWiring implements RuntimeWiringBuilderCustomizer {

    private GameRepository gameRepository;
    private CompanyRepository companyRepository;

    public GameDataWiring(GameRepository gameRepository, CompanyRepository companyRepository) {
        this.gameRepository = gameRepository;
        this.companyRepository = companyRepository;
    }

    @Override
    public void customize(RuntimeWiring.Builder builder) {
        builder.type("Query", builder1 ->
                builder1.dataFetcher("game", env -> {
                    Integer id = Integer.valueOf(env.getArgument("id"));
                    return gameRepository.findById(id);
                })
                .dataFetcher("games", env -> {
                    return gameRepository.findAll();
                })
                .dataFetcher("company", env -> {
                    Integer companyId = Integer.valueOf(env.getArgument("companyId"));
                    return companyRepository.findById(companyId);
                })
                .dataFetcher("companies", env -> {
                    return companyRepository.findAll();
                })
        );

        builder.type("Company", builder1 ->
                builder1.dataFetcher("games", env -> {
                    Company company = env.getSource();
                    return gameRepository.findByCompanyName(company.getName());
                })
        );

        builder.type("Mutation", builder1 ->
                builder1.dataFetcher("createCompany", env -> {
                    Integer companyId = Integer.valueOf(env.getArgument("companyId"));
                    String companyName = env.getArgument("name");

                    Company company = new Company();
                    company.setCompanyId(companyId);
                    company.setName(companyName);

                    return companyRepository.save(company);
                })
                .dataFetcher("createGame", env -> {
                    Integer id = Integer.valueOf(env.getArgument("id"));
                    String gameName = env.getArgument("name");
                    String companyName = env.getArgument("companyName");
                    String gameKind = env.getArgument("kind");

                    Game game = new Game();
                    game.setId(id);
                    game.setName(gameName);
                    game.setCompanyName(companyName);
                    game.setKind(gameKind);

                  return gameRepository.save(game);
                })
        );
    }
}

ポイントとしては、最初に定義した型に対する dataFetcher を全て定義する ということです。

今回の例では、"Query" として定義した中に、"game"、"games"、"company"、"companies" という子要素を定義しました。

そのため、builder に対して "Query" を type メソッドで宣言し、その中でそれぞれの子要素に対して対応するデータ処理を書いていきます。

"game" の場合は、引数として与えられた id を基に、DB 上からレコードを引っ張ってくることになるので、return で gameRepository.findById(id) を返しています。

一方で、Company を呼び出した際には、"games" が呼び出されて、その会社が販売している Game データを紐付けます。このような場合には、builder に対して "Company" を type メソッドで宣言し、対応する Game データを取得します。

このようにして、自分が定義したデータ型に対して、全ての要素に対応するデータ取得方法を実装していきます。

実行

ここまでで実行までの準備が完了です。 REST や Controlller といった実装に慣れている方は少し戸惑うかもしれないのですが、GraphQL の場合は、デフォルトで /graphql に対して POST エンドポイントがマッピングされていて、特に Controller の設定をしなくても大丈夫です。これに加えて、/graphiql という GUI のパスもデフォルトで設定されているので、容易に動作検証を行うことができます。

では、それぞれのクエリについて動作を見ていきましょう。

game(id: ID)

query {
  game(id: 1) {
    id
    name
    companyName
    kind
  }
}

f:id:awdrgyjilp10098:20210815153541p:plain

games

query {
  games {
    id
    name
    companyName
    kind
  }
}

f:id:awdrgyjilp10098:20210815153653p:plain

copmany(id: ID)

query {
  company(companyId: 1) {
    companyId
    name
    games {
      id
      name
      kind
    }
  }
}

対応する Game データの一覧が取得できているのがわかります。 f:id:awdrgyjilp10098:20210815153832p:plainf:id:awdrgyjilp10098:20210815153832p:plain

companies

query {
  companies {
    name
    games{
      id
      name
      kind
    }
  }
}

f:id:awdrgyjilp10098:20210815153942p:plain

Mutation も同様にクエリを構築して、リクエストを投げることができます。

createCompany

mutation {
  createCompany(companyId: 4, name: "KONAMI") {
    companyId
    name
  }
}

f:id:awdrgyjilp10098:20210815154114p:plain

H2 database 上でも確認してみます。 f:id:awdrgyjilp10098:20210815154324p:plain 正しくデータが追加されていることがわかります。

createGame

mutation {
  createGame(id:5, name: "パワフルプロ野球 2020", companyName: "KONAMI", kind: "スポーツゲーム"){
    id
    name
    kind
  }
}

f:id:awdrgyjilp10098:20210815154201p:plain こちらも、H2 database 上でも確認してみます。 f:id:awdrgyjilp10098:20210815154409p:plain 正しくデータが追加されていることがわかります。

まとめ

この記事では、Spring GraphQL を使用して、簡単な Query と Mutation を作成してみました。 実際に実装してみた感想としては、GraphQL に関わる箇所の実装がそこまで複雑じゃないのに、柔軟なエンドポイントを作成できたので、とても利便性が高いと感じました。 特にクライアントが欲しいデータのみリクエストできるところが、魅力的に感じました。

ただ、実際に業務で使用するとなると、

  1. エンドポイントのセキュリティはどうするか => Spring Security を使えば実装できるようだが、結構込み入った話になってくるので、ここに実装の工数が多くかかりそう
  2. データが大量にある場合に、どうやってパフォーマンスを上げるか => DataLoader を使うことでパフォーマンスの向上を図れるが、複雑なデータ型だったりした場合にはうまくいかないかも?

のあたりをどうするか要検討ポイントだと感じました。

ただ、GraphQL でうまくいかなそうなところは、Spring MVC をベースとした REST API を組んだりするなど、方向転換が容易なところが Spring GraphQL の大きな魅力なのかなとも思いました。

参考文献

*1: GraphQL とは - 概要、特徴、メリット・デメリット | Red Hat

*2: https://backpaper0.github.io/spring-graphql-introduction/