一休.com Developers Blog

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

Slack Enterprise Grid における情報バリアの設計

はじめに

社内情報システム部 兼 CISO室 コーポレートエンジニア id:rotom です。一休のコーポレートIT・セキュリティ領域はだいたい全部見てます。

このエントリは 一休.com Advent Calendar 2023 11日目の記事です。昨日は id:naoya による TypeScriptでどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する でした。その他の素敵なエントリも以下のリンクからご覧ください。

qiita.com

一休は今年、全社利用しているコミュニケーションツールである Slack を Enterprise Grid へ移行しました。 Enterprise Grid はその名の通り大規模なエンタープライズ組織向けの管理機能・セキュリティ機能が拡充されたもので、他のプランとは大きく思想が異なります。

Enterprise Grid について取り上げたエントリは多くありますが、その中でも「情報バリア」について詳しく書かれた記事が無かったので、本エントリで解説します。

Enterprise Grid に関する詳細な説明はここでは割愛するので、公式ドキュメントをご覧いただくか、Slack サポートにお問い合わせください。

slack.com

Enterprise Grid 移行と課題

一休ではエンジニアに限らず全ての従業員が Slack をコミュニケーションツールとして利用しています。 これまではビジネスプラスプランで利用してきましたが、監査ログや DLP などの機能を利用し、よりセキュリティ・コンプライアンス体制を強化するため、Enterprise Grid へ移行することにしました。

一休には従前、日常的に利用している一般のワークスペースと、機密情報を取り扱うワークスペースの2つのワークスペースが存在しました。 これらのワークスペースは完全に独立しており、機密情報は一般のワークスペースに持ち出すことができないよう厳しく統制されていました。

今回 Enterprise Grid に移行するにあたり、マルチワークスペースに対応することから、この2つのワークスペースは1つの OrG 配下に置くことにしました。

この際、仕様として OrG 配下のユーザーは、自分が所属していないワークスペースのユーザーに対してもダイレクトメッセージやハドルミーティングが可能です。 ワークスペース間でのダイレクトメッセージやハドルミーティングを禁止する設定は行えず、情報の持ち出しを防ぐために対策が必要でした。

情報バリアとは

この問題を解決するために、Enterprise Grid の情報バリアという機能を利用しました。

Slack 管理者でもビジネスプラス以下のプランの方は聞いたことがない人も多いと思います。あるいは、既に Enterprise Grid で運用している組織でも利用していないことが多いかもしれません。

slack.com

簡単にまとめると、特定の IDPグループ間でのダイレクトメッセージやハドルミーティングを禁止することができる機能です。

また IDPグループという聞き慣れない用語が現れましたが、これは Okta や Microsoft Entra ID などの IdP(Identity Provider)のグループを Slack 上に連携させ、チャンネルやワークスペースと紐付けることができる Enterprise Grid の機能です。

slack.com

この機能を利用することで、擬似的にワークスペース間のダイレクトメッセージ・ハドルミーティングを禁止にすることができると考えました。

情報バリアの設計

ここからは実際に情報バリアの構築した手順を解説します。これから情報バリアの利用を開始しようとしている方は業務影響のない Sandbox 環境で検証してから設定することを推奨します。

IDPグループの作成

IdP として利用している Microsoft Entra ID 側に一般ワークスペース、機密情報ワークスペースそれぞれに所属するユーザーを追加したグループを用意します。 このグループは SCIM(System for Cross-domain Identity Management)により、対応する IDPグループを OrG 上に作成され、Microsoft Entra ID 側のグループに追加されたユーザーの Slack アカウントが自動的に追加されるようになります。

IDPグループは複数のワークスペースやチャンネルと接続することが可能ですが、今回の用途ではそれぞれのグループに対応するワークスペース1つずつに接続します。 これにより Microsoft Entra ID 側で対象のグループに追加されたメンバーは、自動的にワークスペースへ追加されます。

なお、IDPグループの名称変更やユーザーの追加・移動・削除は Slack OrG 管理画面の GUI 上は行えません。全て SCIM で連携されているので IdP 側で変更し、プロビジョニングする必要があります。 API による操作は可能なので必要に応じて SCIM API を利用して操作することは可能です。

情報バリアの有効化

情報バリアは標準で利用できないオプトイン機能なので、OrG オーナーより Slack サポートチームに連絡をして有効化する必要があります。 OrG 管理者やワークスペースのオーナーでもリクエストができないので、ご自身が OrG オーナーではない場合は OrG オーナーにリクエストを依頼してください。

リクエストは /feadback で「 {yourdomain}.enterprise.slack.com で情報バリアが利用できるように機能を有効化してください。」のよう送信すれば OK です。

機能が有効化されると OrG 管理コンソール > セキュリティ > 情報バリアの項目が開けるようになります。

情報バリアの設定

情報バリア内から「障壁を作成」ボタンを押すと情報バリアを作成することができます。なぜかここではバリアが障壁と訳されていますが、気にしないでください。

