一休.com Developers Blog

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

Jotai を使った Dependency 管理とテスト技法

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

一休レストランのフロントエンドアーキテクトを担当してる恩田(@takashi_onda)です。

はじめに

先日の JSConf JP 2024 で「React への依存を最小にするフロントエンドの設計」という内容で登壇しました。

speakerdeck.com

発表では駆け足になってしまった、React への依存をしていない Vanilla JS 部分をどのように構成しているのかを、Dependency 管理とテストの文脈でご紹介したいと思います。

Dependency とは Dependency Injection の Dependency です。 タイトルも「Jotai を使った DI とテスト技法」とした方が伝わりやすいとは思います。 ですが、厳密には injection していないので、あえて Dependency という表現に留めています。

以下 Dependency や依存関係という言葉を使っているときは Dependency Injection の Dependency のことだとご認識ください。

アーキテクチャ

まずは、前提となるアーキテクチャの概観から説明します。

atom graph

ステート管理には Jotai を利用しており、primitive atom にはステートマシンの state だけを持つ、ステートマシンを中心に据えた設計1を採っています。

derived atom はステートマシンから導出しています。 図にあるように jotai-tanstack-query の queryOptions もステートマシンの derived atom です。 これにより、状態が遷移する度に必要に応じて fetch が走り、最新のデータが表示されます。

const isReservable$ = atom((get) => { /* snip */ })

export function useIsReservable() {
  return useAtomValue(isReservable$)
}

React コンポーネントは末端の derived atom を見ているだけなので、ロジックとは疎結合を保っています。

余談ですが、atom の命名として、かつての RxJS に倣い suffix として $ を利用しています。 以降のコード片でも同じ命名としているので $ は atom と思っていただければ。

const transition$ = atom(null, async (get, set, event: CalendarEvent) => {
  const current = get(calendarState$)
  const next = await transition(current, event)
  if (!isEqual(state, next)) {
    set(carendarState$, next)
  }
})

const selectDate$ = atom(null, (_get, set, date: string) => {
  set(transition$, calendarEvent('selectDate', { date: toDate(date) }))
})

export function useSelectDate() {
  return useSetAtom(selectDate$)
}

状態遷移は transition 関数を writable derived atom としていて、すべての変更・副作用は状態遷移を経由して実現しています。

Flux アーキテクチャではあるものの、React コンポーネントからはフックで得られた関数を呼ぶだけの独立した作りであり、表示側同様にロジックの構造とは疎結合になるように留意しています。

Dependency の管理

上述のアーキテクチャでは状態遷移を起点に、データの取得・更新など、外部とのやりとりが発生します。

テストが多くを占めますが、利用場面によって、その振る舞いを切り替えたいときがあります。

ここでは、 Jotai を Dependency の格納庫である Service Locator として活用する手法についてご紹介します。

Jotai で function を管理する

まずは軽く Jotai の TIPS 的なお話から。

Jotai では primitive atom, derived atom いずれも atom 関数で作成します。 その実装では typeof で第一引数が function かどうかを判定して、オーバーロードを行っています。

すなわち、そのままでは function を atom の値として扱えません。 derived atom とみなされてしまうためです。

そこで、以下のようなユーティリティを作成しました。

function functionAtom<F extends Function>(fn: F): WritableAtom<F, [F], void> {
  const wrapper$ = atom({ fn })
  return atom<F, [F], void>(
    (get) => get(wrapper$).fn,
    (_get, set, fn) => {
      set(wrapper$, { fn })
    }
  )
}

テスト時に function を test double に切り替える程度であれば、functionAtom ユーティリティだけで対応できます。 具体的には GraphQL クエリを実行する関数を管理しています。

export const callGraphql$ = functionAtom(callGraphql)

テストコードでは以下のように test double で置き換えています。

describe('queryRestaurants$', () => {
  test('pageCount$', async () => {
    // arrange
    const store = createStore()
    store.set(callGraphql$, vi.fn().mockResolvedValue(/* snip */))
    // act
    const page = await store.get(pageCount$) // drived from queryRestaurants$
    // assert
    expect(page).toEqual(7)
  })
})

Jotai Scope で Dependency を切り替える

次は、もう少し複雑なケースです。

コンポーネントの振る舞いを利用箇所によって切り替えたい、という場面を考えます。 カレンダーやモーダルダイアログで見られるような、複数の操作を持つ複雑なコンポーネントを想定してください。

React で素直に書くならコールバックを渡し、コンポーネント root で Context に保持して、コンポーネントの各所で使う形になるでしょう。

type Dependency = {
  onToggle: (facet: Facet) => boolean
  onCommit: (criteria: SearchCriteria) => void
}

const Context = createContext<Dependency>(defaultDependency)

export function Component(dependency: Dependency) {
  return (
    <Context value={dependency}>
      <ComponentBody />
    </Context>
  )
}

export function useOnToggle() {
  return use(Context).onToggle
}
export function useOnCommit() {
  return use(Context).onCommit
}

さて、そもそもの動機に戻ると、React に依存したコードを最小限にしたい、という背景がありました。 ロジック部分は Vanilla JS だけで完結させるのが理想的です。

言い換えれば、Jotai だけで Dependency を切り替える仕組みを作りたい、ということです。 そこで atoms in atom と jotai-scope を利用することにしました。

コードを見ていただくのが早いと思います。

type Dependency = {
  toggle$: WritableAtom<null, [Facet], boolean>
  commit$: WritableAtom<null, [SearchCriteria], void>
}

const dependencyA: Dependency = {
  toggle$: atom(null, (get, set, facet) => true),
  commit$: atom(null, (get, set, criteria) => {}),
}

const dependencyB: Dependency = {
  toggle$: atom(null, (get, set, facet) => false),
  commit$: atom(null, (get, set, criteria) => {}),
}

type Mode = 'A' | 'B'
const mode$ = atom<Mode>('A')

// atom を返す atom
const dependency$ = atom((get) => {
  switch (get(mode$)) {
    case 'A': return dependencyA
    case 'B': return dependencyB
  }
})

if (import.meta.vitest) {
  const { describe, test, expect } = import.meta.vitest
  describe('dependency$', () => {
    test('mode A toggle', () => {
      // arrange
      const store = createStore()
      store.set(mode$, 'A')
      // act
      const { toggle$ } = store.get(dependency$)
      const result = store.set(toggle$, facetFixture())
      // assert
      expect(result).toBe(true)
    })
    test('mode B', () => {
      // snip
    })
  })
}

Jotai だけで Dependency の切り替えが完結しました。

あとは React とのグルーコードです。

ここで Jotai Scope が登場します。 React コンポーネントでは、振る舞いを切り替える区分値を指定するだけになりました。

export function useToggle() {
  return useSetAtom(useAtomValue(dependency$).toggle$)
}

export function useCommit() {
  return useSetAtom(useAtomValue(dependency$).commit$)
}

export function ModeProvider({ mode, children }: PropsWithChildren<{ mode: Mode }>) {
  return (
    <ScopeProvider atoms={[mode$]}>
      <Init mode={mode} />
      {children}
    </ScopeProvider>
  )
}

function Init({ mode }: { mode: Mode }) {
  const setMode = useSetAtom(mode$)
  useEffect(() => {
    setMode(mode)
  }, [mode, setMode])
  return null
}

テスト技法

一休レストランでは単体テストに Testing Library を利用していません。

React に依存するコードを最小化することで、Vanilla JS だけで単体テストやロジックレベルのシナリオテストを実現しています。

純粋関数で書く

基本的な方針として、derived atom とその計算ロジックは峻別しています。 言い換えれば Jotai の API を利用している部分とロジックの本体となる関数を分離するようにしています。

値を取得する derived atom の例です。

const c$ = atom((get) => {
  const a = get(a$)
  const b = get(b$)
  return calc(a, b)
})

function calc(a: number, b: number) {
  return a + b
}

if (import.meta.vitest) {
  const { describe, test, expect } = import.meta.vitest
  describe('calc', () => {
    test('1 + 2 = 3', () => {
      expect(calc(1, 2)).toEqual(3)
    })
  })
}

テストコードには Jotai への依存はなく、ただの純粋関数のテストになります。

writable derived atom も同様です。

const update$ = atom(null, (get, set) => {
  const a = get(a$)
  const b = get(b$)
  set(value$, (current) => calcNextValue(current, a, b))
})

function calcNextValue(value: Value, a: A, b: B): Value { /* snip */ }

更新処理の中で次の値の計算を純粋関数として分けておけば、引数を与えて返り値を確認するだけの、もっともシンプルな形のテストとして書けるようになります。

実際のコードでは、上述したように、ロジックの中核にステートマシンを据えているので、ステートマシンにイベントを送って次状態を確認するテストがそのほとんどを占めています。

describe('calendar state machine', () => {
  test('日付を変更すると、選択されている時間帯にもっとも近い予約可能な時間を設定する', async () => {
    const fetchTimes = vi.fn().mockResolvedValue({
      restaurant: {
        reservableTimes: ['11:30', '13:00', '18:30', '20:30', '21:00'],
      },
    })
    const { transition } = createStateMachine(fetchCalendar, fetchTimes)
    const current = createCurrent()
    const result = await transition(
      current,
      calendarEvent('selectVisitDate', { visitDate: asDate('2024-10-26') })
    )
    expect(result.value).toEqual('READY')
    expect(result.context.visitTime).toEqual({
      ...current.context,
      visitDate: '2024-10-26',
      selectedVisitDate: '2024-10-26',
      visitTime: '18:30',
    })
  })
})

シナリオテスト

最後に、ロジックレベルのシナリオテストについてご紹介します。

今まで見てきたように、画面上での操作は、ロジックレベルで見ると、ステートマシンの一連の状態遷移になります。 言い換えれば、ユーザーの操作に対応する状態遷移と、ステートマシンから派生する derived atom の値がどうなっているかを確認することで、ロジックレベルのシナリオテストが実現できます。

長くなるので一部だけ抜粋しますが、以下のような形でテストを書いています。

Jotai には依存していますが、一連のユーザー操作とそのときどんな値が得られるべきかのシナリオが Vanilla JS だけでテストできるのがポイントです。

test('人数・日時・時間未指定で、日付だけ選択して予約入力へ', async () => {
  const store = createStore()
  store.set(calendarQueryFn$, async () => reservableCalendar)
  store.set(timesQueryFn$, async () => reservableTimes)
  store.set(now$, '2023-10-25T00:00:00.000+09:00' as DateTime)

  // 初期表示
  await store.set(transition$, calendarInitEvent())
  expect(store.get(visitDate$)).toEqual('2023-10-26')
  expect(store.get(visitTime$)).toEqual('19:00')

  // 日付を選んだとき
  await store.set(selectDate$, toDate('2023-11-04'))
  expect(store.get(visitDate$)).toEqual('2023-11-04')
  expect(store.get(visitTime$)).toEqual('18:30')

  // ...
})

おわりに

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

本記事がフロントエンド設計を検討する際の一助となれば幸いです。


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

www.ikyu.co.jp

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

hrmos.co


  1. 記事では XState を紹介していますが、現在は独自のステートマシン実装への置き換えを進めています。軽量サブセットである @xstate/fsm がバージョン 5 から提供されなくなったこと、型定義や非同期処理の機能不足が理由です。

Cloud WorkflowsとCloud Tasksを使って日次のバッチ処理を作る

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

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

今回は一休.com宿泊のとあるプロジェクトで必要になった 「ホテル・旅館の商品データを日次で更新する」 という処理を

  • Cloud Scheduler
  • Cloud Workflows
  • Cloud Tasks

とWeb APIで構築、運用している事例をご紹介します。

宿泊システムのバッチ処理について(背景・課題)

一休.com 宿泊には、業務に必要なデータ作成や更新を行うバッチ処理が多く存在します。たとえば

  • 投稿されたクチコミ評点を集計してホテル、旅館のスコアを更新する
  • 前月分までの宿泊予約データをもとにユーザーにポイントを付与する

