Un guide de la session ouverte de Spring en vue

1. Vue d'ensemble

La session par demande est un modèle transactionnel pour lier la session de persistance et les cycles de vie des demandes. Sans surprise, Spring est livré avec sa propre implémentation de ce modèle, appelée OpenSessionInViewInterceptor , pour faciliter le travail avec des associations paresseuses et donc améliorer la productivité des développeurs.

Dans ce tutoriel, nous allons d'abord apprendre comment l'intercepteur fonctionne en interne, puis nous verrons comment ce modèle controversé peut être une épée à double tranchant pour nos applications!

2. Présentation de la session ouverte dans View

Pour mieux comprendre le rôle d'Open Session in View (OSIV), supposons que nous ayons une requête entrante:

  1. Spring ouvre une nouvelle session Hibernate au début de la demande. Ces sessions ne sont pas nécessairement connectées à la base de données.
  2. Chaque fois que l'application a besoin d'une session, elle réutilise celle déjà existante.
  3. À la fin de la demande, le même intercepteur ferme cette session.

À première vue, il peut être judicieux d'activer cette fonctionnalité. Après tout, le framework gère la création et la fin de la session, de sorte que les développeurs ne se préoccupent pas de ces détails apparemment de bas niveau. Ceci, à son tour, augmente la productivité des développeurs.

Cependant, parfois, OSIV peut entraîner des problèmes de performances subtils en production . Habituellement, ces types de problèmes sont très difficiles à diagnostiquer.

2.1. Botte de printemps

Par défaut, OSIV est actif dans les applications Spring Boot . Malgré cela, à partir de Spring Boot 2.0, il nous avertit du fait qu'il est activé au démarrage de l'application si nous ne l'avons pas configuré explicitement:

spring.jpa.open-in-view is enabled by default. Therefore, database queries may be performed during view rendering.Explicitly configure spring.jpa.open-in-view to disable this warning

Quoi qu'il en soit, nous pouvons désactiver l'OSIV en utilisant la propriété de configuration spring.jpa.open-in-view :

spring.jpa.open-in-view=false

2.2. Motif ou anti-motif?

Il y a toujours eu des réactions mitigées envers l'OSIV. L'argument principal du camp pro-OSIV est la productivité des développeurs, en particulier lorsqu'il s'agit d'associations paresseuses.

D'un autre côté, les problèmes de performances des bases de données sont le principal argument de la campagne anti-OSIV. Plus tard, nous allons évaluer les deux arguments en détail.

3. Héros d'initialisation paresseuse

Étant donné qu'OSIV lie le cycle de vie de la session à chaque requête, Hibernate peut résoudre les associations paresseuses même après le retour d'un service @Transactional explicite .

Pour mieux comprendre cela, supposons que nous modélisions nos utilisateurs et leurs autorisations de sécurité:

@Entity @Table(name = "users") public class User { @Id @GeneratedValue private Long id; private String username; @ElementCollection private Set permissions; // getters and setters }

Semblable à d'autres relations un-à-plusieurs et plusieurs-à-plusieurs, la propriété permissions est une collection différée.

Ensuite, dans notre implémentation de couche de service, délimitons explicitement notre limite transactionnelle à l'aide de @Transactional :

@Service public class SimpleUserService implements UserService { private final UserRepository userRepository; public SimpleUserService(UserRepository userRepository) { this.userRepository = userRepository; } @Override @Transactional(readOnly = true) public Optional findOne(String username) { return userRepository.findByUsername(username); } }

3.1. L'attente

Voici ce à quoi nous nous attendons lorsque notre code appelle la méthode findOne :

