私のベストプラクティス – Java

1. ループ内ではString.replaceAll()ではなくPattern.compile()を

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()も必要です。この2つは必ずセットでオーバーライドしましょう。

よくある落とし穴が、まだ保存されていないIDのないエンティティです。問題を避けるため、ここは慎重に、早めに永続化しておきましょう。

参考:

これらのメソッドの契約は、常に守らなければなりません:

4. 複数のOSが混在する環境でのDateTimeの落とし穴

OSによって、内部クロックの精度がナノ秒のものもあれば、ミリ秒のものもあります。そのため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やリストなどを入れ子にするのではなく、複雑なデータ型には必ず専用のクラスを作りましょう。

悪い例:

// 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:Memory Leak

キャッシュでは、Memory Leak(メモリリーク)がいとも簡単に起こります。equals()とhashCode()をきちんとオーバーライドし忘れただけで、HashMapがメモリをリークし始めます。常に安全なのは、Mapのキーを手作業で組み立てる方法です。エンティティのIDだけをキーに含めるのが理想です。そうしないと、フィールドをひとつ変更しただけでMapがリークします。それも当然で、ユーザーの名前が変わっても、同じユーザーであることに変わりはないからです。参考:3. equals()とhashCode()を正しく使う

落とし穴その2:Thread Safety

キャッシュには、アトミックでスレッドセーフな操作を提供する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アプリケーションフレームワークはオブジェクトをディスクにフラッシュします。Serializableをうたいながら、transientでもシリアライズ可能でもないデータメンバーを持つオブジェクトは、プログラムのクラッシュを招き、攻撃者に付け入る隙を与えるおそれがあります。

これは時代遅れのように思えます。『Effective Java』第3版には、新しいソフトウェアを開発する際にシリアライズは重要ではない、と書かれています。また、Removing Serialization from Java Is a 'Long-Term Goal' at Oracleという記事もあります。JavaのチーフアーキテクトであるMark Reinhold本人も、こう述べています。

「シリアライズは1997年に犯したひどい過ちだった。」

Springフレームワークがオブジェクトをフラッシュすることがあるのか、という質問はすでにここで出ています:

そこで質問です:

現代のアプリケーションサーバーやフレームワーク、あるいはJ2EEの実装そのもの(特にCDI、EJB、JPAと連携するJavaコンポーネント)は、高負荷時にオブジェクトをディスクにフラッシュするのでしょうか?

それとも、この記述はもう時代遅れなのでしょうか?

回答:

TL;DR ステートフルな分散Webアプリケーションを実装しておらず、セッションの永続化を有効にしておらず、インメモリキャッシュしか使っていないのであれば、Serializableはどこにも必要ないかもしれません。

解説:

セッションレプリケーションでは、最新のServlet仕様でも依然としてJavaのシリアライズが必須です:https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

よくあるのは、ロードバランサーの背後に複数のサーバーがあるケースです。あるサーバーでログインすると、JSESSIONIDクッキーが発行されます。次のリクエストでロードバランサーが別のサーバーに振り分けた場合、ログイン情報やその他のセッションスコープの属性がシリアライズされて新しいサーバーに複製されるので、そこでも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)があり、あえてSerializableオブジェクトを必須にはしていません。それでも、ほとんどの実装では退避の部分にデフォルトでJavaのシリアライズを使っています。

これも、ほかのシリアライズの仕組みに対応するよう調整できます。例えばehcacheの場合:https://www.ehcache.org/documentation/3.8/serializers-copiers.html

8. Hibernate:フェッチ戦略

Hibernateのコントリビューターでもある、とてつもなくカリスマ性のある理屈屋が書いたEAGER fetching is a code smellを踏まえると、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. サービス内で直接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. サービス内で直接fetchする(これが正解!)

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のような使い心地になります)。