開発を快適にするiOSアプリ内ログ確認ツール

開発を快適にするiOSアプリ内ログ確認ツール(履歴・定義辞書・チェックリスト)

こんにちは!レシピ事業部の藤坂(@yujif_) です。

クックパッドiOSアプリの開発者用機能として「ログ確認ツール」を作ってみました。社内で1年以上運用して好評なので、その経緯や学びをまとめてみます。

iOSDC Japan 2022 では「モバイルアプリの行動ログの『仕込み』を快適にする」と題して関連した内容を発表しています。こちらの資料も合わせてご覧ください。

speakerdeck.com

アプリ内ログ確認ツールとは

クックパッドiOSアプリに内蔵された、ログ関連の開発者用ツール*1です。 「ログ履歴」「ログ定義辞書」「ログ送信チェックリスト」の3つの機能があります。

1. ログ履歴

クックパッドiOSアプリ内のログ確認ツール

ユーザーの行動ログ(例:ボタンのタップ、特定の要素の表示など)やAPIサーバーとの通信ログを、クックパッドiOSアプリの中ですぐに確認できます。

開発版のクックパッドiOSアプリ内で、ログ確認ツールを素早く表示できる様子

ログ確認ツールを素早く表示できる様子*2

このツールはデバイスのシェイク(Simulatorでは ⌃ ⌘ Z control + command + Z)でも表示でき、どの画面からでも気軽に使えます。

ログの内容確認が楽になる

必要な情報だけに絞り、種類別に色分けすることで、パッと見て把握しやすくしています。

これまでもロガーからの出力は Xcode 内のコンソール*3や Console.app などで確認できました。

Xcode 14のスクリーンショット。コンソールに行動ログの中身が表示されている。
[ログ確認ツールがなかった頃] Xcodeのコンソールに流れるログは見づらい

しかし、関係のない情報もたくさん流れてくるし、見た目もJSONそのままだったり、差分も分かりづらかったりと、人間にとっては疲れるものでした。

行動ログの構成要素

  • そのログ特有の付加情報
    • 例:対象リソースID、検索キーワード など
  • 全ログ共通で付加されている情報
    • 例:ユーザーID、端末OSバージョン、アプリバージョン など

1行のログには様々な情報が含まれますが、全てのプロパティを常に見たいわけではありません。固有の情報だけに絞って、内容の確認に集中しやすくしました。

2. ログ定義辞書

全ログ定義を一覧表示し、横断検索もできる。iPadにも対応。

送信されたログだけでなく、いま定義されている全てのログについても辞書のように調べられます。

知らないログの意味が分かる

ログ定義ドキュメントの活用

クックパッドでは、Markdown形式のログ定義をもとに型安全なログ実装用コードを生成する仕組みを3年以上運用しています。

techlife.cookpad.com

この仕組みのおかげで、ログ定義一つ一つに必ず説明文が用意されています。何のために導入されたログなのか、パラメータにはどのような値が入るのか、注意点は何か、当時のログ設計者が記したドキュメントから把握できます。

担当領域外にも目を向けやすく

そんな便利なログ定義Markdownですが、沢山の .md ファイルがただ置いてあるだけでは活用されません。 自分の担当領域のログは詳しくても、他の誰かが入れたログは何かきっかけがないとなかなか見ないものです。

アプリ内で見やすくなることで、触っているうちに自然と「こんなのあったんだ!こういうときに使えそう💡」といった境界を越えた発見が生まれるのを狙った部分もあります。

ログを業務に活かしやすくなる

ログ定義画面の「Bdash ServerでSQL例を探す」ボタンをタップすることで、社内のデータ分析SQLと実行結果にすぐたどり着けている図
ログ定義から、社内の分析SQLの例をすぐに探せる

ログを見にきた人は何かを調査・分析したい人のはずなので、それを手助けするリンクを用意しています。

集計・分析へのショートカット

例えばバナーの表示やタップのログであれば、集計SQLを書いて施策の効果検証をしそうです。

クックパッドでは、データ分析SQLを共有できる社内Webサービス「Bdash Server」がよく使われています。そこで、各ログ定義に関するSQL例をBdash Serverですぐに検索できるボタンをつけました(上図)。

techlife.cookpad.com

「この結果が気になるけど、SQLを書くのがちょっと……」という人も、もしすでに誰かが作った結果で事足りるなら即解決できますし、少し違うとしても参考にできるSQLがあるだけで書きやすくなります。

溜まっている知見をなめらかに使えるようにして、全社での生産性向上を狙っています。

3. ログ送信チェックリスト

ログ送信確認チェックリストに4つのログ定義が並んでいる図。4つのうち、3つは「送信済み」だが、1つだけ「未送信」と強調表示されている。
チェックリストに追加すれば、未送信のログがあぶり出される

各ログ定義をチェックリストに登録しておけば、ログ送信時に自動でチェックされ、そのログが送信済み*4かどうかを一瞬で確認できます。

ログが送られていない!?

新機能をリリースしていざ分析しようとしたら、必要なログの一部が送れていないことに気づき、「オァー!実装が漏れてました 💦」と焦る、そんな失敗が実際にありました。

例:「定期便」機能を新たにリリースする場合

  • 見たい指標
    • 定期便初回登録時のファネル
    • キャンペーンごとの効果
    • 定期便解除数
    • 定期便ユーザーのLTV など

施策に関する意思決定者と「最終的に何を知りたいのか」の認識を揃えることで、必要なログが洗い出せます。指標によっては、サーバー側のデータで事足りるものもあれば、モバイルアプリ側での行動ログが必要不可欠な場合もあります。

  • 必要なログ
    • 定期便の商品詳細画面の表示
    • 定期便登録ボタンのタップ(≒定期便登録確認画面の表示)
    • キャンペーンバナーの表示
    • キャンペーンバナーのタップ
    • 定期便解除ボタンのタップ
    • …… などなど

ややこしいのは、ユーザー状態次第では送らないログもあることです。例えば、定期便の初回ユーザー限定のバナーの表示ログは、一度でも定期便登録をしたユーザーからは送られないはずです。他にも、無料会員と有料会員の差、キャンペーンの流入経路ごとの差など、組み合わせ次第でどんどん複雑化していきます。

