Чуть больше года назад мы опубликовали статью “Пасущиеся слоны”, в которой объяснили, как мы — команда Square Mobile Developer Experience (MDX) для Android — успешно модернизировали логику сборки приложений Square для точек продаж. В том посте мы обосновали, почему мы это сделали, и то, что, по нашему мнению, мы получили от этих усилий (например, лучшее разделение задач, тестируемость и, в конечном итоге, повышение производительности). Однако это обоснование было довольно абстрактным, поскольку речь просто шла о том, чтобы рассматривать домен сборки как легитимный домен разработки программного обеспечения и следовать тем же лучшим практикам разработки программного обеспечения, что и для функциональной работы. Эти абстрактные принципы, а также тот факт, что миграция лучших практик была завершена совсем недавно на момент написания статьи, могли вызвать у читателя вполне оправданный скептицизм.
Этот пост является триумфальным продолжением прошлогоднего. Мы покажем, что предсказанные выгоды были реальными, прочными и кумулятивными. Наша сборка стала быстрее, чем до запуска проекта, даже после добавления миллиона строк кода (увеличение на 33%). Мы твердо верим, что наш подход можно обобщить, и что выбор по умолчанию должен быть сосредоточен на лучших практиках и постепенных улучшениях (которые могут открыть большие выигрыши, например, когда мы в конечном итоге смогли включить кэширование конфигурации по умолчанию), и что рискованные попытки или серебряные пули следует рассматривать с большим скептицизмом. Впрочем, как всегда, YMMV.
Чего ожидать, если вы продолжите читать
Это не техническое глубокое погружение. Наоборот, это высокоуровневый обзор того, что мы сделали, почему мы это сделали, что мы получили в результате усилий и почему мы считаем, что наш подход можно обобщить.
Покажите мне данные
Но сначала давайте обновим статистику репозитория с прошлого раза. Год назад репозиторий Point of Sale приложения содержал около 3 миллионов строк кода, разбросанных по 3500 модулям Gradle. Многие десятки разработчиков, работающих над ним, ежедневно запускали около 2000 локальных сборок, что в сумме давало времени на 3 дня в день. Наша система непрерывной интеграции выполняла более 11,000 сборок в день, при этом совокупная стоимость сборки составляла более 48 дней в день. Сегодня тот же репозиторий содержит более 4 миллионов строк кода (+33%), распределенных по почти 4400 модулям Gradle (+25%). Разработчики запускают около 2200 сборок в день (+10%), а совокупная стоимость составляет около 3 дней в день (±0%). Наша система непрерывной интеграции теперь выполняет почти 12,000 сборок в день (+9%), или более 58 дней в день (+30%).
Почти по всем показателям наш репозиторий и связанные с ним показатели значительно выросли по сравнению с прошлым годом. Исходя из этого роста, было бы разумно ожидать, что время локальной сборки также будет расти (то есть ухудшаться). Так ли это?
На самом деле нет. С тех пор как мы начали модернизировать нашу сборку Gradle около полутора лет назад, мы продолжали бороться с длинным хвостом устаревших идиом, добиваясь постепенных успехов (см. ниже некоторые примеры улучшений). Эти дополнительные преимущества сложились таким образом, что сегодня, несмотря на огромный рост размера кода, наши локальные сборки на самом деле быстрее, чем они были до того, как мы начали. В реальности производительность локальной сборки улучшилась настолько, что, несмотря на то, что теперь у нас больше разработчиков, которые запускают больше сборок, совокупное время, затрачиваемое на эти сборки, вообще значительно не увеличилось. Мы поставляем больше функций, быстрее.
Больше кода не обязательно означает более медленную сборку.
Путешествие начинается
Еще в 2019 году наш опыт локальной разработки был далек от оптимального. Локальные сборки занимали «вечность», и из-за невозможности заставить Buck работать на нас, мы просто мигрировали с него и вернулись к Gradle. Этот кризис и его решение были хорошо сформулированы бывшим инженером Square Ральфом Вондрачеком в его докладе на Droidcon Android at Scale @Square. В этом выступлении Ральф описал нашу (тогда новую) модульную структуру и концепцию изолированных или «демо» приложений, которые давали разработчикам возможность итерировать свои новые функции в меньшей песочнице, а не в полном (и очень дорогом в создании) приложении. К сожалению, следствием этой структуры стал взрывной рост количества модулей, который со временем привел к снижению производительности сборки и самой IDE из-за неожиданной неэффективности как системы сборки Gradle, так и Android Gradle Plugin (AGP).
Самая большая неэффективность Gradle в этом контексте — его однопоточная фаза конфигурации, а это означало, что каждый дополнительный модуль создавал постоянные накладные расходы в сборку. Поскольку мы добавляли модули с постоянной скоростью, у нас наблюдался линейный рост времени конфигурации, что сильно сказывалось на времени сборки и синхронизации IDE. Самая интересная неэффективность AGP заключается в том, что каждый Android-модуль имеет большое количество экземпляров конфигурации (корзин зависимостей), что приводит к резкому увеличению использования кучи (памяти). Поскольку мы добавляли модули Android с постоянной скоростью, это приводило к почти линейному росту использования кучи с течением времени, оказывая все большее давление на всю нашу инфраструктуру.
В ответ на эти проблемы с производительностью и другие задачи мы приступили к тому, что оказалось двунаправленным подходом. Во-первых, мы вложили средства в преобразование нашей сборки в Bazel, что было рискованной стратегией из-за ее принципа «все или ничего», но, тем не менее, имело огромные перспективы, если бы мы смогли это осуществить. Когда это не окупилось в сроки, на которые мы надеялись (и когда стали очевидны другие, более насущные проблемы, такие как производительность IDE и проблемы CI), мы начали углублять наше понимание инструмента сборки Gradle, а также наших отношений с Gradle, Inc. Давайте рассмотрим эти две стратегии по очереди.
Шардинг CI: особое примечание
Хотя это и не является основной темой этого поста, стоит отметить, что одним из преимуществ, предоставляемых Bazel «из коробки», является мощная возможность запрашивать граф сборки без необходимости настраивать какую-либо сборку. Это позволяет довольно просто создать разделитель CI, который может, скажем, пропустить 80% шардов (работ) в любом заданном PR. В нашем backend репозитории Java мы используем эту функцию, чтобы сэкономить буквально миллионы долларов в год на затратах, связанных с CI. В нашем Android-репозитории для Point of Sale, в котором используется Gradle, нам пришлось создать пользовательскую систему запросов для достижения тех же результатов. Она работает очень хорошо, но намного медленнее, потому что мы пока не можем пропустить дорогостоящую фазу конфигурирования Gradle. Это область постоянных исследований и разработок команды MDX.
Серебряные пули
Bazel — единственный инструмент сборки, «чтобы править ими всеми». Бесконечное масштабирование, отсутствие узких мест в конфигурации, объединение серверной части и двух мобильных экосистем под одной крышей. И по правде говоря, мы добились большого успеха с ним в нашем Java бэкэнде и iOS-репозитории. Тем не менее, мы должны признать, что мы не добились большого успеха с Bazel в нашем репозитории Point of Sale для Android. Этому есть несколько причин, которые можно обобщить следующим образом — Bazel не является официальным инструментом сборки для Android. Наше историческое использование Gradle (то есть официального инструмента сборки для Android) позволило нам более или менее быстро осваивать новые функции в экосистеме сборки . Некоторые из этих функций, которые либо еще не реализованы в Bazel, либо реализуются с большим отставанием, включают в себя — пакеты приложений (открытый тикет); D8 (открытый PR); R8 (открытый PR); биндинг view (открытый тикет).
Кроме того, Bazel, Bazel Android Rules и плагин Bazel IntelliJ имеют слабую поддержку в других областях, где мы больше доверяем официальной цепочке инструментов (а именно: Gradle и AGP), включая предварительную интеграцию Jetpack Compose в IDE (открытая проблема), а также множество других интеграций IDE (список открытых проблем). В конце концов, с Bazel мы сами несем ответственность за интеграцию с IDE, что означает очень значительные вложения с небольшой гарантией окупаемости по сравнению с агрессивными инвестициями специальной команды Google, явно вложенными в ванильную Android Studio.
В качестве примера того, как это может проявляться на практике (и как решения в одной области — системе сборки — могут иметь последствия в других областях — производительность IDE и цикле разработки), рассмотрим Android Studio Electric Eel. Это настолько значительное улучшение по сравнению с предыдущими версиями Android Studio, что ее поддержка может стать всей нашей стратегией удержания разработчиков. И мы получили эти улучшения почти полностью «бесплатно», в результате того, что не игнорировали официальный набор инструментов. Достижение тех же результатов с Bazel… ну, сначала потребуется, чтобы вся наша сборка работала с Bazel.
Пока это огромная разница. Анализ кода занимает всего секунду или две… около 1–2 секунд, чтобы проанализировать весь файл и включить подсветку синтаксиса. Все еще небольшая задержка при фактическом наборе… но раньше это занимало несколько минут (если вообще когда-либо заканчивалось) для анализа файла, а набора текста в реальном времени не существовало — от фиче-инженера о том, что значит разрабатывать с помощью Android Studio Electric Eel.
Постепенные улучшения, накопление выгоды
Gradle — инструмент сборки, который все любят ненавидеть (если судить по твиттерским передрягам). С его хорошо известными проблемами масштабирования очевидно, что Gradle не подходит для одного из крупнейших в мире Android-репозиториев. И все же: а если бы это было не так? Что, если вместо того, чтобы жаловаться, мы серьезно отнесемся к области сборки как к области приложений усилий, создадим (небольшую) специальную команду и предпримем согласованные усилия? Что если мы воспользуемся всеми лучшими практиками — не только специфичными для Gradle, но и общими для самой разработки программного обеспечения — что если мы станем неумолимыми? А что если мы сделаем это все для официальной цепочки инструментов, чтобы наша небольшая (но мощная) команда могла пожинать плоды сотрудничества с отраслевыми партнерами, такими как Google, Gradle и JetBrains? Инвестиции в официальный тулчейн — это решение принять бесплатные множители. Это также инвестиции в сообщество, поскольку только крупнейшие компании с самыми большими бюджетами смогут сделать инвестиции, необходимые для того, чтобы поддерживать работу с Bazel. И, как мы достаточно ясно продемонстрировали, нет необходимости переключать инструменты сборки. Мы доказали, что Gradle может обрабатывать большие кодовые базы Android. Инвестиции в официальные инструменты — это решение использовать бесплатные множители.
Хотя этот пост не претендует на глубокое погружение, мы хотим перечислить некоторые конкретные изменения, которые мы внесли, исправляя нашу Gradle сборку. Здесь нет никакой магии! Обратите внимание, что некоторые из этих изменений также обсуждаются здесь, а некоторые даже применяются с помощью плагина Gradle Best Practices.
buildSrc мертв. Да здравствует build-logic!
Это изменение подробно обсуждается в статье «Пасущиеся слоны», но, подытоживая, мы твердо убеждены, что в крупных проектах следует избегать buildSrc и вместо этого использовать composite build facility в Gradle. Очень крупным проектам следует серьезно подумать о том, чтобы сделать еще один шаг и опубликовать свои плагины Gradle и использовать их в качестве двоичных файлов в своих основных сборках, поскольку это позволяет избежать накладных расходов на конфигурирование, что легко видеть при использовании составных сборок.
Будьте ленивыми
По мере развития Gradle в последние годы он все больше отходит от того, что он называет «нетерпеливыми API» (eager API), к «ленивым API» (lazy API). Читатели должны ознакомиться с документацией по предотвращению конфигурации задач и ленивой конфигурации, чтобы подробно обсудить, как отложить работу и сделать ваши сборки как можно более ленивыми.
Удалите скрипты
Плагины скриптов — это скрипты Gradle, которые можно применять произвольно. Подумайте о apply from: ‘complicated_thing.gradle’. Это плагин Gradle для плохихи разработчиков, который может увеличить использование кучи и затруднить понимание ваших сборок. Просто напишите плагин.
Устраните межпроектную конфигурацию
Наиболее распространенными примерами межпроектной конфигурации являются использование блоков subprojects и allprojects. Такая плохая конфигурация предотвратит использование предстоящей функции изоляции проекта и опасно свяжет проекты в вашей многопроектной сборке друг с другом, помимо других плохих последствий.
Тестируйте свою сборку
Нет, мы не имеем в виду неявный интеграционный тест под названием «Моя сборка все еще работает?» Если вы на самом деле тестируете свои плагины с помощью Gradle TestKit, вы можете быть уверены, что эти плагины работают и что ваши изменения ничего не сломали. У нас есть буквально сотни таких тестов, которые выполняются на CI (если считать их параметризацию). Например, функциональные тесты для Dependency Analysis Gradle Plugin, стиль которого сильно повлиял на наши собственные тесты. Тестирование позволяет нам выполнять итерации быстро и уверенно, а также открывает большую часть остальных улучшений, обсуждаемых здесь.
Используйте кэш конфигурации
Одной из самых полезных вещей, которые мы сделали (и которая была определена всеми остальными вещами!) было включение кэша конфигурации по умолчанию. По нашим оценкам, это экономит нам около 5400 часов разработчиков ежегодно.
Приведенный выше список далеко не исчерпывающий, но он должен дать читателю представление о том, на чем сосредоточить усилия.
Мы озаглавили этот раздел “Постепенные улучшения, накопление выгоды”, потому что именно это мы регулярно получали, инвестируя в нашу существующую систему сборки. Мы думаем, что преимущество здесь очевидно — поскольку мы смогли продемонстрировать почти постоянный (а иногда ступенчатый) прогресс, поддержка проекта со временем только росла, и всегда было очень легко оправдать дальнейшие инвестиции. В конце концов мы достигли точки, когда жалобы на систему сборки практически исчезли.
Путешествие продолжается
Gradle не идеален, и у нас большие планы на 2023 год. Мы думаем, что одним из самых критических недостатков Gradle является откровенно чрезмерный объем памяти, который потребляют различные процессы Gradle, если вы дадите им шанс. Эта проблема сильно улучшилась с тех пор, как мы начали использовать кэш конфигурации по умолчанию, потому что сборки, которые повторно используют этот кэш, используют намного меньше памяти, чем те, которые этого не делают. Однако это не помогает тем сборкам, которые не могут повторно использовать кэш, и многие сборки по-прежнему этого не делают, включая сборку, связанную с процессом синхронизации IDE. Прямо сейчас Gradle требует до 30 Гб памяти для синхронизации всего нашего репозитория, что слишком много, а также создает неустойчивую ситуацию. Мы надеемся на тесное сотрудничество с Gradle по этим и другим вопросам потребления памяти в следующем году.
Еще одна давняя проблема заключается в том, что этап конфигурации сборки Gradle является однопоточным, что является существенным узким местом для больших сборок, таких как наша (с ее 4400 модулями). Это, наряду с сопутствующими проблемами, такими как детальная конфигурация, является тем, в чем Gradle стремится добиться прогресса в 2023 году с помощью своей инициативы по изолированным проектам. Мы рассчитываем на тесное сотрудничество с ними в тестировании этой функции.
Шаблоны решений: серебряные пули и постепенная выгода
Этот извечный вопрос: исправить то, что у меня есть, или переписать с нуля? Мы все знаем, что единственный реальный ответ на этот вопрос — «это зависит». Компании, которые перенесли свои сборки Android на Bazel, вполне могли сделать правильный выбор в своих конкретных обстоятельствах. (Стороннему наблюдателю буквально невозможно определить правильность решения с какой-либо уверенностью. Технические блоги — это не научно-исследовательские работы, понимаете?) Однако мы думаем, что показали в этом посте, что для производительности команды разработчиков возможно постепенно достичь своих целей, исправляя существующие системы — не нужно заменять их оптом.
Некоторые из действующих здесь факторов включают:
- Сроки. Возможно, сейчас Gradle лучше, чем раньше. Например, Gradle становится все более ленивым.
- Ресурсы. Компания X может иметь средства для создания большой команды, занимающейся инструментами сборки, в то время как компания Y более ограничена.
- Экспертиза. Компания X может иметь значительный опыт в Bazel, в то время как компания Y имеет доступ к экспертизе в Gradle.
- Сотрудничество. Иногда улучшение требует работы с командами в нескольких компаниях. На установление таких отношений уходят годы.
Давайте резюмируем. В этой статье мы показали, что даже очень большие репозитории Android могут успешно управлять производительностью сборок с помощью Gradle. Изменения могут быть достигнуты постепенно, и выгоды часто складываются. Изменения также могут быть достигнуты прозрачно, не требуя, чтобы разработчики фич изучали новую систему сборки или каким-либо образом изменяли свои рабочие процессы. Поскольку повышение производительности происходит регулярно, работа, как правило, оправдывает себя до тех пор, пока вы не забываете постоянно проводить бенчмаркинг. Ничто из этого не требует волшебства, но требует самоотверженности и веры (которую мы надеемся привить здесь!), что это возможно.
Серебряные пули, с другой стороны, хотя часто и интересны, несут в себе гораздо больший риск: они могут никогда не выстрелить. Вы можете приложить огромные усилия для перехода на совершенно новую систему сборки в течение нескольких месяцев или лет, и в конце концов это либо окупится, либо нет. Думайте об этом как о переписывании приложения: кому не нужен красивый проект с нуля, без legacy? Начните сначала, сделайте это правильно с самого начала! Тем не менее, этот «устаревший» код содержит много неявных знаний о предметной области, которые больше нигде не зафиксированы. Ваша переделка может никогда не завершиться, а если и завершится, то почти наверняка займет больше времени, чем предполагалось, а тем временем вы не внедрите новые функции и исправления ошибок для существующих пользователей, потому что ваша команда сосредоточена на рискованной переделке. Вы можете, наконец, выпустить новый продукт только для того, чтобы обнаружить, что теперь у вас просто другие ошибки!
Опытные инженеры знают, что решение сложной проблемы всегда зависит от контекста; то есть, “это зависит”. Но теперь мы точно знаем, что в контексте сборки пространство решений включает и Gradle.