Стратегии загрузки графа объектов в JPA (Часть 1)

Введение

В этой статье,  я буду описывать и обсуждать разные стратегии загрузки графа объектов с JPA 2.1. Это должно помочь Вам выбрать правильную стратегию для Вашего приложения. Сперва я буду объяснять как Вы можете загрузить Ваш граф объектов с JPA. После этого я опишу преимущества и недостатки разных подходов и когда стоит использовать их.

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

Java Persistence API (JPA)

Всякий раз, когда Вы используете такие технологии как Java Persistence API с помощью имплементаций Hibernate, EclipseLink или OpenJPA, Вы вероятно пытаетесь решить следующую проблему. Имеется разработанное на Java объектно-ориентированное приложение и нужно сохранять данные в БД. В Java данные всегда представляются объектами и их атрибутами, в БД же (по крайней мере реляционных) данные представлены концепцией таблиц с записями и столбцами. Проблема решается маппированием записей БД на объекты и наоборот. Это включает в себя такие вопросы: «Как смаппить наследование?», «Как правильно маппировать примитивные Java типы в SQL типы данных?» и «Как решить вопрос с идентификацией объектов?». Именно этими проблемами занимаются ORM фреймворки и JPA предоставляет стандартный API для доступа к ним.

Графы объектов

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

Пример:


@Entity
@Table(name = "usr")
public class User {

@Id
private Long id;

@OneToMany
private Set<Car> cars;

@OneToMany
private Set<UserLogins> logins;
}

Класс User отображен на таблицу в БД под названием «usr». В классе определены следующие атрибуты: id — тип Long и два атрибута ссылочного типа данных Car и UserLogins. Оба типа также являются доменными объектами, отображенными в БД. Классы UserLogins и Car определяют собственные атрибуты и некоторые их них также могут быть ссылочного типа, указывающего на доменный класс. В результате, когда у Вас есть несколько доменных объектов/сущностей, Вы можете столкнуться с большим графом объектов и каждый объект графа хранится в разных таблицах: User объекты хранятся в таблице usr, Car объекты в таблице car и так далее. Вы можете увидеть это на следующей UML диаграмме:

screen-shot-2016-06-17-at-11-48-21

В этом примере Вы можете перейти от User к его Car, затем к ее Axis и Wheels. Длина пути равна трем. Вы видите, что большинство UML ассоциаций между классами имеют множество * и таким образом User может иметь несколько Cars, Car же может иметь несколько Axis и Axis несколько Wheels. В этом примере * представляет небольшое количество между 2-4. Но представьте, у Вас есть объекты, у которых длина графа может намного больше. Таким образом, очень важно выгружать только нужные части графа объектов из БД.

Статические стратегии загрузки

Статические стратегии загрузки определяют какой граф объектов уже будет выгружен на этапе компиляции через маппинги (XML или аннотации), специфичные запросы или так называемые NamedEntityGraphs. Я буду различать стратегии, распространяемые на все приложение такие, как «ленивая» загрузка (lazy loading) или «жадная» загрузка (eager loading) и стратегии, распространяемые на методы и запросы такие, как явные джойны.

Lazy loading

Поведение JPA по умолчанию такое, что только корневой объект, которые явно был запрошен из БД, будет загружен. Все остальные объекты в графе будут «ленивыми». Они будут представлены в виде прокси и загружены в момент обращения к ним. Однако, необходимым условием является то, что сессия Entity manager должны быть открыта. Когда сессия открыта? По умолчанию сессия привязана к жизненному циклу транзакции. Это означает, что если Вы не имеете какой-нибудь открытой транзакции, то сессия будет закрыта после того как Вы выполнили свою операцию с БД. Следующий тест иллюстрирует это поведение. В тесте ожидается LazyInitializationException.


@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { Config.class })
public class StaticFetchTest {

@PersistenceContext
private EntityManager entityManager;

@Test(expected = LazyInitializationException.class)
public void testFailLazyAccess() {
   User user = entityManager.find(User.class, 1L);
   // Lazy init exception cars is lazy but session closed
   user.getCars().stream().forEach(System.out::println);
  }
}

Причина для вызова исключения это, что @PersistenceContext по умолчанию установлен в type=PersistenceContextType.TRANSACTION. Для того чтобы расширить жизненный цикл сессии, Вы можете разместить все операции с БД, которые должны произойти в одной сессии в одну общую транзакцию. Конечно, Вы можете возмутиться, зачем использовать транзакцию, если я не делаю ни одной операции записи, что может привести к лишним нагрузкам. И Вы будете правы. Если Вы используете транзакцию только для определения продолжительности жизненного цикла сессии, Вы должны установить атрибут read-only в значение true. Следующий метод демонстрирует это:


@Test
@Transactional(readOnly = true)
public void testLoadInTx() {
   User user = entityManager.find(User.class, 1L);
   // Cars are loaded on demand because entity manager is still open
   // because of the transaction
   user.getCars().stream().forEach(System.out::println);
}

Сессия открыта до тех пор пока Вы не покинете тестовый метод и таким образом JPA может загрузить объекты, которые потребуются по запросу (прокси объекты будут инициализированы).

Вы можете контролировать время жизни сессии. Предположим, имеется приложение со слоем контроллеров, слоем сервисов и слоем репозиториев. Вы раздумываете куда же вставить транзакции: в контроллер? в сервис? или в репозиторий? Выбор стратегии зависит от приложения, но в общем случае я предпочитаю располагать транзакции на уровне контроллеров. Есть одно исключение из этого правила: если Ваше приложение запускает контроллеры и сервисы на разных машинах и методы сервиса вызываются удаленно через REST или SOAP, для примера, тогда не имеет смысла размещать транзакции в слое контроллеров, потому что из контроллеров невозможно инициализировать прокси объекты, потому что они запускаются на другой JVM. В этом случае я могу посоветовать разместить @Transactional аннотацию только в сервисах.