仕様が複雑になるとミスもしやすいですし、品質保証のテストも手間がかかります。必要なログを漏れなくすべて送るのは結構大変です。チェックリスト機能はこの対策のために作ってみました。

QA作業でログの実装漏れもわかる

ログ送信チェックリストの使い方

まず分析に必要なログ定義を一通りチェックリストに入れます。次に、想定されるユーザーと同様の流れでアプリを操作します。

操作を終えたとき「すべて送信済み」となっているなら問題ありません。アプリをリリースしてOKです。もし「未送信あり」なら、ログ送信の実装漏れがどこかにあるということです。

このチェックリスト機能を使えば、品質保証(QA)の手動テストの時間で、ついでにログの実装漏れも検出できます。

実装担当者以外でも分担できる

アプリ単体で完結するので、Xcodeなどの開発環境も必要なく誰でも実施できます。複数パターンの検証も、エンジニアに限らずチームで分担して一気に進められるのはうれしい点です。

実装方法

アプリ内ログ確認ツールを実現するには、どうすればよいでしょうか?

まず、送信済みログを読み出せること。次に、それらをログ定義ドキュメントとうまく紐付けて扱えること。この2つが必要です。

クックパッドiOSアプリのログ関連の実装概要図

送信済みログを端末内で保持するなら、ファイルへの出力、インメモリでの保持などいくつか方法が考えられます。 今回は、iOSの統合ロギングシステム(以下、OSログ)を活用することにしました。

Logging | Apple Developer Documentation

1. 送信済みログを読み出せるようにする

クックパッドiOSアプリでは、ログ収集ライブラリとして Puree-Swift を使っています。

techlife.cookpad.com

os.Logger で書き込む

以下のようなコードで、簡単にOSログに出力できます。

import os

// ログの出自がわかるように subsystem と category を指定
let logger = Logger(
    subsystem: Bundle.main.bundleIdentifier!,
    category: "ActivityLog"// 行動ログの場合の例
)

let logDataString = """
{
    "user_id": 1234567890,
    "event_category": "recipe_detail",
    "event_name": "tap_save_button",
    "recipe_id": 123456
}
"""

// ログを書き込む
logger.notice("\(logDataString)")

例えば、以下のように OSログ出力用の Puree-Swift のOutputを定義してConfiguration に加えると、ログサーバーへ送信されるログと同じ内容が、端末内のOSログにも出力されます。

より詳細なPuree-Swift の Output 実装例はこちら
import Foundation
import os
import Puree

final class OSLogOutput: InstantiatableOutput {
    let tagPattern: TagPattern
    private let logger: os.Logger // iOS 14+ で利用可能

    required init(logStore: LogStore, tagPattern: TagPattern, options: OutputOptions?) {
        self.tagPattern = tagPattern

        logger = Logger(
            subsystem: Bundle.main.bundleIdentifier!, 
            category: "ActivityLog" // 行動ログの場合の例
        )
    }

    func emit(log: LogEntry) {
        guard let userData = log.userData else {
            assertionFailure("logEntry must have userData")
            return
        }

        guard let payload = try? JSONSerialization.jsonObject(with: userData, options: []) as? [String: Any] else {
            assertionFailure("Cannot decode userData as JSONObject.")
            return
        }

        if let logDataString = prettyJSONString(payload) {
            logger.notice("\(logDataString, privacy: .public)")
            // デフォルトでは情報がマスクされるが、開発版ビルドのみなので、`.public` にしている
        }
    }

    private func prettyJSONString(_ object: Any) -> String? {
        guard let data = try? JSONSerialization.data(withJSONObject: object, options: [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes]) else {
            return nil
        }
        return String(data: data, encoding: .utf8)
    }
}

この os フレームワークの Logger ですが、以下のような特徴があります。

  • 非常に効率的で、アプリの動作遅延なく使える*5
  • Console.app や Xcode のコンソールでログを確認できる
  • センシティブな情報はマスクできる(指定して公開もできる)

developer.apple.com

OSLogStore で読み出す

アプリ内ログ確認ツールで利用する際は、OSLogStore を使ってOSログから読み出しています。これは iOS 15以降で利用できます。

OSLogStore | Apple Developer Documentation

import OSLog

protocol OSLogEntriesDataStoreProtocol: AnyObject {
    func fetchEntries() async throws -> [OSLogEntry]
}

final class OSLogEntriesDataStore: OSLogEntriesDataStoreProtocol {
    func fetchEntries() async throws -> [OSLogEntry] {
        let store = try OSLogStore(scope: .currentProcessIdentifier)
        let predicate = NSPredicate(format: "subsystem == %@", Bundle.main.bundleIdentifier!)

        return try store.getEntries(matching: predicate)
            .reversed()
        // Workaround: `store.getEntries(with: .reverse, matching: predicate)` で降順(新しいログが先)に返されるはずだが、iOS 16時点では機能しないため、ここで逆順にしている。
        // ※追記:iOS 17で直っていました!
    }
}

2. ログ確認ツールで扱いやすいデータに変換する

OSLogEntry のメッセージをデコードする

読み出した OSLogEntrycomposedMessage には行動ログの中身が入っていますが、この時点ではただのJSON文字列です。以下のようなコードで中身をデコードしてアプリ内ログ確認ツールで扱いやすくします。

OSLogEntry のままでは category *6 (先ほどの例では "ActivityLog" という値)を参照できないので、OSLogEntryWithPayload にダウンキャストします。

import OSLog

struct LogEntryResolver {
    /// OSLogEntryから必要な情報を取りだして、アプリ内ログ確認ツールで扱いやすいモデルに変換します
    static func resolve(entry: OSLogEntry) -> CookpadLogEntry? {
        guard let entryWithPayload = entry as? OSLogEntryWithPayload else { return nil }
        guard let payload = decode(message: entry.composedMessage, category: entryWithPayload.category) else { return nil }
        return CookpadLogEntry(id: UUID().uuidString, date: entry.date, payload: payload)
    }
}

行動ログの定義ドキュメントとの紐付け

クックパッドでは、Markdown形式のログ定義から型安全なログ実装用コードを生成するために daifuku *7 というライブラリを使っています。

今回はその仕組みを応用し、アプリ内ログ確認ツールからログ定義ごとの解説情報を参照できるコードを自動生成するようにしました。*8

