トレンドワード機能を新システムに移行するときに考慮したこと

こんにちは。レシピ事業部検索チームの薄羽 (@usulity) です。

続々と関連記事が投稿されていますが、日本とグローバルのクックパッドを統合しました。

この統合に際して、日本のクックパッドの様々な機能がグローバル版へ移植されました。今回は、移植された機能の一つである「人気のキーワード」について、移植した際にどんな課題があってどう解決したのかの一部をご紹介できればと思います。

人気のキーワード

人気のキーワードは、「クックパッドで最近よく検索されているキーワード」を集計して、ランキング形式で掲載する機能です。

日本版クックパッドの人気のキーワードページ
日本版人気のキーワード

このように、人気のキーワードは1時間おきに更新され、その時期・時間帯のトレンドを反映したようなキーワードのランキングになっています。

トップページの検索窓の下にも上位のキーワードが表示されており、人目につきやすい機能の一つです。

グローバル版人気のキーワード

グローバル版にも人気のキーワード機能があります。

基本的な機能は日本のものと同じで、そのときのよく検索されているキーワードを表現しています。

グローバル版人気のキーワードのスクリーンショット
グローバル版英語の人気のキーワード

今回取り組んだタスクは、このグローバル版の人気のキーワードとして、日本の人気のキーワードを日本版の計算方式で表示することがゴールになります。

課題

ということで、「グローバルにも人気のキーワードがあるから、日本語でも集計を実行するように修正すれば終わり!」としたいところなんですが、いくつか問題があります。

そもそも、グローバル版と日本版は別物のアプリケーションであるため違いは色々ありますが、「ログがない」ことと「更新頻度の違い」は移植する上で大きな問題でした。

ログがない

人気のキーワード機能を移植するにあたり、ユーザが移行してきたタイミングではもう人気のキーワードを表示しておきたいという要望がありました。

人気のキーワードは検索ログから計算するため、人気のキーワードを表示するにはグローバル版で検索ログが溜まっている必要があります。

しかし、日本のユーザをグローバル版の方に流すまでは、日本のユーザはグローバル版の方にはいないため検索ログはありません。

このままでは移行してきたタイミングで人気のキーワードを表示することができないので、なんとかグローバルの方にログがない状態でも人気のキーワードを計算できるようにしておきたいです。

また、キーワードのクオリティの面でも懸念があります。

この次でも説明しますが、人気のキーワードの計算は、過去の長い期間に渡ったログを使うことで、そのときの人気度を計算しています。

そのため、移行の初期段階ではログが十分に溜まっていないことで、人気のキーワードの質が不安定になってしまう恐れがあります。

更新頻度の違い

日本の方は毎時の更新なのに対して、グローバルの方は日次の更新になります。

計算頻度が日次か毎時かでは、単なる実行頻度の違い以上の差があり、人気度の計算方法が異なります。

人気度は長期的な目線と短期的な目線の両方で見た時に、目立って検索されている語を抽出しています。

簡略化しますが、例えばグローバル版の方では、人気度を「そのキーワードのその日の検索回数」/ 「過去に渡ったそのキーワードの1日の平均検索回数」で計算します。

こうすることで、あるキーワードが平均よりその日多く検索されていたら、そのキーワードの人気度は高くなります。

毎時の計算においても同じようなことをしますが、時間の区切り方が異なります。

「その時間帯」で目立って検索されたキーワードを調べたいので、毎時の計算では人気度を、「そのキーワードが1時間の間に検索された回数」-「そのキーワードがこの時間帯で検索される平均回数」として計算します。

このように、日次と毎時では人気度の計算方法が異なり、毎時では日次より細かい単位で平均を計算します。

単純にグローバル版の人気のキーワードの計算を毎時に動かしても再現できないことが分かったので、グローバル版の人気のキーワード計算の仕組みを変えるか、新しくバッチジョブを用意する必要があります。

解決

日本版とグローバル版両方のログから合算する

移行先にログがない問題を解決するために、移行先の人気のキーワードは日本とグローバル両方のログから計算します。

幸い、日本版でも人気のキーワードの計算に特別な情報は使っていなかったため、グローバル版のログで情報が足りないといったことはありませんでした。

これにより、グローバル版に移行初期のタイミングでも、良いクオリティの人気のキーワードが計算できるようになります。

また、ユーザの段階的な移行を考えた時にも、この方法は利点があります。

日本版とグローバル両方からログを取得しているため、仮にユーザの50%は日本、もう50%がグローバルにいるようなケースでも、両方のプラットフォームでのユーザ行動を加味した人気のキーワードを計算できます。

実際に、 One Experience ではユーザを日本版から少しずつグローバル版の方へ移行していきました。

