⚛️

Kotlin + DGS で始めるスキーマファーストな GraphQL サーバー開発

2024/03/08に公開

こんにちは!アルダグラムでエンジニア兼PdMをしている @sukechannnn です!

昨年から実装を開始した「KANNAレポート」では、バックエンドのGraphQLサーバーを Kotlin + SpringBoot + DGS で開発しています。

Kotlin × SpringBoot で使える GraphQL フレームワークはけっこう選択肢があり色々迷った末に DGS を選んだのですが、スキーマファーストな開発体験がとても良いです。

この記事では DGS (Netflix DGS Framework) の特徴と、便利な機能をご紹介します!

Netflix DGS Framework とは

Netflix DGS (Domain Graph Service) Framework は、Netflixによって開発されているGraphQLのフレームワークです。

https://netflix.github.io/dgs/

Netflix は DGS をマイクロサービスでの利用を念頭に作ったようですが、普通にモノリシックなサーバーとしても使えます。

GraphQL Java をベースに作られていて、Spring Boot でスキーマベースな開発をするのに適しています。
Kotlin にも対応してて、Java よりも便利な機能が追加されています!

DGS の良いところ

DGSを選んだ主な理由は以下の通りです。

  • 公式ドキュメントがとても充実している
  • スキーマファーストで開発をするための支援機能がいい感じ
  • Relay-style cursor pagination が簡単に定義できる
  • テスト実行がしやすい便利機能がある
  • 外部の GraphQL サーバーにアクセスするクライアントコードを graphql.schema から自動生成できる
  • Netflix が本番で使っていて実績がある

特に良かったところをそれぞれ書いていきます。

公式ドキュメントがとても充実している

改めて貼りますが、公式ドキュメントがとても充実している、かつ分かりやすいです。

https://netflix.github.io/dgs/getting-started/

特に私は Kotlin も Spring Boot も初めて触る状態からのスタートだったので、GraphQL フレームワークの公式ドキュメントが充実していたことはとても大きかったです。

スキーマファーストで開発をするための支援機能がいい感じ

DGS では、GraphQLスキーマに定義したタイプから Java or Kotlin のクラスを生成して、実装に利用することができます。

Kotlin のクラスを生成する場合の build.gradle.kts の記述例です。

val generateGraphQLKotlin = tasks.register("generateGraphQLKotlin", GenerateJavaTask::class.java) {
    schemaPaths = mutableListOf("$projectDir/src/main/resources/schema/schema.graphqls")
    packageName = "com.sample.app.generated.graphql"
    language = "kotlin"
}

以下のように書いた GraphQL スキーマが、

type Query {
  message(id: ID!): Message
}

type Message {
  id: ID!
  title: String
  """メッセージのボディです"""
  body: String
}

次のような Kotlin のクラスとして出力されます。

public data class Message(
    @JsonProperty("id")
    public val id: String,
    @JsonProperty("title")
    public val title: String,
    /**
     * メッセージのボディです
     */
    @JsonProperty("body")
    public val body: String,
) {
    public companion object
}

このクラスを利用したクエリの実装例は、以下のような感じです。
Java な GraphQL では DataFetcher という文言がよく使われますが、Query と書いても普通に動きます。

@DgsComponent
class MessageQuery(
    private val findMessageService: FindMessageService,
) {
    @DgsQuery
    fun message(@InputArgument id: String): Message? {
        val message = findMessageService.execute(id)
        return Message(
            id = message.id,
            title = message.title,
            body = message.body,
        )
    }
}

そして、まだ Experimental ですが、Kotlin の場合はより洗練されたデータクラスを生成することもできます。

https://netflix.github.io/dgs/advanced/kotlin-codegen/#data-classes

generateKotlinNullableClasses = true を追加すると、GraphQL 特有の null の取り回しを扱うことができます。

val generateGraphQLKotlin = tasks.register("generateGraphQLKotlin", GenerateJavaTask::class.java) {
    schemaPaths = mutableListOf("$projectDir/src/main/resources/schema/schema.graphqls")
    packageName = "com.sample.app.generated.graphql"
    language = "kotlin"
    generateKotlinNullableClasses = true // 追加
}

GraphQL ではクライアント側で取得する field を自由に選択できます。つまり、Kotlin でデータを返そうとした時にデータクラスの一部のプロパティが欠落した状態が発生し得ます。そのため、単純に Kotlin のデータクラスの全てのプロパティを non-null として定義することができません。

generateKotlinNullableClasses = true を追加すると、そこらへんの取り回しを考慮したクラスを生成してくれます。その場合は、波括弧 {} で囲った形でレスポンスを返す必要があります。

@DgsComponent
class MessageQuery(
    private val findMessageService: FindMessageService,
) {
    @DgsQuery
    fun message(@InputArgument id: String): Message? {
        val message = findMessageService.execute(id)
        return Message(
            id = { message.id },
            title = { message.title },
            body = { message.body },
        )
    }
}