ログ定義ごとの解説情報の用意

例えば、下記のように解説情報のためのstructを定義します。

struct ActivityLogDefinition: Hashable {
    /// ログイベントカテゴリ(例: `sagasu` )
    var category: Category
    /// ログイベント(例:`show_content`)
    var event: Event

    struct Event: Hashable {
        /// ログイベント名(例:`show_content`)
        var name: String
        /// ログイベントの解説文(例:`さがすタブのコンテンツが画面に表示された時に送信されます。`)
        var description: String
        /// ログイベントに付加されるパラメーター
        var parameterNotes: [ParameterNote]

        /// 各ログに付加されるパラメーターについての解説
        struct ParameterNote: Hashable {
            /// パラメーターのキー名(例:`hashtag_ids`)
            var name: String
            /// パラメーターの解説文(例:`表示されたハッシュタグID`)
            var description: String
            /// パラメーターのSwiftでの型名(例: `String?` )
            var swiftType: String
        }
    }
}

適当にRuby スクリプトを書いて、daifukuを使ってログ定義Markdownの情報を扱い、テンプレートをもとにログ解説情報を Swift の enum として自動生成します。

Markdown からログ定義の enum を生成するRubyスクリプトの例 https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb

自動生成用のテンプレートファイル (.erb) の例
// This file is automatically generated by generate-log-classes.

extension ActivityLogDefinition {
    enum Category: String, Hashable, CaseIterable {
        <%- categories.each do |category| -%>
        case <%= category.variable_name %> = "<%= category.name %>"
        <%- end -%>
    }
}

extension ActivityLogDefinition.Category {
    var description: String {
        switch self {
    <%- categories.each do |category| -%>
        case .<%= category.variable_name %>:
            return  """
            <%- category.descriptions.flat_map(&:lines).each do |description_line| -%>
            <%= description_line.strip %>
            <%- end -%>
            """
    <%- end -%>
        }
    }

    var events: [ActivityLogDefinition.Event] {
        switch self {
    <%- categories.each do |category| -%>
        case .<%= category.variable_name %>:
            <%- if category.available_events.empty? -%>
            return []
            <%- else -%>
            return [
                <%- category.available_events.each do |event| -%>
                .init(
                    name: "<%= event.name %>",
                    description: """
                    <%- event.descriptions.flat_map(&:lines).each do |description_line| -%>
                    <%= description_line.strip %>
                    <%- end -%>
                    """,
                    parameterNotes: [
                    <%- event.columns.each do |column| -%>
                        .init(
                            name: "<%= column.original_name %>",
                            description: """
                            <%- column.descriptions.flat_map(&:lines).each do |description_line| -%>
                            <%= description_line.strip %>
                            <%- end -%>
                            """,
                            swiftType: "<%= column.swift_type %>"
                        ),
                    <%- end -%>
                    ]
                ),
                <%- end -%>
            ]
            <%- end -%>
    <%- end -%>
        }
    }
}

こうして用意した解説情報を、送信済みログと紐付けます。

送信済みログとの紐付け

送信済みログの中身に含まれる eventCategoryeventName の値から、どのログ定義かは一意に定まります。下記のように、送信済みログのペイロードからログ定義解説情報を参照できるようにしました。

extension ActivityLogPayload.DefinitionKey {

    /// 行動ログの定義ごとの解説情報
    var definition: ActivityLogDefinition {
        guard
            let category = ActivityLogDefinition.Category(rawValue: eventCategory),
            let event = category.events.first(where: { $0.name == eventName })
        else {
            fatalError("ログ定義が見つかりませんでした")
        }
        return ActivityLogDefinition(category: category, event: event)
    }
}

3. 便利な機能を色々実装する

あとは「扱いやすくした送信済みログ」と「解説情報」を材料として、自由に料理して好みの画面をつくるだけです。ここでは雑多にいくつかのトピックをご紹介します。

チェックリスト機能

チェックリスト機能は、次のような単純な実装です。

  • チェックリストに登録したログ定義のキーを UserDefaults で保持しておく。
  • 送信済みログの中に、そのキーと一致するログが1つでもあれば、チェックリスト上でそのログ定義を「送信済み」にする。

ネットワーク通信のログにも対応

行動ログだけでなく、ネットワーク通信のログも見られるようにしています。実際にアプリを操作しながら、どのAPIエンドポイントがどのタイミングで使われているのか、すぐに確認できるのは便利です。

Request や Response の詳細も良い感じに表示*9

CharlesProxyman などのサードパーティーアプリのほうが高機能ですし網羅性も高い*10ですが、常に起動しているとも限らないですし、いざ使いたいときにちょっと手間がかかります。

障害発生時の調査にも役立った

例えば、特定の画面がエラーになるといった障害が発生したとき、アプリ単体でも素早く調査できたのは便利でした。発生条件の特定や原因の切り分けがスムーズにできると、より焦らずに対応できます。

なお、ネットワーク通信のログについては OSログには送らず、メモリ上に保持しています。 *11

実装に関して調査しやすくする

ネットワーク通信のログから、API仕様の調査やソースコード検索がすぐできる

行動ログの集計・分析ショートカットと同様に、ネットワーク通信のログを見にきた人はAPIや実装に関して色々調査をしたいはずだということで、以下の社内Webサービスへのリンクを用意しています。

  • APIサーバーに対して実際のリクエストを手軽に試せる「API3 Console」
  • APIサーバーのスキーマ定義からドキュメントを提供する「Garage Playground*12
  • ソースコードやタスク、プロジェクトの管理をしている「GitHub Enterprise」

例えば 「実装箇所を GitHub Enterprise(GHE)で表示する」ボタンは、レポジトリ内のSwiftコードの検索結果のURLを開くだけですが、秒で利用箇所が見つかるのは思っている以上に便利です。

小ネタですが、次のような工夫も入れています。

// recipeID, userID などを含むURLは、GHE検索時にヒットせず不便なので * に変換している
// (例: `/v1/recipes/:id`, `/v1/users/:id/visited_recipes` など)
let query = request.url.path.replacingOccurrences(of: "/([0-9]+)(/|$)", with: "/*$2", options: .regularExpression)

SimulatorではURLコピーに

実機ではブラウザで開き、SimulatorではURLをコピーする

