モノタロウのフロントエンド刷新の取り組み④~BFF×GraphQL導入の狙いとスキーマ設計の試行錯誤

はじめに

こんにちは。モノタロウでECサイトの開発・運用を担当している菊川です。

モノタロウではECサイトのフロントエンド刷新に取り組んでおり、その内容をブログで共有したいと思います。よろしければ、関連記事もあわせてチェックしてみてください。

今回はこの活動におけるBackend For Frontend(BFF)層の導入とGraphQLの採用の取り組みについて紹介させて頂きたいと思います。

この記事では以下について解説します:

  • レガシーなフロントエンドシステムにおけるデータ取得処理の課題(多数のAPIエンドポイント、ページ間の重複実装、手続き的コードによる可読性低下)
  • BFF層の導入とGraphQLの採用により、それらの課題をどのように解消・軽減したか
  • PoCのスキーマ設計で失敗した実例と、そこから得た教訓

刷新前のフロントエンドシステムの課題

バックエンドからのデータ取得処理に焦点を当てると、旧フロントエンドシステムには以下の3つの課題がありました。

  1. 取得すべきデータの多さ ― リクエストを送るべきバックエンドAPIのエンドポイントが多い
  2. ページ毎に重複実装されたデータ取得処理
  3. 手続き的記述による可読性・保守性の低下

以下、それぞれの課題について詳しく説明します。

1. 取得すべきデータの多さ

ページにもよりますが、レンダリングのために、十数種類ものバックエンドAPIからデータを取得する必要があります。例えば商品ページでは、以下のようなデータをそれぞれ別のエンドポイントから取得しなければなりません。

  • 商品グループ情報
  • SKU情報
  • 在庫情報
  • 特価情報
  • 商品レビュー情報
  • 商品FAQ情報
  • 商品販促情報
  • etc...

商品ページのSKU表のUIを一つ取っても、以下の図のように合計4種類ものAPIの情報をもとに画面が構築されています。

エンドポイント毎にリクエストやレスポンスのデータ加工が必要であり、各エンドポイントの仕様を個別に理解しなければなりません。開発者の認知負荷が高く、コード量も自ずと増えるため、ソースの可読性が低い状態でした。

2. ページ毎に重複実装されたデータ取得処理

当社のページには似たUIが複数のページで表示されるケースがあり、例えば以下の画像のような検索ページ詳細モードと商品ページのSKUは表示される情報と見た目がかなり似ています。

商品ページSKU表
商品ページ

検索ページ(詳細モード)
検索ページ(詳細モード)

これらのページのデータ取得処理をそれぞれ擬似コードで示すと以下のようになっています。(説明の都合上、かなり簡素化しています。)

// 検索ページの処理
products = searchProducts(searchCondition)
for each product in products:
    skus   = fetchSKUs(product.id)       // ← 共通化可能
    skuIds = extractIds(skus)            // ← 共通化可能
    prices = fetchPrices(skuIds)         // ← 共通化可能
    stocks = fetchStocks(skuIds)         // ← 共通化可能
// 商品ページの処理
product = fetchProduct(productCode)
skus    = fetchSKUs(product.id)          // ← 共通化可能
skuIds  = extractIds(skus)               // ← 共通化可能
prices  = fetchPrices(skuIds)            // ← 共通化可能
stocks  = fetchStocks(skuIds)            // ← 共通化可能

このように、最初のステップ(商品グループ情報の取得方法)はページによって異なりますが、それ以降のSKU情報・価格情報・在庫情報の取得処理はほぼ同じです。にもかかわらず、各ページに個別に実装されていました。

こうした重複は検索ページと商品ページに限った話ではなく、他のページにも散見されており、保守性に課題がありました。

3. 手続き的記述による可読性・保守性の低下

上記のデータ取得処理は手続き的に記述されていました。

例えば検索ページでは、度重なる機能改修の結果、データ取得・加工処理のほぼすべてが1つのメソッドに集約されていました。このメソッドは1000行を超える、責務が分離されていない巨大な手続き的コードになっていました。

このコードの中では前後の依存関係と条件分岐が入り乱れており、処理の全容を把握することが困難で、開発・保守・運用のいずれにおいても課題がありました。

課題に対するアプローチ: Backend for Frontend(BFF)層の導入とGraphQLの採用