などです。 これらのバッチ処理は宿泊システムの中でも古い部類に入る技術スタック(ASP.NET(C#/VB))で作られており

  • スピーディに開発できない
  • バッチ処理の開発に慣れているメンバーが限られている

といった課題がありました。

新たに必要になったバッチ処理をどうやって作るか

今年の春頃に実施したプロジェクトで「ホテル・旅館の売れ筋商品(プラン)を日次で洗替する」という処理を新たに作る必要が出てきました。

ざっくりとした要件は以下のような内容です。

  • 一休.comに掲載している一部のホテル・旅館を処理対象とする
  • 処理対象のホテル・旅館に対して、直近XX日間の予約を集計して売れ筋商品(プラン)を抽出する
  • 対象の売れ筋商品(プラン)に対してフラグを立てる
  • 処理対象のホテル・旅館は増えたり、減ったりする
  • 売れ筋商品の洗替は日次で行う

前述の背景・課題があったため「新しい開発基盤を作ってバッチ処理をスピーディに開発できるようにする」ことを考えてCTOに壁打ちをしたところ「新しい開発基盤を作る前に、そもそもこれはバッチで作るのがベストなのか?」というフィードバックをもらいました。具体的には

  • 一休.com宿泊では、歴史的経緯*1から、オンライン処理できないものをほとんどバッチで作っている
  • 現在では、そもそもバッチでまとめて処理せずに、非同期化・分散処理をする選択肢もある
  • バッチで作るのが本当にベストなのか、ほかの選択肢も含めて検討したほうがよい

といった内容でした。このフィードバック内容を踏まえて

  1. (もともとの案)新たにバッチ開発の基盤を作る
  2. マネージドなクラウドサービスを組み合わせて作る

を検討し

  • 今回実施したい作業はシンプルな処理の組み合わせで実現可能であること
  • 並列、分散処理を考えやすい要件であること(ホテル・旅館単位で処理しても問題ない)

といった理由から、最終的に2を選択しました。

Cloud Workflows + Cloud Tasks を使ったバッチ処理

クラウドサービスについて、一休では、AWSとGoogle Cloudを併用しています。 新しく作るサービスではGoogle Cloudを使うケースが増えている一方で、一休.com 宿泊ではまだ事例が少なかったこともあり、今回はGoogle Cloudを使うことにしました。

処理フロー

  • Cloud Scheduler
  • Cloud Workflows
  • Cloud Tasks

の3サービスと、シンプルなWeb APIを組み合わせた設計にしており、以下のような流れで動いています。

処理フロー

Cloud Workflows

cloud.google.com

Cloud Workflowsは、マネージドなジョブオーケストレーションサービスです。ワークフローに定義された処理順(ステップ)に従って

  • Google Cloudのサービスを実行する
  • 任意のHTTPエンドポイントにリクエストする

などを実行することができます。 公式ドキュメントにも日次のバッチジョブの例が載っており、バッチ処理がユースケースの1つであることがわかります。

ワークフローで実行したい内容(ステップ)をYAML形式で記述します。 以下は、今回作ったワークフローのイメージです。

main:
  steps:
    - init:
        assign:
          - queueName: "cloud-tasks-queue-name"
    - getTargetHotels:
        call: http.get
        args:
          url: "https://api.example.com/hotels"
          auth:
            type: OIDC
          query:
            target: true
        result: hotelData
    - createCloudTasks:
        palallel:
          for:
            in: ${hotelData.body.hotels}
            value: hotel
            steps:
              - createTask:
                  call: googleapis.cloudtasks.v2.projects.locations.queues.tasks.create
                  args:
                    parent: "projects/${sys.get_env('GOOGLE_CLOUD_PROJECT_ID')}/locations/${sys.get_env('LOCATION')}/queues/${queueName}"
                    body:
                      task:
                        httpRequest:
                          httpMethod: "PUT"
                          url: "https://api.example.com/hotels/${hotel.id}/popular"
                          headers:
                           Content-Type: "application/json"
                          oidcToken:
                            serviceAccountEmail: ${"application@" + projectId + ".iam.gserviceaccount.com"}

Workflowsから外部APIを呼び出す

getTargetHotels のステップで、Web APIへリクエストして対象のホテル・旅館を取得しています。 auth でOIDCを指定していますが、これによってWorkflowsからのAPIリクエストにAuthorizationヘッダを付与することができます。

ワークフローからの認証済みリクエスト  |  Workflows  |  Google Cloud

呼び出されるAPIで、このヘッダを使ってIDTokenを検証することで、Workflowsからのリクエストであることを保証しています。*2

以下は、IDTokenの検証をするミドルウェアのサンプル実装(Go)です。

import (
    "fmt"
    "net/http"
    "strings"

    "google.golang.org/api/idtoken"
)

// IDトークンを検証するミドルウェア
func AuthMiddleware(next http.Handler) http.Handler {
    return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
        // Authorization ヘッダからBearerトークンを取得
        authHeader := r.Header.Get("Authorization")
        if authHeader == "" {
            http.Error(w, "Authorization header is required", http.StatusUnauthorized)
            return
        }

        token := strings.TrimPrefix(authHeader, "Bearer ")

        // IDトークンの検証
        _, err := idtoken.Validate(r.Context(), token, "")
        if err != nil {
            // トークンの検証に失敗した場合はエラーを返す
            http.Error(w, "Invalid ID Token", http.StatusUnauthorized)
            return
        }

        // トークンが有効であれば、次のハンドラーを呼び出す
        next.ServeHTTP(w, r)
    })
}

APIのレスポンスをもとにCloud Tasksにエンキューする

createCloudTask のステップで、Web APIで取得した hotelData.body.hotels に含まれるホテル・旅館ごとにCloud Tasksにエンキューしています。前述したように実行順序を考慮する必要がないため、parallel を使って並列処理しています。

また、 oidcToken を指定することで、Cloud TasksがAPIリクエストを送る際にOIDCトークンを付与することができます。これによってWorkflowsからのAPIリクエストと同様に、API側でIDTokenを検証することができます。

Cloud Tasks

cloud.google.com

Cloud Tasksについては、昨年のAdvent CalendarでCTO室の徳武が詳細に解説していますので、ぜひご覧ください。

zenn.dev zenn.dev

Web API

Cloud Workflows/Cloud Tasksが呼ぶWeb APIは、以下の2つを用意しました。

  1. 処理対象のホテル・旅館を取得するAPI(GET)
  2. 指定されたホテル・旅館IDをもとに売れ筋商品を更新するAPI(PUT)

どちらのAPIも、特定のユースケースに合わせたAPIという形ではなく、単一のリソースを取得/更新するというシンプルな仕様にして再利用可能な設計にしています。

この設計にしたことによって、リリース後に「ホテル・旅館が管理システムから任意の操作をした際に、売れ筋商品を更新したい」というユースケースが出てきたときも、2のAPIを使って対応することができました。

リリース後の運用

このWorkflowsを使ったバッチ処理をリリースした後に、安定運用のためにいくつか変更したポイントがあるのでご紹介します。

Cloud Tasksのキュー設定の調整

Cloud Tasksの設定が適切ではなく、Web APIへの秒間リクエスト数が多すぎてレスポンスが遅くなるという事象があったため

  • 最大ディスパッチ数
  • 最大同時ディスパッチ数

などを調整しました。

キュー設定変更のPull Request

異常終了時の検知を強化

異常があった場合に、受動的に気付けるように

  • Workflowsのエラー処理を調整する
  • エラーログ(Cloud Logging)をSlackに通知する

といった対応をしました。

まとめ

Cloud Workflows + Cloud TasksとWeb APIを組み合わせたバッチ処理を実装した事例をご紹介しました。 個人的な所感としては、以下のようなメリットを感じています。

  • Cloud Workflowsはある程度複雑な処理も定義できるため、バッチ処理で必要な手続きをアプリケーション内部に書かずにシンプルなWeb APIとの組み合わせでバッチ処理を作れる
  • データの更新処理は特に、処理単位を小さくする & Cloud Tasksなどのキュー処理を使うと並列実行やエラー時のリトライをマネージドにできるので、運用が楽になる
    • 実際に、キュー設定の調整をする前は初回エラー → キューのリトライによって成功する、といったケースがあり、運用上問題になることはなかったです

また、今回は採用しませんでしたが、一休社内ではCloud Run Jobsを使ったバッチ処理の基盤も整ってきており、冒頭にご紹介した課題に対して複数の解決方法ができつつあるので、既存のレガシーなバッチ処理も少しずつ刷新していきたいと考えています。

おわりに

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

www.ikyu.co.jp

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

hrmos.co

明日は @yamazakik の「一休バーチャル背景を作ったはなし」です。お楽しみに!

*1:非同期ジョブキューの仕組みがない時代に作られたバッチが多く残っています

*2:実際には、この保証だけでなくほかの方法も含めて安全に運用できるように設計しています

一休.com の情シス / コーポレートIT 変遷、6年を経てどう変わったのか

はじめに

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

このエントリは 一休.com Advent Calendar 2024 16日目の記事です。昨日は id:naoya による TypeScript の Discriminated Union と Haskell の代数的データ型 でした。その他の素敵なエントリも以下のリンクからご覧ください。

qiita.com

2018年のアドベントカレンダーにて「一休における情シスの取り組み」を紹介させていただき、一定の反響をいただくことができました。 早いものであれからすでに6年が経過しました。6年も経つとコーポレートIT も変遷しています。

user-first.ikyu.co.jp

これまで特定の製品・サービスの事例などは断片的に紹介していましたが、6年ぶりに改めて全体像をお話したいと思います。

なお、主に私が進めてきたコーポレートIT、セキュリティ分野に注力して紹介します。ネットワーク、インフラ分野でも非常に多くの変遷・改善がありますが、同僚の ryoma-debari のエントリや、HPE 社のプレスリリースなどもご覧ください。

qiita.com

www.arubanetworks.com

取り組んできたこと

組織体制の変化

before: システム本部
after: コーポレート本部

社内研修資料より

一休の情報システム部門は前身となるインフラチームからの流れを汲んでエンジニア部門に所属していましたが、部署ごとバックオフィス部門に異動しました。

情シスがエンジニアとバックオフィスどちらに所属すべきか、という議論に定説はなく各の組織文化に依る部分がありますが、一休においてはバックオフィス部門に所属することで、人事総務、財務経理、法務などとの連携が円滑になり、後述する本社オフィス移転などの大規模プロジェクトもスムーズに進めることができたと思います。

一休はここ数年で新規事業が複数立ち上がり、ビジネスとしても大きく成長しており、ともなって従業員も増加していますが、情シスのチームは非常にコンパクトに運営できており、2024/12 時点で専任の社員は2名です。 ゼロタッチデプロイやプロビジョニング、ChatOps を始め、業務の自動化・改善が進み、ルーチンワークが占める割合が減ったためです。引き続き情シスの省力化に取り組みます。

オフィスファシリティの刷新

before: 赤坂
after: 紀尾井町

紀尾井町オフィス ラウンジ

長らく赤坂見附のトラディッショナルなビルに3フロア借りていましたが、2022年に当時のZホールディングス、現・LINEヤフーの本社が入居する東京ガーデンテラス紀尾井町 紀尾井タワーへ移転しました。 先日は情シスカンファレンス BTCONJP 2024 の会場にもなりました。

移転のタイミングで多くのオンプレミス資産を廃棄し、昨今のインターネット企業らしいモダンなコーポレートIT へ刷新をしました。 固定電話や FAX を廃止した 話や、入退室管理などのファシリティ周りの話については、下記エントリに詳細を書きましたので合わせてご覧ください。

user-first.ikyu.co.jp

なお、本社移転ほどの規模ではありませんが、6年間で 支社・営業所の立ち上げは6拠点、移転は8拠点 で実施しており、ほぼ常にどこかの拠点へ飛び回っていました。地方拠点においてもオンプレミスで持つ資産は廃止を進め、本社同様に固定電話や FAX、有線 LAN を廃止した非常にコンパクトなインフラ構成になりました。

Slack Enterprise Grid 移行

before: Slack Business Plus
after: Slack Enterprise Grid

10年お世話になっております

一休は2014年より Slack を利用しています、もう11年目になります。そんな10年の節目(?)にプランを最上位である Enterprise Grid へアップグレードしました。

2つあったワークスペースは1つの OrG の配下に統制され、監査ログ API やデータ損失防止(DLP:Data Loss Prevention)などのエンタープライズ組織向けのセキュリティ機能が利用可能になり、よりセキュアに利用できるようになりました。