ここではプライマリーグループにIDPグループ(一般)、障壁の対象にIDPグループ(機密情報)を入力しました。対象は複数の IDP グループを指定することが可能です。

実現

ここまで設定が完了すると、IDPグループ間で情報バリアが作成されます。

別のワークスペースに所属するユーザーのダイレクトメッセージ画面を開くと、このようにポリシーによって送信ができない旨メッセージが表示され、送信ができなくなります。 情報バリアの設定は OrG 画面から設定後、即時反映されるわけではなく少しラグがありました。設定後は少し時間をおいてから動作確認を行うことをおすすめします。

これにより、IdP グループ間の情報バリアの設定で実質的にワークスペース間のダイレクトメッセージ・ハドルミーティングを禁止することができました。

終わりに

Enterprise Grid への移行時に発生した課題と、情報バリアを使って解決した話を書きました。 要所のみ掻い摘んで記載しましたが、Enterprise Grid への移行は他の SaaS のシンプルなプランアップグレードではなく、イニシャルコストとダウンタイムを伴いながら環境を丸ごとお引越しすることになるため、本件以外にも想定外の様々な課題があり、Slack(Salesforce)マネージャー / アーキテクトにサポートいただきながらプロジェクトを完遂できました。

個人的にはSlack 認定管理者試験で学んだ Enterprise Grid の知識を実践で活用することができ、今年最も成長できたプロジェクトのひとつだったと思っています。

私も Enterprise Grid 管理者 1年生なので内容に誤りや、もっと良い方法があるよ!といったご指摘 / ご助言があれば X情シス Slack などでご連絡いただけると嬉しいです。

CM のお時間です

一休では現在コーポレートエンジニアの採用は行っていませんが、ソフトウェアエンジニアをはじめ、多くの職種で積極的に採用を行っています。 選考をともなわないカジュアル面談からも受け付けておりますので、お気軽にご応募ください 👋

www.ikyu.co.jp

明日は id:Kikuch1 による 請求書発行のためにEmbulkを使って爆速でデータを集約した話 です

TypeScriptでどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する

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

昨今は Web アプリケーション開発の世界でも、関数型プログラミングのエッセンスを取り入れるような機会が増えてきました。

とはいえ、一つのアプリケーションを 1 から 10 までがっちり関数型プログラミングで構成するというわけではなく、そのように書くこともあればそうでない従来からの手続き的スタイルで書くところもあるというのが現状で、どこまで関数型プログラミング的な手法を取り入れるかその塩梅もまちまちだと思います。まだ今はその過渡期という印象も受けます。

本稿ではこの辺りを少々考察してみたいと思います。

先日、Qiita Conference 2023 Autumn で以下のテーマで発表を行いました。

この発表では「関数型プログラミング最強!」という話をしたわけではなく、プログラムを関数型で考えるというのはこれこれこういうメンタルモデルにもとづいていて、一方で手続き型で考えるというのはこういうメンタルモデルにもとづく、という整理をしました。

より具体的には

  • 関数型プログラミング ··· 式によって計算を宣言し、関数を適用することで値を得る
  • 手続き型 (命令型) プログラミング ··· 文によって計算機に対し命令を行う。命令によって状態を更新することで結果を得る

と整理できるだろうという話をしました。

この発表の中で、蛇足的に以下のようなスライドを用意していました。

宣言的な記述はどちらかといえば純粋関数型的なパラダイムに基づくもの、命令的な記述は手続き的なパラダイムに基づくものと考えられる一方「純粋関数型言語」の Haskell はすべてのコードが宣言的になるかと思いきや、案外、手続き的な記述をすることもあるし、一度手続きを使うとそこから命令のコンテキスト以下は、手続き的なパラダイムに影響を受けることになります。

後に改めて触れますが、Haskell は「純粋関数型言語」としてのイメージが強いですが、上記のように、戻り値を戻さない (※ 実際にはユニット型を返してはいる) 手続き的なプログラミングが可能です。可能、というよりは、例えばミュータブルなデータ構造を更新したいときなどは、手続き的に書くのが自然です。

このように純粋関数型言語を使うにあたっても、関数型プログラミング / 手続き型プログラミングは一つのプログラムの中で混在する、混在していいものだということがわかります。

TypeScript でどこまで「関数型プログラミング」する?

Haskell の話に触れましたが、普段は私のチームのプロダクトは GraphQL バックエンドも含めて TypeScript でアプリケーション開発を行っています。 この TypeScript でのバックエンド開発については TypeScript による GraphQL バックエンド開発 - Speaker Deck のスライドでも詳しく解説していますが、やや関数型プログラミングよりのスタイルで開発を続けています。

以下のスライドで、その雰囲気が少し伝わるかと思います。

アプリケーションを記述するにあたり各種関数は、基本的に値を返す「式」として定義します。

