一休.com Developers Blog

一休のエンジニア、デザイナー、ディレクターが情報を発信していきます

開発プロセスをインクリメンタルに改善する

一休.comレストランのエンジニアのkymmtです。

2023年度の下半期、一休.comレストランの開発チームでは開発プロセス改善に取り組みました。改善は小さい単位で徐々に進め、バックログの作りかたやカンバンの運用方法を改善することで、フロー効率の向上、開発ペースの把握、チーム内外からの進捗の見える化ができるようになりました。

この記事では、このようなインクリメンタルな開発プロセス改善の取り組みについて紹介します。

従来の開発プロセス

主に2023年度前半の開発プロセスは次のような形でした1

  • プロダクトのリリースに必要なタスクが長いバックログとして存在し、ひたすらタスクを消化
  • その状況に課題を感じ、区切りを入れるために2週間のスプリントを導入

この時点では、スプリントは2週間ごとに状況を確認するためのもので、目標に対するふりかえりや、次のスプリントの計画を作るためのものとしては活用していませんでした。

この開発プロセスに起因して、チームメンバーは次のような課題を感じていました。

  • どの機能に紐づくかが一見してわかりにくい技術的タスクや、やることが曖昧な項目がバックログにある
  • タスクは進んでいるが、ひとまとまりの機能ができるのに時間がかかる
  • 開発ペースを見通しにくく、今後の予定についてチーム内外に説明責任を果たしにくい
  • スプリントを導入したものの、スプリント終了時の残項目が完了しなかった理由など、開発のボトルネックを深掘りできていない

改善の方針

先述した課題を受けて、開発プロセスをできるだけ早く改善したいという機運が生まれました。しかし、スクラムなど大きめの方法論をチームに導入するのはこれまで例がなく、ある種の理想的な開発プロセスには近づけますが、効果が出るまでに時間がかかりそうでした。また、著者(kymmt)は入社直後だったので、技術的なキャッチアップと並行してプロセス改善をサポートしたいという状況でした。

そこで、アジャイル開発のプラクティスをインクリメンタルに導入してプロセスを改善することにしました。

ここで、それらのプラクティスの生まれた理由や避けるべき罠は理解したうえで、課題の解決に必要なものを選択的に導入するという点に気を配りました。最近出た本だと『アジャイルプラクティスガイドブック』は参考になりました。

2023年度後半からの開発プロセス

上記の方針に基づいて、2023年度下半期からは、チームで次のような改善活動に取り組みました。

  • 顧客価値に直結する開発はユーザーストーリーとして項目を整理し、その下で技術的タスクを分解/整理する
  • カンバン上でユーザーストーリーを左から右に流すようにして、顧客価値がどの程度生み出せているか、ボトルネックはどこかを見える化する
  • ユーザーストーリーに対する規模の見積もりとベロシティの計測を繰り返し、開発の見通しを立てられるようにする

これらの活動はある小規模なプロジェクトから始めて、次にもう1つの中規模なプロジェクトに横展開することで、徐々にチーム全体に活動範囲を広げました。

導入の様子

小規模の開発プロジェクトへの導入

すでに述べたとおり、2週間ごとに期間を区切るという枠組みだけ導入されていました。今回はそれを足がかりに、まずは小さい規模の開発プロジェクト(強いていうならエピック)に対してプラクティスを導入していきました。

まず、事前にユーザーストーリーとして開発項目を改めて明らかにしつつ整理し直しました。そして、それらに優先度をつけてバックログ上で並び替えました。あくまでも例ですが、次のようなイメージです。

名前 優先度
ユーザーが関連するレストランの一覧を閲覧できる
ユーザーが人気のレストランの一覧を閲覧できる
ユーザーが近隣のスポットに基づくレストランの一覧を閲覧できる

(ここでは一休.comレストランの利用者のことを「ユーザー」と呼んでいます)

そのうえで、項目の規模を相対見積もりしました。ストーリーに必要な技術的タスクについて認識を合わせながら、それぞれの項目の相対的な規模を比較します。現在に至るまで、フィボナッチ数列に基づくストーリーポイント(1, 2, 3, 5, 8)を使っています。ここでは、プロジェクトに携わる3人ほどで、規模の感覚を揃えて見積もりをしました。古典ですが『アジャイルな見積りと計画づくり』もあらためて参考にしました。

これらの項目を左から右に「To Do」、「In Progress」、「In Review」、「Done」のレーンを持つカンバンで管理します。これまでベロシティを計測したことがなかったので、見積もり実施後の初回スプリントでは、優先度に基づいてバックログの項目を「To Do」に並べ、優先度が高いものから取り組みました。また、できるだけ複数ストーリーを取らない(マルチタスクにならない)ように進めました2

この時点でバックログの項目が整理された状態でカンバン上に現れ、関係者から見て進捗がわかりやすくなりました。また、スプリントを繰り返すなかで、カンバン上にあるストーリーを左から右に流すために複数人で手分けするような動きもできるようになりました。この点が効いて、目標期日をきつめにとっていましたがプロジェクトの作業を完了できました。

一方で、一部の開発プロジェクトだけに改善を適用していたので、チーム全体の開発ペースの計測ができていませんでした。これについては、次の中規模の開発プロジェクトであらためて進めました。

ツールの適切な運用

カンバン導入と前後して、コードベースとプロジェクト管理の距離が近いほうがチームの好みに合っていたので、従来Jiraを使っていたところをGitHub Projectsに移行し、これまで述べた運用に沿うようにカンバンや項目のメタデータを整備しました。また、チームで合意した運用方法はドキュメントとして明文化しました。

GitHub Projectsの効果的な利用方法については、以前このブログでitinaoが紹介しているのでぜひご覧ください。

user-first.ikyu.co.jp

できるだけ業務に支障がないように、Jiraにあったデータも移行しました。こういう移行はやり切るのが大事なので、GitHub APIを利用して必要なデータを極力自動でGitHub側にインポートしました。

一休.comレストラン開発チームのカンバン
一休.comレストラン開発チームのカンバン

項目間の依存関係を示しづらいなどの課題感もありますが、現在はおおむね現状を把握しやすいカンバンを運用できています。

中規模の開発プロジェクトへの導入

前述のとおり、ある程度プラクティスの導入による効果が出てきたので、著者(kymmt)が直接担当しているわけではない別の中規模プロジェクトについても導入してみました。

このフェイズでは、メンバー全員がプラクティスを実践できるように、プロジェクトを進めるメンバーと一緒にストーリーの単位で項目を整理し直し、方法のコツなどを共有しました。さらに、それらの相対規模の見積もりも一緒にやることで、規模に対する感覚をチーム全体で揃えていきました。

もとは「状態管理追加」、「UI実装」のような技術的タスクの単位で項目が並べられていましたが、項目間の依存関係やまとまりを顧客価値として整理することで、何が実現できるか明確になりました。また、カンバン上でユーザーストーリーの粒度で左から右に1つずつ開発項目を流せるようになりました。チームメンバーからも作業が進めやすくなり、1つ1つのユーザーストーリーのリードタイムが向上したという声をもらいました。

加えて、見積もりされたバックログ項目に取り組む中で、チーム全体のベロシティも安定して見えるようになってきたので、今後の開発の見通しを立てやすくなりました。

一休.comレストラン開発チームのベロシティ
一休.comレストラン開発チームのベロシティ

スプリント開始時にチームで計画づくり

以前は前のスプリントの残項目をそのまま次スプリントに移す3というプロセスでしたが、現在はビジネスの状況やすべきことの優先度、またチームのベロシティも都度確認して、目標を決めてバックログを作っています。

結果的に前スプリントで残った分も次のスプリントでやりましょうになることはあるのですが、なにも考えずに移すのではなく議論をしたうえで必要なら移すというプロセスを経るようにしています。

結果

2023年度下半期に次のような開発プロセス改善活動をおこないました。

  • 顧客価値に直結する開発をユーザーストーリーとして項目を整理
  • カンバン上で顧客価値につながる開発の進捗やボトルネックを見える化
  • ユーザーストーリーに対する規模の見積もりとベロシティの計測で開発ペースを見える化
  • スプリントの計画づくりで目標を定め、そのために必要なバックログを作る

もともと技術的にしっかりしたチームだったので、これらの改善活動の結果でフロー効率をよくすることで、以前よりリードタイムの向上や安定が見られるようになりました。

また、ストーリーに基づいた開発項目の見える化によって進捗がチーム内外からわかりやすくなり、デモやレポーティングなど組織運営に必要な業務も進めやすくなりました。先の計画を立てやすく、予定変更にも柔軟に対応できるようになってきています。

他には、計画づくりに意識的に取り組むようになったので、ずるずると開発してしまうことが減りました。ビジネスの推進に必要なことがなにかを都度確認しながら開発を進められています。

これから

すでに始めている取り組みとして、継続的に各チームメンバーがプロセス改善できるように、開発プロセスに関する知識をインプットする読書会を週次で開催しています。先日『カンバン仕事術』を読み終えたところです。

課題としては、技術的に専門性のあるメンバーに下周りの整備のようなタスクが集中したり、緊急の差し込みタスクをシステムに詳しいメンバーが多めに取りがちだったりと、メンバー間のスキルの差によってWIPが多くなったりすることもあります。こういうときにタスクを取捨選択したり、メンバー間で知識を共有していく方法については、既存のプラクティスも参照しながら継続的にチームで考えていくつもりです。