Кстати, Вы можете добавить транзакции в контроллеры и в сервисы (например, в случае если не только контроллеры используют методы сервисов). В этом случае, транзакция будет начата в контроллере и все последующие вызовы сервисов будут присоединены к текущей транзакции. Если Вы вызываете сервис из части приложения вне транзакций, то сервис автоматически запустит новую транзакцию.

Полностью отличается стратегия для использования расширенных сессий. С этой стратегией, сессии открыта настолько долго, сколько это возможно и таким образом не зависит от времени жизни транзакции. Что значит настолько долго, сколько это возможно? Для управления открытой сессией Вам нужно хранить состояние и как долго это состояние может быть обслуживаться зависит от жизненного цикла объекта связанного с сессией EntityManager. В JEE приложении с Session Beans, для примера, Вы можете использовать Stateful Session Bean для управления состоянием. Давайте посмотрим на пример с расширенной сессией:


@RunWith(SpringJUnit4ClassRunner.class)
@ContextConfiguration(classes = { Config.class })
public class StaticFetchTest {

@PersistenceContext(type = PersistenceContextType.EXTENDED)
private EntityManager entityManagerExtended;

@Test
public void testLoadExtended() {
   User user = entityManagerExtended.find(User.class, 1L);
   // Cars are loaded on demand because entity manager is extended
   user.getCars().stream().forEach(System.out::println);
}
В этом примере атрибут entityManagerExtended аннотирован @PersistenceContext и атрибут type установлен в PersistenceContextType.EXTENDED. Это делает доступной расширенную сессию и хотя в этом тесте нет транзакций, он будет выполнен успешно.

Eager loading

 В отличие от «ленивой» загрузки в этой стратегии объекты графа загружаются сразу. Стратегия называется «жадная» загрузка и ее можно включить через маппинг объекта. Для каждого атрибута сущности, указывающего на другую сущность (через аннотации @OneToOne, @ManyToOne, @OneToMany или @ManyToMany) Вы можете указать, чтобы эта сущность загрузилась вместе с исходной. Если Вы хотите загрузить UserLogins в момент загрузки объекта User , то можете указать следующий маппинг:
@Entity
@Table(name = "usr")
public class User {

 @OneToMany(fetch = FetchType.EAGER)
 private Set<UserLogins> logins;
}
Вы установили атрибут fetch в значение FetchType.EAGER, что указывает JPA загрузить UserLogins сразу вместе с объектом User. Теперь можете запустить следующий тест:
@Test
public void testLoadEager() {
   User user = entityManager.find(User.class, 1L);
   // Logins are loaded because attribute is mapped eager
   user.getLogins().stream().forEach(System.out::println);
}
Преимущество этого подхода в  легкости конфигурирования и его влиянии на все приложение. Однако, это также и недостаток, потому что может быть Вы не хотите всегда загружать объекты User с UserLogins. Также этот подход не является эффективным с точки зрения производительности.
Существует подход конфигурации стратегии загрузки, который распространяется не на все приложение, а только на каждую операцию.

Явный join

Если Вы используете запросы, написанные на JPQL (объектно-ориентированное расширение SQL) Вы можете присоединять части графа объектов через ключевое слово JPQL «join». Однако будьте осторожны, есть два варианта join в JPQL. В этом примере мы загружаем только пользователей, у которых есть машина с искомым номерным знаком:


@Test(expected = LazyInitializationException.class)
public void testFailLazyAccessByExplicitJoinInJPQL() {
   TypedQuery<User> loadUserQuery = entityManager
       .createQuery("select usr from User usr left outer join usr.cars cars where
       cars.licensePlate = :licensePlate", User.class);
   loadUserQuery.setParameter("licensePlate", "HIBERNATE");
   User user = loadUserQuery.getSingleResult();
   user.getCars().stream().forEach(System.out::println);
}

Как Вы видите, результатом теста будет LazyInitializationException хотя у нас есть машины, подходящие под условие. Правильным ключевым словом для явного указания join стратегии загрузки является join fetch.


@Test
public void testInitByExplicitFetchJoinInJPAQL() {
    TypedQuery<User> loadUserQuery = entityManager
        .createQuery("select usr from User usr left outer join fetch usr.cars where usr.id =
        :id", User.class);
    loadUserQuery.setParameter("id", 1L);
    User user = loadUserQuery.getSingleResult();
    user.getCars().stream().forEach(System.out::println);
}

Во второй части мы рассмотрим стратегии динамической загрузки.

(Visited 1 605 times, 2 visits today)

5 thoughts to “Стратегии загрузки графа объектов в JPA (Часть 1)”

  1. Добрый день, в своем пет проекте (осваиваю SpringData + JPA + Hibernate) как раз столкнулся с проблемой LazyInitializationException (как гуглил еще ее называют «N+1 select»).
    На stackoverflow — http://stackoverflow.com/questions/42283849/spring-data-jpa-hibernate-could-not-initialize-proxy-no-session-after-fix
    Там меня условно направили. Но я не до конца разобрался. Как в моем случае лучше решить проблему. Я хочу из Базы вытаскивать объявления и потом выгружать их на главную страницу. Буду крайне благодарен, если уделите внимание.

    1. Добрый день!
      Можете скинуть на гитхаб скрипты на создание и наполнение БД?
      Я хочу самостоятельно запустить проект.

Добавить комментарий

Этот сайт использует Akismet для борьбы со спамом. Узнайте как обрабатываются ваши данные комментариев.