さらに小ネタですが、iOS Simulatorで開発中にこのボタンを使うと、Simulator内のSafariが開いてしまい不便*13だったので、こんな対策をしました。 Simulator実行時は URLをクリップボードにコピーするので macOS側 ですぐ開けます。iPhone/iPadの実機では実機のブラウザが開きます。

// リンクボタンの実装例

   var body: some View {
        #if targetEnvironment(simulator)
        buttonForSimulator(targetURL)
        #else
        buttonForRealDevice(targetURL)
        #endif
    }

    private func buttonForSimulator(_ targetURL: URL) -> some View {
        CopyTextButton(
            stringToCopy: targetURL.absoluteString,
            labelTitleForCopied: "URLをコピーしました(Simulator内のSafariで開くと不便なので)"
        ) {
            label
        }
    }

    private func buttonForRealDevice(_ targetURL: URL) -> some View {
        Link(destination: targetURL) {
            label
        }
        .contextMenu { // 実機でも長押しメニューから一応コピーできるようにしている
            CopyTextButton(stringToCopy: targetURL.absoluteString) {
                Label("URLをコピー", systemImage: "doc.on.doc")
            }
        }
    }
import SwiftUI

struct CopyTextButton<Content: View>: View {
    var stringToCopy: String
    var labelTitleForCopied: String
    @ViewBuilder var content: Content

    init(stringToCopy: String, labelTitleForCopied: String = "コピーしました!", @ViewBuilder content: () -> Content) {
        self.stringToCopy = stringToCopy
        self.labelTitleForCopied = labelTitleForCopied
        self.content = content()
    }

    @State private var isCopied = false

    var body: some View {
        Button {
            UIPasteboard.general.string = stringToCopy
            print("[LogChecker] Copied to the pasteboard: \(stringToCopy)")
            Task {
                defer { isCopied = false }
                isCopied = true
                try? await Task.sleep(for: .seconds(3))
            }
        } label: {
            if isCopied {
                Label(labelTitleForCopied, systemImage: "doc.on.doc")
                    .font(.callout)
                    .foregroundColor(.secondary)
                    .imageScale(.small)
            } else {
                content
            }
        }
    }
}

振り返って

よかったこと

このログ確認ツールは、色んな面で開発を楽しくできました。

ログの実装・確認がつらくなくなる

「大事だけど正直面倒な作業」とも感じていたログの実装や確認を幾分か快適にできたと思います。個人的にはアプリ内ログ確認ツールを使いはじめてからは「ちょっと楽しいまである」という気持ちに変化していました。同僚からもSlackなどで「すごい見やすくなってる!」「はちゃめちゃに助かっている」「課金したい」といったポジティブな反応をもらえています。

自由に実験できる環境で遊べる

Viewについては、すべてSwiftUIで実装しました。

普段、一般ユーザー向けに開発している画面は SwiftUI (場合によっては UIKit)を採用していますが、全体的には VIPER アーキテクチャで、画面遷移も UINavigationController をベースに使っています。

techlife.cookpad.com

今回は開発者用ツールということもあって、サポートOSバージョンや不具合などはそこまで気にしなくても済む状況でした。むしろ、こういう機会に積極的に新しい技術を試して、知見を貯めるほうが望ましいでしょう。

チームで合意をとり、ログ確認ツールに関しては @available(iOS 16.0, *) (今なら iOS 17)をつけて、最新のSwiftやSwiftUIの機能を使い放題にしました。制約なく技術を楽しめるエンジニアにとってのオアシスのような場所です。

例えば NavigationStackNavigationSplitView など、普段使っていないSwiftUIの画面遷移関連も試しています。 正規表現を使う箇所では、RegexBuilder も試しました。

ViewThatFitsを使えば、簡単に解決できてすごく便利だ」「この書き方、iOS 17から deprecated になるのか!」「これ便利だけど、この挙動は気を付けないと不具合を生み出しそうだ……」

このように自由に実践して得られた知見や肌感覚は、ただ楽しいだけではなく、近い将来のユーザー向け機能の開発をスムーズにして、とても役立ちます。

欲しいものを作れると楽しい

あったらいいなを次々と実現するのが純粋に楽しかったです。 *14

改善の無限ループが爆速で進むのは楽しい

開発者向けツールはユーザーが自分でもあるので、ニーズの理解も、真に解決できているのかの実感もすぐできます。 作ってみて、試しに使って、新たな発見があってまた作る、この改善が爆速で進められます。

業務にしっかり役立つ「仕事」ではあるものの、楽しくてついやってしまう「趣味」でもあり、「趣味の仕事」という言葉が社内で流行していました。このアプリ内ログ確認ツールも趣味の仕事の一例です。

改善したいこと

ログの読み込みを速くしたい

OSログは書き込みは良いですが、読み込みは遅いようです。 os.signpost と Instruments を使って計測してみると、.getEntries*15 の1行だけで圧倒的に時間がかかっています。

動作環境によって大きく差があり、気にならない程度のときもあれば10秒近くかかってさすがに使いづらいと感じるときもあります。Simulatorと実機の差、OSログに溜まった量の差などいくつか要因がありそうな気もしつつ、詳しくはまだ調べられていません。

画面表示毎に更新すると読み込みで待ちすぎるので、今はキャッシュ層を挟んで更新頻度を下げています。

まとめ

今回は、アプリ内ログ確認ツールの機能や実装方法、分かったことについてご紹介しました。

まだ改善の余地はありますが、ちょっとした工夫の積み重ねによって開発を快適にする目的は一定達成できたと感じています。

日々のサービス開発をより良くするために、この記事が何か少しでも参考になったら幸いです。

*1:このツールは開発版ビルドのみに含まれており、App Store版では利用できません。

*2:SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。

*3:なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。https://developer.apple.com/videos/play/wwdc2023/10226/

*4:ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。

*5:https://developer.apple.com/videos/play/wwdc2020/10168/ より

*6:https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category

*7:ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。https://techlife.cookpad.com/entry/2020/12/29/004145

*8:ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。

*9:このJSONの表示部分は、同僚のNiaさんの SwiftUI で JSON を表示する View を使わせてもらいました。https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8

*10:アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。

*11:元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚のVincent さん が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。

*12:クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。https://techlife.cookpad.com/search?q=Garage

*13:Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。

