Странное поведение "один ко многим" с Spring Data JDBC

Я случайно получаю что-то действительно странное в совокупной обработке Spring Data JDBC (с использованием Spring Boot 2.1 с необходимыми стартерами). Позвольте мне объяснить этот случай (я использую Lombok, хотя проблема может быть связана) ...

Это отрывок из моей сущности:

import java.util.Set;
@Data
public class Person {
    @Id
    private Long id;
    ...
    private Set<Address> address;
}

Это связанный репозиторий Spring Data:

public interface PersonsRepository extends CrudRepository<Person, Long> {
}

И это тест, который не проходит:

@Autowired
private PersonsRepository personDao;
...
Person person = personDao.findById(1L).get();
Assert.assertTrue(person.getAddress().isEmpty());
person.getAddress().add(myAddress); // builder made, whatever
person = personDao.save(person);
Assert.assertEquals(1, person.getAddress().size()); // count is... 2!

Дело в том, что при отладке я обнаружил, что коллекция адресов (которая является Set) содержит ДВЕ ссылки на один и тот же экземпляр присоединенного адреса. Я не понимаю, как в итоге попадают две ссылки, и, что наиболее важно, как SET (на самом деле LinkedHashSet для записи) может обрабатывать один и тот же экземпляр ДВАЖДЫ!

person  Person  (id=218)    
    address LinkedHashSet<E>  (id=228)  
        [0] Address  (id=206)   
        [1] Address  (id=206)   

Кто-нибудь имеет представление об этой ситуации? Спасибо


person Thomas Escolan    schedule 29.11.2018    source источник


Ответы (2)


(Linked)HashSet может (в качестве побочного эффекта) сохранить один и тот же экземпляр дважды, если этот экземпляр тем временем был изменен (цитата из _2 _):

Примечание. Следует проявлять большую осторожность, если изменяемые объекты используются в качестве элементов набора. Поведение набора не указывается, если значение объекта изменяется таким образом, который влияет на equals сравнения, в то время как объект является элементом набора.

Итак, вот что, вероятно, произойдет:

  1. Вы создаете новый экземпляр Address, но его идентификатор не установлен (id=null).
  2. Вы добавляете его к Set, и его хэш-код вычисляется как некоторое значение A.
  3. Вы вызываете PersonsRepository.save, который, скорее всего, сохраняет Address и устанавливает для него ненулевой идентификатор.
  4. PersonsRepository.save, вероятно, также вызывает HashSet.add, чтобы убедиться, что адрес находится в наборе. Но поскольку идентификатор изменился, хэш-код теперь вычисляется как некоторое значение B.
  5. Хэш-коды A и B сопоставляются с разными сегментами в HashSet, поэтому метод Address.equals даже не вызывается во время HashSet.add. В результате вы получите один и тот же экземпляр в двух разных сегментах.

Наконец, я думаю, что ваши сущности должны иметь _18 _ / _ 19_ семантику, основанную только на идентификаторе. Чтобы добиться этого с помощью Lombok, вы должны использовать @EqualsAndHashCode следующим образом:

@Data
@EqualsAndHashCode(of = "id")
public class Person {
    @Id
    private Long id;
    ...
}

@Data
@EqualsAndHashCode(of = "id")
public class Address {
    @Id
    private Long id;
    ...
}

Тем не менее, это не решит вашу проблему, потому что изменяется идентификатор, поэтому хэш-коды по-прежнему будут отличаться.

Один из способов справиться с этим - сохранить Address перед добавлением его в Set.