それぞれからの検索ログを同じ価値と見なして良いかは議論の余地がありますが、それを検証するのは大変なので、1つの検索ログが1回の検索を示すことだけ確認して、移行途中で日本版と大きく結果が離れていなければよしとしました。

このように、データソースを両方のプラットフォームから取得して計算することで、移行段階に左右されずに人気のキーワードを提供することができます。

日本用のバッチジョブを用意する

更新頻度の違いによる人気度の計算方法の違いが大きいため、今回は日本用のバッチジョブを用意することにしました。

グローバル版の方の仕組みを日本版に寄せる方向性も考慮しましたが、更新頻度以外に以下の理由で別のバッチジョブとして作ることにしました。

  1. グローバル版の方の人気のキーワードのクオリティをチェックしながら開発するコストが高い
  2. 日本とそれ以外の言語ではログの量に差があるため、そのためのパラメータ調整にコストがかかる

グローバル版では、多数の言語の人気のキーワードを計算しています。グローバル版の人気のキーワードの仕組みを変えた時、抽出されたキーワードのクオリティへの影響をチェックしたいわけですが、言語が違うのでチェックするのが大変です。

また、日本用にチューニングした人気度の計算方法が、他の言語でうまくいくとは限りません。日本向けに設定したパラメータが他の言語でも適切かどうかわかりせんし、また各言語向けに設定をするのもコストがかかります。

日本とその他の言語で別々のバッチジョブと言っても、将来的にはやはりなるべく1つにまとめられるようにしておきたいので、なるべくグローバル版の仕組みには乗るように注意しました。

結果とまとめ

結果として、ユーザの移行が始まる前に実装が完了し、無事移行初期段階でも人気のキーワードを表示することができました。

グローバル版日本の人気のキーワード
グローバル版日本の人気のキーワード

やはり、ユーザがどっちのプラットフォームに居ても人気のキーワードを計算できることは、One Experience ではとても有効な方法だったと思います。

また、バッチジョブを分けることで他の言語への影響をあまり気にする必要がなかったことも、開発していく上では重要だったと今改めて思います。一方で、人気のキーワードの計算が日本語だけ分かれていることでメンテナンスコストが増えてしまっているので、これを他言語と統合していくということが今後の課題として残っています。

個人的な感想としては、正しく人気のキーワードを計算できているのか最後まで不安がありましたが、結果としては日本版と同様のキーワードをグローバル版でも表示することができて良かったと思っています。

以上、One Experience における人気のキーワード機能の移行の話でした。

面白いと思ってくださった方は、ぜひチャンネル登録と高評価、また他にも色々な角度の One Experience 話が投稿されておりますので、ぜひそちらもご覧ください。

One experience 検索移行の話

こんにちは、レシピ事業部検索チームのオリギル(@orgil_)です。 先日、この開発者ブログで紹介されたOne experienceプロジェクトによって、クックパッドはプロダクト基盤をグローバル版のシステムに移行しました。私はこのプロジェクトにおいて検索の領域で移行の進行、開発を担当していました。このブログでは検索システムの移行について紹介します。

グローバルのシステムに寄せた理由

One experience によってクックパッドは検索基盤をグローバル側のシステムに移行しました。プロジェクト発足当初、日本とグローバルのどちらのシステムに寄せるかをいろんな観点から検討しました。日本ではSolr、Ruby、ECSなどで動くVoyagerというシステム、グローバルではElasticsearch、Python、k8s、Kafkaなどを用いたglobal-search-v2(通称GS2)という検索システムが動いています。検討の際、どちらのシステムも一長一短で、特段優劣をつけがたいものでした。

検索アルゴリズムに関しては両システムで考え方は一緒でした。辞書ベースでクエリ拡張をし、ドキュメントのタイトルや材料などにマッチスコアを付け、辞書の属性によって細かい調整をするようなスコアリングアルゴリズムは両方にあります。

またドキュメントの検索への反映時間はどちらも同等と呼べるものでした。GS2はKafkaを用いたイベント処理システムを用いて、ほぼ即時反映を実現しています。Voyagerでも定期的な同期をするようになっており、レシピの変更が最短5分で検索結果に反映されるようになっています。下記のブログで触れているように、プロダクトが求めるユーザー体験を実現するためには5分でドキュメントが反映されれば十分であるため、どちらのシステムでも同様な価値を届けられます。

VoyagerがGS2より一番優れていた点は、速度でした。議論時点ではGS2のp50, p95 レスポンスタイムはVoyagerより約4倍ほど遅いものでした。これは大きな懸念要素で、速度は検索システムにとってとても大事な指標です。GS2を採用する際に、日本の大きなトラフィック量が増えることでGS2が更に遅くならないかなどの懸念点はありました。