*14:Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: https://youtu.be/2HitJxXXzwY?t=1325

*15:https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries

クックパッドの検索反映時間を 1/288 にしたシステム改修

こんにちは。レシピ事業部の新井(@SpicyCoffee)です。

クックパッドではこれまで、レシピを投稿してから検索結果に反映されるまで最長で 24 時間程度の時間がかかっていました。今回、この時間を 5 分程度、最長でも 10 分程度に短縮することに成功しました。本記事では、プロジェクトオーナーの立場で関わった私が代表してその開発について紹介します。

プロジェクトの目的と数値目標

本プロジェクトでは上記の「レシピを投稿してから検索結果に反映されるまでの時間短縮」が目的とされました。しかし、時間短縮といっても現状 24 時間であるものを "1 時間" にするのか、"1 分" にするのか、"1 秒" にするのかでは話が全然違います。この数値目標は設計を始めとした後の意思決定に大きく影響を与えるため、しっかりとした意図を持った状態で明確に定めておく必要がありました。

そこで、私とプロダクトオーナー*1が議論を重ね、まずは ”今回のプロジェクトで実現したいユーザー体験" を定めました。その体験から必要となる検索結果の反映頻度を逆算し、最終的な数値目標を「中央値 5 分程度、最大でも 10 分以内の検索結果への反映」であると定めることとなりました。同時に定めたプロジェクトのスケジュールは 6 週間であり、見積もりの第一印象としてはかなりギリギリの設定でした。

この記事では、今後本プロジェクトで実現された「検索結果が反映されるまでの時間の短縮」を “short-period indexing” と呼称することにします。

旧システムの概要

プロジェクト発足時点での検索周りのシステム(以下旧システム)を以下に示します。

旧システムの構成

旧システムの肝は以下の2点です。

検索インデックスを生成する日次バッチ

旧システムでは、検索結果の更新を 24 時間に一度でいいと割り切り、日次バッチでインデックスの更新を行っていました。レシピに関する各種メタデータを集め、必要に応じて加工することでドキュメントを生成し、そのドキュメントを Solr に送信することでインデックスを生成します。生成されたインデックスは後ほど説明する ECS を利用したデプロイメントのために S3 に配置されます。

日次更新でよいという割り切りの元に、およそ 100 を超える field の情報を数百万レシピについて毎日生成しており、中には機械学習を用いてレシピにスコアを付与するような処理も含まれていたため、その実行時間は 90 分程度になっていました。

ちなみに、このバッチ自体も 5 年ほど前に旧システムから分離・リプレイスされたものになります。当時の様子は以下の記事に記載してあるため、よろしければあわせてご覧ください。

ECS を利用したデプロイメント

旧システムでは、ECS のタスクとして Solr を起動していました。一般に、検索エンジンのようなステートフルなミドルウェアと ECS は相性がよくないとされています。しかし、旧システムでは S3 にインデックスを配置してタスクの起動時にそれをダウンロードしてくることでステートをコンテナの外に出し、その相性の悪さを解消しています。

この設計は「ステート(= インデックス)の更新頻度が十分に低い」という前提に基づいているものであり、本プロジェクトの目的を達成するためにはリプレイスする必要性が出てくる可能性もある箇所でした。

この開発についての詳細は以下の記事で解説されていますので、よろしければこちらもご覧ください。

目標達成のための課題

旧システムを考察することで、目標を達成するためには以下のような課題があることがわかってきました。

short-period indexing に適した Solr の使い方を再考する

旧システムでは、常在的に起動している Solr は "参照系" のみであり、index の更新時には spot instance として "更新系" の Solr を立ち上げて index を生成していました。index の更新が日次であればこの方法でも問題ありませんが、これが数分以下のオーダーになるなら "更新系" の Solr も常に稼働し、かつ複数の "参照系" Solr に更新を同期する必要がありそうです。

更新の同期方法については、たとえば、Solr にはクラスタを組んで replication を実行するための機能があります。しかし、この機能が ECS に Solr を乗せている状態でも問題なく動作するかは自明ではありません。ECS を活用することによるデプロイやスケーリングの容易性といったメリットは可能な限り残したい*2ものの、そのためには新しい要件に合わせた調査や工夫が必要になりそうです。

そもそも S3 を介してインデックスを配布するやり方が適しているかも含め Solr 周りの構成・設計は大幅に考え直す必要がありそうでした。

インデックスする情報を選別する

前述したように、レシピのドキュメントは 100 前後の field を持っており、中には機械学習を用いて付与されたスコアのようなものも含まれます。これら全ての情報をインデックスしようとすると、そもそもその処理に時間がかかる可能性が高く、short-period indexing のタイムスパンでこれを実行することは困難だと考えられます。したがって、ユーザー体験に立ち返って short-period indexing のスコープに含める field を定義する必要がありました。

また、クックパッドのレシピはユーザー投稿物です。したがって、何のチェックもせずにレシピをインデックスしてしまうと、明らかに料理ではない写真を用いたレシピなどの、不適切な投稿の露出が増えてしまう可能性があります。このことを考えると、インデックスする情報に加えて「どのレシピをインデックスするか」という判定が必要になると予想されました*3

日次バッチによる更新と short-period indexing による更新を同居させる

日次バッチによるインデックス更新は、更新頻度と引き換えではありましたが、緊急時にロールバックが容易になるといったメリットもありました。検索結果に不具合が生じた際、インデックスのバージョンを巻き戻すことで前日時点のインデックスを用いて検索機能を提供することが容易で、これは検索システムそのものの頑健性を支える一つの要素になっています。

この「セーブポイントをつくる」機能は有用なため可能であれば残したく、そうなると日次バッチによる更新と short-period indexing による更新が並列することになります。こうなるとインデックスの更新経路が複数になるため、その際にコンフリクトが起こらないようにシステムを設計する必要がありそうでした。

キャッシュが検索結果の更新を阻害しないようにする

前述した構成図では表現されていませんでしたが、検索システムの周辺には多種多様のキャッシュが存在しています。クライアントアプリからのリクエストを受け付ける API や、検索サーバーからのリクエストを受け付ける Solr と、複数箇所にキャッシュが存在しており、検索インデックスの更新時にはこれらを破棄しなければ検索結果が変化しません。