計算は場合によっては失敗に分岐することがありますが、失敗は Result 型によって表現します。例外をスローすることはしません。そして Result 型を返す関数の合成 ··· 上のスライドの andThen などによって計算のパイプラインを作って実行します。

「Result」はその名前だけを見ると計算の結果だけに関与する部分的な型にも見えますが、実際には Result を導入すると計算構造の構築に Result のもつ合成を使うのが基本になり、実装スタイルに大きな影響を与えます。

例外を使わず合成によって一連の処理を構成するため、基本的にそのロジックの過程では大域脱出しません。結果、計算の流れが一方通行になります。また、Result によりおきうる失敗が全て明示されており、型によってそれを無視したプログラミングはできないようになっています。この両者の制約によってより堅牢な実装が可能になっています。

こう書くと、この宣言的プログラミングのスタイルや Result はとても良いもので何のトレードオフもないものに思えるかもしれません。しかし、やはりそんなことはありません。制約がかかる以上「いや、そこは手続き的に書いたら簡単なのに」と思うことはよくありますし、値が Result に包まれているおかげで、中身の値が欲しいのにいちいちコンテナを意識して実装をしなければならなかったり、面倒ごともあります。

こうなってくるとやはり「たとえどんな場合でも宣言的に、関数型ライクに書くのが良いのか?」という疑問がもたげてきます。

開発当時は、チームでもそういう話題になることもよくありました。手続き的なプログラミングは極力避けて、常に関数型プログラミング的に書くべきなのか? どうなんでしょう? という疑問です。

手続きを Result のコンテキストに閉じる ··· neverthrow の fromPromise / fromThrowable

ところで Result の実装には supermacro/neverthrow: Type-Safe Errors for JS & TypeScript というライブラリを使っています。

neverThrow には計算の合成に必要な基本的な関数が諸々定義されていますが、その中に一風変わった fromPromisefromThrowable という関数があります。

その名の通り Promise (async / await も含む) や例外による大域脱出が使われている手続きを、Result のコンテナの中に閉じ込めるための関数です。これを使うことで、Result を返さないサードパーティのライブラリなども含めて、Result を使った自分たちの関数と合成することが可能になります。

視点を変えれば、この fromPromisefromThrowable を使えば、Result を使いつつも、そのコンテキストの中では async / await を使ったり、例外を使ったりといった「作用のある手続き」を用いたプログラミングをすることができる... というわけです。

例えば以下は実際のプロダクションのコードの中にある、一部の実装です。

これは内部的なマイクロサービスへの HTTP リクエストのための関数ですが、相手側のサービスに無駄なリクエストを投げすぎないよう Redis にキャッシュさせながら問い合わせを行う、という実装になっています。Redist へのアクセスは keyv を使います。

非同期 IO で、かつ Redis を間に挟みながらオリジンの HTTP サーバーへのアクセスを行う実装です。昨今の Web アプリケーション開発ではよくある典型的な実装ですが、これを Result の合成で宣言的に書こうとすると Result が多段になりかなり面倒な実装になってしまいます。

そこで、手続きの流れは普段通り手続き的に async / await で書きつつ、その関数を前述の fromPromise によって Result のコンテキストに包みます。これで「手続き的に書いた方が良い場面ではそうする」ことができます。

const restoreAccessToken =
  ({ keyv, key, requestFunc }: { keyv: Keyv; key: string; requestFunc: requestAccessToken }) =>
  (requestArgs: requestAccessTokenArgs): ReturnType<requestAccessToken> => {
    const promise = async () => {
      // キャッシュを検索
      const value = await keyv.get(key)
      if (value)
        return {
          access_token: value as string,
          expires_in: 0,
          token_type: 'Bearer' as const,
        }

      // キャッシュになかったらリクエストする
      return requestFunc(requestArgs).match(
        (response) => {
          // キャッシュに格納
          keyv.set(
            key,
            response.access_token,
            (response.expires_in - 600) * 1000
          )

          return response
        },
        (error) => {
          throw error
        }
      )
    }

    // fromPromise で Result に包む。エラーの型が unknown になってしまうのに注意
    return fromPromise(promise(), (error: unknown) =>
      error instanceof AuthenticationError ||
      error instanceof ValidationError ||
      error instanceof NetworkError
        ? error
        : new NetworkError(error as string)
    )
  }

このように全てを Result を使って関数型/宣言的に··· とするのではなく、非同期 IO が絡み合う箇所など、手続的に書く方がシンプルに書けるということもよくあるわけです。

関数型プログラミングに固執せず、手続き的に書けばいい時はそうすればいい、ということになります。じゃあその「手続き的に書けばいいとき」というのは一体どういう時なんでしょうか? こういうときはなかなか、TypeScript だけをやっていてもよくわかりません。

そこで他の言語、手続きも書ける純粋関数型言語 Haskell ではどのような考えに基づいてみんな実装しているのか、その例を少し見てみることで相対化してみましょう。ただし当然この短い記事で全部を見ることはできません。いくつかの典型例に着目して見ていくことにしましょう。