いろいろと議論しましたが、最終的にはGS2の方に統合することになりました。一番の決め手は多言語対応がVoyagerでは難しかったからです。GS2はすでに約30言語をサポートしていてElasticsearchのAnalyzerを各言語で細かく設定、運用しており、辞書も言語、地域ごとに別れています。この運用をVoyagerでやるのが難しそうでした。

また辞書の扱い方が大きく違いました。日本の検索は長年運用されて、辞書は成熟しており、変更が頻繁に行われないため、辞書の更新は日次バッチで適用されます。しかし、GS2では辞書の変更も即時反映で、すぐに検索結果が変わります。これは辞書の管理を各地域のCM(コミュニティマネージャー)にお願いしていて、彼ら彼女らが辞書を変更しながら日々検索改善を行っているからです。まだ辞書を一から作っている新興マーケットが多かったため、辞書の変更による検索結果の変化を実際に見ながら辞書の作成をしているためです。 GS2採用にあたって速度面での懸念はありましたが、VoyagerがGS2より速い理由はわかっていて、いくつかGS2でも実現できる仕組みがあったため、なんとかできそうだということで進めました。

検索移行の進め方

検索移行のゴールとしては、日本の検索と完全に同じ結果を返すようにすることを目指しました。GS2の検索ロジックの考え方は日本と同じだと言いましたが、実際の計算式は違っていて、検索結果にいろんな差異があります。通常ならABテストなどで、GS2ベースのスコアリングに置き換えても大丈夫か、検証しながら切り替えることも考えられます。しかしOne experience プロジェクトでは検索のみならず、メインのレシピサービスやデータベース、基盤をまるごと移行しているため、一部をユーザーに出して検証することができませんでした。また日本の人気順検索はプレミアムサービスの大事な機能であるため、慎重になる必要があり、システム移行作業とアルゴリズムの大きな改変の検証を同時にやるリソースがありませんでした。GS2で日本専用の検索クエリビルダーを作ることは容易で、大きな技術的な負債にもならないので、まずは同じ結果を再現することに従事しました。

RBO指標

検索結果が同じになっているかを確かめるために、日本とグローバルの結果を比較する必要がありました。今回の移行で、ふたつの検索結果の一致度を測る指標として、Rank Biased Overlap(RBO)という指標を用いました。RBOは2つの順位付きリストの類似性を測定する指標で、特に順位の上位に重点を置きながら、リスト全体の比較を行える特長があります。

RBOを計算する際の上位100件の結果を減衰パラメータ=0.978に設定して測りました。このパラメータは上位60件の一致度でRBO指標の9割の重みが決まるように設定しました。これはレシピ検索においてほとんどのアクセスが最初の3ページの閲覧に収まっているためです。実装のデフォルトである減衰パラメータ=0.9では上位約10レシピだけでRBOスコアが決められてしまい、上位数件の結果が合ってるかどうかに重きが置かれる指標になってしまうからです。

この比較をTop20000キーワードに対して、新着順と人気順の両方で毎日自動で計算するようにしました。日々のRBOの変化、2万キーワードのRBOの分布で進捗を測り、チーム内で RBOが低かったキーワードを分析しながら、移植機能の優先度をつけたり、仕様の見落としを発見しながら進められました。

最初のRBO計測

RBO値とそれに対するキーワードのヒストグラムで移行の進捗が可視化することができました。上の図は一番最初のRBO分布のグラフです。このときはもちろん何もできていないのでほとんどのキーワードがRBO=0.0という結果でした。

初期の開発過程の変化
最初は一番基礎的な検索サポートをしていきました。Elasticsearchで正しいAnalyzerを設定したり、Voyagerのベースの計算式をGS2に実装したり、検索時のクエリを正しく形態素解析するようにしたり、基礎的な部分を作っていきました。この時点でRBO=0.0結果がほとんどなくなり、大部分が右側に寄っている形になりました。
同日の新着順と人気順のRBO分布
またこれは同日の新着順と人気順の分布です。人気順のみの機能などもあることから、両方ともモニターしていく必要がありました。プロジェクト通して人気順のほうがRBO成績は良い傾向にありました。これは新着のレシピドキュメントが日本とグローバルで同期がズレていたり、人気順は人気順スコアの比重が大きいため似やすいことに起因しています。

