一休.com Developers Blog

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

GitHub Projects を利用したタスク管理

宿泊開発チームでエンジニアをしている @itinao です。
昨年の10月に入社しました。

今回は GitHub Projects を利用したタスク管理について記載します。

なんとなーく GitHub Projects 使うと、KANBANにしてみたり

リストにして使ってみたり

で終わってしまいます。

もっと色々できるんだよってことが伝えられればと思います。

背景

一休ではチームごとにタスクの管理方法が違い、
Google Spreadsheet・GitHub Projects・Jiraなど、チームごとにタスク管理の方法が異なっています。

各ツールの印象は、、

  • Google Spreadsheet
    • 作り込めば便利なんだけど、壊れやすい...
  • Jira
    • 過去に使って、エンジニア目線だと操作感とかそんな好きになれなかったなあという印象があった..
  • GitHub Projects
    • エンジニアだととっつきやすいのと、ツールをアレコレ移動しなくて済む

個人的には Jira か GitHub Projects を使いたくて、
できれば GitHub Projects を選択したいという気持ちがありました。

そのモチベーションでやり方を考え、現在はこの記事の管理方法で落ち着いています。

どんな機能があるか

この4つを抑えておけば良いです。
Custom Fields / Views / Workflows / Insights

ざっくり概念。

Custom Fields

下記の種別でパラメータを作成でき、Draft / ISSUE / Pull request に値をセットすることができます。

docs.github.com

種別 設定できる内容
Text テキスト
Number 数値
Date 日付
Single select 決められた項目のみ選択できる
Iteration 決められた間隔の時間ブロックを作り、その時間ブロックを選択できる

★ 自身のチームではこのようなパラメータを作って運用しています。

名前 種別 設定内容
Status Single select タスクの状態 Backlog / In progress / In review / Done
Epic Single select 作業階層の最上位の単位で、チームが目指す大きなゴールのようなもの Epic1 / Epic2 / Epic3 / 改善 / ...
Estimate Number 見積もり 1, 2, 3, 5, 8, 13, ...
→ フィボナッチ数列で運用しているため、Single selectにしたいところだが Insights で積算を表示させたいので Number
Sprint Iteration 区切りになる開発期間 Sprint1, Sprint2, Sprint3, ...
Function Single select どこの機能か
リポジトリに近いイメージ
管理画面Backend, 管理画面Frontend, ユーザー画面Backend, ...
Priority Single select 優先順位 高, 中, 低
→ 普段は使わないが、バグチケットなどで目印が欲しいときに使う

Views

Table / Board / Roadmap のLayoutで、Draft / ISSUE / Pull requestを表示することができます。

Group by, Slice byが良い感じです。

Group by

設定した項目でグルーピング化し、表示してくれる

Slice by

設定した項目でフィルタリングし、左にメニューが表示される

★ 自身のチームでは、ざっくり、、4つの Viewを良く使っています。

種別 イメージ 説明 利用シーン
プロダクトバックログ GitHub Projects全体のチケットをEpic単位で絞り込めるようにしている 全体のタスクを眺める時
見積もりをする時
スプリントバックログ 現在のSprintのタスクをAssignees単位で絞り込めるようにしている 朝会/夕会で各々の作業を報告する時
自身のタスク 自身がアサインされているタスクをSprint単位で絞り込めるようにしている 自身でアサインされているタスクを確認する時
ADR タスク管理という軸ではないが、議論したことを書いておく
・専用のリポジトリのISSUEを表示している
・GitHub Discussionsでも良い
議論の場

Workflows

自由度は低いですが、
Draft / ISSUE / Pull request の操作をHookとして、特定のアクションを行うように設定ができます。

★自身のチームでは、このような設定をしています。

  • ISSUE / Pull requeset をProjectsに追加した時、Statusを Backlogに設定する
  • ISSUE / Pull requeset をクローズした時、Statusを Doneに設定する
  • Pull requeset をマージした時、Statusを Doneに設定する
  • ADRのリポジトリに ラベル: ADR のISSUEを作成した時、このProjectsに設定する

→ チケットの整理をしたくなるフェーズで Auto-archive items を設定する

ISSUEと Pull requestの紐づけ

ISSUEと Pull requestを紐付けることができ、
これを設定するとマージされたタイミングで紐づいている ISSUEがクローズされます。

Workflowsとセットで使うことで、自動的にステータスをDoneに更新することができます。
★ Pull requestのマージ → ISSUEのクローズ → Custom Fieldsのステータスが Doneになる

※ 注意点としては複数のPull requestを ISSUEに紐づけている場合、1つでもマージされるとISSUEがクローズされてしまう

Insights

Draft / ISSUE / Pull requestの状態を参照し、グラフを作ることができます。

★ 自身のチームではこのような設定をしています。

種別 イメージ 説明
Burn Up 作成されているISSUEとクローズされているISSUEの傾向を確認できる
EPIC Epicごとのタスク量を確認できる(縦軸はチケットの合計)
Velocity Sprintごとの進行速度を確認できる(縦軸はEstimateの合計)
Plan Sprintごとに割り当てられているタスク量を見ることができる(縦軸はEstimateの合計)

タスクの進め方

自身のチームでは、このようなステップでタスクを進めています。
まずはタスクの洗い出しと見積もりです。

  1. タスクの洗い出し
  2. 見積もり

↓ スプリントごとにタスクのアサイン〜開発〜整理を繰り返す

  1. タスクのアサイン
  2. 開発
  3. タスクの整理

タスクの洗い出し

スムーズに見積もりを行うために、何をどこまでやるかが整理できてると良いです。

なのでチケットに概要、どこまでやるかなどを書くルールにしています。
タスクの範囲が曖昧だと見積もりがブレがちになります。

## 概要
◯◯を設定できるようにしたい

## やること
- ◯◯が設定できるようになってる
- backendと疎通し、DBにデータが保存できるようになっている
- mainブランチにマージできている

## 補足
- GraphQLスキーマは決定している状態からスタート

見積もり

下記のようなルールで決めていきます。

  • チケットの重さは 1, 2, 3, 5, 8, 13, .. (フィボナッチ数列)で書き、人日では表さない
  • チケットの重さは 相対評価
    • タスクA が 2で、タスクB が 8だった場合、B の工数は A の工数の 4倍あると見積もる ○
    • タスクCが 2よりもかかりそうだけど、5まではいかないなあ。。と思ったら 3を設定 ○
      • タスクをこなしていくと徐々に成熟していくイメージ
      • 最初のうちはマトリクスを作って意識統一する
        • 1: 数分で終わり、やる内容は明確、リスクがない
        • 2: 数時間で終わり、やる内容は明確、リスクがない
        • 3: 1日で終わり、やる内容は少し整理が必要、リスクがほとんどない
        • 5: 数日で終わり、やる内容は整理が必要、リスクを考慮
        • 8: 1週間で終わり、やる内容は複雑、リスクがある
  • 個人の裁量で決めず、チームで決定する
    • あの人だったら慣れてるから 1日で終わりそうだけど、、自分はもっとかかるかも ×
      • (ブラウザでプランニングポーカーをするサービスがあるので、そちらを活用する)
  • できる限り小さい数字にする
    • 小さくするのは手間だったりするので、手間にならない程度に分解する
      • 分解することでタスクの解像度が上がる ○

チームで決められた値を見積もりに使うことで、
スプリント内でチームがどれくらいチケットを消化できるかが見えるようになります。

現状の課題と今後の展望

運用していると下記のような課題点が出てきました。

  • サブタスクを作りたい
    • 少し大きなチケットを消化する際にチケットを分けたくなることもあるが、現状サブタスクが作れない
  • バーンダウンチャートが見たい
    • いまの進行速度だといつごろ開発が完了するのかを見たくなるが、現状見れない

Slice byは2023年8月に追加されて使いやすくなったように、今後の進化に期待です。 github.blog

今後の展望としては Qaseのようなテスト管理ツールと連携し、
自動テストの実行と絡めた バグチケットの連携まで出来るようになれれば良いなと思っています。

qase.io

まとめ

タスクのチケット化・見積もりをする癖をチームに作るのが最初の課題かなと思います。

  1. スプリントごとにやることを決め、そのチケットを見ながら朝会などで会話する
  2. スプリントでどの程度タスクをこなせたのかを測り、いつ頃までに開発が完了するのかが分かるようになる

このようなフローになればチームの透明性も確保できて良い感じです◯

GitHub Projectsで 小中規模の開発に十分耐えられるので、ブラッシュアップしながら継続して使っていきたいと思います。

さいごに

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

www.ikyu.co.jp

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

hrmos.co

一休.comサイトパフォーマンス改善 - 2023年 夏の振り返り

ヤフー株式会社より出向しております、卯田と申します。
主務で、一休.comおよびYahoo!トラベルのフロントエンド開発を担当しています。
兼務で、ヤフー株式会社の全社横断組織でWebパフォーマンス改善の推進を行っております。

本稿では、直近半年弱(2023年2月〜8月)で、断続的に行っていた一休.comのパフォーマンス改善について振り返ります。

開始が2023年2月となった理由は、Nuxt3バージョンアップ以降にパフォーマンス改善活動に着手したためです。

