Мої найкращі практики – 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. Не оголошуйте складні типи даних inline
Для складних типів даних завжди створюйте окремі класи замість вкладених 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() - Гетери й сетери чітко визначають, як користуватися класом (чи він immutable? Який список можна розширювати? Як створювати об’єкт?) → інкапсуляція сама документує, як використовувати код
- Значно краща читабельність
Винятки:
Для простих зв’язків Pair — чудовий варіант! Наприклад:
Pair<User, Training> pair = this.addCurrentUserToTraining(training);
6. Кешування — але правильно (мінімальний посібник із налаштування)
Кеш (вимовляється /kæʃ/, як англійське «cash») — це, по суті, просто Map, що зберігає записи. Від складніших речей на кшталт інвалідації за часом свідомо відмовилися.
Не завжди потрібна бібліотека, якщо достатньо простої Map! Багато розробників цього не розуміють і зі страху завжди беруть бібліотеки. Найважливіше в кешуванні — інвалідувати кеш у правильний момент.
Пастка №1: витоки пам’яті (memory leaks)
Витоки пам’яті під час кешування трапляються дуже легко. Варто забути коректно перевизначити 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 під назвою Fields in a “Serializable” class should either be transient or serializable. Воно спрацьовує, коли до класу додають несеріалізоване поле. У правилі сказано:
Наприклад, під навантаженням більшість фреймворків застосунків J2EE скидають об’єкти на диск, і нібито Serializable-об’єкт із нетранзієнтними несеріалізованими полями може спричинити збої програми та відчинити двері зловмисникам.
Мені це здається застарілим. В Effective Java 3rd Edition сказано, що серіалізація неактуальна, коли розробляєш нове програмне забезпечення. Крім того, прибрати серіалізацію з Java — «довгострокова мета» Oracle. Сам головний архітектор Java Mark Reinhold заявив:
«Серіалізація була жахливою помилкою 1997 року».
Питання, чи може фреймворк Spring скидати об’єкти на диск, тут уже ставили:
Тож я запитую:
Чи скидають сучасні сервери застосунків, фреймворки або сама реалізація J2EE (особливо компоненти Java, що взаємодіють із CDI, EJB, JPA) об’єкти на диск під сильним навантаженням?
Чи це твердження застаріло?
Відповідь:
TL;DR Якщо ви не реалізуєте stateful розподілений вебзастосунок, не вмикаєте збереження сесій і використовуєте лише кеші в пам’яті, цілком можливо, що 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-сесіях.
На деяких серверах це можна змінити або перевизначити, використавши, наприклад, серіалізацію 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: стратегія завантаження (fetching)
З огляду на статтю 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).