こんにちは、エムスリー エンジニアリンググループ マルチデバイスチームの藤原です。
昨年末に医師向けのスマホアプリを新たにリリースしました。 スマホアプリ向けの BFF(Backends For Frontends) も新規に開発したのですが、そこには SpringBoot + Kotlin + GraphQL なアプリケーションを採用しています。
GraphQL はチームでの採用は初めてで、私もこのプロジェクトで初めて触りました。
そのような状況だったので GraphQL 周りについては試行錯誤を重ねることとなったのですが、今回はその開発の中で見えてきた プラクティス をいくつか紹介したいと思います。
これから SpringBoot + Kotlin + GraphQL な開発をされる方の参考になれば幸いです。

(Godbolemandar [CC BY-SA 4.0], ウィキメディア・コモンズより)
- スカラー型の入力チェックは Custom Scalar を使おう
- 認証結果は GraphQLContext に保存しよう
- ユーザ情報のフィールドにするべきものとそうじゃないもの
- 認証が必要であることは Directive で表現しよう
- エラーハンドリング
- フィールドで Nullable or Non-null を迷うようなら Nullable
- フィールドの引数で Nullable or Non-null を迷うようなら Non-null
- 要素追加の可能性がある Enum を使うときは細心の注意を
- 先人の知恵を借りる
- We are hiring
※ この記事に登場する 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ステップです。
Coercingインタフェースを実装したクラスを作成する- 作成した
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 を持たせる例は以下のようになります。
GraphQLContextインタフェースを実装したAuthContextクラスを作成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ステップです。
SchemaDirectiveWiringインタフェースを実装したクラスを作成する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 をカスタマイズする以外の方法では、
- SpringBoot の
@ExceptionHandlerアノテーションを使う方法*7 GraphQLErrorインタフェースを実装した例外をスローする方法
などがありますが、それらと比べると
- GraphQL の標準的なエラー表現である
pathやlocationsの情報がデフォルトで設定されている(他の方法では独自実装が必要) - バリデーションエラーについてもカスタマイズ可能
- エラーの数もカスタマイズ可能(レスポンス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 になります。つまりこの場合は data が null になり、取得できていたはずの 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 アプリのエンジニアも募集しています。
お気軽にお問い合わせください。
*1:初めてのGraphQL, Eve Porcello Alex Banks 著, 尾崎 沙耶 あんどうやすし 訳
*2:https://github.com/graphql-java/graphql-java
*3:https://github.com/graphql-java-kickstart/graphql-java-servlet
*4:https://github.com/graphql-java-kickstart/graphql-java-tools
*5:https://github.com/graphql-java-kickstart/graphql-spring-boot
*6:https://github.com/relayjs/relay-examples/tree/master/todo
*7:graphql-spring-boot-starterのExceptionハンドリングがめっちゃ便利になってた https://shiraji.hatenablog.com/entry/2019/04/24/080000
*8:When To Use GraphQL Non-Null Fields https://medium.com/@calebmer/when-to-use-graphql-non-null-fields-4059337f6fc8
*9:Relay Server Specification https://relay.dev/docs/en/graphql-server-specification.html
*11:GraphQLスキーマ設計ガイド https://github.com/vvakame/graphql-schema-guide