エムスリーテックブログ

エムスリー(m3)のエンジニア・開発メンバーによる技術ブログです

SpringBoot + Kotlin + GraphQL のアプリ向けスキーマ設計・実装のプラクティス

こんにちは、エムスリー エンジニアリンググループ マルチデバイスチームの藤原です。

昨年末に医師向けのスマホアプリを新たにリリースしました。 スマホアプリ向けの BFF(Backends For Frontends) も新規に開発したのですが、そこには SpringBoot + Kotlin + GraphQL なアプリケーションを採用しています。

GraphQL はチームでの採用は初めてで、私もこのプロジェクトで初めて触りました。 そのような状況だったので GraphQL 周りについては試行錯誤を重ねることとなったのですが、今回はその開発の中で見えてきた プラクティス をいくつか紹介したいと思います。
これから SpringBoot + Kotlin + GraphQL な開発をされる方の参考になれば幸いです。

f:id:m-fujiwara-m3:20200229054126j:plain

ボネリークマタカ(某GraphQLの入門書*1の表紙にもこの鳥が描かれている)

(Godbolemandar [CC BY-SA 4.0], ウィキメディア・コモンズより

※ この記事に登場する graphql-java 依存のクラス等は以下のライブラリとバージョンを元にしています。

  • graphql-java 13.0 *2
  • graphql-java-servlet 8.0.0 *3
  • graphql-java-tools 5.6.1 *4
  • graphql-spring-boot-starter 5.10.0 *5

スカラー型の入力チェックは Custom Scalar を使おう

GraphQL スキーマのフィールドには引数を渡すことができますが、実行時に入力チェックをしたい場合があります。 GraphQL クエリを発行すると対応するリゾルバーが実行されるので、愚直にやるとリゾルバーで入力チェックのロジックを実装することになります。

以下はメッセージ一覧をページネーションで取得するスキーマとリゾルバーの例です。

type Query {
  # メッセージ一覧を取得する
  messages(
    # 取得件数
    first: Int!
    # 指定した文字列が表すメッセージ以降を取得する
    after: String
  ): [Message!]
}
class QueryResolver : GraphQLQueryResolver {
    fun messages(first: Int, after: String?): List<Message> {
        // first に100以上の数値が指定されたらエラーにしたい
        if (first > 100) throw IllegalArgumentException()
        ...
    }
}

リゾルバーで取得件数の上限チェックを行なっていますが、他のリゾルバーでも同じような入力チェックを何回も実装しないといけなくなるかもしれず、あまりよくない匂いがします。

GraphQL では独自に任意のスカラー型を定義することができ、スカラー型に入力チェックを実装することでリゾルバーで入力チェックをする必要がなくなります。
スカラー型で入力チェックするようにした場合、スキーマとリゾルバーの実装は以下のようになります。

# ページネーション用の量指定型(1から99の数値)
scalar PaginationAmount

type Query {
  # メッセージ一覧を取得する
  messages(
    # 取得件数
    first: PaginationAmount!
    # 指定した文字列が表すメッセージ以降を取得する
    after: String
  ): [Message!]
}
typealias PaginationAmount = Int // エイリアスでスキーマ上と型名を合わせると味がいい

class QueryResolver : GraphQLQueryResolver {
    fun messages(first: PaginationAmount, after: String?): List<Message> {
        // この処理が実行される時点で first が 100未満であることは保証される
        ...
    }
}

独自のスカラー型を作成する方法は以下の2ステップです。

  1. Coercing インタフェースを実装したクラスを作成する
  2. 作成した Coercing を元に GraphQLScalarType を作成し、Bean に登録する
class PaginationAmountCoercing : Coercing<Int, Int> {
    override fun parseLiteral(input: Any): Int? {
        // 入力チェックを失敗させる場合は CoercingParseLiteralException を throw する
        ...
    }
    ...
}

@Bean
val PaginationAmount = GraphQLScalarType.newScalar()
    .name("PaginationAmount")
    .description("A positive integer with an upper bound")
    .coercing(PaginationAmountCoercing())
    .build()!!

認証結果は GraphQLContext に保存しよう

SpringBoot をベースにアプリケーションを作っていると認証結果のユーザ情報はリクエストスコープの Bean に保存するような形になりそうですが、 GraphQL には同一リクエストで使いまわすことができるコンテキストの仕組みが用意されています。

AuthContext という独自のクラスに User を持たせる例は以下のようになります。

  1. GraphQLContext インタフェースを実装した AuthContext クラスを作成
  2. build メソッドで AuthContext のインスタンスを返すような GraphQLContextBuilder を作成し Bean に登録
class AuthContext(val user: User?) : GraphQLContext {
    ...
}

@Bean
fun authContextBuilder(): GraphQLContextBuilder = object : DefaultGraphQLContextBuilder() {
    override fun build(
        request: HttpServletRequest,
        response: HttpServletResponse
    ): GraphQLContext {
        // ユーザの情報を取得
        val user = ...
        return AuthContext(user)
    }
}

AuthContext の生成はリクエストごとに1回実行されて、結果はリゾルバーで取得することができます。

class QueryResolver : GraphQLQueryResolver {
    fun messages(
        first: PaginationAmount,
        after: String?,
        environment: DataFetchingEnvironment // 引数に指定することで、ここからコンテキストの取得もできる
    ): List<Message> {
        val authContext = environment.getContext<AuthContext>()
        val user = authContext.user
        ...
    }
}

ユーザ情報のフィールドにするべきものとそうじゃないもの

認証済みユーザ専用の「おすすめコンテンツ」をスキーマで表現しようとすると、ユーザ情報の型におすすめコンテンツのフィールドを追加する形が考えられます。

type Query {
  # 現在のユーザ(未認証の場合は null)
  currentUser: User
}


# ユーザ情報
type User {
  # 会員の名前
  name: String!
  ...

  # おすすめコンテンツ一覧
  recommendedContents: [RecommendedContent]
}

ユーザ情報とおすすめコンテンツを取得するクエリは以下のようになります。

query {
  currentUser {
    name
    recommendedContents {
      ...
    }
  }
}

facebook が提供している GraphQL のサンプル*6と同じ設計方針となっていて一見自然な対応に見えますが、 今後の機能追加の方向によっては問題になってくる可能性があります。 例えば、未認証のユーザに対してもおすすめコンテンツを返さなければならなくなるかもしれません。 おすすめコンテンツ一覧を会員情報のフィールドとしていると、会員ではないユーザについてはそのフィールドにアクセスすることができなくなってしまいます。

このような場合は、会員情報とおすすめコンテンツのリレーションを断つのがシンプルです。 スキーマ定義の一部を修正したものは以下のようになります。

type Query {
  # 現在のユーザ
  currentUser: User

  # おすすめコンテンツ一覧
  recommendedContents: [RecommendedContent]
}

ユーザ情報のフィールドにするものは

  • ユーザを構成している要素である(e.g. メールアドレスなどの登録情報)
  • ユーザの状態を表す要素である(e.g. 保有ポイント数)
  • 他のユーザからも関連が見える必要がある(e.g. SNSの友達一覧)

のいずれかの条件を満たすものにするのが良いでしょう。

認証が必要であることは Directive で表現しよう

未認証の場合に結果を返さないフィールド、もしくは型であることを示したいことがあります。 そのような時は Directive という機能を使えば宣言的に情報を付加することができます。

# 認証済みの場合のみアクセス可能
directive @auth on OBJECT | FIELD_DEFINITION

type User @auth {
  name: String!
}

実行時の振る舞いを変えるような Directive の実装方法は以下の2ステップです。

  1. SchemaDirectiveWiring インタフェースを実装したクラスを作成する
  2. SchemaDirective として Bean に登録する

認証が必要なことを示す Directive の実装は以下のようになります。

class AuthDirective : SchemaDirectiveWiring {
    override fun onField(environment: SchemaDirectiveWiringEnvironment<GraphQLFieldDefinition>): GraphQLFieldDefinition {
        val originalDataFetcher = environment.fieldDataFetcher
        val authDataFetcher = DataFetcher<Any> { dataFetchingEnvironment ->
            val authContext = dataFetchingEnvironment.getContext<AuthContext>()
            if (authContext.user != null) {
                // 認証済みの場合。元のリゾルバーを呼び出す
                originalDataFetcher.get(dataFetchingEnvironment)
            } else {
                // 未認証の場合。
                ...
            }
        }
        return environment.setFieldDataFetcher(authDataFetcher)
    }
}

@Bean
fun directives(): List<SchemaDirective> = listOf(
    SchemaDirective("auth", AuthDirective())
)

エラーハンドリング

graphql-java でのエラーハンドリングの方法はいくつかありますが、 GraphQLErrorHandler をカスタマイズする方法を紹介します。 デフォルトでは DefaultGraphQLErrorHandler が使われるようになっていて、リゾルバーからスローされた例外は "Internal Server Error(s) while executing query" というメッセージの1つのエラーに集約されてしまい詳細不明となってしまいますが、自由にカスタマイズすることが可能です。

実装方法は GraphQLErrorHandler インタフェースを実装したクラスを作成し、Bean に登録するのみです。

@Component
class CustomGraphQLErrorHandler : GraphQLErrorHandler {
    override fun processErrors(errors: List<GraphQLError>): List<GraphQLError> {
        // エラーハンドリングを実装する
        ...
    }
}

GraphQLErrorHandler をカスタマイズする以外の方法では、

  1. SpringBoot の @ExceptionHandler アノテーションを使う方法*7
  2. GraphQLError インタフェースを実装した例外をスローする方法

などがありますが、それらと比べると

  • GraphQL の標準的なエラー表現である pathlocations の情報がデフォルトで設定されている(他の方法では独自実装が必要)
  • バリデーションエラーについてもカスタマイズ可能
  • エラーの数もカスタマイズ可能(レスポンスJSONの errors フィールドに任意の要素数で格納できる)

などのメリットがあります。どこまでカスタマイズしたいか次第なところもありますが、おそらく一番自由度が高いカスタマイズ方法です。

フィールドで Nullable or Non-null を迷うようなら Nullable

Non-null である必要がないフィールドを Non-null で定義してしまうと、取得できたはずのデータを返せなくなる可能性があります。

先ほども例に出したスキーマを例にして説明します。

# Schema
type Query {
  # 現在のユーザ
  currentUser: User

  # おすすめコンテンツ一覧
  recommendedContents: [RecommendedContent]
}
# Query
query {
  currentUser {
    name
  }
  recommendedContents {
    title
  }
}

上記のようなクエリを発行した際に何かしらエラーが発生し、おすすめコンテンツの情報が取得できず以下のような JSON を取得できたとします。

{
  "data": {
    "currentUser" : {
      "name": "エムスリー 太郎"
    },
    "recommendedContents": null
  },
  "errors": [
    {
      "message": "failed to get recommended contents title",
      "path": ["recommendedContents"]
    }
  ]
}

この時、もし recommendedContents の型が [RecommendedContent]! のように Non-null だった場合、null になるはずだったフィールドの親のオブジェクトが null になります。つまりこの場合は datanull になり、取得できていたはずの currentUser のデータさえもクライアントに返らなくなります。

{
  "data": null,
  "errors": [
    {
      "message": "failed to get recommended contents title",
      "path": ["recommendedContents"]
    }
  ]
}

data フィールドは最上位のフィールドで Nullable です。)

