JPA @PostLoad przy zapytaniach readOnly (EclipseLink)

Autor
Damian
Terlecki
13 minut
JPA

Zgodnie ze specyfikacją JPA, metody encji adnotowane przy pomocy @PostLoad służą do wywołania kodu w momencie załadowania encji z bazy danych. Precyzyjniej, następuje to w momencie załadowania encji do persistence context oraz w momencie wywołania operacji refresh. Mylnie można jednak zakładać, że metoda zostanie wywołana zawsze gdy zaciągamy dane z bazy danych.

Zarówno adnotacja @ReadOnly ustawiana na encji, jak i podpowiedź QueryHints.READ_ONLY ("eclipselink.read-only"), specyficzne dla implementacji EclipseLink pozwalają na pominięcie persistence contextu podczas procesowania zapytań. Jest to ciekawa opcja optymalizacyjna pozwalająca zmniejszyć zużycie pamięci sterty przy ładowaniu większych zbiorów danych. Równocześnie pozwala wykorzystać współdzieloną pamięć podręczną na poziomie persistence unit, gdy nie potrzebujemy modyfikować encji.

W przypadku wykorzystania powyższej funkcjonalności nie dojdzie do wywołania metody @PostLoad. Do zobrazowania przykładowej sytuacji posłużę się prostą encją z taką metodą:

package dev.termian.demo;

import lombok.Getter;
import lombok.Setter;
import lombok.ToString;
import org.eclipse.persistence.annotations.Customizer;

import javax.persistence.Entity;
import javax.persistence.GeneratedValue;
import javax.persistence.GenerationType;
import javax.persistence.Id;
import javax.persistence.PostLoad;
import javax.persistence.Table;
import javax.persistence.Transient;

@Getter
@Setter
@ToString(exclude = "id")
@Entity
@Table(name="users")
@Customizer(PostLoadOnReadOnlyDescriptorCustomizer.class)
public class User {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String name;
    @Transient
    private int index = 0;

    @PostLoad
    void postLoad() {
        index++;
    }

}

Test wartości pola index po załadowaniu z bazy danych pokazuje, że metoda nie wywołała się przy zapytaniu readOnly:

package dev.termian.demo;

import org.eclipse.persistence.config.HintValues;
import org.eclipse.persistence.config.QueryHints;
import org.junit.jupiter.api.Test;
import org.springframework.boot.test.context.SpringBootTest;

import javax.persistence.EntityManager;
import javax.persistence.PersistenceContext;
import javax.persistence.TypedQuery;
import javax.persistence.criteria.CriteriaBuilder;
import javax.persistence.criteria.CriteriaQuery;
import javax.transaction.Transactional;
import java.util.Arrays;
import java.util.List;

import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertFalse;

@SpringBootTest
class DemoApplicationTests {

    @PersistenceContext
    EntityManager em;

    @Test
    void testPostLoadReadOnly() {
        CriteriaBuilder builder = em.getCriteriaBuilder();
        CriteriaQuery<User> criteria = builder.createQuery(User.class);
        TypedQuery<User> query = em.createQuery(criteria);

        query.setHint(QueryHints.READ_ONLY, HintValues.TRUE);

        List<User> users = query.getResultList();
        assertPostLoadExecutedOnce(users);
    }

    @Test
    void testPostLoad() {
        CriteriaBuilder builder = em.getCriteriaBuilder();
        CriteriaQuery<User> criteria = builder.createQuery(User.class);
        TypedQuery<User> query = em.createQuery(criteria);

        List<User> users = query.getResultList();
        assertPostLoadExecutedOnce(users);
    }

    private void assertPostLoadExecutedOnce(List<User> users) {
        assertFalse(users.isEmpty());
        for (User user : users) {
            assertEquals(1, user.getIndex());
        }
    }

}
Metoda @PostLoad nie wywołana przy zapytaniu readOnly