一休.com/Yahoo!トラベルのNuxt3バージョンアップ詳細については、以下のブログをご覧ください。

user-first.ikyu.co.jp

サイトパフォーマンス改善の意義

サイトパフォーマンスは、「お客様に上質な体験を提供するための重要非機能要件」と考えています。 一休.comは、「心に贅沢を」をコンセプトに宿泊予約サイトを提供しております。 こちらのコンセプトのもと、便利な機能やUIをお客様に提供したいという気持ちで日々開発しており、パフォーマンスに関しても同じです。 お客様に気持ちよくサイトをご利用いただくためにも、パフォーマンスを維持することは非常に重要であると考えています。

改善の方針

方針1: Core Web Vitalsを改善する

パフォーマンス改善の指標は、サイト全体のCore Web Vitals(フィールドデータのLCP・FID・CLS)としました。

PageSpeed Insightで示すと、赤枠の箇所です。

GoogleではCore Web Vitalsを以下のように定義しています。

Core Web Vitals は、Web 上で実際にユーザーが体験するユーザー エクスペリエンスに関する重要な観点の測定を目的とした一連のフィールド指標(データ)です。 Core Web Vitals には指標と各指標のターゲットとなるしきい値が含まれており、これらを参考にすることで、運営するサイトでのユーザー体験が "良い"、"改善が必要"、"悪い" のいずれの状態にあるかを開発者が定性的に理解できるようになります。

引用: https://web.dev/i18n/ja/defining-core-web-vitals-thresholds/

LCP : Largest Contentful Paint (最大視覚コンテンツの表示時間): 読み込みのパフォーマンスを測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの読み込みが開始されてからの LCP を 2.5 秒以内にする必要があります。

FID : First Input Delay (初回入力までの遅延時間): インタラクティブ性を測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの FID を 100 ミリ秒以下にする必要があります。

CLS : Cumulative Layout Shift (累積レイアウト シフト数): 視覚的な安定性を測定するための指標です。優れたユーザー エクスペリエンスを提供するためには、ページの CLS を 0.1 以下に維持する必要があります。

引用: https://web.dev/i18n/ja/vitals/

また、Core Web Vitalsを改善するためのパフォーマンス指標として、ラボデータ(synthetic monitoringと言う場合もあります)とフィールドデータ(RUMと言う場合もあります)の2種類を提供しています。

Lab data: Lab data is determined by loading a web page in a controlled environment with a predefined set of network and device conditions. These conditions are known as a lab environment, sometimes also referred to as a synthetic environment.

Field data: Field data is determined by monitoring all users who visit a page and measuring a given set of performance metrics for each one of those users' individual experiences. Because field data is based on real-user visits, it reflects the actual devices, network conditions, and geographic locations of your users.

引用: https://web.dev/lab-and-field-data-differences

ラボデータとフィールドデータの関係において重要なことは、フィールドデータの改善が主たる目的であり、ラボデータは、あくまでフィールドデータを改善するための補足情報であるということです。 ユーザー体験を改善することが目的であることを鑑みると、フィールドデータ、さらにはその中でも最も重要と位置付けているCore Web Vitalsが、改善すべきパフォーマンス指標として適切です。 もちろん、Core Web Vitalsを改善するためにブレイクダウンした値として、ラボデータのスコアを改善指標とすることもできますが、あくまで参考程度に留めています。

Core Web Vitalsの3つの指標のバランスも重要と考えています。 LCP、FID、CLSの特定の指標が非常に良い状態を目指すのではなく、3つの指標が満遍なく良好な状態を目指しています。

Core Web Vitalsの良好に関する詳細は、以下のページをご覧ください。 web.dev

方針2: 重要課題から優先的に対応する

パフォーマンス改善は、重要課題(大きく改善が見込める領域)から取り組むことで効率的に改善できます。 改善に時間をかけたにも関わらず、対して改善しなかったでは意味がありません。 特に、解決したい課題を理解せずにTipsベースで取り組むのは注意です。 「xxxでCore Web Vitalsが改善しました!」という記事をみて、同じ手法を取り入れてみたが、イマイチだったという経験がある方もいらっしゃるかもしれません(私もあります)。 そのようなことにならないよう、重要な課題から優先して対処していくことを念頭におきました。 もちろん、全て正しいアプローチで進められたわけではありませんが、常にチーム内で心掛けていました。

改善の進め方

可視化

まずは、重要課題を把握するために、現状を可視化しました。

ブラウザサイド

ブラウザサイドのパフォーマンス可視化には、GoogleのCodelabsに掲載されている資料を参考に、Looker Studioのダッシュボードを作成しました。

こちらがとても役立っています。データ量次第では、無料で構築可能です。

ダッシュボード(一部非掲載)

サーバーサイド

サーバーサイドのパフォーマンス可視化には、もともと一休で導入しているDatadogのダッシュボードトレース機能を利用しました。

全体把握には、ダッシュボードを使って、サーバーサイドのレイテンシを可視化しています。
以下はダッシュボードに載せている図の一例で、レスポンスの75パーセンタイル値の推移です

75パーセンタイルレスポンスタイム

より詳細を見るためにはトレース機能を活用します。 デフォルトのプリセットで、ある程度のタスクをトレースできます。 デフォルトで表示されないタスクのトレースを試みたい場合、Node.jsであればdd-trace-jsのwrap関数で、traceしたい処理をwrapします。

以下は、東京のホテル・旅館のリクエストをトレースしている図です。赤枠は詳細トレース用にwrapしたApollo Clientのキャッシュ計算処理です。 そのほかにもネットワークリクエストの流れを把握できます。

Datadog APM Trace

優先順位決め

可視化したところ、3つの指標で特にスマートフォンのCLSが不良でした。 そこでまずはスマートフォンのCLS改善に取り組みました。 次に、LCPも良好ではなかったためLCPの改善に取り組みました。 FIDは良好であった、かつ、2024年3月にINPに置き換わるということが2023年3月に周知されたため、後対応としました。

補足

INPは、ユーザー操作に適切に画面が反応できているかを示す指標です。 不良な状態であるということはユーザーの操作を阻害していることを意味しています。 したがって、Core Web Vitalsが2024年3月に置き換わるタイミングに関わらず、可能であれば早急に改善したい課題です。 ただし、今後計測のエコシステムが整ってくるだろうという楽観的な希望もあり、節足に取り組むことはしないとチームで判断しました。

具体的な改善内容

具体的な改善内容を、改善に取り組んだ時系列、CLS、LCP、FIDの順に紹介します。
※パフォーマンス改善で実施した改善施策のうち、分かりやすい施策を中心に紹介します。

CLSの改善

上記で作成したダッシュボードを利用することで、

  • どのDOM要素が
  • どれくらいの頻度で
  • どれくらいの大きさ

レイアウトシフトしているかを一覧できるようになりました。
下の画像左のプロット図をご覧ください。

Looker Studio CLS ダッシュボード

右上にプロットされている点が、頻度が高く、大きくレイアウトシフトしているDOM要素を示しています。

この図に従い、右上に存在するDOM要素から順にレイアウトシフトを特定し、改善を施していきました。
以下に、分かりやすい事例として2つ、頻繁に起きていた事例と大きくレイアウトシフトしていた事例を紹介します。

こだわり条件更新時に発生するレイアウトシフト

こだわり条件を変更した際に、「夕朝食付」が消え、「エリア・駅名・キーワード」が表示されます。 この一瞬で、検索フォームの高さが変わることでレイアウトシフトが発生していました。 レイアウトシフトの大きさとしてはそこまでですが、頻繁に発生している事例です。 色々な条件で検索するお客様にとっては、度々ガタついており、目障りな印象を抱いていたかもしれません。

こだわり条件更新時のレイアウトシフト

クチコミ画像表示時に発生するレイアウトシフト

お客様の投稿したクチコミ画像を表示するモーダルです。 画像領域の高さ指定をしていなかったため、画像読み込みの間、領域の高さが0となっていました。 頻度は低いですが、レイアウトシフトの大きさとしては非常に大きい事例です。
※図はYahoo!トラベルですが、一休.comでも同様です。

クチコミ画像のレイアウトシフト

LCPの改善

リソースの読み込み順序の改善

ChromeのDev Toolsのネットワークタブで、リクエストウォーターフォールを確認したところ、大量のJavaScriptとCSSをpreloadしており、LCPとなる画像を取得するタイミングが遅れていました。

修正前のネットワーク

LCP画像にResource Hints を定義することで一定の改善も見込めますが、ページごとに個別最適した実装が必要で少し手間がかかります。 より、サイト全体に効果があるアプローチとして、すでにNuxt3のGitHubで議論されており、解決方法まで示されていたので、こちらを先に採用することにしました。 結果的には、この改善はNuxt3で動いているページ全体への効果が非常に大きく、計測ページ全体で、400msほどLCP改善しました。 本修正は、JavaScriptのloadを遅らせるため、FIDに悪い影響が出る懸念もありましたが、結果的には問題ありませんでした。

ネットワーク前後比

※上記課題は、2023年8月25日リリースのNuxt 3.7experimental機能でも改善が図られています。以下のように、headNext機能を有効化することで検証できます。

export default defineNuxtConfig({
  experimental: {
    headNext: true
  }
})

documentのgzip圧縮