これらの課題を整理すると、根本にあるのは「多数のバックエンドAPIへのデータ取得処理がフロントエンドに埋め込まれていること」でした。そこでまず、データ取得処理をフロントエンドから切り離すために、バックエンドとフロントエンドの間に中間層――Backend for Frontend(BFF)層を導入することにしました。

さらに、BFF層のインターフェースとしてGraphQLを採用しました。GraphQLには、スキーマとResolver関数によりデータ取得ロジックを型単位で定義・共通化できる仕組みがあり、ページ間の重複実装の解消が期待できます。加えて、フロントエンド側ではクエリを宣言的に記述するだけで必要なデータを取得でき、手続き的なコードからの脱却も見込めます。

このアプローチにより、各課題は以下のように解消・軽減できると考えました。

1. 取得すべきデータの多さ => BFF層にデータ取得処理を移行

BFF層を導入しデータ取得処理を移譲することで、フロントエンドはHTMLのレンダリングとユーザーインタラクションの実装に、BFF層はデータ取得処理にそれぞれ集中できるようになります。

フロントエンドは宣言的なGraphQLクエリを書いてBFF層へリクエストするだけで、ページのレンダリングに必要なデータを過不足なく取得できるようになります。

導入前 フロントエンド HTMLレンダリング ユーザーインタラクション - 商品情報API呼び出し - 価格情報API呼び出し - 在庫情報API呼び出し - ・・・ 商品情報API 価格情報API 在庫情報API ・・・ 導入後 フロントエンド HTMLレンダリング ユーザーインタラクション BFF層 商品情報API呼び出し 価格情報API呼び出し 在庫情報API呼び出し ・・・ GraphQLクエリ 商品情報API 価格情報API 在庫情報API ・・・

2. ページ毎に重複実装されたデータ取得処理 => GraphQLスキーマとResolver関数による共通化

GraphQLのサーバー実装ではデータ取得処理を型に対して定義できます。単純化した例ですが、前の節で言及した重複処理は以下のようにGraphQLのスキーマとResolver関数を定義することで共通化できます。

スキーマ定義

type Price {
  unitPrice: Int!
  # other fields ...
}

type Stock {
  quantity: Int!
  # other fields ...
}

type Item {
  id: ID!
  name: String!
  price: Price!
  stock: Stock!
  # other fields ...
}

type Product {
  itemsInProduct: [Item!]!
}

Resolver関数

@Resolver((of) => Product)
class ProductResolver {
  @ResolveField((of) => [Item])
  itemsInProduct(
    @Parent() product: Product,
  ){
    return itemService.getItemsByProductId(product.id)
  }
}

@Resolver((of) => Item)
class ItemResolver {
  @ResolveField((of) => Price)
  price(
    @Parent() item: Item,
  ){
    return priceService.getPriceByItemId(item.id)
  }

  @ResolveField((of) => Stock)
  stock(
    @Parent() item: Item,
  ){
    return stockService.getStockByItemId(item.id)
  }
}

このように、SKU情報・価格情報・在庫情報の取得処理をそれぞれResolver関数として定義することで、どのページから利用しても同じロジックで取得処理が実行されるようになります。

3. 手続き的記述による可読性・保守性の低下 => GraphQLによる宣言的な取得

BFF層にGraphQLを採用することで、フロントエンド側のデータ取得処理はGraphQLクエリに置き換わります。GraphQLクエリは以下のように宣言的な形式です。GraphQLに馴染みがない方でも、商品グループのグループID・グループ名、そしてそれに紐づくSKUのSKU ID・SKU名・価格を取得していることがクエリから読み取れるのではないでしょうか?

query getProduct($input: ProductInput){
  product(input: $input){
    id
    name
    itemsInProduct{
      id
      name
      price
      ...
    }
    ...
  }
}

GraphQLを使うことで、各ページのクエリを読めばそのページで使われているデータが一目瞭然になります。

なお、BFF層の導入を含むフロントエンド刷新の取り組みがもたらした定量的な効果については、連載記事「モノタロウのフロントエンド刷新の取り組み②~工数34%削減を実現した刷新の効果」にて、他の取り組みとあわせて紹介しているのでぜひ読んでみてください。

BFF層の導入を進める上で得られた学び

上記の効果を狙って、BFF層の導入とGraphQLの採用を進めることにしました。当時、社内にGraphQLの先行事例はなかったため、PoC的に進める方針とし、比較的簡単なページ(買ったものリストページ)を題材に取り組みました。PoCではさまざまな気付きがありましたが、ここではスキーマ設計に関する学びを一つ紹介させてください。

