我的最佳實務:Java

1. 在迴圈中用 Pattern.compile() 取代 String.replaceAll()

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

Pattern 每次都必須先編譯。如果同一個 Pattern 要頻繁處理短字串,重複使用已編譯好的 Pattern 效能會明顯更好。

2. 在迴圈中用 StringBuilder 取代 +

在迴圈中,new StringBuilder() 的效能比 + 更好。

3. 正確使用 equals() 與 hashCode()

有時 equals() 或 hashCode() 會把所有欄位都納入計算。這對於檢查實體是否被修改過很方便,但卻是錯的。equals() 的目的不是檢查實體是否被修改過,而是判斷兩個物件即使不是同一個實例,是否仍然代表同一個實體。也就是說:只使用主鍵!

各種集合都會用到它們。在 List 上呼叫 .contains() 時,Java 會使用 equals 方法。HashMap 還需要 hashCode()。這兩個方法應該一起覆寫。

一個常見的陷阱是尚未儲存、還沒有 ID 的實體。這裡要小心,盡早將它持久化,以免出問題。

參考:

這些方法的契約必須始終遵守:

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

下面這些 JPA 標註同樣仰賴 ZonedDateTime.now(),絕對不要不加處理就直接使用:

5. 不要以內嵌方式宣告複雜的資料型別

對於複雜的資料型別,一律建立專屬的類別,而不是層層巢狀使用 Pair、List 之類的型別。

不好的寫法:

// 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. 快取,但要用對(最精簡設定指南)

快取(Cache,發音 /kæʃ/,和「cash」相同)本質上就是一個存放項目的 Map。這裡沒有涉及以時間為基準的失效之類更複雜的機制。

如果一個簡單的 Map 就夠用,就不一定非得引入函式庫!很多開發者不懂這一點,出於擔心一律使用函式庫。快取最重要的一點,是在正確的時機讓快取失效。

陷阱 #1:記憶體洩漏

記憶體洩漏在快取中非常容易發生。只要忘了正確覆寫 equals() 和 hashCode(),就會得到一個發生記憶體洩漏的 HashMap。一個永遠安全的做法是手動建立 Map 的鍵。最好只納入實體的 ID,否則只要修改一個欄位,Map 就會洩漏。這也合情合理:使用者的名字改了,他依然是同一個使用者。參考:3. 正確使用 equals() 與 hashCode()

陷阱 #2:執行緒安全

快取最好使用 ConcurrentHashMap,它提供不可分割(atomic)且執行緒安全的操作。Map 只透過一個呼叫(computeIfAbsent())來修改,藉此達成執行緒安全:

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 規則,名為「Serializable」類別中的欄位應該是 transient 或可序列化的。當你在類別中加入一個無法序列化的欄位時,就會觸發這條規則。規則說明如下:

例如,在高負載下,大多數 J2EE 應用程式框架會把物件寫入磁碟;一個號稱可序列化、卻含有非 transient 且無法序列化之資料成員的物件,可能會導致程式當機,並讓攻擊者有機可乘。

在我看來這已經過時了。Effective Java 第 3 版中提到,開發新軟體時,序列化已經無關緊要。此外,從 Java 中移除序列化是 Oracle 的一個「長期目標」。Java 首席架構師 Mark Reinhold 本人就說過:

「序列化是 1997 年犯下的一個可怕錯誤。」

關於 Spring 框架是否會把物件寫入磁碟,之前已經有人在這裡問過:

所以我想問:

現代的應用程式伺服器、框架或 J2EE 實作本身(尤其是與 CDI、EJB、JPA 互動的 Java 元件),會在高負載下把物件寫入磁碟嗎?

還是說這個說法已經過時了?

回答:

長話短說:如果你實作的不是有狀態的分散式 Web 應用程式,沒有啟用工作階段(session)持久化,而且只使用記憶體內快取,那你也許根本不需要在任何地方用到 Serializable。

說明:

對於工作階段複寫,即使在最新的 Servlet 規格中,Java 序列化仍然是必要的:https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

一個常見的使用情境是:負載平衡器後面有多台伺服器,你在其中一台登入後會拿到一個 JSESSIONID cookie。如果負載平衡器把你的下一個請求轉送到另一台伺服器,你的登入屬性和其他工作階段範圍的屬性就會被序列化並複寫到新伺服器上,這樣你的 JSESSIONID 在那裡依然有效。

另一個需要對工作階段範圍屬性進行序列化的情境,是應用程式伺服器決定把工作階段置換到磁碟的時候。以 Tomcat 8 為例,可以設定在記憶體中的工作階段過多時啟用這項機制,或在伺服器重新啟動時用它來保留工作階段。請參閱 https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

為了支援這類情境,所有相容於 Servlet 5.0 的應用程式伺服器都必須預設支援在 HTTP 工作階段中存放 Serializable 物件。

對於某些伺服器,可以透過 Jackson JSON 序列化或類似機制來調整或取代這個行為。例如請參閱 https://stackoverflow.com/questions/46459707/how-to-use-jackson-instead-of-jdkserializationredisserializer-in-spring

另一個常見的情境是快取。快取的作用是讓物件能盡可能快速地被存取,理想上放在記憶體裡,但也可能暫時轉存到磁碟或外部快取伺服器上。
快取有一套標準 API(JSR-107),它明確選擇不強制要求物件可序列化,但大多數實作在轉存這部分仍預設使用 Java 序列化。

同樣地,你也可以調整這些實作,讓它們支援其他序列化機制,例如 ehcache:https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate:擷取策略

有鑑於 EAGER fetching is a code smell 這篇文章(作者是一位魅力十足、又超愛吹毛求疵的 Hibernate 貢獻者),FetchType 應優先選擇 LAZY。即使某些資料型別離開外鍵就沒有意義,這些型別也確實會被單獨查詢(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. 直接在 service 中解除代理(unproxy)

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. 直接在 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 無縫整合。它能察覺實體的變更,設定也很簡單:

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

模糊搜尋與搜尋行為

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

使用 Luke 對 Lucene 除錯

我們所用的 Hibernate Search 版本必須用 Luke 6.6.0 來除錯。只有這個版本才行!

ElasticSearch

Hibernate Search 也支援 ElasticSearch(此時它會取代 Lucene,模糊搜尋效果更好,更有 Google 的感覺)。