基礎的な機能の移植が一段落し、プロジェクトの中盤ではRBO値が低いクエリ(0.4以下)を並べて、漏れている機能の発見や、未実装機能の優先度付けを行いながら進めました。難しかったのはRBOが0.6-0.8あたりのキーワードの解析でした。RBOが高いキーワードはもうすでにある程度似ている結果で、差分を探すのが難しいものとなっていました。この辺になってくると単純にアルゴリズムの違いだけではなく、ドキュメント作成時の形態素解析の微妙な差異なども影響してくるため、地道に細かく調査しながら進めました。

RBO分布の推移を可視化してみました。移行が進むごとに分布が右側に寄っていく様子が伺えます。RBOは最終的に新着順で RBO>=0.8 の割合が 97.2%、人気順で RBO >=0.8: 95.1% という結果になりました。いくつかの機能は違う形でもってきたり、Voyagerから持ってきたくない仕様などもありましたが、最終的にはとても高い一致率になりました。ユーザーリリース後もKPIが大きく変化することなく移行できました。

速度

検索移行において日本のシステムと同等な検索結果を出すことと同時に、同等な速度を用意することに注力しました。前述したようにGS2はVoyagerの4倍ほど遅いレスポンスタイムでした。

VoyagerがGS2と比べ速い理由はいくつかわかっていました。一つはクエリ拡張の方式です。辞書を使ったクエリ展開は主に同義語展開と、語の親子関係による拡張があります。日本では synonym token filter を使用して同義語展開を行い、親子関係の単語はインデックス時に事前計算して入れています。そのため検索時は元キーワードのみの検索で同義語展開と親子マッチが可能になっています。その変わりに辞書の変更を反映させるには、日次バッチを待つしかないです。対して、GS2では辞書反映をリアルタイムで実現したいため、クエリ拡張をElasticsearchクエリビルド時に行っているため、ESクエリが肥大化し、検索が重い処理になります。

もう一つの速い理由はSolrを用いた検索基盤が最適化されていたことです。Voyagerでは様々な工夫を凝らしてインデックスサイズを極限まで減らしています。またSolr-hakoを用いたシステムがとても高コスパで高パフォーマンスでした。GS2のElasticsearch構成と比較してクラスタを組んでいないし、Solrノードが1インデックスしか持たないため、クエリキャッシュが効率的に使えます。

GS2移行において、実際の日本のトラフィックに近い負荷試験シナリオを用意してパフォーマンスを計測していきました。前述したSynonym token filterや親子関係の事前計算をGS2にも移行して持ってきたので、GS2の他の言語と比較してすでにある程度速くなってました。その上で、API側、Elasticsearchの構成、インデックス設定などいろいろ模索しながらパフォーマンス改善していきました。GS2にはPrometheus, Grafana を用いたAPM機能が備わっていたので、検索の一連の流れ、クエリ解析、Elasticsearchへのリクエストなどのどこがボトルネックになっているのか明確で、注力する領域を明確にできていました。

またユーザーから見えるWebやアプリの検索ページのパフォーマンスも測定しながら、別のチームの方々が、検索基盤より前にあるシステムの最適化にも積極的に取り組んでいました。

最終的にはGS2はp95ではVoyagerより高速なパフォーマンスになりました。p50のベースのレスポンスタイムは上がりましたが、p95レベルでは日本より安定したレスポンスタイムを実現できました。

  • Voyager -> GS2
  • p50: ~22ms -> ~33ms
  • p95: ~80ms -> ~50ms

さいごに

One experienceはUI、体験が大きく変わり、ユーザーに大きな負担を強いる挑戦でした。ユーザーの方には、検索結果とその応答性は同等なものを提供するように検索チームとして注力して来ました。結果、ほとんどのキーワードでは同等な検索結果を完全にGS2のシステムで用意することができ、レスポンスタイムも損なうことなく検索移行を実現することができました。

検索移行が終わったいま、真のGlobalな検索開発が始まりました。一つのチームで約30言語の検索改善に取り組んでいきます。また日本とGlobalの検索アルゴリズムをなるべく揃えていこうとしています。実際のユーザートラフィックがあるなか、改めてGS2に備わっているABテスト基盤を活用しながら、Global、日本両方のアルゴリズムのいいとこ取りをし、同じアルゴリズムに揃えていきたいと思っています。もちろん言語ごとの差異は残ると思っていて、完全に一緒にするとは思っていませんが、お互いから学べるところは多いはずです。

レシピ検索の詳細な話や検索以外の機能など、ここで書ききれない移行の話はたくさんあるのですが、またいずれどこかの機会に話せたらと思います。また、One experienceプロジェクトについて弊社のTechlifeブログでこれまでに、これからもいろんな記事が投稿されていきます。Techlifeのツイッターで随時更新しています、ぜひチェックしてください。

Gradle Composite Build を用いたビルドロジックの共通化について

はじめに

