一休.com Developers Blog

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

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

請求書発行のためにEmbulkを使って爆速でデータを集約した話

こんにちは。宿泊開発チームの菊地です!

このエントリは 一休.com Advent Calendar 2023 12日目の記事です。昨日は id:rotom によるSlack Enterprise Grid における情報バリアの設計でした。その他の素敵なエントリも以下のリンクからご覧ください。

qiita.com

私はEmbulkを使って、各プロダクトの請求データを集約する機能を担当しました。今回は、Embulkの紹介とふりかえりをしていきたいと思います!

背景

一休では、これまでプロダクト毎に請求書発行機能が実装されていました。私のチームでは、2023年10月に施行されたインボイス制度*1の対応として、全プロダクトの請求書を適格請求書形式に改修することになりました。

今後法律が改正されることも考慮して、既存の実装を個別に修正するのではなく、請求書マイクロサービスに一元化して各プロダクトから利用するという方針を立てました。

課題

一休ではプロダクト毎に個別のDBを持っています。プロダクトによって採用しているDBMSも様々です。全プロダクトの請求書を発行するためには、個別に管理されているデータを統合する必要がありました。また社内向けに、複数DBをまたいだ情報を書き出したCSVの発行が求められていました。

これらの要件を満たすため、複数のデータソースからデータを集約して別のデータソースに出力する手法を検討しました。

解決策

この課題を解決するために、Embulkを使って各プロダクトのDBからデータを集約することにしました。

Embulkとは?

www.embulk.org

Embulk is an Open-source Pluggable Bulk Data Loader to/from varieties of storages, file formats, databases, cloud services, and else.

Embulkはあるデータソースからデータを吸い出し、別のデータソースへ転送するためのETLツールです。また、Pluggableとあるように、Embulk本体は基本的な処理順序(inputプラグインを実行し、filterプラグインを実行し、outputプラグインを実行する)のみを制御しており、利用者は個々のユースケースに合わせたプラグイン*2の組み合わせで処理を実現します。

簡単に動かしてみたい方は、embulkのコマンドでquick startが提供されていますので、試してみてください*3

embulk example {dir}

今回の課題に対してEmbulkがマッチした理由

Embulkでは、プラグインを組み合わせることで複数データソースをまたいだ操作が簡単に記述できます。今回の要件では、次の2つの操作ができることが非常に強力でした。

union: 複数のデータソースを連結する

unionプラグインを使うことで、複数のDBからのデータ取得処理を書くことができます。また、一休ではプロダクト毎にPostgresやSQL Serverなどの異なるDBMSを使っているため、適切なinputプラグインが異なります。union プラグインはソースとなる input もまたプラガブルになっており、任意の input プラグインを組み合わせられる自由度の高さも非常にありがたかったです。

config.ymlの記述例

in:
  type: union
  union:
    - name: product_hoge
      in:
        type: sqlserver
        url: product_hoge_jdbc_url
        user: product_hoge_db_user
        password: product_hoge_db_pwd
        query: |
          SELECT
            hoge_id AS common_id,
            amount,
            tax_fee
          FROM
            product_hoge_table
      filters:
        - type: column
          add_columns:
          - { name: product_code, type: string, default: "hoge" }

    - name: product_fuga
      in:
        type: postgresql
        host: product_fuga_db_host
        port: product_fuga_db_port
        user: product_fuga_db_user
        password: product_fuga_db_pwd
        database: product_fuga_db_name
        query: |
          SELECT
            fuga_id AS common_id,
            charge AS amount,
            tax_fee
          FROM
            product_fuga_table
      filters:
        - type: column
          add_columns:
          - { name: product_code, type: string, default: "fuga" }

out:
  type: postgresql
  host: common_db_host
  user: common_db_user
  port: common_db_port
  password: common_db_pwd
  database: common_db_name

  table: common_table
  mode: merge
  merge_rule: [
    "product_code = S.product_code",
    "id = S.common_id",
    "amount = S.amount",
    "tax_fee = S.tax_fee"
  ]

lookup: 複数のデータソースを結合する

csv_lookupプラグインを使うことで、DBから取得した情報に対し、CSV のデータを SQL の left join のような形で結合できます。このプラグインでデータベースと CSV を結合した帳票を得ることができました。処理自体も非常に軽量で、例えば、6,000件のDBレコードに対し18,000行のCSVをlookupしたCSVを発行するジョブは平均5分18秒で実行できました*4

config.ymlの記述例

exec:
  min_output_tasks: 1

in:
  type: sqlserver
  url: hoge_db_jdbc_url
  user: hoge_db_user
  password: hoge_db_pwd
  query: |
    SELECT
      hoge_key,
      hoge_col_1,
      hoge_col_2
    FROM
      hoge_table

filters:
  - type: csv_lookup
    mapping_from:
      - hoge_key
    mapping_to:
      - fuga_key
    new_columns:
      - { name: fuga_col_1, type: string }
      - { name: fuga_col_2, type: string }
    path_of_lookup_file: "ref/fuga.csv"