上記のようなケースが考えられるため、 Nullable か Non-null か迷った時は Nullable とするのが良いと思われます。
また、複数のクエリが同時に問い合わせされた時のことを考えると、Query および Mutation 配下のフィールドは Nullable にしておくのが無難なのかもしれません。 Null可否についての考察はこちらの記事*8がとても参考になりました。

エラーとなった場合を例に出して少し詳しく見てみましたが、互換性の観点でも Nullable の方が望ましいです。
GraphQLをスマホアプリのAPIとして動かしつつスキーマ定義を変更することを考えます。その時、すでにリリースされているバージョンのアプリの互換性を保ちつつ変更する必要が出てきます。

  • Non-null から Nullable に変更する場合
    旧バージョンのアプリでは Non-null な値のみ受け付ける
    -> サーバから null が返ってくるとクラッシュする可能性があるので危険!

  • Nullable から Non-null に変更する場合
    旧バージョンのアプリでは Nullable な値を受け付ける
    -> サーバが null を返さなくなっても問題ないので安全

フィールドの引数で Nullable or Non-null を迷うようなら Non-null

前のセクションではサーバからのレスポンスについては迷うようなら Nullable ということを述べましたが、 クライアントから送られる値については逆のことが言えます。つまり「Nullable or Non-null を迷うようなら Non-null」です。

