Речь в этой небольшой заметке пойдёт про то, как с пользой можно использовать 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 — это всё огонь.

Не бейте строго за первую статью на ресурсе.