RESTで溺れる者がGraphQLを掴む

あまりマジョリティになっていく印象がないGraphQLですが、RESTを使い続けていて修正や拡張を重ねる場面でジワジワ辛さを感じる点もあり一度逃げて試しておこうかなぁという主旨です。

サーバサイドKotlinで試してみようと思ったものの、あまり情報が多く無いため記録として残しておきます。

リポジトリは以下
github.com

GraphQLの超ざっくりイメージ

  • エンドポイントは単一である
  • 型システム持ち
  • クエリ言語とスキーマ言語の組み合わせ
  • 単一リクエストで関連するオブジェクトを一括で取得できる
  • 取得したいフィールドやオブジェクトに対してそれぞれ引数パラメータを渡すことができる
  • 引数には型を与えることができ、デフォルト型に加えてカスタムした型を用意することもできる
  • 認証も単一にできる
  • Fragmentとして再利用可能な共通オブジェクトを作っておける

他にも多くの特徴があり、当然課題解決として銀の弾丸ではないのは認識した上でデメリットについては一旦触れないものとして触っていく。

準備

サーバサイドはKotlinで用意する。
公式ページに掲載されているライブラリgraphql-kotlinを利用する
調べたところコアライブラリであるgraphql-java以外はgraphql-javaのメンテナンスから独立してgraphql-java-kickstartで開発が続けられている。SpringBoot+Kotlinの環境ではgroupidはcom.graphql-javaではなくcom.graphql-java-kickstart を使うのが良さそう。

参考:GraphQL Java

dependencies

  • graphql-spring-boot-starter
     SpringBootアプリケーションをGraphQLサーバーに変換する

  • altair-spring-boot-starter
     Altairスキーマイントロスペクションとクエリデバッグ

  • graphiql-spring-boot-starter
     GraphiQLスキーマのイントロスペクションとクエリのデバッグ

  • voyager-spring-boot-starter
     APIインタラクティブなグラフ化する

build.gradle.kts

implementation ("com.graphql-java-kickstart:graphql-spring-boot-starter:7.0.1")
runtimeOnly ("com.graphql-java-kickstart:altair-spring-boot-starter:7.0.1")
runtimeOnly("com.graphql-java-kickstart:graphiql-spring-boot-starter:7.0.1")
runtimeOnly("com.graphql-java-kickstart:voyager-spring-boot-starter:7.0.1")
implementation ("com.graphql-java-kickstart:graphql-java-tools:6.0.2")

Schema

.graphqlsファイルを作成しスキーマ定義する。
Intellijでファイル作成時に選択可能なGraphQL File だと拡張子が .graphql で生成されスキーマ定義として認識されないので注意が必要。resolverで作成したfunctionをtype Queryとして定義することでqueryとして呼び出すことが可能になる。
なお、GraphQLではデータ取得はQuery,サーバサイドのデータ変更はMutationと呼ぶ。
今回試すquery以外は割愛します。

models.graphqls

type Query {
    getBooks: [Book]
    getBook(id: String!): Book
    getUsers: [User]
}

User.graphqls

type User {
    id: ID!
    full_name: String!
    first_name: String!
    last_name: String!
    age: Int!
    employee_number: String!
    email: String!
    organizations: [Organization]
}

Organization.graphqls

type Organization {
    id: ID!
    userId: ID!
    code: String!
    name: String!
}

Data Classes

GraphQLのtype定義したスキーママッピングするdata classを定義する。
ポイントとしては、入れ子にするOrganizationエンティティはUser data classに含めないこと。

User.kt

data class User(
    val id: String,
    val full_name: String,
    val first_name: String,
    val last_name: String,
    val age: Int,
    val employee_number: String,
    val email: String
)

Organization.kt

data class Organization(
    val id: String,
    val userId: String,
    val code: String,
    val name: String
)

Root Resolvers

今回はqueryのみのため、GraphQLQueryResolverインターフェースを実装するクラスを用意します。
functionをgetXxxx のように作成することでtype Queryの定義と合わせる必要がある。

UserQueryResolver.kt

@Component
class UserQueryResolver(): GraphQLQueryResolver {
    val userRepo = UserRepo()
    fun getUsers(): List<User> {
        return userRepo.findAll()
    }
}

Resolvers

