Οι βέλτιστες πρακτικές μου – Java

1. Pattern.compile() αντί για String.replaceAll() μέσα σε βρόχο

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

Το Pattern πρέπει πάντα πρώτα να μεταγλωττιστεί. Αν το ίδιο Pattern εκτελείται συχνά σε σύντομα strings, η απόδοση είναι αισθητά καλύτερη όταν επαναχρησιμοποιείς το ήδη μεταγλωττισμένο Pattern.

2. StringBuilder αντί για + μέσα σε βρόχο

Μέσα σε βρόχο, το new StringBuilder() είναι πιο αποδοτικό από το +.

3. Σωστή χρήση των equals() και hashCode()

Μερικές φορές στο equals() ή στο hashCode() συμπεριλαμβάνονται όλα τα πεδία. Αυτό είναι χρήσιμο για να ελέγξεις αν ένα entity έχει αλλάξει, αλλά είναι λάθος. Ο σκοπός του equals() δεν είναι να ελέγξει αν ένα entity τροποποιήθηκε, αλλά να διαπιστώσει αν πρόκειται για το ίδιο entity, ακόμη κι αν το στιγμιότυπο του αντικειμένου δεν είναι το ίδιο. Με λίγα λόγια: χρησιμοποίησε μόνο το πρωτεύον κλειδί!

Αυτό χρειάζεται για κάθε είδους collection. Αν καλέσεις .contains() σε μια λίστα, η Java θα χρησιμοποιήσει τη μέθοδο equals. Τα HashMap χρειάζονται επιπλέον το hashCode(). Οι δύο μέθοδοι πρέπει να γίνονται override μαζί.

Μια συνηθισμένη παγίδα είναι τα entities που δεν έχουν αποθηκευτεί και δεν έχουν ID. Εδώ πρόσεχε και κάνε persist νωρίς, για να αποφύγεις προβλήματα.

Δες επίσης:

Το συμβόλαιο αυτών των μεθόδων πρέπει να τηρείται πάντα:

4. Παγίδες με το DateTime όταν αναμειγνύονται λειτουργικά συστήματα

Κάποια λειτουργικά συστήματα έχουν εσωτερικό ρολόι με ακρίβεια νανοδευτερολέπτων, άλλα με ακρίβεια χιλιοστών του δευτερολέπτου. Έτσι το ZonedDateTime.now() δεν λειτουργεί το ίδιο και στις δύο πλατφόρμες και χαλάει το equals(). Αντί γι’ αυτό, προσάρμοζε ΠΑΝΤΑ την ακρίβεια!

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

Και τα παρακάτω annotations της JPA βασίζονται στο ZonedDateTime.now() και δεν πρέπει ποτέ να χρησιμοποιούνται σκέτα:

5. Μη δηλώνεις σύνθετους τύπους δεδομένων inline

Για σύνθετους τύπους δεδομένων φτιάχνε πάντα δικές σου κλάσεις, αντί να φωλιάζεις Pair, λίστες κ.λπ.

Λάθος:

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

Καλύτερα:

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

Πλεονεκτήματα:

Εξαιρέσεις:

Για απλές σχέσεις το Pair είναι εξαιρετικό! Για παράδειγμα:

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

6. Caching – αλλά σωστά (οδηγός ελάχιστης ρύθμισης)

Η cache (προφέρεται /kæʃ/, όπως το «cash») είναι στην ουσία απλώς ένα map που κρατά εγγραφές. Πιο σύνθετα πράγματα, όπως η ακύρωση βάσει χρόνου και τα παρόμοια, παραλείφθηκαν.

Δεν χρειάζεται πάντα βιβλιοθήκη, όταν αρκεί ένα απλό map! Πολλοί προγραμματιστές δεν το καταλαβαίνουν αυτό και από φόβο καταφεύγουν πάντα σε βιβλιοθήκες. Η πιο σημαντική πτυχή του caching είναι η ακύρωση (invalidation) να γίνεται τη σωστή στιγμή.

Παγίδα #1: memory leaks

