Spring GraphQL Introduction

概要

  • Spring GraphQLのマイルストーンバージョンが発表された(現在はM5)
  • GraphQLは良いものだと思うので、みなさんにもSpring GraphQLを知ってもらいたい!
  • まずGraphQLについて簡単に説明
  • それからSpring GraphQLを使ったサーバー側の実装方法を説明

前置き

  • このスライドはSpring GraphQL 1.0.0-M5をもとにしています
  • GraphQLの仕様はCurrent Working Draftを参考にしています
  • スライドに現れるコードは説明のため一部省略していることがあります

このスライドとコード例の置き場所

GraphQL

GraphQLとは

GraphQLはAPIのためのクエリ言語

公式サイトのランディングページで書かれている特徴

  • 型システムによって何ができるのかが分かる
  • 必要なものを問い合わせて取得できる
  • 1回のリクエストで多くのリソースを取得
  • バージョンなしでAPIを進化させられる

特徴:型システムによって何ができるのかが分かる

  • GraphQLは
    • データを表す型を定義して、その型を使用してクエリを実行する
    • 特定のデータベースやストレージには依存していない

型定義の例:ブログ記事とカテゴリー

type Article {
    id: ID
    title: String
    content: String
    category: Category
}

type Category {
    id: ID
    name: String
}

type Query {
    article(id: ID): Article
}
query {
  article(id: "1") {
    id
    title
    content
    category {
      id
      name
    }
  }
}

特別な型:QueryとMutationとSubscription

  • GraphQLは3つの型を特別扱いする
    • Queryはデータ取得のための型でquery操作に対応する
    • Mutationはデータ更新のための型でmutation操作に対応する
    • Subscriptionはイベントをサブスクライブするための型でsubscription操作に対応する
  • これらの型と操作の関連付けはschemaキーワードでカスタマイズできる
    schema {
        query: MyQuery
    }
    

Mutationの例

型定義

type Mutation {
  newArticle(title: String, content: String, categoryId: ID): Article
}

mutation操作

mutation {
  newArticle(title: "...", content: "...", categoryId: "1") {
    id
  }
}

Subscriptionの例

型定義

type Subscription {
  updatedArticle: Article
}

subscription操作

subscription {
  updatedArticle {
    title
  }
}

特徴:必要なものを問い合わせて取得できる

  • GraphQLのクエリは型定義をもとにして必要とするフィールドを指定する
  • レスポンスは指定されたフィールドを返す
  • クライアントが必要とするものだけを明確に取得できる

クエリの例:ブログ記事とカテゴリー

クエリ

query {
  article(id: "1") {
    title
    category {
      name
    }
  }
}

結果(JSON)

{
  "data": {
    "article": {
      "title": "Spring GraphQL introduction",
      "category": {
        "name": "Spring"
      }
    }
  }
}

特徴:1回のリクエストで多くのリソースを取得

  • GraphQLは1回のリクエストでルートとなるデータから関連を辿ってデータを取得して返す

  • また、1回のリクエストでクエリを複数個送信することも可能

    query {
        article1: article(id: "3") { title }
        article2: article(id: "4") { title }
    }
    

GraphQLとREST、リクエスト回数の比較

GraphQL

REST

特徴:バージョンなしでAPIを進化させられる

  • クライアントは必要となるフィールドを明確に指定してクエリを組み立てるため、型にフィールドが追加されても影響が無い

  • フィールド削除のために、それを予告する@deprecatedディレクティブが用意されている

    type Article {
        id: ID
        title: String
        body: String @deprecated(reason: "代わりに`content`を使うこと")
        content: String
        category: Category
    }
    

GraphQLの嬉しいところ

  • 型を定義するだけでクライアントは欲しいデータに合わせてクエリを組み立てられる
    • REST/OpenAPIだと欲しいデータの分だけエンドポイントを定義しないといけない
  • クエリがプログラミング言語に依存しておらず、クエリエディタで書いたものをそのままプログラムに組み込める
    • OpenAPIにおけるクエリはHTTPリクエストなため言語非依存と言えるが、低レイヤーなため言語/ライブラリのAPIになっておりポータビリティは高いとは思えない

