Pull to refresh

Загрузка ленивых полей

Reading time8 min
Views9.8K

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

Объекты могут храниться в локальном хранилище, или же они могут находиться под управлением отдельного микросервиса. В рамках текущей статьи мы рассмотрим и первый, и второй вариант.

Разберем следующий пример: объекты хранятся в локальном хранилище, для работы с которым используем 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, стремится к нулю. Однако, если уже обязательные поля не загружены, то и объект является лениво загруженным. Так же добавлю, что проверки могут быть иными, а в каких то случаях стоит применить синхронизацию при проверке и загрузке полей таким образом).

В результате проделанных манипуляций, в любой точке кода всегда можно вызвать загрузочный метод и получить необходимое поле. Предложенное решение работает не только с данными, получаемыми из удаленных сервисов – его можно адаптировать и к работе с локальной бд.

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

Tags:
Hubs:
Total votes 6: ↑3 and ↓30
Comments9

Articles