PoCでのスキーマ設計の失敗例: フロントエンドに寄せすぎたスキーマ設計

GraphQLのスキーマ設計では、「クライアントのニーズに合わせて設計する」ことが推奨されています。 GraphQLのドキュメントでは、以下のように述べられています。

prefer building a GraphQL schema that describes how clients use the data, rather than mirroring the legacy database schema.

また、Apolloのドキュメントにも同様の説明があります。

A GraphQL schema is most powerful when it's designed for the needs of the clients that will execute operations against it. Although you can structure your types so they match the structure of your back-end data stores, you don't have to! A single object type's fields can be populated with data from any number of different sources. Design your schema based on how data is used, not based on how it's stored.

このプラクティスを踏まえ、PoCではフロントエンド側はBFF層から受け取ったデータを「そのまま」表示に使い、表示ロジックはBFF層に実装する方針で進めました。 その結果、SKUを表すItemモデルは以下のようなフィールドを持った型になっていました。

type Item {
  # ...

  # バスケットボタンを表示するか否か
  shouldShowBasketButton: Boolean!

  # 出荷日時注意メッセージ
  shipAlertMessage: String!

  # ...
}

買ったものリストページでは、これらのフィールドは複数のバックエンドAPIの結果をもとに算出していました。

しかし、このItemモデルを他のページ(商品ページや検索ページ)でも使おうとしたところ、問題が見つかりました。「バスケットボタンを表示するか否か」や「出荷日時注意メッセージの文言」は、ページによってロジックや参照するデータソースが微妙に異なっていたのです。この違いにBFF層で対応しようとすると、resolver内でリクエスト元のページに応じた分岐処理が必要になり、resolverが複雑化してしまいます。これでは本末転倒です。

失敗を踏まえて決めた方針

PoCでの失敗を受けて、他のページへ展開する際のスキーマ設計方針を見直すことにしました。

この検討にあたり、先ほども参照したThinking in Graphsを改めて読み込みました。このドキュメントでは、ビジネスロジックレイヤーを分離し、GraphQLレイヤーはそのエントリーポイントの一つとして機能すべきであるという設計思想が示されています。

この考え方を踏まえ、バックエンドAPIがビジネスロジックを実装しているという前提のもと、BFF層はデータのアグリゲーションに徹する方針としました。具体的には、BFF層の責務を「複数のバックエンドAPIからデータを取得し、集約・紐づけしてフロントエンドに返すこと」に限定し、表示ロジックや計算処理は原則としてフロントエンド側で実装します。

前項で示したPoCのスキーマは、shouldShowBasketButtonshipAlertMessageのように表示ロジックの結果を返す設計でしたが、前述のとおりページ間での共通化に問題がありました。方針見直し後のスキーマは以下のように変更しました。

type Item {
  # ...

  # 在庫情報
  stockInfo: ItemStockInfo!

  # SKU補足情報
  supplementalInfo: ItemSupplementalInfo!

  # ...
}

BFF層は表示ロジックの結果ではなく、在庫情報やSKU補足情報といった元データを集約して返すようにしました。フロントエンドはこれらの元データをもとに、各ページのロジックに従って「バスケットボタンを表示するか否か」の判定や「出荷日時注意メッセージ」の算出を行い、表示に反映させます。こうすることで、ページごとに表示ロジックが異なっていても、BFF層のスキーマやresolverを変更する必要がなくなりました。

この方針のもと商品ページや検索ページへの展開を進めた結果、複数のページでBFFアプリケーションを共通利用しながら、各ページのフロントエンド刷新を進められています。

まとめ

本記事では、弊社のフロントエンド刷新におけるBFF層の導入とGraphQLの採用について紹介させて頂きました。

GraphQLの設計・実装は奥が深く、今回紹介した以外にも、PoCから現在に至るまでに様々な失敗や学びがありました。スキーマ設計一つ取っても細かなプラクティスがいろいろとありますし、パフォーマンスを悪化させない実装上の工夫もいくつか必要でした。これらについても機会を見つけて発信できたらなと思っています。

今回は最後までお読みいただき、ありがとうございました。この記事が、BFF層の導入やGraphQLの採用を検討されている方の参考になれば幸いです。

もし、私たちの取り組みに興味をお持ちいただけましたらカジュアル面談などお声掛けいただけるとうれしいです!

カジュアル面談で話を聞いてみる