Slack はカジュアルにコミュニケーションがとれる便利なツールである反面、情報漏えいの発生源になるリスクもあります。適切に監査・統制することで、利便性と安全性を両立していきます。

クレデンシャル情報を書き込むと自動的に検知・削除・警告をします

Enterprise Grid 向け機能のひとつである「情報バリア」については、2023年のアドベントカレンダーで解説しています。

user-first.ikyu.co.jp

デバイス管理の刷新

before: オンプレミス IT資産管理ツール
after: Microsoft Intune / Jamf Pro

Mac の標準スペックは 2024/12 時点でM4 Max(RAM 64GB)、社内に Intel Mac は0

以前は Windows と Mac それぞれの OS 向けの資産管理ツールをオンプレミスのサーバー上に載せており、オフィスのサーバールームで元気に稼働していました。 Windows Server の EOL のタイミングなどもあり、フルクラウド型のモバイルデバイス管理(MDM:Mobile Device Management)への移行を検討し、Windows は Microsoft Intune、Mac は Jamf Pro を選定しました。

MDM 導入前は入社準備でデスクに PC、iPhone、iPad を数十台並べてひたすらセットアップする光景が風物詩でしたが、Windows は Windows Autopilot、Mac、iPhone、iPad は Apple Business Manager と連携した Automated Device Enrollment によりゼロタッチデプロイが可能になり、キッティングにかかる工数を大幅に削減できました。

www.microsoft.com

www.jamf.com

iPhone / iPad については当時すでに別の MDM が導入されていたのですが、後にリプレイスを行い、現在は Mac と合わせて全て Jamf Pro で統合管理されています。これらの製品は MDM として広く知られているものなので、詳細な説明は割愛します。

当時の一休はエンジニアも含めて Windows の割合が非常に高く、 Windows / Mac 比率 8:2 という状態からの Jamf Pro 導入でした。 マイノリティである Mac は冷遇されがちでほぼ野良管理、自己責任での利用という状態から、Jamf Pro により適切に管理・統制された状態まで進めることができました。

Windows 混在環境における Jamf Pro 導入については、 Jamf Connect も含め導入事例、プレスリリースで広く紹介していただいています。

www.jamf.com

www.jamf.com

EDR / SIEM å°Žå…¥

before: オンプレミス アンチウイルスソフト
after: Microsoft Defender for Endpoint, Microsoft Sentinel

エンドポイントセキュリティもIT資産管理ツール同様、オンプレミスで稼働するアンチウイルスソフトを利用していました。

サーバーの保守運用コストがかかるだけではなく、デバイスへの負荷が大きい、最新 OS への対応が遅い、パターンマッチングでの検知・検疫はできる一方で、侵入後のリアルタイム検知ができないなどの課題もあり、EDR(Endpoint Detection and Response)型のセキュリティ製品へのリプレイスを検討している中で、Microsoft Defneder for Endpoint(以下、 MDE)を導入しました。

www.microsoft.com

Mac については Jamf Protect という製品もありますが、Windows / Mac / iOS / iPadOS などマルチ OS に対応している点からも、Apple デバイスも MDE で運用しています。

同時期に SIEM(Security Information and Event Management)として Microsoft Sentinel を導入しており、MDE や Microsoft Defender for Identity などで検知したログは Microsoft Sentinel に集約され、インシデントは Slack に通知され、リアルタイムに検知・分析・対応ができる運用をしています。

azure.microsoft.com

ライセンス・アカウント管理の改善

before: Google スプレッドシート
after: Snipe-IT, Torii

更新せずに放置していると here メンションがついて赤くなります

Google スプレッドシートなどでがんばっていたIT資産・ライセンス管理については Snipe-IT というOSS の IT資産管理ツール(ITAM:IT Asset Management)を導入しました。 OSS なので自前でホスティングすれば費用はかからず、hosting packages を利用すればランニングコストを支払い SaaS のように利用することもできます。

snipeitapp.com

Snipe-IT に登録された情報をもとに Slack に更新期日の近いライセンスを通知することで、うっかり失効してしまう、自動更新してしまい事後稟議になってしまう、といった事故を防いでいます。

また、近年では SaaS 管理プラットフォーム(SMP:SaaS Management Platform)というジャンルの、いわゆる SaaS を管理する SaaS が登場しています。国産ではジョーシスなどが有名ですが、グローバル SaaS を非常に多く取り扱う一休では Gartner の Magic Quadrant でも高く評価されている Toriiを選定 しました。

www.toriihq.com

こちらでコスト可視化や Microsoft Entra ID の SCIM(System for Cross-domain Identity Management)によるプロビジョニングに対応していない SaaS の棚卸しを実施していきます。まだ導入して日が浅いため、運用設計のノウハウが溜まってきたらどこかでアウトプットできればと思います。

ヘルプデスクの改善

before: Google フォーム
after: Jira Service Management

6年前のエントリでは Google フォームでヘルプデスク対応を行っていると書きましたが、その後、Halp という製品を導入し、Halp が Atlassian に買収されたことで、Jira Service Management(以下、JSM)に統合されました。 Slack のプレミアムワークフローが無償化したことから移行も検討していますが、現時点ではまだ機能に不足を感じており、JSM での運用を続ける予定です。

www.atlassian.com

従業員は Slack に普通に投稿するだけでチケットが自動起票され、クイックに対応可能です。出張や外出が多い営業社員もスマートフォンからスムーズに問い合わせができます。ヘルプデスクでよくある DM 問い合わせ問題も解決しています。

ヘルプデスク改善のあらましについては、下記エントリをご覧ください。

user-first.ikyu.co.jp

Slack 打刻 / 勤怠打刻自動化

before: Web アプリ / モバイルアプリ
after: Slack / Akerun 連携

Slack から打刻できるのはとても便利

一休では勤怠管理システムとしてチムスピ勤怠(TeamSpirit)を利用しています。勤怠打刻をする際は Web アプリから打刻するか、Salesforce のモバイルアプリを利用する必要がありました。 ブラウザを立ち上げて、アクセスパネルアプリケーションから TeamSpirit を開いて打刻をする、というのは少々手間であり、勤怠打刻漏れもよくおきていました。

corp.teamspirit.com

TeamSpirit が Slack 連携機能を提供開始した際には早速設定を行い、Slack で打刻が完結するようになりました。

その後、全社で利用していた入退室カードリーダーをオンプレミスのシステムから Akerun というクラウド型のカードリーダーへリプレイスを行いました。サムターンに設置するタイプの Akerun Pro のイメージが強いかもしれませんが、オフィスビルの電子錠の信号線と連携できる Akerun コントローラーという製品を選定しました。

akerun.com

これによりクラウドサービス上で統合管理ができるようになっただけではなく、API を提供していることから勤怠管理システムとの連動もできるようになりました。こちらも TeamSpirit との API 連携を行うことで、オフィスに出社している際は、オフィスへの初回入室時刻が出勤打刻、最終退室時刻が退勤打刻に自動連携 されるようになりました。

corp.teamspirit.com

パスワードマネージャー全社展開

before: 1Password (高権限者のみ)
after: Keeper

Keeper のログは全て Slack App 経由でチャンネルへ自動通知

パスワードマネージャーは以前から 1Password を利用していましたが、一部の特権を持つエンジニアのみで利用されていました。 一般の従業員は個別にパスワードを管理している状態であり一定のセキュリティリスクを感じており、パスワードマネージャー全社展開を検討していました。

数百人規模に展開する際は ITリテラシーの高くないメンバーにも使っていただくことになりマスターパスワードを紛失してしまった際の懸念や、組織変更への対応の運用負荷に懸念がありました。

そこで SAML による SSO、SCIM によるプロビジョニングに対応した Keeper へリプレイスを行い、全社展開を行いました。導入時の話は事例化もしていただいたので、詳細はこちらもご覧ください。

www.zunda.co.jp

PPAP 廃止

before: PPAP, ファイル共有ツール
after: mxHERO

一休はソフトバンクグループの会社でもあり、ソフトバンクグループは Emotet などのマルウェア対策のため、2022年にパスワード付き圧縮ファイル(いわゆる、PPAP:Password付きZIPファイルを送ります、Passwordを送ります、Angoka、Protocol)を廃止しました。

www.softbank.jp

一休も従来のセキュリティポリシーでは社外へ機密性の高いファイルを送付する際は PPAP で送信するルールでした。またメディア事業など外部と大容量のファイルをやりとりするチームへは個別にファイル共有ツールのアカウントを払い出す運用を行っていました。 このセキュリティポリシーの改定と、代替となる手段の整備を進めました。

PPAP 代替ツールについても多くの製品がありますが、一休では経済産業省 などの官公庁やエンタープライズ企業でも実績のある mxHERO を導入しました。

www.mxhero.com

cloudnative.co.jp

メールの添付ファイルを自動的にファイルストレージの安全な共有リンクに変換して送信することから、誤送信をしてしまった場合もファイルを消したり、アクセス権限を解除したりすることで、情報漏えいを防止することができます。これにより PPAP を代替できると考えました。 一休ではファイルストレージとして Google ドライブを利用しているため、mxHERO と Google ドライブを組み合わせて導入することを検討しました。

Google ドライブは Box と比較すると制限が多い

しかし、Google ドライブは Google アカウントが前提となっていることが多く、Box と比較すると制限事項が多くありました。特に共有リンクに有効期限が付与できないと、共有が不要になったファイルも、設定変更を忘れると URL を知っていれば永久的にアクセスできてしまう可能性があり、解決する必要のある課題でした。 Box の導入も検討しましたが、既存のファイル共有ツールを比較するとランニングコストが大幅に上がってしまうことから断念しました。

GAS の実装で実質的に共有 URL に有効期限を設定

そこで、GAS(Google App Script)によるスクリプトで対象の共有ドライブ内のフォルダを、送信日時タイムスタンプから1週間経過したら自動削除する 、という実装を行い、実質的に共有リンクに1週間の有効期限を設定することにしました。

これにより PPAP を廃止してセキュリティ上のリスクを低下できるだけではなく、従業員はただメールにファイルを添付するだけでよくなったためユーザビリティも向上し、また、ファイル共有サービスの解約によりアカウント管理などに伴う情シスの管理工数も削減することができました。

注意点としては 25MB を超える大容量ファイルは mxHERO のルーティングより Gmail 側の Google ドライブ URL への自動変換が実施されてしまうため、mxHERO 経由で送信することができません。そのため、大容量ファイルについては手動で共有リンクを発行する運用をしています。 こちらも GAS により有効期限を設定していますが、手動で発生している作業も将来的にはより自動化を進めたいと考えています。

ファイルサーバー移行・廃止

before: オンプレミス Windows Server
after: Google ドライブ ( Google Workspace Enterprise Plus )

一休には複数のオンプレミスのファイルサーバーが存在しておりましたが、AWS EC2 上への移行を経て、2023年にGoogle ドライブへの移行が完了し、完全に廃止 しました。

さらっと書きましたが、長年運用していたファイルサーバーにはブラックボックス化したマクロの組まれた Excel が潜んでいたり、情シスでもアクセスしてはいけない機微な情報を保管したフォルダがあったりと一筋縄で行くものではなく、全社を巻き込んでの数年がかりのプロジェクトでした。 ファイルサーバーの運用を行っている情シスの皆さんなら、この大変さを察していただけるのではないでしょうか・・・

なお、ファイルサーバーは複合機からスキャンしたファイルの置き場にもなっていましたが、オンプレミスのプリンタサーバー廃止と合わせてクラウドプリントに移行しており、スキャンしたファイルの置き場も Google ドライブに移行しました。

SASE 導入の見送り

before: VPN
after: 未定

一休のネットワーク構成は現時点ではいわゆる境界型セキュリティであり、社外から社内リソースへ接続する際にはリモート VPN で接続を行います。 「脱・VPN」に向けて以前より SASE(Secure Access Service Edge)の導入を検討しており、今年はいくつかの製品を PoC(Proof of Concept / 概念実証)まで実施しました。

大きな工数をかけて検証を行ってきましたが、特定の通信に対するパフォーマンス低下、開発環境への影響が PoC 期間中に解消せず見込みも立たなかったことから、残念ながら導入に至ることはできませんでした。