互換性の観点で再び整理すると以下のようになるためです。

  • Non-null から Nullable に変更する場合
    旧バージョンのアプリから Non-null なパラメータのみ送られる
    -> サーバが null を受け取れるようになっても問題ないので安全

  • Nullable から Non-null に変更する場合
    旧バージョンのアプリから Nullable なパラメータが送られる
    -> サーバが null を受け取れなくなるとクエリ実行できない可能性があるので危険!

要素追加の可能性がある Enum を使うときは細心の注意を

Enum の要素追加はサーバとクライアントで非互換になる可能性があります。
サーバ側は新しい Enum 要素を実装したバージョンをリリースすればいいですが、クライアント側(スマホアプリ)は、新しい Enum 要素を解釈できるバージョンのアプリをリリースし、ユーザの端末に新しいバージョンのアプリをダウンロードしてもらうところまでが必要になります。 Enum をクエリの結果として使う場合、要素追加される前のスキーマ定義を元にコードを生成した旧バージョンのアプリではサーバから返された新しい Enum 要素が解釈できないためです。
アプリの強制アップデート機能によって解消するという手段もありますが、エンジニアの都合でそれをやるのは避けたいです。

4つ目の要素が追加されようとしている Enum を例にして、いくつか解決策をあげます。

enum UserStatus {
  # 医師
  DOCTOR
  # 医学生
  MEDICAL_STUDENT
  # 薬剤師
  PHARMACIST
  # 薬学生(これから追加される. 古いバージョンのアプリはこの値を知らない)
  # PHARMACY_STUDENT
}

