В типичных серверных приложениях отношения между сущностями часто выражаются через внешние ключи базы данных и ассоциации JPA. Этот подход мощен; он предотвращает сохранение дочерних элементов без родительских, пропагандирует удаление дочерних элементов, когда родитель удаляется, и позволяет обрабатывать сохранение и удаление вместе в соответствии с графом объектов.
Тем не менее, изменения в отношениях в реальной области более широкие, чем простое управление отношениями на уровне репозитория. Когда сущность удаляется, возможно, потребуется удалить и другую сущность, но в некоторых случаях может потребоваться сначала создать пустого родителя, создать вместе родственные сущности или пересчитать связанные значения. Эта статья подведет итоги тому, почему мы хотим обрабатывать отношения на уровне приложения и как мы можем дополнить это, основываясь на структуре JPO базового проекта Vizend.
1. Сохранение JPO В Краткости
JPO базового проекта Vizend, как правило, лаконичны. JPO являются сущностями JPA, но они ближе к модели хранения для сохранения доменных объектов, чем к модели ORM, которая богато выражает отношения. Другими словами, JPO сосредотачивается на сохранении одной строки и преобразовании ее обратно в доменный объект, чем на охвате всего графа объектов домена.
В типичной модели JPA родители и дети могут быть связаны через ссылки на объекты, причем родители владеют постоянной коллекцией детей. Однако в базовой модели Vizend дети хранят ID родителя как @FieldSourceId, а родитель хранит список детей как временный. JPO сохраняет эту структуру простым полевым способом без какой-либо сопоставления отношений.
class Board {
private String id;
private String name;
private transient List<Card> cards;
}
class Card {
private String id;
@FieldSourceId
private String boardId;
private String title;
private transient List<Comment> comments;
}
class Comment {
private String id;
@FieldSourceId
private String cardId;
}
Цель этой структуры — сделать модель хранения простой и продемонстрировать подход, при котором отношения интерпретируются и обрабатываются в коде приложения, а не полагаются на обработку отношений на уровне базы данных.
2. Почему Отношения Обрабатываются Приложениями, А Не Репозиториями
Одна из самых значительных причин заключается в том, что поставщик базы данных и операционная среда не являются фиксированными. Основные проекты или проекты на основе генерации кода могут не иметь окончательной базы данных, на которой они будут работать с самого начала. Хотя большинство RDBMS поддерживают ограничения внешних ключей, способ генерации ограничений, специфика действий ссылок, автоматическая генерация DDL, стратегии миграции и использование триггеров варьируются в зависимости от базы данных и операционной среды.
Если доменные правила сильно зависят от обработки отношений на уровне репозитория, варианты для базы данных проекта становятся ограниченными. Проектирование доменных правил на основе ограничений, триггеров и поведения действий каскада конкретной базы данных может привести к более значительным различиям, чем ожидалось, при переходе на другую базу данных. Поэтому для базовой модели полезно поддерживать простые ссылки ID, чтобы позволить коду приложения интерпретировать правила отношений, а не полагаться на конкретные функции БД.
Вторая причина заключается в том, что изменения отношений шире, чем пропаганда удаления. Обработка отношений на уровне репозитория сильна в предотвращении сохранения детей без родителей или совместного удаления детей при удалении родителя. Однако фактические доменные правила не заканчиваются на этом.
Например, в случаях, когда B должен быть удален, когда A удаляется, это можно выразить в некоторой степени с помощью действий ссылок на уровне репозитория или каскадов JPA удаления. Однако в ситуациях, когда A нужно создать первым, потому что родитель A отсутствует при сохранении B, или когда A должен быть создан вместе с родственным B, или когда значение A нужно изменить или удалить для пересчета значения B, простые действия внешнего ключа трудно выразить.
Использование триггеров базы данных может переместить часть обработки внутрь БД. Тем не менее, это скрывает доменную логику внутри базы данных, а не в коде приложения. Это затрудняет тестирование и отслеживание, и разделение границ сервиса становится сложной задачей. По мере роста приложения изменения отношений становятся более согласованными с случаями использования и доменными правилами, чем с автоматическими действиями репозитория.
3. Проблемы, Возникающие При Решении с Помощью Кода Приложения
Обрабатывая отношения в коде приложения, доменные правила могут быть выражены более явно. Логика удаления карточек, когда удаляется доска, и удаления комментариев, когда удаляется карточка, понятна в коде. Доменные действия, такие как не только удаление, но и запись истории, проверка состояния, проверки разрешений и обновления проекций, также могут быть включены.
Проблема заключается в том, что код обработки отношений имеет тенденцию становиться все более разбросанным. Сначала нужно удалить только карточку, когда удаляется доска. Позже, после создания комментариев под карточкой, логика удаления комментариев добавляется к логике удаления карточки. С возникновением таких понятий, как NotificationRule, DashboardWidget и ActivityLog, код пропаганды удаления становится все больше.
Первая проблема — это упущение. Даже если добавляется новое поле ссылки, существующая логика удаления не может быть автоматически уведомлена. Разработчик должен помнить об этих взаимоотношениях и отражать их в логике удаления. Если упустить, останутся сиротские данные.
Вторая проблема — это наличие нескольких путей. Если существуют пути от A до C через B и от A до C через D одновременно, C может быть достигнут дважды в процессе удаления A. Удаление должно происходить только один раз, но если каждый обработчик или действие независимо вызывает последующие удаления, есть риск вызова одного и того же удаления C дважды.
A -> B -> C
A -> D -> C
Третья проблема — это сложность понимания области влияния. Прежде чем удалить A, трудно понять, какие сущности будут затронуты вместе. Если обработка отношений происходит на уровне репозитория, БД или JPA выполнят определенные правила, но в коде приложения каскадные пути обработки разбросаны по нескольким действиям и обработчикам.
Эти проблемы возникают не потому, что обработка отношений на уровне репозитория не использовалась, а потому что распространение отношений обрабатывалось в коде приложения без последовательного управления потоком выполнения.
4. Направление для решения: DataEvent и CascadeContext
Разделены на Flow, Logic, PolicyHandler и CascadeContext, что делает каждую роль отдельной и выполняющей свои обязанности.
Flow creates execution context
Logic performs its own CUD
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
Flow является точкой входа в случай использования. Здесь создается единственная область выполнения, и генерируется CascadeContext. Logic выполняет фактический CQRS. Он создает, изменяет и удаляет свою сущность и публикует DataEvent. PolicyHandler получает DataEvent и вызывает связанный Logic.
Например, когда поступает запрос на удаление Доски, Flow создает контекст и вызывает BoardLogic. BoardLogic отвечает только за удаление Доски и публикацию события BoardRemoved. Получив событие BoardRemoved, BoardPolicyHandler находит список Карточек и вызывает CardLogic. Затем CardLogic публикует событие CardRemoved, а CardPolicyHandler вызывает CommentLogic.
В этой структуре каждый Logic обрабатывает только свое. BoardLogic не знает о Карточках. CardLogic не знает о Комментариях. PolicyHandler отвечает за распространение отношений.
Самая осторожная точка в каскадном распространении событий — это дублирующая обработка. Одна и та же сущность может быть достигнута через несколько путей. Чтобы предотвратить это, предоставляется CascadeContext для запоминания действий, которые уже были обработаны в пределах одного потока выполнения.
public class CascadeContext {
private final Set<String> completed = new HashSet<>();
public boolean enter(Class<?> type, String id, String action) {
String key = type.getName() + ":" + id + ":" + action;
return completed.add(key);
}
}
PolicyHandler проверяет контекст перед вызовом следующего Logic. Если действие уже было обработано, оно пропускается, и если это первое встреченное действие, вызывается следующий Logic.
public class BoardPolicyHandler {
public void onBoardRemoved(DataEvent event) {
CascadeContext context = cascadeContextHolder.current();
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
if (!context.enter(Card.class, card.getId(), "REMOVE")) {
continue;
}
cardLogic.removeCard(card.getId());
}
}
}
Таким образом, даже если существует структура, где C достигается от A через B и от A через D, C будет обработан только один раз. Если то же самое действие поступит снова, context.enter вернет false, и обработчик перейдет к следующему.
При применении этой структуры должно быть ясно, что события Spring выполняются синхронно. Чтобы распространение произошло в рамках одной транзакции, необходимо использовать общий синхронный поток EventListener. Использование асинхронных событий или событий после коммита затрудняет поддержание одного и того же CascadeContext и транзакции.
5. Наблюдение за отношениями с помощью метаданных
CascadeContext предотвращает дублирование выполнения. Однако, чтобы знать, какие отношения существуют, какие отношения обрабатываются и какие отношения отсутствуют, требуется отдельная наблюдаемость. Здесь можно использовать FieldSourceId и RelationPolicy вместе.
FieldSourceId может использоваться для обнаружения ссылочных отношений. Например, если FieldSourceId привязан к Card.boardId, приложение будет знать, что Card ссылается на Board. Сканируя эту информацию, можно создать граф ссылок.
ReferenceGraph
Board
Card.boardId
Card
Comment.cardId
Однако сам по себе граф ссылок не может сказать нам, как CUD родителя влияет на детей. Когда Board удаляется, следует ли удалить Card, очистить Card.boardId или предотвратить удаление - это отдельная политика.
Для этого мы можем иметь метаданные, такие как RelationPolicy, в методах PolicyHandler. Эта аннотация не заменяет поведение обработчика. Фактическое поведение остается в теле метода. Аннотация служит в качестве метаданных, чтобы указать, за какую именно пропаганду отношений отвечает этот обработчик.
@RelationPolicy(
source = Board.class,
event = DataEventType.REMOVED,
target = Card.class
)
public void onBoardRemoved(DataEvent event) {
List<Card> cards = cardStore.findByBoardId(event.entityId());
for (Card card : cards) {
cardLogic.removeCard(card.getId());
}
}
Таким образом, приложение может иметь два типа графов. Граф ссылок показывает, кто на кого ссылается. Граф пропаганды указывает, какие события влияют на какие цели.
ReferenceGraph
FieldSourceId based graph
PropagationGraph
RelationPolicy based graph
Сравнивая два графа, мы можем исследовать область влияния, отсутствующие политики, дублирующие пути и круговые пути. Например, если существует связь от Card к NotificationRule в графе ссылок, но в графе пропаганды нет политики NotificationRule для события CardRemoved, это может рассматриваться как отсутствующая политика. Напротив, если существуют пути от A к C через B и от A к C через D, мы можем предварительно предупредить о множественных путях во время анализа графа.
Impact
Missing policy
Duplicate path
Cycle
Преимущество этой структуры заключается в том, что она достигает наблюдаемости в обработке отношений, не скрывая доменную логику в аннотациях. FieldSourceId - это метаданные ссылки, RelationPolicy - это метаданные пропаганды, а фактические доменные реакции остаются в теле метода PolicyHandler.
Наконец, эту структуру можно расширить до предварительного просмотра области влияния с помощью dry-run. Добавив флаг dryRun и список влияния в CascadeContext, когда Logic находится в состоянии dry-run, мы можем опустить только фактические изменения в репозитории, при этом публикуя DataEvents. Это позволяет отслеживать цепочку событий, аналогичную фактическому выполнению, избегая при этом изменений в базе данных и вычисляя, какие сущности затронуты. Однако на dry-run можно полагаться только в том случае, если предполагается, что все обработчики DataEvent следуют одним и тем же правилам CascadeContext, так что безопаснее осторожно вводить его, начиная с областей, где область влияния контролируется.
6. Заключение
Внешние ключи базы данных и каскады JPA - это мощные инструменты для управления отношениями. Однако не все изменения отношений объясняются пропагандой жизненного цикла на уровне репозитория. По мере роста приложений изменения отношений выходят за рамки удаления пропаганды, чтобы включать доменные правила, такие как исправления создания, создание братьев и сестер, перерасчеты и обновления проекций.
Способ, которым базовый JPO Vizend остается лаконичным, при этом дети хранят идентификатор родителя как FieldSourceId и собирают список детей родителя как временный, может быть понят в этом контексте. Это выбор оставить место для обработки отношений в коде приложения, не делегируя сильно на DB/JPA.
Конечно, этот выбор накладывает ответственность. Приложение должно напрямую управлять целостностью, которую защищала база данных, и предотвращать пропуски пропаганды или дублирующие выполнения. Чтобы достичь этого, мы обсудили роли Flow, Logic, PolicyHandler и CascadeContext, а также структуру наблюдения за отношениями с помощью FieldSourceId и RelationPolicy.
Logic changes only its own entity
Logic publishes DataEvent
PolicyHandler reacts to DataEvent
CascadeContext prevents duplicate actions
FieldSourceId describes reference graph
RelationPolicy describes propagation graph
Эта структура не ориентирована на простое подобие обработки отношений на уровне репозитория в коде приложения, а скорее на попытку оставить смысл изменений отношений внутри доменного кода, при этом управляя потоком выполнения последовательно.
Спасибо за чтение.
HHkk