我的最佳实践: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,它提供原子的、线程安全的操作。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 应用,没有启用会话持久化,并且只使用内存缓存,那你也许根本不需要在任何地方用到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 的感觉)。