Mes bonnes pratiques – Java
1. Pattern.compile() plutôt que String.replaceAll() dans une boucle
public String replaceAll(String regex, String replacement) {
return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}
Le Pattern doit toujours être compilé d’abord. Si le même Pattern est exécuté souvent sur des chaînes courtes, les performances sont nettement meilleures lorsqu’on réutilise le Pattern
compilé.
2. StringBuilder plutôt que + dans une boucle
Dans une boucle, new StringBuilder() est plus performant que +.
3. Bien utiliser equals() et hashCode()
On inclut parfois tous les champs dans equals() ou hashCode(). C’est pratique pour vérifier si une entité a été modifiée, mais c’est faux. Le but
d’equals() n’est pas de vérifier si une entité a été modifiée, mais de déterminer s’il s’agit bien de la même entité, même si l’instance de l’objet n’est pas la même.
Autrement dit : n’utiliser que la clé primaire !
C’est indispensable pour toutes sortes de collections. Si l’on appelle .contains() sur une List, Java utilise la méthode equals. Les HashMaps ont en plus besoin de hashCode().
Les deux méthodes doivent être redéfinies ensemble.
Un piège classique : les entités pas encore enregistrées, sans ID. Soyez prudent et persistez-les tôt pour éviter les problèmes.
À comparer :
Le contrat de ces méthodes doit toujours être respecté :
4. Les pièges de DateTime quand on mélange les systèmes d’exploitation
Certains systèmes d’exploitation ont une horloge interne à la nanoseconde, d’autres à la milliseconde. ZonedDateTime.now() ne se comporte donc pas de la même façon sur les deux plateformes et casse
equals(). Il faut donc TOUJOURS ajuster la précision !
/**
* truncating the precision, because it relies on the internal clock of linux/macOs/windows.
* windows and older macOS versions use only milliseconds, whereas linux and new macOS versions use microsecond precision.
*/
public static ZonedDateTime getNow() {
return ZonedDateTime.now().truncatedTo(DateTimeUtil.PRECISION);
}
Les annotations JPA suivantes s’appuient elles aussi sur ZonedDateTime.now() et ne devraient jamais être utilisées telles quelles :
@CreationTimestamp@UpdateTimestamp@PastOrPresent
5. Ne pas déclarer les types de données complexes en ligne
Créez toujours vos propres classes pour les types de données complexes plutôt que d’imbriquer des Pair, des listes, etc.
Mauvais :
// unlesbar
public Triple<List<Pair<List<List<ChatMessage>>, ZonedDateTime>>, Language, ChatRoom> getChatMessagesGroupedByDay();
Mieux :
public class FTOChatMessageCollection {
private List<FTOChatMessageCollectionPerDay> messagesPerDay;
private Language language;
private ChatRoom chatRoom;
// constructor, getter & setter ...
}
public class FTOChatMessageCollectionPerDay {
private List<FTOChatMessage> messages;
private ZonedDateTime day;
// constructor, getter & setter ...
}
Avantages :
- Des noms plus parlants que
getFirst()etgetSecond() - L’utilisation est clairement définie par les getters et setters (la classe est-elle immuable ? Quelle liste peut être étendue ? Comment instancier l’objet ?) → l’encapsulation documente d’elle-même l’utilisation du code
- Une lisibilité nettement meilleure
Exceptions :
Pour des relations simples, Pair est parfait ! Par exemple :
Pair<User, Training> pair = this.addCurrentUserToTraining(training);
6. Le cache – mais bien fait (guide de configuration minimale)
Le cache (prononcé /kæʃ/, comme « cash » en anglais) n’est au fond qu’une Map qui conserve des entrées. Nous avons renoncé à des mécanismes plus complexes comme l’invalidation temporelle.
Pas besoin d’une bibliothèque quand une simple Map suffit ! Beaucoup de développeurs ne le comprennent pas et, par crainte, prennent systématiquement une bibliothèque. L’aspect le plus important de la mise en cache, c’est d’invalider au bon moment.
Piège n° 1 : les fuites mémoire
Les fuites mémoire arrivent très facilement avec un cache. Il suffit d’oublier de redéfinir proprement equals() et hashCode(), et l’on se retrouve avec une HashMap
qui fuit. Une méthode toujours sûre consiste à construire soi-même les clés de la Map. L’idéal est de n’y inclure que l’ID de l’entité ; sinon, la Map fuit dès qu’on modifie
un seul champ. Ce qui est d’ailleurs logique : ce n’est pas parce que le prénom d’un utilisateur change qu’il ne s’agit plus du même utilisateur. À comparer : 3. Bien utiliser equals() et
hashCode()
Piège n° 2 : la thread-safety
Le cache utilise de préférence une ConcurrentHashMap, qui offre des opérations atomiques et thread-safe. La Map n’est modifiée que par un seul appel – computeIfAbsent() – ce qui garantit la thread-safety :
private ConcurrentMap<String, T> cache = new ConcurrentHashMap<>();
protected T getOrCompute(final String key, final Supplier<T> computeFunction) {
// thread safe, because `computeIfAbsent` is atomic, so no need for `synchronized` here.
// if you extend this to more than 1 map operation, you should synchronize it
return this.getCacheMap().computeIfAbsent(key, k -> computeFunction.get());
}
Si le code était plus long et comportait plusieurs appels, il faudrait rendre toute la méthode synchronized.
7. La sérialisation – est-elle encore nécessaire ?
Ma question : Il existe une règle Sonar intitulée Fields in a “Serializable” class should either be transient or serializable (les champs d’une classe sérialisable doivent être transient ou sérialisables). Elle se déclenche lorsqu’on ajoute à une classe un champ non sérialisable. La règle affirme :
Par exemple, sous forte charge, la plupart des frameworks d’applications J2EE écrivent les objets sur disque, et un objet prétendument sérialisable comportant des membres non transient et non sérialisables pourrait faire planter le programme et ouvrir la porte aux attaquants.
Cela me paraît dépassé. Effective Java 3rd Edition affirme que la sérialisation n’est plus pertinente pour le développement de nouveaux logiciels. Par ailleurs, Removing Serialization from Java Is a 'Long-Term Goal' at Oracle. L’architecte en chef de Java lui-même, Mark Reinhold, a déclaré :
« La sérialisation a été une horrible erreur en 1997. »
La question de savoir si le framework Spring peut écrire des objets sur disque a déjà été posée ici :
Je pose donc la question :
Des serveurs d’applications ou frameworks modernes, ou l’implémentation J2EE elle-même (en particulier les composants Java qui interagissent avec CDI, EJB, JPA), écrivent-ils encore des objets sur disque sous forte charge ?
Ou cette affirmation est-elle dépassée ?
Réponse :
En bref : si vous n’implémentez pas d’application web distribuable avec état, que vous n’activez pas la persistance des sessions et que vous n’utilisez que des caches en mémoire, vous pouvez sans doute vous passer complètement de Serializable.
Explication :
Pour la réplication de sessions, la sérialisation Java reste indispensable, y compris dans la dernière spécification Servlet : https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments
Cas d’usage courant : vous avez plusieurs serveurs derrière un répartiteur de charge ; vous vous connectez sur l’un d’eux et recevez un cookie JSESSIONID. Si le répartiteur vous envoie vers un autre
serveur à la requête suivante, vos attributs de connexion et les autres attributs de session sont sérialisés et répliqués sur le nouveau serveur, de sorte que votre JSESSIONID y reste valide.
Autre cas où vos attributs de session doivent être sérialisables : lorsque le serveur d’applications décide de décharger des sessions sur disque. Avec Tomcat 8, par exemple, on peut configurer ce mécanisme pour qu’il s’active lorsqu’il y a trop de sessions à garder en mémoire, ou lors d’un redémarrage du serveur, si l’on veut conserver les sessions. Voir https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html
Tout serveur d’applications compatible Servlet 5.0 doit, par défaut, prendre en charge les objets sérialisables dans les sessions HTTP pour ce genre de cas d’usage.
Sur certains serveurs, on peut ajuster ou remplacer ce comportement, par exemple avec une sérialisation JSON via Jackson ou un mécanisme similaire. Voir par exemple https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring
Autre cas d’usage courant : le cache. Il sert à garder des objets accessibles le plus rapidement possible, idéalement en mémoire, mais parfois déchargés temporairement sur disque ou vers un serveur de cache externe.
Il existe une API standard pour cela (JSR-107), qui a délibérément choisi de ne pas exiger d’objets sérialisables, mais la plupart des implémentations utilisent tout de même par défaut la sérialisation Java pour le déchargement.
Là encore, on peut les configurer pour utiliser d’autres mécanismes de sérialisation, par exemple pour ehcache : https://www.ehcache.org/documentation/3.8/serializers-copiers.html
8. Hibernate : la stratégie de fetching
Au vu de l’article EAGER fetching is a code smell, écrit par un je-sais-tout extrêmement charismatique qui contribue à Hibernate,
il vaut mieux préférer LAZY comme FetchType. Même si certains types de données n’ont aucun sens sans clé étrangère, ils sont bel et bien interrogés
seuls (getCount(), tableaux de données, etc.).
Voici tous les scénarios, classés de la solution la moins satisfaisante à la plus satisfaisante :
1. Le pire des cas : LazyInitializationException
Inutile d’en dire plus. Nous partageons tous cette douleur.
2. Solution rapide
public void updateChatRoom(ChatMessage message) {
// reload here or LazyInitializationException would be thrown
message = this.chatMessageService.reload(message); // not optimal: second roundtrip to the database - could easily take 100ms!
// we missed the chance to unproxy without a performance hit.
ChatRoom chatRoom = message.getChatRoom().getUpdatedAt(DateTimeUtil.getNow());
}
3. Unproxy directement dans le service
public ChatMessage getChatMessage(final Long id, boolean withRoom) {
// assume this is the same service + the same transaction.
// so we are in the same persistence context + everything will be performed together at the transaction flush.
// perfect: no additional roundtrip like in example 2.!
ChatMessage message = this.chatMessageService.getById(id);
if (withRoom) {
ChatRoom chatRoom = this.chatMessageService.unproxy(message.getChatRoom());
}
// we should not call message.setChatRoom() -> this getter should not even exist as Hibernate would
// map this relation for us! setting it again would perform an update on our foreign key!!
// so we have no option to set the ChatRoom back to the message and have to pass it everywhere in a Tuple or something similar
return ???;
}
4. Fetch directement dans le service (à faire !)
public ChatMessage getChatMessage(final Long id, boolean withRoom) {
// all aforementioned problems solved
return this.executeSingleResultQuery((query, cb, root) -> {
if (withRoom) {
root.fetch(ChatRoomEntity_.partition).fetch(ChatPartitionEntity_.chatModule);
}
return cb.equal(root.get(ChatRoomEntity_.id), id);
});
}
9. Hibernate Search
Hibernate Search s’intègre parfaitement à Hibernate JPA. Il détecte les modifications des entités et se configure facilement :
@Entity
@Indexed(interceptor = SearchableChatMessageInterceptor.class) // hier können Einträge von der Indexierung ausgeschlossen werden
@FullTextFilterDef(name = "SearchableChatMessageFilter", impl = SearchableChatMessageFilter.class) // hier wird der Key festgelegt
public class ChatMessageEntity implements Searchable { // das Searchable dient als Marker-Interface
@Column
@Field // dieses Feld wird indexiert
private String message;
@ManyToOne(targetEntity = ChatRoomEntity.class, fetch = FetchType.LAZY, optional = false)
@IndexedEmbedded(includeEmbeddedObjectId = true, targetElement = ChatRoomEntity.class) // indexiert den Fremschlüssel mit.
// Über dieses Entity wird auch die Message gesucht,
// wie in @FullTextFilterDef -> SearchableChatMessageFilter definiert
private ChatRoom chatRoom;
// ...
}
En local, la recherche est désactivée :
// DefaultInstanceInitializer.java
// we don't need the global search everywhere, givin an option to override this setting here
protected boolean isBuildSearchIndexes() {
return !Constants.Mode.isDebug();
}
Recherche floue & comportement de recherche
if ((needle.split(" ").length > 1 || needle.contains("\"")) && !searchExact) {
/*
* Simple Query String. Provides basic functionality like:
*
* boolean (AND using “+”, OR using “|”, NOT using “-“)
* prefix (prefix*)
* phrase (“some phrase”)
* precedence (using parentheses)
* fuzzy (fuzy~2)
* near operator for phrase queries (“some phrase”~3)
*/
query = qb.simpleQueryString()
.onField(field)
.andFields(moreFields)
.withAndAsDefaultOperator()
.matching(needle)
.createQuery();
} // ...
Lucene
Déboguer Lucene avec Luke
Notre version d’Hibernate Search doit être déboguée avec Luke en version 6.6.0. Cela ne fonctionne qu’avec cette version !
- Téléchargement : https://github.com/DmitryKey/luke/releases
- Luke permet d’ouvrir les index du dossier
target/. - Un bon moyen de vérifier si les entités sont correctement indexées.
- On peut aussi lancer une recherche directement depuis Luke.
ElasticSearch
Hibernate Search prend aussi en charge ElasticSearch (qui remplace alors Lucene et offre une meilleure recherche floue, plus proche de Google).