DMM.comの、一番深くておもしろいトコロ。

DMM GAMESのプラットフォームリプレイスを支えるBackends For Frontends (BFF) の裏側

DMM GAMESのプラットフォームリプレイスを支えるBackends For Frontends (BFF) の裏側

  • このエントリーをはてなブックマークに追加

はじめに

こんにちは、合同会社EXNOAプラットフォームマイグレーション部の松下です。DMM GAMESのオンラインゲームプラットフォームのリプレイスプロジェクトでフロントエンド開発を担当しています。

プラットフォームマイグレーション部では2020年3月よりオンラインゲームプラットフォームのリプレイスに取り組んできました。現在までにメンテナンス障害情報ページリストページMyゲームトップページのリプレイスを行い、エンドユーザーに向けて公開しています。

リプレイスではアーキテクチャパターンであるBackends For Frontends (以下BFF) を活用しています。本記事ではBFF導入の背景から構築、運用までを紹介します。

BFFの導入を検討している方や、すでに運用されている方の参考になれば幸いです。

BFFとは何か

BFFはクライアントやUIの単位で専用のバックエンドを構築するアーキテクチャパターンです。

BFFを解説しているSam Newman氏の記事では、BFFで解決したい課題が紹介されています。

下図のように、モバイルとWebで異なるユーザー体験を提供するといった場合、APIに求める要件はクライアントごとに異なってきます。このとき、単一のAPIで複数クライアントの要件を満たすように実装すると、APIの変更競合や組織内のコミュニケーションコスト増加でデプロイのボトルネックが発生します。

複数クライアントの要件を含んだバックエンドを構築するパターン

この問題に対し、クライアントごとにバックエンドを構築して、それをクライアント開発と同一のチームが管理することで開発チームの自律性を維持することができるようになります。

クライアントごとにバックエンドを構築するパターン

近年ではマイクロサービスアーキテクチャの普及により、分割されたマイクロサービス群を集約し、UIに適したデータに加工して、フロントエンドに返却する役割を担っている例を見ることが多いです。

Sam Newman - Backends For Frontends

BFFの導入背景

リプレイスプロジェクトの開始

DMM GAMESのオンラインゲームプラットフォームはPCやスマートフォン向けのブラウザ用ゲームを提供するための基盤です。

昨年サービス開始から10周年を迎え、一部のシステムは長期の運用により、開発のアジリティが低下していました。今後さらに事業を成長させていくために、エンドユーザー向けに提供している機能を中心にプラットフォームのリプレイスを実施する意思決定がされました。

既存システムの課題

既存システムの最大の課題は、変更時の影響範囲が不明瞭な状態のため、エンドユーザーに機能を提供するまでリードタイムが長くなっていた点です。

複数のモノリシックなアプリケーションで構成されており、それぞれのアプリケーションでフロントエンドとバックエンドが密結合な状態で、画面主導でアプリケーションが分割されているため、同じような処理を行うビジネスロジックが複数箇所に存在します。

複数のモノリシックアプリケーションで構成される既存システム

※ 本記事中に出てくる構成図は構成要素 (マイクロサービスやモノリス) の個数や通信経路など、実際のシステムに即していない部分があります。

リプレイスシステムのコンセプト

今回のリプレイスプロジェクトではこの課題を解決するため、マイクロサービス化を進めています。ドメイン駆動設計を取り入れて、境界づけられたコンテキストでバックエンドを分割、エンドユーザーに提供する機能単位でフロントエンドを分割し、システム変更時の影響範囲を局所化するように設計することで、開発のアジリティを高めていく戦略です。

この戦略の肝となるのがBFFです。フロントエンドとバックエンドがそれぞれの責務に集中できるように構築しますが、システムとしては結合しないと成立しません。そこでフロントエンドとバックエンドの分割粒度の差を埋めるため、BFFはバックエンドのアグリゲーションを行い、フロントエンド構築に必要なデータを返却する役割を担っています。

マイクロサービスで構成されるリプレイスシステム

リプレイスシステムへの移行

リプレイスシステムと既存システムの前段にCloudFrontを配置しており、URLパスレベルでリクエストを制御しています。リプレイスが完了した機能単位でリクエストをリプレイスシステムに振り分けることで順次移行を進めています。

既存システムからリプレイスシステムへの移行

BFFの技術スタック

今回のリプレイスでは、リプレイスシステムのコンセプトへの適合とフロントエンドの開発効率を考慮して、APIの規格としてはGraphQLを採用し、実装はApollo Serverを用いて行いました。

フロントエンド構築に必要なデータが過不足なく取得可能

GraphQLは宣言的なデータフェッチングをサポートしており、フロントエンド構築に必要なデータを過不足なく取得できます。

