ROUTE06 にて業務委託でフロントエンドエンジニアをしている 寺嶋 です。
先日、私が開発に参画している to B サービス(以下、本サービス)にて一部機能を Server-Driven UI へ移行しました。
本記事ではその移行事例について紹介します。
※一般に公開しているサービスではないため、紹介する GraphQL スキーマの命名は汎用的なものに置き換えています。
移行の背景
本サービスには特定の項目を表として出力する画面がありました。
具体的には下記のItemDetail
に対して、
type ItemDetail { itemOrder: Int! itemId: Int! name: String note: String size: Int ratio: Float memo: String }
バックエンドから以下のような JSON が返却され、
[ { "__typename": "ItemDetail", "itemOrder": 1, "itemId": 1, "name": "適当な名前 1", "note": "適当なノート 1", "size": 1, "ratio": 1.1, "memo": "なし" }, { "__typename": "ItemDetail", "itemOrder": 2, "itemId": 2, "name": "適当な名前 2", "note": "適当なノート 2", "size": 2, "ratio": 1.2, "memo": null } ]
以下のようにテーブルに項目を表示するイメージです。ちなみに、表示された項目は必要に応じて編集することもできます。
名前 | ノート | サイズ | 比率 | メモ | |
---|---|---|---|---|---|
1 | 適当な名前 1 | 適当なノート 1 | 1 | 1.1 | なし |
2 | 適当な名前 2 | 適当なノート 2 | 2 | 1.2 |
名前、ノート、サイズなど、この表に出力される項目はサービスのリリース当初から静的に決められており、ユーザごとに項目を動的に変更することはできませんでした。
この機能において、新規に これらの各項目をユーザーごとに設定可能にする ことが求められました。
具体的には上記の例でいうところの「名前(name
)」や「ノート(note
)」などといった項目について、ユーザーごとに別々の項目名が設定可能になる、ということです。
この要件を満たすためには従来の UI の表示方法では対応できないため、後述する Server-Driven UI への移行が求められました。
Server-Driven UI による解決策の概要
ユーザーごとに任意の項目をテーブルに表示するためには、フロントエンドでバックエンドから取得したデータをもとに UI を構築する必要があります。これはいわゆる Server-Driven UI と呼ばれるもので、バックエンドで UI の構造を定義し、フロントエンドでその定義に従って UI を構築するという設計方針です。
今回のケースでは以下のようなデータモデルを導入しました。
type ItemDetailV2 { itemOrder: Int! itemId: Int! itemAttributes: [ItemAttribute!]! } type ItemAttribute { key: String! value: String type: ItemAttributeType! label: String! } enum ItemAttributeType { STRING INT DECIMAL }
先ほどの例でいうと、例えば「名前(name
)」はItemAttribute
を用いて以下のような構造になります。
{ "key": "name", "value": "適当な名前 1", "type": "STRING", "label": "名前" }
このようにItemAttribute
を用いて各項目を表現し、ItemDetailV2
にそれらをまとめることで、ユーザーごとに異なる項目を表示することが可能になります。
フロントエンドの実装手順
フロントエンドの実装は以下の手順で進めました。
- フロントエンドにおけるデータの変換が動作するか確認
- データの変換処理を抽象化し、再利用可能な関数として実装
- データのバリデーションとフォーマット処理の修正
- 全体的な画面への適用
まずは小規模な PR を作成し、ItemDetailV2
からテーブルに表示するための行情報へのデータの変換処理や画面への表示がうまく動作するか確認します。この PR の時点で実装内容の合意が取れたため、全体的な画面への適用に進みました。
次にデータの変換処理を抽象化し、再利用可能な関数として再実装します。
続いてバリデーションとフォーマット処理を修正します。テーブルに表示する各項目にはそれぞれ入力値の制限や数値のフォーマットが必要になります。これらの情報に関しても、どのようなバリデーションやフォーマットが必要なのかをバックエンドから取得します。そして、それらの情報をもとにバリデーションとフォーマット処理を行いました。
最後に、これらの変更を全体的な画面に適用します。今回移行対象となる画面は約 30 ページありました。そのため改修には時間がかかりました。
以下の画像は最終的なフィーチャーブランチの差分です。フロントエンド側で静的に定義していた項目を動的に取得するようになったこともあり、差分は追加よりも削除の方が多くなっています。
実装時に恩恵を得たポイント
MSW を用いてバックエンド開発を待たずにフロントエンド開発を進められた
本プロジェクトでは、MSW(Mock Service Worker)を利用してバックエンドの API をモックしています。
そのため、開発時はバックエンドの開発を待たずにフロントエンドの開発を進めることができました。MSW で利用するモックデータは GraphQL Code Generator のプラグインである graphql-codegen-typescript-mock-data を利用して自動生成しています。
バックエンドのデータをもとに Zod のスキーマを動的に生成した
これまではname
やnote
など、静的に決められた項目(プロパティ)に対して Zod のスキーマを定義していました。
しかし今回の移行により、ユーザーが任意の項目を追加できるようになったため、これらの項目に対しても Zod のスキーマを動的に生成する必要がありました。
そこでバックエンドから各項目のバリデーション情報を取得し、その情報をもとに Zod のスキーマを動的に生成する仕組みを導入しました。
import { match } from "ts-pattern"; import { ItemAttributeValidationRuleType } from "./generated/graphql"; const buildItemAttributeSchema = ( validationType: ItemAttributeValidationRuleType ) => match(validationType) .with(ItemAttributeValidationRuleType.String, () => { // 文字列の場合のバリデーション }) .with(ItemAttributeValidationRuleType.Int, () => { // 整数の場合のバリデーション }) // その他のバリデーション情報が続く... .exhaustive();
ItemAttributeValidationRuleType
は GraphQL の enum です。バリデーション定義に漏れがないように、ts-pattern のmatch
関数を用いてすべてのパターンが網羅されていることを保証しています。
動的に生成された Zod のスキーマはバリデーション内容に不備がないか多少不安が残るため、テストコードを追加し、意図した通りにバリデーションが行われることを確認しました。
意図しない UI の差分が発生していないことを VRT で確認できた
本プロジェクトでは Storybook を活用して UI コンポーネントを開発しており、あわせて VRT(Visual Regression Testing)ツールである reg-suit を利用しています1。
これにより最終的な UI の差分を確認し、意図した通りの UI が表示されていることを確認できました。
GraphQL と TypeScript による型の恩恵
すでに見たように、本プロジェクトでは GraphQL を用いてデータ構造の定義を行っています。
この GraphQL スキーマは GraphQL Code Generator を利用して TypeScript の型定義や、API クライアントの生成にも利用しています。
これによりデータ構造が変わった箇所は基本的に型エラーとなり、そこを修正するといった手順で実装できます。そのため、先述の通り今回の改修は大規模なものでしたが、ある程度安心感を持って実装を進められました。
影響のある画面を一覧で記録しておいた
先述の通り、今回の改修はそれなりに影響範囲が広いものでした。そのため影響範囲を把握し、実装に漏れがないことを確認することが重要だと感じていました。
そのため、フロントエンドの PR の作成時には影響を受ける画面を一覧で記載し、その画面ごとのスクリーンショットや URL、起点となるコンポーネントのファイル名を記録しておきました。
これはレビュワーの負荷を減らすためのものでしたが、実際には後で追加で修正が必要になった際に、影響がある画面を私自身が把握するのに役立ちました。
おわりに
本記事では Server-Driven UI への移行事例について紹介しました。
今回の移行を通じて、改めて GraphQL や TypeScript による型の恩恵を感じることができました。移行の規模が大きかったもののリリース後に大きな問題が発生しなかったのは、このようなツールの恩恵があったからだと思います。
本記事が Server-Driven UI への移行を検討している方の参考になれば幸いです。
- reg-suit 高速化の取り組みについてはこちらをご確認ください。GitHub Actions で実行する storycap / reg-suit の高速化 - ROUTE06 Tech Blog↩