Haskell でも手続き型で記述するケース

先述の通り Haskell は「純粋関数型」言語ですが、純粋関数型即ち関数型でしか書けないわけではなく実際には手続き型で記述することもよくあります。むしろ積極的に手続きを使う場面もあります。

なお、手続きHaskellについては、以下の書籍がおすすめです。30ページほどの薄い本なので、さっと読めます。

booth.pm

IO を行いたいとき

IO というのは外界の世界とのやり取りを、計算機に命令する手続きだと考えられます。「計算機に命令する」のですから、自然と手続き型 (命令型) のコードを書くことになります。

main :: IO ()
main = do
  name <- getLine
  putStrLn ("Hello, " ++ name)

見ての通り putStrLn からは戻り値を受け取っていません。つまり「文」に相当する記述になっています。

厳密には putStrLn は文ではなく IO () 型の値を返す式です。他の返値を受け取らない式も同様なのですが、ここでは説明のため手続き型プログラミングでいうところの文相当だと思ってください。

ところで main の後ろに do という記述があります。何気ない記述ですが、これこそが Haskell の手続きプログラミングを可能にするものです。

上記を do 記法を使わずに記述することもできるわけですが、

main = getLine >>= (\name -> return ("Hello, " ++ name)) >>= putStrLn

その場合 Monad 型のバインド演算子 >>= を使って関数合成で記述することになります。getLine 関数によって端末からの入力値を受け取ることができますが、それは外界からやってきた値であり、IO 型のコンテナに入っています。それを扱うため、上記のような少し変わった記述が必要になります。

do 記法はこのイディオムを逐次で記述できるようにするシンタックスシュガーです。つまり上記のバインド演算子による実装は、do を脱糖した場合の記述です。

do 記法があることで、コンテナの中に入った値を扱いやすくなります。結果、作用を起こしてその結果を受け取ったりする記述が容易になります。計算機に命令して作用を起こし、その結果を得る··· 手続き的プログラミングそのものですね。

do 記法を使わずに記述していけば見た目は関数の合成になるわけですが、そうした方がいい理由は特にありません。たとえば複数の値を一つずつ出力していきたいのであれば mapMtraverse などを無理して使わなくても、素直に for_ で手続き的にループを回して出力すれば良いのです。

import Data.Foldable (for_)

main :: IO ()
main = do
  for_ [1 .. 10] $ \i -> do
    print i

ミュータブル配列を使いたいとき

Haskell のデータ構造は基本、イミュータブルです。 しかし場合によってはイミュータブルなデータ構造だけでは効率的な実装が不可能な場合があります。

その典型例といえば、配列です。先日 Haskell の Array という記事でも投稿しましたが、Haskell にはイミュータブルな配列と、ミュータブルな配列があります。両者を都度変換して使うこともできます。

イミュータブルな配列は以下のように使います。