こんにちは。レシピ事業部で長期インターン中の松本 (@matsumo0922) です。先日このブログでも公開した通り、クックパッドでは日本とグローバルで体験を統一する One Experience というプロジェクトを行っています。

One Experience 以前では Android 開発においても日本とグローバルでコードベースが異なり、それぞれ使用している技術やライブラリが異なる状態でした。特にグローバルのコードベース (以下 global-android と呼びます) では AGP のバージョンも低く、加えて groovy + buildSrc と言った旧世代のビルドロジックを用いていたため、プロジェクトの進行に支障がある状態でした。本記事では、これらの問題を踏まえ One Experience をより円滑に進めるために施したビルドロジックの改善についてお話しします。

TL;DR

  • global-android では旧世代のビルドロジックを使っていたため One Experience 用の機能開発に支障がある状態だった
  • ライブラリ管理を VersionCatalog へ、スクリプトを Kotlin DSL へ移行すると共に、Gradle Composite Build + Convention Plugins を用いてビルドロジックの共通化を行った
  • 最終的に、各モジュールの build.gradle.kts は非常に簡潔になり、大抵のビルドロジックが共通化され、開発をより効率的に進められるようになった

問題点

global-android の Gradle ビルドには以下のような問題点がありました。

  • dependencies.gradle を用いた手動のライブラリ & バージョン管理
  • groovy で書かれたビルドロジック
  • 大量にモジュールがあるにも関わらず、一部ロジックが共通化されていない

global-android でのライブラリ管理

まず、ライブラリのバージョン管理について。上の画像のように、global-android ではライブラリの定義(バージョンも含む)を gradle の ExtraPropertyExtension を用いて記述し、各モジュールに配布していました。この手法の詳細については割愛しますが、ご想像の通りライブラリ管理 & バージョン管理が複雑になり、かつ IDE の手助けや renovate や dependabot と言ったサービスも使うことができないため、開発者の体験を悪くしていました。

次にビルドロジックについて。global-android には buildSrc が導入されていましたが、記述されているのは CI/CD で用いられる共通タスクの定義のみであり、実際のビルドロジックは各モジュールの .gradle ファイルに分散していました。というのも、buildSrc はビルドロジックを記述するのに最適な場所ではあるものの、すべての gradle build のホットパスであるため、あらゆるビルドでコードをコンパイル & チェックを行ってしまうためです。もちろん初回以外はキャッシュが用いられますが、global-android のような巨大プロジェクトでは無視できないコストが発生します。加えて、buildSrc への変更はプロジェクト全体の classpath 変更、つまりキャッシュが無効になるという意味を持つため、buildSrc へビルドロジックを追加するのは慎重にならざるを得ません。

$ find . -type f -name '*.gradle' -exec wc -l {} + | tail -n1  # プロジェクト全体の Groovy ファイルの行数
3736 total

そこで、ライブラリの管理を VersionCatalog に移行しつつ、 gradle の Conposite Build と Convention Plugins と言った手法を用い、ビルドロジックのみをプロジェクトから分離することでコストの削減、ビルドロジックの共通化を計ることにしました。

Composite Build とは

一言で言えば、「プロジェクトから切り離されたビルド」のことです。Composite Build1 内の各 build は include build と呼ばれ、include build 同士はロジックを共有せず、個別に構成 & 実行されます。

Convention Plugins を用いたプロジェクトの例2

上の例では my-app と my-utils を Composite Build を用いて一つのプロジェクトにまとめています。my-app は my-utils に依存を持つことができますが、この場合の依存は「直接的な依存」にはなりません。これらのモジュールは include build であるため、直接的に my-utils を参照することはできず、build を通して生成された実行可能ファイル(バイナリ)を参照することになります。そのため、buildSrc のような実行速度やキャッシュの問題を発生させずに、あらゆるロジックを記述することが可能になります。

Convention Plugins とは

Convention Plugins3 とはビルドロジックを Gradle Plugin System を用いて共通化し、Plugin として配布する手法のことを指します。Plugin の作成方法については Standalone Gradle Plugin と Precompiled Script Plugin が存在します。それぞれの Plugin の作成方法については割愛しますが、今回は Composite Build を使用する都合上、直接スクリプトファイルを参照できないため、Standalone Gradle Plugin を用いることにしました。

まとめると、Composite Build を用いてモジュールを作成し(build-logic モジュールとします)、中で Convention Plugins を用いてビルドロジックを共通化する、と言った手法を取ることにしました。

実装

構成

最初に、最終的なディレクトリ構成を示します。