今回はUserに外部キーとして持つ想定のOrganizationのマッピング用としてGraphQLResolverインターフェースを実装するクラスを用意します。
UserクラスをDataClassに指定し、OrganizationのListを取得する処理を実装。

UserResolver.kt

@Component
class UserResolver: GraphQLResolver<User> {
    val organizationRepo = OrganizationRepo()
    fun getOrganizations(user: User): List<Organization>{
        return organizationRepo.findByUserId(user.id)
    }
}

ローカル実行

GraphQL Voyager

voyagerを起動することで定義したSchemaをビジュアル化してくれる。
URLはapplication.ymlに記述します。

f:id:yospig:20200714223132p:plain
GraphQL Voyager

GraphiQL

クエリの実行はGraphiQLを使ってブラウザ経由で確認できる。
同じくURLはapplication.ymlに記述します。

f:id:yospig:20200714223538p:plain
GraphiQL

クエリ実行

Bookの一覧を取得

# query
{
  getBooks{
    name
  }
}

# response
{
  "data": {
    "getBooks": [
      {
        "name": "name10"
      },
      {
        "name": "name20"
      },
      {
        "name": "name30"
      },
      {
        "name": "name40"
      },
      {
        "name": "name50"
      }
    ]
  }
}

ID指定で単一のBookを取得

# query
{
  getBook(id: "100") {
    id
    name
  }
}

# response
{
  "data": {
    "getBook": {
      "id": "100",
      "name": "bookName100"
    }
  }
}

Userの一覧(フルネームのみ)を取得

# query
{
  getUsers{
    full_name
  }
}

# response
{
  "data": {
    "getUsers": [
      {
        "full_name": "鈴木太郎"
      },
      {
        "full_name": "田中一郎"
      }
    ]
  }
}

Userの一覧(項目追加)を取得

# query
{
  getUsers{
    id
    full_name
    first_name
    employee_number
    organizations {
      userId
      name
    }
  }
}

# response
{
  "data": {
    "getUsers": [
      {
        "id": "1",
        "full_name": "鈴木太郎",
        "first_name": "太郎",
        "employee_number": "1000",
        "organizations": [
          {
            "userId": "1",
            "name": "情報システム部"
          },
          {
            "userId": "1",
            "name": "新規事業部"
          }
        ]
      },
      {
        "id": "2",
        "full_name": "田中一郎",
        "first_name": "一郎",
        "employee_number": "2000",
        "organizations": [
          {
            "userId": "2",
            "name": "クラウドサービス部"
          }
        ]
      }
    ]
  }
}

複数クエリを一括実行

# query
{
  getBooks{
    name
  }
  getBook(id: "10") {
    id
    name
  }
}

# response
{
  "data": {
    "getBooks": [
      {
        "name": "name10"
      },
      {
        "name": "name20"
      },
      {
        "name": "name30"
      },
      {
        "name": "name40"
      },
      {
        "name": "name50"
      }
    ],
    "getBook": {
      "id": "10",
      "name": "bookName10"
    }
  }
}

雑感まとめ

今回は単純なqueryの実行、複数クエリの一括発行、複数エンティティの入れ子構造の取得などを試しました。

  • 先の複数クエリの一括実行のサンプルは有用性の薄い情報でしたが、1画面で必要な情報のデータソースが外部に点在しているようなシステムの場合は、クライアントサイドでの恩恵は大いにありそう。必要なフィールドに絞ったレスポンスを得られるのもシンプルに良い。
  • 一方で、あまり一括にすぎるとエラー処理やパフォーマンス面では混乱が起きそうな予感もしており、そのあたりの感触は運用してみて初めて解るツラミがあるかもしれない。
  • 調べきれていない点も多い。クラウドインフラのオブジェクトストレージなら問題無さそうだがバイナリデータの扱いはどうなるんだろうか?
  • RESTのような規約ではなくスキーマとクエリ仕様がカッチリ決められているため、実装の揺れは少なそうなのは良い感じ。
  • typeで外部キーまで全て定義してしまうと折角の柔軟性のあるリクエストが阻害される予感がした。queryを一括で複数発行できる強みを活かすならtypeを別定義として適度な疎結合を保つのが良さそう。

参考: Spring Boot + GraphQLでAPIを作成してみよう! - Yahoo! JAPAN Tech Blog