一休.com Developers Blog

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

ダイナミックスコープ再考

一休.com Advent Calendar 2025の25日目の記事です。

一休.com レストランの開発を担当している恩田 @takashi_ondaです。

最近はあまり聞かれることのないダイナミックスコープの話をしてみたいと思います。

はじめに

現代のプログラミング言語ではレキシカルスコープがあまりに当たり前になってしまっていて、ダイナミックスコープという概念自体を聞いたことがない、という人も多いのではないかと思います。

プログラミング言語の歴史を学ぶ際に少し触れられている程度で、実際、手元の『コンピュータプログラミングの概念・技法・モデル』を繙いてみても、900ページ近い大著にもかかわらずダイナミックスコープについての言及は1ページにも満たないほどです。

このようにダイナミックスコープは歴史の中で消えていった概念のように見えます。ですが、用語としては廃れた一方で、今日でも似た仕組み自体が実は再発明されています。使い方に注意は必要ですが、うまくはまると既存コードへの侵襲を最小に抑えながら文脈を伝播させる手段として、今も有効な選択肢だからではないでしょうか。

本稿では、ダイナミックスコープの歴史を振り返りながら、なぜ今も形を変えてその考え方が引き継がれているのか、文脈伝播の観点から見直してみたいと思います。

レキシカルスコープとダイナミックスコープ

まずは定義の確認からはじめたいと思います。

現代のプログラミング言語において、私たちが当たり前のように享受しているのがレキシカルスコープ(静的スコープ)です。

const x = 'Global';

function printX(suffix) {
  const prefix = 'value is '
  console.log(`${prefix}${x}${suffix}`);
}

function withLocalX() {
  const x = 'Local';
  printX('!');
}

withLocalX(); // -> 'value is Global!'
printX('?') // -> 'value is Global?'

ここで、printX に現れる変数に注目して、用語1をふたつ紹介します。

  • 束縛変数(bound variable): 関数の引数(suffix)や内部での宣言(prefix)によって、その場で意味が確定する変数を指します。
  • 自由変数(free variable): 関数の中で宣言も引数定義もされていない変数を指します。この例では x がそれにあたります。

レキシカルスコープとダイナミックスコープの違いは、この自由変数をどう解決するかにあります。

レキシカルスコープのルールはシンプルです。自由変数の意味は関数が定義された場所によって静的に決まる、というものです。上の例では printX が定義された場所の外側にある値 'Global' が参照されます。呼び出し元である withLocalX の内部に同名の変数があっても、それは無視されます。

この性質により、私たちはコードの構造から変数の由来を一意に辿ることができるという恩恵に与っています。

ごくごく自然に感じられると思います。

さて、今回取り上げるダイナミックスコープ(動的スコープ)を見てみましょう。ダイナミックスコープは、自由変数の解決をコード上の位置ではなく、実行時の呼び出しスタックに委ねます。

Perl の local 宣言2を例に見てみましょう。

our $x = "Global";

sub print_x {
    my ($suffix) = @_;
    my $prefix = "value is ";
    print "$prefix$x$suffix\n";
}

sub with_local_x {
    local $x = "Local";
    print_x("!");
}

with_local_x();   # -> "value is Local!"
print_x("?"); # -> "value is Global?"

print_x が呼ばれる際、その自由変数 $x の値は自分を呼び出している実行時のコールスタックの状態で決定されます。with_local_x の中で $x が一時的に変更されているため print_x はその値 "Local" を出力します。そして with_local_x の実行が終われば、その一時的な束縛が解除され $x の値はふたたび "Global" が参照されるようになります。

ダイナミックスコープの歴史

現代の感覚では、ダイナミックスコープは予測不能で不確実なものに見えると思います。では、なぜこのような仕組みが生まれ、利用されてきたのでしょうか。その経緯を振り返ってみたいと思います。

副産物としての誕生

ダイナミックスコープの起源は、1950年代後半の初期の Lisp に遡ります。

初期の Lisp においてダイナミックスコープは、意図的に設計された機能というよりは、素朴な実装の帰結でした。当時のインタプリタにおいて変数の値を解決するもっとも単純な方法は、実行時のシンボルテーブル(A-list と呼ばれる連想リスト)をスタックの根元に向かって順に検索することでした。関数を定義時の環境と一緒に保持するという発想(後にクロージャと呼ばれるもの)はまだなく、この素直な実装が、結果としてダイナミックスコープを生み出しました。

John McCarthy は後に、ダイナミックスコープを、意図した仕様ではなく単なる実装上のバグであり、いずれ修正されるだろうと考えていたと回想しています3

引数バケツリレーの回避策としての受容

しかし、この偶然の挙動は実用上の利便性をもたらしました。

プログラムが複雑化し、関数の呼び出し階層が深くなると、末端の処理で必要になる設定値やフラグを、すべての中間関数に引数として渡し続ける必要が出てきます。いわゆるバケツリレー問題ですね。

ダイナミックスコープを利用すれば、呼び出し元で変数を一時的に束縛するだけで、中間層のコードを一切変更することなく、深い階層にある関数に情報を伝播させることができました。

Scheme によるレキシカルスコープの確立

この状況に変化をもたらしたのが、1970年代に登場した Scheme です。

Gerald Jay Sussman と Guy L. Steele Jr. は、ラムダ計算の理論を忠実に実装する過程で、関数が定義された時点の環境を保持するレキシカルスコープを導入しました。これにより、関数の挙動が呼び出し元に依存するという不確実性が排除され、数学的な一貫性とモジュールとしての独立性が確保されました。

これ以降、プログラミング言語のメインストリームはレキシカルスコープへと収束していき、ダイナミックスコープは扱いの難しいかつての仕組みとして、多くの言語から姿を消していくことになります。

Emacs Lisp における意図的な選択

Scheme がレキシカルスコープによって数学的に整合したモデルを確立していった一方で、Emacs Lisp は長らくダイナミックスコープをデフォルトとして採用し続けました4

当時の計算資源の制約といった実装上の理由もあったようですが、結果としてこの選択は、実行時に振る舞いを拡張・上書き可能なエディタ、というより環境であった Emacs の目指すところと噛み合っていたように思います。

エディタの拡張においては、既存のコマンドやその内部実装に手を入れることなく、ある処理の文脈だけを少し変更したい、という要求が頻繁に現れます。Emacs Lisp では、こうした要求をダイナミックスコープによって自然に満たすことができました。

よく知られている例が、検索時の大文字・小文字の区別を制御する case-fold-search という変数です。この変数を let によって一時的に束縛するだけで、その内部で呼ばれる標準の検索コマンド群の挙動をまとめて変更できます。

(defun my-case-sensitive-search (keyword)
  (let ((case-fold-search nil))
    (search-forward keyword)))

文脈伝播(Context Propagation)

プログラミング言語全体に立ち返れば、前述の通り主流となったのはレキシカルスコープでした。関数の振る舞いが呼び出し元の状態に依存する性質は、大規模化・複雑化するソフトウェア開発において、扱いが難しかったためです。

レキシカルスコープがコードの予測可能性をもたらした一方で、アプリケーション開発には別の課題が残されました。文脈の伝播(Context Propagation)です。

Webアプリケーションを例にとれば、認証情報やトレーシングIDなどの情報は、処理の開始から終了まで、あらゆる階層の関数で参照したくなる横断的な関心事5です。レキシカルスコープでナイーブに実装すると、すべての関数にバケツリレーで渡さなければならず、中間層は不要な責務を負うことになります。

この明示的な記述の煩雑さを避けるため、言語仕様の外側でダイナミックスコープ的な挙動を実現する仕組みが実用化されてきました。Java における ThreadLocal がその代表例です。言語レベルでは静的なスコープによる安全性を選びつつも、ランタイムで暗黙的に文脈を引き継ぐ機構が初期から用意されていました。

ここからしばらく、現代のプログラミング言語で文脈伝播がどう実現されているかを見ていきたいと思います。 各節の細部を追わなくても、明示的に渡すアプローチと暗黙的に伝播させるアプローチがそれぞれ存在する、という雰囲気だけ掴んでもらえれば十分です。

Go の context パッケージ

まずは明示的に文脈を渡す例として Go を見てみます。Go では context.Context を関数の第一引数として渡す規約が確立されており、キャンセル処理やタイムアウト、リクエストスコープの値を伝播させます。

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    ctx := r.Context()
    traceId := generateTraceID()
    ctx = context.WithValue(ctx, traceIdKey, traceId)

    result, err := processOrder(ctx, orderId)
    // ...
}

func processOrder(ctx context.Context, orderId string) (*Order, error) {
    // 中間層も ctx を受け取り、下位に渡す
    return repository.FindOrder(ctx, orderId)
}

func (r *Repository) FindOrder(ctx context.Context, orderId string) (*Order, error) {
    traceId := ctx.Value(traceIdKey).(string)
    r.logger.Info("finding order", "traceId", traceId, "orderId", orderId)
    // ...
}

文脈が引数として明示されるため、関数シグネチャを見ればその関数が文脈を必要とすることが分かります。

しかし、context.WithValue で渡される値については事情が異なります。ctx に何が入っているかはシグネチャからは分からず、実行時に ctx.Value(key) で取り出すまで不明です。つまり、context.Context という引数は明示的に渡されていますが、その中身へのアクセスはキーによる動的な参照になっています。

では、型によってこの暗黙性を解消する方法はあるのでしょうか。

Reader Monad

関数型プログラミングの世界では、この課題に対する手法として Reader Monad が知られています。

Reader Monad の本質は単純です。環境 R を受け取って値 A を返す関数 R => A を、合成可能な形で扱えるようにしたものです。Scala で書いてみましょう。

case class Reader[R, A](run: R => A) {
  def map[B](f: A => B): Reader[R, B] =
    Reader(r => f(run(r)))

  def flatMap[B](f: A => Reader[R, B]): Reader[R, B] =
    Reader(r => f(run(r)).run(r))
}

これで環境に依存する計算を合成可能な形で表現できます。さきほどの例を Reader Monad で実装します。

case class RequestContext(traceId: String)

def findOrder(orderId: String): Reader[RequestContext, Order] =
  Reader { ctx =>
    logger.info(s"finding order: traceId=${ctx.traceId}, orderId=$orderId")
    repository.find(orderId)
  }

def processOrder(orderId: String): Reader[RequestContext, Result] =
  for {
    order  <- findOrder(orderId)
    result <- validateAndProcess(order)
  } yield result

def handleRequest(orderId: String): Reader[RequestContext, Response] =
  for {
    result <- processOrder(orderId)
  } yield Response(result)

// 実行時に環境を注入
val ctx = RequestContext(traceId = "abc-123")
val response = handleRequest("order-789").run(ctx)

関数のシグネチャに注目してください。Reader[RequestContext, Order] という戻り値の型を見るだけで、この関数が RequestContext を必要とすることが分かります。必要な文脈が型レベルで明示されています。

また、for 内包表記により、環境の受け渡しを省略できます。processOrderfindOrder を呼び出していますが、ctx を渡すコードはどこにもありません。Reader の flatMap が環境を伝播してくれるからです。

この手法により、文脈の明示性と記述の簡潔さを両立できます。

Scala の context parameter

このような書き方はよく使われるため、Scala では性質の近い機能が言語レベルでサポートされています。

case class RequestContext(traceId: String)

def findOrder(orderId: String)(using ctx: RequestContext): Order = {
  logger.info(s"finding order: traceId=${ctx.traceId}, orderId=$orderId")
  repository.find(orderId)
}

def processOrder(orderId: String)(using ctx: RequestContext): Result = {
  val order = findOrder(orderId)  // ctx は暗黙的に渡される
  validateAndProcess(order)
}

def handleRequest(orderId: String)(using ctx: RequestContext): Response = {
  val result = processOrder(orderId)  // ctx は暗黙的に渡される
  Response(result)
}

// 呼び出し側で given を定義
given ctx: RequestContext = RequestContext(traceId = "abc-123")
val response = handleRequest("order-789")  // ctx は暗黙的に解決される

using キーワードにより、コンパイラがスコープ内から適切な値を探して自動的に引数を補完します。中間層での明示的な受け渡しが不要でありながら、シグネチャには文脈が明示されています。

これは、レキシカルスコープの型安全性を維持しつつ、ダイナミックスコープが解決していたバケツリレー問題に対処する言語レベルの解答と言えます。

ただし、中間層の関数も (using ctx: RequestContext) をシグネチャに持つ必要があり、文脈の存在自体は伝播経路上のすべての関数に現れます。

ThreadLocal / AsyncLocalStorage

ここまで見てきたのは、いずれも文脈を明示的に表現する手法でした。次に、暗黙的に文脈を伝播させる仕組みを見ていきます。

Java の ThreadLocal は JDK 1.2(1998年)で導入されました。ThreadLocal は、スレッドごとに独立した値を保持する仕組みです。

Webアプリケーションでは、1つのリクエストが1つのスレッドで処理される実行モデルが一般的でした。このモデルにおいて、リクエストスコープの情報(認証情報やトランザクションなど)を、引数で渡すことなく処理の流れ全体で共有する用途で ThreadLocal は広く使われてきました。