導入は見送りにはなりましたが、PoC を通じて貴重なノウハウを得ることができました。 脱・VPN やゼロトラストネットワークの実現に、SASE 導入は必須ではなく、あくまで1つの手段であると考えています。デバイストラストなど別のアプローチからも、ユーザビリティを両立したセキュリティを目指していく予定です。

まとめ

オンプレからクラウド / SaaS 中心のモダンな IT へ

解体されるサーバールームとラック

細かなプロジェクトを上げるとキリがありませんが、ここ数年の取り組みをまとめると、オンプレミスからクラウドへの転換期であったと思います。 それ故に創業当初からオンプレミスの資産がなく、フルクラウドでコーポレートIT を構築している IT企業から見ると目新しさはなく感じると思います。

一休も外から見るとモダンなIT企業に見えるかもしれませんが、1998年に創業し間もなく四半世紀を迎える会社です。多くの資産を抱えた組織であり、クラウドへの移行やゼロトラストネットワークの実現は一朝一夕で実現できるものでありません。 クラウドサービス / SaaS も導入することは目的ではなく、その後の運用設計が重要となってきます。引き続きモダンなコーポレートIT環境を目指して最適化に向けて取り組んでいきます。

色々やった。これからどうするか

これまでは導入事例の取材や、ブログ、勉強会やカンファレンスで発表で外部へアウトプットできる、わかりやすい実績がありました。一方で、クラウド / SaaS も導入・移行フェーズが終わり運用に乗った今、今後はそういった機会も少なくなり、直近は地道な改善活動が多くなってくると思います。(これをチーム内では筋トレタスクと呼んでいます)

目下の課題が解消に向かいつつある中、いかに課題を見つけ出し、ボトムアップでチーム、組織、ビジネスの課題をテクロノジーで解決していくか、を考え筋トレのように日々改善を進めていきます。 直近は現状の VPN の代替となる手段の検証と実装、セキュリティアラートの監視最適化、 DLP を活用した情報漏えい対策の強化、中長期的にはパスキーを活用した社内パスワードレス化 に向けて取り組んでいく予定です。よい成果が得られた際はまたアウトプットをしていきます。

エンジニア採用中です !

前述の通り、一休の情シスはコンパクトに運営しているため採用をしておらず、現時点で増員の予定もありません。 一方で、ソフトウェアエンジニア、SRE、データサイエンティスト、ディレクターなど多くの職種で積極的に採用をしております。

ご興味のある方は以下から Job Description をご覧ください。カジュアル面談もやっています !

www.ikyu.co.jp

TypeScript の Discriminated Union と Haskell の代数的データ型

この記事は 一休.com Advent Calendar 2024 の15日目の記事です。
予定より早く書き上げてしまったので、フライングですが公開してしまいます。

TypeScript の Discriminated Union (判別可能な Union 型) を使うと、いわゆる「代数的データ型」のユースケースを模倣することができます。一休のような予約システム開発においては「ありえない状態を表現しない」方針で型を宣言するためによく利用されています。

「あり得ない状態を表現しない」という型宣言の方針については以下の URL が参考になります。

Designing with types: Making illegal states unrepresentable | F# for fun and profit

このユースケースで Discriminated Union を使う場合、それは文字どおり「型の判別」のために使われます。この場合、判別の手がかりとなる「ディスクリミネーター」はただの分岐のためのシンボル程度の役割にしか見えないでしょう。しかしこれは、本機能の部分的な見方でしかないと考えています。

Haskell など、TypeScript のように模倣ではなく、型システムに代数的データ型がネイティブに組み込まれているプログラミング言語では、代数的データ型こそが新たなデータ型とデータ構造を宣言する手段です。代数的データ構造とパターンマッチを用いて、一般的なオブジェクトだけでなく、リストや木構造などのデータ型を構築・操作することができます。こちらのメンタルモデルから見ると、代数的データ型こそが、データの構築と分解を型安全かつ表現力豊かに扱う基盤を提供するものであり、型駆動開発を支える根幹であると捉えることができます。

本記事では TypeScript の Discriminated Union による代数的データ型の模倣についてまずその基本を確認し、その後 Haskell の代数的データ型の文法をみていきます。後者をみて先のメンタルモデルを獲得したのちに前者を改めて眺めてみることにより、新たな視点で TypeScript の機能を捉えることを目指します。

TypeScript の Discriminated Union (判別可能な Union 型)

TypeScript の Discriminated Union (判別可能な Union 型) を使うと、他のプログラミング言語でいうところの代数的データ型のユースケースを模倣することができます。Discriminated Union はディスクリミネーター (もしくはタグ) と呼ばれる文字列リテラルにより Union で合併した型に含まれる型を判別できるところから「タグつき Union 型」と呼ばれることもあります。

typescriptbook.jp

Discriminated Union をうまく使うと、アプリケーション開発において「存在しない状態」ができることを回避することが出来ます。存在する状態のみを型で宣言することで「存在しない状態ができていないこと」を型チェックにより保証することができます。書籍 Domain Modeling Made Functional などでも語られている非常に有用な実装パターンであり、一休が扱う予約などの業務システム開発でも頻繁に利用しています。

少しその様子を見てみます。

典型例として、何かしらのシステムのユーザー (User) について考えます。ユーザーには会員登録済みの会員 (Member) と、会員登録はしていないゲスト会員 (Guest) の区分があるというのは、よくあるケースでしょう。会員はユーザーID、名前、メールアドレスなどの値をもつが、ゲストはそれらが確定していない。

このとき ユーザーID が null なデータをゲストユーザーとして扱うという実装もあり得ますが、null チェックが必要になるし「ID が null なのがゲスト」という暗黙の仕様を持ち込むことになってしまいます。null に意味は与えたくありません。

そこで以下のように、Member と Guest を定義します。

type User = Member | Guest

type Member = {
  kind: "Member"
  id: number
  name: string
  email: string
}

type Guest = {
  kind: "Guest"
}

User 型のオブジェクトがあったとき、そのオブジェクトが Member 型なのか Guest 型なのかは kind プロパティの値によって判別できます。この kindプロパティが型の判別に使われるディスクリミネーター (あるいはタグ) です。

例えば、Member か Guest かでプレゼンテーションを分けたいというときは以下のように switch 文により Union 型を分解し、それぞれの型ごとに処理を記述することができます。

function showUser(user: User): string {
  switch (user.kind) {
    case "Member":
      return `ID: ${user.id}, Name: ${user.name}, Email: ${user.email}`
    case "Guest":
      return "Guest"
    default:
      assertNever(user)
  }
}

export function assertNever(_: never): never {
  throw new Error("Unexpected value. Should have been never.")
}

assertNever は網羅性チェックのためのイディオムで、これを置くことでナローイングの結果 User 型に含まれるすべての型に対し処理を定義したかを、コンパイル時にチェックすることができます。

以下の絵は実装途中の VSCode です。Member に対する処理は記述したが Guest に対する処理はまだ記述していない段階。コンパイラがエラーを出してくれています。

網羅性チェックによるコンパイルエラー

そして kind プロパティすなわちディスクリミネーターはリテラル型になっており、補完が効きます。

ディスクリミネーターの補完が効く

このように、Union により構造の異なる複数の型を合併しつつもディスクリミネーターによってそれを分解することができ、ナローイングによって型や網羅性チェックが効くことから、代数的データ型をエミューレトできていると言われます。ディスクリミネーターに基づいた switch 文での型の分解は、さながら「パターンマッチ」のように捉えられます。

仮に Discriminated Union を使わず、ゲストユーザーを「ID が null」で表現したとすると以下のように定義することになります。

type User = {
  id: number | null
  name?: string
  email?: string
}

この場合、たとえば ID が null にも関わらず name や email が null でない、という「ありえない状態」を表現できてしまいます。

これは Record 型が AND (積) に基づいたデータ構造の宣言であり、3 つのプロパティがそれぞれ「ある・なし」の 2パターンを取り、その積で合計 8 パターンの状態を取れてしまうことに起因しています。8パターンの状態の中には、実際にはあり得ない状態が含まれます。「ある・ なし」の分岐は ID に関してだけでよいのに、ほかの 2 つのプロパティまでそれに巻き込まれてしまった結果です。

Union 型は OR (和) に基づく合併なので「ID、名前、メールアドレスがある」 Member に、「プロパティがない」 Guest の状態を「足している」だけ。状態の積は取りません。よって合併しても状態が必要以上に増えません。

Making illegal states unrepresentable (ありえない状態を表現しない) というのはこういうことです。

実際のユースケース ··· 絵文字アイコンあるなしの表現

もうひとつ、我々の実際のアプリケーションでの実例の中から、簡単なものを紹介します。

我々の作ってる飲食店向け予約台帳システムには顧客管理の機能がありますが、顧客にタグ付けして分類することができます。タグは視認性向上のため絵文字が設定できるようになっています。

タグには絵文字が使える

タグを新しく作るときは絵文字を設定することができます。絵文字は設定しても、しなくても OK という仕様になっています。

絵文字は設定しても、しなくても OK

さて、このタグ用のアイコンである TagIcon のデータをどう管理するか、型を考えます。

「アイコンがない」というのを null で表現しようとしがちですが、「アイコンなし」という状態はそれはそれで存在する状態と考えることもできます。これを NoIcon という型にしてみます。「ない」を「ある」とみなすことで、状態を定義することができました。

結果、以下のように Union で表現することができるでしょう。こうして null に意味を持たせることを回避します。

type TagIcon = EmojiIcon | NoIcon

type EmojiIcon = {
  kind: "Emoji"
  symbol: string
}

type NoIcon = {
  kind: "NoIcon"
}

型を宣言したからには、この型の値を生成できるようにしましょう。コンストラクタ関数を定義します。このとき、型名と関数名を同じにする コンパニオンオブジェクトパターン を使うと良いです。

function EmojiIcon(symbol: string): EmojiIcon {
  return { kind: "Emoji", symbol }
}

function NoIcon(): NoIcon {
  return { kind: "NoIcon" }
}

少し話しが脱線しますが、EmojiIcon の symbol の文字列が確かに絵文字かどうかをチェックすることで、値の完全性をより厳密にすることができます。

function EmojiIcon(symbol: string): Result<EmojiIcon, ValidationError> {
  return symbol.match(/\p{Emoji}/gu) ? ok({ kind: "Emoji", symbol }) : err(new ValidationError('Emoji ではありません'))
}

プロダクトの実装ではそうしていますが、例外をどう扱うかなど本稿とは関係のないトピックが出てきてしまうので以降省略します。

もとい、これで型、つまりは値の構造の定義とその生成方法を定義できました。あとは先にみた User の例のように、アイコンが絵文字か・絵文字なしかで処理を切り分けたいときは kind プロパティでパターンマッチ的に分解すればよいです。

function toHTMLIcon(icon: TagIcon): string {
  switch (icon.kind) {
    case "Emoji":
      return icon.symbol
    case "NoIcon":
      return ""
    default:
      assertNever(icon)
  }
}

export function assertNever(_: never): never {
  throw new Error("Unexpected value. Should have been never.")
}

追加の仕様で絵文字だけでなく、オリジナルのアップロード画像も扱いたいとしましょう。その場合は Union に新たに ImageIcon 型を追加すればよいでしょう。

type TagIcon = EmojiIcon | NoIcon | ImageIcon // ImageIcon を新たに併合

type EmojiIcon = {
  kind: "Emoji"
  symbol: string
}

type NoIcon = {
  kind: "NoIcon"
}

// これを追加
type ImageIcon = {
  kind: "Image"
  url: string
  name: string
}

ImageIcon 型を Union に追加すると、パターンマッチしている分岐で網羅性チェックが働き、期待通り、コンパイルが通らなくなります。型に応じた処理を追加します。

function toHTMLIcon(icon: TagIcon): string {
  switch (icon.kind) {
    case "Emoji":
      return icon.symbol
    case "NoIcon":
      return ""
    case "Image": // これを追加しないとコンパイルエラー
      return `<img src="${icon.url}" alt="${icon.name}" />`
    default:
      assertNever(icon)
  }
}

実際に作った型を値として使う場合は、以下のような使い方になります。

const icon1 = EmojiIcon("🍣")
const icon2 = NoIcon()
const icon3 = ImageIcon("https://example.com/image.png", "Example Image")