Nuxt3ではdocumentのgzip圧縮をできていませんでした。 そこで、Nuxt3が採用しているhttpフレームワークのunjs/h3のpatchを独自で用意しました。 レスポンス直前にdocumentをgzip圧縮する処理を追加しています。

gzip 前後比

サーバーサイドKeep Aliveの実装

DNS LookupやTCP connectionをバックエンドへのリクエストの度に行っていたため、HTTP(S)/1.1 KeepAliveの実装をしました。

Node.jsのバージョン19からデフォルトで有効になる機能です。

Datadog APM Trace - KeepAlive

SQLの最適化

非常に遅いSQLです。 SQLの実行のみで1.4秒近く時間を要しています。

対象のデータベースはSQL Serverを使っています。 SQL Server Managementで実行プランを確認し、不足しているインデックス情報に従い、インデックスを設定し直すことで改善しました。

検索システムのバージョンアップ

検索システムにはApache Solrを使っています。 古いバージョンのApache Solrを使用していたため、まずは改善土壌を整えるべくバックエンドチームが4月から3ヶ月ほどかけてバージョンアップを行いました。 バージョンアップを行う過程でDeprecatedとなったFieldTypeを改修したところ、検索システムのレイテンシが劇的に改善しました。

Solr クエリのレイテンシー

Solrのドキュメントにレイテンシが改善すると記載されてはいたものの、正直想定していた以上の結果だったとのことです。

(嬉しい誤算ですね。こういうこともあります。 )

結果として、検索システムを呼び出している画面のLCPが200ms改善しました。

FIDの改善

上述の通り、FIDは当初より良好であったことと、INPへと置き換わることが周知されており、後対応としました。 今後は、INPの改善に取り組んでいきたいと考えています。

LCP、FID、CLS、3つの指標が良好になった後

機能開発で、パフォーマンスが悪化することもあります。 週1で、パフォーマンスチェックをする機会を設け、惰性で悪化することを防ぎました。 悪化した場合には、Looker Studioのダッシュボード、Datadog、一週間のコミットを照らし合わせ、改善しています。 幸い、一休.comのフロントエンド開発ではビッグバンになるようなリリースが極めて稀なため、変更コード量も限られており、悪化したとしても原因を特定することには苦労していません。

結果

下図は、直近6ヶ月弱のCore Web Vitalsの推移です。 線が下に行くほど、良い状態を示しています。 2月,3月の改善着手初期でCLSとLCPが大きく改善し、以降、3つの指標が要改善となるのを防ぎつつ、SolrのバージョンアップでLCPがさらに改善しました。 CLSは、良好の範囲内で一時的に悪化しておりましたが、作業時間を確保できたところで改善を施し、元の水準までスコアを戻しています。

スマートフォン
デスクトップ

Googleが毎月更新しているChrome User Experience Reportでもフィールドデータの大まかな傾向を確認できます。

下図は、一休.comのCrUXダッシュボードです。 緑色の領域が良好を示しています。 2022年11月に比べ、2023年8月では良好の割合が増えていることが確認できます。

上:スマートフォン / 下:デスクトップ

一休.comのCrUXダッシュボードの詳細は、以下のページでもご覧いただけます。 lookerstudio.google.com

今後

CLSの改善

CLSは、この6ヶ月間でも、機能改修で、幾度か悪化することがありました。 引き続き監視しつつ、良好を維持できるようにします。

LCPの改善

スタイルの計算とレンダリングの最適化

スタイルの計算とレンダリングに時間がかかっています。
CSSセレクタのパフォーマンスをMicrosoft Edgeのパフォーマンスツールで確認したところ、*, ::after, ::before のCSS変数の計算が大半を占めていました。

セレクターパフォーマンス

CSS変数を埋め込んでいるのは、Tailwindのベーススタイルです。 GitHubで検索してみたところ、Tailwindのリポジトリで同様の議論をしていました。

DOMサイズが大きい場合に顕著に悪化する問題で、一休.comは全体的に初期表示時のDOMサイズが大きいサイトなため影響を受けています。

最もDOMサイズが大きいトップページで、開発環境検証してみたところ、スタイル計算のパフォーマンスが改善されることを確認できました。 他のページへの修正影響を確認した上でリリースしたいと考えています。

また、ブラウザサイドでの画像のリクエストタイミングが、最善ではありません。 LCPの値が好ましくない、かつ、お客様訪問の多いページ・デバイスからResource HintsもしくはFetch Priority属性を実装し、改善を図りたいです。

Apollo Clientのキャッシュ計算処理

Apollo Clientのキャッシュ計算処理に時間を要しています。 実装改修コストも非常に高いですが、重要なページから異なるGraphQL Clientへの移行を始めています。

user-first.ikyu.co.jp

算出方法の改善期待

Googleでは"soft navigations"に関するLCP算出方法の変更を検討しています。 一休.comでは"soft navigations"を多用しており、ユーザーが体験したパフォーマンスにより近くなると期待しています。

developer.chrome.com

FID(INP)の改善

CLSで行った手法同様、まずはINPの計測環境を整備します。 そして、不良かつ頻繁に発生するイベント(動作)を特定し、改善に最も効く重要なイベントから最優先で改善を行っていきたいです。

パフォーマンス改善によるビジネス貢献

現状、パフォーマンス改善によって、どれだけビジネスに貢献できたは把握できておりません。 ヤフー株式会社の全社横断組織にて、ビジネス指標(直帰率、離脱率、コンバージョン率など)とパフォーマンスの相関を計測する環境が整備されつつあるので、 次は、Yahoo!トラベルで、ビジネス貢献にもつながるパフォーマンス改善に取り組んでいきたいです。

最後に

株式会社一休では、上質なウェブ体験を一緒に実現してくださる方を絶賛募集していますo(^▽^)o~♪

一緒に、宿泊・飲食予約の未来を作りましょう!

hrmos.co

【検索改善】マイクロサービス化から適合率向上まで

はじめに

こんにちは。宿泊検索チームの渥美 id:atsumim です。

最近は検索改善のプロジェクトを行っており、特にキーワードでの検索の改善を行っています。
今回はその中でこの1年くらいの改善についてお話しします。

言葉の定義

先にこの記事で用いる言葉の説明をします。

ハード検索

指定した条件と完全に一致する結果のみを返す検索方法です。
今回は ID に変換される検索のことを指します。
ID なので一文字でも違うと、異なる条件として取り扱われます。
より具体的に言えば、下記の検索パネルから選択できる条件はすべて ID に変換されます。

例えば箱根は are=160418 となります

ソフト検索

指定した条件と部分的に一致する結果も返す検索方法です。
今回は ID に変換されない検索(つまり純粋なキーワード)のことを指します。
例えば 一休 と検索したときに 一休み一休さん などの結果が含まれることになります。
より具体的に言えば、上記の検索パネルからできないキーワードのことになります。

箱根かに問題 🦀

ことの発端は「かに」でした。
まずは下記のスクリーンショットをご覧ください。
一年前の一休.com で「箱根, かに」を検索した画面です。

検索結果A 検索結果B

同じ条件であるはずの「箱根」「かに」の検索結果が異なることがわかります。
検索結果Aでは56件、検索結果Bでは96件となっています。
また、表記も 箱根, かに箱根 かに で微妙に異なっています。

なぜこのようなことが起きたのでしょう。
原因は検索方法の違いにあります。

検索結果A
「ハード検索 + ソフト検索」の検索方法です。
細かく見ると「ハード検索: 箱根 , ソフト検索: かに 」という検索になります。
一休.com で使われるクエリパラメータに変換すると ?are=160418&kwd=かに という形になります。

検索結果B
一方、検索結果B は「ソフト検索」のみの検索方法です。
細かく見ると「ソフト検索: 箱根, かに 」という検索になります。
一休.com で使われるクエリパラメータに変換すると ?kwd=箱根 かに という形になります。

ソフト検索では、クチコミの文章や施設・プランの紹介文などからもデータを取得しています。
そのため、施設が実際に箱根になくても、 箱根 というワードがクチコミに入っていると検索結果に表示されてしまっていたのです。
また、クチコミのデータの中には「穏やかに過ごせました」や「静かに楽しめました」などの文章が入っています。 これもソフト検索で「かに」に引っかかってしまい、本来箱根の旅館でかにを食べたいのに全く別の結果が返ってきている状態でした。
これを「箱根かに問題」と呼びます🦀

システム的な問題

「箱根かに問題」が根本的になぜ起きたのか、原因はシステム構成にありました。
元々の検索システムの構成は下記のようになっていました。

Before

システム構成

先に図の説明をします。

キーワードを検索すると、まずバックエンドからサジェストAPIが呼び出されます。
サジェストAPI は1つの単語に対して「キーワードを変換したID( are など)」または「変換できなかったキーワード( kwd )」を返却します。
この「ID に変換できなかったキーワード」に対してキーワードAPIが呼ばれます。
キーワードAPI は前述の通り、「クチコミ」や「施設の説明文」などの文章からキーワードにマッチした施設のデータを返却します。
それを元に検索結果がフロントエンドで描画されます。

これが元々の構成です。

問題点

問題となっていたのは、サジェストAPI です。
本来、その名の通りキーワードに対応するサジェストを表示するために使うのが サジェストAPI です。

