アプリ開発備忘録

PlayStationMobile、Android、UWPの開発備忘録

JVMのGraphQLのサーバー側の実装に使うライブラリを選定する ~ graphql-javaの実装方法

前回の記事
アプリのバックエンドをREST APIからGraphQLに移行する事を検討する。 - アプリ開発備忘録

GraphQLのサーバー側の実装に使うライブラリについて考える

前回の記事を踏まえ、まずはサーバー側で使用するライブラリから検討していきます。

主なライブラリ紹介

まずは公式で紹介されている実装はこちらにあります。
https://graphql.org/code/#java-kotlin

シンプルであり、他のライブラリも主にこちらをコアとして動いているライブラリです。
https://github.com/graphql-java/graphql-java

Netflixが開発しているSpring Bootの実装です。
こちらはスキーマから型を生成する機能もあります。
https://github.com/netflix/dgs-framework

KtorとSpringに対応している実装です。
こちらはコードからスキーマの生成に対応しています。
https://github.com/ExpediaGroup/graphql-kotlin/

現状を加味してのライブラリ選定

今使用しているサーバーとしては、JAX-RS(Jakarta)の実装であるJerseyを使用していて、Springフレームワークは使用していません。
既存のセッションの仕組みや実装を利用したい為、これの上にGraphQLを載せようと思っています。

そのため、 graphql-java を直接使うのが今の実装へ適用させる為のカスタマイズ性が高く、適切だと判断しました。
それから graphql-kotlin はコードからスキーマを生成しますが、以下の点からこれはちょっと違うなと思いました。

  • スキーマを正として、コードを生成する方が適切と考えている
  • GraphQLを理解し、その上で生成されたコードで実装してほしいと考えている

graphql-javaの実装

使い方を調べる

以下が今回使用するサンプルのスキーマ定義になります。
scalarとしてJvmのInt型の範囲が返ってくる事や、Idを定義しました。

テンプレートコード

# schema.graphqls
schema {
    query: Query
}

type Query {
    user(userId: UserId!): User!
}

type User {
    id: UserId!
    age: JvmInt!
    name: String!
    post: [Post]!
}

type Post {
    id: PostId!
    text: String!
}

# scalar.graphqls
scalar JvmInt
scalar PostId
scalar UserId

こちらが基本的な部分になります。値を返すところは別で説明します。

fun main(args: Array<String>) {
    // ファイルを読み込む
    val sdlList = listOf(
        "schema.graphqls",
        "scalar.graphqls",
    ).map {
        Unit.javaClass.classLoader.getResourceAsStream(it)!!
            .bufferedReader()
            .readText()
    }

    val graphQLSchema: GraphQLSchema = SchemaBuilder().buildSchema(sdlList) // 後述のコード
    val gql = GraphQL.newGraphQL(graphQLSchema)
        .queryExecutionStrategy(AsyncExecutionStrategy()) // 並列にフィールドを取得する基本的な実行戦略
        .build()

    // クライアントから呼ぶクエリ
    val result = gql.execute(
        ExecutionInput.newExecutionInput()
            .query(
                """
                    {
                        user(userId: 1) {
                            name
                        }
                    }
                """.trimIndent()
            )
    )

    println("=====result=====")
    println(result)

    println("エラーを出力する")
    result.errors.map {
        when(it) {
            is ExceptionWhileDataFetching -> {
                throw it.exception
            }
            is ValidationError -> {
                throw IllegalStateException(it.message)
            }
            is NonNullableFieldWasNullError -> {
                throw IllegalStateException(it.message)
            }
            else -> TODO("not handle $it")
        }
    }

    println("結果を出力する")
    jacksonObjectMapper()
        .writerWithDefaultPrettyPrinter()
        .let { jackson ->
            println(jackson.writeValueAsString(result.getData<String>()))
        }
}

scalar型やスキーマで定義したtypeを実装していきます。

data class User(
    val userId: Long,
    val name: String,
)