解決策1. unknown だった場合にどうするか決める

Appoloクライアントの場合、未定義の Enum が返ってきたときにクラッシュさせないように unknown という状態にできます。 unknown なときにどうするか決めることができれば特に問題にはならないかもしれません。

解決策2. フィールドを変える

要素追加があるごとにフィールドを変えるという対策が取れます。

type User {
  # 薬学生を含まないステータス
  status: UserStatus!
  # 薬学生を含む新しいステータス(しかしフィールド名のネーミングはとても悪い...)
  newStatus: UserStatus!
}

フィールド名は拡張可能な形にしておく必要がありますが、命名はとても難しくなることが予想されます。 また、フィールドが増え続けるようなら、それはそれでアンチパターンにハマってしまっていると思われます。。

この方法を取る場合、サポートするクライアントのバージョンがわかっているようならコメント等で残しておくと良いでしょう。 そのバージョンの利用者がいなくなった時にフィールドを削除するのに使えます。

解決策3. Enum を使わない

上記の例であれば Enum にせずに同等の情報を返すフィールドを作る方法も取れます。

type User {
  # 学生かどうか
  isStudent: Boolean! 
  # 薬学系ステータスかどうか
  isPharmacyStatus: Boolean!
}

今後拡張の可能性が高いものは Enum として表現するよりも、それ以外のフィールドとして返してクライアント側で決定する方法の方が安全だと思われます。

先人の知恵を借りる

冒頭で述べました通り、GraphQL についてのナレッジがチームにはなかったため悩みどころは多かったです。 特にスキーマ設計についてはサーバサイド、クライアントサイドのエンジニアを交えて議論を重ねました。

スキーマ設計についての指針が欲しいと思っていたところで参考になったドキュメント・書籍を紹介します。

  • Relay Server Specification*9
    Relay Server Specificationは GraphQL の拡張仕様です。スキーマ設計についていくつかの規約を定めています。 Relay に準拠した実装のライブラリも少なくないため、合わせておいて損はないでしょう。

  • GraphQL 公式サイト*10
    GraphQL の一通りの機能についてドキュメントがあり、完全に理解した気分にさせてくれます。機能の説明となっているため使い所などわかりにくいところもありますが、やはり公式ドキュメントは読むべきです。

  • GraphQL スキーマ設計ガイド*11
    あまり日本語化されていない GraphQL の設計周りについて書いてあります。 この書籍が配布された頃にちょうど悩んでいるところだったエラーハンドリングについて特に参考にさせていただきました。

We are hiring

今回は新規アプリのBFFにまつわる話をさせていただきました。
マルチデバイスチームではリリースされたばかりのアプリを成長させるべく一緒に開発に参加してくれる仲間を募集中です。 もちろんサーバサイドに限らず iOS / Android アプリのエンジニアも募集しています。 お気軽にお問い合わせください。

jobs.m3.com