Z powodu braku zaznaczenia docelowego zachowania przez EL, znajdziemy otwarte zgłoszenia błędów 336066 i 477063. W jednym ze zgłoszeń Jan Vermeulen tłumaczy jak problem przekłada się na kod EclipseLinka. W skrócie metoda @PostLoad podpinana jest do EclipseLinkowych zdarzeń clone i refresh mających miejsce właśnie przy odświeżaniu i ładowaniu do kontekstu. Nie jest natomiast podpinana pod zdarzenie build, które występuje również poza kontekstem i mogłoby być potencjalnym rozwiązaniem problemu.

Chcąc więc rozszerzyć działanie metody @PostLoad również poza persistence context, będziemy musieli dostać się do kodu wiążącego metodę ze zdarzeniami EclipseLinkowymi. Szukana operacja odbywa się w klasie EntityListener podczas inicjalizacji persistence unit. EL udostępnia nam natomiast dwa interfejsy do konfiguracji działania naszej warstwy persystencji, z poziomu których możemy podpiąć nasze własne metody. Są to:

  • SessionCustomizer definiowany na poziomie persistence unit;
  • DescriptorCustomizer definiowany na poziomie encji.

SessionCustomizer

Inicjalizacja sesji odbywa się z reguły tuż przed utworzeniem pierwszego kontekstu (entity managera). Jednym z ostatnich jej kroków jest zaaplikowanie konfiguratora. Interfejs udostępnia nam jedynie jedną metodę customize z parametrem sesji, z którego następnie możemy odczytać zainicjalizowane metadane w postaci deskryptorów encji (#1) wykrytych w poprzednich etapach tej fazy.

package dev.termian.demo;

import org.eclipse.persistence.config.SessionCustomizer;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.descriptors.DescriptorEvent;
import org.eclipse.persistence.descriptors.DescriptorEventAdapter;
import org.eclipse.persistence.descriptors.DescriptorEventListener;
import org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener;
import org.eclipse.persistence.sessions.Session;

import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.Map;

public class PostLoadOnReadOnlySessionCustomizer implements SessionCustomizer {

    @Override
    public void customize(Session session) {
        for (ClassDescriptor classDescriptor : session.getDescriptors().values()) { // #1
            DescriptorEventListener entityEventListener = classDescriptor.getEventManager()
                    .getEntityEventListener();
            if (entityEventListener instanceof EntityListener) { // #2
                setPostLoadToPostBuild((EntityListener<?>) entityEventListener);
            }
        }
    }

    private void setPostLoadToPostBuild(EntityListener<?> listener) {
        Map<String, List<Method>> eventMethods = listener.getAllEventMethods(); // #3
        List<Method> methods = eventMethods.getOrDefault(EntityListener.POST_CLONE,
                Collections.emptyList()); // #4
        if (!methods.isEmpty()) {
            Method postLoad = methods.get(0);
            listener.setPostBuildMethod(postLoad); // #5
            System.out.println(postLoad + " bound to the EL postBuild");
        }
    }
}

Entity listener (#2) powiązany z encją zawiera uprzednio odnalezione i powiązane metody cyklu życia encji (#3). Wiedząc, że EclipseLinkowe zdarzenie POST_CLONE (#4) podpięte jest pod metodę @PostLoad, możemy ją podpiąć również pod zdarzenie POST_BUILD (#5).

W powyższym rozwiązaniu istnieje sytuacja, gdy metoda postLoad wywoła się dwa razy (build i clone). Sytuacja wystąpić może podczas klonowania obiektów (persistence unit) w jednostce pracy. Jeśli chcemy pominąć taki przypadek, to wystarczy, że zweryfikujemy ten warunek za pomocą pośredniego listenera (#6) warunkowo (#7) delegującego postBuild do postClone podstawowego listenera (#8):

public class PostLoadOnReadOnlySessionCustomizer implements SessionCustomizer {
    //...
    private void setPostLoadToPostBuild(EntityListener<?> listener, ClassDescriptor classDescriptor) {
        Map<String, List<Method>> eventMethods = listener.getAllEventMethods();
        List<Method> methods = eventMethods.getOrDefault(EntityListener.POST_CLONE, Collections.emptyList());
        if (!methods.isEmpty()) {
            Method postLoad = methods.get(0);
            classDescriptor.getEventManager().addListener(new DescriptorEventAdapter() { // #6
                @Override
                public void postBuild(DescriptorEvent event) {
                    if (!event.getSession().isUnitOfWork()) { // #7
                        listener.postClone(event); // #8
                    }
                }
            });
            System.out.println(postLoad + " bound to the EL postBuild");
        }
    }
}

Konfigurator podpinamy w pliku persistence.xml (właściwa przestrzeń nazw może się różnić w zależności od wersji JPA 2.x i 3.x). Własność eclipselink.session.customizer powinna wskazywać na nazwę pakietową klasy:

<?xml version="1.0" encoding="UTF-8"?>
<persistence version="2.1" xmlns="http://xmlns.jcp.org/xml/ns/persistence"
             xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
             xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/persistence 
             http://xmlns.jcp.org/xml/ns/persistence/persistence_2_1.xsd">
    <persistence-unit name="my-pu" transaction-type="RESOURCE_LOCAL">
        <!--...-->
        <properties>
            <!--...-->
            <property name="eclipselink.session.customizer"
                      value="dev.termian.demo.PostLoadOnReadOnlySessionCustomizer"/>
        </properties>
    </persistence-unit>
</persistence>

DescriptorCustomizer

Podobne rozszerzenie zaimplementować możemy na poziomie właściwej encji. W momencie konfiguracji standardowy EntityListener nie będzie w tym przypadku zainicjalizowany (#9). Nic nie stoi jednak na przeszkodzie, żeby przenieść potrzebną część kodu (#2, #7, #9) do momentu obsługi zdarzenia. Właściwie w ten sam sposób moglibyśmy zrefaktorować przykład w punkcie 8.

package dev.termian.demo;

import org.eclipse.persistence.config.DescriptorCustomizer;
import org.eclipse.persistence.descriptors.ClassDescriptor;
import org.eclipse.persistence.descriptors.DescriptorEvent;
import org.eclipse.persistence.descriptors.DescriptorEventAdapter;
import org.eclipse.persistence.descriptors.DescriptorEventListener;
import org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener;
import org.eclipse.persistence.sessions.Session;

import java.lang.reflect.Method;
import java.util.Collections;
import java.util.List;
import java.util.Map;

public class PostLoadOnReadOnlyDescriptorCustomizer extends DescriptorEventAdapter implements DescriptorCustomizer {

    @Override
    public void customize(ClassDescriptor descriptor) {
        assert descriptor.getEventManager().getEntityEventListener() == null; // #9
        descriptor.getEventManager().addListener(this);
    }

    @Override
    public void postBuild(DescriptorEvent event) {
        // ((User)event.getSource()).postLoad();
        if (!event.getSession().isUnitOfWork()) {
            DescriptorEventListener entityEventListener = event.getDescriptor().getEventManager().getEntityEventListener();
            if (entityEventListener instanceof EntityListener) {
                EntityListener<?> entityListener = (EntityListener<?>) entityEventListener;
                entityListener.postClone(event);
            }
        }
    }

}

Konfigurator podpinamy pod encję adnotacją org.eclipse.persistence.annotations.Customizer: @Customizer(PostLoadOnReadOnlyDescriptorCustomizer.class). Alternatywnie możemy to też zrobić przy pomocy deskryptora presistence bądź EclipseLinkowego deskryptora orm.

Metoda @PostLoad podpięta do zdarzenia POST_BUILD

Miejmy na uwadze to, że korzystamy z wewnętrznego interfejsu EclipseLink w postaci org.eclipse.persistence.internal.jpa.metadata.listeners.EntityListener (przykład aktualny dla wersji EL 2.7.4). Jeśli chcielibyśmy uniezależnić się od implementacji, wystarczy, że zaproponujemy własne rozwiązanie wyszukiwania metod adnotowanych @PostLoad. W takim wypadku warto pamiętać o tym, że metoda może znaleźć się również w klasach nadrzędnych adnotowanych @MappedSuperclass.