class SchemaBuilder {
    fun buildSchema(sdlList: List<String>): GraphQLSchema {
        val option = ParserOptions.newParserOptions()
            .captureSourceLocation(true) // どのスキーマのIndexで問題が起きたかを出力する
            .maxTokens(ParserOptions.MAX_QUERY_TOKENS) // 無限にネストしたリクエストを抑制する。今回は外部からの攻撃ついては主として取り上げない。
            .build()

        // 複数のファイルをマージする
        val typeRegistry: TypeDefinitionRegistry = SchemaParser().parse(
            StringReader(sdlList.first()),
            option,
        ).also { registry ->
            val mergeItems = sdlList.drop(1).map {
                SchemaParser().parse(
                    StringReader(it),
                    option,
                )
            }
            mergeItems.map {
                registry.merge(it)
            }
        }

        return SchemaGenerator()
            .makeExecutableSchema(
                typeRegistry,
                RuntimeWiring.newRuntimeWiring()
                    // このTypeで値を返す所を実装する
                    .type(
                        TypeRuntimeWiring.newTypeWiring("Query") // typeを指定する
                            .dataFetcher("user") { env -> // typeのフィールドを指定する
                                // 引数を取得する
                                val userId = env.arguments["userId"] as Long

                                // 型を返す
                                // userIdがスキーマに定義されていれば、getUserIdを実装すれば良い。(ここではuserIdによってKotlinが生成してくれる)
                                User(userId = userId, name = userId.toString())
                            }
                    )
                    .also { builder ->
                        // scalar型をGraphQLの型から実装で使う形に変換する
                        listOf(
                            "JvmInt",
                        ).forEach { name ->
                            builder.scalar(
                                GraphQLScalarType.newScalar()
                                    .name(name)
                                    .coercing(Scalars.GraphQLInt.coercing)
                                    .build()
                            )
                        }
                        listOf(
                            "PostId",
                            "UserId",
                        ).forEach { name ->
                            builder.scalar(
                                GraphQLScalarType.newScalar()
                                    .name(name)
                                    .coercing(GraphQlLong)
                                    .build()
                            )
                        }
                    }
                    .build()
            )
    }

    object GraphQlLong : Coercing<Long, Long> {
        override fun parseLiteral(input: Any): Long {
            return when (input) {
                is IntValue -> input.value.toLong()
                is FloatValue -> input.value.toLong()
                else -> TODO()
            }
        }

        override fun parseValue(input: Any): Long {
            return input as Long
        }

        override fun serialize(dataFetcherResult: Any): Long {
            return dataFetcherResult as Long
        }
    }
}

実装詳細

注目する所だけコードを抜き出します。
userIdをnameとして返すだけです。これだと、postを要求する時は、Userにpostフィールドを実装しますが、postがほしい時とほしくない時があります。必要なときだけデータをDBやマイクロサービスから取得したいです。その時はどうすれば良いでしょうか。

type User {
    id: UserId!
    age: JvmInt!
    name: String!
    post: [Post]!
}
val query = """
{
    user(userId: 1) {
        name
    }
}
""".trimIndent()

TypeRuntimeWiring.newTypeWiring("Query")
    .dataFetcher("user") { env ->
        val userId = env.arguments["userId"] as Long
        User(userId = userId, name = userId.toString())
    }

結果

{
  "user" : {
    "name" : "1"
  }
}

postを必要なときだけ取得するには以下のようにします。
しかし、これではUserのPostを返す時に必要な、userIdがわかりません。それではどうしたらいいでしょうか。

val query = """
{
    user(userId: 1) {
        name
        post {
            id
            text
        }
    }
}
""".trimIndent()

data class Post(
    val id: Long,
    val text: String,
)

RuntimeWiring.newRuntimeWiring()
    .type(
        TypeRuntimeWiring.newTypeWiring("Query")
            .dataFetcher("user") { env ->
                val userId = env.arguments["userId"] as Long
                User(userId = userId, name = userId.toString())
            }
    )
    .type(
        // UserのTypeを定義し、postで値を返す
        TypeRuntimeWiring.newTypeWiring("User")
            .dataFetcher("post") { env ->
                listOf(Post(id = 0, text = "text"))
            }
    )

結果

{
  "user" : {
    "name" : "1",
    "post" : [ {
      "id" : 0,
      "text" : "text"
    } ]
  }
}

UserのpostフィールドでUserの情報が必要な時は getSource で取得できます。以上の実装でコストの掛かる処理を必要な時だけに実行する事ができるようになりました。

TypeRuntimeWiring.newTypeWiring("User")
    .dataFetcher("post") { env ->
        println("source -> ${env.getSource<User>().userId}")
        listOf(Post(id = 0, text = "text"))
    }

まとめ

graphql-java で必要な部分だけフェッチする方法がわかりました。 次の記事では、これらのコードをスキーマから自動的にコードを生成するようにし、型安全にアクセスする方法を考えようと思います。