⚙️

GraphQL Kotlin + Ktorで始める、Pure KotlinなGraphQLサーバーの実装入門

2022/12/16に公開

この記事は、Money Forward Engineering 2 Advent Calendar 2022 16日目の投稿です。
15日目はHiroVodkaさんの「SimpleCovのテストカバレッジ計測について」でした。

はじめに

自分は先日、12/10(土)に開催されたKotlin Fest 2022に、「GraphQL Kotlin + Spring WebFluxで始める、なるべくPure KotlinなGraphQLサーバーの実装入門」というタイトルで登壇をさせていただきました。

セッションではタイトルの通り、Spring WebFluxにGraphQL Kotlinを導入する方法や実務を通して得た知見などを共有しました。

当日の質疑応答では、意外にもKtorでも利用できるのかという質問をいただきました。
その時は「Ktorでも利用できるがSpring WebFluxに比べると少しハードルがあるかもしれない」というフワッとした回答と、Twitterにて公式の実装例を紹介しました。

https://twitter.com/rokuosan_dev/status/1601392599062888449?s=20&t=OgqqbDbK84AQwgjCyeTa1A

https://twitter.com/yamachoo567/status/1601395755692228608?s=20&t=CiygBgC4s_eKDqurOnK3yA

しかし登壇後、実際にはどういった実装が必要なのか、Spring WebFluxと比較してどうなのか、といったあたりが気になり、実際に自分の手を動かしながら調査しました。
その調査を通じて理解が深まったので、この記事ではKtorにGraphQL Kotlinを導入する方法などを解説したいと思います。

前提

記事内に出てこない開発環境は以下の通りです。

Gradle:7.5.1
Kotlin:1.7.22
JVM:17.0.2 (Amazon.com Inc. 17.0.2+8-LTS)
Ktor:2.2.1
GraphQL Kotlin:7.0.0-alpha.0

また今回使用しているサンプルコードはこちらになります。
https://github.com/yamachoo/graphql-kotlin-ktor

Ktorのセットアップ

まずはプロジェクトの雛形をKtor Project Generatorで作成します。
設定はデフォルトでもOKですが、自分はこんな感じで作成しました!

また今回は単純にGraphQL KotlinとKtorを繋ぐことだけを目的としているので、プラグインは特に追加していません。

graphql-kotlin-serverの追加

Ktorのプロジェクト構成に沿ってgradle.propertiesにGraphQL Kotlinのバージョンを追加し、

gradle.properties
graphql_kotlin_version=7.0.0-alpha.0

graphql-kotlin-serverを依存関係に追加します。

build.gradle.kts
val graphql_kotlin_version: String by project

dependencies {
    implementation("com.expediagroup:graphql-kotlin-server:$graphql_kotlin_version")
}

Ktor用のGraphQLServerの実装について

GprahQL Kotlinのドキュメントを確認すると次のような文章が見つかります。

The top level object in the common package is GraphQLServer<T>. This class is open for extensions and requires that you specify the type of the http requests you will be handling.

  • For Spring Reactive we would define a GraphQLServer<ServerRequest>
  • For Ktor we would define a GraphQLServer<ApplicationRequest>

https://opensource.expediagroup.com/graphql-kotlin/docs/server/graphql-server#graphqlserver

つまり、KtorにGraphQL Kotlinを組み込むためには、GraphQLServer<ApplicationRequest>を利用する必要があります。
ちなみにGraphQLServerは以下のように定義されているので、使用するにはrequestParsercontextFactoryrequestHandlerが必要になります。

https://github.com/ExpediaGroup/graphql-kotlin/blob/cab3ccdc2608d45412c93568fce22a12212f89c6/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/execution/GraphQLServer.kt#L33-L37

requestParserについて

requestParserGraphQLRequestParserがinterfaceなので実装したclassが必要になるため、KtorGraphQLRequestParserというクラスを実装します。

KtorGraphQLRequestParser
class KtorGraphQLRequestParser(
    private val mapper: ObjectMapper
): GraphQLRequestParser<ApplicationRequest> {
    override suspend fun parseRequest(request: ApplicationRequest): GraphQLServerRequest? {
        try {
            val rawRequest = request.call.receiveText()
            return mapper.readValue(rawRequest, GraphQLServerRequest::class.java)
        } catch (e: IOException) {
            throw IOException("Unable to parse GraphQL payload.")
        }
    }
}

中身としては、Ktorのリクエストが受け取ったJSONをJacksonを用いてGraphQLServerRequestに変換しています。

contextFactoryについて

