Bébé est venu voir son pÚre
Et a demandé au bébé
- Ce qui est bon
et ce qui est mauvais
Vladimir Mayakovsky
Cet article concerne Spring Data JPA, notamment dans le rùteau sous-marin que j'ai rencontré en chemin, et bien sûr un peu sur les performances.
Les exemples dĂ©crits dans l'article peuvent ĂȘtre exĂ©cutĂ©s dans l'environnement de test, accessible par rĂ©fĂ©rence .
Remarque pour ceux qui ne sont pas encore passés à Spring Boot 2Dans les versions de Spring Data JPA 2. *, l'interface principale pour travailler avec les référentiels, à savoir CrudRepository
, dont JpaRepository
est hérité, a JpaRepository
. Dans les versions 1. * les principales méthodes ressemblaient à ceci:
public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); }
Dans les nouvelles versions:
public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); }
Commençons donc.
sĂ©lectionnez t. * parmi t oĂč t.id dans (...)
L'une des requĂȘtes les plus courantes est une requĂȘte de la forme «sĂ©lectionner tous les enregistrements pour lesquels la clĂ© appartient Ă l'ensemble transmis». Je suis sĂ»r que presque tous ont Ă©crit ou vu quelque chose comme
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") List<Long> ids); @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
Ceux-ci fonctionnent, les demandes appropriées, il n'y a pas de problÚme de capture ou de performance, mais il y a un petit inconvénient complÚtement invisible.
Avant d'ouvrir la doublure, essayez de penser par vous-mĂȘme.L'inconvĂ©nient est que l'interface est trop Ă©troite pour transmettre des clĂ©s. "Et alors?" - dites-vous. "Eh bien la liste, bien l'ensemble, je ne vois pas de problĂšme ici." Cependant, si nous regardons les mĂ©thodes de l'interface racine qui prennent de nombreuses valeurs, alors partout nous voyons Iterable
:
"Et alors? Et je veux une liste. Pourquoi est-ce pire?"
Pas pire, préparez-vous simplement à l'apparition d'un code similaire à un niveau supérieur dans votre application:
public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); }
Ce code ne fait qu'inverser les collections. Il peut s'avĂ©rer que l'argument de la mĂ©thode est une liste, et la mĂ©thode du rĂ©fĂ©rentiel accepte l'ensemble (ou vice versa), et il vous suffit de le rĂ©organiser pour passer la compilation. Bien sĂ»r, cela ne deviendra pas un problĂšme dans le contexte des frais gĂ©nĂ©raux pour la demande elle-mĂȘme, il s'agit davantage de gestes inutiles.
Par conséquent, il est Iterable
utiliser Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
Z.Y. Si nous parlons d'une méthode de *RepositoryCustom
, alors il est logique d'utiliser Collection
pour simplifier le calcul de la taille à l'intérieur de l'implémentation:
public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds); } public class BankAccountRepositoryImpl { @Override public boolean anyMoneyAvailable(Collection<Long> accountIds) { if (ids.isEmpty()) return false;
Code supplémentaire: clés non dupliquées
Dans la suite de la derniÚre section, je voudrais attirer l'attention sur une idée fausse commune:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
Autres manifestations de la mĂȘme erreur:
Set<Long> ids = new HashSet<>(notUniqueIds); List<BankAccount> accounts = repository.findByUserIds(ids); List<Long> ids = ts.stream().map(T::id).distinct().collect(toList()); List<BankAccount> accounts = repository.findByUserIds(ids); Set<Long> ids = ts.stream().map(T::id).collect(toSet()); List<BankAccount> accounts = repository.findByUserIds(ids);
Ă premiĂšre vue, rien d'inhabituel, non?
Prenez votre temps, pensez par vous-mĂȘme;)Les requĂȘtes HQL / JPQL de la forme select t from t where t.field in ...
finira par devenir une requĂȘte
select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, âŠ)
qui retournera toujours la mĂȘme chose indĂ©pendamment de la prĂ©sence de rĂ©pĂ©titions dans l'argument. Par consĂ©quent, il n'est pas nĂ©cessaire de garantir l'unicitĂ© des clĂ©s. Il existe un cas particulier - Oracle, oĂč le fait d'appuyer sur> 1 000 clĂ©s d'entrĂ©e entraĂźne une erreur. Mais si vous essayez de rĂ©duire le nombre de clĂ©s en excluant les rĂ©pĂ©titions, vous devriez plutĂŽt rĂ©flĂ©chir Ă la raison de leur apparition. L'erreur est probablement quelque part au-dessus.
Donc, dans un bon code, utilisez Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
Samopis
Examinez attentivement ce code et trouvez ici trois défauts et une erreur possible:
@Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids);
Pensez un peu plus- tout est déjà implémenté dans
SimpleJpaRepository::findAllById
- demande inactive lors du passage d'une liste vide (dans
SimpleJpaRepository::findAllById
il y a une vĂ©rification correspondante) - toutes les requĂȘtes dĂ©crites Ă l'aide de
@Query
sont vérifiées au stade de l'élévation du contexte, ce qui prend du temps (contrairement à SimpleJpaRepository::findAllById
) - si Oracle est utilisé, lorsque la collection de clés est vide, nous obtenons l'erreur
ORA-00936: missing expression
(ce qui ne se produira pas lors de l'utilisation de SimpleJpaRepository::findAllById
, voir point 2)
Harry potter et clé composée
Jetez un Ćil Ă deux exemples et choisissez celui que vous prĂ©fĂ©rez:
Nombre de méthodes fois
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; }
Méthode numéro deux
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; }
à premiÚre vue, il n'y a aucune différence. Essayez maintenant la premiÚre méthode et exécutez un test simple:
Dans le journal des requĂȘtes (vous le gardez, non?) Nous verrons ceci:
select e.key1, e.key2 from CompositeKeyEntity e where e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ?
Maintenant, deuxiĂšme exemple
Le journal des requĂȘtes est diffĂ©rent:
select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=?
C'est toute la différence: dans le premier cas, nous recevons toujours 1 demande, dans le second - n demandes.
La raison de ce comportement réside dans SimpleJpaRepository::findAllById
:
La méthode qui vous convient le mieux est de déterminer en fonction de l'importance du nombre de demandes.
Extra CrudRepository :: enregistrer
Souvent, dans le code, il existe un tel contre-motif:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); }
Le lecteur est perplexe: oĂč est l'anti-modĂšle? Ce code semble trĂšs logique: nous obtenons l'entitĂ© - mise Ă jour - sauvegarde. Tout est comme dans les meilleures maisons de Saint-PĂ©tersbourg. J'ose dire que l'appel de CrudRepository::save
est superflu ici.
PremiÚrement: la méthode updateRate
transactionnelle, par conséquent, toutes les modifications dans l'entité gérée sont suivies par Hibernate et se transforment en demande lorsque Session::flush
exécutée, ce qui se produit dans ce code à la fin de la méthode.
DeuxiĂšmement, CrudRepository::save
un coup d'Ćil Ă la mĂ©thode CrudRepository::save
. Comme vous le savez, tous les référentiels sont basés sur SimpleJpaRepository
. Voici l'implémentation de CrudRepository::save
:
@Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } }
Il y a une subtilitĂ© dont tout le monde ne se souvient pas: Hibernate fonctionne Ă travers les Ă©vĂ©nements. En d'autres termes, chaque action utilisateur gĂ©nĂšre un Ă©vĂ©nement qui est mis en file d'attente et traitĂ© en tenant compte d'autres Ă©vĂ©nements dans la mĂȘme file d'attente. Dans ce cas, un appel Ă EntityManager::merge
génÚre un MergeEvent
, qui est traité par défaut dans la DefaultMergeEventListener::onMerge
. Il contient une logique assez ramifiée mais simple pour chacun des états de l'argument entité. Dans notre cas, l'entité est obtenue à partir du référentiel à l'intérieur de la méthode transactionnelle et est à l'état PERSISTANT (c'est-à -dire essentiellement contrÎlée par le framework):
protected void entityIsPersistent(MergeEvent event, Map copyCache) { LOG.trace("Ignoring persistent instance"); Object entity = event.getEntity(); EventSource source = event.getSession(); EntityPersister persister = source.getEntityPersister(event.getEntityName(), entity); ((MergeContext)copyCache).put(entity, entity, true); this.cascadeOnMerge(source, persister, entity, copyCache);
Le diable est dans les détails, à savoir dans les méthodes DefaultMergeEventListener::cascadeOnMerge
et DefaultMergeEventListener::copyValues
. Ăcoutons le discours direct de Vlad Mikhalche , l'un des principaux dĂ©veloppeurs d'Hibernate:
Dans l'appel de méthode copyValues, l'état hydraté est à nouveau copié, de sorte qu'un nouveau tableau est créé de maniÚre redondante, ce qui gaspille les cycles CPU. Si l'entité a des associations enfants et que l'opération de fusion est également répercutée des entités parent aux entités enfant, la surcharge est encore plus importante car chaque entité enfant propage un événement MergeEvent et le cycle se poursuit.
En d'autres termes, on fait du travail que vous ne pouvez pas faire. En consĂ©quence, notre code peut ĂȘtre simplifiĂ© tout en amĂ©liorant ses performances:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; }
Bien sûr, il n'est pas pratique de garder cela à l'esprit lors du développement et de la relecture du code de quelqu'un d'autre, nous aimerions donc apporter des modifications au niveau de la structure filaire afin que la méthode JpaRepository::save
perde ses propriétés nuisibles. Est-ce possible?
Cependant, le lecteur averti a probablement dĂ©jĂ senti que quelque chose n'allait pas. En effet, ce changement ne cassera rien, mais seulement dans le cas simple oĂč il n'y a pas d'entitĂ©s enfants:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; }
Supposons maintenant que son propriétaire soit lié au compte:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; }
Il existe une méthode qui vous permet de déconnecter l'utilisateur du compte et de transférer ce dernier au nouvel utilisateur:
@Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); }
Que va-t-il se passer maintenant? La vérification de em.contains(entity)
renvoie true, ce qui signifie que em.merge(entity)
ne sera pas appelé. Si la clé d'entité User
est créée sur la base de la séquence (l'un des cas les plus courants), elle ne sera pas créée jusqu'à ce que la transaction soit terminée (ou que Session::flush
appelĂ©e manuellement), c'est-Ă -dire que l'utilisateur sera Ă l'Ă©tat DĂTACHĂ et son entitĂ© parent ( compte) - Ă l'Ă©tat PERSISTANT. Dans certains cas, cela peut briser la logique d'application, ce qui s'est produit:
02/03/2018 DATAJPA-931 interrompt la fusion avec RepositoryItemWriter
à cet égard, la tùche Revert optimisations effectuées pour les entités existantes dans CrudRepository :: save a été créée et les modifications ont été apportées: Revert DATAJPA-931 .
Blind CrudRepository :: findById
Nous continuons Ă considĂ©rer le mĂȘme modĂšle de donnĂ©es:
@Entity public class User { @Id Long id;
L'application a une méthode qui crée un nouveau compte pour l'utilisateur spécifié:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser);
Avec la version 2. * l'anti-modÚle indiqué par la flÚche n'est pas si frappant - il est plus clairement visible sur les anciennes versions:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId));
Si vous ne voyez pas la faille \ "Ă l'oeil \", jetez un Ćil aux requĂȘtes: select u.id, u.name from user u where u.id = ? call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
La premiÚre demande, nous obtenons l'utilisateur par clé. Ensuite, nous obtenons la clé du compte nouveau-né de la base de données et l'insérons dans le tableau. Et la seule chose que nous prenons à l'utilisateur est la clé, que nous avons déjà comme argument de méthode. D'un autre cÎté, BankAccount
contient le champ "utilisateur" et nous ne pouvons pas le laisser vide (en tant que personnes décentes, nous définissons une restriction dans le schéma). Les développeurs expérimentés voient probablement déjà un moyen et manger un poisson et monter à cheval obtenir à la fois l'utilisateur et la demande de ne pas:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId));
JpaRepository::getOne
retourne un wrapper sur la clĂ© qui a le mĂȘme type que "l'entitĂ©" vivante. Ce code ne donne que deux requĂȘtes:
call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
Lorsqu'une entité en cours de création contient de nombreux champs avec une relation plusieurs à un / un à un, cette technique permet d'accélérer l'enregistrement et de réduire la charge sur la base de données.
ExĂ©cution de requĂȘtes HQL
Il s'agit d'un sujet distinct et intĂ©ressant :). Le modĂšle de domaine est le mĂȘme et il existe une telle demande:
@Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id);
Considérez le HQL "pur":
select count(ba) from BankAccount ba join ba.user user where user.id = :id
Lors de son exĂ©cution, la requĂȘte SQL suivante sera créée:
select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ?
Le problĂšme ici n'est pas immĂ©diatement Ă©vident, mĂȘme par une vie sage et des dĂ©veloppeurs SQL bien compris: inner join
par clé utilisateur exclura les comptes avec user_id
manquant de l'Ă©chantillon (et dans le bon sens, l'insertion de ceux-ci devrait ĂȘtre interdite au niveau du schĂ©ma), ce qui signifie qu'il n'est pas user_id
de rejoindre la table user
du tout besoin de. La demande peut ĂȘtre simplifiĂ©e (et accĂ©lĂ©rĂ©e):
select count(ba.id) from bank_account ba where ba.user_id = ?
Il existe un moyen d'obtenir facilement ce comportement en c Ă l'aide de HQL:
@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id);
Cette mĂ©thode crĂ©e une requĂȘte «allĂ©gĂ©e».
RĂ©sumĂ© de la requĂȘte et de la mĂ©thode
L'une des principales caractĂ©ristiques de Spring Data est la possibilitĂ© de crĂ©er une requĂȘte Ă partir du nom de la mĂ©thode, ce qui est trĂšs pratique, en particulier en combinaison avec le module complĂ©mentaire intelligent d'IntelliJ IDEA. La requĂȘte dĂ©crite dans l'exemple prĂ©cĂ©dent peut ĂȘtre facilement réécrite:
Il semble ĂȘtre plus simple, plus court et plus lisible, et surtout - vous n'avez pas besoin de regarder la demande elle-mĂȘme. J'ai lu le nom de la mĂ©thode - et c'est dĂ©jĂ clair ce qu'elle choisit et comment. Mais le diable est ici dans les dĂ©tails. Nous avons dĂ©jĂ vu la derniĂšre requĂȘte pour la mĂ©thode marquĂ©e avec @Query
. Que se passera-t-il dans le deuxiĂšme cas?
Babah! select count(ba.id) from bank_account ba left outer join // <
"Que diable!?" - s'exclamera le développeur. AprÚs tout, nous avons déjà vu que violoniste join
pas nécessaire.
La raison est prosaĂŻque:
Si vous n'avez pas encore mis à niveau vers les versions corrigées et que rejoindre la table ralentit la demande ici et maintenant, alors ne désespérez pas: il y a deux façons de soulager la douleur:
une bonne façon est d'ajouter optional = false
(si le circuit le permet):
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; }
La maniĂšre la plus simple consiste Ă ajouter une colonne du mĂȘme type que la clĂ© d'entitĂ© User
et Ă l'utiliser dans des requĂȘtes au lieu du champ user
:
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; @Column(name = "user_id", insertable = false, updatable = false) Long userId; }
Maintenant, la méthode request-from-method sera plus agréable:
long countByUserId(Long id);
donne
select count(ba.id) from bank_account ba where ba.user_id = ?
qu'avons-nous réalisé.
Limite d'échantillonnage
Pour nos besoins, nous devons limiter la sélection (par exemple, nous voulons renvoyer Optional
partir de la méthode *RepositoryCustom
):
select ba.* from bank_account ba order by ba.rate limit ?
Maintenant Java:
@Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; BankAccount account = em .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .getSingleResult(); return Optional.ofNullable(bankAccount); }
Le code spĂ©cifiĂ© a une caractĂ©ristique dĂ©sagrĂ©able: dans le cas oĂč la demande retourne une sĂ©lection vide, une exception sera levĂ©e
Caused by: javax.persistence.NoResultException: No entity found for query
Dans les projets que j'ai vus, cela a été résolu de deux maniÚres principales:
- try-catch avec des variantes de la
Optonal.empty()
exception et le retour d' Optonal.empty()
Ă des mĂ©thodes plus avancĂ©es, comme passer un lambda avec une requĂȘte Ă une mĂ©thode utilitaire - aspect dans lequel les mĂ©thodes de rĂ©fĂ©rentiel sont renvoyĂ©es renvoyĂ©es
Optional
Et trĂšs rarement, j'ai vu la bonne solution:
@Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; return em.unwrap(Session.class) .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .uniqueResultOptional(); }
EntityManager
fait partie de la norme JPA, tandis que Session
appartient à Hibernate et est à mon humble avis un outil plus avancé, qui est souvent oublié.
[Parfois] amélioration néfaste
Lorsque vous avez besoin d'obtenir un petit champ à partir d'une entité "épaisse", nous faisons ceci:
@Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id);
La demande vous permet d'obtenir un champ de type boolean
sans charger l'entitĂ© entiĂšre (avec l'ajout d'un cache de premier niveau, la vĂ©rification des modifications Ă la fin de la session et d'autres dĂ©penses). Parfois, cela non seulement n'amĂ©liore pas les performances, mais vice versa - il crĂ©e des requĂȘtes inutiles Ă partir de zĂ©ro. Imaginez un code qui effectue quelques vĂ©rifications:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
Ce code fait au moins 2 requĂȘtes, bien que la seconde puisse ĂȘtre Ă©vitĂ©e:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
La conclusion est simple: ne négligez pas le cache du premier niveau, dans le cadre d'une transaction, seul le premier JpaRepository::findById
fait référence à la base de données, JpaRepository::findById
cache du premier niveau est toujours JpaRepository::findById
et lié à une session, qui est généralement liée à la transaction en cours.
Tests avec lesquels jouer (le lien vers le référentiel est donné au début de l'article):
- test d'interface étroite:
InterfaceNarrowingTest
- tester un exemple avec une clé composite:
EntityWithCompositeKeyRepositoryTest
- tester l'excĂšs de
CrudRepository::save
: ModifierTest.java
- test aveugle
CrudRepository::findById
: ChildServiceImplTest
- test de
left join
inutile: BankAccountControlRepositoryTest
Le coût d'un appel supplémentaire à CrudRepository::save
peut ĂȘtre calculĂ© Ă l'aide de RedundantSaveBenchmark
. Il est lancé à l'aide de la classe BenchmarkRunner
.