単純にキャッシュを剥がせば各サービスへの負荷増大は避けられず、まずは現状のヒット率等を調査して剥がせるなら少しずつ剥がす、難しそうならサーバーを増やすなどの対応が必要になりそうでした。

新システムの概要

以上に挙げた課題を解決するために、以下の図に示すような全体像のシステムを設計・開発しました。

開発の流れとしては、全体設計についてはプロジェクトメンバーの 4 名全員で議論しながら固め、必要な開発がある程度特定された後に、各位の専門領域に合わせて調査や実装を割り振る形にしました。

新システムの構成

新システムの特徴を以下に示します。

1. User-Managed Index Replication を利用した Solr cluster の構築

新システムでは Solr が提供する User-Managed Index Replication の仕組みを利用して "更新系" と "参照系" を組み合わせた Solr cluster を構築しました。

このモードでは Solr インスタンスは update リクエストを受け付ける 1 台の leader と、検索リクエストを受け付ける複数台の follower に分かれます。follower は設定した時間ごとに leader に対してポーリングを行い、差分をダウンロードします*4。それぞれの Solr は旧システムと変わらず Hako を用いて ECS Task として起動しています。

細かな要件としては、更新がコンフリクトしないように同時に起動している leader は最大 1 task に抑える必要があり、これは ECS の minimumHealthyPercentmaximumPercent を設定することで保証しています。

また、follower は起動時に日次バッチで生成された index を S3 からダウンロードし、その後 leader が保持している更新分を replicate し終わったタイミングで自身の status を healthy としてサービスインします。こうすることで、ヘルスチェックを成功させるタイミングをコントロールし、起動後 replication 途中の follower にアクセスが集中すると、アクセス毎に検索結果が変わってしまうといった問題を防いでいます。

2. EFS を利用した index の永続化

新システムにおいては、leader Solr が再起動や deploy をした場合においても index の状態を保ち、update と replication が正しく動作する状態を保証する必要があります。

これを実現するために、AWS のネットワークストレージサービスである EFS が利用できます。EFS を ECS にアタッチすることで、永続的なストレージをマウントすることができます。しかし、EFS はネットワーク越しにアクセスするストレージであるため、レイテンシ等の性能は ECS のエフェメラルストレージに対して少し劣るものとなってしまいます。

そこで、update リクエストを受け付けて index を永続化する必要のある leader のストレージには EFS を使い、ユーザーからの検索リクエストを受け付けて素早く応答する必要がある follower のストレージには tmpfs を利用することとしています。

また、新システムにおいても、旧システムと同様に日次で計算・付与される field は存在するため、日次バッチで生成された index で EFS の中身を差し替える処理が実行されています。

このとき、index の差し替えや leader/follower の再起動順序によっては replication の整合性が取れなくなり様々な問題が発生することがわかったため、依存関係を丁寧に整理して各処理の実行順序を制御しています*5

3. index update batch の定期実行

index の更新は 5 分ごとに定期実行するバッチで実現しています。その定期実行ごとに「直近 1 時間で更新があったレシピの情報」を取得し、その情報を元に必要な処理を施してドキュメントを生成し、leader に update のリクエストを投げるという流れです。

5 分に一度 update がリクエストされている様子

このとき「そのレシピが不適切な投稿である確率はどのくらいか」を ML によって判定する API へのリクエストを挟むことで、不適切投稿の露出が増えることを防いでいます*6

定期実行バッチにするのではなく、レシピの投稿・更新にフックさせてイベントを発行・キューイングして都度処理する方針も考えましたが、

  • イベントの発行数が多くなり既存の社内基盤を利用することができるかどうかが明らかでなかった
  • リトライ処理の実装が複雑になる
  • そこまでのリアルタイム性が求められていない

ことから採用を見送っています。

本番環境への展開

プロジェクトの完遂には、システムの構築とは別に展開に向けた各種作業も必要です。今回は SRE のメンバーの協力によって、以下に挙げるような作業を事前にキャッチアップ・進行してもらうことができ、非常にスムーズに展開を終えることができました。

キャッシュの整理

システムを構築しても、既存のキャッシュ構成は日次での検索結果更新を前提としていたため、TTL が数時間単位のものになっていました。このままでは、キャッシュの更新間隔が検索結果の更新間隔よりも長くなってしまいます。

検索結果の更新頻度に合わせてキャッシュの TTL を短縮したいですが、調査が不十分のまま進めるとキャッシュの裏側にあるサービスへの負荷が増大し、障害を引き起こしてしまう可能性があります。

そこでまずはキャッシュの設定変更が与えている影響を観測できるように、Prometheus + prometheus_exporter gem を用い、キャッシュのヒット率などを計測するようにしました*7。次にそれらの変化や各サービスの負荷を確認しながらキャッシュの TTL を徐々に短くする変更を行い、最終的に、サービス障害を起こすことなく TTL を 5 分にまで短縮できました。

負荷試験と段階ロールアウト

検索機能の変更はクックパッドのほぼ全ユーザーに影響を与える大規模なものになります。本番展開前の負荷試験は、展開後の障害発生率を抑えることができるのはもちろん、開発者が安心して展開を行えるようになります。

また、展開自体を一度に行うのではなく、徐々にユーザーリクエストを流すような段階ロールアウトの手順を踏むことで、大規模障害の発生率を抑えることができます。

今回は以下の手順で負荷試験と段階ロールアウトを行いました。

  1. 本番の Solr に届いているリクエストをミラーリングし、新 Solr cluster でもリクエストを問題なく捌けるかを確認する(負荷試験)
    負荷試験の概要
  2. 実際に一部のレスポンスを新システムからのものに差し替え、徐々にその割合を大きくしていく(段階ロールアウト)
    段階ロールアウトの概要
  3. 全てのレスポンスが新システムからのものになった後、short-period indexing を有効にする

このうち、1 の負荷試験は Envoy のRequestMirrorPolicyを、 2 の段階ロールアウトは Envoy のWeightedClusterを使って実現しています。

まとめと振り返り

本プロジェクトでは、従来の検索システムではレシピ投稿から結果への表示までに最長 24 時間かかっていたものを、5 分程度にまで短縮することに成功しました。課題の特定から解決までを 6 週間でおこなうというタイトなスケジュールではありましたが、事業要件に過不足のない開発を事故なく完遂することができたのではないかと思います。