先ほどと同じ例を Java で書いてみましょう。

public class RequestContext {
    private static final ThreadLocal<RequestContext> current = new ThreadLocal<>();

    public final String traceId;

    public RequestContext(String traceId) {
        this.traceId = traceId;
    }

    public static RequestContext current() {
        return current.get();
    }

    public static <T> T runWith(RequestContext ctx, Supplier<T> block) {
        RequestContext previous = current.get();
        current.set(ctx);
        try {
            return block.get();
        } finally {
            current.set(previous);
        }
    }
}
public Order findOrder(String orderId) {
    var ctx = RequestContext.current();
    logger.info("finding order: traceId=" + ctx.traceId + ", orderId=" + orderId);
    return repository.find(orderId);
}

public Result processOrder(String orderId) {
    var order = findOrder(orderId);
    return validateAndProcess(order);
}

public Response handleRequest(String orderId) {
    var result = processOrder(orderId);
    return new Response(result);
}

// エントリーポイント
var ctx = new RequestContext("abc-123");
var response = RequestContext.runWith(ctx, () -> handleRequest("order-789"));

findOrderprocessOrder も引数に文脈を持っていません。RequestContext.current() を呼び出すだけで、呼び出し元で設定された値を取得できます。そして runWith のブロックを抜ければ、以前の値に戻ります。Perl の local が実現していた振る舞いと同じですね。

現在では非同期・並行処理が一般的になり、それらに対応した Java の ScopedValue(JDK 21〜、プレビュー)や、Node.js の AsyncLocalStorage が同様の機能を提供しています。これらは値のネストと復元が API に組み込まれており、ダイナミックスコープがコールスタックを遡って値を解決する仕組みにより近いものになっています。

React Context

ここで少し視点を変えて、フロントエンドに目を向けてみましょう。

関数呼び出しの連鎖がコールスタックを形成するように、React ではコンポーネントの親子関係がツリー構造を形成します。そしてここでも、同じバケツリレー問題が現れます。

React では、親から子へデータを渡す際に props を使います。しかし、深くネストしたコンポーネントに値を届けるには、途中のすべてのコンポーネントが props を受け取って下に渡す必要があります。いわゆる props drilling です。

中間層のコンポーネントが自身では使わない props に依存することは、コンポーネントの再利用性を損ない、不要な再レンダリングの原因にもなります。React Context を使えば、Context で囲んだ範囲内のどの深さのコンポーネントからでも、中間層を経由せずに値を取得できます。

const ThemeContext = createContext<'light' | 'dark'>('light');

function App() {
  const theme = localStorage.getItem('theme') ?? 'light';
  return (
    <ThemeContext value={theme}>
      <Header />
      <Main />
      <Footer />
    </ThemeContext>
  );
}

function Main() {
  // Main は theme を知らない
  return <Sidebar />;
}

function Sidebar() {
  const theme = use(ThemeContext);  // 中間層を飛び越えて取得
  return <div className={theme}>...</div>;
}

Context のネストによって値を上書きでき、そのスコープを抜ければ外側の値に戻る。コンポーネントツリーという軸は異なりますが、これもダイナミックスコープの再発見と言えそうです。

侵襲を抑える

ここまで見てきた通り、文脈伝播には万能の解決策がありません。

明示的に引数で渡せば、依存関係は明確になりコードの追跡も容易です。しかし、中間層が自身では使わない引数を知らなければならないという問題が残ります。暗黙的な伝播を使えば中間層の負担は消えますが、今度は依存関係が見えにくくなります。

このトレードオフに対して、別の軸から考えてみたいと思います。既存コードへの侵襲を抑える、という制約を置いた場合、ダイナミックスコープ的な振る舞いはどのように評価できるでしょうか。

現実のコードベースは往々にして理想通りにはなっていません。テストや文脈伝播の仕組みは必要だとわかっていても、スケジュールや優先度の都合で後回しにしたまま、コードが蓄積されてしまうことは起こりがちです。そこに手を入れるとき、引数で明示的に渡したり Reader Monad を導入するのが正攻法ですが、中間層をすべて修正するコストが見合わないこともあります。

以下では、ダイナミックスコープ的な仕組みの利用が、妥協ではあっても有効な選択肢になった例を具体的に見ていきます。いずれも呼び出し元の文脈に応じて値を差し替えたいという要求であり、これはまさにダイナミックスコープが解決していた問題です。実際に遭遇したケースを簡素化して紹介します。

あとからテストダブル

たとえば、外部 API を直接呼び出している関数があり、テストを書きたいとします。理想的には依存性注入で差し替えられる設計になっているべきですが、現実にはそうなっていないコードも多いでしょう。

このとき、API クライアントを AsyncLocalStorage 経由で参照するように変更すれば、テスト時だけテストダブルを差し込むことができます。中間層の関数シグネチャを変更する必要はありません。

具体例を見てみましょう。

// 本番用の取得関数をデフォルト値として設定
const fetchCategoriesContext = new AsyncLocalStorage<
  (ids: CategoryId[]) => Promise<Category[]>
>({ defaultValue: defaultFetchCategories })

function getFetchCategories() {
  const fetchCategories = fetchCategoriesContext.getStore()
  if (!fetchCategories) {
    throw new Error('unreachable: defaultValue is set')
  }
  return fetchCategories
}

この getFetchCategories を利用してカテゴリを取得する関数を定義します。

export async function getCategory(id: CategoryId): Promise<Category | undefined> {
  const fetchCategories = getFetchCategories()
  const categories = await fetchCategories([id])
  return categories[0]
}

テスト時にはテストダブルを差し込む関数を用意します。

/**
 * テスト時に fetchCategories を差し替えて実行する
 */
export async function withTestFetchCategories<T>(
  fetchCategories: (ids: CategoryId[]) => Promise<Category[]>,
  body: () => T | Promise<T>
): Promise<T> {
  return fetchCategoriesContext.run(fetchCategories, body)
}

テストコードでは、withTestFetchCategories のスコープ内でテスト対象を呼び出します。getCategory を利用しているコードも、同じスコープ内で実行すればテストダブルが注入されます。

test('カテゴリを取得できる', async () => {
  const stubFetch = async (ids: CategoryId[]) => [
    { id: ids[0], name: 'テストカテゴリ' }
  ]

  await withTestFetchCategories(stubFetch, async () => {
    const result = await getCategory('cat-1')
    expect(result?.name).toBe('テストカテゴリ')
  })
})

あとからキャッシュ

先ほどのカテゴリデータの例の続きです。ひとつのリクエストを処理する中で getCategory が何度も呼ばれていることがわかりました。毎回バックエンドから取得せずに済むようにキャッシュを導入しましょう。

DataLoader を使えばキャッシュできますが、グローバルにキャッシュすると更新の反映やメモリ管理が複雑になります。そこでリクエスト単位でインスタンスを作ることにしました。具体的には、DataLoader をリクエストスコープで保持するために AsyncLocalStorage をもうひとつ追加します。

type CategoryDataLoader = DataLoader<CategoryId, Category | undefined>
const categoryDataLoaderContext = new AsyncLocalStorage<CategoryDataLoader>()

/** リクエスト単位で DataLoader を保持する */
export async function withCategoryDataLoader<T>(
  request: Request,
  body: () => T | Promise<T>
): Promise<T> {
  const loader = createCategoryDataLoader(request)
  return categoryDataLoaderContext.run(loader, body)
}

function createCategoryDataLoader(request: Request): CategoryDataLoader {
  const fetchCategories = getFetchCategories()
  return new DataLoader<CategoryId, Category | undefined>(
    async (ids) => fetchCategories(ids, request),
    { cache: true }
  )
}

getCategory は DataLoader 経由で取得するように書き換えます。

function getCategoryDataLoader(): CategoryDataLoader {
  const loader = categoryDataLoaderContext.getStore()
  if (!loader) {
    throw new Error('No categoryDataLoader in context')
  }
  return loader
}

// 利用側は DataLoader の存在を意識しない
export async function getCategory(id: CategoryId): Promise<Category | undefined> {
  return getCategoryDataLoader().load(id)
}

リクエストを受けるエントリーポイントで withCategoryDataLoader を適用します。

export async function loader({ request }: Route.LoaderArgs) {
  return await withCategoryDataLoader(request, async () => {
    // この中で getCategory を呼び出す処理
  })
}

これで、中間層の関数が DataLoader を引き回す必要はなく、リクエスト単位のキャッシュが有効になります。

あとから文脈伝播

実は上の例では、キャッシュだけでなく文脈伝播も実現しています。fetchCategories の実装を見てみましょう。

async function defaultFetchCategories(
  ids: readonly CategoryId[],
  request: Request
): Promise<(Category | undefined)[]> {
  const response = await fetch(BACKEND_API, {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'X-Forwarded-For': request.headers.get('X-Forwarded-For') ?? '',
      'Cookie': request.headers.get('Cookie') ?? '',
    },
    body: JSON.stringify({ ids }),
  })
  return response.json()
}

withCategoryDataLoader に渡された request が DataLoader の生成時にキャプチャされ、バックエンドへのリクエスト時に cookie や X-Forwarded-For ヘッダを引き継いでいます。getCategory を呼び出す側は、この伝播の仕組みを意識する必要がありません。

必要になったときに、コードの変更を最小に保ちながら、段階的に導入できる点がこのアプローチの利点です。

一方で、エントリーポイントで withCategoryDataLoader の適用を忘れると実行時エラーになる、という脆さがあります。依存関係が型に現れないため、コンパイル時には検出できません。これはダイナミックスコープ的な仕組みに共通する課題であり、トレードオフとしての慎重な検討が必要です。

おわりに

React Context を説明していたときに、ダイナミックスコープの話をしたことがありました。それがこの記事のきっかけです。

歴史をたどりながら今の技術の位置づけを見直してみるのも、ときにはおもしろいものです。本稿からその一端でも感じていただければ幸いです。


一休では、技術を深く理解しながら、よりよいシステムをともに作っていくエンジニアを募集しています。

www.ikyu.co.jp

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

job.persona-ats.com


  1. ラムダ計算においては、ラムダ抽象 λx. M の本体 M に現れる変数のうち、λx によって束縛されているものを束縛変数、それ以外を自由変数と呼びます。
  2. 古い Lisp の例を考えていたのですが Perl でも local で書けることを同僚が教えてくれました。
  3. John McCarthy, History of Lisp (1978). "In modern terminology, lexical scoping was wanted, and dynamic scoping was obtained. I must confess that I regarded this difficulty as just a bug and expressed confidence that Steve Russell would soon fix it."
  4. 現在の Emacs Lisp ではレキシカルスコープを選択することが可能です。
  5. 横断的関心事(cross-cutting concern)といえば2000年代に注目された AOP(Aspect Oriented Programming, アスペクト指向プログラミング)ですが、その主要なユースケースのひとつに文脈伝播の自動化がありました。ログ出力やトランザクションのコンテキストなどは、ThreadLocal 等の操作を裏側で隠蔽する典型的な例でした。

2025年、一休のコーポレートIT 振り返りとこれから

はじめに

id:rotom です。社内情報システム部 兼 CISO室 所属で ITとセキュリティを何でもやります。

このエントリは 一休.com Advent Calendar 2025 12日目の記事です。 少し遅れての投稿になってしまいましたが、昨日は id:kentana20 による 一休.com 宿泊の料金・ポイント計算処理の改善 - 一休.com Developers Blog でした。その他の素敵なエントリも以下のリンクからご覧ください。

qiita.com

昨年のアドベントカレンダーでは、情シスにおける6年間の取り組みについてご紹介させていただき、一定の反響をいただくことができました。

user-first.ikyu.co.jp

今年はその続きとして、これまでに取り組んできた課題の一部と、今後注力していくテーマについてご紹介します。

取り組んできたこと

SaaS への AI インクルード対応

Gemini for Google Workspace / NotebookLM

workspace.google.com

今年の大きなトピックのひとつが、Gemini for Google Workspace が全ユーザー向けに提供開始されたことでした。 これまでは ChatGPT Plus を利用する際、希望者に専用のバクラクビジネスカードを払い出す形で運用し、その後 ChatGPT Team の登場にあわせてプラン移行を行っていました。

現在も ChatGPT を継続利用しているメンバーはいますが、Gemini でも問題ない場合は 申請や承認なしで誰でも利用可能 になり、利用のハードルが大きく下がりました。

workspace.google.com

あわせて NotebookLM も利用可能となり、新入社員向けのマニュアルや各種規程をまとめて取り込んだり、研修ではドキュメントを読む代わりに Podcast 形式で音声を聞いてもらうなど、さまざまな活用が進んでいます。

なお、一休では Google Workspace の Enterprise Plus プランを契約しているため、これらの機能をフルに利用できます。

Slack AI

slack.com

今年は Slack も AI 機能が組み込まれ、プラン構成が変更されました。 一休では Enterprise Grid を利用していますが、このプランは将来的に廃止予定となり、Enterprise+ への移行が必要になります。