person Tomasz Linkowski    schedule 29.11.2018
comment
Спасибо за понимание. Поскольку я просто провожу эксперименты, я не буду выбирать производственное решение :-) На данный момент я попытаюсь переопределить вывод #save вызовом #findById, чтобы посмотреть, как он себя ведет. Добавлю инфу в ветку! - person Thomas Escolan; 30.11.2018
comment
На самом деле, я выбрал разрешение, противоположное вашему: @EqualsAndHashCode (exclude = id) сработал; но я не уверен, что это хорошая идея в долгосрочной перспективе - person Thomas Escolan; 30.11.2018
comment
@ThomasEscolan Конечно, @EqualsAndHashCode(exclude = "id") сделает свое дело. Но это имеет глубокие последствия, поскольку ваш класс больше не является сущностью - он становится объектом значения. Вы можете узнать об этом больше, например, здесь . - person Tomasz Linkowski; 30.11.2018
comment
Да, Томаш, это абсолютно верно. Кроме того, это был простой случай, когда в базе данных генерируется только идентификатор; хуже всего будет с триггерами и другими значениями по умолчанию! - person Thomas Escolan; 30.11.2018
comment
Дело в том, что операция сохранения Spring Data JDBC возвращает тот же экземпляр (указатель памяти), который был передан, в отличие от Spring Data JPA. Таким образом, вам придется предварительно сохранить все объекты (ваше предложение) или систематически перезагружать корневой объект после сохранения. - person Thomas Escolan; 30.11.2018
comment
@ThomasEscolan Интересно, будет ли работать без предварительного сохранения, если вы замените Set на List. В конце концов, у вас действительно не так много адресов. Но я понимаю, что Set семантически выглядит лучше. - person Tomasz Linkowski; 30.11.2018

Объяснение Томаша Линковски в значительной степени верное. Но я бы поспорил за другое решение проблемы.

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

Затем Address сохраняется и, таким образом, получает новый id, который изменяет его хэш-код. Затем Address добавляется к Person, так как это снова может быть новый экземпляр Address.

Но это тот же самый экземпляр, но теперь с измененным хэш-кодом, в результате чего один и тот же набор содержит один и тот же Address дважды.

Что вам нужно сделать, чтобы это исправить:

Определите equals и hashCode, чтобы оба были стабильными при сохранении экземпляра

т.е. хэш-код не должен изменяться при сохранении экземпляра или в результате чего-либо еще, сделанного в вашем приложении.

Есть несколько возможных подходов.

  1. основание equals и hashCode на подмножестве полей, исключая Id. Убедитесь, что вы не редактируете эти поля после добавления Address в Set. По сути, вы должны относиться к нему как к неизменному классу, даже если это не так. С точки зрения DDD это обрабатывает объект как класс значений.
  2. базируйте equals и hashCode на идентификаторе и задайте идентификатор в конструкторе. С точки зрения предметной области это рассматривает класс как надлежащую сущность, которая идентифицируется своим идентификатором.
person Jens Schauder    schedule 30.11.2018
comment
Хорошо, спасибо. Знаете ли вы, нужно ли нам использовать аннотацию org.springframework.data.annotation.Immutable, чтобы Spring Data предоставляла новые экземпляры? Тогда как же объекты неизменяемых значений отличаются от изменяемых сущностей? - person Thomas Escolan; 30.11.2018
comment
Просто сделайте его неизменным, т. Е. Никаких сеттеров вместо увядания или конструктора, принимающего все аргументы. Но в этом контексте важно то, что хэш-код не меняется. Поэтому, если вы сделаете его неизменным, но хэш-код изменится, у вас снова будет две записи в наборе. - person Jens Schauder; 30.11.2018
comment
Привет, Дженс, когда я попытался объявить неизменяемыми свои сущности (используя Lombok @Value вместо @Data), мои различные тесты (save -insert или update-, найти все, найти по идентификатору) завершились неудачно со всеми видами исключений (неподдерживаемая операция , нарушение ограничения). - person Thomas Escolan; 03.12.2018
comment
NB: с @Immutable дела пошли (немного) лучше. Все еще исследуем :-) - person Thomas Escolan; 03.12.2018
comment
Я создал проблему, чтобы выяснить, можем ли мы улучшить поведение jira.spring.io/browse/DATAJDBC- 300 - person Jens Schauder; 04.12.2018
comment
Я ценю. Меня интересуют сценарии, в которых сущности будут объектами-ценностями для демонстрации моим заинтересованным сторонам, но я совсем не уверен, что они будут оценены. Если вы знаете соответствующие источники (уже видели Эванса и Фаулера), поделитесь, пожалуйста :-) - person Thomas Escolan; 04.12.2018