contextFactoryGraphQLContextFactoryがinterfaceなので実装したclassが必要になります。
ただ今回は最小限の実装をしたいだけなのでContextを使わず、単純に空のclassを定義しています。

KtorGraphQLContextFactory
class KtorGraphQLContextFactory : GraphQLContextFactory<ApplicationRequest>

もしContextも実装する場合はexampleの実装を参考にするか、公式ドキュメントのこのあたりが参考になります。

https://opensource.expediagroup.com/graphql-kotlin/docs/server/graphql-context-factory

requestHandlerについて

requestHandlerGraphQLRequestHandlerがclassなため、デフォルトのまま使用するならimportするだけで済みます。
ちなみにGraphQLRequestHandlerもopen classになっているので自分で拡張できます!

https://github.com/ExpediaGroup/graphql-kotlin/blob/cab3ccdc2608d45412c93568fce22a12212f89c6/servers/graphql-kotlin-server/src/main/kotlin/com/expediagroup/graphql/server/execution/GraphQLRequestHandler.kt#L47-L50

ただし、GraphQLRequestHandlerをインスタンス化するのには、GraphQLオブジェクトが必要なので以下で実装します。

GraphQLスキーマ

GraphQLスキーマをまとめて定義するためのKtorGraphQLSchemaを実装します。
(本来はqueriesにTopLevelObjectを渡すのですが、後で追加するので空で進めています。)

KtorGraphQLSchema
object KtorGraphQLSchema {
    private val config = SchemaGeneratorConfig(
        supportedPackages = listOf("com.yamachoo.graphql")
    )
    private val queries = listOf<TopLevelObject>()

    private val graphQLSchema = toSchema(
        config = config,
        queries = queries
    )

    fun getGraphQLObject(): GraphQL = GraphQL.newGraphQL(graphQLSchema)
        .valueUnboxer(IDValueUnboxer())
        .build()
}

ちなみに.valueUnboxer(IDValueUnboxer())がなぜ必要かは、以下に説明があります。

https://opensource.expediagroup.com/graphql-kotlin/docs/schema-generator/writing-schemas/scalars/#graphql-id

GraphQLサーバーの実装

それでは今まで作成してきたパーツを組み立て、GraphQLServer<ApplicationRequest>を実装します。
そして今回はGraphQLServer<ApplicationRequest>は公開せず、代わりにKtorGraphQLServerにはルーティングで使用するhandleのみを公開しています。

KtorGraphQLServer
object KtorGraphQLServer {
    private val mapper = jacksonObjectMapper()
    private val ktorGraphQLServer = getGraphQLServer()

    suspend fun handle(applicationCall: ApplicationCall) {
        val result = ktorGraphQLServer.execute(applicationCall.request)

        if (result != null) {
            val json = mapper.writeValueAsString(result)
            applicationCall.response.call.respond(json)
        } else {
            applicationCall.response.call.respond(HttpStatusCode.BadRequest, "Invalid request")
        }
    }

    private fun getGraphQLServer(): GraphQLServer<ApplicationRequest> {
        val requestParser = KtorGraphQLRequestParser(mapper)
        val contextFactory = KtorGraphQLContextFactory()
        val graphQL = KtorGraphQLSchema.getGraphQLObject()
        val requestHandler = GraphQLRequestHandler(graphQL)

        return GraphQLServer(requestParser, contextFactory, requestHandler)
    }
}

GraphQLのエンドポイントはPOSTメソッドになるので、次のようなルーティングを実装します。

Routing.kt
fun Application.configureRouting() {

    routing {
        post("/graphql") {
            KtorGraphQLServer.handle(this.call)
        }
    }
}

Queryの作成と登録

サーバーの整備は終わったので実際にGraphQLサーバーが動くのかを確認するため、サンプルのQueryを作成し、登録します。
Queryを継承したHelloQueryを追加し、TopLevelObjectとしてKtorGraphQLSchemaqueriesに追加します。

HelloQuery
object HelloQuery : Query {
    fun hello() = "Hello World!"
}
KtorGraphQLSchema
object KtorGraphQLSchema {
    // 省略
    private val queries = listOf(
        TopLevelObject(HelloQuery)
    )
}

サーバーの起動

以上で準備が整ったので、サーバーを起動します。

./gradlew run

> Task :run
2022-12-15 16:27:01.072 [main] INFO  Application - Autoreload is disabled because the development mode is off.
2022-12-15 16:27:01.214 [main] INFO  Application - Application started in 0.161 seconds.
2022-12-15 16:27:01.295 [DefaultDispatcher-worker-1] INFO  Application - Responding at http://0.0.0.0:8080

この時点ではPlaygroundが実装されていないので、Postmanで試しに叩いてみます。