console.log(toHTMLIcon(icon1)) // 🍣
console.log(toHTMLIcon(icon2)) //
console.log(toHTMLIcon(icon3)) // <img src="https://example.com/image.png" alt="Example Image" />

Discriminated Union により型を構造化し、コンパニオンオブジェクトパターンで生成を実装し、switch 文によるナローイングでパターンマッチ的に分解を実装しました。null を使わず NoIcon という状態を導入したおかげで見通しよく、静的検査を有向に活用しながら実装できました。

ディスクリミネーターは、ただの判別用のシンボル?

ここまででも十分、Discriminated Union の有用性が確認できますが、仕組みとしてはオブジェクトのプロパティに kind など適当なプロパティ名でディスクリミネーターを忍ばせた程度にも見えます。

TypeScript レイヤではナローイングによって型チェックが効くなど上手いこと機能していて座布団一枚! という感じ (?) もありますが、JavaScript のレイヤーでみるとただオブジェクトのプロパティの文字列で分岐しているだけのようにも思えて、そんなに本質的な事柄なのか? とも思えてしまいます。

Discriminated Union が表現できるものは、この程度のものと思っておけばいいのでしょうか? いいえ、という話を続けてみていこうと思います。

Haskell のデータ型宣言

代数的データ型を「模倣できる」 TypeScript ではなく、代数的データ型を型システムにネイティブで搭載しているプログラミング言語、たとえば Haskell で同じ実装がどうなるのか、見てみましょう。

以下のように実装できます。

import Text.Printf (printf)

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

toHTMLIcon :: TagIcon -> String
toHTMLIcon NoIcon = ""
toHTMLIcon (EmojiIcon symbol) =  symbol
toHTMLIcon (ImageIcon url name) = printf "<img src=\"%s\" alt=\"%s\" >" url name

main :: IO ()
main = do
  let icon1 = NoIcon
      icon2 = EmojiIcon "🍣"
      icon3 = ImageIcon "https://exmaple.com/image.png" "Example Image"

  putStrLn $ toHTMLIcon icon1
  putStrLn $ toHTMLIcon icon2
  putStrLn $ toHTMLIcon icon3

TypeScript での実装に比較すると分量がかなり短くなっています。とは言え、コードが短いかどうかはあまり重要ではありません。より詳細に見てみましょう。

まず、TypeScript のケースとは異なりコンストラクタの明示的な実装がないことに気がつきます。

そして toHTMLIcon 関数の引数でパターンマッチをしていますが、TypeScript のディスクリミネーターに相当するのは文字列リテラル的な値ではなく NoIcon EmojiIcon ImageIcon などのシンボルです。Haskell ではこれを「データコンストラクタ」と呼びます。データコンストラクタにより TagIcon 型の値を分解することができています。

TagIcon 型の宣言にもデータコンストラクタが使われています。データコンストラクタはデータ型の形状や構造を定義するものとしても使われます。

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

そして値を生成するときも、データコンストラクタが使われています。

  let icon1 = NoIcon
      icon2 = EmojiIcon "🍣"
      icon3 = ImageIcon "https://exmaple.com/image.png" "Example Image"

このように Haskell ではデータコンストラクタが「タグ付き Union」におけるタグ相当ですが、データコンストラクタは型に基づいた値の分解、データ型の構築、値の生成と、データ型にまつわる操作を提供するものになっています。

TypeScipt で Discriminated Union とコンパニオンオブジェクトパターン、switch 文 と複数の文法を組み合わせて模倣していた機能が、Haskell ではデータコンストラクタという仕組みによって、より密結合された、統一的なかたちで実現されています。これが Haskell における代数的データ型(Algebraic Data Types, ADT)の特徴です。

そして Haskell では新しい型とデータ構造を定義する基本的な方法が、この data キーワードによる宣言です。 ···ということは、このデータコンストラクタを中心とした代数的データ型の文法でより複雑なデータ構造とその型を宣言することができることを意味します。

代数的データ型でより構造的なデータ型を扱う

永続データプログラミングと永続データ構造 - 一休.com Developers Blog で紹介した、二分木 (による永続データ配列) の実装を見てみましょう。実装詳細には立ち入らず、雰囲気だけみてもらえばよいです。

-- データ型の宣言
data Tree a = Leaf a | Node (Tree a) (Tree a)

-- 木を根から走査。パターンマッチと再帰で辿っていく
read :: Int -> Tree a -> a
read _ (Leaf x) = x
read i (Node left right)
  | i < size left = read i left
  | otherwise = read (i - size left) right

write :: Int -> a -> Tree a -> Tree a
write _ v (Leaf _) = Leaf v
write i v (Node left right)
  | i < size left = Node (write i v left) right
  | otherwise = Node left (write (i - size left) v right)

size :: Tree a -> Int
size (Leaf _) = 1
size (Node left right) = size left + size right

fromList :: [a] -> Tree a
fromList [] = error "Cannot build tree from empty list"
fromList [x] = Leaf x
fromList xs =
  let mid = length xs `div` 2
   in Node (fromList (take mid xs)) (fromList (drop mid xs))

main :: IO ()
main = do
  let arr = fromList [1 .. 8 :: Int]

  print $ read 3 arr -- 3

  let arr' = write 3 42 arr

  print $ read 3 arr' -- 42
  print $ read 3 arr  -- 3

重要なポイントとしては、コメントに書いたとおり (1) 完全二分木の木構造を data キーワードのみで宣言していること、(2) 木の中から目的のノードを探すにあたりパターンマッチで分解しながら走査していること、の 2 点が挙げられます。

データ型の宣言を改めてみてみましょう。

data Tree a = Leaf a | Node (Tree a) (Tree a)

Tree 型が再帰的に宣言されているのがわかります。再帰データ型が宣言できるため、木のようなデータ構造を代数的データ型により構築することができます。

さて、こうして木を実装する例をみると代数的データ型は、冒頭でみたような、ただの型を合併して判別する機能というものではなく、まさに「データの型と構造を構築するためのもの」だというのがわかります。

同様にリスト構造の List 型を自前で実装してみましょう。リストの走査として先頭に要素を追加する cons 関数と、リストの値それぞれを写像する mapList 関数も実装してみます。

data List a = Empty | Cons a (List a) deriving (Show)

empty :: List a
empty = Empty

cons :: a -> List a -> List a
cons = Cons

mapList :: (a -> b) -> List a -> List b
mapList _ Empty = Empty
mapList f (Cons x xs) = Cons (f x) (mapList f xs)

-- テスト出力
main :: IO ()
main = do
  let xs = cons 1 (cons 2 (cons 3 empty))

  print (mapList (* 2) xs) -- Cons 2 (Cons 4 (Cons 6 Empty))

先の二分木に同じく、data キーワードにより再帰データ型を定義してリストのデータ構造を構築しています。mapList 関数ではパターンマッチを用いてリストを走査し、リストが保持する値に写像関数を適用しています。データコンストラクタが、データ構造の構築とパターンマッチによる分解双方に利用されていることがわかります。

このように Haskell のデータ型は「値がどのように構造化され、意味づけられるか」を定義する手段です。データコンストラクタはその手段を提供し、構築と分解という双方向の操作を統一的に扱えるようにします。

この観点に立つと、データ型とデータコンストラクタの役割は次のように整理できそうです。

  1. データ型は、プログラム内の「概念モデル」を定義する
  2. データコンストラクタは、そのモデルの構築ルールを提供する
  3. パターンマッチによる分解は、そのモデルを解析し操作する方法を提供する

TypeScript に同様のメンタルモデルを持ち込む

Haskell のデータ型の宣言をここまで見てから、改めて TypeScript に戻ってきましょう。代数的データ型に対するメンタルモデルが大きく更新されているはずです。

その視点で、改めて Discriminated Union よる代数的データ型の模倣を見てみましょう。「 kind プロパティは分岐目的のもの」ではなく Haskell 同様 「データ型を構築、分解する手段」として捉えることができるのではないでしょうか?

さて、TypeScript の型システムも Haskell 同様、再帰データ型は宣言できます。先の Haskell で実装したリストを、TypeScript で、これまでみた Discriminated Union、コンパニオンオブジェクトパターン、switch 文によるパターンマッチのイディオムで、実装してみます。

type List<T> = Empty | Cons<T>

interface Empty {
  kind: "Empty"
}

interface Cons<T> {
  kind: "Cons"
  head: T
  tail: List<T>
}

function Empty(): Empty {
  return { kind: "Empty" }
}

function Cons<T>(head: T, tail: List<T>): Cons<T> {
  return { kind: "Cons", head, tail }
}

type map = <T, U>(f: (a: T) => U, xs: List<T>) => List<U>

const map: map = (f, xs) => {
  switch (xs.kind) {
    case "Empty":
      return Empty()
    case "Cons":
      return Cons(f(xs.head), map(f, xs.tail))
    default:
      assertNever(xs)
  }
}

export function assertNever(_: never): never {
  throw new Error()
}

const xs: List<number> = Cons(1, Cons(2, Cons(3, Empty())))
console.log(map(i => i * 2, xs))

以下が実行結果です。Discriminated Union で構造化されたリストと、各値が写像により倍化された結果が得られています。

$ deno run -A list.ts
{
  kind: "Cons",
  head: 2,
  tail: {
    kind: "Cons",
    head: 4,
    tail: { kind: "Cons", head: 6, tail: { kind: "Empty" } }
  }
}

TypeScript でも無理なく、再帰データ構造を実装できました。

比較してみると TypeScript による代数的データ型は模倣だけあって、Haskell ほど簡潔に表現することはできません。一方で、それをどのようなメンタルモデルで捉えるかは、プログラミング言語の文法には左右されないでしょうから、Haskell のそれ同様に捉えてもよいでしょう。簡潔性は及ばないものの、機能的にはさほど遜色のない実装をすることができました。もちろん、より複雑なパターンマッチを要するものまで実現できるかどうかや、ランタイム性能の影響まで考慮すると Haskell 同等とまではいきませんが。

目論見どおり、TypeScript の Discriminated Union に対する印象をアップデートすることができたでしょうか? できていることを願います 😀

実務で Discriminated Union を用いて再帰データ構造を宣言する、という機会はあまりないとは思いますが、それがただの Union で併合された型を判別できるものと小さく捉えるのではなく、本稿でみた通りデータ型の構築と分解の観点で捉えておくと視点が拡がるでしょうし、より広範囲に適用していってよいものだという確証が得られるのではないかと思います。

余談

TypeScript と Haskell を比較する記事を、過去に幾つか書きました。

TypeScript の型システムは JavaScript の上に後付けされたものということもあり、非常にプラクティカルで便利である一方、個人的には、やや散らかっていてその全体像や各機能の本質を掴みにくいと感じています。Haskell など表現に妥協の少ないプログラミング言語と比較し、相対化することでより深い理解に繋がることは多いです。

Enjoy !

Design Doc でチームを跨いだ開発を円滑に行う

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

宿泊事業本部 ユーザー向け開発チームの原です。 一休.com と Yahoo!トラベルの主にフロントエンドの開発を担当しています。

今回は、普段の開発でコードを書き始める前段階で Design Doc を作ることで、円滑な開発を進められるようになったというお話をします。

チーム構成について

まず、前提を共有するために私達が普段どのような体制で開発しているかを説明します。
私が所属している宿泊事業本部 ユーザー向け開発チームは、一休.com と Yahoo!トラベルの主に toC のユーザー向けの機能開発をしています。ユーザー向け開発チームのメインのミッションはユーザー体験を向上させることであり、そういった施策の機能開発を素早くリリースできることを大事にしています。
一方、プロダクト開発においては機能開発だけではなく、プログラミング言語や依存ライブラリのアップデートや、アーキテクチャの見直しといったシステムの健全性を向上させる取り組みも重要です。
機能開発とシステム改善を同じチームが両立して行えることが理想的かもしれません。しかし、Nuxt でできたフロントエンドのアプリケーションに関しては、施策に関する機能開発はユーザー向け開発チームが、システム改善はフロントエンド改善チームという専任のチームが担当しています。
これは、変化の激しいフロントエンド開発でベストプラクティスを追い求めるには施策開発とシステム改善をする責務を分けたほうが進めやすいという判断によるものです。
実際、フロントエンド改善チームの取り組みにより、

  • Nuxt2 から 3 へのアップデート
  • Options API から Composition API への書き換え