現時点では、コストや実運用での有用性を踏まえ、移行は見送り、引き続き Enterprise Grid を利用しています。 現在のプランでも、チャンネルやスレッドの要約、メッセージ要約、ハドルミーティングの議事録作成などの AI 機能は利用可能です。

来年には Enterprise Grid の販売終了が予定されているため、そのタイミングで Enterprise+ へ移行し、Slack AI も本格的に活用していく予定です。

Atlassian Rovo

Atlassian 製品にも AI エージェント機能が追加されました。現時点では Slack Enterprise+ や Gemini Enterprise(旧 Google Agentspace)を契約していないため、社内で利用できる 横断検索ツール として Rovo を活用しています。

www.atlassian.com

Confluence、Jira、Jira Service Management、Trello といった Atlassian 製品に加え、Google Drive、Slack、Figma などとも連携でき、Confluence の検索欄から複数の SaaS を横断的に検索できるようになりました。

検索は自身のアクセス権限の範囲内に限定されるため、必要以上の情報が表示される心配はありません。

これらの SaaS における AI 機能は、禁止ではなく活用を前提 に整備しており、リリース直後から利用可能とし、全社向けにアナウンスしています。

コールセンターでも安全に使える翻訳 Bot

国内宿泊予約サイトである 一休.com では、インバウンド需要の高まりを受け、今年から多言語対応を進めています。詳細については、以下のエントリもご覧ください。

user-first.ikyu.co.jp

Web サービス側では ChatGPT を利用した自動翻訳により、インバウンドユーザーが希望する言語で宿を探せるようになりました。

一方で、一休は長らく国内向けサービスを提供してきたため、カスタマーサポートを担当するコールセンターでは多言語対応を行っていません。

一般の従業員であれば Google 翻訳や ChatGPT、Gemini などを利用できますが、コールセンターのネットワークは個人情報保護の観点から厳しく制御されており、自由な Web アクセスや LLM の利用ができない環境です。

そこで、Web アクセス不要で利用できる翻訳用の Slack ワークフローを作成しました。翻訳は API 経由で実行され、入力された内容が AI の学習に利用されない設定としています。

Slack ワークフローから ChatGPT を API 経由で呼び出し、指定した言語に翻訳するシンプルな仕組みですが、そのまま翻訳すると実際の利用シーンに合わないケースがありました。

例えば 「温泉」 “Hot Spring” と翻訳されがちですが、箱根への旅行を検討しているインバウンドユーザーの多くは “hakone onsen” と検索しています。

こうした点を踏まえ、「温泉」→「Onsen」 のように、文脈に合った表現になるようカスタムプロンプトを設定しました。

日々のカスタマーサポート業務で便利に活用いただいております。

VPN の廃止

昨年のブログでは、PoC の結果として SASE の導入を見送り、代替手段の検証を進めると記載しましたが、今年 SASE を導入 しました。 製品は Gartner Magic Quadrant for SASE でも Leader ポジションに位置する Netskope を選定しています。

www.netskope.com

cloudnative.co.jp

昨年の PoC 時点では、通信品質や開発環境への影響から導入を見送りましたが、これは Private Access 機能 に起因する課題でした。

課題が解消された場合には Private Access の再検討も視野に入れていますが、現時点では CASB(Cloud Access Security Broker)SWG(Secure Web Gateway) の機能を中心に、情報漏えい防止の観点で活用しています。

Private Access を利用しない場合、VPN の完全な代替とはならないため、従来型 VPN との併用が必要になります。 従来型 VPN はインターネットに公開された IP アドレスや FQDN が必要となり、攻撃対象となるリスクがある点が課題でした。

この課題については、Tailscale という P2P 型 VPN サービスを導入することで解消し、従来型 VPN を廃止することができました

Tailscale の詳細は、アドベントカレンダー 6 日目の以下の記事をご覧ください。

tailscale.com

qiita.com

これからどうするか

エンタープライズ検索

cloud.google.com

冒頭でも触れたエンタープライズ検索については、今後さらに活用範囲を広げていく予定です。 リブランディングされた Gemini Enterprise のトライアルを実施し、営業部門やコーポレート部門とも連携しつつ、全社的な検索体験の向上に取り組んでいきます。

人事マスタの統合

人事マスタが不在、または複数存在する状態は、多くの企業で共通する課題だと考えています。 一休でも、情シスで管理する Microsoft Entra ID、人事で管理する カオナビ や SmartHR、経理で管理するバクラクなど、複数の SaaS がマスタとして参照されている状況があります。加えて、長年運用されているスプレッドシートが残っているなど、人事関連情報が点在している状態です。

この状態では、入退社や部署異動のたびにメンテナンス工数が発生し、入力漏れなどのオペレーションミスにつながる可能性もあります。 そこで、SSOT(Single Source of Truth:信頼できる唯一の情報源)となる統合された人事マスタを用意し、管理の一元化を進めたいと考えています。あわせて、各 SaaS へのアカウント連携や更新処理についても自動化を推進していく方針です。

複数製品を比較・検討した結果、この領域では YESOD が最も要件に合うと判断し、すでに検証を進めています。

yesod.co

まとめ

今年は社会全体でも多くのセキュリティインシデントが発生し、一休の情シスとしても「守り」を重視した一年となりました。 セキュリティ対策の性質上、社外に公開できない取り組みが多い点は少し残念ですが、来年は「攻め」の側面も含め、より多くの事例をご紹介できるよう取り組んでいきます。

そのためには仲間が必要です!

数年ぶりに、情シスのポジションで採用をオープンしました。

未経験から挑戦できるジュニア枠については、想定を超えるご応募をいただき、現在は募集を終了しています。

現在は、社内インフラ・ネットワークをリードしていただくポジションを募集しています。 組織としては成熟フェーズにありますが、Netskope や Tailscale など新しい製品・技術も積極的に取り入れ、モダン化を進めています。

【正社員】コーポレートエンジニア(社内インフラ・ネットワーク担当) - 株式会社一休

少しでもご興味があれば、ぜひカジュアル面談からでもお気軽にご応募ください!

愛すべき Image API - 前世紀の技を現代で

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

一休.com レストランの開発を担当している恩田 @takashi_onda です。

はじめに

今からご紹介するのは、フロントエンドカンファレンス東京 2025 でお話しようとしていた内容です。直前にコロナに感染してしまい、残念ながら登壇は泣く泣くキャンセルになったのですが、その際にブログであらためてご紹介すると言いながらこの時期となってしまいました。

Image API の特徴

ブラウザの Image API には面白い特徴があります。JavaScript で動的にインスタンスが作られて src がセットされたと同時にロードを開始しはじめるのです。

Image の即時ロード

他の外部リソースを読みこむ要素である HTMLScriptElement と比較してみると、その振舞いの違いがわかりやすいでしょう。

script

script 要素がロードを開始するのは、動的に生成された要素が DOM ツリーに追加された時点です。どちらの例もリソースは外部オリジンからで、取得元の性質に違いはありません。お手元のブラウザで Developer Tool を開いて、挙動の違いをご確認いただければと思います。

加えるならば、Image というコンストラクタを持っているというのも特徴ですね。他の要素1 は、JavaScript で動的に生成するには Document: createElement() という API を利用します。

React や Vue をはじめとする現代の Web フロントエンド開発では、直接 DOM 要素を生成するような場面はほとんどなく、読者の中には馴染みのない方も多いのではないかと思います。

さて、外部リソースを操作するという観点では同じようにみえる HTMLImageElementHTMLScriptElement が、なぜこのような振舞いの違いを持つのでしょうか?

最古の API

端的に言えば、歴史的経緯、で片付けられてしまうわけですが、少しばかり昔語りにお付き合いください。

Image が JavaScript から操作できるようになったのは Netscape Navigator 3.0 に搭載された JavaScript 1.1 からで、そのリリースは 1996年なので文字通り最古の API と呼んでよいと思います。

Internet Explorer 3.0 にも同様の機能が実装され、JavaScript を使う Web サイトも少しずつ登場しはじめました。実際的なユースケースとしては、フォームの送信前に入力値をチェックして window.alert で表示するクライアントサイドバリデーションが挙げられます。

現代に地続きの、本格的なフロントエンド開発に利用できる機能は Internet Explorer 4.0 で登場しました。そう、DOM と CSS です。そして、実際のプロダクトで利用しても大丈夫そうという機運が高まったのは、そのシェアが支配的になった 2000〜2001 年頃だと記憶しています。

閑話休題。

画像に話を戻すと、Image API はどんな場面で利用されていたのでしょうか?

大きく普及したテクニックに画像のロールオーバーがありました。特にボタン画像でよく使われていて、マウスカーソルをあわせるとボタンが浮き上がるような効果を実現していました。CSS がまだ存在せず、見出しやボタンなど装飾したい画面要素には画像を用いるしかありませんでした。

この画像ロールオーバーは、 Image のインスタンスを生成したときに即座に画像をロードしはじめる挙動の効果が、顕著に発揮されるユースケースでもありました。

この頃のインターネット接続にはモデムが使われていて、その速度は 28.8kbps がほとんど、最速の機種でも 33.6kbps という時代2でした。ボタンのような小さな画像であってもロードを待つ必要がありました。

ですが Image API によって画像が先読みされると、ボタンにマウスカーソルをあわせたとき、シームレスに画像が切り替わる UX が提供できていたのです。

当時の書き方を思い出しながら再現すると、以下のようなコードで画像のロールオーバーを実現していました。

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 3.2 Final//EN">
<HTML>
<HEAD>
<TITLE>Image Rollover Sample</TITLE>

<SCRIPT LANGUAGE="JavaScript">
<!--
// 画像をプリロード
btn_on  = new Image();
btn_off = new Image();

btn_on.src  = "button_on.gif";
btn_off.src = "button_off.gif";
// -->
</SCRIPT>
</HEAD>

<BODY BGCOLOR="#FFFFFF">

<!-- 画像のロードが終わっていれば即座に切り替わる -->
<A HREF="next.html"
   onMouseOver="document.btn.src=btn_on.src"
   onMouseOut="document.btn.src=btn_off.src">
  <IMG SRC="button_off.gif"
       NAME="btn"
       BORDER="0">
</A>

</BODY>
</HTML>

現代のアレンジ

ここで話が終わってしまえば、インターネット老人会の思い出話に過ぎません。

ですが、上記でご紹介した事前ロードのテクニックは今でも有効です。実際に私が開発を手がけている一休.com レストランでも現役で活躍しています。

Image API の効果

具体的には上記の効果を実現するために Image API を利用しています。

もちろん、実装にも現代的な味付けを加えています。

回線状態が悪い状況を想定しているので、ハイドレーション前の SSR された HTML だけで機能する必要があります。React や Vue のようなフレームワークに制御が渡る前、それも DOM が逐次的に組み立てられていく中で動作するのが理想です。

完全な画像がロードされる前に表示させておく極小のプレースホルダー画像は、Image CDN を利用して動的にオリジナル画像から生成しています。

では、具体的に実装を見ていきましょう。

<img
  src="image.jpg?auto=compress,format&lossless=0&fit=crop&w=3&h=3"
  data-full-src="image.jpg?auto=compress,format&lossless=0&fit=crop&w=176&h=176"
/>

SSR 時には、3x3 サイズで雰囲気だけが伝わる最小の画像を Image CDN で動的に生成しています。サイズは 300 byte 程度なので、回線状態がよくないときでもほとんど待つことなく表示されます。

画像のプリロード処理と置き換えは、<head> 内にインラインで埋め込んだ、 MutationOveserver を使ったミニマムな JavaScript で実現しています。

<script>
  (function () {
    function replace(img) {
      const fullSrc = img.dataset.fullSrc;
      if (!fullSrc) {
        return;
      }
      if (img.src === fullSrc) {
        return;
      }

      const full = new Image();
      full.src = fullSrc;
      full.onload = function () {
        img.src = fullSrc;
      };
    }

    const observer = new MutationObserver((mutations) => {
      mutations.forEach((mr) => {
        if (mr.type === 'childList') {
          mr.addedNodes.forEach((node) => {
            if (node.nodeType === 1 && node.tagName === 'IMG') {
              replace(node);
            }
          });
        }
      });
    });

    observer.observe(document.querySelector('body'), {
      childList: true,
      attributes: false,
      characterData: false,
      subtree: true,
    });

    globalThis.initialImageObserver = observer;
  })();
</script>

ストリームで届く HTML がブラウザによって逐次的にパーズされ、順番に DOM が構築されていく過程を MutationObserver で監視します。

新しい img ノードがあらわれると、new Image でプリロード用の HTMLImageElement インスタンスを生成します。src には最終的に表示したいフルサイズ画像の URL を data 属性から取得してセットします。

最初に紹介したように、このタイミングで画像のロードを即座に開始します。動的に生成した HTMLImageElement は DOM に追加しないので、表示にはなんの影響も与えません。

画像のロードが完了した段階で、その load イベントで元の img ノードの src を差し替えて、プレースホルダー画像からフルサイズ画像になめらかに表示を切り替えています。

