Мне интересно, что было бы лучшим способом реализовать оптическую блокировку (оптимистическое управление параллелизмом) в системе, где экземпляры сущностей с определенной версией не могут храниться между запросами. На самом деле это довольно распространенный сценарий, но почти все примеры основаны на приложениях, которые будут удерживать загруженный объект между запросами (в сеансе http).
Как реализовать оптимистическую блокировку при минимальном загрязнении API?
Ограничения
- Система разработана на основе принципов доменного дизайна.
- Система клиент-сервер
- Экземпляры сущностей нельзя хранить между запросами (по причинам доступности и масштабируемости).
- Технические детали должны как можно меньше загрязнять API домена.
Стек - это Spring с JPA (Hibernate), если это имеет какое-либо значение.
Проблема с использованием только @Version
Во многих документах похоже, что все, что вам нужно сделать, это украсить поле с помощью @Version
, а JPA/Hibernate автоматически проверит версии. Но это работает, только если загруженные объекты с их текущей версией сохраняются в памяти до тех пор, пока обновление не изменит тот же экземпляр.
Что произойдет при использовании @Version
в приложении без сохранения состояния:
- Клиент A загружает элемент с помощью
id = 1
и получаетItem(id = 1, version = 1, name = "a")
- Клиент B загружает элемент с помощью
id = 1
и получаетItem(id = 1, version = 1, name = "a")
- Клиент A изменяет элемент и отправляет его обратно на сервер:
Item(id = 1, version = 1, name = "b")
- Сервер загружает элемент с
EntityManager
, который возвращаетItem(id = 1, version = 1, name = "a")
, он изменяетname
и сохраняетItem(id = 1, version = 1, name = "b")
. Hibernate увеличивает версию до2
. - Клиент B изменяет элемент и отправляет его обратно на сервер:
Item(id = 1, version = 1, name = "c")
. - Сервер загружает элемент с
EntityManager
, который возвращаетItem(id = 1, version = 2, name = "b")
, он изменяетname
и сохраняетItem(id = 1, version = 2, name = "c")
. Hibernate увеличивает версию до3
. Кажется, нет конфликта!
Как вы можете видеть на шаге 6, проблема в том, что EntityManager перезагружает текущую версию (version = 2
) элемента непосредственно перед обновлением. Информация о том, что Клиент B начал редактирование с помощью version = 1
, потеряна, и Hibernate не может обнаружить конфликт. Запрос на обновление, выполняемый клиентом B, должен будет сохраняться вместо Item(id = 1, version = 1, name = "b")
(а не version = 2
).
Автоматическая проверка версии, предоставляемая JPA/Hibernate, будет работать только в том случае, если экземпляры, загруженные в начальный запрос GET, будут поддерживаться в каком-либо сеансе клиента на сервере и будут обновляться позднее соответствующим клиентом. Но на сервере без состояния версия, поступающая от клиента, должна как-то учитываться.
Возможные решения
Явная проверка версии
Явная проверка версии может быть выполнена в методе службы приложения:
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
if (dto.version > item.version) {
throw OptimisticLockException()
}
item.changeName(dto.name)
}
Pros
- Класс домена (
Item
) не нуждается в способе манипулирования версией извне. - Проверка версии не является частью домена (кроме самого свойства версии)
Против
- легко забыть
- Поле версии должно быть открытым
- автоматическая проверка версий фреймворком (в самый последний момент) не используется
Забыть чек можно было бы через дополнительную оболочку (ConcurrencyGuard
в моем примере ниже). Хранилище не будет напрямую возвращать элемент, а будет контейнером, который будет обеспечивать проверку.
@Transactional
fun changeName(dto: ItemDto) {
val guardedItem: ConcurrencyGuard<Item> = itemRepository.findById(dto.id)
val item = guardedItem.checkVersionAndReturnEntity(dto.version)
item.changeName(dto.name)
}
Недостатком может быть то, что в некоторых случаях проверка не нужна (доступ только для чтения). Но может быть и другой метод returnEntityForReadOnlyAccess
. Другим недостатком было бы то, что класс ConcurrencyGuard
привнесет технический аспект в концепцию домена репозитория.
Загрузка по идентификатору и версии
Объекты могут быть загружены по идентификатору и версии, чтобы конфликт отображался во время загрузки.
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findByIdAndVersion(dto.id, dto.version)
item.changeName(dto.name)
}
Если findByIdAndVersion
найдет экземпляр с данным идентификатором, но с другой версией, будет выдан OptimisticLockException
.
Доводы
- невозможно забыть обработать версию
version
не загрязняет все методы объекта домена (хотя репозитории также являются объектами домена)
Против
- Загрязнение API хранилища
findById
без версии понадобится в любом случае для начальной загрузки (когда начинается редактирование), и этот метод может быть легко использован случайно
Обновление с явной версией
@Transactional
fun changeName(dto: itemDto) {
val item = itemRepository.findById(dto.id)
item.changeName(dto.name)
itemRepository.update(item, dto.version)
}
Pros
- не каждый метод мутации объекта должен быть загрязнен параметром версии
Против
- API хранилища загрязнен техническим параметром
version
- Явные
update
методы будут противоречить шаблону "единицы работы"
Явное обновление свойства версии при мутации
Параметр версии может быть передан методам мутации, которые могут внутренне обновить поле версии.
@Entity
class Item(var name: String) {
@Version
private version: Int
fun changeName(name: String, version: Int) {
this.version = version
this.name = name
}
}
Pros
- невозможно забыть
Против
- утечка технических деталей во всех методах мутирующего домена
- легко забыть
- запрещено напрямую изменять атрибут версии управляемых объектов.
Вариантом этого шаблона может быть установка версии непосредственно для загруженного объекта.
@Transactional
fun changeName(dto: ItemDto) {
val item = itemRepository.findById(dto.id)
it.version = dto.version
item.changeName(dto.name)
}
Но это открыло бы доступ к версии непосредственно для чтения и записи, и это увеличило бы вероятность ошибок, так как этот вызов мог быть легко забыт. Однако не каждый метод будет загрязнен параметром version
.
Создать новый объект с тем же идентификатором
В приложении может быть создан новый объект с тем же идентификатором, что и у обновляемого объекта. Этот объект получит свойство version в конструкторе. Вновь созданный объект будет затем объединен в контекст постоянства.
@Transactional
fun update(dto: ItemDto) {
val item = Item(dto.id, dto.version, dto.name) // and other properties ...
repository.save(item)
}
Pros
- соответствует всем видам модификаций
- невозможно забыть атрибут версии
- неизменяемые объекты легко создавать
- во многих случаях нет необходимости сначала загружать существующий объект
Против
- Идентификатор и версия в качестве технических атрибутов являются частью интерфейса классов домена
- Создание новых объектов предотвратит использование методов мутации со значением в домене. Возможно, существует метод
changeName
, который должен выполнять определенное действие только для изменений, но не для начальной настройки имени. Такой метод не будет вызван в этом сценарии. Может быть, этот недостаток можно смягчить с помощью специальных заводских методов. - Конфликтует с шаблоном "единицы работы".
Вопрос
Как бы вы решили это и почему? Есть ли идея получше?
Связанные
- Оптимистическая блокировка в приложении RESTful
- Управление параллелизмом в распределенной среде RESTful с помощью Spring Boot и Angular 2 (это, по сути, "явная проверка версий", реализованная сверху с помощью заголовков HTTP)