といった Vue/Nuxt 界隈の進化に追従したり、

  • GraphQL の client-preset の導入
  • デザインシステムの推進

なども機能開発を止めずに完了しています。こういった取り組みにより、かなり開発者体験がいい環境で日々機能開発ができています。

少し古いエントリーですが、フロントエンド改善チームの取り組みは以下でご確認できます。 user-first.ikyu.co.jp

開発チームと改善チームが分かれている状態においては、うまくコミュニケーションを取らないと問題が生じます。
お互いどんな取り組みをするのか共有しないと、

  • 開発チームの施策で触るコードと、改善チームのリファクタリングしたいコードがコンフリクトする
  • 改善チームが行ったリアーキテクトを開発チームがちゃんと理解しないとベストプラクティスではない実装をしてしまう

といったことが起こり得ます。
特に「ベストプラクティスではない実装をしてしまう」というのは避けたい問題です。
そのため、開発チームが実装した機能は小さな修正を除いては基本的に Pull Request (以下 PR) でレビューしてもらうことになっています。
実際レビューの際に、最適な実装にたどり着くまで時間がかかってしまったということが何度かありました。

前置きが長くなりましたが、こうした別のチームにコードレビューを依頼するとき、円滑な開発を進めるために私が必要だと思っていることを紹介します。

コードレビューについて

私はレビュアーとしてコードをレビューするのは非常に労力のかかる仕事だと思っています。
よく「実装が終わって PR を出したので、もう少しで完了します」みたいなことを言ってしまいがちですが、コードレビューは実装と同等か、場合によってはそれ以上の負担が発生しうる作業だと思っています。
というのも、Approve されるとリリースできるという運用においては、レビュアーの仕事はコード書く人(レビュイー)と同等の責任が発生するためです。
いきなり数百行、数千行規模の差分が発生する修正をレビューするときには

  • その施策や修正の背景
  • 実現するための最適な設計になっているか
  • その diff を取り込むことでどんな影響が起こり得るか

などを考える必要がありますが、それらを一から考えるのは、コードを最初から書くのと同じくらいの負担がかかるものです。
上記のような考慮はコードを書く側(レビュイー)は当然考えたうえで実装しているはずなので、レビュイーからレビュアーにうまく伝えられると負担を軽減できます。
どういった工夫でレビュアーの負担を軽減しようとしているかを紹介します。

いきなりコードを書かない

先程も述べたような差分が数百行、数千行規模の PR をいきなりレビューしてもらうのは、PR の description やコメントをいくら丁寧に書いたとしても、レビュアーの負担は大きいです。
そこで実装に入る前の段階で Design Doc を作成して、大筋の実装内容について合意を取るようにしています。
Design Doc は以下のようなアウトラインで書いています。

## このドキュメントの目的
## やりたいこと
// ここではビジネス的な視点でなぜこの施策をするのかを書きます
## 仕様
// ここでは上記のやりたいことを満たす機能要件を書きます
## 対応内容
// ここではシステム的な視点でどんな対応が必要なのかを書きます

このドキュメントの目的、やりたいことが記載された Design Doc のスクショ

Design Doc の目的は、実装者とレビュアーの間で大まかな実装の合意をとることです。

新規ページ作成を例にすると

  • URL をどう命名するか
  • コンポーネントの階層と、各コンポーネントをどう命名するか
  • サーバー(GraphQL)からデータをどのように取得するか
  • 機能要件を満たすロジックをどう実装するか
    • 既存のロジックで使えるものは何か

などを Design Doc で決定します。
特に命名は先に決めておくと実装、レビューともに楽です。

既存のロジックを使えるというアドバイスがもらえる

(↑ 既存のロジックを使えるというアドバイスがもらえる)

Design Doc で事前に実装方針の合意をとることで、「なぜこのような設計にしたのか」をレビュアーがレビュー時に考える必要がなくなります。 また、レビューする段階で大まかな実装イメージがついているので、レビューの負担が軽減されると考えています。

Pull Request を出す際に気をつけていること

Design Doc との乖離がある場合

Design Doc で実装方針の合意をとれたら、実装をして、完了したらレビューに出します。
当然、実装する中で Design Doc で決めた通りにいかなかったり、もっといい方法が見つかったりすることもあるでしょう。
それを何も共有せずレビューに出してしまうとせっかく実装方針を決めた意義が薄れてしまいます。
Design Doc 時の決定と大きく変わる場合は、レビューを出す前に Design Doc 自体を修正して、もう一度合意を取り直すようにしています。
Pull Request の Description やコメントにその旨を書くだけで伝わるような些細な変更の場合は、レビュー段階でそれを伝えるようにします。

レビュアーの負担を最小限に

当然ですが、レビューを依頼する前に自分で見つけられる粗は見つけておくべきなので、自分がレビュアーのつもりでセルフレビューをします。
施策とは直接は関係ないリファクタリングなど、レビュアーが「これはなぜいま修正が必要なのか?」と疑問を持ちそうな箇所はコメントを残しておきます。

  • 動作確認方法
  • 影響する既存機能が元通り動いていることをどうテストしたのか

といった情報も記載します。
また、実装していてもっと良い書き方があるはずだが思いつかなかったような場合、どんなことを試してうまく行かなったということを残しておくとよいでしょう。

最後に

今回はチーム間を跨いだレビューで私が気をつけていることを紹介しました。
常にペアプロ・モブプロを行っていたり、チームの成熟度が高い場合は Design Doc を作成することの必要性は薄いかもしれません。
ただ、実装タイミングでどんな意思決定がなされたのかという情報は、時間が経った後から見返す際、有用になります。
また、

  • レビューのコストは実装と同じくらいのコストになり得る
  • レビュアーの負担はレビュイーの工夫次第で軽減できる

というのはどこでも共通する話だと思います。

永続データプログラミングと永続データ構造

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

昨今は我々一休のような予約システム開発においても、関数型プログラミング由来のプラクティスを取り入れる機会が増えています。

例えば、値はイミュータブルである方が扱いやすい、関数は副作用のない純粋関数にする方がテスタビリティなども含め何かと都合がよい、そういう場面では積極的に不変な値を使い、関数が冪等になるよう意識的に実装します。ドメインロジックを純粋関数として記述できると、堅牢で責務分離もしやすく、テストやデバッグもしやすいシステムになっていきます。

ところで「関数型プログラミングとはなんぞや」というのに明確な定義はないそうです。ですが突き詰めていくと、計算をなるべく「文」ではなく「式」で宣言することが一つの目標だということに気がつきます。

文と式の違いは何でしょうか?

for 文、代入文、if 文などの文は、基本的には値を返しません。値を返さないということは、文は直接結果を受け取るものではなく、命令になっていると言えます。文は計算機への命令です。

一方の式は、必ず返値を伴いますから、その主な目的は返値を得る、つまり式を評価して計算の結果を得ることだと考えることができます。

customer.archive()

と、文によって暗黙的に customer オブジェクトの内部状態を変更するのではなく

const archivedCustomer = archiveCustomer(customer)

と、引数で与えられた customer オブジェクトを直接変更することなしに、アーカイブ状態に変更されたコピーとしての archivedCustomer オブジェクトを返値として返す、これが式です。この関数は純粋関数として実装し、customer オブジェクトは不変、つまりイミュータブルなものとして扱うと良いでしょう。

式によるイミュータブルなオブジェクトの更新は TypeScript なら

export const archiveCustomer = (customer: Customer): Customer => ({
  ...customer,
  archived: true
})

と、スプレッド構文を使うことで customer オブジェクトのコピーを作りつつ、変更したいプロパティを新たな値に設定したものを返すように実装します。

このように、引数で与えたオブジェクトは直接変更せず、状態を変更した別のオブジェクトを返すような関数の連なりによって計算を定義していくのが関数型プログラミングです。

このあたりの考え方については、過去の発表スライドがありますので参考にしてください。

実際、我々の一部プロダクトのバックエンドでは TypeScript による関数型スタイルでの開発を実践しています。以下はプロダクトのコードの一例で、Customer オブジェクトに新しいメールアドレスの値を追加するための addEmail 関数です。先の実装に同じく、スプレッド構文を使って元のオブジェクトを破壊せずに、メールアドレスが追加されたオブジェクトを返します。

const addEmail =
  (address: EmailAddress) =>
  (customer: Customer): Customer => {
    const newAddress: CustomerEmail = {
      id: generateCustomerEmailId(),
      address,
    }
    return {
      ...customer,
      emails: [...customer.emails, newAddress],
    }
  }

ドメインオブジェクトの状態遷移はすべて、この式による状態遷移のモデルで実装しています。

永続データプログラミング

さて、本記事のテーマは「永続データ」です。永続データとは何でしょうか?

式を意識的に使い、かつ値をイミュータブルに扱うことを基本としてやっていくと、何気なく書いたプログラムの中に特徴的な様子が現れることになります。

以下、リスト操作のプログラムを見てみましょう。リストの先頭や末尾に値を追加したり、適当な値を削除する TypeScript のプログラムです。リストをイミュータブルに扱うべく、値の追加や削除などデータ構造の変更にはスプレッド構文を使い、非破壊的にそれを行うようにします。

// as1: 元のリスト
const as1 = [1, 2, 3, 4, 5];

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

// as4: 新しいリスト (値 3 を削除)
const as4 = as3.filter(x => x !== 3);

console.log("as1:", as1); // [1, 2, 3, 4, 5]
console.log("as2:", as2); // [100, 1, 2, 3, 4, 5]
console.log("as3:", as3); // [100, 1, 2, 3, 4, 5, 500]
console.log("as4:", as4); // [100, 1, 2, 4, 5, 500]

更新をしても元のリストは不変なので、as1 を参照しても更新済みの結果は得られません。リスト操作の返り値を as2 as3 as4 とその都度変数にキャプチャし、そのキャプチャした変数に対して次のリスト操作を行います。こうしてデータ構造は不変でありつつも一連の、連続したリスト操作を表現します。

データ構造を不変にした結果、リストが更新される過程の状態すべてが残りました。リストを何度か更新したにも関わらず、変更前の状態を参照することができています。as1 を参照すれば初期状態を、as2 や as3 で途中の状態を参照することができます。このように値の変更後もそれ以前の状態が残るさまを「永続データ」と呼びます。そして永続データを用いたプログラミングを「永続データプログラミング」と呼びます。

値をイミュータブルに扱うと必然的にそれは永続データになるので、永続データプログラミングはそれ自体、何か特別なテクニックというわけではありません。一方で、値が永続データであることをはっきりさせたい文脈上では「永続データプログラミング」という言葉でプログラミングスタイルを表現すると、その意図が明確になることも多いでしょう。

以下の山本和彦さんの記事では、関数型プログラミングすなわち「永続データプログラミング」であり、永続データを駆使して問題を解くことこそが関数型プログラミングだ、と述べられています。

筆者の関数プログラミングの定義、すなわちこの特集での定義は、「⁠永続データプログラミング」です。永続データとは、破壊できないデータ、つまり再代入できないデータのことです。そして、永続データを駆使して問題を解くのが永続データプログラミングです。

また関数型言語とは、永続データプログラミングを奨励し支援している言語のことです。関数型言語では、再代入の機能がないか、再代入の使用は限定されています。筆者の定義はかなり厳しいほうだと言えます。

第1章 関数プログラミングは難しくない!―初めて学ぶ人にも、挫折した人にもきちんとわかる | gihyo.jp

命令型プログラミングにおいては変更にあたり値を直接破壊的に変更します。変更前のデータ構造の状態を参照することはできません。リストの破壊的変更は、基本的に (式ではなく) 文によって行われるでしょう。文を主体としたプログラミング··· 命令型プログラミングでは、永続ではないデータ、つまり短命データを基本にしていると言えます。一方、式によってプログラムを構成する関数型プログラミングでは、関数の冪等性を確保すべくイミュータブルに値を扱うことになるので、永続データが基本になります。

イミュータブルな値によるプログラミングをする際、そこにある値は不変であるだけでなく、同時に永続データなのだということを認識できると、プログラミングスタイルに対するよりよいメンタルモデルが構築できると思います。

Haskell と永続データプログラミング

やや唐突ですが、イミュータブルといえば純粋関数型言語の Haskell です。先の TypeScript によるリスト操作のプログラムを、Haskell で実装してみます。