この MutationObserver による対応はハイドレーションが完了するまでのもので、それ以後は React コンポーネントが同等の処理を行うように作っており、

  useEffect(() => {
    // @ts-ignore
    const observer = globalThis.initialImageObserver
    if (observer) {
      observer.disconnect()
      // @ts-ignore
      delete globalThis.initialImageObserver
    }
  }, [])

React に制御が渡った段階で observer を終了させています。

おわりに

ここまで読んでいただきありがとうございました。

思い出話を交えながら、最古の Image API がその特徴を活かしながら、現代でもままある回線状態が悪い場面でもユーザー体験を改善している事例をご紹介しました。

今日の視点で Web を構成する技術をみたとき、歪に見えることもあると思います。ですが、過去の制約や工夫の積み重ねが、今もなお、私たちに思わぬヒントを与えてくれるのが Web の面白いところだと感じています。


一休では、ユーザーにより良い体験をともに届けるエンジニアを募集しています。

www.ikyu.co.jp

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

job.persona-ats.com


  1. 厳密には、他にも HTMLOptionElement がコンストラクタ Option を持っています。
  2. 最速のサイトとよくネタにされる阿部寛さんのホームページですが、この頃の一般的なつくりのまま残っている貴重なサイトで、Developer Tool でネットワーク速度を制限して表示すると、当時の雰囲気が体感できます。

予約処理で結果整合を実現するための実装パターン

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

宿泊開発チームでエンジニアをしている @kosuke1012 です。

本記事では、予約処理の中で必要な在庫引当、カード決済などの各処理について、予約処理全体として成功/失敗の結果整合を実現するための実装パターンを紹介します。

背景

現在、一休.com の宿泊予約のシステムでは、予約部分のリニューアルを進めています。

予約リニューアルプロジェクトの全体感もどこかで是非説明したいのですが、アドベントカレンダーの期日も迫ってきているため、 リニューアルの中で取り組んだ、予約処理の結果整合を実現するための実装について書いてみたいと思います。

用語

この記事内での用語の定義をしておきます。

この記事の中で「トランザクション」と言った際には、予約処理全体を指すことにしたいと思います。

また、「カード決済」「在庫引当」と言った個々の処理は「ローカルトランザクション」という言葉で表現したいと思います。

またこの記事では「ロールバック」という言葉を、DBトランザクションのロールバックに限らず、ローカルトランザクションを補償トランザクションにより論理的にロールバックすることも指して使いたいと思います。

「補償トランザクション」はロールバックを実現するための手段として利用します。

要件

宿泊予約トランザクションの中で発行される主なローカルトランザクションは以下の通りです。

  • 在庫引当
  • カード決済
  • 一休ポイント登録
  • サイトコントローラーへの通知
  • ユーザーへのメール通知

これに加えて、予約データの永続化があります。 省略したものもありますが、少なくともこのようなローカルトランザクションを、予約全体として結果整合させる必要があります。

Saga パターン

複数のローカルトランザクションを結果整合させるためのパターンとして有名なものに Saga パターン (詳しくは learn.microsoft.com の記事microservices.io の記事 参照) があります。

自分の理解で簡単に説明すると、補償トランザクションを利用してローカルトランザクションをロールバックすること、そしてそのローカルトランザクションの実行/ロールバックを全体で結果整合させるための設計パターンのことです。 今回我々も、この Saga パターンを利用しました。 と言っても、Saga パターンにはいくつか種類があります。

たとえば、前述の記事にあるようなコレオグラフィパターン(ローカルトランザクション同士が相互に協調しあって全体をコントロールする)、 オーケストレーションパターン(中央集権的なオーケストレータが全体のローカルトランザクションの実行/ロールバックをコントロールする) といった分類があります。

更に詳しく、各ローカルトランザクションの通信の同期/非同期、整合性が結果整合かアトミックか、を加えた分類もあります。(参考: ソフトウェアアーキテクチャ・ハードパーツ 表2-1) 1

しかし一方で、(自分が調べた限り) その具体的な実装に踏み込んだ説明は多くありませんでした。

したがってこの記事では、具体的なパターンを網羅的に説明したり、パターンの中で何に該当するのかと言った体系的な説明というよりは、 実際自分たちがどのような実装をしているのかというところを説明してみたいと思います。

リニューアルの実際

今回、予約リニューアルに伴いドメインモデルを捉えなおし、合わせて技術的な詳細についても見直せる部分は見直してきました。

しかし、今回紹介する実装パターンについても、既存のシステムで大きな問題なくここまで運用されてきたものであるため、 抜本的に設計しなおした、というものではありません。

既存のシステムをあらためて解釈し、整理できる部分は整理していき、改善できる部分は改善したところ、このような形に落ち着いた、というのが実際のところです。

ピボットトランザクションの決定とローカルトランザクションの分類

トランザクションの成否を決定するローカルトランザクションのことを、「ピボットトランザクション」と呼びます。(参考: マイクロサービスパターン 4.3.2)

ピボットトランザクションが失敗した場合、そのトランザクション全体も「失敗」として扱われます。 その場合、ピボットトランザクション以前に実行したローカルトランザクションも「失敗」として扱う必要があります。

これを決定し、各ローカルトランザクションはピボットトランザクションよりも前に実行されるのか、後に実行されるのかを明確にすることで、全体の設計が見通しやすくなります。 我々の場合はピボットトランザクションは「予約データの永続化」と捉えました。

そして、ローカルトランザクションをピボットトランザクションの前後に並べてみると以下の図のようになります。

たとえばカード決済や在庫引当は、それが失敗したら予約も失敗として欲しい、 ユーザーへの予約通知メール送信やサイトコントローラーへの予約通知については、そもそも予約が失敗していたら実行して欲しくない、と言った性格のものになります。

ピボットトランザクションよりも前に実行するローカルトランザクションは予約の成否に応じて補償トランザクションでロールバックし、ピボットトランザクションよりも後に実行するローカルトランザクションは、 ピボットトランザクションが成功している以上は最終的に成功として扱いたいものになります。

後者の「最終的に成功として扱いたい」を実現するパターンとしては、Transactional Outbox パターン などがあります。 この outbox はいわゆるメールの「送信トレイ」を意味していて 2 送信時には outbox のみを作っておいて、outbox をもとにしてリトライするなどで最終的に送信されることを目指す、というものです。

自分たちも、サイトコントローラーへの送信などはこの Transactional Outbox パターンを利用していています。具体的にはピボットトランザクションとなる予約データの永続化のトランザクションの中で、 サイトコントローラー用の outbox のデータを作成しています。 (それを意図して実装したというよりは、実装されているものを解釈すると Transactional Outbox パターンになっていた、という方が正確かもしれません)

そのほか、どうしようもないものは人手での運用にまわしているものもあったりします。

補償トランザクションの実装パターンと「補償ログ」の導入

トランザクションが「失敗」として定義された場合、実行されたローカルトランザクションに対し補償トランザクションを実行していくことになります。 この際、「ローカルトランザクションが実行済みである」ということを把握する必要が出てくると思います。

そのために、実際のローカルトランザクションを実行する前に、「補償ログ」というデータを登録します。 ※「補償ログ」というのは一般用語ではなく造語です。概念としては、データベースの UNDO ログに近いかもしれないです。

ピボットトランザクションが成功した場合

ピボットトランザクションが失敗した場合

たとえば、ローカルトランザクションが成功した後に、ピボットトランザクションが失敗したケースを考えます。 この場合、補償ログがあればそれに対応する補償トランザクションを実行する、ということになります。

以降、補償ログ・補償トランザクションを実装する際に重要なポイントをあげていきます。

1. 補償ログはローカルトランザクションの実行前に登録する

前述の通り、補償ログはローカルトランザクションの実行"前"に登録する必要があります。 仮にローカルトランザクションの実行の後に補償ログを登録する、という実装にしていた場合、 ローカルトランザクションの実行には成功したが、補償ログの登録には失敗した、というシチュエーションを考える必要が出てきてしまいます。これは基本的にローカルトランザクションのロールバックが不可能になってしまうはずです。

したがって、ローカルトランザクションの実行"前"である必要があるのです。 UNDO ログを例に出しましたが、実行"前"に登録する必要があるというのも、 データベースの Write-Ahead Logging (WAL) に似た考え方かなと思います。

また、補償ログの登録に失敗した場合、そのローカルトランザクションは実行せず、「失敗」として扱う必要があります。 (その後にロールバックできなくなるため)

2. ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要がある

補償ログには、補償トランザクション実行に必要な ID などの情報を登録しておきます。 したがって、ローカルトランザクション実行前に補償ログを登録するということは、 ローカルトランザクション実行前に補償トランザクション実行に必要な情報がそろっている必要があるということになります。

例えば、ローカルトランザクションの実行結果としてあるリソースの ID が手に入り、その ID が補償トランザクションのリクエストパラメータとして 要求されるような API では、この要件を満たすことが出来ません。

なお補償ログには補償トランザクション実行に必要十分な ID などの保存にとどめ、逆に個人情報等は保存しないようにします。

3. 補償ログはピボットトランザクションの成功後に削除する

補償トランザクションを実行する場合、補償ログは補償トランザクションの実行後に削除する必要があります。 補償ログの登録の話と同じく、仮に補償ログを削除してから補償トランザクションを実行するようにした場合、 補償トランザクション実行に失敗した場合に、補償トランザクションを再度実行できなくなってしまいます。

またさらに、補償ログの削除はピボットトランザクションの成功後に削除する必要があります。 ピボットトランザクションがトランザクション全体の成否を決定するため、ピボットトランザクションが成功するまでは、 ローカルトランザクションをロールバックする必要がある可能性があるためです。 したがって、ピボットトランザクションが成功するまでは補償ログを削除することは出来ません。

4. 補償トランザクションは冪等にする

補償トランザクションは冪等である必要があります。3 これは、

  • 補償トランザクション実行に失敗した場合
  • 補償トランザクションに成功した後、補償ログの削除に失敗した場合

などで、再度補償トランザクションが実行されうる状態になるためです。

ピボットトランザクションと「ピボットマーカー」の導入

ここまでで、ローカルトランザクションの補償ログと、それを利用した補償トランザクションの実行のための実装パターンを説明しました。 ピボットトランザクションと、ローカルトランザクションを関係づけることで、トランザクション全体の結果整合性を実現することが出来るようになります。

まず、ピボットトランザクションに対しても、ローカルトランザクションと同様、それが進行中であることを示す必要があります。 ピボットトランザクションに対する補償トランザクションは存在しないため、補償ログではなく「ピボットマーカー」と呼ぶことにします。 ※この「ピボットマーカー」も一般用語ではなく今回導入した造語です。

このピボットマーカーを、ローカルトランザクションの開始前にまず作成し、そして、ピボットトランザクションとアトミックに削除することで、 全体としての結果整合性が実現できることになります。

イメージは以下の通りです。

重要な点は以下になります。

1. ピボットマーカーはピボットトランザクションとアトミックに削除する

トランザクション全体で結果整合性を担保する上で、これが最も重要です。 ピボットマーカーは、ピボットトランザクションとアトミックに削除する必要があります。

これにより、

  • ピボットマーカーが存在している=ピボットトランザクションが完了していない
  • ピボットマーカーが存在しない=ピボットトランザクションが成功した

と解釈出来るようになります。

我々は、ピボットマーカーを予約データ永続化先と同じ DB に保存し、予約データ永続化と同じ DB トランザクションでピボットマーカーを削除することで、 この要件を満たしています。

2. 補償ログとピボットマーカーに親子関係を設ける

補償ログとピボットマーカーに親子関係を設けることで、 ローカルトランザクションの補償トランザクションの実行要否と ピボットトランザクション成否を結びつけることが出来ます。 これにより、トランザクション全体の結果整合性を担保することが出来ます。

  • ピボットマーカーが存在していれば、実行済みのローカルトランザクションが存在する可能性があり、ロールバックする場合はローカルトランザクションに対して補償トランザクションを実行する必要がある
  • ピボットマーカーが存在しなければ、トランザクション全体を成功とみなすため、ローカルトランザクションに対して補償トランザクションを実行する必要はない

と解釈することが出来ます。

3. 補償トランザクションを実行する際は常にピボットマーカーを起点に実行する

常にピボットマーカーから補償ログを辿って補償トランザクションを実行するようにします。 こうすることで、ピボットマーカーが存在している場合にのみ補償トランザクションが実行されるようになります。 つまり、ピボットトランザクションが成功した場合は絶対に補償トランザクションが実行されることはない、とすることが出来ます。

トランザクション全体のロールバックの例

ここまでの実装で、トランザクション全体としてロールバックを冪等に実行することが出来るようになります。 ローカルトランザクションの一部が失敗した場合を考えてみます。

  1. ピボットマーカーが作成され、
  2. その後のローカルトランザクション1, 2, 3 と実行されるが ローカルトランザクション3 が失敗し、
  3. ローカルトランザクション1, 2 に対して補償トランザクションを実行してロールバック
  4. 補償ログ削除
  5. 最後にピボットマーカーを削除

このようにして、トランザクション全体をロールバックすることが出来ました。 またこのプロセスは、冪等に実行することが可能です。