{-# LANGUAGE TypeApplications #-}

import Data.Array.IArray
import Data.Array.Unboxed (UArray)

main :: IO ()
main = do
  let as = listArray @UArray (0, 5) [3, 1, 4, 1, 9, 2 :: Int]

  print $ as ! 2

! 演算子が配列への添字アクセスを行う関数で、もちろん O(1) です。

問題は配列の更新です。 イミュータブルなデータ構造は直接は書き換えられないので、更新時にはコピーが発生します。

main :: IO ()
main = do
  let as = listArray @UArray (0, 5) [3, 1, 4, 1, 9, 2 :: Int]

      as' = as // [(0, 4), (1, 8)] -- 配列要素の更新

  -- [4,8,4,1,9,2]
  print $ elems as'

このとき配列全体がコピーされるので、更新関数 // の計算量は O(n) になります。

単一の特定の要素を更新する場合は O(1) で済んで欲しいわけですが、イミュータブルな配列ではそれは不可能です。O(1) での更新が必要なときはミュータブルな配列を使うと良いでしょう。

import Data.Array.IO (IOUArray)
import Data.Array.MArray

main :: IO ()
main = do
  as <- newListArray @IOUArray (0, 5) [3, 1, 4, 1, 9, 2 :: Int]

  writeArray as 0 4
  writeArray as 1 8

  print =<< getElems as

このようにミュータブルな配列を書き換えるのには writeArray などの関数を使います。この関数は「値を更新しろ」という命令を行うものですから、戻り値はありません。すなわち文 (相当) になります。

上記のミュータブル版の実装を見ると、writeArray の戻り値を受け取っていないだけでなく、イミュータブル版の実装の時には出てこなかった <-=<< などの演算子が必要になっています。詳細は割愛しますが、これらはやはり「データ構造を直接書き換える」という作用の結果必要になるもので、IO やミュータブルなデータの更新などの作用を起こすと周囲にも影響が及ぶことが見て取れます。

同様の例として、先の発表スライドの以下のページを見てください。Union-Find (Disjoint Set とも呼ばれます) というデータ構造とそのアルゴリズムを実装した時の比較です。

Union-Find は内部的に集合の管理をするわけですが、その管理用のデータ構造にイミュータブルなデータ構造を使った場合と、ミュータブルなデータ構造を使った場合のインタフェースの比較を行なっています。

左の、Union-Find がイミュータブルになケースでは、Union-Find 内の実装はもちろん、Union-Find を利用する側の実装もイミュータブルにそれを使うことになるので式で宣言します。より宣言的に、関数型プログラミング的にプログラムを構成することになります。

一方、右側のミュータブルな Union-Find はどうでしょうか。

左の実装とは異なり、forM_ (先の for_ と同じです) や unless など戻り値を伴わない制御構造文的なものを使った実装になります。これは無理にそうしているわけではなくて、ミュータブルなデータ構造を更新するのには文を使うことになり、その結果、制御構造も値を戻さない文を使うことになるだけです。作用によってデータを更新する、ミュータブルなデータ構造を使うとそれを皮切りに、そのコンテキストの実装は自然と手続き型になることを意味します。

ということはそのままプログラム全体を構成していくと、プログラム全体が手続き的になってしまいます。そこでミュータブルなデータ構造を改めてイミュータブルなデータ構造に変換するなどして、作用への依存を切って、関数型での記述に戻していくこともできます。

なお、Union-Find はそのデータ構造の都合上、ミュータブルなデータ構造を使って構成する方が望ましいと考えています。具体的にはクエリ時に経路圧縮をすることによってデータ構造内部のバランシングを行うわけですが、このバランシングに作用を伴うためです。詳しくは以前 Haskell で Union-Find とクラスカルのアルゴリズム に記述しました。

手続き的に書く方が「わかりやすい」ケース

IO や ミュータブルなデータ構造の例は、作用があるゆえ結果的に自然と手続き的に書くに至るというようなケースでした。 積極的に手続き的プログラミングを選択したというよりは、半ば受動的に、手続き的に記述するようなケースです。

では、その積極的に手続き的プログラミングを選択したいケースも見てみましょう。

以下 AtCoder の競技プログラミングの問題を題材にしますが、問題の内容は重要でありませんので詳細を理解する必要はありません。問題を解くためのコードの形がどんなものになるかにだけ着目していってください。

次の再帰による深さ優先探索 (DFS) の問題を解いてみます。

atcoder.jp

グラフが与えられる、頂点 1 を出発点にして探索を行った時、同じ頂点を複数通らないパスを数え上げていったときその個数 K を出力する。ただし K が 106 より大きくなる場合はそこで数え上げをやめる。という問題です。DFS を行えばいいだけの問題に見えますが、経路を数え上げた結果上界に達したら計算を打ち切る... いわゆる枝刈りが必要です。

この手の再帰を回しながら数え上げをする実装は、手続き型で書くと思いのほか簡単です。 以下は Python による実装です (ChatGPT に書かせました)

数え上げは再帰関数のスコープを跨いで行う必要がありますが、カウンタ値 kglobal で関数のスコープ外で生存できるようにし、上界の 106 に達したら早期 return で大域脱出すれば良いだけでです。

import sys
sys.setrecursionlimit(10**7)

# Input
n, m = map(int, input().split())
g = [[] for _ in range(n)]
for _ in range(m):
    u, v = map(int, input().split())
    g[u - 1].append(v - 1)
    g[v - 1].append(u - 1)

visited = [-1] * n
k = 0

def dfs(v):
    global k
    k += 1
    if k > 10**6:
        k = 10**6
        return
    visited[v] = 1
    for u in g[v]:
        if visited[u] == -1:
            dfs(u)
    visited[v] = -1

dfs(0)
print(k)

同じような実装を Haskell で、純粋関数で書こうとするとグローバル変数や大域脱出に相当するところをどうするか悩むことになります。 以下は最初に書いた、純粋関数で実装した実装です。

dfs nextStates visited v = do
  let visited' = IS.insert v visited
      us = filter (`IS.notMember` visited') (nextStates v)

  foldl'
    ( \k u ->
        if k >= 10 ^ 6
          then k
          else k + dfs nextStates visited' u
    )
    (length us)
    us

main :: IO ()
main = do
  [n, m] <- getInts
  uvs <- replicateM m getTuple

  let g = graph2 (1, n) uvs
      k = dfs (g !) IS.empty (1 :: Int)

  print $ min (10 ^ 6) (k + 1)

foldl' で畳み込みをする際に上界を超えていたらそれ以上は再帰を行わない、ということを途中で行なっています。

再帰をまたいだカウンターのものを用意するのではなく foldl' の計算結果のアキュムレータを再帰関数が返したものを受け取って、さらにそれをまた foldl' の初期値にして... ということを繰り返しており、その動きを頭の中で想像しようとするとなかなか大変···認知負荷で頭がパンクしそうになります。

Python の実装に比べると、ずっと難しく感じますね。なお、誤解がないよう説明すると、ここではコードの読みやすさが簡単・難しいという話をしているわけはなくて、計算の構造、計算を実際に頭の中で追うときの認知負荷の観点で簡単か、難しいかを論じています。

もとい、先の Python の実装のように再帰関数のコンテキスト中にカウンタ値を共有して計算を打ち切るような実装はできないのものでしょうか? そこで手続き型プログラミングです。

State モナドを使うと、関数の実行コンテキスト間で値を共有しながら計算を進めることができます。実質的に、関数の実行コンテキスト内部に閉じたグローバル変数のように使えます。

dfs nextStates visited v = do
  let us = filter (`IS.notMember` visited) (nextStates v)

  modify' (+ length us)

  k <- get

  when (k < 10 ^ 6) $ do
    forM_ us $ \u -> do
      dfs nextStates (IS.insert v visited) u

main :: IO ()
main = do
  [n, m] <- getInts
  uvs <- replicateM m getTuple

  let g = graph2 (1, n) uvs
      k = execState (dfs (g !) IS.empty 1) 0

  print $ min k (10 ^ 6)

State モナドの更新には、例によって戻り値のない命令 modify' などを使います。カウンタ値が上界を超えたら再帰を呼ぶ必要はないし、純粋関数でやっていた時のように値を戻す必要もないので戻り値のない when により分岐を制御します。

dfs 関数の中が、手続き的になりました。こちらの方が計算の流れは簡単に追うことができるでしょう。

この問題は比較的シンプルではあるので、純粋関数で構成してもなんとかなるかなとは思います。 一方、再帰を呼ぶ、呼ばないの分岐がより複雑になってくると再帰から戻ってきた値をどう結合するかも含めて考えるときの認知負荷が大きくなり、きつくなってきます。

再帰関数による全探索の、別の問題を見てみます。

atcoder.jp

この問題は、縦長の畳、正方形の畳がある決まった数が与えられたとき、それを部屋の中にちょうどよく敷き詰められるか? というパズルを再帰的に全探索することで解く問題です。

詳細は割愛しますが、こちらの問題も State モナドを使って手続き的に記述することで、先のグラフ問題同様に、再帰を継続するしないの選択や、数え上げを、戻り値を戻すことを意識せずに実装できるので比較的、頭の中にあるモデル通りの実装が可能です。

これを純粋関数でやろうとすると再帰から戻ってきた値をどう扱うか、書けないわけではないですが、少し面倒になるでしょう。

dfs (h, w) _ _ s [] = do
  modify' (+ 1)
dfs hw a b s (v@(i, j) : vs)
  | Set.member v s = dfs hw a b s vs
  | otherwise = do
    when (a > 0) $ do
      let !yoko = (i, j + 1)
          !tate = (i + 1, j)

      when (inRange ((1, 1), hw) yoko) $ do
        dfs hw (a - 1) b (Set.insert yoko $ Set.insert v s) vs

      when (inRange ((1, 1), hw) tate) $ do
        dfs hw (a - 1) b (Set.insert tate $ Set.insert v s) vs

    when (b > 0) $ do
      dfs hw a (b - 1) (Set.insert v s) vs

main :: IO ()
main = do
  [h, w, a, b] <- getInts

  let vs = range ((1, 1), (h, w))
      k = execState (dfs (h, w) a b Set.empty vs) (0 :: Int)

  print k

IO を扱いたいとき、ミュータブルなデータ構造を扱いたいとき、再帰関数の例のように手続き的に書く方がわかりやすいとき、など Haskell での手続きプログラミングの例を見ました。いずれの例も、無理をして手続き的に書いているわけではありません。関数型言語を使うからと言って、式による計算の宣言、つまりは関数型プログラミングに固執する必要はないことがわかります。

do 記法や State モナドは決して特別なものではなく言語の基本機能として用意されているものです。つまり、その場その場に応じて適切なパラダイムを選択する ... 関数型と命令型のマルチパラダイムでコードを実装しても特に問題ないからこそでしょう。

ただし手続き型の命令に伴って副作用が現れる場合に、Haskell は型やモナドによって明示的にそれを扱っています。そのため比較的安全に、手続き型プログラミングを純粋関数型プログラミングの中に混在させることが可能になっている... というところが大きなポイントです。これによって都度パラダイムを行き来してもプログラム全体を破壊することなく、堅牢な記述を続けることができるというわけです。

まとめ

Haskell の手続きプログラミングの例を見ました。Haskell でも手続き的プログラミングをする場面というのは案外多くあることがわかります。そしてその、関数型プログラミングと手続き型プログラミングのパラダイムの行き来をスムーズにするのに、do 記法や型が重要な役割を果たしています。

手続き型プログラミングの副作用を安全に隔離しながらも記述のオーバーヘッドを抑えるのに do 記法が貢献しているとも言えます。

裏返せば、do 記法のような文法の支援のないプログラミング言語で無理に純粋関数型だけでやっていこうとすると「ここは手続き的に書けばいいのに」という場面で柔軟な方針が取れず、自分で自分の足を撃っているような状況に陥るかもしれません。例えば Result はモナドのようなものですが、Result が入れ子になって多段になると、すぐにコードが複雑化します。(Rust など、最近のプログラミング言語には Result が組み込みで用意されているものもありますが、そこにはプログラミング言語による文法の支援が、セットで付いています。)

TypeScript には関数型プログラミング言語的な側面があるとはいえ、一方で Haskell の do 記法 (やモナド) のようなプログラミング言語組み込みの機構はありません。よって、純粋関数型言語と完全に真似たスタイルでやっていこうとすると、少し困難が伴うかもしれません。

ここまでで分かったことを列挙すると以下のような考察になります。

  • 明示的に作用を起こしたいのであれば手続きを使えば良い。ただし Haskell はその結果の作用から、純粋関数の世界を守ってくれる機構がある。それがない言語では、作用が伴う時その影響をどう管理するかが論点になる
  • TypeScript には do 記法がない。(パターンマッチや代数的データ型も、エミュレートはできるものの十分とはいえない )。そして TypeScript で記述するようなアプリケーションは非同期 IO をたくさん行うものが多い。···にもかかわらず完全関数型スタイルでやっていくというのには道具が足りてない (と思います)
  • TypeScript でも無理なく宣言的に書けるところはそうすれば良いだろう。同様に、手続き的に書く方がいい場面では (もしその手続きに副作用を伴うなら、それを手続きのスコープに留めることを何かしらで担保しつつ) 手続きで書くので良さそう。「関数型プログラミング」に固執する必要は (Haskell ですらそうなのだから、道具の足りてない TypeScript ではなおのこと) ない。
    • 純粋な値だけで構成されるようなロジックの場合は、イミュータブルかつ宣言的に書いていってもなんら不便はない
    • 副作用のあるところと、純粋に書けるところを分離することでその併用が可能になる。その点で、高階関数による Dependency Injection を積極的に使って業務ロジックの IO への依存を切っていくのは良いプラクティスと言える
  • neverthrow には各種合成関数、 fromPromise や fromThrowable などで、コンテナに入っている値を扱いやすくする機構はあるものの、やはり十分とはいえず。 プログラミング言語そのものがそれをサポートしている言語などに比較するとやはり不便はある。(私たちはシステムが扱っている業務の都合上、その不便を受け入れてでも堅牢性の方を優先しました)

後日さらに理解が深めた結果、私自身がこの考察を否定することもあるかもしれませんので、その点はご了承ください。

長々とした文書をここまで読んでいただき、ありがとうございました。

Solr クエリを速度改善したら Solr 全体のパフォーマンスが向上した

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


一休レストランの開発チームでエンジニアをしている香西です。 今回は Solr クエリの速度改善についてお話します。

背景

2023年10月、一休レストランのスマートフォン用 レストラン詳細ページをリニューアルしました! UI/UX の見直しとともに、使用技術も一新しました。

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

スマートフォン用 レストラン詳細ページ

課題

「日付を選ぶカレンダーの表示が遅い」

社内限定リリースの直後、多方面からこの声が聞こえてきました...
レストランへ行く日付を選ぶカレンダーは予約フローの第一ステップなので、表示速度が遅いことは致命的です。 特に、設定データ(料理のコース種類・席の種類など)が多いレストランでは、カレンダーの空席状況を取得するのに15秒以上かかることがあり、このままでは正式リリースできない状況でした。

空席カレンダーの UI

一休レストランでは、各レストランの空席情報を全文検索システム Solr にインデックスし、予約できる日を Solr で検索して空席状況を取得しています。 Solr には、レストランの設定データごとにドキュメントを作成しインデックスしています。そのため設定データが多いレストランは検索対象のドキュメント数が膨大になり、検索に時間がかかっていました。

やったこと

検索マイクロサービスを経由するのをやめた

Solr へのアクセスは「フロントエンド → バックエンド → 検索マイクロサービス → Solr」という流れで行われます。 従来からあった検索マイクロサービスのオーバーヘッドが大きかったため、検索マイクロサービスを経由するのをやめて、「バックエンド → Solr」に直接アクセスするようにしました。
検索マイクロサービス(C#)で行われていた Solr クエリの組み立てや、Solr からのレスポンスをオブジェクトに変換し在庫計算を行う処理を、バックエンド(Rust)に移行しました。

ワイルドカードを使うようにした

Solr にインデックスされているデータのなかには、日付ごとに異なる情報が含まれています。これらの情報は、それぞれ特定の日付(例:231025)を含むフィールド名で表現されています。

"231025Close_tdt": "2023-10-21T00:00:00Z",
"231025VisitTimeFrom_tdt": "2023-10-25T18:00:00Z",
"231025VisitTimeTo_tdt": "2023-10-25T18:30:00Z",
"231025HasInventory_b": true,
"231025HasRotationOrBlockTime_b": false,
"231025Inventory_ti": 1,
"231025SalesUpperLimitOver_b": false,

例えば、先1か月分の各日付の情報を取得する場合、以下のような Solr クエリを生成していました。

// 変更前

fl=231025Close_tdt,231025VisitTimeFrom_tdt,231025VisitTimeTo_tdt,231025HasInventory_b,231025HasRotationOrBlockTime_b,231025Inventory_ti,231025SalesUpperLimitOver_b,231026Close_tdt,231026VisitTimeFrom_tdt,231026VisitTimeTo_tdt,231026HasInventory_b,231026HasRotationOrBlockTime_b,231026Inventory_ti,231026SalesUpperLimitOver_b,231027Close_tdt,231027VisitTimeFrom_tdt,231027VisitTimeTo_tdt,231027HasInventory_b,231027HasRotationOrBlockTime_b,231027Inventory_ti,231027SalesUpperLimitOver_b,231028Close_tdt,231028VisitTimeFrom_tdt,231028VisitTimeTo_tdt,231028HasInventory_b,231028HasRotationOrBlockTime_b,231028Inventory_ti,231028SalesUpperLimitOver_b,231029Close_tdt,231029VisitTimeFrom_tdt,231029VisitTimeTo_tdt,231029HasInventory_b,231029HasRotationOrBlockTime_b,231029Inventory_ti,231029SalesUpperLimitOver_b,231030Close_tdt,231030VisitTimeFrom_tdt,231030VisitTimeTo_tdt,231030HasInventory_b,231030HasRotationOrBlockTime_b,231030Inventory_ti,231030SalesUpperLimitOver_b...つづく

field list で「231025のフィールド群,231026のフィールド群,231027のフィールド群 ...」のように、特定の日付が含まれるフィールド群を個別に指定していましたが、日付部分(231025)をワイルドカード(??????)に置き換えて「??????のフィールド群」という書き方に変更しました。

// 変更後

fl=??????Close_tdt,??????VisitTimeFrom_tdt,??????VisitTimeTo_tdt,??????HasInventory_b,??????HasRotationOrBlockTime_b,??????Inventory_ti,??????SalesUpperLimitOver_b

この変更により、設定データが多いレストランではレスポンスタイムが約 1/5 に短縮され、大きな改善効果が得られました!

100件ずつ並列で取得するようにした

最初に、検索結果の総件数のみを取得し、総件数を100で割って何回取得すればよいか判断し、100件ずつ並列で Solr にリクエストを送るようにしました。

Rust で Solr から結果を取得するサンプルコードです。search_calendar にカレンダーの検索条件を渡すと、まず Solr から総件数を取得し、そのあと100件ずつ検索結果を取得します。

pub async fn search_calendar(
    &self,
    input: &model::CalendarInput,
) -> anyhow::Result<Vec<model::Date>> {
    let rows = 100;
    let query = CalendarQuery(input.clone());
    // 先に総件数のみを取得する
    let total_count = self.get_solr_data(&query, 0, 0).await?.response.total_count;
    let query = &query;
    // 100 件ずつ取得する
    let futures = (0..total_count.div_ceil(rows)).map(|n| async move {
        self.get_solr_data(query, n * rows, rows).await
    });
    let res = futures_util::future::try_join_all(futures).await?;
    
    // 以下略(Solr の結果をもとに返り値を作る)
}

不要な Solr クエリを削る

改めて Solr クエリに削除できる部分がないか見直しました。

  • 不要なフィールドを取得していないか
  • ユーザーの指定条件によって削除できるフィールドはないか
  • 無駄に group, sort の機能を使用していないか

といった観点でチェックを行いました。

成果

この改善により、カレンダーの空席状況を取得するのに15秒程かかっていたのが2~3秒程度に短縮され、スムーズな UX をユーザーに提供することができました!

また、システム観点でも大きな効果がありました。 今回の速度改善対象は、スマートフォン用 レストラン詳細ページのカレンダーの検索処理でしたが、Solr 全体のパフォーマンスが向上しました。

  • Solr の CPU コア使用率が半分以上減少
  • Solr 全体のレスポンスタイムが450ミリ秒から200ミリ秒程度に短縮

Solr の CPU コア使用率が半分以上減少

Solr 全体のレスポンスタイムが450ミリ秒から200ミリ秒程度に短縮

一休レストランの Solr に関する改善点はまだ多くありますが、少しずつ着実に取り組んでいきたいと思います。

さいごに

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

www.ikyu.co.jp

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

hrmos.co

*1:Next.js で起きた課題については 一休.com Advent Calendar 2023 15日目の記事で解説予定です。

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