一休では、ともに良いサービスをつくっていく仲間を募集中です。

hrmos.co

カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp


  1. 著者(kymmt)は入社前〜入社直後なので聞いた話も含みます
  2. WIP制限に基づく方針ですが、このとき数値はとくに指定していませんでした
  3. Jiraの機能でそうなっていたというのもあります

一休レストランのふつうのRustバックエンド開発

この記事は一休.com Advent Calendar 2023 25日目の記事です。

一休レストランでは、よりスムーズな予約体験の提供を目的とするシステムのリニューアルを進めています。その一環として、2023年10月から、レストラン個別ページの表示から予約までのスマートフォンビューにおいて、バックエンドのサーバをRustで書かれたものに置き換えました。

本番運用が始まって3か月近く経ちましたが、これまで安定して継続的な開発と運用ができています。これはRustだからと構えることなく、「ふつう」のバックエンド開発を心がけてきたからだと考えています。

Advent Calendar 2023最終日は、一休レストランの開発チーム一同から、一休レストランのRustバックエンド開発の様子をお届けします。

Rustを選定した理由

一休レストランのリニューアル計画が始まったころ、一休では宿泊予約サービスや社内の基盤サービスを中心としてGoが標準的なバックエンドの技術スタックでした。

一休レストランの開発でも、宿泊予約サービスでの経験があるメンバーのスキルセットに基づいてGoを使うこともできました。その一方で、この方針だと社内の技術ポートフォリオがGoに偏ってしまうという懸念もありました。

一休では、社内で蓄積する技術的知見に多様性を持たせ、結果として状況に応じて最適な技術選定ができるように、複数のプログラミング言語を使うことを意図的に選択しています。

株式会社一休 会社紹介資料 / introduce-ikyu - Speaker Deck より一休の技術選定の方針について

そこで、チームメンバーの中にRustに詳しいエンジニアがいたことも助けになり、Rustをバックエンドの言語として採用するかどうかを検討しました。

Rustの採用による狙いは次のとおりです。

  • まず置き換えたい参照系処理のCPU利用効率を上げて、高速なバックエンドサーバとする
  • 今後のさらなる開発を見据え、メモリ安全、型安全な開発体験を実現する
  • 技術的知見の多様性という点で、関数型のメンタルモデルでプログラミングできるエンジニアを増やす

同時に、Rustの採用に対する次のような懸念も上がりました。

  • 初めて使うエンジニアにとっては学習に時間がかかる
  • ライブラリの自作が必要となるケースもありそう

Rustは公式ドキュメントdocs.rsのリファレンスなどでドキュメントが充実しているので、学習曲線は急ではあるものの、学習自体は進めやすいと判断しました。

ライブラリについては、Rustから一休の基幹DBであるSQL Serverにどうやって接続するかという技術的な検証が必要でした。最終的には、Prismaが公開しているTiberiusというSQL Server用のDBドライバをベースとして、ある程度アプリケーションから使いやすいインタフェースのライブラリを整備することで開発が進められると判断できました。

これらの議論や調査に基づいて、一休レストランのバックエンドでRustを採用することになりました。

現在、一休レストランのバックエンドを開発するエンジニアは3人います。そのうち2人は、一休レストランの開発をきっかけに、はじめてRustを本格的に利用し始めました。豊富な学習リソースやRustに詳しいメンバーのヘルプを通じて、プロジェクト開始前の学習ではString&strの違いを理解するところから始めたメンバーも、プロジェクト開始後はスムーズに開発できるようになりました。

現在のバックエンドのユースケース

ここからはRustでバックエンドを「ふつう」に開発するための、設計や実装における面白いポイントを紹介していきます。

現在は主に次のユースケースでバックエンドを利用しています。

レストラン情報の取得

店舗情報や予約可能時間など、レストランの情報をお客様に提供するための情報を取得します。機能はGraphQLのクエリとして提供しています。

今回はレストラン個別のページの表示から予約までのフローの置き換えを開発スコープとしたので、現在はこのユースケースが大半を占めています。後述のとおりコードベース上もデータの読み出しに関するコードが多いです。

予約の確保

お客様から入力いただいた情報をもとに予約を確保するエンドポイントをGraphQLのミューテーションとして提供しています。また、実際の予約処理は、予約処理モジュールを持つ既存の社内別サービスに委譲しています。

現在のアーキテクチャ

現在、アプリケーションのアーキテクチャとしてコマンドクエリ責務分離(CQRS)に基づいた構造を採用しています。つまり、データを読み出すだけのクエリと、データの作成や更新をするコマンドで、利用するモデルを分離する方式をとっています。

また、たとえばクエリの場合、DBとSolrそれぞれについてデータアクセス層を設け、GraphQLのデータローダーのようなシステムの界面に近い層からは、データアクセス層を通じてクエリモデルの形式でデータを取得します。

モジュールとその依存関係

これらのモジュールはCargo workspaceを用いて管理しています。この点についてはあとで詳しく説明します。

各モジュールの紹介

上述した図における各層を構成するモジュールについて紹介します。

ドメインモデル

CQRSにおけるクエリとコマンドで利用するモデルを実装している層です。ドメインモデルは他のどのモジュールにも依存しません。また、クエリとコマンドは別モジュールとするためにcrateを分けています。

クエリモデルの例としては、レストラン詳細画面で表示する店舗情報があります。これらのデータは実際は複数のテーブルに存在しますが、クエリモデルはそのような実装詳細には依存せず、クエリの結果としてほしい構造を定義しています。実際には、SQL ServerもしくはSolrから得たデータをクエリモデルに変換して利用します。

#[derive(Debug, Clone)]
pub struct Restaurant {
    pub id: RestaurantId,
    pub name: String,
    pub description: Option<String>,
    // ...
}

コマンドモデルの例としてはお気に入り店舗登録用のコマンドモデルなどが存在します。こちらはまだ数が少ないので割愛します。

データアクセス層

実際のデータを取得するためのロジックを実装している層です。現在は、一休の基幹DBであるSQL Serverや、検索サーバであるSolrからデータを取得しています。このデータアクセス層の利用者に対して、取得したデータをもとにモデルのインスタンスを返します。つまり、ドメインモデルに依存します。

クエリを実行するときは、Serdeserde_withを利用して、データストアから取得した生データをDTOにデシリアライズします。

mod dto {
    // ...

    #[serde_with::serde_as]
    #[derive(Debug, serde::Deserialize)]
    pub struct Restaurant {
        #[serde(rename = "restaurant_id")]
        #[serde_as(as = "serde_with::TryFromInto<i32>")]
        id: RestaurantId,

        #[serde(rename = "restaurant_name")]
        name: String,

        // ...
    }
}

さらに、このDTOからクエリモデルに変換するためにstd::convertFromトレイトやTryFromトレイトを活用しています。詳しくは後述します。

GraphQLとHTTPサーバ

バックエンドはGraphQLを通じてフロントエンドにクエリとミューテーションを提供しています。このGraphQL APIの実装にはasync-graphqlを利用しています。async-graphqlはコードファーストでGraphQLスキーマを定義できるcrateです。

github.com

// Restaurant {
//   name
// }
// のようなスキーマをコードで定義

pub struct Restaurant(pub query_model::Restaurant);

#[async_graphql::Object]
impl Restaurant {
    async fn name(&self) -> &str {
        &self.0.name
    }

    // ...
}

また、HTTPサーバとしてはAxumを利用しています。

github.com

これまではGraphQLなのでエンドポイント1つで済んでいましたが、最近は社内の他サービスと通信するためにインターナルなREST APIを作る機会も増えてきています。

ライブラリ

アプリケーションを構成するモジュールとは別に、独立したロジックをまとめたライブラリとしてのcrateもいくつか作成してworkspaceに含めています。これらのライブラリは他モジュールから利用されます。

たとえば、先述したTiberiusをベースにしたDBドライバや社内サービスのクライアント、他にはログなどの横断的関心事を扱うライブラリが存在します。

Rustによる開発のふりかえり

よかったこと

Rustはビジネスロジックを書くのにも便利

Rustの言語機能として、所有権やライフタイムのようにメモリ安全性を意識したものがよく注目されます。さらに、Webアプリケーションバックエンドを書くうえでは、OptionResultに代表される関数型言語のエッセンスを取り込んだ機能や、データ変換にまつわる機能も非常に便利だとあらためて感じました。

一休レストランは15年以上の歴史があるサービスです。このようなサービスは、しばしば歴史的事情からなるデータ構造やコードを多く持っています。たとえば有効な値とnullの両方が存在しうるカラムを扱うこともあります。このときにOptionを利用することで、ビジネスロジック上でnullにまつわるバグを避け、match式やif let式によって値がないケースをつねに考慮できます。

また、Webアプリケーションは無効な値を入力されたり外部のサービスとの通信に失敗するなど、つねにロジックが失敗する可能性があります。そのようなロジックでは返り値としてResult1を使うことで、確実にエラーをハンドリングできます。また、?演算子を利用することで、コードを簡潔に保ちつつエラーハンドリングできるのも便利な点です。