out:
  type: file
  path_prefix: ./out
  file_ext: csv
  formatter:
    type: csv
    header_line: true
    charset: UTF-8

ふりかえり

Embulkを導入し、予定通りにインボイス対応を完了することができました!実際に使ってみて得た知見をまとめます。

とくに良かったこと

config.ymlの取り回しのよさが開発スピードをあげてくれた

Embulkでデータ移送のジョブを6個、CSV発行のジョブを12個担当しましたが、慣れてからは1日1ジョブのペースで開発を進めることができました。Embulkはconfig.ymlにテンプレートにしたがってSQLやプラグインの実行を記述していくだけで、非常に取り回しがよかったのが開発速度を後押ししてくれました。

config.yml.liquidのサポート

Embulkではconfig.ymlへの変数埋め込みのために、Liquidテンプレートをサポートしています*5。たとえばunionプラグインを使ったconfig.ymlの記述例では、DB接続文字列を指定しています。

  type: union
  union:
    - name: product_hoge
      in:
        type: sqlserver
        url: product_hoge_jdbc_url
        user: product_hoge_db_user
        password: product_hoge_db_pwd
        query: |
          SELECT
            col_1,
            col_2,
            col_3
          FROM
            product_hoge_table

しかし、実際にはDB接続文字列はリポジトリ管理すべき情報ではありませんし、構築環境ごとに専用DBに接続したいものです。そのため、Liquidテンプレートを使い、環境変数から以下のように接続文字列を読み込む実装にしました。

  type: union
  union:
    - name: product_hoge
      in:
        type: sqlserver
        url: {{ env.HOGE_JDBC_URL }}
        user: {{ env.HOGE_DB_USER }}
        password: {{ env.HOGE_DB_PWD }}
        query: |
          SELECT
            col_1,
            col_2,
            col_3
          FROM
            product_hoge_table

注意したほうがいいこと

任意のクエリでlookupしたいときは、CSVを一度経由する必要がある

先ほど、複数のデータソースを結合したCSVの生成に csv_lookupプラグインを紹介しました。プラグイン一覧から、lookup 先にDB テーブルを直接参照できる{db}_lookupプラグインが提供されていることにお気づきの方もいるでしょう。

これらの{db}_lookupプラグインは設定ファイルで指定したテーブルとカラムから自動で lookup する仕組みになっていて、任意のクエリ(SELECT 文)は指定できません。*6。そのため、内部表をサブクエリにしたいケースではこのプラグインが利用できないことに注意が必要です。今回は回避策として、内部表の部分をローカルにCSV出力するEmbulkジョブを実行し、出力したCSVに対してcsv_lookupプラグインでlookupすることにしました。

GCSへのCSVアップロードプラグインにはstorage.objects.listが必要

社内向けにCSVを公開するため、Google Cloud Storageへ保存したいという要件がありました。当初はgcsプラグインを利用して、Embulk内部でGCSへのアップロードまで実行しようと考えていましたが、実装してみると次のエラーが発生してしまいました。

Caused by: java.lang.RuntimeException: org.embulk.config.ConfigException: org.embulk.util.retryhelper.RetryGiveupException: com.google.cloud.storage.StorageException: {my_service_account}@{my_project}.iam.gserviceaccount.com does not have storage.objects.list access to the Google Cloud Storage bucket. Permission 'storage.objects.list' denied on resource (or it may not exist).

ライブラリの内部実装を確認したところ、バケットの存在確認のためにObject Listを取得するようになっていたためでした*7。今回は、なるべく最小のPermissionsに絞ったサービスアカウントを利用したかったため、ローカルに出力したCSVをgsutilでアップロードするスクリプトを組みました。

まとめ

ここまで読んでいただきありがとうございました!使ってみて、EmbulkはPluginが豊富でとても強力なツールであることがわかりました!ETLや複数データソースをまたいだCSV生成を行う際には導入を検討してはいかがでしょうか。

小ネタ:Embulkのメンテナンス体制が新しくなったとのこと!(2023年3月)

EmbulkはFluentdの開発者である古橋氏によって2015年に公開されました*8。その後、氏が創設者であるTreasure Data社によって運用や設計の改善が行われてきましたが、2023年3月からは、社に限定せず広くコアチームを結成し設計検討を行っていく方針が発表されました*9。その経緯については、Treasure Data社のTech Talk2022の発表資料にてより詳しくまとめられています。
イベント資料|TreasureData Tech Talk 2022 - TECH PLAY[テックプレイ]

業務としてのOSS開発のアンビバレンスなど、かなり実情に即した部分まで言及されており示唆に富んだ発表資料でした。私はOSS開発の経験はありませんが、事業会社でエンジニアリングを行ううえでビジネス優先度は常に考慮すべき観点ですので、非常に考えさせられました。

あらためて、OSSメンテナの皆様、いつもありがとうございます!

さいごに

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

www.ikyu.co.jp

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

hrmos.co

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日目の記事で解説予定です。