Mijn best practices – Java

1. Pattern.compile() in plaats van String.replaceAll() in een loop

public String replaceAll(String regex, String replacement) {
    return Pattern.compile(regex).matcher(this).replaceAll(replacement);
}

Het Pattern moet altijd eerst gecompileerd worden. Draait hetzelfde Pattern vaak op korte strings, dan is de performance duidelijk beter als je het gecompileerde Pattern hergebruikt.

2. StringBuilder in plaats van + in een loop

new StringBuilder() presteert beter dan + in een loop.

3. equals() en hashCode() correct gebruiken

Soms worden alle velden meegenomen in equals() of hashCode(). Dat is handig om te controleren of een entity gewijzigd is, maar het is fout. Het doel van equals() is niet om te controleren of een entity gewijzigd is, maar om vast te stellen of het, ook al is de objectinstantie niet dezelfde, toch om dezelfde entity gaat. Kortom: gebruik alleen de primary key!

Dit is nodig voor elk soort collection. Roep je .contains() aan op een List, dan gebruikt Java de equals-methode. HashMaps hebben daarnaast hashCode() nodig. Overschrijf ze daarom altijd samen.

Een veelvoorkomende valkuil zijn niet-opgeslagen entities zonder ID. Wees hier voorzichtig en persisteer vroeg om problemen te voorkomen.

Zie ook:

Het contract van deze methoden moet altijd worden nageleefd:

4. Valkuilen met DateTime bij verschillende besturingssystemen

Sommige besturingssystemen hebben een interne klok met nanoseconden, andere met milliseconden. ZonedDateTime.now() werkt daardoor niet op beide platforms hetzelfde en maakt equals() kapot. Pas daarom ALTIJD de precisie aan!