本来のサジェスト用途
しかしこれを検索のために使っていたために問題が起きていたのです。
サジェストAPIはそもそも1つの単語しか受け取りません。
つまり、例えば「箱根 かに」という2語のキーワードを1語として受け取り「kwd=箱根 かに」というように解釈してしまいます。

これはサジェストの用途であれば問題がないのですが、検索用途では問題になります。
本来「箱根」は are=160418 という ID に変換されるべきなのです。
また、返ってきた kwd に対してフロントエンドでも変換を行う実装が入っており、検索周りの実装を複雑にしていました。

これが「箱根かに問題」の実態です。
もちろん「かに」だけが問題が起きるわけではなく、上で見たように2語以上の単語を検索すると問題が起こるようになっていました。

上記をまとめると、箱根かに問題は下記の2つに分解できます。

  • 使うべきではないサジェスト API を検索に使っている
    • 「箱根」がキーワードとして認識されている
    • キーワード検索に関する実装がフロントエンドにも漏れ出している
  • キーワード API の精度が高くない
    • 「穏やかに」などのキーワードを含まずに純粋な「かに」を抽出したい

マイクロサービスの導入

前者の「使うべきではないサジェスト API を検索に使っている」という課題はマイクロサービスを立てることで解決しました。 以下が新しいシステム構成です。
フロントエンドのキーワード解釈の実装もこのマイクロサービスに寄せています。

After

このマイクロサービスは、例えば

{
  "keyword":"箱根 かに"
}

というリクエストに対して

{
  "areaIds": [
    "160418"
  ],
  "keyword": "かに"
}

というレスポンスを返します。
キーワードをクエリに変換するサービスなので「クエリサービス」と名付けました。
これは我々が求めていたシンプルな形のものです。
かにも綺麗に分かれています₍₍⁽⁽🦀₎₎⁾⁾

また、元々サジェストAPI はデータサイエンス部の管轄で、属人的になっていました。
しかし、今回クエリサービスはデータサイエンスのメンバーだけでなく、検索チームがオーナーシップを持って開発するように取り決めをしました。

適合率を上げる

一方、後者の「キーワード API の精度が高くない」という課題に関しては長期的なメンテナンスが必要です。 我々はこの課題に対して「ハード変換できるキーワードを増やす」というアプローチを取りました。 専門的に言うと長期的には「再現率」を改善し、短中期的には「適合率」を上げるようにしたのです。

今までは 箱根 などのエリアや、朝食付き といったメジャーな検索条件に関しては、すでにキーワードから ID に変換できていました。
しかし「市区町村名」や「グループホテル名」などに関しては ID 変換をしていませんでした。
これらを変換し、適合率を上げるようにした、というわけです。
ID 変換できるキーワードが増えると、キーワードAPI に流れるキーワードは減るので、結果的により検索精度が上がります。

進め方

ソフト検索されているキーワードを監視して、検索需要が高いものから優先して ID 変換するようにしました。 これらはワードクラウドで視覚的に見えるようにしており、文字の大きいものほど検索需要が大きいキーワードということになります。
執筆時の一ヶ月前(2023/07/21)の実際のデータを載せます。
この時点ではグループホテルが変換できていませんでした。

グループホテル変換前の様子

ふふドーミーイン , 星野リゾート が大きい割合を占めているのがわかると思います。
では「グループホテル名」を ID 変換できるようになった今の様子を見てみましょう。

グループホテル変換後の様子

ふふドーミーイン などのグループホテルはなくなり、オールインクルーシブ がデカデカと台頭するようになりました。
(星野リゾート系列は一休では取り扱いがなく、ID がないので , 星のや , omo などが変換できず残っていますが、)全体がまるっと変わったのが見て取れます。

このように検索需要のあるキーワードをハード変換することで精度の高い検索結果を提供する、というのがここ数ヶ月の改善です。 並行してキーワードAPI は随時改善しており、「かに」などの食材については純粋な「かに」が抽出できるようになっています。

将来の展望

システム構成を見直し、ハード変換のカバレッジを上げて今期は過ごしてきました。
しかし、まだまだキーワード改善の余地はたくさんあります。
直近では ChatGPTに自社の情報を組み込みたい① - 一休.com Developers Blog で書かれているように、ChatGPT を使って検索体験を全く異なるものにできないか検証をしています。

「ユーザーの頭の中にあることをそのまま検索できる」ような検索体験を提供できるよう、引き続き開発を行っていきます 🦀

さいごに

一休では随時エンジニアを募集しています。
上記のような検索改善に興味がある方はぜひ下記からご応募ください。

www.ikyu.co.jp

カジュアル面談も実施しているので、話だけ聞きたい!という方でもお待ちしております。 hrmos.co

一休.com、Yahoo!トラベルのフロントエンドにカナリアリリースを導入しました

はじめに

宿泊UI開発チームでソフトウェアエンジニアをしている原です。昨年の10月に入社しました。

私の所属する宿泊プロダクト開発部では主に 一休.comYahoo!トラベル を開発しており、今回お話するのは、両サービスのトップページ、施設一覧ページ、施設詳細ページなどの主要な導線のフロントエンドを担う Nuxt.js で作られたアプリケーションのインフラとデプロイについてです。

今回はこのアプリケーションにカナリアリリースの手法を取り入れて、より安全にリリースできるようになった話をします。

カナリアリリースとは

カナリアリリースとは、複数の実行環境を用意しアプリケーションの新旧のバージョンを同時に稼働させ、一部のユーザーに絞って新環境を公開するリリース手法です。 カナリアリリースによって新バージョンに不具合があった場合でもユーザー全体に影響を及ぼすことなく、リスクを低減してリリースすることができます。

導入のきっかけ

一休では昨年から今年にかけて、宿泊プロダクトのNuxt.jsのバージョンを2系から3系にアップグレードしました。 詳しくは 一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました をご覧ください。

user-first.ikyu.co.jp

上記のブログ記事の「リリース戦略」の項にあるように、メインブランチの内容が反映されているNuxt2バージョンの環境と、検証用ブランチのNuxt3バージョンの環境を立てて、Fastlyでユーザーの振り分けを行っていました。

無事にNuxt3へのバージョンアップが完了し、検証用環境がお役御免になったと思っていたところ、検索フォームの実装をまるごと置き換える大掛かりなリファクタリングが行われました。 同一アプリケーション内で条件分岐によるfeature flagは元来行われていましたが、それを差し込むのも難しいくらい大きな差分が発生するリファクタリングになりました。 そこで影響範囲が大きいリリースになるので失敗のリスクを最小限にしたいと考え、バージョンアップの検証用の環境をカナリア環境と銘打って引き続き使用することにしました。

カナリア環境の実現方法

以下が簡単な構成図です。

カナリア環境を実現するためのシステム構成図
システム構成図

EKS

宿泊プロダクト内のシステムの多くは EKS で稼働していて、この Nuxt.js アプリケーションも EKS で動いています。 クラスタ内に通常バージョンが動作しているものとは別にカナリア環境用のデプロイメントを作成し、そこでカナリアバージョンのアプリケーションを動かします。

Fastly

通常環境とカナリア環境どちらにリクエストを向けるのか振り分けをFastlyで行っています。

以下コード例です(変数やCookieは仮のものです)。

sub vcl_recv {
  // リクエストの振り分け
  if (req.http.Cookie:new-environment-v1) {
      set req.http.new-environment-v1 = req.http.Cookie:new-environment-v1;
  } else {
    set req.http.new-environment-v1 = false;
    // カナリア環境に10%リクエストを振り分ける
    if (randombool(10, 100)) {
      set req.http.new-environment-v1 = true;
    }
    set req.http.new-environment-v1-new-cookie = "new-environment-v1=" req.http.new-environment-v1 "; max-age=31536000; path=/; secure; httponly";
  }
  ...
  // req.backend はfastlyがリクエストを流すオリジンを指定する
  if (req.http.new-environment-v1 == true) {
      set req.backend = new-environment;
    } else {
      set req.backend = normal-environment;
  }
}

sub vcl_deliver {
  // Cookieの付与
  if (req.http.new-environment-v1-new-cookie) {
    add resp.http.Set-Cookie = req.http.new-environment-v1-new-cookie;
    set resp.http.Cache-Control = "no-store";
    unset req.http.new-environment-v1-new-cookie;
  }
  ...
}

大まかな処理の流れを説明すると、

  • リクエストを新旧どちらの環境に向けるのかを識別するCookieの有無を確認
  • Cookieが付与されていなかったら、randombool関数 で一定の割合で新旧どちらに向けるかを決める
  • 新旧どちらかのオリジンにリクエストを流す
  • 新環境へリクエストした場合、次回リクエスト時も新環境へ向けられるようにCookieを付与

という流れになっています。 このアプリケーションは初回リクエスト以降はNuxtサーバーへのリクエストが不要なSPAになっているため、旧環境へ向いていたが動作中に新環境へリクエストしてしまい動作に不具合が生じるといったことも起こりません。 Nuxtのビルド成果物はS3にアップロードしており、アプリケーションが必要とする静的ファイルはカナリアリリースとは関係なく取得することができます。

運用方法

アプリケーションリリース方法

