Мои Best Practices — 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() — не проверить, была ли сущность изменена, а установить, что речь идёт об одной и той же сущности, даже если экземпляры объектов разные. Проще говоря: используйте только первичный ключ!

Это нужно для любых коллекций. Если вызвать .contains() у List, 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 под названием Fields in a “Serializable” class should either be transient or serializable («Поля Serializable-класса должны быть либо transient, либо сериализуемыми»). Оно срабатывает, когда вы добавляете в класс несериализуемое поле. В правиле говорится:

Например, под нагрузкой большинство фреймворков приложений J2EE сбрасывают объекты на диск, и якобы сериализуемый объект с несериализуемыми полями, не помеченными как transient, может привести к падению программы и открыть дверь злоумышленникам.

Мне это кажется устаревшим. В Effective Java 3rd Edition сказано, что при разработке нового ПО сериализация не актуальна. Кроме того, удаление сериализации из Java — «долгосрочная цель» Oracle: Removing Serialization from Java Is a 'Long-Term Goal' at Oracle. Сам главный архитектор Java Mark Reinhold заявил:

«Сериализация была ужасной ошибкой в 1997 году».

Вопрос о том, может ли Spring Framework сбрасывать объекты на диск, здесь уже задавали:

Поэтому спрашиваю:

Сбрасывают ли какие-либо современные серверы приложений, фреймворки или сама реализация J2EE (особенно Java-компоненты, работающие с CDI, EJB, JPA) объекты на диск при высокой нагрузке?

Или это утверждение устарело?

Ответ:

TL;DR Если вы не реализуете распределённое (distributable) веб-приложение с состоянием, не включаете сохранение сессий и используете только кеши в памяти, вы, скорее всего, сможете вообще обойтись без Serializable.

Пояснение:

Для репликации сессий Java-сериализация обязательна и в последней спецификации Servlet: https://jakarta.ee/specifications/servlet/5.0/jakarta-servlet-spec-5.0.html#distributed-environments

Типичный сценарий: у вас несколько серверов за балансировщиком нагрузки, вы входите в систему на одном из них и получаете cookie JSESSIONID. Если балансировщик отправит следующий запрос на другой сервер, ваши данные входа и другие атрибуты сессии будут сериализованы и реплицированы на новый сервер, так что ваш JSESSIONID останется действительным и там.

Ещё один сценарий, в котором атрибутам сессии нужна сериализация, — когда сервер приложений решает выгрузить сессии на диск. Например, в Tomcat 8 можно настроить, чтобы это происходило, когда сессий слишком много, чтобы держать их в памяти, или когда сервер перезапускается, а сессии нужно сохранить. См. https://tomcat.apache.org/tomcat-9.0-doc/config/manager.html

Каждый сервер приложений, совместимый с Servlet 5.0, для таких сценариев по умолчанию обязан поддерживать Serializable-объекты в HTTP-сессиях.

На некоторых серверах это можно настроить или переопределить, например используя JSON-сериализацию через Jackson или что-то подобное. См., например, 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: стратегия загрузки

С учётом статьи 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. 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

Отладка Lucene с помощью Luke

Нашу версию Hibernate Search нужно отлаживать с Luke версии 6.6.0. Работает только с этой версией!

ElasticSearch

Hibernate Search поддерживает и ElasticSearch (тогда он используется вместо Lucene и даёт более качественный нечёткий поиск — почти как в Google).