ロールバック処理では、複数ある補償トランザクションのうちのひとつの実行に失敗したりすることがあり得ます。 そのほか、サーバーのプロセスごと落ちたなどでロールバック全体が完了しなかった場合にも、 実行する必要のある補償トランザクションを確実に実行する必要があります。

そのため我々は、一連のロールバック処理を予約の失敗時にサーバーから同期的に実行することに加えて、 定期的に残っているピボットマーカーを見て、サーバーから実行したものと同じロールバック処理を再実行するジョブを Cloud Run Jobs で用意しています。

ロールバック処理を冪等に実行出来るようにすることで、このように確実にロールバックが完了するように実装することが出来ます。

制約

ここまで説明してきた実装パターンが適用出来る前提として、以下があります。 4

  • ローカルトランザクションが同期的に実行できること
    • ここでいう「同期的」とは、ローカルトランザクションの成否がピボットトランザクション実行までに確定していることを指します
  • ローカルトランザクション同士が強く結合していないこと
    • 順序制約はあってもよいが、補償トランザクションに必要な情報が前段の実行結果に依存しない(=実行前に補償ログへ必要情報を確定できる)こと
  • トランザクション全体としての一貫性は結果整合で良いこと
    • 予約失敗の場合には一時的にでも在庫が引当されてはいけない、と言った制約がある場合はこの実装パターンには向きません

これよりも厳しい要件が必要な場合、この実装パターンそのままは適用できません。

この実装パターンの特徴・利点

紹介した実装パターンの特徴や利点として以下の点があげられると思います。 これらについては、既存のシステムからも、実際実装していて改善しているなというところを感じることが出来ています。

Saga パターンなどを意識せずドメインロジックの実装が可能

ここまで書いておいてなんですが、アプリケーションロジックを実装する際は、このようなことを気にせずに進められるならそれに越したことはないと思います。

ここまで説明してきた実装パターンは、主に I/O を実行するレイヤでのみ気にすれば良いものになっています。 したがって、ドメインロジックと I/O を適切に分離できていれば、ここまでの補償トランザクション周りの実装についても、 ドメインロジックを実装する際に意識する必要はなくなります。

ローカルトランザクションの追加が容易

ローカルトランザクション毎に補償ログ・補償トランザクションの実装を用意すれば、ローカルトランザクションを追加することは比較的容易です。 実際に予約リニューアルプロジェクトを進める中で、段階的にローカルトランザクションを大きな労力なく追加していくことが出来ました。

ローカルトランザクションの変更が容易

ローカルトランザクションそれぞれの独立性が高いため、ローカルトランザクションの実行タイミングや順序などが変更しやすくなります。 例えば在庫引当はもっと早いタイミングに実行してしまいたい、と言った変更です。

おわりに

一休では、ユーザーにより良い体験を提供するため、より良いシステムを一緒につくっていくエンジニアを募集しています。

www.ikyu.co.jp

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

job.persona-ats.com


  1. 我々はオーケストレーションパターン、同期通信、結果整合ということで「おとぎ話 Saga」というものに分類されるようです
  2. 世代を選ぶ話題かもしれないですが
  3. ローカルトランザクションが外部 I/O を含む場合、ローカルトランザクションも冪等であることを必要とされることが多いと思います
  4. ここでいう制約が、まさに 1. で紹介した「おとぎ話 Saga」の特徴です

一休.com 宿泊の料金・ポイント計算処理の改善

宿泊プロダクト開発部の田中(id:kentana20)です。

このエントリーは一休.com Advent Calendar 2025の11日目の記事です。

今回は、一休.com宿泊で進めている

「ホテル/旅館の宿泊料金・ポイントを計算する処理が複数のシステムに分散している状態を改善している」

という取り組みについてご紹介します。

背景・課題

一休.com 宿泊には、いくつか重要な業務が存在しますが、その1つに「宿泊料金・ポイントの計算処理」があります。

  • 各ホテル・旅館が設定した料金
  • サイトを閲覧しているユーザー(会員)の状態
  • ユーザーが指定している検索条件(日付、人数など)
  • 期間限定で実施しているポイントX倍、のようなプロモーション

などの情報に基づいて、宿泊料金を算出したり、予約で得られるポイント数を計算する処理です。 この料金・ポイント計算処理は、以下のような背景・課題がありました。

  • 歴史的経緯から、料金・ポイント計算ロジックが複数のシステムに分散して存在している
  • (複数システムに分散しているため)ロジックの変更を行う際に、複数のシステムに対して同じ変更を繰り返し実施する必要がある
  • 「今年の冬は、こういうポイントアップのプロモーションを実施したい」というビジネスのニーズに対して、必要以上に対応コストがかかってしまう

昨今、ECをはじめとするWebサービスにおいてポイントやクーポンといった販促・インセンティブ機能はビジネス上も重要な要素となっており、一休.com 宿泊においても例外ではありません。

これを踏まえて、各システムで実施している料金・ポイント計算処理を整理し、本来あるべき姿を検討して改善を進めることにしました。

料金・ポイント計算処理の現状整理と課題、改善策

本来あるべき形を検討するにあたり、まずは現状の料金・ポイント計算処理がどうなっているかを整理するところから始めました。 整理については

  1. 料金・ポイント計算をどこで、どんな業務で使っているか
  2. それぞれの業務で、料金・ポイント計算にどんな特徴・違いがあるか
  3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題)
  4. 本来あるべき姿に向けて、現状からどう改善していくか

という4ステップで考えて進めました。

前提)宿泊システムでの料金とポイント計算

ホテル、旅館の宿泊料金は以下のように決まっています。

  • ホテル・旅館
  • 部屋タイプ
  • 宿泊プラン
  • 宿泊日

この4つの要素の組み合わせごとに料金が設定されています。

ホテル・旅館 部屋タイプ プラン 宿泊日 料金 補足
ホテルA スタンダードツイン 朝食付きプラン 2025/12/11 25,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/12 30,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/13 40,000 同じ内容でも日付ごとに料金が異なる(土曜は高い)
ホテルA スタンダードツイン 朝食付きプラン 2025/12/14 25,000
ホテルA スタンダードツイン 朝食付きプラン 2025/12/15 20,000
ホテルA デラックスダブル 素泊まりプラン 2025/12/11 - 料金が設定されていない日もある
ホテルA デラックスダブル 素泊まりプラン 2025/12/13 60,000

こんなイメージです。

また、ポイント計算は以下のような要素が絡んできます。

  • ユーザーの会員ランク(会員ランクによってポイント付与率が変わる)
  • ポイントアップキャンペーン(期間限定でポイント付与率が変わる)
  • クーポン利用(クーポン利用時の割引額を考慮する必要がある)

一休.com宿泊では「予約で付与されるポイントを、その場で使える(ポイント即時割引)」という機能があるため、ユーザーには

  • 元の宿泊料金(値引き前)
  • 即時割引のポイント数(ポイント付与率)
  • 実際に支払う料金(値引き後)

の3つをわかりやすく表示する必要があります。

ユーザーに表示する料金の例

1. 料金・ポイント計算をどんな業務で使っているか

初手として、各システムが料金・ポイント計算処理をどこで、どんな業務で使っているかを整理しました。

  • (a) 検索を高速に行うためのデータ作成・更新業務
    • 後続の検索業務に必要なホテル・旅館の料金を確定するために必要な情報を非正規化して作成・更新する *1
  • (b) 検索業務
    • ユーザーが指定する条件に合わせて予約可能なホテル、旅館や宿泊プランを抽出して画面に表示する
  • (c) 社内でのマーケティング用途向けのデータ作成・更新業務
    • 社内でのデータ分析業務に必要な宿泊プランの料金情報を作成・更新する
    • ユーザーに送る販促メールなどに掲載する宿泊プランの料金を計算する
  • (d) 予約業務
    • 最終的にユーザーが選択した宿泊プランのリアルタイムな料金を計算し、予約を確定する
  • (e) ポイント、クーポンなどの割引計算業務
    • 検索、予約どちらでも、指定条件に対して適用できるポイント、クーポンを抽出・計算する

などです。

2. 料金・ポイント計算各業務の特徴と違い

1の整理を踏まえて、各業務での料金・ポイント計算にどんな特徴があるかを見ていきました。 結果として以下のような違い(特徴)があることがわかりました。

  • 情報の鮮度に関する違い

    • ある程度の精度・鮮度で料金を計算できればよい業務(検索)
    • リアルタイムに正確な料金を計算する必要がある業務(予約)
  • 扱うデータ量の違い

    • 大量の宿泊プランのデータを一括で処理する業務(検索用データ作成・更新、マーケティング用途向けデータ作成・更新)
    • 指定の条件にあった宿泊プランをリアルタイムに処理する業務(予約)
  • 必要な情報の違い

    • 検索では指定条件でトータルのポイント付与率、ポイント数がわかればよい
    • 予約ではプロモーション単位でポイント付与率、ポイント数がわかる必要がある

これを抽象的に捉えると

  1. バッチ処理として大量のデータを一括で処理する業務
  2. リアルタイムに個別のデータを処理する業務

の2つに大別できることがわかりました。

また、各業務を整理する中で「料金」と呼んでいるものが複数存在していて、呼び名が統一できていないこともわかりました。

  1. 宿泊料金(ホテル・旅館が設定した基本料金)
  2. ポイント値引き後の料金
  3. クーポン・ポイント値引きなど、すべての割引を適用した後の最終的な支払料金

などです。これについては、料金の種類を整理してどこでどの料金を使う必要があるのかをまとめました。

3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題)

次のステップとして、それまでの整理をもとに、現状の課題を洗い出して本来あるべき姿を検討しながら、取り組む課題を明確にしていきました。

課題: 宿泊サービスの中で、料金・ポイント計算ロジックが複数のシステムに分散して存在している

  • 長くサービスを運用しているため、新旧それぞれのシステムで料金・ポイント計算ロジックが実装されている状態になっている
    • システム移行の過程では避けられない側面もあります
  • 一方で、新しいシステムの中でもロジックは共有しきれておらず、用途によって分けたシステムごとにロジックが分散している状態になっている
    • 検索用のインデックスデータとマーケティング用データの作成・更新処理がサブシステムに分かれており、ロジックが共有できていない 等

これに対して、本来あるべき姿は、料金・ポイント計算ロジックは1箇所に集約し、各システムから共通で利用できる形にすることと考えて、ロジックの集約に向けた取り組みを行うことにしました。

4. 本来あるべき姿に向けて、現状からどう改善していくか

課題を踏まえて、本来あるべき姿に向けてどう改善していくかを検討しました。 改善のステップとして、以下を考えて進めています。

  • 新システム内でのロジック集約・共通化
    • ステップ1: バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化
    • ステップ2: リアルタイムに個別のデータを処理する業務内も含めてロジックを集約・共通化
  • システム全体でのロジック集約・共通化
    • ステップ3(案): 既存システムから新システムのロジックを呼び出し、システム全体でロジックを集約・共通化

現状と今後の展望

現在は、ステップ1として「バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化」を進めており、先に挙げた業務のうち

  • (a) 検索を高速に行うためのデータ作成・更新業務
    • 後続の検索業務に必要なホテル・旅館の料金を確定するために必要な情報を非正規化して作成・更新する
  • (c) 社内でのマーケティング用途向けのデータ作成・更新業務
    • 社内でのデータ分析業務に必要な宿泊プランの料金情報を作成・更新する
    • ユーザーに送る販促メールなどに掲載する宿泊プランの料金を計算する

の2つの業務を扱うバッチ処理に対して、1つの料金・ポイント計算ロジックを使う形に改善を進めています。今月ようやくプロトタイプが動くようになり、来年1月頃のリリースを目指して進行中です。

リリース後は引き続き、ステップ2を進めていく予定です。

まとめ

今回は、一休.com宿泊で進めている「宿泊料金・ポイント計算処理の改善」という取り組みについて紹介しました。個人的な所感として、既存システムの現状・課題を整理したことで

  • まだその業務を十分に知らないメンバーが理解するために役立った
  • ほかのサービスで同様の課題を考える際に参考になった

といった出来事があり、宿泊システムの改善を考える以外の面でもプラスの効果があったと感じています。

おわりに

一休では、事業の成果をともに目指しつつ、システムの改善も進めていく仲間を募集しています。

www.ikyu.co.jp

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

https://hrmos.co/pages/ikyu/jobs/1745000651779629061hrmos.co

明日は @rotomx の「一休の情シス / コーポレートIT 2025」です。お楽しみに!

*1:一休宿泊では、ユーザーが指定した条件で検索結果を素早く表示する必要があるため、Solr(検索エンジン)に必要な情報をインデックスしています

操作から意味へ ─ Haskell が変えた私のメンタルモデル

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

私は毎年この時期になると Haskell に関する記事を投稿していますが、今年もまた Haskell を題材にしつつ、今回は Haskell を使うことがプログラミング中の思考にどのような影響を与えるかについて考察してみようと思います。

LLM と「言葉が思考を形づくる」という直感

LLM (Large Language Models、大規模言語モデル) は次にくる言葉を予測しているだけなのに、それが知性のように見える。「言葉の推定」でプログラミングすらできてしまうという事実に誰もが驚いたところだと思います。