  1. Dans un premier temps, le proxy Spring intercepte l'appel et obtient la transaction en cours ou en crée une s'il n'en existe pas.
  2. Ensuite, il délègue l'appel de méthode à notre implémentation.
  3. Enfin, le proxy valide la transaction et ferme par conséquent la session sous-jacente . Après tout, nous n'avons besoin que de cette session dans notre couche de service.

Dans l' implémentation de la méthode findOne , nous n'avons pas initialisé la collection d' autorisations . Par conséquent, nous ne devrions pas pouvoir utiliser les autorisations après le retour de la méthode. Si nous effectuons une itération sur cette propriété , nous devrions obtenir une LazyInitializationException.

3.2. Bienvenue dans le monde réel

Écrivons un simple contrôleur REST pour voir si nous pouvons utiliser la propriété permissions :

@RestController @RequestMapping("/users") public class UserController { private final UserService userService; public UserController(UserService userService) { this.userService = userService; } @GetMapping("/{username}") public ResponseEntity findOne(@PathVariable String username) { return userService .findOne(username) .map(DetailedUserDto::fromEntity) .map(ResponseEntity::ok) .orElse(ResponseEntity.notFound().build()); } }

Ici, nous parcourons les autorisations lors de la conversion d'entité en DTO. Puisque nous nous attendons à ce que la conversion échoue avec une exception LazyInitializationException, le test suivant ne devrait pas réussir:

@SpringBootTest @AutoConfigureMockMvc @ActiveProfiles("test") class UserControllerIntegrationTest { @Autowired private UserRepository userRepository; @Autowired private MockMvc mockMvc; @BeforeEach void setUp() { User user = new User(); user.setUsername("root"); user.setPermissions(new HashSet(Arrays.asList("PERM_READ", "PERM_WRITE"))); userRepository.save(user); } @Test void givenTheUserExists_WhenOsivIsEnabled_ThenLazyInitWorksEverywhere() throws Exception { mockMvc.perform(get("/users/root")) .andExpect(status().isOk()) .andExpect(jsonPath("$.username").value("root")) .andExpect(jsonPath("$.permissions", containsInAnyOrder("PERM_READ", "PERM_WRITE"))); } }

Cependant, ce test ne lève aucune exception et réussit.

Étant donné que OSIV crée une session au début de la demande, le proxy transactionnelutilise la session actuellement disponible au lieu d'en créer une nouvelle .

Ainsi, malgré ce à quoi nous pouvons nous attendre, nous pouvons en fait utiliser la propriété permissions même en dehors d'un @Transactional explicite . De plus, ces sortes d'associations paresseuses peuvent être récupérées n'importe où dans la portée de la requête actuelle.

3.3. Sur la productivité des développeurs

Si OSIV n'était pas activé, nous devions initialiser manuellement toutes les associations paresseuses nécessaires dans un contexte transactionnel . Le moyen le plus rudimentaire (et généralement faux) est d'utiliser la méthode Hibernate.initialize () :

@Override @Transactional(readOnly = true) public Optional findOne(String username) { Optional user = userRepository.findByUsername(username); user.ifPresent(u -> Hibernate.initialize(u.getPermissions())); return user; }

À présent, l'effet d'OSIV sur la productivité des développeurs est évident. Cependant, ce n'est pas toujours une question de productivité des développeurs.

4. Méchant de performance

Suppose we have to extend our simple user service to call another remote service after fetching the user from the database:

@Override public Optional findOne(String username) { Optional user = userRepository.findByUsername(username); if (user.isPresent()) { // remote call } return user; }

Here, we're removing the @Transactional annotation since we clearly won't want to keep the connected Session while waiting for the remote service.

4.1. Avoiding Mixed IOs

Let's clarify what happens if we don't remove the @Transactional annotation. Suppose the new remote service is responding a little more slowly than usual:

  1. At first, the Spring proxy gets the current Session or creates a new one. Either way, this Session is not connected yet. That is, it's not using any connection from the pool.
  2. Once we execute the query to find a user, the Session becomes connected and borrows a Connection from the pool.
  3. If the whole method is transactional, then the method proceeds to call the slow remote service while keeping the borrowed Connection.

Imagine that during this period, we get a burst of calls to the findOne method. Then, after a while, all Connections may wait for a response from that API call. Therefore, we may soon run out of database connections.

Mixing database IOs with other types of IOs in a transactional context is a bad smell, and we should avoid it at all costs.

Anyway, since we removed the @Transactional annotation from our service, we're expecting to be safe.

4.2. Exhausting the Connection Pool

When OSIV is active, there is always a Session in the current request scope, even if we remove @Transactional. Although this Session is not connected initially, after our first database IO, it gets connected and remains so until the end of the request.

So, our innocent-looking and recently-optimized service implementation is a recipe for disaster in the presence of OSIV:

@Override public Optional findOne(String username) { Optional user = userRepository.findByUsername(username); if (user.isPresent()) { // remote call } return user; }

Here's what happens while the OSIV is enabled:

  1. At the beginning of the request, the corresponding filter creates a new Session.
  2. When we call the findByUsername method, that Session borrows a Connection from the pool.
  3. The Session remains connected until the end of the request.

Even though we're expecting that our service code won't exhaust the connection pool, the mere presence of OSIV can potentially make the whole application unresponsive.

To make matters even worse, the root cause of the problem (slow remote service) and the symptom (database connection pool) are unrelated. Because of this little correlation, such performance issues are difficult to diagnose in production environments.

4.3. Unnecessary Queries

Unfortunately, exhausting the connection pool is not the only OSIV-related performance issue.

Since the Session is open for the entire request lifecycle, some property navigations may trigger a few more unwanted queries outside of the transactional context. It's even possible to end up with n+1 select problem, and the worst news is that we may not notice this until production.

Adding insult to injury, the Session executes all those extra queries in auto-commit mode. In auto-commit mode, each SQL statement is treated as a transaction and is automatically committed right after it is executed. This, in turn, puts a lot of pressure on the database.

5. Choose Wisely

Whether the OSIV is a pattern or an anti-pattern is irrelevant. The most important thing here is the reality in which we're living.

If we're developing a simple CRUD service, it might make sense to use the OSIV, as we may never encounter those performance issues.

On the other hand, if we find ourselves calling a lot of remote services or there is so much going on outside of our transactional contexts, it's highly recommended to disable the OSIV altogether.

When in doubt, start without OSIV, since we can easily enable it later. On the other hand, disabling an already enabled OSIV may be cumbersome, as we may need to handle a lot of LazyInitializationExceptions.

The bottom line is that we should be aware of the trade-offs when using or ignoring the OSIV.

6. Alternatives

If we disable OSIV, then we should somehow prevent potential LazyInitializationExceptions when dealing with lazy associations. Among a handful of approaches to coping with lazy associations, we're going to enumerate two of them here.

6.1. Entity Graphs

When defining query methods in Spring Data JPA, we can annotate a query method with @EntityGraph to eagerly fetch some part of the entity:

public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "permissions") Optional findByUsername(String username); }

Here, we're defining an ad-hoc entity graph to load the permissions attribute eagerly, even though it's a lazy collection by default.

If we need to return multiple projections from the same query, then we should define multiple queries with different entity graph configurations:

public interface UserRepository extends JpaRepository { @EntityGraph(attributePaths = "permissions") Optional findDetailedByUsername(String username); Optional findSummaryByUsername(String username); }

6.2. Caveats When Using Hibernate.initialize()

One might argue that instead of using entity graphs, we can use the notorious Hibernate.initialize() to fetch lazy associations wherever we need to do so:

@Override @Transactional(readOnly = true) public Optional findOne(String username) { Optional user = userRepository.findByUsername(username); user.ifPresent(u -> Hibernate.initialize(u.getPermissions())); return user; }

They may be clever about it and also suggest to call the getPermissions() method to trigger the fetching process:

Optional user = userRepository.findByUsername(username); user.ifPresent(u -> { Set permissions = u.getPermissions(); System.out.println("Permissions loaded: " + permissions.size()); });

Both approaches aren't recommended since they incur (at least) one extra query, in addition to the original one, to fetch the lazy association. That is, Hibernate generates the following queries to fetch users and their permissions:

> select u.id, u.username from users u where u.username=? > select p.user_id, p.permissions from user_permissions p where p.user_id=? 

Although most databases are pretty good at executing the second query, we should avoid that extra network round-trip.

On the other hand, if we use entity graphs or even Fetch Joins, Hibernate would fetch all the necessary data with just one query:

> select u.id, u.username, p.user_id, p.permissions from users u left outer join user_permissions p on u.id=p.user_id where u.username=?

7. Conclusion

Dans cet article, nous avons tourné notre attention vers une fonctionnalité assez controversée de Spring et de quelques autres frameworks d'entreprise: Open Session in View. Tout d'abord, nous avons été aquatintés avec ce modèle à la fois conceptuellement et implémentation. Ensuite, nous l'avons analysé du point de vue de la productivité et de la performance.

Comme d'habitude, l'exemple de code est disponible over sur GitHub.