振り返ってみると成功の要因としては

  • プロジェクト冒頭にプロダクトの実現すべき体験からブレークダウンする形で要件をしっかりと定義した
    • 後の意思決定に軸が通り、手戻りも少なくなった
  • プロダクトからインフラサイドまで、各領域について高い専門性を持つメンバーが集まった
    • 全体の要件定義やざっくりとした設計は全員で行い、そこから先の詳細開発は各メンバーが担当した
    • プロジェクトのため臨時に結成されたチームだったが、期間中は週2回の check-in MTG を設定してスムーズに同期と相談をおこなえるようにした
  • スポットで機械学習エンジニアなど、他チームの助力も得ることができた

ことが大きかったのではないかと思います。

組織として達成したいミッションがあり、そのための事業・プロダクトがあり、それが実現したい体験を阻んでいる障壁があるところに技術をぶつけてそれを取り除くという仕事は、やはりとてもやりがいのあるものだと改めて実感しました。それぞれに高い専門性を持つメンバーから成るチームで仕事ができたことも含めて、個人的には入社以来もっともおもしろい仕事の一つであったように思います。

Acknowledgements

本プロジェクトは 4 名のメインメンバー+周辺部署のメンバーが関わり、それぞれ力を発揮したことで完遂することのできたプロジェクトです。私一人の力では到底実現できなかったであろう課題解決を共に推進してくれたことに改めて感謝します。

最後に、メインメンバーの 4 名について、各作業をどのように担当したかを明記します。

  • @SpicyCoffee(筆者)
    • 検索エンジニア
    • 担当:プロジェクト全体の統括・最終意思決定 / indexing application の実装
  • @osyoyu
    • 検索エンジニア
    • 担当:Solr Cluster と Persistent Storage 周りの設計・開発
  • @s4ichi
    • SRE
    • 担当:Solr Cluster と Persistent Storage 周りの設計・開発 / 負荷試験とロールアウト
  • @eagletmt
    • SRE
    • 担当:キャッシュの調査と最適化 / indexing application の実装

この記事が、日々技術を用いてユーザー課題を解決しているみなさまのお役に立てば幸いです。

*1:今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。

*2:当時の ECS & 社内基盤 Hako という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。

*3:人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。

*4:leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。

*5:現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。

*6:この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。

*7:クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の multi process modeを用いました。

クックパッドのフロントエンド CSS in JS をゼロランタイムに切り替えました

こんにちは。レシピ事業部のkaorun343です。我々のチームではレシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログにて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSをEmotionからゼロランタイムのvanilla-extractへ変更した話です。

vanilla-extract.style

背景

以前書いた レシピサービスのフロントエンドに CSS in JS を採用した話 - クックパッド開発者ブログでは、CSS in JSライブラリとして Emotion(@emotion/react)を採用した経緯と開発環境整備を紹介しました。採用理由としては以下の通りでした。

  • セレクタに一意なIDが割り振られるので、スタイルを適用した要素とは別の要素への、意図しないスタイル適用を防ぐことができる。
  • ESLintやTypeScriptコンパイラといったJavaScriptの静的解析ツールの恩恵を受けることができ、タイポや機能削除時の削除漏れに気づきやすくなる。
  • styled-componentsのようなスタイルではJSXのツリーを見たときに、機能を持つコンポーネントなのか装飾されたコンポーネントなのかわからず、コードレビューがしにくい。
  • 通常のCSSの記法に慣れたメンバーが多いので、String Styles、すなわちタグ付きテンプレートリテラルを採用する。

このような方針でEmotionの導入を決め、stylelintやeslintを導入し、必要に応じてカスタムルールを作成して機能開発を進めました。

しかしながら、Emotionを導入してから2年ほど経った結果、以下のような課題や懸念を抱えるようになりました。

  1. ページサイズ:SSR時には初期表示用のCSSをEmotionが作るわけですが、このCSSは .css ファイルとしてブラウザに届くのではなく Next.jsから配信されるHTMLに埋め込まれた状態でブラウザに届きます。そのため、ロードバランサーを通過するHTMLのサイズが増加してしまいます。CSSのデータがCDNを通らないため、パフォーマンスの面でもコストの面で問題です。実際、background-imageにbase64の画像URLを埋め込んだときには、その影響が強く出てしまいました。
  2. 動的生成による肥大化:Next.jsのSSR時にうっかりCSSのバリエーションを増やしてしまうと、Next.jsプロセスのメモリ使用量が増大し、アプリケーションが落ちてしまいます。これは、Emotionがインメモリのキャッシュ機構を備えており、一度生成したCSSデータを保持し続けるためです。過去の事例では、レシピごとに異なるbackground-imageを設定するCSSをEmotionで書いたときにこの問題が生じました*1
  3. クライアント側のオーバーヘッド:CSS生成のためにブラウザ上でJavaScriptが実行されるため、ページのパフォーマンスへ影響しうる懸念があります。EmotionはCSSの記述内容の解析、古いブラウザ向けの記述の追加、CSSの合成、そしてスタイルのDOMへの挿入をブラウザ上で実行します。Emotionのcss関数を使えば使うほどCSSに関する処理の実行時間が増えていきます。また、これらの処理をブラウザ上で実行するためのJSのコードが必要となるため、バンドルサイズが増加してしまいます。新システムがホストしているページはスマートフォン向けのページであり、パフォーマンスやバンドルサイズは特に注視しています。

そこで、Emotionから別の CSS 環境への移行を検討しました。

技術選定

上記の課題を踏まえ、以下の要件で新しいCSS 環境を検討しました。

  • Emotionに近い開発体験:Emotionと同様に、CSS クラス名を自分でつける必要がないこと
  • CDNの活用:ビルド時に CSS ファイルが生成されて CDN から静的に配信できること
  • 低いオーバーヘッド:ゼロランタイムであること(ビルド時にCSSを生成し、ブラウザに送られるJavaScriptにはCSSを生成するコードを含まないこと)
  • 将来性:Server Components導入を見据えて、Server Componentsに対応していると嬉しい

