Победить Hibernate в тестах. Возможно?

от автора

Всем, привет! На связи Михаил Поливаха, технический лидер Open Source проекта Axelix (проект, посвященный помочь вам идентифицировать частые проблемы в Java server-side приложениях, в т.ч в рамках работы с Hibernate).

Данная, последняя статья является продолжением небольшой серии статей ответов на вопросы, которые возникли у участников Spring АйО Академии в рамках программы Hibernate. Я рассмотрю последний вопрос, а именно:

“Можно ли в тестах или в целом где-то/как-то проверить, что Hibernate под капотом делает что-то страшное?”

Тут очень важно не давать вам “рыбу”, а научить вас рыбачить, т.е. дать вам некоторый фреймворк о том, что стоит держать в уме при работе с Hibernate.

Контролировать Hibernate во время тестов. Есть нюанс.

Если я вам задам вопрос — парни, а какие проблемы в Hibernate вам больше всего не нравятся? Прозвучат такие вещи, как незаметный N + 1, In Memory Pagination (при явном указании setMaxResult или вместе с Pageable), Cartesian Product в память приложения, или например какой-то непонятный рой SELECT и потом UPDATE/DELETE под капотом и т.д.

Вот всё, что я выше перечислил, да и вообще все “проблемы” с Hibernate можно попытаться категоризировать. Давайте попробуем это сделать.

1. Проблемы на уровне ORM

Есть такие ситуации, когда Hibernate сам понимает, что то, что он собирается сделать, может причинить вред вашему приложению в той или иной форме, но он всё равно это делает, т.к. обязан соблюсти контракт, который его обязывает JPA спека.

Например, вплоть до Hibernate 7.4, запрос ниже бы полностью выполнял Pagination в памяти, это довольно хорошо известно:

@Transactional@Query(    value = "SELECT o FROM Owner o JOIN FETCH o.pets",    countQuery = "SELECT COUNT(o) FROM Owner o")Page<Owner> findAllWithPets(Pageable pageable);

В версии 7.4 Hibernate научился делать pagination в рамках sub-select. Но тем не менее, если вы, например, работаете на Spring Boot 3, то у вас Hibernate 6, и в случае таких вот запросов выше в stdout будет warning:

HHH90003004: firstResult/maxResults specified with collection fetch; applying in memory

И будет он там потому, что Hibernate сам понимает, что его действия могут взорвать ваш heap. Есть даже property, которую мы в Axelix рекомендуем ставить для новых микросервисов, которые не используют ещё 7.4 (а таких ещё много):

spring.jpa.properties.hibernate.query.fail_on_pagination_over_collection_fetch=true

Если ее выставить, то Hibernate будет просто бросать exception там, где ранее бы делал In Memory Pagination.

В общем, что я хочу сказать — вот проблема выше, она Hibernate-ом понимается как реальная проблема. Он её понимает и признает.

Это один пласт проблем. Но как ни странно, их отследить довольно тяжело — Hibernate редко даёт какие-то средства, чтобы вклиниться в его (хотя, всё же, есть моменты, читайте ниже). Например, мы в Axelix для определения In Memory Pagination (если она происходит) просто буквально ловим ILoggingEvent и инспектируем код в рамках него. Потому что других приемлемых способов нет (по крайней мере, нам неизвестны)!

В общем случае, я бы сказал, что ответ на вопрос: “А можно ли это всё поймать в тестах?” для данной категории сильно спцифичен для каждой конкретной проблемы. Например, если In Memory Pagining ещё можно как-то попытаться поймать, то помать Cartesian Product в тестах будет почти нереально.

2. Второй. Проблемы на уровне приложения

Есть концептуально другой пласт проблем. К ним, например, относится N + 1.

Давайте на секунду подумаем, что такое N + 1? Попробуйте сейчас, не смотря никуда, у себя в голове чётко сформулировать определение. Сформулировали? Вот как бы я его сформулировал (неформально):

Это ситуация, при которой происходит итерация в рамках загруженной Hibernate коллекции, где мы последовательно обращаемся к тому или иному ленивому полю в рамках каждой отдельной сущности коллекции, чем вызываем “N” дополнительных SELECT запросов.

То есть, если немного подумать, то становится очевидно, что для Hibernate это просто Lazy Loading. Понятие N + 1 как проблемы существует на уровне приложения, а не на уровне ORM. Проблема не в самом lazy loading, а в семантике lazy loading (иными словами — а что этот конкретный lazy loading значит? Это просто lazy loading, или lazy loading в рамках N + 1?).

В такой ситуации, очевидно, на Hibernate надеяться совсем нет смысла, т.к явление N + 1 происходит на уровне приложения, а ORM об этом не в курсе совершенно — это не его зона ответственности. Без довольно сильного тулинга вокруг Hibernate не обойтись. Можно ли его написать, и если можно, то как?