global-android/
├── build-logic/
│   ├── src/
│   │   └── main/
│   │       └── java/
│   │           ├── convention/
│   │           │   └── FeaturePlugin.kt
│   │           └── primitive/
│   │               ├── ApplicationPlugin.kt
│   │               ├── ComposePlugin.kt
│   │               ├── CommonPlugin.kt
│   │               └── FlavorPlugin.kt
│   ├── build.gradle.kts
│   └── settings.gradle.kts
└── cookpad/
    └── ...

大まかには一般的なモジュールの構成と変わりありません。ただ一つ注意が必要な点は、build-logic は cookpad モジュールに include build されるため、Gradle 的には一つのプロジェクトとして扱われるということです。そのため、直下に settings.gradle.kts を配置して依存の解決方法や Library Repository を宣言する必要があります。今回は build-logic 内で VersionCatalog も用いるため、VersionCatalog の記述も必要です。

src/ ディレクトリ以下に Plugin を配置します。クックパッドでは、単一の機能や構成を提供する Plugin を Primitive Plugin、Primitive Plugin をまとめ一般化した Plugin を Convention Plugin と呼ぶことにし、それぞれの Plugin をディレクトリを分けて配置しています。

build-logic モジュールの作成

ルート直下に build-logic モジュールを作成します。モジュールの作成方法は問いませんが、AndroidStudioのコンテキストメニューから作成してしまうと、不要な proguard ファイルなどが生成されたり、settings.gradle.kts にモジュールとして追加されるなどのお節介が発生するため、あまりおすすめできません。前述の通り、build-logic は Gradle 的には一つのプロジェクトであるため settings.gradle.kts を配置してください。

dependencyResolutionManagement {
    repositories {
        google()
        mavenCentral()
    }
    versionCatalogs {
        create("libs") {
            // アプリプロジェクト側の VesionCatalog を参照する
            from(files("../gradle/libs.versions.toml"))
        }
    }
}

rootProject.name = "build-logic"

内容は通常のプロジェクトの settings.gradle.kts と同様です。Content Filtering などの記述もここに行います。一つ例外的なことは VersionCatalog を明示的に記述していることです。本来 Gradle は./gradle/libs.versions.toml にファイルが配置されていると自動的に libs という名前で拡張プロパティを生成しますが、build-logic から見れば toml ファイルは ../gradle/libs.versions.toml に配置されているため、明示的に記述する必要があります。もちろん、build-logic 固有の toml ファイルを別途用意する場合はこの記述は必要ありません。

build.gradle.kts についても、通常のプロジェクトとなんら変わりません。

plugins {
    `kotlin-dsl`
}

java {
    sourceCompatibility = JavaVersion.VERSION_17
    targetCompatibility = JavaVersion.VERSION_17
}

dependencies {
    implementation(libs.android.gradlePlugin)
    implementation(libs.kotlin.gradlePlugin)
}

拡張関数の作成

build-logic 内では VersionCatalog の拡張プロパティが用意されていなかったり、implementation() や api() と言った DSL が用意されていないため、自力で実装する必要があります。

// VersionCatalog を取得するための拡張プロパティ
internal val Project.libs: VersionCatalog
    get() = extensions.getByType<VersionCatalogsExtension>().named("libs")

internal fun VersionCatalog.version(name: String): String {
    return findVersion(name).get().requiredVersion
}

internal fun VersionCatalog.library(name: String): MinimalExternalModuleDependency {
    return findLibrary(name).get().get()
}

internal fun VersionCatalog.plugin(name: String): PluginDependency {
    return findPlugin(name).get().get()
}

internal fun VersionCatalog.bundle(name: String): Provider<ExternalModuleDependencyBundle> {
    return findBundle(name).get()
}
// 通常のライブラリを implementation するための拡張関数
internal fun DependencyHandlerScope.implementation(artifact: Project) {
    add("implementation", artifact)
}

// bundle などを implementation するための拡張関数
internal fun DependencyHandlerScope.implementation(artifact: MinimalExternalModuleDependency) {
    add("implementation", artifact)
}

// 通常のライブラリを api するための拡張関数
...

加えて、Plugin 内で Extension を便利に利用するための拡張関数も用意しておきます。Gradle 7.1 より BaseAppModuleExtension と LibraryExtension の基底クラスが CommonExtension に変更されているため、実装を共通化できるようになっています。

// 通常の build.gradle.kts の android ブロックに相当
internal fun Project.androidExt(configure: BaseExtension.() -> Unit) {
    (this as ExtensionAware).extensions.configure("android", configure)
}

// Android Project の build.gradle.kts のスコープ
internal fun Project.commonExt(configure: CommonExtension<*, *, *, *, *, *>.() -> Unit) {
    val plugin = if (isApplicationProject()) BaseAppModuleExtension::class.java else LibraryExtension::class.java
    (this as ExtensionAware).extensions.configure(plugin, configure)
}