Τα memory leaks συμβαίνουν πολύ εύκολα στο caching. Αρκεί να ξεχάσεις να κάνεις σωστά override τα equals() και hashCode() και έχεις ήδη ένα HashMap που διαρρέει μνήμη. Μια μέθοδος που είναι πάντα ασφαλής είναι η χειροκίνητη δημιουργία των κλειδιών του map. Το καλό θα ήταν να συμπεριλαμβάνεται αποκλειστικά το ID ενός entity· αλλιώς το map διαρρέει ήδη μόλις αλλάξεις ένα μόνο πεδίο. Πράγμα που είναι και σωστό, αφού ένας χρήστης παραμένει ο ίδιος χρήστης ακόμη κι αν αλλάξει το μικρό του όνομα. Δες: 3. Σωστή χρήση των equals() και hashCode()

Παγίδα #2: thread safety

Η cache ιδανικά χρησιμοποιεί ένα ConcurrentHashMap, που προσφέρει ατομικές, thread-safe λειτουργίες. Το map τροποποιείται μόνο μέσω μίας και μόνο κλήσης – computeIfAbsent() –, και έτσι επιτυγχάνεται 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());
}

Αν ο κώδικας ήταν μεγαλύτερος και υπήρχαν περισσότερες κλήσεις, θα έπρεπε ολόκληρη η μέθοδος να γίνει synchronized.

7. Σειριοποίηση – χρειάζεται ακόμη;

Η ερώτησή μου: Υπάρχει αυτός ο κανόνας του Sonar με το όνομα Fields in a “Serializable” class should either be transient or serializable. Εμφανίζεται όταν προσθέτεις στην κλάση σου ένα πεδίο που δεν είναι serializable. Ο κανόνας αναφέρει:

Για παράδειγμα, υπό φόρτο, τα περισσότερα frameworks εφαρμογών J2EE γράφουν αντικείμενα στον δίσκο, και ένα υποτιθέμενα Serializable αντικείμενο με μέλη δεδομένων που δεν είναι transient ούτε serializable θα μπορούσε να προκαλέσει κατάρρευση του προγράμματος και να ανοίξει την πόρτα σε επιτιθέμενους.

Αυτό μου ακούγεται ξεπερασμένο. Στο Effective Java 3rd Edition αναφέρεται ότι η σειριοποίηση δεν είναι σχετική όταν αναπτύσσεις νέο λογισμικό. Επίσης, η αφαίρεση της σειριοποίησης από τη Java είναι «μακροπρόθεσμος στόχος» της Oracle. Ο ίδιος ο επικεφαλής αρχιτέκτονας της Java, Mark Reinhold, δήλωσε:

«Η σειριοποίηση ήταν ένα φρικτό λάθος το 1997.»

Η ερώτηση για το αν το Spring framework μπορεί να γράφει αντικείμενα στον δίσκο έχει ήδη τεθεί εδώ:

Ρωτάω λοιπόν:

Γράφουν αντικείμενα στον δίσκο υπό μεγάλο φόρτο κάποιοι σύγχρονοι application servers, frameworks ή η ίδια η υλοποίηση του J2EE (ειδικά components της Java που αλληλεπιδρούν με CDI, EJB, JPA);

Ή μήπως αυτός ο ισχυρισμός είναι ξεπερασμένος;

Απάντηση:

TL;DR Αν δεν υλοποιείς μια stateful κατανεμημένη web εφαρμογή, δεν ενεργοποιείς το persistence των sessions και χρησιμοποιείς μόνο caches στη μνήμη, ίσως τα καταφέρεις χωρίς να χρειαστείς πουθενά Serializable.

Εξήγηση:

Για την αναπαραγωγή (replication) των sessions, η σειριοποίηση της Java εξακολουθεί να είναι υποχρεωτική ακόμη και στην πιο πρόσφατη προδιαγραφή Servlet: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

