Подтвердить что ты не робот

Оптимистическая блокировка в приложении без сохранения состояния с помощью JPA/Hibernate

Мне интересно, что было бы лучшим способом реализовать оптическую блокировку (оптимистическое управление параллелизмом) в системе, где экземпляры сущностей с определенной версией не могут храниться между запросами. На самом деле это довольно распространенный сценарий, но почти все примеры основаны на приложениях, которые будут удерживать загруженный объект между запросами (в сеансе http).

Как реализовать оптимистическую блокировку при минимальном загрязнении API?

Ограничения

  • Система разработана на основе принципов доменного дизайна.
  • Система клиент-сервер
  • Экземпляры сущностей нельзя хранить между запросами (по причинам доступности и масштабируемости).
  • Технические детали должны как можно меньше загрязнять API домена.

Стек - это Spring с JPA (Hibernate), если это имеет какое-либо значение.

Проблема с использованием только @Version

Во многих документах похоже, что все, что вам нужно сделать, это украсить поле с помощью @Version, а JPA/Hibernate автоматически проверит версии. Но это работает, только если загруженные объекты с их текущей версией сохраняются в памяти до тех пор, пока обновление не изменит тот же экземпляр.

Что произойдет при использовании @Version в приложении без сохранения состояния:

  1. Клиент A загружает элемент с помощью id = 1 и получает Item(id = 1, version = 1, name = "a")
  2. Клиент B загружает элемент с помощью id = 1 и получает Item(id = 1, version = 1, name = "a")
  3. Клиент A изменяет элемент и отправляет его обратно на сервер: Item(id = 1, version = 1, name = "b")
  4. Сервер загружает элемент с EntityManager, который возвращает Item(id = 1, version = 1, name = "a"), он изменяет name и сохраняет Item(id = 1, version = 1, name = "b"). Hibernate увеличивает версию до 2.
  5. Клиент B изменяет элемент и отправляет его обратно на сервер: Item(id = 1, version = 1, name = "c").
  6. Сервер загружает элемент с 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, который должен выполнять определенное действие только для изменений, но не для начальной настройки имени. Такой метод не будет вызван в этом сценарии. Может быть, этот недостаток можно смягчить с помощью специальных заводских методов.
  • Конфликтует с шаблоном "единицы работы".

Вопрос

Как бы вы решили это и почему? Есть ли идея получше?

Связанные

4b9b3361

Ответ 1

  Сервер загружает элемент с EntityManager, который возвращает Item (id = 1, version = 1, name = "a"), он меняет имя и сохраняет Item (id = 1, version = 1, name = "b"). Hibernate увеличивает версию до 2.

Это неправильное использование API JPA и основная причина вашей ошибки.

Если вместо этого использовать entityManager.merge(itemFromClient), версия оптимистической блокировки будет проверена автоматически, а "обновления из прошлого" отклонены.

Одно предостережение заключается в том, что entityManager.merge объединит все состояние объекта. Если вы хотите обновить только определенные поля, то с простым JPA все будет немного не в порядке. В частности, поскольку вы не можете назначать свойство version, вы должны проверить версию самостоятельно. Однако этот код легко использовать повторно:

<E extends BaseEntity> E find(E clientEntity) {
    E entity = entityManager.find(clientEntity.getClass(), clientEntity.getId());
    if (entity.getVersion() != clientEntity.getVersion()) {
        throw new ObjectOptimisticLockingFailureException(...);
    }
    return entity;
}

и тогда вы можете просто сделать:

public Item updateItem(Item itemFromClient) {
    Item item = find(itemFromClient);
    item.setName(itemFromClient.getName());
    return item;
}

в зависимости от характера полей, которые нельзя изменить, вы также можете сделать следующее:

public Item updateItem(Item itemFromClient) {
    Item item = entityManager.merge(itemFromClient);
    item.setLastUpdated(now());
}

Что касается выполнения DDD, проверка версии является деталью реализации технологии персистентности, и поэтому должна происходить в реализации репозитория.

Чтобы передать версию через различные слои приложения, я считаю удобным сделать версию частью сущности домена или объекта значения. Таким образом, другие слои не должны явно взаимодействовать с полем версии.

Ответ 2

Когда вы загружаете запись из БД для обработки запроса на обновление, вы должны сконфигурировать этот загруженный экземпляр, чтобы та же версия была предоставлена клиентом. Но, к сожалению, когда объектом управляют, его версию нельзя изменить вручную, как того требует спецификация JPA.

Я пытаюсь отследить исходные коды Hibernate и не замечаю, что есть какие-либо специфические функции Hibernate, которые могут обойти это ограничение. К счастью, логика проверки версии проста, так что мы можем проверить ее самостоятельно. Возвращенный объект по-прежнему управляется, что означает, что к нему можно применить шаблон единицы работы:


// the version in the input parameter is the version supplied from the client
public Item findById(Integer itemId, Integer version){
    Item item = entityManager.find(Item.class, itemId);

    if(!item.getVersoin().equals(version)){
      throws  new OptimisticLockException();
    }
    return item;
}

Поскольку озабоченность по поводу API будет загрязнена параметром version, я бы смоделировал entityId и version как концепцию домена, которая представлена объектом значения с именем EntityIdentifier:

public class EntityIdentifier {
    private Integer id;
    private Integer version;
}