従来のRESTではリソースを中心にAPIエンドポイントを設計するため、単一のAPIエンドポイントの呼び出しではフロントエンド構築に必要なデータが取得できないアンダーフェッチングや、必要以上なデータを取得してしまうオーバーフェッチングが生じていました。

これらの問題をRESTで解決しようとすると、画面のユースケースに合ったAPIを構築することになり、ビジネス要件の変更への柔軟性が失われることもあるため、悩ましい問題でした。

GraphQLの採用により、フロントエンドとバックエンドはそれぞれの責務に注力することが可能となりました。

フロントエンド・BFFの開発効率を向上させる仕組みが利用可能

GraphQLは開発効率を向上させるための様々なエコシステムが企業やコミュニティから公開されており、私たちのチームでもその恩恵を受けています。利用している一部の仕組みを紹介します。

GraphQLモックサーバーでフロントエンドやBFFを非同期に開発

Apollo Serverの機能でスキーマからGraphQLのモックサーバーを構築しています。バックエンドの構築を待たずに、フロントエンドの実装が可能となっています。

Mocking - Apollo GraphQL Docs

型定義の自動生成でBFFを型安全に開発

GraphQL Code Generatorでスキーマからリゾルバの型定義、openapi-typescriptでOpenAPI定義からデータソース (バックエンドの呼び出し処理) の型定義をそれぞれ生成しています。これらを使ってBFFを実装することで、型安全に開発を進められています。

Fragment Colocationパターンでクエリとコンポーネントを包括的に管理

フロントエンドはFragment Colocationパターンを用いた開発を行っています。

GraphQLではフラグメントという仕組みがあり、複数のクエリやミューテーション間でロジックの共有が可能です。フラグメントにコンポーネントの描画に必要なクエリ呼び出しをまとめて、コンポーネントとセットで管理するようにして、保守性を確保しています。

また、クエリ単位でApollo Clientのデータ取得用React HookをGraphQL Code Generatorを用いて生成し、開発効率の向上につなげています。

TypeScript React Apollo | GraphQL Codegen Plugin Hub

下記の例の場合は人気ランキング表示用のコンポーネントと人気ランキング表示用のフラグメントをセットで管理しています。

// 人気ランキング情報の表示に必要なフラグメント
export const POPULAR_RANKING_GAME_FRAGMENT = gql
  fragment PopularRankingGameFragment on Game {
    id
    name
    description
    thumbnail
    link
  }
;
// 人気ランキング情報の表示に必要なクエリ
// クエリ単位でApollo Clientのデータ取得用React Hookが生成
export const POPULAR_RANKING_QUERY = gql
  ${POPULAR_RANKING_GAME_FRAGMENT}
  query PopularRankingQuery(
    # 省略
  ) {
    games(
      # 省略
    ) {
      ...PopularRankingGameFragment
    }
  }
;
// 人気ランキング表示のコンポーネント
export const PopularRanking = () => {
  const { data } = useClientPopularRankingQuery();
  return (
    <>
      <h1>人気ランキング</h1>
      <ul>
        {data.games.map((game) => (
          <li key={game.id}>
            <article>
              <a href={game.link}>
                <h2>{game.name}</h2>
                <p>{game.description}</p>
                <img src={game.thumbnail} alt="" />
              </a>
            </article>
          </li>
        ))}
      </ul>
    </>
  );
};

BFFの開発

フロントエンドエンジニアが開発を担当

BFFはフロントエンドエンジニアが開発を担当します。機能要件を満たすためのフロントエンドと、それを構築するために必要なデータを返却するBFFをセットで実装します。

フロントエンドエンジニアとバックエンドエンジニアが開発を担当する領域は下図の通りです。

フロントエンドエンジニアとバックエンドエンジニアの開発領域

実際のBFF開発の流れとしては、下記の通りです。

  • 機能要件確認
    • 画面確認
      • Figmaに定義された画面デザインを見ながら、実装する上での懸念がないか確認
    • 責務確認
      • 要件を実現するために必要な処理について、フロントエンド、BFF、バックエンドのどこに持たせるかを議論
  • GraphQLスキーマ定義
    • 機能で必要な型 ( Object types ) の定義と、関係性の整理を行う
      • オンラインホワイトボードなどに簡単に書いたスキーマベースで会話
    • スキーマファイルを更新する
      • Pull Requestベースで議論を行う
  • 各種実装 (非同期で作業実施)
    • フロントエンド実装
      • BFFモックサーバーを立てて、フロントエンドを実装
    • バックエンド実装
    • BFF実装
      • バックエンド実装後にリゾルバを実装
  • システム結合
    • 開発環境で動作確認を実施

機能要件確認とGraphQLスキーマ定義は機能開発のはじめに行い、チームメンバーがオンライン会議ツールで集まり、同期的に行っています。ある程度開発の方向性が決まると、システムの実装は非同期的に進められます。システムの結合はスプリントごとに細かく行い、結合リスクを低減しています。

