STORES Product Blog

こだわりを持ったお商売を支える「STORES」のテクノロジー部門のメンバーによるブログです。

GraphQLの @oneOf ディレクティブの活用事例

GraphQLの @oneOf ディレクティブの活用事例

STORES でバックエンドエンジニアをしている片桐です。

みなさんはGraphQLの@oneOfというディレクティブをご存知でしょうか? このディレクティブは、GraphQLの標準仕様においてBuilt-in Directivesへの追加が検討されている新しいディレクティブです。

RFC: https://github.com/graphql/graphql-spec/pull/825

今回 STORES のプロダクトで実際に導入してみたので、このディレクティブの具体的な使用例と使用してみて気づいたことを紹介します。

@oneOf ディレクティブとは?

@oneOfinput に対して指定するディレクティブです。このディレクティブは、対象の 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ではinputunionを使用できません。そのため、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によって(全体の長さとしては同じでも)この例でいうsampleItemByparentAIdAndparentBIdが分離されることでだいぶ可読性が改善できたように思っています。元の書き方と比べて劇的に優位というわけではないですが、個人的にはこのような選択肢があるのは非常に好ましく思いました。

@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 では今回のように新しい技術・仕様でも適切に吟味・導入して、プロダクトの開発生産性を上げていける仲間を募集しています。興味を持って頂けた方はぜひご連絡ください。

jobs.st.inc


  1. ただしgraphql-ruby においては、 mutationの input 引数のinput形に @oneOf をつけるには通常とは少し異なる書き方をする必要があります。そのため、ライブラリをレールに沿って使用していれば自然とこのような書き方が強制されます。