Spring GraphQL

Spring GraphQLとは

  • SpringアプリケーションでGraphQLのサーバー側を実装できる
  • そのうちクライアント側も実装できるようになるはず
  • Spring Web MVCとSpring WebFluxの両方に対応
  • GraphQL Javaを使用している
  • Spring Boot Starterが用意されている
  • テストをサポートするクラスが用意されている

使用準備

pom.xmlへ依存を追加する

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-graphql</artifactId>
</dependency>
<!-- もしくは spring-boot-starter-webflux -->
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

※Spring Boot 2.7.0-M1からSpring Bootリポジトリへ移動したためバージョンを指定しなくてよくなった

Subscriptionを使う場合はwebsocketも追加

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-websocket</artifactId>
</dependency>

テストのための依存も追加する

<dependency>
    <groupId>org.springframework.graphql</groupId>
    <artifactId>spring-graphql-test</artifactId>
    <scope>test</scope>
</dependency>
<!-- WebTestClient のため必要になるっぽい -->
<dependency>
    <groupId>org.springframework</groupId>
    <artifactId>spring-webflux</artifactId>
    <scope>test</scope>
</dependency>

型定義を配置する

  • クラスパス上のgraphqlディレクトリ内の次の拡張子のファイルが読み込み対象となる
    • .graphqls
    • .gqls
  • とりあえずsrc/main/resources/graphql/schema.gqlsにでも書いておけばOK

開発時に便利な設定

GraphiQLというGraphQLのGUIクライアントを有効化する

spring.graphql.graphiql.enabled=true

※なお、デフォルトで有効化されているので実際には明示的に設定する必要はない

サーバーサイドの実装方法

データのフェッチ方法を定義する

やることは次の通り。

  • @Controllerを付与したコンポーネントを用意する(Spring Web MVCのコントローラーと同じ)
  • @QueryMapping/@MutationMapping/@SubscriptionMapping/@SchemaMappingを付与したメソッドでデータのフェッチ方法を定義する

データのフェッチ方法を定義する

@Controller
public class BlogController {

    @QueryMapping
    public Optional<Article> article(@Argument Integer id) {
        return articleRepository.findById(id);
    }

    @SchemaMapping
    public Category category(Article article) {
        return article.getCategory();
    }
}

データのフェッチ方法を定義する

type Article {
    id: ID
    title: String
    content: String
    category: Category
}

type Category {
    id: ID
    name: String
}

type Query {
    article(id: ID): Article
}

デモ

メソッドの戻り値の型

  • T
  • java.util.Optional<T>
  • java.lang.Iterable<T>, java.util.stream.Stream<T>, java.util.Iterator<T>, 配列
  • reactor.core.publisher.Mono<T>, reactor.core.publisher.Flux<T>
  • graphql.execution.DataFetcherResult<T>
  • java.util.concurrent.CompletionStage<T>

テスト

テストの書き方

  • テストクラスに@AutoConfigureGraphQlTesterを付ける
  • GraphQlTesterをインジェクションする
  • テストメソッドでGraphQlTesterを使ってテストをする

テストクラスの例

@SpringBootTest
@AutoConfigureMockMvc
@AutoConfigureGraphQlTester
public class BlogTest {

    @Autowired
    private GraphQlTester graphQlTester;

    @Test
    void article() {
        // GraphQlTesterを使ったテストコードをここに書く
    }
}

クエリの構築

GraphQlTesterへ渡すクエリを構築する

String query = "{" +
        "  article(id: 1) {" +
        "    title" +
        "    category {" +
        "      name" +
        "    }" +
        "  }" +
        "}";

クエリの構築

最近のJavaならテキストブロックで書けてありがたい

