有名テック企業の技術ブログを、ひとつのフィードで。
フィード
30件
こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。 iOS 26で導入されたLiquid Glassは、iOS 27で強制的に有効化される予定です。対応を先送りにすると、その間に進む機能開発やデザイン調整がすべてLiquid Glassへの考慮がされないまま積み上がり、後から手戻りが増えていきます。早期に有効化しておくことで、新しい画面や機能を追加する段階でLiquid Glassを前提としたデザイン議論やベストプラクティスの調査ができるようになります。 こうした背景からクックパッドiOSアプリでは早めの対応を進めてきました。この記事では、UINavigationBarとUITabBar周りで遭遇した破壊的変化と、Liquid Glassの新しい仕組みを活用した事例を共有します。 UINavigationBar カスタムtitleViewのUITextFieldがRTLでクラッシュする まずはUINavigationBarに関わる問題です。 クックパッドアプリではいくつかの画面でnavigationItem.titleViewにカスタムの検索バーを設定していました。Liquid Glassを有効化してデバッグしている中で、モーダル遷移かつRTL表示の組み合わせでクラッシュすることに気づきました。ナビゲーションバーの内部レイアウトが刷新された影響でUISearchBarTextFieldのレイアウト計算が破綻し、CALayerのpositionにNaNが入ることが原因でした。 このクラッシュはiOS 26.2では再現されますがiOS 26.4では修正された模様ですが、クックパッドアプリはiOS17以降をサポートするためそのまま利用することはできません。 titleViewベースの検索バーをやめて、UISearchControllerベースに移行することで解決しました。 navigationItem.searchControllerとnavigationItem.preferredSearchBarPlacement = .stackedを用いたレイアウトであればiOS 26.2でも問題なく動作します。 // Before navigationItem.titleView = customSearchBar // After let searchController = UISearchController(searchResultsController: nil) searchController.searchBar.delegate = self navigationItem.searchController = searchController navigationItem.preferredSearchBarPlacement = .stacked UISearchControllerへ移行することでLiquid Glassのガラス質感や検索バーのアニメーションも自然に適用されます。 ナビゲーションバーのアイテムが省略されてしまう Liquid Glassではナビゲーションバーのバーボタンアイテムの扱いが大きく変わりました。従来はleftBarButtonItemsとrightBarButtonItemsの描画サイズが自動調整され、コンテンツの要求するサイズが長い場合でも他要素の表示領域を確保した上でいっぱいに広げることができました。クックパッドのレシピ詳細画面ではこの仕様を利用してレシピタイトルを左寄せにしていましたが、Liquid Glass有効下では各アイテムに最低44ptのタップ領域と8pt程度のスペースが確保されるようになり、収まらないアイテムは標準のoverflowボタンにまとめられるようになりました。 これにより表示可能領域よりも長いタイトルで全ての要素が標準のOverflowボタンに省略されてしまいました。 短いレシピタイトル 長いレシピタイトル タイトルは、そもそもBarButtonItemsに入れること自体が少々特殊な対応だったためレイアウトの調整を頑張るのではなく、中央寄せになる挙動を許容しnavigationItem.titleViewへ移行することにしました。 また、これまでrightBarButtonItemsに自前でUIMenuを表示するOverflowボタンを表示していましたが、UIKitが表示する標準のoverflowボタンの中に意図せず省略されてしまうといった問題が起きやすくなるため、標準の仕組みへ乗ることにしました。具体的にはnavigationItem.additionalOverflowItemsにメニュー項目を登録して置くことで、自動的に標準のOverflowボタンを利用することができます。 navigationItem.additionalOverflowItems = UIDeferredMenuElement.uncached { completion in completion([ UIAction(title: "Delete", attributes: .destructive) { _ in /* ... */ } ]) } 一点注意が必要なのが、UIBarButtonItem(customView:)で初期化したアイテムの扱いです。titleやimageで初期化した場合はUIKitが自動的にmenuRepresentationを設定してくれますが、customViewで初期化した場合はmenuRepresentationがnilになります。menuRepresentationがnilの場合、表示領域が足りなくなるとOverflowメニューにもナビゲーションバーにも表示されなくなります。 let item2 = UIButton() item2.setTitle("Item2", for: .normal) navigationItem.rightBarButtonItems = [ UIBarButtonItem(title: "Item1", style: .plain, target: nil, action: nil), // Item2だけcustomView UIBarButtonItem(customView: item2), UIBarButtonItem(title: "Item3", style: .plain, target: nil, action: nil), UIBarButtonItem(title: "Item4", style: .plain, target: nil, action: nil), UIBarButtonItem(title: "Item5", style: .plain, target: nil, action: nil), UIBarButtonItem(title: "Item6", style: .plain, target: nil, action: nil) ] Item1, Item3の間にItem2が入ってほしいが表示されない customViewベースのアイテムを使う場合はmenuRepresentationを明示的に設定する必要があります。 let barButtonItem = UIBarButtonItem(customView: button) barButtonItem.menuRepresentation = UIAction(title: "Item2") { _ in /* ... */ } Item1, Item3の間にItem2が表示される UITabBar FABと「今日作る」ボタンをUITabBarに統合する 従来のクックパッドアプリでは、レシピ作成用のFAB(Floating Action Button)と今日の献立を開く「今日作る」ボタンをUITabBarの上にオーバーレイとして配置していました。タブバーだけLiquid Glassのデザインに切り替わった状態で、その上に従来のオーバーレイが乗っているとデザインの統一性がなく違和感があったため、Liquid Glassで追加された新しい仕組みを使ってタブバーに統合することにしました。 FABはUITabBarItem(tabBarSystemItem: .search)を使ってタブバー右端にピン留めし、「今日作る」ボタンはUITabAccessoryとしてタブバー下部に配置することで、オーバーレイを廃止してコンテンツの表示領域を広げました。 BeforeAfter Liquid GlassのUITabBarでは、tabBarSystemItem: .searchで作成したアイテムが他のタブとは独立してタブバーの右端に固定配置されます。本来は検索タブ用途のシステムアイテムですが、この固定配置の仕様を利用して、FABのようなアクションボタンをタブバーに統合しています。 献立アクセサリはUITabAccessoryで実現しています。Apple Musicのミニプレーヤーが代表例ですが、クックパッドでは今日の献立に登録されたレシピのサムネイルとカレンダーボタンを常時表示し、どの画面からでも献立にアクセスできるようにしています。 tabBarController.tabBarMinimizeBehavior <span class="synId
こんにちは。レシピ事業部の石川です。 来週 4 月 22 日から 3 日間、RubyKaigi 2026 が開催されます。クックパッドは今年も Platinum スポンサーとして RubyKaigi に協賛いたします。また、スポンサーブースをご用意いたします。 今年のスポンサーブースでは、ここ 1 年のクックパッドでの開発の様子をあっちからこっちまでご紹介いたします。社内で Claude Code の話題が出たのは去年の 3 月でした。皆さまご存知のとおりそこからの 1 年でがらりと変わった開発環境の話は積もるものがございます。またこの 1 年の間もいくつかの新機能がリリースされました。その裏で動いている技術の話も、対面なら細かいニュアンスまでお伝えできます。その他、Rails アプリで pull request を出してからデプロイされるまでの時間を短くするための取り組みや多言語での検索システムの話など、技術の話のタネをいろいろとご用意いたします。 cookpad.com を提供している Rails アプリは現在 Ruby 4.0 & Bundler 4.0 で動いています。次の更新に向けてどんなことができそうか、トークを聞いて回りながら考えようと個人的に思っています。皆さまの考えもぜひお聞かせください。 それでは、現地で雑談できるのを楽しみにしております。また来週!
こんにちは、レシピ事業部バックエンド基盤部の石川です。これは RuboCop のバージョンを上げましょうという記事です。 なんだか最近 RuboCop を使っていたら .rubocop-29343e612b03ba2227a3c3390a755e8d.yml のような名前のファイルが .gitignore を貫通してきませんか? これは他リポジトリなどのリモートにある設定ファイルを inherit_from で参照したときに作られるキャッシュファイルです。ちょっと前までは .rubocop-https---raw-githubusercontent-com-cookpad-styleguide-master--rubocop-yml のような名前だったのが、2025 年末あたりに .rubocop-remote-23b9c67aff31e0f9d6c4a89d5eb660cb.yml のような名前になり *1、そのあと現在の名前になりました *2。現在の名前は $original_name-$hash.yml という規則になっています。 そのような感じでキャッシュファイルの名前が変わり、そしてよくある .gitignore では .rubocop-https?--* のみが ignore されていることが多く、新しいファイル名がこのルールに引っかからなくなったため git diff に現れるようになったという次第です。 さて、実は先日リリースされた RuboCop v1.84.2 からは、デフォルトでこれらのキャッシュファイルがプロジェクトの一番上のディレクトリではなく ~/.cache/rubocop_cache/ のような共通のディレクトリへ保存されるようになりました *3。 したがって、お使いの RuboCop のバージョンを上げていただければこれらのキャッシュファイルは git diff を邪魔しなくなります。プロジェクトのディレクトリの外に保存されるためです。お試しください。 Include/Exclude に関する注意点 ところで、この変更に伴い、inherit_from で参照される設定ファイルの側で Include や Exclude を設定している方には注意点があります。Include や Exclude では相対パスを使って RuboCop の対象となるファイルを制御できますが、実はこの相対パスの起点が設定ファイルの名前によって変わります。 https://docs.rubocop.org/rubocop/configuration.html#path-relativity In .rubocop.yml and any other configuration file beginning with .rubocop, files, and directories are specified relative to the directory where the configuration file is. In configuration files that don’t begin with .rubocop, e.g. our_company_defaults.yml, paths are relative to the directory where rubocop is run. 上記のドキュメントに書かれているように、名前が .rubocop から始まる設定ファイルではその設定ファイルの場所からの相対パスになり、それ以外の場合は rubocop コマンドが実行された場所からの相対パスになります。 今のところ、これは inherit_from で参照されている先の設定ファイルの名前についても同様に判断される挙動になっています。したがって RuboCop のバージョンを上げると意図しない挙動になる可能性があります。 RuboCop v1.84 では、元の設定ファイル名の先頭がキャッシュファイル名の先頭に残る上、キャッシュファイルの保存先がプロジェクトの一番上ではなくなります。このためリモートにある .rubocop.yml を inherit_from で参照している場合、Include や Exclude の相対パスの起点がプロジェクトの一番上でなくなってしまいます。 実際にファイル名によって挙動が変わる様子を以下に示します。ふたつ実行していて、最初はピリオドありの .rubocop.yml、次はピリオドなしの rubocop.yml を使っており、両方とも中身は Exclude です。両者について RuboCop v1.84.2 を動かすと、前者は spec/app_spec.rb が除外されず検査対象に残ってしまっています。なお RuboCop v1.84.1 のデフォルト挙動では前者でも後者と同じく spec/app_spec.rb が対象になっていませんでした。 % rubocop -v 1.84.2 % tree -a . . ├── .rubocop.yml ├── app.rb └── spec └── app_spec.rb 2 directories, 3 files % cat .rubocop.yml inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml # inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml AllCops: DisabledByDefault: true % curl -fsSL https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml AllCops: Exclude: - 'spec/**/*' % rubocop --list-target-files app.rb spec/app_spec.rb % nano .rubocop.yml % cat .rubocop.yml # inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/.rubocop.yml inherit_from: https://gist.githubusercontent.com/nekketsuuu/a0081c4738b52e7fb25e28201a0e6947/raw/977ff9c55aa0b801a023600241a2d7b18ac003ca/rubocop.yml AllCops: DisabledByDefault: true % rubocop --list-target-files app.rb これを避けるため、inherit_from している先のファイル名が適切かチェックしておくのが良いでしょう。弊社では mv .rubocop.yml rubocop.yml としたあと、互換性のために .rubocop.yml では inherit_from: rubocop.yml で参照しておいて徐々に移行していくという手順を試しています。 以上、最近の RuboCop についての情報共有でした。まずはぜひバージョンを上げてみてください。 cookpad.careers *1:https://github.com/rubocop/rubocop/pull/14625 *2:https://github.com/rubocop/rubocop/pull/14761 *3:https://github.com/rubocop/rubocop/pull/14870
はじめに こんにちは。レシピ事業部でアルバイト中の松本 (@matsumo0922) です。クックパッドでは、作るレシピを日付ごとに管理できる、プラン機能をつい先日リリースしました。この機能は Full-Compose で作成されており、日付間のレシピの移動/並び替えに Drag and Drop を採用しています。Drag and Drop は本来リストの並び替えに用いるものでは無いですが、今だに Compose ではリストの並び替え API が充実していないのに加え、視覚効果が貧弱なものが多いのが現状です。そこで Drag and Drop を応用的に並び替え UI に用いることで、直感的で視覚的にもわかりやすい UI/UX を実現することができました。今回は Compose で Drag and Drop を用いて、リストの並び替えを実装する方法と知見をご紹介します。 プラン機能での Drag and Drop を用いた並び替え Drag and Drop の基本 Drag and Drop(以下 DnD)は、ユーザーが要素をドラッグし、別の位置にドロップすることで、並び替えや移動などの操作を直感的に行える UI パターンです。広義では View 間や画面間、アプリ間でのデータのやり取りが可能な機能のことを指します。そのため一般的なリストパターンである「選択と移動」とは異なる機能であることに注意が必要です。プラン機能では、ある日付に登録されたレシピ(アイテム)を別の日付(セクション)へ視覚的に移動させる必要があったため、通常の並び替えではなく DnD を採用することにしました。 Compose での DnD は二つの修飾子で実装することができます。 Modifier.dragAndDropSource Modifier.dragAndDropTarget それぞれ Drag の起点となる Composable と Drop 先の Composable を指します。今回は簡単のために二つの Composable 間でテキストデータをやり取りする実装を考えてみます。 なお、本記事の最終目的は DnD を用いてリストの並び替え UI を実装することなので、基本的な DnD の仕組みや実装を理解されている方は「並び替えへの応用」まで読み飛ばしていただいて構いません。 dragAndDropSource データの送信元となる Composable につける修飾子です。送信したいデータはテキストや画像、バイナリなど複数ニーズがあると思いますが、すべて ClipData クラスでラップして送信します。送信元がわかるように事前に示し合わせた label を付けてインスタンスを生成し、DragAndDropTransferData を返してあげることで DnD がスタートします。label はユーザーへの Description としても用いられることに注意してください。今回は “Hello!” というテキストデータを送信してみます。 private const val LABEL = "DnD sample data for Cookpad." Box( modifier = Modifier .size(128.dp) .background(Color.Red) .dragAndDropSource { _ -> DragAndDropTransferData(ClipData.newPlainText(LABEL,"Hello!")) } ) 上記のコードでドラッグの検知もすべて行ってくれます。ドラッグのタイミングを自分でコントロールしたい場合は、detectDrag... などの Modifier で自分で Drag を検知し、startTransfer を呼び出してあげることで DnD を開始することができます。以下の例は、長押し後のドラッグのみを検知する例です。この Composable 自体が Clickable である場合などに活躍します。 Box( modifier = Modifier .size(128.dp) .background(Color.Red) .dragAndDropSource( block = { // clickable と両立させるために、長押し後のドラッグのみ検知する detectDragGesturesAfterLongPress( onDrag = { _, _ -> /* no-op */ }, onDragStart = { _ -> val clipData = ClipData.newPlainText(MEAL_PLAN_DAD_ITEM_LABEL, id) val data = DragAndDropTransferData(clipData) startTransfer(data) }, ) }, ) ) DnD を開始すると、デフォルトでは当該の Composable を半透明にしたものが視覚効果として提供されます。これを変更したい場合は、drawDragDecoration パラメータのラムダ内で DrawScope が提供されているので、これを用いて任意の視覚効果に変更することができます。 dragAndDropTarget データを受信する Composable につける修飾子です。受け取り状態を Boolean で返す shouldStartDragAndDrop と、DragAndDropTarget という DnD の状態を受け取るコールバックをパラメータに指定します。今回は受け取ったデータをそのまま表示するので、onDrop() 内で先ほど示し合わせた Label かどうかを確認した上で、receiveItem にセットしています。返り値はデータを消費した場合は true、消費しなかった場合は false を返します。 var receiveItem by remember { mutableStateOf("") } Box( modifier = Modifier .size(128.dp) .background(Color.LightGray) .dragAndDropTarget( shouldStartDragAndDrop = { true }, target = object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { val clip = event.toAndroidDragEvent().clipData val item = clip.getItemAt(0).text.toString() if (clip.description.label != LABEL) return false receiveItem = item return true } } ), contentAlignment = Alignment.Center ) { Text(receiveItem) } DragAndDropTarget では DnD の開始や終了、Drag が受け取り可能範囲に入ったか出ていったかなどの情報も取得することができます。詳しくはドキュメントをご覧ください。 onStarted: ドラッグが開始された時に呼ばれる。このターゲットがデータを受け入れ可能かを返す。 onEntered: ドラッグ領域に入った時。 onMoved: 領域内で移動中。 onExited: 領域から出た時。 onDrop: ドロップされた時。ここでデータを取得する。 onEnded: ドラッグ操作が終了した時。 https://developer.android.com/reference/kotlin/androidx/compose/ui/Modifier#(androidx.compose.ui.Modifier).dragAndDropTarget(kotlin.Function1,androidx.compose.ui.draganddrop.DragAndDropTarget 基本コード全体 前述のコードをまとめて動かしてみます。赤い Box が source、グレーの Box が target です。 var sendItem by remember { mutableStateOf("Hello!") } var receiveItem by remember { mutableStateOf("") } Column( modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally, verticalArrangement = Arrangement.spacedBy( space = 128.dp, alignment = Alignment.CenterVertically, ) ) { // 送信側 Box( modifier = Modifier .size(128.dp) .background(Color.Red) .dragAndDropSource { _ -> DragAndDropTransferData(ClipData.newPlainText(LABEL, sendItem)) }, contentAlignment = Alignment.Center, ) { Text(sendItem) } // 受信側 Box( modifier = Modifier .size(128.dp) .background(Color.LightGray) .dragAndDropTarget( shouldStartDragAndDrop = { true }, target = object : DragAndDropTarget { override fun onDrop(event: DragAndDropEvent): Boolean { val clip = event.toAndroidDragEvent().clipData val item = clip.getItemAt(0).text.toString() receiveItem = item return true } } ), contentAlignment = Alignment.Center, ) { Text(receiveItem) } } 赤色の Box から灰色 Box への DnD リッチな視覚効果 上記のコードを改良することで視覚的にセクション間の移動を実現することができます。しかし、視覚効果は最低限でユーザーにとって分かりやすい UI になっているとは言い切れません。もう少しリッチな視覚効果が欲しいところです。プラン機能では日付毎にセクションが独立しているので、ドロップされる日付を拡大 & ハイライトすることで、より分かりやすい UI を実現できました。今回は DropTarget に拡大して枠線をつけてみます。 前述の通り、DragAndDropTarget では DnD の開始や終了、Drag が受け取り可能範囲に入ったか出ていったかを取得することができるので、これを利用します。isFocused という変数で Drop 可能時に受け取り側の Composable を大きく、そして枠線を表示するようにしてみましょう。Modifier の適用順序に注意してください。 // Drop 可能領域に入っているか var isFocused by remember { mutableStateOf(false) } val focusedScale <span class="synStatem
こんにちは、レシピ事業部検索チームの薄羽です。 検索チームでは日々レシピ検索機能の改善を行っています。 最近行った検索結果改善の過程で、2つの検索結果を交互に配置するinterleavingを実装する機会がありました。今回は、この機能の実装の際の課題となる点や、それをどう解決したのかを紹介します。 Interleaving Interleavingは2つのランキングを評価する手法であり、2つのランキングの一番上から文書を交互に取っていくことで、ランキングを生成します。 interleaving機能を実装する際、ページネーションを壊さずにinterleavingを実装するには少し工夫が必要です。2つの検索結果から1つの検索結果を作るため、2つのランキングで重複した文書があると、生成される検索結果には同じ文書が2回表示されることになってしまいます。 どうやるか すでにビズリーチさんがElasticsearchでのinterleavingの取り組みを記事にされています (https://engineering.visional.inc/blog/615/implement-interleaving-for-search-evaluation/)。ビズリーチさんもinterleavingの実装にあたりページネーションの問題にぶつかっていて、その解決のためにinterleavingするページを固定し、すでに表示した文書をキャッシュしておくことで、問題を解決しています。 我々も同様にページネーションの問題にぶつかり、同様にキャッシュを用いましたが、少し別のアプローチを取りました。1ページ目のときだけinterleavingするというアイデアも最初はありましたが、我々のアプリケーションでは検索結果は無限スクロールとなっており、ユーザがページを捲りやすいようになっています。またper_pageも基本20~30と小さいです。2ページ目以降もinterleavingするとなると、2ページ目のinterleavingした結果も保存する必要が出てきてしまいますが、検索結果全てのページをキャッシュするというのはあまり良い方法ではありません。加えて、保守性の観点で1ページ目だけに特別な意味を持たせることをしたくないという理由でページを限定してinterleavingする方法はやめました。 以上を考慮して、我々はページの代わりに「interleavingする片方のランキング」を固定することでページを渡るinterleavingを実装しました。 実装 我々は、片方の検索結果を全て取得してキャッシュに保存するという方法を取りました。 片方の検索結果を全件取得することで、どのページであってももう片方の検索結果から重複する結果を除外した上で検索することが可能になります。 我々のケースでは、interleavingしたい片方のランキングの長さは短く、対象の文書やクエリは限定的で、事前計算されています。また、返却する文書情報も多くないため、検索結果全体をキャッシュしても問題ないと判断しました。 この方法では、最初の検索では2回直列でESへの問い合わせをする必要がありますが、それ以降は通常の検索と並列にキャッシュへのアクセスをすることで、遅延の増加を抑えることができます。 今回、評価のためだけでなくランキングの仕組みの一つとしてinterleavingできるようにしたいため、interleavingを始める位置や長さ・文書の数を柔軟にコントロールできるような機能にしました。そのため、2ページ以降もinterleavingするときの課題として、ページ・per_page、今回はさらにinterleavingを始めるoffsetによって「どっちのランキングの文書から始まるか」が変わり複雑になるということがあります。ここで、単純にページとper_pageのみを考えるのであれば、per_pageとページが共に奇数のときのみ、検索結果の一番上がどっちから始まるかが変わるということがわかります。あとはここにoffsetを考慮して、愚直にどっちから始まるかを計算してあげればよさそうです。 キャッシュを用いた手法では、ページを切り替えるタイミングでキャッシュが切れると検索結果がおかしくなる可能性がありますが、今回のケースではキャッシュのttlを長めにとることができるため、問題にならないと判断しました。 この手法により、ページネーションの整合を保ったままページを渡ったinterleavingを実装することができました。我々のケースではキャッシュの肥大化も問題にならず、特別latencyの増加も認められませんでした。 まとめ Elasticsearchをサーチエンジンとしたinterleavingを実装しました。 interleavingをサーバ側でやるとページネーションが壊れてしまうという問題がありますが、片方のランキングを全てキャッシュしておくことで解決しました。 We are hiring! クックパッドでは現在絶賛採用活動中です。毎日の料理を楽しみにしたい皆様からのご応募を熱烈歓迎しております。まずはいま開いている枠を眺めてみてくださいませ。 https://cookpad.careers/
こんにちは、iOSエンジニアの山田(0x746572616e79)です。 このたび、9月10日から開催される DroidKaigi 2025 と 9月19日から開催される iOSDC Japan 2025 に、それぞれゴールドスポンサーとして協賛いたします。また両イベントともブース出展をいたします。クックパッドではAndroid/iOSエンジニアを積極募集中です。当日は私を含むモバイルエンジニアがブースにおりますので、展示内容はもちろん、普段のプロダクト開発についてや採用に関するお話をカジュアルにできればと思います。 iOSDC Japan 2024のブースの様子です。 今年のスポンサーブースでは、グローバル展開中のクックパッドアプリ開発事例を昨年に引き続きご紹介します。また、最近社内で活発に取り組んでいるAIの利活用事例も併せて展示予定です。特にAI事例は現在試行錯誤の段階ですので、ぜひ皆さまと知見を共有できれば嬉しいです。また、昨年ご好評いただいた「フードクリップ」をノベルティとして配布を予定しています。ぜひお気軽にお立ち寄りください! 皆さまにお会いできるのを楽しみにしています!
こんにちは、レシピ事業部でiOSエンジニアをしている山田(@0x746572616e79)です。つい先日リリースしたプロフィール画面のリニューアルで、SNSアプリでよく見かけるプロフィールコンポーネントとStickyなタブを組み合わせたUIを実装し、いくつか学びがあったので実装方法と合わせてご紹介します。 Stickyヘッダーの設計、実装方針 プロフィール画面は元々、プロフィールコンポーネントの下にレシピ一覧、その下に「つくれぽ」のカルーセルを表示するシンプルな縦スクロール構成でした。 リニューアル後は、プロフィールコンポーネントの下に2つのタブ(レシピ一覧・つくれぽ一覧)を配置し、それぞれが独立したスクロール領域を持つ構成に変更します。各タブの内容は既存の検索結果画面と同じUIコンポーネントを流用できたため、UIPageViewControllerで各一覧画面のViewControllerをホスティング(内包・管理)する設計を採用しました。 class ProfileViewController: UIViewController { private lazy var pageViewController: UIPageViewController = { let viewController = UIPageViewController(transitionStyle: .scroll, navigationOrientation: .horizontal) viewController.dataSource = self viewController.delegate = self return viewController }() private let recipeListViewController: RecipeListViewController // レシピ一覧画面 private let cooksnapListViewController: CooksnapListViewController // つくれぽ一覧画面 // プロフィールヘッダー(ユーザー情報やタブを含む) private let headerProfileView = ProfileView() // ヘッダーの位置を制御する制約 private lazy var headerTopAnchorConstraint = headerProfileView.topAnchor.constraint( equalTo: view.safeAreaLayoutGuide.topAnchor ) override func viewDidLoad() { super.viewDidLoad() setupLayout() pageViewController.setViewControllers([recipeListViewController], direction: .forward, animated: false) } private func setupLayout() { // PageViewControllerを全画面に配置 addChild(pageViewController) view.addSubview(pageViewController.view) pageViewController.view.translatesAutoresizingMaskIntoConstraints = false NSLayoutConstraint.activate([ pageViewController.view.topAnchor.constraint(equalTo: view.topAnchor), pageViewController.view.leadingAnchor.constraint(equalTo: view.leadingAnchor), pageViewController.view.trailingAnchor.constraint(equalTo: view.trailingAnchor), pageViewController.view.bottomAnchor.constraint(equalTo: view.bottomAnchor) ]) pageViewController.didMove(toParent: self) // ヘッダーをPageViewControllerの上に重ねて配置 view.addSubview(headerProfileView) NSLayoutConstraint.activate([ headerTopAnchorConstraint, // この制約のconstantを変更してヘッダーを上下に移動 headerProfileView.leadingAnchor.constraint(equalTo: view.leadingAnchor), headerProfileView.trailingAnchor.constraint(equalTo: view.trailingAnchor), headerProfileView.heightAnchor.constraint(equalToConstant: 200) ]) } } この構成に至るまでに、以下の技術的、体験的な課題がありました。 複数のスクロールビューとヘッダーの連動 ヘッダーの位置に応じた各タブのオフセット調整 ヘッダー領域でスクロールジェスチャーが効かない 順番に見ていきましょう 課題1. 複数のスクロールビューとヘッダーの連動 固定ヘッダーをタブのスクロールに合わせて追従させるだけなら、実装はそれほど複雑ではありません。UIPageViewControllerの上にヘッダーコンポーネントを配置し、各タブのViewControllerにヘッダーと同じ高さのadditionalSafeAreaInsets.topを設定するだけで基本的な動作は実現できます。 override func viewDidLayoutSubviews() { super.viewDidLayoutSubviews() let headerHeight = headerProfileView.frame.height if recipeListViewController.additionalSafeAreaInsets.top != headerHeight || cooksnapListViewController.additionalSafeAreaInsets.top != headerHeight { recipeListViewController.additionalSafeAreaInsets.top = headerHeight cooksnapListViewController.additionalSafeAreaInsets.top = headerHeight } } あとはヘッダーをスクロール量に合わせて上下に移動させることで、スクロール時にヘッダーが自然に隠れるような動作が実現できます。 しかし今回の実装では、各タブ内にも上部に固定表示したいコンポーネント(検索バー)が存在していました。これが実装を複雑にする要因となりました。 スクロールしても検索バーがその場に止まってしまう 単純にadditionalSafeAreaInsetsを設定するだけでは、スクロールに合わせてタブ内の検索バーの位置を動的に調整することができません。プロフィールヘッダーが隠れた分だけ検索バーを上に移動させる必要があり、これには各タブのViewControllerと親のProfileViewController間でのスクロール状態の連携が不可欠でした。 そこで、additionalSafeAreaInsets.topは基本的なレイアウト調整として設定しつつ、スクロール位置を同期するための専用の仕組みを用意しました。 まず、各タブのViewControllerが共通のインターフェースでスクロール情報を提供できるよう、以下のプロトコルを定義します。 protocol ScrollMovementDelegate: AnyObject { func scrollMovementDidScroll(_ scrollView: UIScrollView, viewController: UIViewController) } protocol ScrollMovementProvidingViewController: UIViewController { var scrollMovementDelegate: ScrollMovementDelegate? { get set } func adjustContentTopInset(_ topInset: CGFloat) } 各タブのViewControllerはこのプロトコルに準拠し、スクロール時に親のProfileViewControllerに変更を通知します。 // レシピ一覧のViewControllerで実装 extension RecipeListViewController: UITableViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { scrollMovementDelegate?.scrollMovementDidScroll(scrollView, viewController: self) } } 親ViewControllerでは、受け取ったスクロール情報を元にヘッダーのトップアンカー制約のconstantを調整してヘッダーを上下に移動させつつ、各タブのadjustContentTopInsetメソッドを呼び出してタブ内の固定コンポーネント(検索バー)の位置を連動させます。これにより、プロフィールヘッダーの移動量に合わせて、各タブ内の検索バーも同じ距離だけ上に移動し、一体感のあるスクロール体験を実現できました。 <span class
こんにちは。クックパッドでエンジニアをしている @morishin です。4年前に Bdash Server というデータ分析 SQL を共有・再利用するアプリケーションを作って紹介しましたが、今回はそれをさらに発展させて AI にデータ分析 SQL を書かせる仕組みを作ったのでその紹介をします。 背景 データ分析において SQL を書くのは避けて通れない作業ですが、毎回ゼロから書くのは効率的ではありません。特に複雑な分析クエリや過去に似たような分析を行ったことがある場合、既存のクエリを参考にしたり再利用したりできれば大幅な時間短縮になります。 そこで4年前に分析 SQL の共有・検索ができる Bdash Server というアプリケーションを開発しました。これにより過去に書かれた分析クエリを蓄積し、キーワード検索で参考になるクエリを見つけることができるようになりました。今でも20件/日ほどのデータ分析結果がアップロードされシェアされています。 techlife.cookpad.com github.com しかし、参考になるクエリを見つけても多くの場合は結局は手作業で SQL を書く必要があり、まだまだ効率化の余地がありました。そこで今回、AI に過去のクエリを参照させながら新しい分析 SQL を書いてもらう仕組みを作ってみました。 AI に「新しく提供した○○という機能はどれぐらい使われてますか?」って聞いて SQL が出てきたらうれしくないですか? 作ったもの 今回作ったのは主に2つです。 1. Bdash Server MCP サーバー MCP (Model Context Protocol) は AI エージェントが外部のデータソースにアクセスするための標準プロトコルです。Bdash Server に MCP サーバーを実装することで、AI が過去にアップロードされたクエリを検索・参照できるようになりました。重要な点として、MCP サーバーは SQL 文とその説明のみを取得するため、AI が実際の顧客データにアクセスすることはありません。あくまでクエリの構造やロジックを参考にするだけです。 実装は https://github.com/bdash-app/bdash-server/pull/42 のようになっており、search_bdash_queries というツールを提供します。 Bdash Server の /settings にページの Add to Cursor ボタンからワンクリックでインストールできます。 2. データ分析用 Cursor ワークスペース Cursor は AI エージェントが組み込まれたコードエディタです。データ分析のためのワークスペースの Git リポジトリを作成し、AI が分析 SQL を書くのに参考にすべき情報を思いつく限りリポジトリに詰め込みました。ディレクトリ構造はこんな感じです。 . ├── .cursor │ └── rules │ └── knowledge.mdc # Cursor ルール (後述) ├── explain-dwh-query # クエリの実行プランを確認するスクリプト (後述) └── references ├── # ユーザーの行動ログの定義ファイルのリポジトリ ├── # アプリケーションのデータベーススキーマファイル ├── # 過去の分析クエリが詰まってるリポジトリ ├── # 社内ダッシュボード (Tableau Server) にアップロードされてる SQL クエリが詰まってるフォルダ └── # 社内ダッシュボード用の集計テーブルを作るジョブが詰まってるリポジトリ その上で Cursor の Project Rules である knowledge.mdc にその知識やツールの使い方を記述しています。例えば references フォルダの中のどこに何があるかという情報、分析 SQL のスタイルガイドとベストプラクティス、Bdash Server MCP の使い方、クエリの実行プランの確認方法と最適化の指示などです。Project Rules も多くは自動生成であり、上記の情報をリポジトリに詰め込んでからルールの生成を Cursor 自身に指示することで大枠を作りました。 クエリの実行プランの確認のために、explain-dwh-query という、標準入力にクエリを受け取って EXPLAIN 結果を返すスクリプトを作成して配置しています。これがあると AI がクエリコストの最適化をしてくれるだけでなく、カラム名が間違っていたりして実行できないクエリを事前に修正してくれたりします。専用のスクリプトを用意しているのは AI に自由にクエリを実行する権限を与えたくないからで、EXPLAIN しかできないツールを与えたかったためです。 実際の動作例 実際に使ってみると、次のような流れで分析 SQL が生成されます。 自然言語で分析したい内容を指示 AI がリポジトリ内を grep したり Bdash Server から関連するクエリを検索 過去のクエリを参考にしながら新しい SQL を生成 EXPLAIN で実行プランを確認しながらコストを最適化 例えばある機能をよく使っているユーザーを抽出したいと指示すると、下のスクリーンショットのように AI は過去の類似クエリを検索して SQL を生成してくれました。 また、クエリを書かせるだけでなくログに関する質問にも情報を漁りながら答えてくれます。 コスト最適化はこのように動作しています。 SQL を生成してもらうだけでなく、あの機能のログのイベント名なんだっけ?のようなライトな質問にも答えてくれて便利です。 今後の展望 AI の進歩により、これまで人間が手作業で行っていた多くの作業が自動化されつつあります。やっと分析 SQL を書く作業からも解放されそうです。この記事も一旦 AI に書かせてから加筆・修正しました。 Cursor のワークスペースという形で作りましたが、GitHub Copilot Agent や Cline や Claude Code でもルールと MCP サーバー設定を参照させれば同じように動くと思います。また Devin にも同じリポジトリを参照させれば Cursor を使うまでもなく Slack で指示をして SQL を納品してもらうこともできるのではないかなと思っています。(Bdash Server は社内ネットワークからしか接続できない場所にあるのでそれを使わせるのだけ難しいのですが...) クックパッドのデータウェアハウスには dmemo というデータベースドキュメントがあり、最新のスキーマ情報と人間が書き込んだメモが閲覧できます。先日社内の別のメンバーが dmemo の MCP サーバーを作っていたことを知ったので、それも接続することで正確なスキーマ情報や人間が書いたカラムの説明文を取得することができるはずで、クエリ生成の質が上がりそうです。 さいごに 今回の取り組みは、過去に蓄積された知識を AI に活用させることで、より効率的な分析を実現する一例です。データ分析に携わる方々の参考になれば幸いです。 クックパッドでは、データを活用してサービスを改善していけるエンジニアの仲間を募集しています。興味のある方はお気軽にお声がけください! cookpad.careers
レシピ事業部バックエンド基盤グループの石川です。2025 年 6 月の頭にやった仕事について走り書きのようなメモを残しておきたくなったので、この記事を書いています。 Anthropic 社が開発している Claude Code は、ターミナルの上で動作する LLM agent です: https://docs.anthropic.com/en/docs/claude-code/overview。 Claude Code は Anthropic の API 経由で使う方法の他に、AWS や Google Cloud を経由して Claude のモデルを利用する道を用意してくれています: https://docs.anthropic.com/en/docs/claude-code/third-party-integrations。 AWS を多用しているクックパッドにおいては、Amazon Bedrock の Claude モデル経由で Claude Code を利用できると管理上いくつかの点で利便性があると考えました。そこでこの記事では、利用に至るまでのセットアップやコスト分析の方法、そして実際に利用してみての制限について、簡単にまとめます。 前提 Claude Code のドキュメントを読むと、Bedrock 越しに利用する際は Claude Code 専用の AWS アカウントを新設して使うようにお勧めされています。コスト管理や権限管理を簡単にするためです。 We recommend creating a dedicated AWS account for Claude Code to simplify cost tracking and access control. https://docs.anthropic.com/en/docs/claude-code/amazon-bedrock (2025-06-16 閲覧) 一方でクックパッド社内では歴史的経緯により、それなりに多くのサービスがとあるひとつの AWS アカウントに集まる形で AWS を利用しています。このためこの記事では AWS アカウントを分けることなくひとつのアカウントのまま使う前提で話を進めます。 他のアカウントと分けずに Bedrock を利用する場合、コスト分析の観点では Claude Code のために利用した Bedrock の料金とそれ以外の料金とを分けて集計できるようにしておきたいです。このために使えるものとして、Bedrock の application inference profiles というものがあります: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-use.html。これを使うと Bedrock の InvokeModel API などを呼び出した場合にかかった料金について、cost allocation tags を使った分類ができるようになります。より詳しくは、何らかの基盤モデル (foundation model) を直接呼び出すのではなく、基盤モデルを束ねる概念である inference profile を呼び出すことにし、この inference profile にタグ付けしておくことによって、Bedrock の利用料金をタグごとに分類できるようにするという訳です。このような取り組みは、インフラコストの妥当性を説明する手助けになります (https://techlife.cookpad.com/entry/how-to-describe-infra-cost)。 Application Inference Profiles の作成 Claude Code では Claude 系列のいくつかの種類のモデルを使い分けることになります。ひとつのモデルに対してひとつの application inference profile を定義する必要があり、たとえば Terraform で書くと以下のようになります。なお Bedrock はリージョンごとに使える基盤モデルが異なり、ここでは us-east-1 を想定しています。 locals { # System-defined inference profiles for Anthropic Claude models. model_sources = toset([ "us.anthropic.claude-3-5-haiku-20241022-v1:0", "us.anthropic.claude-3-5-sonnet-20240620-v1:0", "us.anthropic.claude-3-5-sonnet-20241022-v2:0", "us.anthropic.claude-3-7-sonnet-20250219-v1:0", "us.anthropic.claude-3-haiku-20240307-v1:0", "us.anthropic.claude-3-opus-20240229-v1:0", "us.anthropic.claude-3-sonnet-20240229-v1:0", "us.anthropic.claude-opus-4-20250514-v1:0", "us.anthropic.claude-sonnet-4-20250514-v1:0", ]) } data "aws_bedrock_inference_profile" "model_source" { for_each = local.model_sources inference_profile_id = each.value } resource "aws_bedrock_inference_profile" "claude_code" { for_each = local.model_sources name = "claude-code_${replace(replace(each.value, ".", "_"), ":", "_")}" description = "Inference profile used by Claude Code based on ${each.value}" model_source { copy_from = data.aws_bedrock_inference_profile.model_source[each.value].inference_profile_arn } tags = { Project = "claude-code" } } このようにして作った application inference profiles は、Claude Code 用の環境変数である ANTHROPIC_MODEL や ANTHROPIC_SMALL_FAST_MODEL に ARN をセットしておくことによって Claude Code から利用できます。詳しくはドキュメントをご覧ください: https://docs.anthropic.com/en/docs/claude-code/amazon-bedrock。自分が忘れがちだったこととして AWS_REGION もセットする必要があります。 権限管理 Claude Code は開発者それぞれの手元の環境からモデルの呼び出しを行うことになるため、各開発者に対して AWS IAM で bedrock:InvokeModel などについて権限を許可することになります。 上記のように inference profiles 経由でモデルを呼び出したい場合、 inference profiles をリソースとする bedrock:InvokeModel を許可するだけでは権限が足りず、inference profiles の背後にいる基盤モデルについても bedrock:InvokeModel を許可する必要があります。 ただしその両方について素朴に許可すると基盤モデルに対する直接の API 呼び出しも許可されてしまうため、基盤モデルに対する呼び出しについては inference profiles 越しの利用に限って許可するように書くことができます。具体的には condition key として bedrock:InferenceProfileArn を利用します。ドキュメントはこちらです: https://docs.aws.amazon.com/bedrock/latest/userguide/inference-profiles-prereq.html。 たとえば InvokeModel 周りについては以下のような policy が考えられます。基盤モデルの方にだけ Condition があるのがポイントです。 { "Version": "2012-10-17", "Statement": [ { "Effect": "Allow", "Action": [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream" ], "Resource": [ "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-haiku-20241022-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-20240620-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-5-sonnet-20241022-v2:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-7-sonnet-20250219-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-haiku-20240307-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-opus-20240229-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-3-sonnet-20240229-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-opus-4-20250514-v1:0", "arn:aws:bedrock:*::foundation-model/anthropic.claude-sonnet-4-20250514-v1:0" ] "Condition": { "ArnLike": { "bedrock:InferenceProfileArn": "arn:aws:bedrock:us-east-1:012345678901:application-inference-profile/*" } }, }, { "Effect": "Allow", "Action": [ "bedrock:InvokeModel", "bedrock:InvokeModelWithResponseStream" ], "
はじめに こんにちは。レシピ事業部でアルバイト中の松本 (@matsumo0922) です。クックパッドでは以前からモバイルアプリケーション向けのロギングライブラリである Puree を公開していましたが、今回新しく Kotlin Multiplatform1 を用いた KMP 版をリリースしました。また、このライブラリは既に iOS、 Android 両方のクックパッドアプリで動作しており、クックパッド初の Kotlin Multiplatform 導入事例ということで、難しかった点などをライブラリの紹介と合わせてご紹介します。 Pureeとは サービスのログを収集する際に、Web アプリケーションであればサーバー側でログを収集することが可能ですが、モバイルアプリケーションは画面の操作はアプリ側でコントロールされるため、アプリがログを収集して送信する必要があります。アプリの操作は、ユーザーのデバイスの状態(通信環境や電池残量など)、アプリのライフサイクル、ユーザーの複数回タップなど様々な要因を考慮せねばならず、Web アプリケーションのロギングより複雑になりやすいのが現状です。 そこで、上記の様な面倒臭い処理を全て行ってくれるのが「Puree」というライブラリです。Puree は以下の機能を全て内蔵しており、ロギングは Puree に任せてアプリの主要機能の実装に注力することが可能となります。 フィルタリング 特定のログに対して共通のパラメータを付与したり、サンプリングを行うことが可能 バッファリング 特定時間内に複数回送られたログを一時的にバッファ 通信に失敗した場合や送信前にアプリが kill された場合でも、内部データベースに自動保存 リトライ 何らかの要因でログが送信されない場合でも、自動的にリトライ Pureeの仕組み puree-kmp 上記の様に便利なライブラリである Puree ですが、実は様々な言語、OS向けに複数のライブラリがリリースされています。 2014年にリリースされた元祖 Puree である、puree-android 。続いてリリースされた iOS 向け Puree である、puree-ios 。2017年には ReactNative 向けの react-native-puree 。翌年の2018年では Swift で一新された puree-swift 、Android の公式開発言語が Kotlin へと移行してしばらく経った2021年には、Java から Kotlin に一新された、puree-kotlin がリリースされています。 techlife.cookpad.com techlife.cookpad.com この様に様々な種類と歴史がある Puree ですが、それ故に実際の開発現場では様々な弊害も存在しました。Puree のおおまかな仕様は全て似通っていますが、詳細部分ではそれぞれのコードベースごとに異なる仕様があり、実運用してみると iOS と Android でログの送信されるタイミングや数が異なるといった問題が度々発生していました。加えて、すでにレガシーとなっている Java や Objective-C で書かれた puree-android と puree-ios はもちろんのこと、日々進化していくモバイルアプリ界隈で複数のコードベースのライブラリを保守していくのはコスト的にも難しいものがありました。 そこで、Kotlin Multiplatform(以下 KMP)というマルチプラットフォーム技術を用いて複数のコードベースを統合し、一つの Puree にするという目標を掲げて作られたのが「puree-kmp」です。 特徴 puree-kmp は puree-kotlin / puree-swift の設計思想を踏襲しつつ、KMP 化に際して以下の様な変更を行なっています。 型ベースの Filter / Output puree-swift ではタグパターンを用いて文字列ベースの Filter & Output の設定を行っていましたが、それぞれ独立してパターンを設計するのが難しく、より安全な型マッチング方式に移行すべきという議論が社内でも度々起こっていました。満を持して型ベースに移行です。 デフォルトの Filter / Output のサポート 型ベースに移行したことにより、全ての型に対し適応する Filter と Output を登録する必要が出てきました。クックパッドで記録しているログの数は膨大であるため、その全てを Puree に登録していくのは効率的で無いと判断し、デフォルトの Filter と Output をサポートする様に変更しました。 Full Kotlin これは言語としての Java を使用していないという意味ではなく、依存しているライブラリも含めて全て Kotlin で書かれているという意味です。KMP なので当たり前ではありますが、これによりレガシーなライブラリへの依存も一掃することができました。Androidではログのシリアライズには kotlinx.serialization、データの永続化には androidx.room を採用するなど、積極的に開発が行われている公式のライブラリを多用しているため、保守性はもちろんライブラリとしての寿命も伸ばすことができているはずです。 Swift Friendly KMP のライブラリは同じ KMP プロジェクトから参照されることを前提にしているものが多く、Swift から参照されることをあまり考慮されていません。これは KMP としての仕様も少し絡んできますが、KMP は Kotlin → LLVM → Objective-C → Swift という手順を踏んで Swift から参照されるため、それぞれの言語毎の差異を累積してしまいます。例えば、Kotlin の null 安全が Objective-C を通すことによって曖昧になったり、Kotlin の interface は Objective-C の protocol に変換されますが、Objective-C の protocol は Swift の struct で準拠できないと言った問題等、意識して Swift Friendly に書かなければ Swift では利用しづらいライブラリとなってしまいます。puree-kmp では基礎設計をできるだけ共通にしつつ、Swift 向け固有のコードを用意し、Swift からでも利用しやすいライブラリとなることを心がけています。 各プラットフォームへの拡張性 現状 puree-kmp がサポートしているプラットフォームは Android と iOS だけですが、ほぼ全てのコードを共通モジュールで記述しているため、別プラットフォームへの拡張も容易に行える設計となっています。将来的には JVM、Desktop、WebAssembly へ展開していければと考えています。 使い方 前述の通り、設計は以前の Puree から引き継いでいるので、現在 puree-kotlin または puree-swift を利用している場合スムーズに移行することができます。実装例として、以下のような ClickLog を送信する例を考えてみます。 Puree を扱うには以下の4ステップが必要です。 依存の追加 ログを定義 PureeFilter と PureeOutput を実装 ログの型をベースにどの PureeFilter と PureeOutput を使うか登録する より詳しい使い方は README をご覧ください。 導入 Android では Maven Central、iOS では Swift Package manager から導入することができます。 dependencies { implementation("com.cookpad.puree:puree-kmp:$latestVersion") } ログ定義 送信するログを定義します。PureeLog を継承 / 準拠することで Puree で扱えるログクラスとなります。Android ではデフォルトで kotlinx.serialization を使用しているので、@Serializable アノテーションをつけるのを忘れないでください。なお、kotlinx.serialization を使用する場合の注意として、data object でログを定義してしまうとプロパティがシリアライズされませんのでご注意ください(一敗)。 @Serializable data class ClickLog( @SerialName("button_name") val buttonName: String, ) : PureeLog Swift でログクラスを定義する場合は KMP の仕様上 struct は使えませんので、class で実装してください。また、後述しますが Swift で書かれたクラスに対してはもちろん kotlinx.serialization を使うことができないので、自分のシリアライズ機構を実装する必要があります。以下の例では Encodable を用いていますが、class → Json の変換ができればなんでも良いです。プロジェクトに合わせて実装してください。 class ClickLog: PureeLog, Encodable { let buttonName: String init(buttonName: String) { self.buttonName = buttonName } } PureeFilter の実装 PureeFilter を実装してフィルタリング機構を追加します。ここで特定のログを弾いたり、任意のペイロードを追加で載せることも可能です。クックパッドアプリではユーザーの情報や時間などを追加しています。 class AddTimeFilter : PureeFilter { override fun applyFilter(log: JsonObject): JsonObject? { buildJsonObject { put("time", System.currentTimeMillis()) put("log_id", UUID.randomUUID().toString()) }.also { return JsonObject(log + it) } } } class AddTimeFilter: PureeFilter { func applyFilter(log: String) -> String? { guard var json = parseJSON(log) else { return log } let dateFormatter = ISO8601DateFormatter() json["time"] = dateFormatter.string(from: Date()) json["log_id"] = UUID().uuidString return stringifyJSON(json) ?? log } } PureeOutput の実装 PureeOutput ではログの送信機構を実装します。送信方法には二種類あり、ログが発生したら瞬時に送信を行う PureeOutput と、一定期間ログをバッファリングしてまとめて送信を行う PureeBuff
レシピ事業部の石川です。 4 月 16 日(水)から 3 日間、RubyKaigi 2025 が開催されます: https://rubykaigi.org/2025。1 週間後の今日には愛媛に立っている……はず! クックパッドは Platinum スポンサーとして RubyKaigi 2025 に協賛しています。今年はそれに加え、スポンサーブースを出展いたします。 今年のスポンサーブースでは、最近のクックパッドの様子をご理解いただけるコンテンツをご用意しました。2024 年に日本とグローバルにあった 2 つの「クックパッド」サービスを統合したあと、クックパッドでは引き続き、毎日の料理を楽しみにするためサービスの改善に取り組んでいます。日本側の開発者からすると日々の変更が世界規模の影響を与え、グローバル側の開発者からすると日本で昔から使ってくださっているユーザーさんにも価値を届けられるようになりました。Rails を使って多地域・多言語に向けたサービス開発をしている今のクックパッドについて、ご紹介ができたらと考えています。 techlife.cookpad.com また今年は、Rails Foundation の方々にご協力いただき、9 月にヨーロッパにて開かれる Rails World 2025 というカンファレンスの招待券を少数ご用意できることになりました。クックパッドは Rails Foundation のコアメンバーであり、そのご縁で生まれた企画となります。ご招待券は抽選ですので、詳しくはクックパッドのブースまでお越しください。 https://rubyonrails.org/foundation RubyKaigi は今年も面白そうなトークが目白押しとなっており、個人的にも楽しみにしています。叶うことなら 3 体に分身して 3 トラック全部聞きたい……。そしてこの時期、愛媛では鯛が特に美味しいらしく、そちらにも興味津々です。もう待ちきれません。それではまた来週、愛媛でお会いいたしましょう!
検索・MLチームの山口 (@altescy)です。 先月 3/10〜3/14 にかけて長崎で行われた言語処理学会第31回年次大会に参加し発表・聴講してきたので、今回はその参加報告となります。 その前に 4/23 (水) に検索・MLをテーマにオフラインイベント Tech Kitchen を開催します。 Tech Kitchen #31 クックパッドのレシピ検索のいま 〜多言語対応と機械学習〜 私からは『レシピのための埋め込みモデルの学習とベンチマーク構築』についてお話しする予定です。 ご興味のある方はぜひキッチンへ遊びに来てください 🥳 発表した内容 クックパッドからは以下の発表を行いました。 P3-12: LLM を用いた複数レシピに対する調理計画手法の検討 1人の作業者が複数のレシピを同時に調理するのに最適な調理計画を LLM を使って生成するというものです。現在のクックパッドでは作りたいレシピが複数ある場合、それらを行き来しながら作業を進める必要があります。作りたいレシピをいくつか選んだらいい感じにインストラクションが始まってくれると嬉しいな〜という気持ちで今回はこのテーマを選びました。 最近では旅行や MTG の計画を LLM に解かせるベンチマークが提案されていて1、そうした計画タスクは LLM にとって難しいことが知られています。特に料理の計画においては分単位の細かな時間管理が求められるため、単純に手順の開始と終了を順番に出力させるだけではうまく計画を立てられません。コンロなどの調理器具は同時に使える個数も制限される場合が多いため、調理環境に合わせた制約も考慮する必要があります。 LLM によるプランニング手法 今回試した手法では、調理手順の解析 → 大まかな計画の作成 → ルールに基づく調整といったパイプライン的な手法を用いることで、そこそこなクオリティの計画が立てられそうなことを実証しています。一方で、揚げ油の加熱やオーブンの余熱などの非明示的な作業をうまく補完できなかったり、調理終了のタイミングがバラバラになってしまうなど、料理の常識、時間認識に課題があることもわかりました。 以下は生成された調理計画の例です: 生成された調理計画の例 今後の改善案として、 LLM が苦手そうな状態管理や複雑なプランニングを実現するために状態を外部化して API 経由で LLM とやりとりさせたり、エージェント的に反復しながらプランニングさせる、みたいな方法を試したいと考えています。 NLP2025 の所感 今年の年次大会は全体の傾向として LLM・Transformer の内部表現に関する研究が特に多かった印象です。 分析手法も多様 (PCA、SAE、介入、層毎の評価、etc.) で面白く感じました。 実用の観点では出力内容の制御やドメイン適応においてそうした知見を活用できないかと考えています。 また、LLM の評価・データセット構築に関する研究も多く見かけました。 特にクックパッドはレシピというユニークなドメインのテキストを扱う都合上、LLM の評価においても独自のデータ・評価方法を構築する必要があるため大変参考になりました。 ポスターセッションでは評価データや指標の構築にまつわる苦労話なども聞けてよかったです。 世の中は大 LLM 時代に突入し私が学生時代に研究していた時とは様相が変わりつつありますが、まだまだ NLP は面白いと改めて実感することができました。 気になった発表 NLP2025 で発表された研究のうち、特に実用的で面白かったものをいくつか紹介します。 D1-4 ベイズ教師なし文境界認識 概要: 文字 n グラム言語モデルを用いた教師なしの文境界識別手法を提案し、教師ありの既存手法や LLM を使った手法よりも良い性能を達成しています。文字 n グラム言語モデルに文境界を表す潜在変数を導入し、MCMC 法を用いてコーパスから文分割を学習します。実験結果では X (Twitter) のように文末表現が多様でノイジーなテキストに対してもうまく文末を認識できることが示されています。 感想: シンプルなモデルで教師あり手法を上回る性能を達成していることに驚きました。SNS で見られる多様な文末表現 (絵文字、顔文字、♪のような記号など) に教師ありで対応しようとすると多くのアノテーションデータが必要になるわけですが、それなしで高い性能を実現できるのは嬉しいです。また、改行や句点など文末になりやすいパターンを事前分布としてモデルに伝えることができるのも実用上非常に便利だと思いました。 Q4-3 Ruri: 日本語に特化した汎用テキスト埋め込みモデル 概要: 日本語において高い性能を発揮する埋め込みモデル Ruri (v2) に関する発表です。モデルの構造や学習手法に目新しさはないものの、LLM を使ったデータ拡張・モデルマージなどのテクニックを組み合わせて高い性能を達成しています。特に QA/検索データの作成においては、元のテキストを LLM で言い換えてから質問・応答を生成するなど、多様性を生み出す工夫が施されています。 感想: ポスター発表を聞きに行った際に、LLM 言い換えのテクニックとして文章を箇条書きに変換 → 順序の並べ替え → 再度自然な文章に直す、といった方法を利用したと教えてもらいました。生成データを活用する際はその多様性が非常に重要で、実際 Ruri の学習においても単に生成データを増やすだけでは性能評価につながらず、モデルマージを介して性能向上を実現しています。他の研究においても生成データを活用したものがいくつもありましたが、いずれも多様なテキストの生成に課題を感じました。 Q4-9 Mixture-of-Expertsの悲観的な統合による頑健な自然言語理解 概要: MoE (Mixture of Experts) モデルを分布外のデータに対してうまく予測できるようにする手法の研究です。MoE に限らずモデルはデータに含まれる擬似相関も学習してしまう場合が多く、その影響で分布外のデータに対してうまく推論できない場合があります。この研究の提案手法では、各 Expert Model が出力した予測スコアのうち、最も低い確率を割り当てたラベルのスコアを比較することで、擬似相関の影響を抑制して頑健な予測を実現しています。 感想: 手法自体は非常にシンプルでありながら高い分布外データへの予測性能が確認されています。この研究で対象とされていたのは BERT モデルを使った分類タスクでしたが、より一般的なモデルのアンサンブル手法としても利用できそうで良いアイデアだと思いました。 P8-15 逆プロンプトを用いたコールドスタート推薦 概要: 最近の推薦に関する研究では LLM を用いてユーザーのアイテムへの興味を推測する手法がさまざま提案されてるのですが、ユーザー x アイテムのデータを LLM に処理させるのは運用上非常にコストが高いです。この研究ではユーザーの履歴から興味のありそうな情報を LLM を用いて生成し、それを軽量な推薦モデルの学習に利用することでコールドスタート性能を向上させる手法を提案しています。Amazon Review を対象とした性能比較において、全体としてChatGPT を用いた手法よりも良い性能を達成しています。 感想: この手法はアイテムのテキストなどの補助情報を用いたさまざまな推薦手法に活用できます。実験に使用された手法はユーザー・アイテムの特徴量の行列演算のみで推論できるシンプルなものですが、それでも高い性能を示しているため実用的だと感じました。扱うデータによっては LLM による擬似データ生成が難しいケースもありそうですが、多くの推薦タスクで活用できる良いアイデアだと思います。 P9-18 バックトラッキングを活用したマルチエージェントシステムによる複数制約充足プランニング 概要: 旅程のプランニングタスクである Travel Planner ベンチマークを対象に、LLM エージェントによるプランニング手法を提案しています。移動手段・食事・宿泊など部分的な制約を評価するエージェントをそれぞれ用意し、制約を満たさない場合にフィードバックを添えて前のステップに戻る、という方法でプランニングを行います。CoT/ReAct などの手法と比較して高い性能が確認され手法の有効性が示されていますが、都合の良いデータを生成してしまう・途中で情報が欠落するなどのエラーは依然として存在するとのことでした。 感想: 今回クックパッドから発表した内容もプランニングタスクだったため、改善のアイデアとして参考になりました。現状の LLM は直接プランニングするのが非常に困難なので、エージェントを用いて反復的に生成するのは有用だと考えています。一方で、複雑な制約を LLM 自身が正しく評価できるかという問題もあるため、先述した状態管理の外部 API 化みたいな方法は実用の観点でありかもと思っています。 Q9-21 文の埋め込みに効果的な静的単語ベクトルの獲得 概要: 学習済みの LLM のサブワード埋め込みから、文表現に適した単語埋め込みを獲得する、という手法の研究です。同様に LLM のサブワード埋め込みを利用する Model2Vec では単純に PCA による次元削減 + 頻度に基づくスケーリングを行っているのに対し、この研究では文埋め込みをよく識別できるよう文を跨いで共通する成分を取り除くという方法を提案しています。PCA によって得られた主成分のうち、上位を除く中間の主成分を利用することで、文の違いを強調した表現が得られます。また、元のモデルの最終層の表現を使った知識蒸留も施すことで Model2Vec などの類似手法と比べて高い性能を達成しています。 感想: PCA による主成分を使った次元削減の手法はよく知られていますが、違いを強調 (分散を大きく) するために主成分を除くというアイデアはあまり見かけたことがなかったので面白く感じました。蒸留などの追加学習は行われているものの、軽量かつペアデータなどを使わない教師なしの学習で良い埋め込みを得られるのは嬉しいですね。最近では軽量な文埋め込みの手法が盛り上がっている気がして注目しているところです。 おわりに 今回の NLP2025 では LLM の活用や評価に関する多様なアプローチに触れることができ、非常に充実した時間を過ごすことができました。 クックパッドは昨年海外向けサービスとの統合を果たしたこともあり、多言語への対応・機能拡張の点で LLM をはじめとした自然言語処理技術が不可欠だと感じています。 今回得られた知見も活用しながら、よりよいサービスの開発に繋げていきたいと思います。 We're hiring! クックパッドでは機械学習エンジニアを募集しています! ご興味のある方はぜひ採用ページをご覧ください。ご連絡をお待ちしております 🙌 https://open.talentio.com/r/1/c/cookpad/pages/105606 Natural Plan や TravelPlanner などがよく知られています↩
こんにちは。SRE の小川 (@coord_e) です。 東京Ruby会議12というイベントが 2025-01-18 (土) に開催されます。クックパッドは Ruby スポンサーとして協賛させていただきます。クックパッドから基調講演で鈴木 (id:eagletmt) が登壇します。また、筆者の小川もトークで登壇します。 regional.rubykaigi.org クックパッドのスポンサーブースでは、以下の Techlife 記事で話題に上げた One Experience プロジェクトについて Ruby の視点で説明する展示を行います。 One Experience はレシピサービスのコードベースが丸ごと入れ替わるような大きな変化であり、往年の巨大 Rails モノリスである cookpad_all がついに退役に向かうなど Ruby / Rails やアーキテクチャの観点でこれまでのブログでは語り切れないような様々な変化がありました。ブースには One Experience プロジェクトの中心となった社員もおりますので、気になることはなんでもお話しできると思います。メンバー一同、当日ブースでお待ちしております! 更に、東京Ruby会議の非公式関連イベントとして、「Tech Kitchen #30 After 東京Ruby会議12: クックパッドのRubyライフ」を池尻大橋のオフィスで開催します。東京Ruby会議12のテーマ「Rubyと暮らす」にちなみ、クックパッド社員による Ruby の活用にフォーカスしたトークを用意しました。クックパッド社員が業務や日常生活でどのように Ruby を活用しているかを深掘りしていきます。懇親会では、弊社のキッチンでプロのシェフによる美味しい料理を食べながら Ruby の話ができます。 東京Ruby会議12のクックパッドブースにてイベントの参加方法をご案内いたしますので、当日はぜひブースにお越しください。 cookpad.connpass.com
レシピ事業部プロダクト開発グループの @miichan_ocha です。昨年の 12/18 (水) に「Tech Kitchen #29 Mobile App One Experience 〜サービス統合について語っちゃいます モバイルアプリ編〜」を弊社オフィスにて開催しました。 イベントではクックパッドレシピサービスの iOS/Android アプリ開発に携わる3名のモバイルアプリエンジニアから、昨年行った「One Experience」プロジェクトに関するお話をさせていただきました。この記事では、イベント当日の様子を発表資料とともに紹介させていただきます。 なお、「One Experience」プロジェクト自体の概要については下記の記事で紹介しているので、興味のある方はこちらもご覧ください。 日本とグローバルのクックパッドを統合しました モバイルアプリの One Experience 日本版とグローバル版のモバイルアプリ統合の開発の裏側と今後の展望 by 三井田 最初の発表は私、三井田から「One Experience」プロジェクトで行った日本版とグローバル版のクックパッドモバイルアプリの統合や、現在のプロダクト開発グループの開発体制、これからやっていきたいことなどについてお話ししました。 speakerdeck.com グローバル版の iOS/Android アプリを開発していたリポジトリから、日本向けとグローバル向け両方のアプリを配信するにあたってどのように開発を進めていったのか、統合が完了した今、これからどのようなことをやっていきたいのかなどをお話させていただきました。 週次リリースを実現するためのグローバルアプリ開発 by 山田 2番目の発表は、同じくレシピ事業部プロダクト開発グループに所属する山田 (@0x746572616e79) より、週次リリースを実現するためのグローバルアプリ開発についてお話ししました。 speakerdeck.com プロダクト開発グループでは、1週間単位のスプリントでのスクラム開発を採用し、スプリントごとに機能のリリースを行っています。また、機能開発だけでなく、グローバル統合に伴う翻訳作業も同時に進める必要があります。この発表では、Feature Toggle を使ったスプリント(リリース)を跨いだ開発の進め方や、翻訳作業の流れについて紹介させていただきました。 複数リリースに跨った機能改善については、iOSアプリにおける複数リリースに跨った機能改善の開発事例紹介の記事にも詳しい記載があるので、興味のある方はこちらも合わせてご覧ください。 Androidアプリの One Experience リリース by こやまカニ大好き 最後の発表はレシピ事業部で Android アプリ開発を行っている、こやまカニ大好き (@kanidaisuki3) より、Android アプリを(コードベースが)別のアプリで上書きする具体的な手法についてお話ししました。 speakerdeck.com この発表では、アプリのコードベースが完全に別物になっても、それまでのユーザーと同じ状態でログインしたままアプリ内のデータも引き継いだ状態で動かせるようにするために、どのように認証情報やローカルデータのマイグレーション、One Experience 版アプリのリリース作業を行なったのかなどについてお話させていただきました。 パネルディスカッション 発表後は、発表者3名にレシピ事業部部長の大石も加わり、参加者のみなさまからの質問への回答を中心としたパネルディスカッションを行いました。 グローバルのメンバーとのコミュニケーションはどうしているのか、日本版とグローバル版のUI統合はどのように決めたのかなどの多くの質問をいただき、とても充実した時間となりました。当日質問をくださったみなさまありがとうございました。 当時の質問や回答の一部は X の実況でご覧になれます。 懇親会 その後はシェフに作っていただいた料理を食べながら、参加者のみなさまと懇親会を行いました。美味しい料理を食べながら参加者のみなさまとたくさんお話・情報交換できてとても楽しかったです。 https://x.com/mimimi__ko/status/1869339039847092447 より まとめ 今回のイベントはモバイルアプリ関連の Tech Kitchen としてはかなり久々のオフラインイベントで、私自身も久々のオフライン登壇で緊張していたのですが、参加者アンケートでもたくさんのフィードバックをいただき、開催して良かったなと思いました。今後もこのようなオフラインイベントを定期的に開催していけたら良いなと考えております。改めてこのイベントにお越しくださったみなさま、ありがとうございました。
こんにちは。レシピ事業部検索チームの薄羽 (@usulity) です。 続々と関連記事が投稿されていますが、日本とグローバルのクックパッドを統合しました。 この統合に際して、日本のクックパッドの様々な機能がグローバル版へ移植されました。今回は、移植された機能の一つである「人気のキーワード」について、移植した際にどんな課題があってどう解決したのかの一部をご紹介できればと思います。 人気のキーワード 人気のキーワードは、「クックパッドで最近よく検索されているキーワード」を集計して、ランキング形式で掲載する機能です。 日本版人気のキーワード このように、人気のキーワードは1時間おきに更新され、その時期・時間帯のトレンドを反映したようなキーワードのランキングになっています。 トップページの検索窓の下にも上位のキーワードが表示されており、人目につきやすい機能の一つです。 グローバル版人気のキーワード グローバル版にも人気のキーワード機能があります。 基本的な機能は日本のものと同じで、そのときのよく検索されているキーワードを表現しています。 グローバル版英語の人気のキーワード 今回取り組んだタスクは、このグローバル版の人気のキーワードとして、日本の人気のキーワードを日本版の計算方式で表示することがゴールになります。 課題 ということで、「グローバルにも人気のキーワードがあるから、日本語でも集計を実行するように修正すれば終わり!」としたいところなんですが、いくつか問題があります。 そもそも、グローバル版と日本版は別物のアプリケーションであるため違いは色々ありますが、「ログがない」ことと「更新頻度の違い」は移植する上で大きな問題でした。 ログがない 人気のキーワード機能を移植するにあたり、ユーザが移行してきたタイミングではもう人気のキーワードを表示しておきたいという要望がありました。 人気のキーワードは検索ログから計算するため、人気のキーワードを表示するにはグローバル版で検索ログが溜まっている必要があります。 しかし、日本のユーザをグローバル版の方に流すまでは、日本のユーザはグローバル版の方にはいないため検索ログはありません。 このままでは移行してきたタイミングで人気のキーワードを表示することができないので、なんとかグローバルの方にログがない状態でも人気のキーワードを計算できるようにしておきたいです。 また、キーワードのクオリティの面でも懸念があります。 この次でも説明しますが、人気のキーワードの計算は、過去の長い期間に渡ったログを使うことで、そのときの人気度を計算しています。 そのため、移行の初期段階ではログが十分に溜まっていないことで、人気のキーワードの質が不安定になってしまう恐れがあります。 更新頻度の違い 日本の方は毎時の更新なのに対して、グローバルの方は日次の更新になります。 計算頻度が日次か毎時かでは、単なる実行頻度の違い以上の差があり、人気度の計算方法が異なります。 人気度は長期的な目線と短期的な目線の両方で見た時に、目立って検索されている語を抽出しています。 簡略化しますが、例えばグローバル版の方では、人気度を「そのキーワードのその日の検索回数」/ 「過去に渡ったそのキーワードの1日の平均検索回数」で計算します。 こうすることで、あるキーワードが平均よりその日多く検索されていたら、そのキーワードの人気度は高くなります。 毎時の計算においても同じようなことをしますが、時間の区切り方が異なります。 「その時間帯」で目立って検索されたキーワードを調べたいので、毎時の計算では人気度を、「そのキーワードが1時間の間に検索された回数」-「そのキーワードがこの時間帯で検索される平均回数」として計算します。 このように、日次と毎時では人気度の計算方法が異なり、毎時では日次より細かい単位で平均を計算します。 単純にグローバル版の人気のキーワードの計算を毎時に動かしても再現できないことが分かったので、グローバル版の人気のキーワード計算の仕組みを変えるか、新しくバッチジョブを用意する必要があります。 解決 日本版とグローバル版両方のログから合算する 移行先にログがない問題を解決するために、移行先の人気のキーワードは日本とグローバル両方のログから計算します。 幸い、日本版でも人気のキーワードの計算に特別な情報は使っていなかったため、グローバル版のログで情報が足りないといったことはありませんでした。 これにより、グローバル版に移行初期のタイミングでも、良いクオリティの人気のキーワードが計算できるようになります。 また、ユーザの段階的な移行を考えた時にも、この方法は利点があります。 日本版とグローバル両方からログを取得しているため、仮にユーザの50%は日本、もう50%がグローバルにいるようなケースでも、両方のプラットフォームでのユーザ行動を加味した人気のキーワードを計算できます。 実際に、 One Experience ではユーザを日本版から少しずつグローバル版の方へ移行していきました。 それぞれからの検索ログを同じ価値と見なして良いかは議論の余地がありますが、それを検証するのは大変なので、1つの検索ログが1回の検索を示すことだけ確認して、移行途中で日本版と大きく結果が離れていなければよしとしました。 このように、データソースを両方のプラットフォームから取得して計算することで、移行段階に左右されずに人気のキーワードを提供することができます。 日本用のバッチジョブを用意する 更新頻度の違いによる人気度の計算方法の違いが大きいため、今回は日本用のバッチジョブを用意することにしました。 グローバル版の方の仕組みを日本版に寄せる方向性も考慮しましたが、更新頻度以外に以下の理由で別のバッチジョブとして作ることにしました。 グローバル版の方の人気のキーワードのクオリティをチェックしながら開発するコストが高い 日本とそれ以外の言語ではログの量に差があるため、そのためのパラメータ調整にコストがかかる グローバル版では、多数の言語の人気のキーワードを計算しています。グローバル版の人気のキーワードの仕組みを変えた時、抽出されたキーワードのクオリティへの影響をチェックしたいわけですが、言語が違うのでチェックするのが大変です。 また、日本用にチューニングした人気度の計算方法が、他の言語でうまくいくとは限りません。日本向けに設定したパラメータが他の言語でも適切かどうかわかりせんし、また各言語向けに設定をするのもコストがかかります。 日本とその他の言語で別々のバッチジョブと言っても、将来的にはやはりなるべく1つにまとめられるようにしておきたいので、なるべくグローバル版の仕組みには乗るように注意しました。 結果とまとめ 結果として、ユーザの移行が始まる前に実装が完了し、無事移行初期段階でも人気のキーワードを表示することができました。 グローバル版日本の人気のキーワード やはり、ユーザがどっちのプラットフォームに居ても人気のキーワードを計算できることは、One Experience ではとても有効な方法だったと思います。 また、バッチジョブを分けることで他の言語への影響をあまり気にする必要がなかったことも、開発していく上では重要だったと今改めて思います。一方で、人気のキーワードの計算が日本語だけ分かれていることでメンテナンスコストが増えてしまっているので、これを他言語と統合していくということが今後の課題として残っています。 個人的な感想としては、正しく人気のキーワードを計算できているのか最後まで不安がありましたが、結果としては日本版と同様のキーワードをグローバル版でも表示することができて良かったと思っています。 以上、One Experience における人気のキーワード機能の移行の話でした。 面白いと思ってくださった方は、ぜひチャンネル登録と高評価、また他にも色々な角度の One Experience 話が投稿されておりますので、ぜひそちらもご覧ください。
こんにちは、レシピ事業部検索チームのオリギル(@orgil_)です。 先日、この開発者ブログで紹介されたOne experienceプロジェクトによって、クックパッドはプロダクト基盤をグローバル版のシステムに移行しました。私はこのプロジェクトにおいて検索の領域で移行の進行、開発を担当していました。このブログでは検索システムの移行について紹介します。 グローバルのシステムに寄せた理由 One experience によってクックパッドは検索基盤をグローバル側のシステムに移行しました。プロジェクト発足当初、日本とグローバルのどちらのシステムに寄せるかをいろんな観点から検討しました。日本ではSolr、Ruby、ECSなどで動くVoyagerというシステム、グローバルではElasticsearch、Python、k8s、Kafkaなどを用いたglobal-search-v2(通称GS2)という検索システムが動いています。検討の際、どちらのシステムも一長一短で、特段優劣をつけがたいものでした。 検索アルゴリズムに関しては両システムで考え方は一緒でした。辞書ベースでクエリ拡張をし、ドキュメントのタイトルや材料などにマッチスコアを付け、辞書の属性によって細かい調整をするようなスコアリングアルゴリズムは両方にあります。 またドキュメントの検索への反映時間はどちらも同等と呼べるものでした。GS2はKafkaを用いたイベント処理システムを用いて、ほぼ即時反映を実現しています。Voyagerでも定期的な同期をするようになっており、レシピの変更が最短5分で検索結果に反映されるようになっています。下記のブログで触れているように、プロダクトが求めるユーザー体験を実現するためには5分でドキュメントが反映されれば十分であるため、どちらのシステムでも同様な価値を届けられます。 VoyagerがGS2より一番優れていた点は、速度でした。議論時点ではGS2のp50, p95 レスポンスタイムはVoyagerより約4倍ほど遅いものでした。これは大きな懸念要素で、速度は検索システムにとってとても大事な指標です。GS2を採用する際に、日本の大きなトラフィック量が増えることでGS2が更に遅くならないかなどの懸念点はありました。 いろいろと議論しましたが、最終的にはGS2の方に統合することになりました。一番の決め手は多言語対応がVoyagerでは難しかったからです。GS2はすでに約30言語をサポートしていてElasticsearchのAnalyzerを各言語で細かく設定、運用しており、辞書も言語、地域ごとに別れています。この運用をVoyagerでやるのが難しそうでした。 また辞書の扱い方が大きく違いました。日本の検索は長年運用されて、辞書は成熟しており、変更が頻繁に行われないため、辞書の更新は日次バッチで適用されます。しかし、GS2では辞書の変更も即時反映で、すぐに検索結果が変わります。これは辞書の管理を各地域のCM(コミュニティマネージャー)にお願いしていて、彼ら彼女らが辞書を変更しながら日々検索改善を行っているからです。まだ辞書を一から作っている新興マーケットが多かったため、辞書の変更による検索結果の変化を実際に見ながら辞書の作成をしているためです。 GS2採用にあたって速度面での懸念はありましたが、VoyagerがGS2より速い理由はわかっていて、いくつかGS2でも実現できる仕組みがあったため、なんとかできそうだということで進めました。 検索移行の進め方 検索移行のゴールとしては、日本の検索と完全に同じ結果を返すようにすることを目指しました。GS2の検索ロジックの考え方は日本と同じだと言いましたが、実際の計算式は違っていて、検索結果にいろんな差異があります。通常ならABテストなどで、GS2ベースのスコアリングに置き換えても大丈夫か、検証しながら切り替えることも考えられます。しかしOne experience プロジェクトでは検索のみならず、メインのレシピサービスやデータベース、基盤をまるごと移行しているため、一部をユーザーに出して検証することができませんでした。また日本の人気順検索はプレミアムサービスの大事な機能であるため、慎重になる必要があり、システム移行作業とアルゴリズムの大きな改変の検証を同時にやるリソースがありませんでした。GS2で日本専用の検索クエリビルダーを作ることは容易で、大きな技術的な負債にもならないので、まずは同じ結果を再現することに従事しました。 RBO指標 検索結果が同じになっているかを確かめるために、日本とグローバルの結果を比較する必要がありました。今回の移行で、ふたつの検索結果の一致度を測る指標として、Rank Biased Overlap(RBO)という指標を用いました。RBOは2つの順位付きリストの類似性を測定する指標で、特に順位の上位に重点を置きながら、リスト全体の比較を行える特長があります。 RBOを計算する際の上位100件の結果を減衰パラメータ=0.978に設定して測りました。このパラメータは上位60件の一致度でRBO指標の9割の重みが決まるように設定しました。これはレシピ検索においてほとんどのアクセスが最初の3ページの閲覧に収まっているためです。実装のデフォルトである減衰パラメータ=0.9では上位約10レシピだけでRBOスコアが決められてしまい、上位数件の結果が合ってるかどうかに重きが置かれる指標になってしまうからです。 この比較をTop20000キーワードに対して、新着順と人気順の両方で毎日自動で計算するようにしました。日々のRBOの変化、2万キーワードのRBOの分布で進捗を測り、チーム内で RBOが低かったキーワードを分析しながら、移植機能の優先度をつけたり、仕様の見落としを発見しながら進められました。 最初のRBO計測 RBO値とそれに対するキーワードのヒストグラムで移行の進捗が可視化することができました。上の図は一番最初のRBO分布のグラフです。このときはもちろん何もできていないのでほとんどのキーワードがRBO=0.0という結果でした。 初期の開発過程の変化 最初は一番基礎的な検索サポートをしていきました。Elasticsearchで正しいAnalyzerを設定したり、Voyagerのベースの計算式をGS2に実装したり、検索時のクエリを正しく形態素解析するようにしたり、基礎的な部分を作っていきました。この時点でRBO=0.0結果がほとんどなくなり、大部分が右側に寄っている形になりました。 同日の新着順と人気順のRBO分布 またこれは同日の新着順と人気順の分布です。人気順のみの機能などもあることから、両方ともモニターしていく必要がありました。プロジェクト通して人気順のほうがRBO成績は良い傾向にありました。これは新着のレシピドキュメントが日本とグローバルで同期がズレていたり、人気順は人気順スコアの比重が大きいため似やすいことに起因しています。 基礎的な機能の移植が一段落し、プロジェクトの中盤ではRBO値が低いクエリ(0.4以下)を並べて、漏れている機能の発見や、未実装機能の優先度付けを行いながら進めました。難しかったのはRBOが0.6-0.8あたりのキーワードの解析でした。RBOが高いキーワードはもうすでにある程度似ている結果で、差分を探すのが難しいものとなっていました。この辺になってくると単純にアルゴリズムの違いだけではなく、ドキュメント作成時の形態素解析の微妙な差異なども影響してくるため、地道に細かく調査しながら進めました。 RBO分布の推移を可視化してみました。移行が進むごとに分布が右側に寄っていく様子が伺えます。RBOは最終的に新着順で RBO>=0.8 の割合が 97.2%、人気順で RBO >=0.8: 95.1% という結果になりました。いくつかの機能は違う形でもってきたり、Voyagerから持ってきたくない仕様などもありましたが、最終的にはとても高い一致率になりました。ユーザーリリース後もKPIが大きく変化することなく移行できました。 速度 検索移行において日本のシステムと同等な検索結果を出すことと同時に、同等な速度を用意することに注力しました。前述したようにGS2はVoyagerの4倍ほど遅いレスポンスタイムでした。 VoyagerがGS2と比べ速い理由はいくつかわかっていました。一つはクエリ拡張の方式です。辞書を使ったクエリ展開は主に同義語展開と、語の親子関係による拡張があります。日本では synonym token filter を使用して同義語展開を行い、親子関係の単語はインデックス時に事前計算して入れています。そのため検索時は元キーワードのみの検索で同義語展開と親子マッチが可能になっています。その変わりに辞書の変更を反映させるには、日次バッチを待つしかないです。対して、GS2では辞書反映をリアルタイムで実現したいため、クエリ拡張をElasticsearchクエリビルド時に行っているため、ESクエリが肥大化し、検索が重い処理になります。 もう一つの速い理由はSolrを用いた検索基盤が最適化されていたことです。Voyagerでは様々な工夫を凝らしてインデックスサイズを極限まで減らしています。またSolr-hakoを用いたシステムがとても高コスパで高パフォーマンスでした。GS2のElasticsearch構成と比較してクラスタを組んでいないし、Solrノードが1インデックスしか持たないため、クエリキャッシュが効率的に使えます。 GS2移行において、実際の日本のトラフィックに近い負荷試験シナリオを用意してパフォーマンスを計測していきました。前述したSynonym token filterや親子関係の事前計算をGS2にも移行して持ってきたので、GS2の他の言語と比較してすでにある程度速くなってました。その上で、API側、Elasticsearchの構成、インデックス設定などいろいろ模索しながらパフォーマンス改善していきました。GS2にはPrometheus, Grafana を用いたAPM機能が備わっていたので、検索の一連の流れ、クエリ解析、Elasticsearchへのリクエストなどのどこがボトルネックになっているのか明確で、注力する領域を明確にできていました。 またユーザーから見えるWebやアプリの検索ページのパフォーマンスも測定しながら、別のチームの方々が、検索基盤より前にあるシステムの最適化にも積極的に取り組んでいました。 最終的にはGS2はp95ではVoyagerより高速なパフォーマンスになりました。p50のベースのレスポンスタイムは上がりましたが、p95レベルでは日本より安定したレスポンスタイムを実現できました。 Voyager -> GS2 p50: ~22ms -> ~33ms p95: ~80ms -> ~50ms さいごに One experienceはUI、体験が大きく変わり、ユーザーに大きな負担を強いる挑戦でした。ユーザーの方には、検索結果とその応答性は同等なものを提供するように検索チームとして注力して来ました。結果、ほとんどのキーワードでは同等な検索結果を完全にGS2のシステムで用意することができ、レスポンスタイムも損なうことなく検索移行を実現することができました。 検索移行が終わったいま、真のGlobalな検索開発が始まりました。一つのチームで約30言語の検索改善に取り組んでいきます。また日本とGlobalの検索アルゴリズムをなるべく揃えていこうとしています。実際のユーザートラフィックがあるなか、改めてGS2に備わっているABテスト基盤を活用しながら、Global、日本両方のアルゴリズムのいいとこ取りをし、同じアルゴリズムに揃えていきたいと思っています。もちろん言語ごとの差異は残ると思っていて、完全に一緒にするとは思っていませんが、お互いから学べるところは多いはずです。 レシピ検索の詳細な話や検索以外の機能など、ここで書ききれない移行の話はたくさんあるのですが、またいずれどこかの機会に話せたらと思います。また、One experienceプロジェクトについて弊社のTechlifeブログでこれまでに、これからもいろんな記事が投稿されていきます。Techlifeのツイッターで随時更新しています、ぜひチェックしてください。
はじめに こんにちは。レシピ事業部で長期インターン中の松本 (@matsumo0922) です。先日このブログでも公開した通り、クックパッドでは日本とグローバルで体験を統一する One Experience というプロジェクトを行っています。 One Experience 以前では Android 開発においても日本とグローバルでコードベースが異なり、それぞれ使用している技術やライブラリが異なる状態でした。特にグローバルのコードベース (以下 global-android と呼びます) では AGP のバージョンも低く、加えて groovy + buildSrc と言った旧世代のビルドロジックを用いていたため、プロジェクトの進行に支障がある状態でした。本記事では、これらの問題を踏まえ One Experience をより円滑に進めるために施したビルドロジックの改善についてお話しします。 TL;DR global-android では旧世代のビルドロジックを使っていたため One Experience 用の機能開発に支障がある状態だった ライブラリ管理を VersionCatalog へ、スクリプトを Kotlin DSL へ移行すると共に、Gradle Composite Build + Convention Plugins を用いてビルドロジックの共通化を行った 最終的に、各モジュールの build.gradle.kts は非常に簡潔になり、大抵のビルドロジックが共通化され、開発をより効率的に進められるようになった 問題点 global-android の Gradle ビルドには以下のような問題点がありました。 dependencies.gradle を用いた手動のライブラリ & バージョン管理 groovy で書かれたビルドロジック 大量にモジュールがあるにも関わらず、一部ロジックが共通化されていない global-android でのライブラリ管理 まず、ライブラリのバージョン管理について。上の画像のように、global-android ではライブラリの定義(バージョンも含む)を gradle の ExtraPropertyExtension を用いて記述し、各モジュールに配布していました。この手法の詳細については割愛しますが、ご想像の通りライブラリ管理 & バージョン管理が複雑になり、かつ IDE の手助けや renovate や dependabot と言ったサービスも使うことができないため、開発者の体験を悪くしていました。 次にビルドロジックについて。global-android には buildSrc が導入されていましたが、記述されているのは CI/CD で用いられる共通タスクの定義のみであり、実際のビルドロジックは各モジュールの .gradle ファイルに分散していました。というのも、buildSrc はビルドロジックを記述するのに最適な場所ではあるものの、すべての gradle build のホットパスであるため、あらゆるビルドでコードをコンパイル & チェックを行ってしまうためです。もちろん初回以外はキャッシュが用いられますが、global-android のような巨大プロジェクトでは無視できないコストが発生します。加えて、buildSrc への変更はプロジェクト全体の classpath 変更、つまりキャッシュが無効になるという意味を持つため、buildSrc へビルドロジックを追加するのは慎重にならざるを得ません。 $ find . -type f -name '*.gradle' -exec wc -l {} + | tail -n1 # プロジェクト全体の Groovy ファイルの行数 3736 total そこで、ライブラリの管理を VersionCatalog に移行しつつ、 gradle の Conposite Build と Convention Plugins と言った手法を用い、ビルドロジックのみをプロジェクトから分離することでコストの削減、ビルドロジックの共通化を計ることにしました。 Composite Build とは 一言で言えば、「プロジェクトから切り離されたビルド」のことです。Composite Build1 内の各 build は include build と呼ばれ、include build 同士はロジックを共有せず、個別に構成 & 実行されます。 Convention Plugins を用いたプロジェクトの例2 上の例では my-app と my-utils を Composite Build を用いて一つのプロジェクトにまとめています。my-app は my-utils に依存を持つことができますが、この場合の依存は「直接的な依存」にはなりません。これらのモジュールは include build であるため、直接的に my-utils を参照することはできず、build を通して生成された実行可能ファイル(バイナリ)を参照することになります。そのため、buildSrc のような実行速度やキャッシュの問題を発生させずに、あらゆるロジックを記述することが可能になります。 Convention Plugins とは Convention Plugins3 とはビルドロジックを Gradle Plugin System を用いて共通化し、Plugin として配布する手法のことを指します。Plugin の作成方法については Standalone Gradle Plugin と Precompiled Script Plugin が存在します。それぞれの Plugin の作成方法については割愛しますが、今回は Composite Build を使用する都合上、直接スクリプトファイルを参照できないため、Standalone Gradle Plugin を用いることにしました。 まとめると、Composite Build を用いてモジュールを作成し(build-logic モジュールとします)、中で Convention Plugins を用いてビルドロジックを共通化する、と言った手法を取ることにしました。 実装 構成 最初に、最終的なディレクトリ構成を示します。 global-android/ ├── build-logic/ │ ├── src/ │ │ └── main/ │ │ └── java/ │ │ ├── convention/ │ │ │ └── FeaturePlugin.kt │ │ └── primitive/ │ │ ├── ApplicationPlugin.kt │ │ ├── ComposePlugin.kt │ │ ├── CommonPlugin.kt │ │ └── FlavorPlugin.kt │ ├── build.gradle.kts │ └── settings.gradle.kts └── cookpad/ └── ... 大まかには一般的なモジュールの構成と変わりありません。ただ一つ注意が必要な点は、build-logic は cookpad モジュールに include build されるため、Gradle 的には一つのプロジェクトとして扱われるということです。そのため、直下に settings.gradle.kts を配置して依存の解決方法や Library Repository を宣言する必要があります。今回は build-logic 内で VersionCatalog も用いるため、VersionCatalog の記述も必要です。 src/ ディレクトリ以下に Plugin を配置します。クックパッドでは、単一の機能や構成を提供する Plugin を Primitive Plugin、Primitive Plugin をまとめ一般化した Plugin を Convention Plugin と呼ぶことにし、それぞれの Plugin をディレクトリを分けて配置しています。 build-logic モジュールの作成 ルート直下に build-logic モジュールを作成します。モジュールの作成方法は問いませんが、AndroidStudioのコンテキストメニューから作成してしまうと、不要な proguard ファイルなどが生成されたり、settings.gradle.kts にモジュールとして追加されるなどのお節介が発生するため、あまりおすすめできません。前述の通り、build-logic は Gradle 的には一つのプロジェクトであるため settings.gradle.kts を配置してください。 dependencyResolutionManagement { repositories { google() mavenCentral() } versionCatalogs { create("libs") { // アプリプロジェクト側の VesionCatalog を参照する from(files("../gradle/libs.versions.toml")) } } } rootProject.name = "build-logic" 内容は通常のプロジェクトの settings.gradle.kts と同様です。Content Filtering などの記述もここに行います。一つ例外的なことは VersionCatalog を明示的に記述していることです。本来 Gradle は./gradle/libs.versions.toml にファイルが配置されていると自動的に libs という名前で拡張プロパティを生成しますが、build-logic から見れば toml ファイルは ../gradle/libs.versions.toml に配置されているため、明示的に記述する必要があります。もちろん、build-logic 固有の toml ファイルを別途用意する場合はこの記述は必要ありません。 build.gradle.kts についても、通常のプロジェクトとなんら変わりません。 plugins { `kotlin-dsl` } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } dependencies { implementation(libs.android.gradlePlugin) implementation(libs.kotlin.gradlePlugin) } 拡張関数の作成 build-logic 内では VersionCatalog の拡張プロパティが用意されていなかったり、implementation() や api() と言った DSL が用意されていないため、自力で実装する必要があります。 // VersionCatalog を取得するための拡張プロパティ internal val Project.libs: VersionCatalog get() = extensions.getByType<VersionCatalogsExtension>().named("libs") internal fun VersionCatalog.version(name: String): String { return findVersion(name).get().requiredVersion } internal fun VersionCatalog.library(name: String): MinimalExternalModuleDependency { return findLibrary(name).get().get() } internal fun VersionCatalog.plugin(name: String): PluginDependency { return findPlugin(name).get().get() } internal fun VersionCatalog.bundle(name: String): Provider<ExternalModuleDependencyBundle> { return findBundle(name).get() } // 通常のライブラリを implementation するための拡張関数 internal fun DependencyHandlerScope.implementation(artifact: Project) { add("implementation", artifact) } // bundle などを implementation するための拡張関数 internal fun DependencyHandlerScope.implementation(artifact: MinimalExternalModuleDependency) { add("implementation", artifact) } // 通常のライブラリを api するための拡張関数 ... 加えて、Plugin 内で Extension を便利に利用するための拡張関数も用意しておきます。Gradle 7.1 より BaseAppModuleExtension と LibraryExtension の基底クラスが CommonExtension に変更されているため、実装を共通化できるようになっています。 // 通常の build.gradle.kts の android ブロックに相当 internal fun Project.androidExt(configure: BaseExtension.() -> Unit) { (this as ExtensionAware).extensions.configure("android", configure) } // Android Project の build.gradle.kts のスコープ internal fun Project.commonExt(configure: CommonExtension<*, *, *, *, *, *>.() -> Unit) { val plugin = if (isApplicationProject()) BaseAppModuleExtension::class.java else LibraryExtension::class.java (this as ExtensionAware).extensions.configure(plugin, configure) } // この Project が Application Project であるか判定 internal fun Project.isApplicationProject(): Boolean { return project.extensions.findByType(BaseAppModuleExtension::class.java) != null } // この Project が Library Project であるか判定 internal fun Project.isLibraryProject(): Boolean { return project.extensions.findByType(LibraryExtension::class.java) != null } Plugin の実装 global-android では最終的に以下のような Plugin 構成になっています。 Convention Plugin cookpad.convention.android.feature Pr
こんにちは、2024年4月から、新卒でレシピ事業部プロダクト開発グループに所属している、張頌です。私が新卒入社して最初に取り組むこととなったのが、このブログで以前ご紹介したOne Experienceプロジェクトです。 このプロジェクトには、データ移行や多言語対応など、私が経験してこなかったタスクがたくさんありました。私が所属しているチームではスクラム開発によってプロジェクトを進めております。初めてこの体制で大きなプロジェクトに参加することは、大変でしたが面白い体験でした。私はこの中で、データ移行のレシピIDリンクの移行を担当いたしました。 私が最初にこのタスクを携わっていた時に、色々な理由でミスがありました。本稿はこのタスクの新人なりの振り返りをし、次にもし大きなプロジェクトに携わる時に、どう対処すれば問題を回避できるかという知見をまとめようと思います。 レシピIDリンクの移行について 日本版ではレシピに、決まったフォーマットでレシピのIDを書くと、レシピを表示する時にそのレシピIDを持つレシピページに飛ぶリンクが付けられました。以下の画像のように、例えばレシピの手順で「レシピID:12345のソース」と記法で書くと、「レシピID:12345」の部分がリンクになります。 事前調査によると、このようなレシピIDは日本版のレシピにおいて紹介文、材料、手順、コツ・ポイントなどのところにありました。 ところで今回、レシピのデータは日本版のデータベースからグローバル版のデータベースに移行しています。このときレシピのIDが変わるので、何もしないままだとリンク先の整合性が崩れてしまいます。このためレシピIDリンクについても修正が必要ですが、グローバル版のレシピのIDは移行してみないと決まりません。故に、先にレシピのデータを移行し、そのあとレシピIDリンクを置換することにしました。今回のタスクはリンク先となるレシピのIDを、グローバル版のIDに置換することです。 レシピIDリンク移行の振り返り レシピIDリンク移行に取り組み始めた頃、私はまだ慣れていないことや知らないことが多く、あまり細かい調査はできていませんでした。その結果としていくつかの不具合を生んでしまいました。幸いOne Experienceのリリースの前に起こった不具合だったのでユーザーへの影響はなかったのですが、ここではそれらを振り返りたいと思います。 状態遷移の考慮漏れ 最初に取り組んだのは、日本版からグローバル版へ継続的にデータ移行している部分での置換です。データ移行を行っている部分のキャッチアップが難しかったので、コードを見ながら吸収しようと思いました。実際キャッチアップはすぐに終わり、タスクを始めた次の日にはpull requestを出しました。不安なところも色々ありましたが、コードを読みながら問題ないかを確認し、Approveをもらって、Deployしました。 そこで問題が発生しました。日本版でレシピを編集しても、グローバル版でレシピIDが変わらなかったんです。そこでデータ移行のテストファイルから、レシピ編集のシチュエーションをテストしている箇所を見つけて、シミュレートして試しました。Debugしてコードを追っていたら、レシピ編集の際には自分が実装を追加したところとは別の箇所のコードが動いていると気付きました。その後、丁寧にレシピ移行全体の状態遷移を考え、あらゆる状況を考慮して、テストでシミュレートしました。全遷移パターンで対応済みだと確信して、実装完了としました。 振り返ってみると、最初にコードをみた時に、レシピ編集についての実装があることには気付いていたことを思い出しました。このとき自分が実装したところだけで足りているのか不安で、周りの同僚には相談していましたが、実装に詳しい先輩には相談していませんでした。また、テストされている条件を網羅的に試して、できるだけ再現して漏れがないか確認した方が良かったと思います。 不可解なクエリ結果 データ移行の実装では、継続的なデータ移行とは別に、まとまったデータを一括でバッチ処理することでデータ移行を行う実装も存在しました。ここについてもレシピIDリンク移行を行う必要があったのですが、罠がありすぎて、難しかったです。いくつかご紹介します。 バッチ処理によって移行されてきたレシピに対して、後からレシピIDリンクの書き換えを行うことを考えます。どのレシピについて書き換えるべきか判断するための方法として、レシピの最終更新時刻を日本版とグローバル版で比較することで決めることとしました。ここで日本版の最終更新時刻はQueuery(きゅうり)という内製ツールでSQLクエリを実行して得ています。 以前にも似たような実装をした経験があったので、自信満々でpull requestを出して、早速Approveをもらって実行しました。検証環境で確かめて、何個か例を見ると書き変わったことを確認しました。 そこで、また問題が発生しました。データを調査したら、書き換わってないレシピIDが存在していると気付いたのです。そこでまた調査を始めまして、コードを読んで、色々バグが出そうなところにバグの仮説を立ててました。それでも結局見つからなかったので、ちょっと焦ったところもありまして、先輩とペアプロを始めました。 先輩と一緒に調査をしまして、そもそもQueueryから返ってくるレシピのリストに書き換わる対象レシピが入ってないのかもしれないと考え始めました。しかし、手元で同じSQLクエリを実行した結果には対象レシピが入っていたので、結果が違うことはありえないなと最初は思っていました。ところが、実装の方の中間変数をstep by stepに見たら、確かに対象レシピが入ってないことがありました。何故そうなっているのか全く分からず、同僚と共に調査を続けました。最終的に、Queueryの場合と手元の場合でSQLクエリの中でのエスケープの挙動が異なっているのが原因と分かりました。 ダブルチェックの重要さ レシピIDリンクのバッチ処理を実際に動かす前に、継続的な移行のときの失敗を思い出し、全体的なデータ検証をした方がいいと思いました。そこでレシピIDに関連するバグが出そうな仮説を立て、検証することにしました。そういえば私はアイデアをたくさん出すことが得意なので、既存のデータの不具合を結構見つけました。実行する前に見つかって良かったと思いました。 その後もいくつかの問題を切り抜けながら、レシピIDリンクを移行することができました。最終的に無事に移行できて良かったです。最初にミスがあり焦ったけど、難しい仕様があり、罠みたいなバグが難しいのがしょうがなく、失敗しても焦らず、相談してチャレンジしていくことが大事だと非常にわかりました。これもだんだん出来るようになったポイントかなと思いました。 次に活かすポイント 今回の振り返りは、おそらく以下の3つのパターンに分けられると思います。 ポイント1:未知の存在を認識し、詳しい人や前例の知恵を吸収する アクションをとる前に、何を追加で確認しないとダメかを知らないままに、今ままでの知識だけで対応して、ミスる場合です。新卒の時に、特にOne Experienceのような大きいプロジェクトの中で、全面的に細かく教えてもらえる人は少ないので、結局個人判断の時が多いです。ある程度のミスは起こるものであり、教訓にすべきだと思いますが、そのような状況でもできることはあると思います。 たとえば知らないタスクをもらったとき、課題の理解が足りなく解像度が低いままでタスクを進めないことです。自信はいいことだと思いますが、新卒として理解したのは、完全理解との距離があることを認識すべきでした。しかもレビュワーも知らないことがあるので、その時、知らないことがあるかを確認するためのリサーチや、気軽に他の人に聞いていくことで、改善するだろうと思います。 ポイント2:裁量範囲を模索し、適切に先輩とシンクする アクションをとるとき、大量な意思決定があるけど、個人の裁量でできないことを決めちゃった場合です。状態遷移の考慮漏れを起こしたときのように、過信によって問題が起こりえます。 これについてはアクションをとる時に、先輩との報告と相談が必要です。それだけではなく、先輩から助言をもらった時にも、課題と話を完全に吸収してから、議論していくことが大事です。要するに、アクションをとる前に先輩の言っていることを理解してから、自分が何をするつもりか報告し、目標と認識がシンクして初めて、アクションをとるべきだと思います。 ポイント3:未然に防ぐ、考慮漏れ確認をルーチン化する 実際に、新卒に限らず、しょうがなく、人間はミスすることが必ずある生き物なので、そのミスした経験を持って、2度目をないようにするのがいいと思います。そのために、確認ポイントを書面化したり、今回のミスのフィードバックを吸収したりできます。特に重要そうなものを他の人と共有しても良いでしょう。 そして、次に似たようなことがあったら、網羅的な確認をきちんと怠けず行うべきです。自分の変更の影響力を理解しつつ、ユーザーのためにも、考慮漏れ確認をルーチン化した方がいいと思います。 最後に 今回は、One Experienceプロジェクトの環境でのレシピIDリンク移行の時の自分なりの振り返りをしました。私がこのタスクを始めて、想定外の問題がたくさんでました。難しい仕様をもつタスクなので、うまくできなかったところがありました。この振り返りと経験を次に似たようなタスクをやる時に、できるだけ参考にしたいと考えています。
レシピ事業部のHaurta (@0x746572616e79 )です。グローバルサービスとの統合プロジェクト(One Experienceプロジェクト)に伴いiOSアプリケーションもグローバルと日本で別々のアプリケーションを開発していた体制から一変して、グローバルのアプリケーションベースの開発(グローバル版)へ移行を進めました。 グローバルと日本で異なるアプリケーションを開発してきたため、同じクックパッドでも細かな挙動の違いが見られます。気になる挙動がないかどうかチームで何度もウォークスルーを重ねた結果、レシピエディターやプロフィール設定画面で使われるフォトピッカーの挙動が問題として浮上しました。 フォトピッカーの改善を重要なタスクとして取り組むことにしましたが、フォトピッカーに限らずOne Experienceプロジェクトが始まってからはグローバル版のコードベースを読むところからのスタートになるため、このタスクの完了にどのくらい時間がかかるのか推測しづらい状態でした。 一般的に大きなタスクを小さく分割することは行われますが、具体的な分割方法や進行の手法については、慣れや経験だったり試行錯誤が必要です。 この記事では、フォトピッカーの改善を通じて、タスク分割とプロジェクトの進め方について幾つかの学びがあったため、どのようにタスクを分解し進めていったのかを紹介します。 フォトピッカー クックパッドにおいてフォトピッカーは非常に重要な機能の一つで、旧日本版、グローバル版ともにフォトピッカーは標準のPHPickerViewControllerを使用せず、何種類か目的と体験にあった自前のフォトピッカーを実装していました。 旧日本版では、ユーザーがレシピに写真を追加する際、フォトピッカーが最初に起動し、そこからカメラスクリーンに遷移できるボタンが配置されていました。これに対し、グローバル版では、カメラスクリーンが最初に起動し、カメラスクリーンにフォトピッカーへの遷移ボタンが設置されていました。 手順写真を載せようとした時に表示される画面 グローバル版のカメラ機能はシンプルなため、多くのユーザーが外部のカメラアプリを利用するだろうと考えられます。この状況では、画像をアップロードするたびにカメラスクリーンが起動するのは不便です。また、X(旧Twitter)やInstagramといったSNSアプリでは、フォトピッカーを先に表示し、内部にカメラアクセスの導線を設けるのが一般的です。そこで、グローバル版もフォトピッカーを最初に起動する流れに改善することになりました。 問題解決への道筋を立てる グローバル版は各機能がどのように実装されているか、画面の構造、実装を理解するところから改善を進めます。当初は画面の入れ替えだけで済むと考えていましたが、コードリーディングを進めるうちにかなりの工数が必要であることが判明しました。 グローバル版はCoordinator Patternを用いた画面遷移を採用しており、フォトピッカーにはカメラへの遷移を設置する必要がありました。この時、概算で10時間以上の工数がかかる可能性を感じつつも、どこから着手すれば良いのか具体的な見積もりができていません。 グローバル版のアーキテクチャ レシピ事業部はスクラム開発を採用しており、タスクの優先順位や範囲を決定するためにも、工数の見積もりが必要です。非常に正確である必要はありませんが、大雑把過ぎる見積もりでは不透明なため優先順位判断が難しくなります。 改善タスクには破壊的な変更が含まれており、複数スプリントにわたって開発が必要な場合モバイル特有の問題を考える必要があります。モバイルアプリはスプリントごとにリリース*1をしているため、未完成の機能が露出しないようにしなければなりません。そのため、mainブランチへ細かく変更を加えていくのか、開発ブランチを事前に用意しまとめてmainブランチに取り込む方法を採用すべきかどうかを検討しました。 mainブランチに直接細かな変更を積み重ねる方法では、コミットの粒度をコントロールしやすく、各変更の影響範囲を小さく保つことができます。また、最新のmainに追加していくため、安定性を確保しやすいです。しかし、開発中の機能や未完成の変更がリリースで露出してしまわないよう厳重な管理が求められます。 一方、開発ブランチを用いた方法では、mainブランチに影響を与えることなく機能を追加、変更することができます。ただ、開発ブランチでの作業が進む中でmainブランチにも並行して別の変更が加えられることがほとんどで、コンフリクトのリスクが高くなります。また、mainマージをするタイミングで全ての差分をチェックする必要があり、レビューコストの増加につながります。アルファリリースでは、開発ブランチを用いて変更を積み重ねていましたが、mainブランチと大きな差分が発生してしまい、コミットの統合時に多大な工数がかかることがありました。 最終的にはmainブランチへ変更を積み上げていく方法で進めることを決めました。 リリースを跨ぐ機能開発 mainブランチに変更を積み上げていくと決めましたが、ではどう破壊的な変更を閉じておくべきでしょうか? Feature Toggle グローバル版にはFeature Toggle *2 が用意されています。A/Bテストなどで広く使われており、代表的なサービスとしてFirebase Remote Config *3 が有名です。詳しくは取り上げませんが、One Experience向けの機能をグローバル版へ実装、展開する際にもFeature Toggleが利用されていました。遷移先の切り替えや、UITableViewで表示するコンテンツの出し分けなど色々なユースケースでの利用ができます。 class Coordinator: InteractorDelegate { ... func interactorWantsToInsertStepAttachments() { if appContext.featureToggle.supports(.フォトピッカーを先に起動する) { startPhotoPickerFlow() } else { startCameraFlow() } } } 今回のフォトピッカー改善タスクではCoordinatorの画面遷移処理にてFeature Toggleを利用し出し分けを行う方法を採用しました。別の方法としてFeature Toggleがオンのときにはフォトピッカーを、オフのときにはカメラスクリーンを初期表示する単一のCoordinatorを定義する方法も検討しましたが、実装の簡素化と管理のしやすさを考えると独立したCoordinatorを用意する方が適していると今回は判断しました。 デバッグメニューからFeatureToggleの切り替えができる 開発中は端末内のFeatureToggleを強制的に有効化しフォトピッカーを立ち上げ、本番環境では以前のカメラを立ち上げることで意図せず開発中の実装がユーザーの方に見えてしまう事故を防ぐことができます。 タスクと仕様を整理する Feature Toggleによって全ての問題が解決するかというと、そうではありません。Feature Toggleで分岐先を変えるのが良さそうだというのはわかりましたが、依然としてどれくらいの工数がかかるかわからないためタスクと仕様を整理する必要があります。遷移先の画面をFeature Toggleで分岐できるようになったので全く新しい画面を定義して表示することもできますが、また0からフォトピッカーやカメラスクリーンを実装するのは二度手間でその分工数がかかってしまいます。手間を省くためにも可能な限り、既存のコンポーネントや実装は使い回しをしたいです。 (左)改善前のエラー表示、(右)期待するエラー画面 ただ、既存のフォトピッカーは使い回すには少々難しい実装になっていました。たとえばデバイス内の写真にアクセスするための権限をリクエストするPHPhotoLibrary.requestAuthorizationはカメラスクリーンを表示しているタイミングで実行していたり、権限がない時は専用のエラーコンポーネントをフォトピッカーで表示するのではなく、UIAlertControllerを利用したエラー表示を行っていました。このまま画面を入れ替えてしまうと、フォトピッカーのアクセスを拒否したときカメラスクリーンへの導線がなくなってしまい体験が悪いです。エラー画面の追加、アクセス権限をリクエストするタイミング調整など、新しいCoordinatorを定義する前にフォトピッカー自体の機能追加が必要だということがわかりました。 少しずつ取り組むべきタスクが明確になり、優先順位もまた見えてきました。新しいCoordinatorを追加する前にフォトピッカーの機能追加を進める必要がありますが、フォトピッカーにカメラセルを追加する、エラー画面を追加するといった変更はそれぞれ独立したタスクとして進めることができます。また、新しいCoordinatorを追加するのもFeatureToggleを利用することでユーザーの目に触れることなく開発を進められることがわかりました。 ここまで調査時間含めて3h程度で整理、設計をし、やっとある程度正確に工数を見積もることができました。これで他Epicイシューとの優先順位を決めタスクとして進めることができるようになります。 まとめ フォトピッカー改善タスクを例にタスクの分解とリリースを跨いだ機能開発の事例についてご紹介しました。特に、大きなタスクを細かく分解するためにモバイル特有のリリース制約を考慮し、対応が必要な細かいタスクをリストアップする進め方は他のタスクにも応用できると思います。 また、今回は遷移先のコントロールにFeature Toggleを活用し、比較的綺麗な設計を実現しましたが、Feature Toggleの運用には注意点も存在します。実際に、Feature Toggleを使ったアプローチがうまくいかなかったケースもいくつか経験しています。機会があれば、Feature Toggle単体のお話もできればと思います。 この記事で紹介した方法や考え方が、皆さんの今後のアプリケーション開発において何かしらの参考になれば幸いです。 *1:レシピ事業部では1スプリントを一週間で回しているため週次リリースを行っています *2:一般的にはFeature Flagとも呼ばれている(https://martinfowler.com/articles/feature-toggles.html *3:https://firebase.google.com/products/remote-config?hl=ja
こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。 デスクトップ版で表示されるサイドバー ちょっと複雑なサイドバー One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。 このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。 コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む フォルダが多い場合でも全てのフォルダを読み込める レシピの保存やフォルダの作成などの操作時に表示を同期する これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。 Turbo Frames Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。 今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。 Turbo Framesの範囲 多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。 なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。 ページ遷移に伴うリセット Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。 そこでdata-turbo-permanent属性というものを使用しました*2。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。 <div data-turbo-permanent>sidebar</div> これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。 レスポンス Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレートこそ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。 <html> <head></head> <body> <div>破棄される要素</div> <turbo-frame id="dom_id">埋め込みたい要素</turbo-frame> </body> </html> 多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。 Turbo Streams Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。 レスポンス Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。 Turbo Streamsによって更新される要素 この時に返されるレスポンスはおおよそ以下のようになります。 <turbo-stream action="replace" target="dom_id">保存ボタンのHTML</turbo-stream> <turbo-stream action="replace" target="dom_id">ドロップダウンのHTML</turbo-stream> <turbo-stream action="replace" target="dom_id">「きろく」の「すべて」のHTML</turbo-stream> <turbo-stream action="replace" target="dom_id">「きろく」の「保存済み」のHTML</turbo-stream> <turbo-stream action="prepend" target="dom_id">「きろく」の「新規フォルダ」のHTML</turbo-stream> <turbo-stream action="replace" target="dom_id">通知のHTML</turbo-stream> このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。 リダイレクト サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatをHTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。 例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォル
こんにちは、「ウィリアム」です。クックパッドのAndroidエンジニアです。 私の日本語はまだ上手ではないので、これから英語で書きます! Self-introduction (自己紹介) Hi, my name is William, I'm an Android Engineer from the Cookpad's recipe team. I was originally in the global recipe team in Bristol, Uk, but I'm rehired to join the recipe team in Japan instead last year 2023. It is my first time ever in my life to write a techblog, please forgive me for the messy and unorganised structures, よろしくお願いします! Statement If you have been reading our techblog recently, you must be aware of what is currently going on with Cookpad! If you haven't, then I'll summarise it in a paragraph. In the past, Cookpad recipe app operated separately in Japan, and rest of the world, Global. But now, we’ve merged into one, and we call it 'One experience.' It was a long, challenging project, but also an exciting one! Looking back, what followed was the massive backlog of tasks created by the merger. There were so many things we wanted to accomplish, but we were often unable to due to time constraints, shifting priorities, or the limitations of the existing legacy architecture. One day, if I still remember what it was all about, I’ll try to write about them. For now, I'll just write about something we face on a daily basis instead! Recent Challenges We Faced in Android Development Have you ever received a UI/UX requirement that isn't natively supported out of the box? One such requirement we received recently is: Hide the ingredients section if there's not enough vertical space in the recipe card to prevent the cards in the list from expanding due to a long title or smaller screen sizes, which could result in an inconsistent UI. Recipe Card Above is an existing Compose component that we have currently in Your Collection tab screen (also known as きろく tab screen when you switch to JP region in the app). We have a different version of this component depending on the recipe type but ultimately they all have the same content: Recipe card components Recipe title Recipe ingredient list Recipe author information Recipe actionable buttons Recipe image with「公開済み」label, this label appears when it is a published recipe belonging to the user. The Problem When there's not enough space As you can see, the ingredient list section overlaps with the author information section when the recipe title spans two lines. This happens for a few reasons. One of them is that the recipe image needs to be in a 3:4 aspect ratio; another is that the actionable buttons need to be positioned at the bottom of the card; and, additionally, the recipe height is now set to 160.dp. So, there’s a Modifier.weight(1f) and a Modifier.height(160.dp) applied somewhere... This will be more likely to happen when the user enlarges their device's default font size or screen size, which will cause the ingredient list (2) to overlap with other components if there isn’t enough space for it. How to fix it Now we need to make the ingredient list (2) disappear if there isn’t enough space for it, let's dissect the component! Normal recipe card component From the figure above, we identify the three components of the recipe card: the top, bottom, and optional center (ignoring the recipe image). Logically, after the top and bottom are drawn, we need to calculate the remaining vertical space and then determine the required height to display the ingredient list. After researching it for a while, it was actually easier than I originally thought. It's quite straight forward after going through Android documentation here developer.android.com It's time for the classic hello world testing I decided to try using the Layout Compose component to solve the problem above. Below is the composable preview of hello world test I used to experiment with the composable. Layout( modifier = modifier, content = { Text("hello world") }, ) { measurables, constraints -> val placables = measurables.map { it.measure(constraints) } val text = placables[0] layout(width = constraints.maxWidth, height = constraints.maxHeight) { text.placeRelative(0, 0) } } val placables is a list of components that need to be placed in the layout, determined by what we pass into the content parameter above. Another example will be: Layout( modifier = modifier, content = { Text("hello world") Text("goodbye world") }, ) { measurables, constraints -> val placables = measurables.map { it.measure(constraints) } val helloworld = placables[0] val goodbyeworld = placables[1] layout(width = constraints.maxWidth, height = constraints.maxHeight) { helloworld.placeRelative(0, 0) goodbyeworld.placeRelative(0, helloworld.height) } } Now, val placables is a list containing the Text("hello world") node and the Text("goodbye world") node. When using .placeRelative(x, y) to position the node in the layout, the coordinates are relative to the current layout's (0, 0) point, which starts at the top-start corner on LTR devices and the top-end corner on RTL devices. Alternatively, you can use .place(x, y) to position the node, but note that .place(x, y) ignores the RTL context, so it will always position the node relative to the top-left corner, regardless of whether the configuration is RTL or not. Implementation So, after familiarising ourselves with Layout, we can now start implementing the custom composable. Since we identified that there are three parts in the composable, we call them top, center, and bottom: Layout( modifier = modifier, content = { Column { top() // The top part will consist of the recipe title. } Column { center() The center will contain the ingredient list. } Column { bottom() // The bottom part will include the author information and the actionable buttons. } }, ) { measurables, constraints -> { ... } I wrapped them in Column as it's a column design and I can define the custom view composable parameter as ColumnScope.() -> Unit. The next step is to place them in the layout Layout( modifier = modifier, content = {...}, ) { measurables, constraints -> { val placables = measurables.map { it.measure(constraints) } val topPlacable = placables[0] val centerPlacable = placables[1] val bottomPlacable = placables[2] layout(width
こんにちは。レシピ事業部でAndroidアプリ開発をしているこやまカニ大好きです。 好きなイジンカードは行基と近松門左衛門、最近気になるカードは松尾芭蕉です。 このブログの日本とグローバルのクックパッドを統合しましたという記事で、日本とグローバルのクックパッドサービスの統合が行われたこと、プロジェクトの名称が One Experience であったことについて説明がありました。 もちろん One Experience プロジェクトについては Web だけでなくモバイルアプリについても行われており、現在日本向けにリリースされているクックパッドアプリは、クックパッドがサービスを展開しているすべての地域での表示に対応した One Experience版のアプリになっています。 この記事では、モバイルアプリの One Experience について、どのような作業が行われたのか大まかに概要を説明したいと思います。 概要 前提として、クックパッドでは以前から JPアプリと Globalアプリという2つのアプリがリリースされていました。 この2つのアプリは完全に別のアプリとして開発されていて、コードの共有率はほぼ 0% 、認証やAPIなどバックエンドの構成もまったく異なるものでした。 今回行ったモバイルアプリの One Experience とは、一言でいうと Globalアプリのコードで JPアプリを上書きしてアップデートする作業になります。 GlobalアプリとJPアプリはどちらも既存ユーザーがたくさんいるため、現段階では2つのアプリを1つに統合することは行わずに、1つのコード、リポジトリから2つのアプリをリリースしていくことにしています。 ひとつのリポジトリから複数のアプリを配信するというのは Kindle ストア版アプリの配信やAndroidTVアプリの配信などでよくある構成ですが、それによって別のアプリを完全に上書きするというのはかなり珍しいと思います。 こやまカニ大好きのAndroidアプリ開発歴はそこそこ長いのですが、この作業を行ったのは初めてでした。 この記事では、アプリを別のコードベースで上書きする際にどういった考慮が必要だったかを大まかに説明していこうと思います。 One Experience によるモバイルアプリの機能面の変更や細かい技術上の工夫などは後続の別記事で説明されていく予定なので、この記事ではJPアプリの上書きに必要だった作業の概要について説明していきます。 また、この記事では主に Android の用語で説明していきますが、 iOS アプリについてもだいたい同じような雰囲気だと思ってください。 モバイルアプリ固有の特性 詳しい作業内容に入る前に、モバイルアプリのリリースに関する特性について説明したいと思います。 すでにモバイルアプリの開発者はよく知っていることですが、この特性が One Experience のリリースを複雑なものにしているため、あらためて説明します。 ロールバックが難しい(実質できない) モバイルアプリは、常に以前のものよりも大きい version code を持つアプリでしか上書きできません。 この特性により、一度 One Experience版で上書きされたアプリをJP版にロールバックするためには、JP版のバージョンを One Experience版よりも大きい値に変更した上で再度上書きする必要があります。 この方法でロールバックを行うと2つのリポジトリ間でバージョンを細かく管理する必要があるため、リリースフローがとても複雑になります。 さらに、JP版へのロールバックを行った場合、JP版 -> One Experience版 とアップデートしたユーザーだけでなく、 One Experience版を新規にインストールしたユーザーもJP版で上書きされてしまいます。 JP版 -> One Experience版 へのアップデート時に認証情報や一部のローカルデータをマイグレーションすることは決めていましたが、逆方向のアップデートやJP版 -> One Experience版 を2回繰り返した場合のサポートはあまりにも大変すぎるため、リリースに関する制約として One Experience版からのロールバックは行わないと決めました。 これにより考慮事項がかなり減り、 One Experience に集中して進めていく意思表示にもなったので、この意思決定ができたことは良かったと思います。 アプリがユーザーの手に届くまでに時間がかかる(bugfix や挙動変更が瞬時に適用できない) モバイルアプリでは、アプリをサブミットしたあともリリースされるまでに審査があり、さらに公開したあともユーザーの端末にインストールされるまでは時間がかかります。 これは bugfix などのリリースでも同様で、不具合を修正してもユーザーの手元の不具合が発生していたバージョンを上書きするためには数日掛かる場合もあります。 特に One Experience リリースの初期段階では様々な不具合が予想されたため、プラットフォームの段階的な公開機能を利用し、様子を見ながら少しずつ公開率を上げていくことにしました。 One Experience では、初回のリリースから100% リリースまでおよそ3週間掛かっています。 ロールバックが難しい&更新に時間がかかるという状況で後述の認証情報のマイグレーションが失敗すると何もかもおしまいになってしまうので、初回のリリースからマイグレーションに成功したユーザーが観測されるまではかなりドキドキしていました。 JP版アプリをリリースするために行った作業 ここでは One Experience リリース時に実装が必要だった項目について簡単に列挙します。 ここにあげた項目以外もたくさんの修正が入っていますが、特に重要なものについて記述しています。 日本リージョンへの対応 Globalアプリはもともと多言語対応していたので、アプリ内に ja-JP リージョン設定を追加し、翻訳リソースを追加すれば日本語対応できる状態でした。 文字列の翻訳や画像以外でもヘルプページのURLやごく一部の実装はリージョンごとに処理を切り替えていて、 One Experience では ja-JP リージョン特有の処理もいくつか追加しています。 ja-JP リージョンだけの分岐が将来的になくなるのか、そういう仕様のままでずっといくのかはまだ決まっていない箇所もあり、今後はこういったリージョン固有の実装箇所の保守性も高めていけると良いなと考えています。 JPアプリビルド設定 の追加 Globalアプリ(Android)プロジェクトにはもともと Flavor によってビルドするアプリの ApplicationId や versionName などを切り替える機能が実装されていました。 JPアプリではこういった切り替えはすべてモジュールを切り替えることで行っていたので、 Flavor で切り替えることに少し抵抗があったのですが、元々の実装をベースにリファクタリングを加えることで Flavor ベースでの切り替えによって実装することができました。 バージョニング JP版アプリは数年前からリリース年・リリース週番号ベースで自動的にバージョンを採番していました。 対してGlobal 版は手動のセマンティックバージョニングで、 major、minor を繰り上げるタイミングについてはあまり明確になっていませんでした。 JP アプリのほうが version code が大きかったこと、週次リリースというフローはOne Experience後も変わらなかったことから、JP/Global両方のアプリでリリース年・リリース週番号ベースのバージョニングに合わせることにしました。 リリースフロー もともとJP版アプリはかなり自動化された週次のリリースフローを採用していました。 Globalアプリも週次リリースでしたが、運用方法には大きな違いがありました。 一番大きな違いはコードフリーズやサブミットのタイミングが自動化されておらず、リリースマネージャーとなった人間が手動でタイミングを決めていたことです。 Global 版アプリでは機能や画面の更新に対して翻訳リソースの更新を待つ必要があり、持ち回りでリリースマネージャーになった人間の活動時間にも時差があったため、リリースマネージャーがコードフリーズのタイミングを調整できたほうが都合が良かったのです。 最初はJPのように自動化したほうが良いと考えていましたが、 Global 版をベースに開発していくうちに考えを改め、 Global版のリリースフローに合わせることにしました。 現在のリリースフローでは、コードフリーズ、サブミット、リリースなどの処理が Global と JP でほぼ完全に同期して行われています。 Firebase プロジェクトの切り替え JP と Global はそれぞれ別々に開発・運用されているアプリだったため、 Firebase プロジェクトも完全に分離されている状態でした。 最初はビルドするアプリによって Firebase プロジェクトを切り替える方針も検討したのですが、以下の理由により、 Firebase プロジェクトを Global が使っているものに統一することにしました 社内の push 通知送信サービスがマルチproject をサポートできるように改修が必要 コードベースが完全に切り替わるため、Crashlytics に送られるクラッシュ情報の傾向が大きく変わる One Experience 以降のクラッシュ情報だけ管理できれば良い アプリ内の Firebase Analytics ログが完全に切り替わるため、 Firebase Analytics のログ内容が大きく変わる One Experience前後の比較がしたいので連続性はあったほうが良いが、もともと重要なログは FirebaseAnalytics ではなく自前のログで比較する文化だったので、致命的ではない Firebase Dynamic Links を複数のプロジェクトから生成したくない アプリのマイグレーションについて JP版アプリから One Experience版アプリにアップデートしたとき、きちんとマイグレーション処理を実装していなければアプリからログアウトし、すべてのローカルデータにアクセスできなくなります。 One Experience版アプリでは、JP版アプリを利用していたユーザーがそのまま利用できるように、認証情報や一部のローカル保存情報にアクセスできるように特別な実装を入れています。 特に認証情報のマイグレーションに関してはこれだけのために Globalアプリに AccountManager の実装を入れていたり色々な仕組みが入っているのですが、説明するとこれだけで一つの記事になってしまうのでまた別の機会に書くことにします。 まとめ One Experience版アプリをリリースするための取り組みについて説明しました。 機能面、コード面ではこの記事で紹介した以外でも様々な変更がありますが、ここではプロジェクト全体に関わるような大まかなものに絞って紹介させていただきました。 これからもモバイルアプリの One Experience に関する記事は公開予定なので、今後の更新にもご期待ください。
こんにちは。SRE の小川 (@coord_e) です。先日の投稿にあった通り、クックパッドはレシピサービスをグローバル版に統合しました。サービスの統合に伴って、開発や運用のインフラもグローバルチームで利用されているものを使うことになりました。 運用インフラの中でも特に大きな違いとして、日本とグローバル版ではコンテナオーケストレーションの仕組みが異なっています。日本では Amazon Elastic Container Service (ECS) を使ってコンテナを実行していますが、グローバル版では Amazon Elastic Kubernetes Service (EKS) の上でコンテナを実行しています。 また開発面ではデプロイフローに大きな違いがあります。日本では、アプリケーションの新しいリビジョンのデプロイは ChatOps によって行なっていました。main ブランチに PR がマージされ、CI パイプラインが新しいリビジョンのコンテナイメージをビルドした後、開発者が Slack チャンネルでコマンドを実行(発言)することで、そのリビジョンのデプロイを行います。日本版での開発フローは下の記事に詳しく記載されています。 一方、グローバル版では、アプリケーションリポジトリでの PR マージ後に自動でデプロイまでが行われます。後に詳しく説明しますが、これは Flux という OSS を活用して実現されています。全体的に GitOps の流れに乗っており、アプリケーションの Git リポジトリへの push を起点としてのちのデプロイの全ての行程が自動で進行するようになっています。 fluxcd.io グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません なお、コンテナオーケストレーションやデプロイの方法含め、One Experience 後にグローバルと日本の間でインフラをどうしていくかは議論の最中です。本稿で紹介する手法は、基本的に短期的に運用上の問題点を解決するためにフォーカスした選択をしています。 リバートによるロールバックとその課題 さて、新しいリビジョンをデプロイした後に、それが原因となった問題が発覚した場合、その変更を速やかに取り消す必要があります(ロールバック)。これまで、グローバル版ではロールバックはコミットのリバートによって行われていました。変更を取り消すコミットを新たに積み、それをデプロイするという流れです。これは GitOps の流れから逸れることなく、通常のデプロイワークフローに乗ってオペレーションができるという点で優れています。しかし、グローバル版での開発を進めるにつれて、リバートによるロールバックの課題がいくつかわかってきました。 通常のデプロイフローに乗っているため、変更が巻き戻るまで時間がかかります。特に CI 上でのテスト実行のオーバーヘッドが無視できません。 一部 Flaky なテストも存在しており、それをリトライしているとテストが全て通るまで長い時間がかかってしまう場合があります。 さらにデプロイは直列に行われ、直前に他のデプロイが起きているとそのデプロイが終わるまで待つ必要があります。 通常のデプロイフローに乗っているため、変更に承認が必要です。私たちは GitHub 上で main ブランチへのマージに一名以上の Approve を必須としていますが、障害対応においてはこのオーバーヘッドもあります。 もちろん障害発生時にはこれをバイパスしてマージできるように特権を用意しておく方法もあり得ますが、障害発生時にのみ特権を使うという判断や制御は難しくなることが予想されます。 基本的に、障害発生時には、ユーザーへの影響を最小限にとどめるために即座にロールバックを完了したいです。しかし、リバートによってロールバックを行うとどうしても時間がかかりすぎてしまいます。もちろんデプロイフローを高速化するのは有効ですが、テストの実行やイメージのビルドは避けられないため、例えば原因の特定から1分以内にロールバックを開始するといったことは難しいでしょう。 これまでの日本版の開発では問題発生時に1分もかからずロールバックを開始できていました。今回 One Experience で日本チームがグローバル版の開発に合流しましたが、ロールバック手順が整備されておらず、障害が発生した際にすぐに回復できずに 40 分ほどサービスをダウンさせてしまう出来事がありました。これをきっかけに、日本版での開発と同様にグローバル版にも即時にロールバックを実行できる仕組みを整備することにしました。 私たちが利用している Flux では、マニフェストを同期しているリポジトリでのリバートによってロールバックを実現するのが筋のようです1。しかし、後述するように私たちはマニフェストリポジトリの自動更新を行っているためそれを止める必要があったり、また Helm Controller のデプロイ待ちの問題があったりと、単なるマニフェストリポジトリのリバートでは即時ロールバックの要件を満たすことができませんでした。そこで、私たちは通常のデプロイフローからは外れた、即時ロールバックのための独自のオペレーションを構築することにしました。 GitOps から外れる: どこで流れを止めるか 通常のデプロイフローから外れるということは、自動デプロイの流れをある点で停止することを意味します。まず、現在のグローバル版のデプロイの詳しい流れを下の図に示します。なお、グローバル版では Deployment を含むアプリケーションのリソースは Helm チャートとしてパッケージ化されており、Helm リリースの Values からデプロイするイメージのタグを注入しています。 グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません アプリケーションのリポジトリで新しいコミットが push されると、CI がコンテナイメージをビルドし ECR リポジトリにイメージを push します。 これを Flux の Image Update Automation 機能が自動的に検知し、アプリケーションに対応する HelmRelease の .spec.values に記述されたイメージのタグを更新するコミットを作成してマニフェストリポジトリへ push します。HelmRelease というのは Flux が Helm のリリースを管理するために用いるカスタムリソースで、.spec.values に Helm リリースの Values を記述しておくと Helm Controller が自動で helm install や helm upgrade を実行します。 マニフェストリポジトリの内容は Kustomize で構成されており、Flux の Kustomize Controller がマニフェストリポジトリの内容を自動的にクラスタへ反映するように設定されています。これにより先ほど push された .spec.values の変更がクラスタ内の HelmRelease オブジェクトに反映されます。 HelmRelease オブジェクトが変更されると、Flux の Helm Controller がそれを検知し、自動的に helm upgrade を実行します。これによって最終的に新しいイメージのタグが Deployment の spec まで反映され、Deployment のロールアウトが起こります。 ロールバックにおいては、アプリケーションの Deployment の spec に記述されているイメージのタグを問題発生以前のものに書き換えることが目標となります。単に直接 Deployment を書き換えるのは、その後リポジトリに push があると Flux がそれを上書きしてしまうため適切ではありません。では、どのようにしてこれを達成すると良いでしょうか。 方法1. Helm より上流でイメージのタグを戻す まず考えられるのが、HelmRelease までの部分でデプロイの流れをせき止め、ロールバック先のイメージのタグを強制的に使わせるという方法です。上の図でいうと、次のどちらかになるでしょう: イメージのタグを ImagePolicy で固定し、(2) を実質的に停止する (3) を停止し、HelmRelease の .spec.values にあるイメージのタグを直接書き換える どちらのやり方でも HelmRelease の .spec.values が戻り、それを検知した Flux の Helm Controller が helm upgrade を実行して正しく Deployment の spec にあるイメージのタグを変更し、ロールバックが実現できるでしょう。 Helm より上流でイメージのタグを戻す場合のロールバック。黄色が ImagePolicy で固定する方法、赤が HelmRelease の .spec.values を書き換える方法 しかし、この方法では長めの待ち時間が発生してしまう問題が考えられます。Helm Controller はデフォルトで helm upgrade 実行後に各種リソースが ready になるまで待つようになっており、私たちもこの挙動を採用しています(helm upgrade --wait と同じ挙動)。 fluxcd.io そして、Helm Controller はひとつの Helm リリースに対して helm upgrade を直列に行うため、ロールバックしようとした際に進行中のデプロイがあるとそれが終わるまで、すなわち Pod が全部入れ替わって ready になるまで待つことになってしまいます。現状、これには場合によって 5 分を超える時間が必要で、一刻も早くロールバックを行いたい状況においてこれを待つのは適切ではありません。そして、このデプロイ完了待ちを中断する方法は今の所ないようです。もちろん Helm Controller を再起動すれば止まりますが、ロールバック対象の Helm リリースとは関係のない Helm リリースの制御にも影響するため、筋の良いやり方とは言えません。 これらの理由から、ロールバックの適用で Helm Controller に頼らない方法を採用する判断をしました。 方法2. 直接 Helm リリースのリビジョンを戻す Helm には Helm のリビジョン管理があります。helm upgrade のたびに Helm はクラスタ内に Helm リリースの完全なマニフェスト情報の履歴を保存しています。Helm では helm rollback コマンドでその情報を使って以前のリリースの状態を復元することができます。 helm.sh これを使う場合、上流から .spec.values が更新された時に Helm Controller がリリースの状態を上書きしてしまわないように HelmRelease の同期を停止する必要があります。上の図でいうと (4) を停止し、最後の Deployment を直接(Helm の実装を使って)書き換えるアプローチになります。この方法なら既に Helm Controller による helm upgrade が進行中でも即座にロールバックを開始できます2。Deployment はロールアウトの途中であっても変更があれば即座に新しいロールアウトを開始するため、helm rollback で Deployment の spec が書き換わり次第すぐにイメージが戻り始めます。 <a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#rollove
はじめに こんにちは。クックパッド SRE の @mozamimy です。先日この開発者ブログで One Experience プロジェクトについての紹介がありました。 このプロジェクトにおいて、わたしは日本版からグローバル版への移行の際の全般的なパフォーマンス周りについて取り組んでいました。 パフォーマンスと一言でいっても、その中にはネットワークやアプリケーションレイヤでのレイテンシ、MySQL などのミドルウェアでのレイテンシなど、様々な要因が関わってきます。それらの改善において、何よりも重要なのはまず観測することです。移行において取り組んだ様々な作業のうち、ここでは CloudWatch RUM や Calibre といったツールを用いた Web ブラウザからのアクセスのパフォーマンス観測に焦点を当てて紹介します。 プラットフォーム移行によるパフォーマンスの劣化をできる限り避けたい 先述の One Experience の紹介記事でも書かれている通り、日本版とグローバル版は完全に独立したプラットフォーム上で動作しています。すなわち日本版とグローバル版ではパフォーマンス特性もまったく異なるということです。 まず、データの統合による MySQL 関連のデータ増加によるパフォーマンス特性の変化が懸念事項の一つでした。グローバル版と日本版のデータ量を比較すると日本版の方が大きく、統合後にはデータ量が大幅に増加するからです。データ移行の作業の一部である継続的なデータ移行については、先日鈴木による記事が公開されています。 その他にもユーザにとって確実に悪影響があると見込まれていたのが、システムが動作しているリージョンの違いによるレイテンシの増加です。両方とも AWS を利用していますが、日本版は ap-northeast-1 (東京) に、グローバル版は us-east-1 (バージニア北部) にデプロイされています1。 One Experience ではグローバル版に日本版を統合するという方針になった以上、そのままのアーキテクチャだと日本からのトラフィックは太平洋と北米を横断することになるため、ネットワーク起因のレイテンシの増加は避けられません。この差を埋めるために、 グローバル版の Rails アプリケーションの実装を改善してパフォーマンスを上げる MySQL クエリの改善や適切なキャッシュの利用などでパフォーマンスを上げる フロントエンドの実装を改善して見かけのパフォーマンスを上げる マルチリージョンデプロイ: 日本ユーザから近い場所にサーバを置く などなど、移行について動き始めた段階でいろいろと改善する余地・手段があることは漠然と分かっていました。ただし取り組みによっては実装・運用コストが大きいため、まずは現状をしっかりと把握して何から取り組んでいくとよいかを考える必要がありました。また、パフォーマンス差を埋めるための判断材料としての計測ももちろんですが、それはそうとして実際に移行を進めていくにあたって日本版とグローバル版のパフォーマンス差の日々の変化をトラックする必要もありました。 ユーザから見た総合的なパフォーマンスを測定するための手法として、RUM (Real User Monitoring) および synthetic monitoring があります。One Experience では RUM として Amazon CloudWatch RUM (以下 CloudWatch RUM と表記) を採用し、synthetic monitoring として Calibre を採用しました。CloudWatch RUM の利用にあたっていろいろ工夫した点があるので、本稿では特に CloudWatch RUM について深掘りしていきます。 CloudWatch RUM の導入と活用するための工夫 RUM について RUM (Real User Monitoring) はユーザから見た総合的なパフォーマンスを計測する上でメインとなるもので、クライアントサイドで計測したパフォーマンス指標となる数値やエラーなどを収集し、分析・可視化するためのツールです。 One Experience においては RUM で収集した Core Web Vitals のうち、LCP (Largest Contentful Paint) を KPI として利用することにしました。詳細な説明はリンク先に譲りますが、LCP とはウェブページ上のもっとも大きな面積を占める画像または HTML エレメントが描画されるまでの時間を指します。 RUM を実現するためのサービスは Datadog などをはじめいろいろと選択肢はありますが、以下の理由から CloudWatch RUM を選択しました。 競合のソリューションと比べて比較的安価 既に AWS を利用しており、社内での手続きのもろもろを省けるため導入のハードルが低い 生のログを CloudWatch Logs に出せるので高度な分析がしやすく、AWS の別サービスとの連携が可能 CloudWatch RUM の導入 CloudWatch RUM の Rails アプリケーションへの組み込みや AWS 側でのリソースの準備は特に難しいこともなく、ドキュメントにしたがって以下のような手順を踏めば実際にイベントデータが送信される状態になりました。 Cognito identity pool を用意 RUM の app monitor を作成 2 で作成した app monitor にログを送信できる権限を持つ IAM role を作成 identity pool に IAM role を紐付け Rails アプリケーションのフロントエンドに RUM 用のコードスニペットを追加 対象となる Rails アプリケーションは日本版とグローバル版の両方になりますが、app monitor はあえて一つにしました。日本版・グローバル版ともに同一の web origin である https://cookpad.com を利用していたことと、のちほど紹介する分析においてイベントデータが一つの CloudWatch Logs グループにまとまっているほうが都合がよいからです。 導入自体は簡単ですが、詳細な分析を行うためにいくつかの設定を行う必要があったので、以下のセクションでそれらについて説明します。 CloudWatch Logs へのエクスポート イベントデータの詳細な分析をしたい場合、app monitor の作成時に CloudWatch Logs に出力する設定を入れると便利です。またこの際、ロググループに expire を設定しておくとよいでしょう。CloudWatch Logs のコストの多くをログの取り込みが占めるとはいえ、ストレージにかかるコストも無視できません。 要件に応じた attribute の追加 Rails アプリケーションの JavaScript コードで RUM クライアントを初期化する際、任意の attribute を追加することができます。今回は、グローバル版か日本版を区別するための railsApp、Rails のコントローラを区別するための railsController、Rails のアクションを区別するための railsAction をそれぞれ設定することで分析しやすくしました。これらを設定しておくことで CloudWatch RUM のコンソールでイベントを絞り込んで分析できます。たとえば、デフォルトで付与される countryCode などの attribute を組み合わせて以下のようにフィルタすると、「グローバル版のレシピ詳細ページに日本からスマートフォンでアクセスしたイベント」に絞り込むことができます。 サンプリングレートの設定 RUM クライアントの設定の際には適切なサンプリングレートを定める必要があります。100% に近付ければ近付けるほどよりよい精度でデータが得られますが当然コストは増加します。CloudWatch RUM はイベント数による課金なのでトラフィック量からおおよその料金を予測しやすいです。うっかりクラウド破産しないよう、ある程度見積もった上で少ない割合から始めて、AWS Cost Explorer を眺めながら要件や制約に応じて適切に調整するとよいでしょう。 収集したデータを AWS コンソールから分析・トラックする イベントデータの収集を開始すれば、以下のスクリーンショットのように CloudWatch RUM のコンソールからフィルタや期間で掘り下げていく形でパフォーマンスについて分析することができます。エラーや JavaScript による HTTP(S) 通信の実行、セッションごとにイベントを確認するなど、インクリメンタルに分析したり、パフォーマンスについてざっと眺めたりできます。 75 パーセンタイルで LCP を確認したい いっぽうで少し融通の効かないところもあり、特に LCP の 75 パーセンタイルの値がコンソール上で確認できないことは問題でした。 Core Web Vitals から引用した以下の図のように、LCP の良し悪しを判断するしきい値として一般的に 75 パーセンタイルを用いるとよいとされています。平均値ではデータに偏りがある場合に指標として適切でなくなってしまう場合があるからです。もちろん要件によってこの条件をカスタマイズできますが、One Experience においては基本の 75 パーセンタイルで 2.5 秒以内を基準とすることに決めていたので、平均値しか見られないことは問題でした。 この点については AWS に既に要望をあげていますが、これを自力で解決できないか考えてみます。たくさんあるビルディングブロックの組み合わせでユーザごとの要求に柔軟に対応できるのが AWS の強みです。 CloudWatch RUM で収集したデータを利用しやすいように集計する さて、ここまでの流れでコンソールに頼らずに RUM のデータを独自に集計して分析したいというモチベーションについて説明しました。このセクションでは、それを実際にどのように実現するかを考えてみます。 CloudWatch Logs Insights を利用する はじめに思いつくのが CloudWatch Logs Insights です。RUM のデータは JSON 文字列として CloudWatch Logs にエクスポートされているので自然に Insights が利用できます。Grafana もデータソースとして CloudWatch Logs Insights をサポートしているので、これを利用すれば Grafana でダッシュボードが作れそうです。 SQL に親しんでいる身として構文にちょっとクセを感じますが、たとえば以下のようなクエリで、日本からモバイルデバイスでレシピページにアクセスしたときの LCP を p75 で集計して求めることができます。漉し器となる filter をパイプでつないで上からレコードを流していき、最後に stats で集計するというイメージですね。 filter event_type = "com.amazon.rum.largest_contentful_paint_event" | filter metadata.railsApp = "Global" | filter metadata.railsController = "recipes" | filter metadata.railsAction = "show" | filter metadata.countryCode = "JP" | filter (metadata.deviceType = "mobile" or metadata.deviceType = "tablet") | stats pct(event_details.value, 75) これでめでたしめでたし... とはいきません。スクリーンショットの集計結果の 7.8 GB (!) という値に注目してください。これは見たままスキャン量で、これに比例して金銭的コストとクエリ実行時間がかかります。上述の例では期間を1 日に絞って集計してこのスキャン量となっており、アドホックな分析なら大きな問題になりませんが、期間を伸ばした上で Grafana 上にたくさんペインを作って表示させると、それだけたくさんのクエリが発行されることになってしまうので実用的ではありませんでした。 Timestream を利用したサマリーテーブルの作成 このようなシチュエーションは CloudWatch Logs Insights に限らず一般的なデータ分析あるあるです。このような場合、集計を定期実行して専用のテーブルに保存しておくのが常套手段です。 ではどこに集計結果を保存するのかということが問題となりますが、ここでは Amazon Timestream for LiveAnalytics (以下 Timestream と表記します) を採用しました。Timestream は AWS のマネージドな時系列データベースです。ヘビーユースに耐えることを特長としていますが、ライトな使い方でもコストが非常に少なく済み、雑にデータを入れてクエリできる便利ストレージであることが個人的には魅力だと感じています。DynamoDB も似たような用途で使えますが、シンプルな KVS では微妙にかゆいところに手が届かないユースケースをカバーしているところが好きです。 さて、以下に Timestream および Lambda を用いた集計システムの概要を示します。 矢印はイベントデータの流れを示しており、CloudWatch RUM からエクスポートされた生のイベントデータが CloudWatch Logs に送られ、CloudWatch Logs Insights API を叩く Lambda function が Timestream table に結果を保存し、開発者がその Timestream table に Grafana を通してクエリするという形になっています。この Lambda function は EventBridge によって日次で実行されるように設定されています。 この Timestream テーブルに対して、たとえば以下のようなクエリを実行すると以下のような結果が返ってきます。SQL 風にクエリできるので脳にやさしいです。 select * from cookpad_rum.global_web_lcp_jp where time between ago(7d) and now() order by