Привет, хабр! Сегодняшняя статья навеяна довольно стандартной ситуацией – существует некий «большой» объект, но для работы приложения далеко не всегда требуется загружать его полностью в память. Для решения такой проблемы существует ленивая загрузка полей. Суть её состоит в том, что загрузка поля объекта откладывается до того момента, как оно [поле] понадобится.
Объекты могут храниться в локальном хранилище, или же они могут находиться под управлением отдельного микросервиса. В рамках текущей статьи мы рассмотрим и первый, и второй вариант.
Разберем следующий пример: объекты хранятся в локальном хранилище, для работы с которым используем 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, стремится к нулю. Однако, если уже обязательные поля не загружены, то и объект является лениво загруженным. Так же добавлю, что проверки могут быть иными, а в каких то случаях стоит применить синхронизацию при проверке и загрузке полей таким образом).
В результате проделанных манипуляций, в любой точке кода всегда можно вызвать загрузочный метод и получить необходимое поле. Предложенное решение работает не только с данными, получаемыми из удаленных сервисов – его можно адаптировать и к работе с локальной бд.
Проблемой же является необходимость отправки нескольких запросов. Частично эту проблему сглаживает наличие кэша, однако когда данный подход стоит применять, а когда нет – зависит от конкретной ситуации.