Οι βέλτιστες πρακτικές μου – 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() και δεν πρέπει ποτέ να χρησιμοποιούνται σκέτα:
@CreationTimestamp@UpdateTimestamp@PastOrPresent
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 ...
}
Πλεονεκτήματα:
- Καλύτερα ονόματα από τα
getFirst()καιgetSecond() - Η χρήση ορίζεται με σαφήνεια μέσω getters και setters (είναι η κλάση immutable; Ποια λίστα επιτρέπεται να επεκταθεί; Πώς δημιουργείται το αντικείμενο;) → η ενθυλάκωση τεκμηριώνει από μόνη της τη χρήση του κώδικα
- Αισθητά καλύτερη αναγνωσιμότητα
Εξαιρέσεις:
Για απλές σχέσεις το 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. Λειτουργεί μόνο με αυτή την έκδοση!
- Λήψη: https://github.com/DmitryKey/luke/releases
- Με αυτό μπορείς να ανοίξεις τα indexes από τον φάκελο
target/. - Καλό σημείο για να δεις αν τα entities γίνονται καν σωστά index.
- Μπορείς επίσης να ξεκινήσεις μια αναζήτηση μέσα από το Luke.
ElasticSearch
Το Hibernate Search υποστηρίζει επίσης το ElasticSearch (που τότε χρησιμοποιείται αντί για το Lucene και έχει καλύτερη ασαφή αναζήτηση / θυμίζει περισσότερο Google).