Μια συνηθισμένη περίπτωση: έχεις πολλούς servers πίσω από έναν load balancer, συνδέεσαι σε έναν από αυτούς και παίρνεις ένα cookie JSESSIONID. Αν ο load balancer σε στείλει σε άλλον server στο επόμενο αίτημα, τα attributes σύνδεσης και τα υπόλοιπα session-scoped attributes σου θα σειριοποιηθούν και θα αναπαραχθούν στον νέο server, ώστε το JSESSIONID σου να είναι έγκυρο και εκεί.

Μια άλλη περίπτωση όπου χρειάζεσαι σειριοποίηση για τα session-scoped attributes σου είναι όταν ο application server αποφασίσει να μεταφέρει sessions στον δίσκο. Στον Tomcat 8, για παράδειγμα, αυτό μπορεί να ρυθμιστεί ώστε να ενεργοποιείται όταν υπάρχουν πάρα πολλά sessions για να κρατηθούν στη μνήμη ή όταν γίνεται επανεκκίνηση του server και θέλεις να διατηρήσεις τα sessions ενεργά. Δες https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

Κάθε application server συμβατός με Servlet 5.0 πρέπει από προεπιλογή να υποστηρίζει Serializable αντικείμενα στα HTTP sessions για τέτοιες περιπτώσεις.

Σε ορισμένους servers αυτό μπορεί να ρυθμιστεί ή να αντικατασταθεί, π.χ. με σειριοποίηση JSON μέσω Jackson ή κάτι παρόμοιο. Δες για παράδειγμα https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

Μια άλλη συνηθισμένη περίπτωση είναι το caching. Χρησιμοποιείται για να είναι τα αντικείμενα προσβάσιμα όσο πιο γρήγορα γίνεται, ιδανικά στη μνήμη, αλλά ενδεχομένως και προσωρινά μεταφερμένα στον δίσκο ή σε έναν εξωτερικό cache server.
Υπάρχει ένα τυποποιημένο API γι’ αυτό (JSR-107), που επέλεξε ρητά να μην απαιτεί Serializable αντικείμενα, αλλά οι περισσότερες υλοποιήσεις εξακολουθούν να χρησιμοποιούν από προεπιλογή τη σειριοποίηση της Java για τη μεταφορά στον δίσκο.

Και αυτές μπορούν να ρυθμιστούν ώστε να υποστηρίζουν άλλους μηχανισμούς σειριοποίησης, π.χ. για το ehcache: https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate: στρατηγική fetching

Λαμβάνοντας υπόψη το EAGER fetching is a code smell, γραμμένο από έναν εξαιρετικά χαρισματικό ξερόλα που είναι contributor του Hibernate, το LAZY είναι προτιμότερο ως FetchType. Ακόμη κι αν κάποιοι τύποι δεδομένων δεν έχουν νόημα χωρίς ξένο κλειδί, αυτοί οι τύποι γίνονται κανονικά query και μεμονωμένα (getCount(), πίνακες δεδομένων κ.λπ.).

Ακολουθούν όλα τα σενάρια ταξινομημένα κατά το πόσο ικανοποιητική είναι η λύση, ξεκινώντας από τη λιγότερο ικανοποιητική:

1. Το χειρότερο σενάριο: LazyInitializationException

Δεν χρειάζονται λόγια. Όλοι μοιραζόμαστε αυτόν τον πόνο.

2. Γρήγορη λύση

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 απευθείας στο 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 απευθείας στο service (κάνε αυτό!)

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 συνδέεται απρόσκοπτα με το Hibernate JPA. Αντιλαμβάνεται πότε αλλάζουν τα entities και ρυθμίζεται εύκολα:

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

    // ...

}

Τοπικά η αναζήτηση είναι απενεργοποιημένη:

// 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) & συμπεριφορά αναζήτησης

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

Debugging του Lucene με το Luke

Η δική μας έκδοση του Hibernate Search πρέπει να γίνεται debug με το Luke, έκδοση 6.6.0. Λειτουργεί μόνο με αυτή την έκδοση!

ElasticSearch

Το Hibernate Search υποστηρίζει επίσης το ElasticSearch (που τότε χρησιμοποιείται αντί για το Lucene και έχει καλύτερη ασαφή αναζήτηση / θυμίζει περισσότερο Google).