Spring Patterns. Часть 2. Spring + ThreadLocal. AOP. Transaction cache
Всем привет. Я разрабатываю приложения с использованием Java, Spring Boot, Hibernate.
В прошлой статье я показал реализацию паттерна Spring Fluent Interface. При помощи которого можно инкапсулировать похожие действия внутри приложения в модуль, предоставлять клиентскому коду удобный декларативный API, и при этом «кишки» модуля имеют доступ к «магии» Spring.
https://habr.com/ru/articles/846864/
В этой статье я хочу поделиться опытом работы с Spring и ThreadLocal переменными.
Предисловие:
В вашем приложении может внезапно оказаться, что ваша текущая структура кода не идеальна. Например, пришли новые бизнес-требования, которые никто не ожидал. Или начались проблемы с производительностью. При этом кода написано много, а баг/доработку нужно «вчера». Использование ThreadLocal поможет в этой ситуации.
ThreadLocal — это потоко-безопасная переменная. Под капотом у которой ConcurrentHashMap. Ключ — текущий поток (там чутка сложнее, но для понимания будет достаточно). Значение может быть любым типом, ThreadLocal типизирована
ThreadLocal> EXAMPLE_1 = ThreadLocal.withInitial(null);
ThreadLocal> EXAMPLE_2 = ThreadLocal.withInitial(ArrayList::new);
Какие проблемы могут возникнуть?
Важно очищать ThreadLocal переменную. Дело в том, что скорее всего, ваше приложение использует пул потоков. И может возникнуть ситуация, что поток достали из пула, отправили делать работу, поток записал что-то в ThreadLocal, отработал, лёг в пул потоков. Далее поток либо умирает по истечению времени. Либо отправляется делать какую-то работу. И если тот же самый поток пойдет делать ту же самую работу — в его ThreadLocal остались «чужие» данные.
Далее я покажу несколько способов применения ThreadLocal переменную и очистки.
Пример 1. AOP.
Допустим у вас есть какая-то цепочка действий (далее workflow), например:
@RestController → @Service → @Repository → … → @Service → @RestController
И может оказаться так, что вам надо на каком-то этапе этой цепочки действий получать что-то, чего нет в параметрах сигнатуры метода. При этом, например, сигнатура метода не ваша, а задана интерфейсом сторонней библиотеки. Или ваша, но придется много править.
Например, есть вот такой сервис:
@Service
@RequiredArgsConstructor
public class SuperService {
public String run(String name) {
/** очень сложная бизнес логика */
return "42";
}
}
И вам надо иметь доступ к этому name в любом месте workflow.
Тогда простейшее решение выглядит следующим образом:
Мы создаем спринговый синглетон, обертку над ThreadLocal переменной.
@Service
@RequiredArgsConstructor
public class ExampleThreadLocalVariable {
private static final ThreadLocal VAR = ThreadLocal.withInitial(() -> null);
public void set(String string) {
VAR.set(string);
}
public String get() {
return VAR.get();
}
public void clean() {
VAR.remove();
}
}
Оборачиваем SuperService «проксёй» при помощи @Primary.
@Primary
@Service
@RequiredArgsConstructor
public class PrimaryService1 extends SuperService {
private final ExampleThreadLocalVariable exampleThreadLocalVariable;
@Override
public String run(String name) {
exampleThreadLocalVariable.set(name);
return super.run(name);
}
}
«Инжектим» наш бин с ThreadLocal, записываем name. Каждый раз, перед выполнением оригинального метода, значение ThreadLocal переменной будет перезаписываться.
Теперь мы имеем возможность в любом месте workflow «заинжектить» бин с ThreadLocal и получить значение.
Несколько дополнительных комментариев:
1. Я предпочитаю оборачивать ThreadLocal переменную спринговым синглетоном. Это позволяет «мокать» переменную в Unit тестах, как-то настраивать в компонентных тестах, очищать перед/после в интеграционных тестах.
2. Я предпочитаю инкапсулировать в методы переменной, дополнительную логику. Например, можно вернуть Optional, или ругнуться. По ситуации.
public Optional getOptional() {
return Optional.ofNullable(VAR.get());
}
public String getOrThrow() {
String result = VAR.get();
if (result == null) {
throw new IllegalArgumentException("Need init before use.");
}
return result;
}
3. Я предпочитаю явно писать очистку в finally блоке. Это позволит всем читателям кода быстрее понять, что об очистке позаботились.
@Override
public String run(String name) {
try {
exampleThreadLocalVariable.set(name);
return super.run(name);
} finally {
exampleThreadLocalVariable.clean();
}
}
В этом примере рассмотрен кейс, в котором мы точно знаем в каком месте перезаписывать/очищать ThreadLocal переменную, далее я покажу, что делать, если такое место неизвестно. Если коротко — то в конце транзакции с учетом commit/rollback.
Пример 2. TransactionCache.
Может возникнуть ситуация, что в рамках выполнения одного workflow несколько раз запускается одна и та же тяжелая логика, например, это может быть сложный запрос в бд, результат которого не поменяется за время выполнения workflow.
Давайте тут пойдем с конца. Накидаем инфраструктурного кода, для запуска работы в конце транзакции. Создадим вот такой интерфейс:
@FunctionalInterface
public interface SimpleAfterCompletionCallback {
void run();
}
И попросим спринг запускать его работу на стадии AFTER_COMPLETION.
@Service
@RequiredArgsConstructor
public class ExampleEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION)
public void runSimpleAfterCompletionCallback(SimpleAfterCompletionCallback callback) {
callback.run();
}
}
Т.е. мы будем очищать ThreadLocal переменную в конце транзакции, если транзакция успешна или не успешна.
Клиентский код будет выглядеть следующим образом, наглядности ради — в примере всё в одном классе:
@Service
@RequiredArgsConstructor
public class TransactionalCache {
private static final ThreadLocal VAR = ThreadLocal.withInitial(() -> null);
private final ApplicationEventPublisher applicationEventPublisher;
private final ExampleThreadLocalVariable exampleThreadLocalVariable;
public String run() {
String result = VAR.get();
if (result == null) {
result = doMainLogic();
initThreadLocalVariable(result);
}
return result;
}
private String doMainLogic() {
/** сложная бизнес логика */
return "42";
}
private void initThreadLocalVariable(String result) {
exampleThreadLocalVariable.set(result);
applicationEventPublisher.publishEvent((SimpleAfterCompletionCallback) exampleThreadLocalVariable::clean);
}
}
Если в ThreadLocal переменную уже что-то записали — вернем это что-то. Если не записали, запустим оригинальный метод, его результат запишем в ThreadLocal, опубликуем событие очистки, повесив на конец транзакции. Получается GOF паттерн Registry с спрингом и @Transaction.
Можно вынести ThreadLocal в отдельный бин, и пусть она сама публикует событие при инициализации. Ругается если другой разработчик использует вне транзакции, ругается если get до инициализации. Зависит от вашего конкретного случая.
Заключение.
В данной статье мы рассмотрели примеры использования ThreadLocal переменной в мире Spring.
Ознакомились с важностью её очистки.
Рассмотрели два способа очистки, когда место перезаписи/очистки известно и когда не известно.
ThreadLocal переменные в коде — это временное решение проблемы, следующим шагом должен быть рефакторинг и отказ от ThreadLocal.
Код можно посмотреть тут:
https://github.com/AlekseyShibayev/spring-patterns/tree/main/src/main/java/com/company/app/threadlocal