identity-first-model.ru.md
306 lines
| 11.2 KiB
| text/x-minidsrc
|
MarkdownLexer
|
|
r40 | # Identity-first model: как сохранить lazy semantics без своих событий | ||
| В системах конфигурации и сборки почти всегда возникает одно и то же напряжение: | ||||
| * хочется иметь **наблюдаемую модель**, на которую можно подписаться через что-то вроде `all(...)` | ||||
| * но не хочется, чтобы такая подписка **ломала ленивость** | ||||
| * ещё меньше хочется тащить в архитектуру собственную шину событий, порядок подписки и отдельный lifecycle | ||||
| Один из рабочих способов решить это — **разделить identity и вычисляемое состояние**. | ||||
| ## Суть принципа | ||||
| Идея простая: | ||||
| * отдельный объект отвечает только за **identity** и минимальные метаданные выбора | ||||
| * всё содержательное состояние агрегата получается **отдельными вызовами API** | ||||
| * эти вызовы уже могут быть lazy, дорогими, вычисляемыми, кэшируемыми, провайдерными — какими угодно | ||||
| То есть вместо одного “толстого” объекта мы получаем два слоя: | ||||
| ### 1. Identity layer | ||||
| Лёгкие объекты, которые: | ||||
| * дёшево создаются | ||||
| * не мутируют | ||||
| * безопасно наблюдаются eagerly | ||||
| * годятся как ключи и точки подписки | ||||
| ### 2. State / aggregate access layer | ||||
| Отдельные API, которые: | ||||
| * по identity находят или вычисляют содержимое | ||||
| * делают heavy work только по требованию | ||||
| * могут сохранять ленивую семантику | ||||
| Это особенно полезно там, где коллекция должна быть replayable, но при этом не должна тащить за собой дорогую материализацию. | ||||
| --- | ||||
| ## Проблема, которую это решает | ||||
| Рассмотрим типичную ситуацию в Gradle-подобной модели. | ||||
| Есть коллекция объектов, на которую адаптер хочет подписаться: | ||||
| ```java | ||||
| projections.getProjections().all(projection -> { | ||||
| ... | ||||
| }); | ||||
| ``` | ||||
| Если `projection` — это тяжёлый объект, внутри которого уже лежат: | ||||
| * вычисленные bindings | ||||
| * провайдеры на source sets | ||||
| * derived state | ||||
| * полу-материализованные сущности | ||||
| то `all(...)` начинает рано раскрывать то, что хотелось оставить ленивым. | ||||
| Появляются симптомы: | ||||
| * ломается lazy semantics | ||||
| * растёт связность | ||||
| * становится важен порядок применения плагинов | ||||
| * хочется заводить собственные события: `onCreated`, `onResolved`, `onMaterialized` | ||||
| Архитектура начинает скрипеть не потому, что `all(...)` плох, а потому что **слишком много смысла засунуто в наблюдаемый объект**. | ||||
| --- | ||||
| ## Решение: наблюдать identity, а не состояние | ||||
| Если сделать наблюдаемыми только identity objects, картина меняется. | ||||
| Например: | ||||
| ```java | ||||
| public interface SourceSetProjection extends Named { | ||||
| } | ||||
| ``` | ||||
| Такой объект содержит только identity: | ||||
| * `name` | ||||
| * возможно, ещё пару метаданных выбора | ||||
| * но не тяжёлое содержимое | ||||
| Тогда подписка: | ||||
| ```java | ||||
| projections.getProjections().all(projection -> { | ||||
| ... | ||||
| }); | ||||
| ``` | ||||
| больше не страшна. | ||||
| Она eagerly materializes только дешёвые ключи, а не весь агрегатный граф. | ||||
| Содержимое запрашивается отдельно: | ||||
| ```java | ||||
| Set<VariantLayerBinding> bindings = projections.getBindings(projection); | ||||
| NamedDomainObjectProvider<GenericSourceSet> sourceSet = | ||||
| materializer.getSourceSet(projection.getName()); | ||||
| ``` | ||||
| И вот эти вызовы уже могут быть: | ||||
| * lazy | ||||
| * вычисляемыми | ||||
| * кэшируемыми | ||||
| * привязанными к runtime lifecycle | ||||
| --- | ||||
| ## Пример | ||||
| ### Неудачный вариант | ||||
| Представим такой интерфейс: | ||||
| ```java | ||||
| public interface SourceSetProjection extends Named { | ||||
| Set<VariantLayerBinding> getBindings(); | ||||
| NamedDomainObjectProvider<GenericSourceSet> getSourceSet(); | ||||
| } | ||||
| ``` | ||||
| Проблемы тут сразу видны: | ||||
| * `SourceSetProjection` уже не identity object, а почти агрегат | ||||
| * внутри смешаны: | ||||
| * symbolic identity | ||||
| * relation data | ||||
| * runtime reference в чужой домен | ||||
| * подписка через `all(...)` начинает тащить за собой больше, чем хотелось бы | ||||
| На словах объект называется “projection”, а по факту внутри у него уже полсистемы. | ||||
| --- | ||||
| ### Более удачный вариант | ||||
| Разделяем ответственность: | ||||
| ```java | ||||
| public interface SourceSetProjection extends Named { | ||||
| } | ||||
| ``` | ||||
| ```java | ||||
| public interface SourceSetProjections { | ||||
| NamedDomainObjectCollection<SourceSetProjection> getProjections(); | ||||
| Set<VariantLayerBinding> getBindings(String sourceSetName); | ||||
| } | ||||
| ``` | ||||
| ```java | ||||
| public interface SourceSetMaterializer { | ||||
| NamedDomainObjectProvider<GenericSourceSet> getSourceSet(String sourceSetName); | ||||
| } | ||||
| ``` | ||||
| Теперь сценарий адаптера выглядит так: | ||||
| ```java | ||||
| projections.getProjections().all(projection -> { | ||||
| Set<VariantLayerBinding> bindings = | ||||
| projections.getBindings(projection.getName()); | ||||
| NamedDomainObjectProvider<GenericSourceSet> sourceSet = | ||||
| materializer.getSourceSet(projection.getName()); | ||||
| // apply adapter-specific policy | ||||
| }); | ||||
| ``` | ||||
| Что изменилось: | ||||
| * replayable подписка сохранилась | ||||
| * eager materialization допустима, потому что `SourceSetProjection` дешёвый | ||||
| * дорогое и вычисляемое состояние ушло в отдельные API | ||||
| * materialization остаётся под контролем одного владельца | ||||
| --- | ||||
| ## Почему это лучше событий | ||||
| Когда в модели смешаны identity и состояние, очень быстро хочется изобретать события: | ||||
| * `projectionCreated` | ||||
| * `projectionResolved` | ||||
| * `sourceSetAvailable` | ||||
| * `sourceSetMaterialized` | ||||
| Потому что в какой-то момент становится важно, **когда именно** объект уже “достаточно готов”. | ||||
| Если же наблюдаемый объект содержит только identity, а всё тяжёлое получается отдельными вызовами, событийная модель часто вообще не нужна. | ||||
| Получается более спокойная схема: | ||||
| * есть **identity registry** | ||||
| * есть **lookup API** для связей | ||||
| * есть **lazy materialization API** для тяжёлых сущностей | ||||
| То есть вместо событий: | ||||
| > “когда объект станет достаточно готов, я что-то сделаю” | ||||
| получается обычный и понятный flow: | ||||
| > “я вижу identity, а нужное состояние спрошу отдельно, когда оно мне действительно понадобится” | ||||
| Это проще и для reasoning, и для тестирования, и для эволюции API. | ||||
| --- | ||||
| ## Что именно должно жить в identity object | ||||
| Identity object не обязан быть “абсолютно пустым”. | ||||
| Он может содержать **метаданные выбора**, если они: | ||||
| * дешевы | ||||
| * стабильны | ||||
| * не требуют тяжёлой инициализации | ||||
| * не превращают объект в агрегат | ||||
| Например: | ||||
| * `id` | ||||
| * `name` | ||||
| * `kind` | ||||
| * `domain key` | ||||
| * maybe `type` | ||||
| Но он не должен содержать: | ||||
| * вычисляемое содержимое агрегата | ||||
| * ссылки времени выполнения на чужие домены | ||||
| * lazy providers на heavy objects | ||||
| * derived state, который может провоцировать раннюю материализацию | ||||
| Хорошая практическая формула: | ||||
| **identity object contains selection metadata; aggregate content is obtained separately.** | ||||
| --- | ||||
| ## Когда этот принцип особенно полезен | ||||
| Он особенно хорош, если: | ||||
| * есть replayable наблюдение через `all(...)` | ||||
| * identity-объекты дешёвые и почти immutable | ||||
| * содержимое агрегата может быть дорогим | ||||
| * нужен clean split между symbolic model и runtime model | ||||
| * хочется избежать собственной событийной шины | ||||
| Для Gradle-подобных моделей это вообще очень естественный приём. | ||||
| --- | ||||
| ## Когда он не нужен | ||||
| Если объект: | ||||
| * маленький | ||||
| * дешёвый | ||||
| * уже сам по себе и есть всё его содержимое | ||||
| то разделение на identity и отдельные lookup API может быть лишним. | ||||
| То есть этот принцип полезен не как догма, а как инструмент. | ||||
| Его стоит применять там, где реально есть риск смешения: | ||||
| * ключа | ||||
| * состояния | ||||
| * вычисления | ||||
| * runtime reference | ||||
| --- | ||||
| ## Практический итог | ||||
| Если сформулировать коротко, то принцип такой: | ||||
| 1. **Identity objects** содержат только identity и дешёвые метаданные выбора. | ||||
| 2. **Агрегатное содержимое** не хранится внутри них, а получается отдельными API-вызовами. | ||||
| 3. Эти API уже могут выполнять: | ||||
| * lazy resolution | ||||
| * кэширование | ||||
| * heavy computation | ||||
| * materialization | ||||
| 4. Благодаря этому можно безопасно использовать replayable механизмы вроде `all(...)`, не разрушая ленивую семантику там, где она действительно важна. | ||||
| Именно так удаётся совместить: | ||||
| * простое наблюдение | ||||
| * чистую модель | ||||
| * отсутствие собственных событий | ||||
| * ленивую материализацию тяжёлого состояния | ||||