通常バージョンのアプリケーションはreleaseブランチへのマージを契機にCIで自動的にimageをビルド、pushをしてリリースされます。 カナリアバージョンも同様にcanary-releaseブランチへのマージを契機にカナリア環境へリリースされます。

スケールイン、スケールアウト

カナリアリリースを使用していない間はインフラコストの削減のため、podの最小レプリカ数を最小限にしています。 カナリアリリース開始時に流すリクエストの割合に応じてpodの最小レプリカ数を引き上げます。

リクエスト割合の調整

上述のvclのrandomboolの割合を変更します。 この際に割合が正しく反映されるようにnew-environment-v1 といった変数やCookieのsuffixのバージョンもインクリメントさせる必要があります。

以下カナリア環境へのリクエスト割合を10%から0%に変更する際のコード差分の例です。

 sub vcl_recv {
-  if (req.http.Cookie:new-environment-v1) {
-      set req.http.new-environment-v1 = req.http.Cookie:new-environment-v1;
+  if (req.http.Cookie:new-environment-v2) {
+      set req.http.new-environment-v2 = req.http.Cookie:new-environment-v2;
   } else {
     set req.http.new-environment-v1 = false;
-    if (randombool(10, 100)) {
-      set req.http.new-environment-v1 = true;
+    if (randombool(0, 100)) {
+      set req.http.new-environment-v2 = true;
     }
-    set req.http.new-environment-v1-new-cookie = "new-environment-v1=" req.http.new-environment-v1 "; max-age=31536000; path=/; secure; httponly";
+    set req.http.new-environment-v2-new-cookie = "new-environment-v1=" req.http.new-environment-v2 "; max-age=31536000; path=/; secure; httponly";
   }
   ...
-  if (req.http.new-environment-v1 == true) {
+  if (req.http.new-environment-v2 == true) {
       set req.backend = new-environment;
     } else {
       set req.backend = normal-environment;
    }

このように割合変更(カナリア環境へのリクエストを取りやめることも含む)をする度に、コードの修正が必要になります。

カナリアリリース導入の効果

Nuxtをはじめとしたライブラリのバージョンアップや、コード差分が大きく影響範囲の大きなリリースに対するリスクを大幅に軽減できるようになりました。 またこのNuxtアプリケーションは、宿泊事業部内の複数チームが開発しているのですが、カナリアリリースの概要やリリース手順書といったドキュメントの作成をしたり、リリースの都度レクチャーすることで、どのチームもカナリアリリースによって安全にデプロイできるようになりました。

課題

上述の通りカナリア環境へのリクエスト割合を変更するたびにVCLの変数のsuffixを変更する必要があり、そこそこ面倒な作業になっています。 また、カナリアリリース実施中に通常環境のリリースがあった際にその内容をカナリア環境に取り込む必要があります。そのため常に通常バージョンのリリース動向をチェックする必要があり、これもそれなりの負担があります。 これらを自動化などでコストを軽減できないかと模索中です。

最後に

一休ではプロダクトの機能開発をしながらボトムアップで開発基盤の改善もしたい!というエンジニアを大募集中です。 カジュアル面談も実施しているので、お気軽にご応募ください。

www.ikyu.co.jp

hrmos.co

ChatGPTに自社の情報を組み込みたい①

はじめに

こんにちは、一休.comデータサイエンス部の平田です。 みなさんChatGPT活用してますか?

一エンジニアとして便利に使ってはいるものの、自社サービスにどのように組み込もうか模索しているところも多いかもしれません。 一番の利用先として思いつくのが、自社の情報をもとに質問に答えるチャットボットではないでしょうか。

その中では、ハイコンテキストな検索(例えば、「東京から2時間以内で子供も楽しめるアクティビティがあって、景色も良い宿」のような)にも答えられるとボットの価値が増します。 ChatGPTが事前に学習した内容では古く、正確ではないためそういった検索に応えるのはかなり厳しいです。

そのため、こちら側が持っているデータを渡してあげる必要があるのですが、今回はその自社の情報をどう組み込むのか、という部分についてご紹介します。

素のChatGPTでは?

ChatGPTに例えば「熱海で子供も楽しめるアクティビティがあって、景色も良い宿を教えてください」 を聞いてそのまま納得できる内容が返ってくるなら、組み込むだけで済むのでかなり楽です。 まずは試してみます。

熱海は温泉地として有名で、子供から大人まで楽しめる観光地がたくさんあります。以下は、子供も楽しめるアクティビティと景色の良い宿泊施設の一例です。

アクティビティ:
熱海海洋公園: 水族館で、色とりどりの魚やイルカショーなどが楽しめます。
熱海梅園: 季節によっては梅の花が楽しめる庭園で、散策やピクニックが楽しめます。
熱海サンビーチ: 夏場は海水浴が楽しめるビーチで、子供たちに人気です。
MOA美術館: アートに興味を持つ家族には、美術館の散策もおすすめです。

宿泊施設:
アタミセイザンリゾート: 子供向けのプールや遊び場があり、家族連れに人気。部屋からの海の眺めも素晴らしいです。
ホテルニュー熱海: 温泉が楽しめる宿で、子供用のアメニティも充実しています。海を一望できる部屋もあります。
熱海温泉 ホテルマイクローネ: 子供向けのプレイルームやプールがあり、温泉も楽しめます。部屋からの景色も良いです。
これらの施設は、子供向けのアクティビティと素晴らしい景色を楽しめるため、家族旅行にぴったりです。予約時には、子供向けの設備やサービスがあることを確認すると良いでしょう。最新の情報や詳細は、公式ウェブサイトや旅行サイトで確認することをおすすめします。

アクティビティと宿を出してくれるのでぱっと見は良さそうですが、熱海海洋公園はありませんし、宿泊施設に至っては3つともありません。 ChatGPTの学習データは2021年9月までのものなので、かつてはあったのかもしれません。 やはり、最新性のある情報を提供させるために、こちらから与えてあげる必要がありそうです。

情報を組み込む方法

ファインチューニング

質問文と答えの対を大量に用意し、それを事前学習されたモデルに対してさらに学習させることでオリジナルのモデルを作る方法があります。

platform.openai.com

質問文と答えの対を人手で作るのも難しいので、ChatGPTにやらせます。

response = openai.Completion.create(
            engine=COMPLETIONS_MODEL,
            prompt=f"""次の複数の口コミから、50文字以内の日本語の質問文を1つ生成してください。
            \n\nテキスト: {context}\n\n質問文:\n1.""",
            temperature=0.8,
            max_tokens=400,
            top_p=1,
            stop=["\n\n"]
)
prompt=f"次のテキストに基づいて質問に答えてください\n\nテキスト: {row.context}\n\n質問文:\n{row.questions}\n\n答え:\n1.",

ただ、質の高い対を大量に作るのは難しく、学習としてもあまりいい結果になりませんでした。(例はcurieモデル)

上手くいった例
上手く行かなかった例(こちらを向かう海を渡る…?)
8/22にcompletionモデルではなく、chatのモデルである gpt-3.5-turbo-0613 を対象にしたファインチューニングができるようになりました。 chatのモデルで行うともしかしたらいい結果が得られるかもしれません。

(↓見ると難しそうですが…)

ChatGPT の Fine-tuning を試したけど上手くいかなかった話

埋め込みベクトル表現

ファインチューニング以外にも情報を渡す方法としては、プロンプトに必要な情報がまとまった文章を加えておき、 それに基づいて文章を生成してもらうというものがあります。

そのためには、「どの情報を渡すか」という部分をこちらで選択する必要があります。 ChatGPTのtoken数の制限、価格を考慮すると全てを渡すことはできません。 必要最低限の量を渡す方法として、クエリをベクトル化して、あらかじめベクトル化した情報と類似度が高いものだけをプロンプトに加えるようにします。

ファインチューニングと比較したとき、渡す情報をコントロールしやすいメリットがある反面、プロンプトが肥大化しやすいというデメリットがあります。

検証

今回は、口コミの中から必要な情報だけを抽出できるのか?というところをトライしてみます。 ベクトルで類似度をスコアリングしたのち、各項目について言及しているかどうかを正規表現で正誤判定させることにしています。

検証ではOpenAIのembedding APIを使用しています。対象は単語になっていますが、任意の文章をベクトル化することができます。

また、英語だと精度が良いらしいですが、ベクトルマッチングにおいてもそうなのかついでに調べてみます。 ちなみに翻訳にはdeepL APIを使いました。

querys = {'朝食': ['朝食', '朝ごはん', '朝ご飯', '朝御飯', '朝餉', 'ブレックファースト'],
         'ペット': ['ペット', '犬', 'わんこ', 'ドッグ', 'わんちゃん','愛犬','ワンコ','ワンちゃん'], 
         '花火': ['花火'],
         '絶景': ['絶景', '景色がいい', '景色が良い', '景色のいい', '景色の良い'],
         'バリアフリー': ['バリアフリー', '車椅子', '車いす', '足が悪い', '脚が悪い'],
         '有名建築家': ['有名建築家','隈研吾','安藤忠雄','北川原温','坂茂','山口隆','岸本和彦']}

target_query = '有名建築家'
target_vec = get_embedding([target_query])[0]["embedding"]
target_query_eng = translate_text([target_query])[0]
target_eng_vec = get_embedding([target_query_eng])[0]["embedding"]

df['test'] = df.review_text.apply(lambda x: any([q in x for q in querys[target_query]]))    
df['jpn_score'] = df.embedding.apply(lambda x: calc_cossim(target_vec, x))
df['eng_score'] = df.embedding_english.apply(lambda x: calc_cossim(target_eng_vec, x))

ROC曲線は、スコアの閾値を0~1で動かしたときに、横軸に疑陽性の率、縦軸に真陽性の率をプロットしたものです。 下側の面積をAUCと呼び、1なら完全な分類、0.5ならランダムな分類と同程度の精度だと評価されます。

朝食(日本語)
朝食(英語翻訳)

項目 日本語 英語翻訳
朝食 0.83 0.88
ペット 0.92 0.85
花火 0.95 0.97
景色 0.90 0.83
バリアフリー 0.63 0.78
有名建築家 0.95 0.95

単語によって、日本語が良かったり英語が良かったりバラバラですね。 明らかに英語の精度が良くなるかと思ったので意外でした。 有名建築家は正解データ数が少ないので両者高くなっています。

実際の口コミ抽出例

施設、キーワードを入力するとマッチした口コミを返すAPIを作りました。 口コミの一部分を返すことで、プロンプトが長くなるのを防ぐ工夫もしています。

以下はkeyword=温泉をとある施設の口コミを対象に入れたときの一例です。

一つ目は「温泉」がちゃんと入っていますね。二つ目は温泉に近い「温水プール」が入っています。

{
    "hotel_id": "00002627",
    "review_id": "1000022712",
    "review_text": "3連泊させていただきました。主にホテルにこもって過ごすことを前提にお伺いしましたが、客室はとても居心地がよく、朝焼けがとても綺麗に見えました。
また、プールもこじんまりとしていますが十分楽しめました。
特に23時までオープンしていることからナイトプールはとても綺麗なライティングで、夜空も綺麗に眺めることができました。
また、料理のレベルが高く、味も見た目にも楽しめるものばかりでした。
盛り付けはとってもオシャレでした(部屋食もよかった)。
接客は過度なものはなく、他のホテルに比べるとややあっさりした印象ですが、感じの良い方達ばかりでした。
温泉・お風呂は星4つにしたのですが、特段不満に感じることはありませんでした(水圧、綺麗さなどどれも満足)。
館内至るところ、アロマの香りが楽しめたのもよかったです。またお伺いしたいです。ありがとうございました。",
    "score": 0.75787675,
    "matches": [
        {
            "match_text": "温泉・お風呂は星4つにしたのですが、特段不満に感じることはありませんでした(水圧、綺麗さなどどれも満足)。館内至るところ、アロマの香りが楽しめたのもよかったです。",
            "positive_score": 0.89829713,
            "score": 0.75787675
        },
        {
            "match_text": "また、プールもこじんまりとしていますが十分楽しめました。",
            "positive_score": 0.89906454,
            "score": 0.71830595
        }
    ]
},
{
    "hotel_id": "00002627",
    "review_id": "1001226432",
    "review_text": "10月30日宿泊\n客室はゆったりしていて寛げる\n温水プールは温かく景色が良い\n
レストランはおしゃれな空間で\n雰囲気が良い\nスタッフは一生懸命で好感が持てる\n来月またお世話になります",
    "score": 0.7502389,
    "matches": [
        {
            "match_text": "10月30日宿泊\n客室はゆったりしていて寛げる\n温水プールは温かく景色が良い",
            "positive_score": 0.779632,
            "score": 0.7502389
        },
        {
            "match_text": "レストランはおしゃれな空間で\n雰囲気が良い",
            "positive_score": 0.79234755,
            "score": 0.7080128
        }
    ]
}

まとめ

今回は自社の情報をChatGPTに組み込む方法をご紹介しました。

しかし実はまだ、冒頭の「熱海で子供も楽しめるアクティビティがあって、景色も良い宿を教えてください」に応えられるものが出来ていません。 これは、全国の施設情報をベクトルマッチングで一部に絞っていてもなお量が多くてプロンプトに埋め込むことができないからです。

解決方法についてはChatGPTの次の記事でお伝えできればと思います。

また一休では、ともに良いサービスをつくっていく仲間を積極募集中です!応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください!

hrmos.co

一休.com、Yahoo!トラベルのNuxtをNuxt3にアップグレードしました

CTO室プラットフォーム開発チームの山口(@igayamaguchi)です。
プラットフォーム開発チームではさらに内部でプロジェクトチームが分かれており、私はフロントエンド改善チームというチームでリーダーをしています。
フロントエンド改善チームでは主に一休.com、Yahoo!トラベルのフロントエンドの改善を行っております。

今回は一休.com、Yahoo!トラベルで使用しているNuxtのバージョンを2から3にアップグレードしたお話をさせていただきます。

一休.com、Yahoo!トラベルではトップページや検索ページ、ホテル・旅館の詳細ページなど主要なページのフロントエンドはNuxtで開発されています。
NuxtのバックエンドにはGo+gqlgenでGraphQLのサーバーを立てており、NuxtからはApolloを使用してバックエンドと通信を行っています。
このNuxtのバージョンは2となっており、それを今回3にアップグレードしました。

なぜバージョンを上げたのか

話は2022年7月半ばにさかのぼります。
当時は宿泊事業におけるフロントエンド開発の課題について議論をしていました。
一休.com/Yahoo!トラベルではフロントエンドのコードの共通化を行っており、同じリポジトリ内の同じコードで両サイトを実現しています。

user-first.ikyu.co.jp

しかし当初はうまくいっていた一休.com/Yahoo!トラベルのコード共通化、コンポーネント共通化も課題が生まれ、開発速度が大きく落ちてきていました。

上記はフロントエンドの現状のコードの問題をレポーティングしたときの資料から抜粋したものです。
上記画像のようにコードの重複が多く発生していました。(SDとは一休社内におけるスマホを指す用語です)
何か変更をしようとすると一休.com/Yahoo!トラベルとPC/スマホで別々になったコードに変更を加える必要があり変更箇所が4倍になるようになっていました。

この問題に対してデザインシステムを作ることで小さなUIコンポーネントやスタイルの共通化をすることが出来ました。

user-first.ikyu.co.jp

しかしVueの状態やGraphQLに絡んだ処理などロジックに関連する処理の共通化がまだうまくいっていませんでした。
これはうまく抽象化、共通化する手段がNuxt2/Vue2では提供されていないことが問題でした。
Mixinなどを使えば可能ではありますが、Mixinでは見通しが悪くなる可能性が高く暗黙的な抽象化が行われあまりよい形にはなりません。
そこでVue3で登場したComposition APIです。
https://vuejs.org/guide/introduction.html#composition-api
これを使うことで処理の共通化が行いやすくなり、コードの無用な重複も解消することができます。

Nuxt3/Vue3に移行すると他にも利点があり、以下に簡単に記します。

  • 設計を全く違うものにできる
    • Composition APIを使うことができると設計が全く変わる。より良い抽象化、共通化が可能になる
  • 状態管理の選択肢が増える。(useState, Piniaなど)
  • 型が効いて安全に
    • Vue3に上がることでVolar(vue-tsc)と組み合わせて型が効くように
  • 開発環境が爆速に
    • Viteが本当に早い

他にもSuspense、Teleport、ビルドの最適化、などいいことはたくさん。
色々利点がありますがやはり設計が改善できる、ということが大きいです。
Nuxt3の方がより良い方法でコンポーネント、状態管理の設計ができるため移行を決断したのが7月末です。
8月くらいから実際に移行に向けての3人のチームメンバーで作業を進めて2月頭にリリースしました。 (最初期は4人で途中からメンバーの異動があったため基本的に3人)

移行指針

まずは最初に移行の指針を立てました。
それは 最低限のコストで移行する というものです。
Nuxt3にアップグレードする理由はより良い設計にして開発をしやすいようにするためです。
移行しながらきれいな書き方にすることもできますが、その場合移行期間が延びるのと、リリース時にビッグバンリリースとなってしまいます。
移行のコストとリスクを最小限にするためにこの指針を立て、プロジェクト進行時には常にこの指針に従い意思決定を行っていきました。

移行戦略

次に移行戦略を立てました。
Nuxt3/Vue3のドキュメントを読み、Nuxt3/Vue3の破壊的変更を洗い出し、タスクの一覧化、移行戦略を立てました。
移行戦略では以下のことを定めました。

  • Nuxt Bridgeを使うか
  • タスクの切り分け
  • ブランチ運用
  • ミニマムに検証をする
  • リリース戦略

Nuxt Bridgeを使うか

NuxtではNuxt Bridgeというモジュールが提供されています。
https://github.com/nuxt/bridge
Nuxt2に対してこれを導入することで、Nuxt2でありながらNuxt3の機能を利用できる前方互換性のあるレイヤーです。
これを使うことでNuxt2のままNuxt3に対応するコードに徐々に移行するということができます。
しかし私たちはこれを使わない選択をしました。

1つ目の理由はNuxt Bridgeに対応していないモジュールがあることです。
私たちの使用しているNuxt moduleでは、Tailwind CSSなどが例として挙げられます。
そういったモジュールにどういった対応が必要になるのか、そこの対応にどのくらいの時間がかかるか予測できませんでした。
最悪開発が止まりリリースができなくなる可能性がありました。

2つ目の理由は二度テストを行う必要があることです。
上記のモジュール対応もあり、移行時にはサイト全体のテストを行う必要があります。
Nuxt2からNuxt Bridgeに移行するときとNuxt BridgeからNuxt3に移行するときで2度テストを行わなければいけません。

これらの理由によりNuxt Bridgeを使わずNuxt3への移行を行いました。
この決断は後に意思決定もシンプルにすることができ非常によかったです。
あらゆる対応がNuxt Bridge、Nuxt3両方を意識するのではなくシンプルにNuxt3のことだけを考えればよくなりコストが下がりました。

タスクの切り分け

アップグレードをするためにタスクを対応タイミングに応じて切り分けを行いました。
大まかには以下の3つです

  • Nuxt2/Vue2の状態で対応
  • Nuxt3/Vue3の状態で対応
  • リリース前に対応

まず1つ目のNuxt2/Vue2の状態で対応可能なものです。
Nuxt3/Vue3の破壊的変更はNuxt2/Vue2の状態でも対応できるものがあります。
例えばfunctionalコンポーネントがVue3で廃止されたためfunctionalコンポーネントを通常のコンポーネントに変更したり、Vueのfiltersが廃止されたため関数に書き換える、といったものです。

2つ目はNuxt3の状態で対応可能なものです。
1つ目を行ってからNuxt2/Vue2のときに対応できないNuxt3/Vue3の破壊的変更の対応です。
例えばモジュールやプラグインの書き換えです。

3つ目はリリース前に対応するものです。
これはテスト、画面の確認などです。

さらにタスクとは別にこのプロジェクトでやらないことも明記してリスト化するようにしました。
例えばNuxt3/Vue3に移行することでComposition APIが使えるようになるわけですが、「変更を最小限にするために移行時にComposition APIへの書き換えは行わない」、というように理由とともにやらないことを明記してリスト化していました。
実際に作業を進める上でもチームで議論して都度都度これは今やるべきではないとなったものを追加していました。

ブランチ運用

作業を進めるために作業ブランチをどうやって運用するかを決めました。

前述のタスクの切り分けを基に以下のようなブランチ運用を行うようにしました。

  • 破壊的変更に対してNuxt2/Vue2の時点で対応できるものをまず対応してmasterマージ
  • 事前対応できるものが終わり次第Nuxt3移行用のブランチを作成、そのブランチに対して残りの破壊的変更の対応をマージ
  • 各施策のブランチがmasterにマージされた場合、適宜Nuxt3移行用のブランチに取り込み

このようにブランチを運用することでNuxt3移行リリース時にマージするコードの変更は最小限になるようにしました。

ミニマムに検証する

Nuxt3/Vue3を初めて触る際は様々な挙動がわからず悩む時があります。
そういったものを実際のプロダクトで検証しようとすると手間です。
そのため最小限でプロダクションに近しいNuxt3のboilerplateを用意し、そこで様々な実装の検証を行うようにしました。

リリース戦略

本番リリースでは一休.com、Yahoo!トラベルそれぞれを順々に公開するようにしました。

  • 一休.comで10%のユーザーに公開
  • 問題がなければ一休.comで100%のユーザーに公開
  • 問題がなければYahoo!トラベルで10%のユーザーに公開
  • 問題がなければYahoo!トラベルで100%のユーザーに公開

ユーザーの振り分けはFastlyで行い、あるユーザーは常にNuxt2、あるユーザーは常にNuxt3を見るように設定を行いました。
この時、一部のページ、例えばトップページだけNuxt3を公開する、ということはしませんでした。
理由はNuxtがSPA的なJSを使用したページ遷移がありそれを振り分けるのは難しいと判断したためです。

このように移行戦略を立ててNuxt3へのアップグレードを開始しました。

作業を開始する前に

ここまでで戦略を立てて作業を始める準備は整ったのですが、ここでもう一つ作業を開始する前に取り組んだことがあります。
それがNuxt3/Vue3のドキュメントをチームで読み漁ったことです。
プロジェクトを始めた当初はNuxt3/Vue3について解像度が低く当初に洗い出したタスクで過不足がないか、どのくらい時間がかかるのかはかなり不透明だったため、解像度を上げるためにやってみようということで始めました。
毎朝1時間~1時間半ほどチームでZoomで集まりドキュメントを画面共有しながら読みました。
チームメンバーはNuxt2/Vue2の開発経験はあったため、Vueはマイグレーションガイド(https://v3-migration.vuejs.org/ )を、Nuxt3は頭からドキュメント(https://nuxt.com/ )を読んで機能差分について話しながら読み進めました。(当時Nuxt3はマイグレーションガイドが作成途中の項目が薄かったためドキュメントを頭から読んでいます。今なら https://nuxt.com/docs/migration/overview を読めばよいと思います)
結果チームメンバーそれぞれに基礎知識がつきタスクや対応方法の議論が活発になる土壌を作れたためよかった試みでした。

移行作業

ここからは実際に行った移行作業の具体的なお話をします。
とはいえ基本的には前述のNuxt、Vueそれぞれのマイグレーションガイドを読み、破壊的変更となるものを対応しています。
そこで今回、私たちが躓いたものや独自の対応をしているものを紹介させていただきます。

defineComponent/defineNuxtComponent

Nuxt2/Vue2でVueコンポーネントを作成するときに Vue.extend を使っていましたが、Nuxt3では defineNuxtComponent を使う必要があります。
https://nuxt.com/docs/api/utils/define-nuxt-component
今回、破壊的変更は別ブランチを立てて変更をしていくためmasterブランチマージ時に発生する変更は小さくしたいです。
そのためmasterブランチ上でも defineNuxtComponent を使用出来るようにaliasとなる関数を用意しました。

/**
 * defineComponent
 */
import Vue from 'vue'
import { VueConstructor } from 'vue/types'

type VueExtend = VueConstructor['extend']

// @ts-ignore
const defineComponent: VueExtend = (options) => Vue.extend(options)
export { defineComponent }

/**
 * dynamic import
 */
export function defineAsyncComponent(f: Function): Function {
  return f
}

これを用いてmasterブランチに各コンポーネントを Vue.extend から defineComponent に書き換えました。

import { defineComponent } from '~/nuxt3-alias'

export default defineComponent({
  // 中のoption apiはそのまま
})

Nuxt3移行ブランチでは以下のようにaliasを差し替えるだけでよいのでリリース時の変更が最小限になります。

import { defineAsyncComponent as _defineAsyncComponent } from 'vue'

import { defineNuxtComponent } from '#imports'

const defineComponent = defineNuxtComponent
export { defineComponent }

const defineAsyncComponent = _defineAsyncComponent
export { defineAsyncComponent }

fetchフック

Nuxt2ではfetchフックという各コンポーネントで非同期にデータ取得、設定を行うことができる専用のhookが用意されています。 https://nuxtjs.org/docs/components-glossary/fetch/

これがNuxt3では使うことが出来ないようになっていました。 useAsyncData useFetch というものが使えるようになっていてそちらに置き換えが必要です。
https://v3.nuxtjs.org/getting-started/data-fetching
しかし useAsyncData useFetch を使うにはComposition APIへの書き換えが必要です。
これには時間がかかることが予想されたのとmasterブランチとの差分が大きくなってしまうのでfetchフックをそのままにどうにかできないか検討しました。
最終的には以下のようなプラグインを作り、fetchメソッドをそのまま解釈できるようにして対応しました。
難点が1つあって、fetchフックを使用するときはnuxt2FetchKey というキーを定義する必要があるのと、fetchフックを使用するコンポーネントが画面で複数個所で呼ばれないことを前提にしています。

export default defineNuxtPlugin((nuxt) => {
  nuxt.vueApp.mixin({
    async serverPrefetch() {
      if (!hasFetch(this)) {
        return
      }
      if (this.$options.fetchOnServer === false) {
        return
      }
      await this.$options.fetch.call(this)
      if (!nuxt.payload.data) {
        nuxt.payload.data = {}
      }
      nuxt.payload.data[this.$_fetchKey] = this.$data
      this.$_fetchResolve()
    },
    created() {
      if (!hasFetch(this)) {
        return
      }
      this.$_fetchKey = this.$options.nuxt2FetchKey

      this.$_fetchPromise = new Promise((resolve) => {
        this.$_fetchResolve = resolve
      })
    },
    async beforeMount() {
      if (!hasFetch(this)) {
        return
      }

      if (!window.__NUXT__) {
        window.__NUXT__ = { data: {} }
      }

      const serverData = window.__NUXT__.data[this.$_fetchKey]
      if (serverData) {
        Object.assign(this.$data, serverData)
        window.__NUXT__.data[this.$_fetchKey] = undefined
        return
      }

      await this.$options.fetch.call(this)
    },
  })
})

function hasFetch(vm) {
  return (
    vm.$options &&
    typeof vm.$options.fetch === 'function' &&
    !vm.$options.fetch.length
  )
}

使用するコンポーネント側

export default defineNuxtComponent({
  nuxt2FetchKey: "defineNuxtComponentFetchKey",
  async fetch() {
    // 非同期に何かを取得して設定する
  },
});

headメソッド

Nuxt2で使用することができるheadメソッドの内部実装がNuxt3で変わり、computedを解釈することができなくなっていました。
例えば以下のようにheadメソッド内でcomputedを使用しているとundefinedになります。

export default defineComponent({
  head(): MetaInfo {
    return {
      title: this.hoge, // undefinedになる
    }
  },
  comptued: {
    hoge() {
      return 'hoge'
    }
  },
})

これに対応するために独自にプラグインを作り、Nuxt2と同じような動きをできるように、 xxx.call(this) によるVueコンテキストを注入しての実行、実行後の値をwatchにより監視し、変更後に useHead を使用してmeta情報への適用をするようにしました。

export default defineNuxtPlugin((nuxt) => {
  nuxt.vueApp.mixin({
    data() {
      return {
        head: undefined,
      }
    },
    created() {
      if (!this.$options.oldHead || this.$options.head) return
      this.$watch(
        () => {
          return this.$options.oldHead.call(this)
        },
        // @ts-expect-error
        (newValue) => {
          this.head = newValue
        },
        { immediate: true, deep: true },
      )
      this.$_fetchPromise?.then(() => {
        this.head = this.$options.oldHead.call(this)
      })
      useHead(() => this.head)
    },
  })
})

コンポーネントを使用する側は head というメソッドを oldHead というメソッドに書き換えるだけで正しくcomputedが解釈できるようにしました。

export default defineNuxtComponent({
  oldHead() {
    return {
      title: this.hoge,
    }
  },
  comptued: {
    hoge() {
      return 'hoge'
    }
  },
})

他にもいくつか問題があり対応をしています。

  • Apollo
    • NuxtのApolloモジュールは現在Nuxt3対応がされていますが、つい最近まではNuxt3に対応していませんでした
    • vue-apolloを直接使用しNuxtのプラグインとして独自実装
  • Storybook
    • NuxtのStorybookモジュールは現在もNuxt3は未対応のまま
    • @storybook/vue3を使い独自に実装
  • axiosの脱却
    • ApolloのRestLinkへ移行

他にもここには書ききれないほどに様々な対応をしていますが、この記事では省略させていただきます。
私たちはかなり早い段階でNuxt3対応を始めたことでNuxt3のバグを踏むということも多々ありました。
今であればバグも減り、ドキュメントも整ってきているのでもう少し早く移行を進められるかもしれません。
しかし早い段階で対応できたことで本来実現したかった設計の改善に着手できており、この面ではよかったと思っています。

終わり

現在はNuxt3を使って更なる改善に挑戦中です。 この記事がこれからNuxt3へのアップグレードを行う方々の力になれば幸いです。

一休では一緒に働く仲間を募集しています。まずはカジュアル面談からお気軽にご応募ください!

hrmos.co

宿特化の写真投稿SNS「YADOLINK」のUIUX設計 について

YADOLINK事業部デザイナーの李と申します。

YADOLINKは、一休が運営する「ホテルや旅館など”宿”が大好きな人たちが集まるSNS」です。宿に特化したサービスだからこそ宿泊体験を気兼ねなく投稿でき、深い共感を得られます。 web版を2022年4月19日に公開し、iOSアプリを2023年1月24日(火)にローンチしました。

YADOLINK by 一休.com

YADOLINK by 一休.com

  • IKYU Corporation
  • 旅行
  • 無料
apps.apple.com

1. 写真投稿SNSの “ベーシック” を考える

どこまでInstagramに寄せるべきか?

YADOLINKは写真投稿が主要な手段のSNSです。広く知られている写真投稿SNSにInstagramがありますが、「インスタっぽいものだよ!」と簡単に伝えることで、最初の利用するハードルが下がると考えました。どのようにInstagramに寄せて、どのように差別するかを最初に検討しました。

フィードUI

Instagramの最も基本的なUIはホーム画面に表示される通常の投稿フィードです。

多くのユーザが使い慣れているUIであるため、YADOLINKでもユーザーの投稿をフィード形式にすることを決定しました。しかし、Instagramのフィードをそのままにするのではなく、YADOLINK独自のユーザーニーズに合わせて、情報整理を行い、以下の部分を差別化することにしました。

1. 宿に特化したサービスのため、宿泊施設が一番重要な要素となり、フィードに宿泊施設名を表示するように追加した。

2. 投稿全体に対しての説明だけでなく、写真ごとに説明&思いを記述できるようにキャプションを追加した。

3. 「いいね」をするハードルを下げるため「いいね数」だけを表示して、「いいね」をした人のリストは非表示した。

4. ユーザーの体験をよりスムーズにするため、ページ遷移せずにコメントできるようにコメントのUIをモーダルにした。(アプリのみ)

5. 初期リリース時には、お互い知らない人同士の投稿にいいねやコメントすることのハードルが高いと思われるため、いいね数やコメント数が少なくても投稿するモチベーションを高めるため、「閲覧数」という指標を表示することを決定した。

2列フィードUI
中華圏を中心とした新興SNSを参考に

初期リリース時にはフィードUIのみが提供されましたが、一覧性が低く1スクロールで1~1.5の投稿しか見られない、タイムラインに流れている投稿が自分がフォローしている人の投稿に限らず全ての投稿が表示されるなどの課題がありました。

この課題を解決するために、下記の2つの改善を行いました。

1. YADOLINKでは中華圏を中心とした新興SNSでよく見られる「2列フィード」のUIを導入しました。これは、生活必需品とは異なり、宿みたいな嗜好品を「気になるからとりあえず見てみたい(予約意欲までいかない場合も含む)」という場合、ユーザーのニーズが漠然としていることが多いです。「行きたい宿を探す」というより「漫然とユーザーの投稿を眺めている内に行きたい宿を見つける」という仕様がYADOLINKユーザーに最もマッチすると考えました。「行きたい宿が決まっている」場合は、YADOLINKよりも一休.comの方を利用するユーザーが多いかもしれません。「小紅書」という中国のSNSの2列フィードUIは、このようなユーザーの気持ちにとことん寄り添い、参考になりました!

※「小紅書」(RED) は、2022年2月時点、登録ユーザー数3億人の動画や写真などを共有できる「インスタグラムとアマゾンが合体したようなアプリ」です。

2. 2列フィードUIの導入に伴い、トップページは「おすすめ」「フォロー」「新着投稿」の三つのタブに分けられ、それぞれがタイムラインとして閲覧できるようになりました。

宿の写真が引き立っていることを一番大事に

一休.comというサービス従来の上質感を残しつつ、新しいサービスとしての活発・躍動感も表現できるようにデザインしています。

主役である宿の写真が引き立っていることを一番大事にしています。InstagramなどのSNSで流行っている写真の上に文字入れインパクト系の画像は上位表示させないなど、一休.comの上質な世界観を取り入れています。

2. SNSは “アプリが命”

YADOLINKはWebからスタートしましたが、最初からアプリを意識していました。なぜならば、SNSは「アプリが命」だと考えられているからです。頻繁に開かれるものであり、毎回ブラウザを開くことは大変面倒です。「いいね」「コメント」「フォロー」などの機能にもリアルタイムのプッシュ通知が不可欠です。アプリではウェブサイトで実装が難しいインタラクションも実現できるため、ユーザーの没頭を促し、ストレスなく操作することが期待できます。

下記の図のようにアプリでは
・スワイプでタブ移動、ページ遷移できるようになった
・インタラクションでおすすめ投稿一覧と投稿詳細間がスムーズな往来になった
WEB
 
APP
・ログインフローのUIUX改善
WEB
 
APP
アプリ開発に伴い、その他の改善点も整理してみました。興味がある方ぜひ触ってみてください〜
・新しく実装された関連投稿で次々に行きたい宿が見つかる
・いいねの傾向に基づいたおすすめ投稿の精度向上
・写真選択段階に並び順を変えられるように
・投稿画像の縦横方向を指定できるように
・トリミング時の画像拡大・縮小
・入力画面にてスクロースせずに全ての項目を入力できるように
・キャプションの追加などはページ遷移→モーダルに変更
・自分の投稿へのコメント通知により素早く返事ができるように
・他にも自分への投稿へのいいねやフォロー通知が受け取れる
・受け取りたい通知はカテゴリごとに制御可能

3.まとめ

「YADOLINK」は、お宿を訪れた方が自分で撮影した写真を投稿できる「宿特化型SNS」です。写真を中心に、ユーザーが直感的に好みの宿を探しやすいインターフェースを提供しています。自分自身では言語化できないニーズを発見したり、まだ知らなかった「自分好みのお宿」との偶然の出会いを演出します。

これまで一休にはSNSサービスがありませんでしたが、YADOLINKをきっかけに新しいUIUXに取り組み、試行錯誤しながら多くのことを学びました。

特定のジャンルを絞らないSNSとは異なり、宿泊施設に特化したSNSであるため、ユーザー層や利用頻度などが比較的限定的ですが、UIUXの改善により、より多くのユーザーに愛用していただけるよう努めています。

一休では、ともに良いサービスをつくっていく仲間を積極募集中です。応募前にカジュアルに面談をすることも可能ですので、お気軽にご連絡ください。

hrmos.co