他には、一休レストランだと予約可能な時間や食事コースの検索結果などでコレクションを操作する場面が数多くあります。このようなときに、イテレータとmapfilterのようなイテレータアダプタを利用することで、コレクションにまつわるビジネスロジックを簡潔に書けるのもよい点だと感じています。

アプリケーションの各層で型安全にデータを変換

先述したように、このアプリケーションでは複数のモジュールで責務を分けています。よって、そのままではデータアクセス層でデータストアから取得した生のデータをDTOを経由してクエリモデルに変換するロジックを書く必要が出てきます。

ここで、FromトレイトやTryFromトレイトを用いて型安全なデータの変換を実装することで、層の間で安全にデータを受け渡しできます。たとえばDTOをクエリモデルに変換するためにFromトレイトやTryFromトレイトをDTOに対して実装し、適切にモデルへ変換できるようにしています。

impl From<dto::Restaurant> for query_model::Restaurant {
    fn from(d: dto::Restaurant) -> Self {
        query_model::Restaurant {
            id: d.id,
            name: d.name,
            // ...
        }
    }
}

このようにモデルに対して変換のためのトレイトを実装しておけば、あとはfromtry_fromintotry_intoを使うだけで層の間の型安全なデータ変換が可能になります。

Cargo workspaceを活用した開発

Cargo workspaceを活用してモジュール間の依存関係を制御しながら開発できているのもよい点です。

リポジトリのルートディレクトリにあるCargo.tomlでは、workspaceのmembersとしてアプリケーション内の各モジュールを指定しています。そして、それらのモジュールをcrateとして実装し、各crateのCargo.tomlではアーキテクチャを意識して他のcrateへの依存関係を設定することで、意図しない依存はコンパイラによってエラーにできる構造にしています。

# ルートディレクトリのCargo.toml
[workspace]
resolver = "2"
members = [
    "backend/*",
]

# データアクセス層のCargo.toml
[package]
name = "backend-data-access"
version.workspace = true
authors.workspace = true
edition.workspace = true
publish.workspace = true

[dependencies]
backend-query-model = { workspace = true }

また、モジュールをcrateに分離したことで、コードを変更したときに、変更のあったcrateとそのcrateに依存するcrateだけを再ビルドすればよくなりました。結果として、毎回アプリケーション全体をビルドせずに済み、開発時のビルド時間の短縮にも貢献しています。

パフォーマンスの向上

もちろんパフォーマンスの向上も当初の狙いどおり達成できた点であり、よかったことの1つです。

バックエンドはGoogle Cloud Runで運用しています。現在は年末年始でレストラン予約が非常に増える時期ですが、ピーク時でも3台程度のインスタンスでリクエストを受けることができています。

また、一休レストランのバックエンドの一部をRustに移行したことで、従来のPythonのバックエンドにおけるKubernetes DeploymentのReplicaSet数を次のように60程度から40程度に減らすことができました。

Wed 4以降はPythonバックエンドの負荷をオフロードできた

他には、バックエンドの高速化にともなってサービス全体の構成を最適化することで、一休レストラン全体のパフォーマンスが向上しました。こちらについてはチームメンバーのkozaiyが次の記事に詳しく書いたのでご覧ください。

user-first.ikyu.co.jp

もっとよくなると嬉しいこと

エコシステムのさらなる成熟

Webアプリケーションバックエンドを開発するうえで、さらにプラットフォームのRust対応が拡充されると開発が楽になりそうです。

たとえば、現在はCloud Runを使っているので、APMとしてCloud Traceを利用することにしました。しかし、公式にはRustのSDKが提供されていないことから、独自のライブラリを開発することで対応しています。

まとめ

この記事では、一休レストランにおいてRustを採用した理由と、Rustによる「ふつう」のWebアプリケーションバックエンド開発の様子について紹介しました。

Rustを採用したことで、期待どおり性能面で大きなメリットを得ることができました。また、RustやCargoの機能を適切に活用することで、生産性を保ちつつ今後の継続性も考慮した設計で開発を進めることができています。

新たにRustを利用し始めたチームメンバーからは、Rustに対する感想として

  • 自分自身にプログラミングを教えてくれる言語だなと思いました
  • プログラミングする上で、気にすべきポイントを気にさせてくれる言語

という声もあがっています。

今後のバックエンドの展望としては、よりよい予約体験の提供やレガシーシステムの改善を目的として、

  • 高速なレスポンスが求められるレストラン検索
  • レストラン予約のロジックなどのレガシーかつコアドメインであるモジュール

についてもRustで置き換えていく予定です。このような箇所では、高いパフォーマンスや型に守られた開発体験を提供してくれるRustを活かすことができるだろうと考えています。

このような技術的なチャレンジができる一休レストランのバックエンド開発に興味があるかたは、ぜひカジュアル面談応募ページや求人ページからご連絡ください。

hrmos.co

www.ikyu.co.jp


  1. 一休レストランではanyhow::Resultを利用しています

開発ディレクターの進化と挑戦

この記事は 一休.com Advent Calendar 2023 24日目の記事です。


宿泊プロダクト開発で開発ディレクターをしています、橋本と申します。
ついにクリスマスイブ。残すところこの記事を含めて2つとなりました。
本日の記事では開発ディレクター1年目の奮闘劇を皆さんに紹介したいと思います。
同じディレクターの方はもちろん、何か新しいことに挑戦している皆さんに届くと嬉しいです。

簡単に経歴紹介

新卒でNWインフラの会社に入社し、 エンジニアとして法人顧客のサービス導入をサポートをしてきました。 AWSの運用、セキュリティ商材の導入、NW機器の導入運用、スマホ管理サービス導入など様々な分野を担当し、直近では技術営業として提案メインでの活動に従事していました。

5年目になったころ、サービスの導入ではなく、サービスを作ることに興味を持ち、プロダクト開発という新しい分野にチャレンジすることを決めました。
そこから社内の制度を活用し、現在は一休にお世話になっています。

奮闘劇

インプット多量死をなんとか免れた序盤

入社前に開発ディレクターとはなんぞやということですごく簡単な資格だけ取りました。
Webディレクション | Web検定(ウェブケン)
一般的なWeb業界の用語がメインで、実際に何をする役割なのかはふわっとだけ学びました。ただ、実際に入社してみると、言葉通り「右も左もわからない」状況でした。

社内で使われているツールで触ったことがあったのがGmailとSlackだけで、進め方以前に使い方がわからない。。
業務フローについて説明を受けるも、表面的なところだけ分かった気になってしまう。。
SQLも書けないのでデータ抽出を頼まれても時間がかかる。。
操作方法について聞くも1回では理解できないので、録画をして後で自分でコンフルにまとめる日々。。
なによりアウトプットが何もできない状態でした。

そこで自分が意識したのは、

  • わからないことはわからないままにしない
  • 一度教えてもらったことは、次回からは一人でできるようになる

とにかく周りに迷惑をかけないように、渡されたタスクはミスなくこなせるようにすることを日々考えていました。
まずは仕事に慣れること、一人分の仕事ができるようになることを目指してがむしゃらに取り組む日々でした。

とにかくインプット量が多くて整理しきれなくなりそうになるのをなんとか踏ん張った2カ月。
2カ月目で起きたのが常に追いかけまわしていたディレクターの先輩が産休に入られるという出来事。。。

必死に犬掻きをする中盤

産休に入られた先輩から複数プロジェクトのディレクションを引き継ぎました。
正直、やってやる!という気持ちと、自分が主体になることでプロジェクトが失敗するのではないかという不安で、精神的には余裕のない状態でした。
実際に業務に取り掛かると、
引き継いだ業務をうまく進めようと意気込むが、頑張りどころと向かう先がイマイチ合っておらず、日々犬掻き状態。。
チームからはディレクションとしての役割を求められるが調整業務にも何日も時間をかけてしまう状態。。

今振り返るとこんな状態でした。

  • 進め方や要件、仕様について各所と調整をしているつもりが、状況や要望を聞いてきて持って帰るだけの伝書鳩になっていた
  • ユースケースを複数考慮できず出戻りが発生することが多かった
  • 自分がやるべき最低限タスクができていないのに、改善や新規の提案など背伸びをして何か価値を出そうと空回りしていた

振り返るとなかなか恥ずかしいですね、、

この状況を打破すべく意識したことは

  • 取り組む前に進もうとしている方向の認識合わせを行う
  • 悩むポイントはこまめに壁打ちを行う

チームマネージャーに週1回、プロジェクトの進め方やチームのコンディションについて会話をする時間をいただきました。 これがとても大きかった…!
この時に必ず自分の考えをもって臨み、ギャップを埋めていくことに努めた結果、
敷いてもらったレール上を進めることはできるようになってきました。

では次は自ら動けるようにならねば。。。

自分の役割が何となくわかってきた今

複数のプロジェクトを経験することで、プロジェクトの初期、中盤、リリース前、リリース後のぞれぞれのタイミングでディレクションがやるべきことがわかってきました。
「あのプロジェクトと同じように、こう進めていきます」といえるようになったのは大きい。
今後の動きを予測して動けるようになったこともあり、チームメンバーや他部署から依頼をされることも増えてきました。
さらに成長を感じたところとしては、【考えるタスク】を少しずつこなせるようになったこと。
調整業務やチームの開発を前に進めることだけではなく、本来のディレクション(方向を示す)という意味での【考えるタスク】を担当し、チームがその方向に進んでいくという体験が少しずつできるようになってきています。

