サーバーサイド Kotlin で GraphQL の実装ができる DGS framework の動きを確認する

63358 ワード

はじめに

Kotlin で GraphQL を使う方法を調べていて DGS framework を見つけたのですが、ネストされたクエリがどのように実行されるのか具体的な動作を確認したかったので触ってみました。

私は Kotlin についても GraphQL についても現在勉強中です。

DGS framework について

DGS framework は Netflix がオープンソース化した[1] Spring Boot の上で動作する GraphQL のためのスキーマファーストなフレームワークです。Netflix 社内では本番で動作しているようです[2][3]

DGS framework の導入

開発環境には IntelliJ IDEA を使用しています。GraphQL のプラグインと、DGS のプラグインをインストールしました。

DGS のインストールなどは公式サイトの Getting Started に従って進めました。基本的にはドキュメントのスキーマとコードでそのまま動きましたが、コードには package と import の追加が必要でした。また、spring-boot-devtools を設定することで自動リスタートができて便利でした[4]

ひとまずこれで動かすことはできたのですが、ドキュメントにも

Note that we have a Codegen plugin that can do this automatically, but in this guide we'll manually write the classes.

と書いてある通り、コードを自動生成するプラグインがあり、これのおかげでスキーマファーストにできるので、次にこのプラグインのセットアップをしたのですが、ここのセットアップで下記の点に詰まりました。

  1. プラグインの設定を Kotlin で書く方法がわからなかった
  2. Resolver のサンプルコードが生成されなかった

1. プラグインの設定を Kotlin で書く方法がわからなかった

ドキュメントにはプラグインの設定方法が書いてあるのですが、おそらく Groovy で記述してあり、Gradle の設定に Groovy を使っていればそのまま動くのかも知れませんが (実際に試してはいないのでおそらくです)、Kotlin を使うにあたり Gradle の設定も Kotlin で記述していたので、そのままでは動きませんでした。Kotlin でどう書くのかいまいちわからなかったのですが、サンプルとして紹介されているリポジトリを見ていたら設定している部分[5]や、リポジトリのイシューにそれらしいやりとり[6]があったので、それらを参考に設定したら動きました。具体的には下記のような内容になります。schemaPaths の設定はデフォルトで大丈夫そうだったのでドキュメントでは設定されていましたが下記では設定していません。

tasks.withType<com.netflix.graphql.dgs.codegen.gradle.GenerateJavaTask> {
    packageName = "com.example.demo.generated"
    generateClient = true
}

2. Resolver のサンプルコードが生成されなかった

ドキュメント

generateJava generates the data fetchers and places them in build/generated-examples.

と書いてあるのですが、動かしてもサンプルコードが生成されなかったので調べたところ、言語が Kotlin になっていると生成されないようでした[7]。プラグインの設定で language という項目があるので java を指定して実行してみたところ、サンプルコードが生成されました。生成されたサンプルコードを確認したところ、ドキュメントにも

NOTE: generateJava does NOT add the data fetchers that it generates to your project’s sources. These fetchers serve mainly as a basic boilerplate code that require further implementation from you.

と書いてあるのですが、シンプルなコードだったので Kotlin で動かなくても特に問題なさそうだと思いました。また、ドキュメントには generated-examples に生成されると書いてありますが、生成されたディレクトリは generated/sources/dgs-codegen-generated-examples となっており、パスを設定できる設定項目の exampleOutputDir も設定からはなくなっているようでした。

具体的な動作の確認

DGS framework の動かし方はわかったので、具体的な動作を確認するために下記のような挙動をするコードを書きました。一言でいうと図書管理アプリのようなものです。

  • ログインしてトークンを取得できる
  • ログアウトできる
  • ユーザーは登録してある本を借りることができる
  • ユーザーは借りた本を返すことができる
  • ユーザーは自分が借りている本の一覧を取得できる
  • ユーザーは自分が過去に借りた本の一覧を取得できる
  • 本の一覧を取得できる
  • 本を借りているユーザーを取得できる

値をただ返すだけのコードでは実際に複雑になった時にどうなるのかわからないと思ったため、あえて循環参照しているスキーマにし、データの保持の仕方もイベントだけを保持するようにしてそこから値を計算するようにしています。実際のコードは下記になります。動いているコード全体は GitHub にあります。

type Query {
    me(token: String!): User!
    books: [Book!]!
}

type Mutation {
    signIn(id: ID!): String!
    signOut(token: String!): Boolean!
    rentBook(token: String!, id: ID!): Book!
    returnBook(token: String!, id: ID!): Book!
}

type User {
    id: ID!
    name: String!
    has: [Book!]!
    history: [Book!]!
}

type Book {
    id: ID!
    title: String!
    rentedBy: User
}
package com.example.demo

import org.springframework.boot.autoconfigure.SpringBootApplication
import org.springframework.boot.runApplication

@SpringBootApplication
class DemoApplication

fun main(args: Array<String>) {
    runApplication<DemoApplication>(*args)
}

data class User(val id: String, val name: String)
data class Book(val id: String, val title: String)
data class SignedInUser(val id: String, val token: String)
data class RentEvent(val userId: String, val bookId: String, val rentedAt: Int)
data class ReturnEvent(val userId: String, val bookId: String, val returnedAt: Int)