LLM を本当の知性とみなすかどうかは議論の分かれるところだと思いますが、LLM の原理をみるに、少なくとも「言語」が推論や思考の形式に深く影響するという直感は正しいのではないかと思います。

ところで「言語」といえば、我々はプログラミング言語を用います。「プログラミング言語」がその名の通り「言語」なら、プログラミング言語もまた思考に影響を与えるのではないか、そんなことを思います。

ChatGPT に「プログラミング言語が思考に影響を与えるなら、使うプログラミング言語を変えると自分の思考が変わるということはありそうですね」と尋ねてみたところ、以下のような返答が帰ってきました。

「Haskell は言語ではなく哲学」笑

いやあ、さすがにそれは大袈裟すぎるだろうとは思いつつ「思考が "操作" ではなく"意味" を軸に処理されるようになる」という点については、頷けるところがあります。

半年前の『関数型まつり』でも、競技プログラミングとアルゴリズムを題材にこの話を少し紹介しました。

Haskell でアルゴリズムを抽象化する / 関数型言語で競技プログラミング - Speaker Deck

今回はその中の一部、Haskell を使うとプログラムを見る目・・・メンタルモデルが変わるよ、という話をしてみたいと思います。

map と fold:再帰構造をどう“見る”かが思考を変える

一般の命令型言語の場合、値やデータ構造は基本的に書き換えが可能です。それを命令によって書き換えながら望む結果を得る、という考え方でプログラムを構成します。

例えば 1 から n までの和を取りたい場合、以下のように書くことができます。

int total = 0;

for (int i = 1; i <= n; i++) {
    total += i;
}

変数に値を代入するという操作を通じて、値を書き換えます。それを for ループで繰り返し操作します。操作が終わったところで total 変数の値は、目的の 1 から n までの和になっているはずです。

Haskell の場合、変数は基本的に書き換えることができません。値の書き換えのような「操作」で計算を構成するのではなく「値に関数を再帰的に適用する」ことで計算を構成します。

f acc [] = acc
f acc (x : xs) = f (acc + x) xs -- f 関数を再帰

main :: IO ()
main = do
  ...

  print $ f 0 [1 .. n]

しかし何かプログラムを構成するたびに再帰をイチから書くのはプリミティブすぎます。Haskell には fold (畳み込み) や map (写像) のような、再帰構造を一般化した基本操作が用意されています。

先の和は foldl' を使うことで以下のように書けます。

print $ foldl' (+) 0 [1 .. n]

命令型言語で配列やリストの各要素を変換したいとき、やはり値を書き換えるという操作が中心になります。たとえば、1 から n までの整数それぞれに 1 を足した新しい配列を作る場合は以下のように書くことができます。(ChatGPT に書かせました)

vector<int> xs;
xs.reserve(n);

for (int i = 1; i <= n; i++) {
    xs.push_back(i + 1);  // 値を書き換えて格納する
}

Haskell では、やはり値の書き換えという操作ではなく、「各要素を変換する」という計算を map (写像) で表現します。

print $ map (+1) [1 .. n]

命令型言語では for 文や代入文、配列、if 文というプリミティブな操作で、多くのことができるのはみなさんご存知の通りです。 それと同じく Haskell では map や fold (と filter など) で、同様に、多くのことができます。

プログラミング言語におけるプリミティブな構文要素が異なる。これが、命令型言語と Haskell のような関数型言語の大きな違いです。

「動き」ではなく「意味」でプログラムを捉える

map や fold の「意味」はそれぞれ「写像」や「畳み込み」です。

慣れないうちは map や fold を、命令型プログラミングの for 文そのほか同様に動きで捉えてしまって、つい頭の中で値が再帰的に変換されていく様子をシミュレートしてしまうかしれません。

しかし、ある程度書き慣れてくると let xs' = map (+1) [1 ..n] という記述は「xs'[1 .. n] というリストの写像だ」と、その意味そのままで解釈、記述できるようになっていきます。fold も同じです。この意味だけでコードを捉えても特に困らないので、動きについてはあまり考えなくなります。

ちなみにここで言っているのは「意図」ではなく「意味」です。「意味」はプログラム自身が持つ構造的・数学的な「何を表すか」のこと。

プログラマの「意図」とは無関係にプログラムが構造として「意味」を持つことがあります。そして Haskell のような抽象度の高い言語では、この「意味」が支配的になると思っています。

プログラムそのものが表す構造・関係というのは、たとえば

  • 「fold はモノイドの結合である」
  • 「関数 f :: A -> B は A を B に写す写像である」
  • 「map は関手(functor)の写像で、構造を保つ」
  • 「IO は合成可能な計算のコンテナである」

みたいな解釈のこと。

あるコードが「何を表現しているか」「どんな数学的構造に対応するか」という、客観的な意味のことです。

Haskell の再帰的データ構造と map / fold

ところで Haskell で宣言するデータ構造は、再帰的データ構造です。

data List a = Nil | Cons a (List a)

再帰的データ構造は「全体が、同じ型の部分構造を含んで定義されているデータ構造」です。リストや木構造などが典型例ですが、Haskell のイミュータブルなデータ構造は概ねこの再帰的データ構造として定義されています。

詳細が気になる方は、昨年私が書いたこちらの記事も参照してください。

永続データプログラミングと永続データ構造 - 一休.com Developers Blog

さてリストの例でも分かるように、再帰的データ構造は「全体」を分解すると必ず「同じ型の部分構造」が出てきます。

  • 全体が空か
  • 要素と “残りの構造” からできている

この 「分解 → 要素への処理 → 部分構造の再帰」 という流れが、再帰的データ構造を扱うときの最も自然で基本的な操作です。そして、この 自然な操作を一般化したものが map と fold です。

map と fold は再帰的データ構造に適した最小の操作

繰り返しになりますが、リストを始め、Set や Map など、Haskell が提供する多くのデータ構造は再帰的データ構造で定義されています。この再帰的データ構造に対して何か処理をしたいとき、だいたい次の 2 つの行動 (両方、またはいずれか) が発生します。

  • 各要素を何らかの関数で変換する
    • 構造はそのまま
    • 中身だけ変える

これはまさに map (写像) です。

  • 構造全体を 1 つの値に畳み込む
    • 各要素を読み取り
    • 結合演算で集約する

これは fold (畳み込み) ですね。

たとえば、3×3 の格子点を集合 Set (Int, Int) で持ち、それを平行移動させたい場面を考えてみます。

Haskell では集合全体に対する写像 として、そのまま表現できます。

main :: IO ()
main = do
  -- n <- getInt

  let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)]
      s' = Set.map (+ (5, 2)) s

  print s' -- fromList_[(6,3),(6,4),(6,5),(7,3),(7,4),(7,5),(8,3),(8,4),(8,5)]

集合の順序・構造は保たれたまま、要素だけが変換されます。これは再帰構造の「写像」という観点から見ても自然です。

同じ Set を使って、今度は「集合全体を 1 つの値に集約 (畳み込み)」することを考えてみます。 たとえば点集合の平均座標(重心)を求める場合です。Haskell なら fold でたたむだけです。

main :: IO ()
main = do
  let s = Set.fromList [(1, 1), (1, 2), (1, 3), (2, 1), (2, 2), (2, 3), (3, 1), (3, 2), (3, 3) :: (Int, Int)]

      (sx, sy, cnt) =
        foldl'
          (\(ax, ay, c) (x, y) -> (ax + x, ay + y, c + 1))
          (0, 0, 0)
          s

      center =
        ( fromIntegral sx / fromIntegral cnt,
          fromIntegral sy / fromIntegral cnt
        )

  print center -- (2,0, 2.0)

このように、Haskell の再帰データ構造は map や fold で操作できて、map には写像、 fold には畳み込み (集約) という意味があり、いま見たような「平行移動」や「重心を求める」のよう計算も写像、集約の意味で捉えて記述することが可能になります。

Haskell で記述すると「意味」が自然と浮かび上がる

map と fold が強力なのは、 単に便利だからでも、抽象的だからでもありません。それらが再帰構造の本質的な二つの操作に完全に対応しており、 コードの表現がそのままデータ構造の意味に一致するからです。

  • map → 「A を B へ写像した」
  • fold → 「A を単位元と結合演算で畳んだ」

命令的な「どうやってやるか」ではなく、「何を表すか」「どんな変換なのか」という意味がそのままコードになります。ここが、Haskell が「意味で考える」言語だと私が考える重要なポイントです。

プログラミング言語は思考の外部化装置だとも考えられます。そしてコードというのは外部化された思考のまとまりです。

命令型プログラミングでは、for文、代入文という文、つまり計算機への命令が基本操作になっています。それをベースにコードを組み立てていったとき、そこで外部化されるのは「操作、動きのまとまり」でしょう。

一方、Haskell では map や fold などの式、つまり意味が基本操作になっている。それをベースにコードを組み立てていったとき、そこで外部化されるのはより抽象度の高い意味の構造に近づくのではないか、と考えています。

もちろん、map と fold だけが「操作ではなく意味で考える」ことに寄与している要素ではなく、そのほか関数合成、型、モナド、永続データ構造などなど Haskell を支える様々な概念が統合されて、コードを意味構造に導くのだと思います。

二分探索について考える

もう一つ別の例についても考えてみます。 二分探索のアルゴリズムは、プログラマであれば誰もが知るところです。

おそらく、初めて二分探索を学んだときは、多くの人がそれを 「配列の真ん中を見て」「条件を満たすかどうかで左か右に進んで」 といった操作の流れを頭の中に描いて理解したのではないでしょうか。

これはやはり「動き」を理解の中心に置く捉え方です。

意味的な捉え方:二分探索は「境界を見つける」アルゴリズム

一方で、特に競技プログラミングをやっている人などは、二分探索を「境界を見つけるアルゴリズム」 として意味的に捉えていることが多いのではないでしょうか。

ある領域の中に条件が true になる領域、条件が false になる領域があり、その境界(true → false に切り替わる点)を効率的に求めるのが本質だ、という解釈です。以下の文書などでも詳しく解説されています。

この境界を高速に見つけることこそが二分探索の意味であり、 動きの詳細(左右どちらを見る、など)はその「境界探索」を実現する手段にすぎません。

bisect2 という関数に抽象化する

二分探索は素で実装すると off-by-one なバグを埋め込みやすいので、私は以下のように bisect2 という名前で二分探索の関数をライブラリ化しています。

-- | 左が true / 右が false で境界を引く
bisect2 :: (Integral a) => (a, a) -> (a -> Bool) -> (a, a)
bisect2 (ok, ng) f
  | abs (ng - ok) == 1 = (ok, ng)
  | f m = bisect2 (m, ng) f
  | otherwise = bisect2 (ok, m) f
  where
    m = (ok + ng) `div` 2

この関数は「左側 ok が true の代表値」「右側 ng が false の代表値」であることを前提に、 true 域と false 域の境界を特定する計算に特化しています。

この二分探索の関数は以下のように使います。

let (ok, _) = bisect2 (0, 10 ^ 18) (\x -> countBy (>= x) as >= x)

print ok

ここで引数として渡している高階関数 f :: a -> Bool は、 「x に対してその条件が成り立つかどうか」を返す写像であり「二分探索における境界の“意味そのものを表現する関数」と言えます。

改めて一歩引いてみてみると、高階関数 f によってパラメータ化された境界条件の存在が「二分探索は境界を見つけるアルゴリズム」だという意味構造をよりはっきりと表しているように見えてきます。こうやって、アルゴリズムを記述していてもそこに意味構造が自然と浮かび上がってくる。

そしてこの「二分探索の境界を引く、 f は境界条件だ」という意味構造を自然に捉えられるようになると、今度は思考が逆転して、二分探索をしようとするとき探索の動きで考えるのではなく、「境界条件をどのように写像として表現するか」という「意味の頭」で考えるようになります。

思考や発想そのものが、操作や動きを考えることから、意味から出発するように変わるのです。これこそ、プログラミング言語の特徴が思考に影響を与えた結果辿り着いた思考の癖だと私は思っています。

長年プログラミングをやってて思うこと

抽象度の高い Haskell のようなプログラミング言語を使うと思考が変わる、メンタルモデルがアップデートされるのではないかという仮説を、自分の実体験に基づき、紹介してきました。

以下、主観的な考察です。

プログラミングは一見すると知的作業のように見えます。でもその実は、反復作業によりプログラミング言語を反射的に操作できるよう身体化させることが必要だと思っています。プログラミング言語の本を読んだだけでスラスラとプログラムが書ける人は希で、多くの場合、繰り返し繰り返し記述して、考えなくても手癖でコードが記述できるようになって初めて、そのプログラミング言語を自分の道具にできたと実感するのではないでしょうか。

そして繰り返し繰り返し同じようなコードを書いて、同じような構造をみつけて、同じようなプログラムを構築する。その過程で同じような構造を何度も目にすることで、人はそこから抽象を見い出すことができるようになる。そしてより上手に、構造を描けるようになる。