最近ではこのようなことに悩んでいます。

  • 開発目線になりすぎてビジネス観点(価値あるもの適切なタイミングで世に出すためにはどうすべきか)が漏れてしまうことがある

これは同じ悩みをお持ちの方もいらっしゃるのではないでしょうか。
開発ディレクターは開発メンバーと過ごす時間が多いこともあり陥りがちな思考だと思います。 安全にミスなく進めるためにはとても重要ですが、忘れてはいけないのは、
リリースをすることがゴールではなく、【価値のあるプロダクトを生み出すことがゴールである】ということです。

例えばA案が良いと思って進めていたけれど、リリース直前になってB案の方が顧客の満足度も高く、売上にもつながるとわかったケースがあるとします。
開発チームとしては、直前で変更を加えなくてはいけない、リリース日の延長はなるべく避けたい、という状況はストレスにつながると思います。
しかし、私たちが進むべきゴールは【価値あるプロダクトを生み出すこと】です。
開発チームには負荷がかかりますがディレクターとしてはサンクコストではなく、プロダクトの価値を見るべきです。

私は同様の経験を通じで、ディレクターは開発目線とプロダクトオーナーのどちらの目線も持つことがとても重要だと身をもって学びました。
ディレクターとして働き始めた当初は、ディレクターって正直いなくても開発は進むよな…と自分の価値を見つけられずにいました。
今では、プロダクトの価値を最大化すること、さらに開発チームとプロダクトオーナーの両者が最も進めやすい方法を模索することがディレクションの価値だと思っています。

今後に向けて

今の私が意識し、目標にしていることを宣言させてください。
私は、エンジニアより技術力はない。
私は、マーケターよりも市場の把握や予測に強くない。
私は、営業よりも現場の考え方が理解できていない。
けれど、チームの推進力を高め、開発によって生み出されるプロダクトを価値あるものにする力は誰よりも持てるようになりたい。

そのために2つのことを意識していきたいと考えています。

  1. チームマネジメントについて学び、チームに合った進め方でさらに推進力を上げていく
  2. ビジネス目線を常に意識し、開発で生まれるサービスが価値あるものになるようにディレクションを行う

一人のディレクターとしてチームや会社にとってなくてはならない存在になることを目指してきます。

最後に一言

勇気を出して、別業界かつ別職種にチャレンジしたことを本当によかったと思っています。
辛い時もありますが、日々自己成長できていると実感することができています。
この場を借りてチームメンバー、同じエンジニアメンバー、一休の皆さんに感謝の気持を伝えたいです。いつも温かいアドバイス、ありがとうございます。
これからも明るさと元気を取柄に頑張ります!

一休レストランの XState 導入記

このエントリーは 一休.comのカレンダー | Advent Calendar 2023 - Qiita の22日目の記事です。

レストランプロダクトUI開発チームの鍛治です。 一休レストランのフロントエンドを担当しています。

一休レストランでは Next.js App Router Remix を採用しています。

user-first.ikyu.co.jp

昨年の終わり頃から始まった一休レストランのリニューアルですが、フロントエンドは Nuxt v2 (Vue 2) から Next.js App Router (React) に、という大きな切り替えで、不慣れだった我々は React 初心者がひっかかる落とし穴を全部踏み抜いてきました。

例えば、チュートリアルに従って useState で変化する状態を定義して、最初はそれで全てがうまくいっていました。機能追加していく過程でいつの間にか一つ増え二つ増え、あとはズルズルと。 ふと我に返ると一つのコンポーネントに10個もの useState が生えてしまっていました。 その結果、 &&, ||, ?? のオンパレードと三項演算子だらけの JSX だけが残りました。何度も何度も読み返してるのに、コンポーネントが今どんな状態にあるのか、さっぱり把握できない…

他にも、

  • バケツリレー コールバック
  • useEffect 問題

といった落とし穴を踏み抜いてきました。

フロントエンドの状態管理って本当に難しいですよね。

あらためて本日は React 状態管理改善の第一弾として useState 濫用からどう抜け出したのかについてお話しします。

コールバックや useEffect 問題は来月以降の記事でご紹介する予定です。

useState の難しさ

まずは一番初歩的なところから考えてみましょう。

複数のuseStateフックを使用する場合、予期しない状態の組み合わせが発生する可能性があります。

function Sample() {
 const [show, setShow] = useState(false);
 const [disabled, setDisabled] = useState(false);
 const toggle = useCallback(() => {
   setShow((prev) => !prev);
 }, []);
 const toggleDisabled = useCallback(() => {
   setDisabled((prev) => !prev);
 }, []);
 return (
   <>
     <button onClick={toggle} disabled={disabled}>
       show
     </button>
     <button onClick={toggleDisabled}>disable</button>
     <SampleModal show={show} />
   </>
 );
}

このシンプルな例では、show(モーダル表示用)と disabled(ボタン無効化用)の二つの状態を管理しています。

しかし、たった二つしかないのに show === true && disabled === true のように、ボタンが無効化されているにも関わらずモーダルが表示されている、という矛盾した状態を表現できてしまいます。useState で管理する状態が増えれば増えるほど、矛盾した状態を生んでしまう可能性は高くなります。

この問題を解決するためには、コンポーネントの粒度を小さくし、useState には primitive 値を入れず構造化されたデータを用いて、ありえない状態を生まないようにするのが自然な発想でしょう。


type State = Initial | Disabled | Modal


type Initial = {
 type: 'Initial'
 disabled: false
 show: boolean
}


type Disabled = {
 type: 'Disabled'
 disabled: true
 show: false
}


type Modal = {
 type: 'Modal'
 disabled: false
 show: true
 modalData: ModalData
}