object Resource {
    val users = listOf(User("1", "user 1"), User("2", "user 2"))
    val books = listOf(Book("1", "book 1"), Book("2", "book 2"), Book("3", "book 3"))

    val signedInUsers = mutableListOf<SignedInUser>()
    val rentEvents = mutableListOf<RentEvent>()
    val returnEvents = mutableListOf<ReturnEvent>()
}
package com.example.demo.datafetchers

import com.example.demo.Resource
import com.example.demo.SignedInUser
import com.example.demo.generated.types.Book
import com.example.demo.generated.types.User
import com.netflix.graphql.dgs.*
import java.security.SecureRandom

@DgsComponent
class UsersDataFetcher {
    @DgsQuery
    fun me(@InputArgument token: String): User {
        val userId = Resource.signedInUsers.find { it.token == token }?.id ?: throw IllegalArgumentException(token)
        val user = Resource.users.find { it.id == userId }!!

        return User(user.id, user.name, listOf(), listOf())
    }

    @DgsData(parentType = "User")
    fun has(dfe: DgsDataFetchingEnvironment): List<Book> {
        val user = dfe.getSource<User>()

        return Resource.rentEvents
            .filter { it.userId == user.id }
            .groupBy { it.bookId }
            .map { rentEventsByBookId ->
                rentEventsByBookId.value.maxByOrNull { it.rentedAt }!!
            }
            .filter { rentEvent ->
                Resource.returnEvents.find { it.userId == user.id && it.bookId == rentEvent.bookId && it.returnedAt >= rentEvent.rentedAt } == null
            }
            .map { it.bookId }
            .fold(listOf<String>()) { bookIds, bookId -> bookIds + bookId }
            .let { bookIds -> Resource.books.filter { bookIds.contains(it.id) } }
            .map { Book(it.id, it.title) }
    }

    @DgsData(parentType = "User")
    fun history(dfe: DgsDataFetchingEnvironment): List<Book> {
        val user = dfe.getSource<User>()

        return Resource.rentEvents
            .filter { it.userId == user.id }
            .map { rentEvent -> Resource.books.find { it.id == rentEvent.bookId }!! }
            .map { Book(it.id, it.title) }
    }

    @DgsMutation
    fun signIn(@InputArgument id: String): String {
        val user = Resource.users.find { it.id == id } ?: throw IllegalArgumentException(id)

        val token = SecureRandom().nextDouble().toBigDecimal().toPlainString()
        Resource.signedInUsers.add(SignedInUser(user.id, token))

        return token
    }

    @DgsMutation
    fun signOut(@InputArgument token: String): Boolean {
        Resource.signedInUsers.find { it.token == token }?.id ?: throw IllegalArgumentException(token)
        Resource.signedInUsers.removeIf { it.token == token }

        return true
    }
}
package com.example.demo.datafetchers

import com.example.demo.RentEvent
import com.example.demo.Resource
import com.example.demo.ReturnEvent
import com.example.demo.generated.types.Book
import com.example.demo.generated.types.User
import com.netflix.graphql.dgs.*


@DgsComponent
class BooksDataFetcher {
    @DgsQuery
    fun books(): List<Book> {
        return Resource.books.map { Book(it.id, it.title) }
    }

    @DgsData(parentType = "Book")
    fun rentedBy(dfe: DgsDataFetchingEnvironment): User? {
        val book = dfe.getSource<Book>()

        val lastRentEvent = Resource.rentEvents.filter { it.bookId == book.id }.maxByOrNull { it.rentedAt }
        val lastReturnEvent = Resource.returnEvents.filter { it.bookId == book.id }.maxByOrNull { it.returnedAt }

        return lastRentEvent?.let {
            if (lastReturnEvent == null || lastRentEvent.rentedAt > lastReturnEvent.returnedAt) {
                Resource.users
                    .find { it.id == lastRentEvent.userId }
                    ?.let { User(it.id, it.name, listOf(), listOf()) }
            } else {
                null
            }
        }
    }

    @DgsMutation
    fun rentBook(@InputArgument token: String, @InputArgument id: String): Book {
        val userId = Resource.signedInUsers.find { it.token == token }?.id ?: throw IllegalArgumentException(token)
        val book = Resource.books.find { it.id == id } ?: throw IllegalArgumentException(id)

        val lastRentEvent = Resource.rentEvents.filter { it.bookId == book.id }.maxByOrNull { it.rentedAt }
        val lastReturnEvent = Resource.returnEvents.filter { it.bookId == book.id }.maxByOrNull { it.returnedAt }

        if (lastRentEvent != null && lastReturnEvent == null || lastRentEvent != null && lastReturnEvent != null && lastRentEvent.rentedAt > lastReturnEvent.returnedAt) {
            throw IllegalArgumentException(id)
        }

        Resource.rentEvents.add(RentEvent(userId, id, (System.currentTimeMillis() / 1000).toInt()))

        return Book(book.id, book.title)
    }

