宿泊プロダクト開発部の田中(id:kentana20)です。
このエントリーは一休.com Advent Calendar 2023の14日目の記事です。昨日は@kosuke1012によるADR を1年間書いてみた感想でした。このチームの活動に刺激を受けて、自分のチームでもADRを導入して現在も活用しています。
今回は自分が担当している一休.com宿泊の管理システムのフロントエンド設計について、この1年ほどで行った改善をお話します。
宿泊の管理システムについて
一休.com宿泊の管理システムは、一休社内とホテルの2面で構成されていて、利用者は一休の社内スタッフとホテルの担当者がおり、それぞれ以下のような業務に活用しています。
- 一休社内のスタッフ
- ホテルの作成、一休全体の予約の管理 など
- ホテル担当者
- ホテル情報の管理、商品の在庫や料金設定 など
新しい管理システムについて
1年半ほど前から、この管理システムに大きめの機能追加をするプロジェクトが発足し、現在も続いています。
このプロジェクトは社内スタッフ向け、ホテル担当者向けの両面をカバーする必要があったのですが、新機能を開発をするにあたり
- 新機能は中長期での開発・運用を想定していること
- 既存システムで採用しているフレームワークやコードベースが古くなっており、新機能をスピーディに開発していくのに難があったこと
- 新機能は既存システムに依存せずに作れそうなこと
などの点から、既存のシステムとは別に新システムをゼロから開発する方針を決めました。
新システムのテクノロジースタックは、先行して刷新をしていた一休.com、Yahoo!トラベルの画面に合わせる形で
- フロントエンド: Nuxt.js、TypeScript、Apollo Client、Tailwind CSS
- バックエンド: Go、GraphQL(gqlgen)
という構成にしました。 Nuxt.jsについては開発開始時点ではRC版だったv3を採用しました。
開発初期のフロントエンド設計
コンポーネントは4レイヤー方式を採用
Components配下は
- pages
- features
- objects
- elements
の4レイヤー構成を採用しており、各レイヤーの役割は以下のとおりです。
レイヤー | 役割 | 具体例 | 再利用性 | 外部アクセス | 反証 |
---|---|---|---|---|---|
pages | ページ固有のコンポーネント群 ページ固有の API アクセス、表示を担う |
ホテル管理ページ | ✕ | ◯ | 複数ページで使われるもの |
features | 機能を持った共通コンポーネント API アクセスをする |
グローバルヘッダー |
◯ | ◯ | API アクセスをしない ページ固有の UI |
objects | アプリケーション上の機能、デザインのひと固まりとなるコンポーネント |
サイドメニュー | ◯ | ✕ | API アクセスをする ページ全体を実装 ボタンなどプリミティブな要素 |
elements | HTML のサブセットとなるもっともプリミティブなコンポーネント アプリケーション全体の統一感に寄与するコンポーネント |
チェックボックス ボタン |
◯ | ✕ | API アクセスをする 様々なコンポーネントを用いたデザイン状のかたまり |
この設計は一休.comのユーザー向けシステムに倣った形で、Atomic Designと当時の一休レストランで採用していたITCSSによるレイヤードアーキテクチャをベースに、宿泊サービスの開発に合わせてカスタマイズした設計となっています。
実際の画面だと、こんな形で用途に応じて各レイヤーにコンポーネントを作成してUIの開発をしています。
UIのコンポーネントライブラリを採用
- デザイナーがいないプロジェクト
- 一覧(テーブル)や入力フォームがよく登場する管理画面で一貫したUIを素早く提供したい
という点から、Vue/Nuxtで利用できるUIコンポーネントライブラリとして、Alibabaグループが開発しているElementのVue3対応版であるElement Plusを採用しました。
A Vue 3 UI Framework | Element Plus
当時はVuetifyとElement Plusを比較検討したのですが
- フォームの画面ではVuetifyよりも書きやすい
- 当時のVuetifyはVue3サポートが完了していなかったがElement Plusは対応済(現在はVuetifyもVue3をサポートしています)
- Element Plusの方がTailwind CSSとの親和性が高い
といった点からElement Plusを選択しました。
当時RCだったNuxt.js v3に対応したUIコンポーネントライブラリは多くありませんでしたが、現在はVuetifyやQuasarなどのライブラリが対応しており、選択肢が広がっています
これ以上の設計、方針は決めなかった
ほかにも開発方針として
- コンポーネントの分割方針をどうするか
- Composition API(コンポーネントとロジックの分離)をどう活用するか
- 社内スタッフ向け、ホテル向けと2面ある管理画面のUIでコンポーネントを共用するのか
など、初期に決めるべきことはたくさんあったのですが、機能開発をいち早く進めるためにこれらの方針を明確に定めずに開発を進めてしまいました。
振り返ると、これはとても良くない判断で、むしろ早く作るためにもっとじっくり設計や開発方針を練るべきだったと考えています。
初期ローンチ後の課題
- 2022年4月~9月 ... 初期開発
- 2022年12月~2023年3月 ... 大きめな機能追加
を経て、その後も機能追加や改善を続けていくことになったのですが、機能追加の際に以下のような課題を感じました。
- 新たにコンポーネントを開発する際に迷うことが多い
- コンポーネントのインターフェース(Props)をどう定義するか
- GraphQLのFragmentをどう使っていくべきか
- エラーメッセージをどこにどう書くか
- コードの見通しが良くない
- 入力項目が多いフォーム画面のロジックを扱うcomposablesが肥大化していて、見通しが悪い
- 型を厳密に扱えていない
- as, anyを使っている箇所があり、型の安全性を担保できていない記述がある
これらを踏まえて、チームメンバーとも相談をした上で中長期で開発・運用していくためにフロントエンドの設計を改善することにしました。
改善した内容
宿泊事業を成長させるためのプロジェクトという前提があるため、ビジネスとして必要な機能追加をしながら、少しずつ以下の改善を行い、現在も継続しています。
1. コンポーネント設計の見直し
ディレクトリ構成の変更
前述のコンポーネントレイヤーのうち、特にobjects配下にコンポーネントが多く存在しており、見通しが悪かったため、以下のルールで分別しました。
- 社内、ホテル、共通のコンポーネントを分別する構成に変更
ディレクトリ | 役割 |
---|---|
inside | 一休社内スタッフ用の管理画面のみで使用するコンポーネント |
accommodations | ホテル向けの管理画面のみで使用するコンポーネント |
shared | 2つの管理画面で共用するコンポーネント |
大きくなったコンポーネントの分割
大きいものになると1コンポーネントで1,000行に近いサイズになっていて、見通しが悪かったため 1コンポーネント350行程度を目安とする というガイドラインを定めてコンポーネントを分割しました。分割時にコンポーネントの依存関係を明確にするために、以下のルールで分割後に再配置をしました。
components └objects └inside └HotelDescription └HotelDescription.vue(親コンポーネント) └components ├child1/child1.vue(親コンポーネントのみで使う子コンポーネントその1) └child2/child2.vue(親コンポーネントのみで使う子コンポーネントその2)
Fragment Colocationを導入してコンポーネントのインターフェースとFragmentを整理
改善前はルールを敷かずにFragmentによるGraphQLクエリの共通化をしていました。 以下はコード例です。
fragment HotelFragment on Hotel { id name description address rooms }
// HotelFragmentを必要とするコンポーネント // idとdescriptionがあれば良いが他の情報も含んだFragmentをPropsとして要求してしまっている <template> <div>{{ id }}</div> <div>{{ description }}</div> </template> <script setup lang="ts"> interface Props { hotel: HotelFragment } </script>
これにより
- オーバーフェッチが発生していた*1
- 共通化しているFragmentの配置場所が定まっていない
という課題があったため、Fragment Colocationを導入しました。
Fragmentによるデータの宣言を強制しているRelayの設計を参考に、以下のようなルールでコンポーネントのインターフェースとFragmentを扱うようにしています。
- Fragmentファイルは利用するコンポーネントと同階層に配置する
- コンポーネントのインターフェース(Props)はFragmentの型で定義する
- Fragment名は「コンポーネント名 + GraphQLスキーマの型名」で命名する
改善後のファイル配置とコード例はこんな形です。
components └objects └inside └HotelDescription(コンポーネントのディレクトリ) ├HotelDescription.vue(ホテルの説明文を表示するコンポーネント) └HotelDescription_Hotel.frag.graphql(コンポーネントが利用するFragment)
- コンポーネントのインターフェース
<script setup lang="ts"> interface Props { hotel: HotelDescriptionHotelFragment } </script>
- Fragment
fragment HotelDescriptionHotel on Hotel { id description }
プロジェクトで利用しているGraphQL Code GeneratorのClient PresetではFragment Maskingという機能が提供されていて、これによってFragmentで取得するフィールドは利用するコンポーネント以外からは参照できないように隠蔽化もできますが、まだこの機能は有効にしていません。
2. 業務処理(composables)の分割
Vue.jsのComposition APIの設計に沿って、コンポーネント内のロジックをcomposablesに書いていく方針で進めていましたが、入力内容が多いフォームの画面では
- 登録や変更処理などのふるまい
- フォームの初期状態
- Validation
などが1箇所に書かれており、記述量が多く見通しが悪くなっていました。
これを解決するために、ルートに lib/domain
というディレクトリを設置して
- フォームの初期状態
- Validation
を分離する設計に変更しました。
lib └domain └Hotel ├HotelForm.ts └HoetlValidator.ts
// HotelForm.ts export type HotelForm = { name?: Scalars['String'] description?: Scalars['String'] ... }
// HotelValidator.ts export function useHotelValidator(form: HotelForm) { const descriptionCheck = (description: string) { // descriptionに対するチェック処理 } const rules = computed<FormRules>(() => { return { description: [ { validator: descriptionCheck, trigger: 'change', }, ], } }) return { rules, } }
// composables export function useHotel() { // HotelFormの初期化 const hotelForm: HotelForm = reactive({ name: undefined, description: undefined, }) // Hotelに関する業務処理 ... return { validationRules: useHotelValidator(form).rules, }
// validationを使うFormを持つVueコンポーネント <template> <Form :model="form" :rules="validationRules" > ... </Form> </template> <script setup lang="ts"> import { useHotel } from './composables' const { form, validationRules, } = useHotel() </script>
3. 型安全に開発できるように厳しいlint設定に変更
初期開発時はeslint, prettierによるコードフォーマット、型検査は導入していましたが、非nullアサーション(!)や型アサーションによるasやany型の利用を制限していませんでした。
この結果、本来は型ガードやアサーション関数を使って型を保証するべきところを!, asを使ってコンパイルエラーを回避したり、any型を不用意に使うケースが出てきてしまいました。 (以下でもasやanyの危険性について語られていて、TypeScriptによる型の安全性を享受するために避けるべき、と書かれています)
敗北者のTypeScript #TypeScript - Qiita
これを踏まえて
- 非nullアサーション
- 型アサーション
- any型
の利用箇所を撲滅してlintで制限することにしました。小さい単位で作業を分割して進められるように
- 修正対象箇所がわかるようにwarningを出すようにlintを変更
- 地道にwarningが出なくなるように書き換え
- warningがなくなったらlint設定を変更してerrorにしてCIで止まるようにする
というステップで作業を実施しました。
非nullアサーションは完全に撲滅できましたが、型アサーションとanyの利用は改善の途中です。
4. 秩序を保てる開発体制、ドキュメントの整備
1~3でだいぶコードに秩序がある状態になりましたが、今後の開発によって悪化しないように以下を実施しました。
- コードレビューの強化
- CODEOWNERによるレビューを必須にして、定めた設計方針に沿った内容になっているかを識者がレビューする体制に
- ドキュメントの整備
- コンポーネントのレイヤーと役割、Fragmentの利用方針、スタイルガイドなどをリポジトリのWikiにまとめて開発やレビューでの指摘に活用
現在と今後
これらを積み重ねた結果
- components配下はかなり見通しがよくなり、秩序がある状態になった
- 設計・開発をする際の指針ができており、レビューも指摘しやすくなった
など、改善の効果を感じています。先月末~現在にかけて、新機能を開発しているのですが、フロントエンドの開発はとてもスムーズで、迷うことがほぼなくなってきました。
今後やりたいこと
引き続きプロジェクトを進めながら改善を続ける状態を維持したいと思っています。 具体的に考えている大きめの改善テーマとしては
- @Vue/apollo(@vue/apollo-composable)の脱却
- v4のβ期間が長く、バージョンアップによって意図しない不具合が入ったことがあるため、別のGraphQLクライアントへの変更を検討中
- E2Eテストの整備
- 機能追加・変更時のリグレッションテストを効率的に行うため、Playwrightを導入してE2Eテストを整備、CIに組み込む予定で改善中
などがあります。
改善を継続するためのポイント
- プロジェクトで開発する際に違和感を感じたら、熱量があるうちにIssueにする(コードレビューや開発しながらやるとよい)
- 上がったIssueを開発者で議論・認識合わせをしておく
- 機能開発とセットで改善することを常に考える
改善のネタを常に仕込んでおいて、機能開発をする際に「あ、あれ一緒にやりません?」みたいな形で組み込んで機能追加とシステム改善を同時にやっていくのが理想だと考えています。
まとめ
一休.com宿泊の管理画面のフロントエンド設計について、開発初期から現在までの変遷と今後について紹介しました。 本来は開発初期に決めておくべき内容を決めなかったことでローンチ後に改善することになってしまいましたが、まだシステムが大きくないタイミングで改善を進められたことは良かったと思っています。
一緒に改善を進めてくれているチームメンバーにとても感謝しています。
今後もこのシステムで
- 大きなビジネス成果につなげる
- 中長期で開発・運用していけるシステムにする
を両立してやっていけるように、引き続きやっていきたいと思います。
おわりに
一休では、技術的にも妥協せず、事業の成果をともに目指せる仲間を募集しています。
まずはカジュアル面談からお気軽にご応募ください!
明日はtak-ondaの「一休レストランで Next.js App Router から Remix に乗り換えた話」です。お楽しみに!
*1:Apollo Clientのキャッシュや、GraphQLサーバのLoaderによって発生しないケースもあります