// この Project が Application Project であるか判定
internal fun Project.isApplicationProject(): Boolean {
    return project.extensions.findByType(BaseAppModuleExtension::class.java) != null
}

// この Project が Library Project であるか判定
internal fun Project.isLibraryProject(): Boolean {
    return project.extensions.findByType(LibraryExtension::class.java) != null
}

Plugin の実装

global-android では最終的に以下のような Plugin 構成になっています。

  • Convention Plugin
    • cookpad.convention.android.feature
  • Primitive Plugin
    • cookpad.primitive.android.application
    • cookpad.primitive.android.library
    • cookpad.primitive.android.compose
    • cookpad.primitive.android.flavor
    • cookpad.primitive.android.lint
    • cookpad.primitive.detekt
    • cookpad.primitive.common

Primitive Plugin では単一の機能や構成を提供し、他の Primitive Plugin に依存することがないようにする必要があります。Convention Plugin は Primitive Plugin を参照することはできますが、その逆はできません。また、Convention Plugin 同士の参照がないように注意してください。

global-android での Plugin 実装例をいくつか挙げていきます。

FeaturePlugin

cookpad.convention.android.feature は global-android に大量に存在する Feature モジュールが参照する Convention Plugin として実装されています。Primitive Plugin を纏めるだけでなく、モジュールの依存関係やライブラリの依存もここで定義することにより、Feature モジュールとしての実装を強制することが可能になっています。

package convention

class FeaturePlugin : Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            with(pluginManager) {
          // 必要な Primitive Plugin を記述
                apply("cookpad.primitive.android.library")
                apply("cookpad.primitive.android.compose")
                apply("cookpad.primitive.android.flavor")
                apply("cookpad.primitive.android.lint")
                apply("cookpad.primitive.common")
                apply("cookpad.primitive.detekt")
            }

            dependencies {
          // Feature モジュールが依存すべきモジュールを記述
                implementation(project(":core"))
                implementation(project(":entity"))
                implementation(project(":usecase"))
                implementation(project(":repository"))
                implementation(project(":view-components"))

                // 共通のライブラリなどを記述
                implementation(libs.bundle("kotlin"))
                implementation(libs.bundle("koin"))

                implementation(libs.library("androidx-appcompat"))
                implementation(libs.library("google-material"))

                testImplementation(libs.bundle("test"))
            }
        }
    }
}

LibraryPlugin

cookpad.primitive.android.library は Library モジュールが利用する Plugin です。Convention Plugin からも参照されていますが、この Plugin では targetSdkVersion や compileSdkVersion、その他様々なビルドオプションなどを記述しています。CommonExtension を利用して設定できる項目は configureAndroid という関数に切り出し、ApplicationPlugin と共通化しています。

package primitive

class LibraryPlugin: Plugin<Project> {
    override fun apply(target: Project) {
        with(target) {
            // LibraryPlugin は "com.android.library" のみを apply
            // その他の機能が必要な場合は別の Plugin を作成する
            pluginManager.apply("com.android.library")

            extensions.configure<LibraryExtension> {
                // Android Project に必要な設定
                configureAndroid(this)

                defaultConfig.targetSdk = libs.version("targetSdk").toInt()
                buildFeatures.viewBinding = true
                buildFeatures.buildConfig = true
            }
        }
    }
}

internal fun Project.configureAndroid(commonExtension: CommonExtension<*, *, *, *, *, *>) {
    commonExtension.apply {
        defaultConfig {
       // global-android では minSdkVersion や compileSdkVersion なども VersionCatalog に記述している
            minSdk = libs.version("minSdk").toInt()
            compileSdk = libs.version("compileSdk").toInt()
        }

        testOptions {
            animationsDisabled = true
        }

        compileOptions {
            sourceCompatibility = JavaVersion.VERSION_17
            targetCompatibility = JavaVersion.VERSION_17
            encoding = "UTF-8"
        }

        packaging {
            resources.excludes.addAll(
                listOf(...)
            )
        }
    }
}

FlavorPlugin

cookpad.primitive.android.flavor は Build Flavor を設定する Plugin です。先日このブログにも投稿4された通り、 global-android では日本のコードベースを統合する際に applicationId などの都合上、 Build Flavor を用いて日本向けビルドをグローバル向けビルドに切り替える方式を採用しています。デバッグ用、Global Production用、JP Production 用などと複数のビルド設定を各モジュールに記載するのは極めて非効率であるため、Plugin に纏めるようにしています。

package primitive