String query = """
        {
            article(id: 1) {
                title
                category {
                    name
                }
            }
        }
        """;

クエリの実行とアサーション

graphQlTester.query(query)
        .execute()

        .path("article.title")
        .entity(String.class)
        .isEqualTo("Spring GraphQL introduction")

        .path("article.category.name")
        .entity(String.class)
        .isEqualTo("GraphQL");

※なおpathメソッドに渡すパスにはJsonPathが使える

N + 1問題

N + 1問題とは

  • 主にDBアクセス周りで言及される話題
  • ループの中で都度クエリを発行してパフォーマンス低下を招いてしまう問題
  • 例:select * from articlearticleを複数件取得、取得したarticleをループしながらselect * from category where id = ?categoryを取得する
    • 最初の1回 + articleの件数分のクエリが発行されてしまう

N + 1問題の解決策:DataLoader

  • DBアクセスであればテーブルを結合すれば解決できる
  • GraphQLではDataLoaderを使う
  • 先程の例を使って雑に述べるとselect * from category where id = ?をN回行っていたところを、select * from category where id in (...)を1回行うようにする

DataLoaderを使った実装方法(1.0.0-M3以降)

  • コントローラーに@BatchMappingを付与したメソッドを定義する
  • 引数はソースのリスト
  • 戻り値はリスト、またはマップ(org.dataloader.BatchLoaderorg.dataloader.MappedBatchLoaderへ対応)

@BatchMappingを付与したメソッドの実装例

@BatchMapping
public List<Author> author(List<Comic> sources) {
    List<Integer> ids = sources.stream().map(Comic::getAuthorId).toList();
    Map<Integer, Author> authors = authorRepository.findByIds(ids).stream()
            .collect(Collectors.toMap(Author::getId, Function.identity()));
    return ids.stream().map(authors::get).toList();
}

詳しくはSpring GraphQL 1.0.0-M3でDataLoaderもアノテーションマッピングできるようになったを参照

DataLoaderを使った実装方法(1.0.0-M2まで)

  • org.dataloader.BatchLoaderを実装したクラスを作る
  • org.springframework.graphql.web.WebInterceptorを実装したクラスを用意してinterceptメソッドでリクエストオブジェクトへDataLoaderを登録する
  • データフェッチ時にenvからDataLoaderを取り出し、loadメソッドを使用する

BatchLoaderの実装例

@Component
public class AuthorLoader implements BatchLoader<Integer, Author> {

    private final AuthorRepository authorRepository;

    @Override
    public CompletionStage<List<Author>> load(List<Integer> keys) {
        List<Author> authors = authorRepository.findAllById(keys);
        return CompletableFuture.completedStage(authors);
    }
}

WebInterceptorでDataLoaderを登録

@Component
public class AuthorLoaderRegistration implements WebInterceptor {

    private final AuthorLoader authorLoader;

    @Override
    public Mono<WebOutput> intercept(WebInput webInput, WebInterceptorChain chain) {
        webInput.configureExecutionInput((input, builder) -> {
            DataLoaderRegistry registry = new DataLoaderRegistry();
            DataLoader<Integer, Author> dataLoader = DataLoaderFactory.newDataLoader(authorLoader);
            registry.register("authorLoader", dataLoader);
            return builder.dataLoaderRegistry(registry).build();
        });
        return chain.next(webInput);
    }
}

DataLoaderでデータフェッチ

@Controller
public class ComicController {

    @SchemaMapping
    public CompletableFuture<Author> author(Comic source, DataFetchingEnvironment env) {
        DataLoader<Integer, Author> authorLoader = env.getDataLoader("authorLoader");
        return authorLoader.load(source.getAuthorId());
    }
}

デモ

DataLoader

  • DataLoaderOptionsmaxBatchSizeでバッチサイズを設定できる

  • BatchLoaderの他にMappedBatchLoaderがある

    public interface BatchLoader<K, V> {
        CompletionStage<List<V>> load(List<K> keys);
    }
    
    public interface MappedBatchLoader<K, V> {
        CompletionStage<Map<K, V>> load(Set<K> keys);
    }
    

