GraphQLを試す!

この前職場でGraphQLの質問を受けたんですが、やったことがないので答えられませんでした。というわけで、これを機に理解しておこうかと思います。

GraphQLとは?

graphql.org

そもそもGraphQLとは一体全体どんなものなのでしょうか?公式サイトには以下の文言が書かれています。

GraphQL is a query language for APIs and a runtime for fulfilling those queries with your existing data. GraphQL provides a complete and understandable description of the data in your API, gives clients the power to ask for exactly what they need and nothing more, makes it easier to evolve APIs over time, and enables powerful developer tools.

要約するとGraphQLとはクエリ言語の一つで、実行時にデータを満たす特徴があるそうです。完全でわかりやすくAPIのデータを表明でき、クライアントの必要に応じたやりとり(どれが必要でどれがいらないとか)ができます。さらにAPIを進化させやすく、開発ツールのパワーを最大化できるとも書いてあります。

これだけ読んでも全くわからないので、とりあえずチュートリアルをやってみました。

チュートリアルをやってみる

JavaでできるチュートリアルがあったのでKotlinに置き換えてやってみました。

手順は以下のとおりです。

  1. Spring Boot アプリケーションの作成
  2. Schemaファイルの作成
  3. DataFetcherの作成

今回作成したものの全体はGitHubで公開しています。

Spring Boot アプリケーションの作成

GraphQLに関する依存関係を追加したSpringBootのプロジェクトを作成します。GraphQLに関する依存は以下の2つになります。

implementation("com.graphql-java:graphql-java:11.0")
implementation("com.graphql-java:graphql-java-spring-boot-starter-webmvc:1.0")


全体の build.gradle.kts はこんな感じ。

import org.jetbrains.kotlin.gradle.tasks.KotlinCompile

plugins {
    id("org.springframework.boot") version "2.2.4.RELEASE"
    id("io.spring.dependency-management") version "1.0.9.RELEASE"
    kotlin("jvm") version "1.3.61"
    kotlin("plugin.spring") version "1.3.61"
}

group = "com.graphql-java.tutorial"
version = "0.0.1-SNAPSHOT"
java.sourceCompatibility = JavaVersion.VERSION_1_8

repositories {
    mavenCentral()
}

dependencies {
    implementation("com.graphql-java:graphql-java:11.0")
    implementation("com.graphql-java:graphql-java-spring-boot-starter-webmvc:1.0")
    implementation("com.google.guava:guava:26.0-jre")
    implementation("org.springframework.boot:spring-boot-starter-web")
    implementation("org.jetbrains.kotlin:kotlin-reflect")
    implementation("org.jetbrains.kotlin:kotlin-stdlib-jdk8")
    testImplementation("org.springframework.boot:spring-boot-starter-test") {
        exclude(group = "org.junit.vintage", module = "junit-vintage-engine")
    }
}

tasks.withType<Test> {
    useJUnitPlatform()
}

tasks.withType<KotlinCompile> {
    kotlinOptions {
        freeCompilerArgs = listOf("-Xjsr305=strict")
        jvmTarget = "1.8"
    }
}

Schemaファイルの作成

GraphQLではSchemaファイルと呼ばれるAPIスキーマを定義するファイルを用意するみたいです。GraphQLはクライアントやサーバーのやり取りをこのスキーマファイルに定義されている内容をもとに行うようです。なので柔軟なAPI呼び出しを行えるんですね。

今回は src/main/resources/schema.graphqls に以下の内容を記述しました。

type Query {
    bookById(id: ID): Book
}

type Book {
    id: ID
    name: String
    pageCount: Int
    author: Author
}

type Author {
    id: ID
    firstName: String
    lastName: String
}

DataFetcherの作成

最後にDataFetcherと呼ばれるものを作れば終わりですが、これが少し理解が難しいです。先に作成したものを載せます。

package com.graphqljava.tutorial.bookdetails

import graphql.schema.DataFetcher
import org.springframework.stereotype.Component

@Component
class GraphQLDataFetchers {

    private val books = listOf(
            mapOf("id" to "book-1",
                    "name" to "Harry Potter and the Philosopher's Stone",
                    "pageCount" to "223",
                    "authorId" to "author-1"),
            mapOf("id" to "book-2",
                    "name" to "Moby Dick",
                    "pageCount" to "635",
                    "authorId" to "author-3"),
            mapOf("id" to "book-3",
                    "name" to "Interview with the vampire",
                    "pageCount" to "371",
                    "authorId" to "author-3")
    )

    private val authors = listOf(
            mapOf("id" to "author-1",
                    "firstName" to "Joanne",
                    "lastName" to "Rowling"),
            mapOf("id" to "author-2",
                    "firsName" to "Herman",
                    "lastName" to "Melville"),
            mapOf("id" to "author-3",
                    "firstName" to "Anne",
                    "lastName" to "Rice")
    )

    fun getBookByIdDataFetcher(): DataFetcher<Map<String, String>?> {
        return DataFetcher { dataFetchingEnvironment ->
            val bookId = dataFetchingEnvironment.getArgument<String>("id")
            books.stream().filter { it["id"] == bookId }.findFirst().orElse(null)
        }
    }

