Мои 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(), и использовать их как есть не стоит никогда:
@CreationTimestamp@UpdateTimestamp@PastOrPresent
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 ...
}
Преимущества:
- Имена понятнее, чем
getFirst()иgetSecond() - Геттеры и сеттеры чётко задают, как пользоваться классом (неизменяем ли он? Какой список можно дополнять? Как создавать объект?) → инкапсуляция сама документирует, как использовать код
- Читаемость заметно выше
Исключения:
Для простых связей 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. Работает только с этой версией!
- Скачать: https://github.com/DmitryKey/luke/releases
- С его помощью можно открывать индексы из папки
target/. - Удобный способ проверить, правильно ли вообще индексируются сущности.
- Поиск можно запустить и прямо из Luke.
ElasticSearch
Hibernate Search поддерживает и ElasticSearch (тогда он используется вместо Lucene и даёт более качественный нечёткий поиск — почти как в Google).