Внедрение зависимостей на этапе компиляции в масштабе: как Kora сохраняет скорость больших проектов¶
4 августа 2026
Внедрение зависимостей на этапе компиляции обладает очевидной привлекательностью: перенести обнаружение зависимостей, проверку графа и связывание из запуска приложения в компилятор, а затем запускать сервис с уже известным графом. Время выполнения становится проще, запуск — более детерминированным, а многие ошибки, которые в противном случае появлялись бы только при создании контейнера, превращаются в обычные ошибки компиляции.
Есть и очевидное возражение.
Если фреймворк выполняет больше работы во время компиляции, что происходит, когда приложение становится большим?
Наивную картину легко нарисовать:
Огромный монолитный модуль компиляции
↓
множество компонентов
↓
большой граф зависимостей
↓
больше работы обработки аннотаций
↓
больше инвалидаций после каждого изменения
↓
медленнее обратная связь разработчику
Если бы это была единственная возможная архитектура, то внедрение зависимостей на этапе компиляции в конце концов столкнулось бы с собственной проблемой масштабирования. Фреймворк экономил бы работу во время выполнения, концентрируя всё больше и больше работы в одном огромном шаге компиляции. Изменение небольшой функциональности могло бы заставить компилятор и процессор внедрения зависимостей пересмотреть значительную часть приложения. В таком мире возражение было бы справедливым.
Но большое приложение не обязано оставаться одним огромным модулем компиляции.
Это различие является ключевым для понимания того, как фреймворк Kora подходит к внедрению зависимостей на этапе компиляции в большой кодовой базе. Kora не требует, чтобы каждый компонент сервиса
находился в одном проекте Gradle, а затем заставлял один вызов обработки аннотаций заново обнаруживать весь сервис после каждого изменения. Его модель внедрения зависимостей включает явный механизм
для многомодульных приложений: @KoraSubmodule.
Это меняет модель масштабирования.
Достаточно большой сервис вместо этого может выглядеть так:
Каждый домен — это модуль Gradle. Каждый модуль компилируется независимо. Каждый модуль может предоставлять свои объявления Kora через границу подмодуля. Финальный модуль приложения остаётся корнем композиции, но ему больше не нужно, чтобы каждый исходный файл всей кодовой базы принадлежал его собственному модулю компиляции.
Получившаяся топология сборки ближе к такой:
Большая кодовая база
↓
Модули Gradle
↓
Границы KoraSubmodule
↓
независимая компиляция
↓
инкрементальная пересборка затронутых модулей
↓
финальная композиция графа Kora
Это приводит к более полезному способу думать о внедрении зависимостей на этапе компиляции в масштабе:
Внедрение зависимостей на этапе компиляции не означает компиляцию всего приложения с нуля после каждого изменения. В правильно модуляризованной кодовой базе границы компиляции и архитектурные границы могут быть одним и тем же.
Это гораздо более сильная модель, чем просто надежда на то, что обработчик аннотаций станет достаточно быстрым, чтобы пережить бесконечно растущий монолит. Она делает масштабируемость сборки отчасти архитектурным свойством. Те же границы модулей, которые уменьшают связанность, делают владение более ясным и ограничивают направление зависимостей, могут также ограничивать объём обработки фреймворка, который должен происходить после локального изменения.
Поэтому подход Kora — это не только «делать внедрение зависимостей на этапе компиляции». Более интересная идея в том, что внедрение зависимостей на этапе компиляции может участвовать в обычной инкрементальной модели сборки модульного JVM-приложения.
Модель стоимости внедрения зависимостей на этапе компиляции¶
Чтобы понять, почему модуляризация важна, полезно разделить несколько видов работы, которые часто объединяются в фразу «этап компиляции».
Сборка Kora не просто запускает один загадочный этап внедрения зависимостей. У сборки есть граф задач, у компилятора есть исходные входные данные, у обработчиков аннотаций или KSP есть свои входные и выходные данные, сгенерированный исходный код компилируется, а Gradle решает, какие задачи актуальны, какие можно восстановить из кеша, какие могут выполняться параллельно и какие нижестоящие модули действительно нуждаются в перекомпиляции.
На высоком уровне компиляция Kora может включать такую работу:
Компиляция исходников
├── компиляция языка
├── обработка аннотаций / KSP
│ ├── обнаружение компонентов
│ ├── анализ графа
│ ├── сгенерированные фабрики
│ ├── сгенерированные репозитории
│ ├── сгенерированные мапперы
│ ├── сгенерированный HTTP-код
│ └── сгенерированный AOP-код
└── компиляция сгенерированных исходников
Эта работа реальна. Kora намеренно платит за анализ фреймворка во время сборки, потому что результатом является обычный сгенерированный код, а не сканирование classpath во время выполнения, рефлексивное разрешение, динамические прокси или сборка контейнера.
Ключевой вопрос не в том, существует ли работа на этапе компиляции. Она существует.
Полезный вопрос: какова единица инвалидации этой работы?
Если единица инвалидации — «весь репозиторий», то растущий репозиторий в конечном счёте порождает всё более дорогой цикл правка-компиляция. Если единица инвалидации — «затронутый проект Gradle плюс нижестоящая работа, чьи входные данные действительно изменились», то рост можно контролировать гораздо лучше.
Именно поэтому структура сборки имеет значение.
Тысяча классов в одном модуле компиляции и тысяча классов, разделённых на десять правильно изолированных модулей, могут представлять примерно одинаковый объём исходного кода, но они не представляют одинаковую проблему инкрементальной сборки. В монолитном случае один вызов компилятора видит всё. В модульном случае Gradle может рассуждать о независимых задачах, выходах модулей, рёбрах зависимостей, изменениях ABI, кешах и параллельном выполнении.
Граф зависимостей внутри работающего приложения всё ещё может быть большим. Граф исходников, который нужно пересмотреть после одной правки, — не обязан.
@KoraApp: финальный корень композиции¶
Контейнер зависимостей Kora начинается с @KoraApp. Аннотированный интерфейс — это точка сборки полного графа приложения.
Небольшое приложение может разумно держать большинство своих объявлений в одном модуле Gradle:
Компоненты, обнаруженные в этой области компиляции, фабрики модулей, подключённые к приложению, и сгенерированные компоненты, требуемые графом, разрешаются в финальный контейнер приложения.
Для небольшого или среднего сервиса это может быть именно то, что нужно. Один модуль легко обозреть, легко собрать, и он избегает накладных расходов структуры сборки. Модуляризация не является автоматическим улучшением только потому, что она возможна.
Проблема масштабирования появляется, когда кодовая база становится достаточно большой, чтобы один исходный набор перестал быть естественной единицей владения или компиляции. Сервис может накапливать несколько бизнес-доменов, слои интеграции, области доступа к данным и группы функциональности. Если всё это остаётся физически внутри одного проекта Gradle, каждый процессор фреймворка, работающий в этой компиляции, видит всё более широкий мир.
Соблазнительный, но неверный вывод состоит в том, что внедрение зависимостей на этапе компиляции поэтому требует постоянного компромисса:
Модель Kora не требует, чтобы этот компромисс рос без ограничений. Точка композиции приложения может оставаться централизованной, в то время как обнаружение компонентов распределено по модулям компиляции.
Именно для этого предназначен @KoraSubmodule.
@KoraSubmodule: сохранение внедрения зависимостей через границы компиляции¶
@KoraSubmodule отмечает границу модуля, которая участвует в модели внедрения зависимостей Kora на этапе компиляции.
Концептуально подмодуль говорит:
Этот модуль Gradle владеет набором компонентов и объявлений модулей Kora. Скомпилируй и обобщи их здесь, а затем сделай этот набор доступным финальному графу приложения.
Например, представим проект Gradle catalog:
Этот проект может содержать собственные классы @Component и интерфейсы @Module. Во время компиляции Kora генерирует реализацию подмодуля, представляющую объявления, видимые в этом модуле
компиляции.
Финальный проект application затем может подключить эту границу:
Важная архитектурная деталь в том, что главному приложению не нужно, чтобы все эти исходники доменов стали одним гигантским модулем компиляции. Каждый модуль домена компилируется как собственный проект Gradle и вносит сгенерированную поверхность модуля в финальную композицию.
Более реалистичная форма зависимостей может выглядеть так:
┌─────────────────┐
│ application │
│ @KoraApp │
└────────┬────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
▼ ▼ ▼
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ users │ │ orders │ │ billing │
│@KoraSubmodule│ │@KoraSubmodule│ │@KoraSubmodule│
└──────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
▼ ▼ ▼
components, components, components,
repositories, repositories, repositories,
controllers integrations clients
Время выполнения по-прежнему получает один связный граф зависимостей. Сборке не нужно рассматривать весь репозиторий как один вызов компилятора.
Это и есть центральное свойство масштабирования.
Архитектурная модуляризация становится модуляризацией сборки¶
Большие системы обычно модуляризуются по архитектурным причинам задолго до того, как производительность сборки становится доминирующей заботой.
Причины хорошо знакомы:
- один домен не должен без необходимости залезать в реализацию другого домена;
- владение должно быть видимым;
- у зависимостей должно быть направление;
- публичные API должны быть меньше деталей реализации;
- инфраструктурный код не должен протекать повсюду;
- тесты должны иметь возможность нацеливаться на связную подсистему;
- команды должны иметь возможность менять одну область, не понимая весь репозиторий.
Внедрение зависимостей на этапе компиляции добавляет ещё одно преимущество: те же границы могут ограничивать анализ фреймворка.
Рассмотрим два дизайна.
Один гигантский модуль¶
src/main/java
├── users/...
├── orders/...
├── billing/...
├── catalog/...
├── notifications/...
└── infrastructure/...
Всё принадлежит одной компиляции Gradle compileJava или KSP. Изменение внутри catalog принадлежит той же задаче компилятора, которой принадлежат orders, billing и всё остальное.
Модули доменов¶
Теперь у каждого проекта есть собственные задачи компиляции и выходы. Gradle может рассуждать о них отдельно.
Это не значит, что каждая правка пересобирает ровно один модуль. Изменение может распространяться вниз по потоку, когда меняются зависимые входные данные. Смысл в том, что распространением управляют реальные зависимости сборки и изменения API, а не просто тот факт, что все исходные файлы случайно разделяют один модуль компиляции.
Идеальная архитектура поэтому выравнивает три графа:
Им не нужно быть идеально идентичными, но когда они в целом согласуются, система приобретает полезное свойство: область кода, концептуально затронутая изменением, ближе к области, которую системе сборки приходится пересматривать.
Это гораздо более здоровая стратегия масштабирования, чем попытка оптимизировать одну постоянно растущую глобальную фазу компиляции.
Что происходит после локального изменения?¶
Предположим, разработчик меняет внутреннюю реализацию внутри модуля catalog.
Упрощённая ментальная модель говорит:
изменить один класс
↓
Kora использует внедрение зависимостей на этапе компиляции
↓
весь граф приложения должен быть пересобран
↓
весь репозиторий перекомпилируется
Так современная многопроектная сборка Gradle ведёт себя не обязательно.
Более реалистичный поток такой:
правка в :catalog
↓
компиляция :catalog становится грязной
↓
обработка Kora для :catalog запускается по мере необходимости
↓
сгенерированный вывод подмодуля :catalog пересобирается
↓
Gradle оценивает зависимые задачи
↓
только нижестоящие задачи, чьи эффективные входные данные изменились, нуждаются в работе
Здесь есть несколько слоёв оптимизации.
Во-первых, несвязанные соседние модули — это независимые единицы компиляции. Если billing не зависит от catalog, правка исходника в catalog не делает задачу компилятора billing грязной по
своей природе.
Во-вторых, Gradle поддерживает инкрементальную компиляцию и избегание компиляции. Изменение только реализации, не меняющее соответствующий ABI зависимости, может не требовать нижестоящей Java-компиляции так же, как изменение публичного типа или сигнатуры метода. Точное поведение зависит от языка, плагинов, сгенерированных выходов, характеристик процессора и входных данных задач, поэтому его не следует сводить к универсальному обещанию, что «приватные изменения никогда ничего не пересобирают». Но общий принцип важен: изменение зависимости проекта на диске не эквивалентно «перекомпиляции всего репозитория с нуля».
В-третьих, кеш сборки может переиспользовать выходы задач, когда Gradle находит идентичный отпечаток входных данных. Это особенно ценно в CI, где чистое рабочее пространство не обязательно означает, что каждая задача должна выполняться, если удалённый кеш сборки уже содержит корректные выходы.
В-четвёртых, демон Gradle и кеш конфигурации снижают повторные накладные расходы сборки за пределами самой компиляции.
В-пятых, независимые задачи проекта могут выполняться параллельно, когда граф задач это позволяет.
Результат — не магия. Это обычная инженерия Gradle, применённая к фреймворку, чья работа на этапе компиляции уважает явные границы модулей.
Графики сборки Kora требуют правильной интерпретации¶
Лендинг Kora разделяет поведение сборки на две категории: чистая сборка артефакта и кешированная или инкрементальная сборка артефакта. Это различие важнее любого отдельного числа на графике.
Чистая сборка отвечает на полезный, но ограниченный вопрос:
Если ничего из предыдущей сборки нельзя переиспользовать, сколько всего работы требуется для получения артефакта приложения?
Это важно для тестирования воспроизводимости, некоторых сред CI, конвейеров релизов и сценариев холодной сборки.
Но это не доминирующий цикл для разработчика, который весь день меняет один сервис.
Обычный цикл ближе к такому:
Сценарий кешированной сборки Kora явно отражает инструменты, ожидаемые в этом цикле: кеш сборки Gradle, кеш конфигурации, переиспользование демона, параллельное выполнение, многомодульная сборка и инкрементальная компиляция.
Это создаёт лучшую модель производительности:
Стоимость чистой сборки
= вся требуемая работа по компиляции и генерации
Стоимость инкрементальной сборки
= инвалидированная работа
+ затронутая сгенерированная работа
+ требуемая нижестоящая работа
+ работа по упаковке/тестам
- переиспользуемые выходы
- актуальные задачи
- кешированные задачи
- параллелизуемая независимая работа
Вторая формула — это то, что важно для обратной связи разработчика в масштабе.
Фреймворки с компиляцией на этапе компиляции иногда оцениваются только по первой формуле, тогда как фреймворки с работой во время выполнения неформально оцениваются по второй. Такое сравнение вводит в заблуждение. Если вопрос — продуктивность разработчика, оба должны измеряться в одном и том же реалистичном рабочем процессе правка-сборка-тест.
Фреймворк, переносящий работу в компиляцию, всё ещё может иметь отличную обратную связь, когда эта работа инкрементальна, разбита на части, кешируема и параллелизуема.
Граф задач Gradle — часть архитектуры¶
Как только сервис становится многопроектным, граф задач Gradle становится конкретным представлением архитектурного направления зависимостей.
Представьте такую структуру проекта:
Зависимости могут выглядеть так:
:domain-model
/ | \
/ | \
▼ ▼ ▼
:users :catalog :orders
/ \
▼ ▼
:billing :notifications
\ /
\ /
▼ ▼
:application
▲
│
:infrastructure
Эта топология сообщает Gradle нечто полезное. :users и :catalog могут компилироваться независимо. Если они не зависят друг от друга, они также могут компилироваться параллельно. :application
ждёт выходы модулей, которые он потребляет, но не заставляет все вышестоящие модули становиться одной задачей.
У сборки поэтому два вида параллелизма.
Первый — это параллелизм на уровне задач: несвязанные задачи компиляции модулей могут выполняться одновременно.
Второй — это избегание работы: задачи, чьи входные данные не изменились, могут вообще не выполняться.
Избегание работы обычно мощнее, чем простое добавление потоков. Самая быстрая задача компилятора — та, которую Gradle может доказать, что выполнять не нужно.
Вот почему разумная декомпозиция проекта так важна. Плохо структурированный граф модулей может случайно сериализовать сборку:
Даже если модули по отдельности малы, глубокая цепочка зависимостей ограничивает параллелизм и увеличивает нижестоящее влияние изменений возле дна.
Более здоровая структура часто имеет более широкие независимые области и тонкий слой композиции:
:users ─────────┐
:catalog ───────┤
:orders ────────┤
:billing ───────┼──> :application
:notifications ─┤
:infra-db ──────┤
:infra-http ────┘
Цель — не максимальное веерное расхождение ради него самого. Цель — граф зависимостей, который представляет реальную архитектуру и избегает ненужной транзитивной связанности.
Направление зависимостей важнее количества модулей¶
Многомодульные сборки не являются быстрыми просто потому, что содержат много модулей.
Репозиторий с пятьюдесятью модулями может компилироваться хуже, чем с десятью, если каждый модуль зависит почти от каждого другого. Выигрыш в производительности исходит от границ плюс дисциплины зависимостей.
Предположим, orders напрямую зависит от внутренних классов из catalog, users, billing, notifications и infrastructure. Небольшое публичное изменение в фундаментальном модуле может
распространяться широко. Репозиторий может быть физически разделён, но логически оставаться монолитом.
Лучший дизайн предоставляет узкие контракты.
Например:
Потребители зависят от :catalog-api, а не от :catalog-impl.
Или, если разделение API и реализации на отдельные проекты Gradle было бы излишним, тот же принцип можно применить внутри одного проекта, используя обычную видимость Java/Kotlin и тщательно выбранные экспортируемые типы.
Центральное правило таково:
Граница модуля ограничивает инвалидацию сборки только тогда, когда поверхность зависимостей через эту границу достаточно стабильна.
Это не ограничение, специфичное для Kora. Так работает избегание компиляции в любой большой JVM-сборке. Kora просто делает эту связь особенно актуальной, потому что само обнаружение графа фреймворка происходит на этапе компиляции.
Где должен жить корень композиции¶
Большой сервис Kora выигрывает от намеренно скучного корневого модуля application.
Его работа — не содержать бизнес-логику. Его работа — композиция.
Типичный корень может владеть:
- интерфейсом
@KoraApp; - подключениями модулей верхнего уровня;
- точкой входа процесса;
- склейкой конфигурации уровня окружения, где это необходимо;
- упаковкой приложения;
- возможно, переопределениями системного уровня.
Концептуально:
Это даёт проекту видимый ответ на вопрос: что составляет развёрнутое приложение?
Модули доменов владеют своими компонентами. Модуль приложения решает, какие домены и инфраструктурные модули собираются в процесс.
Такое устройство также держит слой композиции относительно стабильным. Большинство ежедневных правок происходит ниже него.
Корневой граф всё ещё важен, потому что Kora в конечном счёте должна проверять полный граф зависимостей. Отсутствующая зависимость, пересекающая границы подмодулей, всё равно должна где-то дать сбой. Модуляризацию на этапе компиляции не следует понимать как создание изолированных контейнеров времени выполнения. Финальное приложение остаётся одним графом.
Преимущество в том, что процессор может потреблять скомпилированные объявления модулей, а не требовать, чтобы каждый исходный файл находился в одной области обнаружения.
Это важное различие:
Kora может иметь первое, не требуя второго.
Практичная компоновка большого сервиса¶
По-настоящему большой сервис обычно следует разделять вокруг связных возможностей, а не слоёв фреймворка.
Слабая модуляризация выглядит так:
Такая структура часто создаёт плотные кросс-модульные зависимости, потому что одна бизнес-функциональность охватывает каждый слой. Изменение функциональности может затронуть четыре проекта. Границы сборки не совпадают с границами владения.
Более сильный дизайн обычно ориентирован на домены:
:application
:users
├── api
├── service
└── persistence
:catalog
├── api
├── service
└── persistence
:orders
├── api
├── service
└── persistence
:billing
├── client
├── service
└── persistence
:notifications
├── listener
└── service
:infrastructure
├── database
├── telemetry
└── shared integrations
Не каждый показанный здесь каталог нуждается в отдельном модуле Gradle. Это часто было бы слишком мелкозернисто. Важные границы Gradle — это :users, :catalog, :orders и подобные единицы уровня
домена.
Каждый домен может предоставлять один подмодуль Kora:
Он также может подключать внешние модули Kora, которыми владеет или которые требует, там, где такое владение семантически уместно. Финальное приложение затем компонует домены.
Топология сборки становится примерно такой:
┌──────────────┐
│ application │
└───────┬──────┘
│
┌───────────────┼────────────────┐
│ │ │
▼ ▼ ▼
users catalog orders
│
┌───────┴────────┐
▼ ▼
billing notifications
Такое устройство даёт сразу несколько полезных эффектов:
- владение доменами видимо;
- случайные зависимости труднее ввести;
- тесты могут нацеливаться на модуль домена напрямую;
- обработка Kora разбита на части;
- Gradle может избегать несвязанной работы;
- независимые модули могут компилироваться параллельно;
- попадания в кеш CI становятся более гранулярными.
Улучшение сборки — это не трюк, добавленный после архитектуры. Это следствие архитектуры.
Изменения реализации против изменений API¶
Одна из самых важных деталей в любом обсуждении инкрементальной сборки — разница между изменением реализации и изменением внешне видимого контракта модуля.
Рассмотрим этот класс в :catalog:
Если разработчик меняет только приватный алгоритм, сам модуль, очевидно, должен скомпилироваться заново. Его сгенерированный код также может нуждаться в пересмотре в зависимости от входных данных процессора.
Но потребители :catalog не обязательно наблюдали изменение контракта на уровне исходников. Публичный тип и форма метода могут быть идентичны.
Теперь сравним с этим:
Публичный контракт изменился. Код ниже по потоку, использующий этот метод, может нуждаться в перекомпиляции или может не скомпилироваться.
Это различие и может использовать модульная сборка.
На практике цель производительности не в том:
Никогда не пересобирать ничего ниже по потоку.
Это было бы невозможно и нежелательно. Если контракт изменился, нижестоящая валидация — это именно то, что нужно.
Реальная цель:
Не заставлять несвязанный код платить за изменение лишь потому, что всё разделяет один модуль компиляции.
Это гораздо более защитимое обещание.
Безопасность на этапе компиляции и инкрементальная компиляция не враги. Хорошо спроектированная сборка позволяет компилятору агрессивно перепроверять код, когда меняются контракты, избегая при этом нерелевантной работы, когда они не меняются.
Сгенерированный код не устраняет инкрементальность¶
Ещё одно распространённое заблуждение — что генерация исходников по своей природе разрушает инкрементальные сборки.
Она может, если процессор ведёт себя как глобальный чёрный ящик, чей вывод зависит от всего дерева исходников для каждого входа. Но сама генерация исходников не подразумевает такую модель.
Механизм подмодулей Kora явно спроектирован для установления границ обнаружения. Компиляция подмодуля обобщает компоненты и модули, которыми владеет этот проект Gradle, в сгенерированный исходник, который может подключить финальное приложение.
Это означает, что сгенерированный исходник участвует в выводе модуля так же, как другие скомпилированные артефакты.
Полезная ментальная модель:
исходник, написанный вручную
+
объявления Kora
↓
локальная обработка модуля
↓
сгенерированный исходник модуля
↓
скомпилированный артефакт модуля
↓
потребляется нижестоящим проектом
Сгенерированный исходник — это не скрытое состояние времени выполнения. Это продукт сборки.
У этого есть и практическое преимущество для отладки. Если разработчик хочет узнать, что модуль вносит в финальное приложение, сгенерированный исходник даёт нечто конкретное для изучения. Производительность сборки и прозрачность усиливают друг друга: фреймворк создаёт явный артефакт на той же границе, которую Gradle уже понимает.
Кеш сборки: переиспользование работы фреймворка на этапе компиляции¶
Генерация на этапе компиляции становится ещё более привлекательной, когда выходы задач можно кешировать.
Кеш сборки меняет вопрос с:
Выполнялась ли эта задача в этом рабочем пространстве раньше?
на:
Уже ли мы где-то собрали этот точный набор входных данных?
Если ответ да, Gradle может восстановить выходы вместо выполнения задачи.
Для модульного сервиса Kora это может быть особенно эффективно в CI. Рассмотрим pull request, который меняет только :orders.
Без полезной стратегии кеширования:
Со здоровым удалённым кешем сборки:
свежий CI-воркер
↓
восстановить выходы неизменённых модулей
↓
скомпилировать изменённые/затронутые модули
↓
композиция/упаковка
↓
запустить требуемые тесты
Это одна из причин, почему чистую файловую систему не следует автоматически приравнивать к полному холодному вычислению. Современные CI-сборки могут быть эфемерными и при этом переиспользовать работу.
Конечно, эффективность кеша зависит от воспроизводимых входных данных задач, стабильных тулчейнов, корректного поведения плагинов и дисциплинированной конфигурации сборки. Кеш не заменяет модуляризацию. Но модуляризация улучшает гранулярность, на которой можно переиспользовать кешированные результаты.
Одна огромная задача компиляции даёт кешу один огромный ключ. Измените любой релевантный вход — и вся задача промахивается.
Десять связных задач компиляции дают сборке десять независимых возможностей для попаданий.
Кеш конфигурации и некомпиляционная часть обратной связи¶
Разработчики часто приписывают всю задержку сборки компиляции, даже когда конфигурация проекта потребляет заметную часть каждого вызова.
Кеш конфигурации Gradle атакует другой слой проблемы. Он может переиспользовать сконфигурированный граф задач, а не пересчитывать все скрипты сборки при каждом вызове, когда сборка с ним совместима.
Это становится важнее по мере роста репозиториев. Большая многопроектная сборка в противном случае может тратить значительное время ещё до начала компиляции.
Полный цикл обратной связи примерно такой:
время обратной связи разработчика
=
запуск/конфигурация Gradle
+ планирование задач
+ обработка исходников
+ компиляция
+ тесты
+ упаковка или выполнение
Оптимизация только обработчика аннотаций при игнорировании остальной части этого уравнения даёт неполную картину.
Вот почему сценарий кешированной сборки Kora примечателен тем, что включает весь современный тулчейн Gradle, а не представляет скорость процессора изолированно.
Внедрение зависимостей на этапе компиляции живёт внутри системы сборки. Его практическую производительность следует оценивать там.
Параллельная компиляция помогает, но форма зависимостей решает, насколько¶
Gradle может выполнять задачи из независимых проектов одновременно. Многомодульное приложение Kora поэтому может использовать доступные ядра CPU эффективнее, чем одна монолитная задача компиляции, в некоторых рабочих нагрузках.
Предположим, users, catalog и notifications независимы:
┌─ compile :users ──────────┐
edit/build ├─ compile :catalog ────────┼─> compile :application
└─ compile :notifications ──┘
Три вышестоящие задачи потенциально могут перекрываться.
Но параллелизм ограничен порядком зависимостей. Если каждый модуль зависит от общего модуля, который сам постоянно меняется, эта общая зависимость становится точкой синхронизации. Если граф — длинная
цепочка, сборка остаётся последовательной независимо от --parallel.
Это приводит к более широкому инженерному уроку:
Параллелизм сборки — это архитектурное свойство до того, как это флаг Gradle.
--parallel не может создать независимость, которой нет в графе зависимостей.
Хороший граф модулей создаёт возможности для параллельного выполнения. Gradle затем использует их.
Почему «общий» модуль может стать горячей точкой сборки¶
Многие большие репозитории в конце концов создают модуль с названием вроде:
Сначала это выглядит эффективно. Все могут переиспользовать одни и те же хелперы.
Со временем это часто становится одним из худших мест для инвалидации сборки, потому что почти каждый домен зависит от него.
Граф превращается в:
Изменение :shared потенциально затрагивает почти весь репозиторий.
Внедрение зависимостей на этапе компиляции делает эту стоимость видимой, но не создаёт лежащую в основе архитектурную проблему. Сильно связанный общий модуль уже является горячей точкой связанности.
Средство обычно в том, чтобы сделать общие модули меньше и стабильнее:
или вернуть хелперы в домен, который ими реально владеет.
Принцип прост: зависимости возле дна графа проекта должны меняться реже, чем зависимости возле листьев.
Если всё зависит от модуля, относитесь к его API как к инфраструктуре.
Насколько далеко следует модуляризовать?¶
Если модули могут улучшить инкрементальную компиляцию, почему бы не создать сотни?
Потому что у границ модулей тоже есть стоимость.
Каждый проект Gradle может добавлять:
- накладные расходы конфигурации;
- размер графа задач;
- сложность управления зависимостями;
- работу по синхронизации IDE;
- больше файлов сборки или логики конвенционных плагинов;
- больше публикуемых/внутренних артефактов;
- больше мест для размышлений о видимости;
- больше кросс-модульной настройки тестов;
- больше возможностей для случайных циклов зависимостей на уровне проекта.
Чрезвычайно мелкозернистая модуляризация также может делать обычную разработку раздражающей. Перемещение небольшого класса может потребовать изменения зависимостей. Простые рефакторинги могут пересекать границы артефактов. Разработчики тратят больше времени на навигацию по структуре сборки, чем по бизнес-коду.
Поэтому существует U-образная кривая:
Стоимость сборки / обслуживания
^
| \ /
| \ /
| \_____ _____/
| \_________/
|
+---------------------------------> количество модулей
слишком мало полезно слишком много
При слишком малом количестве модулей единицы компиляции становятся большими, зависимости не ограничены, а изменения инвалидируют широкие области.
При слишком большом количестве модулей доминируют накладные расходы на оркестрацию и архитектурная церемония.
Цель — не максимальная модуляризация. Это связная модуляризация.
Практичный модуль обычно заслуживает существования, когда у него есть несколько из этих свойств:
- чёткое владение доменом;
- достаточно стабильная граница;
- содержательные независимые тесты;
- достаточно реализации, чтобы окупить накладные расходы проекта сборки;
- направление зависимостей, которое имеет архитектурный смысл;
- жизненный цикл изменений, отличный от соседнего кода;
- ценность как независимо переиспользуемой внутренней возможности.
Если модуль содержал бы три класса, которые всегда меняются вместе с другим модулем, это, вероятно, не полезная граница.
Если модуль содержит полную область домена, которой владеет команда и на которую зависят через небольшой набор контрактов, это, вероятно, полезная граница.
Разумный путь миграции для растущего сервиса Kora¶
Сервису не нужно начинать жизнь с замысловатой многопроектной сборки.
Это оптимизировало бы масштаб, которого у кодовой базы ещё нет.
Более естественная прогрессия:
Этап 1
один модуль Gradle
один @KoraApp
↓ рост
Этап 2
выделить чёткие инфраструктурные или доменные границы
↓ рост
Этап 3
доменные модули Gradle с @KoraSubmodule
↓ рост
Этап 4
стабилизировать направление зависимостей
ввести кеш сборки
включить кеш конфигурации
распараллелить независимые задачи
измерять модули критического пути
Ключ в том, чтобы модуляризовать, когда архитектура уже подсказывает границу.
Kora делает такое выделение совместимым со своей моделью внедрения зависимостей. Домен не теряет инъекцию на этапе компиляции лишь потому, что переехал в другой проект Gradle. @KoraSubmodule
обеспечивает мост между локальной компиляцией модуля и финальным графом приложения.
Это означает, что архитектурная декомпозиция не требует отступления к сканированию во время выполнения или паттерну сервис-локатора.
Приложение остаётся статически скомпонованным.
Производительность чистой сборки всё ещё важна¶
Инкрементальная компиляция не должна становиться оправданием для игнорирования чистых сборок.
Конвейеры CI иногда инвалидируют кеши. Меняются тулчейны. Меняются версии зависимостей. Релизные сборки могут намеренно запускаться без переиспользования. Разработчики впервые клонируют репозиторий. Здоровый проект не должен требовать часовую загрузку только потому, что последующие сборки быстры.
История сборки Kora поэтому несёт две обязанности:
Чистая сборка
→ общая эффективность фреймворка/компилятора
Инкрементальная сборка
→ локальность, инвалидация, кешируемость, параллелизм
Первая — отчасти проблема реализации фреймворка, вторая — проблема фреймворк-плюс-архитектура-плюс-система-сборки. Это различие предотвращает две противоположные ошибки: говорить, что раз инкрементальные сборки существуют, стоимость процессора не важна (она важна), или говорить, что раз обработка аннотаций добавляет работу чистой сборке, каждая правка в большом сервисе должна быть медленной (это не следует).
Хороший дизайн большого проекта решает обе.
Выигрыш во время выполнения всё ещё является сутью¶
Зачем вообще принимать работу на этапе компиляции?
Потому что Kora использует её, чтобы убрать работу из гораздо более операционно чувствительной фазы: запуска приложения и обработки запросов.
На этапе компиляции Kora может разрешить и проверить граф зависимостей, сгенерировать связывание, сгенерировать интеграционный код и превратить поведение фреймворка в обычный скомпилированный Java/Kotlin.
Во время выполнения сервису не нужно заново обнаруживать эту архитектуру, сканируя classpath и строя контейнер из метаданных.
Компромисс можно представить так:
Традиционная работа времени выполнения
------------------------
обнаружить
отразить
разрешить
проксировать
связать
запустить
Kora
----
компиляция:
обнаружить
проверить
сгенерировать
связать
время выполнения:
инстанцировать
инициализировать
обслуживать
Стоимость компиляции платится за каждую сборку.
Стоимость времени выполнения платится за каждый экземпляр приложения, за каждое развёртывание, за каждый перезапуск, за каждый тестовый процесс и часто по всему парку, содержащему множество копий сервиса.
Это не значит, что время компиляции бесплатно или неважно. Время разработчика тоже дорого. Это значит, что корректная инженерная цель — не «перенести как можно больше работы в компиляцию независимо от задержки сборки». Цель — сделать работу на этапе компиляции ограниченной и достаточно инкрементальной, чтобы преимущества времени выполнения не повреждали цикл разработчика.
@KoraSubmodule важен, потому что поддерживает именно такой баланс.
Границы компиляции могут быть архитектурными границами¶
Здесь есть более глубокая идея, чем оптимизация сборки.
Контейнеры зависимостей времени выполнения могут делать композицию приложения глобально удобной. Положи классы на classpath, добавь аннотации, позволь сканированию обнаружить их и позволь контейнеру времени выполнения собрать результат.
Такое удобство может размывать границы. Если каждый класс глобально обнаруживаем, фреймворк оказывает мало давления на решение о том, какой модуль компиляции реально владеет компонентом.
Модель Kora более явная. Процессор анализирует модуль, содержащий @KoraApp, и модули, явно помеченные как подмодули Kora. Обычные модули проекта не становятся областями обнаружения автоматически.
Это делает участие модуля намеренным.
Архитектура может сказать:
Этот домен владеет этими компонентами.
Этот проект Gradle компилирует их.
Этот подмодуль Kora предоставляет их.
Корневое приложение компонует их.
Одна и та же граница поэтому имеет несколько значений:
Граница домена
=
граница владения исходниками
=
граница компиляции Gradle
=
граница обнаружения компонентов Kora
Когда эти границы выравниваются, разработчики получают систему, о которой легче рассуждать и статически, и операционно.
Сборка не просто становится быстрее. Архитектура становится более читаемой.
Что измерять в реальном репозитории¶
Командам, оценивающим внедрение зависимостей на этапе компиляции, следует избегать измерения только одного числа.
Полезный набор бенчмарков для большого репозитория Kora должен включать как минимум несколько сценариев.
1. Холодная чистая сборка¶
Это показывает общую стоимость, когда нельзя переиспользовать ничего.
2. Тёплая сборка без изменений¶
Запустите ту же сборку дважды и измерьте, сколько Gradle избегает, когда не изменилось ни одного входа.
Это выявляет проблемы конфигурации или модели задач.
3. Правка реализации в листе¶
Измените приватную деталь реализации в низкоуровневом доменном модуле и пересоберите.
Это приближает обычную итерацию локальной разработки.
4. Правка публичного API в листе¶
Измените публичную сигнатуру и измерьте, как далеко распространяется перекомпиляция.
Это раскрывает реальное веерное расхождение зависимостей архитектуры.
5. Правка общего фундаментального модуля¶
Измените модуль, от которого сильно зависят.
Это даёт худший реалистичный инкрементальный случай и часто выявляет архитектурные горячие точки.
6. CI-сборка с удалённым кешем¶
Соберите изменение на свежем воркере с заполненным удалённым кешем.
Это измеряет, что на самом деле испытывают контрибьюторы в оптимизированном конвейере.
7. Параллельная и непараллельная сборка¶
Сравните критический путь и использование CPU. Если --parallel почти не помогает, исследуйте граф зависимостей проекта, а не предполагайте, что виноват Gradle.
Получившаяся таблица гораздо информативнее, чем «фреймворк A компилируется за X секунд».
Например:
Сценарий Что он вам говорит
---------------------------------------------------------------
Чистая сборка Общая эффективность компиляции/генерации
Сборка без изменений Эффективность актуальности/конфигурации
Правка реализации в листе Локальность инвалидации
Правка публичного API Веерное расхождение нижестоящих зависимостей
Правка общего модуля Архитектурный худший случай
CI с удалённым кешем Гранулярность/воспроизводимость кеша
Параллельная сборка Доступный параллелизм на уровне задач
Самое важное число для опыта разработчика — часто правка реализации в листе, а не чистая сборка.
Когда производительность сборки начинает деградировать¶
Если большой сервис Kora становится медленным в компиляции, ответ не должен немедленно быть «внедрение зависимостей на этапе компиляции не масштабируется».
Сначала определите, какой слой на самом деле дорог.
Полезная диагностическая последовательность:
Медленная сборка
↓
Медленна ли конфигурация Gradle?
├─ да → кеш конфигурации / логика сборки
↓ нет
Запускается ли много несвязанных задач компиляции?
├─ да → исследуйте инвалидацию и зависимости модулей
↓ нет
Доминирует ли задача компиляции/KSP одного модуля?
├─ да → модуль может быть слишком большим или перегруженным процессорами
↓ нет
Сериализован ли критический путь?
├─ да → исследуйте цепочку зависимостей проекта
↓ нет
Плохи ли показатели попаданий в кеш?
├─ да → исследуйте входы задач и воспроизводимость
↓ нет
Доминируют ли тесты?
└─ да → компиляция не является основной проблемой
Это важно, потому что «время сборки» — агрегированная метрика. Команда может потратить недели на настройку обработчика аннотаций, в то время как 60 процентов настенного времени на самом деле занимают интеграционные тесты или конфигурация скриптов сборки.
Профилировщик должен решать, куда направить усилия по оптимизации.
Когда модуль Kora слишком велик¶
Модуль Gradle, вероятно, становится слишком большим, когда несколько симптомов появляются вместе:
- задачи компиляции или KSP доминируют в инкрементальных сборках;
- несвязанные команды часто модифицируют один и тот же проект;
- проект содержит несколько бизнес-доменов;
- изменения многократно инвалидируют сгенерированный код для далёкой функциональности;
- список зависимостей модуля велик и разнороден;
- тесты требуют широких фикстур, потому что у модуля нет связной ответственности;
- разработчики не могут описать модуль, не сказав «большая часть приложения».
В этот момент разделение может улучшить архитектуру и локальность сборки одновременно.
Правильное разделение обычно не является произвольной нарезкой пакетов. Ищите независимо меняющиеся домены.
Например:
может естественно стать:
если у этих областей уже есть различные концепции и владение.
Каждая из них затем может предоставлять границу подмодуля Kora и компоноваться приложением.
Когда в репозитории слишком много модулей¶
Противоположный режим отказа тоже виден.
Вероятно, у вас слишком много модулей, когда:
- разработчики регулярно меняют пять или десять файлов сборки ради одной функциональности;
- большинство модулей содержат лишь горстку классов;
- зависимости проекта образуют лабиринт, более сложный, чем бизнес-архитектура;
- конфигурация Gradle и синхронизация проекта становятся значительными;
- циклические архитектурные связи «решаются» созданием большего числа крошечных интерфейсных модулей;
- тесты требуют замысловатой кросс-проектной склейки фикстур;
- разработчики больше не знают, куда относится новый код.
Система сборки должна поддерживать архитектуру, а не становиться архитектурой. Если два модуля всегда меняются вместе, владеются вместе, развёртываются вместе и не имеют содержательного независимого контракта, их слияние может быть оптимизацией. Цель — стабильная золотая середина, где модули достаточно велики, чтобы быть связными, и достаточно малы, чтобы ограничивать изменения.
Стратегия масштабирования Kora намеренно обычна¶
Возможно, самый интересный аспект этого дизайна в том, что Kora не нужен проприетарный распределённый компилятор или специальный инкрементальный демон внедрения зависимостей, чтобы модель работала.
Он использует концепции, которые JVM-команды уже понимают:
- многопроектные сборки Gradle;
- модули компилятора;
- обработку аннотаций или KSP;
- сгенерированный исходник;
- стабильные зависимости проектов;
- инкрементальную компиляцию;
- кеширование сборки;
- кеширование конфигурации;
- параллельное выполнение задач.
@KoraSubmodule встраивает граф внедрения зависимостей в эти существующие механизмы.
Это согласуется с более широкой философией дизайна Kora. Вместо того чтобы прятать структуру приложения за контейнером времени выполнения, он пытается превратить поведение фреймворка в явный исходник и обычные артефакты сборки.
Стратегия масштабирования поэтому не такова:
Сделать один гигантский вызов процессора бесконечно быстрым.
Она такова:
Держать работу процессора эффективной, но также перестать просить один вызов владеть бесконечно растущей вселенной исходников.
Это более устойчивый ответ.
Самая важная ментальная модель¶
Для небольших приложений этого достаточно:
Для больших приложений используйте вместо этого такую модель:
исходники users ───────────────> компиляция users ───────┐
│
исходники catalog ─────────────> компиляция catalog ─────┤
│
исходники orders ──────────────> компиляция orders ──────┼─> композиция приложения
│ @KoraApp
исходники billing ─────────────> компиляция billing ─────┤
│
исходники notifications ───────> компиляция notifications┘
У каждого домена своя область компиляции, и каждый домен может предоставлять свои объявления через @KoraSubmodule. Gradle определяет, что грязно, что переиспользуемо и что может выполняться параллельно, а финальное приложение Kora проверяет и компонует полный граф.
Это ключ к пониманию фразы внедрение зависимостей на этапе компиляции в масштабе. Компиляционная природа фреймворка не требует, чтобы единицей компиляции была вся кодовая база.
Заключение¶
Внедрение зависимостей на этапе компиляции создаёт простую задачу: по мере роста приложения у фреймворка появляется больше объявлений и больший граф зависимостей для обработки. Если вся кодовая база остаётся одним монолитным модулем компиляции, задержка сборки может расти вместе с ней.
Решение — не притворяться, что компиляция бесплатна.
Решение — перестать рассматривать «большое приложение» и «один модуль компиляции» как синонимы.
Kora предоставляет @KoraSubmodule специально для многопроектных приложений. Модули доменов могут владеть своими компонентами и объявлениями модулей, компилировать их независимо и предоставлять их
отдельному корневому приложению, где @KoraApp выполняет финальную композицию. Это позволяет архитектуре внедрения зависимостей следовать обычным границам компиляции Gradle вместо того, чтобы
схлопывать репозиторий обратно в одну область обработки исходников.
Как только такая структура существует, остальная часть современного тулчейна Gradle становится актуальной: инкрементальная компиляция уменьшает ненужную нижестоящую работу, избегание компиляции различает изменения реализации и API там, где это возможно, кеш сборки переиспользует выходы модулей, кеш конфигурации снижает повторную стоимость конфигурации, а независимые модули могут компилироваться параллельно.
Ни один из этих механизмов не гарантирует, что каждая правка локальна. Изменение публичного контракта должно распространяться. Часто модифицируемый общий модуль может инвалидировать многих потребителей. Плохо спроектированный граф модулей может сериализовать сборку. Слишком много крошечных модулей могут создавать собственные накладные расходы.
Именно в этом и дело.
Масштабируемость сборки становится архитектурной проблемой с понятными правилами, а не неизбежным налогом внедрения зависимостей на этапе компиляции.
Большой сервис Kora поэтому следует проектировать так, чтобы бизнес-границы, границы Gradle и границы обнаружения внедрения зависимостей усиливали друг друга. Корневое приложение остаётся местом, где собирается полный граф, в то время как отдельные домены становятся независимо компилируемыми участниками этого графа.
Получившийся принцип прост:
Большое приложение не обязано оставаться одним огромным модулем компиляции. Та же модуляризация, что улучшает архитектуру, также ограничивает работу фреймворка на этапе компиляции.
И из него следует более сильный вывод:
Внедрение зависимостей на этапе компиляции не означает компиляцию всего приложения с нуля после каждого изменения. В правильно модуляризованной кодовой базе границы компиляции и архитектурные границы могут быть одним и тем же.