GraphQL Kotlin + Ktorで始める、Pure KotlinなGraphQLサーバーの実装入門
この記事は、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にて公式の実装例を紹介しました。
しかし登壇後、実際にはどういった実装が必要なのか、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
また今回使用しているサンプルコードはこちらになります。
Ktorのセットアップ
まずはプロジェクトの雛形をKtor Project Generatorで作成します。
設定はデフォルトでもOKですが、自分はこんな感じで作成しました!
また今回は単純にGraphQL KotlinとKtorを繋ぐことだけを目的としているので、プラグインは特に追加していません。
graphql-kotlin-serverの追加
Ktorのプロジェクト構成に沿ってgradle.properties
にGraphQL Kotlinのバージョンを追加し、
graphql_kotlin_version=7.0.0-alpha.0
graphql-kotlin-server
を依存関係に追加します。
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>
つまり、KtorにGraphQL Kotlinを組み込むためには、GraphQLServer<ApplicationRequest>
を利用する必要があります。
ちなみにGraphQLServer
は以下のように定義されているので、使用するにはrequestParser
、contextFactory
、requestHandler
が必要になります。
requestParserについて
requestParser
はGraphQLRequestParser
がinterfaceなので実装したclassが必要になるため、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について
contextFactory
はGraphQLContextFactory
がinterfaceなので実装したclassが必要になります。
ただ今回は最小限の実装をしたいだけなのでContextを使わず、単純に空のclassを定義しています。
class KtorGraphQLContextFactory : GraphQLContextFactory<ApplicationRequest>
もしContextも実装する場合はexampleの実装を参考にするか、公式ドキュメントのこのあたりが参考になります。
requestHandlerについて
requestHandler
はGraphQLRequestHandler
がclassなため、デフォルトのまま使用するならimportするだけで済みます。
ちなみにGraphQLRequestHandler
もopen classになっているので自分で拡張できます!
ただし、GraphQLRequestHandler
をインスタンス化するのには、GraphQL
オブジェクトが必要なので以下で実装します。
GraphQLスキーマ
GraphQLスキーマをまとめて定義するためのKtorGraphQLSchema
を実装します。
(本来はqueriesにTopLevelObject
を渡すのですが、後で追加するので空で進めています。)
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())
がなぜ必要かは、以下に説明があります。
GraphQLサーバーの実装
それでは今まで作成してきたパーツを組み立て、GraphQLServer<ApplicationRequest>
を実装します。
そして今回はGraphQLServer<ApplicationRequest>
を公開せず、代わりにKtorGraphQLServer
にはルーティングで使用するhandle
のみを公開しています。
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メソッドになるので、次のようなルーティングを実装します。
fun Application.configureRouting() {
routing {
post("/graphql") {
KtorGraphQLServer.handle(this.call)
}
}
}
Queryの作成と登録
サーバーの整備は終わったので実際にGraphQLサーバーが動くのかを確認するため、サンプルのQueryを作成し、登録します。
Query
を継承したHelloQueryを追加し、TopLevelObject
としてKtorGraphQLSchema
のqueries
に追加します。
object HelloQuery : Query {
fun hello() = "Hello World!"
}
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です。
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を追加します。
このHTMLにはgraphql-playground-reactを読み込むJSや初期描画が終わるまでロゴマークを表示し、ロードが終わったら画面を差し替えるといった簡単な処理が書かれています。
その他の主要な機能について
ここから先は軽く調べた程度なので、リンクなどを中心に紹介させていただきます🙏
SDLを外部に公開するAPIを実装する場合
外部向けにSDLを公開する場合は、GraphQLSchema
のprint
メソッドを使ってスキーマを文字列として返すルーティングを設定します。
fun Application.configureRouting() {
routing {
// 省略
get("/sdl") {
call.respondText(KtorGraphQLSchema.graphQLSchema.print())
}
}
}
Data Loaders
Data Loadersは以下のように、dataLoaderRegistryFactory
を実装し、GraphQLRequestHandler
の引数に追加してあげれば利用できます。
ドキュメントだと、このあたりが参考になりそうです。
Subscriptions
一方でGraphQLのSubscriptionsは公式のKtorのexampleには実装がないです。
(自分も今回きちんと確認して、初めて知りました👀)
そのためgraphql-kotlin-spring-server
のsubscriptionsを参考に、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