Затем имейте BaseRepository, чтобы загрузить сущность с помощью EntityIdentifier. Если значение version в EntityIdentifier равно NULL, оно будет считаться самой последней версией. Все репозитории других объектов будут расширять его для повторного использования этого метода:

public abstract class BaseRepository<T extends Entity> {

    private EntityManager entityManager;

    public T findById(EntityIdentifier identifier){

         T t = entityManager.find(getEntityClass(), identifier.getId());    

        if(identifier.getVersion() != null && !t.getVersion().equals(identifier.getVersion())){
            throws new OptimisticLockException();
        }
        return t;
 } 

Note: This method does not mean loading the state of the entity at an exact version since we are not doing event sourcing here and will not store the entity state at every version. The state of the loaded entity will always be the latest version , the version in EntityIdentifier is just for handling the optimistic locking.

Чтобы сделать его более универсальным и простым в использовании, я также определю интерфейс EntityBackable так, чтобы BaseRepository мог загрузить объект с резервной копией чего-либо (например, DTO), как только они его реализуют.

public interface EntityBackable{
    public EntityIdentifier getBackedEntityIdentifier();
}

И добавьте следующий метод в BaseRepository:

 public T findById(EntityBackable eb){
     return findById(eb.getBackedEntityIdentifier());
 }

Итак, в конце служба приложений ItemDto и updateItem() выглядит следующим образом:

public class ItemDto implements EntityBackable {

    private Integer id;
    private Integer version;

    @Override
    public EntityIdentifier getBackedEntityIdentifier(){
         return new EntityIdentifier(id ,version);
    }
}
@Transactional
public void changeName(ItemDto dto){
    Item item = itemRepository.findById(dto);
    item.changeName(dto.getName());
}

Подводя итог, это решение может:

  • Единица работы по-прежнему действительна
  • API хранилища не будет заполнен параметром версии
  • Все технические подробности об управлении версией заключены в BaseRepository, поэтому никакие технические подробности не просочились в домен.

Примечание:

  • setVersion() по-прежнему необходимо раскрывать из сущности домена. Но я согласен с этим, поскольку сущность, получаемая из хранилища, управляется, что означает, что на сущность не влияют даже те вызовы, которые вызывают разработчики setVersion(). Если вы действительно не хотите, чтобы разработчики звонили setVersion(). Вы можете просто добавить тест ArchUnit, чтобы убедиться, что он может быть вызван только из BaseRepository.

Ответ 3

Все объяснения и предложения, сделанные здесь, были очень полезны, но, поскольку окончательные решения немного отличаются, я думаю, что стоит поделиться им.

Манипулирование version напрямую не работало должным образом и конфликтует со спецификацией JPA, поэтому это не было вариантом.

Окончательное решение - явная проверка версии + автоматическая проверка версии JPA Hibernate. Явная проверка версии выполняется на прикладном уровне:

@Transactional
fun changeName(dto: ItemDto) {
    val item = itemRepository.findById(dto.id)
    rejectConcurrentModification(dto, item)
    item.changeName(dto.name)
}

Чтобы уменьшить количество повторений, проверка выполняется отдельным методом:

fun rejectConcurrentModification(dto: Versioned, entity: Versioned) {
    if (dto.version != entity.version) {
        throw ConcurrentModificationException(
            "Client providing version ${dto.version} tried to change " + 
            "entity with version ${entity.version}.")
    }
}

Сущности и DTO реализуют интерфейс Versioned:

interface Versioned {
    val version: Int
}

@Entity
class Item : Versioned {
    @Version
    override val version: Int = 0
}

data class ItemDto(override val version: Int) : Versioned

Но вытащить version из обоих и передать его в rejectConcurrentModification будет одинаково хорошо:

rejectConcurrentModification(dto.version, item.verion)

Очевидным недостатком явной проверки на уровне приложения является то, что о ней можно забыть. Но поскольку в любом случае хранилище должно обеспечивать способ загрузки сущности без версии, добавление версии в метод find хранилища также не будет безопасным.

Преимущество явной проверки версии на уровне приложения состоит в том, что она не загрязняет уровень домена, за исключением того, что version должен быть читаемым извне (путем реализации интерфейса Versioned). Методы объекта или хранилища, которые являются частью домена, не загрязнены параметрами version.

То, что явная проверка версии не выполняется в самый последний возможный момент времени, не имеет значения. Если между этой проверкой и окончательным обновлением базы данных другой пользователь изменит ту же сущность, то автоматическая проверка версии с помощью Hibernate вступит в силу, поскольку версия, загруженная в начале запроса на обновление, все еще находится в памяти (в стеке метод changeName в моем примере). Таким образом, первая явная проверка предотвратит одновременное изменение между началом редактирования на клиенте и явной проверкой версии. А автоматическая проверка версии предотвратит одновременное изменение между явной проверкой и окончательным обновлением базы данных.

Ответ 4

Чтобы предотвратить одновременные изменения, мы должны отслеживать, какая версия элемента где-то изменяется.

Если бы приложение имело состояние, у нас была бы возможность сохранить эту информацию на стороне сервера, возможно, во время сеанса, хотя это может быть не лучшим выбором.

В приложении без сохранения состояния эта информация должна идти до клиента и возвращаться с каждым изменяющим запросом.

Итак, IMO, если предотвращение одновременных изменений является функциональным требованием, то наличие информации о версии элемента в мутирующих вызовах API не загрязняет API, оно делает его полным.