ページング

公式オススメのページング

  • GraphQL公式ガイドにPaginationというドキュメントがある
  • ページングのオススメ方式が説明されており、最後にRelayGraphQL Cursor Connections Specificationが紹介されている
    • Relay is a JavaScript framework for building data-driven React applications.

GraphQL Cursor Connections Specification

  • 末尾にConnectionと付いた型を定義する
  • フィールドはedgespageInfo
  • edgesはリストで各要素は返したい型とカーソルのペア
  • pageInfoは返されたページの情報
  • クエリは1ページの最大要素数と条件となるカーソルを受け取る
  • カーソルはStringまたはStringにシリアライズされるカスタムscalar

Connections例:型定義

type ExampleConnection {
    edges: [ExampleEdge!]!
    pageInfo: PageInfo!
}
type ExampleEdge {
    node: Example!
    cursor: String!
}
type PageInfo {
    hasPreviousPage: Boolean!
    hasNextPage: Boolean!
    startCursor: String
    endCursor: String
}

Connections例:クエリ定義

type Query {
    # 前方のカーソル
    exampleForward(first: Int! = 10, after: String): ExampleConnection!
    # 後方のカーソル
    exampleBackward(last: Int! = 10, before: String): ExampleConnection!
}
  • なお、前方と後方のどちらの場合も返されるエッジの順序は同じにする必要がある
  • 前方は3, 4, 5...、後方は5, 4, 3...とするのはダメということ

Connections例:クエリ

query {
  exampleForward(first: 10, after: "...") {
    edges {
      node { field1 field2 field3 }
      cursor
    }
    pageInfo {
      hasPreviousPage
      hasNextPage
      startCursor
      endCursor
    }
  }
}

Spring GraphQLでConnections

  • Spring GraphQLで、というかGraphQL Javaで用意されているクラスを使う
    • graphql.relay.Connection<T>
    • graphql.relay.Edge<T>
    • graphql.relay.ConnectionCursor
    • graphql.relay.PageInfo

Connections実装例:DataFetcher全体

@QueryMapping
public Connection<Example> exampleForward(
        @Argument int first, @Argument String after) {

    List<Edge<Example>> edges = ...

    PageInfo pageInfo = ...

    return new DefaultConnection<>(edges, pageInfo);
}

Connections実装例:Edgeのリスト構築

List<Example> examples = ...

List<Edge<Example>> edges = examples.stream().map(example -> {
    ConnectionCursor cursor = new DefaultConnectionCursor(example.getId());
    return new DefaultEdge<>(example, cursor);
}).collect(Collectors.toList());

Connections実装例:PageInfo構築

ConnectionCursor startCursor = new DefaultConnectionCursor("...");
ConnectionCursor endCursor = new DefaultConnectionCursor("...");
boolean hasPreviousPage = ...
boolean hasNextPage = ...

PageInfo pageInfo = new DefaultPageInfo(
    startCursor, endCursor, hasPreviousPage, hasNextPage);

デモ

エラーの返却と例外ハンドリング

GraphQLのエラー表現

GraphQLの仕様にはエラーの表現も含まれている

{
    "errors": [
        {
            "message": "...",
            "location": [{"line": 1, "column": 2}],
            "path": ["path", "to", "field"]
        }
    ]
}

Spring GraphQLでエラーを返す

コントローラーから例外をスローするとエラーに変換してくれる

@QueryMapping
public Object hello() {
    throw new Exception("Exception occurred while processing hello");
}
{
    "errors": [{
        "message": "Exception occurred while processing Foo.bar",
        "location": [{"line": 1, "column": 2}],
        "path": ["hello"]
    }]
}

Spring GraphQLでの例外ハンドリング

例外をハンドリングする場合はDataFetcherExceptionResolverを実装する。DataFetcherExceptionResolverAdapterを利用するのが少しだけ簡単。