    @DgsMutation
    fun returnBook(@InputArgument token: String, @InputArgument id: String): Book {
        val userId = Resource.signedInUsers.find { it.token == token }?.id ?: throw IllegalArgumentException(token)
        val book = Resource.books.find { it.id == id } ?: throw IllegalArgumentException(id)

        val lastRentEvent = Resource.rentEvents.filter { it.bookId == book.id }.maxByOrNull { it.rentedAt }
        val lastReturnEvent = Resource.returnEvents.filter { it.bookId == book.id }.maxByOrNull { it.returnedAt }

        if (lastRentEvent == null || lastReturnEvent != null && lastRentEvent.rentedAt <= lastReturnEvent.returnedAt) {
            throw IllegalArgumentException(id)
        }

        Resource.returnEvents.add(ReturnEvent(userId, id, (System.currentTimeMillis() / 1000).toInt()))

        return Book(book.id, book.title)
    }
}

DGS framework のアノテーションの使い方についてはここに説明があります。また、ネストされたフィールドの処理についてはここに説明があります。ネストされたフィールドの場合、渡ってきた値をそのまま使うパターン、型を拡張してそこに値を持たせるパターン (スキーマにない値はレスポンスの時にドロップされる)、ローカルコンテキストに値を持たせるパターン、があるようです。今回は一番シンプルな渡ってきた値をそのまま使うパターンでやっています。実装中に、ネストされたフィールドをうまく取得できずに詰まったのですが、その時はスキーマでリストじゃないフィールドに対してリストを返してしまっていたので、型が合わずにドロップされていたのかも知れません。型を合わせたらちゃんと動くようになりました。

このコードに対して

{
  books {
    id
  }
}

のようなクエリを投げると

{
  "data": {
    "books": [
      {
        "id": "1"
      },
      {
        "id": "2"
      },
      {
        "id": "3"
      }
    ]
  }
}

のようなデータが返ってきます。さらにログインしていろいろ貸し借りした後に

query Me($token: String!) {
  me(token: $token) {
    id
    name
    has {
      id
      title
      rentedBy {
        id
      }
    }
    history {
      id
      title
      rentedBy {
        id
        name
        has {
          id
          title
          rentedBy {
            name
          }
        }
      }
    }
  }
}

のようなクエリを投げると

{
  "data": {
    "me": {
      "id": "1",
      "name": "user 1",
      "has": [
        {
          "id": "2",
          "title": "book 2",
          "rentedBy": {
            "id": "1"
          }
        }
      ],
      "history": [
        {
          "id": "1",
          "title": "book 1",
          "rentedBy": null
        },
        {
          "id": "2",
          "title": "book 2",
          "rentedBy": {
            "id": "1",
            "name": "user 1",
            "has": [
              {
                "id": "2",
                "title": "book 2",
                "rentedBy": {
                  "name": "user 1"
                }
              }
            ]
          }
        }
      ]
    }
  }
}

のようなデータが返ってきます。この時、下記の順で処理が実行されました。

  1. me of UsersDataFetcher
  2. has of UsersDataFetcher
  3. rentedBy of BooksDataFetcher
  4. history of UsersDataFetcher
  5. rentedBy of BooksDataFetcher
  6. rentedBy of BooksDataFetcher
  7. has of UsersDataFetcher
  8. rentedBy of BooksDataFetcher

ネストされているフィールドごとにちゃんと呼び出されているようでした。また、Query だけではなく Mutation の場合も同様に parentType に沿って処理が実行されます。has: [Book!]! のように必須の型はどうすればいいのかなと思ったのですが、とりあえず空のリストを設定しておいて、後からネストされたフィールドの方で値が設定されればその値で上書きされるようでした。

今回はそこまでやらなかったのですが、N+1 問題についてのドキュメントもあるので、対応することは可能そうだと思いました。

おわりに

今回は DGS framework を使って実際にコードを動かすことでどのような動作をするのか確認してみました。私がサーバーサイドの GraphQL ライブラリをちゃんと触ったことがないので、実際にどう動くのか想像できていなかったのですが、今回動かしてみて実装のイメージがついたのでよかったです。

脚注
  1. Open Sourcing the Netflix Domain Graph Service Framework: GraphQL for Spring Boot | by Netflix Technology Blog | Netflix TechBlog ↩︎

  2. Home - DGS Framework の Q&A の Is it production ready? の部分 ↩︎

  3. Netflix Open Sources Their Domain Graph Service Framework: GraphQL for Spring Boot ↩︎

  4. そのままでは動かなかったので設定が必要でした IntelliJ IDEA 2021.2以降に於けるSpring Boot DevToolsのLiveReload設定 ↩︎

  5. https://github.com/Netflix/dgs-examples-kotlin/blob/56e7371ffad312a9d59f1318d04ab5426515e842/build.gradle.kts ↩︎

  6. https://github.com/Netflix/dgs-codegen/issues?q=generateJava ↩︎

  7. https://github.com/Netflix/dgs-codegen/blob/c790be670061de7544981bc2c72d81de92900116/graphql-dgs-codegen-core/src/main/kotlin/com/netflix/graphql/dgs/codegen/CodeGen.kt のコードを追ったら Java でしか生成されていませんでした ↩︎