STORES Product Blog

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

サービス間通信を GraphQL Schema Stitching で実装する

はじめに

STORES 予約でエンジニアをしているhiromu617です。この度 STORES では STORES レジ に STORES 予約 がもつ予約情報を連携できる機能をリリースしました。

この機能を提供するにあたってサービス間で通信をする必要がありました。サービス間の通信には、Schema Stitchingという技術を活用しています。

本記事では、Schema Stitchingの活用例について紹介します。

どのような機能か

STORES レジ に STORES 予約 がもつ予約情報を連携できる機能です。STORES 予約を通じて入れた予約情報を元に STORES レジ で会計することができます。

予約情報を連携した STORES レジの画面

前提として STORES では商売に関する複数のプロダクトが展開されています。

それらのプロダクトのすべてが STORES という1つの会社から生まれた訳ではなく、元々別の会社のプロダクトとして生まれ、 STORES に統合されたという歴史があります。STORES 予約 、STORES レジ もその例外ではなく元々別々の会社で生まれたプロダクトでした。

この機能の実現の裏側には、事業者や店舗を管理するシステムや認証基盤をはじめとする共通基盤の各プロダクトへの導入があります。今回は、それらの共通基盤が導入された前提での話なのでそれらの説明は割愛します。

システムの構成

予約情報を連携する以前の STORES レジ は次のような構成で動いていました。(説明のため簡略化しています)

STORES レジ構成図 (予約情報を連携する前)

クライアントであるレジアプリとレジバックエンドはGraphQLを用いて通信を行なっています。また、レジバックエンドはRuby on Railsで動いており、graphql-rubyによりGraphQLサーバーが実装されています。

product.st.inc

予約情報を連携した STORES レジ は次のような構成で動いています。予約バックエンドもレジと同様にRuby on Railsで動いており、graphql-rubyによりGraphQLサーバーが実装されています。

予約情報を連携した STORES レジの構成図

ここで、前述のとおり、プロダクト間の通信に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

つまり、開発のサイクルは次のようになります。

  1. 予約バックエンドで実装を行う。
  2. レジバックエンドはそれをもとにオブジェクトの定義とリゾルバーを実装する。
  3. アプリ側でAPIの繋ぎ込みを実装する。

この方法で開発を進める中で大きな問題がありました。

それは、それぞれの作業が前の作業に依存しているため開発のリードタイムが大きくなってしまうことです。予約バックエンドで変更がある度に、レジバックエンドはその変更に追従する必要がありとても面倒です。

また、レジバックエンドと予約バックエンドで重複する記述が多く無駄の多いコードになってしまいます。

ここで、これら問題を解決するためにSchema Stitching の採用を検討しました。

Schema Stitchingとは

Schema Stitchingはgraphql-toolsによって提供されている機能の1つです。

Schema Stitchingを利用すると、複数のGraphQLのリソースが存在する構成において、各リソースのスキーマをマージした1つのスキーマをクライアントに提供することができます。

https://github.com/gmac/graphql-stitching-ruby より引用

今回の事例に置き換えると、レジバックエンドが提供するスキーマと予約バックエンドが提供するスキーマをマージしたスキーマがクライアントに提供されます。そして、クライアントから透過的に予約バックエンドにアクセスすることができます。

また、複数のリソースに跨ったデータの取得もライブラリによって行われます。前述のようなResolver内でデータを取得する実装は不要になります。

https://github.com/gmac/graphql-stitching-ruby より引用

Schema Stitching を利用すると開発のサイクルは次のようになります。

  1. 予約バックエンドで実装を行う。
  2. Schema Stitchingによってマージされるスキーマを更新する
  3. アプリ側でAPIの繋ぎ込みを実装する。

つまり、予約バックエンドに変更があった際、レジバックエンドはスキーマを更新するだけで良いということになります。

実装には graphql-stitching-rubyのgemを利用しています。

github.com

Schema Stitchingと似たアプローチとしてApplo Federationが存在します。こちらはSchema Stitching とはフィールドを解決するアプローチが異なっています。

zenn.dev

実装例

予約情報を 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 では、非同期でイベントを送受信できる仕組みが備わっておりこれを利用します。以下の手順で予約が現地決済済みかどうかをクライアントに返します。

  1. レジでお会計をする。
  2. レジバックエンドでOrderが作成される。Order作成イベントが送信される。
  3. 予約バックエンドでOrder作成イベントを受信する。これをもとにOrderのキャッシュをDBに保存する。
  4. 予約情報を返す際、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を予約側が返すReservationidと同一の値を返す必要があります。

実装は以下のようになります。

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には、今回はReservationidのフィールドをレジ側、予約側それぞれで同一となるように実装を行ったので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
  }
}

この時、ライブラリによって次のような流れでクライアントにデータが返されます。

  1. レジから予約に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を用いたサービス間通信の実装例を紹介しました。

参考にしていただければ幸いです。