TECH PLAY

株式会社一休

株式会社一休 の技術ブログ

161

一休.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 内包表記により、環境の受け渡しを省略できます。 processOrder は findOrder を呼び出していますが、 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" )); findOrder も processOrder も引数に文脈を持っていません。 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 ラムダ計算においては、ラムダ抽象 λx. M の本体 M に現れる変数のうち、λx によって束縛されているものを束縛変数、それ以外を自由変数と呼びます。 ↩ 古い Lisp の例を考えていたのですが Perl でも local で書けることを同僚が教えてくれました。 ↩ 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." ↩ 現在の Emacs Lisp ではレキシカルスコープを選択することが可能です。 ↩ 横断的関心事(cross-cutting concern)といえば2000年代に注目された AOP(Aspect Oriented Programming, アスペクト指向プログラミング)ですが、その主要なユースケースのひとつに文脈伝播の自動化がありました。ログ出力やトランザクションのコンテキストなどは、ThreadLocal 等の操作を裏側で隠蔽する典型的な例でした。 ↩
アバター
はじめに 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 など新しい製品・技術も積極的に取り入れ、モダン化を進めています。 【正社員】コーポレートエンジニア(社内インフラ・ネットワーク担当) - 株式会社一休 少しでもご興味があれば、ぜひカジュアル面談からでもお気軽にご応募ください!
アバター
この記事は 一休.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 要素を生成するような場面はほとんどなく、読者の中には馴染みのない方も多いのではないかと思います。 さて、外部リソースを操作するという観点では同じようにみえる HTMLImageElement と HTMLScriptElement が、なぜこのような振舞いの違いを持つのでしょうか? 最古の 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 厳密には、他にも HTMLOptionElement がコンストラクタ Option を持っています。 ↩ 最速のサイトとよくネタにされる阿部寛さんのホームページですが、この頃の一般的なつくりのまま残っている貴重なサイトで、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, 3 と実行されるが ローカルトランザクション3 が失敗し、 ローカルトランザクション1, 2 に対して補償トランザクションを実行してロールバック 補償ログ削除 最後にピボットマーカーを削除 このようにして、トランザクション全体をロールバックすることが出来ました。 またこのプロセスは、冪等に実行することが可能です。 ロールバック処理では、複数ある補償トランザクションのうちのひとつの実行に失敗したりすることがあり得ます。 そのほか、サーバーのプロセスごと落ちたなどでロールバック全体が完了しなかった場合にも、 実行する必要のある補償トランザクションを確実に実行する必要があります。 そのため我々は、一連のロールバック処理を予約の失敗時にサーバーから同期的に実行することに加えて、 定期的に残っているピボットマーカーを見て、サーバーから実行したものと同じロールバック処理を再実行するジョブを Cloud Run Jobs で用意しています。 ロールバック処理を冪等に実行出来るようにすることで、このように確実にロールバックが完了するように実装することが出来ます。 制約 ここまで説明してきた実装パターンが適用出来る前提として、以下があります。 4 ローカルトランザクションが同期的に実行できること ここでいう「同期的」とは、ローカルトランザクションの成否がピボットトランザクション実行までに確定していることを指します ローカルトランザクション同士が強く結合していないこと 順序制約はあってもよいが、補償トランザクションに必要な情報が前段の実行結果に依存しない(=実行前に補償ログへ必要情報を確定できる)こと トランザクション全体としての一貫性は結果整合で良いこと 予約失敗の場合には一時的にでも在庫が引当されてはいけない、と言った制約がある場合はこの実装パターンには向きません これよりも厳しい要件が必要な場合、この実装パターンそのままは適用できません。 この実装パターンの特徴・利点 紹介した実装パターンの特徴や利点として以下の点があげられると思います。 これらについては、既存のシステムからも、実際実装していて改善しているなというところを感じることが出来ています。 Saga パターンなどを意識せずドメインロジックの実装が可能 ここまで書いておいてなんですが、アプリケーションロジックを実装する際は、このようなことを気にせずに進められるならそれに越したことはないと思います。 ここまで説明してきた実装パターンは、主に I/O を実行するレイヤでのみ気にすれば良いものになっています。 したがって、ドメインロジックと I/O を適切に分離できていれば、ここまでの補償トランザクション周りの実装についても、 ドメインロジックを実装する際に意識する必要はなくなります。 ローカルトランザクションの追加が容易 ローカルトランザクション毎に補償ログ・補償トランザクションの実装を用意すれば、ローカルトランザクションを追加することは比較的容易です。 実際に予約リニューアルプロジェクトを進める中で、段階的にローカルトランザクションを大きな労力なく追加していくことが出来ました。 ローカルトランザクションの変更が容易 ローカルトランザクションそれぞれの独立性が高いため、ローカルトランザクションの実行タイミングや順序などが変更しやすくなります。 例えば在庫引当はもっと早いタイミングに実行してしまいたい、と言った変更です。 おわりに 一休では、ユーザーにより良い体験を提供するため、より良いシステムを一緒につくっていくエンジニアを募集しています。 www.ikyu.co.jp まずはカジュアル面談からお気軽にご応募ください! job.persona-ats.com 我々はオーケストレーションパターン、同期通信、結果整合ということで「おとぎ話 Saga」というものに分類されるようです ↩ 世代を選ぶ話題かもしれないですが ↩ ローカルトランザクションが外部 I/O を含む場合、ローカルトランザクションも冪等であることを必要とされることが多いと思います ↩ ここでいう制約が、まさに 1. で紹介した「おとぎ話 Saga」の特徴です ↩
アバター
背景・課題 料金・ポイント計算処理の現状整理と課題、改善策 前提)宿泊システムでの料金とポイント計算 1. 料金・ポイント計算をどんな業務で使っているか 2. 料金・ポイント計算各業務の特徴と違い 3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題) 4. 本来あるべき姿に向けて、現状からどう改善していくか 現状と今後の展望 まとめ おわりに 宿泊プロダクト開発部の田中( id:kentana20 )です。 このエントリーは 一休.com Advent Calendar 2025 の11日目の記事です。 今回は、一休.com宿泊で進めている 「ホテル/旅館の宿泊料金・ポイントを計算する処理が複数のシステムに分散している状態を改善している」 という取り組みについてご紹介します。 背景・課題 一休.com 宿泊には、いくつか重要な業務が存在しますが、その1つに「宿泊料金・ポイントの計算処理」があります。 各ホテル・旅館が設定した料金 サイトを閲覧しているユーザー(会員)の状態 ユーザーが指定している検索条件(日付、人数など) 期間限定で実施しているポイントX倍、のようなプロモーション などの情報に基づいて、宿泊料金を算出したり、予約で得られるポイント数を計算する処理です。 この料金・ポイント計算処理は、以下のような背景・課題がありました。 歴史的経緯から、料金・ポイント計算ロジックが複数のシステムに分散して存在している (複数システムに分散しているため)ロジックの変更を行う際に、複数のシステムに対して同じ変更を繰り返し実施する必要がある 「今年の冬は、こういうポイントアップのプロモーションを実施したい」というビジネスのニーズに対して、必要以上に対応コストがかかってしまう 昨今、ECをはじめとするWebサービスにおいてポイントやクーポンといった販促・インセンティブ機能はビジネス上も重要な要素となっており、一休.com 宿泊においても例外ではありません。 これを踏まえて、各システムで実施している料金・ポイント計算処理を整理し、本来あるべき姿を検討して改善を進めることにしました。 料金・ポイント計算処理の現状整理と課題、改善策 本来あるべき形を検討するにあたり、まずは現状の料金・ポイント計算処理がどうなっているかを整理するところから始めました。 整理については 料金・ポイント計算をどこで、どんな業務で使っているか それぞれの業務で、料金・ポイント計算にどんな特徴・違いがあるか 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題) 本来あるべき姿に向けて、現状からどう改善していくか という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の整理を踏まえて、各業務での料金・ポイント計算にどんな特徴があるかを見ていきました。 結果として以下のような違い(特徴)があることがわかりました。 情報の鮮度に関する違い ある程度の精度・鮮度で料金を計算できればよい業務(検索) リアルタイムに正確な料金を計算する必要がある業務(予約) 扱うデータ量の違い 大量の宿泊プランのデータを一括で処理する業務(検索用データ作成・更新、マーケティング用途向けデータ作成・更新) 指定の条件にあった宿泊プランをリアルタイムに処理する業務(予約) 必要な情報の違い 検索では指定条件でトータルのポイント付与率、ポイント数がわかればよい 予約ではプロモーション単位でポイント付与率、ポイント数がわかる必要がある これを抽象的に捉えると バッチ処理として大量のデータを一括で処理する業務 リアルタイムに個別のデータを処理する業務 の2つに大別できることがわかりました。 また、各業務を整理する中で「料金」と呼んでいるものが複数存在していて、呼び名が統一できていないこともわかりました。 宿泊料金(ホテル・旅館が設定した基本料金) ポイント値引き後の料金 クーポン・ポイント値引きなど、すべての割引を適用した後の最終的な支払料金 などです。これについては、料金の種類を整理してどこでどの料金を使う必要があるのかをまとめました。 3. 本来あるべき料金・ポイント計算ロジックの姿と、現状のギャップは何か(課題) 次のステップとして、それまでの整理をもとに、現状の課題を洗い出して本来あるべき姿を検討しながら、取り組む課題を明確にしていきました。 課題: 宿泊サービスの中で、料金・ポイント計算ロジックが複数のシステムに分散して存在している 長くサービスを運用しているため、新旧それぞれのシステムで料金・ポイント計算ロジックが実装されている状態になっている システム移行の過程では避けられない側面もあります 一方で、新しいシステムの中でもロジックは共有しきれておらず、用途によって分けたシステムごとにロジックが分散している状態になっている 検索用のインデックスデータとマーケティング用データの作成・更新処理がサブシステムに分かれており、ロジックが共有できていない 等 これに対して、本来あるべき姿は、料金・ポイント計算ロジックは1箇所に集約し、各システムから共通で利用できる形にすることと考えて、ロジックの集約に向けた取り組みを行うことにしました。 4. 本来あるべき姿に向けて、現状からどう改善していくか 課題を踏まえて、本来あるべき姿に向けてどう改善していくかを検討しました。 改善のステップとして、以下を考えて進めています。 新システム内でのロジック集約・共通化 ステップ1: バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化 ステップ2: リアルタイムに個別のデータを処理する業務内も含めてロジックを集約・共通化 システム全体でのロジック集約・共通化 ステップ3(案): 既存システムから新システムのロジックを呼び出し、システム全体でロジックを集約・共通化 現状と今後の展望 現在は、ステップ1として「バッチ処理として大量のデータを一括で処理する業務内でロジックを集約・共通化」を進めており、先に挙げた業務のうち (a) 検索を高速に行うためのデータ作成・更新業務 後続の検索業務に必要なホテル・旅館の料金を確定するために必要な情報を非正規化して作成・更新する (c) 社内でのマーケティング用途向けのデータ作成・更新業務 社内でのデータ分析業務に必要な宿泊プランの料金情報を作成・更新する ユーザーに送る販促メールなどに掲載する宿泊プランの料金を計算する の2つの業務を扱うバッチ処理に対して、1つの料金・ポイント計算ロジックを使う形に改善を進めています。今月ようやくプロトタイプが動くようになり、来年1月頃のリリースを目指して進行中です。 リリース後は引き続き、ステップ2を進めていく予定です。 まとめ 今回は、一休.com宿泊で進めている「宿泊料金・ポイント計算処理の改善」という取り組みについて紹介しました。個人的な所感として、既存システムの現状・課題を整理したことで まだその業務を十分に知らないメンバーが理解するために役立った ほかのサービスで同様の課題を考える際に参考になった といった出来事があり、宿泊システムの改善を考える以外の面でもプラスの効果があったと感じています。 おわりに 一休では、事業の成果をともに目指しつつ、システムの改善も進めていく仲間を募集しています。 www.ikyu.co.jp まずはカジュアル面談からお気軽にご応募ください! https://hrmos.co/pages/ikyu/jobs/1745000651779629061 hrmos.co 明日は @rotomx の「一休の情シス / コーポレートIT 2025」です。お楽しみに! *1 : 一休宿泊では、ユーザーが指定した条件で検索結果を素早く表示する必要があるため、Solr(検索エンジン)に必要な情報をインデックスしています
アバター
この記事は 一休.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 に切り替わる点)を効率的に求めるのが本質だ、という解釈です。以下の文書などでも詳しく解説されています。 AtCoder灰・茶・緑色の方必見!二分探索を絶対にバグらせないで書く方法 この境界を高速に見つけることこそが二分探索の意味であり、 動きの詳細(左右どちらを見る、など)はその「境界探索」を実現する手段にすぎません。 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] と書きたいし、理解したい。そんな気持ちです。 今年も長々とした駄文を最後まで読んでいただきありがとうございました。
アバター
この記事は 一休.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 です。カジュアルなレストランや居酒屋に加えてカラオケまで、多彩な店舗ラインアップを揃えた飲食店予約サービスです。 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 グルメ コード上の色名と実際の色が一致しないため、メンテナンス時に混乱を招く原因となっていました。 色そのものの名前 (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 で 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/1745000651779629061 hrmos.co 一休レストランで Next.js App Router から Remix に乗り換えた話 ↩ https://paypaygourmet.yahoo.co.jp/ フッターより ↩
アバター
この記事は 一休.com Advent Calendar 2025 の1日目の記事です。 一休.com レストランの開発を担当している恩田( @takashi_onda )です。 はじめに 昨日 2025/11/30 に開催された フロントエンドカンファレンス関西 で、「細粒度リアクティブステートのスコープとライフサイクル」というタイトルで発表を行いました。 speakerdeck.com 発表ではまたしても終盤が駆け足になってしまいました。本稿では、その際に十分に触れられなかった論点のうち、特にスコープに焦点をあて、その課題と解決案をご紹介したいと思います。 細粒度リアクティブステート 細粒度リアクティブステート (fine-grained reactivity) 1 とは、 Solid や Svelte 5 の Runes で謳われている、UI を必要最小限な単位でリアクティブに更新するフロントエンドのアーキテクチャです。現代の複雑化した Web アプリケーションで UX を軽快に保つためのステート管理の大きな潮流で、 Signals として TC39 で標準化の議論が進められています。 その背景にあるのは、値の変更を依存グラフで追跡し、必要最小限の更新を可能にする宣言的な計算モデルです。大雑把なイメージをつかむなら「スプレッドシート」と考えれば分かりやすいでしょう。 上の例のように、スプレッドシートでは、セルの値が変更されるとそのセルに依存する他のセルが自動的に再計算されます。同様に、細粒度リアクティブステートでは値が変更されると、その値から計算で導出される派生値やそれらの値を利用する 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)); さきほどの例で説明すると subtotal や tax , total は本来他の注文明細でも再利用可能なロジックです。ですが、この例では appleLineSubtotal をはじめとする atom に直接依存してしまっており使い回すことができません。また、直接的な依存は単体テストの arrange も煩雑にします。 実際のプロダクトの規模では、依存が広く深く複雑になり、構造を簡単に把握できなくなるのが一番の問題でした。数十個単位の atom がフラットにひとつの大きなグラフになってしまい、手を入れる際に、ホワイトボードで依存関係を再整理して理解の見直しが必要な場面もありました。わかりやすく例えるならば、巨大なひとつの関数で実装されたコードを保守するようなイメージです。 Bunshi この問題の解決にヒントを与えてくれたのが Bunshi です。 もともとは Jotai で多数の atom を構造化する jotai-moleculous というライブラリとして開発されていました。ですが、その思想は React/Jotai に限らず汎用的に有効だったため、他の状態管理ライブラリやフレームワークでも利用できるように拡張されたのが Bunshi です。 詳細に踏み込むと長くなるので割愛 4 しますが、Bunshi が提供する以下の機能が問題解決のヒントになりました。 atom をグルーピングした molecule という単位のモジュール moleculeInterface という抽象への依存とその解決の仕組み (Dependency Injection) 生存期間とライフサイクル (Scope 5 ) 検討当初は 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 である subtotal や tax はクロージャ内に閉じているため、外部から参照できません。カプセル化の導入です。 コンポーネントでは props で atom を渡します。atom は不変なオブジェクトなので、メモ化された Total が再レンダリングされるのは total の依存グラフを構成する atom の値に変化があったときとなり、 fine-grained reactivity が実現できています。 おわりに ここまで読んでいただきありがとうございました。 Jotai を題材に、細粒度リアクティブステートで複雑なフロントエンドの状態をモデリングするときに課題となるスコープとその解決案をご紹介しました。 人間の認知サイズに収まるように分割し、依存を明示化し、抽象を導入する。ここまでを振り返ると、細粒度リアクティブステートのような新しい概念を扱う場合でも、特別なことはなく、重要なのはプログラミングの普遍的な設計原則に立ち返ることでした。 本稿が、フロントエンドにおける状態管理設計を検討する際の一助となれば幸いです。 一休では、本記事でお伝えしたような課題をともに解決するエンジニアを募集しています。 www.ikyu.co.jp まずはカジュアル面談からお気軽にご応募ください! job.persona-ats.com まだ定まった訳語がなく、リアクティビティとカタカナで表現するのにも違和感があったので、ここでは「細粒度リアクティブステート」と訳しています。fine-grained reactivity とそれを実現するステート管理の仕組み、ぐらいに捉えていただければと思います。 ↩ 先日 1.0 がリリース されました。 ↩ 依存の向きを一方向にするために、レイヤの単位で分割しています。 ↩ bunshiを理解する という記事にわかりやすく解説されています。 ↩ Bunshi の Scope はプログラミング言語におけるスコープではなく molecule の生存期間を意味します。React 実装では React Context が利用されています。 ↩
アバター
コーポレート本部 社内情報システム部 兼 CISO室 id:rotom です。 11/15(土) にハイブリッド形式で情シス向けのテックカンファレンス BTCONJP 2025 が開催されます。 btcon.jp corp-engr.connpass.com Business Technology Conference Japan(BTCONJP)は、ITをビジネステクノロジーの領域に昇華し、日本のあらゆる経済活動をアップデートするイベントです。 昨年に引き続き私が core staff として運営に参画しており、所属企業である一休はブロンズスポンサーとして協賛しております。 prtimes.jp 「情シスが創るビジネスの明日」というテーマのもとで、様々なセッションやイベントを楽しめる1日です。 オフライン会場は株式会社一休も入居する、東京ガーデンテラス紀尾井町 紀尾井タワーのLINEヤフー株式会社 本社です。 map.yahoo.co.jp Dining での懇親会も予定しておりますので、ぜひ現地でご参加ください!
アバター
一休のいがにんこと山口( @igayamaguchi )です。 一休はVue Fes Japan 2025にスポンサーとしてブースを出展します。 vuefes.jp 日程は10月25日(土)です。 Vue Fes JapanはVue.jsの知見が集まるイベントです。今年はそれだけにとどまらず Vue.jsからEvan Youさん ReactからはDan Abramovさん Svelteからはdominikgさん という海外の著名なOSS開発者が登壇するなんとも楽しみなイベントになっています。 該当セッション 当日のスポンサーブースでは各種ノベルティを用意してお待ちしています。 以下のバナーを目印にぜひお越しください。
アバター
※9/21追記: 体調不良のため登壇はキャンセルとなりました 今月 9 月 21 日に フロントエンドカンファレンス東京 2025 が開催されます。このカンファレンスに一休.comレストランのフロントエンドアーキテクトを務めるエンジニア恩田 ( @takashi_onda ) が登壇します。 フロントエンドカンファレンス東京とは フロントエンドカンファレンス東京は「フロントエンドを次世代に」をテーマとして開催する技術カンファレンスです。 本カンファレンスは次世代を担うエンジニアに向けて、フロントエンドの第一線に立つエンジニアが知見を共有し、成長の機会を提供します。また、開発現場で活躍するエンジニアが外部発信するきっかけを作るとともに、初心者が実践的なノウハウを学べる場となることを目指します。 登壇やAMA、他の参加者との議論を通じて知識と繋がりを深め、これまでに築いたフロントエンドの技術と文化を未来へ伝えるためのカンファレンスです。 fec-tokyo.connpass.com 発表内容 「愛すべき Image API - 前世紀の技を現代で」というタイトルで発表します。以下プロポーザルです。 今から四半世紀以上前、JavaScript が生まれた頃から使える Web API に Image があります。 Image には面白い特徴があって、DOM ツリーに追加される前から画像を取得しはじめます。 このような仕様になっているのは、当時の貧弱な回線状況では、画像のプリロードが非常に重要なユースケースであったためです。 たとえば、この振る舞いを利用したテクニックに、ボタン画像のロールオーバーを読み込み待ちなしに実現する手法がありました。 温故知新といいますが、実は、この技法は現代でも有効です。実際に私たちのプロダクトでは、特にモバイル回線利用時に大きくユーザー体験を向上させてくれています。 本トークでは、画像 CDN や MutationObserver を使って現代風に味つけをした Image API の実践的な TIPS を、実プロダクトのデモやコードでご紹介します。昔話を交えながら、面白おかしくお話しできればと思います。 おわりに 本カンファレンスはオンライン視聴やアーカイブがございませんが、現地参加者の方はぜひ発表を聞きに来ていただければと思います!
アバター
はじめに こんにちは。一休データサイエンス部の平田です。 一休.comは主に国内の宿泊施設を取り扱う予約サイトですが、インバウンド需要の高まりを受け多言語対応を進めており、2025年の3月に国際サイトをリリースいたしました。対象言語は英語、中国語(繁体字・簡体字)、韓国語、タイ語、ベトナム語、マレー語、インドネシア語です。 一休.comトップページのメニューから言語を切り替えることができます 一休.com英語版のトップページ 一休.com英語版のホテル紹介ページ 今回は主にデータとして存在する日本語をどうやって翻訳したかということと、その注意点についてフォーカスして書いていきます。 自動翻訳サービスの選定 自動翻訳の必要性 ユーザーに見える部分を全て翻訳するため、かなり多岐に渡るテーブルが対象になり、文字量にして約10億文字を翻訳する必要があります。翻訳後の分量で言えば8つの言語で翻訳するため80億文字にものぼります。さらに、それらのデータは日々更新されています。 これを人手で翻訳するのは時間とコストがかかりすぎて現実的ではないため、自動翻訳で対応することになりました。 選定 さて、自動翻訳の場合、どのサービスを使うかを選定する必要があります。 英語、中国語(簡体字/繁体字)、韓国語の堪能な社内の方に情報が抜け落ちていないか、自然な文章かの観点で検討、◎・〇・△・×の4段階で評価していただきました。 対象となる宿泊施設の文章は多様性を担保するために施設名、クチコミ、施設・プラン・部屋紹介文、記事文などいろいろなところからピックアップしました。 こちらがその一例です。 万葉集や日本書記にも登場する歴史ある南紀白浜温泉。 安全性と快適性を考慮した畳風呂や自然に囲まれた露天風呂が自慢です。 豪華バイキングや充実のホテル内施設などワンランク上のサービスを心ゆくまでお楽しみいただけます。 上記の中国語(簡体字)訳を翻訳を専門とするサービスを含む4つのサービスで比較しました。ChatGPTのプロンプトはシンプルに「○○語に翻訳してください」としています。 ちなみに、この4つにコストの差異はほとんどないです。 ChatGPT 4o 万叶集和日本书记中也有记载的历史悠久的南纪白滨温泉。 以安全性和舒适性为考虑的榻榻米浴池和被自然环绕的露天浴池是我们的骄傲。 豪华自助餐和完善的酒店内设施等一流服务让您尽情享受。 評価: ◎ サービスA 南溪白滨温泉历史悠久,在《万叶集》和《日本列国志》中都有记载。 酒店拥有以安全和舒适为设计理念的榻榻米浴池,以及被大自然环绕的露天浴池。 客人可以尽情享受更高级的服务,包括丰盛的自助餐和一流的酒店设施。 評価: ✕ 日本書紀の翻訳が間違っている。 サービスB 南纪白滨温泉历史悠久,曾出现在《万叶集》和《日本书纪》中。 我们为考虑到安全性和舒适性的榻榻米浴池以及被大自然包围的露天浴池感到自豪。 您可以尽情享受更高水平的服务,例如豪华的自助餐和全方位的酒店设施。 評価: ◎ サービスC 南纪白滨温泉是出现在万叶州和日本书纪的历史悠久的温泉。 我们以安全舒适的榻榻米浴池和被大自然包围的露天浴池感到自豪。 您可以尽情享受豪华自助餐和丰富的酒店设施等一流的服务。 評価: ✕ 万葉集の翻訳が間違い。 このように評価をつけていった結果をまとめると下の表のようになりました。 英語 ◎と〇の数 △と×の数 ChatGPT 23 1 サービスA 13 11 サービスB 11 13 サービスC 19 5 中国語 ◎と〇の数 △と×の数 ChatGPT 19 2 サービスA 12 9 サービスB 10 11 サービスC 9 12 ◎と〇の数をみると圧倒的に他の自動翻訳サービスよりChatGPTが優れていることが分かります。したがって、ChatGPTを採用することにしました。(ただし、これは2024年6月での結果なので現在は評価が変わっている可能性もあります。) ChatGPTのプロンプト、コード 翻訳辞書 自動翻訳の問題点として、同じ日本語でも必ず同じ結果になるわけではない、という問題点があります。 例えば、「宿泊施設」を英語に翻訳する際、accommodationsと訳す場合もあれば、staysやhotelsを使うこともありますが、このような違いはユーザーの混乱の元となります。 今回は頻繁に登場する重要な単語はオリジナルの辞書を作成し、必ずそれに変換するようにプロンプトに指示しました。下がその一例です。 検索語句 英語 中国語(簡体字) 中国語(繁体字) 韓国語 タイ語 ベトナム語 マレー語 インドネシア語 ホテル Hotel 酒店 飯店 호텔 โรงแรม Khách sạn Hotel Hotel 旅館 Ryokan 日式旅馆 日式旅館 료칸 เรียวกัง nhà trọ kiểu Nhật penginapan gaya Jepun penginapan gaya Jepang 「ホテル」が中国語(簡体字)だと「酒店」で中国語(繁体字)だと「飯店」なのは面白いですね。 プロンプト、バッチのコード 翻訳辞書も加味しつつ、コードを書いていきます。 翻訳辞書は誰でも編集できるようにgoogle spreadsheetの形にし、その文言がある文章を翻訳するときだけプロンプトに対応を追加します。 省略しますが、get_dictionary_from_textはその読み込み処理です。 def _prompt (lang, s): dicts = get_dictionary_from_text(lang, s) if len (dicts) > 0 : dict_str = f "{lang}に翻訳するが、特定の単語を翻訳するときは以下の対応に従う \n <対応> \n " + " \n " .join(dicts) + " \n </対応>" else : dict_str = "" if lang == "中国語(繁体字)" : lang = "中国語(台湾の繁体字)" yen = "日圓" elif lang == "中国語(簡体字)" : yen = "日元" elif lang == "韓国語" : yen = "엔" else : yen = "Yen" return f """次の文章をルールに従って自然で簡潔な{lang}に翻訳してください <ルール> 翻訳文のみ出力する 宿泊予約サイトに掲載する文章として適切かどうかを検討する 改行マークを保持する {dict_str} 絶対確実に{lang}に翻訳する 金額の記載がある場合その金額を誤りなく記述してください(日本円は{yen}などとする) 日本語が残っていることが無いように見直してください </ルール> """ 「絶対確実に{lang}に翻訳する」 「日本語が残っていることが無いように見直してください」 などとしなくても良さそうですが、翻訳が綺麗になされないことがまれにあるため、翻訳残しを可能な限り減らそうとして試行錯誤した結果こうなっています。 参考: 対話型AIに一生懸命お願いをすると回答の精度が上がる!感情的刺激というプロンプトエンジニアリングのメカニズム バッチ処理 大量に翻訳する必要があるため、OpenAIのBatch APIを扱うライブラリを作りました。 Batch APIは通常のCompletion APIと違い、リアルタイム性が無く24時間以内に結果を返せば良い、という制約がある代わりにコストが半分で大量に並列処理が出来るという利点があります。翻訳用途に限らず、デイリーのちょっとしたタスクなどにも適しています。 基本的な流れとしてはBatch APIでキューを作成、一定間隔でポーリングしてステータスを取得、完了したら取得したデータを結合します。 以下にコードを示します。(長くなるので重要なところだけ) 工夫としては、識別子をmetadataに記載することで、ポーリング時の取得を容易にしています。 from openai import OpenAI class OpenAIUtil : ''' OpenAIのAPI周りの記述を簡略化するためのライブラリ ''' DEFAULT_MODEL = "gpt-4o" DEFAULT_TEMPERATURE = 0 DEFAULT_RESPONSE_FORMAT = None DEFAULT_TOOLS = None DEFAULT_PREDICTION = None def __init__ (self, api_key): self.client = OpenAI( api_key=api_key ) # custom_idsはmessagesを一意に識別するためのもの def batch_chat (self, custom_ids, messages_list, show_json_only= False , model=DEFAULT_MODEL, response_format=DEFAULT_RESPONSE_FORMAT, temperature=DEFAULT_TEMPERATURE, chunk_size= 50000 , **kwargs): self._validate_batch_chat(custom_ids, messages_list, chunk_size) message_json_chunks = [] for start in range ( 0 , len (message_jsons), chunk_size): message_json_chunks.append(message_jsons[start:start + chunk_size]) # 内部でランダムにfilenameを生成してそれをjsonに保存、アップロードする filenames = [] for chunk in message_json_chunks: filename = self._generate_random_name() + ".jsonl" filenames.append(filename) self._to_jsonl(chunk, filename) print (f "output file: {', '.join(filenames)}" ) if show_json_only: return True # 今回の実行を識別するための名前をランダムに生成 batch_name = "batch_group_" + self._generate_random_name() for filename in filenames: self._create_openai_batch(filename, batch_name) self._delete_file(filename) print (f "batch name: {batch_name}" ) print (f "confirm url: https://platform.openai.com/batches" ) result = self._get_batch_result(batch_name) return result def _create_openai_batch (self, filename, batch_name): batch_input_file = self.client.files.create( file = open (filename, "rb" ), purpose= "batch" ) batchins = self.client.batches.create( input_file_id=batch_input_file.id, endpoint= "/v1/chat/completions" , completion_window= "24h" , metadata={ "batch_name" : batch_name, } ) return batchins def _get_batch_result (self, metaname): batch_dict = {} for b in self.client.batches.list(limit= 100 ).data: batch_name = b.metadata.get( 'batch_name' , '' ) if metaname != batch_name: continue batch_dict[b.id] = b.status while True : for batch_id in batch_dict.keys(): b = self.client.batches.retrieve(batch_id) batch_dict[batch_id] = b.status if all (v in ( 'completed' , 'failed' ) for v in batch_dict.values()): break time.sleep( 10 ) result = [] for batch_id in batch_dict.keys(): b = self.client.batches.retrieve(batch_id) if b.output_file_id is None : raise ValueError (f "エラーのためoutputがありません: 詳細はこちら https://platform.openai.com/batches/{batch_id}" ) content = self.client.files.content(b.output_file_id).read().decode( 'utf-8' ) for c in content.split( ' \n ' ): if c == "" : continue cd = json.loads(c) result.append(cd) return result 注意点 大半は上記プロンプト、コードでしっかり翻訳されてくれるのですが、先述した通り大量に翻訳するためエラー(誤翻訳)の数もそれなりになってきます。ここではどういうエラーがあったかとその解決策について記しておきます。 同じ文字の繰り返し 基本的に紹介文は、その宿泊施設の担当者が書くのですが、単純なテキストを書くだけにとどまらずテキストで装飾をつけることがよくあります。 <わんちゃんとご宿泊が可能なプランです>━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━●下記URL先より ... (以下略) ~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~当ホテルはすべてのお客様に安心してご利用いただけるよう ... (以下略) この事自体は問題なく、ユーザーも見やすくなるので良いのですが、これをChatGPTで翻訳させるとおかしな出力をすることがありました。 例えば後者の出力文が ~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※ ~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※~※ ... (以下10万文字繰り返し) となったりします。 ChatGPTのアーキテクチャであるTransformerの性質上、前のトークンから次のトークンを確率的に生成しています。 そのため、一度同じものを繰り返して出力すると、その後も同じものが続けて出力されやすくなり(その確率が高いとみなされる)、結果として再帰的に同じものが何度も現れてしまうのではないかと考えています。 実はfrequency_penaltyというパラメータでこれを抑制することができます。 調整した結果、frequency_penalty=1.0としました。このパラメータはBatch APIでも適用されます。これによって精度は維持しつつ、100%この現象は起きなくなりました。 装飾部分を1回取り除いて翻訳した後に戻す、など面倒なことをせずに済みました。 ちなみに全角文字は英語圏などでも表示されるのでそのままでも許容としました。 似た単語の繰り返し 上と類似の問題ですが、記号の繰り返しではなく似た意味の単位返されることもありました。で繰り こちらも例を挙げて紹介すると、 「松本電鉄・波田駅」又は「山形村役場」より送迎(2名様以上にて) ※冬季(12月ー3月)は送迎は実施しておりません。 という文章を英語に翻訳すると Shuttle Service available between either “Matsuden-Hata Station” OR “Yamagatamura Town Hall” exclusively applicable minimum group size two persons required eligibility criteria met accordingly operational restrictions apply seasonal limitations enforced specifically wintertime December-through-March period suspended temporarily unavailable services provisioned ... (中略) ... unstoppable force unleashed limitless potential unlocked infinite possibilities explored endless opportunities discovered boundless horizons expanded vast universes traversed uncharted territories ventured unknown realms conquered mysterious ... (略、以下10万文字ほど続く) となっていました。 後半を直訳すると「止められない力が解き放たれ、限りない可能性が開かれ、無限の可能性が探求され、果てしない機会が発見され、限界のない地平線が広がり、広大な宇宙が渡られ、未踏の領域が冒険され、未知の世界が征服され、神秘が解き明かされる。」ということで、村役場から送迎車に乗ると何故か神秘が解き明かされてしまいました。この後はどんどんスピリチュアルな方向に進んでいきます。 こちらはトークンとしては違うものの繰り返しなため、残念ながらfrequency_penaltyが意味をなさないです。 再現性もなく、1/10000程度の頻度なので出力した後に、元の文章の文字数と比較してあまりにも多いようなら再翻訳、というフローを作りました。 (通常の翻訳は、英語なら日本語の2倍~3倍、中国語は0.6~1.0倍、マレー語・インドネシア語は2.5倍~3.5倍となります) 日本語が残る 特に中国語に多かったのですが、文章によっては一部翻訳されないケースがありました。 タイ語の例: 「草津・嬬恋・四万」 →「คุซัทสึ · つまごい · ชิมะ」 タイ語などに日本語が残っているとかなり目立つので、こちらも平仮名や漢字が残っているかどうかをチェックして再翻訳させています。エリアのマスターなどは頻繁に変わるものでも無いので目でチェックして地道に再翻訳しました。 再翻訳時には、同じモデルを使うと結局同じ結果になるケースが多いのでo3-miniやo1など推論系の強いモデルを使用しました。コストが高いので全部推論系には変えられませんが、パッチ的な使い方だと大したコストにはなりません。(辛いのが、ここまでやっても残ってしまうことがあるのでざっと見て残っていたら手で直したりもしました......) 翻訳API バッチで翻訳した後も終わりではなく、データが都度更新されるたびに翻訳をかける必要があります。施設担当者が日本語を更新した後、なるべく他の言語での反映をすぐ行いたいかつ、翻訳辞書や上記の様々な対応を取り入れたいため、社内にAPIを立て、そこを経由して翻訳することにしました また、UIの翻訳にも活用できるようにプロンプトにコンテキストを埋め込めるようにして、カレンダーの「金」は"friday"で素材の「金」は"Gold"などが正しく翻訳できるようにしています。 UIの翻訳についてもこの記事では紹介しきれないほど様々な工夫がなされています。 さいごに この記事ではChatGPTによる大量翻訳のやり方をご紹介しました。 これからも一休.comは市場変化を取り入れつつ成長していきます! まずはカジュアル面談からお気軽にご応募ください! データサイエンス部の応募はこちらから!
アバター
概要 初めまして、CTO室のいがにんこと山口( @igayamaguchi )です。一休.com/Yahoo!トラベルのフロントエンドの開発を担当しています。 この記事ではWebアプリケーションのフロントエンドの画面実装をボトムアップに実装することのメリットと、その方法を紹介します。 ボトムアップに画面を実装する ボトムアップに画面を実装する、というのは小さなコンポーネントや処理から実装をしていき、それを組み合わせて徐々に大きなコンポーネントを作り、最終的に画面を作る実装方法です。 昨今のWebアプリケーションの実装で使用するReactやVueといったフレームワークはHTML、CSS、JavaScriptなどをディレクトリ、コンポーネントとしてまとめて実装することができます。この機能を利用し、 input、ボタンといった小さなコンポーネントを作成 上記のコンポーネントを使用しさらに大きなコンポーネントを作成 さらに上記のコンポーネントを使用しページを作る という開発の進め方がボトムアップに画面を実装する方法です。 これらのコンポーネントにはビジネスロジックが入り込んできたり、表示に必要なデータが変わります。そういったものをどう実装するのかも重要であり、それらもボトムアップにすることでよいものになります。 Webサイトはトップダウンで実装されがち ボトムアップの逆のアプローチであるトップダウンに実装する方法とその問題点を整理します。 トップダウンに実装する、というのは画面を上から順に作成する方法です。ベースとなるHTML、CSS、JavaScriptのファイルを作成し、デザインを見て上から順に実装していきます。従来のWeb開発は、1ページ単位でHTML、CSS、JavaScriptをまとめて作るスタイルが主流でした。デザインが1枚のページとして作成され、そのデザインに対となるように1つのHTML、CSS、JavaScriptを作成して開発をするというのは、昔は一般的なアプローチだったと思います。 しかしこの方法では、以下の問題が起こりがちです。 1ファイルに大量のコードが混在し、把握しにくくなる 異なる業務処理が密結合し、影響範囲が不明確になる 一部だけ改修したくても、他に思わぬ影響が及ぶ サイト全体で使いまわせるようなものに考えが及びにくく、長期的によいコンポーネント設計、切り出しが行いにくい コンポーネントを切り出すにしても、サイト全体で使いまわすものとページ固有で使うものを同時に考えなくてはならず、コンテキストの切り替えが困難 チーム開発でコンフリクトが頻発する 特に、1画面で複数の機能があるページではより深刻な問題になります。 こういった問題意識のもとでは、ボトムアップに実装することの重要性が高まってきます。 昨今のフロントエンドのフレームワークではコンポーネントを作ることができるので丸々1ページを1コンポーネントで作ることはないと思いますが、それでも、ある程度大きいコンポーネントを作ってしまいがちです。責務を複数持つような大きなコンポーネントを作成した場合、上述したトップダウンの開発と同様の問題が発生します。 例えば、実現したいことやワークフローをベースに設計を考えたり、デザインを面で見る思考に囚われると、コンポーネントも大きくなりがちだったりします。特定のinputやボタン/カードをコンポーネント化せず、ページや大きなコンポーネントにそのままHTML/CSSを書いていたりはしていないでしょうか。 実例の紹介 では実際に実例を見ながらどうやって問題が解決されるかを説明していきます。まずは大枠の流れです。 全体のコンポーネント抽出、設計 汎用的に使用できるUIのコンポーネントを実装 ベースとなるワークフロー、画面の仮実装 各グループごとのコンポーネントの実装 この流れ通りに説明をしていきます。 実際に作成される画面はこちらです。 全体のコンポーネント抽出、設計 まず最初に画面を見てコンポーネントを抽出し、設計を考えます。ボトムアップといえど、全体としてどのようなコンポーネントが必要になるのか、各所でどうやって使いまわすかの見通しを立てておきます。これにより、ある箇所のために作ったコンポーネントが局所最適になり、他のページで使えなくなるということを防ぎます。 これらのコンポーネントはいくつかに分類できます。 1.どんなサイトでも使用できる汎用UI要素 例: input、チェックボックス、ボタン 2.サイト内で汎用的に使用できるサイト特有のUI要素 例: キャンセルポリシー、料金情報 3.そのページ内で使いまわすページ固有のUI要素 例: そのページ用のお知らせ、エラー表示、固有のレイアウトを実現するための箱 4.ページを業務処理ごとにグルーピングしたUI要素 例: カード決済や予約者情報といったフォーム、予約時の注意事項 分けたコンポーネントはそれぞれ前のもののみに依存し、次のものには依存しないように分けます。これらを順に実装していきます。 最初の3つが汎用的に使用できるUIのコンポーネント、最後の4つ目がページを実際に作るためのコンポーネントとなります。 汎用的に使用できるUIのコンポーネントを実装 どんなコンポーネントを実装するか計画したら実装に移っていきます。 汎用的に使用できるUIのコンポーネントから実装を始めていきます。 どんなサイトでも使用できる汎用UI要素 最初に実装するコンポーネントは、どんなサイトでも使用できる汎用UI要素です。例えば、input、ボタンといったHTMLのサブセットのようなUI要素や、テーブル、モーダルといったUI要素です。 各コンポーネントに応じてどんな表示パターンがあるかをしっかり考えて実装します。inputであれば通常表示、値が入った時、エラー時、disable時などいろいろなパターンが考えられます。 コンポーネントのコードに業務仕様が入らないようにして、どのサイトでも使えるように設計することで、そのサイト内でも使いまわしやすいものにできます。 最初にサイト全体のベースとなるUI要素を作ることで、そのコンポーネントがサイト全体で使い勝手の良いものになっているかをUIに焦点を当てて考えることができます。 サイト内で汎用的に使用できるサイト特有のUI要素 次に実装するコンポーネントはサイト内で汎用的に使用できるサイト特有のUI要素です。例えば、一休.comであればいたるところにある料金情報やキャンセルポリシー、ホテルのリンクカードなどです。 業務仕様を含みつつもサイト内で使いまわしが効き、まとまっているとメンテナンスが楽になるもの、変更タイミングが同じものをコンポーネントとして実装します。いくつかレイアウトのパターンがあるものはコンポーネントのpropsで調整できるよう設計しておきます。 後々触れますが、一休.comではGraphQLを使用しているので、こういったコンポーネントにはfragmentを定義して必要なデータの取得を強制することで、間違った値が設定されることも防いでいます。 ページ内で使いまわすページ固有のUI要素 3つ目に実装するコンポーネントは、ページ内で使いまわすページ固有のUI要素です。例えば、そのページ用のお知らせ、エラー表示、固有のレイアウトを実現するための箱です。 ここまで実装をすると、既存のコンポーネントを組み合わせるだけで、ある程度の画面デザインを作成することが可能になります。後続で説明する機能のUIを実装するときに、いちいちサイトやページ全体のUIコンポーネントの設計に頭を切り替える必要がなくなり、業務のUIの実装に集中できる状態を作ることができます。たとえば、カード決済の入力欄を実装するとき、inputなどのUIコンポーネントの使いやすさに思考を切り替えることなく、カード決済の業務のみに向き合うことができるようになります。 これを守るために、ページの業務のUIをいきなり作り始めないことが大切です。 ベースとなるワークフロー、画面の仮実装 画面表示のためのUIコンポーネントがそろったら、次に行うのは大枠のワークフローの実装です。ここでは少しだけトップダウンに実装を考えていきます。 まず各業務コンポーネントを表示するために必要なデータを取得するフローを組みます。一休.comではGraphQLを使用しているため、必要なqueryを投げる処理を組んであげます。 以下は簡易的に書いた例です。 const graphqlQuery = graphql( ` query DraftOrder($draftOrderId: DraftOrderIdScalar!) { draftOrder(id: $draftOrderId) { id # ここにfragmentを追加予定 } } ` ) export function useBookingForm () { const variables = computed(() => ( { /* 様々な値 */ } )) const { data } = await useAsyncQuery(graphqlQuery, variables) const draftOrder = computed(() => data.value.draftOrder) return { draftOrder , } } 上記のように、メインのデータ取得となるqueryでは各コンポーネント用のfragmentを読み込む想定で作成します。 さらに、ページのルートとなるコンポーネントから各コンポーネントへ、取得したデータをpropsへ流し込めるようにしておきます。 < script setup lang = "ts" > const { draftOrder } = await useBookingForm () </ script > < template > < div > <!-- この段階ではまだコンポーネント呼び出しはないが、以下のようにpropsに設定するイメージ --> < PaxProfile :draftOrder /> </ div > </ template > これにより、後は各コンポーネント実装時に必要なデータをfragmentで記述し、fragmentとコンポーネントをベースの実装に差し込むだけでページ、コンポーネントが機能するようになります。 次からは各グループの業務処理、UIを実装していくことになります。 ページを業務処理ごとにグルーピングしたUI要素 UIコンポーネントとページのデータ連携の仕組みがそろったところで、ページに含まれる複数の業務をグルーピングし、それぞれのグループのUIを実装することで、ページを作り上げていきます。例えば支払方法を選択するUI要素です。 すでにUIコンポーネントがそろっている状況なので、各UIを実装するときにはコンポーネントを組み合わせて少しスペーシングを調整するくらいで済むようになっているはずです。いちいちinputの実装をしたりする必要はありません。ただどんなプロパティを使ってコンポーネントを呼び出し、どうやって並べるか、だけを考えればよいです。 < template > < Box > < SectionHeader title = "お支払い方法" class = "mb-6" size = "2xl" tag= "h2" /> < InvalidArgumentErrorBox :errors= "errors" /> < div class = "mb-4" > < ErrorBox v-if= "errorMessage" > {{ errorMessage }} </ ErrorBox > </ div > < section class = "pc:p-6 rounded border border-gray-300 bg-gray-100 p-4" > < div class = "flex items-baseline justify-between" > < InputLabel : id = "cardNumber.name" text = "カード番号" required /> < ul class = "relative flex gap-x-2" > < li v- for = "company in cardCompanies" :key= "company.name" > < Component :is= "company.svg" height = "28" width = "28" /> </ li > </ ul > </ div > < Input : id = "cardNumber.name" v-model.numberText= "cardNumber.value" placeholder = "1234 1234 1234 1234" :error-message= "cardNumber.errorMessage" :input-props= "{ autocomplete: 'cc-number', inputmode: 'numeric', }" /> <!-- ... --> </ section > </ Box > </ template > 必要なデータについてもfragmentを定義し、propsに設定して、 < script > export const fragment = graphql ( ` fragment CreditCardInputDraftOrder on DraftOrder { checkOutDate cardSalesDate } ` ) </ script > < script setup lang = "ts" > defineProps < { draftOrder : FragmentType <typeof fragment > }> </ script > < template > <!-- 上記のtemplate... --> </ template > ベースのqueryにfragmentを追加、 const graphqlQuery = graphql( ` query DraftOrder($draftOrderId: DraftOrderIdScalar!) { draftOrder(id: $draftOrderId) { id # 追加 ...CreditCardInputDraftOrder } } ` ) propsに流し込んだら自動で取得してコンポーネントが描画できます。 < script setup lang = "ts" > const { draftOrder } = await useBookingForm () </ script > < template > < div > < PaxProfile :draftOrder /> <!-- 追加 --> < CreditCardInput :draftOrder /> </ div > </ temlate > このとき注目してほしいのは、ベースの実装はこの結合部分のみを意識しており、各業務コンポーネントについては関知しないことです。ベースの実装が知っているのはコンポーネントを使うこと、そのコンポーネントに必要なfragmentのみです。こうすることで、各コンポーネントの実装に集中しつつ、fragmentとコンポーネントの呼び出しだけで簡単にページにコンポーネントを組み込むことができるようになっています。 これを繰り返すことで画面がどんどん組みあがっていきます。 さらに業務ごとにコンポーネントを切って実装することでその特定業務のコンポーネントに集中できます。例えばカード決済の入力欄を実装するときに、予約者情報など別の業務について考えずに済みます。 さらにさらにチームで開発をするときにも、他メンバーが別の箇所を実装していても、自分の箇所と切り離して考えることができます。ベースの土台の実装にのっとった実装となっていれば影響を受けずに並列での開発が可能です。 まとめ ボトムアップに画面を実装することで以下の良いことがあります。 汎用的なUI要素を最初に実装することで サイト全体で見て、使い勝手の良いものになっているかをUIに焦点を当てて考えることができる 各業務コンポーネントを作るときに、いちいち土台のコンポーネント設計に頭を切り替える必要がなくなる 例えばカード決済の入力欄を開発する、となったときにinputの見た目がどうとかを考えずに済む ベースを実装し枠組みを作ることで そのワークフローに乗るだけで各業務の実装に集中することができる グループごとの実装を分けることで 各業務コンポーネントを作るときに、その業務のコンポーネントに集中できる 例えばカード決済の入力欄を開発するときに、予約者情報など別の業務について考えずに済む 他メンバーが別の箇所を実装していても、自分の箇所と切り離して考えることができる ベースの土台の実装にのっとった実装となっていれば影響を受けずに並列での開発が可能 ぜひみなさんもボトムアップに実装をしていきましょう。
アバター
kymmt です。 先日2月10日に、一休のフロントエンド技術にフォーカスしたイベント「一休 Frontend Meetup」を開催しました。 ikyu.connpass.com 一休 Frontend Meetupとしては2年半ぶりの開催となりました。 このイベントでは一休開発チームのメンバーが登壇し、各サービスのフロントエンドについて工夫や知見を紹介しました。この記事ではイベントの様子を紹介します! 当日のハッシュタグは #ikyu_dev でご覧になれます。 発表 『一休.com のログイン体験を支える技術 〜Web Components x Vue.js 活用事例と最適化について〜』 1つ目の発表は、認証基盤などの開発に携わる渥美さんによる『一休.com のログイン体験を支える技術』でした。 一休の各サービスが利用している社内認証基盤では、ユーザーのログイン/SMS認証の際に表示するモーダルウインドウなどを提供するアセットを配布しています。この発表では、ユーザー体験も考慮してスムーズなログインできるモーダルウインドウをWeb ComponentsやVue.jsを用いて開発する方法について紹介しました。 『Webパフォーマンス改善 〜宿泊予約サービスでの取り組み〜』 2つ目の発表は、CTO室で全社的なフロントエンド改善に取り組む卯田さんによる『Webパフォーマンス改善 〜宿泊予約サービスでの取り組み〜』でした 1 。 宿泊予約サービスの一休.comのWebパフォーマンス改善では、指標として主にCore Web Vitalsの値をトラッキングしています。改善の方針としては、特定の箇所をカリカリにチューニングするより、ユーザー体験重視で全体的に遅くならないよう気をつけています。 発表では、実際に改善活動で指標を監視するために使っているLooker StudioやDatadogのダッシュボードをデモを交えつつ紹介しました。また、フロントエンドに関する知見を収集するための方法についても紹介しました。 『一休の世界観を形にする、ガイドラインとデザインシステム』 3つ目の発表は、一休.comレストランのデザイナー高橋さんによる『一休の世界観を形にする、ガイドラインとデザインシステム』でした。 一休では、デザイナーを中心に「一休らしさ」を実現するための IKYU Design Guideline を策定し、各サービスを横断してブランドイメージの一貫性を保つようにしています。 ガイドライン策定による成果として、デザインシステムの運用と各プロダクトへの適用や、デザイナーとエンジニアの協働がやりやすくなったので、一休らしい世界観の提供に役立っているという話がありました。 『飲食店予約台帳を支えるインタラクティブUI設計と実装』 最後の発表は、RESZAIKO台帳のエンジニア白井さんによる『飲食店予約台帳を支えるインタラクティブUI設計と実装』でした。 RESZAIKO は一休が飲食店向けに提供している予約管理のSaaSです。RESZAIKOが提供するサービスの1つとして、今回発表のテーマになった予約台帳サービスがあります。 発表では、iPadのようなタブレットで操作しやすい予約台帳サービスのインタラクティブUIを設計する方法について、 UIをインタラクティブにするための基本的な方法 Canvasの使いどころ コンポーネントとしてUIのレイヤーを実装することで責務を整理する手法 などを中心に解説しました。 おわりに 「一休 Frontend Meetup」での一休のフロントエンド技術領域に関する発表の様子を紹介しました。一休では、宿泊予約やレストラン予約の領域でユーザーファーストなサービスを作りたいというフロントエンドエンジニアを募集しています! hrmos.co 当日は40人ほどの方に来場いただきました。ご来場いただいたみなさま、ありがとうございました! 資料非公開です ↩
アバター
この記事は 一休.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 のことだとご認識ください。 アーキテクチャ まずは、前提となるアーキテクチャの概観から説明します。 ステート管理には 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 記事では XState を紹介していますが、現在は独自のステートマシン実装への置き換えを進めています。軽量サブセットである @xstate/fsm がバージョン 5 から提供されなくなったこと、型定義や非同期処理の機能不足が理由です。 ↩
アバター
宿泊システムのバッチ処理について(背景・課題) 新たに必要になったバッチ処理をどうやって作るか Cloud Workflows + Cloud Tasks を使ったバッチ処理 処理フロー Cloud Workflows Workflowsから外部APIを呼び出す APIのレスポンスをもとにCloud Tasksにエンキューする Cloud Tasks Web API リリース後の運用 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 から、オンライン処理できないものをほとんどバッチで作っている 現在では、そもそもバッチでまとめて処理せずに、非同期化・分散処理をする選択肢もある バッチで作るのが本当にベストなのか、ほかの選択肢も含めて検討したほうがよい といった内容でした。このフィードバック内容を踏まえて (もともとの案)新たにバッチ開発の基盤を作る マネージドなクラウドサービスを組み合わせて作る を検討し 今回実施したい作業はシンプルな処理の組み合わせで実現可能であること 並列、分散処理を考えやすい要件であること(ホテル・旅館単位で処理しても問題ない) といった理由から、最終的に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つを用意しました。 処理対象のホテル・旅館を取得するAPI(GET) 指定されたホテル・旅館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 : 実際には、この保証だけでなくほかの方法も含めて安全に運用できるように設計しています
アバター
はじめに 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
アバター
この記事は 一休.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 のデータ型は「値がどのように構造化され、意味づけられるか」を定義する手段です。データコンストラクタはその手段を提供し、構築と分解という双方向の操作を統一的に扱えるようにします。 この観点に立つと、データ型とデータコンストラクタの役割は次のように整理できそうです。 データ型は、プログラム内の「概念モデル」を定義する データコンストラクタは、そのモデルの構築ルールを提供する パターンマッチによる分解は、そのモデルを解析し操作する方法を提供する 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でどこまで「関数型プログラミング」するか ─ 「手続き Haskell」から考察する - 一休.com Developers Blog 永続データプログラミングと永続データ構造 - 一休.com Developers Blog TypeScript の型システムは JavaScript の上に後付けされたものということもあり、非常にプラクティカルで便利である一方、個人的には、やや散らかっていてその全体像や各機能の本質を掴みにくいと感じています。Haskell など表現に妥協の少ないプログラミング言語と比較し、相対化することでより深い理解に繋がることは多いです。 雑にまとめると Haskell やれば TypeScript 書くの上達する — naoya (@naoya_ito) 2024年11月16日 Enjoy !
アバター
この記事は 一休.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 の目的は、実装者とレビュアーの間で大まかな実装の合意をとることです。 新規ページ作成を例にすると 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 のときはリスト末尾に を追加する DELETE なら末尾の値を削除 SAVE 1 のときは、今使っているリストを ID 番号 の領域に保存、 LOAD 1 なら ID 番号 の領域からリストを復元する クエリのたび、その時点でのリストの末尾の要素を出力する という問題になっています。 この問題を永続データなしで解こうとすると、リストを更新しても以前の状況に戻れるような木のデータ構造を自分で構築する必要がありなかなか面倒です。一方、永続データを前提にすると、何の苦労もなく解けてしまいます。 以下は 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 ] ; すでにお気づきの方も多いと思いますが、値の更新にあたり、リスト全体のコピーが走ってしまっています。一つ値を追加、削除、更新するだけでもリストの要素 件に対し 件のコピーが走る。つまり の計算量が必要になってしまいます。永続データプログラミングは良いものですが、ナイーブに実装するとデータコピーによる計算量の増大を招きがちです。 Haskell など、イミュータブルが前提のプログラミング言語はこの問題をどうしているのでしょうか? 結論、データ構造全体をコピーするのではなく「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」ことによって計算量を抑え、不変でありながらも効率的なデータ更新が可能になるようにリストその他のデータ構造が実装されています。つまり同じ「リスト」でも、命令型プログラミングのそれと、不変なデータ構造のそれは実装自体が異なるのです。抽象は同じ「リスト」でも具体が違うと言えるでしょう。 変更あったところだけをコピーし、それ以外は元の値と共有を行うこのデータ構造の実装手法は Structural Sharing と呼ばれることもあります。Structural Sharing により不変でありながら効率的に更新が可能な永続データのデータ構造を「永続データ構造」と呼びます。 永続データ構造については、以下の書籍にその実装方法含め詳しく記載されています。 純粋関数型データ構造 - アスキードワンゴ もとい、例えば Haskell のリストは先頭の値を操作する場合は です。先頭要素だけがコピーされていて、それ以降の要素が更新前後の二つのリストで共有されるからです。 同じく、先の実装でも利用した Data.IntMap という辞書、こちらも永続データ構造ですが、内部的にはパトリシア木で実装されていて、値の挿入やキーの探索は、整数のビット長程度の計算量 ··· をデータサイズ、 をビット長としたとき に収まります。 Haskell で利用する標準的なデータ構造 ··· List、Map、Set、Sequence、Heap は、すべてイミュータブルでありながら、値の探索や変更が や 程度の計算量で行える永続データ構造になっています。(なお、誤解の無いよう補足すると、ミュータブルなデータ構造もあります。ミュータブルなデータ構造は手続き的プログラミングで変更することになります) 永続データ構造を利用することによって、永続データプログラミング時にもパフォーマンスをそれほど犠牲にせず、大量のデータを扱うことが可能になります。裏を返せば、永続データプログラミングをより広範囲に実践していくには、永続データ構造が必要不可欠であるとも言えます。関数型プログラミングは値が不変であることをよしとしますが、そのためには永続データ構造が必要かつ重要なパーツなのです。 TypeScript その他のプログラミング言語で永続データプログラミングを実践するとき、純粋関数型言語とは異なり、素の状態では永続データ構造の支援がないということは念頭に置いておくべきでしょう。 TypeScript や Python で永続データ構造を利用するには? TypeScript の Array、Map、Set などの標準的なデータ構造はすべて命令型データ構造、つまりミュータブルです。命令型のプログラミング言語においては、どの言語も同様でしょう。一方、プログラミング言語によっては List、Map、Set などの永続データ構造バージョンを提供するサードパーティライブラリがあります。 Immutable.js (JavaScript / TypeScript) pyrsistent · PyPI (Python) これらのライブラリを導入することで、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 ) は次の要素への参照を返す。これだけで永続スタックが実装できます。 二つのスタックを結合する ( (++) ) ときはどうしても かかってしまいますが、その際も双方のリストをコピーするのではなく古いリストの一方だけをコピーし、のこりの一つは新しいリストで共有されるように実装しています。 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)) 永続配列 永続配列は、配列といっても命令型の配列のように連続した領域を索引で参照できるようにするモデルではなく、完全二分木で表現します。 値は葉に持たせて、インデックスによる参照時には根から二分木を辿って目的の葉を特定します。そのため、参照時の計算量は ではなく となります。 二分木による配列の表現 更新時には「変更されるノードとそのノードへ直接的・間接的に参照を持つノードだけをコピーする」という考えに従い、根から更新対象の葉までを辿る経路上のノードをコピーする経路コピーという手法を使います。経路をコピーするといっても、木の高さ程度ですから更新も結局 になります。 経路コピーについては 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
アバター