main :: IO ()
main = do
  let as1 = [1, 2, 3, 4, 5]
      as2 = 100 : as1
      as3 = as2 ++ [500]
      as4 = delete 3 as3

  print as1 -- [1,2,3,4,5] 
  print as2 -- [100,1,2,3,4,5]
  print as3 -- [100,1,2,3,4,5,500]
  print as4 -- [100,1,2,4,5,500]

Haskell はリストはもちろん、基本的に値がそもそもがイミュータブルです。リスト操作の API はすべて非破壊的になるよう実装されているので、変更にあたり TypeScript のようにスプレッド構文でデータを明示的にコピーしたりする必要はありません。裏を返せば、変更は永続データ的に表現せざるを得ず、式によってプログラムを構成することが必須となります。結果、Haskell による実装は自然と永続データプログラミングになります。

関数型プログラミングすなわち永続データプログラミングだ、というのは、この必然性から来ています。

永続データの特性を利用した問題解決

永続データプログラミングは不変な値を使うことですから、それを実践することで記事冒頭で挙げたようなプログラムの堅牢性など様々なメリットを享受できるわけですが、「変更前の過去の状態を参照できる」という、値が不変であるというよりは、まさに「永続」データの特性が部分が活きるケースがあります。

わかりやすい題材として、競技プログラミングの問題を例に挙げます。

atcoder.jp

問題文を読むのが面倒な方のために、これがどんな問題か簡単に解説します。入力の指示に従ってリストを更新しつつ、任意のタイミングでそのリストの現在の状態を保存する。また任意のタイミングで復元できるようするという、データ構造の保存と復元を題材にした問題です。

ADD 3
SAVE 1
ADD 4
SAVE 2
LOAD 1
DELETE
DELETE
LOAD 2
SAVE 1
LOAD 3
LOAD 1

こういうクエリが入力として与えられる。

  • 空のリストが最初にある
  • クエリを上から順番に解釈して、ADD 3 のときはリスト末尾に  3 を追加する
  • DELETE なら末尾の値を削除
  • SAVE 1 のときは、今使っているリストを ID 番号  1 の領域に保存、LOAD 1 なら ID 番号  1 の領域からリストを復元する
  • クエリのたび、その時点でのリストの末尾の要素を出力する

という問題になっています。

この問題を永続データなしで解こうとすると、リストを更新しても以前の状況に戻れるような木のデータ構造を自分で構築する必要がありなかなか面倒です。一方、永続データを前提にすると、何の苦労もなく解けてしまいます。

以下は Haskell で実装した例です。やっていることは、クエリの内容に合わせてリストに値を追加・削除、保存と復元のときは辞書 (IntMap) に、その時点のリストを格納しているだけです。問題文の通りにシミュレーションしているだけ、とも言えます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力

Haskell のリストは永続データですから、値を変更しても変更以前の値が残ります。その値が暗黙的に他で書き換えられる事はありません。よって素直にリストを辞書に保存しておけばよいのです。一方、命令型プログラミングにおいてリストがミュータブルな場合は、ある時点の参照を辞書に保存したとしても、どこかで書き換えが発生すると、辞書に保存された参照の先のデータが書き換わるためうまくいきません。

永続データ構造

さて、ここからが本題です。TypeScript でリストを永続データとして扱うにあたり、スプレッド構文によるコピーを使いました。

// as2: 新しいリスト (先頭に 100 を追加)
const as2 = [100, ...as1];

// as3: 新しいリスト (末尾に 500 を追加)
const as3 = [...as2, 500];

すでにお気づきの方も多いと思いますが、値の更新にあたり、リスト全体のコピーが走ってしまっています。一つ値を追加、削除、更新するだけでもリストの要素  n 件に対し  n 件のコピーが走る。つまり  O(n) の計算量が必要になってしまいます。永続データプログラミングは良いものですが、ナイーブに実装するとデータコピーによる計算量の増大を招きがちです。

Haskell など、イミュータブルが前提のプログラミング言語はこの問題をどうしているのでしょうか?

結論、データ構造全体をコピーするのではなく「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」ことによって計算量を抑え、不変でありながらも効率的なデータ更新が可能になるようにリストその他のデータ構造が実装されています。つまり同じ「リスト」でも、命令型プログラミングのそれと、不変なデータ構造のそれは実装自体が異なるのです。抽象は同じ「リスト」でも具体が違うと言えるでしょう。

変更あったところだけをコピーし、それ以外は元の値と共有を行うこのデータ構造の実装手法は Structural Sharing と呼ばれることもあります。Structural Sharing により不変でありながら効率的に更新が可能な永続データのデータ構造を「永続データ構造」と呼びます。

永続データ構造については、以下の書籍にその実装方法含め詳しく記載されています。

純粋関数型データ構造 - アスキードワンゴ

もとい、例えば Haskell のリストは先頭の値を操作する場合は  O(1) です。先頭要素だけがコピーされていて、それ以降の要素が更新前後の二つのリストで共有されるからです。

同じく、先の実装でも利用した Data.IntMap という辞書、こちらも永続データ構造ですが、内部的にはパトリシア木で実装されていて、値の挿入やキーの探索は、整数のビット長程度の計算量 ···  n をデータサイズ、 W をビット長としたとき  O(min(n, W)) に収まります。

Haskell で利用する標準的なデータ構造 ··· List、Map、Set、Sequence、Heap は、すべてイミュータブルでありながら、値の探索や変更が  O(1) や  O(\log n) 程度の計算量で行える永続データ構造になっています。(なお、誤解の無いよう補足すると、ミュータブルなデータ構造もあります。ミュータブルなデータ構造は手続き的プログラミングで変更することになります)

永続データ構造を利用することによって、永続データプログラミング時にもパフォーマンスをそれほど犠牲にせず、大量のデータを扱うことが可能になります。裏を返せば、永続データプログラミングをより広範囲に実践していくには、永続データ構造が必要不可欠であるとも言えます。関数型プログラミングは値が不変であることをよしとしますが、そのためには永続データ構造が必要かつ重要なパーツなのです。

TypeScript その他のプログラミング言語で永続データプログラミングを実践するとき、純粋関数型言語とは異なり、素の状態では永続データ構造の支援がないということは念頭に置いておくべきでしょう。

TypeScript や Python で永続データ構造を利用するには?

TypeScript の Array、Map、Set などの標準的なデータ構造はすべて命令型データ構造、つまりミュータブルです。命令型のプログラミング言語においては、どの言語も同様でしょう。一方、プログラミング言語によっては List、Map、Set などの永続データ構造バージョンを提供するサードパーティライブラリがあります。

これらのライブラリを導入することで、TypeScript や Python で永続データ構造を利用することができます。しかし、実際のところこれらの永続データ構造の実装が、広く普及しているようには思えません。

永続データ構造は業務システム開発にも必須か?

結論からいうと、命令型のプログラミング言語で業務システム開発をする場合には、必須ではないでしょう。

永続データプログラミング自体は良い作法ですが、業務システムにおいては、大量データのナイーブなコピーが走るような実装をする場面が少ないから、というのが理由だと思います。

Haskell のような関数型言語を使っているのであれば、永続データ構造は標準的に提供されていて、そもそも必須かどうかすら気にする必要がありません。永続データ構造のメカニズムを全く知らなくても、自然にそれを使ったプログラムを書くように導かれます。

命令型言語を使いつつも、永続データプログラミングを実践するケースではどうでしょうか? 速度が必要な多くの場面では、いったん永続データを諦め、単に命令型データ構造を利用すれば事足りるので、わざわざ永続データ構造を持ち出す必要はないでしょう。ドメインオブジェクトの変更をイミュータブルに表現するためコピーする場合も、せいぜい 10 か 20 程度のプロパティをコピーする程度で、コピー 1回にあたり数万件といったオーダーのコピーが発生するようなことは希でしょう。

よって業務システム開発において Immutable.js や pyrsistent のようなサードパーティライブラリを積極的に使いたい場面は、先に解いた競技プログラミング問題のように、永続データ構造の永続である特性そのものが機能要件として必要になるケースに限られるのではないか? と思います。

Immutable.js の開発が停滞しているのは、フロントエンドで永続データ構造の需要が乏しいからでしょう。このようなデータ構造自体は非常に重要な概念で、多くのプログラミング言語に存在します。我々フロントエンドエンジニアが依存するブラウザの内部でも、効率的なデータ処理のために多用されているはずです。しかし、フロントエンドエンジニアがイミュータブルに求めているのは処理速度ではなく設計の改善です。だからこそ、Immutable.js に代わって Immer が隆盛したのでしょう。

Immutable.jsとImmer、ちゃんと使い分けていますか?

一方、純粋関数型言語で競技プログラミングのような大きなデータを扱うプログラミングを行う場合、永続データ構造は必須ですし、また永続データ構造を利用していることを意識することでよりよい実装が可能になると思っています。個人的にはこの「永続データ構造によって、より良い実装が可能になる」点こそが本質的だと思っています。

先の競技プログラミングの実装を改めてみてみます。

main :: IO ()
main = do
  q <- readLn @Int
  qs <- map words <$> replicateM q getLine

  let qs' = [if null args then (command, -1) else (command, stringToInt (head args)) | command : args <- qs]

  let res = scanl' f ([], IM.empty) qs'
        where
          f (xs, s) query = case query of
            ("ADD", x) -> (x : xs, s) -- リストに値を追加
            ("DELETE", _) -> (drop1 xs, s) -- リストから値を削除
            ("SAVE", y) -> (xs, IM.insert y xs s) -- 辞書にこの時点のリストを保存
            ("LOAD", z) -> (IM.findWithDefault [] z s, s) -- 辞書から保存したリストを復元
            _ -> error "!?"

  printList [headDef (-1) xs | (xs, _) <- tail res] -- 各クエリのタイミングでのリストの先頭要素を得て、出力 (※)

このプログラムでは、クエリのたびに、その時点でのリストの値を出力する必要があります。が、上記のプログラムでは (クエリのたびに都度出力を得ているのではなく) クエリを全部処理し終えてから、最終的な出力、つまりプレゼンテーションを組み立てています。(※) の実装です。

データ構造が命令型データ構造の場合、こうはいきません。ある時点のデータ構造の状態はその時点にしか参照できないため、プレゼンテーションをそのタイミングで得る必要があります。

一方、永続データ構造の場合、各々時点のデータ構造の状態を後からでも参照できますし、メモリ上にデータ構造を保持しておいても Structural Sharing によりそれが肥大化することもありません。このプログラムのように、中核になる計算 ··· つまりドメインロジックをすべて処理し終えてから、改めてプレゼンテーションに変換することが可能です。プレゼンテーション・ドメイン分離の観点において、永続データ構造が重要な役割を果たしています。この考え方は、実装スタイルに大きな影響を与えます。

この点に関する詳細は、競技プログラミング文脈を絡めて話す必要もあり長くなりそうなので改めて別の記事にしようと思います。

さて、業務システム開発には必須とは言えないと私見は述べましたが、命令型プログラミング言語でも値を不変に扱うとき、このナイーブなコピーが走る問題を意識できているかどうかは重要でしょう。多くの関数型言語においてはこの課題を永続データ構造によって解消しているということは、知っておいて損はありません。

永続データ構造の実装例

「永続データ構造」というと字面から何かすごそうなものを思い浮かべるかもしれませんが、その実装方法を知っておくともう少し身近なものに感じられると思います。永続データ構造の中でも比較的実装が簡単な、永続スタックと永続配列の実装を紹介して終わりにしたいと思います。実装の詳細については解説しませんが、雰囲気だけみてもらって「何か特別なことをしなくても普通に実装できるんだな」という雰囲気を掴んでもらえたらと思います。

永続スタック

Haskell で実装した永続スタックの一例です。再帰データ型でリストのようなデータ構造を宣言し、API として head tail (++) など基本的な関数を実装します。

代数的データ型でリンクリスト構造を宣言し、先頭要素への参照を返すように実装します。先頭要素を参照したいとき (head) は、先頭要素への参照からそれを取り出し値を得るだけ。先頭以外の要素を得る、つまり分解したいとき (tail) は次の要素への参照を返す。これだけで永続スタックが実装できます。

