Речь в этой небольшой заметке пойдёт про то, как с пользой можно использовать MDC в Spring-проекте. Поводом написать статью послужила другая недавняя статья на Хабре.

Мы — небольшая команда backend-разработчиков, включая меня — работаем над проектом сервера для мобильных приложений для некой организации. Приложениями пользуются только её сотрудники и значительного highload-а у нас нет. Поэтому для сервера мы выбрали максимально привычный нам стек: Java и Spring Boot на servlet-контейнерах.
Здесь я хотел бы рассказать про свой подход к логированию бизнес-процессов при помощи MDC. Почему именно MDC? Фактически, имеется несколько реплик приложения, которые развёрнуты в Kubernetes, и все логи стекаются в единый агрегатор (Graylog). В конфигурации logback-а добавлен специальный appender, который отправляет все логи по нужному адресу, а также к каждому сообщению добавляет все имеющиеся в MDC поля:
logback-graylog.xml
<?xml version="1.0" encoding="UTF-8"?> <configuration> <include resource="org/springframework/boot/logging/logback/defaults.xml"/> <include resource="org/springframework/boot/logging/logback/console-appender.xml"/> <springProperty name="graylog_environment" scope="context" source="logging.graylog.environment" defaultValue="local"/> <springProperty name="graylog_host" scope="context" source="logging.graylog.host" defaultValue="127.0.0.1"/> <springProperty name="graylog_port" scope="context" source="logging.graylog.port" defaultValue="12201"/> <springProperty name="graylog_microservice" scope="context" source="logging.graylog.microservice" defaultValue=""/> <appender name="UDP_GELF" class="biz.paluch.logging.gelf.logback.GelfLogbackAppender"> <host>${graylog_host}</host> <port>${graylog_port}</port> <version>1.1</version> <extractStackTrace>true</extractStackTrace> <filterStackTrace>true</filterStackTrace> <includeFullMdc>true</includeFullMdc> <additionalFields>environment=${graylog_environment},microservice=${graylog_microservice}</additionalFields> <additionalFieldTypes>environment=String,microservice=String</additionalFieldTypes> <timestampPattern>yyyy-MM-dd HH:mm:ss,SSS</timestampPattern> <maximumMessageSize>8192</maximumMessageSize> </appender> <root level="DEBUG"> <appender-ref ref="UDP_GELF"/> <appender-ref ref="CONSOLE"/> </root> </configuration>
Это очень удобно — можно отправить название окружения (боевое или одно из тестовых), название конкретного микросервиса, Spring Cloud Sleuth добавляет в него данные трассировки (traceId и spanId), а мне захотелось отправлять туда такие поля, которые в последствии помогут искать в Graylog-е причины каких-то сбоев именно мне. Если на вашем проекте используется стек ELK, то скорее всего там всё аналогично, просто мне с ним сталкиваться не приходилось.
Подавляющая часть входящих запросов проходит через кастомный Security-фильтр, который по заголовкам запроса определяет пользователя. Именно в этот момент удобно подложить в MDC то, что хочется. Сперва описываем класс с теми полями, которые хочется подложить:
@UtilityClass public class MdcKeys { /** * Значение HTTP-заголовка User-Agent из внешнего запроса. */ public final String MDC_USER_AGENT = "user-agent"; /** * Значение заголовка Authorization из внешнего запроса. */ public final String MDC_USER_TOKEN = "authorization"; /** * Логин пользователя, от имени которого поступил запрос. */ public final String MDC_USER_LOGIN = "login"; /** * URL, на который поступил внешний запрос. */ public final String MDC_API_URL = "apiUrl"; // ... и некоторые другие ... }
Если просто устанавливать значения через MDC#put то они не будут удаляться из него после обработки запроса, что чревато проблемами: не все запросы должны быть авторизованы, поэтому часть проскакивает мимо вызова AuthenticationManager-а. Так как потоки, которые обрабатывают запросы, живут в servlet-контейнере и не пересоздаются, старые значения «замусоривают» нам картину. Выход простой — обернуть в try-catch и почистить за собой в блоке finally.
Далее возникает вопрос о том, что делать с методами, помеченными аннотацией @Async. Методы, помеченные ей, выполняются на отдельном пуле потоков, а так как значения в MDC локальны по отношению к текущему потоку, нужно их как-то туда скопировать и в конце метода не забыть удалить. Решение подсмотрено опять же у Spring Security. Находим место в конфигурации, где у нас создаётся пул потоков:
/** * TaskExecutor, используемый для запуска асинхронных методов. */ @Bean @Qualifier("taskExecutor") TaskExecutor taskExecutor() { ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor(); // ... код настройки taskExecutor-а опущен ... taskExecutor.setTaskDecorator(new AsyncTaskCustomDecorator()); return taskExecutor; }
И устанавливаем декоратор, который будет оборачивать все запускаемые асинхронные методы своим кодом:
private static class AsyncTaskCustomDecorator implements TaskDecorator { @Override @NonNull public Runnable decorate(@NonNull Runnable runnable) { var runnableWithRestoredMDC = LoggingUtils.decorateMdcCopying(runnable); return new DelegatingSecurityContextRunnable(runnableWithRestoredMDC); } }
Фактически, оборачивание происходит дважды: один раз нашим кодом (LoggingUtils#decorateMdcCopying) и один раз для самой Spring Security (иначе мы сломаем передачу контекста в SecurityContextHolder-е). Можно было бы «сделать всю работу» на месте, но мы вынесли код в утильный класс. Смотрим дальше:
@UtilityClass public class LoggingUtils { private final Set<String> COPYABLE_MDC_FIELDS = Set.of( MdcKeys.MDC_USER_AGENT, MdcKeys.MDC_USER_TOKEN, MdcKeys.MDC_USER_LOGIN, MdcKeys.MDC_API_URL, MdcKeys.MDC_MOBILE_FEATURE); /** * Декорирует Runnable таким образом, что при его вызове в другом потоке * будут восстановлены значения нескольких определённых полей MDC из * текущего потока. */ public Runnable decorateMdcCopying(Runnable runnable) { // Получаем значения, которые нужно будет восстановить в MDC. Map<String, String> mdcMap = getMdcMeaningfulMap(); return () -> { // Восстанавливаем в значения полей MDC из потока-создателя. try (var ignored = mdcCloseable(mdcMap)) { // Вызываем оригинальный код. runnable.run(); } }; } private Map<String, String> getMdcMeaningfulMap() { return StreamEx.of(COPYABLE_MDC_FIELDS) .mapToEntry(MDC::get) .nonNullValues() .toMap(); } public MdcCloseable mdcCloseable(Map<String, String> values) { // Если нечего устанавливать, вернём ничего не закрывающий singleton. if (MapUtils.isEmpty(values)) { return MdcCloseable.EMPTY; } // Вариант, когда нам не нужно будет ничего восстанавливать в MDC. var mdcMap = MapUtils.emptyIfNull(MDC.getCopyOfContextMap()); if (MapUtils.isEmpty(mdcMap)) { return new MdcCloseable(values, Collections.emptyMap()); } // Значения, которые нужно будет вернуть в MDC // (если они там были до нас). Map<String, String> original = EntryStream.of(mdcMap) .nonNullValues() .filterKeys(values::containsKey) .filterKeyValue((k, v) -> Objects.equals(v, mdcMap.get(k))) .toMap(); return new MdcCloseable(values, original); } public MdcCloseable mdcCloseable(String key, String value) { return mdcCloseable(Map.of(key, value)); } }
И, да, мы написали собственную альтернативу MDC.MDCCloseable:
/** * Данный класс отличается от org.slf4j.MDC.MDCCloseable следующим: * <ol> * <li>работает со множеством значений,</li> * <li>умеет восстанавливать предыдущие значения.</li> * </ol> */ @RequiredArgsConstructor(access = AccessLevel.PRIVATE) public class MdcCloseable implements Closeable { public static final MdcCloseable EMPTY = new MdcCloseable( Collections.emptySet(), Collections.emptyMap()); private final Set<String> values; private final Map<String, String> original; MdcCloseable(Map<String, String> values, Map<String, String> original) { this(values.keySet(), original); values.forEach(MDC::put); } @Override public void close() { // Удалили все новые значения при закрытии. values.forEach(MDC::remove); // Восстановили все предыдущие значения. original.forEach(MDC::put); } }
Данный класс можно использовать отдельно в коде приложения для установки каких-то дополнительных полей, которые помогут в дальнейшем искать логи в агрегаторе, например:
// ... какой-то код ... try (var ignored = LoggingUtils.mdcCloseable(MdcKeys.SOME_EXT_SVC_URL, url) { /* Все логи в агрегаторе из этого блока кода будут содержать ключ MdcKeys.SOME_EXT_SVC_URL и значение url. */ } // ... какой-то код ...
Всё вышеописанное может оказаться дичайшим over-engineering-ом.
Я не очень близко знаком с Reactor и WebFlux, поэтому мне кажется, что применить с ними аналогичный подход будет немного сложнее.
Lombok; var, Map.of, Set.of и другие фичи новых версий Java; StreamEx — это всё огонь.
Не бейте строго за первую статью на ресурсе.
ссылка на оригинал статьи https://habr.com/ru/post/540648/
Добавить комментарий