開発対象の機能の特性にもよりますが、フロントエンドとBFFの双方を構築するフロントエンドエンジニアの方が、バックエンドエンジニアと比較して開発工数がかかるケースが多かったです。UIとビジネスロジックの分離に対して最初にコストをかけることで、中長期での開発のアジリティの確保を期待しています。

運用観点では負荷対策やモニタリングなど、フロントエンド以外の専門的なスキルを要求される場面があるため、バックエンドエンジニアやSREと協業して進めています。

責務はAPIアグリゲーションに留める

BFFはAPIから取得したデータを組み合わせて、UI構築に必要なデータに変換する責務を担っています。

ビジネスロジックをバックエンドが担うことで、BFFの責務が肥大化することを回避しています。プラットフォームの保守性や拡張性を長期に渡って確保するため、機能要件を実現する上で必要な処理をどこに持たせるかは、チーム内で必要に応じて議論を行っています。

また、レガシーなバックエンドのデータをUI構築で必要とする場合のファサードやアダプターとしても活用しています。このようなバックエンドでは他システムの依存関係からインタフェースの変更を行えない場合があります。BFFでデータの変換やインタフェースの提供を行うことで、フロントエンドはレガシーなバックエンドの影響を受けずに実装を行うことができます。

BFFの運用

GraphQLスキーマの運用

抽象と具体の使い分けでスキーマ定義

スキーマ定義はGraphQL開発の中でも難易度が高い作業です。スキーマ設計次第では拡張性や再利用性が失われて、保守が困難になります。

GraphQLを導入した当初は画面実装でクエリが必要になるたびにスキーマを定義していました。この進め方では特定の画面要件がスキーマに反映されてしまい、別の画面を開発する際に再利用が難しくなるという問題が発生しました。

現在の開発ではやり方を変えて、機能実装で必要な型 (Object types) の抽出とそれらの関係性の整理を行い、そのあとにスキーマファイルの更新とレビュー、という流れで開発を進めました。

まずはObject Typesを抽出し、関係性を整理します。ホワイトボードに内容をまとめて、チーム内で議論を行っています。

次にスキーマファイルを更新します。Object Typesに加えて、QueryやFieldなど細部の定義を追加しています。作業完了後にチーム内でPull Requestのレビューを行います。

スキーマ定義についてはShopifyのGraphQLチュートリアルの記事が役立ちました。

チュートリアル: GraphQL APIの設計

セマンティックバージョニングでスキーマを安全に変更

スキーマは複数チームが更新を行う形で運用されています。開発速度を落とさずに、かつ安全に変更を行うために、スキーマのバージョニングにセマンティックバージョニングを採用しました。

原則としてスキーマ更新は後方互換性を保った形で行われ、更新に機能性が含まれるかどうかでマイナーとパッチの更新を使い分けています。破壊的な変更が含まれる場合はメジャーの更新を行っています。

セマンティック バージョニング 2.0.0 | Semantic Versioning

スキーマファイルはnpmパッケージの形で配布され、共通ライブラリやアプリケーションで利用する形になっています。スキーマ更新の適用はRenovateを用いて、マイナーやパッチのバージョンアップは自動で行い、破壊的な変更があるメジャーのバージョンアップは手動で行っています。

共通ライブラリやアプリケーションはスキーマに依存

また、スキーマファイル更新のPull RequestではGraphQL Inspectorを実行しています。破壊的変更がある箇所にはアノテーションを付与しています。これにより、意図しない破壊的変更を防いでいます。

GraphQL Inspectorによるスキーマの破壊的変更の検知

可観測性・負荷対策

分散トレーシングによるリクエストの可視化

GraphQLは単一のAPIエンドポイントで様々なクエリを受け付けるため、Rest APIのようにAPIエンドポイントごとに監視ができません。また、一度のリクエストで様々なマイクロサービスと通信をしているため、可観測性を確保することが困難です。

この課題のアプローチとして、Datadog APMでトレースを収集し、リクエストを可視化しています。こうすることでマイクロサービスの通信経路や処理時間、エラー発生など視覚的に確認できます。

また、アクセスログやエラーログもトレースに関連付けており、インシデント対応やバグ修正などの場面で効果的に利用しています。

分散トレーシングによるリクエストの可視化

実際のアクセスに近い形での負荷試験の実施

BFFを本番環境で稼働させるためには、リリース前に負荷試験を実施し、本番環境で想定される負荷に対しての性能担保とインフラの適切なキャパシティプランニングが必要です。

現在のアーキテクチャではBFFは複数のフロントエンドが呼ばれる構成になっています。それぞれのフロントエンドは呼び出すクエリやアクセス特性が異なります。BFF単体の負荷試験では性能を担保することが難しかったので、フロントエンドとバックエンド、BFFをすべて結合した状態で負荷試験を実施しました。