二つのスタックを結合する ((++)) ときはどうしても  O(n) かかってしまいますが、その際も双方のリストをコピーするのではなく古いリストの一方だけをコピーし、のこりの一つは新しいリストで共有されるように実装しています。

import Prelude hiding ((++))

data Stack a = Nil | Cons a (Stack a) deriving (Show, Eq)

empty :: Stack a
empty = Nil

isEmpty :: Stack a -> Bool
isEmpty Nil = True
isEmpty _ = False

cons :: a -> Stack a -> Stack a
cons = Cons

head :: Stack a -> a
head Nil = error "EMPTY"
head (Cons x _) = x

tail :: Stack a -> Stack a
tail Nil = error "EMPTY"
tail (Cons _ xs) = xs

(++) :: Stack a -> Stack a -> Stack a
Nil ++ ys = ys
Cons x xs ++ ys = Cons x (xs ++ ys)

main :: IO ()
main = do
  let s0 :: Stack Int
      s0 = empty
      s1 = cons (1 :: Int) s0
      s2 = cons (2 :: Int) s1
      s3 = cons (3 :: Int) s1

      s4 = s1 ++ s3

  print s0
  print s1
  print s2
  print s3
  print s4

出力結果は以下です。

Nil
Cons_1_Nil
Cons_2_(Cons_1_Nil)
Cons_3_(Cons_1_Nil)
Cons_1_(Cons_3_(Cons_1_Nil))

永続配列

永続配列は、配列といっても命令型の配列のように連続した領域を索引で参照できるようにするモデルではなく、完全二分木で表現します。

値は葉に持たせて、インデックスによる参照時には根から二分木を辿って目的の葉を特定します。そのため、参照時の計算量は  O(1) ではなく  O(\log n) となります。

二分木による配列の表現

更新時には「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」という考えに従い、根から更新対象の葉までを辿る経路上のノードをコピーする経路コピーという手法を使います。経路をコピーするといっても、木の高さ程度ですから更新も結局  O(\log n) になります。

経路コピーについては Path Copying による永続データ構造 - Speaker Deck のスライドがわかりやすいと思います。

{-# LANGUAGE DeriveFunctor #-}

import Prelude hiding (read)

data Tree a = Leaf a | Node (Tree a) (Tree a) deriving (Show, Functor)

fromList :: [a] -> Tree a
fromList [] = error "Cannot build tree from empty list"
fromList [x] = Leaf x
fromList xs =
  let mid = length xs `div` 2
   in Node (fromList (take mid xs)) (fromList (drop mid xs))

read :: Int -> Tree a -> a
read _ (Leaf x) = x
read i (Node left right)
  | i < size left = read i left
  | otherwise = read (i - size left) right

write :: Int -> a -> Tree a -> Tree a
write _ v (Leaf _) = Leaf v
write i v (Node left right)
  | i < size left = Node (write i v left) right
  | otherwise = Node left (write (i - size left) v right)

size :: Tree a -> Int
size (Leaf _) = 1
size (Node left right) = size left + size right

main :: IO ()
main = do
  let arr = fromList [1 .. 8 :: Int]
  print arr

  print $ read 3 arr

  let arr' = write 3 42 arr

  print $ read 3 arr'
  print $ read 3 arr

永続スタック、永続配列の実装を簡単ですが紹介しました。

何か特殊な技法を使うというものではなくスタック、配列などの抽象が要求する操作を考え、その抽象に適した具体的で効率的なデータ構造を用意する、というのが永続データ構造の実装です。

まとめ

永続データプログラミングと永続データ構造について解説しました。

  • 不変な値を使い、式でプログラムを宣言すると永続データプログラミングになる
  • 永続データプログラミングでは、変更前の値を破壊しない。変更後も変更前の値を参照できるという特徴を持つ
  • 関数型プログラミングすなわち永続データプログラミングである、とも考えられる
  • 永続データプログラミングにおけるデータコピーを最小限に留め効率的な変更を可能にする不変データ構造が「永続データ構造」
  • 業務システム開発において、永続データ構造は必須とは言えない。パフォーマンスが必要な場面で、永続データ構造を持ち出す以外の解決方法がある
  • 大量データを扱うことが基本で、かつ値を不変に扱いたいなら永続データ構造は必須
  • 一般のシステム開発においても機能要件として「永続」データが必要になるなら、Immutable.js とかを利用しても良いかも
  • 関数型プログラミングが、不変でありながらも値の変更をどのように実現しているかは永続データ構造に着目するとよく理解できる

というお話でした。

途中少し触れた、永続データ構造を前提にした計算の分離については別途あらためて記事にしたいと思います。

追記

以下に記事にしました。

zenn.dev

一休.com Developers Blogの執筆環境2024

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

kymmtです。

当ブログ「一休.com Developers Blog」は、以前からはてなブログで運用しています。そして、今年からは執筆環境を少し改善しました。具体的には、GitHubを用いて記事の作成や公開ができるようにしました。

この記事では、当ブログの執筆環境をどのように改善し、ふだん運用しているかについて紹介します。

HatenaBlog Workflows Boilerplateの導入

従来は、執筆者が記事をローカルやブログ管理画面のエディタ上で書き、なんらかの方法でレビューを受け、公開するというフローでした。このフローで一番ネックになりやすいのはレビューで、Slack上でレビューが展開されがちになり、議論を追いづらいという問題がありました。

そこで、執筆環境の改善のために、はてなさんがβ版として公開しているHatenaBlog Workflows Boilerplateを利用して、記事執筆用のリポジトリを整備しました。

github.com

これは、GitHub Actionsのはてなブログ用reusable workflow集であるhatenablog-workflowsを用いた、はてなブログ記事執筆用のリポジトリテンプレートです。

リポジトリを整備した結果、GitHub上ではてなブログの記事をMarkdownファイルとして管理したり、GitHub Actionsで原稿の同期や公開などの操作を実行できるようになりました。

現在は、記事執筆のフローは次のようになっています。

  1. 下書き用pull request (PR)作成actionを実行
  2. 手元にブランチを持ってきて執筆
  3. ときどきコミットをpushしてはてなブログに同期し、プレビュー画面で確認
  4. PR上でレビュー
  5. 公開できる状態にしてPRをmainにマージし、自動で記事公開

普段開発に携わっているメンバーはGitHubに慣れています。そのようなメンバーがPR上でブログ記事執筆やレビューができるようにすることでの執筆体験向上を図りました。とくに、レビューをPRで実施することで、あるコメントがどの文章に対するものなのか分かりやすくなる点は便利だと感じています。社内のメンバーからも、

ブログが GitHub で管理できるようになったのは、レビューの観点でホントありがたい

という感想をもらっています。

記事のネタ管理

記事のネタはGitHubのissueとして管理しています。どの執筆PRでネタが記事になったのかや、ネタに関する細かいメモも記録しています。情報の一元化の観点では、スプレッドシートなどで別管理するよりは好ましいと思います。

æ ¡æ­£

校正はtextlintを利用しています。pull_requestトリガーでtextlintによる校正を実行するGitHub Actionsのワークフローを設定しています。ワークフローは.github/workflows/textlint.yamlに次のようなものを置いています。

name: textlint

on:
  pull_request:
    paths:
      - "draft_entries/*.md"
      - "entries/*.md"

jobs:
  run:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
        with:
          fetch-depth: 0
      - uses: actions/setup-node@v4
      - run: npm ci
      - name: Add textlint problem matcher
        run: echo "::add-matcher::.github/textlint-unix.json"
      - name: Lint files added/modified in the PR
        run: |
          git diff --diff-filter=AM origin/main...HEAD --name-only -- '*.md' | xargs npx textlint --format unix

.github/textlint-unix.jsonは次のとおりです。

{
  "problemMatcher": [
    {
      "owner": "textlint-unix",
      "pattern": [
        {
          "regexp": "^(.+):(\\d+):(\\d+):\\s(.*)$",
          "file": 1,
          "line": 2,
          "column": 3,
          "message": 4
        }
      ]
    }
  ]
}

これでPR画面上にtextlintによる校正結果が表示されるようになります。

problem matcherによるtextlintの校正結果の表示

textlintで現在使っているルールは次のとおり最低限のものです。技術記事もテクニカルライティングの一種であるとは思いますが、Web上で気軽に読んでもらう類のものでもあるため、文体にある程度個性が出るのは問題ないと考え、そこまで厳しいルールにはしていません。

レビュアー

記事ファイルが配置されるディレクトリのコードオーナーに技術広報担当者を設定して、担当者のレビューが通れば記事を公開してOK、というフローにしました。現在は2名の担当者で回しています。

HatenaBlog Workflows Boilerplateを利用しているという前提で、.github/CODEOWNERSを次のように設定しています。ここで@orgにはGitHub orgの名前が、teamには技術広報担当者からなるteamの名前が入ります。

/draft_entries/ @org/team
/entries/ @org/team

この設定に加えて、リポジトリのbranch protection rulesとしてmainブランチでコードオーナーのレビューを必須にすることで、必ず技術広報担当者が目を通せる仕組みとしています。

とはいえ、各記事の内容については執筆者のチームメンバーのほうが深く理解していることも多いです。ですので、内容の正確性という観点ではチームメンバーどうしでレビューしてもらい、技術広報担当者は会社の名前で社外に公開する記事としてふさわしいかという観点でのチェックをすることが多いです。

余談: HatenaBlog Workflows Boilerplateへのフィーチャーリクエスト

上記のBoilerplateを用いてリポジトリを運用しているうちに、ほしい機能が1つ生まれたので、該当リポジトリのissueを通じて機能をリクエストしました。

github.com

具体的には、下書きPRを作成したとき、そのPRはdraft状態になっていてほしいというものです。GitHubの仕様上、PRがdraftからready for reviewになるとき、コードオーナーへレビュー依頼の通知が送られるようになっています。ですので、記事を書き始めるときのPRとしてはdraftとするのが自然と考えた、というものです。

結果、機能として無事取り込んでいただけました。ありがとうございました1。

staff.hatenablog.com

おわりに

この記事では2024年の当ブログの執筆環境について紹介しました。GitHubを起点とする執筆環境や自動化を取り入れることで、執筆体験を向上できました。この記事も紹介した仕組みで書きました。引き続き、このブログで一休での開発における興味深いトピックをお届けしていければと思います!


  1. 機能リリース後に、draft機能がGitHubの有料プランの機能であることに伴うフォローアップもしていただきました。ありがとうございました

一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します

kymmtです。

11/30に開催されるRust.Tokyo 2024に一休はゴールドスポンサーとして協賛します。

rust.tokyo

一休でのRustの活用

一休では一休.comレストランにおいてRustの活用を進めています。昨年に当ブログで活用の様子を紹介した際は、当時開発が進んでいたレストラン予約サービスWeb UIのバックエンドにおけるユースケースだけに触れていました。

user-first.ikyu.co.jp

それから1年弱経過した現在では、Rust活用の場はさらに広がっており、Rustを書いているメンバーも増えてきています。

Rust.Tokyoのゴールドスポンサー

2024年はRustでWebサービスを開発するのがますます現実的な選択肢として挙がるようになった年だったのではないかと思います。たとえば、RustでWebアプリケーションを開発するための本が複数発売されるなど1、学習リソースは着実に揃ってきています。このような環境のなかで、一休もRust採用企業として活用事例の共有などを通じてコミュニティを活性化させることが重要だと考えています。

以上のような状況に基づいて、一休はこのたびRust.Tokyo 2024にゴールドスポンサーとして協賛させていただくことになりました。当日は会場で

  • スポンサーセッション登壇
  • ブースの出展

をやります。

スポンサーセッションでは、トラックAで15:25から「総会員数1,500万人のレストランWeb予約サービスにおけるRustの活用」と題してkymmtが発表します。2024年現在の一休.comレストランにおけるRust活用の様子のスナップショットとして、システムの設計や技術的なトピックについて紹介する予定です。

また、会場ではブースを出展する予定です。技術広報チームを中心に、一休.comレストランのエンジニアとしてkymmtが、ほかにも宿泊予約サービスの一休.comのエンジニアとEMがブースに参加する予定です。一休のサービス開発の様子について興味があるかたはぜひお越しください。

おわりに

11/30に一休はRust.Tokyo 2024にゴールドスポンサーとして協賛します。参加者のかたは当日会場でお会いしましょう!