Привет, хабр! Сегодняшняя статья навеяна довольно стандартной ситуацией – существует некий «большой» объект, но для работы приложения далеко не всегда требуется загружать его полностью в память. Для решения такой проблемы существует ленивая загрузка полей. Суть её состоит в том, что загрузка поля объекта откладывается до того момента, как оно [поле] понадобится.

Объекты могут храниться в локальном хранилище, или же они могут находиться под управлением отдельного микросервиса. В рамках текущей статьи мы рассмотрим и первый, и второй вариант.
Разберем следующий пример: объекты хранятся в локальном хранилище, для работы с которым используем Spring Data.
Существует база данных со следующими таблицами: Организация, Сотрудник, Коды организации (да, коды организации могли бы быть встраиваемой сущностью, но для наглядности выделены в отдельную таблицу). Они [таблицы] представляются следующими сущностями:
Организация
@Data @Accessors(chain = true) @Entity @NoArgsConstructor @Table(name = "org") public class Organization { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @OneToOne(targetEntity = Employee.class, fetch = FetchType.LAZY) @JoinColumn(name = "head_id") private Employee head; @OneToMany(targetEntity = Employee.class, mappedBy = "organization", fetch = FetchType.LAZY) private Set<Employee> employees = new HashSet(); @OneToOne(targetEntity = OrganizationCodes.class, fetch = FetchType.LAZY) @JoinColumn(name = "code_id") private OrganizationCodes codes; }
Сотрудник
@Data @Accessors(chain = true) @Entity @NoArgsConstructor @Table(name = "employee") public class Employee { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "first_name", nullable = false) private String firstName; @Column(name = "last_name", nullable = false) private String lastName; @ManyToOne(targetEntity = Organization.class, fetch = FetchType.LAZY) @JoinColumn(name = "org_id") private Organization organization; }
Коды организации
@Data @Accessors(chain = true) @Entity @NoArgsConstructor @Table(name = "employee") public class OrganizationCodes { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @Column(name = "inn", nullable = false) private String inn; @Column(name = "kpp", nullable = false) private String kpp; @Column(name = "ogrn", nullable = false) private String ogrn; }
Как можно заметить, все поля организации (кроме идентификатора) являются «ленивыми».
Для каждой сущности написаны простые репозитории и сервисные классы.
Репозитории
@Repository public interface OrganizationRepository extends JpaRepository<Organization,Long> { }
@Repository public interface EmployeeRepository extends JpaRepository<Employee, Long> { Set<Employee> findByOrganization(Organization organization); }
@Repository public interface OrganizationCodesRepository extends JpaRepository<OrganizationCodes, Long> { }
Сервисные классы
@Service public class OrganizationServiceImpl implements OrganizationService { private final OrganizationRepository repository; @Autowired public OrganizationServiceImpl(OrganizationRepository repository){ this.repository = repository; } @Override public Organization getById(long orgId) { return repository.getById(orgId); } }
Реализация остальных сервисов не важна в рамках статьи, поэтому приведу только интерфейсы
public interface EmployeeService { Employee getById(long employeeId); Set<Employee> getByOrgId(long orgId); }
public interface OrganizationCodesService { OrganizationCodes getById(long id); }
Итак, если мы загрузим организацию и попробуем что то сделать, например, с полем head
private String getHeadNameFromOrg(long orgId) { Employee head = organizationService.getById(orgId).getHead(); return head.getFirstName(); }
то увидим ошибку:org.hibernate.LazyInitializationException: could not initialize proxy [ru.brutforcer.org.service.entity.Organization#1] - no Session. Это потому, что данное поле является «ленивым», и просто так с ним не поработать.
Загрузить ленивое поле в Spring Data (да и вообще в Hibernate) можно следующим образом:
1) Указать аннотацию @Transactional на методе, и работать с полем в пределах одной транзакции:
@Transactional public String getHeadNameFromOrg(long orgId) { Employee head = organizationService.getById(orgId).getHead(); return head.getFirstName(); }
2) Указать необходимость загрузки поля непосредственно в методе репозитория
@Repository public interface OrganizationRepository extends JpaRepository<Organization,Long> { @Query("SELECT org FROM Organization org join fetch org.head where org.id = :orgId") Organization getByIdFetchHead(@Param("orgId") long orgId); }
И, добавив соответствующий метод в сервис, использовать уже новый метод
public String getHeadNameFromOrg(long orgId) { Employee head = organizationService.getByIdFetchHead(orgId).getHead(); return head.getFirstName(); }
Первый вариант довольно прост, но может создать длительную транзакцию. Это не всегда хорошо - как минимум, могут появиться проблемы с ее [транзакции] изоляцией. А еще в этом случае происходит несколько запросов в базу данных (хоть и в пределах одной транзакции).
Второй вариант лично мне нравится больше, но имеет другую проблему: что, если у нас будет не 2 ленивых поля, а десять? И нужно будет загружать их в разных комбинациях? Тогда наш репозиторий будет разрастаться огромным количеством методов.
Интерфейс сервисного класса
Вот так будет выглядеть интерфейс сервисного класса со всеми комбинациями загрузки ленивых полей, если их количество 3шт. И это только загрузка сущности по идентификатору.
public interface OrganizationService { Organization getById(long orgId); Organization getByIdFetchHead(long orgId); Organization getByIdFetchCodes(long orgId); Organization getByIdFetchEmployees(long orgId); Organization getByIdFetchHeadFetchCodes(long orgId); Organization getByIdFetchCodesFetchEmployees(long orgId); Organization getByIdFetchEmployeesFetchHead(long orgId); Organization getByIdFetchAllProperties(long orgId); }
Если в кратце, количество сочетаний будет 2^n, где n - количество ленивых полей: для 4х полей 16, для 5 полей 32 и т.д.
Рассмотрим другую ситуацию – данные находятся под управлением другого микросервиса, общение с которым происходит посредством HTTP запросов в формате REST. Тут магия Hibernate уже не поможет.
Приведу некоторые уточнения.
Имеется возможность запросить каждое из «ленивых полей» отдельно.
При загрузке у «ленивых» объектов-полей обязательно заполнено поле id.
Полную реализацию методов загрузки сущностей для данного примера приводить не буду: ограничусь интерфейсами и некоторыми общими моментами.
Появляется вопрос: можно ли в запросе указать, какие поля загружать, а какие нет? Если да, то есть вариант создать кучу методов, как во втором варианте решения первого примера.
В противном случае, получается, сервис предоставляет возможность или вытянуть объект полностью «лениво» , или полностью загруженный.
Что можно в этом случае сделать? Очевидно, написать метод, который будет кидать запрос и подгружать конкретное поле. Получится примерно то же самое, что и в первом варианте, только немного (совсем чуть-чуть) сложнее.
@Service public class OrganizationServiceImpl implements OrganizationService { private final OrganizationRepository repository; private final EmployeeService employeeService; @Autowired public OrganizationServiceImpl(OrganizationRepository repository, EmployeeService employeeService) { this.repository = repository; this.employeeService = employeeService; } @Override public Organization getByIdFetchHead(long orgId) { Organization org = repository.getById(orgId); Employee head = employeeService.getById(org.getHead().getId()); org.setHead(head); return org; } }
Теперь загрузка происходит в 2 запроса вместо одного, и тут придется подумать – загружать ли объект жадно, но за один запрос, или только необходимое, но за два. Например, может быть такое, что два мелких запроса будут обрабатываться быстрее одного большого.
Особенно сильно в данном вопросе будет решать наличие реализованного кэша при загрузке ленивых полей: если его использование будет уместным, то вместо второго запроса значение можно брать из кэша.
Однако, у нас остается проблема: куча методов с разными комбинациями загрузки полей. Это очень неудобно, и решение может быть следующим.
Добавим в основную сущность методы для загрузки ленивых полей (в целом, они копируют поведение обычных геттеров, однако, например, если в проекте используется Project Reactor, они могут возвращать соответствующие Mono или Flux) и копирующий конструктор (т.к. мы говорим о загрузке данных из другого микросервиса, все аннотации, связанные с JPA я убрал):
@Data @Accessors(chain = true) @NoArgsConstructor public class Organization { private Long id; private Employee head; private Set<Employee> employees; private OrganizationCodes codes; public Employee loadHead(){ return this.head; } public Set<Employee> loadEmployees(){ return this.employees; } public OrganizationCodes loadCodes(){ return this.codes; } public Organization(Organization org) { this.id = org.getId(); this.head = org.getHead(); this.codes = org.getCodes(); this.employees = org.getEmployees(); } }
Далее в сервисном классе перед возвратом результата переопределяем поведение методов загрузки:
@Service public class OrganizationServiceImpl implements OrganizationService { private final OrganizationRepository repository; private final EmployeeService employeeService; private final OrganizationCodesService organizationCodesService; @Autowired public OrganizationServiceImpl(OrganizationRepository repository, EmployeeService employeeService, OrganizationCodesService organizationCodesService) { this.repository = repository; this.employeeService = employeeService; this.organizationCodesService = organizationCodesService; } @Override public Organization getById(long orgId) { Organization org = repository.getById(orgId); return delegate(org); } private Organization delegate(Organization org) { return new Organization(org){ @Override public Employee loadHead() { if (super.getHead() != null && super.getHead().getFirstName() != null) { return super.getHead(); } else if (super.getHead() != null && super.getHead().getId() != null) { Employee head = employeeService.getById(org.getHead().getId()); super.setHead(head); return head; } else { return null; } } @Override public Set<Employee> loadEmployees() { if (super.getEmployees() != null) return super.getEmployees(); else { Set<Employee> employees = employeeService.getByOrgId(org.getId()); super.setEmployees(employees); return employees; } } @Override public OrganizationCodes loadCodes() { if (super.getCodes() != null && super.getCodes().getInn() != null) { return super.getCodes(); } else if (super.getCodes() != null && super.getCodes().getId() != null) { OrganizationCodes codes = organizationCodesService.getById(super.getCodes().getId()); super.setCodes(codes); return codes; } else { return null; } } }; } }
(Примечание: проверка загруженности полей head и codes, помимо обычной проверки на null, содержит проверку еще внутренних полей объектов. Это сделано, потому что по условию у данных объектов обязательно должен быть заполнен id сущности. Соответственно, вероятность того, что эти поля при ленивой загрузке равны null, стремится к нулю. Однако, если уже обязательные поля не загружены, то и объект является лениво загруженным. Так же добавлю, что проверки могут быть иными, а в каких то случаях стоит применить синхронизацию при проверке и загрузке полей таким образом).
В результате проделанных манипуляций, в любой точке кода всегда можно вызвать загрузочный метод и получить необходимое поле. Предложенное решение работает не только с данными, получаемыми из удаленных сервисов – его можно адаптировать и к работе с локальной бд.
Проблемой же является необходимость отправки нескольких запросов. Частично эту проблему сглаживает наличие кэша, однако когда данный подход стоит применять, а когда нет – зависит от конкретной ситуации.
