##// END OF EJS Templates
WIP stable versions CompileUnitsView, RoleProjectionsView, VariantSourcesExtension
WIP stable versions CompileUnitsView, RoleProjectionsView, VariantSourcesExtension

File last commit:

r40:924d9107c025 default
r42:d67a4d2c04cf default
Show More
identity-first-model.ru.md
306 lines | 11.2 KiB | text/x-minidsrc | MarkdownLexer
/ identity-first-model.ru.md

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-подобной модели.

Есть коллекция объектов, на которую адаптер хочет подписаться:

projections.getProjections().all(projection -> {
    ...
});

Если projection — это тяжёлый объект, внутри которого уже лежат:

  • вычисленные bindings
  • провайдеры на source sets
  • derived state
  • полу-материализованные сущности

то all(...) начинает рано раскрывать то, что хотелось оставить ленивым.

Появляются симптомы:

  • ломается lazy semantics
  • растёт связность
  • становится важен порядок применения плагинов
  • хочется заводить собственные события: onCreated, onResolved, onMaterialized

Архитектура начинает скрипеть не потому, что all(...) плох, а потому что слишком много смысла засунуто в наблюдаемый объект.


Решение: наблюдать identity, а не состояние

Если сделать наблюдаемыми только identity objects, картина меняется.

Например:

public interface SourceSetProjection extends Named {
}

Такой объект содержит только identity:

  • name
  • возможно, ещё пару метаданных выбора
  • но не тяжёлое содержимое

Тогда подписка:

projections.getProjections().all(projection -> {
    ...
});

больше не страшна. Она eagerly materializes только дешёвые ключи, а не весь агрегатный граф.

Содержимое запрашивается отдельно:

Set<VariantLayerBinding> bindings = projections.getBindings(projection);
NamedDomainObjectProvider<GenericSourceSet> sourceSet =
    materializer.getSourceSet(projection.getName());

И вот эти вызовы уже могут быть:

  • lazy
  • вычисляемыми
  • кэшируемыми
  • привязанными к runtime lifecycle

Пример

Неудачный вариант

Представим такой интерфейс:

public interface SourceSetProjection extends Named {
    Set<VariantLayerBinding> getBindings();
    NamedDomainObjectProvider<GenericSourceSet> getSourceSet();
}

Проблемы тут сразу видны:

  • SourceSetProjection уже не identity object, а почти агрегат
  • внутри смешаны:

  • symbolic identity

  • relation data
  • runtime reference в чужой домен
  • подписка через all(...) начинает тащить за собой больше, чем хотелось бы

На словах объект называется “projection”, а по факту внутри у него уже полсистемы.


Более удачный вариант

Разделяем ответственность:

public interface SourceSetProjection extends Named {
}
public interface SourceSetProjections {
    NamedDomainObjectCollection<SourceSetProjection> getProjections();
    Set<VariantLayerBinding> getBindings(String sourceSetName);
}
public interface SourceSetMaterializer {
    NamedDomainObjectProvider<GenericSourceSet> getSourceSet(String sourceSetName);
}

Теперь сценарий адаптера выглядит так:

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
  • Благодаря этому можно безопасно использовать replayable механизмы вроде all(...), не разрушая ленивую семантику там, где она действительно важна.

Именно так удаётся совместить:

  • простое наблюдение
  • чистую модель
  • отсутствие собственных событий
  • ленивую материализацию тяжёлого состояния