はじめに
STORES 予約でエンジニアをしているhiromu617です。この度 STORES では STORES レジ に STORES 予約 がもつ予約情報を連携できる機能をリリースしました。
この機能を提供するにあたってサービス間で通信をする必要がありました。サービス間の通信には、Schema Stitchingという技術を活用しています。
本記事では、Schema Stitchingの活用例について紹介します。
どのような機能か
STORES レジ に STORES 予約 がもつ予約情報を連携できる機能です。STORES 予約を通じて入れた予約情報を元に STORES レジ で会計することができます。
前提として STORES では商売に関する複数のプロダクトが展開されています。
それらのプロダクトのすべてが STORES という1つの会社から生まれた訳ではなく、元々別の会社のプロダクトとして生まれ、 STORES に統合されたという歴史があります。STORES 予約 、STORES レジ もその例外ではなく元々別々の会社で生まれたプロダクトでした。
この機能の実現の裏側には、事業者や店舗を管理するシステムや認証基盤をはじめとする共通基盤の各プロダクトへの導入があります。今回は、それらの共通基盤が導入された前提での話なのでそれらの説明は割愛します。
システムの構成
予約情報を連携する以前の STORES レジ は次のような構成で動いていました。(説明のため簡略化しています)
クライアントであるレジアプリとレジバックエンドはGraphQLを用いて通信を行なっています。また、レジバックエンドはRuby on Railsで動いており、graphql-rubyによりGraphQLサーバーが実装されています。
予約情報を連携した STORES レジ は次のような構成で動いています。予約バックエンドもレジと同様にRuby on Railsで動いており、graphql-rubyによりGraphQLサーバーが実装されています。
ここで、前述のとおり、プロダクト間の通信にSchema Stitching という技術を採用しています。
Schema Stitching を採用した経緯
予約情報を STORES レジに連携するにあたって、レジバックエンドは予約情報を含めたレスポンスをクライアントに返す必要がありました。
まず私たちは、手動でリクエストを組み立ててデータを予約バックエンドに問い合わせるアプローチを取りました。
例えば、予約情報を取得する際は次のように実装していました。
まず、予約側にreservation(id: ID!)
のクエリを実装しておきます。
続いて、レジ側で次のようにオブジェクトを定義します。
module Types class ReservationType < Types::BaseObject field :id, ID, null: false field :customer, Types::CustomerType, null: false # fieldの定義が続く # ... end end
続いてリゾルバーの実装が次のようになります。リゾルバー内で予約バックエンドからデータを取得しています。
module Resolvers class ReservationResolver < BaseResolver argument :id, ID, required: true, description: '予約ID' type Types::ReservationType, null: true def resolve(**variables) GraphQLClient.query(query: QUERY, variables:).reservation end QUERY <<~GRAPHQL { query Reservation($id: ID!) { reservation(id: $id) { id customer # 予約情報が続く } } } GRAPHQL end end
つまり、開発のサイクルは次のようになります。
- 予約バックエンドで実装を行う。
- レジバックエンドはそれをもとにオブジェクトの定義とリゾルバーを実装する。
- アプリ側でAPIの繋ぎ込みを実装する。
この方法で開発を進める中で大きな問題がありました。
それは、それぞれの作業が前の作業に依存しているため開発のリードタイムが大きくなってしまうことです。予約バックエンドで変更がある度に、レジバックエンドはその変更に追従する必要がありとても面倒です。
また、レジバックエンドと予約バックエンドで重複する記述が多く無駄の多いコードになってしまいます。
ここで、これら問題を解決するためにSchema Stitching の採用を検討しました。
Schema Stitchingとは
Schema Stitchingはgraphql-toolsによって提供されている機能の1つです。
Schema Stitchingを利用すると、複数のGraphQLのリソースが存在する構成において、各リソースのスキーマをマージした1つのスキーマをクライアントに提供することができます。
今回の事例に置き換えると、レジバックエンドが提供するスキーマと予約バックエンドが提供するスキーマをマージしたスキーマがクライアントに提供されます。そして、クライアントから透過的に予約バックエンドにアクセスすることができます。
また、複数のリソースに跨ったデータの取得もライブラリによって行われます。前述のようなResolver内でデータを取得する実装は不要になります。
Schema Stitching を利用すると開発のサイクルは次のようになります。
- 予約バックエンドで実装を行う。
- Schema Stitchingによってマージされるスキーマを更新する
- アプリ側でAPIの繋ぎ込みを実装する。
つまり、予約バックエンドに変更があった際、レジバックエンドはスキーマを更新するだけで良いということになります。
実装には graphql-stitching-rubyのgemを利用しています。
Schema Stitchingと似たアプローチとしてApplo Federationが存在します。こちらはSchema Stitching とはフィールドを解決するアプローチが異なっています。
実装例
予約情報を STORES レジに連携するにあたって、Schema Stitchingによってシンプルに機能を実装ができた例を1つ紹介します。
機能の要件の1つとして、STORES レジで会計された予約に対して、現地決済済みであることをUIに反映するという要件があります。
具体的には、現地決済済みの予約についてはカレンダー枠が塗りつぶされる表示になったり、予約の詳細画面において「お会計へ」のボタンが「決済済み」という文言に変わりボタンが非活性になったりします。
レジで予約情報を元に会計をすると該当の予約のIDを持ったOrder
のレコードがレジバックエンドに作成されます。ある予約に対して、その予約のIDをもつOrder
のレコードが存在すれば現地決済済み、存在しなければ未決済ということになります。
コードで表すと次のようになります。
*実際のテーブル構造とは異なります
# 決済済みの予約の時 Order.exsists?(reservation_id: paid_reservation.id) # => true # 未決済の予約の時 Order.exsists?(reservation_id: not_paid_reservation.id) # => false
ここで現地決済済みかどうかをUIに反映するにあたって、予約バックエンドが持っている予約情報とレジバックエンドが持っているOrder
の情報をどのようにクライアントに返すかを考えます。これを実現するにはいくつか方法が考えられます。
案1 予約情報と支払い済みかどうかの情報を別々に返す
type Query { """ 予約を取得するクエリ """ reservations: [Reservation!]! """ 予約IDを渡して支払い済みかどうかを取得するクエリ """ isPaidsByReservationIds(reservationIds: [ID!]!): [Boolean]! }
一つ目は上のような2つのクエリをクライアントから叩いて、それらの情報を突合してUIに反映する方法です。
この方法でも、要件を満たすことはできるのですが、クライアント側の処理が煩雑になってしまいます。下のコードのようにReservation
のフィールドとして一緒に支払い済みかどうかを返す方がより扱いやすいインターフェースといえます。
type Reservation { id: ID! isPaid: Boolean! ... }
案2 予約バックエンドにOrder
のキャッシュのレコードをもつ
2つ目は予約側にOrder
のレコードをキャッシュする方法です。
STORES では、非同期でイベントを送受信できる仕組みが備わっておりこれを利用します。以下の手順で予約が現地決済済みかどうかをクライアントに返します。
- レジでお会計をする。
- レジバックエンドで
Order
が作成される。Order
作成イベントが送信される。 - 予約バックエンドで
Order
作成イベントを受信する。これをもとにOrder
のキャッシュをDBに保存する。 - 予約情報を返す際、DBに保存した
Order
のキャッシュを参照して現地済みかどうかを判定する。
一見この方法には問題ないように思えますが、いくつか考慮すべき点があります。
例えば、Order
を取り消した時、現地決済済みの予約を未決済として扱いたいという要件があるとします。この時、Order削除イベントを予約側で受け取るようにする必要があるでしょう。ここで、Order作成イベントとOrder削除イベントの受信がもし前後してしまった時にも正しく判定できる必要があります。
また、送信したイベントがロストしてしまった時には復旧が必要となります。
この方法では、予約側で参照するのはあくまでもOrder
のキャッシュなので、Order
本体の変更に追従する必要があり面倒です。
Order
のレコード本体を参照した方が考えることは少なくなりそうです。
その方法が次で説明する案3になります。
案3 Schema Stitchingを利用する
3つ目が、Schema Stitchingを利用する方法です。
Schema Stitchingを利用すると、予約情報を取得する際、現地決済済みかどうかを表すフィールドのみレジバックエンドで判定して予約情報にマージして返すことができます。
予約側の実装
予約側では以下のような スキーマでid
の配列を引数に取り予約情報を配列で返すクエリを提供します。
type Query { """ 予約を取得するクエリ """ reservations(ids: [ID!]!): [Reservation]! } type Reservation { id: ID! customer: Customer! ...予約情報が続く }
The only requirement is that both the field argument and return type must be lists, and the query results are expected to be a mapped set with null holding the position of missing results. GitHub - gmac/graphql-stitching-ruby: GraphQL Schema Stitching for Ruby
ここで、reservations(ids: [ID!]!)
のクエリは、結果をnullableかつ引数のidsに対応するように返す必要があります。
# ids: ["1", "2", "3"] # result: [{ id: "1" }, null, { id: "3" }]
reservations(ids: [ID!]!)
のクエリは、予約が提供するReservation
とレジが提供するReservation
を対応させるために、ライブラリによって叩かれます。
レジ側の実装
レジバックエンド側にも予約側と同名であるReservation
のオブジェクトを定義します。
ここで、id
を予約側が返すReservation
のid
と同一の値を返す必要があります。
実装は以下のようになります。
module Types class ReservationType < Types::BaseObject field :id, ID, null: false, description: "予約ID" field :is_paid, Boolean, null: false, description: "現地決済済みかどうか" end end
また、予約側と同様に、id
の配列を引数に取り予約情報を配列で返すクエリを実装します。
type Query { paidReservations( ids: [ID!]! ): [Reservation]! }
Resolverの実装は次のようになります。
ここで予約IDに対応するOrder
を取得してその有無によってis_paid
のフィールドを判定しています。
予約側ではReservation
のレコードがGraphQLでのReservation
のオブジェクトに対応しますが、レジ側ではただのhashがReservation
のオブジェクトに対応することになります。
module Resolvers class PaidReservationsResolver < BaseResolver argument :ids, [ID], required: true type [Types::ReservationType], null: false def resolve(ids: []) paid_reservations = Order.where(reservation_id: ids).index_by(&:reservation_id) ids.map do |id| { id:, is_paid: paid_reservations[id].present? } end end end end
GraphQL::Stitching::Client
を生成する部分のコードは次のようになります。
ここでは、オブジェクトをマージする方法を指定しています。
field_name
には先ほど実装したクエリをそれぞれ指定します。keyには、今回はReservation
のid
のフィールドをレジ側、予約側それぞれで同一となるように実装を行ったのでid
を指定します。
オブジェクトのマージ方法の指定は、今回の方法のほかに、IDLのディレクティブで指定する方法もあります。
@client = GraphQL::Stitching::Client.new(locations: { regi: { schema: ::Pos::Schema, stitch: [ { field_name: "paidReservations", key: "id" }, ], }, reserve: { schema: ::Reserve::SCHEMA, stitch: [ { field_name: "reservations", key: "id" }, ], }, })
これまでのコードからGraphqlのスキーマは次のように生成されます。
ディレクティブは全てgraphql-stitchingが定義、付与したものです。
Reservation
のオブジェクトがマージされていることがわかります。
directive @resolver(arg: String!, federation: Boolean, field: String!, key: String!, list: Boolean, location: String!) repeatable on INTERFACE | OBJECT | UNION directive @source(location: String!) repeatable on FIELD_DEFINITION type Query { reservations(ids: [ID!]!): [Reservation]! @source(location: "reserve") paidReservations( ids: [ID!]! ): [Reservation]! @source(location: "regi") } type Reservation @resolver(location: "regi", key: "id", field: "paidReservations", arg: "ids", list: true) @resolver(location: "reserve", key: "id", field: "reservations", arg: "ids", list: true) { id: ID! @source(location: "regi") @source(location: "reserve") customer: Customer! @source(location: "reserve") """ 諸々の予約情報 """ isPaid: Boolean! @source(location: "regi") }
挙動について
クライアントから次のようなクエリを叩いた時を考えます。
query reservations { reservations(ids: [1,2,3]) { id customer { name } isPaid } }
この時、ライブラリによって次のような流れでクライアントにデータが返されます。
- レジから予約に
reservations(ids: [1,2,3])
のクエリが叩かれる
↓のようなデータが返される
{ "data": { "reservations": [ { "id": 1, "customer": { "name": "Bob" } }, { "id": 2, "customer": { "name": "Alice" } }, { "id": 3, "customer": { "name": "Carol" } } ] } }
2.レジからレジにpaidReservations(ids: [1,2,3])
が叩かれる
↓のようなデータが返される
{ "data": { "reservations": [ { "id": 1, "isPaid": true }, { "id": 2, "isPaid": true }, { "id": 3, "isPaid": false } ] } }
3.これらの結果がマージされてクライアントに返される
{ "data": { "reservations": [ { "id": 1, "customer": { "name": "Bob" }, "isPaid": true }, { "id": 2, "customer": { "name": "Alice" }, "isPaid": true }, { "id": 3, "customer": { "name": "Carol" }, "isPaid": false } ] } }
以上の実装により、クライアントからも扱いやすいインターフェイスかつOrder本体を参照する方法で予約が現地決済済みかどうかを返すことができました。
おわりに
本記事ではSchema Stitchingを用いたサービス間通信の実装例を紹介しました。
参考にしていただければ幸いです。