    fun getAuthorDataFetcher(): DataFetcher<Map<String, String>?> {
        return DataFetcher { dataFetchingEnvironment ->
            val book = dataFetchingEnvironment.getSource<Map<String, String>>()
            val authorId = book["authorId"]
            authors.stream().filter { it["id"] == authorId }.findFirst().orElse(null)
        }
    }

}

DataFetcherとはその名の通り、データを取ってくる役割を持ったクラスです。上のコードだと、 getBookByIdDataFetchergetAuthorDataFetcher がDataFetcherのインスタンスを返しています。GraphQLはこのDataFetcherを使ってリクエストに応じデータを取得します。

DataFetcherはスキーマファイルで定義した型、フィールドごと作れます。指定しない場合は PropertyDataFetcher が使用されます。今回は Book と Author に対してDataFetcherを作りましたが、それらのフィールドに対しては作っていないので、フィールド値を取得するときには PropertyDataFetcher が使用されます。

DataFetcherのインスタンスDataFetcherインターフェイスを実装する必要があります。インターフェイスT get(DataFetchingEnvironment environment) throws Exception; というメソッドが定義されていて、このメソッドの戻り値がクエリの応答(レスポンス)に該当します。以下のリンク先にそのインターフェイスが載っています。


DataFetcherが戻り値を返すには通常、クエリの内容を取得する必要が出てきます。これは引数の DataFetchingEnvironment が情報を持っているので、問い合わせて使用します。上記の例だと以下の2パターンでクエリの内容を取得しています。

  • getArgument ... クエリの引数を取得する。今回はBookIdを取得している。
  • getSource ... 親フィールドの情報を取得する。今回はどのAuthorのデータを返すかを調べるために、親となるBookのフィールドを取得している。

あとはお作法に則った形で GraphQL というインスタンスを作り、Spring の Bean に登録してあげればチュートリアルは完了です。

package com.graphqljava.tutorial.bookdetails

import com.google.common.io.Resources
import graphql.GraphQL
import graphql.schema.GraphQLSchema
import graphql.schema.idl.RuntimeWiring
import graphql.schema.idl.SchemaGenerator
import graphql.schema.idl.SchemaParser
import graphql.schema.idl.TypeRuntimeWiring
import org.springframework.beans.factory.annotation.Autowired
import org.springframework.context.annotation.Bean
import org.springframework.stereotype.Component
import javax.annotation.PostConstruct

@Component
class GraphQLProvider {

    private var graphQl: GraphQL? = null

    @Autowired
    private lateinit var graphQLDataFetchers: GraphQLDataFetchers

    @Bean
    fun graphQL(): GraphQL? {
        return graphQl
    }

    @PostConstruct
    @Suppress("UnstableApiUsage")
    fun init() {
        // schema.graphqls ファイルの読み込み
        val url = Resources.getResource("schema.graphqls")
        val sdl = Resources.toString(url, Charsets.UTF_8)

        // GraphQLSchema 及び GraphQL インスタンスの作成
        val graphQLSchema = buildSchema(sdl)
        this.graphQl = GraphQL.newGraphQL(graphQLSchema).build()
    }

    /**
     * GraphQLSchema インスタンスの作成
     */
    fun buildSchema(sdl: String): GraphQLSchema {
        val typeRegistry = SchemaParser().parse(sdl)
        val runtimeWiring = buildWiring()
        return SchemaGenerator().makeExecutableSchema(typeRegistry, runtimeWiring)
    }

    private fun buildWiring(): RuntimeWiring {
        return RuntimeWiring.newRuntimeWiring()
                .type(TypeRuntimeWiring.newTypeWiring("Query")
                        .dataFetcher("bookById", graphQLDataFetchers.getBookByIdDataFetcher()))
                .type(TypeRuntimeWiring.newTypeWiring("Book")
                        .dataFetcher("author", graphQLDataFetchers.getAuthorDataFetcher()))
                .build()
    }

}

作成したDataFetcherは、上記のコードの buildWiring メソッド内で使っています。

APIを叩いてみる

実際にAPIを叩いてみました。APIを叩く便利なツールを公式が用意しているのでそちらを使用します。

エンドポイントはデフォルトでは graphql になっているので、 http://localhost:8080/graphql をアクセス先に設定し、以下のリクエストを送信します。

{
  bookById(id: "book-1") {
    id
    author {
      firstName
      lastName
    }
  }
}

レスポンスは以下のようになりました。

{
  "data": {
    "bookById": {
      "id": "book-1",
      "author": {
        "firstName": "Joanne",
        "lastName": "Rowling"
      }
    }
  }
}

リクエストで指定したbookIdの内容が、指定したものだけ返却されているのがわかります。この例のように、GraphQLではクライアントがどんな情報がほしいのかを細かく指定することができるのが特徴です。

ここでふと思ったのですが、スキーマで定義されていないリクエストを送るとどうなるのか...?実際にやってみました。authorageを含め送信すると以下のようになりました。

{
  bookById(id: "book-1") {
    id
    author {
      firstName
      lastName
      age
    }
  }
}
{
  "data": {
    "bookById": {
      "id": "book-1",
      "author": {
        "firstName": "Joanne",
        "lastName": "Rowling",
        "age": null
      }
    }
  }
}

レスポンスを見ると分かる通りnullになるみたいです。