Spring+GraphQLからKtor+GraphQLに移行する

はじめに

これは Ubie Advent Calendar 2019 - Qiita の7日目の記事です。

6日目は かみな/Kaito Minatoya@Ubie (@kamina_zzz) | Twitter による 地球上の Kubernetes ユーザーは絶対使うべきツールたちを紹介するよ - Qiita でした。

想定読者

  • Spring + Kotlin + GraphQLを使っていて、Ktorに移行したくなっちゃった人 (需要🤔)

本題

UbieでAdvent CalendarをKtor縛りで書くぞ!となりました。じゃあ自分は何を書こうかなーって考えてたらGraphQLに決まってるじゃん?と振られたので、UbieではKtor+GraphQLは使ってないのですが、もしSpringのアプリケーションである https://github.com/ubie-inc/kotlin-graphql-sample をKtorに移し替えるとしたら何をするのかを調査しつつ、記事にすることにしました。

この記事では一旦動くところまでを目指します。

生かすコード、諦めるコード

フレームワークを載せ替えるとしてもそこまでガッツリ書き直す訳ではなく、生かすことが出来るコードが多々あります。今回は出来うる限りコードを生かす方向にしました。そのため、graphql-java-toolsを同様に採用します。

github.com

これを採用するため、domainquery resolver, resolver はSpringのアノテーションを消すだけで再利用出来ます。

import com.coxautodev.graphql.tools.GraphQLResolver
- import org.springframework.stereotype.Component

- @Component
class DiseaseResolver(...) : GraphQLResolver<Disease> {

jdbc も同様に修正するのですが、Springのjdbcを使っているため、思った以上にガッツリ修正が入ります。この辺りはKtorで利用するDBアクセスライブラリ次第になります。ここではあまり関係ないので、省略します。

スキーマファイルもSpringの時同様にresources直下に置きます。

再利用できるコードもありますが、完全に捨てるコードもあります。

例えば、JdbcConfigKotlinGraphQLSampleApplication これらはSpringのための設定なので再利用は出来ません。ただこのようなコードだけなのでほとんどのコードが再利用することが可能となります。

新規実装

IntelliJのウィザードで作成されるここから、新規の実装をしていきます。

fun main(args: Array<String>): Unit = io.ktor.server.netty.EngineMain.main(args)

@Suppress("unused") // Referenced in application.conf
@kotlin.jvm.JvmOverloads
fun Application.module(testing: Boolean = false) {
}

最初にRoutingの定義です。 GraphQLなので /graphql にコードを書いていきます。GraphQLは GETとPOSTのサポートをしているので、一応、両方とものAPIを書きます。

fun Application.module(testing: Boolean = false) {
    routing {
        get("/graphql") {
            TODO()
        }

        post("/graphql") {
            TODO()
        }
    }
}

最初にリクエストをパースします。リクエストはこんなJSONです。

{
    "query":"{\n  drug(id:\"id\") {\n    name\n  }\n}",
    "variables":null,
    "operationName":null
}

これをこのままdata classにします。

data class GraphQLRequest(
    val query: String = "",
    val operationName: String? = "",
    val variables: Map<String, Any>? = mapOf()
)

Ktorでデフォルトで使える、 ObjectMapper を使ってパース処理を書くと。

fun Application.module(testing: Boolean = false) {
    routing {
        get("/graphql") {
            val stringRequest = call.receive<String>()
            val mapper = ObjectMapper()
            val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        }

        post("/graphql") {
            val stringRequest = call.receive<String>()
            val mapper = ObjectMapper()
            val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        }
    }
}

何度も同じ処理を二箇所に書くのは辛いので、 GraphQLController を作成します。

class GraphQLController {
    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        TODO()
    }
}

これを使ってコード量減らします。

fun Application.module(testing: Boolean = false) {
    // Controller
    val graphQLController = GraphQLController()

    routing {
        get("/graphql") {
            graphQLController.handleGraphQL(call)
        }

        post("/graphql") {
            graphQLController.handleGraphQL(call)
        }
    }
}

さて次は実際に graphql-java を利用してコードです。

    val filePath = "kotlingraphqlsample.graphqls"
    val graphQLSchema = SchemaParserBuilder()
        .file(filePath)
        .resolvers(
            listOf(diseaseResolver, 
                DiseaseQueryResolver(diseaseService),
                // 他のResolverもここに記述する
                ))
        .build()
        .makeExecutableSchema()
    val graphQL = GraphQL.newGraphQL(graphQLSchema).build()

この graphQL インスタンスをController内で使い、それぞれのResolverにアクセスしていきます。graphQL インスタンスはDI使うなり、コンストラクタに差し込むなり、Controller内でインスタンス化するなり、好きなようにControllerに差し込みます。

    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        val context = ConcurrentHashMap<String, Any>()
        // graphQLは上でインスタンス化したもの
        val result = graphQL.execute(
            ExecutionInput.newExecutionInput()
                .query(request.query)
                .operationName(request.operationName)
                .variables(request.variables ?: emptyMap())
                .context(context)
        )
        TODO()
    }

最後にresultをJSONにして、返します。

    suspend fun handleGraphQL(call: ApplicationCall) {
        val stringRequest = call.receive<String>()
        val mapper = ObjectMapper()
        val request = mapper.readValue(stringRequest, GraphQLRequest::class.java)
        val context = ConcurrentHashMap<String, Any>()
        val result = graphQL.execute(
            ExecutionInput.newExecutionInput()
                .query(request.query)
                .operationName(request.operationName)
                .variables(request.variables ?: emptyMap())
                .context(context)
        )
        val json = mapper.writeValueAsString(result.toSpecification())
        call.respondText { json }
    }

これでAPIは完成。APIアクセスを簡単にするために、GraphiQLも導入します。 graphiql/packages/examples/graphiql-cdn at master · graphql/graphiql · GitHub この辺りのサンプルコードをそのまま拝借し、index.htmlとしてプロジェクト直下に置きます。

fun Application.module(testing: Boolean = false) {
    // Controller
    val graphQLController = GraphQLController()

    routing {
        static("/graphiql") {
            // GraphiQL
            default("index.html")
        }

        get("/graphql") {
            graphQLController.handleGraphQL(call)
        }

        post("/graphql") {
            graphQLController.handleGraphQL(call)
        }
    }
}

Runボタン押して、実際にGraphiQL http://localhost:8080/graphiql にアクセスしてみます。

f:id:shiraji:20191207224439p:plain

見えるようになりました。yay!

終わりに

いかがでしたでしょうか?(一回やってみたかった)

やってみて思ったんですが、Springですでに動いているのであれば、今のところKtorに移す必要ないなーと言うのが感想です。

今回はこんなレポジトリのコードを参考にしました。

あまりアクティブな開発がされていないので、今後どうなるのかわからないですが、もうちょっとKtorのGraphQL周りのプラグインが出来たら良いなーと。

自分で作れってことか 🙃