検討した結果、これらの要件を満たすライブラリとしてvanilla-extractが挙がりました。 vanilla-extractではCSSをJavaScriptのオブジェクトとして .css.[jt]s という拡張子のファイルに記述します。これをvanilla-extractの各種バンドラに対応したプラグインがCSSに変換し、CSSファイルを生成します。また、それぞれのスタイルは一意なクラス名をセレクタとしており、Emotionと同じように意図しないスタイル適用を防ぐことができます。

Emotionとvanilla-extractの比較を表にすると以下のようになり、技術選定で重視した項目を満たしています。

比較項目 Emotion vanilla-extract
クラス名の自動付与
CDNから配布可能 (HTMLに埋め込まれる)
ゼロランタイム (ブラウザ)
Server Components対応 (CSRのみ)
コンポーネントと同じファイルに書ける (.css.[jt]sに書く必要がある)
CSSの書き方 String Styles、Object Styles Object Stylesのみ
Stylelint (未対応)
スナップショットテスト (なし)
ベンダープレフィクスの自動付与 (なし)

(比較当時、@emotion/reactはv11.10.0、@vanilla-extract/cssはv1.9.1でした)

筆者がvanilla-extractを提案した際は、記述方法の違いやビルド成果物の差がわかるように、実際のページを書き換えたプルリクエストを例示しました。CSSのコード量が小さいOGP画像生成用のページを対象にしました。

vanilla-extractのデメリット

一方で、vanilla-extractにはデメリットも存在します。

CSSの書き方

まず、これまで通りString Stylesで記述することができなくなりました。この点について懸念点がないかデザイナーの方に伺ったところ、「CSSを書ければ問題ない」とのことでした。vanilla-extractは .css.[jt]s に記述する必要がありますが、この点についても、チームメンバーから合意をもらいました。

Stylelint

加えて記法が変わったことによりStylelintで検査できなくなりました。しかしながら、CSSのプロパティや値のタイポ・プロパティの重複はTypeScriptで見つけられますし、我々のアプリケーションでは詳細度に関連して困るような書き方をしていないので、Stylelintを廃止するデメリットは小さいと判断しました。

スナップショットテスト

Emotionでは@emotion/jestがスナップショットテストにCSSの記述を表示する仕組みを提供していました。しかしvanilla-extractでは提供されておらず、スナップショットテストでCSSの記述を確認することもできなくなりました。スナップショットテストについては、運良くバグを検知できるほどのメリットしかないと判断し、使えなくなるデメリットは小さいと判断しました。

ベンダープレフィクスの自動付与

Emotionはベンダープレフィクスを自動で付与してくれるのですが、Emotionではライブラリ利用者がブラウザのバージョンを指定できないため、クックパッドの推奨環境より古いブラウザを対象としたプロパティも追加されていました。クックパッドの推奨環境も鑑み、自動付与がなくなるデメリットは小さいと判断しました。

vanilla-extractへの移行

CSSの記述をvanilla-extractへ移行することを決定した後、移行作業にとりかかりました。

最初はすべて手作業で書き換えていたのですが、途中から正規表現を使った簡素な変換ツールを導入して移行作業がスピードアップしました。 Emotionとvanilla-extractは共存できたため、手が空いているときに手分けをして少しずつ移行していきました。また、Next.jsアプリケーション本体だけではなく、共通コンポーネントパッケージ、そして社内のデザインシステムのReactライブラリもvanilla-extractに移行しました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = css`
  flex: 1;
  box-sizing: border-box;
  background-color: white;
`

const linkDisableStyle = css`
  ${linkStyle}
  background-color: gray;
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle}>
        Link
      </a>
      <a href="#" css={linkDisableStyle}>
        Disabled Link
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

export const linkStyle = style({
  flex: 1,
  boxSizing: 'border-box',
  backgroundColor: 'white',
})

export const linkDisabledStyle = style([
  linkStyle,
  {
    backgroundColor: 'gray',
  },
])

// MyComponent.js

import { linkDisabledStyle, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a href="#" className={linkStyle}>
        Link
      </a>
      <a href="#" className={linkDisabledStyle}>
        Disabled Link
      </a>
    </section>
  )
}

動的にスタイルを生成していた箇所については、 @vanilla-extract/dynamic や @vanilla-extract/recipesを利用して問題なく置き換えられました。

// Emotion

import { css } from '@emotion/react'

const linkStyle = (size) => css`
  width: ${size};
  height: ${size};
`

export const MyComponent = () => {
  return (
    <section>
      <a href="#" css={linkStyle('100px')}>
        Link 1
      </a>
      <a href="#" css={linkStyle('200px')}>
        Link 2
      </a>
    </section>
  )
}
// vanilla-extract
// MyComponent.css.js

import { createVar, style } from '@vanilla-extract/css'

export const sizeVar = createVar()

export const linkStyle = style({
  width: sizeVar,
  height: sizeVar,
})

// MyComponent.js

import { assignInlineVars } from '@vanilla-extract/dynamic'
import { sizeVar, linkStyle } from './MyComponent.css.js'

export const MyComponent = () => {
  return (
    <section>
      <a 
        href="#"
        className={linkStyle} 
        style={assignInlineVars({ [sizeVar]: '100px' })}
      >
        Link 1
      </a>
      <a
        href="#"
        className={linkStyle}
        style={assignInlineVars({ [sizeVar]: '200px' })}
      >
        Link 2
      </a>
    </section>
  )
}

移行した結果、Emotionで課題や懸念に感じていたことを解消できました。

  1. ページサイズ:CSSファイルにページ全体のCSSが含まれるようになり、CDNから配布できるようになりました。background-imageとしてbase64の画像ファイルを埋め込んだ場合でもロードバランサーを通るHTMLのサイズが大きくなることはありません。
  2. 動的生成による肥大化:メモリ使用量が増加してNext.jsプロセスが落ちることはなくなりました。
  3. クライアント側のオーバーヘッド:CSSの生成はビルド時にのみおこなわれるようになり、生成のためのJavaScriptは@vanilla-extract/dynamicや@vanilla-extract/recipesだけになりました。また、Emotionのランタイム削除によりバンドルサイズはgzipで10kB弱減少しました。

エンジニアやデザイナーからも、特にネガティブな意見は出ていません。初めてvanilla-extractを触るメンバーも、問題なくCSSを変更できています。

さいごに

今回はレシピサービスの新システムにおける ゼロランタイムCSS in JS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。

*1:このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです