STORES でバックエンドエンジニアをしている片桐です。
みなさんはGraphQLの@oneOf
というディレクティブをご存知でしょうか? このディレクティブは、GraphQLの標準仕様においてBuilt-in Directivesへの追加が検討されている新しいディレクティブです。
RFC: https://github.com/graphql/graphql-spec/pull/825
今回 STORES のプロダクトで実際に導入してみたので、このディレクティブの具体的な使用例と使用してみて気づいたことを紹介します。
@oneOf
ディレクティブとは?
@oneOf
は input
に対して指定するディレクティブです。このディレクティブは、対象の input
に対して「inputのfieldのうち、1つだけが指定されていないければならない」という制約を表します。
より具体的には、以下の仕様を満たすように値を設定することを要求します。
- inputのfieldのうち、1つだけに値が設定されていなければならない
- どのfieldにも値を設定しなかったり、2つ以上のfieldに値を設定したりしてはならない
- 「設定しない」はfield自体を送らないことを指す。nullを設定したfieldは「値を設定された」とみなされる
- 値を設定する唯一のfieldには、null以外の値を設定しなければならない
※ 詳細な仕様についてはRFCを参照してください。当記事作成時点ですと、RFCの中にエラーになる値の具体例の表があるので理解の助けになるかと思います。
ライブラリによるサポート
@oneOf
ディレクティブはGraphQLの標準仕様となる見込みですので、今後はGraphQLライブラリによるネイティブサポートが見込まれます。つまり、複数のfieldに値が設定された場合を自力でハンドリングする必要はなく、ライブラリにエラー処理を任せられるようになることが期待できます。
実際、弊社の Ruby on Rails で構築されているプロダクトで使用しているgraphql-ruby
というライブラリでは、仕様の標準化に先駆けて@oneOf
がサポートされています。
具体的な採用例
この記事では、@oneOf
の具体的な採用例を2つ紹介させていただきます。本記事執筆にあたって改めてRFCを確認したのですが、今回紹介する事例はいずれもRFCに例示されているものに近しい使い方をしていました。そのため、@oneOf
ディレクティブの趣旨に沿った使い方となっており皆様の参考にもなるのではないかなと思っています。
Union 相当の値をinputで表現する
GraphQLではinput
でunion
を使用できません。そのため、union相当の値はそれぞれのtype用のfieldをoptionalで用意し、いずれか1つのみを設定させるようなSchemaで実現されてきました。この場合、複数のfieldが設定された場合はエラーにする実装が必要になります。
@oneOf
の導入後も、それぞれのtype用のfieldが必要になるのは変わりません。しかし複数のfieldが設定された場合の例外処理はGraphQLライブラリ(= GraphQLの型システム)に任せることができるようになります。
union Sample
の定義
union Sample = SampleText | SampleImage type SampleText { value: String! } type SampleImage { url: String! alt: String! }
Sample
を作成するmutation
extend type Mutation { createSample(input: CreateSampleInput!): CreateSamplePayload! } input CreateSampleInput { sample: SampleInput! } input SampleInput @oneOf { text: SampleTextInput image: SampleImageInput } input SampleTextInput { value: String! } input SampleImageInput { imageSignedId: String! alt: String! }
識別子(id等)以外の値を用いてデータを取得するqueryを汎用的に定義する
あるtypeの要素を1つを取得するqueryの名前は、そのtype名にすることが一般的です。
例: SampleItemを1つ取得するqueryは sampleItem(id: ID!): SampleItem!
と定義する
しかし、ときには id
のような識別子とは別の値で要素を特定して取得したい場合があります。そういった場合にsampleItem
というquery名を使ってしまうのは不適切です。この名前をここで使ってしまうと、後になって「識別子で取得したい」という用途が出てきた場合に、sampleItem
が使えないためにイレギュラーな名前のqueryにする必要が出てきてしまいます。
そのため、このようなケースでは、 sampleItemByXxxx
のように、要素の特定方法ごとにqueryを定義する方法が一般的に用いられてきました。
extend type Query { sampleItemByParentAIdAndParentBId(parentAId: ID!, parentBId: ID!): SampleResult! sampleItemByExternalId(externalId: String!): SampleResult! }
この方法は上に挙げたようなquery名を占有してしまう問題がなく、定義としては非常に妥当なものです。しかし致命的ではないものの以下のような課題があります。
- 特定方法の数だけqueryを生やす必要がある
- 複数の値で要素を特定する場合はquery名が長くなってしまう
@oneOf
を導入することで、以下のような書き方を導入する選択肢が出てきます。
extend type Query { sampleItemBy(criteria: SampleItemByInput): SampleItemResult! } input SampleItemByInput @oneOf { parentAIdAndparentBId: SampleItemByParentAAndParentBInput externalId: String } input SampleItemByParentAAndParentBInput { parentAId: ID! parentBId: ID! }
この定義が、元の定義に比べてどれほど有用かについては解釈が分かれるところかなと思います。ただ私の対応した事例では、課題となっているqueryは「それなりの長さのtype名の要素を、それなりに長い名前の複数のfieldで取得する」というものでした。主観的ではありますがこの事例においては「長いquery名」が可読性のをかなり損なっているように感じました。
そのため、@oneOf
によって(全体の長さとしては同じでも)この例でいうsampleItemBy
とparentAIdAndparentBId
が分離されることでだいぶ可読性が改善できたように思っています。元の書き方と比べて劇的に優位というわけではないですが、個人的にはこのような選択肢があるのは非常に好ましく思いました。
@oneOf
の使用にあたって気づいたこと
今回 @oneOf
を利用するにあたって気づいた点として、「@oneOf
をつける場所」には気をつける必要があるということを感じました。
例えば、あるデータSample
を作成するようなmutationは以下のように定義するのが一般的かと思います。
type Sample { id: ID! name: String! age: Int! } extend type Mutation { createSample(input: CreateSampleInput!): CreateSamplePayload! } input CreateSampleInput { name: String! age: Int! }
ここから素直に連想すると、「具体例」で紹介した union を作成する mutation の定義は以下のようになります。
union Sample = SampleText | SampleImage extend type Mutation { createSample(input: CreateSampleInput!): CreateSamplePayload! } input CreateSampleInput @oneOf { text: CreateSampleTextInput image: CreateSampleImageInput }
このような定義方法はお勧めしません。なぜかというと、CreateSampleInput
に追加のfieldを追加する余地がないためです。
例えば、リクエストの冪等性を担保するために、idempotencyKey
というfieldを追加で渡す必要が出てきた場合を考えてみます。type を作成する mutation の場合は、以下のようにfieldを追加できます。
input CreateSampleInput { # 追加 idempotencyKey: String! name: String! age: Int! }
しかし、union を作成する mutation の場合は、CreateSampleInput
に @oneOf
をつけてしまうとこのようなキーを追加する余地がありません。
input CreateSampleInput @oneOf { # いずれか1つのfieldしか指定できない以上、このような定義は成立しない idempotencyKey: String! text: CreateSampleTextInput image: CreateSampleImageInput }
以下のように input
と同じ階層にfieldを追加はできます。しかし、他のmutationは「input
のみを引数として受け取る」という形で定義されている中、この場合にだけイレギュラーな形式になるのはあまり好ましいことではありません。
extend type Mutation { createSample( idempotencyKey: String!, input: CreateSampleInput!, ): CreateSamplePayload! }
ですので、union を作成する mutation においては、冒頭の例のように1階層先の input に @oneOf
を設定するのが望ましいように思いました。1
input SampleInput { # この形式であればここにfieldを追加できる余地がある sample: SampleInput! } input SampleInput @oneOf { text: CreateSampleTextInput image: CreateSampleImageInput }
終わりに
今回はGraphQLの新しい仕様を実際に導入してみた事例をご紹介させていただきました。個人的には @oneOf
は今までのGraphQLの微妙に使いづらかった部分を、シンプルな形で改善してくれる非常に良い仕様だと感じています。
STORES では今回のように新しい技術・仕様でも適切に吟味・導入して、プロダクトの開発生産性を上げていける仲間を募集しています。興味を持って頂けた方はぜひご連絡ください。
-
ただしgraphql-ruby においては、 mutationの
input
引数のinput形に@oneOf
をつけるには通常とは少し異なる書き方をする必要があります。そのため、ライブラリをレールに沿って使用していれば自然とこのような書き方が強制されます。↩