ちゃんとQueryが読み込まれています🎉

Playgroundの実装

ただ毎回、Postmanを使うのも面倒なので、graphql-kotlin-spring-serverだと標準で用意されているPlaygroundを設定したいと思います。

やることは2つです。

  • Playground用のルーティングを設定する
  • Playground用のHTMLを用意する

以下のようにルーティングとHTMLを返却するロジックを書けばOKです。

Routing.kt
fun Application.configureRouting() {

    routing {
        // 省略
        get("/playground") {
            this.call.respondText(buildPlaygroundHtml("graphql", "subscriptions"), ContentType.Text.Html)
        }
    }
}

private fun buildPlaygroundHtml(graphQLEndpoint: String, subscriptionsEndpoint: String) =
    Application::class.java.classLoader.getResource("graphql-playground.html")?.readText()
        ?.replace("\${graphQLEndpoint}", graphQLEndpoint)
        ?.replace("\${subscriptionsEndpoint}", subscriptionsEndpoint)
        ?: throw IllegalStateException("graphql-playground.html cannot be found in the classpath")

また忘れずにresources配下に以下のHTMLを追加します。

https://github.com/ExpediaGroup/graphql-kotlin/blob/6.x.x/examples/server/ktor-server/src/main/resources/graphql-playground.html

このHTMLにはgraphql-playground-reactを読み込むJSや初期描画が終わるまでロゴマークを表示し、ロードが終わったら画面を差し替えるといった簡単な処理が書かれています。

その他の主要な機能について

ここから先は軽く調べた程度なので、リンクなどを中心に紹介させていただきます🙏

SDLを外部に公開するAPIを実装する場合

外部向けにSDLを公開する場合は、GraphQLSchemaprintメソッドを使ってスキーマを文字列として返すルーティングを設定します。

Routing.kt
fun Application.configureRouting() {

    routing {
        // 省略
        get("/sdl") {
            call.respondText(KtorGraphQLSchema.graphQLSchema.print())
        }
    }
}

Data Loaders

Data Loadersは以下のように、dataLoaderRegistryFactoryを実装し、GraphQLRequestHandlerの引数に追加してあげれば利用できます。

https://github.com/ExpediaGroup/graphql-kotlin/blob/186e0e2a83dc59556cd7a5e302f378bfdc6bdf32/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/schema/dataloaders/BookDataLoader.kt#L25-L38

https://github.com/ExpediaGroup/graphql-kotlin/blob/186e0e2a83dc59556cd7a5e302f378bfdc6bdf32/examples/server/ktor-server/src/main/kotlin/com/expediagroup/graphql/examples/server/ktor/KtorGraphQLServer.kt#L38-L48

ドキュメントだと、このあたりが参考になりそうです。

https://opensource.expediagroup.com/graphql-kotlin/docs/server/data-loader/

Subscriptions

一方でGraphQLのSubscriptionsは公式のKtorのexampleには実装がないです。
(自分も今回きちんと確認して、初めて知りました👀)

そのためgraphql-kotlin-spring-serversubscriptionsを参考に、graphql-javaで実装する必要があるかもです。

さいごに

以上でKtorでのGraphQL Kotlinの導入方法についての解説はおしまいです。
この記事が少しでも皆さんのKtorでGraphQL Kotlinを使用する際のハードルを下げ、実装の解像度を上げることができれば幸いです。

ちなみに自分はこの調査を通じて、普段の業務で使用しているgraphql-kotlin-spring-serverの有り難みをひしひしと感じました。
特にSpringのDIの仕組みに乗っかり、QueryやMutationなどを自動で登録できるあたりは当たり前だと思っていたのですが、実は本当に便利な機構なのだと実感しました。
(※この部分はKtorでも自分で実装してあげればできるとは思いますが)

一方でKtorは自分でちゃんと組み込んでいく感が強く、ライブラリの挙動を理解するのにとても役立ち、GraphQL Kotlinというライブラリの理解度を一段上げられました。
そしてKtorのv2で出たCustom pluginsを上手く使うことができれば、KtorにGraphQL Kotlinを組み込むのも楽になるのではないかなと妄想していますw

また今後、「GraphQL Kotlin + Ktor」は個人開発などで使用するチャンスを見つけて、さらに知見を深められたらと思います!


現在、自分の所属するマネーフォワード 名古屋開発拠点ではエンジニアを募集してます!
サーバーサイドKotlinはもちろん、他にも挑戦的な取り組みをしてます。
まずは気軽にカジュアル面談からでも歓迎ですので、ご応募お待ちしてます👉求人ページ

Discussion