CrudRepository.save() игнорирует FetchType.EAGER
Для работы с БД в приложении используется Spring Data. Имеется две сущности. Film:
//аннотации опущены
public class Film {
// другие аннотации опущены
@ManyToMany(fetch = FetchType.EAGER)
private List<User> users;
}
И User:
//аннотации опущены
public class User {
// другие аннотации опущены
@ManyToMany(fetch = FetchType.LAZY)
private List<Film> films;
}
Они связаны отношением ManyToMany. Причём для Film список User загружается EAGER. Теперь я хочу сохранить новый объект Film в репозиторий:
@Transactional(readOnly = true)
public interface CrudFilmRepository extends JpaRepository<Film, Integer> {
}
При вызове CrudFilmRepository#save мне возвращается новосозданный Film, причём ещё и с id. Здесь всё работает без нареканий.
Теперь я создал новый класс FilmTo. Он будет содержать в себе не List<User> users, а List<Integer> userIds, благодаря чему с представления надо будет передавать меньше данных. Разумеется, я не могу передать в CrudFilmRepository#save объект FilmTo, потому что репозиторий не поймёт, что это за объект. Поэтому я делаю промежуточный метод, который получает из объекта FilmTo объект Film:
private Film getFromTo(FilmTo film) {
return new Film(
Arrays.stream(film.getUserIds())
.map(crudUserRepository::getOne)
.collect(Collectors.toList()));
}
План действий такой:
- В сервис приходит объект FilmTo, содержащий не список пользователей, а список их айдишников.
- В методе
getFromTo()из объекта FilmTo получаем Film. Важно! Нам надо из списка айдишников получить список пользователей. Для лучшего перфоманса я вытаскиваю изcrudUserRepositoryне целые объекты User, а ссылки на них (метод getOne). - Сохраняем полученный объект Film в репозиторий.
ПРОБЛЕМА:
При передаче в CrudFilmRepository#save объекта Film, полученного из FilmTo при помощи метода getFromTo(), возвращается объект Film с новым id, но при этом список List<User> users в нём при итерации возвращает LazyInitializationException, то есть этот список инициализируется прокси-объектами, а должен инициализироваться реальными объектами, потому что поле users в Film отмечен как EAGER.
Почему при передаче в CrudFilmRepository#save объекта с полями, инициализированными при помощи getOne(), возвращается объект с полями, так же проинициализированными не реальными объектами, а прокси-обёртками? Почему EAGER в данном случае не имеет силы и как это исправить?
Ответы (2 шт):
Вам необходимо реализовать интерфейс Persistable
Судя по этой статье
Точка принятия решения находится в классе
org.springframework.data.jdbc.core.JdbcAggregateTemplate
метод: public <T> T save(T instance)
вот фрагмент этого метода:
Function<T, MutableAggregateChange<T>> changeCreator =
persistentEntity.isNew(instance) ? this::createInsertChange : this::createUpdateChange;
return store(instance, changeCreator, persistentEntity);
Вся суть находится в persistentEntity.
Заглянем в persistentEntity.isNew, там увидим:
public boolean isNew(Object bean) {
this.verifyBeanType(bean);
return ((IsNewStrategy)this.isNewStrategy.get()).isNew(bean);
}
Получается, что есть некая «стратегия», которая и определяет, новый объект или нет. Вопрос сводится к изучению, что это такое.
Обратимся к определению стратегии
Это конструктор класса: org.springframework.data.mapping.model.
Вот фрагмент:
this.isNewStrategy = Lazy.of(() ->
Persistable.class.isAssignableFrom(information.getType())
? PersistableIsNewStrategy.INSTANCE
: getFallbackIsNewStrategy());
Что тут происходит?
Если сохраняемый объект имплементирует интерфейс Persistable, то используется соответствующая стратегия (об этом мы еще поговорим), если нет, то работает логика, представленная в методе getFallbackIsNewStrategy. Давайте на этом моменте остановимся подробнее.
Немного пройдем по цепочке вызовов getFallbackIsNewStrategy и окажемся в методе
public boolean isNew(Object entity) класса PersistentEntityIsNewStrategy.
В этом методе и определяется – новый объект или нет. Для этого берется значение поля, отмеченного аннотацией Id.
- Если значение null – значит объект новый.
- Если не null, то возможно варианты.
- Если это не примитивный тип данных, значит все понятно – это объект не новый.
- Если тип данных примитивный, то он по определению не может быть null, и выполняется проверка на 0.
Еще раз сформулируем работу этой стратегии.
- Берем значение поля Id
- Если null – объект новый
- Иначе, если не примитивный тип, значит – объект не новый.
- Если примитивный тип и значение 0, то новый, иначе не новый.
Получается, все довольно просто и логично. У нового объекта идентификатора нет, поэтому он и новый. Значение ключевого поля формируется на стороне базы данных и возвращается вместе с сохраненным объектом.
А что делать, если по каким-то причинам Id-шник надо сформировать в java-коде и передать в базу данных. В этом случае даже в новом объекте поле идентификатора будет заполнено и описанная выше стратегия уже не сработает.
Что делать в этой ситуации?
Использовать вторую стратегию, основанную на интерфейсе Persistable.
У этого интерфейса есть два метода getId и isNew.
Чтобы воспользоваться этим механизмом надо у объекта, который мы хотим сохранить, имплементировать этот интерфейс и самостоятельно определить, когда объект новый, а когда нет.
При выполнении кода:
this.isNewStrategy = Lazy.of(() ->
Persistable.class.isAssignableFrom(information.getType())
? PersistableIsNewStrategy.INSTANCE
: getFallbackIsNewStrategy());
Spring определит, что сохраняемый объект имплементирует интерфейс Persistable и вызовет метод isNew.
Я получил верный ответ на en.so, причём от разработчика Hibernate (да, я проверил, это вроде бы правда). Вот как он звучит:
save() просто делегирует EntityManager.persist или EntityManager.merge и возвращает объект, возвращенный EntityManager.merge или переданный вами. Здесь FetchType не играет роли. Если вы хотите, чтобы ассоциация загружалась, вы можете использовать refresh или findOne.
И да, я провёл тщательный дебаг, чтобы выяснить, что происходит в методе JpaRepository#save(). Лично для меня это не то что было откровением, но дало некоторое представление.
Когда мы создаём репозиторий, используя Spring DataJPA, то в нём нет никаких методов:
@Transactional(readOnly = true)
public interface CrudFilmRepository extends JpaRepository<Film, Integer> {
}
то есть Spring должен сам инициализировать его подходящим классом. И в моём случае для этого использовался класс SimpleJpaRepository.
вызов CrudFilmRepository#save по сути является вызовом SimpleJpaRepository#save, который выглядит так:
@Transactional
@Override
public <S extends T> S save(S entity) {
Assert.notNull(entity, "Entity must not be null.");
if (entityInformation.isNew(entity)) {
em.persist(entity);
return entity;
} else {
return em.merge(entity);
}
}
Ну и всё в принципе становится более-менее понятно. К моему огромному сожалению я так и не смог найти способ посмотреть, как выглядит persist() под капотом. Но как минимум persist() декларируется просто как метод, который, насколько я понимаю, загружает сущность в контекст персистентности. Нигде не декларируется, что этот метод должен инициализировать прокси в полях сохраняемого объекта реальными объектами. Для этого может служить refresh().
Ну и вроде бы получается, что FetchType.EAGER относится только к методам получения объекта из базы, но не к методам сохранения, поэтому он и игнорировался. Единственное, что я ещё хочу - это посмотреть на persist() под капотом.
Вообще странно, что мне никто не указал на основополагающую проблему в моей архитектуре, которую я сам заметил не так давно и которая является причиной проблемы, которую я пытался решить в этом вопросе.
Для сохранения нового объекта Film, я заполнил его поле List<User>users ссылками, полученными при помощи getOne(). Зачем я так сделал? Чтобы сэкономить обращения к базе, потому что getOne() не обращается к базе. Но если я ожидал, что FetchType.EAGER сработает и метод save() вернёт мне Film с нормально пронициализированными полями, то это всё равно бы вызвало обращение к базе данных!
В таком случае было бы минимум два запроса:
- Сохранение
Filmв базу данных. - Загрузка содержимого
List<User>usersиз базы данных.
И поэтому, если для меня имеет значение, что будет возвращать CrudFilmRepository#save, то мне не следует использовать для инициализации getOne(). Я в любом случае не смогу избежать лишнего обращения к базе, если буду передавать с фронта FilmTo, а не Film.