これは言ってみれば、絵を描くとか、何か作品をつくるという行為によく似ているように思います。反復、繰り返しによる積み重ねが、より高い次元へとそれを導く。繰り返し繰り返しやっているうちに、気がつけばずいぶんと遠くに辿り着く。

この長年の積み重ねを、操作や動きを中心に据えたプログラミング言語でやっていくか、意味を中心に据えたプログラミング言語でやるかで辿り着く場所が大きく異なるのではないか、という実感があります。

何か新しいプログラミング言語に手を出すとき、もちろん実用性の面からそれを選ぶのも良いと思います。

でも別の視点として、プログラミング言語が思考に影響を与えるだろうという観点から、いつも使っている言語とは少しパラダイムが離れたものを使ってみるのも面白いと思います。今回みたとおり、新しいプログラミング言語を身体化する過程でメンタルモデルが更新されて、プログラミングに対する新たな視点が手に入るでしょう。

関数型プログラミングの実践として「不変な値で組み立てていくとプログラムが堅牢になるよ」とか「型安全にすると変更が楽だよとか」実用的なテクニックや旨みを中心に語ること自体は否定しません。でも、私としてはそういうことよりも、よりよいプログラミングの目を養うために関数型プログラミングや Haskell を学んでみたら? というのが本音としてあります。

そのとき、やっぱり反復や繰り返しが大事です。ちょっとやってみる、だけではもの足りない。

命令型プログラミングに慣れた人ほど、map や fold を最初から写像や畳み込み (集約) と直接意味で考えるのが難しい。頭のなかで操作を追ってしまう。でも、繰り返し繰り返しやっていると、やがて、操作を経由しなくても、写像、集約のような意味で脳が直接的に認知できるようになる。

よく、日本語ネイティブな人が英語を話すとき、慣れないうちは英語を一度日本語に頭の中で変換すると言います。でも、そのうち英語を英語のまま脳が処理できるようになるらしいです。それによく似ていて、最初は、動きに変換して考える癖が抜けない。でも、繰り返しやってるうちに、その癖が抜ける。それが一つの到達点だと思います。

私はできればプログラムを操作のまとまりではなく、意味の構造として捉えたいという欲求があります。それはたぶん、それを操作列としてではなく意味構造として捉えるほうが、情報としての圧縮率が高いからではないかと思っています。

自分の脳はさほど、操作的推論に強くない。だから、より低い認知負荷で対象を把握・理解する、記憶するためにはより圧縮率の高い表現の方が望ましかったんだと思います。Haskell ならプログラムを意味構造として組み立てていく、解釈するのが、命令型言語よりもやりやすい。それが今のところの自分の結論です。

ベクトルの和は (x + d1, y + d2) ではなくて v + d と書けたほうが嬉しいし、「ビット全探索」ではなく subsequences だし、直積を求めるなら for の二重ループではなく [ (x, y) | x <- xs, y <- ys] あるいは sequence [xs, ys] と書きたいし、理解したい。そんな気持ちです。

今年も長々とした駄文を最後まで読んでいただきありがとうございました。

Panda CSS でデザインシステムのメンタルモデルを確立する

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

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

2025 年 4 月、PayPay グルメ の全面リニューアルが完了しました。このリニューアルでは「一休.com レストラン」と「PayPay グルメ」の 2 つのサービスを 1 つのコードベースに統合しています。

一休レストラン・PayPay グルメではリニューアルプロジェクトを契機に Tailwind CSS から Panda CSS への置き換えを進めています。 また置き換えやってるのか 1 と思われるかもしれませんが、もちろん理由あっての導入です。本稿では、なぜ導入したのか、それにより何が得られたのかをご紹介したいと思います。

PayPayグルメについて

本記事で登場する「PayPayグルメ」について簡単に説明します。

PayPayグルメは「PayPay株式会社の協力のもとLINEヤフー株式会社が運営しているサービス」2です。カジュアルなレストランや居酒屋に加えてカラオケまで、多彩な店舗ラインアップを揃えた飲食店予約サービスです。

PayPayグルメトップ

2025年3月末日まではLINEヤフーが開発/運用の両方を担っていました。その後、2025年4月からは一休に開発/運用が委託されています。主な理由としては、一休がLINEヤフーのグループ会社であり、一休が培ってきた飲食店予約事業の知見を活用しつつグループ内の運用を一本化するためです。

PayPay グルメと一休レストランの統合

PayPay グルメを一休に移管するにあたっては、現行の一休.comレストランのシステムにPayPayグルメを統合するという選択肢をとりました。つまり、アプリケーションのリポジトリを一休.comレストランと PayPay グルメで共通化し、2つのサイトを配信できるような仕組みを作ることにしました。

さらに、統合にあたってPayPayグルメのUIを全面リニューアルすることにしました。具体的には、一休.comレストランの UI をもとにしつつ、新たに PayPay グルメのブランドイメージを表現したデザインを適用しました。

統合にあたり、まずは1つのリポジトリで2つのサイトを運用するために解決した課題をいくつか紹介します。

サイト固有のコードをどうするか

1つのコードベースで2サイトを実現するにあたって、次のような課題がありました。

1つ目の課題は、フロントエンドのバンドルサイズ問題です。一方のサイトにしか使わないライブラリがもう一方のサイトのプロダクションビルドに含まれてしまうと、ネットワーク帯域や計算資源を無駄に消費してしまいます。例えば、PayPay グルメでのみ利用している地図サービス MapBox のクライアントライブラリが挙げられます

2つ目の課題がより重要です。プロジェクト開始時点では PayPay グルメのリニューアルは公表されていませんでした。バンドルサイズに目を瞑ったとしても、一休.comレストランのプロダクションビルドに PayPay グルメを推測されるようなコードを含めるわけにはいきません。

上記の課題を解決するために、ビルド時に Vite の環境変数を切り替えることで、本番で各サイトのコードに他方のサイトだけでしか使わないコードが入り込まないようにしました。

各サイト固有のデザインの適用

一休レストランのみ開発している時は特に意識しませんでしたが、2 サイトで使用する色が異なります。

最初期のプロトタイプでは、一休.comレストランのためのデザイントークンの値を直接書き換えて、PayPay グルメのルック&フィールを検証していました。

const ikyu = process.env.VITE_MODE !== 'ppg'

module.exports = {
  theme: {
    extend: {
      colors: {
        accent: {
          // PayPay グルメでは "pink" だがカラーはブルー
          pink:  ikyu ? '#ff4d4d' : '#3895ff',
          brown: ikyu ? '#af9b65' : '#3895ff',
          khaki: ikyu ? '#c0b28b' : '#3895ff',
          beige: ikyu ? '#f8f6f1' : '#E5F1FF',
          blue:  ikyu ? '#397bbe' : '#3895ff',
        },
      },
    },
  },
}

当時のデザイントークンは上記の通り意味と色の名前が混在していて、プロトタイプ時点のコードでは accent-pink なのに表示される色は青という状況でした。 例えば、以下のような実装では問題が発生します。

export function ReserveButton() {
  return (
    // PayPay グルメではブルーになる
    <button className="accent-pink">
      空席確認・予約
    </button>
  )
}

このコードではクラス名が accent-pink ですが、PayPayグルメ では実際にはブルー #3895ff が表示されます。 このコードは実際に以下のような画面が表示されています。

一休 PayPay グルメ
一休レストラン予約ボタン PayPayグルメ予約ボタン

コード上の色名と実際の色が一致しないため、メンテナンス時に混乱を招く原因となっていました。 色そのものの名前 (primitive) と、それをどういう場面でどういう効果を与えるために使うのかという意味 (semantic) を峻別することの重要性に気付かされた瞬間でした。

さきほどの例を、primitive token と semantic token を分けると以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'

// primitive token
const ikyuPink = '#ff4d4d'
const ppgBlue = '#3895ff'

module.exports = {
  theme: {
    extend: {
      colors: {
        button: {
          // sematic token
          primary:  ikyu ? ikyuPink : ppgBlue,
        },
      },
    },
  },
}
export function ReserveButton() {
  return (
    <button className="button-primary">
      クーポンを獲得する
    </button>
  )
}

こうした反省を踏まえ、デザイントークンをあらためて見直すことになりました。 ここまでお伝えしてきたように、値そのものの名前である primitive token とそれに対する意味付けである semantic token をちゃんと認識し、峻別していく、という整理です。

この過程で Panda CSS の採用を決定しました。 Panda CSS では core token と semantic token を分けて定義する形になっており、色と意味の峻別という私たちがあらたに得たメンタルモデルを後押ししてくれるからです。

もともと Panda CSS に注目していたしていたことも追い風となり、リニューアルで大規模刷新する今こそ、移行コストを一度に払おうという結論に至りました。

本記事でのトークンについて

ここで用語の整理をさせてください。 デザイントークンの見直しの中で、一休のデザインシステムでは、ここまで述べてきたように primitive token と semantic token という整理を行いました。 一方、Panda CSS では core token / semantic token という用語が使われています。

トークンの種類 一休デザインシステム Panda CSS
値そのもののトークン primitive token core token
意味を持つトークン semantic token semantic token

以降、一休デザインシステム文脈では primitive token 、Panda CSS の文脈では core token と呼びます。

Panda CSS

Panda CSS は型安全かつゼロランタイムで使える CSS-in-JS のツールです。 Chakra UI の開発チームが「Chakra の設計思想をライブラリ非依存で使い回せるように」と開発しており、最終的には純粋な CSS ファイルを出力するため実行時コストはゼロになります。 そのうえ、 TypeScript の型情報を活用できるのが最大の特徴です。

本章では「前章で挙げた課題を Panda CSS がどのように解決したか」に絞って解説します。 セットアップ手順やユーティリティ API などの基本的な使い方は、公式ドキュメントや他の紹介記事を参考にしてください。

Design Token の階層化

Panda CSS は W3C Design Tokens Community Group が策定しているデザイントークンをファーストクラスサポートしています。 Panda CSS の設定ファイルでは core token と semantic token を分けられますが、使うときは core token も制限なく参照できます。 そこで core と semantic を厳密にわけるため、あえて "触ってはいけない" という意味を込めて not.recommend.* という prefix を導入しました。 実際の設定は以下のようになります。

const ikyu = process.env.VITE_MODE !== 'ppg'
// PayPay グルメのリニューアルの公表前なので、
// CSS のカスタムプロパティに PPG という名前が出ないようにしている
const ppg = {
  blue: {
    dark: { value: '#3895FF' },
    basic: { value: '#4DA0FF' },
    light: { value: '#BADAFF' },
    pale: { value: '#E5F1FF' },
  },
  /* …ほかの primitive */
}
export default defineConfig({
  theme: {
    // core token
    tokens: {
      not: {
        recommend: {
          ikyu: {
            red: { value: '#FF4D4D' },
            /* …ほかの primitive */
          },
        }
      }
    },

    // semantic token
    semanticTokens: {
      colors: {
        reserve: {
          value: ikyu ? { colors.not.recommend.ikyu.red } : ppg.blue.dark
        },
        /* …ほかの semantic */
      },
    },
  }
})

core token (primitive) である not.recommend.* 以下のトークンは自動補完には出てきますが、名前からして「コンポーネントで使うものではない」と一目で分かります。 そしてコンポーネント側は以下のように色名ではなく 役割名(reserveButton / login / review ...)だけを参照します。

export function ReserveButton() {
  return (
    <button className={css({ color: 'reserve'})}>
      予約へすすむ
    </button>
  )
}

Variant

前述したカラーの課題は、一休レストランと PayPay グルメが同一リポジトリで開発することが決定した時に顕在化しましたが、元々一休レストランのみを開発していた頃から抱えている課題もありました。 それはデザインツールと実装の連携です。 当時は XD から Figma へ移行した直後で Variant 機能を十分に理解できておらず、ボタンやタブの状態を boolean フラグで切り替える実装が散見されました。 例えば、以下のような実装が典型的でした。

import clsx from 'clsx'
import type { PropsWithChildren } from 'react'

export function Button({
  children,
  onClick,
  isPrimary,
}: PropsWithChildren<{ onClick: () => void; isPrimary: boolean }>) {
  return (
    <button
      onClick={onClick}
      // boolean でユーティリティを切り替え
      className={clsx(
        'p-2 border-neutral-400',
        isPrimary && 'bg-red-300',
      )}
      type="button"
    >
      {children}
    </button>
  )
}

このアプローチの問題は、ボタンの状態が増えるたびに boolean props が増え、条件分岐が複雑になることです。 上記の例では props で isPrimary の boolean 値のみを受け取ってますが、他にも isRounded && isLarge ... と膨れ上がりました。 その結果、 className が条件分岐だらけでメンテナンスが困難になりました。 また、Figma で定義されている Variant と UI コンポーネントで持つ状態の粒度が異なっており、認知負荷が高い状態になりました。 そこで tailwind-variants を投入し「variant をコード側で表現しよう」と試みたものの、ガイドライン策定が追いつかず部分採用のまま立ち消えたという苦い経験もありました。

Panda CSS では Variant がデフォルトでサポートされており、 Variant の key/value をそのままコードに反映することができます。