class FlavorPlugin: Plugin<Project> {
    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("global") {
                dimension = Dimension.REGION
                isDefault = true
            }

            // JP リージョンを設定
            create("jp") {
                dimension = Dimension.REGION

                extra.apply {
                    set(ExtraKey.ApplicationId, "com.cookpad.android.activities")
                    set(ExtraKey.VersionCode, jpVersion.getVersionCode(project))
                    set(ExtraKey.VersionName, jpVersion.getVersionName(project))
                }
            }

            // Production トラックを設定
            create("production") {
                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
            }
        }
    }
}

Tips: KTS ファイルを用いて Convention Plugins を作る

今回は org.gradle.Plugin を実装する手法をとっていますが、*.gradle.kts ファイルを用いて Convention Plugins を作る方法も存在します。

実装は非常に簡単で my-convention-plugin.gradle.kts と言ったファイルを build-logic 内の src/ ディレクトリに配置するだけです。src/ 内に配置された *.gradle.kts ファイルは Plugin クラスにプリコンパイルされ、例の場合は my-convention-plugin の部分を key として使うことができるようになります。加えて、後述する Plugin の登録が不要だったり、settings.gradle.kts のロジックを共通化して Convention Plugins にすることができたりなど、様々なメリットも存在します。

通常のビルドロジック共通化には飽きた!という方は、kotlinx-rpc のリポジトリの中で具体的な実装例を見れますので、ぜひ参考にしてください。

github.com

Plugin の登録

作成した Plugin はスタンドアローンの JAR ファイルとしてプリコンパイルされる Binary Plugin であるため、アプリプロジェクトから参照するためには Gradle に Plugin を登録してあげる必要があります。

前述した build-logic の build.gradle.kts 内で以下のようにして Plugin を登録します

gradlePlugin {
    plugins {
        // Convention Plugins
        register("ConventionFeature") {
            id = "cookpad.convention.android.feature"
            implementationClass = "convention.FeaturePlugin"
        }

        // Primitive Plugins
        register("PrimitiveApplication") {
            id = "cookpad.primitive.android.application"
            implementationClass = "primitive.ApplicationPlugin"
        }
        register("PrimitiveLibrary") {
            id = "cookpad.primitive.android.library"
            implementationClass = "primitive.LibraryPlugin"
        }
        ...
    }
}

Include Build の設定

以上で build-logic モジュールの記述はほぼ完了したため、最後にアプリプロジェクト側で build-logic を include build として指定し、加えてこれまで記述した Plugin に実装を移していきます。

include build として指定する方法は簡単で、アプリプロジェクト側の settings.gradle.kts に以下のように変更を加えるだけです。間違って通常のモジュールと同様に include しないように気をつけてください。

pluginManagement {
    includeBuild("build-logic") // build-logic モジュールを include build する
    repositories {
        google()
        mavenCentral()
    }
}

dependencyResolutionManagement {
    repositoriesMode.set(RepositoriesMode.PREFER_PROJECT)
    repositories {
        google()
        mavenCentral()
    }
}

最後に各モジュールの build.gradle に記述していたビルドロジックを Plugin に移行します。以下の画像はあるモジュールの build.gradle にあった記述を Plugin に移行した例です。サイズの関係上、特に複雑なロジックが記述されていないモジュールの build.gradle を例としていますが、build variant や build flavor を記述しているモジュールの場合、さらに多くの行数を削減することができました。ほぼ全ての記述を Plugin を用いて共通化できているため、最終的な build.gradle.kts のモジュール固有の記述は namespace のみとなっています。

あるモジュールの build.gradle.kts

まとめ

最終的なプロジェクト全体の build.gradle.kts の行数は以下のとおりです。

$ find . -type f -name '*.gradle.kts' -exec wc -l {} + | tail -n1  # 全体の行数
1823 total

Gradle Composite Build + Convention Plugins を用いたビルドロジックの共通化により、記述量を半分近く削減することが可能となりました。

この改善は私が One Experience の開発に携わる際に一番最初に行ったものです。前述した Build Flavor の設定に加え、CI、リリースなどコードベースが統合することでプロジェクトとしてもより複雑なビルドロジックを持たざるを得なくなるだろうと予想し、本格的な開発に入る前に今回の改善を行いました。実際、プロジェクトを進めるにあたって今回お話ししたような複雑なロジックが幾度となく追加されており、今回の改善がこれらのロジックをより簡潔かつ簡易に導入することができる状態としています。global-android のような巨大なプロジェクトでビルドロジックに手を入れるのは非常に困難でしたが、今回の改善が開発者自身の体験、引いては One Experience プロジェクト全体の進行にも向上にも大きく貢献することができたと自負しています。