@Component
public class MyExceptionResolver extends DataFetcherExceptionResolverAdapter {
    @Override
    public GraphQLError resolveException(
                Throwable e, DataFetchingEnvironment env) {
        if(e instanceof MyException) {
            GraphQLError error = GraphqlErrorBuilder.newError(env).message("...").build();
            return error;
        }
        return null;
    }
}

その他の話題

紹介していないGraphQLの仕様はまだまだある

  • Scalar
  • Interface
  • Union
  • Enum
  • Input Object
  • Directive

ScalarとEnumの実装方法を簡単に説明

Scalarの実装方法

scalar URI
@Component
public class ScalarDataWiring implements RuntimeWiringConfigurer {

    @Override
    public void configure(RuntimeWiring.Builder paramBuilder) {
        Coercing<?, ?> coercing = new URICoercing();
        GraphQLScalarType scalarType = GraphQLScalarType.newScalar()
            .name("URI").coercing(coercing).build();
        paramBuilder.scalar(scalarType);
    }
}

Scalarの実装方法

public class URICoercing implements Coercing<URI, String> {
    @Override
    public String serialize(Object dataFetcherResult) throws CoercingSerializeException {
        return dataFetcherResult.toString();
    }

    @Override
    public URI parseValue(Object input) throws CoercingParseValueException {
        return URI.create((String) input);
    }

    @Override
    public URI parseLiteral(Object input) throws CoercingParseLiteralException {
        return URI.create(((StringValue) input).getValue());
    }
}

Enumの実装方法

enum Visibility {
    PUBLIC
    PRIVATE
}
public enum Visibility {
    PUBLIC,
    PRIVATE;
}

Enumの実装方法

@Component
public class EnumDataWiring implements RuntimeWiringConfigurer {

    @Override
    public void configure(RuntimeWiring.Builder paramBuilder) {
        EnumValuesProvider enumValuesProvider =
            new NaturalEnumValuesProvider<>(Visibility.class);
        paramBuilder.type("Visibility", b -> b.enumValues(enumValuesProvider));
    }
}

GraphQLに合わないAPIが欲しいときは?

例えば

  • 複雑なテーブル結合を必要とするクエリ
  • バイナリデータを返したい(ダウンロード)

など

そういった場合はRestControllerを書けば良い(Spring Web MVC/Spring WebFluxが土台になっている強み)

コンテキストの伝播

GraphQL Javaから呼び出されるコントローラーのメソッドはSpring Web MVCのリクエストをハンドリングするスレッドと同じスレッドで実行されるとは限らない

そのためスレッドローカルで持ち回すような値はorg.springframework.graphql.execution.ThreadLocalAccessorを実装して伝搬させてあげる必要がある

詳しくはSpring GraphQLリファレンスのContext Propagationセクションを参照

認証・認可はどうする?

  • GraphQLのエンドポイントは単一なため、エンドポイントに対する認証・認可だけでは不十分
  • Spring GraphQLのリファレンスにはデータをフェッチするときに使用するサービスクラスなどに@PreAuthorize@Securedを付けて保護する方法が記載されている

メトリクス

spring-boot-starter-actuatorがクラスパス上に存在するとGraphQLリクエストのメトリクスが収集される

ここまでのデモで収集されたメトリクスを見てみる(というデモ)

Querydslの統合

Querydslのリポジトリからデータフェッチ定義が簡単に作れるらしい(まだ試していない)

詳しくはSpring GraphQLリファレンスのData Integrationセクションを参照

ロードマップ

  • マイルストーンフェーズは11月のSpring Boot 2.6以降も続く
  • 今年の後半にリリース候補版(RC)フェーズに入る予定

今後に入るかもしれない機能

  • Spring Dataの統合(Querydslと同様に)
  • DataLoader登録方法の改善
  • GraphQLクライアントの追加
  • マルチパート(ファイルアップロード)の対応

など

参考リソース