また、Intellij のプラグインがあって、スキーマから簡単に実装を辿れるようになっています。
けっこう便利です!

https://plugins.jetbrains.com/plugin/17852-dgs

Relay-style cursor pagination が簡単に定義できる

スキーマベースで GraphQL を開発する時に面倒なのが Pagination ではないでしょうか。

基本的には Relay-style cursor pagination を実装することになると思いますが、そのまま定義すると以下のような定義が必要になります。

type Query {
  messages: MessageConnection
}

type MessageConnection {
  edges: [MessageEdge]
  pageInfo: PageInfo
}

type MessageEdge {
  node: Message
  cursor: String
}

type Message {
  id: ID!
  title: String
  body: String
}

type PageInfo {
  startCursor: String
  endCursor: String
  hasPreviousPage: Boolean
  hasNextPage: Boolean
}

上記の edge や node を毎回書くのはけっこう大変だし、同じような定義が増えてスキーマが見づらくなりますよね。

DGS を使うと @connection ディレクティブを使って、以下のように書くことができます。

type Query {
  messages: MessageConnection
}

type Message @connection {
  id: ID!
  title: String
  body: String
}

type 名の後に @connection を付けることで、自動で Relay-style cursor pagination に必要なスキーマを追加してくれます。
これのおかげで本質的なスキーマ定義に集中でき、かつ後から見ても分かりやすくなります 👏

次のような形で実装に利用することができます。

import com.sample.app.generated.graphql.types.Message
import com.netflix.graphql.dgs.DgsComponent
import com.netflix.graphql.dgs.DgsQuery
import com.netflix.graphql.dgs.InputArgument
import graphql.relay.Connection
import graphql.relay.SimpleListConnection

@DgsComponent
class MessagesQuery(
    private val listMessagesService: ListMessagesService,
) {
    @DgsQuery
    fun messages(env: DataFetchingEnvironment): Connection<Message> {
        val messages = listMessagesService.execute()
        return SimpleListConnection(messages)[env]
    }
}

ただ、この実装だと、全てのデータをDBから取得してページネーションする実装になっています。
DBから必要なデータのみを取得する場合のページネーションの実装を、おまけで記事の一番下に書いたので参考まで 🙏

テスト実行がしやすい便利機能がある

テスト時に簡単に実装した GraphQL を実行できるように DgsQueryExecutor というクラスが実装されていて、これを使うと GraphQL を実行した後の結果を検証するところまでテストすることができます(サンプルコード)。

@SpringBootTest
class MessageQueryTest {
    @Autowired
    lateinit var dgsQueryExecutor: DgsQueryExecutor

    @MockBean
    lateinit var findMessageService: FindMessageService

    private val id = UUID.randomUUID()

    @BeforeEach
    fun before() {
        Mockito.`when`(findMessageService.execute(id)).thenAnswer {
            Message(id, "mock title", "test body")
        }
    }

    @Test
    fun message() {
        val messageTitle: List<String> = dgsQueryExecutor.executeAndExtractJsonPath("""
                    {
                        message {
                            id
                            title
                            body
                        }
                    }
                """.trimIndent(), "data.message.title")

        assertThat(messageTitle).isEqualTo("mock title")
    }
}

executeAndExtractJsonPath の書き方にちょっとクセがあるのですが、慣れればとても便利です。

外部の GraphQL サーバーにアクセスするクライアントコードを graphql.schema から自動生成できる

DGS は GraphQL でマイクロサービスを構成することを念頭に作られているため、他 GraphQL サーバーにアクセスするクライアントコードを自動生成する機能があります。

※ js の GraphQL Code Generator のように Introspection query を使ってネットワーク経由でいい感じにやってくれる仕組みはないので、graphql.schema ファイルを食わせる必要があります

https://netflix.github.io/dgs/advanced/java-client/

型安全に他の GraphQL サーバーにアクセスするコードが書けるので便利なのですが、書き方にけっこうクセがあります(そしてドキュメントの通りに書いても上手く動かず、以下のようになる)。

val graphQLQueryRequest: GraphQLQueryRequest = GraphQLQueryRequest(
    TicksGraphQLQuery.Builder()
        .first(first)
        .after(after)
        .build(),
    TicksConnectionProjectionRoot<TicksProjectionRoot<BaseSubProjectionNode<Tick, DgsConstants.QUERY>, BaseSubProjectionNode<Tick, DgsConstants.QUERY>>>()
        .node()
        .date()
        .route()
        .name()
        .votes()
        .starRating()
        .parent()
        .grade()
)

val query = graphQLQueryRequest.serialize()
val response = gqlClient.executeQuery(query)
val ticks = response.extractValueAsObject(
    "ticks.nodes",
    object : TypeRef<List<Tick>>() {}
).first()

と思っていたら、Kotlin で GQL っぽい感じで書けるみたいですね!まだ試せていないので、今度実装してみようと思います。