FigmaのButton定義

Figma で color / size を定義している場合、実装側は次のように Figma の variant と同じ名前と値で Panda CSS の cva を定義します。

import type { PropsWithChildren } from 'react'
import { type RecipeVariantProps, cva } from '~/styled-system/css'

// Variant の定義
const button = cva({
  base: {
    borderRadius: 'md',
  },
  variants: {
    color: {
      primary: {
        backgroundGradient: 'button.primary',
        color: 'button.primary.text',
        fontWeight: 'bold',
      },
      secondary: {
        backgroundGradient: 'button.secondary',
        color: 'button.secondary.text',
        fontWeight: 'bold',
      },
      normal: {
        borderWidth: 'thin',
        borderColor: 'button.normal.border',
        backgroundColor: 'button.normal.background',
        color: 'button.normal.text',
      },
    },
    size: {
      xs: {
        paddingX: '3',
        paddingY: '2',
        fontSize: 'sm',
      },
      sm: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'sm',
      },
      md: {
        paddingX: '4',
        paddingY: '3',
        fontSize: 'md',
      },
      lg: {
        paddingX: '6',
        paddingY: '4',
        fontSize: 'lg',
      },
    },
  },

  /** Figma の “Default” と同義 */
  defaultVariants: {
    color: 'normal',
    size: 'md',
  },
})

type Props = PropsWithChildren<RecipeVariantProps<typeof button> & { onClick?: () => void }>

function Button({ children, ...buttonStyle }: Props) {
  const style = button(buttonStyle)
  return (
    <button className={style} type="button">
      {children}
    </button>
  )
}

Figma と 1:1 になるコンポーネントができました。誤って存在しない Variant 名を指定しても TypeScript の型チェックが効くのも嬉しいところです。

// 使用側
export function ReserveButton() {
  return (
    <Button color="primary" size="md">
      予約へすすむ
    </Button>
  )
}

Figma で定義された Variant に沿ってエンジニアは同じキーを持つ Variant を cva / sva で実装することができます。 このように Figma と実装で Variant の粒度を合わせることができ、デザインと UI コンポーネントの実装がシームレスへと繋がるようになりました。

おわりに

Panda CSS に移行した際、開発チームのメンバーから以下のようなフィードバックがありました。

  • 新しい UI を考える中で、primitive / semantic を峻別して議論ができるようになった
  • デザインと実装がシームレスに繋がった
    • デザイン:パターン(variants)を作る -> 実装:そのパターン単位で cva / sva を作成する
  • Variant の key やトークン名が型安全になり開発体験が良い
  • 古くから css を触っているエンジニアには、Panda CSS だと css property がほぼそのままなのでわかりやすいと好評

Panda CSS 導入時は Tailwind CSS とは異なるスタイル定義で慣れが必要でしたが、概ね好評でした。

移行はまだ完全には終わっておらず、現在も Tailwind CSS が残っているコンポーネントもまだまだあります。 引き続き Panda CSS に書き換えながら、コンポーネントの見直しやデザインガイドラインの整備を進めています。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

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

https://hrmos.co/pages/ikyu/jobs/1745000651779629061hrmos.co

細粒度リアクティブステートのスコープ設計

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

一休.com レストランの開発を担当している恩田(@takashi_onda)です。

はじめに

昨日 2025/11/30 に開催されたフロントエンドカンファレンス関西で、「細粒度リアクティブステートのスコープとライフサイクル」というタイトルで発表を行いました。

speakerdeck.com

発表ではまたしても終盤が駆け足になってしまいました。本稿では、その際に十分に触れられなかった論点のうち、特にスコープに焦点をあて、その課題と解決案をご紹介したいと思います。

細粒度リアクティブステート

細粒度リアクティブステート (fine-grained reactivity) 1 とは、SolidSvelte 5 の Runes で謳われている、UI を必要最小限な単位でリアクティブに更新するフロントエンドのアーキテクチャです。現代の複雑化した Web アプリケーションで UX を軽快に保つためのステート管理の大きな潮流で、Signals として TC39 で標準化の議論が進められています。

その背景にあるのは、値の変更を依存グラフで追跡し、必要最小限の更新を可能にする宣言的な計算モデルです。大雑把なイメージをつかむなら「スプレッドシート」と考えれば分かりやすいでしょう。

derived changed

上の例のように、スプレッドシートでは、セルの値が変更されるとそのセルに依存する他のセルが自動的に再計算されます。同様に、細粒度リアクティブステートでは値が変更されると、その値から計算で導出される派生値やそれらの値を利用する UI コンポーネントが自動的に更新されます。

このように小さな状態をボトムアップに組み立ててモデリングするのが、細粒度リアクティブステートの特徴です。

React においては、一休.com レストランも大いにお世話になっている Jotai が細粒度リアクティブステートを実現するライブラリにあたると言えます。

React はその計算モデルの特性上 fine-grained reactivity を実現するのが難しいフレームワークですが、コンポーネントを小さく分割し memoization すれば近しい挙動が可能です。React Compiler 2 による自動化で React でも実現しやすくなりました。

ナイーブな実装

ここからは React と Jotai を前提に話を進めます。さきほどのスプレッドシートの例を Jotai で実装してみましょう。

const appleUnitPrice = atom(100);
const appleQty = atom(1);

const orangeUnitPrice = atom(200);
const orangeQty = atom(2);

const bananaUnitPrice = atom(300);
const bananaQty = atom(3);

const appleLineSubtotal = atom((get) => {
  return get(appleUnitPrice) * get(appleQty);
});
const orangeLineSubtotal = atom((get) => {
  return get(orangeUnitPrice) * get(orangeQty);
});
const bananaLineSubtotal = atom((get) => {
  return get(bananaUnitPrice) * get(bananaQty);
});

const subtotal = atom((get) => {
  return get(appleLineSubtotal) +
    get(orangeLineSubtotal) +
    get(bananaLineSubtotal);
});
const tax = atom((get) => get(subtotal) * 0.10);
const total = atom((get) => get(subtotal) + get(tax));

スプレッドシートがそのまま表現できていることが見て取れると思います。

次に単体テストを追加します。Jotai は Vanilla JS で利用できるので、Vitest などのテストフレームワークで簡単にテストが記述できます。

describe("Jotai Test", () => {
  test("total", () => {
    // arrange
    const store = createStore();
    // assert: initial values
    expect(store.get(appleLineSubtotal)).toBe(100);
    expect(store.get(subtotal)).toBe(1400);
    expect(store.get(tax)).toBe(140);
    expect(store.get(total)).toBe(1540);
    
    // act
    store.set(appleQty, 10);
    
    // assert: changed values
    expect(store.get(appleLineSubtotal)).toBe(1000);
    expect(store.get(subtotal)).toBe(2300);
    expect(store.get(tax)).toBe(230);
    expect(store.get(total)).toBe(2530);
  });
});

コンポーネントからは次のように利用します。

function Total() {
  const total = useAtomValue(total);
  return <div>Total: {total}</div>;
}

Jotai を使ってナイーブに実装すると、このようなコードになると思います。

私たちも当初は同様の実装でフロントエンドロジックを構築していましたが、規模の拡大とともにスコープとライフサイクルに起因する問題が顕在化してきました。

スコープ

ここで言うスコープは、その名の通りプログラミング言語における変数や関数の可視性のことで、具体的には atom がどこから参照できるかを指します。

ご存知の通り JavaScript のモジュール機構である ES Module はファイル単位でしか可視性の制御ができません。export してどこからでも参照できるようにするか、export せずそのファイル内に閉じるかの二択です。

実装しようとする機能(feature)が小さい間は、ひとつのファイルに atom を定義し Vitest の in-source test で単体テストを記述、最終的にコンポーネントだけを export すれば問題ありません。

しかし、一定以上の規模になると、すべてをひとつのファイルにまとめるのは現実的ではなくなります。atom 定義、単体テスト、カスタムフック、そしてコンポーネントとレイヤごとにファイルを分割3することになります。

ファイルを分割して単体テストやカスタムフックから atom を参照するために export したとき、問題となるのが VSCode や WebStorm などのエディタ・IDEの自動補完機能です。使いたいシンボル名を数文字入力すると補完候補が表示され、確定するだけで自動で import が挿入され、と、あまりに簡単に参照できてしまいます。

新しい機能を実装するとき、使えそうな atom が候補に出てきてタブで確定、とやっていくと、結果、できあがるのが密結合した巨大な atom グラフです。

const subtotal = atom((get) => {
  return get(appleLineSubtotal) +
    get(orangeLineSubtotal) +
    get(bananaLineSubtotal);
});
const tax = atom((get) => get(subtotal) * 0.10);
const total = atom((get) => get(subtotal) + get(tax));

さきほどの例で説明すると subtotaltax, total は本来他の注文明細でも再利用可能なロジックです。ですが、この例では appleLineSubtotal をはじめとする atom に直接依存してしまっており使い回すことができません。また、直接的な依存は単体テストの arrange も煩雑にします。

実際のプロダクトの規模では、依存が広く深く複雑になり、構造を簡単に把握できなくなるのが一番の問題でした。数十個単位の atom がフラットにひとつの大きなグラフになってしまい、手を入れる際に、ホワイトボードで依存関係を再整理して理解の見直しが必要な場面もありました。わかりやすく例えるならば、巨大なひとつの関数で実装されたコードを保守するようなイメージです。

Bunshi

この問題の解決にヒントを与えてくれたのが Bunshi です。

もともとは Jotai で多数の atom を構造化する jotai-moleculous というライブラリとして開発されていました。ですが、その思想は React/Jotai に限らず汎用的に有効だったため、他の状態管理ライブラリやフレームワークでも利用できるように拡張されたのが Bunshi です。

詳細に踏み込むと長くなるので割愛4しますが、Bunshi が提供する以下の機能が問題解決のヒントになりました。

  • atom をグルーピングした molecule という単位のモジュール
  • moleculeInterface という抽象への依存とその解決の仕組み (Dependency Injection)
  • 生存期間とライフサイクル (Scope5)

検討当初は Bunshi 自体の採用も視野に入れていましたが、コアドメイン部分の外部ライブラリへの依存を最小に保ちたかったこと、そしてデザインパターンとして同等の機能が実現できるという判断から、Bunshi の思想を参考にしつつ自前で実装することにしました。

解決

具体的なコードで見てみましょう。さきほどのスプレッドシートの例です。

function createTotalAtom(lineItems: Atom<number>[]) {
  const subtotal = atom((get) => {
    return lineItems.reduce((sum, item) => {
      return sum + get(item);
    }, 0);
  });
  const tax = atom((get) => get(subtotal) * 0.10);
  const total = atom((get) => get(subtotal) + get(tax));
  return total;
}

type Props = { lineItems: Atom<number>[] };
const Total = memo(({ lineItems }: Props) => {
  const totalAtom = useMemo(
    () => createTotalAtom(lineItems),
    [lineItems]
  );
  const total = useAtomValue(totalAtom);
  return (
    <div>
      <p>Total: {total}</p>
    </div>
  );
});

クロージャをモジュールの単位とします。createTotalAtom 関数がモジュールです。

この関数は引数で依存としての atom を受け取り、その依存を使った derived atom total を返します。依存を引数として明示的に記述するという制約がポイントで、これにより依存が無秩序に追加されてしまうことを抑止できます。

また、引数で依存が抽象化されることで、同じロジックを異なる依存で再利用できるようになります。lineItems は任意の注文明細で利用可能です。単体テストの arrange では、シンプルな primitive atom に差し替えられるのも大きな利点です。

中間の derived atom である subtotaltax はクロージャ内に閉じているため、外部から参照できません。カプセル化の導入です。

コンポーネントでは props で atom を渡します。atom は不変なオブジェクトなので、メモ化された Total が再レンダリングされるのは total の依存グラフを構成する atom の値に変化があったときとなり、 fine-grained reactivity が実現できています。

おわりに

ここまで読んでいただきありがとうございました。

Jotai を題材に、細粒度リアクティブステートで複雑なフロントエンドの状態をモデリングするときに課題となるスコープとその解決案をご紹介しました。

人間の認知サイズに収まるように分割し、依存を明示化し、抽象を導入する。ここまでを振り返ると、細粒度リアクティブステートのような新しい概念を扱う場合でも、特別なことはなく、重要なのはプログラミングの普遍的な設計原則に立ち返ることでした。

本稿が、フロントエンドにおける状態管理設計を検討する際の一助となれば幸いです。


一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。

www.ikyu.co.jp

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

job.persona-ats.com


  1. まだ定まった訳語がなく、リアクティビティとカタカナで表現するのにも違和感があったので、ここでは「細粒度リアクティブステート」と訳しています。fine-grained reactivity とそれを実現するステート管理の仕組み、ぐらいに捉えていただければと思います。
  2. 先日 1.0 がリリースされました。
  3. 依存の向きを一方向にするために、レイヤの単位で分割しています。
  4. bunshiを理解するという記事にわかりやすく解説されています。
  5. Bunshi の Scope はプログラミング言語におけるスコープではなく molecule の生存期間を意味します。React 実装では React Context が利用されています。