Если мы говорим конкретно про N + 1, то ответ в целом — можно и мы это сделали в Axelix в Open Source. Сейчас расскажу, как именно это делали например мы. Исходный код открыт, посмотрите, если есть желание.

Общий Подход к Детекшену N + 1

Hibernate не знает, что такое N + 1, но знает, что такое Lazy Loading ассоциации, и уже этот момент можно отловить. Например, как мы это делаем в NPlusOneCollectionLoadListener:

    @Override    public void onInitializeCollection(InitializeCollectionEvent event) {        try {            PersistentCollection persistentCollection = event.getCollection();            EventSource eventSource = event.getSession();            CollectionPersister collectionPersister = eventSource                    .getPersistenceContextInternal()                    .getCollectionEntry(persistentCollection)                    .getLoadedPersister();            LazyLoadingTarget lazyLoadingTarget = parseLazyLoadingTarget(collectionPersister.getRole());            if (lazyLoadingTarget != null) {                transactionAccessor.recordLazyLoading(lazyLoadingTarget);            }        } catch (Exception ignored) {        }    }    // The general "role" format is expected to look like this: com.example.Order.items    public static @Nullable LazyLoadingTarget parseLazyLoadingTarget(String role) {        try {            int separatorIndex = role.lastIndexOf(".");            Class<?> ownerEntityClass = Class.forName(role.substring(0, separatorIndex));            String propertyName = role.substring(separatorIndex + 1);            return new LazyLoadingTarget(ownerEntityClass, propertyName);        } catch (ClassNotFoundException | IndexOutOfBoundsException e) {            log.warn(                    "Unexpected propertyPath format '{}'. Axelix cannot recognize that, so lazy loading and potential N + 1 is not going to be tracked for this property",                    role);            return null; // it means that the role format is not the one that we expect        }    }

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

Axelix, например, на данный момент, принимает такое решение: Если мы заметили, что в рамках транзакции были несколько lazy loading в рамках одной и той же ассоциации, то мы считаем, что это N + 1.

И теперь давайте подумаем: Верно ли это всегда? Ну вот тут спорно, т.к например в данном примере с кодом, если предположить, что Order.items загружены были лениво — мы, вроде как, не имеем N + 1:

public void compareOrders(Long previousId, Long currentId) {    var previous = orderRepository.findById(previousId).orElseThrow();    var current = orderRepository.findById(currentId).orElseThrow();    // Два ленивых обращения к Order.items, но никакой коллекции ордеров тут нет -    // мы просто сравниваем две конкретные версии одного и того же заказа    if (previous.getItems().size() != current.getItems().size()) {        throw new IllegalStateException("Состав заказа изменился");    }}

Или имеем? А если я этот пример поменяю вот на такой:

public void compareOrders(Long previousId, Long currentId) {    var orders = orderRepository.findAllById(List.of(previousId, currentId));    var previous = orders.get(0);    // Ровно то же сравнение, что и выше, но теперь в рамках итерации    // по загруженной коллекции ордеров: на каждом шаге снова ленивое    // обращение к Order.items и у previous, и у current    for (var current : orders) {        if (previous.getItems().size() != current.getItems().size()) {            throw new IllegalStateException("Состав заказа изменился");        }        previous = current;    }}

А ведь это один и тот же код-то по сути! Иными словами, я хочу сказать, что здесь нужно чётко фиксировать определение N + 1 на уровне приложения. И уже в зависимости от определения надо решать — считать ли примеры кода выше N + 1, или нет.

Мы в Axelix намеренно решили, что оба кейса выше всё же стоит рассматривать как N + 1, поэтому, на UI, он будет вам репортиться:

UI Axelix в рамках существующих транзакций

UI Axelix в рамках существующих транзакций

P.S: Помимо прочего, тут есть небольшой нюанс: N + 1, строго говоря, может случаться и без открытой транзакции в случае включенного OSIV. Это отдельный кейс, который мы пока не рассматриваем, т.к тут придется усложнять картину.

Микро-Вывод

Я бы хотел тут подвести небольшой микро-вывод. В целом, обнаружить какие-то сложные явления, которые происходят на уровне приложения в рамках работы с Hibernate:

  • N + 1

  • Блокирующие вызовы в транзакциях и т.д.

иногда можно, но это будет требовать написания от вас довольно большого и нетривиального кода.

Собственный инструментарий для детекшена именно таких проблем, как N + 1 — я писать вам не рекомендую, т.к. скорее всего, издержки на написание и поддержку такой логики будут довольно большими. Иногде кажется, что можно нечто подобное сделать, но на практике там очень много деталей и нюансов и корнер кейсов, говорю вам как core maintainer Axelix.

Но не всё так печально. Некоторые инсайды в рамках тестов вы всё же сможете обнаружить. Читайте ниже.

Проблемы общего характера

Отдельной секцией я бы хотел добавить проблемы общего характера. Довольно часто у людей есть какой-то условный hot-path сохранения или обновления данных, где они просто хотят убедиться в том, что Hibernate не делает какой-то там подковерной работы как диверсант. Нет никаких там лишних SELECT, UPDATE и т.д. Знакомо же, правда?

Тут надо сделать шаг назад и спросить: а что мы понимаем под диверсией под капотом? (я очень надеюсь, что найдется такой человек, который прочитает это предложение в отрыве от контекста всей статьи).

Пообщавшись с людьми, мы обнаружим, что чаще всего люди хотят примерно следующее: иметь уверенность в том, что человек написал repository.save(), и что у него выполнится конкретно INSERT в таблицу и ничего более. Например, имея такой кусок кода:

@Servicepublic class OwnerService {    private final OwnerRepository ownerRepository;    public OwnerService(OwnerRepository ownerRepository) {        this.ownerRepository = ownerRepository;    }    @Transactional    public Owner registerOwner(String firstName, String lastName) {        var owner = new Owner(firstName, lastName);        return ownerRepository.save(owner);    }}

Человек хочет быть уверен, что там не будет там никаких скрытых SELECT и т.д. Думаю, что часть людей узнала себя. Можно ли так сделать?

Ответ: в целом да, можно. У Hibernate есть Statistics API, который с одной стороны не входит в JPA стандарт, а с другой может нам дать некоторые детали по исполнению запросов в сессии. Есть ещё т.н. hypersistence-utils, но в данном случае он просто является очень тонкой оберткой над Hibernate Statistics API, так что смысла его тащить вам в test classpath для решения конкретно этой пробелмы — я не вижу.

На код выше можно написать такой вот тест:

// Нужно включить hibernate.generate_statistics@SpringBootTestclass OwnerServiceTest {    @Autowired    private OwnerService ownerService;    @Autowired    private EntityManagerFactory entityManagerFactory;    @Test    @Transactional    void registerOwner_issuesExactlyOneInsert() {        // given        Statistics statistics = entityManagerFactory                .unwrap(SessionFactory.class)                .getStatistics();        statistics.clear();        // when        ownerService.registerOwner("John", "Doe");        // then        assertThat(statistics.getEntityInsertCount()).isEqualTo(1);        assertThat(statistics.getEntityUpdateCount()).isZero();        assertThat(statistics.getEntityDeleteCount()).isZero();    }}

Данный API позволяет проверить, что в рамках метода выполнился только один INSERT. Работает он на основе того, что я в тесте открыл транзакцию (Spring Boot Test конечно это распознает и откроет транзакцию, которая должна будет сделать в конце rollback), тем самым открыв Hibernate Session — это поведение по-умолчанию, думаю, тут сюрпризов нет.

Поскольку Statistics API собирает статистику на уровне SessionFactory, а не индивидуальных Session-ов, нам в каждом тесте приходится обращаться к SessionFactory, в рамках которой была открыта сессия, чтобы сначала почистить всю статистику по ней. А уже потом можно делать проверки на то, что нас интересует (Строго говоря, некоторая телеметрия собирается и на уровне Session, но она довольно скудная и на практике вам мало чем может помочь).

В целом, собираемой телеметрии (кол-во INSERT, UPDATE, DELETE statement-ов и т.д.) вам хватит для того, чтобы сделать некие базовые проверки хотя бы на количество тех или иных DML запросов. На моём опыте, для простых кейсов, где не требуется понимание семантики (!) запросов (например N + 1) — такого API вполне себе достаточно.

Выводы

На практике, ответ на вопрос:

“Можно ли в тестах как-то проверить, что Hibernate под капотом делает что-то страшное?”

Сильно зависит от того, а что именно вы хотите обнаруживать. Какой-то тулинг написать можно, например, Axelix умеет обнаруживать как In Memory Pagination, так и N + 1, так и ряд других проблем, но это не тесты — это решение на уровне всей вашей микросервисной экосистемы.

Тем не менее, а каких-то ситуациях вам автоматизированные тесты как этап Quality Gate могут помочь. В общем, в первую очередь лучше спросить себя — какую проблему именно тут мы пытаемся решить. И дальше уже строить решение, можно ли (с учётом того, что я написал выше) написать эффективный тест, можно ли отдать это внешнему тулингу, а может быть (идеальный кейс) проблему можно вообще не решать.

Всем успехов!**

ссылка на оригинал статьи https://habr.com/ru/articles/1068176/