function Sample() {
 const [state, setState] = useState<State>({ type: 'Initial', disabled: false, show: false })
 const open = useCallback(() => {
   setState({ type: 'Modal', modalData: 'data', disabled: false, show: true })
 }, [setState])
 const toggleDisabled = useCallback(() => {
   if(state.disabled) {
     setState({ type: 'Disabled', disabled: true, show: false })
   } else {
     setState({ type: 'Initial', disabled: false, show: false })
   }
  }, [setState])
  return (
   <>
     <button onClick={open} disabled={state.disabled}>
       show
     </button>
     <button onClick={toggleDisabled}>disable</button>
     <SampleModal show={state.show} />
   </>
 )

useState + union 型では足りなかった

上述した実装のように、union 型によって不正な状態が作られなくなりました。

遷移はイベントハンドラ内で暗黙的に記述されます。上記のモーダルでは状態が2つしかなく、シンプルな実装なので遷移の全体像を把握できていますが、状態の数が増え遷移が複雑になると遷移の全体を把握するのが困難になり、人為的に遷移先を決定するロジックをテストする必要があります。結果、誤って不正な遷移が紛れ込む場合があります。

例えば、一休レストランでは空席確認カレンダーという機能があります。

空席確認カレンダー

上記空席確認カレンダーの状態遷移図は以下のようになります。黒色で囲われているのが状態で、灰色で囲われているのが遷移イベントです。

カレンダーの状態遷移図

状態が7個、遷移イベントが20個あり、イベントハンドラ内での遷移先を決めるロジックが複雑になってしまい不正な遷移を起こしてしまう可能性がありました。

このような不正な遷移を人為的ではなく機械的に防ぐために、state machine を導入します。

state machine とは?

state machine は複数の「状態」と「状態間の遷移」で構成されます。

上述した web 画面のシナリオを例にすると「フラットな状態」(通常の状態)から「モーダルが開いた状態」への遷移は「 show ボタンをクリックする」というイベントによって行われます。

「モーダルが開いた状態」では再度 「show クリック」イベントが発生しても、そのイベントに対応する状態遷移は定義されていないので、それ以上何も起きません。

また「フラットな状態」から最初に disabled ボタンが押されて (disable イベントが発火して)「ボタンが無効化された状態」になると、そこで仮に show イベントが発火しても、同様に show イベントに対応する状態遷移が定義されていないので、「ボタンが無効なのにモーダルが開いてしまう」という矛盾した状態が生じません。

モーダルの状態遷移図

state machine では、あらかじめ定義した状態とその状態間の遷移しか存在しないので、予期しない状態に陥ることがありません。state machine を導入すると、アプリケーションロジックを明確かつ宣言的に定義できるのが非常に魅力的なポイントです。

XState (state machine) の導入

state mcahine を導入するために、XState を使った状態管理方法を導入することを決定しました。

もちろん他の解決策もあったと思います。

例えば、弊社 CTO が以前ご紹介した TypeScript の discriminated union (タグ付きユニオン型)で状態を、関数で遷移を表現する手法はその一つであり、弊社プロダクトで実績あるソリューションであることは間違いありません。

techplay.jp

ただ、現在の自分達では、制約のない状況下でうまく型を定義して、状態を完全にコントロールできるという自信は持てませんでした。state machine もどきの不完全な物を生み出してしまわないか不安があったのです。

XState であれば state machine を正しく定義することを強制されます。技術としてのフレームワークに留まらず、思考のフレームワークとしてガイドレールを提示してくれる点を評価しました。

XState とは?

stately.ai

state machineを作成することができる非常に高機能なライブラリです。

例えば、フロントエンドのサンプルとしてよく用いられる TODO リストを XState で実装*1すると以下のようになります。

type TodoList = {
 items: {
   id: number
   name: string
   completed: boolean
 }[]
}


type TodoEvent = Add | Toggle | Disable | Enable


type Add = {
 type: 'ADD'
 item: {
   id: number
   name: string
   completed: boolean
 }
}


type Toggle = {
 type: 'TOGGLE'
 id: number
}


type Disable = {
 type: 'DISABLE'
}


type Enable = {
 type: 'ENABLE'
}


type TodoState = { value: 'ACTIVE'; context: TodoList } | { value: 'INACTIVE'; context: TodoList }


export const machine = createMachine<TodoList, TodoEvent, TodoState>({
 initial: 'ACTIVE',
 states: {
   ACTIVE: {
     on: {
       ADD: {
         target: 'ACTIVE',
         actions: assign((ctx, event) => ({ items: [...ctx.items, event.item] })),
       },
       TOGGLE: {
         target: 'ACTIVE',
         actions: assign((ctx, event) => ({
           items: ctx.items.map((item) =>
             item.id === event.id ? { ...item, completed: !item.completed } : item
           ),
         })),
       },
       DISABLE: 'INACTIVE',
     },
   },
   INACTIVE: {
     on: {
       ENABLE: 'ACTIVE',
     },
   },
 },
})

まず state として TODO を追加したりトグルを変更が可能な状態の ACTIVE と、なにもできない状態の INACTIVE を定義します。

次に、各 state が各イベントを受け取った時にどの状態に遷移するか、すなわち状態遷移を on で定義し、その状態遷移時の副作用としてのデータ更新を actions で指定することで、state machine が完成します。

XStateでは、内部情報として context (詳しいことは後のセクションで説明します)を持ちます。ADD イベントでは context である items に 新しい TODO を追加しています。

XState で定義した state mahine では、INACTIVE の状態で ADDTOGGLE のイベントに対する状態遷移を定義していないので、ありえない状態に遷移しないことが保証されます。

context

context とは、state machine が扱う状態の「詳細」や「変動する部分」を吸収して、複雑な状況に対応する仕組みです。

state machine 、厳密には有限状態機械(FSM: Finite State Machine)の「有限」は、あくまで数学的な「有限」です。

実際のアプリケーションでは、管理しなければならない状態に紐づくデータや条件が複雑で、有限状態機械を原理的に適用すると、たとえ「有限」であっても、人間の認知能力ではとうてい把握しきれない膨大なバリエーションを生み出してしまいます。

有限状態機械を現実的に利用するために 状態とその状態に関連するデータを分離して、context という形で保存・管理します。

例えば以下のように、ユーザーの入力やアプリケーションの現在の状態など、状態自体ではなく、状態の「内容」を表すデータのことです。

  • 予約する人数日時
  • 予約の際に選択する支払い方法
  • 使用するクーポン情報

予約入力の状態遷移図

XState で管理すべきでない状態

XState で全ての状態を管理すべきと言ってるわけではありません。ボタンを押すとモーダルが表示される状態遷移は、XState で管理してしまうと却ってオーバーエンジニアリングになってしまいます。

また、以下の場合は状態として持つべきではありません。

  • 状態遷移から独立しており、値が操作の過程で変化しないもの

例えば、API レスポンスは state machine の遷移に変化する値ではないので XState で管理すべきではなく、useState で管理すべきです。

XStateで管理すべき基準としては

  • 1つのコンポーネントで useState が3つ以上定義されている
  • 何かアクションを起こした時の遷移先が2つ以上ある

場合だと思ってます。(プロダクトによって基準は違うと思うのであくまで目安です)

XStateを導入して良かったこと

フロントエンドの改修が容易になった

state machine によりありえない状態ができないことが担保されているので、フロントエンドの改修をする際に大きいバグが起きなくなりました。

実装前の仕様 / モデリングの議論ができるようになった

state machine が画面のドメインモデルとなるので、画面や機能を作成する際にどのような state machine にするか議論することで、意図せずも画面や機能のモデリングの議論ができるようになりました。

所感

XState による state machine という考え方のガイドレールができたことで、条件文を最小限にする state mahine のメンタルモデルが形成されてきたように思います。

また、上述したように全て XState で管理すべきだとは思ってません。適材適所で XState をうまく活用していきたいです。

さいごに

一休では、より良いサービスを作ってくれる仲間を募集しています!

www.ikyu.co.jp

カジュアル面談も実施していますので、ぜひお気軽にご連絡ください!

hrmos.co

*1:XState 4 ベースのコードです。XState 5には近日中に移行予定です

Cloud Runで開発用環境を沢山作る

概要

この記事は 一休.com Advent Calendar 2023 16日目の記事です。

RESZAIKO開発チームの松村です。

一休では各サービス毎に、開発中のサービスの動作を社内で確認できる環境があります。 それぞれmain(master)ブランチと自動的に同期している環境と、特定のブランチを指定して利用できる環境の2種類があります。

今回、RESZAIKOの新規サービス(予約画面)に対してブランチを指定してデプロイできる環境を作成したので、その方針と反省点と今後について記述していきます。

  • 現在運用中の予約画面

開発環境を作る理由

一休では長らく、EKS上に複数の環境を用意して、ブランチを指定すると開発環境にデプロイするシステムが利用されてきました。 一般的にこのような環境を構築するのは以下のような理由が挙げられます。

  • 動作確認
    • マイクロサービスで、異なるブランチ同士の組み合わせで動作確認がしたい
    • ローカルだと何故か再現しない
    • デプロイがちゃんと動くか確認したい
  • 他人と成果物の共有
    • リリースできるほど動作に自信は無いが、ステークホルダーと内容を共有したい

本サービスではPrismaを利用してDBのスキーマをアプリのコードと同じリポジトリで管理しているため、 複数の新機能を平行して開発していく場合に開発環境が1つだと、DB定義が衝突したりして尚更大変です。 そこで、複数の開発環境を作成できるようにしました。

本サービスは基盤にGoogle CloudのCloud Runを使用しています。 Cloud Runは特に設定しなければアクセスがある時だけコンテナが起動するようになっているので、EKSを使用した場合よりスペックやコストをあまり気にせず環境を増やしていけます。

実現方法

サーバはCloud Runで動いていて、デプロイは Github Actionsで行っています。 そのため、開発環境用のGithub Actions Workflowを作成していきます。

デプロイを行うGithub Actions Workflowの作成

本記事の主旨から外れるので詳しく説明しませんが、 Google CloudにはGithub Actionsと連携してデプロイを行うための機能 が各種用意されているので、参考にしてWorkflowのyamlファイルを作成します。

name: backend.demo.create

on:
  workflow_dispatch:
    inputs:
      name:
        required: true
        type: string
        description: "Environment name to deploy"

jobs:
  build:
    runs-on: ubuntu-latest

    steps:
      - name: "Checkout"
        uses: actions/checkout@v3

      # SecretからGCPの認証用のjsonを読み出す
      - id: "auth"
        uses: "google-github-actions/auth@v0"
        with:
          credentials_json: "${{ secrets.gcp-dev-service-accont-key }}"

      - name: "Set up Cloud SDK"
        uses: "google-github-actions/setup-gcloud@v0"
        with:
          install_components: 'alpha,beta'
      # 以下ビルド・デプロイの記述

Workflowの呼び出し

Workflowに workflow_dispatch を定義することで、 外部からREST APIでWorkflowを呼び出すことができます。 開発環境用のアプリを作成して、そちらからREST APIで必要に応じてWorkflowを呼び出してあげます。

POST https://api.github.com/repos/test/test-repo/actions/workflows/backend.demo.create/dispatches
Content-Type: application/json
Accept: application/vnd.github+json
Authorization: Bearer <TOKEN>
X-GitHub-Api-Version: 2022-11-28

{
  "ref":"feature/branch-to-test",
  "inputs":{"name":"demo-1"}
}

実装された運用

ブランチデプロイサービス画面

こんな感じのアプリを作成しました。 ブランチ名を入力して Deploy を押すと、デモ環境に該当のブランチがデプロイされます。 いつ、誰が、どのブランチをデプロイしたかを記録するようになっています。 削除機能はまだ実装していないので、使い終わったらmainブランチを手動で適用する運用になっています。

反省と将来

折角Cloud Runを使っているのに、既存の他サービスの仕様に引きずられた実装にしてしまいました。 特に以下の点が良くないです。

  • 設定ファイルをコピペして増やしていたので、環境を増やす毎に同じような設定ファイルが増える
  • 環境毎に社内用のドメイン( [env-name].dev.reszaiko.com のような)を作っていたので、環境を増やす度にDNSとSSLの設定が必要になる

このため、気軽に環境を増減させる事が困難になっていて、既存の問題をそのまま引き継いでいます。

  • 使わなくなった環境を戻し忘れてそのまま占有し続ける
  • 空いている環境がない場合、他の環境を使っている人とコミュニケーションして融通してもらう必要がある

このままデプロイ環境を作るなら

ブランチデプロイ環境として、全てのブランチに対して自動的にデモ環境を作成、破棄するのが理想です。 コンテナのビルドやDBやサーバの用意、デプロイは既にGithub Actionsで行うようにしていますし、 開発環境へのアクセスはCloud Routerを利用して振り分けているため、 dev.reszaiko.com/[branch-name]/ のように環境毎のパスの追加もGithub Action上で構築できます。

また、特に開発環境を必要としない軽微な修正に対しても無制限に環境を作るのを防ぐために、以下の手段が考えられます。

  • dev-**** のように、特定のprefixを持つブランチに対して自動で環境を作る
  • 既存のデプロイ用UIを拡張して、環境数を増やしたり減らしたりできるようにする

前者はブランチが消えれば自動で環境が消えるので、使わなくなった環境が残ってしまうというよくある問題が解消できます。 後者はUI上で存在する環境の把握やアプリへのリンク、DBのリセットなど機能を追加する事ができて便利です。

開発環境を作らないと駄目なのか

そもそもブランチデプロイ環境が必要か、という問題もあります。

開発中のブランチを長期間利用していると本番環境との乖離が大きくなり、mainブランチにマージする際に入念なチェックが必要になります。 RESZAIKOの予約チームでは トランクベース開発 のように 頻繁にリリースする手法を導入するか議論していますが、 このような手法では開発中の機能はフィーチャーフラグを利用して出し分けるのが適しています。

RESZAIKOでは LaunchDarkly というフィーチャーフラグ機能を提供してくれるSaasを導入しているため、 コストをかけてブランチデプロイ環境を開発していくよりは、フィーチャーフラグを適切に利用する体制を整備し、開発環境はmainブランチと同期したものだけで運用していく方がいいかもしれません。

まとめ

使用している技術やサービスは日々新しい物が導入対象になるので、最適な開発手法というのはその時に合わせて検討する必要があります。 次に記事を書くときは「トランクベース開発に合わせたフィーチャーフラグの運用法」みたいなのが書けるように頑張ります。

一休では、共に働くエンジニアを募集しています。

www.ikyu.co.jp

カジュアル面談も実施しているので、お気軽にご応募ください。

hrmos.co

一休レストランで Next.js App Router から Remix に乗り換えた話

このエントリーは一休.com Advent Calendar 2023の15日目の記事になります。


CTO 室の恩田です。

現在は一休レストランのフロントエンドのリアーキテクトを手がけています。 今日はその中で Next.js App Router から Remix に乗り換えた話をご紹介したいと思います*1

背景

6日目の記事で香西から紹介させていただきましたが、2023年10月に一休レストランのスマートフォン用レストラン詳細ページをリニューアルしました。

あらためてリニューアルでの技術的な変更点を再掲すると:

  • バックエンド言語:Python から Rust へ
  • フロントエンドフレームワーク:Nuxt v2 から Next.js App Router へ

つまり、このエントリは先日リリースしたばかりの Next.js から Remix に乗り換えた、という話になります。

図らずも、昨今盛り上がっている Next.js 論争*2に足を踏み入れることになりました。

Next.js App Router について

まずは disclaimer として、あくまで一休レストランにおいて Next.js App Router が "not for us" であっただけで Next.js そのものに対する評価ではないことは申し添えておきます。

その上で、ここでは Next.js App Router を採用した経緯と、実際に採用してみてどんな課題に遭遇したのかを簡単に説明したいと思います。

当初 Next.js を採用した経緯

採用を決めたのは Next.js 13 の発表直後、一休レストランのリニューアル計画が動きはじめた頃になります。

以下が主に評価した点ですが、

  • メタフレームワークとしてデファクトスタンダードとしての地歩を固めつつあったこと
  • 弊社内の別プロダクトで Next.js (Pages Router) の採用実績が複数あること
  • そして toC サービスである一休レストランにとって、カリカリにチューニングできそうな React Server Component が非常に魅力的なフィーチャーであったこと

特に最後の React Server Component が採用の決め手となりました。

先日の Next.js 14 で発表された Partial Prerendering もそうですが、toC サービスの欲しい機能をピンポイントに突いてくるニクいフレームワークです。

Next.js の Pain Points

そもそも今回のリニューアルにおけるビジネス上のゴールは、一休レストランで予約するとき、お店に電話をかけたときのようなスムーズな体験を提供する、というものでした。

しかし、社内レビューや canary release の過程で見つかったユーザー体験の問題を改善するにあたって、Next.js App Router では実現が難しそうな課題がいくつか見つかってきました。

History API の state を触れない

リニューアルしたスマートフォン版一休レストランは以下のような画面遷移になります。

レストラン詳細ページ

空席確認カレンダーモーダル

人数・日時を選択する空席確認カレンダーのモーダル表示がポイントです。*3

ここでの選択は予約にいたるまでの一連の流れのワンステップなので、操作中はブラウザの「戻る」やリロードで開いた状態を維持したいモーダルです。

ただ、その状態で URL が LINE などで共有されたときは、モーダルのない詳細ページが開いて欲しい場面でもあります。

Next.js App Router の Link コンポーネントや useRouter フックでは History API の state を操作することはできず、URL を変更せずにブラウザ履歴を積んだ上で画面表示を変更することができません。

Cache-Control ヘッダを自由に設定できない

Next.js App Router では Cache-Control ヘッダは Dynamic Functions が利用されたかどうかと Route Segment Config で設定した値を元に Next.js 自身が出力する仕様となっており、利用者が自由に値を設定することはできません。

例えば searchParams を参照しただけで Dynamic Functions と判定され、強制的に Cache-Control: private, no-cache, no-store, max-age=0, must-revalidate が出力されてしまいます。

Fastly を CDN として利用している一休では、Cache-Control ヘッダを制御できない*4という制限は、パフォーマンスやインフラ負荷に影響を与える大きな問題です。

また、レストラン詳細ページ以降のページだけが今回のリニューアル範囲のため、 bfcache が無効になってしまうのも、既存ページとの遷移でユーザー体験に悪影響を及ぼします。

継続的なアップデートに懸念を覚えた

Next.js のパッチバージョンを上げたときに production build でだけ 500 エラーが発生するという問題に幾度か苦しめられました。

App Router で運用している世界の様々なサイトで同じ問題が発生していたら大きな Issue になっているはずで、一休レストランのコード、もしくは利用ライブラリのいずれかに原因があったことには間違いないとは思います。

現象の再現状況の特定が難しく、加えて調査に十分なリソースを割けなかったという背景もありましたが、正確な原因が掴めず仕舞いとなってしまったことには歯痒い思いとともに、懸念が残りました。

Remix への乗り換え

上記の課題を解決するため、最終的には Remix に乗り換えることを決定しました。

Remix を採用した理由

Next.js App Router で抱えていた課題の裏返しになるのですが、そもそもの Remix の設計指針である、Web 標準 API を尊重している点*5を特に重視しました。

History API

改善したかったクライアントサイドのナビゲーションを例に取ると、Remix の提供している Link コンポーネントや useNavigate フックは History API *6 の薄い wrapper になっていて state を利用することが可能です。

具体的には、Remix 自身もスクロール位置の維持をはじめとするクライアントサイドナビゲーションの管理に History API state を利用していて、Remix API で利用者が指定した stateHistory API state では、

{
  "usr": {"state": ["set", "from", "Remix API"]}, 
  "key": "dgfkntlh", 
  "idx": 2
}

上記の例のように Remix が定義する History state の構造の中の "usr" キーの中に格納されます。

この構造を理解していれば、直接 History API replaceState を呼ぶことで Remix の遷移は抑止しつつ state だけを置き換えるような運用も実現できます。

Cache-Control ヘッダ

Next.js Pages Router の getServerSideProps に相当する Remix の機能に loader があります。

loader の引数や返り値は Web 標準の Request / Response なので Cache-Control にも出力したかった値を設定でき、CDN やブラウザキャッシュをコントロールする自由を取り戻しました。

その他

他にも Next.js App Router の Async Server Component に相当する効果*7が得られる defer など、toC サービスである一休レストランにとって魅力的な機能を備えています。

検討した代替案

Remix 以外に検討した対策についても簡単にご紹介します。

Next.js に patch をあてる

Cache-Control ヘッダの問題は Next.js の設計方針そのものでどうしようもないので、 pnpm patch でヘッダを出力している Next.js の当該コードを上書きしてしまう対策*8も試しました。

ですが Cache-Control を制御したい path が増える度に patch を更新するのは手間がかかって煩わしいし、ヘッダを書き換えられるようになるだけで、ナビゲーション問題は解決できません。

Pages Router への切り替え

Pages Router への切り替えも少しだけ検討しました。

一休の他プロダクトで Pages Router の実績はあるので安定性に不安はありませんが、React Server Component に期待したパフォーマンス面はあまり期待できそうにありません。*9

また Vercel の開発リソースも App Router にほぼ向けられているだろうし、現時点において Pages Router を選択するのは将来性も見込めないと判断しました。

Remix 置き換えで得られた効果

ちょうど Remix 版をリリースして一週間経過したところですが、以下のような効果が得られています。

継続的なアップデート

2023-12-18 追記

つい先日の 12/14 にリリースされたばかりの Remix 2.4.0 まで、問題なく追随できていることをご報告しておきます。

Fastly の cache hit ratio が 63% → 68% に

置き換えの目的の内の一つである CDN とブラウザキャッシュの有効活用です。

背景で紹介していますが、リニューアル対象はスマートフォン用のレストラン詳細ページ以降のみで、一休レストラン全体から見れば、ごく限られた範囲でしかありません。

にも関わらず、一休レストラン全体の cache hit ratio を 5% ポイント近く向上させることができました。

インフラの効率化もさることながら、Fastly のキャッシュから返ってくるときのレスポンス速度は圧倒的に高速なので、ユーザー体験を向上させる改善に繋がったことが何よりも嬉しい成果です。

Cloud Run の効率化

ここは意図していませんでしたが Remix 乗り換えで得られた嬉しい副作用です。

メモリ使用量が 1/4 に

Cloud Run Memory Utilization

グラフの通りメモリ使用量が 1/4 に減りました。 一休レストランは夕方から夜にかけてアクセスのピークを迎えるのですが、その間も安定して同じ水準を保っています。

コンテナ起動時間が 1/2 に

Cloud Run Startup Latency

Next.js では 20 秒強かかっていたコンテナ起動時間が 10 秒に縮まりました。

Next.js 時代からの課題ですが、ローカルでは一瞬で起動するのに、Cloud Run だと起動に時間がかかってしまう問題は調査中です。

所感と最近の議論

Remix に乗り換えての個人的な所感になりますが、Web 標準 API がそのまま使えて、利用者が思った通りにコントロールできる非常に扱いやすいフレームワークだと感じています。

上記はあくまで私の印象になるので、最近の Next.js の議論で特に参考にさせていただいたリソースを紹介します。

今後の展望

現時点ではまだ Remix に置き換えただけで、ようやく改善のための足回りが整った、という段階です。

引き続きよりよいユーザー体験を目指して、本丸のナビゲーションの改善、CDN キャッシュ効率向上によるレスポンスの高速化を進めていきたいと思います。

おわりに

今回の一休レストランの問題だけでなく、フロントエンド領域で難しい課題をまだまだ抱えています。

一休では、事業の成功を技術面からともに支える仲間を募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

hrmos.co

*1:同じ一休レストランフロントエンドのリアーキテクトの一環で XState を導入した話は22日目の記事でご紹介しています。

*2:後段で紹介します。

*3:カレンダーの状態管理についての紆余曲折については22日目の XState の記事で紹介しているので、ご笑覧いただければ幸いです。

*4:Fastly のキャッシュ制御は Surrogate-Control ヘッダで、ブラウザキャッシュのための Cache-Control ヘッダは VCL など他の手段で上書きすることはできますが...

*5:Remix サイトのトップページに "Focused on web standards and modern web app UX" と掲げられています。

*6:Navigation API が早く普及して欲しい...

*7:正確に述べると fetch 処理は loader に一元化して Promise を defer を使って返す必要があります。

*8:この問題は他の利用者も困っているようで Next.js の Issue 内に patch をあてる workaround が紹介されています。

*9:Remix 公式ブログの Next.js との比較記事 で詳解されていますが Pages Router と比較すると Remix に軍配があがるようです。

宿泊管理システムのフロントエンド設計と改善の変遷

宿泊プロダクト開発部の田中(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コンポーネントライブラリは多くありませんでしたが、現在はVuetifyQuasarなどのライブラリが対応しており、選択肢が広がっています

これ以上の設計、方針は決めなかった

ほかにも開発方針として

  • コンポーネントの分割方針をどうするか
  • 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で取得するフィールドは利用するコンポーネント以外からは参照できないように隠蔽化もできますが、まだこの機能は有効にしていません。

the-guild.dev

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で制限することにしました。小さい単位で作業を分割して進められるように

  1. 修正対象箇所がわかるようにwarningを出すようにlintを変更
  2. 地道にwarningが出なくなるように書き換え
  3. warningがなくなったらlint設定を変更してerrorにしてCIで止まるようにする

というステップで作業を実施しました。

asの撲滅のためのpull request

CIで止まるようにlintでエラーになるようにするpull request

非nullアサーションは完全に撲滅できましたが、型アサーションとanyの利用は改善の途中です。

4. 秩序を保てる開発体制、ドキュメントの整備

1~3でだいぶコードに秩序がある状態になりましたが、今後の開発によって悪化しないように以下を実施しました。

  • コードレビューの強化
    • CODEOWNERによるレビューを必須にして、定めた設計方針に沿った内容になっているかを識者がレビューする体制に

docs.github.com

  • ドキュメントの整備
    • コンポーネントのレイヤーと役割、Fragmentの利用方針、スタイルガイドなどをリポジトリのWikiにまとめて開発やレビューでの指摘に活用

Wiki(抜粋)

現在と今後

これらを積み重ねた結果

  • components配下はかなり見通しがよくなり、秩序がある状態になった
  • 設計・開発をする際の指針ができており、レビューも指摘しやすくなった

など、改善の効果を感じています。先月末~現在にかけて、新機能を開発しているのですが、フロントエンドの開発はとてもスムーズで、迷うことがほぼなくなってきました。

今後やりたいこと

引き続きプロジェクトを進めながら改善を続ける状態を維持したいと思っています。 具体的に考えている大きめの改善テーマとしては

  • @Vue/apollo(@vue/apollo-composable)の脱却
    • v4のβ期間が長く、バージョンアップによって意図しない不具合が入ったことがあるため、別のGraphQLクライアントへの変更を検討中
  • E2Eテストの整備
    • 機能追加・変更時のリグレッションテストを効率的に行うため、Playwrightを導入してE2Eテストを整備、CIに組み込む予定で改善中

などがあります。

改善を継続するためのポイント

  • プロジェクトで開発する際に違和感を感じたら、熱量があるうちにIssueにする(コードレビューや開発しながらやるとよい)
  • 上がったIssueを開発者で議論・認識合わせをしておく
  • 機能開発とセットで改善することを常に考える

改善のネタを常に仕込んでおいて、機能開発をする際に「あ、あれ一緒にやりません?」みたいな形で組み込んで機能追加とシステム改善を同時にやっていくのが理想だと考えています。

まとめ

一休.com宿泊の管理画面のフロントエンド設計について、開発初期から現在までの変遷と今後について紹介しました。 本来は開発初期に決めておくべき内容を決めなかったことでローンチ後に改善することになってしまいましたが、まだシステムが大きくないタイミングで改善を進められたことは良かったと思っています。

一緒に改善を進めてくれているチームメンバーにとても感謝しています。

今後もこのシステムで

  • 大きなビジネス成果につなげる
  • 中長期で開発・運用していけるシステムにする

を両立してやっていけるように、引き続きやっていきたいと思います。

おわりに

一休では、技術的にも妥協せず、事業の成果をともに目指せる仲間を募集しています。

www.ikyu.co.jp

まずはカジュアル面談からお気軽にご応募ください!

hrmos.co

明日はtak-ondaの「一休レストランで Next.js App Router から Remix に乗り換えた話」です。お楽しみに!

*1:Apollo Clientのキャッシュや、GraphQLサーバのLoaderによって発生しないケースもあります

ADR を1年間書いてみた感想

宿泊開発チームでエンジニアをしている @kosuke1012 です。チームで ADR を書き始めて1年くらい経ったので、その感想を書いてみたいと思います。

この記事は 一休.comのカレンダー | Advent Calendar 2023 - Qiita の13日目の記事です。

ADRとは

アーキテクチャ・ディシジョン・レコードの略で、アーキテクチャに関する意思決定を軽量なテキストドキュメントで記録していくものです。

出典はこちらで、

わかりやすい和訳は以下の記事が、

事例は以下の記事が分かりやすかったです。

ADRを導入したねらい

機能を追加したり改修したりする際は、チーム外のメンバー含む様々な人との議論を経て、仕様やアーキテクチャが決定されていくと思います。

そうした議論を経た最終的な決定は実際のプロダクトやアーキテクチャ図などに表現されるのですが、「どうしてそのような仕様やアーキテクチャになっているのか」と言った部分を後から知りたくなったりすることがありました。

これは ADR で解決したい課題そのものと言って良いものなので、チームで ADR を書いていってみよう!という話になりました。

採用したフォーマット

いろいろなフォーマットがあるようなのですが、まずは以下のようなフォーマットで記載しました。

# タイトル
タイトルには、一目で論点がわかるタイトルを記載します。可能な限り具体的で、それでいて簡潔なタイトルを心がけると良さそうです。(これが難しい)

# ステータス
draft, proposed, accepted, rejected, deprecated, superseded

原典のフォーマットには draft はありませんが、この段階で決定を除いて記載しておいて、MTG で決定みたいに進めたいシチュエーションがあったので、追加してみました。
proposed で一旦完成で、チーム(またはチーム間)で合意ができたら accepted にするのが良いかと思います。
別な議論などで決定が覆された場合、当該 ADR の決定を修正するのではなく、当該 ADR (ADR: 1 とする) のステータスを ( rejected: ADR: 2 に伴い ) とした上で、別途新しく ADR を起こし ( ADR: 2 とする )、そのステータスを (proposed: ADR: 1は破棄 ) などとすると良いです。

# コンテキスト
コンテキストには、その ADR の決定が求められている背景や、対応案、対応案に対する評価を記載します。

# 決定
コンテキストを踏まえた決定を、受動的ではなく、肯定的かつ能動的に記載します。

# 影響
この決定の結果生じる影響を記載します。これは、決定の結果得られるメリットのほか、コンテキストで記載した対案を選択しなかった故のデメリットであったりも記載すると良いと思いました。
また、決定の結果、今後チームで意識しなければならないことであったり、改めて必要になる機能やその ADR を記載しても良いと思います。

Michael Nygard さんのフォーマットそのままに draft ステータスだけを追加しています。「ADR を書くときのコツ」の項で後述しますが、draft ステータスは結論が決まっていない段階で ADR を書くのに便利です。このフォーマットで1年運用してみましたが、必要十分だなという感じでした。

ADRの格納場所

私のチームではドキュメントシステムに Confluence を利用していたので、 ADR もそこに記載していきました。そのほかの選択肢としては、プロダクトの GitHub のリポジトリに置く案もあったのですが、そうするとプロダクトを横断する ADR や、具体的なプロダクトが決まっていない柔らかい段階での ADR の置き場に困ったりするので、 Confluence に落ち着きました。

ADR は自分たち以外のいくつかのチームでも書くようになったのですが、その管理方法はチームによりけりでした。
例えば GitHub Projects を利用したタスク管理 - 一休.com Developers Blog のチームでは、ADR 専用のリポジトリを作った上で、GitHub Issues に記載していったようでした。これなら先述の問題はクリアできています。
プロジェクト管理に GitHub Projects を用いている場合は GitHub に一元化することが出来て相性も良いため、GitHub Issues に記載していく方法が良いかもです。

書いてみたADRの例

個々のADR

ADRに番号を振ってプロダクトや案件ごとにまとめています

書いてみてよかったところ

ADR を書いてみてよかったことをいくつか書いてみます。

1. 「ここの設計どうしてこうなってたんだっけ?」に困らない

ADR を書いた1番のモチベーションです。これが解消するのは非常に助かりました。自チームだけではなく、他チームが困っているときに「スッ…」と ADR をスマートに差し出すこともできました。

2. 議論の効率が上がる

以下の複数のポイントで、開発する中での議論の効率が上がりました。

議論が蒸し返らない

1.とも重なるのですが、議論になるような仕様上/設計上のポイントでその背景を思い出すのに手間取ったり、(新事実が見つからない限りは)「やっぱりこっちの方がいいのでは」みたいな話にならないので、議論の効率が上がります。

意思決定するべきことが明確になる

「ADR を書くときのコツ」項で後述するのですが、あらかじめ draft の状態で ADR を記載しておくことで、意思決定しなければいけない項目が明確になり、議論の中であいまいにせず意思決定するようになり、議論の効率が上がります。

意思決定したことが明確になる

ADR を導入してから、MTG の最後に「hoge の件 ADR に書いておきましょう」といった会話が増えました。これによって、意思決定したことをクリアに言語化することになり、議論の効率が上がります。

仕様検討~決定までのフレームワークができる

チームで議論が必要になった際に「じゃ、ADR 書いてまとめておきましょう」という流れができるのが結構良く、検討の中心となるメンバーが増えたり変わったりしてもフレームワークに沿って進めることで議論のレベルを保ちやすくなります。

3. 新規メンバーが立ち上がりやすくなる

新しく参画したメンバーが疑問に思うであろうポイントに ADR があるケースが多いので、キャッチアップしやすいという意見も上がりました。

ADR を書くときのコツ

良い ADR を書くのには割とコツがあることがわかってきたので、気づいた点を書いてみます。

1. タイトルは体言止めにせず、文にする

「hoge について」や「hoge の設計」など、体言止めにするのではなく、「hoge は fuga とする」といったように、タイトルを文にします。

こうすると、タイトルをみるだけで内容が一発でわかるほか、ADR を書く際にも論点がクリアになり、記載や議論の効率があがりました。

2. 結論が決まっていなくても ADR を書きはじめてしまう

結論が決まっていない段階であっても ADR を書きはじめることで、何を決める必要があるのかが明確になってよかったです。
未定のところは実際に hoge などと書いておいて、それを元に議論して、決定事項で hoge を埋める感じです。

3. コンテキストを SCQA フォーマットで書く

コンテキストの章で、結論に至るまでのギャップをいかに埋めるかというのが大切なのですが、これが慣れるまで結構難しいです。

その際のフォーマットとして、SCQA というのが有用でした。『考える・書く技術』という本で紹介されているフォーマットなのですが、

  • S: Situation 状況
  • C: Complication 複雑化
  • Q: Question 疑問
  • A: Answer 答え

Situation でまず状況の説明をして、それに続く Complication で、今回の Question やその Answer が必要になるトリガーを説明します。

www.diamond.co.jp

上で記載した ADR の例で行くと、

S:

(Slack リンク) での記載の通り、未付与のトランザクションに対して、PayPayの取消が発生することは考えられる。

C ~ Q :

この場合に、
1. 新たに取消トランザクションを作成した上で、新規と取消のトランザクションを見て付与取消バッチに判断してもらうのか
2. 既存のトランザクションを論理削除するのか
の2通りの対応がありうるが、どちらにするか。
1.のメリットとしては、
...
などのメリットがある一方で、
...
というデメリットはある。

のような感じです。

このフレームワークは、ADRに限らず、割と複雑な PR の Description を書く際にも有用だなと思いました。 ちなみに SCQA フォーマットは『スタッフエンジニア』という本でも紹介されていて(私もそれで知りました)、
曰く、

多くの議論で、冒頭の段落が巧みに構成されているだけで重要な対話に火が灯る。

だそうです。シビれますね。 bookplus.nikkei.com

4. コンテキストに、もうほぼ結論の手前まで書いてしまう

前述の通り、コンテキストで背景の共有 → 問題意識の共有、と進めた上で「決定」の項目で結論を書くのですが、コンテキストにどこまで書くかというのが悩みどころです。

これは好みもありますが、もうほぼほぼ結論の手前まで「コンテキスト」の項目に書いてしまえば良いと思いました。

コンテキストで結論の手前まで書いた結果、読み手が「決定」を読んだ感想としては『でしょうね~』となるくらいまで書いてしまって良いのではないかなと思います。

5. とにかく軽量にする

優先順位として、
開発する中での重要な意思決定の記録を漏らさないこと > リッチな ADR を書くこと
として、1つ1つの ADR を軽量にして、記載するハードルを下げることを意識すると良さそうです。

1つ1つの ADR にあまり力を入れすぎると、だんだんと書かなくなっていってしまうことがありました。

6. アーキテクチャに限らず、仕様上の決定も ADR に記載していく

ADR はアーキテクチャ以外の決定の記録にも有用でした。それらの決定が、アーキテクチャ上の決定に影響を与えることもあるため、同じ ADR として並べて管理しておくと便利でした。

ADR では足りないところ

ここまで説明してきた ADR ですが、それだけでは足りないなと思う部分もありました。

検討する単位が大きいものを1つのADRで書こうとするのは厳しい

「hogehoge の仕様検討、といった粒度のものを一つの ADR がチームで出てきたのですが、決める論点が多かったり、発散したりしてしまってあまりうまくいかなかった」という意見がありました。

「ADR を書くときのコツ」の項に「タイトルは体言止めにせず、文にする」「とにかく軽量にする」と記載しましたが、逆に言うと、これが出来ないようなテーマについては、ADR には向かないのではないかと思いました。

「全体として今どうなっているのか」を示すドキュメントはADR とは別にほしい

ADR は、ここの意思決定やその背景を記述するドキュメントですが、それに加えて、やはり「全体として今どのような設計になっているのか」といったドキュメントは必要だなと思いました。いわゆる Design Docs がそれにあたると思います。

Design Docs があり、その個々の設計に至った意思決定やその背景がADRとして残されていると理想的なのではないかと思います。全体としての What を Design Docs に記載して、Why を ADR でサポートするイメージでしょうか。

Design Docs とのすみわけ

Design Docs には、Why に答える項目を含めたフォーマットもあったりするので、チームの中で ADR と Design Docs のすみわけの指針がそろっていると良さそうです。一つの観点として「Design Docs が実装でのフィードバックに基づいて継続的に更新される性質を持ち、一方でADRはスナップショットである」という性質の違いがありそう、との意見が出ました。

以上から、Design Docs と ADR の性質の違いをまとめてみます。

反映するもの 時間軸 答える対象
Design Docs (特に実装以後) 実装 What
ADR 意思決定 スナップショット Why

表中 Design Docs と ADR としてまとめていますが、必ずしもそれぞれのフォーマットでフルに記載する必要はないかもしれません。 例えば ADR はログの形で簡易的に記載していったり、逆に Design Docs も必要な部分だけ記載する、といった判断もあるかもしれません。

これらの項目があることを考慮しておくと、必要十分なドキュメントを用意していけるのではないかと思いました。

さいごに

一休では、ともに試行錯誤しながらよいサービスを作ってくれる仲間を募集しています! www.ikyu.co.jp

カジュアル面談も実施していますので、ぜひお気軽にご連絡ください!

hrmos.co