クックパッド開発者ブログ 2024-12-12T11:00:00+09:00 cookpadtech Hatena::Blog hatenablog://blog/12921228815724287123 トレンドワード機能を新システムに移行するときに考慮したこと hatenablog://entry/6802418398310512953 2024-12-12T11:00:00+09:00 2024-12-12T11:00:01+09:00 こんにちは。レシピ事業部検索チームの薄羽 (@usulity) です。 続々と関連記事が投稿されていますが、日本とグローバルのクックパッドを統合しました。 この統合に際して、日本のクックパッドの様々な機能がグローバル版へ移植されました。今回は、移植された機能の一つである「人気のキーワード」について、移植した際にどんな課題があってどう解決したのかの一部をご紹介できればと思います。 人気のキーワード 人気のキーワードは、「クックパッドで最近よく検索されているキーワード」を集計して、ランキング形式で掲載する機能です。 日本版人気のキーワード このように、人気のキーワードは1時間おきに更新され、その時… <p>こんにちは。レシピ事業部検索チームの薄羽 (<a href="https://x.com/usulity">@usulity</a>) です。</p> <p>続々と関連記事が投稿されていますが、日本とグローバルのクックパッドを統合しました。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>この統合に際して、日本のクックパッドの様々な機能がグローバル版へ移植されました。今回は、移植された機能の一つである「人気のキーワード」について、移植した際にどんな課題があってどう解決したのかの一部をご紹介できればと思います。</p> <h2 id="人気のキーワード">人気のキーワード</h2> <p>人気のキーワードは、「クックパッドで最近よく検索されているキーワード」を集計して、ランキング形式で掲載する機能です。</p> <p><figure class="figure-image figure-image-fotolife" title="日本版人気のキーワード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/argonism/20241210/20241210153659.png" alt="&#x65E5;&#x672C;&#x7248;&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x306E;&#x4EBA;&#x6C17;&#x306E;&#x30AD;&#x30FC;&#x30EF;&#x30FC;&#x30C9;&#x30DA;&#x30FC;&#x30B8;" width="1200" height="771" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>日本版人気のキーワード</figcaption></figure></p> <p>このように、人気のキーワードは1時間おきに更新され、その時期・時間帯のトレンドを反映したようなキーワードのランキングになっています。</p> <p>トップページの検索窓の下にも上位のキーワードが表示されており、人目につきやすい機能の一つです。</p> <h2 id="グローバル版人気のキーワード">グローバル版人気のキーワード</h2> <p>グローバル版にも人気のキーワード機能があります。</p> <p>基本的な機能は日本のものと同じで、そのときのよく検索されているキーワードを表現しています。</p> <p><figure class="figure-image figure-image-fotolife" title="グローバル版人気のキーワード"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/argonism/20241210/20241210153653.png" alt="&#x30B0;&#x30ED;&#x30FC;&#x30D0;&#x30EB;&#x7248;&#x4EBA;&#x6C17;&#x306E;&#x30AD;&#x30FC;&#x30EF;&#x30FC;&#x30C9;&#x306E;&#x30B9;&#x30AF;&#x30EA;&#x30FC;&#x30F3;&#x30B7;&#x30E7;&#x30C3;&#x30C8;" width="1200" height="378" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グローバル版英語の人気のキーワード</figcaption></figure></p> <p>今回取り組んだタスクは、このグローバル版の人気のキーワードとして、日本の人気のキーワードを日本版の計算方式で表示することがゴールになります。</p> <h2 id="課題">課題</h2> <p>ということで、「グローバルにも人気のキーワードがあるから、日本語でも集計を実行するように修正すれば終わり!」としたいところなんですが、いくつか問題があります。</p> <p>そもそも、グローバル版と日本版は別物のアプリケーションであるため違いは色々ありますが、「ログがない」ことと「更新頻度の違い」は移植する上で大きな問題でした。</p> <h3 id="ログがない">ログがない</h3> <p>人気のキーワード機能を移植するにあたり、ユーザが移行してきたタイミングではもう人気のキーワードを表示しておきたいという要望がありました。</p> <p>人気のキーワードは検索ログから計算するため、人気のキーワードを表示するにはグローバル版で検索ログが溜まっている必要があります。</p> <p>しかし、日本のユーザをグローバル版の方に流すまでは、日本のユーザはグローバル版の方にはいないため検索ログはありません。</p> <p>このままでは移行してきたタイミングで人気のキーワードを表示することができないので、なんとかグローバルの方にログがない状態でも人気のキーワードを計算できるようにしておきたいです。</p> <p>また、キーワードのクオリティの面でも懸念があります。</p> <p>この次でも説明しますが、人気のキーワードの計算は、過去の長い期間に渡ったログを使うことで、そのときの人気度を計算しています。</p> <p>そのため、移行の初期段階ではログが十分に溜まっていないことで、人気のキーワードの質が不安定になってしまう恐れがあります。</p> <h3 id="更新頻度の違い">更新頻度の違い</h3> <p>日本の方は<strong>毎時</strong>の更新なのに対して、グローバルの方は<strong>日次</strong>の更新になります。</p> <p>計算頻度が日次か毎時かでは、単なる実行頻度の違い以上の差があり、人気度の計算方法が異なります。</p> <p>人気度は長期的な目線と短期的な目線の両方で見た時に、目立って検索されている語を抽出しています。</p> <p>簡略化しますが、例えばグローバル版の方では、人気度を「そのキーワードのその日の検索回数」/ 「過去に渡ったそのキーワードの1日の平均検索回数」で計算します。</p> <p>こうすることで、あるキーワードが平均よりその日多く検索されていたら、そのキーワードの人気度は高くなります。</p> <p>毎時の計算においても同じようなことをしますが、時間の区切り方が異なります。</p> <p>「その時間帯」で目立って検索されたキーワードを調べたいので、毎時の計算では人気度を、「そのキーワードが1時間の間に検索された回数」-「そのキーワードがこの時間帯で検索される平均回数」として計算します。</p> <p>このように、日次と毎時では人気度の計算方法が異なり、毎時では日次より細かい単位で平均を計算します。</p> <p>単純にグローバル版の人気のキーワードの計算を毎時に動かしても再現できないことが分かったので、グローバル版の人気のキーワード計算の仕組みを変えるか、新しくバッチジョブを用意する必要があります。</p> <h2 id="解決">解決</h2> <h3 id="日本版とグローバル版両方のログから合算する">日本版とグローバル版両方のログから合算する</h3> <p>移行先にログがない問題を解決するために、移行先の人気のキーワードは日本とグローバル両方のログから計算します。</p> <p>幸い、日本版でも人気のキーワードの計算に特別な情報は使っていなかったため、グローバル版のログで情報が足りないといったことはありませんでした。</p> <p>これにより、グローバル版に移行初期のタイミングでも、良いクオリティの人気のキーワードが計算できるようになります。</p> <p>また、ユーザの段階的な移行を考えた時にも、この方法は利点があります。</p> <p>日本版とグローバル両方からログを取得しているため、仮にユーザの50%は日本、もう50%がグローバルにいるようなケースでも、両方のプラットフォームでのユーザ行動を加味した人気のキーワードを計算できます。</p> <p>実際に、 One Experience ではユーザを日本版から少しずつグローバル版の方へ移行していきました。</p> <p>それぞれからの検索ログを同じ価値と見なして良いかは議論の余地がありますが、それを検証するのは大変なので、1つの検索ログが1回の検索を示すことだけ確認して、移行途中で日本版と大きく結果が離れていなければよしとしました。</p> <p>このように、データソースを両方のプラットフォームから取得して計算することで、移行段階に左右されずに人気のキーワードを提供することができます。</p> <h3 id="日本用のバッチジョブを用意する">日本用のバッチジョブを用意する</h3> <p>更新頻度の違いによる人気度の計算方法の違いが大きいため、今回は日本用のバッチジョブを用意することにしました。</p> <p>グローバル版の方の仕組みを日本版に寄せる方向性も考慮しましたが、更新頻度以外に以下の理由で別のバッチジョブとして作ることにしました。</p> <ol> <li>グローバル版の方の人気のキーワードのクオリティをチェックしながら開発するコストが高い</li> <li>日本とそれ以外の言語ではログの量に差があるため、そのためのパラメータ調整にコストがかかる</li> </ol> <p>グローバル版では、多数の言語の人気のキーワードを計算しています。グローバル版の人気のキーワードの仕組みを変えた時、抽出されたキーワードのクオリティへの影響をチェックしたいわけですが、言語が違うのでチェックするのが大変です。</p> <p>また、日本用にチューニングした人気度の計算方法が、他の言語でうまくいくとは限りません。日本向けに設定したパラメータが他の言語でも適切かどうかわかりせんし、また各言語向けに設定をするのもコストがかかります。</p> <p>日本とその他の言語で別々のバッチジョブと言っても、将来的にはやはりなるべく1つにまとめられるようにしておきたいので、なるべくグローバル版の仕組みには乗るように注意しました。</p> <h2 id="結果とまとめ">結果とまとめ</h2> <p>結果として、ユーザの移行が始まる前に実装が完了し、無事移行初期段階でも人気のキーワードを表示することができました。</p> <p><figure class="figure-image figure-image-fotolife" title="ja_JP_trending_keyword"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/argonism/20241210/20241210153547.png" alt="&#x30B0;&#x30ED;&#x30FC;&#x30D0;&#x30EB;&#x7248;&#x65E5;&#x672C;&#x306E;&#x4EBA;&#x6C17;&#x306E;&#x30AD;&#x30FC;&#x30EF;&#x30FC;&#x30C9;" width="1200" height="378" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グローバル版日本の人気のキーワード</figcaption></figure></p> <p>やはり、ユーザがどっちのプラットフォームに居ても人気のキーワードを計算できることは、One Experience ではとても有効な方法だったと思います。</p> <p>また、バッチジョブを分けることで他の言語への影響をあまり気にする必要がなかったことも、開発していく上では重要だったと今改めて思います。一方で、人気のキーワードの計算が日本語だけ分かれていることでメンテナンスコストが増えてしまっているので、これを他言語と統合していくということが今後の課題として残っています。</p> <p>個人的な感想としては、正しく人気のキーワードを計算できているのか最後まで不安がありましたが、結果としては日本版と同様のキーワードをグローバル版でも表示することができて良かったと思っています。</p> <p>以上、One Experience における人気のキーワード機能の移行の話でした。</p> <p>面白いと思ってくださった方は、ぜひチャンネル登録と高評価、また他にも色々な角度の One Experience 話が投稿されておりますので、ぜひそちらもご覧ください。</p> argonism One experience 検索移行の話 hatenablog://entry/6802418398310554437 2024-12-11T11:12:00+09:00 2024-12-11T11:12:00+09:00 こんにちは、レシピ事業部検索チームのオリギル(@orgil_)です。 先日、この開発者ブログで紹介されたOne experienceプロジェクトによって、クックパッドはプロダクト基盤をグローバル版のシステムに移行しました。私はこのプロジェクトにおいて検索の領域で移行の進行、開発を担当していました。このブログでは検索システムの移行について紹介します。 グローバルのシステムに寄せた理由 One experience によってクックパッドは検索基盤をグローバル側のシステムに移行しました。プロジェクト発足当初、日本とグローバルのどちらのシステムに寄せるかをいろんな観点から検討しました。日本ではSolr… <p>こんにちは、レシピ事業部検索チームのオリギル(<a href="https://twitter.com/orgil_">@orgil_</a>)です。 先日、この開発者ブログで紹介されたOne experienceプロジェクトによって、クックパッドはプロダクト基盤をグローバル版のシステムに移行しました。私はこのプロジェクトにおいて検索の領域で移行の進行、開発を担当していました。このブログでは検索システムの移行について紹介します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <h2 id="グローバルのシステムに寄せた理由">グローバルのシステムに寄せた理由</h2> <p>One experience によってクックパッドは検索基盤をグローバル側のシステムに移行しました。プロジェクト発足当初、日本とグローバルのどちらのシステムに寄せるかをいろんな観点から検討しました。日本ではSolr、Ruby、ECSなどで動く<strong>Voyager</strong>というシステム、グローバルではElasticsearch、Python、k8s、Kafkaなどを用いたglobal-search-v2(通称<strong>GS2</strong>)という検索システムが動いています。検討の際、どちらのシステムも一長一短で、特段優劣をつけがたいものでした。</p> <p>検索アルゴリズムに関しては両システムで考え方は一緒でした。辞書ベースでクエリ拡張をし、ドキュメントのタイトルや材料などにマッチスコアを付け、辞書の属性によって細かい調整をするようなスコアリングアルゴリズムは両方にあります。</p> <p>またドキュメントの検索への反映時間はどちらも同等と呼べるものでした。GS2はKafkaを用いたイベント処理システムを用いて、ほぼ即時反映を実現しています。Voyagerでも定期的な同期をするようになっており、レシピの変更が最短5分で検索結果に反映されるようになっています。下記のブログで触れているように、プロダクトが求めるユーザー体験を実現するためには5分でドキュメントが反映されれば十分であるため、どちらのシステムでも同様な価値を届けられます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2023%2F10%2F05%2F150000" title="クックパッドの検索反映時間を 1/288 にしたシステム改修 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>VoyagerがGS2より一番優れていた点は、速度でした。議論時点ではGS2のp50, p95 レスポンスタイムはVoyagerより約4倍ほど遅いものでした。これは大きな懸念要素で、速度は検索システムにとってとても大事な指標です。GS2を採用する際に、日本の大きなトラフィック量が増えることでGS2が更に遅くならないかなどの懸念点はありました。</p> <p>いろいろと議論しましたが、最終的にはGS2の方に統合することになりました。一番の決め手は多言語対応がVoyagerでは難しかったからです。GS2はすでに約30言語をサポートしていてElasticsearchのAnalyzerを各言語で細かく設定、運用しており、辞書も言語、地域ごとに別れています。この運用をVoyagerでやるのが難しそうでした。</p> <p>また辞書の扱い方が大きく違いました。日本の検索は長年運用されて、辞書は成熟しており、変更が頻繁に行われないため、辞書の更新は日次バッチで適用されます。しかし、GS2では辞書の変更も即時反映で、すぐに検索結果が変わります。これは辞書の管理を各地域のCM(コミュニティマネージャー)にお願いしていて、彼ら彼女らが辞書を変更しながら日々検索改善を行っているからです。まだ辞書を一から作っている新興マーケットが多かったため、辞書の変更による検索結果の変化を実際に見ながら辞書の作成をしているためです。 GS2採用にあたって速度面での懸念はありましたが、VoyagerがGS2より速い理由はわかっていて、いくつかGS2でも実現できる仕組みがあったため、なんとかできそうだということで進めました。</p> <h2 id="検索移行の進め方">検索移行の進め方</h2> <p>検索移行のゴールとしては、日本の検索と完全に同じ結果を返すようにすることを目指しました。GS2の検索ロジックの考え方は日本と同じだと言いましたが、実際の計算式は違っていて、検索結果にいろんな差異があります。通常ならABテストなどで、GS2ベースのスコアリングに置き換えても大丈夫か、検証しながら切り替えることも考えられます。しかしOne experience プロジェクトでは検索のみならず、メインのレシピサービスやデータベース、基盤をまるごと移行しているため、一部をユーザーに出して検証することができませんでした。また日本の人気順検索はプレミアムサービスの大事な機能であるため、慎重になる必要があり、システム移行作業とアルゴリズムの大きな改変の検証を同時にやるリソースがありませんでした。GS2で日本専用の検索クエリビルダーを作ることは容易で、大きな技術的な負債にもならないので、まずは同じ結果を再現することに従事しました。</p> <h3 id="RBO指標">RBO指標</h3> <p>検索結果が同じになっているかを確かめるために、日本とグローバルの結果を比較する必要がありました。今回の移行で、ふたつの検索結果の一致度を測る指標として、Rank Biased Overlap(RBO)という指標を用いました。RBOは2つの順位付きリストの類似性を測定する指標で、特に順位の上位に重点を置きながら、リスト全体の比較を行える特長があります。</p> <p>RBOを計算する際の上位100件の結果を減衰パラメータ=0.978に設定して測りました。このパラメータは上位60件の一致度でRBO指標の9割の重みが決まるように設定しました。これはレシピ検索においてほとんどのアクセスが最初の3ページの閲覧に収まっているためです。実装のデフォルトである減衰パラメータ=0.9では上位約10レシピだけでRBOスコアが決められてしまい、上位数件の結果が合ってるかどうかに重きが置かれる指標になってしまうからです。</p> <p>この比較をTop20000キーワードに対して、新着順と人気順の両方で毎日自動で計算するようにしました。日々のRBOの変化、2万キーワードのRBOの分布で進捗を測り、チーム内で RBOが低かったキーワードを分析しながら、移植機能の優先度をつけたり、仕様の見落としを発見しながら進められました。</p> <p><figure class="figure-image figure-image-fotolife" title="最初のRBO計測"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/giga811/20241210/20241210193133.png" width="640" height="480" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>最初のRBO計測</figcaption></figure></p> <p>RBO値とそれに対するキーワードのヒストグラムで移行の進捗が可視化することができました。上の図は一番最初のRBO分布のグラフです。このときはもちろん何もできていないのでほとんどのキーワードがRBO=0.0という結果でした。 <figure class="figure-image figure-image-fotolife" title="初期の開発過程の変化"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/giga811/20241210/20241210195006.png" width="1200" height="309" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>初期の開発過程の変化</figcaption></figure> 最初は一番基礎的な検索サポートをしていきました。Elasticsearchで正しいAnalyzerを設定したり、Voyagerのベースの計算式をGS2に実装したり、検索時のクエリを正しく形態素解析するようにしたり、基礎的な部分を作っていきました。この時点でRBO=0.0結果がほとんどなくなり、大部分が右側に寄っている形になりました。 <figure class="figure-image figure-image-fotolife" title="同日の新着順と人気順のRBO分布"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/giga811/20241210/20241210195044.png" width="1200" height="483" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>同日の新着順と人気順のRBO分布</figcaption></figure> またこれは同日の新着順と人気順の分布です。人気順のみの機能などもあることから、両方ともモニターしていく必要がありました。プロジェクト通して人気順のほうがRBO成績は良い傾向にありました。これは新着のレシピドキュメントが日本とグローバルで同期がズレていたり、人気順は人気順スコアの比重が大きいため似やすいことに起因しています。</p> <p>基礎的な機能の移植が一段落し、プロジェクトの中盤ではRBO値が低いクエリ(0.4以下)を並べて、漏れている機能の発見や、未実装機能の優先度付けを行いながら進めました。難しかったのはRBOが0.6-0.8あたりのキーワードの解析でした。RBOが高いキーワードはもうすでにある程度似ている結果で、差分を探すのが難しいものとなっていました。この辺になってくると単純にアルゴリズムの違いだけではなく、ドキュメント作成時の形態素解析の微妙な差異なども影響してくるため、地道に細かく調査しながら進めました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/g/giga811/20241210/20241210195147.gif" width="640" height="480" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> RBO分布の推移を可視化してみました。移行が進むごとに分布が右側に寄っていく様子が伺えます。RBOは最終的に新着順で RBO>=0.8 の割合が 97.2%、人気順で RBO >=0.8: 95.1% という結果になりました。いくつかの機能は違う形でもってきたり、Voyagerから持ってきたくない仕様などもありましたが、最終的にはとても高い一致率になりました。ユーザーリリース後もKPIが大きく変化することなく移行できました。</p> <h2 id="速度">速度</h2> <p>検索移行において日本のシステムと同等な検索結果を出すことと同時に、同等な速度を用意することに注力しました。前述したようにGS2はVoyagerの4倍ほど遅いレスポンスタイムでした。</p> <p>VoyagerがGS2と比べ速い理由はいくつかわかっていました。一つはクエリ拡張の方式です。辞書を使ったクエリ展開は主に同義語展開と、語の親子関係による拡張があります。日本では synonym token filter を使用して同義語展開を行い、親子関係の単語はインデックス時に事前計算して入れています。そのため検索時は元キーワードのみの検索で同義語展開と親子マッチが可能になっています。その変わりに辞書の変更を反映させるには、日次バッチを待つしかないです。対して、GS2では辞書反映をリアルタイムで実現したいため、クエリ拡張をElasticsearchクエリビルド時に行っているため、ESクエリが肥大化し、検索が重い処理になります。</p> <p>もう一つの速い理由はSolrを用いた検索基盤が最適化されていたことです。Voyagerでは様々な工夫を凝らしてインデックスサイズを極限まで減らしています。また<a href="https://techlife.cookpad.com/entry/2020/11/25/080100">Solr-hakoを用いたシステム</a>がとても高コスパで高パフォーマンスでした。GS2のElasticsearch構成と比較してクラスタを組んでいないし、Solrノードが1インデックスしか持たないため、クエリキャッシュが効率的に使えます。</p> <p>GS2移行において、実際の日本のトラフィックに近い負荷試験シナリオを用意してパフォーマンスを計測していきました。前述したSynonym token filterや親子関係の事前計算をGS2にも移行して持ってきたので、GS2の他の言語と比較してすでにある程度速くなってました。その上で、API側、Elasticsearchの構成、インデックス設定などいろいろ模索しながらパフォーマンス改善していきました。GS2にはPrometheus, Grafana を用いたAPM機能が備わっていたので、検索の一連の流れ、クエリ解析、Elasticsearchへのリクエストなどのどこがボトルネックになっているのか明確で、注力する領域を明確にできていました。</p> <p>またユーザーから見える<a href="https://techlife.cookpad.com/entry/2024/10/23/110000">Webやアプリの検索ページのパフォーマンスも測定</a>しながら、別のチームの方々が、検索基盤より前にあるシステムの最適化にも積極的に取り組んでいました。</p> <p>最終的にはGS2はp95ではVoyagerより高速なパフォーマンスになりました。p50のベースのレスポンスタイムは上がりましたが、p95レベルでは日本より安定したレスポンスタイムを実現できました。</p> <ul> <li>Voyager -> GS2</li> <li>p50: ~22ms -> ~33ms</li> <li>p95: ~80ms -> ~50ms</li> </ul> <h2 id="さいごに">さいごに</h2> <p>One experienceはUI、体験が大きく変わり、ユーザーに大きな負担を強いる挑戦でした。ユーザーの方には、検索結果とその応答性は同等なものを提供するように検索チームとして注力して来ました。結果、ほとんどのキーワードでは同等な検索結果を完全にGS2のシステムで用意することができ、レスポンスタイムも損なうことなく検索移行を実現することができました。</p> <p>検索移行が終わったいま、真のGlobalな検索開発が始まりました。一つのチームで約30言語の検索改善に取り組んでいきます。また日本とGlobalの検索アルゴリズムをなるべく揃えていこうとしています。実際のユーザートラフィックがあるなか、改めてGS2に備わっているABテスト基盤を活用しながら、Global、日本両方のアルゴリズムのいいとこ取りをし、同じアルゴリズムに揃えていきたいと思っています。もちろん言語ごとの差異は残ると思っていて、完全に一緒にするとは思っていませんが、お互いから学べるところは多いはずです。</p> <p>レシピ検索の詳細な話や検索以外の機能など、ここで書ききれない移行の話はたくさんあるのですが、またいずれどこかの機会に話せたらと思います。また、One experienceプロジェクトについて弊社のTechlifeブログでこれまでに、これからもいろんな記事が投稿されていきます。<a href="https://twitter.com/cookpad_tech">Techlifeのツイッター</a>で随時更新しています、ぜひチェックしてください。</p> giga811 Gradle Composite Build を用いたビルドロジックの共通化について hatenablog://entry/6802418398304973388 2024-11-20T13:00:00+09:00 2024-11-20T13:00:00+09:00 はじめに こんにちは。レシピ事業部で長期インターン中の松本 (@matsumo0922) です。先日このブログでも公開した通り、クックパッドでは日本とグローバルで体験を統一する One Experience というプロジェクトを行っています。 One Experience 以前では Android 開発においても日本とグローバルでコードベースが異なり、それぞれ使用している技術やライブラリが異なる状態でした。特にグローバルのコードベース (以下 global-android と呼びます) では AGP のバージョンも低く、加えて groovy + buildSrc と言った旧世代のビルドロジック… <h2 id="はじめに"><strong>はじめに</strong></h2> <p>こんにちは。レシピ事業部で長期インターン中の松本 (<a href="https://x.com/matsumo0922">@matsumo0922</a>) です。先日このブログでも公開した通り、クックパッドでは日本とグローバルで体験を統一する One Experience というプロジェクトを行っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>One Experience 以前では Android 開発においても日本とグローバルでコードベースが異なり、それぞれ使用している技術やライブラリが異なる状態でした。特にグローバルのコードベース (以下 <code>global-android</code> と呼びます) では AGP のバージョンも低く、加えて groovy + buildSrc と言った旧世代のビルドロジックを用いていたため、プロジェクトの進行に支障がある状態でした。本記事では、これらの問題を踏まえ One Experience をより円滑に進めるために施したビルドロジックの改善についてお話しします。</p> <h2 id="TLDR"><strong>TL;DR</strong></h2> <ul> <li>global-android では旧世代のビルドロジックを使っていたため One Experience 用の機能開発に支障がある状態だった</li> <li>ライブラリ管理を VersionCatalog へ、スクリプトを Kotlin DSL へ移行すると共に、Gradle Composite Build + Convention Plugins を用いてビルドロジックの共通化を行った</li> <li>最終的に、各モジュールの <code>build.gradle.kts</code> は非常に簡潔になり、大抵のビルドロジックが共通化され、開発をより効率的に進められるようになった</li> </ul> <h2 id="問題点"><strong>問題点</strong></h2> <p>global-android の Gradle ビルドには以下のような問題点がありました。</p> <ul> <li>dependencies.gradle を用いた手動のライブラリ &amp; バージョン管理</li> <li>groovy で書かれたビルドロジック</li> <li>大量にモジュールがあるにも関わらず、一部ロジックが共通化されていない</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="global-android でのライブラリ管理"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/matsumo0922/20241118/20241118160440.png" width="1200" height="658" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>global-android でのライブラリ管理</figcaption></figure></p> <p>まず、ライブラリのバージョン管理について。上の画像のように、global-android ではライブラリの定義(バージョンも含む)を gradle の <code>ExtraPropertyExtension</code> を用いて記述し、各モジュールに配布していました。この手法の詳細については割愛しますが、ご想像の通りライブラリ管理 &amp; バージョン管理が複雑になり、かつ IDE の手助けや renovate ã‚„ dependabot と言ったサービスも使うことができないため、開発者の体験を悪くしていました。</p> <p>次にビルドロジックについて。global-android には buildSrc が導入されていましたが、記述されているのは CI/CD で用いられる共通タスクの定義のみであり、実際のビルドロジックは各モジュールの <code>.gradle</code> ファイルに分散していました。というのも、buildSrc はビルドロジックを記述するのに最適な場所ではあるものの、すべての gradle build のホットパスであるため、あらゆるビルドでコードをコンパイル &amp; チェックを行ってしまうためです。もちろん初回以外はキャッシュが用いられますが、global-android のような巨大プロジェクトでは無視できないコストが発生します。加えて、buildSrc への変更はプロジェクト全体の classpath 変更、つまりキャッシュが無効になるという意味を持つため、buildSrc へビルドロジックを追加するのは慎重にならざるを得ません。</p> <pre class="code" data-lang="" data-unlink>$ find . -type f -name &#39;*.gradle&#39; -exec wc -l {} + | tail -n1 # プロジェクト全体の Groovy ファイルの行数 3736 total</pre> <p>そこで、ライブラリの管理を VersionCatalog に移行しつつ、 gradle の <code>Conposite Build</code> と <code>Convention Plugins</code> と言った手法を用い、ビルドロジックのみをプロジェクトから分離することでコストの削減、ビルドロジックの共通化を計ることにしました。</p> <h2 id="Composite-Build-とは"><strong>Composite Build とは</strong></h2> <p>一言で言えば、「プロジェクトから切り離されたビルド」のことです。Composite Build<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup> 内の各 build は <code>include build</code> と呼ばれ、include build 同士はロジックを共有せず、個別に構成 &amp; 実行されます。</p> <p><figure class="figure-image figure-image-fotolife" title="Convention Plugins を用いたプロジェクトの例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/matsumo0922/20241118/20241118160605.png" width="1200" height="676" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Convention Plugins を用いたプロジェクトの例<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup></figcaption></figure></p> <p>上の例では <code>my-app</code> と <code>my-utils</code> ã‚’ Composite Build を用いて一つのプロジェクトにまとめています。<code>my-app</code> は <code>my-utils</code> に依存を持つことができますが、この場合の依存は「直接的な依存」にはなりません。これらのモジュールは include build であるため、直接的に <code>my-utils</code> を参照することはできず、build を通して生成された実行可能ファイル(バイナリ)を参照することになります。そのため、buildSrc のような実行速度やキャッシュの問題を発生させずに、あらゆるロジックを記述することが可能になります。</p> <h2 id="Convention-Plugins-とは"><strong>Convention Plugins とは</strong></h2> <p>Convention Plugins<sup id="fnref:3"><a href="#fn:3" rel="footnote">3</a></sup> とはビルドロジックを Gradle Plugin System を用いて共通化し、Plugin として配布する手法のことを指します。Plugin の作成方法については <code>Standalone Gradle Plugin</code> と <code>Precompiled Script Plugin</code> が存在します。それぞれの Plugin の作成方法については割愛しますが、今回は Composite Build を使用する都合上、直接スクリプトファイルを参照できないため、<code>Standalone Gradle Plugin</code> を用いることにしました。</p> <p>まとめると、<code>Composite Build</code> を用いてモジュールを作成し(<code>build-logic</code> モジュールとします)、中で <code>Convention Plugins</code> を用いてビルドロジックを共通化する、と言った手法を取ることにしました。</p> <h2 id="実装"><strong>実装</strong></h2> <h3 id="構成"><strong>構成</strong></h3> <p>最初に、最終的なディレクトリ構成を示します。</p> <pre class="code" data-lang="" data-unlink>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/ └── ...</pre> <p>大まかには一般的なモジュールの構成と変わりありません。ただ一つ注意が必要な点は、<code>build-logic</code> は <code>cookpad</code> モジュールに include build されるため、Gradle 的には一つのプロジェクトとして扱われるということです。そのため、直下に <code>settings.gradle.kts</code> を配置して依存の解決方法や Library Repository を宣言する必要があります。今回は <code>build-logic</code> 内で VersionCatalog も用いるため、VersionCatalog の記述も必要です。</p> <p><code>src/</code> ディレクトリ以下に Plugin を配置します。クックパッドでは、単一の機能や構成を提供する Plugin ã‚’ Primitive Plugin、Primitive Plugin をまとめ一般化した Plugin ã‚’ Convention Plugin と呼ぶことにし、それぞれの Plugin をディレクトリを分けて配置しています。</p> <h3 id="build-logic-モジュールの作成"><strong>build-logic モジュールの作成</strong></h3> <p>ルート直下に <code>build-logic</code> モジュールを作成します。モジュールの作成方法は問いませんが、AndroidStudioのコンテキストメニューから作成してしまうと、不要な proguard ファイルなどが生成されたり、<code>settings.gradle.kts</code> にモジュールとして追加されるなどのお節介が発生するため、あまりおすすめできません。前述の通り、<code>build-logic</code> は Gradle 的には一つのプロジェクトであるため <code>settings.gradle.kts</code> を配置してください。</p> <pre class="code" data-lang="" data-unlink>dependencyResolutionManagement { repositories { google() mavenCentral() } versionCatalogs { create(&#34;libs&#34;) { // アプリプロジェクト側の VesionCatalog を参照する from(files(&#34;../gradle/libs.versions.toml&#34;)) } } } rootProject.name = &#34;build-logic&#34;</pre> <p>内容は通常のプロジェクトの <code>settings.gradle.kts</code> と同様です。Content Filtering などの記述もここに行います。一つ例外的なことは VersionCatalog を明示的に記述していることです。本来 Gradle は<code>./gradle/libs.versions.toml</code> にファイルが配置されていると自動的に <code>libs</code> という名前で拡張プロパティを生成しますが、<code>build-logic</code> から見れば toml ファイルは <code>../gradle/libs.versions.toml</code> に配置されているため、明示的に記述する必要があります。もちろん、<code>build-logic</code> 固有の toml ファイルを別途用意する場合はこの記述は必要ありません。</p> <p><code>build.gradle.kts</code> についても、通常のプロジェクトとなんら変わりません。</p> <pre class="code" data-lang="" data-unlink>plugins { `kotlin-dsl` } java { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 } dependencies { implementation(libs.android.gradlePlugin) implementation(libs.kotlin.gradlePlugin) } </pre> <h3 id="拡張関数の作成"><strong>拡張関数の作成</strong></h3> <p><code>build-logic</code> 内では VersionCatalog の拡張プロパティが用意されていなかったり、<code>implementation()</code> ã‚„ <code>api()</code> と言った DSL が用意されていないため、自力で実装する必要があります。</p> <pre class="code" data-lang="" data-unlink>// VersionCatalog を取得するための拡張プロパティ internal val Project.libs: VersionCatalog get() = extensions.getByType&lt;VersionCatalogsExtension&gt;().named(&#34;libs&#34;) 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&lt;ExternalModuleDependencyBundle&gt; { return findBundle(name).get() }</pre> <pre class="code" data-lang="" data-unlink>// 通常のライブラリを implementation するための拡張関数 internal fun DependencyHandlerScope.implementation(artifact: Project) { add(&#34;implementation&#34;, artifact) } // bundle などを implementation するための拡張関数 internal fun DependencyHandlerScope.implementation(artifact: MinimalExternalModuleDependency) { add(&#34;implementation&#34;, artifact) } // 通常のライブラリを api するための拡張関数 ...</pre> <p>加えて、Plugin 内で Extension を便利に利用するための拡張関数も用意しておきます。Gradle 7.1 より <code>BaseAppModuleExtension</code> と <code>LibraryExtension</code> の基底クラスが <code>CommonExtension</code> に変更されているため、実装を共通化できるようになっています。</p> <pre class="code" data-lang="" data-unlink>// 通常の build.gradle.kts の android ブロックに相当 internal fun Project.androidExt(configure: BaseExtension.() -&gt; Unit) { (this as ExtensionAware).extensions.configure(&#34;android&#34;, configure) } // Android Project の build.gradle.kts のスコープ internal fun Project.commonExt(configure: CommonExtension&lt;*, *, *, *, *, *&gt;.() -&gt; 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 }</pre> <h3 id="Plugin-の実装"><strong>Plugin の実装</strong></h3> <p>global-android では最終的に以下のような Plugin 構成になっています。</p> <ul> <li>Convention Plugin <ul> <li><code>cookpad.convention.android.feature</code></li> </ul> </li> <li>Primitive Plugin <ul> <li><code>cookpad.primitive.android.application</code></li> <li><code>cookpad.primitive.android.library</code></li> <li><code>cookpad.primitive.android.compose</code></li> <li><code>cookpad.primitive.android.flavor</code></li> <li><code>cookpad.primitive.android.lint</code></li> <li><code>cookpad.primitive.detekt</code></li> <li><code>cookpad.primitive.common</code></li> </ul> </li> </ul> <p>Primitive Plugin では単一の機能や構成を提供し、他の Primitive Plugin に依存することがないようにする必要があります。Convention Plugin は Primitive Plugin を参照することはできますが、その逆はできません。また、Convention Plugin 同士の参照がないように注意してください。</p> <p>global-android での Plugin 実装例をいくつか挙げていきます。</p> <h4 id="FeaturePlugin"><strong>FeaturePlugin</strong></h4> <p><code>cookpad.convention.android.feature</code> は global-android に大量に存在する Feature モジュールが参照する Convention Plugin として実装されています。Primitive Plugin を纏めるだけでなく、モジュールの依存関係やライブラリの依存もここで定義することにより、Feature モジュールとしての実装を強制することが可能になっています。</p> <pre class="code" data-lang="" data-unlink>package convention class FeaturePlugin : Plugin&lt;Project&gt; { override fun apply(target: Project) { with(target) { with(pluginManager) {           // 必要な Primitive Plugin を記述 apply(&#34;cookpad.primitive.android.library&#34;) apply(&#34;cookpad.primitive.android.compose&#34;) apply(&#34;cookpad.primitive.android.flavor&#34;) apply(&#34;cookpad.primitive.android.lint&#34;) apply(&#34;cookpad.primitive.common&#34;) apply(&#34;cookpad.primitive.detekt&#34;) } dependencies {           // Feature モジュールが依存すべきモジュールを記述 implementation(project(&#34;:core&#34;)) implementation(project(&#34;:entity&#34;)) implementation(project(&#34;:usecase&#34;)) implementation(project(&#34;:repository&#34;)) implementation(project(&#34;:view-components&#34;)) // 共通のライブラリなどを記述 implementation(libs.bundle(&#34;kotlin&#34;)) implementation(libs.bundle(&#34;koin&#34;)) implementation(libs.library(&#34;androidx-appcompat&#34;)) implementation(libs.library(&#34;google-material&#34;)) testImplementation(libs.bundle(&#34;test&#34;)) } } } }</pre> <h4 id="LibraryPlugin"><strong>LibraryPlugin</strong></h4> <p><code>cookpad.primitive.android.library</code> は Library モジュールが利用する Plugin です。Convention Plugin からも参照されていますが、この Plugin では <code>targetSdkVersion</code> ã‚„ <code>compileSdkVersion</code>、その他様々なビルドオプションなどを記述しています。<code>CommonExtension</code> を利用して設定できる項目は <code>configureAndroid</code> という関数に切り出し、<code>ApplicationPlugin</code> と共通化しています。</p> <pre class="code" data-lang="" data-unlink>package primitive class LibraryPlugin: Plugin&lt;Project&gt; { override fun apply(target: Project) { with(target) { // LibraryPlugin は &#34;com.android.library&#34; のみを apply // その他の機能が必要な場合は別の Plugin を作成する pluginManager.apply(&#34;com.android.library&#34;) extensions.configure&lt;LibraryExtension&gt; { // Android Project に必要な設定 configureAndroid(this) defaultConfig.targetSdk = libs.version(&#34;targetSdk&#34;).toInt() buildFeatures.viewBinding = true buildFeatures.buildConfig = true } } } } internal fun Project.configureAndroid(commonExtension: CommonExtension&lt;*, *, *, *, *, *&gt;) { commonExtension.apply { defaultConfig {        // global-android では minSdkVersion ã‚„ compileSdkVersion なども VersionCatalog に記述している minSdk = libs.version(&#34;minSdk&#34;).toInt() compileSdk = libs.version(&#34;compileSdk&#34;).toInt() } testOptions { animationsDisabled = true } compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 encoding = &#34;UTF-8&#34; } packaging { resources.excludes.addAll( listOf(...) ) } } }</pre> <h4 id="FlavorPlugin"><strong>FlavorPlugin</strong></h4> <p><code>cookpad.primitive.android.flavor</code> は Build Flavor を設定する Plugin です。先日このブログにも投稿<sup id="fnref:4"><a href="#fn:4" rel="footnote">4</a></sup>された通り、 global-android では日本のコードベースを統合する際に applicationId などの都合上、 Build Flavor を用いて日本向けビルドをグローバル向けビルドに切り替える方式を採用しています。デバッグ用、Global Production用、JP Production 用などと複数のビルド設定を各モジュールに記載するのは極めて非効率であるため、Plugin に纏めるようにしています。</p> <pre class="code" data-lang="" data-unlink>package primitive class FlavorPlugin: Plugin&lt;Project&gt; { override fun apply(target: Project) { with(target) {    // Build Flavor を設定 configureFlavors() configureVariantFilter() } } } private fun Project.configureFlavors() { androidExt { // グローバルと日本でバージョニングが異なるため、それぞれのバージョンを取得 val globalVersion = GlobalVersion.build(project) val jpVersion = JpVersion.build(project) flavorDimensions(Dimension.REGION, Dimension.DEPLOYMENT_TRACK) productFlavors { // Global リージョンを設定 create(&#34;global&#34;) { dimension = Dimension.REGION isDefault = true } // JP リージョンを設定 create(&#34;jp&#34;) { dimension = Dimension.REGION extra.apply { set(ExtraKey.ApplicationId, &#34;com.cookpad.android.activities&#34;) set(ExtraKey.VersionCode, jpVersion.getVersionCode(project)) set(ExtraKey.VersionName, jpVersion.getVersionName(project)) } } // Production トラックを設定 create(&#34;production&#34;) { dimension = Dimension.DEPLOYMENT_TRACK isDefault = true } ... } productFlavors.all { if (isApplicationProject()) { if (extra.has(ExtraKey.ApplicationId)) { applicationId = extra[ExtraKey.ApplicationId].toString() } if (extra.has(ExtraKey.VersionCode)) { versionCode = extra[ExtraKey.VersionCode].toString().toInt() } if (extra.has(ExtraKey.VersionName)) { versionName = extra[ExtraKey.VersionName].toString() } ... } } } } private fun Project.configureVariantFilter() { androidExt { variantFilter { if (flavors.map { it.name }.contains(Flavor.DEVELOPERS_SANDBOX)) { ignore = true } } } }</pre> <h4 id="Tips-KTS-ファイルを用いて-Convention-Plugins-を作る"><strong>Tips: KTS ファイルを用いて Convention Plugins を作る</strong></h4> <p>今回は <code>org.gradle.Plugin</code> を実装する手法をとっていますが、<code>*.gradle.kts</code> ファイルを用いて Convention Plugins を作る方法も存在します。</p> <p>実装は非常に簡単で <code>my-convention-plugin.gradle.kts</code> と言ったファイルを <code>build-logic</code> 内の <code>src/</code> ディレクトリに配置するだけです。<code>src/</code> 内に配置された <code>*.gradle.kts</code> ファイルは Plugin クラスにプリコンパイルされ、例の場合は <code>my-convention-plugin</code> の部分を key として使うことができるようになります。加えて、後述する Plugin の登録が不要だったり、<code>settings.gradle.kts</code> のロジックを共通化して Convention Plugins にすることができたりなど、様々なメリットも存在します。</p> <p>通常のビルドロジック共通化には飽きた!という方は、kotlinx-rpc のリポジトリの中で具体的な実装例を見れますので、ぜひ参考にしてください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2FKotlin%2Fkotlinx-rpc%2Ftree%2Fmain%2Fgradle-conventions%2Fsrc%2Fmain%2Fkotlin" title="kotlinx-rpc/gradle-conventions/src/main/kotlin at main · Kotlin/kotlinx-rpc" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://github.com/Kotlin/kotlinx-rpc/tree/main/gradle-conventions/src/main/kotlin">github.com</a></cite></p> <h3 id="Plugin-の登録"><strong>Plugin の登録</strong></h3> <p>作成した Plugin はスタンドアローンの JAR ファイルとしてプリコンパイルされる Binary Plugin であるため、アプリプロジェクトから参照するためには Gradle に Plugin を登録してあげる必要があります。</p> <p>前述した <code>build-logic</code> の <code>build.gradle.kts</code> 内で以下のようにして Plugin を登録します</p> <pre class="code" data-lang="" data-unlink>gradlePlugin { plugins { // Convention Plugins register(&#34;ConventionFeature&#34;) { id = &#34;cookpad.convention.android.feature&#34; implementationClass = &#34;convention.FeaturePlugin&#34; } // Primitive Plugins register(&#34;PrimitiveApplication&#34;) { id = &#34;cookpad.primitive.android.application&#34; implementationClass = &#34;primitive.ApplicationPlugin&#34; } register(&#34;PrimitiveLibrary&#34;) { id = &#34;cookpad.primitive.android.library&#34; implementationClass = &#34;primitive.LibraryPlugin&#34; } ... } }</pre> <h3 id="Include-Build-の設定"><strong>Include Build の設定</strong></h3> <p>以上で <code>build-logic</code> モジュールの記述はほぼ完了したため、最後にアプリプロジェクト側で <code>build-logic</code> ã‚’ include build として指定し、加えてこれまで記述した Plugin に実装を移していきます。</p> <p>include build として指定する方法は簡単で、アプリプロジェクト側の <code>settings.gradle.kts</code> に以下のように変更を加えるだけです。間違って通常のモジュールと同様に <code>include</code> しないように気をつけてください。</p> <pre class="code" data-lang="" data-unlink>pluginManagement { includeBuild(&#34;build-logic&#34;) // build-logic モジュールを include build する repositories { google() mavenCentral() } } dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.PREFER_PROJECT) repositories { google() mavenCentral() } }</pre> <p>最後に各モジュールの <code>build.gradle</code> に記述していたビルドロジックを Plugin に移行します。以下の画像はあるモジュールの <code>build.gradle</code> にあった記述を Plugin に移行した例です。サイズの関係上、特に複雑なロジックが記述されていないモジュールの <code>build.gradle</code> を例としていますが、build variant ã‚„ build flavor を記述しているモジュールの場合、さらに多くの行数を削減することができました。ほぼ全ての記述を Plugin を用いて共通化できているため、最終的な <code>build.gradle.kts</code> のモジュール固有の記述は <code>namespace</code> のみとなっています。</p> <p><figure class="figure-image figure-image-fotolife" title="あるモジュールの build.gradle.kts"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/matsumo0922/20241118/20241118160316.png" width="1200" height="738" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>あるモジュールの build.gradle.kts</figcaption></figure></p> <h2 id="まとめ"><strong>まとめ</strong></h2> <p>最終的なプロジェクト全体の <code>build.gradle.kts</code> の行数は以下のとおりです。</p> <pre class="code" data-lang="" data-unlink>$ find . -type f -name &#39;*.gradle.kts&#39; -exec wc -l {} + | tail -n1 # 全体の行数 1823 total</pre> <p>Gradle Composite Build + Convention Plugins を用いたビルドロジックの共通化により、記述量を半分近く削減することが可能となりました。</p> <p>この改善は私が One Experience の開発に携わる際に一番最初に行ったものです。前述した Build Flavor の設定に加え、CI、リリースなどコードベースが統合することでプロジェクトとしてもより複雑なビルドロジックを持たざるを得なくなるだろうと予想し、本格的な開発に入る前に今回の改善を行いました。実際、プロジェクトを進めるにあたって今回お話ししたような複雑なロジックが幾度となく追加されており、今回の改善がこれらのロジックをより簡潔かつ簡易に導入することができる状態としています。global-android のような巨大なプロジェクトでビルドロジックに手を入れるのは非常に困難でしたが、今回の改善が開発者自身の体験、引いては One Experience プロジェクト全体の進行にも向上にも大きく貢献することができたと自負しています。</p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> <a href="https://docs.gradle.org/current/userguide/composite_builds.html">https://docs.gradle.org/current/userguide/composite_builds.html</a><a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> <a href="https://docs.gradle.org/current/userguide/composite_builds.html">https://docs.gradle.org/current/userguide/composite_builds.html</a><a href="#fnref:2" rev="footnote">&#8617;</a></li> <li id="fn:3"> <a href="https://docs.gradle.org/current/userguide/custom_plugins.html#sec:convention_plugins">https://docs.gradle.org/current/userguide/custom_plugins.html#sec:convention_plugins</a><a href="#fnref:3" rev="footnote">&#8617;</a></li> <li id="fn:4"> <a href="https://techlife.cookpad.com/entry/mobile_one_experience">https://techlife.cookpad.com/entry/mobile_one_experience</a><a href="#fnref:4" rev="footnote">&#8617;</a></li> </ol> </div> matsumo0922 レシピIDリンク移行の話 hatenablog://entry/6802418398303665900 2024-11-15T11:25:00+09:00 2024-11-15T11:25:00+09:00 こんにちは、2024å¹´4月から、新卒でレシピ事業部プロダクト開発グループに所属している、張頌です。私が新卒入社して最初に取り組むこととなったのが、このブログで以前ご紹介したOne Experienceプロジェクトです。 このプロジェクトには、データ移行や多言語対応など、私が経験してこなかったタスクがたくさんありました。私が所属しているチームではスクラム開発によってプロジェクトを進めております。初めてこの体制で大きなプロジェクトに参加することは、大変でしたが面白い体験でした。私はこの中で、データ移行のレシピIDリンクの移行を担当いたしました。 私が最初にこのタスクを携わっていた時に、色々な理由で… <p>こんにちは、2024å¹´4月から、新卒でレシピ事業部プロダクト開発グループに所属している、張頌です。私が新卒入社して最初に取り組むこととなったのが、このブログで以前ご紹介したOne Experienceプロジェクトです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>このプロジェクトには、データ移行や多言語対応など、私が経験してこなかったタスクがたくさんありました。私が所属しているチームではスクラム開発によってプロジェクトを進めております。初めてこの体制で大きなプロジェクトに参加することは、大変でしたが面白い体験でした。私はこの中で、データ移行のレシピIDリンクの移行を担当いたしました。</p> <p>私が最初にこのタスクを携わっていた時に、色々な理由でミスがありました。本稿はこのタスクの新人なりの振り返りをし、次にもし大きなプロジェクトに携わる時に、どう対処すれば問題を回避できるかという知見をまとめようと思います。</p> <h2 id="レシピIDリンクの移行について">レシピIDリンクの移行について</h2> <p>日本版ではレシピに、決まったフォーマットでレシピのIDを書くと、レシピを表示する時にそのレシピIDを持つレシピページに飛ぶリンクが付けられました。以下の画像のように、例えばレシピの手順で「レシピID:12345のソース」と記法で書くと、「レシピID:12345」の部分がリンクになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/malcarol/20241113/20241113173613.png" width="990" height="186" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>事前調査によると、このようなレシピIDは日本版のレシピにおいて紹介文、材料、手順、コツ・ポイントなどのところにありました。</p> <p>ところで今回、レシピのデータは日本版のデータベースからグローバル版のデータベースに移行しています。このときレシピのIDが変わるので、何もしないままだとリンク先の整合性が崩れてしまいます。このためレシピIDリンクについても修正が必要ですが、グローバル版のレシピのIDは移行してみないと決まりません。故に、先にレシピのデータを移行し、そのあとレシピIDリンクを置換することにしました。今回のタスクはリンク先となるレシピのIDを、グローバル版のIDに置換することです。</p> <h2 id="レシピIDリンク移行の振り返り">レシピIDリンク移行の振り返り</h2> <p>レシピIDリンク移行に取り組み始めた頃、私はまだ慣れていないことや知らないことが多く、あまり細かい調査はできていませんでした。その結果としていくつかの不具合を生んでしまいました。幸いOne Experienceのリリースの前に起こった不具合だったのでユーザーへの影響はなかったのですが、ここではそれらを振り返りたいと思います。</p> <h3 id="状態遷移の考慮漏れ">状態遷移の考慮漏れ</h3> <p>最初に取り組んだのは、日本版からグローバル版へ継続的にデータ移行している部分での置換です。データ移行を行っている部分のキャッチアップが難しかったので、コードを見ながら吸収しようと思いました。実際キャッチアップはすぐに終わり、タスクを始めた次の日にはpull requestを出しました。不安なところも色々ありましたが、コードを読みながら問題ないかを確認し、Approveをもらって、Deployしました。</p> <p>そこで問題が発生しました。日本版でレシピを編集しても、グローバル版でレシピIDが変わらなかったんです。そこでデータ移行のテストファイルから、レシピ編集のシチュエーションをテストしている箇所を見つけて、シミュレートして試しました。Debugしてコードを追っていたら、レシピ編集の際には自分が実装を追加したところとは別の箇所のコードが動いていると気付きました。その後、丁寧にレシピ移行全体の状態遷移を考え、あらゆる状況を考慮して、テストでシミュレートしました。全遷移パターンで対応済みだと確信して、実装完了としました。</p> <p>振り返ってみると、最初にコードをみた時に、レシピ編集についての実装があることには気付いていたことを思い出しました。このとき自分が実装したところだけで足りているのか不安で、周りの同僚には相談していましたが、実装に詳しい先輩には相談していませんでした。また、テストされている条件を網羅的に試して、できるだけ再現して漏れがないか確認した方が良かったと思います。</p> <h3 id="不可解なクエリ結果">不可解なクエリ結果</h3> <p>データ移行の実装では、継続的なデータ移行とは別に、まとまったデータを一括でバッチ処理することでデータ移行を行う実装も存在しました。ここについてもレシピIDリンク移行を行う必要があったのですが、罠がありすぎて、難しかったです。いくつかご紹介します。</p> <p>バッチ処理によって移行されてきたレシピに対して、後からレシピIDリンクの書き換えを行うことを考えます。どのレシピについて書き換えるべきか判断するための方法として、レシピの最終更新時刻を日本版とグローバル版で比較することで決めることとしました。ここで日本版の最終更新時刻はQueuery(きゅうり)という内製ツールでSQLクエリを実行して得ています。</p> <p>以前にも似たような実装をした経験があったので、自信満々でpull requestを出して、早速Approveをもらって実行しました。検証環境で確かめて、何個か例を見ると書き変わったことを確認しました。</p> <p>そこで、また問題が発生しました。データを調査したら、書き換わってないレシピIDが存在していると気付いたのです。そこでまた調査を始めまして、コードを読んで、色々バグが出そうなところにバグの仮説を立ててました。それでも結局見つからなかったので、ちょっと焦ったところもありまして、先輩とペアプロを始めました。</p> <p>先輩と一緒に調査をしまして、そもそもQueueryから返ってくるレシピのリストに書き換わる対象レシピが入ってないのかもしれないと考え始めました。しかし、手元で同じSQLクエリを実行した結果には対象レシピが入っていたので、結果が違うことはありえないなと最初は思っていました。ところが、実装の方の中間変数をstep by stepに見たら、確かに対象レシピが入ってないことがありました。何故そうなっているのか全く分からず、同僚と共に調査を続けました。最終的に、Queueryの場合と手元の場合でSQLクエリの中でのエスケープの挙動が異なっているのが原因と分かりました。</p> <h3 id="ダブルチェックの重要さ">ダブルチェックの重要さ</h3> <p>レシピIDリンクのバッチ処理を実際に動かす前に、継続的な移行のときの失敗を思い出し、全体的なデータ検証をした方がいいと思いました。そこでレシピIDに関連するバグが出そうな仮説を立て、検証することにしました。そういえば私はアイデアをたくさん出すことが得意なので、既存のデータの不具合を結構見つけました。実行する前に見つかって良かったと思いました。</p> <p>その後もいくつかの問題を切り抜けながら、レシピIDリンクを移行することができました。最終的に無事に移行できて良かったです。最初にミスがあり焦ったけど、難しい仕様があり、罠みたいなバグが難しいのがしょうがなく、失敗しても焦らず、相談してチャレンジしていくことが大事だと非常にわかりました。これもだんだん出来るようになったポイントかなと思いました。</p> <h2 id="次に活かすポイント">次に活かすポイント</h2> <p>今回の振り返りは、おそらく以下の3つのパターンに分けられると思います。</p> <h3 id="ポイント1未知の存在を認識し詳しい人や前例の知恵を吸収する">ポイント1:未知の存在を認識し、詳しい人や前例の知恵を吸収する</h3> <p>アクションをとる前に、何を追加で確認しないとダメかを知らないままに、今ままでの知識だけで対応して、ミスる場合です。新卒の時に、特にOne Experienceのような大きいプロジェクトの中で、全面的に細かく教えてもらえる人は少ないので、結局個人判断の時が多いです。ある程度のミスは起こるものであり、教訓にすべきだと思いますが、そのような状況でもできることはあると思います。</p> <p>たとえば知らないタスクをもらったとき、課題の理解が足りなく解像度が低いままでタスクを進めないことです。自信はいいことだと思いますが、新卒として理解したのは、完全理解との距離があることを認識すべきでした。しかもレビュワーも知らないことがあるので、その時、知らないことがあるかを確認するためのリサーチや、気軽に他の人に聞いていくことで、改善するだろうと思います。</p> <h3 id="ポイント2裁量範囲を模索し適切に先輩とシンクする">ポイント2:裁量範囲を模索し、適切に先輩とシンクする</h3> <p>アクションをとるとき、大量な意思決定があるけど、個人の裁量でできないことを決めちゃった場合です。状態遷移の考慮漏れを起こしたときのように、過信によって問題が起こりえます。</p> <p>これについてはアクションをとる時に、先輩との報告と相談が必要です。それだけではなく、先輩から助言をもらった時にも、課題と話を完全に吸収してから、議論していくことが大事です。要するに、アクションをとる前に先輩の言っていることを理解してから、自分が何をするつもりか報告し、目標と認識がシンクして初めて、アクションをとるべきだと思います。</p> <h3 id="ポイント3未然に防ぐ考慮漏れ確認をルーチン化する">ポイント3:未然に防ぐ、考慮漏れ確認をルーチン化する</h3> <p>実際に、新卒に限らず、しょうがなく、人間はミスすることが必ずある生き物なので、そのミスした経験を持って、2度目をないようにするのがいいと思います。そのために、確認ポイントを書面化したり、今回のミスのフィードバックを吸収したりできます。特に重要そうなものを他の人と共有しても良いでしょう。</p> <p>そして、次に似たようなことがあったら、網羅的な確認をきちんと怠けず行うべきです。自分の変更の影響力を理解しつつ、ユーザーのためにも、考慮漏れ確認をルーチン化した方がいいと思います。</p> <h2 id="最後に">最後に</h2> <p>今回は、One Experienceプロジェクトの環境でのレシピIDリンク移行の時の自分なりの振り返りをしました。私がこのタスクを始めて、想定外の問題がたくさんでました。難しい仕様をもつタスクなので、うまくできなかったところがありました。この振り返りと経験を次に似たようなタスクをやる時に、できるだけ参考にしたいと考えています。</p> malcarol iOSアプリにおける複数リリースに跨った機能改善の開発事例紹介 hatenablog://entry/6802418398303600210 2024-11-14T11:14:00+09:00 2024-11-14T11:14:00+09:00 レシピ事業部のHaurta (@0x746572616e79 )です。グローバルサービスとの統合プロジェクト(One Experienceプロジェクト)に伴いiOSアプリケーションもグローバルと日本で別々のアプリケーションを開発していた体制から一変して、グローバルのアプリケーションベースの開発(グローバル版)へ移行を進めました。 グローバルと日本で異なるアプリケーションを開発してきたため、同じクックパッドでも細かな挙動の違いが見られます。気になる挙動がないかどうかチームで何度もウォークスルーを重ねた結果、レシピエディターやプロフィール設定画面で使われるフォトピッカーの挙動が問題として浮上しまし… <p>レシピ事業部のHaurta (<a href="https://x.com/0x746572616e79">@0x746572616e79 </a>)です。グローバルサービスとの統合プロジェクト(One Experienceプロジェクト)に伴いiOSアプリケーションもグローバルと日本で別々のアプリケーションを開発していた体制から一変して、グローバルのアプリケーションベースの開発(グローバル版)へ移行を進めました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2Fmobile_one_experience" title="モバイルアプリの One Experience - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>グローバルと日本で異なるアプリケーションを開発してきたため、同じクックパッドでも細かな挙動の違いが見られます。気になる挙動がないかどうかチームで何度もウォークスルーを重ねた結果、レシピエディターやプロフィール設定画面で使われるフォトピッカーの挙動が問題として浮上しました。</p> <p>フォトピッカーの改善を重要なタスクとして取り組むことにしましたが、フォトピッカーに限らずOne Experienceプロジェクトが始まってからはグローバル版のコードベースを読むところからのスタートになるため、このタスクの完了にどのくらい時間がかかるのか推測しづらい状態でした。</p> <p>一般的に大きなタスクを小さく分割することは行われますが、具体的な分割方法や進行の手法については、慣れや経験だったり試行錯誤が必要です。</p> <p>この記事では、フォトピッカーの改善を通じて、タスク分割とプロジェクトの進め方について幾つかの学びがあったため、どのようにタスクを分解し進めていったのかを紹介します。</p> <h2 id="フォトピッカー">フォトピッカー</h2> <p>クックパッドにおいてフォトピッカーは非常に重要な機能の一つで、旧日本版、グローバル版ともにフォトピッカーは標準のPHPickerViewControllerを使用せず、何種類か目的と体験にあった自前のフォトピッカーを実装していました。</p> <p>旧日本版では、ユーザーがレシピに写真を追加する際、フォトピッカーが最初に起動し、そこからカメラスクリーンに遷移できるボタンが配置されていました。これに対し、グローバル版では、カメラスクリーンが最初に起動し、カメラスクリーンにフォトピッカーへの遷移ボタンが設置されていました。</p> <p><figure class="figure-image figure-image-fotolife" title="手順写真を載せようとした時に表示される画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tera_ny/20241113/20241113121736.png" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>手順写真を載せようとした時に表示される画面</figcaption></figure></p> <p>グローバル版のカメラ機能はシンプルなため、多くのユーザーが外部のカメラアプリを利用するだろうと考えられます。この状況では、画像をアップロードするたびにカメラスクリーンが起動するのは不便です。また、X(旧Twitter)やInstagramといったSNSアプリでは、フォトピッカーを先に表示し、内部にカメラアクセスの導線を設けるのが一般的です。そこで、グローバル版もフォトピッカーを最初に起動する流れに改善することになりました。</p> <h2 id="問題解決への道筋を立てる">問題解決への道筋を立てる</h2> <p>グローバル版は各機能がどのように実装されているか、画面の構造、実装を理解するところから改善を進めます。当初は画面の入れ替えだけで済むと考えていましたが、コードリーディングを進めるうちにかなりの工数が必要であることが判明しました。</p> <p>グローバル版はCoordinator Patternを用いた画面遷移を採用しており、フォトピッカーにはカメラへの遷移を設置する必要がありました。この時、概算で10時間以上の工数がかかる可能性を感じつつも、どこから着手すれば良いのか具体的な見積もりができていません。</p> <p><figure class="figure-image figure-image-fotolife" title="グローバル版のアーキテクチャ"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tera_ny/20241113/20241113114007.png" width="1200" height="861" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グローバル版のアーキテクチャ</figcaption></figure></p> <p>レシピ事業部はスクラム開発を採用しており、タスクの優先順位や範囲を決定するためにも、工数の見積もりが必要です。非常に正確である必要はありませんが、大雑把過ぎる見積もりでは不透明なため優先順位判断が難しくなります。</p> <p>改善タスクには破壊的な変更が含まれており、複数スプリントにわたって開発が必要な場合モバイル特有の問題を考える必要があります。モバイルアプリはスプリントごとにリリース<a href="#f-80add568" id="fn-80add568" name="fn-80add568" title="レシピ事業部では1スプリントを一週間で回しているため週次リリースを行っています">*1</a>をしているため、未完成の機能が露出しないようにしなければなりません。そのため、mainブランチへ細かく変更を加えていくのか、開発ブランチを事前に用意しまとめてmainブランチに取り込む方法を採用すべきかどうかを検討しました。</p> <p>mainブランチに直接細かな変更を積み重ねる方法では、コミットの粒度をコントロールしやすく、各変更の影響範囲を小さく保つことができます。また、最新のmainに追加していくため、安定性を確保しやすいです。しかし、開発中の機能や未完成の変更がリリースで露出してしまわないよう厳重な管理が求められます。</p> <p>一方、開発ブランチを用いた方法では、mainブランチに影響を与えることなく機能を追加、変更することができます。ただ、開発ブランチでの作業が進む中でmainブランチにも並行して別の変更が加えられることがほとんどで、コンフリクトのリスクが高くなります。また、mainマージをするタイミングで全ての差分をチェックする必要があり、レビューコストの増加につながります。アルファリリースでは、開発ブランチを用いて変更を積み重ねていましたが、mainブランチと大きな差分が発生してしまい、コミットの統合時に多大な工数がかかることがありました。</p> <p>最終的にはmainブランチへ変更を積み上げていく方法で進めることを決めました。</p> <h2 id="リリースを跨ぐ機能開発">リリースを跨ぐ機能開発</h2> <p>mainブランチに変更を積み上げていくと決めましたが、ではどう破壊的な変更を閉じておくべきでしょうか?</p> <h3 id="Feature-Toggle">Feature Toggle</h3> <p>グローバル版にはFeature Toggle <a href="#f-4fa7bcab" id="fn-4fa7bcab" name="fn-4fa7bcab" title="一般的にはFeature Flagとも呼ばれている(https://martinfowler.com/articles/feature-toggles.html">*2</a> が用意されています。A/Bテストなどで広く使われており、代表的なサービスとしてFirebase Remote Config <a href="#f-d7238a0c" id="fn-d7238a0c" name="fn-d7238a0c" title="https://firebase.google.com/products/remote-config?hl=ja">*3</a> が有名です。詳しくは取り上げませんが、One Experience向けの機能をグローバル版へ実装、展開する際にもFeature Toggleが利用されていました。遷移先の切り替えや、UITableViewで表示するコンテンツの出し分けなど色々なユースケースでの利用ができます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">class</span> <span class="synIdentifier">Coordinator</span><span class="synSpecial">:</span> <span class="synType">InteractorDelegate</span> { <span class="synIdentifier">...</span> <span class="synPreProc">func</span> <span class="synIdentifier">interactorWantsToInsertStepAttachments</span>() { <span class="synStatement">if</span> appContext.featureToggle.supports(.フォトピッカーを先に起動する) { startPhotoPickerFlow() } <span class="synStatement">else</span> { startCameraFlow() } } } </pre> <p>今回のフォトピッカー改善タスクではCoordinatorの画面遷移処理にてFeature Toggleを利用し出し分けを行う方法を採用しました。別の方法としてFeature Toggleがオンのときにはフォトピッカーを、オフのときにはカメラスクリーンを初期表示する単一のCoordinatorを定義する方法も検討しましたが、実装の簡素化と管理のしやすさを考えると独立したCoordinatorを用意する方が適していると今回は判断しました。</p> <p><figure class="figure-image figure-image-fotolife" title="デバッグメニューからFeatureToggleの切り替えができる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tera_ny/20241113/20241113115659.png" width="1113" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>デバッグメニューからFeatureToggleの切り替えができる</figcaption></figure></p> <p>開発中は端末内のFeatureToggleを強制的に有効化しフォトピッカーを立ち上げ、本番環境では以前のカメラを立ち上げることで意図せず開発中の実装がユーザーの方に見えてしまう事故を防ぐことができます。</p> <h3 id="タスクと仕様を整理する">タスクと仕様を整理する</h3> <p>Feature Toggleによって全ての問題が解決するかというと、そうではありません。Feature Toggleで分岐先を変えるのが良さそうだというのはわかりましたが、依然としてどれくらいの工数がかかるかわからないためタスクと仕様を整理する必要があります。遷移先の画面をFeature Toggleで分岐できるようになったので全く新しい画面を定義して表示することもできますが、また0からフォトピッカーやカメラスクリーンを実装するのは二度手間でその分工数がかかってしまいます。手間を省くためにも可能な限り、既存のコンポーネントや実装は使い回しをしたいです。</p> <p><figure class="figure-image figure-image-fotolife" title="(å·¦)改善前のエラー表示、(右)期待するエラー画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tera_ny/20241113/20241113121752.png" width="1200" height="902" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>(å·¦)改善前のエラー表示、(右)期待するエラー画面</figcaption></figure></p> <p>ただ、既存のフォトピッカーは使い回すには少々難しい実装になっていました。たとえばデバイス内の写真にアクセスするための権限をリクエストする<code>PHPhotoLibrary.requestAuthorization</code>はカメラスクリーンを表示しているタイミングで実行していたり、権限がない時は専用のエラーコンポーネントをフォトピッカーで表示するのではなく、UIAlertControllerを利用したエラー表示を行っていました。このまま画面を入れ替えてしまうと、フォトピッカーのアクセスを拒否したときカメラスクリーンへの導線がなくなってしまい体験が悪いです。エラー画面の追加、アクセス権限をリクエストするタイミング調整など、新しいCoordinatorを定義する前にフォトピッカー自体の機能追加が必要だということがわかりました。</p> <p>少しずつ取り組むべきタスクが明確になり、優先順位もまた見えてきました。新しいCoordinatorを追加する前にフォトピッカーの機能追加を進める必要がありますが、フォトピッカーにカメラセルを追加する、エラー画面を追加するといった変更はそれぞれ独立したタスクとして進めることができます。また、新しいCoordinatorを追加するのもFeatureToggleを利用することでユーザーの目に触れることなく開発を進められることがわかりました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/tera_ny/20241113/20241113120551.png" width="1200" height="821" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ここまで調査時間含めて3h程度で整理、設計をし、やっとある程度正確に工数を見積もることができました。これで他Epicイシューとの優先順位を決めタスクとして進めることができるようになります。</p> <h2 id="まとめ">まとめ</h2> <p>フォトピッカー改善タスクを例にタスクの分解とリリースを跨いだ機能開発の事例についてご紹介しました。特に、大きなタスクを細かく分解するためにモバイル特有のリリース制約を考慮し、対応が必要な細かいタスクをリストアップする進め方は他のタスクにも応用できると思います。</p> <p>また、今回は遷移先のコントロールにFeature Toggleを活用し、比較的綺麗な設計を実現しましたが、Feature Toggleの運用には注意点も存在します。実際に、Feature Toggleを使ったアプローチがうまくいかなかったケースもいくつか経験しています。機会があれば、Feature Toggle単体のお話もできればと思います。</p> <p>この記事で紹介した方法や考え方が、皆さんの今後のアプリケーション開発において何かしらの参考になれば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-80add568" id="f-80add568" name="f-80add568" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">レシピ事業部では1スプリントを一週間で回しているため週次リリースを行っています</span></p> <p class="footnote"><a href="#fn-4fa7bcab" id="f-4fa7bcab" name="f-4fa7bcab" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">一般的にはFeature Flagとも呼ばれている(<a href="https://martinfowler.com/articles/feature-toggles.html">https://martinfowler.com/articles/feature-toggles.html</a></span></p> <p class="footnote"><a href="#fn-d7238a0c" id="f-d7238a0c" name="f-d7238a0c" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://firebase.google.com/products/remote-config?hl=ja">https://firebase.google.com/products/remote-config?hl=ja</a></span></p> </div> tera_ny ちょっと複雑なサイドバーをHotwireで簡単に作りたい hatenablog://entry/6802418398303359771 2024-11-13T13:00:00+09:00 2024-11-13T13:00:00+09:00 こんにちは、レシピ事業部プロダクト開発グループの渡邉(@taso0096)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました*1。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。 デスクトップ版で表示されるサイドバー ちょっと複雑なサイドバー … <p>こんにちは、レシピ事業部プロダクト開発グループの渡邉(<a href="https://x.com/taso0096">@taso0096</a>)です。 クックパッドは最近、One Experienceというプロジェクトによって日本版とグローバル版のシステムが統合されました。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe> どちらのシステムもRailsで実装されているという点は同じですが、統合先となったグローバル版ではHotwireが使われていました<a href="#f-15f87afa" id="fn-15f87afa" name="fn-15f87afa" title="日本版のスマートフォンWebの主要ページではNext.jsが採用されていました( https://techlife.cookpad.com/entry/2020/12/01/093000 )">*1</a>。そのため、One Experience関連の開発ではHotwireが積極的に活用されています。本記事ではそんなHotwireの多くの機能が使われたデスクトップ版のサイドバーについてご紹介します。</p> <p><figure class="figure-image figure-image-fotolife" title="デスクトップ版で表示されるサイドバー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taso0096/20241112/20241112132100.png" width="1200" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>デスクトップ版で表示されるサイドバー</figcaption></figure></p> <h1 id="ちょっと複雑なサイドバー">ちょっと複雑なサイドバー</h1> <p>One Experienceに伴い、グローバル版にもともと存在したUIのまま移行するのではなく、いくつか画面構成の変更を入れる事になりました。特にデスクトップ版においては、自分のコンテンツにより素早くアクセスできるようにするためにサイドバーの導入が決まりました。</p> <p>このサイドバーでは、一般的なナビゲーションメニューのほかに「きろく」と呼ばれるコンテンツを表示しています。「きろく」とはユーザの保存レシピや投稿レシピなどを整理するための機能です。また、「きろく」に保存したレシピはユーザーさんの手で、フォルダを作成して分類できます。このとき、レシピやフォルダの数が多いユーザーさんでも充分素早く表示させたいです。更に、サイドバーの外でレシピが保存されたとき、リロードせずともサイドバーの中の表示も追従する必要があります。以上を整理すると、作りたいものは以下のようなものということになります。</p> <ul> <li>コンテンツが多く読み込みに時間がかかる場合を考慮して非同期で読み込む</li> <li>フォルダが多い場合でも全てのフォルダを読み込める</li> <li>レシピの保存やフォルダの作成などの操作時に表示を同期する</li> </ul> <p>これらの要件はHotwireを使えば簡単に実装することができます。ここではHotwireの各機能を軽く説明しつつ、それぞれどういった実装をしたのかご紹介します。</p> <h1 id="Turbo-Frames">Turbo Frames</h1> <p>Turbo Framesとはページ全体をリロードせずに部分的な更新を可能にするための機能です。部分更新したい箇所をTurbo Framesのタグで囲うことで、そのタグ内の部分更新が行われます。また、iframeのようにURLを指定することで全く別のページを埋め込むことも可能です。この場合はタグの中身が最初にレンダリングされ、その後に非同期で別のページが読み込まれます。</p> <p>今回実装したサイドバーでは、非同期でコンテンツを読み込むためにTurbo Framesを利用しました。そのためにTurbo Framesで表示される専用ページを新規作成し、図の赤枠内で読み込むようにしました。この赤枠の部分ではローディングが最初に表示され、ページの読み込み後に非同期でコンテンツが読み込まれます。</p> <p><figure class="figure-image figure-image-fotolife" title="Turbo Framesの範囲"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taso0096/20241112/20241112132506.png" width="1200" height="689" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Turbo Framesの範囲</figcaption></figure></p> <p>多くの場合、Rails側で実装してある既存のページのロジックなどをそのまま流用して簡単に埋め込めるというのがTurbo Framesのメリットかと思います。一方、この例では新規ページをわざわざ作成しました。これはフォルダのページネーション実装をシンプルにするためで、Stimulus で実装しています。詳細は後ほど解説します。</p> <p>なお、Turbo Framesはlazy loadingも可能であり、今回も設定しています。サイドバーはデスクトップ版ではスクロール位置などに関係なく常に表示されるものですが、スマホ版だとそうではないためです。</p> <h2 id="ページ遷移に伴うリセット">ページ遷移に伴うリセット</h2> <p>Turbo Framesによって非同期でのコンテンツの取得が実現できました。しかし、このままではページ遷移するたびにコンテンツの再取得が行われてしまいます。場合によってはそれでも問題ないですが、今回はページネーションによって読み込まれたフォルダの情報がリセットされることを避けたいと考えました。そうでなくともページ遷移する度にサイドバーがローディングによって一瞬使えなくなることはかなり不便だと思います。</p> <p>そこでdata-turbo-permanent属性というものを使用しました<a href="#f-4d37b0c5" id="fn-4d37b0c5" name="fn-4d37b0c5" title="https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads">*2</a>。以下のようにこの属性が付与されたDOMはページ遷移時にもDOMが維持されます。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier">&lt;</span><span class="synStatement">div</span><span class="synIdentifier"> </span><span class="synType">data</span><span class="synIdentifier">-turbo-permanent&gt;</span>sidebar<span class="synIdentifier">&lt;/</span><span class="synStatement">div</span><span class="synIdentifier">&gt;</span> </pre> <p>これにより一度読み込んだTurbo Framesのコンテンツは通常の画面遷移ではリセットされなくなります。これを再度読み込むにはブラウザのリロードやJavaScriptによる再読み込みの処理が必要になります。</p> <h2 id="レスポンス">レスポンス</h2> <p>Turbo Framesが取得するHTMLはページに埋め込まれる部分だけではありません。layoutテンプレートこそ使用されませんがActionViewでレンダリングされたページ全体がレスポンスとして返されます。そのため、ActionViewの中の一要素のみを埋め込みたいといった場合はそれ以外のレスポンスは破棄されてしまいます。おおよそのレスポンスは以下のようになっており、ブラウザ側のHotwireランタイムで解釈されて画面に埋め込まれます。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier">&lt;</span><span class="synStatement">html</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span><span class="synStatement">head</span><span class="synIdentifier">&gt;&lt;/</span><span class="synStatement">head</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span><span class="synStatement">body</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span><span class="synStatement">div</span><span class="synIdentifier">&gt;</span>破棄される要素<span class="synIdentifier">&lt;/</span><span class="synStatement">div</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-<span class="synStatement">frame</span><span class="synIdentifier"> </span><span class="synType">id</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>埋め込みたい要素<span class="synIdentifier">&lt;/</span>turbo-<span class="synStatement">frame</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;/</span><span class="synStatement">body</span><span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;/</span><span class="synStatement">html</span><span class="synIdentifier">&gt;</span> </pre> <p>多少の無駄は生じてしまいますが、上でも書いたように既存のページをほぼそのまま流用可能であるというメリットの方が大きいと考えます。なお、今回は新規ページを作成しActionView全体が埋め込まれる形にしたため、そもそも破棄される要素は存在しません。</p> <h1 id="Turbo-Streams">Turbo Streams</h1> <p>Turbo Streamsとはリアルタイムでのデータ更新を簡単にするための機能です。ページに対してDOMの追加・変更・削除などが可能であり、複数箇所を同時に更新することもできます。今回はレシピを新規で保存した際のレシピ数の更新や、フォルダ自体の編集を反映するために使用しました。</p> <h2 id="レスポンス-1">レスポンス</h2> <p>Turbo Framesと違い、Turbo Streamsではページ全体ではなく、差分のみをサーバーからレスポンスします。DOMをどのように扱うかについてはTurbo Streamsのアクションによって指示されます。例えばユーザが新規でレシピをフォルダに追加した場合を考えます。この場合は画面の赤枠部分が全て更新されます。</p> <p><figure class="figure-image figure-image-fotolife" title="Turbo Streamsによって更新される要素"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/taso0096/20241112/20241112133016.png" width="1200" height="690" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Turbo Streamsによって更新される要素</figcaption></figure></p> <p>この時に返されるレスポンスはおおよそ以下のようになります。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;replace&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>保存ボタンのHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;replace&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>ドロップダウンのHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;replace&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>「きろく」の「すべて」のHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;replace&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>「きろく」の「保存済み」のHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;prepend&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>「きろく」の「新規フォルダ」のHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> <span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;replace&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;dom_id&quot;</span><span class="synIdentifier">&gt;</span>通知のHTML<span class="synIdentifier">&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> </pre> <p>このレスポンスをHotwireランタイムが解釈して画面の更新が行われます。</p> <h2 id="リダイレクト">リダイレクト</h2> <p>サイドバーの同期を実装するにあたって、これまでは画面の更新が不要だったいくつかの既存のリクエストのformatã‚’HTMLからTurbo Streamsに置き換える必要がありました。基本的に問題なく置き換えることが可能でしたが、リダイレクトが必要な場合は少し工夫する必要がありました。</p> <p>例えばユーザがフォルダのページからそのフォルダを削除した場合を考えます。この場合はサイドバーの「きろく」から対象のフォルダのDOMを削除した上で「きろく」のトップにリダイレクトするという仕様になっています。これはデフォルトのアクションでは対応できないため、以下のようなリダイレクトのためのカスタムアクションを追加することで対応しました。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink>Turbo<span class="synStatement">.</span>StreamActions<span class="synStatement">.</span>redirect <span class="synStatement">=</span> <span class="synStatement">function</span> <span class="synSpecial">()</span> <span class="synSpecial">{</span> Turbo<span class="synStatement">.</span><span class="synIdentifier">visit</span><span class="synSpecial">(</span><span class="synStatement">this.</span>target<span class="synSpecial">)</span> <span class="synSpecial">}</span> </pre> <p>View側では通常のアクションとほとんど同じように呼び出すことが可能です。</p> <pre class="code" data-lang="" data-unlink>&lt;%= turbo_stream.action :redirect, path %&gt;</pre> <p>実際のレスポンスは以下のようになり、Hotwireランタイムで解釈されてリダイレクトが実行されます。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier">&lt;</span>turbo-stream<span class="synIdentifier"> </span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;redirect&quot;</span><span class="synIdentifier"> </span><span class="synType">target</span><span class="synIdentifier">=</span><span class="synConstant">&quot;path&quot;</span><span class="synIdentifier">&gt;&lt;/</span>turbo-stream<span class="synIdentifier">&gt;</span> </pre> <p>カスタムアクションは任意のJavaScriptを簡単に実行できるためかなり便利な機能です。しかし、だからと言ってデフォルトアクションで対処可能な機能に対してカスタムアクションを作成してしまうとコードの一貫性が失われてしまうため注意が必要です。</p> <h1 id="Stimulus">Stimulus</h1> <p>StimulusとはHTMLとJavaScriptを適切に切り離して書くための枠組みです。これによってHTMLだけを見た時にどんな挙動をするのかわかりやすくしたり、コード自体の再利用性を高めるメリットがあります。CSSがHTMLのclass属性を介して紐づいているように、StimulusではHTMLのデータ属性を介して任意のJavaScriptによる操作を可能にします。このJavaScriptはcontrollerという単位で分けられており、controllerのメソッドをDOMのライフサイクルやイベントをフックに実行するというのが主な機能です。これによって再利用しやすいJavaScriptになるような仕組みになっています。今回はページネーションのための無限スクロールとアクティブ状態の更新のために使われました。</p> <h2 id="無限スクロール">無限スクロール</h2> <p>Turbo Framesのセクションでフォルダのページネーションについて解説しましたが、より使いやすくするために無限スクロールに対応します。グローバル版には元々無限スクロール用のcontrollerが実装されていたためこれをそのまま使用しました。実装としてはシンプルで、スクロールイベントを監視し閾値を超えたら次のページを非同期通信で読み込むというものです。Stimulusのtargetsとvaluesの機能を使ってリストの中身はどのDOMなのか、次のページのURLは何なのかといった情報を管理しています。</p> <p>なお、Hotwireにおける無限スクロールの実装としてTurbo Framesの遅延読み込みを活用したものもあります。この場合は自分ではJavaScriptを一切書かずに無限スクロールの実装が可能です。ただし、単純なページネーションにcontrollerを登録だけすれば済むStimulusと比較すると、Viewに少し手を入れる必要がある点には注意が必要です。今回は便利に使える既存実装があったのでTurbo Framesによる無限スクロールをあえて採用するようなことはしませんでした。</p> <h2 id="アクティブ状態の更新">アクティブ状態の更新</h2> <p>Turbo Framesのセクションで説明したように、サイドバーは画面遷移によるリセットを回避するためにdata-turbo-permanent属性が指定されています。しかし、これによって現在のページに応じて「きろく」のリンクのアクティブ状態を更新する機能が壊れてしまいました。これに対処するにはJavaScriptによってページ遷移を検出してアクティブ状態を更新する必要があります。作成したcontrollerの中身自体は単純なものですが、メソッドを呼び出すためのイベントの指定だけ少し特殊になっています。Stimulusではdata-action属性を使ってどのイベントにフックしてメソッドを実行するか指定することができます。このとき、基本的には属性が指定されたDOMに対するイベントを参照しますが、ページ遷移のようなグローバルのイベントフックしたい場合は<code>@document</code>のようなsuffixを指定することで対応できます<a href="#f-9a1db471" id="fn-9a1db471" name="fn-9a1db471" title="https://stimulus.hotwired.dev/reference/actions#global-events">*3</a>。</p> <pre class="code lang-html" data-lang="html" data-unlink><span class="synIdentifier">&lt;</span><span class="synStatement">div</span><span class="synIdentifier"> </span><span class="synType">data</span><span class="synIdentifier">-</span><span class="synType">action</span><span class="synIdentifier">=</span><span class="synConstant">&quot;turbo:visit@document-&gt;controller#method&quot;</span><span class="synIdentifier">&gt;&lt;/</span><span class="synStatement">div</span><span class="synIdentifier">&gt;</span> </pre> <h1 id="まとめ">まとめ</h1> <p>Hotwireを活用したちょっと複雑なサイドバーの実装についてご紹介しました。Hotwireの仕組みを利用することでインタラクティブなUIのためのJavaScriptをほとんど書かずに主要な機能の実装ができたかと思います。個人的には元々はNext.jsを書いていたこともありJavaScriptは好きですが、Railsを書く上でHotwireはかなり良くできた仕組みだと感じています。Hotwireはまだ使い始めたばかりの技術ですので、新しい知見が溜まったらまた共有したいと思います。</p> <div class="footnote"> <p class="footnote"><a href="#fn-15f87afa" id="f-15f87afa" name="f-15f87afa" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">日本版のスマートフォンWebの主要ページではNext.jsが採用されていました( <a href="https://techlife.cookpad.com/entry/2020/12/01/093000">https://techlife.cookpad.com/entry/2020/12/01/093000</a> )</span></p> <p class="footnote"><a href="#fn-4d37b0c5" id="f-4d37b0c5" name="f-4d37b0c5" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads">https://turbo.hotwired.dev/handbook/building#persisting-elements-across-page-loads</a></span></p> <p class="footnote"><a href="#fn-9a1db471" id="f-9a1db471" name="f-9a1db471" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://stimulus.hotwired.dev/reference/actions#global-events">https://stimulus.hotwired.dev/reference/actions#global-events</a></span></p> </div> taso0096 Simple Custom Compose Layout hatenablog://entry/6802418398301565839 2024-11-12T13:14:00+09:00 2024-11-12T13:14:00+09:00 こんにちは、「ウィリアム」です。クックパッドの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 … <p>こんにちは、「ウィリアム」です。クックパッドのAndroidエンジニアです。 私の日本語はまだ上手ではないので、これから英語で書きます!</p> <h3 id="Self-introduction-自己紹介">Self-introduction (自己紹介)</h3> <p>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, よろしくお願いします!</p> <h3 id="Statement">Statement</h3> <p>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.</p> <blockquote><p>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.'</p></blockquote> <p>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.</p> <p>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!</p> <h3 id="Recent-Challenges-We-Faced-in-Android-Development">Recent Challenges We Faced in Android Development</h3> <p>Have you ever received a UI/UX requirement that isn't natively supported out of the box? One such requirement we received recently is:</p> <blockquote><p>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.</p></blockquote> <p><figure class="figure-image figure-image-fotolife" title="Recipe Card"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241110/20241110215219.png" width="962" height="426" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Recipe Card</figcaption></figure></p> <p>Above is an existing Compose component that we have currently in <code>Your Collection</code> tab screen (also known as <code>きろく</code> 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:</p> <p><figure class="figure-image figure-image-fotolife" title="Recipe card components"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241110/20241110222712.png" width="962" height="426" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Recipe card components</figcaption></figure></p> <ol> <li>Recipe title</li> <li>Recipe ingredient list</li> <li>Recipe author information</li> <li>Recipe actionable buttons</li> <li>Recipe image with「公開済み」label, this label appears when it is a published recipe belonging to the user.</li> </ol> <h3 id="The-Problem">The Problem</h3> <p><figure class="figure-image figure-image-fotolife" title="When there&#x27;s not enough space"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241110/20241110223644.png" width="794" height="352" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>When there&#x27;s not enough space</figcaption></figure></p> <p>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.</p> <p>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 <code>160.dp</code>.</p> <p>So, there’s a <code>Modifier.weight(1f)</code> and a <code>Modifier.height(160.dp)</code> applied somewhere...</p> <p>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.</p> <h3 id="How-to-fix-it">How to fix it</h3> <p>Now we need to make the ingredient list (2) disappear if there isn’t enough space for it, let's dissect the component!</p> <p><figure class="figure-image figure-image-fotolife" title="Normal recipe card component"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241110/20241110232900.png" width="1182" height="426" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Normal recipe card component</figcaption></figure></p> <p>From the figure above, we identify the three components of the recipe card: the top, bottom, and optional center (ignoring the recipe image).</p> <p>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.</p> <p>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 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.android.com%2Fdevelop%2Fui%2Fcompose%2Flayouts%2Fcustom" title="Custom layouts  |  Jetpack Compose  |  Android Developers" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.android.com/develop/ui/compose/layouts/custom">developer.android.com</a></cite></p> <h3 id="Its-time-for-the-classic-hello-world-testing">It's time for the classic hello world testing</h3> <p>I decided to try using the <code>Layout</code> Compose component to solve the problem above. Below is the composable preview of <code>hello world</code> test I used to experiment with the composable.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241111/20241111000837.png" width="296" height="158" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <pre class="code" data-lang="" data-unlink>Layout( modifier = modifier, content = { Text(&#34;hello world&#34;) }, ) { measurables, constraints -&gt; val placables = measurables.map { it.measure(constraints) } val text = placables[0] layout(width = constraints.maxWidth, height = constraints.maxHeight) { text.placeRelative(0, 0) } }</pre> <p><code>val placables</code> 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:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241111/20241111000908.png" width="390" height="200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <pre class="code" data-lang="" data-unlink>Layout( modifier = modifier, content = { Text(&#34;hello world&#34;) Text(&#34;goodbye world&#34;) }, ) { measurables, constraints -&gt; 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) } }</pre> <p>Now, <code>val placables</code> is a list containing the <code>Text("hello world")</code> node and the <code>Text("goodbye world")</code> node.</p> <p>When using <code>.placeRelative(x, y)</code> 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.</p> <p>Alternatively, you can use <code>.place(x, y)</code> to position the node, but note that <code>.place(x, y)</code> 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.</p> <h3 id="Implementation">Implementation</h3> <p>So, after familiarising ourselves with <code>Layout</code>, 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:</p> <pre class="code" data-lang="" data-unlink>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 -&gt; { ... }</pre> <p>I wrapped them in <code>Column</code> as it's a column design and I can define the custom view composable parameter as <code>ColumnScope.() -&gt; Unit</code>.</p> <p>The next step is to place them in the layout</p> <pre class="code" data-lang="" data-unlink>Layout( modifier = modifier, content = {...}, ) { measurables, constraints -&gt; { val placables = measurables.map { it.measure(constraints) } val topPlacable = placables[0] val centerPlacable = placables[1] val bottomPlacable = placables[2] layout(width = constraints.maxWidth, height = constraints.maxHeight) { topPlacable.place(0, 0) bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height) } }</pre> <p>The code above will position the top component at the top-start and the bottom component at the bottom-start. Once the top and bottom components are in place, we will need to measure the available vertical space to determine if there is enough room to place the center component, which is the ingredient list.</p> <pre class="code" data-lang="" data-unlink>Layout( modifier = modifier, content = {...}, ) { measurables, constraints -&gt; { val placables = measurables.map { it.measure(constraints) } val topPlacable = placables[0] val centerPlacable = placables[1] val bottomPlacable = placables[2] layout(width = constraints.maxWidth, height = constraints.maxHeight) { topPlacable.place(0, 0) bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height) val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight if (centerPlacable.measuredHeight &lt;= availableVerticalHeight) { centerPlacable.place(0, topPlacable.measuredHeight) } } }</pre> <p>The steps to calculate the available height are as follows:</p> <ol> <li>First, we get the maximum height of the parent layout.</li> <li>Using the parent layout's height, we subtract the measured height of the top component and the measured height of the bottom component.</li> <li>With the remaining vertical height, we compare it to the center component's measured height and determine if there's enough space for it.</li> <li>If there is enough space for the center component, we place it below the top component. Otherwise, we do nothing.</li> </ol> <p>With that in place, we can now use it in the recipe card composable and check if it works! The results are as follows as we tweak the device's font size and screen size:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241111/20241111005817.png" width="1200" height="256" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Yay! It works, and now it's in production! It seems to be fine, as there haven't been any crash logs related to it, so that's good news... so far.</p> <p>Below is the actual display of the custom view in my device after changing the default system font size from x1.0 to x1.3.</p> <p><figure class="figure-image figure-image-fotolife" title="How it looks like in my Samsung S23 Ultra device with system font size enlarged by x1.3"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/kinyeutan/20241111/20241111010158.jpg" width="560" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>How it looks like in my Samsung S23 Ultra device with system font size enlarged by x1.3</figcaption></figure></p> <p>If the top component or the bottom component takes up more vertical space than is available, they will overlap. However, this depends on the specific feature using the layout. In our current use case, this is acceptable, since the top component is simply a recipe title with a maximum of two lines, and the bottom component is just author information (with a maximum of one line) and actionable buttons with fixed sizes. Therefore, we don't need to handle this edge case at this time, according to the YAGNI (You Aren't Gonna Need It) principle.</p> <h3 id="The-final-code">The final code</h3> <pre class="code" data-lang="" data-unlink>@Composable fun ResponsiveColumn( top: @Composable ColumnScope.() -&gt; Unit, center: @Composable ColumnScope.() -&gt; Unit, modifier: Modifier = Modifier, bottom: @Composable ColumnScope.() -&gt; Unit ) { Layout( modifier = modifier, content = { Column { top() } Column { center() } Column { bottom() } }, ) { measurables, constraints -&gt; val placables = measurables.map { it.measure(constraints) } val topPlacable = placables[0] val centerPlacable = placables[1] val bottomPlacable = placables[2] layout(width = constraints.maxWidth, height = constraints.maxHeight) { topPlacable.placeRelative(0, 0) bottomPlacable.place(0, constraints.maxHeight - bottomPlacable.height) val availableVerticalHeight = constraints.maxHeight - topPlacable.measuredHeight - bottomPlacable.measuredHeight if (centerPlacable.measuredHeight &lt;= availableVerticalHeight) { centerPlacable.placeRelative(0, topPlacable.measuredHeight) } } } }</pre> <h3 id="Closing-statement">Closing statement</h3> <p>Building your custom Composable layout is actually quite straightforward and fun, although I deliberately left out some explanation in the post such as the layout's measurables and constraints.. please do read the Android documentation for their explanation! You can also check out in the code too if you prefers that.</p> <p>There's also another use case for a similar technique inside <code>Modifier.layout {}</code> when implementing a horizontally-scrolling, full-width carousel in a lazy grid layout with content padding. This ensures that the carousel can scroll past the content padding. I’ll write about that in the future if I have the opportunity to do so.</p> <p>If you're interested in the recipe I used in this post, feel free to take a look at the link provided below! Although it’s in English, it’s quite simple and straightforward. It took me a lot of trial and error to get the taste as close as possible to the one I had in Sweden when I was working there.</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fcookpad.com%2Fjp%2Fr%2F16442143" title="Fish soup (fisksoppa) 🇸🇪 Recipe by William" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://cookpad.com/jp/r/16442143">cookpad.com</a></cite></p> <p><span style="font-size: 80%">* Clicking the link should open your Cookpad app if it's installed, it should work even regardless of which Play Store / App Store you installed it from ^^</span></p> <p>I hope to improve my writing and provide more interesting use cases in the future. Thank you very much for reading!</p> kinyeutan モバイルアプリの One Experience hatenablog://entry/6802418398302391771 2024-11-11T11:11:11+09:00 2024-11-11T11:17:13+09:00 こんにちは。レシピ事業部でAndroidアプリ開発をしているこやまカニ大好きです。 好きなイジンカードは行基と近松門左衛門、最近気になるカードは松尾芭蕉です。 このブログの日本とグローバルのクックパッドを統合しましたという記事で、日本とグローバルのクックパッドサービスの統合が行われたこと、プロジェクトの名称が One Experience であったことについて説明がありました。 もちろん One Experience プロジェクトについては Web だけでなくモバイルアプリについても行われており、現在日本向けにリリースされているクックパッドアプリは、クックパッドがサービスを展開しているすべての… <p>こんにちは。レシピ事業部でAndroidアプリ開発をしているこやまカニ大好きです。<br/> 好きなイジンカードは<a href="https://one-draw.jp/ijinden/cardlist/001/cardlist_img.html">行基</a>と<a href="https://one-draw.jp/ijinden/cardlist/003/cardlist_img.html">近松門左衛門</a>、最近気になるカードは<a href="https://one-draw.jp/ijinden/cardlist/003/cardlist_img.html">松尾芭蕉</a>です。</p> <p>このブログの<a href="https://techlife.cookpad.com/entry/2024/10/10/105832">日本とグローバルのクックパッドを統合しました</a>という記事で、日本とグローバルのクックパッドサービスの統合が行われたこと、プロジェクトの名称が One Experience であったことについて説明がありました。<br/> もちろん One Experience プロジェクトについては Web だけでなくモバイルアプリについても行われており、現在日本向けにリリースされているクックパッドアプリは、クックパッドがサービスを展開しているすべての地域での表示に対応した One Experience版のアプリになっています。<br/> この記事では、モバイルアプリの One Experience について、どのような作業が行われたのか大まかに概要を説明したいと思います。</p> <h1 id="概要">概要</h1> <p>前提として、クックパッドでは以前から JPアプリと Globalアプリという2つのアプリがリリースされていました。<br/> この2つのアプリは完全に別のアプリとして開発されていて、コードの共有率はほぼ 0% 、認証やAPIなどバックエンドの構成もまったく異なるものでした。<br/> 今回行ったモバイルアプリの One Experience とは、一言でいうと Globalアプリのコードで JPアプリを上書きしてアップデートする作業になります。<br/> GlobalアプリとJPアプリはどちらも既存ユーザーがたくさんいるため、現段階では2つのアプリを1つに統合することは行わずに、1つのコード、リポジトリから2つのアプリをリリースしていくことにしています。</p> <p>ひとつのリポジトリから複数のアプリを配信するというのは Kindle ストア版アプリの配信やAndroidTVアプリの配信などでよくある構成ですが、それによって別のアプリを完全に上書きするというのはかなり珍しいと思います。<br/> こやまカニ大好きのAndroidアプリ開発歴はそこそこ長いのですが、この作業を行ったのは初めてでした。</p> <p>この記事では、アプリを別のコードベースで上書きする際にどういった考慮が必要だったかを大まかに説明していこうと思います。<br/> One Experience によるモバイルアプリの機能面の変更や細かい技術上の工夫などは後続の別記事で説明されていく予定なので、この記事ではJPアプリの上書きに必要だった作業の概要について説明していきます。<br/> また、この記事では主に Android の用語で説明していきますが、 iOS アプリについてもだいたい同じような雰囲気だと思ってください。</p> <h2 id="モバイルアプリ固有の特性">モバイルアプリ固有の特性</h2> <p>詳しい作業内容に入る前に、モバイルアプリのリリースに関する特性について説明したいと思います。<br/> すでにモバイルアプリの開発者はよく知っていることですが、この特性が One Experience のリリースを複雑なものにしているため、あらためて説明します。</p> <h3 id="ロールバックが難しい実質できない">ロールバックが難しい(実質できない)</h3> <p>モバイルアプリは、常に以前のものよりも大きい version code を持つアプリでしか上書きできません。<br/> この特性により、一度 One Experience版で上書きされたアプリをJP版にロールバックするためには、JP版のバージョンを One Experience版よりも大きい値に変更した上で再度上書きする必要があります。<br/> この方法でロールバックを行うと2つのリポジトリ間でバージョンを細かく管理する必要があるため、リリースフローがとても複雑になります。</p> <p>さらに、JP版へのロールバックを行った場合、JP版 -> One Experience版 とアップデートしたユーザーだけでなく、 One Experience版を新規にインストールしたユーザーもJP版で上書きされてしまいます。<br/> JP版 -> One Experience版 へのアップデート時に認証情報や一部のローカルデータをマイグレーションすることは決めていましたが、逆方向のアップデートやJP版 -> One Experience版 ã‚’2回繰り返した場合のサポートはあまりにも大変すぎるため、リリースに関する制約として <strong>One Experience版からのロールバックは行わない</strong>と決めました。<br/> これにより考慮事項がかなり減り、 One Experience に集中して進めていく意思表示にもなったので、この意思決定ができたことは良かったと思います。</p> <h3 id="アプリがユーザーの手に届くまでに時間がかかるbugfix-や挙動変更が瞬時に適用できない">アプリがユーザーの手に届くまでに時間がかかる(bugfix や挙動変更が瞬時に適用できない)</h3> <p>モバイルアプリでは、アプリをサブミットしたあともリリースされるまでに審査があり、さらに公開したあともユーザーの端末にインストールされるまでは時間がかかります。<br/> これは bugfix などのリリースでも同様で、不具合を修正してもユーザーの手元の不具合が発生していたバージョンを上書きするためには数日掛かる場合もあります。</p> <p>特に One Experience リリースの初期段階では様々な不具合が予想されたため、プラットフォームの<a href="https://support.google.com/googleplay/android-developer/answer/6346149?hl=ja">段階的な公開</a>機能を利用し、様子を見ながら少しずつ公開率を上げていくことにしました。 One Experience では、初回のリリースから100% リリースまでおよそ3週間掛かっています。 ロールバックが難しい&amp;更新に時間がかかるという状況で後述の認証情報のマイグレーションが失敗すると何もかもおしまいになってしまうので、初回のリリースからマイグレーションに成功したユーザーが観測されるまではかなりドキドキしていました。</p> <h2 id="JP版アプリをリリースするために行った作業">JP版アプリをリリースするために行った作業</h2> <p>ここでは One Experience リリース時に実装が必要だった項目について簡単に列挙します。<br/> ここにあげた項目以外もたくさんの修正が入っていますが、特に重要なものについて記述しています。</p> <h3 id="日本リージョンへの対応">日本リージョンへの対応</h3> <p>Globalアプリはもともと多言語対応していたので、アプリ内に ja-JP リージョン設定を追加し、翻訳リソースを追加すれば日本語対応できる状態でした。<br/> 文字列の翻訳や画像以外でもヘルプページのURLやごく一部の実装はリージョンごとに処理を切り替えていて、 One Experience では ja-JP リージョン特有の処理もいくつか追加しています。<br/> ja-JP リージョンだけの分岐が将来的になくなるのか、そういう仕様のままでずっといくのかはまだ決まっていない箇所もあり、今後はこういったリージョン固有の実装箇所の保守性も高めていけると良いなと考えています。</p> <h3 id="JPアプリビルド設定-の追加">JPアプリビルド設定 の追加</h3> <p>Globalアプリ(Android)プロジェクトにはもともと Flavor によってビルドするアプリの ApplicationId ã‚„ versionName などを切り替える機能が実装されていました。<br/> JPアプリではこういった切り替えはすべてモジュールを切り替えることで行っていたので、 Flavor で切り替えることに少し抵抗があったのですが、元々の実装をベースにリファクタリングを加えることで Flavor ベースでの切り替えによって実装することができました。</p> <h3 id="バージョニング">バージョニング</h3> <p>JP版アプリは数年前からリリース年・リリース週番号ベースで自動的にバージョンを採番していました。<br/> 対してGlobal 版は手動のセマンティックバージョニングで、 major、minor を繰り上げるタイミングについてはあまり明確になっていませんでした。<br/> JP アプリのほうが version code が大きかったこと、週次リリースというフローはOne Experience後も変わらなかったことから、JP/Global両方のアプリでリリース年・リリース週番号ベースのバージョニングに合わせることにしました。</p> <h3 id="リリースフロー">リリースフロー</h3> <p>もともとJP版アプリはかなり自動化された<a href="https://techlife.cookpad.com/entry/2018/09/14/090000">週次のリリースフロー</a>を採用していました。<br/> Globalアプリも週次リリースでしたが、運用方法には大きな違いがありました。<br/> 一番大きな違いはコードフリーズやサブミットのタイミングが自動化されておらず、リリースマネージャーとなった人間が手動でタイミングを決めていたことです。<br/> Global 版アプリでは機能や画面の更新に対して翻訳リソースの更新を待つ必要があり、持ち回りでリリースマネージャーになった人間の活動時間にも時差があったため、リリースマネージャーがコードフリーズのタイミングを調整できたほうが都合が良かったのです。<br/> 最初はJPのように自動化したほうが良いと考えていましたが、 Global 版をベースに開発していくうちに考えを改め、 Global版のリリースフローに合わせることにしました。<br/> 現在のリリースフローでは、コードフリーズ、サブミット、リリースなどの処理が Global と JP でほぼ完全に同期して行われています。</p> <h3 id="Firebase-プロジェクトの切り替え">Firebase プロジェクトの切り替え</h3> <p>JP と Global はそれぞれ別々に開発・運用されているアプリだったため、 Firebase プロジェクトも完全に分離されている状態でした。<br/> 最初はビルドするアプリによって Firebase プロジェクトを切り替える方針も検討したのですが、以下の理由により、 Firebase プロジェクトを Global が使っているものに統一することにしました</p> <ul> <li>社内の push 通知送信サービスがマルチproject をサポートできるように改修が必要</li> <li>コードベースが完全に切り替わるため、Crashlytics に送られるクラッシュ情報の傾向が大きく変わる <ul> <li>One Experience 以降のクラッシュ情報だけ管理できれば良い</li> </ul> </li> <li>アプリ内の Firebase Analytics ログが完全に切り替わるため、 Firebase Analytics のログ内容が大きく変わる <ul> <li>One Experience前後の比較がしたいので連続性はあったほうが良いが、もともと重要なログは FirebaseAnalytics ではなく自前のログで比較する文化だったので、致命的ではない</li> </ul> </li> <li>Firebase Dynamic Links を複数のプロジェクトから生成したくない</li> </ul> <h3 id="アプリのマイグレーションについて">アプリのマイグレーションについて</h3> <p>JP版アプリから One Experience版アプリにアップデートしたとき、きちんとマイグレーション処理を実装していなければアプリからログアウトし、すべてのローカルデータにアクセスできなくなります。<br/> One Experience版アプリでは、JP版アプリを利用していたユーザーがそのまま利用できるように、認証情報や一部のローカル保存情報にアクセスできるように特別な実装を入れています。</p> <p>特に認証情報のマイグレーションに関してはこれだけのために Globalアプリに AccountManager の実装を入れていたり色々な仕組みが入っているのですが、説明するとこれだけで一つの記事になってしまうのでまた別の機会に書くことにします。</p> <h2 id="まとめ">まとめ</h2> <p>One Experience版アプリをリリースするための取り組みについて説明しました。<br/> 機能面、コード面ではこの記事で紹介した以外でも様々な変更がありますが、ここではプロジェクト全体に関わるような大まかなものに絞って紹介させていただきました。<br/> これからもモバイルアプリの One Experience に関する記事は公開予定なので、今後の更新にもご期待ください。</p> nein37 Flux + Helm における即時ロールバック hatenablog://entry/6802418398299536451 2024-10-28T20:43:40+09:00 2024-10-28T20:43:40+09:00 こんにちは。SRE の小川 (@coord_e) です。先日の投稿にあった通り、クックパッドはレシピサービスをグローバル版に統合しました。サービスの統合に伴って、開発や運用のインフラもグローバルチームで利用されているものを使うことになりました。 運用インフラの中でも特に大きな違いとして、日本とグローバル版ではコンテナオーケストレーションの仕組みが異なっています。日本では Amazon Elastic Container Service (ECS) を使ってコンテナを実行していますが、グローバル版では Amazon Elastic Kubernetes Service (EKS) の上でコンテナ… <p>こんにちは。SRE の小川 (@coord_e) です。先日の投稿にあった通り、クックパッドは<a href="https://cookpad.com/">レシピサービス</a>をグローバル版に統合しました。サービスの統合に伴って、開発や運用のインフラもグローバルチームで利用されているものを使うことになりました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>運用インフラの中でも特に大きな違いとして、日本とグローバル版ではコンテナオーケストレーションの仕組みが異なっています。日本では Amazon Elastic Container Service (ECS) を使ってコンテナを実行していますが、グローバル版では Amazon Elastic Kubernetes Service (EKS) の上でコンテナを実行しています。</p> <p>また開発面ではデプロイフローに大きな違いがあります。日本では、アプリケーションの新しいリビジョンのデプロイは ChatOps によって行なっていました。main ブランチに PR がマージされ、CI パイプラインが新しいリビジョンのコンテナイメージをビルドした後、開発者が Slack チャンネルでコマンドを実行(発言)することで、そのリビジョンのデプロイを行います。日本版での開発フローは下の記事に詳しく記載されています。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2018%2F04%2F02%2F140846" title="Web アプリケーションを把握するためのコンソール - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>一方、グローバル版では、アプリケーションリポジトリでの PR マージ後に自動でデプロイまでが行われます。後に詳しく説明しますが、これは <a href="https://fluxcd.io/">Flux</a> という OSS を活用して実現されています。全体的に GitOps の流れに乗っており、アプリケーションの Git リポジトリへの push を起点としてのちのデプロイの全ての行程が自動で進行するようになっています。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ffluxcd.io%2Fflux%2Fconcepts%2F%23gitops" title="Core Concepts" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://fluxcd.io/flux/concepts/#gitops">fluxcd.io</a></cite></p> <p><figure class="figure-image figure-image-fotolife" title="グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028111530.png" width="1200" height="512" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません</figcaption></figure></p> <p>なお、コンテナオーケストレーションやデプロイの方法含め、One Experience 後にグローバルと日本の間でインフラをどうしていくかは議論の最中です。本稿で紹介する手法は、基本的に短期的に運用上の問題点を解決するためにフォーカスした選択をしています。</p> <h1 id="リバートによるロールバックとその課題">リバートによるロールバックとその課題</h1> <p>さて、新しいリビジョンをデプロイした後に、それが原因となった問題が発覚した場合、その変更を速やかに取り消す必要があります(ロールバック)。これまで、グローバル版ではロールバックはコミットのリバートによって行われていました。変更を取り消すコミットを新たに積み、それをデプロイするという流れです。これは GitOps の流れから逸れることなく、通常のデプロイワークフローに乗ってオペレーションができるという点で優れています。しかし、グローバル版での開発を進めるにつれて、リバートによるロールバックの課題がいくつかわかってきました。</p> <ul> <li>通常のデプロイフローに乗っているため、変更が巻き戻るまで時間がかかります。特に CI 上でのテスト実行のオーバーヘッドが無視できません。 <ul> <li>一部 Flaky なテストも存在しており、それをリトライしているとテストが全て通るまで長い時間がかかってしまう場合があります。</li> <li>さらにデプロイは直列に行われ、直前に他のデプロイが起きているとそのデプロイが終わるまで待つ必要があります。</li> </ul> </li> <li>通常のデプロイフローに乗っているため、変更に承認が必要です。私たちは GitHub 上で main ブランチへのマージに一名以上の Approve を必須としていますが、障害対応においてはこのオーバーヘッドもあります。 <ul> <li>もちろん障害発生時にはこれをバイパスしてマージできるように特権を用意しておく方法もあり得ますが、障害発生時にのみ特権を使うという判断や制御は難しくなることが予想されます。</li> </ul> </li> </ul> <p>基本的に、障害発生時には、ユーザーへの影響を最小限にとどめるために即座にロールバックを完了したいです。しかし、リバートによってロールバックを行うとどうしても時間がかかりすぎてしまいます。もちろんデプロイフローを高速化するのは有効ですが、テストの実行やイメージのビルドは避けられないため、例えば原因の特定から1分以内にロールバックを開始するといったことは難しいでしょう。</p> <p>これまでの日本版の開発では問題発生時に1分もかからずロールバックを開始できていました。今回 One Experience で日本チームがグローバル版の開発に合流しましたが、ロールバック手順が整備されておらず、障害が発生した際にすぐに回復できずに 40 分ほどサービスをダウンさせてしまう出来事がありました。これをきっかけに、日本版での開発と同様にグローバル版にも即時にロールバックを実行できる仕組みを整備することにしました。</p> <p>私たちが利用している Flux では、マニフェストを同期しているリポジトリでのリバートによってロールバックを実現するのが筋のようです<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>。しかし、後述するように私たちはマニフェストリポジトリの自動更新を行っているためそれを止める必要があったり、また Helm Controller のデプロイ待ちの問題があったりと、単なるマニフェストリポジトリのリバートでは即時ロールバックの要件を満たすことができませんでした。そこで、私たちは通常のデプロイフローからは外れた、即時ロールバックのための独自のオペレーションを構築することにしました。</p> <h1 id="GitOps-から外れる-どこで流れを止めるか">GitOps から外れる: どこで流れを止めるか</h1> <p>通常のデプロイフローから外れるということは、自動デプロイの流れをある点で停止することを意味します。まず、現在のグローバル版のデプロイの詳しい流れを下の図に示します。なお、グローバル版では Deployment を含むアプリケーションのリソースは <a href="https://helm.sh/">Helm</a> チャートとしてパッケージ化されており、Helm リリースの Values からデプロイするイメージのタグを注入しています。</p> <p><figure class="figure-image figure-image-fotolife" title="グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028111508.png" width="1200" height="162" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>グローバル版プラットフォームの自動デプロイの流れ。矢印は情報の流れを表しておりリクエストの方向とは必ずしも一致しません</figcaption></figure></p> <ol> <li>アプリケーションのリポジトリで新しいコミットが push されると、CI がコンテナイメージをビルドし ECR リポジトリにイメージを push します。</li> <li>これを Flux の <a href="https://fluxcd.io/flux/guides/image-update/">Image Update Automation 機能</a>が自動的に検知し、アプリケーションに対応する <a href="https://fluxcd.io/flux/components/helm/api/v2/#helm.toolkit.fluxcd.io/v2.HelmRelease">HelmRelease</a> の <code>.spec.values</code> に記述されたイメージのタグを更新するコミットを作成してマニフェストリポジトリへ push します。HelmRelease というのは Flux が Helm のリリースを管理するために用いるカスタムリソースで、<code>.spec.values</code> に Helm リリースの Values を記述しておくと <a href="https://fluxcd.io/flux/components/helm/">Helm Controller</a> が自動で <code>helm install</code> ã‚„ <code>helm upgrade</code> を実行します。</li> <li>マニフェストリポジトリの内容は <a href="https://kustomize.io">Kustomize</a> で構成されており、Flux の <a href="https://fluxcd.io/flux/components/kustomize/">Kustomize Controller</a> がマニフェストリポジトリの内容を自動的にクラスタへ反映するように設定されています。これにより先ほど push された <code>.spec.values</code> の変更がクラスタ内の HelmRelease オブジェクトに反映されます。</li> <li>HelmRelease オブジェクトが変更されると、Flux の Helm Controller がそれを検知し、自動的に <code>helm upgrade</code> を実行します。これによって最終的に新しいイメージのタグが Deployment の spec まで反映され、Deployment のロールアウトが起こります。</li> </ol> <p>ロールバックにおいては、アプリケーションの Deployment の spec に記述されているイメージのタグを問題発生以前のものに書き換えることが目標となります。単に直接 Deployment を書き換えるのは、その後リポジトリに push があると Flux がそれを上書きしてしまうため適切ではありません。では、どのようにしてこれを達成すると良いでしょうか。</p> <h2 id="方法1-Helm-より上流でイメージのタグを戻す">方法1. Helm より上流でイメージのタグを戻す</h2> <p>まず考えられるのが、HelmRelease までの部分でデプロイの流れをせき止め、ロールバック先のイメージのタグを強制的に使わせるという方法です。上の図でいうと、次のどちらかになるでしょう:</p> <ul> <li>イメージのタグを <a href="https://fluxcd.io/flux/components/image/reflector-api/v1beta2/#image.toolkit.fluxcd.io/v1beta2.ImagePolicy">ImagePolicy</a> で固定し、(2) を実質的に停止する</li> <li>(3) を停止し、HelmRelease の <code>.spec.values</code> にあるイメージのタグを直接書き換える</li> </ul> <p>どちらのやり方でも HelmRelease の <code>.spec.values</code> が戻り、それを検知した Flux の Helm Controller が <code>helm upgrade</code> を実行して正しく Deployment の spec にあるイメージのタグを変更し、ロールバックが実現できるでしょう。</p> <p><figure class="figure-image figure-image-fotolife" title="Helm より上流でイメージのタグを戻す場合のロールバック。黄色が ImagePolicy で固定する方法、赤が HelmRelease の .spec.values を書き換える方法"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028111554.png" width="1200" height="282" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Helm より上流でイメージのタグを戻す場合のロールバック。黄色が ImagePolicy で固定する方法、赤が HelmRelease の .spec.values を書き換える方法</figcaption></figure></p> <p>しかし、この方法では長めの待ち時間が発生してしまう問題が考えられます。Helm Controller はデフォルトで <code>helm upgrade</code> 実行後に各種リソースが ready になるまで待つようになっており、私たちもこの挙動を採用しています(<code>helm upgrade --wait</code> と同じ挙動)。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ffluxcd.io%2Fflux%2Fcomponents%2Fhelm%2Fapi%2Fv2%2F%23helm.toolkit.fluxcd.io%2Fv2.Upgrade" title="Helm API reference v2" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://fluxcd.io/flux/components/helm/api/v2/#helm.toolkit.fluxcd.io/v2.Upgrade">fluxcd.io</a></cite></p> <p>そして、Helm Controller はひとつの Helm リリースに対して <code>helm upgrade</code> を直列に行うため、ロールバックしようとした際に進行中のデプロイがあるとそれが終わるまで、すなわち Pod が全部入れ替わって ready になるまで待つことになってしまいます。現状、これには場合によって 5 分を超える時間が必要で、一刻も早くロールバックを行いたい状況においてこれを待つのは適切ではありません。そして、このデプロイ完了待ちを中断する方法は今の所ないようです。もちろん Helm Controller を再起動すれば止まりますが、ロールバック対象の Helm リリースとは関係のない Helm リリースの制御にも影響するため、筋の良いやり方とは言えません。</p> <p>これらの理由から、ロールバックの適用で Helm Controller に頼らない方法を採用する判断をしました。</p> <h2 id="方法2-直接-Helm-リリースのリビジョンを戻す">方法2. 直接 Helm リリースのリビジョンを戻す</h2> <p>Helm には Helm のリビジョン管理があります。<code>helm upgrade</code> のたびに Helm はクラスタ内に Helm リリースの完全なマニフェスト情報の履歴を保存しています。Helm では <code>helm rollback</code> コマンドでその情報を使って以前のリリースの状態を復元することができます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhelm.sh%2Fdocs%2Fhelm%2Fhelm_rollback%2F" title="Helm Rollback" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://helm.sh/docs/helm/helm_rollback/">helm.sh</a></cite></p> <p>これを使う場合、上流から <code>.spec.values</code> が更新された時に Helm Controller がリリースの状態を上書きしてしまわないように HelmRelease の同期を停止する必要があります。上の図でいうと (4) を停止し、最後の Deployment を直接(Helm の実装を使って)書き換えるアプローチになります。この方法なら既に Helm Controller による <code>helm upgrade</code> が進行中でも即座にロールバックを開始できます<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup>。Deployment はロールアウトの途中であっても変更があれば即座に新しいロールアウトを開始するため、<code>helm rollback</code> で Deployment の spec が書き換わり次第すぐにイメージが戻り始めます。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fkubernetes.io%2Fdocs%2Fconcepts%2Fworkloads%2Fcontrollers%2Fdeployment%2F%23rollover-aka-multiple-updates-in-flight" title="Deployments" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://kubernetes.io/docs/concepts/workloads/controllers/deployment/#rollover-aka-multiple-updates-in-flight">kubernetes.io</a></cite></p> <p>この方法は直ちにロールバックが開始できるという点に加えて、イメージのタグ以外の要素のロールバックにも使えるという点で優れています。これまで説明していませんでしたが、私たちはアプリケーションイメージに加えてアプリケーションの Helm チャートそれ自体を変更することがあります。例えば、コンテナに割り当てるリソース量や渡す環境変数を変更する場合がこれにあたります。そのような変更を取り消したいような時にも、<code>helm rollback</code> によって同じ手続きで対応できるわけです。</p> <p><figure class="figure-image figure-image-fotolife" title="helm rollback を使う場合のロールバック"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028111612.png" width="1200" height="268" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>helm rollback を使う場合のロールバック</figcaption></figure></p> <h1 id="ChatOps-コマンドとしての実装">ChatOps コマンドとしての実装</h1> <p>つまり、Flux による HelmRelease の管理を停止し、<code>helm rollback</code> を手元から実行すれば良いのでしょうか?しかし、ロールバックを実行するのは開発者であり、インフラの管理者ではありません。その点で、手元から実行するには次のような問題があります:</p> <ul> <li><code>helm rollback</code> は、そのチャートに含まれるリソースをおおかた更新する操作であり、実行には相応の権限を必要とします。開発者にはそのような権限がありません(し、付与するのも適切ではありません)。</li> <li><code>helm rollback</code> がロールバック先として受け付けるのは Helm リリースのリビジョンであり、アプリケーションの Git リビジョン(コミットハッシュ)ではありません。そして、開発者はアプリケーションのコミットハッシュから Helm リリースのリビジョンを探す方法を知らないかもしれず、対応の遅れや不正確さに繋がります。 <ul> <li>メンバーによって Kubernetes や現行のデプロイフローに対する習熟度はまちまちであり、こういった手動のオペレーションの正しい手順を見つけて実行するのには時間がかかってしまうかもしれません。</li> </ul> </li> </ul> <p>つまり、開発者の代わりに <code>helm rollback</code> を実行する主体が必要になります。Web アプリケーションとして用意するなどいくつか方法は考えられますが、今回は Slack のチャットボットのコマンドを実装してそこから <code>helm rollback</code> を実行することにしました (ChatOps)。ChatOps によるロールバックには次のような利点があります:</p> <ul> <li>ロールバックを行ったという事実が即座に共有され、コミュニケーションが容易になります。</li> <li>記録がよく残り、パーマリンクとして用いることもできます。</li> <li>オペレーションの方法について、実際にそのオペレーションを実行していない人も知ることができ、今後のオペレーションに活かすことができます。</li> </ul> <p>このような性質は、特にロールバックのような迅速な対応とコミュニケーションを両立しなければならない局面において非常に有効です。私たちは日本で長い間 ChatOps によるデプロイとロールバックを実践してきたこともあり、今回も開発者向けのインターフェイスとして ChatOps を採用することに決めました。幸い、私たちは ChatOps コマンドを容易に開発できる基盤を整備しており、今回もそれに乗ることで Slack とのインターフェースについて意識せずに ChatOps コマンドを開発することができました。</p> <h2 id="ロールバックのスラッシュコマンド">ロールバックのスラッシュコマンド</h2> <p>Slack 上で次のように発言することでロールバックを開始できるような実装を作成しました。なお laboty というのは、先ほど説明した内製 ChatOps 基盤の名前です。</p> <pre class="code" data-lang="" data-unlink>/laboty global-web-platform rollback {アプリケーション名} {ロールバック先のコミットハッシュ} </pre> <p>日本の開発フローではデプロイが明示的な操作だったため、ロールバックでは一つ前のリビジョンに戻していました。一方グローバル版の自動デプロイの環境下では、ロールバック先となるリビジョンは明らかではなく、開発者が明示的に指定する必要があります。そこで <code>helm rollback</code> のためにロールバック先の Helm リリースのリビジョンを知る必要がありますが、私たちの実装では開発者が指定したアプリケーションのコミットハッシュから対応する Helm リリースのリビジョンを自動的に決定するようになっています。</p> <p>また、私たちは自動デプロイの結果を Slack チャンネルに通知しています。これには Flux の <a href="https://fluxcd.io/flux/monitoring/alerts/">Alert 機能</a>を使っているのですが、デプロイ進行中に <code>helm rollback</code> で割り込んだ場合にロールバックの完了がデプロイ完了として通知されてしまうおそれがありました。そのため、<code>helm rollback</code> の実行前にデプロイ通知のための Alert も停止しています。</p> <p>まとめると、このスラッシュコマンドは下の操作を順番に行います。</p> <ul> <li><code>flux suspend helmrelease {アプリケーション名}</code></li> <li><code>flux suspend alert {アプリケーション名}-deploy-notifier</code></li> <li><code>helm rollback {アプリケーション名} {ロールバック対象のリビジョン}</code></li> </ul> <p>実際にはそれぞれの操作は CLI の呼び出しではなく Go API を使った Kubernetes API の呼び出しとして実装されています。Flux の suspend 操作は対象オブジェクトの <code>.spec.suspend</code> フィールドを true にすることで実現でき、それを <code>sigs.k8s.io/controller-runtime/pkg/client</code> パッケージを使ってオブジェクトのパッチ操作として実装しています。<code>helm rollback</code> については Helm が Go SDK を提供しており、それをそのまま利用しています。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fhelm.sh%2Fdocs%2Ftopics%2Fadvanced%2F%23go-sdk" title="Advanced Helm Techniques" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://helm.sh/docs/topics/advanced/#go-sdk">helm.sh</a></cite></p> <p><figure class="figure-image figure-image-fotolife" title="/laboty global-web-platform rollback を実際に実行した様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028104825.png" width="1200" height="250" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>/laboty global-web-platform rollback を実際に実行した様子</figcaption></figure></p> <h2 id="復旧のスラッシュコマンド">復旧のスラッシュコマンド</h2> <p>上で説明した通り、今回実装したロールバック操作は、自動デプロイを停止します。これはロールバックを要するような緊急時には適切ですが、アプリケーションリポジトリ上で問題が解消されたらその状態でデプロイを行い、開発を再開するために自動デプロイを再開する必要があります。私たちは、このために次のようなスラッシュコマンドを実装しました。</p> <pre class="code" data-lang="" data-unlink>/laboty global-web-platform recover-from-rollback {アプリケーション名} {回復したコミットハッシュ} </pre> <p>このスラッシュコマンドは、自動デプロイを再開するためのものであるため、回復したコミットハッシュを渡す必要はないと思われるかもしれません。しかし、先に説明した私たちのデプロイフローでは、アプリケーションリポジトリの main ブランチに PR がマージされてから HelmRelease の <code>.spec.values</code> までイメージのタグが伝搬するまである程度時間がかかります。そして開発者が recover-from-rollback を実行したタイミングでまだ回復したコミットが HelmRelease に反映されていないと、自動デプロイを再開した途端に古い(回復前の)イメージのデプロイが始まってしまいます。そのようなミスを防ぎ、意図したコミットのイメージからデプロイが再開することを確実にするため、回復したコミットハッシュを引数として受け取ってそれを確認するような実装にしています。具体的には、このスラッシュコマンドは下の操作を順番に行います。</p> <ul> <li>アプリケーションの HelmRelease の <code>.spec.values</code> に回復したコミットハッシュのイメージタグが記述されていることを確認</li> <li><code>flux resume alert {アプリケーション名}-deploy-notifier</code></li> <li><code>flux resume helmrelease {アプリケーション名}</code></li> </ul> <p><figure class="figure-image figure-image-fotolife" title="/laboty global-web-platform recover-from-rollback を実際に実行した様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/coorde/20241028/20241028104847.png" width="1200" height="166" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>/laboty global-web-platform recover-from-rollback を実際に実行した様子</figcaption></figure></p> <h1 id="まとめ">まとめ</h1> <p>Flux と Helm を使って GitOps をしているグローバル版プラットフォームにおいて、開発者による即時ロールバックを実現した方法について紹介しました。冒頭で紹介したとおり、リバートによってロールバックをしていた時は障害発生から復旧まで40分ほどかかってしまうこともあったのですが、このしくみの導入によって問題の発覚後直ちにロールバックが開始できるようになりました。</p> <p>もちろん障害時にサービスへの影響を最小限にとどめるという意味でもロールバックは大事なことですが、サービス開発の側面においても、いつでも直ちにロールバックできるという認知は速度を上げるために大事だと考えています。今回の即時ロールバックの導入は、そういった面でも意味のある取り組みになったのではないかと思います。</p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> <a href="https://github.com/fluxcd/flux2/discussions/2916">https://github.com/fluxcd/flux2/discussions/2916</a><a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> helm rollback はリリースのステータスが pending-upgrade だったとしても無視します<a href="#fnref:2" rev="footnote">&#8617;</a></li> </ol> </div> coorde 日本とアメリカの太平洋を超えたパフォーマンス改善の取り組み: CloudWatch RUM を中心としたパフォーマンス測定とデータ活用方法 hatenablog://entry/6802418398298034958 2024-10-23T11:00:00+09:00 2024-10-23T11:34:33+09:00 はじめに こんにちは。クックパッド SRE の @mozamimy です。先日この開発者ブログで One Experience プロジェクトについての紹介がありました。 このプロジェクトにおいて、わたしは日本版からグローバル版への移行の際の全般的なパフォーマンス周りについて取り組んでいました。 パフォーマンスと一言でいっても、その中にはネットワークやアプリケーションレイヤでのレイテンシ、MySQL などのミドルウェアでのレイテンシなど、様々な要因が関わってきます。それらの改善において、何よりも重要なのはまず観測することです。移行において取り組んだ様々な作業のうち、ここでは CloudWatch… <h2 id="はじめに">はじめに</h2> <p>こんにちは。クックパッド SRE の <a href="https://x.com/mozamimy">@mozamimy</a> です。先日この開発者ブログで One Experience プロジェクトについての紹介がありました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>このプロジェクトにおいて、わたしは日本版からグローバル版への移行の際の全般的なパフォーマンス周りについて取り組んでいました。</p> <p>パフォーマンスと一言でいっても、その中にはネットワークやアプリケーションレイヤでのレイテンシ、MySQL などのミドルウェアでのレイテンシなど、様々な要因が関わってきます。それらの改善において、何よりも重要なのはまず観測することです。移行において取り組んだ様々な作業のうち、ここでは CloudWatch RUM ã‚„ Calibre といったツールを用いた Web ブラウザからのアクセスのパフォーマンス観測に焦点を当てて紹介します。</p> <h2 id="プラットフォーム移行によるパフォーマンスの劣化をできる限り避けたい">プラットフォーム移行によるパフォーマンスの劣化をできる限り避けたい</h2> <p>先述の One Experience の紹介記事でも書かれている通り、日本版とグローバル版は完全に独立したプラットフォーム上で動作しています。すなわち日本版とグローバル版ではパフォーマンス特性もまったく異なるということです。</p> <p>まず、データの統合による MySQL 関連のデータ増加によるパフォーマンス特性の変化が懸念事項の一つでした。グローバル版と日本版のデータ量を比較すると日本版の方が大きく、統合後にはデータ量が大幅に増加するからです。データ移行の作業の一部である継続的なデータ移行については、先日鈴木による記事が公開されています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F16%2F101605" title="DMS を利用した継続的なデータ変更検知 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>その他にもユーザにとって確実に悪影響があると見込まれていたのが、システムが動作しているリージョンの違いによるレイテンシの増加です。両方とも AWS を利用していますが、日本版は ap-northeast-1 (東京) に、グローバル版は us-east-1 (バージニア北部) にデプロイされています<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>。</p> <p>One Experience ではグローバル版に日本版を統合するという方針になった以上、そのままのアーキテクチャだと日本からのトラフィックは太平洋と北米を横断することになるため、ネットワーク起因のレイテンシの増加は避けられません。この差を埋めるために、</p> <ul> <li>グローバル版の Rails アプリケーションの実装を改善してパフォーマンスを上げる</li> <li>MySQL クエリの改善や適切なキャッシュの利用などでパフォーマンスを上げる</li> <li>フロントエンドの実装を改善して見かけのパフォーマンスを上げる</li> <li>マルチリージョンデプロイ: 日本ユーザから近い場所にサーバを置く</li> </ul> <p>などなど、移行について動き始めた段階でいろいろと改善する余地・手段があることは漠然と分かっていました。ただし取り組みによっては実装・運用コストが大きいため、まずは現状をしっかりと把握して何から取り組んでいくとよいかを考える必要がありました。また、パフォーマンス差を埋めるための判断材料としての計測ももちろんですが、それはそうとして実際に移行を進めていくにあたって日本版とグローバル版のパフォーマンス差の日々の変化をトラックする必要もありました。</p> <p>ユーザから見た総合的なパフォーマンスを測定するための手法として、RUM (Real User Monitoring) および synthetic monitoring があります。One Experience では RUM として Amazon CloudWatch RUM (以下 CloudWatch RUM と表記) を採用し、synthetic monitoring として <a href="https://calibreapp.com/">Calibre</a> を採用しました。CloudWatch RUM の利用にあたっていろいろ工夫した点があるので、本稿では特に CloudWatch RUM について深掘りしていきます。</p> <h2 id="CloudWatch-RUM-の導入と活用するための工夫">CloudWatch RUM の導入と活用するための工夫</h2> <h3 id="RUM-について">RUM について</h3> <p>RUM (Real User Monitoring) はユーザから見た総合的なパフォーマンスを計測する上でメインとなるもので、クライアントサイドで計測したパフォーマンス指標となる数値やエラーなどを収集し、分析・可視化するためのツールです。</p> <p>One Experience においては RUM で収集した <a href="https://web.dev/articles/vitals">Core Web Vitals</a> のうち、LCP (Largest Contentful Paint) ã‚’ KPI として利用することにしました。詳細な説明はリンク先に譲りますが、LCP とはウェブページ上のもっとも大きな面積を占める画像または HTML エレメントが描画されるまでの時間を指します。</p> <p>RUM を実現するためのサービスは Datadog などをはじめいろいろと選択肢はありますが、以下の理由から CloudWatch RUM を選択しました。</p> <ul> <li>競合のソリューションと比べて比較的安価</li> <li>既に AWS を利用しており、社内での手続きのもろもろを省けるため導入のハードルが低い</li> <li>生のログを CloudWatch Logs に出せるので高度な分析がしやすく、AWS の別サービスとの連携が可能</li> </ul> <h3 id="CloudWatch-RUM-の導入">CloudWatch RUM の導入</h3> <p>CloudWatch RUM の Rails アプリケーションへの組み込みや AWS 側でのリソースの準備は特に難しいこともなく、<a href="https://docs.aws.amazon.com/AmazonCloudWatch/latest/monitoring/CloudWatch-RUM-get-started.html">ドキュメント</a>にしたがって以下のような手順を踏めば実際にイベントデータが送信される状態になりました。</p> <ol> <li>Cognito identity pool を用意</li> <li>RUM の app monitor を作成</li> <li>2 で作成した app monitor にログを送信できる権限を持つ IAM role を作成</li> <li>identity pool に IAM role を紐付け</li> <li>Rails アプリケーションのフロントエンドに RUM 用のコードスニペットを追加</li> </ol> <p>対象となる Rails アプリケーションは日本版とグローバル版の両方になりますが、app monitor はあえて一つにしました。日本版・グローバル版ともに同一の web origin である <a href="https://cookpad.com/">https://cookpad.com</a> を利用していたことと、のちほど紹介する分析においてイベントデータが一つの CloudWatch Logs グループにまとまっているほうが都合がよいからです。</p> <p>導入自体は簡単ですが、詳細な分析を行うためにいくつかの設定を行う必要があったので、以下のセクションでそれらについて説明します。</p> <h4 id="CloudWatch-Logs-へのエクスポート">CloudWatch Logs へのエクスポート</h4> <p>イベントデータの詳細な分析をしたい場合、app monitor の作成時に CloudWatch Logs に出力する設定を入れると便利です。またこの際、ロググループに expire を設定しておくとよいでしょう。CloudWatch Logs のコストの多くをログの取り込みが占めるとはいえ、ストレージにかかるコストも無視できません。</p> <h4 id="要件に応じた-attribute-の追加">要件に応じた attribute の追加</h4> <p>Rails アプリケーションの JavaScript コードで RUM クライアントを初期化する際、任意の attribute を追加することができます。今回は、グローバル版か日本版を区別するための railsApp、Rails のコントローラを区別するための railsController、Rails のアクションを区別するための railsAction をそれぞれ設定することで分析しやすくしました。これらを設定しておくことで CloudWatch RUM のコンソールでイベントを絞り込んで分析できます。たとえば、デフォルトで付与される countryCode などの attribute を組み合わせて以下のようにフィルタすると、「グローバル版のレシピ詳細ページに日本からスマートフォンでアクセスしたイベント」に絞り込むことができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135117.png" width="1076" height="430" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="サンプリングレートの設定">サンプリングレートの設定</h4> <p>RUM クライアントの設定の際には適切なサンプリングレートを定める必要があります。100% に近付ければ近付けるほどよりよい精度でデータが得られますが当然コストは増加します。CloudWatch RUM はイベント数による課金なのでトラフィック量からおおよその料金を予測しやすいです。うっかりクラウド破産しないよう、ある程度見積もった上で少ない割合から始めて、AWS Cost Explorer を眺めながら要件や制約に応じて適切に調整するとよいでしょう。</p> <h3 id="収集したデータを-AWS-コンソールから分析トラックする">収集したデータを AWS コンソールから分析・トラックする</h3> <p>イベントデータの収集を開始すれば、以下のスクリーンショットのように CloudWatch RUM のコンソールからフィルタや期間で掘り下げていく形でパフォーマンスについて分析することができます。エラーや JavaScript による HTTP(S) 通信の実行、セッションごとにイベントを確認するなど、インクリメンタルに分析したり、パフォーマンスについてざっと眺めたりできます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135335.png" width="1200" height="1082" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h4 id="75-パーセンタイルで-LCP-を確認したい">75 パーセンタイルで LCP を確認したい</h4> <p>いっぽうで少し融通の効かないところもあり、特に LCP の 75 パーセンタイルの値がコンソール上で確認できないことは問題でした。</p> <p><a href="https://web.dev/articles/defining-core-web-vitals-thresholds">Core Web Vitals</a> から引用した以下の図のように、LCP の良し悪しを判断するしきい値として一般的に 75 パーセンタイルを用いるとよいとされています。平均値ではデータに偏りがある場合に指標として適切でなくなってしまう場合があるからです。もちろん要件によってこの条件をカスタマイズできますが、One Experience においては基本の 75 パーセンタイルで 2.5 秒以内を基準とすることに決めていたので、平均値しか見られないことは問題でした。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135318.png" width="1200" height="597" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この点については AWS に既に要望をあげていますが、これを自力で解決できないか考えてみます。たくさんあるビルディングブロックの組み合わせでユーザごとの要求に柔軟に対応できるのが AWS の強みです。</p> <h3 id="CloudWatch-RUM-で収集したデータを利用しやすいように集計する">CloudWatch RUM で収集したデータを利用しやすいように集計する</h3> <p>さて、ここまでの流れでコンソールに頼らずに RUM のデータを独自に集計して分析したいというモチベーションについて説明しました。このセクションでは、それを実際にどのように実現するかを考えてみます。</p> <h4 id="CloudWatch-Logs-Insights-を利用する">CloudWatch Logs Insights を利用する</h4> <p>はじめに思いつくのが CloudWatch Logs Insights です。RUM のデータは JSON 文字列として CloudWatch Logs にエクスポートされているので自然に Insights が利用できます。Grafana もデータソースとして CloudWatch Logs Insights をサポートしているので、これを利用すれば Grafana でダッシュボードが作れそうです。</p> <p>SQL に親しんでいる身として構文にちょっとクセを感じますが、たとえば以下のようなクエリで、日本からモバイルデバイスでレシピページにアクセスしたときの LCP ã‚’ p75 で集計して求めることができます。漉し器となる filter をパイプでつないで上からレコードを流していき、最後に stats で集計するというイメージですね。</p> <pre class="code plain" data-lang="plain" data-unlink>filter event_type = &#34;com.amazon.rum.largest_contentful_paint_event&#34; | filter metadata.railsApp = &#34;Global&#34; | filter metadata.railsController = &#34;recipes&#34; | filter metadata.railsAction = &#34;show&#34; | filter metadata.countryCode = &#34;JP&#34; | filter (metadata.deviceType = &#34;mobile&#34; or metadata.deviceType = &#34;tablet&#34;) | stats pct(event_details.value, 75) </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135410.png" width="1200" height="321" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これでめでたしめでたし... とはいきません。スクリーンショットの集計結果の <strong>7.8 GB (!)</strong> という値に注目してください。これは見たままスキャン量で、これに比例して金銭的コストとクエリ実行時間がかかります。上述の例では期間を1 日に絞って集計してこのスキャン量となっており、アドホックな分析なら大きな問題になりませんが、期間を伸ばした上で Grafana 上にたくさんペインを作って表示させると、それだけたくさんのクエリが発行されることになってしまうので実用的ではありませんでした。</p> <h4 id="Timestream-を利用したサマリーテーブルの作成">Timestream を利用したサマリーテーブルの作成</h4> <p>このようなシチュエーションは CloudWatch Logs Insights に限らず一般的なデータ分析あるあるです。このような場合、集計を定期実行して専用のテーブルに保存しておくのが常套手段です。</p> <p>ではどこに集計結果を保存するのかということが問題となりますが、ここでは <a href="https://aws.amazon.com/jp/timestream/">Amazon Timestream for LiveAnalytics</a> (以下 Timestream と表記します) を採用しました。Timestream は AWS のマネージドな時系列データベースです。ヘビーユースに耐えることを特長としていますが、ライトな使い方でもコストが非常に少なく済み、雑にデータを入れてクエリできる便利ストレージであることが個人的には魅力だと感じています。DynamoDB も似たような用途で使えますが、シンプルな KVS では微妙にかゆいところに手が届かないユースケースをカバーしているところが好きです。</p> <p>さて、以下に Timestream および Lambda を用いた集計システムの概要を示します。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135704.png" width="746" height="391" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>矢印はイベントデータの流れを示しており、CloudWatch RUM からエクスポートされた生のイベントデータが CloudWatch Logs に送られ、CloudWatch Logs Insights API を叩く Lambda function が Timestream table に結果を保存し、開発者がその Timestream table に Grafana を通してクエリするという形になっています。この Lambda function は EventBridge によって日次で実行されるように設定されています。</p> <p>この Timestream テーブルに対して、たとえば以下のようなクエリを実行すると以下のような結果が返ってきます。SQL 風にクエリできるので脳にやさしいです。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">select</span> * <span class="synSpecial">from</span> cookpad_rum.global_web_lcp_jp <span class="synSpecial">where</span> time <span class="synStatement">between</span> ago(7d) <span class="synStatement">and</span> now() <span class="synSpecial">order</span> <span class="synSpecial">by</span> time <span class="synSpecial">desc</span> ; </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022135841.png" width="1200" height="424" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>d_ プレフィックスをもつカラムを dimension (キーのようなもの) として設定しています。ここではデバイス・Rails アクション・Rails コントローラを dimension にし、それに対して LCP を保存するという形になっています。</p> <p>実際に Grafana ダッシュボードに設定されているクエリは以下のような感じになっています。パーセンタイルやデバイスをダッシュボードのプルダウンから変更できるように Grafana の変数を利用していることや、そのままだとギザギザして分かりにくい傾向をなめらかに見やすくするために移動平均をとっているところがミソです。</p> <pre class="code lang-sql" data-lang="sql" data-unlink><span class="synStatement">select</span> time , <span class="synIdentifier">avg</span>(lcp_p${statistics}_ms) over (<span class="synSpecial">order</span> <span class="synSpecial">by</span> time <span class="synSpecial">rows</span> <span class="synStatement">between</span> <span class="synConstant">5</span> preceding <span class="synStatement">and</span> <span class="synSpecial">current</span> <span class="synSpecial">row</span>) <span class="synSpecial">as</span> JP <span class="synSpecial">from</span> cookpad_rum.global_web_lcp_jp <span class="synSpecial">where</span> d_controller = <span class="synSpecial">'</span><span class="synConstant">recipe</span><span class="synSpecial">'</span> <span class="synStatement">and</span> d_action = <span class="synSpecial">'</span><span class="synConstant">show</span><span class="synSpecial">'</span> <span class="synStatement">and</span> d_device = <span class="synSpecial">'</span><span class="synConstant">$device</span><span class="synSpecial">'</span> ; </pre> <p>ここまでの作業によって、以下のような感じで Grafana で LCP を可視化することができました<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup>。水色の線は One Experience の web 版の移行が完全に完了した日です。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022140001.png" width="1200" height="621" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ちなみに日本版のレシピページの LCP はおよそ 0.9s で、日本からアクセスしたグローバル版の LCP はおよそ 1.3s です。元より悪化していることは否定できませんが、太平洋をまたぐことによる 150ms ほどのペナルティがある状態で、Core Web Vitals で基準とされている 2.5s よりは速いですし、まあまあ悪くないと言って差し支えないのではないでしょうか。</p> <p>このようにして、Timestream を利用して RUM で収集した生のデータを Grafana から利用しやすい形に変えたことで、CloudWatch Metrics ã‚„ Prometheus を通してクエリできる他のシステムメトリクスとあわせて安価かつ高速に表示できるようになりました。また、Grafana の他にも QuickSight などの BI ツールと連携することもでき、Redshift ã‚„ Athena を通してクエリできる多様なデータソースと組み合わせるなど応用の幅が広がります。</p> <h3 id="実際に移行する前に日本からアクセスした場合のパフォーマンスについて知りたい">実際に移行する前に日本からアクセスした場合のパフォーマンスについて知りたい</h3> <p>さて、ここまでの話で、CloudWatch で収集した RUM データの分析ができるようになりました。しかし RUM においては、実際にユーザに RUM クライアントを通じてデータを送ってもらう必要があります。すなわち、実際にリリースするまでデータがとれない、ということです。</p> <p>このような問題があるため、一般的に RUM と synthetic monitoring の両方を使い分けるとよいとされています。synthetic monitoring はサーバ上のヘッドレスブラウザから人工的なリクエストを発生させ、RUM のようにメトリクスを収集してパフォーマンス改善に役立てるツールです。ツールにより機能の差異がありますが、パフォーマンスの数値や描画時の動画、回線状況のシミュレーション (光ケーブルやモバイル回線など)、パフォーマンス改善のためのヒントなどが提供されるのが一般的です。</p> <p>One Experience では、RUM と synthetic monitoring を組み合わせて、以下のようにしてリリース前から観測を行っていました。</p> <ul> <li>グローバル版に台湾からアクセスしたときの RUM データを活用した</li> <li>synthetic monitoring として <a href="https://calibreapp.com/">Calibre</a> を採用してテストを設定した</li> </ul> <p>グローバル版は One Experience 以前から多くの国と地域に向けてサービスを展開しており、台湾もその中の一つです。台湾には日本と似た比較的高速なネットワーク環境があり、地理的にも近い場所にあります。そのため、台湾から送られてくる RUM のデータはベンチマークとして利用しやすいと考えました。</p> <p>台湾の RUM データだけでは不十分なところを補うため、先述の <a href="https://calibreapp.com/">Calibre</a> を導入してグローバル版の日本向けページ (<a href="https://cookpad.com/ja">https://cookpad.com/jp</a>) に対して、アクセス元のロケーションを日本に設定してテストを設定しました。本稿の趣旨から外れるため詳細な説明は割愛しますが、比較的安価で既に利用実績があったことと、我々にとって必要十分な機能を揃えていたのが Calibre を採用した理由です。</p> <p>Synthetic monitoring による決まったページへの人工的なリクエストでは、どうしても回線状況を含めた実際のユーザの状況と乖離してしまう部分はありますが、おおまかな傾向は掴めますしどこに改善する余地があるのかを調査する助けになりました。Calibre をはじめとした synthetic monitoring ツールに実装されている、改善点をアドバイスしてくれる機能も活用しました。</p> <p>定期テスト完了時に webhook を介して外部に通知する機能が Calibre にはあるのですが、この通知を受けて Timestream にデータを格納し、Grafana で可視化する仕組みも作りました。これはこれで Lambda の Function URL を活用していたり、Function URL の弱点を補うちょっとした認証の仕組みを入れてみたり、Timestream を活用したりなど個人的に面白いと思っている要素が詰まった仕組みなのですが、記事がどんどん大きくなるのでここでは割愛します。ともかく、以下のスクリーンショットのような感じで synthetic monitoring のメトリクスも Grafana に出せるようになりました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/m/mozamimy/20241022/20241022140019.png" width="1200" height="293" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h2 id="まとめ">まとめ</h2> <p>本稿では、One Experience におけるパフォーマンス関連の取り組みのうち、ユーザから一番近い場所でのパフォーマンス情報を収集して可視化することにフォーカスして説明しました。</p> <p>これらの仕組みを整えた上で、他のシステムメトリクスや手作業で行うテストによる定性的な問題の調査および、パフォーマンスの観測結果を組み合わせてパフォーマンスを改善してきました。一通り One Experience がリリースされた現在も、クックパッドを快適に使ってもらうためのパフォーマンス改善の取り組みは続いています。</p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> 日本からアクセスする場合、東京で 5ms、米国西部で 100ms、米国東部で 150ms がおおよその RTT の目安とされています<a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> One Experience リリース前のサンプル数が少ないうちからこの仕組みを入れていたため、7 月下旬あたりの数値がとんでもないことになってしまっていますが...。<a href="#fnref:2" rev="footnote">&#8617;</a></li> </ol> </div> mozamimy DMS を利用した継続的なデータ変更検知 hatenablog://entry/6802418398296291522 2024-10-16T10:16:05+09:00 2024-10-16T10:16:05+09:00 SRE の鈴木 (id:eagletmt) です。先日、この開発者ブログで赤松から One Experience プロジェクトについての紹介がありました。 自分もこの One Experiene プロジェクトに携わっており、このプロジェクトが始まったちょうど1年くらい前にはイギリスにあるオフィスに行き、グローバル版のシステムを開発・運用しているメンバーと直接顔を合わせたりもしていました。自分は One Experience プロジェクトにおいて主にデータ移行について担当していました。データ移行に必要な作業はいくつかありますが、この記事ではその中から日本版のシステムのデータベースで発生したデータ… <p>SRE の鈴木 (<a href="http://blog.hatena.ne.jp/eagletmt/">id:eagletmt</a>) です。先日、この開発者ブログで赤松から One Experience プロジェクトについての紹介がありました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F10%2F10%2F105832" title="日本とグローバルのクックパッドを統合しました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>自分もこの One Experiene プロジェクトに携わっており、このプロジェクトが始まったちょうど1年くらい前にはイギリスにあるオフィスに行き、グローバル版のシステムを開発・運用しているメンバーと直接顔を合わせたりもしていました。自分は One Experience プロジェクトにおいて主にデータ移行について担当していました。データ移行に必要な作業はいくつかありますが、この記事ではその中から日本版のシステムのデータベースで発生したデータの変更をどのようにしてグローバル版のシステムのデータベースに継続的に反映したかの部分について紹介します。</p> <h1 id="One-Experience-でのデータ移行">One Experience でのデータ移行</h1> <p>赤松の記事にも書かれていたように、日本版とグローバル版は完全に独立したシステムとして動いており、Aurora MySQL を利用しているという共通点はありつつもデータベースサーバはそれぞれ独立していました。グローバル版のシステムはいくつかのレシピサービスを買収し取り込みながら成長してきた歴史がありますが、取り込み元のサービスを一度停止してからデータを移行するのが基本でした。</p> <p>今回の場合、取り込み元のサービスにあたる日本版のシステムを一度停止してからデータ移行を行うという手段を取る可能性も考えましたが、利用者の多い日本版のシステムに停止期間を設けることには高いリスクがありますし、グローバル版に移行したバージョンのモバイルアプリを段階的にリリースする計画や本番環境の実データを使って少人数に対して事前にユーザテストを行う計画が早期に決定されたので、両方のシステムを稼働させながらダウンタイム無しでデータ移行を行う方針になりました。そのためには日本版のシステムで発生したデータの変更を継続的にグローバル版のシステムに反映するしくみが必要となります。実際、このおかげで One Experience のロールアウトを段階的に安全に進めることができました。</p> <h1 id="データ変更を検知する方法">データ変更を検知する方法</h1> <p>データの変更を継続的に反映するには、まずはデータの変更を検知する必要があります。日本版のシステムでは、マイクロサービス間でデータ連携をするために Ping というライブラリを用意し Rails アプリから Amazon SNS にイベントを発行するようにしていました。Ping については過去の記事 <a href="https://techlife.cookpad.com/entry/2017/05/10/130000">https://techlife.cookpad.com/entry/2017/05/10/130000</a> でも少し触れられています。</p> <p>しかし、この Ping を長年運用しているうちに出てきた課題の1つとして信頼性があまり高くないという点がありました。Rails のレイヤでイベントを発行するため、たとえばデータを更新したのにイベントを発行する実装を忘れてしまったり、データを更新してからイベントを発行するまでの間に予期せぬ例外などで処理が中断してしまったり、イベントを発行する実装を経由しないようなイレギュラーな更新処理を実行してしまったりと、データが更新されているのにイベントが発行されないケースがいくつか考えられました。</p> <p>今回はより信頼性の高い方法として MySQL の binlog を使うことを考えました。binlog であればどのような手段で更新されてもデータの変更を検知できそうです。一方で binlog を扱うようなソフトウェアを自作することにはややハードルがありました。binlog 中のイベントを正しく解釈する必要がありますし、binlog をどこまで読んだかを一貫性を持ってどこかに永続化する必要があったりと、考慮しなければならない点がいくつかありそうです。そこで、AWS Database Migration Service (DMS) に着目しました。DMS はその名の通りデータベース移行用のサービスですが、その機能の1つとして MySQL の binlog を読んで Amazon Kinesis Data Streams にその内容を送信することができます。<br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Task.CDC.html">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Task.CDC.html</a><br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html</a><br/> Kinesis Data Streams にデータをストリームすることができれば、その先は AWS Lambda を起動したり Kinesis Client Library (KCL) を使ったりすることができるので、データの処理方法の幅が一気に広がります。DMS の transformation rules でも簡単なデータの加工はできますが、日本版のシステムとグローバル版のシステムでは MySQL のテーブルのスキーマも当然異なっており自由度高くデータを加工して移行する必要があったため、今回は Kinesis Data Streams から Lambda を起動してデータを加工しながらグローバル版のシステムに継続的にデータ変更を伝えることにしました。</p> <h1 id="DMS-を利用した継続的なデータ変更検知">DMS を利用した継続的なデータ変更検知</h1> <p>DMS を利用した継続的なデータ変更検知の概要は以下のように図示できます。</p> <p><figure class="figure-image figure-image-fotolife" title="DMS を利用した継続的なデータ変更検知"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/e/eagletmt/20241015/20241015161220.png" width="1200" height="335" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>DMS を利用した継続的なデータ変更検知</figcaption></figure></p> <h2 id="Aurora-MySQL---DMS">Aurora MySQL -&gt; DMS</h2> <p>まず、事前準備として Aurora MySQL で binlog を作るよう設定しておきます。これはパラメータグループで設定できます。<br/> <a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_LogAccess.MySQL.BinaryFormat.html">https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/USER_LogAccess.MySQL.BinaryFormat.html</a><br/> Aurora MySQL の binlog を読むような機能は DMS では CDC と呼ばれています。Aurora MySQL をソースとしたエンドポイントを作成し、レプリケーションインスタンスと CDC を指定したレプリケーションタスクを作成すればすぐに用意できます。<br/> また、今回の用途では変更前の値と変更後の値の両方を知りたいという要件がありました。たとえばどのカラムが変更されたのかを知りたい場合、変更前の値が必要になります。DMS の Before Image という機能を使うと変更前の値も含めて Kinesis Data Streams に送ることができます。<br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html#CHAP_Target.Kinesis.BeforeImage">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html#CHAP_Target.Kinesis.BeforeImage</a><br/> この点も DMS の利用を決めた理由の1つです。</p> <p>DMS の CDC を利用する上で注意する必要があるのは LOB の扱いです。LOB とは large binary object を意味する DMS の用語で、Aurora MySQL においては mediumtext 型の値などが該当します。<br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Source.MySQL.html">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Source.MySQL.html</a><br/> LOB のカラムの値はデフォルトでは含まれないため、mediumtext 型などがある場合は LOB に関する設定も必要になります。<br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Tasks.LOBSupport.html">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Tasks.LOBSupport.html</a><br/> そして LOB の扱い方についてはいくつか種類がありますが、Kinesis Data Streams をターゲットとしている場合は Limited LOB mode しか使えないという制限があります。<br/> <a href="https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html#CHAP_Target.Kinesis.Limitations">https://docs.aws.amazon.com/dms/latest/userguide/CHAP_Target.Kinesis.html#CHAP_Target.Kinesis.Limitations</a><br/> また、Before Image 機能を使うときにも LOB には制限があります。今回のケースでは LOB に該当するカラムが一部含まれていたので Limited LOB mode を有効にしつつ、そのカラムは変更前の値を知る必要がなかったのでそのカラムでは Before Image 機能は無効化しました。</p> <h2 id="DMS---Kinesis-Data-Streams">DMS -&gt; Kinesis Data Streams</h2> <p>この部分は DMS のレプリケーションタスクのターゲットに Kinesis Data Streams に向けたエンドポイントを指定するだけなのでとくにこれといった工夫はありません。日本版のシステムはマイクロサービスに分割されていたためいくつかの Aurora MySQL クラスタからデータ移行する必要があり、Aurora MySQL クラスタ毎に DMS のレプリケーションタスクを作成しつつも送信先の Kinesis Data Streams は1つだけにしました。</p> <h2 id="Kinesis-Data-Streams---Lambda">Kinesis Data Streams -&gt; Lambda</h2> <p>Lambda の event source mapping において Kinesis Data Streams を指定することで、Kinesis Data Streams に流れてきたデータを入力として Lambda を起動することができます。<br/> <a href="https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html">https://docs.aws.amazon.com/lambda/latest/dg/with-kinesis.html</a><br/> より効率的にデータを処理するため、ある程度イベントを溜めてからまとめて Lambda を起動することもできます (いわゆる batching)。 batching を行うことでより効率的にデータを処理することができますが、batching でまとめられたイベントの一部だけ処理に失敗したときのことを考慮する必要がでてきます。このような状況に対応するため、Lambda では ReportBatchItemFailures を有効化することで部分的な失敗を扱うことができます。<br/> <a href="https://docs.aws.amazon.com/lambda/latest/dg/services-kinesis-batchfailurereporting.html">https://docs.aws.amazon.com/lambda/latest/dg/services-kinesis-batchfailurereporting.html</a><br/> 今回のデータ移行でもこれを活用し、batching を行いながら必要なリトライ回数が減るようにしています。</p> <h2 id="Lambda---Global-system">Lambda -&gt; Global system</h2> <p>Kinesis Data Streams を経由しながら DMS がキャプチャした変更前の値と変更後の値、そしてそのメタデータを入力として Lambda が起動されます。Lambda はメタデータに含まれているテーブル名や変更が行われたカラム名を見ながら、グローバル版のシステムに必要なデータを組み立てて送信します。データの種類によっては、日本版のシステムでは2つ以上のテーブルで表現していたデータをグローバル版のシステムでは1つのテーブルにマッピングする必要があるケースもあり、DMS がキャプチャした1つのテーブルの変更内容だけでは必要なデータが足りないこともありました。その場合はデータ元の Aurora MySQL クラスタにクエリし直してデータを補うことにしました。厳密な意味では一貫性がとれていない方法ですが、このような楽観的な方法でデータ変更を反映しつつ、後からデータ移行の失敗に気付いたときに移行し直して整合性をとるという方針でデータ移行を進めました。</p> <h2 id="コスト">コスト</h2> <p>この構成においては主に</p> <ul> <li>DMS</li> <li>Kinesis Data Streams</li> <li>Lambda</li> </ul> <p>という AWS のサービスを利用していますが、料金面で支配的だったのは DMS のレプリケーションインスタンス、Kinesis Data Streams のシャード、そして Lambda のログを保存する CloudWatch Logs でした。batching の効果もあり、Lambda の料金はこれらと比べるとほんのわずかです。料金がどれくらい必要かはデータの変更頻度がどれくらいかに大きく依存しますが、クックパッドというサービスは更新系よりも参照系のほうがずっと多い性質を持っているため、DMS のレプリケーションインスタンスは dms.t3.small で十分でしたし、Kinesis Data Streams は単一シャードでもほとんど間に合う規模でした。一方でインフラ管理はすべて AWS に任せることができているので、運用の手間も含めたコストはかなり安いと言えると思います。</p> <h1 id="まとめ">まとめ</h1> <p>One Experience プロジェクトにおけるデータ移行を担当し、その一部である日本版のシステムのデータベースで発生したデータの変更をグローバル版のシステムのデータベースに継続的に反映する方法について紹介しました。binlog を読むためのライブラリは世の中にいくつかあり、有名なところだと Go 向けのモジュールの <a href="https://pkg.go.dev/github.com/go-mysql-org/go-mysql">github.com/go-mysql-org/go-mysql</a> が実装していたりしますが、DMS を利用することで面倒な箇所を AWS のマネージドサービスに任せることができたと思います。今回は異なるシステム間の単方向の継続的なデータ移行のために利用しましたが、最初のセクションで触れた Ping のようにサービス間のデータ連携の基盤としても利用できそうですし、意外と応用範囲は広いかもしれません。</p> eagletmt 日本とグローバルのクックパッドを統合しました hatenablog://entry/6802340630912747374 2024-10-10T10:58:32+09:00 2024-11-07T11:57:25+09:00 こんにちは、レシピ事業部プロダクト開発グループの赤松(@ukstudio)です。 昨年の10月頃からレシピ事業部ではひとつの大きなプロジェクトに取り組んでいました。このプロジェクトは社内ではOne experienceと呼ばれています。本記事ではこのOne experienceについてご紹介します。 One experienceとはなにか クックパッドではレシピサービスを日本を含めた71ヶ国、29言語で展開しています。これまでは日本のサービスと海外のサービスは独立した別のシステムで別のサービスとして展開してきました。日本のレシピサービスは日本の組織が、海外のレシピサービスはイギリスにオフィスを… <p>こんにちは、レシピ事業部プロダクト開発グループの赤松(<a href="https://twitter.com/ukstudio">@ukstudio</a>)です。</p> <p>昨年の10月頃からレシピ事業部ではひとつの大きなプロジェクトに取り組んでいました。このプロジェクトは社内ではOne experienceと呼ばれています。本記事ではこのOne experienceについてご紹介します。</p> <h2 id="One-experienceとはなにか">One experienceとはなにか</h2> <p>クックパッドでは<a href="https://cookpad.com">レシピサービス</a>を日本を含めた71ヶ国、29言語で展開しています。これまでは日本のサービスと海外のサービスは独立した別のシステムで別のサービスとして展開してきました。日本のレシピサービスは日本の組織が、海外のレシピサービスはイギリスにオフィスを置く海外の組織によって開発・運営を行なっていました。</p> <p>One experienceはこの独立した2つのサービスを1つに統合し、国内・国外関係なく、全ユーザーに同じ体験を提供することを目的としたプロジェクトです。</p> <p>現在、このOne experienceプロジェクトはほぼ完了しており、すでに海外で開発されていたシステム (以後グローバル版) に日本のレシピサービスが統合されています。一部の古いバージョンのモバイルアプリを使っているユーザーを除いてほぼすべての日本のユーザーのアクセスがグローバル版システムによって処理されるようになっています。</p> <h2 id="リソースを集中させ大きなインパクトを生み出す">リソースを集中させ、大きなインパクトを生み出す</h2> <p>なぜ私たちはレシピサービスを統合することにしたのでしょうか。</p> <p>私たちは「毎日の料理を楽しみにする」をミッションに掲げ、全力で取り組んでいます。このミッションの実現のためには様々なチャレンジを通して大きなインパクトを生み出していく必要があります。</p> <p>ですが、日本と海外のサービスがそれぞれ別の組織で開発されていると、開発のためのリソースが分散してしまいますし、どちらかのみに作られた機能は当然そのサービスを使っているユーザーにしか届きません。</p> <p>そうではなく、より大きな価値をよりたくさんの人に届けるために、2つのレシピサービスを1つに統合し、全員が1つのサービスを開発することとなりました。</p> <h2 id="日本のレシピサービスを海外のレシピサービスへ統合する">日本のレシピサービスを海外のレシピサービスへ統合する</h2> <p>レシピサービスを統合することが決まったあと、日本のシステムをベースに統合するのか、グローバル版のシステムをベースに統合するのか議論しました。</p> <p>日本版もグローバル版も主要なWebアプリケーションがRailsで実装されているという点では同じですが、インフラやWebフロントエンドで採用している技術スタックや、非同期ジョブやバッチ実行のための仕組みなど様々な点が異なっています。同様にモバイルアプリケーションも採用しているアーキテクチャなど日本版とグローバル版とで異なっていました。</p> <p>このように日本版とグローバル版には様々な違いがあり、どちらに統合するにしてもなんらかのメリット・デメリットが存在します。そのうえで、最終的に「グローバル版をベースに日本のサービスを統合する」という決断を行いました。16年運用されてきた日本のRailsアプリが、10年運用されてきたグローバルのRailsアプリへ統合されることとなったのです。</p> <p>決定の背景として、海外で展開していたレシピサービスは他の国や地域のレシピサービスを買収し取り込むことで成長してきた経緯があり、そのため外部のデータなどを取り込むための機能が既に存在していました。また、複数地域・言語でサービスを展開しているため、国際化の仕組みも備わっています。このような仕組みがない日本版システムに統合するよりも、グローバル版システムに統合する方がトータルでもっとも素早くシステムを統合できると判断したため、今回の決定に至りました。</p> <h2 id="サービスの統合は難しい">サービスの統合は難しい</h2> <p>グローバル版に日本のレシピサービスを移行することが決まったとは言え、考えることはとても多くありました。</p> <p>例えばグローバル版と日本版のレシピサービスではレシピを投稿したり、探すことができるというコアな部分は一緒ですが、グローバル版にしかない機能、日本版にしかない機能、グローバル版にも日本版にもあるが仕様が異なる機能、UIが違うものなど様々な違いがあります。</p> <p>これらについては主に以下の観点から整理を行いました。</p> <ul> <li>日本版で実装されていて、One experienceプロジェクト完了後も残したい機能を移植する <ul> <li>= 日本版で実装されていて、One experienceプロジェクト完了後になくなる機能を決める</li> </ul> </li> <li>グローバル版で実装されていて、One experienceプロジェクト完了後に残さない機能は削除する</li> </ul> <p>特に日本版からグローバル版への機能の移植は、そのまま移植するのではなく、利用してくれているユーザーにとってあるべき形は何なのか、改めて考えながら移植を行いました。そのため一部の機能は日本版で提供していたものと変わっているものもあります。</p> <p>また、検索はレシピサービスにおいて非常に重要な要素であり、特に人気順は有料会員向けの機能です。よって、日本版システムで実現できていたのと同等の品質でグローバル版の検索システムでも提供できる必要がありました。</p> <p>そして、グローバル版は日本版とは別のシステムとして稼動しているサービスなため、当然データベースも別で運用されています。これは日本版のデータをグローバル版のデータベースに持っていく必要があるということです。グローバル版は日本の開発組織とは完全に独立して0から開発されていたため、日本版とグローバル版ではデータベースのスキーマにも違いがありますし、データを移行するにあたってダウンタイムを設けるかどうかで対応の難易度も変わってきます。今回、データ移行については両システムともダウンタイムなしでデータ移行を実現できました。</p> <p>このような課題をひとつひとつ解決し、無事統合を完了させることができましたが、まだ今後の土台ができただけとも言えます。今後はよりたくさんの価値を届けられるよう引き続き開発を続けていきます。また、今回の統合によりレシピサービスを利用してくださっているユーザーにとって体験が大きく変わりました。現在も様々なご意見が寄せられており、これらにも目を通しながら鋭意改善を続けています。</p> <h2 id="さいごに">さいごに</h2> <p>以上がOne experienceプロジェクトの概要です。</p> <p>One experienceプロジェクトは、最初に立てた計画をそのまま最後まで実行したわけではありません。途中でユーザーインタビューやテストも実施して、その結果を踏まえて実装するものが変わることも何度もありました。最初はなくす予定だった機能を最終的に復活させたこともあります。</p> <p>最終的な形がはっきりしないままプロジェクトを進めるのは心理的にそれなりの負荷がありましたが、ユーザーによりよい価値を届けるためにはこのやり方で良かったと今では思っています。最初にすべてを決めてしまうと、それを作り切ることだけが目的になってしまいかねないからです。</p> <p>この不確実な状況でもOne experienceプロジェクトが完遂できたのは、データマイグレーションや採用する技術スタックなどの技術的な方針を最初に決め最後までほぼブレなかったこと、プロジェクトの各領域のリーダーたちがしっかりと各々の責任を果たし、連携できたからでしょう。</p> <p>長らく運用してきたサービスのシステムを統合することはここでは書ききれない課題がたくさんありました。</p> <p>この記事で軽く触れたものもありますが、いくつか例を挙げると以下のようなものがあります。</p> <ul> <li>両システムを稼動させたまま、どうやって日本版のデータの変更をグローバル版にリアルタイムに反映するのか</li> <li>日本の検索をグローバル版のシステムにどう移植したか</li> <li>日本のチーム主導で作る機能をどのようにして国際化対応するか</li> <li>グローバル版への移行による日本からのアクセスにおけるパフォーマンスの劣化をできる限り軽減したい</li> <li>などなど</li> </ul> <p>これらについても今後このブログで発信していく予定です。ぜひ弊社のXアカウントをフォローして更新をお待ちください。</p> <p><a href="https://twitter.com/cookpad_tech">https://twitter.com/cookpad_tech</a></p> ukstudio Cookpad Summer Internship 2024に参加しました hatenablog://entry/6802340630910914839 2024-10-02T16:58:12+09:00 2024-10-02T16:58:12+09:00 はじめに こんにちは。9月からクックパッドで1ヶ月間サマーインターンシップに参加していた中尾です。 今回はクックパッドで1ヶ月間過ごしてみて、やったことや感じたことをレポートしていきます。 自己紹介 私は現在、立命館大学の学部3年で、情報系を専攻しています。今回のインターンシップでは遠方に住んでいるということもあり、初めの1週間はオフィスに出社し、以降はリモートで勤務していました。宿泊したホテルからオフィスまでが徒歩2分くらいで激アツでした。 インターンシップに参加するまで クックパッドは技術力の高い会社というイメージで、ロゴやデザインが好きだったということもあり(かわいいですよね?)、募集が… <h2 id="はじめに">はじめに</h2> <p>こんにちは。9月からクックパッドで1ヶ月間サマーインターンシップに参加していた中尾です。 今回はクックパッドで1ヶ月間過ごしてみて、やったことや感じたことをレポートしていきます。</p> <h2 id="自己紹介">自己紹介</h2> <p>私は現在、立命館大学の学部3年で、情報系を専攻しています。今回のインターンシップでは遠方に住んでいるということもあり、初めの1週間はオフィスに出社し、以降はリモートで勤務していました。宿泊したホテルからオフィスまでが徒歩2分くらいで激アツでした。</p> <h2 id="インターンシップに参加するまで">インターンシップに参加するまで</h2> <p>クックパッドは技術力の高い会社というイメージで、ロゴやデザインが好きだったということもあり(かわいいですよね?)、募集が始まる前からインターンシップに参加したいと思っていました。そんなとき、いつものように魔法のスプレッドシートというインターンシップの情報が集まるサイトを監視していたところ、クックパッドの募集を見つけたためすぐさま応募しました。技術課題や技術面接を終えて、合格の連絡をいただいた時はとても嬉しかったです。みなとみらい行くぞ!!!と思っていたら、オフィスが移転していて渋谷駅の隣の池尻大橋でした。みなさん、クックパッドは今は池尻大橋にあります!!!</p> <h2 id="会社の雰囲気">会社の雰囲気</h2> <p>技術が好きで、性格は穏やかな人が多いと思います。 初週にインターン生のために懇親会を開いてもらったのですが、そこで「技術のキャッチアップ方法」や、「技術力をもっと深めたい」などの質問や悩んでいることを話すと、親身になって色々教えてくださいました。今まではぼんやりしていた自分にとって目指すべきエンジニア像が明確になり、冗談抜きで人生が変わったと思います。自分は技術がすごく好きなので、みんなでワイワイ技術について話せて楽しかったです。 また、Slackでの会話も盛んで、気軽に質問や提案ができる環境でした。そのおかげで、あまり話したことのない方にも気軽にメンションしてタスクについての質問をすることができました。</p> <h2 id="インターンシップの内容">インターンシップの内容</h2> <h3 id="タスク">タスク</h3> <p>最初はonboardingタスクのような感じで、簡単なWebフロントの修正などから始めました。そこから徐々に大きめのタスクを触らせてもらい、新規機能の開発にも携わることができました。私はRailsを触るのは初めてだったのですが、各タスクで少しずつ触る領域が違ったので、徐々にRailsのことがわかってきて楽しかったです。また、DWHに対してSQLクエリを叩いて対象レコードの数を調査したり、作成したバッチ処理の実行時間を計測したりは、実際に運用されている大規模なプロダクトならではという感じで楽しかったです。 どのようなタスクがやりたいかをヒアリングして尊重してくれたので、やりたいことがあればどんどん挑戦できる環境だと思います!</p> <h3 id="スクラム">スクラム</h3> <p>私はスクラム開発をするのが初めてだったので、良い経験になりました。現在のクックパッドのスクラム開発では、1週間を1スプリントとして、週に1度スプリントのプランニングやレビューを行います。また、毎日集まって進捗報告をするデイリースクラムというものもあります。チームって感じがして良いですね!!! スクラム開発では、チームとしての課題や進捗を全員で共有して取り組むことができるので、全体像が把握しやすいのが良いと思いました。また、デイリースクラムでチームメンバーそれぞれの声を聞くことができたので、親近感が高まって話しかけやすくなったのがよかったです。チームとしての結束力は間違いなく高まると思います。 一方で、プランニングやレビューでかなり時間を取られてしまうので、メリットもデメリットもそれぞれあるなと思いました。</p> <h3 id="メンタリング">メンタリング</h3> <p>サポートは非常に充実していました。 メンターの方と毎日1on1ミーティングを開いて、タスクの進捗や悩みの共有、ちょっとした雑談などをしていました。また、1on1以外の時間でも、「今話せますか?」と声をかけると気軽にSlackのハドルミーティングで話すことができました。とてもコミュニケーションの取りやすい方で、勉強になる部分が多かったです。 メンターの方がお休みの期間があったりもしたのですが、その期間は別の方がメンター代理をやってくださり、サポート面で困ったことは全くありませんでした。</p> <h3 id="成果発表">成果発表</h3> <p>インターン期間の総括として、最後に成果発表を行いました。 成果発表というと少し緊迫したイメージが湧いてしまうかもしれませんが、ワイワイとした賑やかな雰囲気でした。 スライドを使って10-15分くらい発表をし、質疑応答やフィードバックを行いました。 自分の発表に対して質問やリアクションをたくさんしてもらえて嬉しかったです!! <figure class="figure-image figure-image-fotolife" title="成果発表中のSlack"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/E/Exzrg/20241002/20241002164400.png" width="964" height="984" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>成果発表中のSlack</figcaption></figure></p> <h2 id="最後に">最後に</h2> <p>仕様に関しての提案をしたり、新規機能の開発に携わらせていただくなど、本当に1人の社員のように働くことができて楽しかったです。 1ヶ月間本当にありがとうございました!!</p> Exzrg 開発環境のデータベースでも本番環境相当のデータを使う hatenablog://entry/6802340630909597944 2024-10-01T10:55:03+09:00 2024-11-07T11:57:41+09:00 こんにちは。レシピ事業部バックエンド基盤グループの石川です。 2014 年、このブログに『開発環境のデータをできるだけ本番に近づける』というタイトルの記事が投稿されました。 クックパッドでは、ユーザーさんが実際に体験している状況と近い状況を再現しながら開発することに価値があると考えています。技術的には、最初からレコードがたくさんあることによってパフォーマンス問題に気付きやすくなるなどの長所がありますし、サービス開発としても、実際のユーザーさんの体験を最速でなぞって素早くフィードバックループを回せるようになるという長所があります。 この慣習は 2014 年の記事から 10 年経った今でも続いてい… <p>こんにちは。レシピ事業部バックエンド基盤グループの石川です。</p> <p>2014 年、このブログに『開発環境のデータをできるだけ本番に近づける』というタイトルの記事が投稿されました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2014%2F10%2F03%2F110806" title="開発環境のデータをできるだけ本番に近づける - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>クックパッドでは、ユーザーさんが実際に体験している状況と近い状況を再現しながら開発することに価値があると考えています。技術的には、最初からレコードがたくさんあることによってパフォーマンス問題に気付きやすくなるなどの長所がありますし、サービス開発としても、実際のユーザーさんの体験を最速でなぞって素早くフィードバックループを回せるようになるという長所があります。</p> <p>この慣習は 2014 年の記事から 10 年経った今でも続いています。一方でその実現手法については変化を続けてきました。現在のクックパッドでは状況に応じていくつかの手段を使い分けています。それらの手段については今まであまり公開されていなかったような気がするため、この記事ではそのうちのひとつをご紹介いたします。</p> <p>なおこの記事で紹介する手法は社内で数年使っているもので、以前弊社に在籍していた菅原 (@sgwr_dts) が作成し、その後奥村 (@hfm)、鈴木 (<a href="http://blog.hatena.ne.jp/eagletmt/">id:eagletmt</a>)、小川 (@coord_e)、および自分を含めたチームとして開発してきました。ご承知おきください。</p> <h2 id="前提">前提</h2> <p>クックパッドでアプリケーションがデプロイされる環境としては本番環境の他に、本番環境へ出す前の検証作業で使う環境(検証環境)があります。これらふたつは AWS 上にデプロイされています。それとは別に開発者ひとりひとりの手元のパソコンでアプリケーションを動かす環境を開発環境と呼ぶことにしましょう。このあたりは文献によって微妙に呼び方が異なるので、この記事ではこれらの呼び方を使うことにします。</p> <p>クックパッドでは RDBMS として Amazon Aurora MySQL を多用しており、特に cookpad.com を提供するアプリケーション群が使う RDBMS の多くは Aurora MySQL です。Aurora のリリースが <a href="https://aws.amazon.com/about-aws/whats-new/2014/11/12/introducing-amazon-rds-for-aurora/">2014 å¹´ 11 月らしい</a> ので、早速 10 年前にはなかったものが出てきましたね。</p> <p>それではここから、Aurora を対象として、定期的に本番環境のデータベースの複製を作って検証環境や開発環境のデータベースとして使うやり方についてご説明します。</p> <h2 id="Aurora-クラスターのリストア">Aurora クラスターのリストア</h2> <p>さて、Aurora では<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/aurora-restore-snapshot.html">リストア</a>という操作を行うことで既存の DB クラスターを元に同じデータを持った別の DB クラスターを作成できます。Aurora のリストアはそれなりに速く、クラスターの VolumeBytesUsed が 1 テラバイトを超えていても 5 分程度で完了します。そのあと出来上がったクラスターに DB インスタンスをひとつ作る作業に 10 分程度かかるので、そちらの方が長いです。</p> <p>こうして出来上がったクラスターをそのまま検証環境として使えるとラクなのですが、そのままだとセキュリティやプライバシー上の懸念があります。たとえばユーザーさんのメールアドレスを開発者全員が読めてしまうと問題なわけです。これを避けるため、リストアによって出来上がったクラスターに入っているデータのマスキングを行います。</p> <p>どのデータをマスクしてどのデータをそのまま残すかは、DB クラスターごとに設定ファイルを作って管理しています。たとえば以下の Jsonnet ファイルのような感じです。この設定では、どのテーブルを残してよいか許可する一覧を持っておいて、それ以外のテーブルは truncate で空っぽにしてしまいます。</p> <pre class="code jsonnet" data-lang="jsonnet" data-unlink>local allowed_tables = [ &#39;recipes&#39;, &#39;ingredients&#39;, &#39;users&#39;, // などなど…… ]; { database: &#39;global_main&#39;, truncate: true, only: allowed_tables, pre_queries: [], rules: [], post_queries: [], }</pre> <p>上の例には書きませんでしたが、特定のテーブルの特定のカラムだけ処理するクエリを流すこともできます。たとえば credentials テーブルの email カラムの値をすべて <code>${user_id}@example.com</code> のようなダミー文字列に書き換えてしまう、などです。</p> <p>ただし特定のテーブルのカラムを書き換える処理は、テーブルが巨大な場合は長い時間がかかります。実際にあった例として、すぐ上に書いた credentials テーブルの書き換えはデータ量が小さい場合はうまくいくのですが、本番環境の巨大なデータで試すと一晩経ってもクエリ実行が終わりませんでした。もちろん DB インスタンスのタイプやどういう書き換えをするかで実行時間は変わるので、このあたりは兼ね合いになります。credentials テーブルについては要件の方を変え、全レコードを残すことは諦め、一度レコードをすべて消したあと特定のスタッフユーザーだけ新しくレコードを作る処理を入れて回避しました。</p> <p>なお、このマスキングの仕組みは社内的には新しいものではなく、2020 年に公開した『Amazon RDS/Auroraをクローンするシステムを作った話』でも同じ仕組みを使っています。設定ファイルも共用しています。こちらは検証環境や開発環境と直接は関係なく、単に複製を作ってクエリパフォーマンスを計測するなどの用途で使うものです。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2020%2F08%2F20%2F090000" title="Amazon RDS/Auroraをクローンするシステムを作った話 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <h2 id="定期的な実行">定期的な実行</h2> <p>この記事の最初の方で定期的に複製を作っていると書いたように、検証環境のデータベースについて、データベースを新しくリストアし古い方と入れ替える作業を、日次のバッチジョブとして実行しています。</p> <p>この際、Aurora のリストア操作はパラメーターグループなどの設定値を指定しないとデフォルトのものが使われるため、古い方に設定されているものを引き継ぐようにしています。DB ユーザーも改めて作る必要があり、古い方のデータベースのユーザー情報と権限を調べ新しい方に再作成します。</p> <p>また、検証環境にしかないレコードについても移行処理を行っています。実はリストアした後のデータベースでは各テーブルの主キーの AUTO_INCREMENT を大きな値に設定しています。これによりそれぞれのレコードについて主キーがしきい値より大きい場合は検証環境のみにあるデータだと判断し、古い方のデータベースから新しい方のデータベースへレコードを流し込んでいます。これにより開発者による検証環境での作業をなるべく壊さないようにしています。</p> <p>外部キー制約がある場合は少しやっかいで、検証環境にのみ存在するレコードの参照している先が本番環境のレコードの場合、参照先のレコードが入れ替えのタイミングで削除されている可能性があります。このような場合どうするかは各チームに任せていて、たとえばとあるチームではこのような外部キーの不整合を見つけて検証環境側のレコードを消してしまう処理を入れています。</p> <p>古い方と新しい方のデータベースを入れ替えると書きましたが、これは対象のデータベースを参照するための DNS レコードを作り、DNS レコードの参照先を書き換えることで実現しています。これにより接続元が持っている情報は書き換えずにデータベースを入れ替えられます。</p> <h2 id="クロスリージョンリードレプリカ">クロスリージョン・リードレプリカ</h2> <p>今回のツールで作られたデータベースに対しては、手元の開発環境からでも接続できるように設定しています。弊社では <a href="https://www.twingate.com/">Twingate</a> を利用して開発者が手元からクラウドへ安全に接続できるようにしており、その一環で接続を許可しています。</p> <p>ここで問題になるのは通信速度です。AWS にあるデータベースに対して、検証環境では同じく AWS にあるアプリケーションとの通信になりますが、開発環境では手元のパソコンで動くアプリケーションとの通信になります。この通信に時間がかかると、ちょっと手元でサーバーアプリを動かしてレスポンスを見ようとしても、複数のクエリを実行するために何回も通信が往復してレスポンスまでの時間が遅くなり、ユーザー体験に乖離が生まれてしまいます。他の環境と比べてクエリ 1 回あたり 100 ミリ秒遅くなったとしても、直列で 10 クエリしていると合計 1 秒遅くなってしまいます。正直まともに確認できません。</p> <p>特に、目的のデータベースが海を越えた向こう側に位置している場合は厄介です。実際、データベースはアメリカにありつつ開発者は日本に居るといった状況が起こっています。クエリが海の上を行ったり来たりして、しっかり時間がかかります。</p> <p>そこでそのような場合は、Aurora が備えている<a href="https://docs.aws.amazon.com/AmazonRDS/latest/AuroraUserGuide/AuroraMySQL.Replication.CrossRegion.html">リージョンを跨いでリードレプリカを作成できる機能</a>を使っています(クロスリージョン・リードレプリカ)。つまりライターインスタンスはアメリカに置きつつ、リーダーインスタンスは東京にあるものを参照できるようにします。クックパッドのサービスは多くの部分で read heavy なので、開発環境でもそれなりの応答時間で確認できるようになります。</p> <p>ただし、クロスリージョン・リードレプリカの作成には多少時間がかかります。いま実際に動かしている例では 2 時間弱を要します。1 日は超えていないので許容範囲ではあるのですが、実行失敗時のリトライまで考えると少し面倒な点ではあります。</p> <h2 id="長所と短所">長所と短所</h2> <p>以上、検証環境や開発環境で本番環境相当のデータを使えるようにするために使っている仕組みのひとつを解説しました。このように本番環境相当の検証ができるようにすることで、サービス開発を高速に進められるようにしています。またここでは説明しませんでしたが、本番環境の DB バージョンを上げたいとき先に検証環境で試せるように、リストアした後に DB バージョンを変更できる機能を入れていたりもしています。</p> <p>今回の仕組みはマスキングがそれなりに柔軟にできるという意味で扱いやすく、社内では複数のデータベースで利用しています。一方で本番環境に起こった変更の反映は翌日となるため、本番環境でのデータ変更の勢いも再現したいような場合には不向きです。イベントドリブンな部分の検証を受動的には行いづらい、などですね。</p> <p>リアルタイムに近い状態で本番環境に近いデータを用意したい場合は、MySQL の binlog を直接使った仕組みや、AWS Database Migration Service (DMS) を使った仕組みが考えられます。これらも状況によっては便利なのですが、AUTO_INCREMENT の調整の簡易さ、マスキングの柔軟性、壊れたときのメンテナンスの容易さなどに差異があります。特に何かしらが壊れたときに直すのがやや面倒なため、日次入れ替えで充分な場合はそうするようにしています。</p> <p>ところで……。途中でデータベースがアメリカにある事例の話をしましたが、一体それはどんな場合なのでしょうか? 詳細はこの記事ではまだ秘密! 後日ご紹介予定ですので、弊社の X アカウントをフォローして更新をお待ちください。</p> <p><a href="https://twitter.com/cookpad_tech">https://twitter.com/cookpad_tech</a></p> nekketsuuu iOSDC Japan 2024に社員2名が登壇 & スポンサー企画のご案内 hatenablog://entry/6801883189129866366 2024-08-21T09:58:37+09:00 2024-08-22T10:29:27+09:00 こんにちは!クックパッドでモバイルアプリ開発エンジニアをしている新堀 (@tk108gabalian) です。 現在クックパッドでは空前のイジンデンブームが到来しております。イジンデンというのはダイソーから発売されているトレーディングカードゲームです。社内では日々イジンデン対応社員達のバトルが繰り広げられています。ダイブトゥ……イジンデン! さて、今年もiOSDCが8/22(木)〜8/24(土)に開催されますね! クックパッドはゴールドスポンサーとして協賛させていただきます。 トーク紹介 今回クックパッドからは2名が登壇いたします! Day 1 8/23(金) 17:40〜 Track B ル… <p>こんにちは!クックパッドでモバイルアプリ開発エンジニアをしている新堀 (<a href="https://twitter.com/tk108gabalian">@tk108gabalian</a>) です。</p> <p>現在クックパッドでは空前のイジンデンブームが到来しております。イジンデンというのはダイソーから発売されているトレーディングカードゲームです。社内では日々イジンデン対応社員達のバトルが繰り広げられています。ダイブトゥ……イジンデン!</p> <p>さて、今年もiOSDCが8/22(木)〜8/24(土)に開催されますね! クックパッドはゴールドスポンサーとして協賛させていただきます。</p> <h1 id="トーク紹介">トーク紹介</h1> <p>今回クックパッドからは2名が登壇いたします!</p> <h2 id="Day-1">Day 1</h2> <h2 id="823金-1740">8/23(金) 17:40〜</h2> <h3 id="Track-B-ルーキーズLT5分">Track B ルーキーズLT(5分)</h3> <ul> <li>登壇者: æ–°å € / <a href="https://twitter.com/tk108gabalian">@tk108gabalian</a></li> <li>タイトル: iOS17のScrollViewはちょっとできる子</li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ffortee.jp%2Fiosdc-japan-2024%2Fproposal%2F04726719-f798-4942-86e7-56742791a5fd" title="iOS17のScrollViewはちょっとできる子" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://fortee.jp/iosdc-japan-2024/proposal/04726719-f798-4942-86e7-56742791a5fd">fortee.jp</a></cite></p> <p>SwiftUIの登場当初はカスタマイズ性に乏しいと思われていたScrollViewですが、ここ数年のアップデートで様々な機能が追加され、できることが増えてきました。 このセッションでは、iOS17で追加されたmodifierを中心に、SwiftUIのScrollViewがどのように進化したのかを具体的な例を交えてご紹介します。 ScrollViewを使ってちょっとリッチなUIを構築することに興味がある方はぜひご覧ください!</p> <h2 id="Day2">Day2</h2> <h2 id="824土-1125">8/24(土) 11:25〜</h2> <h3 id="Track-C-スポンサーセッション20分">Track C スポンサーセッション(20分)</h3> <ul> <li>登壇者: paruru / <a href="https://twitter.com/0x746572616e79">@ 0x746572616e79</a></li> <li>タイトル: 新卒のiOSエンジニアが"クックパッドの今"を紹介!</li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ffortee.jp%2Fiosdc-japan-2024%2Fproposal%2F4e501f95-ea97-4299-97f8-3195597a638c" title="新卒のiOSエンジニアが&quot;クックパッドの今&quot;を紹介!" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://fortee.jp/iosdc-japan-2024/proposal/4e501f95-ea97-4299-97f8-3195597a638c">fortee.jp</a></cite></p> <p>このセッションでは、弊社新卒のparuruがレシピ事業部で何を取り組んでいるのかをご紹介します。 アプリの品質を保つ取り組み、デザインシステム、リリースフロー、日本と海外向けアプリの違い... などのキーワードに興味がある方はぜひご覧ください!</p> <h1 id="ノベルティの紹介">ノベルティの紹介</h1> <p>当日ブースの企画に参加いただいた方に先着でフードクリップを配布させていただきます! 開封済みの袋を封をする際に是非ご活用ください。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20240821/20240821094942.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20240821/20240821094945.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20240821/20240821094948.jpg" width="1200" height="800" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="ブースの紹介">ブースの紹介</h1> <p>クックパッドは今年もブースを出展します! ブースでは先にご紹介したノベルティを配布するほか、クックパッドのアーキテクチャや開発方法についてご紹介します。 興味のある方はぜひブースまでお越しください! もちろん、上記以外の目的で弊社のエンジニアとお話しに来ていただいても構いません。 メンバー一同、当日ブースでお待ちしております!</p> <p><figure class="figure-image figure-image-fotolife" title="2022年のブースの様子です。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/t/toya108_climbing/20240821/20240821094951.jpg" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>2022年のブースの様子です。</figcaption></figure></p> <h1 id="おわりに">おわりに</h1> <p>今年は猛暑が続いており、iOSDC当日も厳しい暑さが予想されますが、暑さに負けずにカンファレンスを楽しんでいきましょう!</p> toya108_climbing Cookpad Drinkup at RubyKaigi 2024 を開くために気にしていたこと hatenablog://entry/6801883189111735352 2024-06-05T09:00:03+09:00 2024-06-07T09:28:26+09:00 RubyKaigi 2024 が開かれました。クックパッドは協賛しており、懇親会も開きました。この記事では、カンファレンスで懇親会を開くにあたって気をつけていたことや、うまくいったこと、うまくいかなかったことをまとめます。RubyKaigi に関わらず、技術者コミュニティを盛り上げる手段のひとつとしてご覧ください。 <p>レシピ事業部バックエンド基盤グループの石川です。</p> <p>2024 å¹´ 5 月 15 日から 17 日にかけて、<a href="https://rubykaigi.org/2024/">RubyKaigi 2024</a> が開かれました。クックパッドは Wi-Fi スポンサーとして協賛しており、また 16 日の夜には Cookpad Drinkup at RubyKaigi 2024 と称して懇親会を開きました。</p> <p><figure class="figure-image figure-image-fotolife" title="クックパッド一行"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nekketsuuu/20240604/20240604133534.jpg" alt="RubyKaigi 2024 &#x306E;&#x30ED;&#x30B4;&#x304C;&#x5927;&#x304D;&#x304F;&#x63CF;&#x304B;&#x308C;&#x305F;&#x58C1;&#x306E;&#x524D;&#x3067;&#x4EBA;&#x3005;&#x304C;&#x4E26;&#x3093;&#x3067;&#x5199;&#x771F;&#x306B;&#x5199;&#x3063;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>クックパッド一行</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="懇親会のお店の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nekketsuuu/20240604/20240604131619.jpg" alt="&#x5C45;&#x9152;&#x5C4B;&#x306E;&#x5165;&#x53E3;&#x304C;&#x5199;&#x3063;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;&#x3072;&#x3068;&#x308A;&#x306E;&#x7537;&#x6027;&#x304C;&#x5165;&#x53E3;&#x306E;&#x305D;&#x3070;&#x3067;&#x624B;&#x3092;&#x5E83;&#x3052;&#x3066;&#x53C2;&#x52A0;&#x8005;&#x3092;&#x6B53;&#x8FCE;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;" width="1200" height="900" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>懇親会のお店の様子</figcaption></figure></p> <p>この記事では、カンファレンスで懇親会を開くにあたって気をつけていたことや、うまくいったこと、うまくいかなかったことをまとめます。RubyKaigi に関わらず、技術者コミュニティを盛り上げる手段のひとつとしてご覧ください。</p> <h2 id="Drinkup-の目的">Drinkup の目的</h2> <p>今回の Cookpad Drinkup at RubyKaigi 2024 は自分が取りまとめを担当しました。</p> <p>この drinkup の参加募集ページには「参加者同士の交流の場として是非ご利用ください」という一文を載せていました。カンファレンスにおいて企業が懇親会を開くというのは、もちろん弊社のことをもっと知ってもらいたいという広報・採用的な目的もありますが、同時に、我々が普段使っている技術のコミュニティをもっと盛り上げたいという意図があります。クックパッドでは Ruby を複数のサービスでヘビーに利用しています。我々としても「Ruby が死ぬ」と困る訳です。このため自分も含めて Ruby のユーザーをモチベートしたり、ユーザー同士のネットワークを広げて人々がやれることを増やしていったりといった活動には意味があるのです。</p> <p>特に(最近の)RubyKaigi では、各トークの後に質疑応答の時間が設けられていません。休憩時間はありますが、スポンサーブースをまわったり、ちょっとした会話や開発をしていたりすると過ぎてしまう程度の時間です。このためトークについての雑談をするための比較的落ち着いた場としても利用していただけたらなという思いもありました。</p> <p>自分がコミュニティ運営で迷ったとき個人的に参考にしている書籍『アート・オブ・コミュニティ』(Jono Bacon 著、渋川よしき 訳)に載っている言葉として、「ファミリーの価値を創造する」という言い回しがあります。単に同じ界隈に居る人、というだけに留まらず、同じ技術を日々使い、同じ文脈を共有する「ファミリー」と話して何かが生まれる場になれば、最高ですね。</p> <h2 id="気にかけていたこと">気にかけていたこと</h2> <p>そういった意味で drinkup を開くために、開催に向けて気にしていた細かいことがいくつかあります。ここに列挙してみます。</p> <h3 id="参加者が喋りやすい空間にする">参加者が喋りやすい空間にする</h3> <p>お店を選ぶ際、広めの部屋に席が並んでいて、美味しいご飯や飲み物を手に取りつつ、どちらかというと喋ることに比重が向くようなお店を選びました。皆さんには会話をしてほしいので今回は意図的に観光向けの見世物は考慮から外しました。喋りながら途中で自由に席替えしていくような会なので、ある程度移動の自由が効く食事を目指す形です。自分は考えていませんでしたが他社さんの事例だと、ドリンクサーバー制だったので飲み物を取るために人が出入りし集団が撹拌されて色んな人と喋れたという例もあるようですね。</p> <p>ただし今回沖縄での開催となった RubyKaigi においては、遠方にあるお店の細かい部分まで確認するのがなかなか難しい側面もありました。そこで RubyKaigi 運営の中にいらっしゃる現地の方々のコメントを伺ったり(その節はありがとうございました)、食べログさんや Google マップさんにある写真をよく見たり、最終的にはお店の方に相談したりと、多めに確認をしました。割とギリギリまでドキドキしていましたね。いっそのこと一度事前に沖縄で現地視察しても良かったかもしれないです。自分は本職のソフトウェアエンジニアの仕事があるので、なかなかコレばっかりやっていられないのが難しいところではありますが。</p> <p>お店選びの他に、開催ポリシーを示しておくことも重要だと考えています。今回は <a href="https://rubykaigi.org/2024/policies/">RubyKaigi 公式が定めているポリシー</a>に則っており、このことを表示していました。またポリシー違反が見受けられたとき報告するために弊社側の連絡先を用意し、参加者のみが見れる情報として connpass に掲載しておりました。</p> <p>ポリシーの延長線上として、物理的なセキュリティをどう確保するかも考慮には入れておりました。参加者ではない方が強引に入って来ないようにどうするとか、そういうのですね。厳しい話としては <a href="https://www.publickey1.jp/blog/23/it_2025.html">2023 å¹´ 12 月の事例</a>もまだ記憶に新しいところです。今回はお店をまるまる貸し切りにしたのでお店の入口のところで守ることにし、最初の入場のタイミングでは誰が入ってよいのか弊社の社員が判断、落ち着いた後はお店の方にお任せしていました。またお店を選ぶ時点でも、あまりにも繁華街に近すぎるお店は避けていました。考えすぎだったかもしれませんが、近くに客引きが立ち並ぶ場所があったのです。</p> <p>写真撮影についても工夫できるポイントがあります。Drinkup では、運営としては参加者の方々が盛り上がっている様子を記録に残したくなりますし、また参加者同士においても <a href="https://x.com/search?q=%23rubyfriends">#rubyfriends</a> などの交流のために写真を撮りたくなるでしょう。RubyKaigi では公式の取り組みとして、名札の紐の色で写真撮影の可否を個々人が表明できるようになさっていました。我々の drinkup でもコレを利用させていただき、参加者には名札を持ってきてもらうようにしていました。名札があることによって RubyKaigi 参加者だと確認できるのと参加者同士で名前が分かるというのもあり、便利でした。事前・事後イベントで似た方式を採用なさっていた会もありましたね。また、失敗としては、写真を撮りやすくなるかもと思って実験的にフォトプロップスを作って持っていったのですが、これは殆ど機能しませんでした。唐突すぎたかな。</p> <p><figure class="figure-image figure-image-fotolife" title="名札の紐の様子。 https://rubykaigi.org/2024/onsite/より引用"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/nekketsuuu/20240604/20240604131908.jpg" alt="&#x540D;&#x672D;&#x304C;&#x3075;&#x305F;&#x3064;&#x8868;&#x793A;&#x3055;&#x308C;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;&#x3072;&#x3068;&#x3064;&#x306F;&#x9EC4;&#x8272;&#x3067;&#x3001;&#x5199;&#x771F;&#x64AE;&#x5F71; OK &#x3092;&#x793A;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;&#x3082;&#x3046;&#x3072;&#x3068;&#x3064;&#x306F;&#x767D;&#x8272;&#x3067;&#x3001;&#x5199;&#x771F;&#x64AE;&#x5F71; NG &#x3092;&#x793A;&#x3057;&#x3066;&#x3044;&#x307E;&#x3059;&#x3002;" width="800" height="411" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>名札の紐の様子。 <a href="https://rubykaigi.org/2024/onsite/">https://rubykaigi.org/2024/onsite/</a> より引用</figcaption></figure></p> <h3 id="多様な参加者が集まれるようにする">多様な参加者が集まれるようにする</h3> <p>また、参加者の層が一極集中しないようにもしていました。RubyKaigi には世界から参加者が集まります。アメリカやヨーロッパはもちろん、それ以外の場所も含め世界中から集まります。当然さまざまなバックグラウンドを持った方々がいらっしゃるので、できる限り広い範囲の方が参加しやすいように心がけました。話題も多種多様なものが集まると面白そうですしね。</p> <p>参加募集のページが日英併記になっているのもその一環です。そもそも RubyKaigi の公用語は英語なのです。現地の受付においても日英対応です。</p> <p>参加者を募集し始める時刻もタイムゾーンを気にして、せめて人口が多そうな場所の真夜中にならないように……程度の確認をしていました。後から思いついたのですが、これはたとえば参加者枠を 4 等分して 6 時間ごとに増枠していく、でも良かったかもしれません。ただ結果としては募集開始して即座に枠が埋まり切ることはなく、かつその後もゆっくりとキャンセルと応募が繰り返され補欠枠が繰り上がっていたので、若干気にし過ぎではあったのでしょう。そもそも本当は RubyKaigi 当日に参加したくなったとしてもフラッと立ち寄れるくらいの参加方法にしたいという話はあったのですが、予算や事前準備との兼ね合いがなかなか難しいですね。</p> <p>難しかったのは食べられないものに対する対応です。好き嫌い、アレルギー、宗教上の理由など、人それぞれ食べられないものは存在します。少人数の集まりであればうまく避けて調整できるでしょうが、今回の会は大人数な上、開催直前まで参加者が確定しません。またお店の都合として、大人数の料理を作るためにどうしても大皿料理は避けづらいです。この点については事前にお店の方と相談し、事前の連絡があれば都度対応を考えるという方式にし、参加者が相談できる連絡先を用意しておりました。ただもっと良い方法はありそうな気がするので、今後も模索したいところです。</p> <p>また、他社さんがなさっていて良いなと思ったのが、参加枠をいくつかに区切るやり方です。RubyKaigi 初参加の方向けの枠を作ったり、海外勢向けの枠を作ったり、など。あまり分けすぎてもそれはそれで大変そうですが、drinkup 参加に際して不慣れな方でも不利になりすぎないような仕組みを作っておくのは有用そうに感じています。</p> <h3 id="その他細かいこと">その他細かいこと</h3> <ul> <li>会場に近いお店を選ぶ。今回選んだお店は、RubyKaigi の会場から徒歩 2 分ほどの場所でした。この結果、ぎりぎりまで会場で議論してから移動したり、一度ホテルに帰って荷物を整理してから移動したりできたのではないかと思います。</li> <li>イベント運営側と連携を取る。開始時間の目安であったりウェブサイトへの掲載であったり、相談しておくべきことがありました。</li> <li>イベント用のバナーを作る。SNS に投稿しやすくなったりします。今回は社内のデザイナーの方にお願いして作ってもらいました。</li> <li>アルコールを嗜まれない方でも楽しめるようにする。イベントを発信する側としては、過度にお酒を強調するような言い方は控えていました。まあ drinkup って言っちゃってるんですが……。また、会場が居酒屋であった以上、一定の参加しづらさはあったかもしれません。</li> <li>お店への支払い方法も事前に確認する。大人数だと金額も大きめになるのでその場で払うのが難しくなります。今回は後日の銀行振り込みが可能だったのでそうしました。振り込みまでにかかる日数も含めお店側と事前に連携が必要です。</li> <li>connpass で「重複参加を許可しない」に設定する。同時間帯に様々なイベントが開かれることは予想できたので、なるべく厳密な参加者数を保つために重複は許しませんでした。</li> <li>connpass の出席管理機能を活用する。当日どなたが既にいらっしゃったのか把握するため、connpass の機能を使いました。具体的には入口のところで connpass の受付票番号を見せていただき、名札と番号で照合して connpass の出席チェックボックスにマークしていました。具体的にはパソコンを開いてページ内検索でやりくりしたのですが、これがスマホだと操作しづらくて厳しかっただろうと思います。</li> </ul> <p>今回は connpass さんを利用したので connpass の言葉で書いていますが、Doorkeeper さんなど他のサービスを利用される際も似たような感じになるはずです。Doorkeeper にはチケットのバーコードを読み取って出欠管理する機能があるので、より多人数のイベントでは便利でしょう。今回の drinkup は弊社社員を除いて 50 名ほどの規模でした。</p> <h2 id="結び">結び</h2> <p>というわけで、自分が今回 Cookpad Drinkup at RubyKaigi 2024 を開くにあたって気にしていたことをまとめてみました。自分自身このような不特定の方が多人数集まる会を主催したのは初めてで、不安になるタイミングが何回かあったので、似たような方の助けになると良いなと思います。</p> <p>それではまた次のイベントでお会いいたしましょう👋</p> <hr /> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2024%2F06%2F04%2F120000" title="クックパッドは RubyKaigi 2024 に参加していました!イベントレポート - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2024/06/04/120000">techlife.cookpad.com</a></cite></p> nekketsuuu クックパッドは RubyKaigi 2024 に参加していました!イベントレポート hatenablog://entry/6801883189111448809 2024-06-04T12:00:00+09:00 2024-06-06T23:22:24+09:00 はじめに こんにちは。レシピ事業部プロダクト開発グループの堀内 (@Sota_Horiuchi)です。普段はバックエンドの開発を行っている新卒2年目のエンジニアです。 RubyKaigi 2024が 2024 å¹´ 5 月 15 日から 17 日に沖縄県那覇市で開催され、クックパッドからは総勢 24 名が参加しました。参加したメンバーのうち 13 名が新卒 3 年目までのエンジニアであり、社内の若手バックエンドエンジニアがほぼ参加していました。 また、クックパッドは Wi-Fi スポンサーとして協賛しており、更に 16 日の夜には Cookpad Drinkup at RubyKaigi 202… <h2 id="はじめに">はじめに</h2> <p>こんにちは。レシピ事業部プロダクト開発グループの堀内 (<a href="https://x.com/Sota_Horiuchi">@Sota_Horiuchi</a>)です。普段はバックエンドの開発を行っている新卒2年目のエンジニアです。</p> <p><a href="https://rubykaigi.org/2024/">RubyKaigi 2024</a>が 2024 å¹´ 5 月 15 日から 17 日に沖縄県那覇市で開催され、クックパッドからは総勢 24 名が参加しました。参加したメンバーのうち 13 名が新卒 3 年目までのエンジニアであり、社内の若手バックエンドエンジニアがほぼ参加していました。 また、クックパッドは Wi-Fi スポンサーとして協賛しており、更に 16 日の夜には Cookpad Drinkup at RubyKaigi 2024 と称して懇親会を開きました。 ドリンクアップの話はまた後日記事として公開されると思います。</p> <p><blockquote data-conversation="none" class="twitter-tweet" data-lang="ja"><p lang="ja" dir="ltr">Cookpad Drinkup、もう少しでオープンです🍻 お待ちしておりま〜す! <a href="https://twitter.com/hashtag/rubykaigi?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigi</a> <a href="https://twitter.com/hashtag/rubykaigi_cookpad?src=hash&amp;ref_src=twsrc%5Etfw">#rubykaigi_cookpad</a><a href="https://t.co/vksM1bZ9kW">https://t.co/vksM1bZ9kW</a> <a href="https://t.co/2Q6rOV0uqi">pic.twitter.com/2Q6rOV0uqi</a></p>&mdash; Cookpad Tech Life (@cookpad_tech) <a href="https://twitter.com/cookpad_tech/status/1791042673396289785?ref_src=twsrc%5Etfw">2024å¹´5月16æ—¥</a></blockquote> <script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script> </p> <h2 id="RubyKaigi-2024-について">RubyKaigi 2024 について</h2> <p>セッションは全部で 53(去年 51 )で、スポンサー数は 97(去年 89 )でした。スポンサー数は個人スポンサーが存在していた 2011 年を抜けば過去最大の値でした。 今回最も大きく変わっていたのはイベントの数であり、今年は 23(去年 9 )のイベント(パーティ)が設定されていました。夜な夜な那覇市のどこかで Rubyist の集まりが開かれており、自分も連日さまざまなところで飲みいろいろな方と交流ができました。 また、夜だけでなく昼には他社のエンジニア同士で集まりランチに向かうという取り組みがちらほら起こっており、他社の Rubyist の方と交流することができました。</p> <h2 id="RubyKaigi-2024の全体感">RubyKaigi 2024の全体感</h2> <p>今回の RubyKaigi のセッションをテーマ別にざっくり分類してみると、パフォーマンスに関するセッションが多く見られました。</p> <p>パフォーマンスについては Matz さんの Keynote でも第1にパフォーマンス、第2にパフォーマンス...とにかくこれからは(も)パフォーマンス改善に注力していきたいと述べられていました。具体的には VM の高速化、メモリ効率の改善、スレッド=パフォーマンス改善という需要への対応、ソフトウェア面での高速化等で、今後どれだけ改善していくか楽しみです。</p> <p>また、パフォーマンス以外では、型やパーサー、 wasm の話も多くありました。型については day 3 の Ruby Committers and the World でも白熱した議論が起こり面白かったです。Ruby の型についてはまだまだ方が付きそうにありませんね。 パーサーの話で言えば、Matz さんからシンタックス・モラトリアムという、少なくとも今年いっぱいは文法に手を加えないようにし、パーサ周りの改善に注力していこうという提案がなされました。手書きパーサの Prism と文法定義から生成する Lrama が今後それぞれどのように発展し、互いに影響を与えていくのか注目です。世はまさに大パーサ時代。</p> <h2 id="パフォーマンス">パフォーマンス</h2> <p>先ほどRubyKaigi 2024ではパフォーマンスの話が多かったと述べましたが、ここでは特に印象に残ったパフォーマンスのトークについて紹介します。</p> <p>取り上げるのはRubyそのものの実行速度の向上を目的とした、<a href="https://rubykaigi.org/2024/presentations/tenderlove.html">Speeding up Instance Variables with Red-Black Trees</a> です。</p> <p>本トークは Ruby のインスタンス変数へのアクセスを平衡二分探索木である赤黒木で高速化したという内容です。既にこの内容は Ruby 3.4にマージされており、Pull Request は右です (<a href="https://github.com/ruby/ruby/pull/8744">https://github.com/ruby/ruby/pull/8744</a> )。 この高速化においては、Ruby 3.2 で導入された、インスタンス変数の管理方法である Object Shape が前提となっています。( RubyKaigi 2022でCRubyに実装された時の発表 <a href="https://rubykaigi.org/2022/presentations/jemmaissroff.html">https://rubykaigi.org/2022/presentations/jemmaissroff.html</a> )</p> <p>このトークを取り上げた理由としては、私自身がデータ構造やアルゴリズムを使った高速化について興味があったのと、思ったより前提となるこの Object Shape の日本語解説が少なく、Object Shape を理解する上で誰かの参考になればと思い取り上げました。</p> <p>そもそも Ruby (CRuby) ではインスタンス変数を配列で保存しているため<sup id="fnref:1"><a href="#fn:1" rel="footnote">1</a></sup>、インスタンス変数にアクセスするには、当該配列においてインスタンス変数と対応している要素のindexを知る必要があります。Ruby VM ではインラインキャッシュと呼ばれるしくみにより、index をキャッシュすることで高速化を行なっています。しかし、同じインスタンス変数名があるクラスが複数登場するとキャッシュがうまく機能しないという問題がありました。そんな時、Ruby 3.2で導入された Object Shape がこれを解決しました。 Object Shape により「別のインスタンスだが、同名インスタンス変数のindexが同じである」ということを認識することができるようになり、index が同じなので使いまわせるとして、キャッシュヒット率を向上させることができるようになりました。</p> <p>それでもキャッシュヒットしない(キャッシュミス)が起こる事例はかなりあるようで、例えば下のようなメモ化は Object Shape と相性が悪いようです。</p> <pre class="code lang-ruby" data-lang="ruby" data-unlink><span class="synPreProc">class</span> <span class="synType">Sample</span> <span class="synPreProc">def</span> <span class="synIdentifier">initialize</span>(x) <span class="synIdentifier">@hoge</span> = x <span class="synPreProc">end</span> <span class="synPreProc">def</span> <span class="synIdentifier">foo</span>(x) <span class="synIdentifier">@_foo</span> ||= x <span class="synPreProc">end</span> <span class="synPreProc">def</span> <span class="synIdentifier">bar</span>(x) <span class="synIdentifier">@_bar</span> ||= x <span class="synPreProc">end</span> <span class="synPreProc">end</span> </pre> <p>Object Shape ではインスタンス変数の初呼び出しにより Shape が決定されます。そのため、インスタンスにおける foo と bar の最初の呼び出しの順番が異なるだけで違う Shape になってしまい、キャッシュを使うことができません。結果としてキャッシュミスが起こります。キャッシュミスが起こるとインスタンス変数の index を獲得する必要があります。Object Shape を使った場合、そのクラスが複雑な Shape でない場合はインスタンス変数の index を獲得するために O(|インスタンス変数|) の計算処理が必要でした。 そのため、キャッシュミス時の index の高速取得が求められていました。</p> <p>Object Shape 自体はそもそも木構造でそれぞれの Shape を管理していて且つそれぞれのインスタンス変数の index が各ノードに格納されています。本トークのアイディアはこの木構造をできるだけ均一の高さにすることで index の探索を早くするというものです。</p> <p>個人的な感想として、赤黒木は同じ平衡二分探索木であるAVL木に比べて木の形が歪になりやすいことが知られているので、ノードの検索は AVL 木の方が速いはずです。そのため、AVL 木で実装した場合とどちらが速いのか気になりました。 また、本トークを聴くまでは Object Shape についてあまり詳しく知らなかったので、本セッションにより Object Shape の気持ちも多少感じることができました。 加えて、Ruby の高速化のため Object Shape に優しいコード(インスタンス変数の代入は同じ順序で行う方が良い)を書こうという気になれました。 ただ一方で社内の人と議論をした際、「Ruby のユーザーとしてはどう書いてもある程度速くなってほしい」という話題になり、普段から Object Shape を考えながらコードを書くことは難しいよねという話になりました。 もちろん普段から常に意識することは難しいですが、例えばメモ化をできるだけ控えるとか、コンストラクタにおける変数初期化の際に変数の順番を一定にするツールを使う(あるのか?)とかである程度 Object Shape フレンドリーなコードを書くことができるのかなとも思いました。参考<sup id="fnref:2"><a href="#fn:2" rel="footnote">2</a></sup></p> <p>今後もどのような高速化手法が出てくるか楽しみです。</p> <h2 id="終わりに">終わりに</h2> <p>次回の RubyKaigi 2025 は愛媛県松山市にて開催予定です。 愛媛県には千と千尋の神隠しの舞台になった道後温泉やゆるキャラのバリィさん<sup id="fnref:3"><a href="#fn:3" rel="footnote">3</a></sup>などがいるのでとても楽しみです。</p> <div class="footnotes"> <hr/> <ol> <li id="fn:1"> <a href="https://github.com/ruby/ruby/blob/fd549b229b0822198ddc847703194263a2186ed1/include/ruby/internal/core/robject.h#L97">https://github.com/ruby/ruby/blob/fd549b229b0822198ddc847703194263a2186ed1/include/ruby/internal/core/robject.h#L97</a><a href="#fnref:1" rev="footnote">&#8617;</a></li> <li id="fn:2"> <a href="https://island94.org/2023/10/writing-object-shape-friendly-code-in-ruby">https://island94.org/2023/10/writing-object-shape-friendly-code-in-ruby</a><a href="#fnref:2" rev="footnote">&#8617;</a></li> <li id="fn:3"> <a href="https://www.barysan.net">https://www.barysan.net</a><a href="#fnref:3" rev="footnote">&#8617;</a></li> </ol> </div> horiso0921 NLP2024 に参加しました hatenablog://entry/6801883189100841820 2024-04-25T13:03:54+09:00 2024-04-25T13:03:54+09:00 こんにちは! 技術部機械学習グループの山口 (@altescy) です。 先月、神戸にて開催された言語処理学会第30回年次大会 (NLP2024)に同じく機械学習グループの深澤 (@fufufukakaka)と共に参加してきました。 昨年に引き続き今年も過去最多の参加者数となり、言語処理研究の盛り上がりを実感しました。 特に去年の年次大会 (NLP2023) のタイミングで GPT-4 が発表されて以降、自然言語処理の研究は大きな転換期を迎えていると感じます。 大規模言語モデル (LLM) が研究の主流となる中、どんな課題や発見があるのか、期待をもって参加する大会となりました。 この記事では … <p>こんにちは! 技術部機械学習グループの山口 (<a href="https://x.com/altescy">@altescy</a>) です。</p> <p>先月、神戸にて開催された<a href="https://www.anlp.jp/nlp2024/">言語処理学会第30回年次大会 (NLP2024)</a>に同じく機械学習グループの深澤 (<a href="https://x.com/fukkaa1225">@fufufukakaka</a>)と共に参加してきました。 昨年に引き続き今年も過去最多の参加者数となり、言語処理研究の盛り上がりを実感しました。</p> <p>特に去年の年次大会 (NLP2023) のタイミングで GPT-4 が発表されて以降、自然言語処理の研究は大きな転換期を迎えていると感じます。 大規模言語モデル (LLM) が研究の主流となる中、どんな課題や発見があるのか、期待をもって参加する大会となりました。</p> <p>この記事では NLP2024 にてクックパッドから発表した 2 つの研究と、その他の興味深かった研究についていくつか紹介します。</p> <h2 id="発表内容の紹介">発表内容の紹介</h2> <p>クックパッドからは以下 2 つの研究を発表しました。</p> <ul> <li><a href="#p2-11-sequential-recommendation-%E3%81%AB%E3%81%8A%E3%81%91%E3%82%8B%E3%83%86%E3%82%AD%E3%82%B9%E3%83%88%E6%83%85%E5%A0%B1%E3%82%92%E6%B4%BB%E7%94%A8%E3%81%97%E3%81%9F%E6%9C%AA%E7%9F%A5%E3%82%A2%E3%82%A4%E3%83%86%E3%83%A0%E3%81%B8%E3%81%AE%E5%AF%BE%E5%87%A6%E6%B3%95%E3%81%AB%E9%96%A2%E3%81%99%E3%82%8B%E5%88%86%E6%9E%90">P2-11: Sequential Recommendation におけるテキスト情報を活用した未知アイテムへの対処法に関する分析</a></li> <li><a href="#p3-8-recipests-%E3%83%AC%E3%82%B7%E3%83%94%E3%81%AE%E3%81%9F%E3%82%81%E3%81%AE%E9%A1%9E%E4%BC%BC%E6%80%A7%E8%A9%95%E4%BE%A1">P3-8: RecipeSTS: レシピのための類似性評価</a></li> </ul> <h3 id="P2-11-Sequential-Recommendation-におけるテキスト情報を活用した未知アイテムへの対処法に関する分析">P2-11: Sequential Recommendation におけるテキスト情報を活用した未知アイテムへの対処法に関する分析</h3> <ul> <li>著者: 深澤祐援、山口泰弘</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P2-11.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P2-11.pdf</a></li> </ul> <p>推薦モデルの一種として、Sequential Recommendation Modelがあります。これはアイテムIDの系列情報を基に次にアクションすべきアイテムを推薦するモデルです。このモデルは未知のユーザーであっても、既知のアイテムで構成された履歴があれば推薦が可能です。しかし、未知のアイテムが入力として与えられた場合、そのアイテムはモデルにとってout-of-vocabularyであり、入力に使用することはできません。実際のサービスで推薦モデルを運用する上では、学習時に存在しなかった新着アイテムを無視せざるを得ないですが、仮にそうしたアイテムだけを見ているユーザがいたとすると、そのユーザには推薦を表出できません。 この研究では、未知のアイテムが入力された際に最も効果的にそれを取り扱う方法を検討しました。</p> <p>本研究ではクックパッドにおける実際の閲覧履歴データ、及びNII IDRで公開しているつくれぽデータセットを使用しました。Sequential Recommendation Modelとしては、2021å¹´SIGIRで発表されたCOREを使用します。</p> <p>今回、未知アイテムを取り扱う方法として3つの手法を提案しました。</p> <ol> <li>テキスト情報を用いた未知アイテムのID埋め込み推測(Embedding Mapping) <ul> <li>未知アイテムが持つ情報としてテキスト情報(本研究ではレシピタイトル)を使用します。</li> <li>予め学習された推薦モデルのID埋め込みとアイテムのテキスト情報を対にして、テキスト情報からID埋め込みを推定するモデルを学習します。</li> <li>未知アイテムが入力された際には、そのアイテムのテキスト情報を用いてID埋め込みを推定し、そのID埋め込みを用いて推薦を行います。</li> <li>この手法の構築にはBERT及びLSTMを使用しました。</li> </ul> </li> <li>類似度が高いアイテムによる置換(Replace Similar Item) <ul> <li>未知アイテムが入力された際に、そのアイテムとテキストの類似度が高い既知アイテムを入力履歴から探し出し、そのアイテムを未知アイテムの代わりに入力として使用します。</li> </ul> </li> <li>未知アイテムを入力履歴から除外(Ignore) <ul> <li>未知アイテムが入力された際に、そのアイテムを入力履歴から除外し、残りのアイテムのみを使用して推薦を行います。</li> </ul> </li> </ol> <p>履歴の先頭または末尾から最大5つが未知アイテムであると仮定します。未知アイテムとなったものについては、推薦モデルから対象のID埋め込みを削除して入力に利用できないようにします。</p> <p>以下が実験結果となります。それぞれのデータセットに対して、各手法を適用した際のNDCG@50の結果です。</p> <p><figure class="figure-image figure-image-fotolife" title="NDCG@50 の結果一覧"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20240423/20240423182832.png" width="1200" height="670" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>NDCG@50 の結果一覧</figcaption></figure></p> <p>まず分かる基本的なこととして、履歴の先頭側よりも末尾側(より最近のアイテム)が未知である場合の方が精度影響が大きいことが見て取れます。各手法に関する結果としては、今回提案した手法である Embedding Mapping が他手法と比べて十分な精度を出すには至らず、多くの場合でシンプルな別手法が上回る結果となりました。今回対象とした系列が10要素以上のもののみを対象としていたこともあり、単純に未知アイテムを除外する Ignore がほとんどのケースで最も良い性能を示しました。ただ、閲覧データ・つくれぽデータのどちらを対象としていたかによって若干結果は異なっており、類似アイテムが並びやすい閲覧データに対しては Replace Similar Item が有効に働く場面もありました。</p> <p>この結果に関連して以下のような分析を行いました。 事前にレシピテキストで学習されたfastTextベクトルでコサイン類似度が0.8以上となるタイトル類似ペアを100組抽出します。その後、Embedding Mapping(LSTM)とCOREのID埋め込みベクトルを使ってペアのコサイン類似度を計算し、その平均を算出しました。</p> <p><figure class="figure-image figure-image-fotolife" title="類似ペアの調査"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20240423/20240423182908.png" width="670" height="226" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>類似ペアの調査</figcaption></figure></p> <p>この結果を見ると、本来近づけたかった Embedding Mapping(LSTM) と CORE との間で全く異なる傾向が見られています。fastTextベクトルの類似度が高いレシピ同士であっても、CORE のID埋め込みの類似度は比較的低めであることがわかりました。 このことから、今回提案した Embedding Mapping では捉えきれない性質が CORE の ID埋め込みに備わっていることが考えられます。ID埋め込みを復元するように学習することで性質を獲得できないか、と考えて取り掛かったのですがまだ改善できる点があるようでした。</p> <p>Sequential Recommendation Model はオンライン推薦を実装する上で非常に重要な選択肢の一つです。一方で実サービスでの運用を考えると、モデル学習後に登録される新着アイテムを上手く推論に利用できるようになれば、今まで以上にユーザの行動を捉えられるようになるはずです。 今後の研究では、より広いパラメータ設定ので実験を行うと共により良い ID埋め込みの推定方法を模索するなどに努めていきたいと思います。</p> <h3 id="P3-8-RecipeSTS-レシピのための類似性評価">P3-8: RecipeSTS: レシピのための類似性評価</h3> <ul> <li>著者: 山口泰弘、深澤祐援、原島純</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P3-8.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P3-8.pdf</a></li> </ul> <p>レシピは自然言語で書かれる文書形態の一種であるものの、通常のテキストとは異なる特徴を持っているため既存のデータセットで評価された基盤モデルがレシピを上手く扱えるかどうかは定かではありません。 この研究では言語モデルのレシピ処理能力を理解するための第一歩として、レシピタイトルを対象にした STS (Semantic Textual Similarity) データセットを構築し、既存の言語モデルの評価と今後の研究方針を示しました。</p> <p>データセットの作成にあたっては、2つの異なるレシピタイトルのペアに対して以下のアノテーション基準に基づき人手によるアノテーションを実施しました。 500件のペアについて、1ペアあたり 5 人の作業者が 0 ~ 5 のスコアを付与し、その平均値を正解のスコアとして採用しました。</p> <p><figure class="figure-image figure-image-fotolife" title="RecipeSTS のアノテーション基準"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20240423/20240423182934.png" width="1094" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>RecipeSTS のアノテーション基準</figcaption></figure></p> <p>作成した RecipeSTS データセットを用いて言語モデル (BERT / T5) の性能を評価した結果が以下の図になります。 各言語モデルから作成したレシピタイトルの埋め込み表現のコサイン類似度と、アノテーションされたスコアのスピアマン順位相関係数を示しています。 また、<code>+ fine-tuning</code> は事前学習済みモデルに対して独自のレシピデータで追加学習したモデルを表しています。</p> <p><figure class="figure-image figure-image-fotolife" title="RecipeSTS の評価結果"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20240423/20240423183004.png" width="1137" height="780" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>RecipeSTS の評価結果</figcaption></figure></p> <p>今回試した中で最も高い性能を示したのはレシピデータで追加学習した BERT モデルでした。 合わせて評価を行った <a href="https://github.com/yahoojapan/JGLUE?tab=readme-ov-file#jsts">JSTS</a> の結果と比べると、既存の言語モデルは一般的なテキストに比べてレシピテキストの処理は不得意な傾向があるように見えます。</p> <p>また、追加で OpenAI Embedding API を使った評価も上図下部に記載しました。 論文執筆時点では text-embedding-3 の公開前であったため追加学習したモデルが最も高い性能を示していましたが、text-embedding-3-large は今回比較したモデルの中で最高性能を達成しています。 それでもやはり JSTS の結果と比べるとレシピテキストを不得意とする傾向はあるようで、レシピ処理における課題は依然として残されていると言えるでしょう。</p> <p><figure class="figure-image figure-image-fotolife" title="RecipeSTS の事例"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/altescy/20240423/20240423183030.png" width="1200" height="381" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>RecipeSTS の事例</figcaption></figure></p> <p>いくつかの事例をピックアップしてみると、上図の (a)、(b)、(d) のように表層的な類似・相違の影響で意味的な類似性を捉えられていないケースが複数存在しました。 また、事例 (c) はどちらも「オイスター炒め」であるものの、調理法や食材など着目する観点によって類似性の評価が変化するケースと考えられます。 レシピの類似性評価においては、より多面的な基準が必要になりそうです。</p> <p>類似性評価はモデル選択など機械学習タスクの基礎のみでなく、検索や推薦といった応用においても重要な要素です。 今後の研究では、調理法・食材・味付けといったよりレシピに特化した多面的な基準に基づくデータセットの構築や、レシピに適した基盤モデルの開発に取り組みたいと考えています。</p> <h2 id="気になった発表">気になった発表</h2> <p>以下は NLP2024 で発表された研究の中から、山口・深澤が特に興味深かったものをピックアップして紹介します。</p> <h3 id="A4-3-LLM-の出力結果に対する人間による評価分析とGPT-4-による自動評価との比較分析">A4-3: LLM の出力結果に対する人間による評価分析とGPT-4 による自動評価との比較分析</h3> <ul> <li>紹介: 山口</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/A4-3.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/A4-3.pdf</a></li> </ul> <p>題の通り、LLMの出力結果を人間、GPT-4で評価した結果を比較分析するという研究です。 <code>GPT3.5-turbo-1106</code> (GPT-3.5) と <code>houou-instruction-7b-v2</code> (houou) を対象に、Rakuda ベンチマークを用いてそれぞれの応答について関連性・正確性など複数の観点で評価を行っています。 実験の結果、人間とGPT-4の判断には乖離があり、GPT-4 は houou の方が優れていると評価した一方、人間は GPT-3.5 の方が優れていると評価したケースが多かったとのことです。</p> <p>houou は具体的な数値や情報を含む応答を生成する傾向があるらしく、GPT-4 はそうした情報の具体性を評価したと考えられています。 しかし、人間が事実確認も含めて houou を評価したところ、ハルシネーションが多く、特に正確性の点で劣っていると判断されたようです。 houou の学習に利用された ichikara-instruction データセットは具体的な情報を含む例が多く、その傾向が houou の出力にも反映されていると考えられています。</p> <p>この研究を見ると、LLMを評価に利用することやインストラクションデータセットを設計・構築する難しさを感じます。 情報の具体性と正確性のトレードオフについて示唆を得られる興味深い発表でした。</p> <h3 id="P6-25-自己認知は-LM-as-KB-の信頼性を高めるか">P6-25: 自己認知は LM as KB の信頼性を高めるか</h3> <ul> <li>紹介: 山口</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P6-25.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P6-25.pdf</a></li> </ul> <p>真偽値で回答可能なQAタスク (StrategyQA) を対象に、予測の不確実性を考慮して応答を行う仕組みを提案した研究です。 予測が不確実な場合は質問を等価な命題集合・論理式へと再帰的に分割し、それぞれの命題に対して回答を得ることで与えられた質問に答えるという手法 (Back-off LMKB) を提案しています。 不確実性を考慮しない場合や間接証明しない場合と比べて、提案手法を用いることでより正確な回答を得られることを示しています。</p> <p>応答が真偽値であることを利用して質問を論理式に分解するという発想は合理的で興味深いと感じました。 最初の回答で真偽不明だった質問においても間接証明により正確な回答が得られていて、提案手法の有効性が示されています。 一方、課題にも書かれているように不確実性の推定や命題集合の生成精度については今後の発展が期待されます。</p> <p>個人的に自己認知的なアプローチは LLM をはじめとした AI システムの能力向上につながるのではないかと期待しています。 モデル自身が出力を再帰的に検証するという仕組みは、他のタスクにも適用できる可能性があると感じました。</p> <h3 id="P10-6-事前学習済みの分散表現は表層的な知識を獲得しているか">P10-6: 事前学習済みの分散表現は表層的な知識を獲得しているか</h3> <ul> <li>紹介: 山口</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P10-6.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/P10-6.pdf</a></li> </ul> <p>現在の LLM、例えば GPT-3.5 では 『「人類学者」の 3 文字目は何ですか?』といった表層に関関する質問に正しく回答できない場合が多くあります。 この研究ではWord2Vec・BERT・T5・Llama2 など複数の学習済みの言語モデルを対象に、分散表現や生成結果を用いてモデルに表層的な情報がどの程度含まれているかを調査しています。 文字数や構成文字の予測といったタスクを通して、学習済み言語モデルが表層の情報を部分的には獲得しているものの、出力に表層の知識を反映させることが不得意であったり、出現位置や順序の情報は十分獲得できていないことが示されています。</p> <p>特に興味深かったのは、文字数を予測するタスクにおいて分散表現から予測した場合とテキスト生成で予測した場合の性能の差です。 BERT ã‚„ Llama2 において、分散表現を利用した場合にはある程度予測できているものの、テキスト生成で予測した場合にはその性能が大きく低下したとのことです。 モデルの内部に表層の情報を獲得できたとしても、出力のメカニズムによってそれを反映できていない可能性がありそうです。</p> <p>文字数制限のある要約など、タスクによっては表層の情報が重要になる場合もあるはずです。 言語モデルの振る舞いや能力を理解するためには、意味的な評価と合わせて表層の情報を扱う能力について考えることも大切だと感じました。</p> <h3 id="A10-4-平均プーリングによる文埋め込みの再検討-平均は点群の要約として十分か">A10-4: 平均プーリングによる文埋め込みの再検討: 平均は点群の要約として十分か?</h3> <ul> <li>紹介: 深澤</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/A10-4.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/A10-4.pdf</a></li> </ul> <p>文章などをベクトル化することは近年の検索拡張生成(RAG) などを実装する上で必要不可欠な技術として認識されてきています。こちらの研究では文埋め込みを作る際に最もよく用いられる平均プーリングが、単語埋め込みの空間的な広がりの情報を潰してしまう問題を指摘しています。つまり、「意味の異なる点群なのに平均が近くなる」という問題です。 これを確かめるための実験として、WMD(Word Mover's Distance) による点群としての類似度と平均プーリング後のL2距離、人手評価による類似度を用意し、それぞれの類似度を比較しています。結果として、WMDによる類似度が低い場合に平均プーリング後のL2距離が高いケースがいくつか存在していることが確認できたとのことです。この結果は経験的に平均プーリングが有効であることを示していますが、同時に考慮しなければならないケースが有ることも示しています。</p> <p>点群を点群のまま捉えられるリーズナブルなモデルを用意できればいいのですが、基本的に点として圧縮されている平均プーリングの方が現時点ではやはり扱いやすいです。ただ、個人的にも平均プーリングという操作が言葉の意味を正しく捉えられているかというと疑問がある、と常々感じていたため、こちらの研究におけるクエスチョンは非常に共感できました。 今回調査した STS データでは平均プーリングでほとんどのケースに対応できていましたが、ドメインを絞ったりしてみると特有のドメインでは問題が発生しやすいなどがあるかもしれないと思っています。とても今後が気になる興味深い研究でした。</p> <h3 id="E6-2-意味変化の統計的法則は1000年成り立つ">E6-2: 意味変化の統計的法則は1000年成り立つ</h3> <ul> <li>紹介: 深澤</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/E6-2.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/E6-2.pdf</a></li> </ul> <p>意味変化の統計的法則とは、頻度語ほど意味変化の度合いが小さく多義語ほど意味変化の度合いが大きくなるという、ACL2016 にて Hamilton らが発表したものを指しています。先の研究における時間範囲は 1800年から2000年でしたが、こちらの研究では聖書を対象に含めることで、Hamilton が発見した意味変化の統計的法則が1000年経過しても成り立つことを調べています。シード語を設定し、聖書を構成するラテン語とラテン語をもとにして派生したロマンス語との間で意味変化が成り立つかを調べたところ、頻度が高い・多義性の低いラテン語語源ほどロマンス語形との意味のずれが小さくなる傾向が見られ、1000年単位であっても意味変化の統計的法則が成り立つことを示したとのことでした。</p> <p>個人的に Hamiltion の研究は当時読んだときからとても印象に残っていました。こちらの研究は聖書に着目してその時間範囲を広げた分析を行う、というのがユニークだなと感じ、紹介させていただきました。意味変化の法則が長い時間を書けても変化しない普遍的なものだとすると、今後も同様の変化が今我々が扱っている言葉でも起きうるということになります。例えばマルチエージェンシミュレーションなどで人工言語のモデリングを行う際などにも今回の法則を取り入れることでより自然な言語生成が可能になるかもしれません。非常に興味深い研究でした。</p> <h3 id="B7-4-文脈構造を利用した埋め込み表現学習の提案">B7-4: 文脈構造を利用した埋め込み表現学習の提案</h3> <ul> <li>紹介: 深澤</li> <li>è«–æ–‡: <a href="https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/B7-4.pdf">https://www.anlp.jp/proceedings/annual_meeting/2024/pdf_dir/B7-4.pdf</a></li> </ul> <p>文章や画像に対する埋め込み表現は、検索を始めとした様々なアプリケーションで利用されています。この研究では、文脈構造を利用することで埋め込み表現の表現能力を向上させるための最適輸送を用いた教師なし埋め込み学習手法を提案しています。BERT を対象として考えた時、先行研究では CLS トークンのみに着目した学習が行われます。一方で提案手法では最適輸送を用いてシャッフルした文章の各トークンについて輸送コストを最小化するように学習させることで、文脈構造を考慮した埋め込み表現を獲得することに成功しています。得られたモデルは SimCSE などの先行研究で提案されたモデルよりも STS タスクなどにおいて高い性能を示しているとのことです。</p> <p>こちらの研究を紹介させていただいた理由は、先に示した平均プーリングに関する調査を行った論文と同じモチベーションによるものです。つまり、平均プーリングで潰す・CLSだけに着目するよりももっと良い方法があるのではないか、という問いです。こちらの論文は最適輸送によって点群全体での学習を行う、というアプローチで、非常に納得しやすいものでした。発表後の質疑応答で、点群全体を見る分どうしても計算時間がかかってしまう、という問題があるとのことでしたが、今後も追いかけたい研究だな、と感じました。</p> <h2 id="おわりに">おわりに</h2> <p>今回の記事では NLP2024 の参加レポートをお届けしました。</p> <p>冒頭にも書いた通り、去年と比較して NLP2024 は特に LLM の存在感を強く感じる大会だったように感じます。 LLM の構築や評価の研究はもちろん、その他の研究においても LLM との比較や LLM の活用を意識した研究を多く見かけました。 きっとこの流れはまだしばらく続くのでしょう。 激動の時期を迎える中、今大会で得られた知見をもとに LLM をはじめとした最新の言語処理技術を実際のサービスにも活用していきたいと思います!</p> altescy AWS 内で大規模言語モデルを利用できる Amazon Bedrock を使って作る RAG アプリケーション hatenablog://entry/6801883189051716186 2023-10-19T13:48:02+09:00 2023-10-19T13:50:33+09:00 こんにちは。機械学習グループの深澤(@fukkaa1225)です。 先日、Amazon Bedrock が一般利用できるよう(GA)になりました 。本記事ではこちらを用いて RAG(Retrieval-augmented generation) アプリケーションを作成してみた様子と、他 LLM モデルとの比較結果についてご紹介します。 Amazon Bedrock とは aws.amazon.com 公式サイトより文言を引用します。 Amazon Bedrock は、Amazon や主要な AI スタートアップ企業が提供する基盤モデル (FM) ã‚’ API を通じて利用できるようにする完全マネ… <p>こんにちは。機械学習グループの深澤(<a href="https://twitter.com/fukkaa1225">@fukkaa1225</a>)です。</p> <p>先日、Amazon Bedrock が一般利用できるよう(GA)になりました 。本記事ではこちらを用いて RAG(Retrieval-augmented generation) アプリケーションを作成してみた様子と、他 LLM モデルとの比較結果についてご紹介します。</p> <h2 id="Amazon-Bedrock-とは">Amazon Bedrock とは</h2> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Faws.amazon.com%2Fjp%2Fbedrock%2F" title="基盤モデルによる生成系 AI アプリケーションの構築 - Amazon Bedrock - AWS" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://aws.amazon.com/jp/bedrock/">aws.amazon.com</a></cite></p> <p>公式サイトより文言を引用します。</p> <blockquote><p>Amazon Bedrock は、Amazon や主要な AI スタートアップ企業が提供する基盤モデル (FM) ã‚’ API を通じて利用できるようにする完全マネージド型サービスです。そのため、さまざまな FM から選択して、ユースケースに最も適したモデルを見つけることができます。Amazon Bedrock のサーバーレスエクスペリエンスにより、すぐに FM を開始したり、FM を簡単に試したり、独自のデータを使用して FM をプライベートにカスタマイズしたり、AWS のツールや機能を使用して FM をアプリケーションにシームレスに統合してデプロイしたりできます。Amazon Bedrock のエージェントはフルマネージド型で、デベロッパーは独自の知識源に基づいて最新の回答を提供し、幅広いユースケースのタスクを完了できる生成系 AI アプリケーションを簡単に作成できます。</p></blockquote> <p>殆どの企業にとって、現時点で LLM を使うときには OpenAI が提供する GPT-3.5-turbo, GPT-4 を使うことがほぼ唯一の選択肢になっているかと思います。弊社も GPT シリーズの API を活用して社内版 ChatGPT を展開しています。 一方で、OpenAI を用いる上でいくつか考えなくてはならない問題もあります。例えば、権限管理が API Key によってなされているため取り扱いに注意する必要があったり、OpenAI と通信する必要がある以上セキュリティ要件を満たせないケースがあるなどの点が挙げられます。 これに対して Amazon Bedrock は AWS のサービスであるため IAM での権限管理が可能です。通信も AWS 内で完結しており、VPC と接続できるのも嬉しいポイントです。モデルについても、GPT シリーズに匹敵する十分な性能を持ったものが用意されています。</p> <h3 id="Claude-とは">Claude とは</h3> <p>Amazon Bedrock で利用できるモデルはいくつかありますが、日本語での質問応答に適したものとなると実質的に使えるモデルは Claude シリーズです。 Claude シリーズは Anthropic が提供しているモデルです。Chatbot-arena・Nejumi JGLUE スコアリーダーボード などいくつかのベンチマークで Claude シリーズの能力は GPT シリーズに匹敵するスコアを出しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fwandb.ai%2Fwandb%2FLLM_evaluation_Japan%2Freports%2FLLM-JGLUE---Vmlldzo0NTUzMDE2%3FaccessToken%3Du1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa" title="Nejumi LLMリーダーボード" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://wandb.ai/wandb/LLM_evaluation_Japan/reports/LLM-JGLUE---Vmlldzo0NTUzMDE2?accessToken=u1ttt89al8oo5p5j12eq3nldxh0378os9qjjh14ha1yg88nvs5irmuao044b6eqa">wandb.ai</a></cite></p> <p>Amazon Bedrock では Playground で Claude シリーズとのチャットを試すこともできます。試してみると、想像以上に流暢な日本語で喋ってくれることがわかります。下図のように夕飯の献立を提案してくれました。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/fufufukakaka/20231019/20231019132857.png" width="1200" height="627" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Claude シリーズの優れている点として、入力できるトークン数の多さと価格の安さが挙げられます。Claude シリーズに入力可能なトークン数は脅威の 10万トークンで、これは GPT-4 の 8192 トークンと比較すると圧倒的な数字です。 また、価格も GPT-4 と比べると非常に安いです。2023/10/12 時点で <a href="https://aws.amazon.com/jp/bedrock/pricing/">Build Generative AI Applications with Foundation Models - Amazon Bedrock Pricing - AWS</a> を見ると Claude 2 の値段は掲載されていないため、 Claude の値段で比較を行います。単位はドルです。</p> <table> <thead> <tr> <th> Model </th> <th> Input </th> <th> Output </th> </tr> </thead> <tbody> <tr> <td> Claude(100k) </td> <td> 0.011 </td> <td> 0.032 </td> </tr> <tr> <td> GPT-4(8k) </td> <td> 0.03 </td> <td> 0.06 </td> </tr> <tr> <td> GPT-4(32k) </td> <td> 0.06 </td> <td> 0.12 </td> </tr> <tr> <td> GPT-3.5-turbo(4k) </td> <td> 0.0015 </td> <td> 0.002 </td> </tr> <tr> <td> GPT-3.5-turbo(16k) </td> <td> 0.003 </td> <td> 0.004 </td> </tr> </tbody> </table> <p>Claude と GPT-4 のみの比較で言えば、Claude は 1/3 以下の値段となっています。それでいてベンチマーク上ではかなり良い勝負をしているので、非常に優れた選択肢であると言えるでしょう。GPT-3.5-turbo と比較してしまうと GPT-3.5-turbo の安さが際立ちます。トークン数や応答の質に関して満足できるならばやはり GPT-3.5-turbo は有力な選択肢ではあります。 一方で 10万トークンを実現しながら AWS 内で通信を完結できる十分な性能を持った LLM を用いることができる Bedrock はそれだけで十分なメリットを持っていると思います。</p> <h2 id="社内文書に対する-RAG-アプリケーションを作成する">社内文書に対する RAG アプリケーションを作成する</h2> <p>Cookpad には Groupad と呼ばれる社内 wiki のようなものがあります。かなり長年運用されており、様々な知見が蓄積されていて日々の仕事を助けてくれています。 一方で問題もあり、Groupad に対する検索システムはあまりチューニングを行っていないため、同義語解決などがされず、ほしい結果を得るのに苦労することがありました。 そこで、ユーザーからのクエリに基づいて外部データから関連するドキュメントを検索し、検索結果を prompt に埋め込んで LLM に結果を生成させて表示するアプリケーション (Retrieval-augmented generation、RAG) を作成することにしました。 LLM を用いて semantics を考慮したベクトル検索と質問応答を実装することで、チューニングの手間なく今よりも幅広い検索結果を得られるだろうと考えました。</p> <p>以下、実際に作ってみた様子をご紹介します。 RAG アプリケーションを作るために必要なコンポーネントはいくつか存在します。代表的なものとしては以下のようなものかと思います。</p> <ul> <li>Vector DB ... RAG のソースとなる情報(ここでは Groupad の文書群)をベクトルとして保持しておくための DB <ul> <li>Embedding Function ... ベクトル DB に文書を追加する際に文書をベクトルに変換する</li> <li>Retriever ... ベクトル DB から検索クエリにマッチする文書を取得する</li> </ul> </li> <li>LLM ... クエリから得られた関連文書を元に、LLM による対話応答を行う</li> </ul> <p>この内、LLM は Amazon Bedrock で使うことができる Claude 2 を用います。 Vector DB は Chroma DB を用いることにします。 簡単な構成図としては以下のようなものになります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/f/fufufukakaka/20231019/20231019132901.png" width="1200" height="658" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>なお、Bedrock には知識ベースと接続して質問応答を行うアプリケーションを作るための機能が存在します。<a href="https://aws.amazon.com/jp/bedrock/knowledge-bases/">&#x691C;&#x7D22;&#x62E1;&#x5F35;&#x751F;&#x6210; (RAG) - Amazon Bedrock &#x306E;&#x30CA;&#x30EC;&#x30C3;&#x30B8;&#x30D9;&#x30FC;&#x30B9; - AWS</a> この場合 Embedding には Bedrock が提供する Amazon Titan を使うことになります。ですが、Amazon Titan は現時点では英語にのみ対応したモデルで、日本語のテキストに対する埋め込み表現を得るのに適したモデルとなっていないため、今回は自分たちで huggingface hub から適したモデルをダウンロードして使うこととします。</p> <h3 id="Vector-DB">Vector DB</h3> <p>embedding function には <code>oshizo/sbert-jsnli-luke-japanese-base-lite</code> を利用しました。これを用いて Groupad の文書群をベクトル化し、 Chroma DB に保存します。 ベクトル検索をするだけのクライアントを用意するなら例えば以下のようなものが考えられるかと思います。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">class</span> <span class="synIdentifier">VectorSearcher</span>: <span class="synStatement">def</span> <span class="synIdentifier">__init__</span>(self, db_path: <span class="synIdentifier">str</span>) -&gt; <span class="synIdentifier">None</span>: embedding_function = embedding_functions.SentenceTransformerEmbeddingFunction(model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span>) client = chromadb.PersistentClient(path=db_path) self.collection = client.create_collection(<span class="synConstant">&quot;groupad&quot;</span>, embedding_function=embedding_function) <span class="synStatement">def</span> <span class="synIdentifier">set_groupad_data</span>(self, data_path: <span class="synIdentifier">str</span>) -&gt; <span class="synIdentifier">None</span>: logger.info(<span class="synConstant">&quot;set groupad data&quot;</span>) <span class="synComment"># columns: id, title, content, created_at</span> df = pd.read_csv(data_path) self.collection.add( ids=df[<span class="synConstant">&quot;id&quot;</span>].apply(<span class="synIdentifier">str</span>).values.tolist(), documents=df[<span class="synConstant">&quot;content&quot;</span>].values.tolist(), metadatas=[{<span class="synConstant">&quot;title&quot;</span>: title, <span class="synConstant">&quot;created_at&quot;</span>: created_at} <span class="synStatement">for</span> title, created_at <span class="synStatement">in</span> <span class="synIdentifier">zip</span>(df[<span class="synConstant">&quot;title&quot;</span>].values, df[<span class="synConstant">&quot;created_at&quot;</span>].values)], ) logger.info(<span class="synConstant">&quot;set done&quot;</span>) <span class="synStatement">def</span> <span class="synIdentifier">search</span>(self, query: <span class="synIdentifier">str</span>, top_k: <span class="synIdentifier">int</span> = <span class="synConstant">10</span>) -&gt; <span class="synIdentifier">list</span>[<span class="synIdentifier">tuple</span>[<span class="synIdentifier">str</span>, <span class="synIdentifier">str</span>]]: res = self.collection.query(query_texts=[query], n_results=top_k) <span class="synStatement">return</span> res </pre> <p>今回は RAG ã‚’ LangChain を用いて実装するため、実際にはこのベクトル検索クラスは用いません。</p> <h3 id="LLM-Claude-2">LLM: Claude 2</h3> <p>では続いて、今回の目玉である Claude 2 ã‚’ Amazon Bedrock から利用する設定をします。</p> <p>Bedrock の client はシンプルに <code>import boto3; client = boto3.client('bedrock')</code> で使えるようになっています。<a href="https://boto3.amazonaws.com/v1/documentation/api/latest/reference/services/bedrock.html">Bedrock - Boto3 1.28.66 documentation</a></p> <p>LangChain に Bedrock を使えるオプションが存在するので、今回はVector DB と接続する部分をそれに頼ることにします。 以下のように簡単に書けますので、あとはいつもの OpenAI などを使うときと同じように使えるはずです。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> langchain.llms <span class="synPreProc">import</span> Bedrock llm = Bedrock( credentials_profile_name=<span class="synConstant">&quot;bedrock&quot;</span>, model_id=<span class="synConstant">&quot;anthropic.claude-v2&quot;</span>, model_kwargs={ <span class="synConstant">&quot;max_tokens_to_sample&quot;</span>: <span class="synConstant">1000</span> } ) </pre> <h3 id="RAG-全体像">RAG 全体像</h3> <p>ChromaDB の設定などをすべて LangChain 上で行うようにして、以下のようにすれば RAG を実装できます。</p> <p>なお ChromaDB の設定上、sqlite のバージョンが 3.35.5 以上でないといけないため、事前に sqlite の設定が必要な場合があります。 (ビルドして <code>export LD_LIBRARY_PATH=sqlite-3.42.0/.libs</code> を指定するなど)</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synPreProc">from</span> langchain.chains <span class="synPreProc">import</span> RetrievalQA <span class="synPreProc">from</span> langchain.document_loaders.csv_loader <span class="synPreProc">import</span> CSVLoader <span class="synPreProc">from</span> langchain.embeddings <span class="synPreProc">import</span> HuggingFaceEmbeddings <span class="synPreProc">from</span> langchain.llms <span class="synPreProc">import</span> Bedrock <span class="synPreProc">from</span> langchain.vectorstores <span class="synPreProc">import</span> Chroma <span class="synStatement">def</span> <span class="synIdentifier">build_qa_chain</span>(file_path: <span class="synIdentifier">str</span>) -&gt; RetrievalQA: loader = CSVLoader(file_path=file_path, source_column=<span class="synConstant">&quot;content&quot;</span>) data = loader.load_and_split() embeddings = HuggingFaceEmbeddings( model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span> ) vectorstore = Chroma.from_documents(documents=data, embedding=embeddings) llm = Bedrock( credentials_profile_name=<span class="synConstant">&quot;bedrock&quot;</span>, model_id=<span class="synConstant">&quot;anthropic.claude-v2&quot;</span>, model_kwargs={<span class="synConstant">&quot;max_tokens_to_sample&quot;</span>: <span class="synConstant">1000</span>}, ) qa_chain = RetrievalQA.from_chain_type(llm, retriever=vectorstore.as_retriever()) <span class="synStatement">return</span> qa_chain </pre> <p>実際に使うときは以下のようにして利用することができます。</p> <pre class="code lang-python" data-lang="python" data-unlink>qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;ほげほげ&quot;</span>}) </pre> <h2 id="実際の出力を見てみた">実際の出力を見てみた</h2> <p>さて、ここまで RAG の実装方針について示してきました。社内ドキュメントを対象とした実際の例をご紹介することはできませんが、今回は代替データとして深澤が Techlife で公開した記事をソースとして、RAG の挙動を確かめることとします。</p> <p>今回のコードでは content カラムだけを取り込むようにしているので、以下の記事の markdown を持ってきて idx,content カラムの形式で csv <code>"data/fukasawa_techlife.csv"</code> に保存します。</p> <ul> <li><a href="https://techlife.cookpad.com/entry/nlp2023-attendance">NLP2023 &#x306B;&#x53C2;&#x52A0;&#x3057;&#x307E;&#x3057;&#x305F;&#xFF1A;&#x8074;&#x8B1B;&#x7DE8; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/cookpad-mart-item-recommendation">&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306B;&#x304A;&#x3051;&#x308B; item-to-item &#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C7;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x306E;&#x5909;&#x9077; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2021/11/04/090000">RecBole &#x3092;&#x7528;&#x3044;&#x3066;&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306E;&#x30C7;&#x30FC;&#x30BF;&#x306B;&#x5BFE;&#x3059;&#x308B;50&#x4EE5;&#x4E0A;&#x306E;&#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C9;&#x30E2;&#x30C7;&#x30EB;&#x306E;&#x5B9F;&#x9A13;&#x3092;&#x3057;&#x3066;&#x307F;&#x305F; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2021/09/06/130000">Cookpad Summer Internship 2021 10 Day Tech&#x30B3;&#x30FC;&#x30B9;&#x3092;&#x958B;&#x50AC;&#x3057;&#x307E;&#x3057;&#x305F;&#xFF01; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> <li><a href="https://techlife.cookpad.com/entry/2020/05/15/120000">&#x7CFB;&#x5217;&#x30E9;&#x30D9;&#x30EA;&#x30F3;&#x30B0;&#x306B;&#x3088;&#x308B; NPS &#x30B3;&#x30E1;&#x30F3;&#x30C8;&#x306E;&#x30DD;&#x30B8;&#x30C6;&#x30A3;&#x30D6;&#x30FB;&#x30CD;&#x30AC;&#x30C6;&#x30A3;&#x30D6;&#x90E8;&#x5206;&#x306E;&#x62BD;&#x51FA; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a></li> </ul> <p><code>qa_chain = build_qa_chain("data/fukasawa_techlife.csv")</code> として qa_chain を取り出せば質問応答を行うパイプラインが用意できます。 出力を見てみましょう。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">4</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできたレコメンドについて教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできたレコメンドについて教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">では、深澤さんが取り組んできたクックパッドマートのレコメンドについて要点をまとめます。</span> <span class="synConstant">- 最初はItem2Vecでレコメンデーションを実装していましたが、オフラインの評価指標が思わしくなかったため他の手法を探していました</span> <span class="synConstant">- そこでRecBoleを利用して30モデル以上の実験を行った結果、RecVAEが良好な指標を示しました</span> <span class="synConstant">- RecVAEとItem2Vecをオンラインで比較するため、インターリービングによるテストを実施しました</span> <span class="synConstant">- その結果、RecVAEの方が指標的に良く、Item2VecからRecVAEへの移行を決定しました</span> <span class="synConstant">深澤さんはItem2Vecから始まり、RecBoleを利用した実験を重ね、最終的にRecVAEへ移行したことが分かります。レコメンデーション精度の改善に向けた断続的な取り組みが伺えます。</span> <span class="synConstant">'''</span>} </pre> <p>質問応答としてはかなり完璧ですね。<a href="https://techlife.cookpad.com/entry/cookpad-mart-item-recommendation">&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x30DE;&#x30FC;&#x30C8;&#x306B;&#x304A;&#x3051;&#x308B; item-to-item &#x30EC;&#x30B3;&#x30E1;&#x30F3;&#x30C7;&#x30FC;&#x30B7;&#x30E7;&#x30F3;&#x306E;&#x5909;&#x9077; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a> での内容です。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">5</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできた仕事について教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできた仕事について教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">はい、深澤さんの仕事についてまとめてみました。</span> <span class="synConstant">- クックパッドに入社後、主に自然言語処理を活用したプロジェクトを担当されています。</span> <span class="synConstant">- NPSアンケートの自動分類システムを構築したり、本記事で紹介されているように、NPSコメントのポジティブ/ネガティブ部分の抽出システムの開発に取り組んでいます。</span> <span class="synConstant">- 料理手順の自動生成プロジェクトにも関わっていて、手順間の照応解析や手順から材料を自動抽出する研究などを行っています。</span> <span class="synConstant">- Google BigQueryを活用した大規模データ分析も行っており、レシピ閲覧ログの分析結果を基にレシピ検索の改善を図るなどしています。</span> <span class="synConstant">- 推薦システムの研究開発にも携わっており、Matrix Factorizationã‚„ popularity-based モデルなどを用いたレコメンデーションエンジンの開発をしています。</span> <span class="synConstant">- 卒業論文ではソーシャルメディアテキスト(Twitter)のセンチメント分析を研究していました。</span> <span class="synConstant">- 論文執筆や学会発表も積極的に行っており、自然言語処理分野を中心に研究開発を進めています。</span> <span class="synConstant">以上のように、クックパッドにおける深澤さんの仕事は、自然言語処理とデータ分析を組み合わせた研究開発が中心だと言えそうです。</span> <span class="synConstant">'''</span>} </pre> <p><code>料理手順の自動生成プロジェクト</code> ã‚„ <code>ソーシャルメディアテキスト(Twitter)のセンチメント分析</code> はしていないので hallucination が見られますが、大体の雰囲気はあっています。ちょっと範囲の広い聞き方をしたのが良くなかったのかもしれません。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">6</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんはインターンシップにもかかわっていたことがあるのですか?&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんはインターンシップにもかかわっていたことがあるのですか?'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'''</span> <span class="synConstant">はい、深澤さんはクックパッドでインターンシップにかかわっていました。</span> <span class="synConstant">深澤さんはクックパッドの研究開発部に所属していて、過去にサマーインターンシップのメンターを務めた経験があるようです。</span> <span class="synConstant">例えば、このブログ記事では2020年のサマーインターンシップで深澤さんがメンターを務めた様子が書かれています。</span> <span class="synConstant">https://techlife.cookpad.com/entry/2020/09/14/140000</span> <span class="synConstant">この記事によると、深澤さんは機械学習コースのメンターとして、インターン学生の技術指導を行ったそうです。また、インターンシップのプログラム作成にも関わったとのことです。他にも、深澤さんは過去のインターンシップで機械学習の講義を行ったり、インターン学生の研究開発にアドバイスをしたりするなど、インターンシップに積極的に関わってきたようです。そのため、深澤さんはインターンシップにかかわった経験が豊富で、インターン学生の育成に力を入れているエンジニアのようです。</span> <span class="synConstant">'''</span>} </pre> <p>回答してきた URL ã‚„ <code>機械学習コースのメンターとして、インターン学生の技術指導</code> というあたりもまた hallucination が起きています。一方で事実も存在しており、<code>インターンシップのプログラム作成にも関わった</code> という部分などは事実です。</p> <h3 id="GPT-4-との回答比較">GPT-4 との回答比較</h3> <p>claude-v2 には hallucination が見られましたが、GPT-4 ではどうでしょうか。出力を比較するためにコードに以下の変更を加えます:</p> <ul> <li><code>llm</code> の部分を <code>langchain.chat_models.ChatOpenAI</code> を用いるようにする</li> <li>GPT-4 の Max Token Size 8192 に合わせて <code>langchain.text_splitter.RecursiveCharacterTextSplitter</code> ã‚„ max_token パラメータの調整を行う</li> </ul> <p>最終的には以下のようなコードで実行しました。</p> <pre class="code lang-python" data-lang="python" data-unlink><span class="synStatement">def</span> <span class="synIdentifier">build_qa_chain2</span>(file_path: <span class="synIdentifier">str</span>) -&gt; RetrievalQA: loader = CSVLoader(file_path=file_path, source_column=<span class="synConstant">&quot;content&quot;</span>) data = loader.load_and_split(RecursiveCharacterTextSplitter(chunk_size=<span class="synConstant">2048</span>, chunk_overlap=<span class="synConstant">0</span>)) embeddings = HuggingFaceEmbeddings( model_name=<span class="synConstant">&quot;oshizo/sbert-jsnli-luke-japanese-base-lite&quot;</span> ) vectorstore = Chroma.from_documents(documents=data, embedding=embeddings) llm = ChatOpenAI( model_name=<span class="synConstant">&quot;gpt-4&quot;</span>, temperature=<span class="synConstant">0</span>, max_tokens=<span class="synConstant">4096</span> ) qa_chain = RetrievalQA.from_chain_type( llm, retriever=vectorstore.as_retriever(), chain_type=<span class="synConstant">&quot;stuff&quot;</span> ) <span class="synStatement">return</span> qa_chain </pre> <p>Claude 2 に対して行ったのと同じ質問を投げてみた結果、以下のような結果を得られました。</p> <pre class="code lang-python" data-lang="python" data-unlink>In [<span class="synConstant">71</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできたレコメンドについて教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできたレコメンドについて教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'深澤さんは、レコメンド系のプロジェクトRecBoleに取り組んでいます。RecBoleは再現性に取り組むプロジェクトで、50以上のレコメンドモデルを一つのコマンドで試すことができます。深澤さんは、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、これらのレコメンドモデルを試す実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できました。'</span>} In [<span class="synConstant">72</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんが取り組んできた仕事について教えてください&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんが取り組んできた仕事について教えてください'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'深澤さんはクックパッドの研究開発部で働いており、レコメンド系のプロジェクトRecBoleに注目しています。RecBoleを用いて、クックパッドが展開している事業の一つであるクックパッドマートのデータを使って、50以上のレコメンドモデルの実験を行いました。その結果、テストデータの6000ユーザに対して2000ユーザ(三分の一)に正しい推薦を行うことができるモデルを発見できたとのことです。'</span>} In [<span class="synConstant">73</span>]: <span class="synIdentifier">print</span>(qa_chain({<span class="synConstant">&quot;query&quot;</span>: <span class="synConstant">&quot;深澤さんはインターンシップにもかかわっていたことがあるのですか?&quot;</span>})) {<span class="synConstant">'query'</span>: <span class="synConstant">'深澤さんはインターンシップにもかかわっていたことがあるのですか?'</span>, <span class="synConstant">'result'</span>: <span class="synConstant">'文脈からは深澤さんがインターンシップに関与していたかどうかは明らかになりません。'</span>} </pre> <p>GPT-4 はレコメンドと仕事に関する質問については Claude 2 と同じような返答をしています。一方、インターンシップの話題に関してはうまく見つけられなかったのか答えられない、という返答になりました。hallucination を抑えるような学習の結果として、このように外れではない無難な回答に終始しているのかもしれません。</p> <p>Claude 2 は context window が大きい分長い系列を処理できますが、長距離依存をうまく扱えず hallucination が起こった可能性が考えられます。そうだとすれば max_token パラメータを調整することで hallucination を抑制できたかもしれません。</p> <p>LLM のこうした傾向の違いについてはまだあまり調査できていないため、今後比較検討できればと思っています。</p> <h2 id="まとめ">まとめ</h2> <p>Amazon Bedrock が提供している Claude 2 と LangChain を組み合わせて RAG アプリケーションを作成してみました。 Claude 2 は OpenAI のモデルと比べても性能やコストの点で魅力であり、これを AWS のサービスとして用いることができるのは非常に便利だなと感じました。 Amazon Bedrock の到来によって社内ドキュメント に対する RAG アプリケーションを作るハードルはかなり下がったと感じています。積極的に社内で試して、ゆくゆくはユーザの方にも使っていただけるような LLM アプリケーションを作る際の知見をためていきたいです。</p> <p>この記事を読んでいただきありがとうございました。 クックパッド機械学習グループでは引き続き最先端の機械学習の技術をプロダクトで活かすべく、試行錯誤と開発を進めていきます。</p> fufufukakaka Hatamoto 〜モバイルアプリに関する情報を一元管理するためのWebアプリケーション〜 hatenablog://entry/6801883189051182465 2023-10-18T10:00:00+09:00 2023-10-18T10:00:22+09:00 こんにちは。元モバイル基盤部(現クックパッドマートプロダクト開発部)の大川(@aomathwift)です。 クックパッドでは、レシピサービスのクックパッドアプリだけでなく、生鮮 EC サービスのクックパッドマートをはじめ、複数の iOS アプリを開発しています。 複数のアプリを開発する上で、機能そのものの開発以外に開発者が気にかけないといけないことがいくつかあります。 例えば、各種証明書の有効期限、ライブラリの選定やバージョンアップなどです。開発者はこれらの関心事に対して、全てのアプリで同じように注意していなければいけません。 クックパッドでは、「Hatamoto」という Web アプリケーシ… <p>こんにちは。元モバイル基盤部(現クックパッドマートプロダクト開発部)の大川(<a href="https://twitter.com/aomathwift">@aomathwift</a>)です。</p> <p>クックパッドでは、レシピサービスのクックパッドアプリだけでなく、生鮮 EC サービスのクックパッドマートをはじめ、複数の iOS アプリを開発しています。</p> <p>複数のアプリを開発する上で、機能そのものの開発以外に開発者が気にかけないといけないことがいくつかあります。 例えば、各種証明書の有効期限、ライブラリの選定やバージョンアップなどです。開発者はこれらの関心事に対して、全てのアプリで同じように注意していなければいけません。</p> <p>クックパッドでは、「Hatamoto」という Web アプリケーションでこれらのアプリに関する情報を一元管理しています。 本記事では、iOS アプリにまつわる情報管理の中で生じる課題と、Hatamoto がそれをどのように解決しているかを紹介します。</p> <h1 id="Hatamoto-導入前の課題">Hatamoto 導入前の課題</h1> <p>クックパッドでは、モバイル基盤というグループが App Store Connect をはじめとするアプリ開発に必要なサービスのワークスペースの管理者としての役割を担っています。アプリの証明書の発行等のオペレーションは、モバイル基盤グループで行っています。</p> <p>このモバイル基盤グループに所属するエンジニア目線で、Hatamoto 導入前は社内アプリの証明書管理やライブラリの更新に関していくつかの課題がありました。</p> <h2 id="開発者が自発的に証明書の有効期限に気づく必要がある">開発者が自発的に証明書の有効期限に気づく必要がある</h2> <p>iOSアプリでプッシュ通知を送信するには、Apple Push Notification Service(以下 APNs )の証明書が必要です。この証明書は1年という有効期限を持つため、継続的にプッシュ通知を送り続けるためには期限が切れる前に更新して差し替える必要があります。 もしこの証明書の差し替えを忘れて有効期限を過ぎると、アプリのプッシュ通知が突然届かなくなってしまいます。 そのため、アプリ開発者達は APNs 証明書の有効期限を把握し、期限が切れる前に自発的にモバイル基盤グループに更新を依頼する必要がありました。</p> <p>また、クックパッドでは、<a href="https://developer.apple.com/jp/programs/enterprise/">Apple Developer Enterprise Program</a> に加入しており、社内で動作確認するためのアプリを配信するのに利用しています。この Apple Developer Enterprise Program を使ってアプリを配信する際にも、通常の Apple Developer Program を使った配信と同様に Provisioning Profile が必要になります。 もしこの Provisioning Profile の更新を忘れて有効期限を過ぎると、社内ですでに配信済みだったアプリが突然利用できなくなるという、開発を進める上での問題が起こります。さらに、通常の Apple Developer Program で作成した App Store 配信用の Provisioning Profile も、更新を忘れると App Store にアップロードするバイナリをビルドできない問題に直面します。 したがって、これらも APNs 証明書と同様に、期限が切れる前に気づいて更新を依頼する必要がありました。</p> <h2 id="更新が必要なライブラリを使い続けていることに気づけない">更新が必要なライブラリを使い続けていることに気づけない</h2> <p>クックパッドで開発している iOS アプリでは、オープンソースのライブラリから社内ライブラリまで、様々なライブラリを利用しています。 モバイル基盤グループでは、あるライブラリで脆弱性を含んだバージョンがリリースされたり、既存の挙動を壊すような問題・変更のあるバージョンがリリースされたりした場合に、そのライブラリを使用しているアプリの開発者に迅速に共有したいという需要がありました。<a href="#f-6b93ebfd" name="fn-6b93ebfd" title="社内ではアプリごとにライブラリの更新を監視するスクリプトを自前で書いて週次で実行していましたが、利用するバージョンを固定している場合や、更新の緊急性が高い場合にこれが機能しないという事情がありました。現在は、Renovate のようなツールを利用するのも解決手段の一つだと思います。">*1</a></p> <p>しかし、各アプリで使われているライブラリとそのバージョンを常に人力で追い続けることは難しく、利用しているアプリ側で実際に問題に直面してから対処するということが多いのが実情でした。</p> <h1 id="Hatamotoの機能">Hatamotoの機能</h1> <p>上記の課題の解決をモチベーションに、Hatamoto には以下の機能が実装されています。</p> <h2 id="証明書の有効期限を監視し期日が近づいたら-Issue-を起票する">証明書の有効期限を監視し、期日が近づいたら Issue を起票する</h2> <p>各アプリの APNs 証明書に加え、Provisioning Profile ・開発者証明書の有効期限を一覧して管理できます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135006.png" width="1200" height="585" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>また、この収集した有効期限を毎日定期実行されるジョブで監視し、有効期限が1ヶ月以内に近づいた場合にモバイル基盤グループへの依頼用リポジトリ宛に更新依頼 Issue を自動で立てることができます。</p> <h2 id="各アプリで使われているライブラリを一覧する">各アプリで使われているライブラリを一覧する</h2> <p>アプリごとに、使用しているライブラリとそのバージョンを一覧できます。 iOS はライブラリの管理方法が SwiftPM、CocoaPods、 Carthage と複数存在するので、それぞれに対し異なる収集方法で収集しています。 また、この収集した情報を活かし、「このライブラリを利用しているアプリ」も参照できます。 この機能は <a href="https://techlife.cookpad.com/entry/2017/03/23/115619">gem_collector</a> にインスパイアされています。</p> <h2 id="ライブラリを利用しているアプリに向けての一括アナウンスをする">ライブラリを利用しているアプリに向けての一括アナウンスをする</h2> <p>これもまた gem_collector にインスパイアを受けた機能になりますが、特定のライブラリを使用しているアプリの開発リポジトリに対して、一斉に Issue を起票することができます。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135118.png" width="1200" height="417" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>これは、ライブラリ側に問題が見つかり早急にアップデートを依頼したい場合や、社内ライブラリに破壊的な変更が加わった場合などに利用される想定で作られています。</p> <p>ちなみに Hatamoto という名前は、モバイルアプリにまつわるデータを「監視する」という意味合いから監視役→衛兵→戦国時代における武将の近衛兵=旗本という流れで名付けられています。</p> <h1 id="各機能の実現方法">各機能の実現方法</h1> <p>各機能は以下のように実装しています。</p> <h2 id="証明書の有効期限管理">証明書の有効期限管理</h2> <p>各証明書の有効期限管理は、それぞれ以下の図のような流れで実現しています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135226.png" width="1200" height="737" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>APNs 証明書の更新の際は、有効な証明書の有無を確認し、必要に応じて新規作成するという作業をまとめて行う fastlane の <a href="https://docs.fastlane.tools/actions/pem/">pem</a> というアクションを利用しています<a href="#f-ebb25834" name="fn-ebb25834" title=" App Store Connect API には証明書を作成する機能がないため、作成処理を完全に自動化することは現状できません">*2</a>。</p> <p>この機能を利用するためには、Apple Developer の AppManager 以上のユーザーでログインする必要があり、そのログインの際に他要素認証が求められることから、APNs 証明書の更新はモバイル基盤グループのメンバーによって手動で実行されています。 この更新の際、作成された証明書を Amazon S3 (以下 S3 )のバケットにアップロードするようにしているので、その中身を見て有効期限をチェックしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135317.png" width="1200" height="632" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Provisioning Profile・開発者証明書の有効期限の取得には <a href="https://developer.apple.com/documentation/appstoreconnectapi/profiles">App Store Connect API</a> を使っています。</p> <p>有効期限が近づいているかどうかは、毎日定期実行されるジョブ<a href="#f-b4e41c88" name="fn-b4e41c88" title="クックパッドで利用されている Ruby 製の Web ベースのジョブスケジューラー Kuroko2 を利用しています">*3</a>で今日の日付と有効期限を比較して確認しています。</p> <h2 id="使用ライブラリの収集">使用ライブラリの収集</h2> <p>各アプリが利用しているライブラリの取得は、パッケージマネージャーごとに異なる方法で実現しています。</p> <h3 id="CarthageSwiftPM">Carthage、SwiftPM</h3> <p><a href="https://docs.github.com/en/rest?apiVersion=2022-11-28">GitHub API</a> を使って各リポジトリ内の Cartfile.resolved、Package.resolved の中身を取得しパースしています。同じく GitHub API 経由で、利用しているライブラリの最新バージョンを取得しています。</p> <h3 id="CocoaPods">CocoaPods</h3> <p>CocoaPods で解決されたライブラリのリストとそれぞれのバージョンが記述される Podfile.lock には、そのライブラリのリポジトリ URL が記載されません。そのため、SwiftPM ã‚„ Carthage と同様に Podfile.lock の中身を基に GitHub API を利用してライブラリの最新バージョンを取得するというのは難しいです。 したがって、利用している CocoaPods ライブラリの取得及び最新の podspec の情報の取得は、各アプリのメインブランチの CI で実行するようにしました。ここで収集した情報を S3 にアップロードし、アップロードされた情報を同様に Kuroko2 のジョブを定期実行して取得し DB に登録する、という流れにしています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/a/aomathwift/20231017/20231017135946.png" width="1200" height="569" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Hatamoto-を実際に運用してみて">Hatamoto を実際に運用してみて</h1> <p>Hatamoto を実際に運用してみて、最も良かったのは証明書の有効期限を気にする必要がなくなったことです。証明書は各アプリの管理者がダウンロードして差し替えることになるので、管理者であるモバイル基盤グループのオペレーションが減りました。</p> <p>また、各アプリが使っているライブラリを一覧できるというのは、思いの外管理者以外の開発者の目線から役に立つ場面がありました。 たとえば、新しくライブラリを導入しようとしたときに、社内にそのライブラリを使っているアプリが他に無いかを調べ、導入方法を確認する、といった活用例がありました。</p> <p>逆に、ライブラリを利用しているアプリに向けての一括アナウンスは未だ活用されていません。対応が必要な問題がみつかったときに、各アプリで開発者側が自発的に Issue を立てて対応していることが多く、開発者達の対応力の高さ故にあまり需要がない機能となってしまいました。</p> <h1 id="おわりに">おわりに</h1> <p>この記事では、アプリの各種証明書や各プロジェクトが依存している開発ツール・ライブラリなど、 iOS アプリに関する情報を一元管理するための Web アプリケーション「Hatamoto」を紹介しました。 このツールのおかげで、 iOS アプリを開発していく上で面倒となる管理部分が効率化できています。App Store Connect API の機能が増えれば、証明書の生成フローなど更に自動化できる部分が増え、より便利に利用することもできそうです。</p> <p>この Hatamoto は主に App Store Connect API と GitHub API 、fastlane を使って比較的単純な処理で実現されています。複数アプリにおける証明書等の情報管理に悩む方は、Hatamoto のようなWebアプリによる管理を試してみてはいかがでしょうか。</p> <div class="footnote"> <p class="footnote"><a href="#fn-6b93ebfd" name="f-6b93ebfd" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">社内ではアプリごとにライブラリの更新を監視するスクリプトを自前で書いて週次で実行していましたが、利用するバージョンを固定している場合や、更新の緊急性が高い場合にこれが機能しないという事情がありました。現在は、<a href="https://github.com/marketplace/renovate">Renovate</a> のようなツールを利用するのも解決手段の一つだと思います。</span></p> <p class="footnote"><a href="#fn-ebb25834" name="f-ebb25834" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"> App Store Connect API には証明書を作成する機能がないため、作成処理を完全に自動化することは現状できません</span></p> <p class="footnote"><a href="#fn-b4e41c88" name="f-b4e41c88" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドで利用されている Ruby 製の Web ベースのジョブスケジューラー Kuroko2 を利用しています</span></p> </div> aomathwift 開発を快適にするiOSアプリ内ログ確認ツール hatenablog://entry/820878482972950423 2023-10-13T17:30:00+09:00 2023-10-13T17:35:20+09:00 クックパッドでは、iOSアプリ内の行動ログやネットワーク通信ログを見やすく使いやすくする「ログ確認ツール」を活用しています。その使い方や背景、実装時の知見などについて詳しくご紹介します。 <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231013/20231013164516.png" alt="&#x958B;&#x767A;&#x3092;&#x5FEB;&#x9069;&#x306B;&#x3059;&#x308B;iOS&#x30A2;&#x30D7;&#x30EA;&#x5185;&#x30ED;&#x30B0;&#x78BA;&#x8A8D;&#x30C4;&#x30FC;&#x30EB;&#xFF08;&#x5C65;&#x6B74;&#x30FB;&#x5B9A;&#x7FA9;&#x8F9E;&#x66F8;&#x30FB;&#x30C1;&#x30A7;&#x30C3;&#x30AF;&#x30EA;&#x30B9;&#x30C8;&#xFF09;" width="1200" height="600" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>こんにちは!レシピ事業部の藤坂(<a href="https://twitter.com/yujif_">@yujif_</a>) です。</p> <p>クックパッドiOSアプリの開発者用機能として「ログ確認ツール」を作ってみました。社内で1年以上運用して好評なので、その経緯や学びをまとめてみます。</p> <p>iOSDC Japan 2022 では「<a href="https://www.youtube.com/watch?list=PLod2oSGQp3W6tx5JMQntpuZ-fNpP4Y_Hh&amp;v=4NGvI9PoAIQ">モバイルアプリの行動ログの『仕込み』を快適にする</a>」と題して関連した内容を発表しています。こちらの資料も合わせてご覧ください。</p> <p><iframe id="talk_frame_920663" class="speakerdeck-iframe" src="//speakerdeck.com/player/32c68404daf74ab8b42c2a004415922a" width="710" height="399" style="aspect-ratio:710/399; border:0; padding:0; margin:0; background:transparent;" frameborder="0" allowtransparency="true" allowfullscreen="allowfullscreen"></iframe> <cite class="hatena-citation"><a href="https://speakerdeck.com/yujif/iosdc-japan-2022-mobile-app-logging">speakerdeck.com</a></cite></p> <ul class="table-of-contents"> <li><a href="#アプリ内ログ確認ツールとは">アプリ内ログ確認ツールとは</a><ul> <li><a href="#1-ログ履歴">1. ログ履歴</a><ul> <li><a href="#ログの内容確認が楽になる">ログの内容確認が楽になる</a></li> </ul> </li> <li><a href="#2-ログ定義辞書">2. ログ定義辞書</a><ul> <li><a href="#知らないログの意味が分かる">知らないログの意味が分かる</a></li> <li><a href="#ログを業務に活かしやすくなる">ログを業務に活かしやすくなる</a></li> </ul> </li> <li><a href="#3-ログ送信チェックリスト">3. ログ送信チェックリスト</a><ul> <li><a href="#ログが送られていない">ログが送られていない!?</a></li> <li><a href="#QA作業でログの実装漏れもわかる">QA作業でログの実装漏れもわかる</a></li> <li><a href="#実装担当者以外でも分担できる">実装担当者以外でも分担できる</a></li> </ul> </li> </ul> </li> <li><a href="#実装方法">実装方法</a><ul> <li><a href="#1-送信済みログを読み出せるようにする">1. 送信済みログを読み出せるようにする</a><ul> <li><a href="#osLogger-で書き込む">os.Logger で書き込む</a></li> <li><a href="#OSLogStore-で読み出す">OSLogStore で読み出す</a></li> </ul> </li> <li><a href="#2-ログ確認ツールで扱いやすいデータに変換する">2. ログ確認ツールで扱いやすいデータに変換する</a><ul> <li><a href="#OSLogEntry-のメッセージをデコードする">OSLogEntry のメッセージをデコードする</a></li> <li><a href="#行動ログの定義ドキュメントとの紐付け">行動ログの定義ドキュメントとの紐付け</a></li> </ul> </li> <li><a href="#3-便利な機能を色々実装する">3. 便利な機能を色々実装する</a><ul> <li><a href="#チェックリスト機能">チェックリスト機能</a></li> <li><a href="#ネットワーク通信のログにも対応">ネットワーク通信のログにも対応</a><ul> <li><a href="#実装に関して調査しやすくする">実装に関して調査しやすくする</a></li> <li><a href="#SimulatorではURLコピーに">SimulatorではURLコピーに</a></li> </ul> </li> </ul> </li> </ul> </li> <li><a href="#振り返って">振り返って</a><ul> <li><a href="#よかったこと">よかったこと</a><ul> <li><a href="#ログの実装確認がつらくなくなる">ログの実装・確認がつらくなくなる</a></li> <li><a href="#自由に実験できる環境で遊べる">自由に実験できる環境で遊べる</a></li> <li><a href="#欲しいものを作れると楽しい">欲しいものを作れると楽しい</a></li> </ul> </li> <li><a href="#改善したいこと">改善したいこと</a><ul> <li><a href="#ログの読み込みを速くしたい">ログの読み込みを速くしたい</a></li> </ul> </li> <li><a href="#まとめ">まとめ</a></li> </ul> </li> </ul> <h1 id="アプリ内ログ確認ツールとは">アプリ内ログ確認ツールとは</h1> <p>クックパッドiOSアプリに内蔵された、ログ関連の開発者用ツール<a href="#f-899f9b8c" name="fn-899f9b8c" title="このツールは開発版ビルドのみに含まれており、App Store版では利用できません。">*1</a>です。 「ログ履歴」「ログ定義辞書」「ログ送信チェックリスト」の3つの機能があります。</p> <h2 id="1-ログ履歴">1. ログ履歴</h2> <p><figure class="figure-image figure-image-fotolife" title="クックパッドiOSアプリ内のログ確認ツール"></p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012151415.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012151136.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></div> <p><figcaption>クックパッドiOSアプリ内のログ確認ツール</figure></p> <p>ユーザーの行動ログ(例:ボタンのタップ、特定の要素の表示など)やAPIサーバーとの通信ログを、クックパッドiOSアプリの中ですぐに確認できます。</p> <p><figure class="figure-image figure-image-fotolife" title="開発版のクックパッドiOSアプリ内で、ログ確認ツールを素早く表示できる様子"></p> <div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231012/20231012150813.gif" alt="&#x958B;&#x767A;&#x7248;&#x306E;&#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;iOS&#x30A2;&#x30D7;&#x30EA;&#x5185;&#x3067;&#x3001;&#x30ED;&#x30B0;&#x78BA;&#x8A8D;&#x30C4;&#x30FC;&#x30EB;&#x3092;&#x7D20;&#x65E9;&#x304F;&#x8868;&#x793A;&#x3067;&#x304D;&#x308B;&#x69D8;&#x5B50;" width="590" height="1176" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span></div> <p><figcaption>ログ確認ツールを素早く表示できる様子<a href="#f-315eaa85" name="fn-315eaa85" title="SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。">*2</a></figcaption></figure></p> <p>このツールはデバイスのシェイク(Simulatorでは ⌃ ⌘ Z <code>control + command + Z</code>)でも表示でき、どの画面からでも気軽に使えます。</p> <h3 id="ログの内容確認が楽になる">ログの内容確認が楽になる</h3> <p>必要な情報だけに絞り、種類別に色分けすることで、パッと見て把握しやすくしています。</p> <p>これまでもロガーからの出力は Xcode 内のコンソール<a href="#f-2a16df53" name="fn-2a16df53" title="なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。https://developer.apple.com/videos/play/wwdc2023/10226/ ">*3</a>ã‚„ Console.app などで確認できました。</p> <p><figure class="figure-image figure-image-fotolife" title="[Before] Xcodeのコンソールに流れるログは見づらい"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008020615.png" alt="Xcode 14&#x306E;&#x30B9;&#x30AF;&#x30EA;&#x30FC;&#x30F3;&#x30B7;&#x30E7;&#x30C3;&#x30C8;&#x3002;&#x30B3;&#x30F3;&#x30BD;&#x30FC;&#x30EB;&#x306B;&#x884C;&#x52D5;&#x30ED;&#x30B0;&#x306E;&#x4E2D;&#x8EAB;&#x304C;&#x8868;&#x793A;&#x3055;&#x308C;&#x3066;&#x3044;&#x308B;&#x3002;" width="600" height="322" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>[ログ確認ツールがなかった頃] Xcodeのコンソールに流れるログは見づらい</figcaption></figure></p> <p>しかし、関係のない情報もたくさん流れてくるし、見た目もJSONそのままだったり、差分も分かりづらかったりと、人間にとっては疲れるものでした。</p> <p><strong>行動ログの構成要素</strong></p> <ul> <li>そのログ特有の付加情報 <ul> <li>例:対象リソースID、検索キーワード など</li> </ul> </li> <li>全ログ共通で付加されている情報 <ul> <li>例:ユーザーID、端末OSバージョン、アプリバージョン など</li> </ul> </li> </ul> <p>1行のログには様々な情報が含まれますが、全てのプロパティを常に見たいわけではありません。固有の情報だけに絞って、内容の確認に集中しやすくしました。</p> <h2 id="2-ログ定義辞書">2. ログ定義辞書</h2> <p><figure class="figure-image figure-image-fotolife" title="iPadで見る「ログ定義一覧」"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008022235.png" alt="" width="1200" height="801" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>全ログ定義を一覧表示し、横断検索もできる。iPadにも対応。</figcaption></figure></p> <p>送信されたログだけでなく、いま定義されている全てのログについても辞書のように調べられます。</p> <h3 id="知らないログの意味が分かる">知らないログの意味が分かる</h3> <p><strong>ログ定義ドキュメントの活用</strong></p> <p>クックパッドでは、Markdown形式のログ定義をもとに型安全なログ実装用コードを生成する仕組みを3年以上運用しています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2020%2F11%2F05%2F110000" title="ドキュメントベースの型安全なモバイルアプリ行動ログ基盤の構築 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2020/11/05/110000">techlife.cookpad.com</a></cite></p> <p>この仕組みのおかげで、ログ定義一つ一つに必ず説明文が用意されています。何のために導入されたログなのか、パラメータにはどのような値が入るのか、注意点は何か、当時のログ設計者が記したドキュメントから把握できます。</p> <p><strong>担当領域外にも目を向けやすく</strong></p> <p>そんな便利なログ定義Markdownですが、沢山の <code>.md</code> ファイルがただ置いてあるだけでは活用されません。 自分の担当領域のログは詳しくても、他の誰かが入れたログは何かきっかけがないとなかなか見ないものです。</p> <p>アプリ内で見やすくなることで、触っているうちに自然と「こんなのあったんだ!こういうときに使えそう💡」といった境界を越えた発見が生まれるのを狙った部分もあります。</p> <h3 id="ログを業務に活かしやすくなる">ログを業務に活かしやすくなる</h3> <p><figure class="figure-image figure-image-fotolife" title="ログ定義から、社内の分析SQL例をすぐに探せる"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008023549.png" alt="&#x30ED;&#x30B0;&#x5B9A;&#x7FA9;&#x753B;&#x9762;&#x306E;&#x300C;Bdash Server&#x3067;SQL&#x4F8B;&#x3092;&#x63A2;&#x3059;&#x300D;&#x30DC;&#x30BF;&#x30F3;&#x3092;&#x30BF;&#x30C3;&#x30D7;&#x3059;&#x308B;&#x3053;&#x3068;&#x3067;&#x3001;&#x793E;&#x5185;&#x306E;&#x30C7;&#x30FC;&#x30BF;&#x5206;&#x6790;SQL&#x3068;&#x5B9F;&#x884C;&#x7D50;&#x679C;&#x306B;&#x3059;&#x3050;&#x305F;&#x3069;&#x308A;&#x7740;&#x3051;&#x3066;&#x3044;&#x308B;&#x56F3;" width="1200" height="789" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ログ定義から、社内の分析SQLの例をすぐに探せる</figcaption></figure></p> <p>ログを見にきた人は何かを調査・分析したい人のはずなので、それを手助けするリンクを用意しています。</p> <p><strong>集計・分析へのショートカット</strong></p> <p>例えばバナーの表示やタップのログであれば、集計SQLを書いて施策の効果検証をしそうです。</p> <p>クックパッドでは、データ分析SQLを共有できる社内Webサービス「Bdash Server」がよく使われています。そこで、各ログ定義に関するSQL例をBdash Serverですぐに検索できるボタンをつけました(上図)。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2021%2F06%2F11%2F120000" title="データ分析 SQL とその実行結果を共有・検索できるアプリ Bdash Server を作りました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2021/06/11/120000">techlife.cookpad.com</a></cite></p> <p>「この結果が気になるけど、SQLを書くのがちょっと……」という人も、もしすでに誰かが作った結果で事足りるなら即解決できますし、少し違うとしても参考にできるSQLがあるだけで書きやすくなります。</p> <p>溜まっている知見をなめらかに使えるようにして、全社での生産性向上を狙っています。</p> <h2 id="3-ログ送信チェックリスト">3. ログ送信チェックリスト</h2> <p><figure class="figure-image figure-image-fotolife" title="ログ送信確認チェックリストに4つのログ定義が並んでいる図。4つのうち、3つは「送信済み」だが、1つだけ「未送信」と強調表示されている。"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231008/20231008153505.png" alt="&#x30ED;&#x30B0;&#x9001;&#x4FE1;&#x78BA;&#x8A8D;&#x30C1;&#x30A7;&#x30C3;&#x30AF;&#x30EA;&#x30B9;&#x30C8;&#x306B;4&#x3064;&#x306E;&#x30ED;&#x30B0;&#x5B9A;&#x7FA9;&#x304C;&#x4E26;&#x3093;&#x3067;&#x3044;&#x308B;&#x56F3;&#x3002;4&#x3064;&#x306E;&#x3046;&#x3061;&#x3001;3&#x3064;&#x306F;&#x300C;&#x9001;&#x4FE1;&#x6E08;&#x307F;&#x300D;&#x3060;&#x304C;&#x3001;1&#x3064;&#x3060;&#x3051;&#x300C;&#x672A;&#x9001;&#x4FE1;&#x300D;&#x3068;&#x5F37;&#x8ABF;&#x8868;&#x793A;&#x3055;&#x308C;&#x3066;&#x3044;&#x308B;&#x3002;" width="601" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>チェックリストに追加すれば、未送信のログがあぶり出される</figcaption></figure></p> <p>各ログ定義をチェックリストに登録しておけば、ログ送信時に自動でチェックされ、そのログが送信済み<a href="#f-b74e4bdd" name="fn-b74e4bdd" title="ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。">*4</a>かどうかを一瞬で確認できます。</p> <h3 id="ログが送られていない">ログが送られていない!?</h3> <p>新機能をリリースしていざ分析しようとしたら、必要なログの一部が送れていないことに気づき、「オァー!実装が漏れてました 💦」と焦る、そんな失敗が実際にありました。</p> <p><strong>例:「定期便」機能を新たにリリースする場合</strong></p> <ul> <li>見たい指標 <ul> <li>定期便初回登録時のファネル</li> <li>キャンペーンごとの効果</li> <li>定期便解除数</li> <li>定期便ユーザーのLTV など</li> </ul> </li> </ul> <p>施策に関する意思決定者と「最終的に何を知りたいのか」の認識を揃えることで、必要なログが洗い出せます。指標によっては、サーバー側のデータで事足りるものもあれば、モバイルアプリ側での行動ログが必要不可欠な場合もあります。</p> <ul> <li>必要なログ <ul> <li>定期便の商品詳細画面の表示</li> <li>定期便登録ボタンのタップ(≒定期便登録確認画面の表示)</li> <li>キャンペーンバナーの表示</li> <li>キャンペーンバナーのタップ</li> <li>定期便解除ボタンのタップ</li> <li>…… などなど</li> </ul> </li> </ul> <p>ややこしいのは、ユーザー状態次第では送らないログもあることです。例えば、定期便の初回ユーザー限定のバナーの表示ログは、一度でも定期便登録をしたユーザーからは送られないはずです。他にも、無料会員と有料会員の差、キャンペーンの流入経路ごとの差など、組み合わせ次第でどんどん複雑化していきます。</p> <p>仕様が複雑になるとミスもしやすいですし、品質保証のテストも手間がかかります。必要なログを漏れなくすべて送るのは結構大変です。チェックリスト機能はこの対策のために作ってみました。</p> <h3 id="QA作業でログの実装漏れもわかる">QA作業でログの実装漏れもわかる</h3> <p><figure class="figure-image figure-image-fotolife" title="ログ送信チェックリストの使い方"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231009/20231009193458.gif" width="720" height="260" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ログ送信チェックリストの使い方</figcaption></figure></p> <p>まず分析に必要なログ定義を一通りチェックリストに入れます。次に、想定されるユーザーと同様の流れでアプリを操作します。</p> <p>操作を終えたとき「すべて送信済み」となっているなら問題ありません。アプリをリリースしてOKです。もし「未送信あり」なら、ログ送信の実装漏れがどこかにあるということです。</p> <p>このチェックリスト機能を使えば、品質保証(QA)の手動テストの時間で、ついでにログの実装漏れも検出できます。</p> <h3 id="実装担当者以外でも分担できる">実装担当者以外でも分担できる</h3> <p>アプリ単体で完結するので、Xcodeなどの開発環境も必要なく誰でも実施できます。複数パターンの検証も、エンジニアに限らずチームで分担して一気に進められるのはうれしい点です。</p> <h1 id="実装方法">実装方法</h1> <p>アプリ内ログ確認ツールを実現するには、どうすればよいでしょうか?</p> <p>まず、送信済みログを読み出せること。次に、それらをログ定義ドキュメントとうまく紐付けて扱えること。この2つが必要です。</p> <p><figure class="figure-image figure-image-fotolife" title="クックパッドiOSアプリのログ関連の実装概要図"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231009/20231009204815.png" width="1200" height="675" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>クックパッドiOSアプリのログ関連の実装概要図</figcaption></figure></p> <p>送信済みログを端末内で保持するなら、ファイルへの出力、インメモリでの保持などいくつか方法が考えられます。 今回は、iOSの統合ロギングシステム(以下、OSログ)を活用することにしました。</p> <p><a href="https://developer.apple.com/documentation/os/logging">Logging | Apple Developer Documentation</a></p> <h2 id="1-送信済みログを読み出せるようにする">1. 送信済みログを読み出せるようにする</h2> <p>クックパッドiOSアプリでは、ログ収集ライブラリとして <code>Puree-Swift</code> を使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2018%2F02%2F28%2F113000" title="良い感じにログを収集するライブラリ、Puree-Swiftをリリースしました - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2018/02/28/113000">techlife.cookpad.com</a></cite></p> <h3 id="osLogger-で書き込む">os.Logger で書き込む</h3> <p>以下のようなコードで、簡単にOSログに出力できます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> os <span class="synComment">// ログの出自がわかるように subsystem と category を指定</span> <span class="synPreProc">let</span> <span class="synIdentifier">logger</span> <span class="synIdentifier">=</span> Logger( subsystem<span class="synSpecial">:</span> <span class="synType">Bundle.main.bundleIdentifier!</span>, category<span class="synSpecial">:</span> <span class="synConstant">&quot;ActivityLog&quot;</span><span class="synComment">// 行動ログの場合の例</span> ) <span class="synPreProc">let</span> <span class="synIdentifier">logDataString</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant">{</span> <span class="synConstant"> &quot;</span>user_id<span class="synConstant">&quot;: 1234567890,</span> <span class="synConstant"> &quot;</span>event_category<span class="synConstant">&quot;: &quot;</span>recipe_detail<span class="synConstant">&quot;,</span> <span class="synConstant"> &quot;</span>event_name<span class="synConstant">&quot;: &quot;</span>tap_save_button<span class="synConstant">&quot;,</span> <span class="synConstant"> &quot;</span>recipe_id<span class="synConstant">&quot;: 123456</span> <span class="synConstant">}</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synComment">// ログを書き込む</span> logger.notice(<span class="synConstant">&quot;</span><span class="synSpecial">\(</span>logDataString<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) </pre> <p>例えば、以下のように OSログ出力用の <code>Puree-Swift</code> のOutputを定義してConfiguration に加えると、ログサーバーへ送信されるログと同じ内容が、端末内のOSログにも出力されます。</p> <div style="border: 1px solid #aaa; border-radius: 4px; padding: 0.5em;"> <details> <summary>より詳細なPuree-Swift の Output 実装例はこちら</summary> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> Foundation <span class="synPreProc">import</span> os <span class="synPreProc">import</span> Puree <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">OSLogOutput</span><span class="synSpecial">:</span> <span class="synType">InstantiatableOutput</span> { <span class="synPreProc">let</span> <span class="synIdentifier">tagPattern</span><span class="synSpecial">:</span> <span class="synType">TagPattern</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">logger</span><span class="synSpecial">:</span> <span class="synType">os.Logger</span> <span class="synComment">// iOS 14+ で利用可能</span> <span class="synStatement">required</span> <span class="synIdentifier">init</span>(logStore<span class="synSpecial">:</span> <span class="synType">LogStore</span>, tagPattern<span class="synSpecial">:</span> <span class="synType">TagPattern</span>, options<span class="synSpecial">:</span> <span class="synType">OutputOptions?</span>) { <span class="synIdentifier">self</span>.tagPattern <span class="synIdentifier">=</span> tagPattern logger <span class="synIdentifier">=</span> Logger( subsystem<span class="synSpecial">:</span> <span class="synType">Bundle.main.bundleIdentifier!</span>, category<span class="synSpecial">:</span> <span class="synConstant">&quot;ActivityLog&quot;</span> <span class="synComment">// 行動ログの場合の例</span> ) } <span class="synPreProc">func</span> <span class="synIdentifier">emit</span>(log<span class="synSpecial">:</span> <span class="synType">LogEntry</span>) { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">userData</span> <span class="synIdentifier">=</span> log.userData <span class="synStatement">else</span> { assertionFailure(<span class="synConstant">&quot;logEntry must have userData&quot;</span>) <span class="synStatement">return</span> } <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">payload</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span>? JSONSerialization.jsonObject(with<span class="synSpecial">:</span> <span class="synType">userData</span>, options<span class="synSpecial">:</span> <span class="synSpecial">[]</span>) <span class="synStatement">as?</span> <span class="synSpecial">[</span><span class="synType">String</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synStatement">else</span> { assertionFailure(<span class="synConstant">&quot;Cannot decode userData as JSONObject.&quot;</span>) <span class="synStatement">return</span> } <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">logDataString</span> <span class="synIdentifier">=</span> prettyJSONString(payload) { logger.notice(<span class="synConstant">&quot;</span><span class="synSpecial">\(</span>logDataString, privacy<span class="synSpecial">:</span> .<span class="synStatement">public</span><span class="synSpecial">)</span><span class="synConstant">&quot;</span>) <span class="synComment">// デフォルトでは情報がマスクされるが、開発版ビルドのみなので、`.public` にしている</span> } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">prettyJSONString</span>(_ object<span class="synSpecial">:</span> <span class="synType">Any</span>) <span class="synSpecial">-&gt;</span> <span class="synType">String?</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span>? JSONSerialization.data(withJSONObject<span class="synSpecial">:</span> <span class="synType">object</span>, options<span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">.prettyPrinted, .sortedKeys, .withoutEscapingSlashes</span><span class="synSpecial">]</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">return</span> String(data<span class="synSpecial">:</span> <span class="synType">data</span>, encoding<span class="synSpecial">:</span> .utf8) } } </pre> </details> </div> <p>この os フレームワークの Logger ですが、以下のような特徴があります。</p> <ul> <li>非常に効率的で、アプリの動作遅延なく使える<a href="#f-158f8cb4" name="fn-158f8cb4" title="https://developer.apple.com/videos/play/wwdc2020/10168/ より">*5</a></li> <li>Console.app ã‚„ Xcode のコンソールでログを確認できる</li> <li>センシティブな情報はマスクできる(指定して公開もできる)</li> </ul> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fdeveloper.apple.com%2Fdocumentation%2Fos%2Flogger" title="Logger | Apple Developer Documentation" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://developer.apple.com/documentation/os/logger">developer.apple.com</a></cite></p> <h3 id="OSLogStore-で読み出す">OSLogStore で読み出す</h3> <p>アプリ内ログ確認ツールで利用する際は、<code>OSLogStore</code> を使ってOSログから読み出しています。これは iOS 15以降で利用できます。</p> <p> <a href="https://developer.apple.com/documentation/oslog/oslogstore">OSLogStore | Apple Developer Documentation</a></p> <pre class="code lang-swift" data-lang="swift" data-unlink> <span class="synPreProc">import</span> OSLog <span class="synPreProc">protocol</span> <span class="synIdentifier">OSLogEntriesDataStoreProtocol</span><span class="synSpecial">:</span> <span class="synType">AnyObject</span> { <span class="synPreProc">func</span> <span class="synIdentifier">fetchEntries</span>() async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">OSLogEntry</span><span class="synSpecial">]</span> } <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">OSLogEntriesDataStore</span><span class="synSpecial">:</span> <span class="synType">OSLogEntriesDataStoreProtocol</span> { <span class="synPreProc">func</span> <span class="synIdentifier">fetchEntries</span>() async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">OSLogEntry</span><span class="synSpecial">]</span> { <span class="synPreProc">let</span> <span class="synIdentifier">store</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> OSLogStore(scope<span class="synSpecial">:</span> .currentProcessIdentifier) <span class="synPreProc">let</span> <span class="synIdentifier">predicate</span> <span class="synIdentifier">=</span> NSPredicate(format<span class="synSpecial">:</span> <span class="synConstant">&quot;subsystem == %@&quot;</span>, Bundle.main.bundleIdentifier<span class="synIdentifier">!</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> store.getEntries(matching<span class="synSpecial">:</span> <span class="synType">predicate</span>) .reversed() <span class="synComment">// Workaround: `store.getEntries(with: .reverse, matching: predicate)` で降順(新しいログが先)に返されるはずだが、iOS 16時点では機能しないため、ここで逆順にしている。</span> <span class="synComment">// ※追記:iOS 17で直っていました!</span> } } </pre> <h2 id="2-ログ確認ツールで扱いやすいデータに変換する">2. ログ確認ツールで扱いやすいデータに変換する</h2> <h3 id="OSLogEntry-のメッセージをデコードする">OSLogEntry のメッセージをデコードする</h3> <p>読み出した <code>OSLogEntry</code> の <code>composedMessage</code> には行動ログの中身が入っていますが、この時点ではただのJSON文字列です。以下のようなコードで中身をデコードしてアプリ内ログ確認ツールで扱いやすくします。</p> <p><code>OSLogEntry</code> のままでは <code>category</code> <a href="#f-73cb7b3b" name="fn-73cb7b3b" title="https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category">*6</a> (先ほどの例では "ActivityLog" という値)を参照できないので、<code>OSLogEntryWithPayload</code> にダウンキャストします。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> OSLog <span class="synPreProc">struct</span> <span class="synIdentifier">LogEntryResolver</span> { <span class="synComment">/// OSLogEntryから必要な情報を取りだして、アプリ内ログ確認ツールで扱いやすいモデルに変換します</span> <span class="synStatement">static</span> <span class="synPreProc">func</span> <span class="synIdentifier">resolve</span>(entry<span class="synSpecial">:</span> <span class="synType">OSLogEntry</span>) <span class="synSpecial">-&gt;</span> <span class="synType">CookpadLogEntry?</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">entryWithPayload</span> <span class="synIdentifier">=</span> entry <span class="synStatement">as?</span> <span class="synType">OSLogEntryWithPayload</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">payload</span> <span class="synIdentifier">=</span> decode(message<span class="synSpecial">:</span> <span class="synType">entry.composedMessage</span>, category<span class="synSpecial">:</span> <span class="synType">entryWithPayload.category</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> <span class="synConstant">nil</span> } <span class="synStatement">return</span> CookpadLogEntry(id<span class="synSpecial">:</span> <span class="synType">UUID</span>().uuidString, date<span class="synSpecial">:</span> <span class="synType">entry.date</span>, payload<span class="synSpecial">:</span> <span class="synType">payload</span>) } } </pre> <h3 id="行動ログの定義ドキュメントとの紐付け">行動ログの定義ドキュメントとの紐付け</h3> <p>クックパッドでは、Markdown形式のログ定義から型安全なログ実装用コードを生成するために <code>daifuku</code> <a href="#f-6841f8d7" name="fn-6841f8d7" title="ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。https://techlife.cookpad.com/entry/2020/12/29/004145 ">*7</a> というライブラリを使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fgithub.com%2Fcookpad%2Fdaifuku" title="GitHub - cookpad/daifuku: A markdown parser and compiler for log definitions in mobile applications" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>今回はその仕組みを応用し、アプリ内ログ確認ツールからログ定義ごとの解説情報を参照できるコードを自動生成するようにしました。<a href="#f-ef4c1da7" name="fn-ef4c1da7" title="ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。">*8</a></p> <p><strong>ログ定義ごとの解説情報の用意</strong></p> <p>例えば、下記のように解説情報のためのstructを定義します。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ActivityLogDefinition</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// ログイベントカテゴリ(例: `sagasu` )</span> <span class="synPreProc">var</span> <span class="synIdentifier">category</span><span class="synSpecial">:</span> <span class="synType">Category</span> <span class="synComment">/// ログイベント(例:`show_content`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">event</span><span class="synSpecial">:</span> <span class="synType">Event</span> <span class="synPreProc">struct</span> <span class="synIdentifier">Event</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// ログイベント名(例:`show_content`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// ログイベントの解説文(例:`さがすタブのコンテンツが画面に表示された時に送信されます。`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// ログイベントに付加されるパラメーター</span> <span class="synPreProc">var</span> <span class="synIdentifier">parameterNotes</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ParameterNote</span><span class="synSpecial">]</span> <span class="synComment">/// 各ログに付加されるパラメーターについての解説</span> <span class="synPreProc">struct</span> <span class="synIdentifier">ParameterNote</span><span class="synSpecial">:</span> <span class="synType">Hashable</span> { <span class="synComment">/// パラメーターのキー名(例:`hashtag_ids`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">name</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// パラメーターの解説文(例:`表示されたハッシュタグID`)</span> <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synComment">/// パラメーターのSwiftでの型名(例: `String?` )</span> <span class="synPreProc">var</span> <span class="synIdentifier">swiftType</span><span class="synSpecial">:</span> <span class="synType">String</span> } } } </pre> <p>適当にRuby スクリプトを書いて、<code>daifuku</code>を使ってログ定義Markdownの情報を扱い、テンプレートをもとにログ解説情報を Swift の enum として自動生成します。</p> <p>Markdown からログ定義の enum を生成するRubyスクリプトの例 <a href="https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb">https://github.com/cookpad/daifuku/blob/e3cbfd1066fd7704b8210696aa90d5546ff6857d/example/iOS/generate-log-classes.rb</a></p> <div style="border: 1px solid #aaa; border-radius: 4px; padding: 0.5em;"> <details> <summary>自動生成用のテンプレートファイル (.erb) の例</summary> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// This file is automatically generated by generate-log-classes.</span> <span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogDefinition</span> { <span class="synPreProc">enum</span> <span class="synIdentifier">Category</span><span class="synSpecial">:</span> <span class="synType">String</span>, Hashable, CaseIterable { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> <span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;&lt;%= category.name %&gt;&quot;</span> <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> } } <span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogDefinition.Category</span> { <span class="synPreProc">var</span> <span class="synIdentifier">description</span><span class="synSpecial">:</span> <span class="synType">String</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> .<span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span><span class="synSpecial">:</span> <span class="synType">return</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant"> &lt;%- category.descriptions.flat_map(&amp;:lines).each do |description_line| -%&gt;</span> <span class="synConstant"> &lt;%= description_line.strip %&gt;</span> <span class="synConstant"> &lt;%- end -%&gt;</span> <span class="synConstant"> &quot;&quot;&quot;</span> <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> } } <span class="synPreProc">var</span> <span class="synIdentifier">events</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ActivityLogDefinition.Event</span><span class="synSpecial">]</span> { <span class="synStatement">switch</span> <span class="synIdentifier">self</span> { <span class="synIdentifier">&lt;%-</span> categories.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>category<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">case</span> .<span class="synIdentifier">&lt;%=</span> category.variable_name <span class="synIdentifier">%&gt;</span><span class="synSpecial">:</span> <span class="synIdentifier">&lt;%-</span> <span class="synStatement">if</span> category.available_events.empty? <span class="synIdentifier">-%&gt;</span> <span class="synStatement">return</span> [] <span class="synIdentifier">&lt;%-</span> <span class="synStatement">else</span> <span class="synIdentifier">-%&gt;</span> <span class="synStatement">return</span> [ <span class="synIdentifier">&lt;%-</span> category.available_events.each <span class="synStatement">do</span> <span class="synIdentifier">|</span>event<span class="synIdentifier">|</span> <span class="synIdentifier">-%&gt;</span> .<span class="synIdentifier">init</span>( name<span class="synSpecial">:</span> <span class="synConstant">&quot;&lt;%= event.name %&gt;&quot;</span>, description<span class="synSpecial">:</span> <span class="synConstant">&quot;&quot;&quot;</span> <span class="synConstant"> &lt;%- event.descriptions.flat_map(&amp;:lines).each do |description_line| -%&gt;</span> <span class="synConstant"> &lt;%= description_line.strip %&gt;</span> <span class="synConstant"> &lt;%- end -%&gt;</span> <span class="synConstant"> &quot;&quot;&quot;</span>, parameterNotes<span class="synSpecial">:</span> <span class="synSpecial">[</span> <span class="synType"> &lt;%- event.columns.each do |column| -%&gt;</span> <span class="synType"> .init</span><span class="synSpecial">(</span> <span class="synType"> name: &quot;&lt;%= column.original_name %&gt;&quot;</span>, <span class="synType"> description: &quot;&quot;&quot;</span> <span class="synType"> &lt;%- column.descriptions.flat_map</span><span class="synSpecial">(</span><span class="synType">&amp;:lines</span><span class="synSpecial">)</span><span class="synType">.each do |description_line| -%&gt;</span> <span class="synType"> &lt;%= description_line.strip %&gt;</span> <span class="synType"> &lt;%- end -%&gt;</span> <span class="synType"> &quot;&quot;&quot;</span>, <span class="synType"> swiftType: &quot;&lt;%= column.swift_type %&gt;&quot;</span> <span class="synType"> </span><span class="synSpecial">)</span><span class="synType">,</span> <span class="synType"> &lt;%- end -%&gt;</span> <span class="synType"> </span><span class="synSpecial">]</span> ), <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> ] <span class="synIdentifier">&lt;%-</span> end <span class="synIdentifier">-%&gt;</span> <span class="synSpecial">&lt;%- </span><span class="synIdentifier">end</span><span class="synSpecial"> -%&gt;</span> } } } </pre> </details> </div> <p>こうして用意した解説情報を、送信済みログと紐付けます。</p> <p><strong>送信済みログとの紐付け</strong></p> <p>送信済みログの中身に含まれる <code>eventCategory</code> と <code>eventName</code> の値から、どのログ定義かは一意に定まります。下記のように、送信済みログのペイロードからログ定義解説情報を参照できるようにしました。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">extension</span> <span class="synIdentifier">ActivityLogPayload.DefinitionKey</span> { <span class="synComment">/// 行動ログの定義ごとの解説情報</span> <span class="synPreProc">var</span> <span class="synIdentifier">definition</span><span class="synSpecial">:</span> <span class="synType">ActivityLogDefinition</span> { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">category</span> <span class="synIdentifier">=</span> ActivityLogDefinition.Category(rawValue<span class="synSpecial">:</span> <span class="synType">eventCategory</span>), <span class="synPreProc">let</span> <span class="synIdentifier">event</span> <span class="synIdentifier">=</span> category.events.first(<span class="synStatement">where</span><span class="synSpecial">:</span> { <span class="synIdentifier">$0</span>.name <span class="synIdentifier">==</span> eventName }) <span class="synStatement">else</span> { fatalError(<span class="synConstant">&quot;ログ定義が見つかりませんでした&quot;</span>) } <span class="synStatement">return</span> ActivityLogDefinition(category<span class="synSpecial">:</span> <span class="synType">category</span>, event<span class="synSpecial">:</span> <span class="synType">event</span>) } } </pre> <h2 id="3-便利な機能を色々実装する">3. 便利な機能を色々実装する</h2> <p>あとは「扱いやすくした送信済みログ」と「解説情報」を材料として、自由に料理して好みの画面をつくるだけです。ここでは雑多にいくつかのトピックをご紹介します。</p> <h3 id="チェックリスト機能">チェックリスト機能</h3> <p>チェックリスト機能は、次のような単純な実装です。</p> <ul> <li>チェックリストに登録したログ定義のキーを UserDefaults で保持しておく。</li> <li>送信済みログの中に、そのキーと一致するログが1つでもあれば、チェックリスト上でそのログ定義を「送信済み」にする。</li> </ul> <h3 id="ネットワーク通信のログにも対応">ネットワーク通信のログにも対応</h3> <p>行動ログだけでなく、ネットワーク通信のログも見られるようにしています。実際にアプリを操作しながら、どのAPIエンドポイントがどのタイミングで使われているのか、すぐに確認できるのは便利です。</p> <p><figure class="figure-image figure-image-fotolife" title=" Request ã‚„ Response の詳細も良い感じに表示"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011172953.png" width="500" height="987" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011131320.gif" width="590" height="1176" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption> Request ã‚„ Response の詳細も良い感じに表示<a href="#f-4164d41f" name="fn-4164d41f" title="このJSONの表示部分は、同僚のNiaさんの [https://zenn.dev/niaeashes/articles/fca80c3ae3f8b4:title] を使わせてもらいました。https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8">*9</a></figcaption></figure></p> <p><a href="https://www.charlesproxy.com/">Charles</a> ã‚„ <a href="https://proxyman.io/">Proxyman</a> などのサードパーティーアプリのほうが高機能ですし網羅性も高い<a href="#f-99417883" name="fn-99417883" title="アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。">*10</a>ですが、常に起動しているとも限らないですし、いざ使いたいときにちょっと手間がかかります。</p> <p><figure class="figure-image figure-image-fotolife" title=""><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011182939.png" width="609" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>障害発生時の調査にも役立った</figcaption></figure></p> <p>例えば、特定の画面がエラーになるといった障害が発生したとき、アプリ単体でも素早く調査できたのは便利でした。発生条件の特定や原因の切り分けがスムーズにできると、より焦らずに対応できます。</p> <p>なお、ネットワーク通信のログについては OSログには送らず、メモリ上に保持しています。 <a href="#f-8d0b9558" name="fn-8d0b9558" title="元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚のVincent さん が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。">*11</a></p> <h4 id="実装に関して調査しやすくする">実装に関して調査しやすくする</h4> <p><figure class="figure-image figure-image-fotolife" title="実装の調査が捗るリンク"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011181717.png" width="1200" height="853" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>ネットワーク通信のログから、API仕様の調査やソースコード検索がすぐできる</figcaption></figure></p> <p>行動ログの集計・分析ショートカットと同様に、ネットワーク通信のログを見にきた人はAPIや実装に関して色々調査をしたいはずだということで、以下の社内Webサービスへのリンクを用意しています。</p> <ul> <li>APIサーバーに対して実際のリクエストを手軽に試せる「API3 Console」</li> <li>APIサーバーのスキーマ定義からドキュメントを提供する「Garage Playground<a href="#f-b6ce1ca1" name="fn-b6ce1ca1" title="クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。https://techlife.cookpad.com/search?q=Garage">*12</a>」</li> <li>ソースコードやタスク、プロジェクトの管理をしている「GitHub Enterprise」</li> </ul> <p>例えば 「実装箇所を GitHub Enterprise(GHE)で表示する」ボタンは、レポジトリ内のSwiftコードの検索結果のURLを開くだけですが、秒で利用箇所が見つかるのは思っている以上に便利です。</p> <p>小ネタですが、次のような工夫も入れています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// recipeID, userID などを含むURLは、GHE検索時にヒットせず不便なので * に変換している</span> <span class="synComment">// (例: `/v1/recipes/:id`, `/v1/users/:id/visited_recipes` など)</span> <span class="synPreProc">let</span> <span class="synIdentifier">query</span> <span class="synIdentifier">=</span> request.url.path.replacingOccurrences(of<span class="synSpecial">:</span> <span class="synConstant">&quot;/([0-9]+)(/|$)&quot;</span>, with<span class="synSpecial">:</span> <span class="synConstant">&quot;/*$2&quot;</span>, options<span class="synSpecial">:</span> .regularExpression) </pre> <h4 id="SimulatorではURLコピーに">SimulatorではURLコピーに</h4> <p><figure class="figure-image figure-image-fotolife" title="実機ではブラウザで開き、SimulatorではURLをクリップボードにコピーする"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231011/20231011051235.gif" width="590" height="470" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>実機ではブラウザで開き、SimulatorではURLをコピーする</figcaption></figure></p> <p>さらに小ネタですが、iOS Simulatorで開発中にこのボタンを使うと、Simulator内のSafariが開いてしまい不便<a href="#f-4ecb99da" name="fn-4ecb99da" title="Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。">*13</a>だったので、こんな対策をしました。 Simulator実行時は URLをクリップボードにコピーするので macOS側 ですぐ開けます。iPhone/iPadの実機では実機のブラウザが開きます。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// リンクボタンの実装例</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synPreProc"> #if</span> targetEnvironment(simulator) buttonForSimulator(targetURL) <span class="synPreProc"> #else</span> buttonForRealDevice(targetURL) <span class="synPreProc"> #endif</span> } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">buttonForSimulator</span>(_ targetURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { CopyTextButton( stringToCopy<span class="synSpecial">:</span> <span class="synType">targetURL.absoluteString</span>, labelTitleForCopied<span class="synSpecial">:</span> <span class="synConstant">&quot;URLをコピーしました(Simulator内のSafariで開くと不便なので)&quot;</span> ) { label } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">buttonForRealDevice</span>(_ targetURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { Link(destination<span class="synSpecial">:</span> <span class="synType">targetURL</span>) { label } .contextMenu { <span class="synComment">// 実機でも長押しメニューから一応コピーできるようにしている</span> CopyTextButton(stringToCopy<span class="synSpecial">:</span> <span class="synType">targetURL.absoluteString</span>) { Label(<span class="synConstant">&quot;URLをコピー&quot;</span>, systemImage<span class="synSpecial">:</span> <span class="synConstant">&quot;doc.on.doc&quot;</span>) } } } </pre> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synPreProc">struct</span> <span class="synIdentifier">CopyTextButton</span><span class="synSpecial">&lt;</span><span class="synIdentifier">Content</span><span class="synSpecial">: </span><span class="synType">View</span><span class="synSpecial">&gt;:</span> <span class="synType">View</span> { <span class="synPreProc">var</span> <span class="synIdentifier">stringToCopy</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synPreProc">var</span> <span class="synIdentifier">labelTitleForCopied</span><span class="synSpecial">:</span> <span class="synType">String</span> <span class="synType">@ViewBuilder</span> <span class="synType">var</span> content<span class="synSpecial">:</span> <span class="synType">Content</span> <span class="synIdentifier">init</span>(stringToCopy<span class="synSpecial">:</span> <span class="synType">String</span>, labelTitleForCopied<span class="synSpecial">:</span> <span class="synType">String</span> <span class="synIdentifier">=</span> <span class="synConstant">&quot;コピーしました!&quot;</span>, <span class="synType">@ViewBuilder</span> <span class="synType">content</span><span class="synSpecial">: ()</span> <span class="synSpecial">-&gt;</span> <span class="synType">Content</span>) { <span class="synIdentifier">self</span>.stringToCopy <span class="synIdentifier">=</span> stringToCopy <span class="synIdentifier">self</span>.labelTitleForCopied <span class="synIdentifier">=</span> labelTitleForCopied <span class="synIdentifier">self</span>.content <span class="synIdentifier">=</span> content() } <span class="synType">@State</span> <span class="synType">private</span> <span class="synPreProc">var</span> <span class="synIdentifier">isCopied</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { Button { UIPasteboard.general.string <span class="synIdentifier">=</span> stringToCopy print(<span class="synConstant">&quot;[LogChecker] Copied to the pasteboard: </span><span class="synSpecial">\(</span>stringToCopy<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) Task { <span class="synStatement">defer</span> { isCopied <span class="synIdentifier">=</span> <span class="synConstant">false</span> } isCopied <span class="synIdentifier">=</span> <span class="synConstant">true</span> <span class="synStatement">try</span>? await Task.sleep(<span class="synStatement">for</span><span class="synSpecial">:</span> .seconds(<span class="synConstant">3</span>)) } } label<span class="synSpecial">:</span> { <span class="synStatement">if</span> isCopied { Label(labelTitleForCopied, systemImage<span class="synSpecial">:</span> <span class="synConstant">&quot;doc.on.doc&quot;</span>) .font(.callout) .foregroundColor(.secondary) .imageScale(.small) } <span class="synStatement">else</span> { content } } } } </pre> <h1 id="振り返って">振り返って</h1> <h2 id="よかったこと">よかったこと</h2> <p>このログ確認ツールは、色んな面で開発を楽しくできました。</p> <h3 id="ログの実装確認がつらくなくなる">ログの実装・確認がつらくなくなる</h3> <p>「大事だけど正直面倒な作業」とも感じていたログの実装や確認を幾分か快適にできたと思います。個人的にはアプリ内ログ確認ツールを使いはじめてからは「ちょっと楽しいまである」という気持ちに変化していました。同僚からもSlackなどで「すごい見やすくなってる!」「はちゃめちゃに助かっている」「課金したい」といったポジティブな反応をもらえています。</p> <h3 id="自由に実験できる環境で遊べる">自由に実験できる環境で遊べる</h3> <p>Viewについては、すべてSwiftUIで実装しました。</p> <p>普段、一般ユーザー向けに開発している画面は SwiftUI (場合によっては UIKit)を採用していますが、全体的には VIPER アーキテクチャで、画面遷移も UINavigationController をベースに使っています。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2021%2F11%2F01%2F090000" title="UINavigationControllerをカスタマイズ 〜OSの影響を受けづらいカスタムナビゲーションの実装〜 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2021/11/01/090000">techlife.cookpad.com</a></cite></p> <p>今回は開発者用ツールということもあって、サポートOSバージョンや不具合などはそこまで気にしなくても済む状況でした。むしろ、こういう機会に積極的に新しい技術を試して、知見を貯めるほうが望ましいでしょう。</p> <p>チームで合意をとり、ログ確認ツールに関しては <code>@available(iOS 16.0, *)</code> (今なら iOS 17)をつけて、最新のSwiftã‚„SwiftUIの機能を使い放題にしました。制約なく技術を楽しめるエンジニアにとってのオアシスのような場所です。</p> <p>例えば <code>NavigationStack</code> ã‚„ <code>NavigationSplitView</code> など、普段使っていないSwiftUIの画面遷移関連も試しています。 正規表現を使う箇所では、<code>RegexBuilder</code> も試しました。</p> <p>「<code>ViewThatFits</code>を使えば、簡単に解決できてすごく便利だ」「この書き方、iOS 17から deprecated になるのか!」「これ便利だけど、この挙動は気を付けないと不具合を生み出しそうだ……」</p> <p>このように自由に実践して得られた知見や肌感覚は、ただ楽しいだけではなく、近い将来のユーザー向け機能の開発をスムーズにして、とても役立ちます。</p> <h3 id="欲しいものを作れると楽しい">欲しいものを作れると楽しい</h3> <p>あったらいいなを次々と実現するのが純粋に楽しかったです。 <a href="#f-17ee0445" name="fn-17ee0445" title="Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: https://youtu.be/2HitJxXXzwY?t=1325 ">*14</a></p> <p><figure class="figure-image figure-image-fotolife" title="改善の無限ループが爆速で進むのは楽しい"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/y/y_f/20231013/20231013155021.png" width="1200" height="825" loading="lazy" title="" class="hatena-fotolife" style="width:300px" itemprop="image"></span><figcaption>改善の無限ループが爆速で進むのは楽しい</figcaption></figure></p> <p>開発者向けツールはユーザーが自分でもあるので、ニーズの理解も、真に解決できているのかの実感もすぐできます。 作ってみて、試しに使って、新たな発見があってまた作る、この改善が爆速で進められます。</p> <p>業務にしっかり役立つ「仕事」ではあるものの、楽しくてついやってしまう「趣味」でもあり、「趣味の仕事」という言葉が社内で流行していました。このアプリ内ログ確認ツールも趣味の仕事の一例です。</p> <h2 id="改善したいこと">改善したいこと</h2> <h3 id="ログの読み込みを速くしたい">ログの読み込みを速くしたい</h3> <p>OSログは書き込みは良いですが、読み込みは遅いようです。 <code>os.signpost</code> と Instruments を使って計測してみると、<code>.getEntries</code><a href="#f-77c1a087" name="fn-77c1a087" title="https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries">*15</a> の1行だけで圧倒的に時間がかかっています。</p> <p>動作環境によって大きく差があり、気にならない程度のときもあれば10秒近くかかってさすがに使いづらいと感じるときもあります。Simulatorと実機の差、OSログに溜まった量の差などいくつか要因がありそうな気もしつつ、詳しくはまだ調べられていません。</p> <p>画面表示毎に更新すると読み込みで待ちすぎるので、今はキャッシュ層を挟んで更新頻度を下げています。</p> <h2 id="まとめ">まとめ</h2> <p>今回は、アプリ内ログ確認ツールの機能や実装方法、分かったことについてご紹介しました。</p> <p>まだ改善の余地はありますが、ちょっとした工夫の積み重ねによって開発を快適にする目的は一定達成できたと感じています。</p> <p>日々のサービス開発をより良くするために、この記事が何か少しでも参考になったら幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-899f9b8c" name="f-899f9b8c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">このツールは開発版ビルドのみに含まれており、App Store版では利用できません。</span></p> <p class="footnote"><a href="#fn-315eaa85" name="f-315eaa85" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">SwiftUIで作られた画面なので .blur(radius:) を適当につけるだけで簡単にぼかせて、こういうGIFをつくるときに便利です。</span></p> <p class="footnote"><a href="#fn-2a16df53" name="f-2a16df53" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">なお、Xcode 15 では Debug Console が強化され、重要度やログの種類ごとにフィルタリングできるなど便利になりました。<a href="https://developer.apple.com/videos/play/wwdc2023/10226/">https://developer.apple.com/videos/play/wwdc2023/10226/</a> </span></p> <p class="footnote"><a href="#fn-b74e4bdd" name="f-b74e4bdd" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここでの「送信済み」とは、アプリの起動から終了までの間での話です。アプリを再起動すると、すべて「未送信」に戻ります。</span></p> <p class="footnote"><a href="#fn-158f8cb4" name="f-158f8cb4" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/videos/play/wwdc2020/10168/">https://developer.apple.com/videos/play/wwdc2020/10168/</a> より</span></p> <p class="footnote"><a href="#fn-73cb7b3b" name="f-73cb7b3b" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category">https://developer.apple.com/documentation/oslog/oslogentrywithpayload/3366053-category</a></span></p> <p class="footnote"><a href="#fn-6841f8d7" name="f-6841f8d7" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">ちなみに daifuku の由来は「大福帳」から。クックパッドでは、2020年頃に新しいログの仕組み、通称「大統一アクティビティログ」を導入した際に、すべてのカラムが1つのテーブルに横長に存在する非正規化された「大福帳型テーブル」に行動ログを集積するようになりました。<a href="https://techlife.cookpad.com/entry/2020/12/29/004145">https://techlife.cookpad.com/entry/2020/12/29/004145</a> </span></p> <p class="footnote"><a href="#fn-ef4c1da7" name="f-ef4c1da7" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">ここでは詳細を省いていますが、デモアプリを後々公開できればと思っています。</span></p> <p class="footnote"><a href="#fn-4164d41f" name="f-4164d41f" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">このJSONの表示部分は、同僚のNiaさんの <a href="https://zenn.dev/niaeashes/articles/fca80c3ae3f8b4">SwiftUI &#x3067; JSON &#x3092;&#x8868;&#x793A;&#x3059;&#x308B; View</a> を使わせてもらいました。<a href="https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8">https://gist.github.com/niaeashes/e2c927c8d5ddac3b161e2dbe6f0e75b8</a></span></p> <p class="footnote"><a href="#fn-99417883" name="f-99417883" class="footnote-number">*10</a><span class="footnote-delimiter">:</span><span class="footnote-text">アプリ内ログ確認ツールでは、自社のAPIクライアントを経由する通信のみに対応しています。例えば、 Firebase などサードパーティーライブラリの通信は対象外です。</span></p> <p class="footnote"><a href="#fn-8d0b9558" name="f-8d0b9558" class="footnote-number">*11</a><span class="footnote-delimiter">:</span><span class="footnote-text">元々はURLとステータスコード程度の簡素な情報だけだったのでOSログに入れていましたが、同僚の<a href="https://twitter.com/vincentisambart">Vincent さん</a> が response body も含める対応や、GraphQL の POST request への対応をしてくれました。APIクライアントに interceptor として追加し、一定量までメモリ上に保持するようになっています。</span></p> <p class="footnote"><a href="#fn-b6ce1ca1" name="f-b6ce1ca1" class="footnote-number">*12</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドでは Garage と呼ばれるRESTful Web API 開発を楽にするライブラリが標準的に使われています。<a href="https://techlife.cookpad.com/search?q=Garage">https://techlife.cookpad.com/search?q=Garage</a></span></p> <p class="footnote"><a href="#fn-4ecb99da" name="f-4ecb99da" class="footnote-number">*13</a><span class="footnote-delimiter">:</span><span class="footnote-text">Simulator内のMobile Safariでも使えることは使えますが、ログインが必要で「うーーーん」となってしまいました。macOS側で開けるほうが快適そうです。</span></p> <p class="footnote"><a href="#fn-17ee0445" name="f-17ee0445" class="footnote-number">*14</a><span class="footnote-delimiter">:</span><span class="footnote-text">Cookpad TechConf 2022のLTでも「めちゃくちゃ楽しかった仕事の話をさせてほしい〜iOSアプリのログ編〜」として発表しています。動画: <a href="https://youtu.be/2HitJxXXzwY?t=1325">https://youtu.be/2HitJxXXzwY?t=1325</a> </span></p> <p class="footnote"><a href="#fn-77c1a087" name="f-77c1a087" class="footnote-number">*15</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries">https://developer.apple.com/documentation/oslog/oslogstore/3204125-getentries</a></span></p> </div> y_f クックパッドの検索反映時間を 1/288 にしたシステム改修 hatenablog://entry/820878482973215525 2023-10-05T15:00:00+09:00 2023-10-05T15:00:00+09:00 レシピを投稿してから検索結果に反映されるまでの時間を、24 時間から 5 分にまで短縮したシステム改修について紹介します。 <p>こんにちは。レシピ事業部の新井(<a href="https://twitter.com/SpicyCoffee66">@SpicyCoffee</a>)です。</p> <p>クックパッドではこれまで、レシピを投稿してから検索結果に反映されるまで最長で 24 時間程度の時間がかかっていました。今回、この時間を 5 分程度、最長でも 10 分程度に短縮することに成功しました。本記事では、プロジェクトオーナーの立場で関わった私が代表してその開発について紹介します。</p> <h1 id="プロジェクトの目的と数値目標">プロジェクトの目的と数値目標</h1> <p>本プロジェクトでは上記の「レシピを投稿してから検索結果に反映されるまでの時間短縮」が目的とされました。しかし、時間短縮といっても現状 24 時間であるものを "1 時間" にするのか、"1 分" にするのか、"1 秒" にするのかでは話が全然違います。この数値目標は設計を始めとした後の意思決定に大きく影響を与えるため、しっかりとした意図を持った状態で明確に定めておく必要がありました。</p> <p>そこで、私とプロダクトオーナー<a href="#f-9b11b8ee" name="fn-9b11b8ee" title="今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。">*1</a>が議論を重ね、まずは ”今回のプロジェクトで実現したいユーザー体験" を定めました。その体験から必要となる検索結果の反映頻度を逆算し、最終的な数値目標を「中央値 5 分程度、最大でも 10 分以内の検索結果への反映」であると定めることとなりました。同時に定めたプロジェクトのスケジュールは 6 週間であり、見積もりの第一印象としてはかなりギリギリの設定でした。</p> <p>この記事では、今後本プロジェクトで実現された「検索結果が反映されるまでの時間の短縮」を “short-period indexing” と呼称することにします。</p> <h1 id="旧システムの概要">旧システムの概要</h1> <p>プロジェクト発足時点での検索周りのシステム(以下旧システム)を以下に示します。</p> <p><figure class="figure-image figure-image-fotolife" title="旧システムの構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005140329.png" width="1200" height="665" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>旧システムの構成</figcaption></figure></p> <p>旧システムの肝は以下の2点です。</p> <h2 id="検索インデックスを生成する日次バッチ">検索インデックスを生成する日次バッチ</h2> <p>旧システムでは、検索結果の更新を 24 時間に一度でいいと割り切り、日次バッチでインデックスの更新を行っていました。レシピに関する各種メタデータを集め、必要に応じて加工することでドキュメントを生成し、そのドキュメントを Solr に送信することでインデックスを生成します。生成されたインデックスは後ほど説明する ECS を利用したデプロイメントのために S3 に配置されます。</p> <p>日次更新でよいという割り切りの元に、およそ 100 を超える field の情報を数百万レシピについて毎日生成しており、中には機械学習を用いてレシピにスコアを付与するような処理も含まれていたため、その実行時間は 90 分程度になっていました。</p> <p>ちなみに、このバッチ自体も 5 年ほど前に旧システムから分離・リプレイスされたものになります。当時の様子は以下の記事に記載してあるため、よろしければあわせてご覧ください。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2019%2F06%2F17%2F123000" title="レシピ検索を支えるレガシーでクリティカルな大規模バッチを刷新した話 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <h2 id="ECS-を利用したデプロイメント">ECS を利用したデプロイメント</h2> <p>旧システムでは、ECS のタスクとして Solr を起動していました。一般に、検索エンジンのようなステートフルなミドルウェアと ECS は相性がよくないとされています。しかし、旧システムでは S3 にインデックスを配置してタスクの起動時にそれをダウンロードしてくることでステートをコンテナの外に出し、その相性の悪さを解消しています。</p> <p>この設計は「ステート(= インデックス)の更新頻度が十分に低い」という前提に基づいているものであり、本プロジェクトの目的を達成するためにはリプレイスする必要性が出てくる可能性もある箇所でした。</p> <p>この開発についての詳細は以下の記事で解説されていますので、よろしければこちらもご覧ください。 <iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2020%2F11%2F25%2F080000" title="人気順検索のSolrはスケールのためにディスクを捨てた - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <h1 id="目標達成のための課題">目標達成のための課題</h1> <p>旧システムを考察することで、目標を達成するためには以下のような課題があることがわかってきました。</p> <h2 id="short-period-indexing-に適した-Solr-の使い方を再考する">short-period indexing に適した Solr の使い方を再考する</h2> <p>旧システムでは、常在的に起動している Solr は "参照系" のみであり、index の更新時には spot instance として "æ›´æ–°ç³»" の Solr を立ち上げて index を生成していました。index の更新が日次であればこの方法でも問題ありませんが、これが数分以下のオーダーになるなら "æ›´æ–°ç³»" の Solr も常に稼働し、かつ複数の "参照系" Solr に更新を同期する必要がありそうです。</p> <p>更新の同期方法については、たとえば、Solr にはクラスタを組んで replication を実行するための機能があります。しかし、この機能が ECS に Solr を乗せている状態でも問題なく動作するかは自明ではありません。ECS を活用することによるデプロイやスケーリングの容易性といったメリットは可能な限り残したい<a href="#f-f14419c3" name="fn-f14419c3" title="当時の ECS &amp;amp; 社内基盤 Hako という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。">*2</a>ものの、そのためには新しい要件に合わせた調査や工夫が必要になりそうです。</p> <p>そもそも S3 を介してインデックスを配布するやり方が適しているかも含め Solr 周りの構成・設計は大幅に考え直す必要がありそうでした。</p> <h2 id="インデックスする情報を選別する">インデックスする情報を選別する</h2> <p>前述したように、レシピのドキュメントは 100 前後の field を持っており、中には機械学習を用いて付与されたスコアのようなものも含まれます。これら全ての情報をインデックスしようとすると、そもそもその処理に時間がかかる可能性が高く、short-period indexing のタイムスパンでこれを実行することは困難だと考えられます。したがって、ユーザー体験に立ち返って short-period indexing のスコープに含める field を定義する必要がありました。</p> <p>また、クックパッドのレシピはユーザー投稿物です。したがって、何のチェックもせずにレシピをインデックスしてしまうと、明らかに料理ではない写真を用いたレシピなどの、不適切な投稿の露出が増えてしまう可能性があります。このことを考えると、インデックスする情報に加えて「どのレシピをインデックスするか」という判定が必要になると予想されました<a href="#f-f1ecce9b" name="fn-f1ecce9b" title="人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。">*3</a>。</p> <h2 id="日次バッチによる更新と-short-period-indexing-による更新を同居させる">日次バッチによる更新と short-period indexing による更新を同居させる</h2> <p>日次バッチによるインデックス更新は、更新頻度と引き換えではありましたが、緊急時にロールバックが容易になるといったメリットもありました。検索結果に不具合が生じた際、インデックスのバージョンを巻き戻すことで前日時点のインデックスを用いて検索機能を提供することが容易で、これは検索システムそのものの頑健性を支える一つの要素になっています。</p> <p>この「セーブポイントをつくる」機能は有用なため可能であれば残したく、そうなると日次バッチによる更新と short-period indexing による更新が並列することになります。こうなるとインデックスの更新経路が複数になるため、その際にコンフリクトが起こらないようにシステムを設計する必要がありそうでした。</p> <h2 id="キャッシュが検索結果の更新を阻害しないようにする">キャッシュが検索結果の更新を阻害しないようにする</h2> <p>前述した構成図では表現されていませんでしたが、検索システムの周辺には多種多様のキャッシュが存在しています。クライアントアプリからのリクエストを受け付ける API や、検索サーバーからのリクエストを受け付ける Solr と、複数箇所にキャッシュが存在しており、検索インデックスの更新時にはこれらを破棄しなければ検索結果が変化しません。</p> <p>単純にキャッシュを剥がせば各サービスへの負荷増大は避けられず、まずは現状のヒット率等を調査して剥がせるなら少しずつ剥がす、難しそうならサーバーを増やすなどの対応が必要になりそうでした。</p> <h1 id="新システムの概要">新システムの概要</h1> <p>以上に挙げた課題を解決するために、以下の図に示すような全体像のシステムを設計・開発しました。</p> <p>開発の流れとしては、全体設計についてはプロジェクトメンバーの 4 名全員で議論しながら固め、必要な開発がある程度特定された後に、各位の専門領域に合わせて調査や実装を割り振る形にしました。</p> <p><figure class="figure-image figure-image-fotolife" title="新システムの構成"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005140655.png" width="1200" height="856" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>新システムの構成</figcaption></figure></p> <p>新システムの特徴を以下に示します。</p> <h2 id="1-User-Managed-Index-Replication-を利用した-Solr-cluster-の構築">1. User-Managed Index Replication を利用した Solr cluster の構築</h2> <p>新システムでは Solr が提供する <a href="https://solr.apache.org/guide/solr/9_1/deployment-guide/user-managed-index-replication.html">User-Managed Index Replication</a> の仕組みを利用して "æ›´æ–°ç³»" と "参照系" を組み合わせた Solr cluster を構築しました。</p> <p>このモードでは Solr インスタンスは update リクエストを受け付ける 1 台の leader と、検索リクエストを受け付ける複数台の follower に分かれます。follower は設定した時間ごとに leader に対してポーリングを行い、差分をダウンロードします<a href="#f-5d18f9a0" name="fn-5d18f9a0" title="leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。">*4</a>。それぞれの Solr は旧システムと変わらず Hako を用いて ECS Task として起動しています。</p> <p>細かな要件としては、更新がコンフリクトしないように同時に起動している leader は最大 1 task に抑える必要があり、これは ECS の <code>minimumHealthyPercent</code> ã‚„ <code>maximumPercent</code> を設定することで保証しています。</p> <p>また、follower は起動時に日次バッチで生成された index ã‚’ S3 からダウンロードし、その後 leader が保持している更新分を replicate し終わったタイミングで自身の status ã‚’ healthy としてサービスインします。こうすることで、ヘルスチェックを成功させるタイミングをコントロールし、起動後 replication 途中の follower にアクセスが集中すると、アクセス毎に検索結果が変わってしまうといった問題を防いでいます。</p> <h2 id="2-EFS-を利用した-index-の永続化">2. EFS を利用した index の永続化</h2> <p>新システムにおいては、leader Solr が再起動や deploy をした場合においても index の状態を保ち、update と replication が正しく動作する状態を保証する必要があります。</p> <p>これを実現するために、AWS のネットワークストレージサービスである EFS が利用できます。EFS ã‚’ ECS にアタッチすることで、永続的なストレージをマウントすることができます。しかし、EFS はネットワーク越しにアクセスするストレージであるため、レイテンシ等の性能は ECS のエフェメラルストレージに対して少し劣るものとなってしまいます。</p> <p>そこで、update リクエストを受け付けて index を永続化する必要のある leader のストレージには EFS を使い、ユーザーからの検索リクエストを受け付けて素早く応答する必要がある follower のストレージには tmpfs を利用することとしています。</p> <p>また、新システムにおいても、旧システムと同様に日次で計算・付与される field は存在するため、日次バッチで生成された index で EFS の中身を差し替える処理が実行されています。</p> <p>このとき、index の差し替えや leader/follower の再起動順序によっては replication の整合性が取れなくなり様々な問題が発生することがわかったため、依存関係を丁寧に整理して各処理の実行順序を制御しています<a href="#f-7ea06241" name="fn-7ea06241" title="現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。">*5</a>。</p> <h2 id="3-index-update-batch-の定期実行">3. index update batch の定期実行</h2> <p>index の更新は 5 分ごとに定期実行するバッチで実現しています。その定期実行ごとに「直近 1 時間で更新があったレシピの情報」を取得し、その情報を元に必要な処理を施してドキュメントを生成し、leader に update のリクエストを投げるという流れです。</p> <p><figure class="figure-image figure-image-fotolife" title="5 分に一度 update がリクエストされている様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005141050.png" width="1200" height="322" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>5 分に一度 update がリクエストされている様子</figcaption></figure></p> <p>このとき「そのレシピが不適切な投稿である確率はどのくらいか」を ML によって判定する API へのリクエストを挟むことで、不適切投稿の露出が増えることを防いでいます<a href="#f-68d5fb5c" name="fn-68d5fb5c" title="この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。">*6</a>。</p> <p>定期実行バッチにするのではなく、レシピの投稿・更新にフックさせてイベントを発行・キューイングして都度処理する方針も考えましたが、</p> <ul> <li>イベントの発行数が多くなり既存の社内基盤を利用することができるかどうかが明らかでなかった</li> <li>リトライ処理の実装が複雑になる</li> <li>そこまでのリアルタイム性が求められていない</li> </ul> <p>ことから採用を見送っています。</p> <h1 id="本番環境への展開">本番環境への展開</h1> <p>プロジェクトの完遂には、システムの構築とは別に展開に向けた各種作業も必要です。今回は SRE のメンバーの協力によって、以下に挙げるような作業を事前にキャッチアップ・進行してもらうことができ、非常にスムーズに展開を終えることができました。</p> <h2 id="キャッシュの整理">キャッシュの整理</h2> <p>システムを構築しても、既存のキャッシュ構成は日次での検索結果更新を前提としていたため、TTL が数時間単位のものになっていました。このままでは、キャッシュの更新間隔が検索結果の更新間隔よりも長くなってしまいます。</p> <p>検索結果の更新頻度に合わせてキャッシュの TTL を短縮したいですが、調査が不十分のまま進めるとキャッシュの裏側にあるサービスへの負荷が増大し、障害を引き起こしてしまう可能性があります。</p> <p>そこでまずはキャッシュの設定変更が与えている影響を観測できるように、Prometheus + prometheus_exporter gem を用い、キャッシュのヒット率などを計測するようにしました<a href="#f-441c6e5a" name="fn-441c6e5a" title="クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の multi process modeを用いました。">*7</a>。次にそれらの変化や各サービスの負荷を確認しながらキャッシュの TTL を徐々に短くする変更を行い、最終的に、サービス障害を起こすことなく TTL ã‚’ 5 分にまで短縮できました。</p> <h2 id="負荷試験と段階ロールアウト">負荷試験と段階ロールアウト</h2> <p>検索機能の変更はクックパッドのほぼ全ユーザーに影響を与える大規模なものになります。本番展開前の負荷試験は、展開後の障害発生率を抑えることができるのはもちろん、開発者が安心して展開を行えるようになります。</p> <p>また、展開自体を一度に行うのではなく、徐々にユーザーリクエストを流すような段階ロールアウトの手順を踏むことで、大規模障害の発生率を抑えることができます。</p> <p>今回は以下の手順で負荷試験と段階ロールアウトを行いました。</p> <ol> <li>本番の Solr に届いているリクエストをミラーリングし、新 Solr cluster でもリクエストを問題なく捌けるかを確認する(負荷試験) <figure class="figure-image figure-image-fotolife" title="負荷試験の概要"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005141214.png" width="1160" height="582" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>負荷試験の概要</figcaption></figure></li> <li>実際に一部のレスポンスを新システムからのものに差し替え、徐々にその割合を大きくしていく(段階ロールアウト) <figure class="figure-image figure-image-fotolife" title="段階ロールアウトの概要"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/spicycoffee/20231005/20231005141301.png" width="1149" height="572" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>段階ロールアウトの概要</figcaption></figure></li> <li>全てのレスポンスが新システムからのものになった後、short-period indexing を有効にする</li> </ol> <p>このうち、1 の負荷試験は Envoy の<a href="https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#envoy-v3-api-msg-config-route-v3-routeaction-requestmirrorpolicy">RequestMirrorPolicy</a>を、 2 の段階ロールアウトは Envoy の<a href="https://www.envoyproxy.io/docs/envoy/latest/api-v3/config/route/v3/route_components.proto#config-route-v3-weightedcluster">WeightedCluster</a>を使って実現しています。</p> <h1 id="まとめと振り返り">まとめと振り返り</h1> <p>本プロジェクトでは、従来の検索システムではレシピ投稿から結果への表示までに最長 24 時間かかっていたものを、5 分程度にまで短縮することに成功しました。課題の特定から解決までを 6 週間でおこなうというタイトなスケジュールではありましたが、事業要件に過不足のない開発を事故なく完遂することができたのではないかと思います。</p> <p>振り返ってみると成功の要因としては</p> <ul> <li>プロジェクト冒頭にプロダクトの実現すべき体験からブレークダウンする形で要件をしっかりと定義した <ul> <li>後の意思決定に軸が通り、手戻りも少なくなった</li> </ul> </li> <li>プロダクトからインフラサイドまで、各領域について高い専門性を持つメンバーが集まった <ul> <li>全体の要件定義やざっくりとした設計は全員で行い、そこから先の詳細開発は各メンバーが担当した</li> <li>プロジェクトのため臨時に結成されたチームだったが、期間中は週2回の check-in MTG を設定してスムーズに同期と相談をおこなえるようにした</li> </ul> </li> <li>スポットで機械学習エンジニアなど、他チームの助力も得ることができた</li> </ul> <p>ことが大きかったのではないかと思います。</p> <p>組織として達成したいミッションがあり、そのための事業・プロダクトがあり、それが実現したい体験を阻んでいる障壁があるところに技術をぶつけてそれを取り除くという仕事は、やはりとてもやりがいのあるものだと改めて実感しました。それぞれに高い専門性を持つメンバーから成るチームで仕事ができたことも含めて、個人的には入社以来もっともおもしろい仕事の一つであったように思います。</p> <h1 id="Acknowledgements">Acknowledgements</h1> <p>本プロジェクトは 4 名のメインメンバー+周辺部署のメンバーが関わり、それぞれ力を発揮したことで完遂することのできたプロジェクトです。私一人の力では到底実現できなかったであろう課題解決を共に推進してくれたことに改めて感謝します。</p> <p>最後に、メインメンバーの 4 名について、各作業をどのように担当したかを明記します。</p> <ul> <li><a href="https://twitter.com/SpicyCoffee66">@SpicyCoffee</a>(筆者) <ul> <li>検索エンジニア</li> <li>担当:プロジェクト全体の統括・最終意思決定 / indexing application の実装</li> </ul> </li> <li><a href="https://twitter.com/osyoyu">@osyoyu</a> <ul> <li>検索エンジニア</li> <li>担当:Solr Cluster と Persistent Storage 周りの設計・開発</li> </ul> </li> <li><a href="https://twitter.com/s4ichi">@s4ichi</a> <ul> <li>SRE</li> <li>担当:Solr Cluster と Persistent Storage 周りの設計・開発 / 負荷試験とロールアウト</li> </ul> </li> <li><a href="https://twitter.com/eagletmt">@eagletmt</a> <ul> <li>SRE</li> <li>担当:キャッシュの調査と最適化 / indexing application の実装</li> </ul> </li> </ul> <p>この記事が、日々技術を用いてユーザー課題を解決しているみなさまのお役に立てば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-9b11b8ee" name="f-9b11b8ee" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">今回は CEO がその役割を担っていました。社長と直接仕事をする機会が降ってきてラッキー。</span></p> <p class="footnote"><a href="#fn-f14419c3" name="f-f14419c3" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">当時の ECS &amp; 社内基盤 <a href="https://github.com/eagletmt/hako">Hako</a> という構成は運用負荷が低い上に非常に安定しており、Solr が直接の理由となって障害が起きたのは年に 1 度もないように記憶しています。</span></p> <p class="footnote"><a href="#fn-f1ecce9b" name="f-f1ecce9b" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">人手によるレシピの全件チェックは short-period indexing 以前も行われていたため、「オペレーションの見直しも含め、レシピチェック周りでもシステム変更が必要になると予想された」という表現の方が正確かもしれません。</span></p> <p class="footnote"><a href="#fn-5d18f9a0" name="f-5d18f9a0" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">leader から follower に対して変更を通知しない点は MySQL の replication との違いかもしれません。</span></p> <p class="footnote"><a href="#fn-7ea06241" name="f-7ea06241" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">現状の実装だと検索結果が数時間前の状態に一瞬だけ巻き戻ってしまったりするのですが、実装難易度を考えてこれを仕様側で許容するといった判断もおこなっています。</span></p> <p class="footnote"><a href="#fn-68d5fb5c" name="f-68d5fb5c" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">この API の開発は、投稿物のチェックを行っているチームと機械学習チームの協力によって迅速に開発されました。</span></p> <p class="footnote"><a href="#fn-441c6e5a" name="f-441c6e5a" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">クックパッドが採用している Unicorn はマルチプロセスで動いているため、prometheus_exporter の <a href="https://github.com/discourse/prometheus_exporter#multi-process-mode">multi process mode</a>を用いました。</span></p> </div> spicycoffee クックパッドのフロントエンド CSS in JS をゼロランタイムに切り替えました hatenablog://entry/820878482972440079 2023-10-03T10:52:40+09:00 2023-10-03T18:18:21+09:00 こんにちは。レシピ事業部のkaorun343です。我々のチームではレシピサービスのフロントエンドを Next.js と GraphQL のシステムに置き換えている話 - クックパッド開発者ブログにて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSã‚’Emotionからゼロランタイムのvanilla-extractへ変更した話です。 vanilla-extract.style 背景 以前書いた レシピサービスのフロントエンドに CSS in JS を採用した話 - クックパッド開発者ブログでは、CSS in JSライ… <p>こんにちは。レシピ事業部のkaorun343です。我々のチームでは<a href="https://techlife.cookpad.com/entry/2020/12/01/093000">&#x30EC;&#x30B7;&#x30D4;&#x30B5;&#x30FC;&#x30D3;&#x30B9;&#x306E;&#x30D5;&#x30ED;&#x30F3;&#x30C8;&#x30A8;&#x30F3;&#x30C9;&#x3092; Next.js &#x3068; GraphQL &#x306E;&#x30B7;&#x30B9;&#x30C6;&#x30E0;&#x306B;&#x7F6E;&#x304D;&#x63DB;&#x3048;&#x3066;&#x3044;&#x308B;&#x8A71; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a>にて紹介したとおり、レシピサービスを Next.js ベースの新システムへと移行しています。今回は、この新システムのCSS in JSã‚’Emotionからゼロランタイムのvanilla-extractへ変更した話です。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fvanilla-extract.style%2F" title="vanilla-extract — Zero-runtime Stylesheets-in-TypeScript." class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://vanilla-extract.style/">vanilla-extract.style</a></cite></p> <h3 id="背景">背景</h3> <p>以前書いた <a href="https://techlife.cookpad.com/entry/2021/03/15/090000">&#x30EC;&#x30B7;&#x30D4;&#x30B5;&#x30FC;&#x30D3;&#x30B9;&#x306E;&#x30D5;&#x30ED;&#x30F3;&#x30C8;&#x30A8;&#x30F3;&#x30C9;&#x306B; CSS in JS &#x3092;&#x63A1;&#x7528;&#x3057;&#x305F;&#x8A71; - &#x30AF;&#x30C3;&#x30AF;&#x30D1;&#x30C3;&#x30C9;&#x958B;&#x767A;&#x8005;&#x30D6;&#x30ED;&#x30B0;</a>では、CSS in JSライブラリとして Emotion(@emotion/react)を採用した経緯と開発環境整備を紹介しました。採用理由としては以下の通りでした。</p> <ul> <li>セレクタに一意なIDが割り振られるので、スタイルを適用した要素とは別の要素への、意図しないスタイル適用を防ぐことができる。</li> <li>ESLintã‚„TypeScriptコンパイラといったJavaScriptの静的解析ツールの恩恵を受けることができ、タイポや機能削除時の削除漏れに気づきやすくなる。</li> <li>styled-componentsのようなスタイルではJSXのツリーを見たときに、機能を持つコンポーネントなのか装飾されたコンポーネントなのかわからず、コードレビューがしにくい。</li> <li>通常のCSSの記法に慣れたメンバーが多いので、String Styles、すなわちタグ付きテンプレートリテラルを採用する。</li> </ul> <p>このような方針でEmotionの導入を決め、stylelintã‚„eslintを導入し、必要に応じてカスタムルールを作成して機能開発を進めました。</p> <p>しかしながら、Emotionを導入してから2年ほど経った結果、以下のような課題や懸念を抱えるようになりました。</p> <ol> <li><strong>ページサイズ</strong>:SSR時には初期表示用のCSSã‚’Emotionが作るわけですが、このCSSは .css ファイルとしてブラウザに届くのではなく Next.jsから配信されるHTMLに埋め込まれた状態でブラウザに届きます。そのため、ロードバランサーを通過するHTMLのサイズが増加してしまいます。CSSのデータがCDNを通らないため、パフォーマンスの面でもコストの面で問題です。実際、background-imageにbase64の画像URLを埋め込んだときには、その影響が強く出てしまいました。</li> <li><strong>動的生成による肥大化</strong>:Next.jsのSSR時にうっかりCSSのバリエーションを増やしてしまうと、Next.jsプロセスのメモリ使用量が増大し、アプリケーションが落ちてしまいます。これは、Emotionがインメモリのキャッシュ機構を備えており、一度生成したCSSデータを保持し続けるためです。過去の事例では、レシピごとに異なるbackground-imageを設定するCSSã‚’Emotionで書いたときにこの問題が生じました<a href="#f-77e91b7c" name="fn-77e91b7c" title="このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです">*1</a>。</li> <li><strong>クライアント側のオーバーヘッド</strong>:CSS生成のためにブラウザ上でJavaScriptが実行されるため、ページのパフォーマンスへ影響しうる懸念があります。EmotionはCSSの記述内容の解析、古いブラウザ向けの記述の追加、CSSの合成、そしてスタイルのDOMへの挿入をブラウザ上で実行します。Emotionの<code>css</code>関数を使えば使うほどCSSに関する処理の実行時間が増えていきます。また、これらの処理をブラウザ上で実行するためのJSのコードが必要となるため、バンドルサイズが増加してしまいます。新システムがホストしているページはスマートフォン向けのページであり、パフォーマンスやバンドルサイズは特に注視しています。</li> </ol> <p>そこで、Emotionから別の CSS 環境への移行を検討しました。</p> <h3 id="技術選定">技術選定</h3> <p>上記の課題を踏まえ、以下の要件で新しいCSS 環境を検討しました。</p> <ul> <li><strong>Emotionに近い開発体験</strong>:Emotionと同様に、CSS クラス名を自分でつける必要がないこと</li> <li><strong>CDNの活用</strong>:ビルド時に CSS ファイルが生成されて CDN から静的に配信できること</li> <li><strong>低いオーバーヘッド</strong>:ゼロランタイムであること(ビルド時にCSSを生成し、ブラウザに送られるJavaScriptにはCSSを生成するコードを含まないこと)</li> <li><strong>将来性</strong>:Server Components導入を見据えて、Server Componentsに対応していると嬉しい</li> </ul> <p>検討した結果、これらの要件を満たすライブラリとしてvanilla-extractが挙がりました。 vanilla-extractではCSSã‚’JavaScriptのオブジェクトとして .css.[jt]s という拡張子のファイルに記述します。これをvanilla-extractの各種バンドラに対応したプラグインがCSSに変換し、CSSファイルを生成します。また、それぞれのスタイルは一意なクラス名をセレクタとしており、Emotionと同じように意図しないスタイル適用を防ぐことができます。</p> <p>Emotionとvanilla-extractの比較を表にすると以下のようになり、技術選定で重視した項目を満たしています。</p> <table> <thead> <tr> <th> 比較項目 </th> <th> Emotion </th> <th> vanilla-extract </th> </tr> </thead> <tbody> <tr> <td> クラス名の自動付与 </td> <td> ✓ </td> <td> ✓ </td> </tr> <tr> <td> CDNから配布可能 </td> <td> (HTMLに埋め込まれる) </td> <td> ✓ </td> </tr> <tr> <td> ゼロランタイム </td> <td> (ブラウザ) </td> <td> ✓ </td> </tr> <tr> <td> Server Components対応 </td> <td> (CSRのみ) </td> <td> ✓ </td> </tr> <tr> <td> コンポーネントと同じファイルに書ける </td> <td> ✓ </td> <td> (.css.[jt]sに書く必要がある)</td> </tr> <tr> <td> CSSの書き方 </td> <td> String Styles、Object Styles </td> <td> Object Stylesのみ </td> </tr> <tr> <td> Stylelint </td> <td> ✓ </td> <td> (未対応)</td> </tr> <tr> <td> スナップショットテスト </td> <td> ✓ </td> <td> (なし) </td> </tr> <tr> <td> ベンダープレフィクスの自動付与 </td> <td> ✓ </td> <td>(なし)</td> </tr> </tbody> </table> <p>(比較当時、@emotion/reactはv11.10.0、@vanilla-extract/cssはv1.9.1でした)</p> <p>筆者がvanilla-extractを提案した際は、記述方法の違いやビルド成果物の差がわかるように、実際のページを書き換えたプルリクエストを例示しました。CSSのコード量が小さい<a href="https://techlife.cookpad.com/entry/dynamic-og-image">OGP画像生成用のページ</a>を対象にしました。</p> <h4 id="vanilla-extractのデメリット">vanilla-extractのデメリット</h4> <p>一方で、vanilla-extractにはデメリットも存在します。</p> <h5 id="CSSの書き方">CSSの書き方</h5> <p>まず、これまで通りString Stylesで記述することができなくなりました。この点について懸念点がないかデザイナーの方に伺ったところ、「CSSを書ければ問題ない」とのことでした。vanilla-extractは .css.[jt]s に記述する必要がありますが、この点についても、チームメンバーから合意をもらいました。</p> <h5 id="Stylelint">Stylelint</h5> <p>加えて記法が変わったことによりStylelintで検査できなくなりました。しかしながら、CSSのプロパティや値のタイポ・プロパティの重複はTypeScriptで見つけられますし、我々のアプリケーションでは詳細度に関連して困るような書き方をしていないので、Stylelintを廃止するデメリットは小さいと判断しました。</p> <h5 id="スナップショットテスト">スナップショットテスト</h5> <p>Emotionでは@emotion/jestがスナップショットテストにCSSの記述を表示する仕組みを提供していました。しかしvanilla-extractでは提供されておらず、スナップショットテストでCSSの記述を確認することもできなくなりました。スナップショットテストについては、運良くバグを検知できるほどのメリットしかないと判断し、使えなくなるデメリットは小さいと判断しました。</p> <h5 id="ベンダープレフィクスの自動付与">ベンダープレフィクスの自動付与</h5> <p>Emotionはベンダープレフィクスを自動で付与してくれるのですが、Emotionではライブラリ利用者がブラウザのバージョンを指定できないため、クックパッドの推奨環境より古いブラウザを対象としたプロパティも追加されていました。クックパッドの推奨環境も鑑み、自動付与がなくなるデメリットは小さいと判断しました。</p> <h3 id="vanilla-extractへの移行">vanilla-extractへの移行</h3> <p>CSSの記述をvanilla-extractへ移行することを決定した後、移行作業にとりかかりました。</p> <p>最初はすべて手作業で書き換えていたのですが、途中から正規表現を使った簡素な変換ツールを導入して移行作業がスピードアップしました。 Emotionとvanilla-extractは共存できたため、手が空いているときに手分けをして少しずつ移行していきました。また、Next.jsアプリケーション本体だけではなく、共通コンポーネントパッケージ、そして社内のデザインシステムのReactライブラリもvanilla-extractに移行しました。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// Emotion</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> css <span class="synIdentifier">}</span> from <span class="synConstant">'@emotion/react'</span> <span class="synStatement">const</span> linkStyle = css<span class="synConstant">`</span> <span class="synConstant"> flex: 1;</span> <span class="synConstant"> box-sizing: border-box;</span> <span class="synConstant"> background-color: white;</span> <span class="synConstant">`</span> <span class="synStatement">const</span> linkDisableStyle = css<span class="synConstant">`</span> <span class="synConstant"> </span><span class="synSpecial">${linkStyle}</span> <span class="synConstant"> background-color: gray;</span> <span class="synConstant">`</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span>&gt; Link &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkDisableStyle<span class="synIdentifier">}</span>&gt; Disabled Link &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// vanilla-extract</span> <span class="synComment">// MyComponent.css.js</span> <span class="synStatement">export</span> <span class="synStatement">const</span> linkStyle = style(<span class="synIdentifier">{</span> flex: 1, boxSizing: <span class="synConstant">'border-box'</span>, backgroundColor: <span class="synConstant">'white'</span>, <span class="synIdentifier">}</span>) <span class="synStatement">export</span> <span class="synStatement">const</span> linkDisabledStyle = style(<span class="synIdentifier">[</span> linkStyle, <span class="synIdentifier">{</span> backgroundColor: <span class="synConstant">'gray'</span>, <span class="synIdentifier">}</span>, <span class="synIdentifier">]</span>) <span class="synComment">// MyComponent.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> linkDisabledStyle, linkStyle <span class="synIdentifier">}</span> from <span class="synConstant">'./MyComponent.css.js'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span>&gt; Link &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkDisabledStyle<span class="synIdentifier">}</span>&gt; Disabled Link &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <p>動的にスタイルを生成していた箇所については、 @vanilla-extract/dynamic ã‚„ @vanilla-extract/recipesを利用して問題なく置き換えられました。</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// Emotion</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> css <span class="synIdentifier">}</span> from <span class="synConstant">'@emotion/react'</span> <span class="synStatement">const</span> linkStyle = (size) =&gt; css<span class="synConstant">`</span> <span class="synConstant"> width: </span><span class="synSpecial">${size}</span><span class="synConstant">;</span> <span class="synConstant"> height: </span><span class="synSpecial">${size}</span><span class="synConstant">;</span> <span class="synConstant">`</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle(<span class="synConstant">'100px'</span>)<span class="synIdentifier">}</span>&gt; Link 1 &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> css=<span class="synIdentifier">{</span>linkStyle(<span class="synConstant">'200px'</span>)<span class="synIdentifier">}</span>&gt; Link 2 &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synComment">// vanilla-extract</span> <span class="synComment">// MyComponent.css.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> createVar, style <span class="synIdentifier">}</span> from <span class="synConstant">'@vanilla-extract/css'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> sizeVar = createVar() <span class="synStatement">export</span> <span class="synStatement">const</span> linkStyle = style(<span class="synIdentifier">{</span> width: sizeVar, height: sizeVar, <span class="synIdentifier">}</span>) <span class="synComment">// MyComponent.js</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> assignInlineVars <span class="synIdentifier">}</span> from <span class="synConstant">'@vanilla-extract/dynamic'</span> <span class="synStatement">import</span> <span class="synIdentifier">{</span> sizeVar, linkStyle <span class="synIdentifier">}</span> from <span class="synConstant">'./MyComponent.css.js'</span> <span class="synStatement">export</span> <span class="synStatement">const</span> MyComponent = () =&gt; <span class="synIdentifier">{</span> <span class="synStatement">return</span> ( &lt;section&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span> style=<span class="synIdentifier">{</span>assignInlineVars(<span class="synIdentifier">{</span> <span class="synIdentifier">[</span>sizeVar<span class="synIdentifier">]</span>: <span class="synConstant">'100px'</span> <span class="synIdentifier">}</span>)<span class="synIdentifier">}</span> &gt; Link 1 &lt;/a&gt; &lt;a href=<span class="synConstant">&quot;#&quot;</span> className=<span class="synIdentifier">{</span>linkStyle<span class="synIdentifier">}</span> style=<span class="synIdentifier">{</span>assignInlineVars(<span class="synIdentifier">{</span> <span class="synIdentifier">[</span>sizeVar<span class="synIdentifier">]</span>: <span class="synConstant">'200px'</span> <span class="synIdentifier">}</span>)<span class="synIdentifier">}</span> &gt; Link 2 &lt;/a&gt; &lt;/section&gt; ) <span class="synIdentifier">}</span> </pre> <p>移行した結果、Emotionで課題や懸念に感じていたことを解消できました。</p> <ol> <li><strong>ページサイズ</strong>:CSSファイルにページ全体のCSSが含まれるようになり、CDNから配布できるようになりました。background-imageとしてbase64の画像ファイルを埋め込んだ場合でもロードバランサーを通るHTMLのサイズが大きくなることはありません。</li> <li><strong>動的生成による肥大化</strong>:メモリ使用量が増加してNext.jsプロセスが落ちることはなくなりました。</li> <li><strong>クライアント側のオーバーヘッド</strong>:CSSの生成はビルド時にのみおこなわれるようになり、生成のためのJavaScriptは@vanilla-extract/dynamicã‚„@vanilla-extract/recipesだけになりました。また、Emotionのランタイム削除によりバンドルサイズはgzipで10kB弱減少しました。</li> </ol> <p>エンジニアやデザイナーからも、特にネガティブな意見は出ていません。初めてvanilla-extractを触るメンバーも、問題なくCSSを変更できています。</p> <h3 id="さいごに">さいごに</h3> <p>今回はレシピサービスの新システムにおける ゼロランタイムCSS in JS の話を紹介しました。クックパッドではこれからもモダンな技術によるレシピサービスの刷新を進めていきます。</p> <div class="footnote"> <p class="footnote"><a href="#fn-77e91b7c" name="f-77e91b7c" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">このケースではstyle属性に直接background-imageを指定するCSSを付与して問題を回避しましたが、開発者がこの特性を気にし続けるのは難しいです</span></p> </div> kaorun343 iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作る hatenablog://entry/820878482967113840 2023-09-14T16:00:00+09:00 2023-09-20T23:24:21+09:00 こんにちは、クックパッドマートプロダクト開発部の佐藤(@n_atmark)です。 普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。 動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。 フレームインスペクタの動作の様子 (gif) アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマ… <p>こんにちは、クックパッドマートプロダクト開発部の佐藤(<a href="https://twitter.com/n_atmark">@n_atmark</a>)です。</p> <p>普段はクックパッドマートのモバイルアプリ開発に従事しています。 今回、iOSアプリに実装されたUI要素のフレームやマージンを手軽に確認できるツールを作ってみたのでその紹介を行います。</p> <p>動作している物を見ていただくのが分かりやすいと思うので、早速ですが動作イメージがこちらになります。</p> <p><figure class="figure-image figure-image-fotolife" title="フレームインスペクタの動作の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230914/20230914162704.gif" alt="&#x30D5;&#x30EC;&#x30FC;&#x30E0;&#x30A4;&#x30F3;&#x30B9;&#x30DA;&#x30AF;&#x30BF;&#x306E;&#x52D5;&#x4F5C;&#x306E;&#x69D8;&#x5B50;" width="577" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span><figcaption>フレームインスペクタの動作の様子 (<a href="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913154929.gif">gif</a>)</figcaption></figure></p> <p>アプリに実装されたUI要素を長押しすると、スクリーンとの距離やUI要素のサイズ、角丸の半径を表示するようにしています。 また、2本指で二つのUI要素を長押しすると、長押ししたUI要素間のマージンを表示するようにしています。</p> <h2 id="開発の背景">開発の背景</h2> <p>私が普段開発に従事しているクックパッドマートiOSアプリでは元々5の倍数マージンを採用していたのですが、これを4の倍数マージンに変えたいという背景がありました。</p> <p>後発のクックパッドマートAndroidアプリで4の倍数マージンを採用しており、デザイナーが画面デザインを作成するために5の倍数マージン / 4の倍数マージンを切り替えてデザインを作らないといけないという課題があり、どちらかに統一したいという要望がありました。クックパッド社内の他のiOSアプリでも4の倍数マージンを採用していることもあり、クックパッドマートiOSアプリも4の倍数マージンに合わせることになりました。</p> <p>しかし、マージン値を機械的に置き換えるのは難しく、現在は気づいた箇所から徐々に置き換えていく方針で進めています。 クックパッドマートiOSアプリにはUIKit (AutoLayout) で作られた画面もあればSwiftUIで作られた画面もあり、マージンを直接設定している箇所や変数に置いている箇所、アニメーションのためにマージン値を切り替えている箇所などがあるため、統一した方法で置き換えができないためです。また、10ptの箇所を8ptに置き換えるべきか12ptに置き換えるべきかといった問題もあります。</p> <p>そこで、マージンの違いに気づきやすくするという目的で今回のツールを開発しました。 QA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みとして用意しています。</p> <h2 id="実装の紹介">実装の紹介</h2> <p>実装の全体は <a href="https://gist.github.com/natmark/ef27845aff19059e74916df421223b79">https://gist.github.com/natmark/ef27845aff19059e74916df421223b79</a> に置いてあります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">DebugFrameInspectorView</span><span class="synSpecial">:</span> <span class="synType">UIView</span> { <span class="synIdentifier">init</span>() { <span class="synIdentifier">super</span>.<span class="synIdentifier">init</span>(frame<span class="synSpecial">:</span> .zero) backgroundColor <span class="synIdentifier">=</span> .clear isUserInteractionEnabled <span class="synIdentifier">=</span> <span class="synConstant">false</span> } <span class="synComment">// … ç•¥</span> } </pre> <p><code>DebugFrameInspectorView</code> がマージンやフレームサイズを表示しているViewです。 <code>backgroundColor = .clear</code> かつ <code>isUserInteractionEnabled = false</code> なViewとなっていて、これを <code>keyWindow</code> に対して <code>addSubView(_:)</code> して利用してもらう想定です。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">setup</span>() { <span class="synPreProc">let</span> <span class="synIdentifier">singleLongPressGestureRecognizer</span> <span class="synIdentifier">=</span> UILongPressGestureRecognizer(target<span class="synSpecial">:</span> <span class="synType">self</span>, action<span class="synSpecial">:</span> #selector(didSingleLongPress(_<span class="synSpecial">:</span>))) singleLongPressGestureRecognizer.minimumPressDuration <span class="synIdentifier">=</span> <span class="synConstant">0.2</span> singleLongPressGestureRecognizer.numberOfTouchesRequired <span class="synIdentifier">=</span> <span class="synConstant">1</span> window?.addGestureRecognizer(singleLongPressGestureRecognizer) <span class="synPreProc">let</span> <span class="synIdentifier">doubleLongPressGestureRecognizer</span> <span class="synIdentifier">=</span> UILongPressGestureRecognizer(target<span class="synSpecial">:</span> <span class="synType">self</span>, action<span class="synSpecial">:</span> #selector(didDoubleLongPress(_<span class="synSpecial">:</span>))) doubleLongPressGestureRecognizer.minimumPressDuration <span class="synIdentifier">=</span> <span class="synConstant">0.2</span> doubleLongPressGestureRecognizer.numberOfTouchesRequired <span class="synIdentifier">=</span> <span class="synConstant">2</span> window?.addGestureRecognizer(doubleLongPressGestureRecognizer) } </pre> <p><code>setup()</code> メソッドの中で <code>UILongPressGestureRecognizer</code> ã‚’ <code>UIWindow</code> に追加して長押しのジェスチャーを補足できるようにしています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synType">@objc</span> <span class="synType">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">didSingleLongPress</span>(_ sender<span class="synSpecial">:</span> <span class="synType">UILongPressGestureRecognizer</span>) { <span class="synStatement">if</span> sender.state <span class="synIdentifier">==</span> .began { <span class="synPreProc">let</span> <span class="synIdentifier">positionInWindow</span> <span class="synIdentifier">=</span> sender.location(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">window</span>) <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">hitView</span> <span class="synIdentifier">=</span> window?.hitTest(positionInWindow, with<span class="synSpecial">:</span> <span class="synType">nil</span>) { <span class="synPreProc">let</span> <span class="synIdentifier">positionInHitView</span> <span class="synIdentifier">=</span> sender.location(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">hitView</span>) <span class="synPreProc">let</span> <span class="synIdentifier">globalRect</span> <span class="synIdentifier">=</span> CGRect( x<span class="synSpecial">:</span> <span class="synType">positionInWindow.x</span> <span class="synIdentifier">-</span> positionInHitView.x, y<span class="synSpecial">:</span> <span class="synType">positionInWindow.y</span> <span class="synIdentifier">-</span> positionInHitView.y, width<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.width</span>, height<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.height</span> ) singlePressValue <span class="synIdentifier">=</span> SinglePressValue( viewWireframe<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( rect<span class="synSpecial">:</span> <span class="synType">globalRect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synType">hitView.layer.cornerRadius</span>, maskedCorners<span class="synSpecial">:</span> <span class="synType">hitView.layer.maskedCorners</span> ) ) setNeedsDisplay() } } <span class="synStatement">else</span> <span class="synStatement">if</span> sender.state <span class="synIdentifier">==</span> .ended { singlePressValue <span class="synIdentifier">=</span> <span class="synConstant">nil</span> setNeedsDisplay() } } <span class="synType">@objc</span> <span class="synType">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">didDoubleLongPress</span>(_ sender<span class="synSpecial">:</span> <span class="synType">UILongPressGestureRecognizer</span>) { <span class="synComment">// ç•¥</span> } </pre> <p>長押し時の処理がこちらになります。1本指で長押しするか2本指で長押しするかによって <code>didSingleLongPress(_:)</code> <code>didDoubleLongPress(_:)</code> と実装を分けていますが、内容としてはほぼ同じ処理になります。</p> <p><code>let positionInWindow = sender.location(in: window)</code> で <code>keyWindow</code> 内におけるタッチ位置を取得した後 <code>hitTest(_:with:)</code> を用いてタップされたViewを特定しています。 (<code>let hitView = window?.hitTest(positionInWindow, with: nil)</code> の箇所)</p> <p>その後タップされたViewのframe (superviewを基準にした相対位置) ã‚’ <code>keyWindow</code> 内における座標に変換したいので、<code>hitView</code> 内でのタッチ位置の座標を取得し ( <code>let positionInHitView = sender.location(in: hitView)</code> の箇所) 、 <code>positionInWindow</code> から <code>positionInHitView</code> の座標分ずらすことで <code>keyWindow</code> 内における座標を取得しています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">let</span> <span class="synIdentifier">globalRect</span> <span class="synIdentifier">=</span> CGRect( x<span class="synSpecial">:</span> <span class="synType">positionInWindow.x</span> <span class="synIdentifier">-</span> positionInHitView.x, y<span class="synSpecial">:</span> <span class="synType">positionInWindow.y</span> <span class="synIdentifier">-</span> positionInHitView.y, width<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.width</span>, height<span class="synSpecial">:</span> <span class="synType">hitView.frame.size.height</span> ) </pre> <p><code>singlePressValue</code> 、<code>doublePressValue</code> という変数に値を保持しておいて <code>setNeedsDisplay()</code> を呼び出すことで <code>draw(_:)</code> メソッドを呼び出し、フレーム境界やマージンなどの線の描画を行っています。</p> <p>線の描画に関しては詳しく触れませんが、CoreGraphicsを用いてゴリゴリ記述しています。</p> <h2 id="利用方法">利用方法</h2> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">/// SceneDelegate.swift</span> <span class="synPreProc">func</span> <span class="synIdentifier">scene</span>(_ scene<span class="synSpecial">:</span> <span class="synType">UIScene</span>, willConnectTo session<span class="synSpecial">:</span> <span class="synType">UISceneSession</span>, options connectionOptions<span class="synSpecial">:</span> <span class="synType">UIScene.ConnectionOptions</span>) { <span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">windowScene</span> <span class="synIdentifier">=</span> (scene <span class="synStatement">as?</span> <span class="synType">UIWindowScene</span>) <span class="synStatement">else</span> { <span class="synStatement">return</span> } <span class="synPreProc">let</span> <span class="synIdentifier">window</span><span class="synSpecial">:</span> <span class="synType">UIWindow</span> <span class="synComment">// 以下はアプリの構成によって変わるので必要箇所だけ入れてください</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">keyWindow</span> <span class="synIdentifier">=</span> windowScene.keyWindow { <span class="synComment">// keyWindowがある場合 (SwiftUI.App利用時)</span> window <span class="synIdentifier">=</span> keyWindow } <span class="synStatement">else</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">firstWindow</span> <span class="synIdentifier">=</span> windowScene.windows.first { <span class="synComment">// keyWindowはないが、windowが存在する場合 (Main Storyboard利用時)</span> window <span class="synIdentifier">=</span> firstWindow window.makeKeyAndVisible() } <span class="synStatement">else</span> { <span class="synComment">// window自体存在しない場合 (Storyboard不使用時)</span> window <span class="synIdentifier">=</span> UIWindow(windowScene<span class="synSpecial">:</span> <span class="synType">windowScene</span>) window.makeKeyAndVisible() window.rootViewController <span class="synIdentifier">=</span> MyRootViewController() <span class="synIdentifier">self</span>.window <span class="synIdentifier">=</span> window } <span class="synPreProc"> #if</span> DEBUG <span class="synPreProc">let</span> <span class="synIdentifier">debugFrameInspectorView</span> <span class="synIdentifier">=</span> DebugFrameInspectorView() window.addSubview(debugFrameInspectorView) debugFrameInspectorView.translatesAutoresizingMaskIntoConstraints <span class="synIdentifier">=</span> <span class="synConstant">false</span> NSLayoutConstraint.activate([ window.leadingAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.leadingAnchor</span>), window.trailingAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.trailingAnchor</span>), window.topAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.topAnchor</span>), window.bottomAnchor.constraint(equalTo<span class="synSpecial">:</span> <span class="synType">debugFrameInspectorView.bottomAnchor</span>), ]) debugFrameInspectorView.setup() <span class="synPreProc"> #endif</span> } </pre> <p><code>keyWindow</code> となるUIWindowに対して <code>addSubView(_:)</code> した後、 <code>setup()</code> を呼び出すことで利用できます。</p> <p>デバッグ用の機能なのでCompilation conditionsを使ってbuild configurationが <code>DEBUG</code> の時のみ有効にするなどしておくことをオススメします。</p> <h2 id="実際に開発したツールを使ってもらって">実際に開発したツールを使ってもらって</h2> <p>開発の背景の箇所にもある通り、今回のツールの目的としてはQA担当者やデザイナーが実際のアプリの画面を見て気になった「マージンの違和感」をサクッと確かめられるような仕組みを用意することでした。</p> <p>チームメンバーに実際に使ってもらうと、以下のようなコメントをもらいました。</p> <h4 id="ポジティブな意見">ポジティブな意見</h4> <ul> <li>(エンジニア) 実際のアプリのマージンを実装を見に行かなくても確かめられるのは嬉しい</li> <li>(デザイナー) Webだとインスペクタですぐ要素を確認できるのにネイティブアプリだと見ることができないので、アプリでもフレーム値を確認できるのがありがたい</li> </ul> <h4 id="追加要望">追加要望</h4> <ul> <li>(デザイナー) 実装されているフォントサイズや色も確認できると嬉しい</li> </ul> <h4 id="ネガティブな意見">ネガティブな意見</h4> <ul> <li>(エンジニア) ボタンの領域を長押しした時に、ボタン内の要素のフレームサイズが見れない</li> <li>(エンジニア) フレームサイズが実装を行った際に意図したサイズと違って表示されることがある</li> </ul> <p>実装されたアプリ上でマージン値をサクッと確かめられることに対して一定効果がありそうなことが分かりました。 また、フォントサイズや色も確認できるとアプリに実装されたデザインが正しいかどうかを確認する用途で、より便利に使えそうです。</p> <p>一方で、前後に重なった要素に対してはフレームサイズの確認がうまくできないという課題や、実際の実装値と表示される値が違うことがあるという課題も浮き彫りになりました。</p> <h2 id="実装が難しい部分の紹介">実装が難しい部分の紹介</h2> <p>ここまで開発したフレームインスペクタの紹介をしましたが、できないことも結構あります。 先ほどチームメンバーから要望のあった「実装されているフォントサイズや色」といった要素や、「実装値と表示値が違う」という課題の解決も現状難しいと感じている点です。</p> <p>その難しさの元になるのがSwiftUIで実装された画面です。</p> <p>UIKitとSwiftUIで以下のような画面を実装したとします。</p> <h3 id="UIKit">UIKit</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913162229.png" width="1200" height="1016" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h3 id="SwiftUI">SwiftUI</h3> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913162238.png" width="1200" height="868" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p><code>UIWindow</code> の <code>subViews</code> を辿って階層構造を表示するとそれぞれ下のようになります。</p> <table> <tr> <th>UIKit</th> <th>SwiftUI</th> </tr> <tr> <td> UIWindow<br> â”” UITransitionView<br> &nbsp;&nbsp;â”” UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;â”” UIView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;â”” UIStackView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;├ UIStackView<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;│ ├ UIImageView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;│ â”” UILabel (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;â”” UILabel (テキストテキストテキスト)<br> </td> <td> UIWindow<br> â”” UITransitionView<br> &nbsp;&nbsp;â”” UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;â”” _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt;<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ CGDrawingView (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;â”” CGDrawingView (テキストテキストテキスト)<br> </td> </tr> </table> <p>UIKitの場合は <code>UIImageView</code> ã‚„ <code>UILabel</code> といったお馴染みのViewクラスなので、 <code>UIView</code> 型の <code>subview</code> をキャストすれば簡単にプロパティを確認できますが、SwiftUIの場合はSwiftUIのView構造ではなく、描画用のクラスである <code>SwiftUI._UIGraphicsView</code> ã‚„ <code>SwiftUI.DisplayList.ViewUpdater.Platform.CGDrawingView</code> といったprivateなクラスが利用されます。</p> <p>これが一つ目の難しいポイントで、SwiftUIでViewを組み立てた時にどういったModifierが適用されたかといった情報を持たないため、これらからフォントサイズや設定された色を取り出すことは困難です。</p> <p>また、UIKitではViewの階層構造が保持されるのに対して、SwiftUIでは <code>_UIHostingView</code> というクラスの配下にフラットに展開されてしまいます。 SwiftUIのViewを組み立てる時に利用した <code>VStack</code> ã‚„ <code>HStack</code> は、フレームの決定だけに用いられUIViewの世界においては現れません。 これが二つ目の難しいポイントです。</p> <p>「<code>VStack</code> ã‚„ <code>HStack</code> は、フレームの決定だけに用いられUIViewの世界においては現れない」というのがどう難しさにつながっているのか説明するために以下の図を用意してみました。</p> <table> <thead> <tr> <th style="text-align:center;">UIKit</th> <th style="text-align:center;">SwiftUI</th> </tr> </thead> <tbody> <tr> <td style="text-align:center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163324.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span></td> <td style="text-align:center;"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163305.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span></td> </tr> </tbody> </table> <p>これは <code>UIWindow</code> の <code>subviews</code> を辿ってそれぞれの <code>CALayer</code> に対して枠線を表示したものです。</p> <p>SwiftUI側の実装で、「<b>タイトル</b>」および「テキストテキストテキスト」には <code>.frame(maxWidth: .infinity, alignment: .leading)</code> をつけているものの <code>CGDrawingView</code> が表現するフレームはテキストが文字幅に縮んでしまっていることがわかると思います。</p> <p>元々VStackに設定していた <code>.padding(.horizontal, 20)</code> のうち、<code>trailing</code> 側に関しては正しく効いているかどうか、今回のフレームインスペクタでは上手く確認することができません。</p> <h3 id="補足">補足</h3> <p>ちなみに <code>VStack</code> および <code>HStack</code> に <code>.background(Color.white)</code> を追加するとVStack/HStackの領域が描画され、下のように画面幅にフレームが広がっているのを確認できます。</p> <table> <tr> <th>View構造</th> <th>フレームレイアウト</th> </tr> <tr> <td> UIWindow<br> â”” UITransitionView<br> &nbsp;&nbsp;â”” UIDropShadowView<br> &nbsp;&nbsp;&nbsp;&nbsp;â”” _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt;<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (HStackのbackground)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (VStackのbackground)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ _UIGraphicsView (<font color="red">♡</font>)<br> &nbsp;&nbsp;&nbsp;&nbsp;├ CGDrawingView (<b>タイトル</b>)<br> &nbsp;&nbsp;&nbsp;&nbsp;â”” CGDrawingView (テキストテキストテキスト)<br> </td> <td> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163313.png" width="554" height="1200" loading="lazy" title="" class="hatena-fotolife" style="width:320px" itemprop="image"></span> </td> </tr> </table> <h2 id="今後の展望">今後の展望</h2> <p>今回作ったフレームインスペクタではSwiftUIで作った画面表示にまだ課題があることを紹介しました。 ところでXcodeには <code>Debug View Hierarchy</code> というデバッグ機能があることはよく知られていると思います。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/n/n_atmark/20230913/20230913163813.png" width="1200" height="651" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>この <code>Debug View Hierarchy</code> を用いるとSwiftUIで開発された画面に関しても <code>Horizontal Stack</code> ã‚„ <code>Text</code> といったSwiftUIで組み立てたViewの構造や、テキストのフォントといったModifierも確認することができます。</p> <p>どうにか <code>Debug View Hierarchy</code> で表示しているような情報を取得できると、今回開発したフレームインスペクタに機能追加することができそうです。</p> <h3 id="_UIHostingView_viewDebugData"><code>_UIHostingView._viewDebugData()</code></h3> <p><code>SwiftUI._UIHostingView</code> に非公開APIとして <code>_viewDebugData()</code> というメソッドが存在します。これを用いるとデバッグ用にSwiftUIのView構造を解析できそうです。 (<a href="https://apurin.me/articles/swiftui-secrets/">https://apurin.me/articles/swiftui-secrets/</a> を参考にさせていただきました)</p> <p>SwiftUIのView構造をもう一度示すのですが</p> <pre></code>UIWindow â”” UITransitionView â”” UIDropShadowView â”” _UIHostingView&lt;ModifiedContent&lt;AnyView, RootModifier&gt;&gt; ├ _UIGraphicsView (<font color="red">♡</font>) ├ CGDrawingView (<b>タイトル</b>) â”” CGDrawingView (テキストテキストテキスト) </code></pre> <p>階層を辿ることで <code>_UIHostingView</code> を取得できそうです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">ViewDebugData</span> { <span class="synPreProc">let</span> <span class="synIdentifier">data</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">_ViewDebug.Property</span><span class="synSpecial">:</span><span class="synType"> Any</span><span class="synSpecial">]</span> <span class="synPreProc">let</span> <span class="synIdentifier">childData</span><span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> } <span class="synPreProc">protocol</span> <span class="synIdentifier">DebuggableSwiftUIView</span> { <span class="synPreProc">func</span> <span class="synIdentifier">viewDebugData</span>() <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> } <span class="synPreProc">extension</span> <span class="synIdentifier">_UIHostingView</span><span class="synSpecial">:</span> <span class="synType">DebuggableSwiftUIView</span> { <span class="synPreProc">func</span> <span class="synIdentifier">viewDebugData</span>() <span class="synSpecial">-&gt;</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span> { <span class="synPreProc">let</span> <span class="synIdentifier">_viewDebugData</span> <span class="synIdentifier">=</span> _viewDebugData() <span class="synStatement">return</span> unsafeBitCast(_viewDebugData, to<span class="synSpecial">:</span> <span class="synSpecial">[</span><span class="synType">ViewDebugData</span><span class="synSpecial">]</span>.<span class="synIdentifier">self</span>) } } </pre> <p>DebuggableSwiftUIView というプロトコルを用意して <code>_UIHostingView</code> に準拠させています。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">digDebuggableSwiftUIView</span>(from view<span class="synSpecial">:</span> <span class="synType">UIView</span>) <span class="synSpecial">-&gt; (</span><span class="synType">any DebuggableSwiftUIView</span><span class="synSpecial">)</span>? { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">debuggableView</span> <span class="synIdentifier">=</span> view <span class="synStatement">as?</span> <span class="synType">DebuggableSwiftUIView</span> { <span class="synStatement">return</span> debuggableView } <span class="synStatement">else</span> { <span class="synStatement">for</span> subView <span class="synStatement">in</span> view.subviews { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">debuggableView</span> <span class="synIdentifier">=</span> debuggableView(from<span class="synSpecial">:</span> <span class="synType">subView</span>) { <span class="synStatement">return</span> debuggableView } } <span class="synStatement">return</span> <span class="synConstant">nil</span> } } </pre> <p>DebuggableSwiftUIViewに準拠したViewを探索するメソッドを用意して</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">guard</span> <span class="synPreProc">let</span> <span class="synIdentifier">window</span> <span class="synStatement">else</span> { <span class="synStatement">return</span> } <span class="synPreProc">let</span> <span class="synIdentifier">viewDebugData</span> <span class="synIdentifier">=</span> digDebuggableSwiftUIView(from<span class="synSpecial">:</span> <span class="synType">window</span>)?.viewDebugData() print(viewDebugData) </pre> <p><code>viewDebugData()</code> を呼び出すことで、<code>_UIHostingView._viewDebugData()</code> の結果を確認できそうです。</p> <p><code>_viewDebugData()</code> の出力はかなり大きいので出力の全体は <a href="https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118">https://gist.github.com/natmark/0c13ded1ae0bf97f1f4bbd991f9e0118</a> に置いておきます。</p> <p><code>SwiftUI._ViewDebug.Property.type</code> の部分だけ階層表示すると</p> <pre><code><font size="-3">_SafeAreaInsetsModifier â”” ModifiedContent<ModifiedContent<ModifiedContent<AnyView, RootModifier>, EditModeScopeModifier>, HitTestBindingModifier> â”” HitTestBindingModifier â”” ModifiedContent<ModifiedContent<AnyView, RootModifier>, EditModeScopeModifier> â”” EditModeScopeModifier â”” ModifiedContent<_ViewModifier_Content<EditModeScopeModifier>, TransformModifier> â”” TransformModifier â”” _ViewModifier_Content<EditModeScopeModifier> â”” ModifiedContent<AnyView, RootModifier> â”” RootModifier â”” ModifiedContent<ModifiedContent<_ViewModifier_Content<RootModifier>, RootEnvironmentModifier>, PresentedSceneValueInputModifier> â”” PresentedSceneValueInputModifier â”” ModifiedContent<_ViewModifier_Content<RootModifier>, RootEnvironmentModifier> â”” RootEnvironmentModifier â”” _ViewModifier_Content<RootModifier> â”” AnyView â”” ContentView â”” ModifiedContent<VStack<TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>>, _PaddingLayout> â”” _PaddingLayout â”” VStack<TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>> â”” Tree<_VStackLayout, TupleView<(HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>>, ModifiedContent<Text, _FlexFrameLayout>)>> ├ _FlexFrameLayout ├ â”” Text ├ â”” AccessibilityStyledTextContentView ├ â”” ModifiedContent<ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier>, AccessibilityLargeContentViewModifier<Text>> ├ â”” AccessibilityLargeContentViewModifier<Text> ├ â”” ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier> ├ â”” AccessibilityAttachmentModifier ├ â”” StyledTextContentView â”” HStack<TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>> â”” Tree<_HStackLayout, TupleView<(ModifiedContent<ModifiedContent<Image, _EnvironmentKeyWritingModifier<Optional<Color>>>, _FrameLayout>, ModifiedContent<Text, _FlexFrameLayout>)>> ├ _FlexFrameLayout ├ â”” Text ├ â”” AccessibilityStyledTextContentView ├ â”” ModifiedContent<ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier>, AccessibilityLargeContentViewModifier<Text>> ├ â”” AccessibilityLargeContentViewModifier<Text> ├ â”” ModifiedContent<StyledTextContentView, AccessibilityAttachmentModifier> ├ â”” AccessibilityAttachmentModifier ├ â”” StyledTextContentView â”” _FrameLayout â”” Image â”” ModifiedContent<Resolved, AccessibilityAttachmentModifier> â”” AccessibilityAttachmentModifier â”” Resolved </font></code></pre> <p>のようになっており、Viewの階層構造が取れそうです。 また、<code>SwiftUI._ViewDebug.Property.type: SwiftUI.Text</code> の箇所だけピックアップしてみると</p> <pre class="code" data-lang="" data-unlink>SampleSwiftUIView.ViewDebugData( data: [ SwiftUI._ViewDebug.Property.value: SwiftUI.Text( storage: SwiftUI.Text.Storage.anyTextStorage(&lt;LocalizedTextStorage: 0x00006000017f54a0&gt;: \&#34;テキストテキストテキスト\&#34;), modifiers: [SwiftUI.Text.Modifier.font(Optional(SwiftUI.Font(provider: SwiftUI.(unknown context at $105d9f910).FontBox&lt;SwiftUI.Font.(unknown context at $105e5aae8).SystemProvider&gt;)))] ), SwiftUI._ViewDebug.Property.type: SwiftUI.Text, SwiftUI._ViewDebug.Property.size: (191.0, 20.333333333333332), SwiftUI._ViewDebug.Property.position: (20.0, 442.5) ], childData: [] // ç•¥ )</pre> <p>Modifierに関しても確認することができました。</p> <p><code>_viewDebugData</code> の内容を実際にデバッグツールに活用しようと思うとかなり骨が折れる作業になりそうですが、SwiftUIのView構造をここまで詳細に取得できれば、Viewのデバッグツールを作るにあたってできることは広がりそうです。</p> <h2 id="まとめ">まとめ</h2> <p>今回、アプリに実装されたUI要素のフレームサイズやマージンを簡単に確認できるツールを作って紹介しました。</p> <p>チームメンバーに使ってもらって、サクッとフレームサイズやマージンを確認する用途であれば便利に使えそうなことが分かった一方で、より完成度の高いツールを目指そうとした際に、SwiftUI製のViewのインスペクタ実装は難しい部分が多いことも分かりました。</p> <p><code>_UIHostingView</code> のprivate APIである <code>_viewDebugData()</code> を使うと詳細なSwiftUIのView構造を利用できそうなこと分かったため、 <code>_viewDebugData()</code> のデータを活用したデバッグツールの改善に関しても引き続き検討してみようと考えています。</p> <p>今回の記事が快適なデバッグ環境構築の参考になれば嬉しいです。</p> n_atmark クローズしたサービスの管理画面を静的サイトにする hatenablog://entry/820878482963625603 2023-09-01T10:04:02+09:00 2023-09-06T14:53:37+09:00 こんにちは、技術部の石川です。 ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。 一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。 やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデー… <p>こんにちは、技術部の石川です。</p> <p>ある日、社内の各種アプリケーションを眺めている中で、とあるクローズしたサービスの管理画面を担っていたウェブアプリが今も動いていると気付きました。簡単にヒアリングしたところ、サービス自体はクローズしたものの、保有していたデータが次のチャレンジに生かせるため管理画面だけ残しているとのことでした。</p> <p>一方で、その管理画面へのアクセスはそう多くありませんでした。毎日ちょっとだけのリクエストを処理するためだけにデータベースとサーバーが動いており、少し無駄がある状態になっていました。</p> <p>やや気になったので検討した結果、最終的にこの管理画面アプリを Next.js 製の静的なデータビューワーサイトとしてリニューアルし、社内向けの GitHub Pages として提供されている状態にできました。この記事ではその顛末をご紹介します。</p> <h2 id="技術選定">技術選定</h2> <p>いくつか事前調査をした結果、今回の管理画面について以下のことが分かりました。</p> <ul> <li>Rails が動いている。サーバー側では graphql-ruby を利用した GraphQL API が動いていて、ウェブフロントエンド側では素の React が API にリクエストを行いながらページを作っている。データベースは PostgreSQL (Amazon RDS for PostgreSQL)。</li> <li>データ量はそこまで多くないが、目で全件確認できるほど少なくもない。</li> <li>ページの種別は、Rails の <code>app/javascript/pages</code> 下にある index.tsx を数えてみると 80 程度。移植しなくて良いページもそれなりにありそう。</li> <li>画像や映像を表示しているページがある。</li> <li>データの追加や更新はもう行わない。</li> <li>認証・認可は不要になる。正確には、社内ネットに閉じた環境であれば全公開で構わない。</li> <li>予定としては今後数年アクセスするつもりがある。</li> <li>データの一覧ページのところにある検索機能は残したい。</li> </ul> <p>この状況下で、現状の管理画面アプリに替わる運用として以下の選択肢を考えました。</p> <ul> <li>DWH へのクエリで済ませてしまう。クックパッドでは Amazon Redshift に各種データを集積し、DWH として活用しています。<a href="#f-7d5b6688" name="fn-7d5b6688" title="https://techlife.cookpad.com/entry/2019/10/18/090000">*1</a>今回のアプリが利用しているデータベース上の情報は DWH にもあるため、キレイな画面は消してしまって素朴な SQL クエリにしてしまうことは可能です。クエリ結果を共有するアプリが常用されていたりもします。<a href="#f-f4daf0b7" name="fn-f4daf0b7" title="https://techlife.cookpad.com/entry/2021/06/11/120000">*2</a></li> <li>BI ツールのダッシュボードとして再実装する。</li> <li>データベースやアプリをサーバーレスな構成に移植する。</li> <li>静的サイトジェネレーターの何かしらを使って再実装する。</li> <li>今ある管理画面に対して古典的なウェブスクレイピングを行って全ページのファイルを取得し、それを手直ししたうえで静的サイトとして提供する。検索機能は諦めて、ブラウザのページ内検索を使う。</li> </ul> <p>このうち、DWH 案とダッシュボード案はすぐに取り下げました。画像や映像とテキストが横に並んだ状態でパッと一覧できる現状を保ちたいという利用者からの要望があったのと、再実装したいページ種別数がそれなりにあってクエリやダッシュボードとして作るには時間がかかりそうだったのが理由です。</p> <p>サーバーレス化をやってみるのも面白そうではありました。たとえばデータベースだけ Amazon Aurora Serverless にしてアプリはそのまま、というのは社内に過去事例もありできそうでした。一方でアプリが残る以上そのうちセキュリティアップデート等の対応は必要になるため、もっとラクができるなら嬉しいと考えました。クローズしたサービスのアプリはオーナー不在になる確率が高そうで、誰がメンテナンスするのかという問題もありました。<a href="#f-4b212a06" name="fn-4b212a06" title="もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。">*3</a></p> <p>というわけで静的サイトジェネレーターかスクレイピングの 2 択になりました。ラクそうだったスクレイピングを選んでも良かったのですが、ここで少し欲を出して、静的サイトジェネレーターを試してみたいという気分になりました。</p> <p>静的サイトジェネレーターを使う場合、元々の画面が React で実装されているので、移植の容易さを考えると Next.js ã‚„ Gatsby などの選択肢が考えられます。実は Gatsby で静的サイト化するのは別の小さな社内向けアプリで前例がありました。ただ 2023 年現在の社内では Next.js を利用しているアプリが多くあり、また Next.js の静的サイト向け機能である <a href="https://nextjs.org/docs/app/building-your-application/deploying/static-exports">Static Exports</a> を自分で使ってみたことがなかったため、技術検証の意味も込めて Next.js を使ってみることにしました。<a href="#f-042b27a5" name="fn-042b27a5" title="この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。">*4</a></p> <p>React 以外の依存関係も確認しておきましょう。元々の管理画面のコードで使われている package.json を見てみたところ、UI ライブラリとして Ant Design (antd)、GraphQL クライアントとして Apollo が入っており、その他小さなライブラリがすこし入っているという状況でした。このくらいの複雑度なら移植できそうだなと判断しました。</p> <p>さて、Next.js の Static Exports で再実装するのであれば、PostgreSQL に入っているデータをどうするか考えなければいけません。AWS 上にデータベースが残ったままだとコストはあまり減りませんし、ローカル環境のデータベースに移すならシンプルな形にしたいです。考えた結果、今回のデータはそこまで巨大では無かったため、SQLite へ移植して .sqlite3 ファイルとして持ってしまうことにしました。jsonb 型など PostgreSQL 固有の機能を使っている箇所もあったのですが数箇所しか無かったため SQLite 向けに書き直し、SQLite のみだと足りない処理はデータベースからデータを取ってきた後にアプリ側で行うようにしてしまいました。</p> <p>データ取得まわりについては GraphQL を使うことにしました。元々の Rails 製 GraphQL API をコピペしてくれば GraphQL スキーマとサーバーが出来ますし、クライアント側でも型が自動生成できてラクが出来そうという目論見がありました。SQLite に対して直接 SQL クエリを走らせても良かったのですが、自分が元の管理画面の実装にそこまで詳しくなかったというのもあり、どこまで複雑なクエリが必要になるのか実装前の時点で判断しづらかったためコピペにしてしまいました。</p> <h2 id="実装">実装</h2> <p>方針が決まってしまえば後は実装するだけです。API サーバーは元の Rails のコードをそのままコピペしてきて動かすようにし、フロントエンドは <code>create-next-app</code> しました。Next.js 13 で App Router を使いつつ、Static Exports のために next.config.js が</p> <pre class="code lang-javascript" data-lang="javascript" data-unlink><span class="synStatement">const</span> nextConfig = <span class="synIdentifier">{</span> output: <span class="synConstant">'export'</span>, <span class="synIdentifier">}</span> module.exports = nextConfig </pre> <p>になっています。<a href="#f-de2afe0b" name="fn-de2afe0b" title="最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。">*5</a></p> <p><code>next build</code> をする際には横で SQLite に繋がった Rails 製 GraphQL API を動かしていて、必要に応じて API にリクエストすることでデータを取ってきます。Static Exports の場合はビルドが終わると HTML 等のファイル群が生成されるので、開発環境では <code>serve</code> を使うなどすれば閲覧できます。</p> <p>実装の移植について、ページや React Components の移植はまあまあコピペで終わりました。元々の実装がそれなりにコンポーネント化されていて全容を把握しやすかったのと、元と変わらず GraphQL を使っているあたりが効きました。</p> <p>ただし Server Components と Client Components、つまりどのコンポーネントの処理はサーバー側で行われどの処理はブラウザ側で行われるのかについては整理する必要がありました。Static Exports を行ううえでは API からデータを取得する部分はすべてサーバー側で行われていないといけませんし、逆に <code>useState</code> を使うような箇所はブラウザ側で行われなければなりません。元々の管理画面ではブラウザから GraphQL リクエストを行っていたため、至るところにある API リクエストはサーバー側に集中するよう書き換えが必要でした。データの流れを整理した結果としてひとつのコンポーネントを分割して Server Component と Client Component に分けたりもしました。</p> <p>とはいえ「データの追加や更新はもう行わない」という制約がとても強く効いていて、実装は比較的シンプルになりました。データの追加・更新のために存在していた React コードをばっさり削除していった結果、大抵のページは「Server Components でデータを取得して、子となる UI コンポーネントにデータを渡す。子コンポーネントは必要であれば Client Components にする」くらいの単純さになりました。</p> <p>Client Components を用いたのは主に、検索フォームを設置しているところと antd 5.8 が必要とするところです。<a href="#f-0e92a6ba" name="fn-0e92a6ba" title="antd で必要になるのは https://ant.design/docs/react/use-with-next#using-nextjs-app-router に書かれている &quot;if you use the above sub-components in your page, you can add &quot;use client&quot; to the first line of the page component to avoid warnings&quot; です。">*6</a></p> <p>検索機能については、元の管理画面ではサーバー側に検索機能を実装していましたが静的サイトでは不可能なので、ブラウザ側の JavaScript で素朴に <code>String.prototype.includes</code> を使って絞り込むことで実現しました。ページネーションして見た目上の表示件数を減らしはしましたが、それなりの数のデータがあっても高速に動作するのでブラウザは凄いですね……。もし複雑な全文検索が欲しくなる箇所があれば <a href="https://github.com/olivernn/lunr.js">Lunr.js</a> 等を使ってみるつもりでしたが、今回はそこまで複雑な検索は無かったため使わずに済んでしまいました。</p> <p>そんなこんなで詳細が固まり、まずは複雑そうなページから実装してみたところ上手く動いたため実装を進め、必要なページすべてについて実装しきることができました。</p> <h2 id="振り返り">振り返り</h2> <p>全部終わったあと振り返ってみると、Next.js の App Router と GraphQL を使うことにしたのは成功だったと感じます。コピペできたのもそうですし、実装している最中、App Router のディレクトリ構造を使って GraphQL クエリや小さい React Components のファイルたちをページの実装の近くに配置できるのがラクでした。</p> <p>具体的にはたとえば graphql-codegen で <a href="https://the-guild.dev/graphql/codegen/plugins/presets/near-operation-file-preset"><code>near-operation-file</code></a> を使うようにしたうえで、以下のようなファイル配置になるわけです。</p> <pre class="code" data-lang="" data-unlink>app ├── _lib │ └── types.generated.ts ├── recipes │ ├── _lib │ │ ├── Table.tsx │ │ ├── query.generated.ts │ │ └── query.graphql │ └── page.tsx : :</pre> <p>元々の実装もページごとのコードとページ固有の React Components、それとたまに全体で共有の React Components という感じだったので、それをそのまま持ってくることが出来ました。データの流れを整理する過程で小さい Server Components ã‚„ Client Components が生まれたのですが、このディレクトリ構造だと気になりません。</p> <p>Static Exports は何の問題もなく動いてくれました。移植の際に <code>&lt;a&gt;</code> タグをすべて next/link の <code>&lt;Link&gt;</code> に書き換えたため、ただでさえ静的サイトで速いのに prefetch のおかげで更に速く感じられるサイトが出来上がりました。</p> <p>いちおう実装前の不安点として、antd などの依存ライブラリは実装時点での最新バージョンまで上げないと App Router および React Server Components 対応の不充分な点がありそうだとは分かっていました。この関係で元の管理画面で使っていたバージョンからメジャーバージョンを上げないといけないライブラリもありました。とはいえ実装前にザーッと各ライブラリの変更履歴を眺めた結果そこまで困らなさそうと判断し、実際あまり困りませんでした。</p> <p>また App Router の関係なのか Static Exports の関係なのかはちゃんと調べていませんが、いくつかのエラーで原因を調べるのに少し時間がかかりました。エラーメッセージとスタックトレースが分かりづらかったのですよね……。<code>"use client";</code> をつけて Client Components にしたら直るのだけどエラーメッセージから直接は分かりづらい類のエラーにはいくつか遭遇しました。</p> <p>とはいえ始まりから終わりまで見ると、そう労力をかけずに移植できたので満足です。この管理画面が提供しているデータは社内を見渡してもそれなりにユニークな料理データでして、これを参照しやすい形で残し続けられることには価値があると、個人的にも考えています。そういった意義のついでに技術検証も出来たオトクな仕事でした。</p> <div class="footnote"> <p class="footnote"><a href="#fn-7d5b6688" name="f-7d5b6688" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2019/10/18/090000">https://techlife.cookpad.com/entry/2019/10/18/090000</a></span></p> <p class="footnote"><a href="#fn-f4daf0b7" name="f-f4daf0b7" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text"><a href="https://techlife.cookpad.com/entry/2021/06/11/120000">https://techlife.cookpad.com/entry/2021/06/11/120000</a></span></p> <p class="footnote"><a href="#fn-4b212a06" name="f-4b212a06" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん静的サイトにしたとしても JavaScript ライブラリの更新が必要になる可能性が無いとは言えないのですが。数年くらい経って新しいブラウザーでうまく動かなくなったときくらいでしょうかね。</span></p> <p class="footnote"><a href="#fn-042b27a5" name="f-042b27a5" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">この管理画面を開発していたエンジニアはサービスクローズ後も社内に在籍しているのですが、このあたりの技術検証をしてみたいという自分の要望から自分が実装してみることにしたのでした。</span></p> <p class="footnote"><a href="#fn-de2afe0b" name="f-de2afe0b" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">最終的な next.config.js では更に、next/image の最適化は Static Exports では意味が無いので unoptimized: true にしたり、実装の都合で trailingSlash: true にしたりもしています。GitHub Pages にデプロイする前には basePath を調整するのもやっています。</span></p> <p class="footnote"><a href="#fn-0e92a6ba" name="f-0e92a6ba" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">antd で必要になるのは <a href="https://ant.design/docs/react/use-with-next#using-nextjs-app-router">https://ant.design/docs/react/use-with-next#using-nextjs-app-router</a> に書かれている "if you use the above sub-components in your page, you can add "use client" to the first line of the page component to avoid warnings" です。</span></p> </div> nekketsuuu Rubyの並列並行処理のこれまでとこれから hatenablog://entry/820878482963206936 2023-08-31T15:25:11+09:00 2023-08-31T15:29:53+09:00 本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。 Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。 <p>技術部の笹田です。今日で退職するので、バタバタと返却などの準備をしています。</p> <p>本記事では、Rubyの並行並列処理の改善についての私の取り組みについて、おもに RubyKaigi 2022 と 2023 で発表した内容をもとにご紹介します。</p> <p>並行と並列はよく似た言葉ですが、本記事では次のような意味で使います。</p> <p>並行処理(concurrent processing)は、「複数の独立した実行単位が、待っていればいつか終わる(もしくは、処理が進む)」という論理的な概念で、古典的にはタイムシェアリングシステムなどが挙げられます。</p> <p>並列処理(parallel processing)は、「複数の独立した実行単位のうちのいくつかが、あるタイミングで同時に動いている」という物理的な概念で、古典的には複数のCPU上で同時に実行させる、というものです。最近では、1つのCPU上で複数コアが同時に動いている、というのが普通になってきましたね。</p> <p>Ruby(CRuby/MRI)は古くからThreadによる並行処理のための仕組みを提供しており、並列処理はUnixなどのプロセスなどを用いる、つまりRubyの外側(?)の機能と組み合わせて使う必要がありました。そして、Ruby 3.0から導入された Ractor で Ruby プロセス内で利用できる並列処理の仕組みが導入されました。まだまだ不十分なので、これからもっと頑張っていこう、っていう内容の記事になります。</p> <h2 id="簡単な歴史">簡単な歴史</h2> <h3 id="Ruby-18-まで">Ruby 1.8 まで</h3> <p>Ruby 1.8 までは、Rubyはユーザーレベルスレッドと呼ばれる仕組みで、OSなどが提供するネイティブスレッド(下記NTとも表記、Pthreadã‚„Windows APIのスレッド)を1つつかって、複数のRubyスレッド(下記、RTとも表記、<code>Thread.new{}</code>で作るやつ)を管理していました。1つのネイティブスレッドで複数(M個)の Rubyスレッドを管理するので、M:1 モデルということもあります(世間的には 1:N スレッドモデルということが多いのですが記事の都合上、M:1 と書いておきます)。</p> <p><figure class="figure-image figure-image-fotolife" title="M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113351.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>M:1 (N:1) model, Green threads, user level threads, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>複数のRubyスレッドは、(1) I/Oã‚„ sleep、Mutex などで待ちが入るタイミング、(2) 一定時間(タイムスライス)経過したときのタイミングで切り替わります。I/O に関しては、select(に類する)システムコールで準備できたかを定期的に確認しながら管理します。</p> <p>この手法の利点と欠点は次の通りです。</p> <ul> <li>利点: <ul> <li>ユーザレベルスレッドなので、生成は速い</li> </ul> </li> <li>欠点: <ul> <li>当時はRubyスレッドの切り替えをスタックを丸々コピーするという手法を使っていたので、結構遅いものでした。</li> <li>select で待ちを制御できない処理、たとえば <code>waitpid()</code> ã‚„ <code>flock()</code>、ドメイン名解決、待ちが入るようなライブラリ関数などで待っていると他のRubyスレッドに切り替わらない</li> <li>ポーリング出来る処理は都度ポーリングで対処していたが、スレッド数が増えるとスケールしない可能性がある</li> <li>ネイティブスレッドを占有するほうが都合がよいライブラリ(GUI系など)を素直に使えない</li> <li>作るのが(メンテし続けるのが)結構大変</li> </ul> </li> </ul> <h3 id="Ruby-19-Thread">Ruby 1.9 Thread</h3> <p>Ruby 1.9 では、Rubyスレッド一つにつきネイティブスレッド1つ用意する 1:1 モデルに変更されました。というのも、ユーザレベルスレッドはいろいろ tricky で、実際に実装していた私には手がおえなかったからです。また、1:1 モデルにすることで、並行処理だけでなく、並列処理への拡張も視野に入っていたからです。</p> <p><figure class="figure-image figure-image-fotolife" title="1:1 model, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113458.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>1:1 model, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>Ruby 1.9 で 1:1 モデルを導入する際には、GVL(Global VM Lock)というのを導入しました。ただ一つのGVLをロックしているRubyスレッドしか動かない、というモデルとすることで、同時にRubyスレッドはたかだか1つしか動かない、というのものです。そのため、Rubyスレッドは相変わらず並行処理をサポートしますが、並列処理はサポートしません。いろいろな理由はあるのですが、要は「実装が簡単だから」ということに尽きます。</p> <p>(余談:どちらも簡単に拘っているのは、手抜きしたいというのが本音なのですが、建前でいうと、マンパワーのない状況で安定した処理系を提供するためには適切なトレードオフであるのではないかな、と思っており、この判断は今振り返っても妥当だったんじゃないかなと思います。2006年ごろですかね)</p> <ul> <li>利点: <ul> <li>1:1 スレッドモデルは結構簡単</li> <li>GVL があるので並列に実行されないためにさらに簡単</li> <li>GVL を解放することで(C-extension)、ブロックする処理をしている間、他のRubyスレッドに処理を移すことができる</li> <li>GVL を解放することで(C-extension)、複数の処理(Rubyで記述してある処理ではない)を並列に実行することができる。例えば、Bignumの時間がかかる計算や、I/O の処理(Ruby インタプリタとは独立した処理)など。</li> <li>Rubyスレッド切り替えはネイティブスレッドによって実装されるので速い</li> <li>スケジューリングをネイティブスレッドに任せてしまうので楽</li> </ul> </li> <li>欠点: <ul> <li>1:1 モデルなので Ruby スレッドを1つ作るたびにネイティブスレッド 1 つ必要になり、Rubyスレッド数がスケールしない(ネイティブスレッドの実装による)</li> <li>GVL があるので並列実行できない</li> <li>Rubyスレッド間で処理を受け渡すような処理が遅い(CPU core 間で処理を頻繁に切り替えるときに遅い)</li> <li>スケジューリングをネイティブスレッドに任せてしまうので、Ruby側からの細かい制御が難しい</li> </ul> </li> </ul> <p>Ruby 1.8 であった問題がだいぶ解決されているのがわかると思います。そのほかはトレードオフですね。</p> <h3 id="Ruby-19-Fiber">Ruby 1.9 Fiber</h3> <p>Ruby 1.9 では、ユーザレベルスレッドの利点をちょっとのこしておこうということで Fiber が導入されました。Fiber は Ruby 1.8 のユーザレベルスレッドとほぼ同様ですが、タイムスライスがなく、I/O などで自動的にスイッチすることのない、という点が異なります。</p> <p>Fiber は当初は Ruby 1.8 のスレッド切り替え処理をそのまま踏襲していたのですが、のちのバージョンで改善され、今では CPU ごとにアセンブラを用いて記述する、というものになりました。</p> <h3 id="Ruby-30-の-Fiber-scheduler">Ruby 3.0 の Fiber scheduler</h3> <p>Ruby 3.0 で導入された Fiber scheduler は、I/O など、ブロックするような処理を契機にフックを呼び出す仕組みで、自分で Fiber をスケジュールする処理(これを総称して Fiber scheduler)を記述するための仕組みです。実際には、自分で記述するのではなく、すでにある gem を利用するといいでしょう。</p> <ul> <li>利点: <ul> <li>ユーザレベルスレッドの利点(低コストな生成)を得られる</li> <li>自分でスケジューラーを記述できる</li> <li>タイムスライスがないため予測可能性が上がる</li> </ul> </li> <li>欠点: <ul> <li>Ruby 1.8 スレッドと同じ(管理できないブロックする処理では他のFiberに切り替えられない)</li> <li>Fiber を意識する必要がある</li> <li>タイムスライスがない</li> </ul> </li> </ul> <p>アプリケーションに特化したスケジューラを記述できるというのは、最高性能を目指すという観点からは良いものですが、多くの場合 too much じゃないかなぁ、というのが私の感想です。</p> <h3 id="Ruby-30-の-Ractor">Ruby 3.0 の Ractor</h3> <p>そもそも Ruby スレッド、というかいわゆるスレッド一般って、変更可能(Mutable)データの共有によるデータレース、レースコンディションの問題があり、Mutexなどで正しく排他制御が必要です。なので、私は、スレッド難しいなぁ、あんまり便利にしたくないなぁ、という意見を持っていました。この問題に対処するために、例えば他の言語では次のような工夫をしていました。</p> <ul> <li>領域を完全に分けてしまう: Unix などのプロセス、Racket(のPlace)</li> <li>すべてのデータを Immutable にして、Immutable なデータしか共有できないようにする: Erlang, Elixir(のProcess)</li> <li>型でいい感じになんとかする: Rust</li> <li>データを共有する方法を標準化する: Java, Golang(Goroutine)</li> <li>実行時にやばそうなところを検知して修正する: Valgrind, Thread sanitiser, ...</li> </ul> <p>Rubyで取れそうな戦略は何かな、ということを考えて、「"Immutable なデータしか共有できないようにする" なら、なんとかなりそうかな?」という発想で Ractor を設計しました。イメージとしては、Unixなどのプロセスに近いですが、共有可能な部分は共有するし、Mutable でもロックを必須とするなら共有可能にする、というところが近いです。</p> <ul> <li>利点: <ul> <li>並列実行が可能</li> <li>データレースなどの問題が原理上起こらない</li> </ul> </li> <li>欠点: <ul> <li>共有可能オブジェクトを制限するので、ふつうのRubyプログラムが multi-Ractor の上では動かない</li> <li>実装が悪いので色々遅い</li> </ul> </li> </ul> <p>「実装が悪い」という部分は、改善すればいいのですが、いくつか大きな問題がありました。</p> <ul> <li>1:1 スレッドを踏襲している(Ractor 内に 1 個 Ruby スレッドを作るが、それが 1 ネイティブスレッドを要求する)</li> <li>すべてをとめて GC しなければいけないので Ractor 数にスケールしない</li> </ul> <p>欠点の1つ目の問題から、なかなか利用されず、ではスクラッチで Ractor ベースに書き直せばよくなるか、というと2つ目の欠点である実装が悪いという点から利用も進まない、となれば順当に実装をよくしていくぞ、というのが今年の RubyKaigi 2023 での私の発表でした。</p> <p><figure class="figure-image figure-image-fotolife" title="“Ractor” reconsidered, or 2nd progress report of MaNy projects"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113943.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>“Ractor” reconsidered, or 2nd progress report of MaNy projects</figcaption></figure></p> <p><a href="https://rubykaigi.org/2023/presentations/ko1.html#day1">"Ractor" reconsidered - RubyKaigi 2023</a></p> <p>要点としては、</p> <ul> <li>Ractor はせっかく入ったんだけど <ul> <li>既存のコードがすぐには動かないから使われていない</li> <li>性能が悪いことが多くてなかなか使われていない</li> </ul> </li> <li>とりあえず性能よくすることで、小規模なコードから使われるんじゃないだろうか</li> <li>そのためにはこういうことをやったし、これからこういうことをするよ</li> </ul> <p><figure class="figure-image figure-image-fotolife" title="Future expected situation on Ractor, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113133.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Future expected situation on Ractor, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <p>というものでした。</p> <h2 id="MN-スレッドの導入">M:N スレッドの導入</h2> <p>というわけで「こういうことをしたよ/するよ」という話です。</p> <p>まず、Ractorã‚„Rubyスレッドは、1:1モデルであることで、生成が遅かったり生成できる数が少なかったりします(1:1モデルの欠点)。そこで、M:N スレッドモデルを導入できないか、今(というか去年から)開発中です。</p> <p>という内容が RubyKaigi 2022 での私の発表でした。</p> <p><figure class="figure-image figure-image-fotolife" title="Making *MaNy* threads on Ruby"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113830.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Making <em>MaNy</em> threads on Ruby</figcaption></figure></p> <p><a href="https://rubykaigi.org/2022/presentations/ko1.html">Making <em>MaNy</em> threads on Ruby - RubyKaigi 2022</a></p> <p>ちなみに、M:N スレッドモデルの実装なので MaNy プロジェクトというコードネームを中田さんにつけてもらいました。</p> <ul> <li>M:1スレッドは、RubyスレッドM個にたいしてネイティブスレッドが1個(Ruby 1.8 まで)</li> <li>1:1スレッドは、Rubyスレッド1個にたいしてネイティブスレッドが1個(Ruby 1.9~)</li> <li>M:Nスレッドは、RubyスレッドM個にたいしてネイティブスレッドがN個</li> </ul> <p>というモデルです。N をコア数にすることで、十分小さい数のN個のネイティブスレッドで、M個のRubyスレッド(例えば1000個)を賄おうというものです。これで、「1:1スレッドモデルがスケールしない」という問題が解決します。</p> <p><figure class="figure-image figure-image-fotolife" title="Thread system implementation techniques, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112246.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Thread system implementation techniques, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>欠点としては、実装が複雑であることですが、そこは頑張りました/頑張っています。</p> <p>同じRactorに属するRubyスレッドは同時並列には動かないので、1つしかRactorを動かさない場合はNをいくら多くしても2個以上のネイティブスレッドを使うことはありません。なので、その点はユーザレベルスレッドと同じです。 あまりRubyスレッドをよくしたいという動機はないのですが(Ractor 使ってほしい)、副次的に現在のマルチ Ruby スレッドプログラムがユーザレベルスレッドモデルを用いることで改善されることもあるかもしれません。</p> <p><figure class="figure-image figure-image-fotolife" title="M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112630.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>M:1 Thread level scheduling in a Ractor, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>ユーザレベルスレッドで問題となっていた、どーしょーもなくブロックしてしまう処理は、そのブロックしてしまう(可能性のある)処理を実行中、1:1スレッド、つまり1つのRubyスレッドが1つのネイティブスレッドを占有する、という状態にしておきます。ちなみにこの状態から戻らないと、他のRubyスレッドに処理がうつらないような気がしますが、ちゃんと移すために、準備のできたRubyスレッドを実行するためのネイティブスレッドを1個余分に追加します。つまり、N はそのようなスケジュール可能なネイティブスレッドの数であり、占有されている状態のネイティブスレッドは数に入れません(上限なしにネイティブスレッドが作られる可能性がありますが、動かないよりまし、というスタンスです)。</p> <p><figure class="figure-image figure-image-fotolife" title="Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112416.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Handle unmanaged blocking operations, quoted from my RubyKaigi2022 talk</figcaption></figure></p> <p>この工夫により、ユーザーレベルスレッドモデルで問題であった「何か処理が止まってしまう」ときに別のRubyスレッドに切り替わらなくなる、という問題が解決します。 M:Nスケジューラは、切り替わらなくなるかもしれない、といった危険のなくなった、そしてタイムスライスで切り替わる(プリエンプションがある)組み込みの Fiber scheduler みたいなもの、というとらえ方もできると思います。</p> <p>このM:Nスレッドの実装は、Go language の構成によく似ています。ちょっとした違いとしては、goroutine はどれも順不同で実行できるのですが、Rubyのスレッドは同じRactorに所属している場合、同時に動くことはできない(GVLをもつRubyスレッドしか動かせない)、という制約です。この制約を満たすため、Ractor内のRubyスレッドについてのスケジューラと、どのRactorを動かすか選ぶRactorについてのスケジューラの2つのスケジューラによる構成となっています(もちろん細かい違いはほかにもいろいろあります)。</p> <p>M:Nスレッドは、多くの場合で(ちゃんと作って有れば)問題ないと思われるのですが、どうしても仕組み的にネイティブスレッドの Thread local storage に依存した作りになっているコードを利用すると破綻する、という問題があります(あるRubyスレッドが異なるネイティブスレッドで実行するようになるため)。そこで、今のところ M:N スレッドモデルはデフォルトではオンにならないようにしようということにしています。より正しくは、メインRactorの中でRubyスレッドを作る場合(つまり、ふつうのRubyによるスレッドプログラムの場合)、1:1 スレッドという従来のスレッドモデルで実行されることになります。複数Ractorを利用する場合は、メインRactor以外はM:Nスレッドモデルで実行されます。</p> <p>今のところ、<code>RUBY_MN_THREADS=1</code> という環境変数でメインRactorでのM:Nスレッド対応を指定出来るようにする予定です。もし M:N スレッドの実装がマージされたら試してみてください。ちなみに、ネイティブスレッド数の上限Nを指定するには、今のところ<code>RUBY_MAX_CPU=n</code>という環境変数で指定できるようにする予定です。</p> <p>詳細は当該チケットをご覧ください: <a href="https://bugs.ruby-lang.org/issues/19842">Feature #19842: Introduce M:N threads - Ruby master - Ruby Issue Tracking System</a></p> <p>性能改善などはあまりきちんとできていないのですが、去年の RubyKaigi 2022 の発表で、少し述べています。場合によってはだいぶ速くなっています。</p> <p><figure class="figure-image figure-image-fotolife" title="Ring example, quoted from my RubyKaig 2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112739.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ring example, quoted from my RubyKaig 2022 talk</figcaption></figure></p> <p><figure class="figure-image figure-image-fotolife" title="Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831112809.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ring example, compare with Go/Loop time, quoted from my RubyKaigi 2022 talk</figcaption></figure></p> <h2 id="Ractor-local-GC-の導入">Ractor local GC の導入</h2> <p>現在 Ractor が遅いもっとも大きな理由が GC です。並列に実行される Ractor ですが、GC をするためにはすべてを止める必要があり、とめた状態で唯一のネイティブスレッド上でGCが実行されるようになっています。これは色々遅いので、RactorごとにGCをそれぞれ並列に実行する、というRactor local GCができないか試行錯誤中です(他の方が試験実装中)。</p> <p><figure class="figure-image figure-image-fotolife" title="Ractor local GC, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113102.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ractor local GC, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <p>これを実現するためには、Ractorがなまじっか Immutable オブジェクトは Ractor 間で共有できるといった仕組みから、きちんと動かすためには分散GCが必要になります。現在、実装しながら問題を見つけ解決していくような手探りな感じで開発を進めています。来年くらいに何かご紹介できるといいですね。</p> <p><figure class="figure-image figure-image-fotolife" title="Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/k/koichi-sasada/20230831/20230831113020.png" width="800" height="450" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Ractor local GC needs distributed GC, quoted from my RubyKaigi2023 talk</figcaption></figure></p> <h2 id="おわりに">おわりに</h2> <p>本稿では、Rubyの並行並列処理について外観し、利点と欠点をまとめました。そして、それらの欠点を解消するために M:N スレッドモデルの実装を行っており、現状をご紹介しました。また、さらにまともな性能にするためにはRactor local GCが必要であるということをご紹介しました。</p> <p>いまのところ、M:Nスケジューラは、1:1モデルにしておけば(デフォルトです)テスト通っているのですが、M:Nスケジューラを有効にすると「あれー?」というところでバグを出すので、Ruby 3.3 にマージできるのか予断を許さない感じです。がんばろ。</p> <p>クックパッドでは、実はずっとこの辺をやっていました。</p> <ul> <li>2016: Guild (のちの Ractor)構想の発表</li> <li>2017: Ractor につなげるための、Fiber 周りの整理(この年にクックパッド入社)</li> <li>2018: Ractor の実装の検討</li> <li>2019: Ractor の実装(RubyKaigi 2019 では Ruby で MRI を書くための話をしていたけど)</li> <li>2020: Ractor の入ったRubyのリリース(Guild -> Ractor に名前が変わったのもこの年)</li> <li>2021: Ractor もデバッグできるようにするために debug.gem の開発(まだ Ractor では動かないんだけど)</li> <li>2022: M:N スケジューラ構想の発表とプロトタイプ</li> <li>2023: M:N スケジューラで Ractor を動かせるように</li> </ul> <p>(やっていたのはこれだけじゃないけど)同じテーマで何年やってるんだ、という気もしますが、長い目で開発を支えてくれたクックパッドに深く感謝します。</p> <p>そんな感じでまだまだやることがイッパイありますが、「並列並行処理を書くならRubyもいいね」といってもらえるように、これからも頑張っていきたいと思います。</p> <p>読まなくてもよい余談です。私の卒論(2002)はスレッドライブラリ(pthread)の実装でして、20年たっても似たようなことしかやってねーな、という感想があります(でも、20年ずっと楽しいので幸せ。ちなみにYARV開発は2004年からなのでもうすぐ20年)。M:N スケジューラはその頃から考えてはいたんだけど、当時は逆に遅くなるかもなぁ、などと思っていたところでした。Go がだいたい同じようなことをしている、ということを確認できたので、結構自信をもって進めているという次第です。まぁ、Go はほかのランタイムを全部自分でかいているので、TLSみたいな互換性問題があまり起きないというところはあるとは思うんですが。2018年のRubyConf で、まつもとさんの部屋でこんな構想を話して、「でも2020年には間に合わないよなー」と言っていた内容が、やっと形になってきました。うまくまとめたいな。回り道しすぎ?</p> <p>というわけで、またどこかで成果をおみせできることを楽しみにしています。</p> <p>Happy hacking!</p> koichi-sasada TechMTG文字起こしレポート:クックパッドマートのAndroidアプリのUI開発のこれまでとこれから hatenablog://entry/820878482954241916 2023-07-31T18:18:11+09:00 2023-07-31T18:18:11+09:00 こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。 今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。 クックパッドマートのAndroidアプリのUI開発のこれまでとこれから こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。 今回はAndroidアプ… <p>こんにちは、CTO室の緑川です。クックパッドでは隔週で全エンジニアが集まるTechMTGというミーティングを行っています。今回はTechMTGで話した技術的な取り組みや解説を文字起こしレポートとしてお届けします。</p> <p>今回は4月19日に発表されたクックパッドマートでのAndroidアプリのUI開発についてです。 以下、レポート本文です。</p> <h2 id="クックパッドマートのAndroidアプリのUI開発のこれまでとこれから">クックパッドマートのAndroidアプリのUI開発のこれまでとこれから</h2> <p>こんにちは、門田です。 2016年に新卒入社をして2019年にクックパッドマートに移動し、今は買物プロダクト開発部というところでAndroidエンジニアをしています。</p> <p>今回はAndroidアプリのUI開発の話をしようと思います。 Jetpack Composeの話がメインになると思っていたんですけど、去年の11月や先月にもJetpack Composeの話は社内でしていたので、今回はそんなに話すこともなくなったかなと思ったのですが、「クックパッドマートでJetpack Composeをどういうふうに使ってきたか?」という話はできていなかったと思うので、今回はそれを話そうかなと思っています。</p> <h3 id="アプリの実装で最も時間がかかる部分">アプリの実装で最も時間がかかる部分</h3> <p>最初に問題定義をしたいのですが、アプリの実装をするときに最も時間がかかるところって皆さんどこだと思いますか? 僕の中ではなんといってもUI開発が一番大きいと思っています。今回はこれを前提に話していこうかなと思います。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161556.png" width="1200" height="577" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>アプリの開発をしていく上で素早くUIを構築することが、素早く機能を開発していくことに最も寄与するんじゃないかなと思っています。じゃあそれをどうすれば実現できるのだろうというところを考えたときに、パターン化して簡単に誰でも早く開発できればいいんじゃないかということを考えて、クックパッドマートの開発初期から結構そのことを考えて実装してきました。</p> <p>2019年にクックパッドマートを作り始めたときはAndroid View、いわゆるXMLを使って構築して、コードで味付けしていくみたいな方法が一般的だった時代なんですけど、この時にはRecyclerViewという要素をいっぱい並べるライブラリと、あとそれを簡単に使うためのGroupieっていうライブラリを使って複雑な画面の構築を簡単にしていくのをやっていました。これのメリットとしてはレイアウトファイル、たとえば商品詳細だったりとか、長いレイアウトのファイルでもある程度のまとまりごとに分割して組み立てていくことができるので、全体がシンプルに構築できるということに一つ大きなメリットがあります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161622.png" width="1200" height="595" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとは一覧画面と詳細画面みたいなところで実装のパターン化ができて、これも実装速度の向上に大きく寄与をしたんじゃないかなと思っています。これに関しては当時テックブロックにも書いてたのでこれを参照してください。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftechlife.cookpad.com%2Fentry%2F2019%2F04%2F11%2F130000" title="クックパッドマートAndroidアプリの画面実装を最高にした話【連載:クックパッドマート開発の裏側 vol.4】 - クックパッド開発者ブログ" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe><cite class="hatena-citation"><a href="https://techlife.cookpad.com/entry/2019/04/11/130000">techlife.cookpad.com</a></cite></p> <p>具体的にはどんなふうになっていくかというと、上の方からSectionっていう画面の単位を分けていってそれを横側に書いていくと、画面全体の構築も割と見やすい状態になっているし、一つ一つのレイアウトのファイルの単位も左側のlayout XMLが構築されていたら右側でそれに対して名前を入れたりとか画像を設定したりとか、というふうに設定していくと結構わかりやすいシンプルなレイアウトを組めたんじゃないかなと思っています。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161649.png" width="1200" height="695" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span> <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731162435.png" width="1200" height="657" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>ただ、この辺は開発上の問題が結構あって、たとえば動的なUIの更新というのが地味に難しかったりするんですよね。たとえば、要素を追加したりとか削除したりとかチップみたいなUI要素を入れたい時とかにViewを追加削除するのは結構力技になりがちで難しかったり、角丸とかボーダーとかの表現がAndroidだと地味に面倒くさいところがあってすごく難しいところでした。</p> <p>あとは複雑なUI状態というのがLayout XMLのPreviewって一つしかないので、実際にデータを当てはめて確認するしか道がないので結構難しいというのがあります。クックパッドマートだと特に商品の受け取りの状態って複数あって、6・7種類ぐらいあるんですけどステージング環境だとその状態を再現するのにも一苦労するので、その動作確認がすごく大変だったっていうのがあります。あとは全体をコードで組み立てているので画面全体の構築っていうところが確認できないのが結構難しいところでした。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161732.png" width="1200" height="616" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>たとえばUIを追加とか、削除とかするみたいな話なんですけど、jQueryとかを見てもらえると分かると思うんですけど、特定のdivタグに子要素のビューを全部消してもう一回入れ直すみたいなことを力技で入れていた気がして、実行してみないとうまくできたかわからないとか、入れたところのビューの大きさだったり要素間のマージンだったりとかが結構調整しづらかったりとか、そういう問題がいっぱい起きました。あと角丸をつけたいだけでこれだけ書かなきゃいけなくて、これをbackgroundに設定するみたいなことをやらなきゃいけなくてすごく面倒くさかったっていうのがあります。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161757.png" width="1200" height="660" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとはパターン化を考えるときに、たとえばこのおためし価格っていうところは、当日適用されるパターンとされないパターンがあるんですけど、されないパターンをどういうふうに確認するのかというと、されないパターンを心の目で感じ取って実装するしかないというのが結構きついです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161820.png" width="665" height="613" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>AndroidとかiOSの開発って結構レイアウトが崩れることがあると思うんですけどこういうところが大きいんじゃないかなと僕は思ってます。</p> <h3 id="Jetpack-ComposeはUI開発の何を変えたか">Jetpack ComposeはUI開発の何を変えたか?</h3> <p>2022年は結構Jetpack Composeの年になったかと思うんですけど、Jetpack ComposeってUI開発の何を変えたかっていうと、そういう動的なViewの追加削除だったりとか角丸だったりとかボーダーだったりとかそういう今までちょっとやりづらかったUIの表現だったりとかを簡略化してくれたのが結構大きいかなと思うんです。</p> <p>他には高性能なPreviewを作ってくれたっていうのが僕の中では一番 Jetpack Composeの中でいいと思っていて、これが強力すぎてこれを扱うための開発スタイルの設計を去年はしていました。Jetpack Composeは直感的にif文を書いたりとかして条件式によってこのViewが表示されるされないみたいなものを制御できたりとか、あと角丸を表示するときに.clipって書くだけで一行で表現できたりとかこういうのが本当に助かりますね。</p> <h3 id="Previewのここがすごい">Previewのここがすごい</h3> <p>あと、今日はPreviewの話をすごくしたいんですけど、Previewはめちゃくちゃ良くて、PreviewのすごくいいところはPreviewを自分で書くことができるところです。逆に言うと自分で書かないといけないんですけど、任意の要素に対してPreviewを自分で書けるんです。なので、どんなPreviewでも自分で作ることができて、つまり同じUIに対しても複数のデータを入れてPreviewを書くことももちろんできるというのがかなり強いところかなと思います。あとはLive Editってホットリロードみたいなやつですね。Composeのコードを編集していると横で自動でコンパイルを走ってPreviewがどんどん更新されていってその状態が見えるという感じになってます。 <span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161856.png" width="1200" height="589" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Previewが出たばっかりのときはAndroid Studioが結構不安定でうまく動かなかったときもあったんですけど、最近はかなり安定して使えるようになってきたので、これなしではUI開発が進まないというぐらいに最高になってきました。</p> <p>あとInteractive ModeだったりとかDeploy Previewっていうのがあって、作ったPreviewを実機とかエミュレーターにもインストールできるんです。たとえばボタンを押したときにトースト通知を表示してくださいとか、ボタンを押したときにこの要素を隠してくださいとかそういうイベントとかも全部出てきているので、実はPreviewだけでアプリも作れて軽いプロトタイピングみたいなところもできるというのが結構大きいなと個人的には思っています。</p> <p>で、これをどういうふうに扱っていこうかというところを考えたときにLive Editにすごく注目していて、Live Editを有効に扱うためにはアプリのコンパイル時間を短くする必要があるんです。   なんでかと言うと、さっき言ったとおりLive Editするときは横でコンパイルを走って自動でPreviewが更新されるという話をしてたので、逆に言うとコンパイル時間が長かったら編集している際にコンパイルがすごく時間がかかって、Previewが表示されるまで30秒とか1分とかかかるっていうような状態になると結構きついと思うので、その時間を短くする必要があります。Composeの関数を書くためだけのモジュールを各フィーチャーごとに分離するような開発手法をクックパッド マートでは取り組みました。で、これでPreviewしたいモジュールだけビルドをすればよくなるので、かなりコンパイルも楽になったという感じです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161926.png" width="1200" height="573" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとは表示パターンをPreviewで網羅できるようになったということで、ちゃんと網羅しようといういくつかの実装ルールだったりとかも決めたりしました。 実際にPreviewを書くと@Previewって付いているアノテーションが書いてある関数がPreview用のComposeの関数で、実際のComposeの関数に対して引き数にデータを渡してあげれば右側にデータが出てきます。上に書いてあるProviderが実際にこのPreviewに渡したいデータの一覧みたいな感じになっていて、これを増やしていくことでいろんな状態のPreviewをいっぱい作ることができたりします。</p> <p>で、これ受取一覧画面の受取のデータの種類をバーッとPreviewで作ってみたいやつなんですけどこれすごく便利でアプリで実機で実際に確認しようとするとこれ全部再現するのめちゃくちゃ大変なんですけど、Previewで確認するだけでこんなに簡単に作れるっていうのはすごく良かったなっていう感じです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731161956.png" width="1200" height="674" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>あとはさっきのモジュールの分離の話をすると、モジュールはいっぱいわけています。で、Previewに必要なビルド範囲を最小限にしています。アプリのフルビルドって結構時間かかっていてクックパッドマートのアプリだと5分とか、最悪7分くらいかかったりするんですけど、フィーチャモジュールの一つだけのビルドとかだと数十秒程度で終わったりするので、これで開発速度が変わっているなという印象がありますね。</p> <p>あとは開発方針の話でいくつかの実装ルールを分けた話を軽くしたと思うんですけど、画面全体のことをScreenというふうにクックパッドマートでは呼んでいって、区切り線で囲まれた部分をSectionというふうに呼んでいます。あとはSectionをいくつかの単位でまとめたのをContentというふうに呼んでいるんですけど、実装に落としていくとContentが並んでいるみたいな実装だったり、ファイルを分けてそのContentの中身にはラベルにあるテキストとSectionとみたいな感じでそれぞれ分けているというふうにして、しかもこれに対してそれぞれでPreviewも作れるので画面全体の構築もそうだし画面の細かいところに対してのUIのPreviewもかなり簡単に作れるようになったっていうのがすごく大きい変化だったなというふうに思います。</p> <h3 id="これからの話">これからの話</h3> <p>ここまでが2022年ぐらいまで、僕らが今まで開発してきたUI開発の話だったんですけど2023年だからこれからの話でちょっとしようかなと思います。最近Relayっていうツールが気になっていて、これが何かと言うとfigmaのデータをComposeのコードに変換してくれるツールなんですね。で、この辺冷静に考えてみると結構面倒くさい作業で、デザイナーさんがfigmaでデザイン作ってくれて、我々アプリエンジニアがSwift UIだったりとかJetpack Composeでそれを実現するっていう開発フローになってるんですけど、これっていうのはfigmaとSwift UIとJetpack Composeで同じデザインで3回作っているっていうことになっていて、すごくだるくないかっていうふうに最近は考えています。逆に言うと、これをなくせればUIの開発って最高になるんじゃねって最近はちょっと考えたりしています。</p> <p>コード変換だったりコード生成だったりを行ってくれるツールっていろいろあると思うんですけど、このRelayってツールは何がいいかっていうとAndroid Developersが公式で出してるツールなんですよね。だからちょっと興味があって、ちょっと見てるんですけどまだまだな段階といえそうな予感はしています。</p> <p>ちょっと使ってみたんですけど、商品詳細画面のfigmaã‚’ComponentとしてRelayに喰わせるとComposeの関数を勝手に作ってくれます。今はレイアウトとか崩れちゃっているんですけど、フォントを調整して上手くいくととわりときれいに描画されたりとかして、結構簡単に作れてUI開発がわりとスムーズになるんじゃないかなというふうに妄想してるところです。</p> <p>Relayを簡単に触れてみた所感としてはfigmaのリンクを貼るだけでコードが生成されるので簡単に使えるっていうのはすごくよくて、デザインのアップデートをfigmaにして、実装も右クリックで簡単にアップデートできるのでいいんじゃないかなと思ってるんですけど、Android Studio上からアップデートするしかなくてCIとかで更新するのがちょっと難しそうっていうところはひとつネックかなと思ってます。あとはfigmaのデザインがAuto Layoutっていう結構きれいにかかなきゃいけないという仕組みを使わないといけなくて、それがちょっと面倒くさいなっていうところがあります。</p> <p>あと細かい制御が難しいっていうところがあるんですけど、割り切って使える場面はありそうかなと思っていて、共通Componentになるものだけ切り出すとか、そういうところはうまく使えるところがないかなって探しながら妄想しているところです。クックパッドマートでは細かく定義されてるものがあるので、小さなComponentぐらいだったらうまく使えないかなっていうのはちょっと妄想してたりします。</p> <p>クックパッドマートのAndroidのUI開発についてたくさん話してきたんですけど、Jetpack Composeはいいぞと今まで皆さんも聞いてきたと思うんですけど、Jetpack Composeはいいんですよ。特にLive Previewが最高にいいので、皆さんもぜひ使ってみてください。あとは最後に言ってた通りデザインをコードに写し込む写経みたいな作業をいつか終わらせられたらいいなと思っているところです。</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/cookpadtech/20230731/20230731162022.png" width="1200" height="603" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> cookpadtech iOS画像非同期取得 hatenablog://entry/820878482952300372 2023-07-25T09:00:00+09:00 2023-07-25T09:00:07+09:00 こんにちは、モバイル基盤のヴァンサン(@vincentisambart)です。 半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。 自作経緯 長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。 この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。 使っているライブラリのメン… <p>こんにちは、モバイル基盤のヴァンサン(<a href="https://twitter.com/vincentisambart">@vincentisambart</a>)です。</p> <p>半年くらい前に、iOSクックパッドアプリで画像非同期取得を自作することになりました。導入してから何ヶ月も問題なく動いているので、どう動いているのか紹介しようと思います。でもその前に自作することになった経緯を説明しましょう。</p> <h2 id="自作経緯">自作経緯</h2> <p>長年画像非同期取得に既存のライブラリを使っていましたが、昨年ライブラリの不具合で画像の取得が稀に失敗していたバグがいくつかありました。バグが修正されて、その数ヶ月後にまた似た問題。</p> <p>この状態が好ましくなかったので、以下の選択肢のどれかにしようと議論しました。</p> <ul> <li>使っているライブラリのメンテナンスにもっと直接参加する <ul> <li>コードが古くメンテナンスしやすくなさそうでした。</li> </ul> </li> <li>使っているライブラリのバージョンを固定する <ul> <li>自動的に更新をやめても、バグ修正や最新のOSの対応のために定期的に更新した方が良いでしょう。</li> </ul> </li> <li>別のライブラリにするか <ul> <li>選定が難しいでしょう。例えばアプリをリリースしないとライブラリの安定性が判断しづらいです。</li> </ul> </li> <li>自作するか</li> </ul> <p>ライブラリを使っていたものの、複雑な機能は使っていませんでした。必要だったのは画像のダウンロード、キャッシュ、<a href="https://ja.wikipedia.org/wiki/WebP">WebP</a>対応、くらいです。</p> <p>クックパッドでモバイルアプリの画像はWebPを使っているので、画像取得を自作することになっても、WebP読み込みにライブラリが必要だと思っていました。でも議論中に、iOS 14以降<code>UIImage</code>がWebPを読み込めるのが発覚しました。その時点で最新のiOSクックパッドアプリの最小サポートバージョンはすでにiOS 15でした。</p> <p>画像ダウンロードとキャッシュだけが必要なら自作してみても良いかもという結論になりました。</p> <h2 id="実装">実装</h2> <p>経緯の次は実装の詳細を説明しようと思います。その中で、まずは一番複雑そうなキャッシュの実装はどうしましょうか。</p> <h3 id="キャッシュ">キャッシュ</h3> <p>多くの画像非同期取得ライブラリがキャッシュを2段階で行います:</p> <ul> <li>取得された画像ファイルをディスクにキャッシュします。アプリを再起動してもデータは残ります(ただし端末に空き容量が足りなくなった場合、OSが一部を消すことがあります)。</li> <li>画像ファイルが読み込まれた<code>UIImage</code>をメモリ上にキャッシュします。もちろんアプリを再起動したら一掃されます。ディスクからのファイル読み込みも、画像データのデコードも、メインスレッドでやらない方が良いことですが、<code>UIImage</code>をメモリ上でキャッシュするとどっちも必要ないのでこのキャッシュは直接メインスレッドで扱えます。</li> </ul> <p>どちらのキャッシュの種類もできれば自分で実装したくありません。ディスクやメモリの空き容量、キャッシュの使っている容量、を気にする必要があるのはややこしそうです。でも実はFoundationにそのためのツールがあります。</p> <p>ダウンロードされるデータをキャッシュするために<a href="https://developer.apple.com/documentation/foundation/urlcache"><code>URLCache</code></a>があります。ダウンロードに使う<code>URLSession</code>の<code>configuration</code>に代入するだけでダウンロードするデータがキャッシュされるようになります。</p> <p>メモリ上でデータをキャッシュするために<a href="https://developer.apple.com/documentation/foundation/nscache"><code>NSCache</code></a>があります。使い方が<code>Dictionary</code>に近いです。キーと値が<code>AnyObject</code>であるべきなのでSwiftから使う場合少し不便な場面もありますが、今回キー(<code>URL</code>)ã‚’<code>NSURL</code>に簡単にキャストできるので、別のものにラップせずに<code>AnyOject</code>にできます。</p> <h3 id="API">API</h3> <p>キャッシュのためのツールが揃ったので、画像取得APIを見てみましょう。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">enum</span> <span class="synIdentifier">LoadingImage</span> { <span class="synStatement">case</span> cached(UIImage) <span class="synStatement">case</span> inProgress(Task<span class="synIdentifier">&lt;</span>UIImage, any Error<span class="synIdentifier">&gt;</span>) } <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { </pre> <p><code>Task</code>が見られるのでSwift Concurrencyが使われているのですが、asyncメソッドではありません。</p> <p>ビューの読み込みや表示時にOSから呼ばれるメソッド(<code>collectionView(_:cellForItemAt:)</code>、<code>viewDidLoad</code>、など)は基本的にasyncではありません。</p> <p><code>loadImage</code>の定義が<code>func loadImage(from imageURL: URL) async throws -&gt; UIImage</code>でしたら、asyncでないメソッドから呼ぶと新規タスクを作成する必要があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">viewDidLoad</span>() { Task { <span class="synType">@MainActor</span> <span class="synType">in</span> <span class="synComment">// このコードが`viewDidLoad`のタイミングで実行されるのではなく、</span> <span class="synComment">// `MainActor`が次回実行するようにキューされます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> await imageLoader.loadImage(from<span class="synSpecial">:</span> <span class="synType">imageURL</span>) imageView.image <span class="synIdentifier">=</span> image } } </pre> <p><code>loadImage</code>内で<code>await</code>をせずに<code>return</code>したとしても、同じ<code>MainActor</code>で実行される別のタスク内なので、<code>viewDidLoad</code>の後に実行されてしまいます。画像がメモリ上のキャッシュにあったとしても、ビューの最初に描写で画像が表示されない可能性があります。最初の描写で画像なし、次の描写で画像あり、はチカチカして雑に見えます。フェードインを使えば少しマシですが、すぐ表示できればしたいです。</p> <h2 id="loadImage"><code>loadImage</code></h2> <p>本来の<code>loadImage</code>のコードを見てみましょう。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { <span class="synComment">// 画像がメモリ上キャッシュに入っていれば、すぐ返します。</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> cachedImage(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">return</span> .cached(image) } <span class="synComment">// バックグラウンドタスクで取得とデコードを行います。</span> <span class="synComment">// `detached`を使うのはactorを引き継がないためです。</span> <span class="synStatement">return</span> .inProgress(Task.detached { <span class="synPreProc">let</span> <span class="synIdentifier">request</span> <span class="synIdentifier">=</span> URLRequest(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) <span class="synComment">// コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、</span> <span class="synComment">// 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await <span class="synIdentifier">self</span>.loadData(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> <span class="synIdentifier">self</span>.decode(data, <span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) }) } </pre> <p>ダウンロードをキャンセルしたければ、<code>inProgress()</code>に入ったタスクの<code>cancel()</code>メソッドを呼びます。<code>Task.init()</code>や今回のように<code>Task.detached()</code>を使うとstructured concurrencyではないので、<code>loadImage(from:)</code>の戻り値を放置してもタスクがキャンセルされることはありません。</p> <p>上記に使われている<code>cachedImage(for:)</code>が<code>NSCache</code>のメソッドを呼ぶだけです。<code>URL</code>が<code>AnyObject</code>ではないので、<code>NSCache</code>のキーに使うには<code>NSURL</code>にキャストする必要があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">memoryCache</span> <span class="synIdentifier">=</span> NSCache<span class="synIdentifier">&lt;</span>NSURL, UIImage<span class="synIdentifier">&gt;</span>() <span class="synPreProc">func</span> <span class="synIdentifier">cachedImage</span>(<span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">UIImage?</span> { memoryCache.object(forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) } </pre> <p><code>loadData(for:)</code>に関しては、<code>URLSession.data(for:)</code>をもう少し使いやすくするだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">session</span><span class="synSpecial">:</span> <span class="synType">URLSession</span> <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadData</span>(<span class="synStatement">for</span> request<span class="synSpecial">:</span> <span class="synType">URLRequest</span>) async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">Data</span> { <span class="synStatement">do</span> { <span class="synStatement">return</span> <span class="synStatement">try</span> await session.data(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>).<span class="synConstant">0</span> } <span class="synStatement">catch</span> { <span class="synComment">// タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、</span> <span class="synComment">// Swiftでは`CancellationError`がもっと自然だと思います。</span> <span class="synComment">// タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。</span> <span class="synStatement">try</span> Task.checkCancellation() <span class="synStatement">throw</span> error } } </pre> <p><code>decode(_:for:)</code>が<code>UIImage(data:)</code>をラップして、デコードされた画像を<code>NSCache</code>に入れてくれるだけです。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">decode</span>(_ data<span class="synSpecial">:</span> <span class="synType">Data</span>, <span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">UIImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> UIImage(data<span class="synSpecial">:</span> <span class="synType">data</span>) { memoryCache.setObject(image, forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) <span class="synStatement">return</span> image } <span class="synStatement">else</span> { <span class="synStatement">throw</span> InvalidImageDataError(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) } } </pre> <p><code>ImageLoader</code>の全コードをこの記事の一番下にまとめました。上記になかった<code>init</code>も含まれています。</p> <h2 id="使い方">使い方</h2> <p>上記に実装したAPIは基本的に以下のように使えば良いのではないでしょうか。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// 以前行われた取得が終わっていなければキャンセルします。</span> <span class="synComment">// キャンセルされる可能性ないなら、`loadTask`インスタンス変数は要らないでしょう。</span> <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">loadTask</span> { loadTask.cancel() <span class="synIdentifier">self</span>.loadTask <span class="synIdentifier">=</span> <span class="synConstant">nil</span> } <span class="synComment">// 取得を始めます。</span> <span class="synStatement">switch</span> imageLoader.loadImage(from<span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">case</span> <span class="synPreProc">let</span> .cached(image)<span class="synSpecial">:</span> <span class="synComment">// `image`をそのまま表示できます。</span> <span class="synStatement">case</span> <span class="synPreProc">let</span> .inProgress(loadTask)<span class="synSpecial">:</span> <span class="synComment">// キャンセルできるために`loadTask`をとっておきます。</span> <span class="synIdentifier">self</span>.loadTask <span class="synIdentifier">=</span> loadTask Task { <span class="synType">@MainActor</span> <span class="synSpecial">[</span><span class="synType">weak self</span><span class="synSpecial">]</span> <span class="synStatement">in</span> <span class="synStatement">do</span> { <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await loadTask.value <span class="synComment">// 無事に画像を取得できたので表示できます。</span> } <span class="synStatement">catch</span> <span class="synStatement">is</span> <span class="synType">CancellationError</span> { <span class="synComment">// 待ち合わせていた`loadTask`がキャンセルされたので何もやるべきではありません。</span> } <span class="synStatement">catch</span> { <span class="synComment">// 取得が失敗したので、placeholderを表示することが多いです。</span> } } } </pre> <h2 id="移行">移行</h2> <p>iOSクックパッドアプリで以前使っていたライブラリから自作<code>ImageLoader</code>への移行が割とスムーズでした。<a href="https://techlife.cookpad.com/entry/2021/06/16/110000">モジュール化</a>によって画像読み込みが抽象化されていた場面多かったですし、ほとんどの画像が限られた数のビューに表示されています。</p> <p>画像取得がまだ抽象化されていなかったら、まず抽象化するか、新しい実装の上に以前のに近いAPIを用意するか、の2択ですかね。前者は抽象化が終わったら移行するけど、後者は新しいコードに移行してから以前のAPIの利用を少しずつ減らします。</p> <h2 id="最後に">最後に</h2> <p>iOSの既存のツールを使えば、シンプルな画像取得は割とシュッと実装できました。だからといって画像取得を自作した方が良いわけでもありません。アプリによって状況が違います。自作したら自分でメンテナンスする必要がありますし、必要になった機能も自分で実装します。</p> <p>自作するかどうか関係なく、画像取得が抽象化されていると、別のライブラリに移行しやすいので、いま直接ライブラリを使っていていて変える可能性があれば、とりあえず抽象化しても良いかもしれません。</p> <p>この記事のコードを元に自作するのでしたら、機能の追加が必要かもしれません。ここで紹介されていませんが、iOSクックパッドアプリでは、画像をprefetchする仕組みや、メモリ上キャッシュになかった画像を表示にフェードインで表示させる<code>FadeInImageView</code>(UIKit版)と<code>FadeInImage</code>(SwiftUI版)があります。</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">// This project is licensed under the MIT No Attribution license.</span> <span class="synComment">// </span> <span class="synComment">// Copyright (c) 2023 Cookpad Inc.</span> <span class="synComment">// </span> <span class="synComment">// Permission is hereby granted, free of charge, to any person obtaining a copy</span> <span class="synComment">// of this software and associated documentation files (the &quot;Software&quot;), to deal</span> <span class="synComment">// in the Software without restriction, including without limitation the rights</span> <span class="synComment">// to use, copy, modify, merge, publish, distribute, sublicense, and/or sell</span> <span class="synComment">// copies of the Software, and to permit persons to whom the Software is</span> <span class="synComment">// furnished to do so.</span> <span class="synComment">//</span> <span class="synComment">// THE SOFTWARE IS PROVIDED &quot;AS IS&quot;, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR</span> <span class="synComment">// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,</span> <span class="synComment">// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE</span> <span class="synComment">// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER</span> <span class="synComment">// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,</span> <span class="synComment">// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE</span> <span class="synComment">// SOFTWARE.</span> <span class="synPreProc">import</span> Foundation <span class="synPreProc">import</span> UIKit <span class="synStatement">public</span> <span class="synPreProc">enum</span> <span class="synIdentifier">LoadingImage</span> { <span class="synStatement">case</span> cached(UIImage) <span class="synStatement">case</span> inProgress(Task<span class="synIdentifier">&lt;</span>UIImage, any Error<span class="synIdentifier">&gt;</span>) } <span class="synStatement">public</span> <span class="synPreProc">struct</span> <span class="synIdentifier">InvalidImageDataError</span><span class="synSpecial">:</span> <span class="synType">Error</span> { <span class="synStatement">public</span> <span class="synPreProc">var</span> <span class="synIdentifier">url</span><span class="synSpecial">:</span> <span class="synType">URL</span> <span class="synStatement">public</span> <span class="synIdentifier">init</span>(url<span class="synSpecial">:</span> <span class="synType">URL</span>) { <span class="synIdentifier">self</span>.url <span class="synIdentifier">=</span> url } } <span class="synStatement">public</span> <span class="synStatement">final</span> <span class="synPreProc">class</span> <span class="synIdentifier">ImageLoader</span> { <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">session</span><span class="synSpecial">:</span> <span class="synType">URLSession</span> <span class="synStatement">private</span> <span class="synPreProc">let</span> <span class="synIdentifier">memoryCache</span> <span class="synIdentifier">=</span> NSCache<span class="synIdentifier">&lt;</span>NSURL, UIImage<span class="synIdentifier">&gt;</span>() <span class="synStatement">public</span> <span class="synIdentifier">init</span>() { <span class="synPreProc">let</span> <span class="synIdentifier">configuration</span> <span class="synIdentifier">=</span> URLSessionConfiguration.<span class="synStatement">default</span> <span class="synPreProc">let</span> <span class="synIdentifier">cacheDirectoryURL</span><span class="synSpecial">:</span> <span class="synType">URL?</span> <span class="synStatement">do</span> { <span class="synPreProc">let</span> <span class="synIdentifier">systemCacheURL</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> FileManager.<span class="synStatement">default</span>.url(<span class="synStatement">for</span><span class="synSpecial">:</span> .cachesDirectory, <span class="synStatement">in</span><span class="synSpecial">:</span> .userDomainMask, appropriateFor<span class="synSpecial">:</span> <span class="synType">nil</span>, create<span class="synSpecial">:</span> <span class="synType">true</span>) cacheDirectoryURL <span class="synIdentifier">=</span> systemCacheURL.appendingPathComponent(<span class="synConstant">&quot;CookpadImageLoader&quot;</span>, isDirectory<span class="synSpecial">:</span> <span class="synType">true</span>) } <span class="synStatement">catch</span> { assertionFailure(<span class="synConstant">&quot;Could not create cache path: </span><span class="synSpecial">\(</span>error<span class="synSpecial">)</span><span class="synConstant">&quot;</span>) cacheDirectoryURL <span class="synIdentifier">=</span> <span class="synConstant">nil</span> } <span class="synComment">// デフォルトでは`URLCache.shared`が使われますが、もう少しディスク容量を使える画像専用のを使います。</span> configuration.urlCache <span class="synIdentifier">=</span> URLCache( <span class="synComment">// `memoryCapacity`は試した限り0でも問題なく動きそうですが、一応念の為少しのメモリを割り当てます。</span> memoryCapacity<span class="synSpecial">:</span> <span class="synType">URLCache.shared.memoryCapacity</span>, diskCapacity<span class="synSpecial">:</span> <span class="synType">URLCache.shared.diskCapacity</span> <span class="synIdentifier">*</span> <span class="synConstant">4</span>, directory<span class="synSpecial">:</span> <span class="synType">cacheDirectoryURL</span> ) session <span class="synIdentifier">=</span> .<span class="synIdentifier">init</span>(configuration<span class="synSpecial">:</span> <span class="synType">configuration</span>) } <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">cachedImage</span>(<span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">UIImage?</span> { memoryCache.object(forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">decode</span>(_ data<span class="synSpecial">:</span> <span class="synType">Data</span>, <span class="synStatement">for</span> imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">UIImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> UIImage(data<span class="synSpecial">:</span> <span class="synType">data</span>) { memoryCache.setObject(image, forKey<span class="synSpecial">:</span> <span class="synType">imageURL</span> <span class="synStatement">as</span> <span class="synType">NSURL</span>) <span class="synStatement">return</span> image } <span class="synStatement">else</span> { <span class="synStatement">throw</span> InvalidImageDataError(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) } } <span class="synStatement">private</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadData</span>(<span class="synStatement">for</span> request<span class="synSpecial">:</span> <span class="synType">URLRequest</span>) async <span class="synStatement">throws</span> <span class="synSpecial">-&gt;</span> <span class="synType">Data</span> { <span class="synStatement">do</span> { <span class="synStatement">return</span> <span class="synStatement">try</span> await session.data(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>).<span class="synConstant">0</span> } <span class="synStatement">catch</span> { <span class="synComment">// タスクがキャンセルされた時、`session.data(for:)`がCocoaのエラーを発生されるのですが、</span> <span class="synComment">// Swiftでは`CancellationError`がもっと自然だと思います。</span> <span class="synComment">// タスクがキャンセルされている場合`CancellationError`を発生させる`try Task.checkCancellation()`がちょうど良いです。</span> <span class="synStatement">try</span> Task.checkCancellation() <span class="synStatement">throw</span> error } } <span class="synStatement">public</span> <span class="synPreProc">func</span> <span class="synIdentifier">loadImage</span>(from imageURL<span class="synSpecial">:</span> <span class="synType">URL</span>) <span class="synSpecial">-&gt;</span> <span class="synType">LoadingImage</span> { <span class="synStatement">if</span> <span class="synPreProc">let</span> <span class="synIdentifier">image</span> <span class="synIdentifier">=</span> cachedImage(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) { <span class="synStatement">return</span> .cached(image) } <span class="synStatement">return</span> .inProgress(Task.detached { <span class="synPreProc">let</span> <span class="synIdentifier">request</span> <span class="synIdentifier">=</span> URLRequest(url<span class="synSpecial">:</span> <span class="synType">imageURL</span>) <span class="synComment">// コードの分かりやすさのために画像が`URLCache`に入っているのかどうか区別していませんが、</span> <span class="synComment">// 必要であれば`urlCache.cachedResponse(for: request)`で入っているかどうか確認できます。</span> <span class="synPreProc">let</span> <span class="synIdentifier">data</span> <span class="synIdentifier">=</span> <span class="synStatement">try</span> await <span class="synIdentifier">self</span>.loadData(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">request</span>) <span class="synStatement">return</span> <span class="synStatement">try</span> <span class="synIdentifier">self</span>.decode(data, <span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">imageURL</span>) }) } } </pre> vincentisambart Project Googrename: Google Workspace で 14 年運用されたドメインエイリアスをプライマリドメインに変更 & 全ユーザーを安全にリネームする hatenablog://entry/820878482945343579 2023-06-28T17:04:51+09:00 2023-06-28T17:04:51+09:00 id:sora_h がクックパッドの Google Workspace でドメインエイリアスとして運用されていたものをプライマリドメインへ変更、全ユーザーのドメインも合わせて大規模なリネームを安全に実施した道のりを解説します。 <p><figure class="figure-image figure-image-fotolife" title="Google Workspace の Primary Domain Changed 画面のスクリーンショット"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160304.png" width="1200" height="501" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>コーポレートエンジニアリング部の <a href="http://blog.hatena.ne.jp/sora_h/">id:sora_h</a> です <a href="#f-55f9a260" name="fn-55f9a260" title="技術部 SRE グループが主務です。一応…">*1</a>。今回は 3 ヵ月ほど前に実施した、Google Workspace テナントのプライマリドメイン変更について、記録を兼ねて説明します。</p> <p>クックパッドは 2009 å¹´é ƒ <a href="#f-d60ff08e" name="fn-d60ff08e" title="テナント自体は 2007 年から、業務に本格的に使われたのは 2009 年から、らしいですが詳細は不明です">*2</a> より Google Workspace <a href="#f-9eed22d0" name="fn-9eed22d0" title="当時は Google Apps">*3</a> を利用しています。当社の対外的なメールアドレスは cookpad.com ですが、Google ではプライマリドメインとして cookpad.jp が設定されています。各ユーザーには cookpad.com のアドレスを別名 (エイリアス) として登録されていて、メールアドレスとしては cookpad.com を利用、ただ Google へログインする時だけ cookpad.jp を利用する運用になっていました。想像が出来ると思いますが、これが様々な面で不便・混乱を発生させていました。どうしてこうなった… <a href="#f-6766843a" name="fn-6766843a" title="これも導入時期と同様に経緯は完全に損われていて不明、利用していないドメインで「一旦」設定してみて、そのまま本番利用されちゃったパターンと想像しています">*4</a>。</p> <p>この負債を解決すべく、2022/8 頃から緩やかに準備を始め、2023/3 上旬に全ユーザーのドメインとプライマリドメインを cookpad.com に変更しました。本稿では変更に踏み切った理由から下準備、当日~事後の作業について解説します。そこそこ長く様々なタスクがあったため乱雑な記事となっていますが、何かの役に立てば幸いです。</p> <ul class="table-of-contents"> <li><a href="#変更のモチベーション">変更のモチベーション</a></li> <li><a href="#Google-Workspace-におけるドメインとは">Google Workspace におけるドメインとは</a><ul> <li><a href="#エイリアス">エイリアス</a></li> <li><a href="#Cookpad-における-Google-Workspace-ドメイン設定">Cookpad における Google Workspace ドメイン設定</a></li> <li><a href="#プライマリドメインまで変更するかどうか">プライマリドメインまで変更するかどうか</a></li> </ul> </li> <li><a href="#目標-影響を最小に抑える">目標: 影響を最小に抑える</a></li> <li><a href="#プロジェクトの流れ">プロジェクトの流れ</a></li> <li><a href="#事前準備-各種サービス-SaaS-の影響確認">事前準備: 各種サービス (SaaS) の影響確認</a></li> <li><a href="#事前準備-内製システムの準備">事前準備: 内製システムの準備</a></li> <li><a href="#事前準備-メール受信ダウンタイムの影響を最小化する">事前準備: メール受信ダウンタイムの影響を最小化する</a></li> <li><a href="#事前準備-作業用データの準備">事前準備: 作業用データの準備</a></li> <li><a href="#作業当日-プライマリドメイン変更作業">作業当日: プライマリドメイン変更作業</a><ul> <li><a href="#直前作業-MXレコードの切り替え">直前作業: MXレコードの切り替え</a></li> <li><a href="#ドメインエイリアスとしての-cookpadcom-を削除">ドメインエイリアスとしての cookpad.com を削除</a></li> <li><a href="#セカンダリドメインとして-cookpadcom-を再追加">セカンダリドメインとして cookpad.com を再追加</a></li> <li><a href="#全ユーザーグループのドメインを-cookpadcom-に変更">全ユーザー・グループのドメインを cookpad.com に変更</a></li> <li><a href="#MXレコードの復旧">MXレコードの復旧</a></li> <li><a href="#プライマリドメインの変更">プライマリドメインの変更</a></li> <li><a href="#障害-メールエイリアスの考慮不足">障害: メールエイリアスの考慮不足</a></li> </ul> </li> <li><a href="#事後作業">事後作業</a><ul> <li><a href="#各種SaaSへの反映確認作業">各種SaaSへの反映・確認作業</a></li> <li><a href="#Asana">Asana</a></li> <li><a href="#Figma">Figma</a></li> <li><a href="#Zoom">Zoom</a></li> <li><a href="#Slack">Slack</a></li> <li><a href="#メールダウンタイムの事後報告">メールダウンタイムの事後報告</a></li> <li><a href="#DKIM-の再設定">DKIM の再設定</a></li> </ul> </li> <li><a href="#社内アナウンスについて">社内アナウンスについて</a></li> <li><a href="#振り返り">振り返り</a></li> <li><a href="#Acknowledgements">Acknowledgements</a></li> </ul> <h3 id="変更のモチベーション">変更のモチベーション</h3> <p><figure class="figure-image figure-image-fotolife" title="図: 作業前に撮影した筆者の Google アカウントの様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160106.png" width="404" height="245" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図: 作業前に撮影した筆者の Google アカウントの様子</figcaption></figure></p> <p>これは多岐にわたるため、代表的なものを箇条書きで説明します:</p> <ul> <li>ユーザーから見てメールアドレスやアカウント名が cookpad.com と cookpad.jp の 2 種類を持つことになり混乱する <ul> <li>対外的なメールアドレスは cookpad.com であるべきだが cookpad.jp を利用してしまう</li> <li>逆に Google アカウントは cookpad.jp なのに cookpad.com を入力してエラーになってしまう</li> </ul> </li> <li>クックパッドは Azure AD を併用していて <a href="#f-f7cd5bb7" name="fn-f7cd5bb7" title="Intune の存在、SAML機能の柔軟性など諸々で併用になっていますが、本プロジェクトでやっとアドレスが統一され混乱を防げるようになったので Google Workspace は Azure AD からのフェデレーションに切り替えたい…!">*5</a>、そちらは cookpad.com であるため、同様に混乱する <ul> <li>Google ログインから Azure AD の SAML に切り替えた場合 SAML 上は cookpad.jp を名乗らせる必要がある (もしくは後述のように頑張って変更作業を行う)。</li> </ul> </li> <li>Slack が当初 Google ログインによって cookpad.jp の利用であったため <a href="#f-0a97cccc" name="fn-0a97cccc" title="Slack がまさに Enterprise Grid で SAML に変更になった時、cookpad.jp ã‚’ SAML で Azure AD に名乗らせていた例">*6</a>、Slack Connect の招待を cookpad.com のアドレスへ送信されると Slack アプリ上でそれを受諾することができない、などなど各種アプリでの不都合</li> <li>cookpad.com (メールアドレス, Azure AD) と cookpad.jp (Google) の意味に違いがあるため、社内システム各所の実装で読み替えなどの考慮が必要 <ul> <li>各位の認識とその実装がまちまちで不具合が…</li> </ul> </li> <li>Google Workspace のプライマリドメインを変更できなくなる条件を満たしてしまうサービスを利用できない (Chrome OS の MDM など)</li> </ul> <p>ただし、本稿が非常に長いことから分かるように、丁寧な調査と作業を行わなければ様々な問題が発生してしまうため、腰がたいへん重いものでした。業務で Chrome OS を一部で利用していることから Chrome OS の MDM を検討するのが再検討のキッカケとして大きな要因でした。</p> <h3 id="Google-Workspace-におけるドメインとは">Google Workspace におけるドメインとは</h3> <p>前提として、Google Workspace におけるドメインについて軽く解説します。</p> <p>Google Workspace 配下のユーザーはテナントに登録されているドメインを利用します。各 Google Workspace テナントは複数のドメインが登録でき、それを配下のユーザーに設定します。</p> <p>登録されているドメインの 1 つをプライマリドメインに指定し、デフォルトかつテナントを示すドメインとして各所で利用されます。その上で、プライマリドメイン以外にセカンダリドメインも設定してユーザーごとに使い分けることが可能になっています。</p> <h4 id="エイリアス">エイリアス</h4> <p>Google Workspace ユーザー/グループにはエイリアスを設定できます。エイリアスとして(異なるユーザー名・ドメインを持つ)別のメールアドレスを追加し、そのメールアドレスでもメールを送受信できるようになります <a href="#f-2c3d8465" name="fn-2c3d8465" title="人の名前は姓名いずれも変わったり、ビジネスネームが異なったり、衝突したりするため、ユーザー名に命名規則を設けるのは非常におすすめしません。ひどい例では、イレギュラーがあるのにユーザー名の命名規則を鵜呑みにしてメールアドレスの推測を行うような実装も発生してしまいます。当社では記号の制約はありますが、任意のユーザー名を入社時にリクエストすることが出来るようになっています">*7</a>。</p> <p>そして、ユーザー・グループ単位に設定するエイリアスとは別にドメインエイリアスも利用できます。ドメインエイリアスはその名の通りドメイン全体に対して他のドメインを別名として登録するもので、ユーザー・グループにエイリアスとして登録せずとも、エイリアスが存在するドメインでアドレスが存在していれば、そのドメインエイリアスでもメールの送受信が可能になります。</p> <p>ドメインエイリアスはプライマリドメインを含むテナントのドメインに対して追加するものです。そのためドメインエイリアスはユーザーの(プライマリの)メールアドレスとして直接割り当てることが不可能になっています。</p> <h4 id="Cookpad-における-Google-Workspace-ドメイン設定">Cookpad における Google Workspace ドメイン設定</h4> <p>本プロジェクト以前の Google Workspace のドメインは、ざっくり以下のようになっていました。</p> <ul> <li>cookpad.jp (プライマリドメイン) <ul> <li>cookpad.com (ドメインエイリアス)</li> </ul> </li> <li>{その他個別のエイリアスで利用する前提の細々としたドメイン} <a href="#f-3576f14d" name="fn-3576f14d" title="昔は hostmaster@ でメールを受けて SSL 証明書発行の承認とかをしていたので、そういうやつのために色々追加されていました">*8</a></li> </ul> <p>冒頭で書いたように、cookpad.jp がプライマリドメイン・全ユーザー/グループのドメインに設定されています。cookpad.com をメールアドレスとして利用するため、cookpad.com は cookpad.jp に対するドメインエイリアスになっていました。</p> <p>本プロジェクトでは cookpad.com をプライマリドメインに変更することが最終目的です。察しの良い方は気付くと思いますが、変更先のドメインがドメインエイリアスとして既に設定されていることで、まあまあ苦労します。</p> <h4 id="プライマリドメインまで変更するかどうか">プライマリドメインまで変更するかどうか</h4> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fsupport.google.com%2Fa%2Fanswer%2F7009324%3Fhl%3Den" title="Change your primary domain for Google Workspace - Google Workspace Admin Help" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>ユーザーが利用する = ユーザーの Google アカウントとなるドメインを変更するだけであればプライマリドメインの変更は必須ではありません。ただ、Google Cloud Platform の組織名など各所で混乱を残してしまうこと、変更しない理由もないこと、Google の OAuth2/OIDC の hd オプションの挙動がイマイチになる <a href="#f-5ad8fcdd" name="fn-5ad8fcdd" title="後述しますが、Google Workspace ã‚„ Cloud Identity のドメインを元にアカウント選択画面の表示をフィルタしたりスキップ、また新規ログイン時にフォームにドメインをあらかじめ表示する機能。組織のプライマリドメイン != ユーザーのドメイン時の挙動が不定">*9</a>。ことから今回はユーザーのドメインに加え、プライマリドメインまで変更を行いました。</p> <p>プライマリドメインについては上記ヘルプに記載されているように「変更できない」条件が存在します。そのうちの大きな物が Chrome Enterprise ã‚„ Chrome OS 端末をエンロールしているという点で、これはモチベーションの 1 つとなっていたため、後戻り出来なくなる前に済ませたかったため踏み切りました。</p> <h3 id="目標-影響を最小に抑える">目標: 影響を最小に抑える</h3> <p>ユーザー・グループのドメインを変更するだけなら Google Workspace のドキュメント通りに作業するだけなので簡単ですが、それだけで済むはずがありません。本プロジェクトには各種サービス断など影響を最小限に抑えることが大きな目標としてありました。これは下記が含まれます:</p> <ul> <li><strong>メール:</strong> 受信が出来ない時間を短く抑える。出来ない場合でもその影響を最小にする</li> <li><strong>ログイン連携の継続:</strong> Google アカウントでログインしている各種サービスへのアクセスを損わないようにする。ログインしたら新規ユーザーになってしまう、といったトラブルを回避する</li> <li><strong>混乱を避ける:</strong> 各種アナウンスをきっちり行いユーザーの混乱を避ける。特に強制ログアウトが発生した場合に備える</li> </ul> <p>特に Google の OAuth2/OIDC を利用してログインを行っている各種 SaaS への影響が心配でした。真っ当に実装していれば sub クレームを利用してユーザー検索を行う実装になるはず <a href="#f-0dca8a1f" name="fn-0dca8a1f" title="Google ã‚‚ sub claim を利用しろ、email は適さないゾと ドキュメントで明記しています。OIDC 仕様上でも sub クレーム以外での照合は non-conformant です https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability 。">*10</a> です。</p> <p>ただ、OIDC ã‚„ OAuth2 + userinfo API で得た email を元にユーザーを検索している不届きなサービスは確実に存在します (しました)。万が一業務でそのようなサービスが利用されていた場合 (いました)、業務に影響が生じてしまうため、それらについては事前に対策を行う必要があります。</p> <h3 id="プロジェクトの流れ">プロジェクトの流れ</h3> <p>本プロジェクトは下記の手順で進行しました。冒頭に記載した通り 2022/8 ~ 2023/2 頃まで緩やかに準備、2023/3 上旬に一気に片付けるようなスケジュールです。</p> <ol> <li>事前準備: 各種サービス (SaaS)、社内(内製)システムへの影響を確認、必要な作業についてアナウンスを準備</li> <li>事前準備: 検証環境 (Google Workspace テナントなど) を用意して各種挙動のチェック、ユーザー・グループ情報変更に利用するスクリプトの準備</li> <li>各自作業: 事前にアナウンスした各種サービスで必要な対応を各自で実施</li> <li>直前作業: 各種サービスで必要な対応を実施</li> <li>本番作業: 本番 Google Workspace テナントの変更作業</li> <li>事後作業: 各種サービスで必要な対応を実施</li> <li>事前準備: 各種サービス (SaaS) の影響確認</li> </ol> <h3 id="事前準備-各種サービス-SaaS-の影響確認">事前準備: 各種サービス (SaaS) の影響確認</h3> <p>というわけで、本プロジェクトでまず最初に行ったのは各種サービスでの影響・必要なアクションを確認することです。プロジェクトの大半の期間は地道に順次この点検を行っていた気がします。</p> <p>Google Admin から利用履歴のある OAuth2 Client のリストを取得できるため、取得した上で業務で利用している・されていそうな <a href="#f-d41066f8" name="fn-d41066f8" title="シャドーITっぽいというよりかは、子会社で利用していて本体で関知していないサービス等がそこそこ">*11</a> サービスのリストを作成しました。およそ 30 サービスがリストアップされ、順次サポートに聞く、検証環境でちょっとアカウントリネームしてみて動作を試してみる…というのを地道に行います。</p> <p>すると、やはり下記のようにさまざまなパターンで対応を求められることが分かってきました。</p> <ul> <li>何も対応する必要はない (ログインしなおせばメールアドレスの変更が自動で反映される)</li> <li>事前に各自でメールアドレスを変更しておく必要がある</li> <li>事前に管理者が手動でメールアドレスを 1 ユーザーずつ変更して回る必要がある</li> <li>事前に管理者が新しいドメインを allowlist に追加したり所有権の検証を済ませておく必要がある</li> <li>管理者からドメイン変更を伝えたらまとめてサポートに変更してもらえる</li> <li>管理者で新旧が分かる csv をサポートに提出したら変更してもらえる</li> <li>管理者でユーザーを一度削除して再招待しなければいけない</li> <li>ログイン自体はリネーム後でも継続して行えるが、メールアドレス変更は手動で各自が行う必要がある</li> </ul> <p>はい、真面目に OIDC 仕様通りにログインを実装してないところがあまりにも多すぎて本当に困ります。OIDC プロバイダから得られるメールアドレスを信用してそれでしかマッチングをしないのはあまりにも雑実装すぎます <a href="#f-5fe8f86d" name="fn-5fe8f86d" title="そういえば最近 https://www.descope.com/blog/post/noauth とかありましたね。Google の場合は email_verified claim も見ていればいいけど、見ていないところも実はあったりしたんだろうか?">*12</a> 。</p> <p>今回のプライマリドメイン変更では新規にドメインを取得してそれを変更するわけではなく、10 年ほど平行して両ドメインともメールアドレスとして使ってきた <a href="#f-1bd2ca63" name="fn-1bd2ca63" title="変更元の cookpad.jp は意図せず使われてきてしまった、と言うのが正しいんですが">*13</a> 関係で、人によっては cookpad.jp → cookpad.com に管理者やサポートでリネームしようとしたらコンフリクトしてしまった! という事例がかなり発生しやすい状況です <a href="#f-f9abd0ca" name="fn-f9abd0ca" title="たとえば Google ログインを強制できず、パスワードでサインアップやアカウント招待を受諾してしまうとそうなってしまう">*14</a>。これについても問合せを行い、下記のようなパターンが分かってきました。</p> <ul> <li>本人にどちらかのアカウントを削除してもらう必要がある <a href="#f-2b9c1d76" name="fn-2b9c1d76" title="Google ログインできない場合でもパスワードリセットの手順でなんとかなったりする事が多かった">*15</a></li> <li>サポートがユーザーマージを行ってくれる <a href="#f-03a6a6da" name="fn-03a6a6da" title="ドメイン所有権の検証があるサービスだとお願いできたり、一度テナントに .jp, .com 両方のユーザーを入れてからだったり">*16</a></li> </ul> <p>そもそも企業が利用することを前提としているサービスでもドメインのリネームやユーザー名のリネームがある旨をサポートに伝えてもなかなか理解してもらえないことが多くて結構困ってしまうところです。ドメインだけじゃなくてユーザー名もわりと変わることがあるので…。SAML ならともかく OAuth2/OIDC を利用してるなら sub claim を適切に利用してほしい (重要な事なので何度でも書きます)。</p> <p>まず、事前に各自で作業が必要なサービスについては事前にリストアップ・作業内容を Wiki に記載して全社アナウンスで各自の点検を依頼します。各自で作業が必要ないサービスでも、変更作業中は新規ログインができない可能性があり、それについては全て手元の端末でログインができているか確認するように記述しました。</p> <p>当日に複雑な対応をしなければならなかったいくつかのサービスについては、後述する変更作業の節で解説します。</p> <p>なお、Google Workspace コアサービス以外の Google のサービスについては GCP を含めて現在まで大きな問題は報告されていません。こちらの事前調査もサポートを通して行いたかったところ、コアサービスでないことから Google Workspace のサポートからは返答を貰えず各サービスのサポートへ誘導され、各サービスのサポートからは Google Workspace のサポートへ誘導されました。そのため、一部の非コアサービスで重要な Google Analytics ã‚„ Google Cloud Platform については検証環境で権限などが維持されることを確認して済ませました。</p> <p>※ 大きな問題はありませんでしたが、筆者のアカウントでは Google カレンダーが一切読み込まれない (Web だと白紙になる) という事象が発生しました。サポート曰く 24 時間は <em>propagation</em> にかかるらしかったので、おとなしく待ったら自然に解消してゆきました。</p> <h3 id="事前準備-内製システムの準備">事前準備: 内製システムの準備</h3> <p>社内で内製しているシステムはほとんど <a href="#f-a40f5a87" name="fn-a40f5a87" title="日本側の場合。UK オフィスが中心となっているグローバル事業は ALB + Azure AD OIDC だったりします">*17</a> が <a href="https://techlife.cookpad.com/entry/2018/04/02/140846">Hako で ECS にデプロイ</a> されていて、またユーザー認証はだいたい omniauth-google_oauth2 gem を利用しています。プライマリドメイン変更時の追従をスムーズに行うため、大半のパターンである Hako + Rails + omniauth-google_oauth2 について対応を検討しました。</p> <p>まず、omniauth-google_oauth2 gem では hd オプションに Google Workspace のドメインを指定することで omniauth strategy レベルでログイン可能なドメインを制限できます (hosted domain, hd claim の検証)。omniauth-google_oauth2 の hd オプション自体は複数のドメインを指定可能ですが、hd オプションは Google の OAuth2 認可エンドポイントへも引き継がれていて、こちらは複数の指定を受け付けません。hd オプションを利用するとアカウント選択画面で表示するアカウントを絞ったり、1つに絞れたら省略したり、また新規ログインの際もドメイン名が補完されるためユーザー体験上有益です。この利点を失いたくはありませんでした <a href="#f-9ce5d767" name="fn-9ce5d767" title="もちろん、一時的に hd に両方指定してもらって後で戻すというのもアリですが、後で戻してもらうことは必須ではないため、戻してもらえなかったところだけ不便なままという状況が予想できます">*18</a>。また、hd オプション以外でもドメインを検証している箇所もちらほら確認できました。</p> <p>プライマリドメインの変更作業は週末、日本時間の土曜朝に行いました。何も手を打たないのであれば各自で週明けに cookpad.jp ã‚’ cookpad.com に書き換えてデプロイしたり、DB 上のレコードを書き換えてもらうというところですが、社内システムに週明けに各自で変更をデプロイするまで、新規ログインできずに週末中の業務が停止する事態は避けたいものです。</p> <p>検討した結果、AWS Systems Manager Parameter Store に現在の Google Workspace プライマリドメインを示す値を保存しました (作業前は cookpad.jp)。これを環境変数として ECS タスクに事前に入れてデプロイ、その環境変数が存在すれば hd オプションを含めプライマリドメインを示す値として利用するようにエンジニア全員に依頼をかけました。プライマリドメイン変更後、Parameter Store 上の更新して各 ECS サービスのタスクを再起動すればプライマリドメイン変更の反映が完了するという仕掛けです。</p> <p>これに加えて、DB などに保存されている Google アカウント名があれば cookpad.jp → cookpad.com にしてもらう必要があります <a href="#f-99f459b1" name="fn-99f459b1" title="もちろん認証は sub claim を利用して認証が行われるのが望ましいですが、各種フォームなどメールアドレスから引くことも当然あるため">*19</a>。アナウンスから作業直後の移行期間中はどちらが来てもいいように cookpad.jp ã‚’ .com に読み替えてくれ、といった基本的な事も含めて確認をお願いしました。</p> <p><figure class="figure-image figure-image-fotolife" title="エンジニア組織全体へのアナウンス文面 (GitHub Issue)"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160028.png" width="1103" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>エンジニア組織全体へのアナウンス文面 (GitHub Issue)</figcaption></figure></p> <p>プライマリドメイン変更作業後は用意した Parameter Store の値を更新し、ECS タスクを再起動することで反映させます。当社はほとんど Fargate の利用がなく EC2 をコンテナインスタンスとして利用しているので、社内向けサービス用の ECS クラスタに対応する Auto scaling group の instance refresh を実行しました。</p> <p>多数の ECS サービスに force new update を行うとクラスタ全体が乱れて必要以上のコンテナインスタンス数にになる、集積率が一時的に下がる、コンテナインスタンスの起動の待ちが逐次発生して時間がかかる、といったデメリットがあるため、instance refresh でコンテナインスタンスを全て入れ替えてタスクの再起動に替えるのがおすすめです。</p> <h3 id="事前準備-メール受信ダウンタイムの影響を最小化する">事前準備: メール受信ダウンタイムの影響を最小化する</h3> <p>次はメール受信についてです。前述したように、変更前の Google Workspace テナントは下記のようなドメイン構成になっていました:</p> <ul> <li>cookpad.jp (プライマリドメイン) <ul> <li>cookpad.com (ドメインエイリアス)</li> </ul> </li> </ul> <p>ドメインエイリアスを利用しているので、ユーザーを cookpad.jp ドメインで作成するともれなく cookpad.com のメールアドレスがエイリアスとして付いてくる状態です。</p> <p>そして変更作業後は、下記を満たしている必要があります。</p> <ul> <li>既存ユーザー・グループのドメインが cookpad.com に切り替わっている</li> <li>既存ユーザー・グループは引き続き cookpad.jp ドメインでもメールを受け取ることができる</li> </ul> <p>これを達成するためには、2パターンの案がありました:</p> <ul> <li>A案: 下記構成に変更する <ul> <li>cookpad.com (プライマリドメイン) <ul> <li>cookpad.jp (ドメインエイリアス)</li> </ul> </li> </ul> </li> <li>B案: 下記構成にした上で、全ユーザー・グループにエイリアスを個別に追加する <ul> <li>cookpad.com (プライマリドメイン)</li> <li>cookpad.jp (セカンダリドメイン)</li> </ul> </li> </ul> <p>長期的には、新規ユーザーは cookpad.jp のドメインでメールを受信する必要がないため、周辺サービス含めて変更が完了したタイミングで cookpad.jp ドメインでのメールアドレス付与を停止したいと考えていました。その場合 A 案だといつまでも引きずり、新規利用を完全に止めることができません。したがって今回は B 案を採用しました。</p> <p>ただし、A 案 B 案どちらにも問題があり、Google Workspace はドメインエイリアスをユーザー・グループに直接割り当て可能な「ドメイン」に変更する操作が存在しません。どちらの場合も、一度ドメインエイリアスを削除、ドメインとして再追加して所有権の検証をやり直さなければいけません。また、削除→追加は即座に行えず、削除完了をしばらく待つ必要があります。</p> <p>また、A 案だと cookpad.com へ変更し終えた後に cookpad.jp を削除、その上でエイリアスとして再追加する手順が必要なため作業時間が伸びてしまうのも問題と考えました。</p> <p>ドメインエイリアスを削除すると当然ながらエイリアスによって発生していたメールアドレスは存在しなくなります。したがって、変更先かつメールアドレスとしてはプライマリである cookpad.com ドメインでメールを一定時間受信できないダウンタイムが発生します。削除完了~ドメイン再追加~エイリアス再追加がどれくらいの時間になるかは予測ができません <a href="#f-ec846153" name="fn-ec846153" title="事前に検証用の Google Workspace 上で同じ数のユーザーを作成してみれば分かるかもしれませんが、アテにならない上に費用が無駄にかかってしまう…">*20</a>。このダウンタイムについてどうするかを考えました。</p> <p>人間同士のメール送受信であればあまり問題にはなりませんが、このダウンタイム中は Google の MTA がメールを受信した際、送信元のサーバにメールボックスが存在しない hard bounce (5.2.1 NoSuchUser) が返ることになります。送信元が supression list を持つようなシステムからのメールをダウンタイム中に受信して bounce した際、その対応として自動で supression list に入って以後のメールが送信されなくなる可能性があります。</p> <p>Supression list に入っていることが自分で確認できないサービスは多く、サポートに確認しなければいけないサービスが多数存在します。それを各位で思い出してもらってサポートに問い合わせなければならない…という状況にするのは避けたいものです。出来ることなら soft bounce にしてリトライを促したいと考えました。</p> <p>検討した結果、作業日前にダウンタイムが発生する cookpad.com の MX レコードの TTL を短くして、ダウンタイムが発生する前に一時的にデプロイした Postfix サーバーへ向けることにしました。以下のような設定で cookpad.com, cookpad.jp 宛のメールについては 4.3.2 の soft bounce になり、送信者にリトライを促せます。</p> <pre class="code" data-lang="" data-unlink># main.cf maillog_file = /dev/stdout debug_peer_level = 1 compatibility_level = 2 myhostname = inboundmx.googrename.cookpad.com mydomain = cookpad.com myorigin = googrename.cookpad.com mydestination = $myhostname, localhost.$mydomain, localhost, $mydomain, cookpad.jp local_recipient_maps = # 4xx unknown_local_recipient_reject_code = 450 smtpd_recipient_restrictions = reject_unauth_destination, defer inet_interfaces = all smtp_tls_security_level = may smtpd_tls_security_level = may smtpd_tls_auth_only = yes smtpd_tls_key_file = /etc/postfix/cert/key.pem smtpd_tls_cert_file = /etc/postfix/cert/cert.pem smtpd_tls_CAfile = /etc/postfix/cert/chain.pem smtpd_tls_loglevel = 1 smtpd_tls_received_header = yes # tls cipher list ã‚„ mandatory protocols は省略; Mozilla SSL Configuration Generator などを利用するのがオススメ https://ssl-config.mozilla.org/#server=postfix&amp;version=3.4.8&amp;config=intermediate&amp;openssl=1.1.1k&amp;guideline=5.7</pre> <p>これを利用すればメールを受信したことにせずデータを保存することもないので、完全ステートレスで運用可能です。検証環境で動作確認をしたところ、問題なくメール受信をリトライさせて遅延して受信できました。</p> <p>これは当社の標準的なプラットフォームである <a href="https://techlife.cookpad.com/entry/2018/04/02/140846">Amazon ECS へ Hako を利用</a> して作業数日前にデプロイしました。StartTLS がある都合、NLB の TLS 終端機能は利用できないため ECS タスクに TLS 証明書を渡す必要があります。TLS 証明書は当社の場合 ECS 上のシステムでは <a href="https://github.com/sorah/acmesmith">sorah/acmesmith</a> を利用して発行・コンテナ起動時に S3 から取得するのが一般的なため、その仕組みを利用しました。また、この設計で受信するだけであれば外部にメールを送信することはないため、AWS の OP25B 解除なども行っていません。</p> <p>なお、代案としては MX レコードの削除や MX レコード上で工夫することでリトライさせられないか検討しましたが、 <a href="https://datatracker.ietf.org/doc/html/rfc5321#section-5.1">RFC 5321 § 5.1. </a> や実際の挙動をいくつか確認する限りでも確実にリトライさせられるものではないと判断し、Postfix の設定に至っています。</p> <p>これでメールに関する準備も終わりました。</p> <h3 id="事前準備-作業用データの準備">事前準備: 作業用データの準備</h3> <p>作業数日前に、テナント内のユーザーリストを元に実作業で利用する各種データを作成しました。このため、実作業一週間前に IT ヘルプデスクや HR チームと連携の元、テナント内の新規ユーザー作成を一時的に保留・新規作成を止めてもらうようにお願いしています。</p> <p>作業で利用するデータは一部 SaaS のサポートへ提出するリネーム対応表、また Google Admin へインポートする CSV などがありました。dry-run を兼ねる、また terraform plan file のように反映内容を明らかにするため、可能であれば事前にデータを自動作成できるようにして確認しながら作業する意図で行っています。</p> <h3 id="作業当日-プライマリドメイン変更作業">作業当日: プライマリドメイン変更作業</h3> <p>日本時間の土曜朝から作業を開始、ざっくり下記の手順で進行しました。以下に解説します。</p> <ol> <li>直前作業: 一部社内サービスをメンテに入れたり、MXレコードを前述の Postfix に入れ替える</li> <li>ドメインエイリアス cookpad.com の削除 (ここでメール受信のダウンタイムが始まる)</li> <li>cookpad.com をドメインとして再追加</li> <li>全ユーザー・グループのドメインを cookpad.com に変更</li> <li>全ユーザー・グループのエイリアスに cookpad.jp を追加</li> <li>メール受信が可能であることを確認して MX レコードの復旧</li> <li>社内サービス向けの Parameter Store 上の値を変更して一斉反映</li> <li>プライマリドメインを cookpad.com へ変更</li> <li>各種 SaaS へ変更を反映</li> </ol> <p>これらの手順は事前に作業手順書を用意してログを記入しながら進行しました。また、作業開始前に Google のユーザーリストなどを一通りダウンロードしてバックアップしておきます。</p> <h4 id="直前作業-MXレコードの切り替え">直前作業: MXレコードの切り替え</h4> <p>まずはメール受信のダウンタイムに備えて MX レコードを前述の Postfix へ変更します。作業週の早いうちに予め TTL を短縮しておき迅速な反映を狙いましたが、しっかり問題なく切り換えが行われました。</p> <p>SMTP の各種確認では swaks が便利でよく利用しています。事前に OP25B が解除された IP アドレスを持つマシンやサーバを用意して対応する SPF レコードも作成しておき、swaks でメールを送信して動作チェックを進めました。</p> <pre class="code" data-lang="" data-unlink>swaks --from &#39;sorah@[REDACTED]&#39; --to &#39;[email protected]&#39; --tls-optional-strict --tls-verify</pre> <h4 id="ドメインエイリアスとしての-cookpadcom-を削除">ドメインエイリアスとしての cookpad.com を削除</h4> <p><figure class="figure-image figure-image-fotolife" title="Google Workspace Admin のドメインエイリアス削除確認画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160004.png" width="1015" height="614" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></figure></p> <p>次は cookpad.com ã‚’ Google Workspace のドメインエイリアスから削除します。最初に書いたように削除しなければ通常のドメインとして再追加ができないため行いますが、ここからメール送受信のダウンタイムが発生します。</p> <p>ドメインエイリアス削除実行後は即座に消えるわけではなくしばらく時間がかかります。(記憶が正しければ) ドメイン一覧からは即座に消失しますが、ユーザー詳細ページを確認するとユーザーから順次エイリアスが削除されていく様子が確認できます。</p> <p>こちらも swaks で状況を確認できます。MX レコードに指定される SMTP サーバへメールを直送すると、NoSuchUser エラーが返ってくることを確認しました。</p> <pre class="code" data-lang="" data-unlink>swaks --from &#39;sorah@[REDACTED]&#39; --to &#39;[email protected]&#39; --server aspmx.l.google.com:25 --tls-optional-strict --tls-verify</pre> <h4 id="セカンダリドメインとして-cookpadcom-を再追加">セカンダリドメインとして cookpad.com を再追加</h4> <p>当社の環境ではおよそ 1 時間をかけてドメインエイリアスの削除が完全に完了しました。辞書順でエイリアスがユーザーから削除されていっているようだったので、辞書順で最後のユーザーからも消えたことを確認してから再追加を実行します。</p> <p>ドメインが一度削除されている都合、ドメイン所有権の再確認が必要です。こちらは DNS レコードを用いて速やかに完了させました。</p> <h4 id="全ユーザーグループのドメインを-cookpadcom-に変更">全ユーザー・グループのドメインを cookpad.com に変更</h4> <p>ドメインをテナントに再追加しただけでは、エイリアスではないため各所で利用され始めることはありません。今回の目的はプライマリドメインに加え全ユーザー・グループが cookpad.com を主たるアドレスとして利用する状況を作ることなので、次はその変更作業を行います。</p> <p>今回の作業では、ユーザーは Google Admin でエキスポートした CSV の New Primary Email 属性を埋めて CSV インポートすることで行いました。旧 Primary Email はエイリアスとして自動で残るため、この対応で問題なく変更を行うことができます。 <strong>ただし既存のエイリアスを除くため、実際には全て API で実施するのが無難です。</strong> この点については後述します。</p> <p>グループは Google Admin API を利用して一括更新を行いました。</p> <p>作業チームのアカウントについては後回しにして手動で実行しました。リネームによって過去には再ログインが必要になったりした記憶がありますが、2023/3 に確認した限りでは Android ã‚„ iOS <a href="#f-f20f6dab" name="fn-f20f6dab" title="純正メール・カレンダーで利用する iOS 自体に登録されているアカウント情報。Google 製アプリでは不要だった">*21</a> 以外で再ログインを求められませんでした。強制ログアウトによって全ての端末でログイン不可能になるなど (人はパスワードを忘れるので) 大きなトラブルは回避できるため、これは嬉しい挙動でした。</p> <h4 id="MXレコードの復旧">MXレコードの復旧</h4> <p>cookpad.com ドメインで再度メールが受信できるようになったことを確認して、MXレコードを元に戻します。不測の事態に備え、このタイミングでは TTL は短いまま維持しておきます。</p> <p>メールの受信はレコードを変更せずともドメインエイリアス削除時の実行例と同様に swaks で確認可能です。</p> <h4 id="プライマリドメインの変更">プライマリドメインの変更</h4> <p>ここまで問題なく来たら後はテナントのプライマリドメインを変更するだけです。</p> <p>ただし、プライマリドメイン変更不可の条件に引っ掛かりすぐに変更することはできませんでした。 <a href="https://support.google.com/a/answer/7009324?hl=en">https://support.google.com/a/answer/7009324?hl=en</a> に記載されているように様々な条件があるのですが、変更時のエラーでは何がダメかは教えてもらえません。</p> <p><figure class="figure-image figure-image-fotolife" title="プライマリドメイン変更時のエラー画面"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160011.png" width="1040" height="695" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>プライマリドメイン変更時のエラー画面</figcaption></figure></p> <p>プライマリドメイン変更を行うか、行わないかはユーザーに(ほぼ)影響する話ではないため、ユーザー・グループの primary email address のドメインが変更されていれば問題はありません。ただ先述の通り完遂はさせたい、というところで対応を検討しました。</p> <p>予定されていた作業時間中の反映は諦めましたが、幸いにしてチャットサポートに問い合わせたところ迅速に回答してもらえました <a href="#f-5d6bda02" name="fn-5d6bda02" title="チャットサポートはログが残らない上その場で解決まで至らないイメージが強かったのですが、Google Workspace の英語サポートは割とその期待は上回った気がします">*22</a>。事前に目視して問題ないと思っていた Chrome Enterprise の試用ライセンス <a href="#f-1ea41bf8" name="fn-1ea41bf8" title="リセラーからトライアル目的で渡されたものと記憶していますがこれも詳細が不明…">*23</a> が実はアクティブで引っ掛かっていました。プライマリドメイン変更のために hold を依頼して、その日の深夜に改めて変更を完了しました。</p> <h4 id="障害-メールエイリアスの考慮不足">障害: メールエイリアスの考慮不足</h4> <p>作業が一息ついたところで考慮不足が発覚しました。ユーザー・グループのprimary email については cookpad.jp から .com へ変更されましたが、secondary email - つまり個別に付与しているエイリアスについて cookpad.com への変更が必要だったところ考慮から漏れていました。この状態だと cookpad.com の secondary email address へメールが着信した際に NoSuchUser で hard bounce が返ってしまいます。</p> <p>急ぎ MX レコードを再度 Postfix へ戻し、スクリプトを書いて復旧作業へ取り掛かりました。これについては既存のエイリアスで cookpad.jp ドメインのみ存在するアドレスを抽出し、cookpad.com ドメインのエイリアスとして追加しました。</p> <p>Email の変更は旧名の alias が自動生成されるから CSV で良いのでは? となってインポート用 CSV の生成部のコードレビューまでしたところ、お互いで完全に存在を忘れる大きめなミスとなってしまいました。この点のリカバリについては後述します。</p> <h3 id="事後作業">事後作業</h3> <p>Google Workspace テナント自体の作業はこれで完了ですが、ここからは事後に行った作業について解説します。</p> <p><figure class="figure-image figure-image-fotolife" title="図: 本当にできたのかと驚く社員の様子"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628155957.png" width="840" height="679" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>図: 本当にできたのかと驚く社員の様子</figcaption></figure></p> <h4 id="各種SaaSへの反映確認作業">各種SaaSへの反映・確認作業</h4> <p>各種 SaaS の管理者側作業が下記の通り存在しました。</p> <ul> <li>管理コンソールからユーザー情報を更新する</li> <li>サポート側リネーム完了を待つ (もしくは Google 側の完了を伝えて実施してもらう。いずれにせよ日程は事前に相談しておく)</li> <li>サポートに Google 側のリネームが完了した旨伝達する</li> </ul> <p>管理コンソールからユーザー情報を更新する場合、一括で出来ない例がほとんどでした。複数人で分担してひたすらメールアドレスの更新を実施しました。</p> <p>それ以外のいくつかの SaaS では事前に合意していた時間までにリネームが完了していない、または新ドメインである cookpad.com のアドレスに既にアカウントが存在してリネームできなかった (コンフリクト) といったトラブルがありました。</p> <p>本稿では対応が長引いた SaaS を中心に代表例を解説します。なお、もし本稿をプライマリドメイン変更のために参照している方がいれば、サービス側の実装や状況は変化する可能性があるため、本稿の内容は参考程度に留めて都度問合せや検証は行うようにしてください。</p> <h4 id="Asana">Asana</h4> <p>Asana はテナント(組織) ごとに複数のドメインを登録可能ですが、cookpad.jp と cookpad.com がそれぞれ独立した組織で存在している状態でした。</p> <p><figure class="figure-image figure-image-fotolife" title="Asana の組織切り替えメニュー"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628155919.png" width="570" height="440" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><figcaption>Asana の組織切り替えメニュー</figcaption></figure></p> <p>社内横断的に動いている一部のチームが 2 つの組織を頻繁に切り替えなければならないというフィードバックがあったのも受け、こちらについては準備も兼ねて、Asana については 2023/1 頃に準備を始めました。Asana 側に組織の移行ガイドが用意されていたため、これに従って組織を 1 つにマージ <a href="#f-2a1bf5cc" name="fn-2a1bf5cc" title="実際にはマージではなく、統合先の組織にデータがコピーされる実装のようです">*24</a> しました。</p> <p><a href="https://asana.com/guide/help/organizations/data-migration">https://asana.com/guide/help/organizations/data-migration</a></p> <p>ユーザーのマージについては、ユーザーに cookpad.jp, cookpad.com 両方のメールアドレスを登録して検証を通してもらうことで自動で成されるようでした。マージが必要なユーザーは個別に連絡をして対応を依頼しました。</p> <h4 id="Figma">Figma</h4> <p>Figma は Google ログインについてメールアドレスをベースに実装されているようです。幸いにしてサポートが CSV を元にした一括のリネームを行ってもらえました。また、サポートに依頼するドメイン追加の手順が必要だったため、これは事前に済ませてあります。</p> <p>また、土日での作業については合意が得られなかったため、金曜のうちにリネームを実施してもらいました。この対応として、事前のアナウンスで業務で重要な場合は Figma のログイン状態を確認して、ログインできていなければログインをするように周知しました。</p> <p>実施した結果、コンフリクトが生じているユーザーがいましたが、それ以外は無事に完了しました。コンフリクトしたユーザーについては Figma サポートに空かどうか確認してもらい、空であれば削除してリネームを続行してもらうことができたので、そのように依頼しました。</p> <h4 id="Zoom">Zoom</h4> <p>Zoom に関しても同様の対応で CSV を元にリネームとなりました。ドメイン追加・検証を事前に行う点も同様です。</p> <p>こちらは事前に CSV を送付し作業日程まで合意していたところ、事前に内容を精査してもらえず、作業時間になってコンフリクトが発生したため全体の作業を中断したと報告されてしまいました。それに加え、事前に作成した全 Google ユーザーの新旧対照 CSV を送っていたのですが、実在する Zoom ユーザーに絞ってほしいとのリクエストがありました。事前に送っているのだから事前にチェックしておいてほしい………。</p> <p>コンフリクトについては Google ログインではなくそれ以外の手段 (メールアドレス+パスワード) でサインアップ・ログインしてしまった場合に発生します。このような問題が生じてしまうのも複数のドメインがある故と言えます。仮にユーザー本人が誤りに気付いてログインし直したとしても、作成されてしまったアカウントとユーザーはそのまま残置されるため、コンフリクトへ繋がるという訳でした。</p> <p>次に、残念ながらもともと休日に実施している関係でサポートから追加のレスが返ってくることはなく、大変困ったことになりました。 その場で検証を行ったところ、なかなか厄介なことが分かりました。特にコンフリクトがなければ Google ログイン後にメールアドレスを更新するか聞かれ、更新すれば Zoom ユーザーはリネームに問題なく追従できます。</p> <p>しかし、コンフリクトがある場合の挙動が非常に困りました。まず、 cookpad.com アドレスで Zoom ユーザーが既に存在する場合は容赦なくそちらへログインする事になり、下記の選択肢が提示されます:</p> <ol> <li>メールアドレスを変更する</li> <li>ドメインに対応するアカウントへ参加する</li> <li>無視して続行する</li> </ol> <p><a href="https://support.zoom.us/hc/en-us/articles/4405656980109-Advanced-Associated-Domain-configurations">https://support.zoom.us/hc/en-us/articles/4405656980109-Advanced-Associated-Domain-configurations</a> (実施当時よりユーザーが目にする画面の例が掲載されていてドキュメントが拡充されている!)</p> <p>これらの選択肢はアカウントに追加したドメインごとにカスタマイズ可能です。本プロジェクトの目標として「影響を最小限に抑える」を掲げているため、業務を支障なく継続できる必要があります。従って、これまで利用していた cookpad.jp の Zoom ユーザーへ復帰できるよう整える必要が生じました。</p> <p>検証の末、(3) 無視して続行する → 無料プラン (Basic) のアカウントを削除、という手順を辿り cookpad.com 名義の(1ユーザーしかいない)アカウントとユーザーを削除することで cookpad.jp ユーザーへログイン、リネームへ追従できることが判明しました。</p> <p>(2) のオプション (consolidation) については実施されると IT で管理するメインの Zoom アカウントへ参加し、cookpad.jp と cookpad.com 名義で重複するユーザーが存在してしまう上、自力での復帰が不可能になるため無効化しました。(1) のオプションのみ提示するカスタマイズも可能ですが、cookpad.jp の Zoom ユーザーが既に存在してそのメールアドレスには変更できない上、cookpad.com は誤って作成されたユーザーそのもので不可能なため見送りました。</p> <p>アカウント削除の操作はクリック数も多くやや不安だったため、日英両方でスクリーンショットも添えた作業手順のアナウンスページを追加で周知しました。</p> <p>調査の結果パスワードログイン → Google ログインへ切り替える際の確認画面も存在する事が判明し、フロー含めてやや複雑な文章になりましたが、その分丁寧に作成したため混乱なく乗り越えられました。週明けから IT ヘルプデスクに対応してもらったトラブルシュートで、Google/Zoom のクッキーを全削除しないと cookpad.com へ更新されない例などいくつか追加でケースが見つかりましたが、概ね問題なかったようです。</p> <p>設定変更までに誤ってアカウントが consolidate されてしまったパターンなど、メインの Zoom アカウントに cookpad.jp, cookpad.com 両ユーザーが存在してしまうケースが発生しましたが、これについては Zoom サポートに週明けにバルクでのリネームと合わせてマージ対応をお願いできました。</p> <h4 id="Slack">Slack</h4> <p>Slack に関しても Google ログインで運用を開始したため、現状は SAML ログインですが cookpad.jp から cookpad.com に変更する必要があります <a href="#f-71aa549b" name="fn-71aa549b" title="かなり前に Enterprise Grid プランに移行しているので現在は Azure AD から SAML ログイン、cookpad.jp を名乗らせています。Enterprise Grid 移行時にリネームを検討したところ当時は不可能だった覚え">*25</a>。今回サポートに聞いたところ SCIM を利用するのが早いかも、という提案を貰いました。</p> <p>スクリプトレスで済めば &amp; このタイミングで SCIM 導入ができれば嬉しいということで SCIM を導入する方向で決めましたが、検証から導入までまあまあ時間がかかってしまい、実は Slack について完了したのはつい先日の事です。その上で導入直後に SCIM v2 のリリースがされており、タイミングが悪かったですね…。</p> <p>Azure AD から SCIM を行っていて、cookpad.jp で最初にマッチングさせ cookpad.com に変更を行いました。Slack は user name から display name に方針を切り替えてだいぶ経ちますが、user name は API 上の各所に残っていて、SCIM も例外ではありません。SCIM のドキュメントにも記載されているように文字種・文字長制限を回避して指定する必要があります。</p> <p>SCIM 化にあたって問題だったのはこの userName を重複せずに設定することです。Display name はユーザーが自由に設定できるようにしたいため SCIM では渡さないことにしたため、新規にプロビジョンされたユーザーは userName を目にします。したがって、ここは機械的な値に設定できません。Azure AD テンプレートのデフォルトである email のローカルパートを採用することにしましたが、single/multi channel guest の userName とも衝突してはならないという所で何件か衝突が発生しました。これについては個別にエラーを確認して guest 側を別名に変更するという措置を行いました。</p> <h4 id="メールダウンタイムの事後報告">メールダウンタイムの事後報告</h4> <p>週明けまでに復旧できていないと困る SaaS 類の対応まで一段落したところで、メールダウンタイムの事後報告と、メールエイリアス障害の事後対応を行います。</p> <p>まずはダウンタイム中に着信したメールログを集計してユーザー・グループごとに着信した envelope from のリストを作成しました。CloudWatch Logs Insights で Postfix のログを下記クエリで抽出します:</p> <pre><code>fields @timestamp, @message, @logStream, @log | filter @message like /NOQUEUE/ | parse @message /RCPT from (?&lt;remote_mta&gt;.+?)\[(?&lt;remote_ip&gt;.+?)\]: (?&lt;response_code&gt;[0-9. ]+?) &lt;(?&lt;recipient_email&gt;.+?)&gt;: (?&lt;response_message&gt;.+?);/ | parse @message /; from=&lt;(?&lt;from_email&gt;.*?)&gt; to=&lt;(?&lt;to_email&gt;.+?)&gt; proto=(?&lt;proto&gt;.+?) helo=&lt;(?&lt;helo&gt;.+?)&gt;/ | filter from_email not like /bounces.google.com/ | stats count(*) as cnt by from_email, to_email | sort to_email asc, cnt desc | limit 10000 </code></pre> <p>これで得られる from_email は envelope from であるため、Google Sheets にインポートして各種配信サービスの動的に作成される envelope from を正規化、エイリアスを primary email address に変換、再集計のち CSV で出力しました。</p> <p>障害自体は wiki で周知の上、このデータを添えて障害の対象だったユーザー・それ以外で文面を分けてメールで個別にお知らせを送信しました。送信したデータがどれくらい役に立つかは分かりませんが、少なくとも障害がなかった場合についてはリトライで後から到着すべきメールが来ているかどうかは判断できるということで実施しています。</p> <p>また、エイリアスが削除されたことにより Gmail の Send mail as 設定も自動で剥奪されることを確認したため、再設定についても案内しました。</p> <h4 id="DKIM-の再設定">DKIM の再設定</h4> <p>これもメール関連で盲点の1つでしたが、ドメインエイリアスを削除した時点で DKIM の設定も消失します。実施後しばらくしてセキュリティチームのメンバーから DKIM 忘れてない? と言われて慌てて設定しました <a href="#f-4b6482da" name="fn-4b6482da" title="DMARC の reject ポリシー有効化のためにレポートを監視していたようです">*26</a>。再セットアップにより鍵長も現代的になったのでお得だったと思うことにします。</p> <h3 id="社内アナウンスについて">社内アナウンスについて</h3> <p>さて長々と書いてまいりましたが、最後に作業中および作業前後に実施した社内へのアナウンスについて言及しようと思います。今回はアカウントのリネームによって社内システムや SaaS にアクセス不可能になるリスクや、事前事後に各自の作業が存り、また既に書いているようにその他不測の事態が発生する可能性があったため、比較的丁寧にアナウンスを執筆しました。</p> <p>Slack での社内アナウンスはもちろん、事後 Zoom ã‚„ Figma などリネーム・マージの積み残しが発生したため、常に状況が分かるページを更新し続けていました。</p> <p><figure class="figure-image figure-image-fotolife" title="各種アナウンス文面"><div class="images-row mceNonEditable"><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160036.png" width="1200" height="768" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628155947.png" width="1200" height="780" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/s/sora_h/20230628/20230628160101.png" width="1200" height="158" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></div><figcaption>各種アナウンス文面</figcaption></figure></p> <p>今回、リネーム後にいくつかトラブルがあったり、想定スケジュール通りに SaaS 含めリネームが完了しないといった計画外の出来事がありましたが、実は日本・グローバル通してほぼ問い合わせがありませんでした。丁寧なドキュメントや簡潔なアナウンスが混乱を防ぎセルフサービスで解決も出来るということを改めて実感できるプロジェクトとなりました。</p> <h3 id="振り返り">振り返り</h3> <p>さて、以上が Google Workspace プライマリドメイン変更プロジェクトの解説となります。まとめと振り返りしては下記でしょうか。</p> <ul> <li><strong>Google Workspace 外のサービスへの影響を調査するのがとにかく大変</strong> <ul> <li>Relying Party 各位には OIDC の適切な実装をお願いしたい。ドメイン以外でもメールアドレスは普通に変わりうるもの。</li> <li>最近 <a href="https://techcommunity.microsoft.com/t5/microsoft-entra-azure-ad-blog/the-false-identifier-anti-pattern/ba-p/3846013">The False Identifier Anti-pattern</a> といういい記事が Microsoft Entra チームから出ていたのでオススメです。</li> <li>SAML ログインに対応するのは必須ではない <a href="#f-b01be3ce" name="fn-b01be3ce" title="しかし SAML が嫌なら任意の OIDC クライアントを設定させてほしい…">*27</a> として、Google ログインなどを強制するオプションは欲しい。それはそれとしてサポートコストが掛かるのは分かるが <a href="https://sso.tax">SSO を有償オプションにしないでほしい</a>。</li> </ul> </li> <li><strong>作業日程を JST 土曜朝にしたが、金曜深夜にするべきだった</strong> <ul> <li>各種サービスのサポートが米国西海岸時間 (PT) で稼動しているため</li> <li>そもそも本件をまったく理解してもらえない <a href="#f-8aafcc3a" name="fn-8aafcc3a" title="実際説明に苦労したサービスはたくさんあった">*28</a> 、対応が複雑、といった理由で英語話者へエスカレされることも想定し、日本国外で開発されているサービスへの問合せは日本語サポートがあろうとも全て英語で行っていたのもある。もちろん日本語サポートが無い場合も多数だった。</li> <li>当社で社内システムメンテ時に考慮すべきタイムゾーンは UK 時間と日本時間。いずれにも被っていない PT、かつ平日に実施すればスムーズにサポートに作業を行ってもらえたり、リアルタイムに連携ができたと思われる</li> <li>また、土曜作業で水曜に Google Workspace を含めた IdP のフリーズを宣言したが、サポートとの TZ 差による RTT を考慮して月曜にはフリーズしておくべきだった</li> </ul> </li> <li><strong>プライマリドメインの変更可・不可は事前に確認しておくべきだった</strong> <ul> <li>正確には途中で(諸事情で)タスクがこぼれてしまった… そしてこれについてはサポートに問い合わせるしかないのが大変なところ</li> </ul> </li> <li><strong>ほとんどのメールについてロストを簡単な仕掛けで防げたのは良かった</strong> <ul> <li>ただしエイリアスを見落としてメールのエイリアスのみ障害を起こしてしまった</li> </ul> </li> <li><strong>日本・グローバルチーム両方で実施後、週明けからのトラブルやユーザー問合せはほぼ無しを達成できて良かった</strong> <ul> <li>丁寧なドキュメント・簡潔なアナウンスが助けたと思われる</li> <li>英日両方でドキュメントを用意したり、スクリーンショットまで英日で分けるのは作業負荷としては割と大変ではある</li> <li>また、変更によって強制ログアウトが発生しないのが良かった。モバイルでも iOS/iPadOS では Google 製アプリは何もなく、Android でもパスワードのみでメールアドレスまで求められることがなかった</li> </ul> </li> </ul> <p>総作業時間としてはプロジェクト終盤に集中していたとはいえ、2022/8 ~ 2023/3、Slack などを含めれば 2023/6 までと長期間にわたり本当に大変だったので仮ドメイン名で運用を開始するのは本当におすすめしません <a href="#f-3eff14a5" name="fn-3eff14a5" title="関係ないですが Active Directory (Windows Server) のドメインもちゃんとしたドメインを使うのが良いです。public TLD のサブドメインかなにかを設定した上で(それもKerberos Realmになるのでユーザーに分かりやすいドメインの下が良いと思います)、UPN suffix をメールアドレスに合わせましょう。">*29</a>。</p> <h3 id="Acknowledgements">Acknowledgements</h3> <p>本プロジェクトは筆者 (sorah) だけでは手が到底足りなかったため、<a href="https://www.i-style.jp/">株式会社I-Style</a> の板垣 崇司さま, 菅井 祐太朗 (<a href="http://blog.hatena.ne.jp/hokkai7go/">id:hokkai7go</a>) さま, 中田 健史さまに事前調査やスクリプトの作成・検証のご協力をいただきました。</p> <p>また下記の記事を先行事例として参考にさせていただきました。やれば出来るんだ…! というところで踏み切るキッカケにもなりました。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Ftech.pepabo.com%2F2022%2F01%2F19%2Fprimary-domain-change%2F" title="Google Workspaceのプライマリドメイン変更を実施しました - Pepabo Tech Portal" class="embed-card embed-webcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 155px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>また、こちらは同様の懸念で別テナントへの引っ越しを行った事例だそうです。本稿のように頑張れば同じアカウントを維持してリネームすることもできますが、興味深い事例として紹介します。</p> <p><iframe src="https://hatenablog-parts.com/embed?url=https%3A%2F%2Fproduct.st.inc%2Fentry%2F2023%2F04%2F03%2F123846" title="社名変更に伴うGoogle Workspaceアカウントの引越し手順を解説します - STORES Product Blog" class="embed-card embed-blogcard" scrolling="no" frameborder="0" style="display: block; width: 100%; height: 190px; max-width: 500px; margin: 10px 0px;" loading="lazy"></iframe></p> <p>みなさまがこの記事を役に立てないことを願いつつ終わりたいと思いますが、何かの役に立てば幸いです。</p> <div class="footnote"> <p class="footnote"><a href="#fn-55f9a260" name="f-55f9a260" class="footnote-number">*1</a><span class="footnote-delimiter">:</span><span class="footnote-text">技術部 SRE グループが主務です。一応…</span></p> <p class="footnote"><a href="#fn-d60ff08e" name="f-d60ff08e" class="footnote-number">*2</a><span class="footnote-delimiter">:</span><span class="footnote-text">テナント自体は 2007 年から、業務に本格的に使われたのは 2009 年から、らしいですが詳細は不明です</span></p> <p class="footnote"><a href="#fn-9eed22d0" name="f-9eed22d0" class="footnote-number">*3</a><span class="footnote-delimiter">:</span><span class="footnote-text">当時は Google Apps</span></p> <p class="footnote"><a href="#fn-6766843a" name="f-6766843a" class="footnote-number">*4</a><span class="footnote-delimiter">:</span><span class="footnote-text">これも導入時期と同様に経緯は完全に損われていて不明、利用していないドメインで「一旦」設定してみて、そのまま本番利用されちゃったパターンと想像しています</span></p> <p class="footnote"><a href="#fn-f7cd5bb7" name="f-f7cd5bb7" class="footnote-number">*5</a><span class="footnote-delimiter">:</span><span class="footnote-text">Intune の存在、SAML機能の柔軟性など諸々で併用になっていますが、本プロジェクトでやっとアドレスが統一され混乱を防げるようになったので Google Workspace は Azure AD からのフェデレーションに切り替えたい…!</span></p> <p class="footnote"><a href="#fn-0a97cccc" name="f-0a97cccc" class="footnote-number">*6</a><span class="footnote-delimiter">:</span><span class="footnote-text">Slack がまさに Enterprise Grid で SAML に変更になった時、cookpad.jp ã‚’ SAML で Azure AD に名乗らせていた例</span></p> <p class="footnote"><a href="#fn-2c3d8465" name="f-2c3d8465" class="footnote-number">*7</a><span class="footnote-delimiter">:</span><span class="footnote-text">人の名前は姓名いずれも変わったり、ビジネスネームが異なったり、衝突したりするため、ユーザー名に命名規則を設けるのは非常におすすめしません。ひどい例では、イレギュラーがあるのにユーザー名の命名規則を鵜呑みにしてメールアドレスの推測を行うような実装も発生してしまいます。当社では記号の制約はありますが、任意のユーザー名を入社時にリクエストすることが出来るようになっています</span></p> <p class="footnote"><a href="#fn-3576f14d" name="f-3576f14d" class="footnote-number">*8</a><span class="footnote-delimiter">:</span><span class="footnote-text">昔は hostmaster@ でメールを受けて SSL 証明書発行の承認とかをしていたので、そういうやつのために色々追加されていました</span></p> <p class="footnote"><a href="#fn-5ad8fcdd" name="f-5ad8fcdd" class="footnote-number">*9</a><span class="footnote-delimiter">:</span><span class="footnote-text">後述しますが、Google Workspace ã‚„ Cloud Identity のドメインを元にアカウント選択画面の表示をフィルタしたりスキップ、また新規ログイン時にフォームにドメインをあらかじめ表示する機能。組織のプライマリドメイン != ユーザーのドメイン時の挙動が不定</span></p> <p class="footnote"><a href="#fn-0dca8a1f" name="f-0dca8a1f" class="footnote-number">*10</a><span class="footnote-delimiter">:</span><span class="footnote-text">Google ã‚‚ sub claim を利用しろ、email は適さないゾと <a href="https://developers.google.com/identity/openid-connect/openid-connect#obtainuserinfo">ドキュメントで明記しています</a>。OIDC 仕様上でも sub クレーム以外での照合は non-conformant です <a href="https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability">https://openid.net/specs/openid-connect-core-1_0.html#ClaimStability</a> 。</span></p> <p class="footnote"><a href="#fn-d41066f8" name="f-d41066f8" class="footnote-number">*11</a><span class="footnote-delimiter">:</span><span class="footnote-text">シャドーITっぽいというよりかは、子会社で利用していて本体で関知していないサービス等がそこそこ</span></p> <p class="footnote"><a href="#fn-5fe8f86d" name="f-5fe8f86d" class="footnote-number">*12</a><span class="footnote-delimiter">:</span><span class="footnote-text">そういえば最近 <a href="https://www.descope.com/blog/post/noauth">https://www.descope.com/blog/post/noauth</a> とかありましたね。Google の場合は email_verified claim も見ていればいいけど、見ていないところも実はあったりしたんだろうか?</span></p> <p class="footnote"><a href="#fn-1bd2ca63" name="f-1bd2ca63" class="footnote-number">*13</a><span class="footnote-delimiter">:</span><span class="footnote-text">変更元の cookpad.jp は意図せず使われてきてしまった、と言うのが正しいんですが</span></p> <p class="footnote"><a href="#fn-f9abd0ca" name="f-f9abd0ca" class="footnote-number">*14</a><span class="footnote-delimiter">:</span><span class="footnote-text">たとえば Google ログインを強制できず、パスワードでサインアップやアカウント招待を受諾してしまうとそうなってしまう</span></p> <p class="footnote"><a href="#fn-2b9c1d76" name="f-2b9c1d76" class="footnote-number">*15</a><span class="footnote-delimiter">:</span><span class="footnote-text">Google ログインできない場合でもパスワードリセットの手順でなんとかなったりする事が多かった</span></p> <p class="footnote"><a href="#fn-03a6a6da" name="f-03a6a6da" class="footnote-number">*16</a><span class="footnote-delimiter">:</span><span class="footnote-text">ドメイン所有権の検証があるサービスだとお願いできたり、一度テナントに .jp, .com 両方のユーザーを入れてからだったり</span></p> <p class="footnote"><a href="#fn-a40f5a87" name="f-a40f5a87" class="footnote-number">*17</a><span class="footnote-delimiter">:</span><span class="footnote-text">日本側の場合。UK オフィスが中心となっているグローバル事業は ALB + Azure AD OIDC だったりします</span></p> <p class="footnote"><a href="#fn-9ce5d767" name="f-9ce5d767" class="footnote-number">*18</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん、一時的に hd に両方指定してもらって後で戻すというのもアリですが、後で戻してもらうことは必須ではないため、戻してもらえなかったところだけ不便なままという状況が予想できます</span></p> <p class="footnote"><a href="#fn-99f459b1" name="f-99f459b1" class="footnote-number">*19</a><span class="footnote-delimiter">:</span><span class="footnote-text">もちろん認証は sub claim を利用して認証が行われるのが望ましいですが、各種フォームなどメールアドレスから引くことも当然あるため</span></p> <p class="footnote"><a href="#fn-ec846153" name="f-ec846153" class="footnote-number">*20</a><span class="footnote-delimiter">:</span><span class="footnote-text">事前に検証用の Google Workspace 上で同じ数のユーザーを作成してみれば分かるかもしれませんが、アテにならない上に費用が無駄にかかってしまう…</span></p> <p class="footnote"><a href="#fn-f20f6dab" name="f-f20f6dab" class="footnote-number">*21</a><span class="footnote-delimiter">:</span><span class="footnote-text">純正メール・カレンダーで利用する iOS 自体に登録されているアカウント情報。Google 製アプリでは不要だった</span></p> <p class="footnote"><a href="#fn-5d6bda02" name="f-5d6bda02" class="footnote-number">*22</a><span class="footnote-delimiter">:</span><span class="footnote-text">チャットサポートはログが残らない上その場で解決まで至らないイメージが強かったのですが、Google Workspace の英語サポートは割とその期待は上回った気がします</span></p> <p class="footnote"><a href="#fn-1ea41bf8" name="f-1ea41bf8" class="footnote-number">*23</a><span class="footnote-delimiter">:</span><span class="footnote-text">リセラーからトライアル目的で渡されたものと記憶していますがこれも詳細が不明…</span></p> <p class="footnote"><a href="#fn-2a1bf5cc" name="f-2a1bf5cc" class="footnote-number">*24</a><span class="footnote-delimiter">:</span><span class="footnote-text">実際にはマージではなく、統合先の組織にデータがコピーされる実装のようです</span></p> <p class="footnote"><a href="#fn-71aa549b" name="f-71aa549b" class="footnote-number">*25</a><span class="footnote-delimiter">:</span><span class="footnote-text">かなり前に Enterprise Grid プランに移行しているので現在は Azure AD から SAML ログイン、cookpad.jp を名乗らせています。Enterprise Grid 移行時にリネームを検討したところ当時は不可能だった覚え</span></p> <p class="footnote"><a href="#fn-4b6482da" name="f-4b6482da" class="footnote-number">*26</a><span class="footnote-delimiter">:</span><span class="footnote-text">DMARC の reject ポリシー有効化のためにレポートを監視していたようです</span></p> <p class="footnote"><a href="#fn-b01be3ce" name="f-b01be3ce" class="footnote-number">*27</a><span class="footnote-delimiter">:</span><span class="footnote-text">しかし SAML が嫌なら任意の OIDC クライアントを設定させてほしい…</span></p> <p class="footnote"><a href="#fn-8aafcc3a" name="f-8aafcc3a" class="footnote-number">*28</a><span class="footnote-delimiter">:</span><span class="footnote-text">実際説明に苦労したサービスはたくさんあった</span></p> <p class="footnote"><a href="#fn-3eff14a5" name="f-3eff14a5" class="footnote-number">*29</a><span class="footnote-delimiter">:</span><span class="footnote-text">関係ないですが Active Directory (Windows Server) のドメインもちゃんとしたドメインを使うのが良いです。public TLD のサブドメインかなにかを設定した上で(それもKerberos Realmになるのでユーザーに分かりやすいドメインの下が良いと思います)、UPN suffix をメールアドレスに合わせましょう。</span></p> </div> sora_h Path Drawing in SwiftUI hatenablog://entry/820878482943197956 2023-06-21T16:25:23+09:00 2023-06-21T16:25:23+09:00 How to draw shapes using paths in SwiftUI, starting from the basics. <p>Hi, this is Chris Trott (<a href="https://twitter.com/twocentstudios">@twocentstudios</a>) from Cookpad Mart's iOS team.</p> <p>In this post I want to share a few tips for how to draw shapes using paths in SwiftUI, starting from the basics. The code in this post targets iOS 16 and Xcode 14, but is low-level enough that it should be relatively forward and backward compatible.</p> <p>Drawing paths manually is not a common task in day-to-day app work. It can be especially tedious for complex shapes. However, it can sometimes be a most prudent choice over bitmaps or 3rd party rendering libraries.</p> <p>You can view the complete code from this post from <a href="https://gist.github.com/twocentstudios/6deb870942ce0b69816c7550c73a3a14">this gist</a>.</p> <h1 id="Contents">Contents</h1> <ul> <li>Basic shapes</li> <li>Styling</li> <li>Drawing line-by-line</li> <li>How to use arcs</li> <li>How to use quadratic bezier curves</li> <li>Path operations</li> <li>Creating a chat bubble shape</li> <li>Trimming a path</li> <li>Transition animations</li> </ul> <h1 id="Basic-shapes">Basic shapes</h1> <p>SwiftUI has a protocol <code>Shape</code> – both conforming to, and conceptually similar to <code>View</code> – that we can use to draw custom shapes.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">protocol</span> <span class="synIdentifier">Shape</span> <span class="synSpecial">:</span> <span class="synType">Animatable</span>, View </pre> <p>It has one requirement: a function that takes a <code>CGRect</code> and returns a <code>Path</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">import</span> SwiftUI <span class="synPreProc">struct</span> <span class="synIdentifier">MyCustomShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synComment">/// </span><span class="synTodo">TODO</span><span class="synComment">: return a `Path`</span> } } </pre> <p>As we'll see later, the input <code>rect</code> is determined by SwiftUI's <a href="https://kean.blog/post/swiftui-layout-system#layout-process">layout system rules</a>, but the path we return can draw anywhere, including outside the bounds of <code>rect</code>.</p> <p><code>Path</code> is SwiftUI's drawing command primitive, while UIKit has <code>UIBezierPath</code> and CoreGraphics has <code>CGPath</code>. All are similar, but not quite the same.</p> <p>Let's use SwiftUI's <code>Path</code> primitives to make a simple rounded rectangle.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path(roundedRect<span class="synSpecial">:</span> <span class="synType">rect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synConstant">20</span>) } } </pre> <p>Of course this is the same as SwiftUI's built-in shape:</p> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectangle(cornerRadius<span class="synSpecial">:</span> <span class="synConstant">20</span>) </pre> <p>A <code>Shape</code> has only a "<a href="https://developer.apple.com/documentation/swiftui/shape">default fill</a> based on the foreground color", so let's add a SwiftUI Preview for it.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { RoundedRectShape() .fill(.gray) .frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">150</span>) .padding(<span class="synConstant">50</span>) .previewLayout(.sizeThatFits) } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182416.png" width="654" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Why not make a custom view modifier for making previewing more convenient:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">extension</span> <span class="synIdentifier">View</span> { <span class="synType">@ViewBuilder</span> <span class="synType">func</span> previewingShape() <span class="synSpecial">-&gt;</span> <span class="synType">some</span> View { frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">150</span>) .padding(<span class="synConstant">50</span>) .previewLayout(.sizeThatFits) } } <span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { RoundedRectShape() .fill(.gray) .previewingShape() } } </pre> <h1 id="Styling">Styling</h1> <p>Before we get too far into the weeds with <code>Path</code>, we should take a look at basic <code>Shape</code> styling. Otherwise, how will we be able to see what we're drawing?</p> <p>We can either stroke <em>or</em> fill a shape instance, but not both. This is because <code>.stroke</code> and <code>.fill</code> are both defined on <code>Shape</code> but return a <code>View</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .fill(.gray) </pre> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .stroke(.gray) </pre> <pre class="code lang-swift" data-lang="swift" data-unlink>RoundedRectShape() .stroke(.gray) .fill(.gray) <span class="synComment">// Error: Value of type 'some View' has no member 'fill'</span> </pre> <table> <thead> <tr> <th>Fill</th> <th>Stroke</th> </tr> </thead> <tbody> <tr> <td><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182416.png" width="654" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> <td><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182842.png" width="658" height="552" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></td> </tr> </tbody> </table> <p><a href="https://www.hackingwithswift.com/quick-start/swiftui/how-to-fill-and-stroke-shapes-at-the-same-time">To do both</a>, we need to layer two separate instances of the shape:</p> <pre class="code lang-swift" data-lang="swift" data-unlink>ZStack { RoundedRectShape() .fill(.gray) RoundedRectShape() .stroke(Color.black, lineWidth<span class="synSpecial">:</span> <span class="synConstant">4</span>) } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182909.png" width="652" height="554" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Drawing-line-by-line">Drawing line-by-line</h1> <p>We draw a path line-by-line or curve-by-curve as if we were describing pen strokes to a friend.</p> <ul> <li><code>move(to:)</code> moves the "cursor" without drawing.</li> <li><code>addLine(to:)</code> draws a line from current "cursor" to the the <code>to</code> point.</li> <li><code>closeSubpath()</code> marks the subpath as closed by drawing a line from the "cursor" back to the start point if necessary.</li> </ul> <blockquote><p><strong>Note</strong>: it's required to call <code>move(to:)</code> before adding a line or curve. Otherwise the path will not appear. When adding a complete subpath like <code>addEllipse(in:)</code>, <code>move(to:)</code> is <em>not</em> required.</p></blockquote> <p>Let's draw a banner shape, starting from the bottom left corner:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BannerShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.midY</span>)) p.closeSubpath() } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620182953.png" width="658" height="462" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Since we're using the <code>rect</code> parameter to specify our drawing points, the shape will always be <em>relative</em> to the size of the view.</p> <p>We could also specify <code>absolute</code> coordinates:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BannerAbsoluteShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">10</span>, y<span class="synSpecial">:</span> <span class="synConstant">50</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">10</span>, y<span class="synSpecial">:</span> <span class="synConstant">10</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synConstant">100</span>, y<span class="synSpecial">:</span> <span class="synConstant">30</span>)) p.closeSubpath() } } } </pre> <p>And you can see from the lighter gray background color I've added to the view that the path that defines the shape no longer fills it.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183015.png" width="662" height="456" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="How-to-use-arcs">How to use arcs</h1> <p>There are three APIs for drawing an arc:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and a</span> <span class="synComment">/// difference in angle.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addRelativeArc</span>(center<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, startAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, delta<span class="synSpecial">:</span> <span class="synType">Angle</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) <span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and angles.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(center<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, startAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, endAngle<span class="synSpecial">:</span> <span class="synType">Angle</span>, clockwise<span class="synSpecial">:</span> <span class="synType">Bool</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) <span class="synComment">/// Adds an arc of a circle to the path, specified with a radius and two</span> <span class="synComment">/// tangent lines.</span> <span class="synStatement">public</span> <span class="synStatement">mutating</span> <span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(tangent1End p1<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, tangent2End p2<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>, transform<span class="synSpecial">:</span> <span class="synType">CGAffineTransform</span> <span class="synIdentifier">=</span> .identity) </pre> <p>The first two add a new subpath disconnected from the current path.</p> <p>The last one – using tangents – adds an arc connected to the current subpath. We can use this API to add an arc to a line-by-line drawing session like the banner above.</p> <p>Let's create the rounded rectangle shape with only the <code>addLine</code> and <code>addArc</code> primitives. It should take a corner radius as a parameter and draw inside the provided bounds rectangle.</p> <p>First, we'll visualize what we want to draw. The black-outlined rectangle is a representation of our input rectangle and the gray-filled shape is the target shape we want to draw.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183045.png" width="1200" height="697" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>The corner radius <code>r</code> can be visualized as a square, situated at each corner of the bounds rectangle, with side <code>r</code>.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183107.png" width="321" height="303" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Looking at the <a href="https://developer.apple.com/documentation/swiftui/path/addarc(tangent1end:tangent2end:radius:transform:"><code>addArc</code></a>) function again:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">addArc</span>(tangent1End p1<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, tangent2End p2<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, radius<span class="synSpecial">:</span> <span class="synType">CGFloat</span>) </pre> <p>We need to assemble 4 parameters:</p> <ol> <li><code>startPoint</code> (implicit; this is where the "cursor" is)</li> <li><code>tangent1End</code></li> <li><code>tangent2End</code></li> <li><code>radius</code></li> </ol> <p>We only know (4) <code>radius</code>.</p> <p>Despite the potentially 🤔 names, the tangents correspond to the following points on the aforementioned square:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183123.png" width="321" height="318" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Zooming out to the whole rectangle, if we decide to draw clockwise, that means we'll have 4 arcs with the following points:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183134.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Let's alternate drawing lines and arcs, clockwise, and in the following order:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183147.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We want the points to be drawn relative to the bounds rectangle. We can use the following helper functions on <code>CGRect</code> to derive the corner points we need:</p> <ul> <li><code>CGRect.minX</code></li> <li><code>CGRect.maxX</code></li> <li><code>CGRect.minY</code></li> <li><code>CGRect.maxY</code></li> <li><code>CGRect.midX</code> (also useful)</li> <li><code>CGRect.midY</code> (also useful)</li> </ul> <blockquote><p>If you mix up these helpers while writing drawing code, you're in good company.</p></blockquote> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183201.png" width="1200" height="671" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We derive the non-corner points by adding or subtracting the corner radius.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183216.png" width="1200" height="294" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>With all the details worked out, all we have to do is arrange the code:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcUnsafeShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">-</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span> <span class="synIdentifier">+</span> cornerRadius), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">-</span> cornerRadius)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">-</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">-</span> cornerRadius), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span> <span class="synIdentifier">+</span> cornerRadius)) p.addArc( tangent1End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), tangent2End<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> cornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), radius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span> ) p.closeSubpath() } } } </pre> <p>If we overlay SwiftUI's build-in <code>RoundedRectangle</code> shape, ours looks pretty good:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcUnsafeView_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">20</span> ZStack { RoundedRectArcUnsafeShape(cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>) .stroke(.gray, lineWidth<span class="synSpecial">:</span> <span class="synConstant">9</span>) RoundedRectangle(cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>, style<span class="synSpecial">:</span> .circular) .stroke(.red, lineWidth<span class="synSpecial">:</span> <span class="synConstant">1</span>) } .previewingShape() } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183240.png" width="888" height="608" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>But what happens if we make <code>cornerRadius</code> something like <code>100</code> (when our shape height is <code>100</code>)?</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183254.png" width="884" height="608" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Looks like SwiftUI's version does some bounds checking so the shape becomes a capsule or circle. Let's fix our implementation:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">RoundedRectArcShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">let</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">maxBoundedCornerRadius</span> <span class="synIdentifier">=</span> min(min(cornerRadius, rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>), rect.height <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>) <span class="synPreProc">let</span> <span class="synIdentifier">minBoundedCornerRadius</span> <span class="synIdentifier">=</span> max(maxBoundedCornerRadius, <span class="synConstant">0.0</span>) <span class="synPreProc">let</span> <span class="synIdentifier">boundedCornerRadius</span> <span class="synIdentifier">=</span> minBoundedCornerRadius <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span> <span class="synIdentifier">+</span> boundedCornerRadius, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) <span class="synComment">// ...</span> } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183308.png" width="890" height="616" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>That's better. As a bonus, I'm showing SwiftUI's <code>.continuous</code> corner style in blue over the <code>.circular</code> style in red.</p> <h1 id="How-to-use-quadratic-bezier-curves">How to use quadratic bezier curves</h1> <p>We often want to avoid the kinds of sharp corners that appear when connecting <code>line</code>s, but don't necessarily want to use circular <code>arc</code>s.</p> <p>For smoother lines, the <code>Path</code> API gives us:</p> <ul> <li><code>addCurve</code> for cubic Bézier curves</li> <li><code>addQuadCurve</code> for quadratic Bézier curves</li> </ul> <p>Cubic Bézier curves give us a lot of flexibility. They can also be a <a href="https://pomax.github.io/bezierinfo/">weighty</a> <a href="https://en.wikipedia.org/wiki/B%C3%A9zier_curve">topic</a>. I recommend <a href="https://www.youtube.com/watch?v=aVwxzDHniEw">this YouTube video</a> by Freya Holmér.</p> <p>I've found quadratic Bézier curves as a nice compromise between flexibility and complexity, so let's try to quickly build some intuition on how to use them.</p> <p>Let's start by looking at the <a href="https://developer.apple.com/documentation/swiftui/path/addquadcurve(to:control:"><code>addQuadCurve</code></a>) function:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">func</span> <span class="synIdentifier">addQuadCurve</span>(to p<span class="synSpecial">:</span> <span class="synType">CGPoint</span>, control cp<span class="synSpecial">:</span> <span class="synType">CGPoint</span>) </pre> <p>We need to assemble 3 parameters:</p> <ol> <li><code>startPoint</code> (implicit; this is where the "cursor" is)</li> <li><code>endPoint</code> (<code>p</code>)</li> <li><code>controlPoint</code> (<code>cp</code>)</li> </ol> <p>When we set up the three points as various triangles, we can see that the curve is stretched towards the control point.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183332.png" width="1200" height="579" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Calculating the actual positions of the three points will depend on our use case.</p> <p>Let's say we want to draw a simple quad curve as a "scoop" with the control point at the bottom. But we'll allow the caller to specify a relative position on the x-axis for the control point.</p> <p>Add the input rectangle to our planning diagram will help us determine how to calculate each of the three points:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183344.png" width="1200" height="579" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>With that, here's the code:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">QuadCurveScoop</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synComment">/// 0...1</span> <span class="synPreProc">var</span> <span class="synIdentifier">pointOffsetFraction</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0.0</span> <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.minX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>)) p.addQuadCurve( to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span>, y<span class="synSpecial">:</span> <span class="synType">rect.minY</span>), control<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">rect.maxX</span> <span class="synIdentifier">*</span> pointOffsetFraction, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>) ) } } } </pre> <blockquote><p>If we don't explicitly close the subpath, SwiftUI presumably closes it for us when drawing.</p></blockquote> <p>I've set up the preview to mimic the figure above, and I've added an overlay to show the input rectangle and approximate control point for each curve.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183358.png" width="882" height="884" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Path-operations">Path operations</h1> <p>Path operations look like set operations: <code>union</code>, <code>intersection</code>, <code>subtracting</code>, etc.</p> <p>These operations allow us to combine subpaths in unique ways, without necessarily needing to draw line-by-line or arc-by-arc.</p> <p>Let's try making a cloud shape by adding together 3 ellipses:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud1Shape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">inset</span> <span class="synIdentifier">=</span> rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span> <span class="synStatement">return</span> Path { p <span class="synStatement">in</span> p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synConstant">0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span>))) p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>))) p.addEllipse(<span class="synStatement">in</span><span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synConstant">0</span>))) } } } </pre> <p>When we fill it, it looks fine:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud1View_Previews</span><span class="synSpecial">:</span> <span class="synType">PreviewProvider</span> { <span class="synStatement">static</span> <span class="synPreProc">var</span> <span class="synIdentifier">previews</span><span class="synSpecial">:</span> <span class="synType">some</span> View { Cloud1Shape() .fill(.gray) .previewingShape() } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183416.png" width="658" height="456" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>But if we decide to draw an outline instead, it looks like 3 ellipses:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183426.png" width="656" height="454" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>We can fix this by joining the shapes together using the <a href="https://developer.apple.com/documentation/coregraphics/cgpath/3994971-union"><code>union</code></a> path operation.</p> <p>The path operation APIs are available on iOS 16+. Unfortunately, they're defined on <code>CGPath</code> and not <code>Path</code>. It's simple to convert between them, but we'll have to rewrite our path drawing code.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">Cloud2Shape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">inset</span> <span class="synIdentifier">=</span> rect.width <span class="synIdentifier">/</span> <span class="synConstant">2.0</span> <span class="synPreProc">let</span> <span class="synIdentifier">leftEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synConstant">0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">centerEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synType">inset</span> <span class="synIdentifier">/</span> <span class="synConstant">2.0</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">rightEllipse</span> <span class="synIdentifier">=</span> Path(ellipseIn<span class="synSpecial">:</span> <span class="synType">rect.inset</span>(by<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(top<span class="synSpecial">:</span> <span class="synConstant">0</span>, left<span class="synSpecial">:</span> <span class="synType">inset</span>, bottom<span class="synSpecial">:</span> <span class="synConstant">0</span>, right<span class="synSpecial">:</span> <span class="synConstant">0</span>))) <span class="synPreProc">let</span> <span class="synIdentifier">combinedCGPath</span> <span class="synIdentifier">=</span> leftEllipse.cgPath .union(centerEllipse.cgPath) .union(rightEllipse.cgPath) <span class="synStatement">return</span> Path(combinedCGPath) } } </pre> <p>Now when we outline the shape, we get a cloud again.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183437.png" width="648" height="446" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Creating-a-chat-bubble-shape">Creating a chat bubble shape</h1> <p>I used the above techniques to create a chat bubble shape for the onboarding section of the recently decommissioned <a href="https://note.com/tabedori/">Tabedori たべドリ</a> app.</p> <p>The arrow position on the bottom can be adjusted by providing <code>arrowOffsetFraction</code>.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">MapOnboardingBubbleShape</span><span class="synSpecial">:</span> <span class="synType">Shape</span> { <span class="synPreProc">var</span> <span class="synIdentifier">cornerRadius</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">12</span> <span class="synPreProc">var</span> <span class="synIdentifier">arrowRectSize</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">20</span> <span class="synPreProc">var</span> <span class="synIdentifier">arcLength</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">12</span> <span class="synComment">/// 0.0 = left, 0.5 = center, 1.0 = right</span> <span class="synPreProc">var</span> <span class="synIdentifier">arrowOffsetFraction</span><span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0.5</span> <span class="synPreProc">func</span> <span class="synIdentifier">baseXPos</span>(<span class="synStatement">for</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">CGFloat</span> { (rect.maxX <span class="synIdentifier">-</span> cornerRadius <span class="synIdentifier">-</span> cornerRadius <span class="synIdentifier">-</span> arrowRectSize) <span class="synIdentifier">*</span> arrowOffsetFraction <span class="synIdentifier">+</span> cornerRadius } <span class="synPreProc">func</span> <span class="synIdentifier">path</span>(<span class="synStatement">in</span> rect<span class="synSpecial">:</span> <span class="synType">CGRect</span>) <span class="synSpecial">-&gt;</span> <span class="synType">Path</span> { <span class="synPreProc">let</span> <span class="synIdentifier">roundedRect</span> <span class="synIdentifier">=</span> Path(roundedRect<span class="synSpecial">:</span> <span class="synType">rect</span>, cornerRadius<span class="synSpecial">:</span> <span class="synType">cornerRadius</span>) <span class="synPreProc">let</span> <span class="synIdentifier">arrowPath</span> <span class="synIdentifier">=</span> Path { p <span class="synStatement">in</span> p.move(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>), y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength )) p.addQuadCurve( to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize <span class="synIdentifier">-</span> arcLength ), control<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>( x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span> <span class="synIdentifier">+</span> arrowRectSize ) ) p.addLine(to<span class="synSpecial">:</span> .<span class="synIdentifier">init</span>(x<span class="synSpecial">:</span> <span class="synType">baseXPos</span>(<span class="synStatement">for</span><span class="synSpecial">:</span> <span class="synType">rect</span>) <span class="synIdentifier">+</span> arrowRectSize, y<span class="synSpecial">:</span> <span class="synType">rect.maxY</span>)) p.closeSubpath() } <span class="synPreProc">let</span> <span class="synIdentifier">combinedCGPath</span> <span class="synIdentifier">=</span> roundedRect.cgPath.union(arrowPath.cgPath) <span class="synPreProc">let</span> <span class="synIdentifier">combinedPath</span> <span class="synIdentifier">=</span> Path(combinedCGPath) <span class="synStatement">return</span> combinedPath } } </pre> <p>The <code>arrowOffsetFraction</code> is the text inside the bubble.</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183452.png" width="676" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <p>Here's a screenshot of it in context:</p> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183506.jpg" width="553" height="1200" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Trimming-a-path">Trimming a path</h1> <p>Animating the path is something that can't be done (easily) with a single static image, but is easy to do with a <code>Shape</code>.</p> <p>The <code>trim</code> modifier on <code>Shape</code> allows you to draw only a variable fraction of the path.</p> <p>Since SwiftUI is adept at many kinds of animations, we can use it to animate the path being drawn:</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">DrawBubbleView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@State</span> <span class="synType">var</span> drawFraction<span class="synSpecial">:</span> <span class="synType">CGFloat</span> <span class="synIdentifier">=</span> <span class="synConstant">0</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { VStack { MapOnboardingBubbleShape() .trim(from<span class="synSpecial">:</span> <span class="synConstant">0</span>, to<span class="synSpecial">:</span> <span class="synType">drawFraction</span>) .stroke(.gray, lineWidth<span class="synSpecial">:</span> <span class="synConstant">3</span>) .animation(.spring(), value<span class="synSpecial">:</span> <span class="synType">drawFraction</span>) .frame(width<span class="synSpecial">:</span> <span class="synConstant">150</span>, height<span class="synSpecial">:</span> <span class="synConstant">100</span>) .padding(.bottom, <span class="synConstant">50</span>) Button(drawFraction <span class="synIdentifier">&gt;</span> <span class="synConstant">0.0</span> ? <span class="synConstant">&quot;Hide&quot;</span> <span class="synSpecial">:</span> <span class="synConstant">&quot;Show&quot;</span>) { drawFraction <span class="synIdentifier">=</span> drawFraction <span class="synIdentifier">&gt;</span> <span class="synConstant">0.0</span> ? <span class="synConstant">0.0</span> <span class="synSpecial">:</span> <span class="synConstant">1.0</span> } .tint(Color.gray) } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183524.gif" width="628" height="776" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Transition-animations">Transition animations</h1> <p>And finally, since <code>Shape</code>s have appearance/disappearance transitions like any other <code>View</code>, we can add a fun springy insertion animation.</p> <pre class="code lang-swift" data-lang="swift" data-unlink><span class="synPreProc">struct</span> <span class="synIdentifier">BubbleTransitionView</span><span class="synSpecial">:</span> <span class="synType">View</span> { <span class="synType">@State</span> <span class="synType">var</span> isVisible<span class="synSpecial">:</span> <span class="synType">Bool</span> <span class="synIdentifier">=</span> <span class="synConstant">false</span> <span class="synPreProc">var</span> <span class="synIdentifier">body</span><span class="synSpecial">:</span> <span class="synType">some</span> View { VStack { ZStack { <span class="synStatement">if</span> isVisible { Text(<span class="synConstant">&quot;Hello!&quot;</span>) .padding(<span class="synConstant">30</span>) .background { MapOnboardingBubbleShape().fill(Color(.systemGray5)) } .transition(.opacity.combined(with<span class="synSpecial">:</span> .scale).animation(.spring(response<span class="synSpecial">:</span> <span class="synConstant">0.25</span>, dampingFraction<span class="synSpecial">:</span> <span class="synConstant">0.7</span>))) } } .frame(width<span class="synSpecial">:</span> <span class="synConstant">200</span>, height<span class="synSpecial">:</span> <span class="synConstant">100</span>) .padding(.bottom, <span class="synConstant">50</span>) Button(isVisible ? <span class="synConstant">&quot;Hide&quot;</span> <span class="synSpecial">:</span> <span class="synConstant">&quot;Show&quot;</span>) { isVisible.toggle() } } } } </pre> <p><span itemscope itemtype="http://schema.org/Photograph"><img src="https://cdn-ak.f.st-hatena.com/images/fotolife/c/christopher-trott/20230620/20230620183543.gif" width="628" height="776" loading="lazy" title="" class="hatena-fotolife" itemprop="image"></span></p> <h1 id="Conclusion">Conclusion</h1> <p>Thanks for reading! I hope this post has led you on a <em>path</em> of enlightenment.</p> christopher-trott