負荷試験の詳細はDMM.go #4 (YouTube)の「大規模プラットフォームにおけるGoの利用例」で発表されているので、ご興味ある方はリンクからご確認ください。

リゾルバ単位のキャッシュの導入

5月にエンドユーザーに公開したトップページは、開発段階から負荷の懸念を抱えていました。

  • 既存システムの運用実績からある程度のアクセスが見込まれた
  • トップページの要件を満たすためにバックエンドの呼び出しの回数増加や複雑化が予想された
    • 様々なランキングやバナーの表示
    • エンドユーザーごとに最適化されたコンテンツの表示

リプレイスシステムの可用性や弾力性を検討した結果、BFFのリゾルバ単位のキャッシュを導入し、バックエンドの負荷軽減を図ることにしました。

キャッシュ導入では各リゾルバをキャッシュ対象可否とキャッシュ対象のデータの種類で分類しました。

  • キャッシュする
    • パブリックキャッシュ
      • ユーザーにかかわらず内容が同じデータ
    • プライベートキャッシュ
      • ユーザーごとに内容が変わるデータ
  • キャッシュしない

キャッシュの書き込みや読み取りの処理を担う関数を準備し、それを各リゾルバで呼ぶようにして、簡単にキャッシュを導入できるようにしました。

// 各リゾルバの実装
export const foo = async (
  _,
  args,
  context
) => {
  const callback = async () => {
    // リゾルバの処理を書く
    // REST APIへのデータ取得処理やデータ加工処理を書く
    return datasource.getFoo()
  };
  // キャッシュの書き込みや読み取りを担う関数の呼び出し
  // リゾルバ処理をコールバックで渡す
  return connectCache({
    // キャッシュのキーを生成するためのデータ
    keyConfig: {
      id: 'foo',
      // キャッシュの種類 (PUBLIC or PRIVATE)
      scope: 'PRIVATE',
      // 認証やデバイス等の情報
      context,
      params: args,
    },
    // キャッシュ期限
    expire: 600,
    callback,
    fallback: null,
  });
};

キャッシュを利用したときのリゾルバの処理の流れは下記の通りです。

キャッシュ利用時のリゾルバの処理

キャッシュを導入したことで、画面表示に必要なデータの多くをキャッシュから取得するようになり、バックエンドへの通信を大幅に減らすことができました。

キャッシュ導入結果

BFFの今後

リプレイスシステムの適用

リプレイスシステムは2つのチームが協業して開発・運用を行っています。リプレイスチームは複数のリプレイス対象の既存システムを、順番に新しいシステムに置き換えています。

一方のプロダクトチームは、既存システムとリプレイスシステムの双方の機能追加や保守を行っています。

開発組織とシステム開発

将来的にはエンドユーザーに提供している機能の大半はリプレイスシステムに置き換えられて、既存システムは縮小していく予定です。リプレイスチームとプロダクトチームでリプレイスシステムに対するナレッジギャップがある状況で、組織全体にリプレイスシステムをどのように適用していくかが課題になっています。

現在、この課題に対しては2つの取り組みを行っています。

1つ目はよもやま会の実施です。毎週リプレイスチームとプロダクトチームのフロントエンドエンジニアが集まり、システムを開発・運用する上で課題について話し合ったり、リプレイスシステムのアップデート情報の共有を行ったりして、双方の開発が円滑に進むように取り組んでいます。

2つ目はレビューです。プロダクトチームがリプレイスシステムの改修を行うときに、プロダクトチーム内で行われるコードや設計のレビューに加えて、リプレイスチームもレビューに参加しています。リプレイスチームからはリプレイスシステム特有の設計や実装の考慮点のフォローを行っています。

レビューは品質担保につながる一方で、双方のチームの開発スピードに影響してくるため、将来的に各チームで自律した開発ができることを目標として、サポートのあり方を考えています。

さいごに

オンラインゲームプラットフォームのリプレイスを支えるBFFの導入から運用までを紹介しました。

現在までに複数アプリケーションをエンドユーザーに公開し、BFF自体も1年半の運用を行ってきました。現段階ではフロントエンドとバックエンドをそれぞれの責務に集中させるというリプレイスシステムのコンセプトの狙い通りになっています。

一方でリプレイスシステムの組織全体への適用は道半ばです。複数チームの協業が必要で様々な課題を抱えています。

今後はプロダクトチームがリプレイスシステムを改修する機会も増えてくる予定です。機能開発を柔軟かつ迅速にできるプラットフォームの実現を目指して、リプレイスシステムの開発と運用に取り組んでいきます。

EXNOAではゲームプラットフォーム「DMM GAMES」のフロントエンドの開発を担うフロントエンドエンジニアを募集しています。ご興味のある方は以下の募集ページをご確認ください。

dmmgames.co.jp