https://netflix.github.io/dgs/advanced/kotlin-codegen/#query-projections

val query: String = DgsClient.buildQuery {
    series(title = "Stranger Things") {
        actors {
            name 
            age
        }
        releaseDate
        endDate
    }
}

さいごに

DGS + Kotlin を使ったスキーマファーストな GraphQL の開発について紹介しました。
開発体験が良くて気に入っているので、魅力が伝わったら嬉しいです!

DGS は今も活発に開発されており、Kotlin のサポートもどんどん強化されています。
今後の進化も楽しみです!

おまけ: Pagination の実装について

↑で @connection ディレクティブを使うと Relay-style cursor pagination が簡単に書けると書きましたが、それはスキーマの話で、なぜかまともに使える Connection の実装が DGS にも GraphQL Java にもありません。

一応 graphql.relay.SimpleListConnection があるのですが、これは DB から全件取得する前提の実装になっているので、本番環境で使うのは難しいと思います。

ということで、GraphQL-Ruby を参考に offset ベースの Connection 用ロジックを自前で実装しました。offset ベースにすることで、柔軟な並び替え機能を実装することができます(GraphQL-Ruby の作者の議論)。

パフォーマンス面の懸念はありつつ、GraphQL-Ruby を利用している多くのサービスでは動いているので、いったんこれでGoしています。

/**
 * graphql.relay.SimpleListConnection を参考に実装している
 */
class CustomListConnection<R>(
    private val entityData: List<R>?,
    private val offset: Int,
    private val first: Int,
) {
    companion object {
        /**
         * cursor を元に DB クエリで利用する offset を返す
         * GraphQL の cursor は 0 始まりだが、SQL の offset は 1 始まりなので +1 している
         */
        fun getDbOffset(cursor: String?): Int {
            if (cursor.isNullOrEmpty()) return 0

            return runCatching { Base64.getDecoder().decode(cursor) }
                .fold(
                    onFailure = {
                        throw IllegalArgumentException(
                            String.format(
                                Locale.US,
                                "The cursor is not in base64 format : '%s'",
                                cursor,
                            ),
                            it,
                        )
                    },
                    onSuccess = {
                        String(it, StandardCharsets.UTF_8).toInt() + 1
                    },
                )
        }

        /**
         * hasNextPage を判定するために、first に1を足した値で SQL の limit を設定する
         */
        fun getLimit(first: Int): Int = first + 1

        /**
         * 引数で与えられた offset 数値を Base64 エンコードして cursor として返す
         * GraphQL-Ruby を参考に、数値のみで cursor を組み立てている
         */
        fun createCursor(offset: Int): String {
            val bytes = (offset.toString()).toByteArray(StandardCharsets.UTF_8)
            return Base64.getEncoder().encodeToString(bytes)
        }
    }

    /**
     * Relay connection type のレスポンスを組み立てて返す
     */
    fun <T> getResponse(responseBuilder: (R) -> T): Connection<T> {
        val responseData = entityData?.take(first)?.map(responseBuilder)

        val edges = buildEdges(responseData)
        if (edges.isEmpty()) {
            return emptyConnection()
        }

        val hasNextPage = (entityData?.size ?: 0) > first
                val hasPreviousPage = offset != 0
        val firstEdge = edges[0]
        val lastEdge = edges[edges.size - 1]

        val pageInfo: PageInfo = DefaultPageInfo(
            firstEdge.cursor,
            lastEdge.cursor,
            hasPreviousPage,
            hasNextPage,
        )

        return DefaultConnection(
            edges,
            pageInfo,
        )
    }

    private fun <T> buildEdges(data: List<T>?): List<Edge<T>> {
        data ?: return emptyList()
        val edges: MutableList<Edge<T>> = mutableListOf()
        var idx = offset
        for (dataObj in data) {
            edges.add(DefaultEdge(dataObj, DefaultConnectionCursor(createCursor(idx))))
            idx++
        }
        return edges
    }

    private fun <T> emptyConnection(): Connection<T> {
        val pageInfo: PageInfo = DefaultPageInfo(null, null, false, false)
        return DefaultConnection(ImmutableKit.emptyList(), pageInfo)
    }
}

GraphQL Query のクラスでは次のように利用します。

@DgsComponent
class MessagesQuery(
    private val listMessagesService: ListMessagesService,
) {

    @DgsQuery
    fun messages(
        @InputArgument after: String?,
        @InputArgument first: Int,
    ): Connection<Message> {
        val offset = CustomListConnection.getDbOffset(after)
        val limit = CustomListConnection.getLimit(first)

        val messages = listMessagesService(offset, limit)

        return CustomListConnection(
            messages,
            offset,
            first,
        ).getResponse { message ->
            Message(
                id = message.id,
                title: message.title,
                body: message.body,
            )
        }
    }
}

これで、必要な分だけDBからデータを取得して返すページネーションを実装できました!

お試しあれ〜

アルダグラム Tech Blog

Discussion