一休.com Developers Blog

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

(追記あり)フロントエンドカンファレンス東京 2025 に一休のエンジニアが登壇します

※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の多言語対応

はじめに

こんにちは。一休データサイエンス部の平田です。

一休.comは主に国内の宿泊施設を取り扱う予約サイトですが、インバウンド需要の高まりを受け多言語対応を進めており、2025年の3月に国際サイトをリリースいたしました。対象言語は英語、中国語(繁体字・簡体字)、韓国語、タイ語、ベトナム語、マレー語、インドネシア語です。

一休.comトップページ 一休.comトップページのメニューから言語を切り替えることができます

一休.com英語版のトップページ 一休.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の見た目がどうとかを考えずに済む
  • ベースを実装し枠組みを作ることで
    • そのワークフローに乗るだけで各業務の実装に集中することができる
  • グループごとの実装を分けることで
    • 各業務コンポーネントを作るときに、その業務のコンポーネントに集中できる
      • 例えばカード決済の入力欄を開発するときに、予約者情報など別の業務について考えずに済む
    • 他メンバーが別の箇所を実装していても、自分の箇所と切り離して考えることができる
    • ベースの土台の実装にのっとった実装となっていれば影響を受けずに並列での開発が可能

ぜひみなさんもボトムアップに実装をしていきましょう。

一休 Frontend Meetupでエンジニアとデザイナーがフロントエンド開発の知見について紹介しました

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人ほどの方に来場いただきました。ご来場いただいたみなさま、ありがとうございました!


  1. 資料非公開です

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

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

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

はじめに

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

speakerdeck.com

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

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

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

アーキテクチャ

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

atom graph

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

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

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

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

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

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

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

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

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

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

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

Dependency の管理

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

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

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

Jotai で function を管理する

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

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

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

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

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

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

export const callGraphql$ = functionAtom(callGraphql)

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

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

Jotai Scope で Dependency を切り替える

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

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

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

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

const Context = createContext<Dependency>(defaultDependency)

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

テスト技法

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

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

純粋関数で書く

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

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

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

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

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

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

writable derived atom も同様です。

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

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

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

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

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

シナリオテスト

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

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

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

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

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

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

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

  // ...
})

おわりに

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

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


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

www.ikyu.co.jp

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

hrmos.co


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

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

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

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

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

  • Cloud Scheduler
  • Cloud Workflows
  • Cloud Tasks

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

を検討し

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

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

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

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

処理フロー

  • Cloud Scheduler
  • Cloud Workflows
  • Cloud Tasks

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

処理フロー

Cloud Workflows

cloud.google.com

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

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

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

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

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

Workflowsから外部APIを呼び出す

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

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

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

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

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

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

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

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

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

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

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

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

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

Cloud Tasks

cloud.google.com

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

zenn.dev zenn.dev

Web API

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

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

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

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

リリース後の運用

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

Cloud Tasksのキュー設定の調整

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

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

などを調整しました。

キュー設定変更のPull Request

異常終了時の検知を強化

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

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

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

まとめ

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

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

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

おわりに

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

www.ikyu.co.jp

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

hrmos.co

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

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

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

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

はじめに

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

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

qiita.com

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

user-first.ikyu.co.jp

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

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

qiita.com

www.arubanetworks.com

取り組んできたこと

組織体制の変化

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

社内研修資料より

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

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

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

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

before: 赤坂
after: 紀尾井町

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

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

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

user-first.ikyu.co.jp

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

Slack Enterprise Grid 移行

before: Slack Business Plus
after: Slack Enterprise Grid

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

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

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

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

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

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

user-first.ikyu.co.jp

デバイス管理の刷新

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

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

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

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

www.microsoft.com

www.jamf.com

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

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

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

www.jamf.com

www.jamf.com

EDR / SIEM 導入

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

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

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

www.microsoft.com

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

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

azure.microsoft.com

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

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

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

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

snipeitapp.com

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

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

www.toriihq.com

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

ヘルプデスクの改善

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

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

www.atlassian.com

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

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

user-first.ikyu.co.jp

Slack 打刻 / 勤怠打刻自動化

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

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

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

corp.teamspirit.com

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

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

akerun.com

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

corp.teamspirit.com

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

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

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

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

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

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

www.zunda.co.jp

PPAP 廃止

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

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

www.softbank.jp

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

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

www.mxhero.com

cloudnative.co.jp

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

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

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

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

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

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

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

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

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

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

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

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

SASE 導入の見送り

before: VPN
after: 未定

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

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

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

まとめ

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

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

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

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

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

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

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

エンジニア採用中です !

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

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

www.ikyu.co.jp

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

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

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

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

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

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

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

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

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

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

typescriptbook.jp

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

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

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

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

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

type User = Member | Guest

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

type Guest = {
  kind: "Guest"
}

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

タグには絵文字が使える

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

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

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

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

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

type TagIcon = EmojiIcon | NoIcon

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

type NoIcon = {
  kind: "NoIcon"
}

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

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

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

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

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

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

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

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

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

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

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

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

type NoIcon = {
  kind: "NoIcon"
}

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

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

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

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

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

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

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

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

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

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

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

Haskell のデータ型宣言

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

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

import Text.Printf (printf)

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

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

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

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

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

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

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

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

data TagIcon = NoIcon | EmojiIcon String | ImageIcon String String

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

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

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

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

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

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

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

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

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

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

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

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

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

  print $ read 3 arr -- 3

  let arr' = write 3 42 arr

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

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

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

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

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

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

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

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

empty :: List a
empty = Empty

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

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

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

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

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

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

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

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

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

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

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

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

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

interface Empty {
  kind: "Empty"
}

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

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

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

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

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

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

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

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

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

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

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

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

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

余談

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

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

Enjoy !