/**
 * 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);
}

Ook de volgende JPA-annotaties vertrouwen op ZonedDateTime.now() en mogen nooit zomaar gebruikt worden:

5. Complexe datatypen niet inline declareren

Maak voor complexe datatypen altijd eigen klassen, in plaats van Pairs, Lists enzovoort in elkaar te nesten.

Slecht:

// unlesbar
public Triple<List<Pair<List<List<ChatMessage>>, ZonedDateTime>>, Language, ChatRoom> getChatMessagesGroupedByDay();

Beter:

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 ...
}

Voordelen:

Uitzonderingen:

Voor eenvoudige relaties is Pair prima! Bijvoorbeeld:

Pair<User, Training> pair = this.addCurrentUserToTraining(training);

6. Caching – maar dan goed (minimale set-upgids)

De cache (uitspraak /kæʃ/, als “cash”) is in principe gewoon een map die entries vasthoudt. Complexere zaken zoals tijdgebaseerde invalidatie en dergelijke zijn bewust weggelaten.

Het hoeft niet altijd een library te zijn als een simpele map volstaat! Veel ontwikkelaars begrijpen dat niet en grijpen uit angst standaard naar libraries. Het belangrijkste bij caching is dat je op het juiste moment invalideert.

Valkuil #1: memory leaks

Memory leaks ontstaan bij caching heel gemakkelijk. Vergeet je equals() en hashCode() netjes te overschrijven, dan heb je meteen een HashMap die geheugen lekt. Een methode die altijd veilig is, is de map keys handmatig opbouwen. Het beste is om uitsluitend de ID van een entity mee te nemen, anders lekt de map al zodra je ook maar één veld wijzigt. En dat klopt ook: alleen omdat de voornaam van een user verandert, blijft het nog steeds dezelfde user. Zie ook: 3. equals() en hashCode() correct gebruiken

Valkuil #2: thread safety

De cache gebruikt idealiter een ConcurrentHashMap, die atomaire, thread-safe operaties biedt. De map wordt maar via één enkele call gewijzigd – computeIfAbsent() – en zo is de thread safety gegarandeerd:

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());
}

Was de code langer en waren er meerdere aanroepen, dan zou de hele methode synchronized moeten worden.

7. Serialisatie – is die nog nodig?

Mijn vraag: Er bestaat een Sonar-regel met de naam Fields in a “Serializable” class should either be transient or serializable. Die verschijnt zodra je een niet-serialiseerbaar veld aan je klasse toevoegt. De regel zegt:

Onder belasting schrijven de meeste J2EE-applicatieframeworks objecten bijvoorbeeld naar schijf, en een zogenaamd Serializable object met niet-transient, niet-serialiseerbare data members kan het programma laten crashen en de deur openzetten voor aanvallers.

Dat klinkt voor mij verouderd. In Effective Java 3rd Edition staat dat serialisatie niet relevant is bij het ontwikkelen van nieuwe software. Bovendien: serialisatie uit Java verwijderen is een ‘langetermijndoel’ bij Oracle. De hoofdarchitect van Java zelf, Mark Reinhold, zei:

“Serialisatie was in 1997 een vreselijke vergissing.”

De vraag of het Spring-framework objecten naar schijf kan wegschrijven, werd hier al eerder gesteld:

Mijn vraag is dus:

Schrijven moderne applicatieservers, frameworks of de J2EE-implementatie zelf (vooral Java-componenten die met CDI, EJB of JPA werken) onder zware belasting objecten naar schijf?

Of is deze bewering verouderd?

Antwoord:

Kort samengevat: als je geen stateful, gedistribueerde webapplicatie bouwt, geen sessiepersistentie inschakelt en alleen in-memory caches gebruikt, kun je er waarschijnlijk mee wegkomen dat je nergens Serializable nodig hebt.

Uitleg:

Voor sessiereplicatie is Java-serialisatie ook in de nieuwste Servlet-specificatie nog steeds een must: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

Een veelvoorkomende use case: je hebt meerdere servers achter een load balancer en je logt in op één server, waarna je een JSESSIONID-cookie krijgt. Stuurt de load balancer je bij het volgende verzoek naar een andere server, dan worden je loginattributen en andere session-scoped attributen geserialiseerd en naar de nieuwe server gerepliceerd, zodat je JSESSIONID daar nog steeds geldig is.

Een andere use case waarbij je serialisatie nodig hebt voor je session-scoped attributen, is wanneer de applicatieserver besluit sessies naar schijf weg te schrijven. Bij bijvoorbeeld Tomcat 8 kun je dit zo configureren dat het gebeurt als er te veel sessies zijn om in het geheugen te houden, of wanneer de server herstart wordt en je de sessies in leven wilt houden. Zie https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

Elke applicatieserver die compatibel is met Servlet 5.0 moet voor dit soort use cases standaard Serializable objecten in HTTP-sessies ondersteunen.

Bij sommige servers kun je dit aanpassen of vervangen, bijvoorbeeld door Jackson-JSON-serialisatie of iets vergelijkbaars. Zie bijvoorbeeld https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

Een andere veelvoorkomende use case is caching. Daarmee houd je objecten zo snel mogelijk toegankelijk, idealiter in het geheugen, maar eventueel tijdelijk uitbesteed aan schijf of aan een externe cacheserver.
Daarvoor bestaat een standaard-API (JSR-107) die er bewust voor heeft gekozen geen Serializable objecten te vereisen, maar de meeste implementaties gebruiken voor het uitbesteden toch standaard Java-serialisatie.

Ook die kun je aanpassen om andere serialisatiemechanismen te ondersteunen, bijvoorbeeld bij ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate: fetchingstrategie

Gezien EAGER fetching is a code smell, geschreven door een buitengewoon charismatische betweter die Hibernate-contributor is, heeft LAZY als FetchType de voorkeur. Ook al hebben sommige datatypen zonder foreign key geen zin, toch worden die typen wel degelijk los opgevraagd (getCount(), datatabellen enzovoort).

Hier alle scenario’s, gesorteerd op hoe bevredigend de oplossing is, te beginnen met de minst bevredigende:

1. Worstcasescenario: LazyInitializationException

Woorden overbodig. We kennen deze pijn allemaal.

2. Snelle fix

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. Direct unproxyen in de 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. Direct fetchen in de service (doe dit!)

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 is naadloos verbonden met Hibernate JPA. Het merkt op wanneer entities veranderen en is eenvoudig te configureren:

@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;

    // ...

}

Lokaal staat de zoekfunctie uit:

// 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();
}

Fuzzy zoeken & zoekgedrag

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

Lucene debuggen met Luke

Onze versie van Hibernate Search moet met Luke versie 6.6.0 worden gedebugd. Het werkt alleen met deze versie!

ElasticSearch

Hibernate Search ondersteunt ook ElasticSearch (dat dan in plaats van Lucene wordt gebruikt en beter fuzzy zoekt / meer een Google-gevoel geeft).