От сервлетов к реактору 4

Часть 4. Унификация @Around-аспектов

Реакторный зал Нововоронежской АЭС
Содержание

Ещё одна любопытная задачка, возникающая при внедрении реактивного стека в сервлетные приложения – это поддержка AOP-аспектов, оборачивающих тело целевого метода. Такие аспекты декларируются с аннотацией org.aspectj.lang.annotation.Around и имеют возможность повлиять как на аргументы перехватываемого метода, так и на его результат, вплоть до полной его замены. Например, вот так может выглядеть примитивный аспект, логирующий аргументы и результат любого метода, помеченного аннотацией @GetMapping:

@Aspect
@Component
public class LoggingAspect {
  private static final Logger log = LoggerFactory.getLogger(LoggingAspect.class);

  @Around("within(pro.toparvion.sample.gateway.gatewaydemo..*) " +    // [1]
      " && execution(@org.springframework.web.bind.annotation.GetMapping * *.*(..))") // [2]
  public Object logArgsAndResult(ProceedingJoinPoint pjp) throws Throwable {
    Object[] args = pjp.getArgs();             // [3]
    String targetMethodName = pjp.getSignature().getName();
    log.debug("Метод '{}' вызван с аргументами: {}", targetMethodName, args);
    Object response = pjp.proceed(args);       // [4]
    log.debug("Метод '{}' ответил: {}", targetMethodName, response);
    return response;
  }
}

1️⃣ Ограничиваем область действия аспекта, чтобы не напороться.
2️⃣ Задаём шаблон методов, на которые должен действовать аспект.
3️⃣ Получаем аргументы и имя целевого метода из точки вкрапления.
4️⃣ После логирования аргументов делегируем управление целевому методу, чтобы потом сразу залогировать его результат. Для простоты аспект не учитывает случай, когда метод завершается исключением.

Проблема

Проблема здесь, в сущности, та же, что и с фильтром для поддержки MDC: из-за “двухэтапного” исполнения реактивного конвейера аспект захватит лишь результат декларации конвейера, но не реально курсирующие по нему данные:

16:24:18.136 DEBUG [ctor-http-nio-3] LoggingAspect : Метод 'proxy' ответил: MonoPeekTerminal

, т.е. вместо действительного результата в логах всегда будет числиться MonoPeekTerminal (для данного примера).

Решение

Решением может стать такое же (как с MDC) разделение аспекта на два: для сервлетного и реактивного режимов (при помощи аннотации @ConditionalOnWebApplication). Однако, в отличие от случая с MDC, здесь мы не привязаны к API веб-фильтров, а значит, можем переписать аспект так, чтобы он один умел работать в обоих режимах. Это может быть особенно ценным, если логика аспекта достаточно сложна и/или объёмна, чтобы сохранить её как можно более DRY.

Чтобы это сделать, нужно:

  1. Уметь отличать реактивные методы от императивных;
  2. Уметь назначать желаемое поведение отложенно, т.е. не в момент применения аспекта, а позже, когда по конвейеру будут проходить данные.

Первое достаточно легко сделать анализом типа результата метода: если он представляет собой реактивный “хвостик” в виде Mono<?> или Flux<?>, то и весь метод можно считать реактивным. Второе решается использованием соответствующих реактивных операторов: в рассматриваемом простом случае для Mono это будет метод doOnSuccess(), а для FluxdoOnNext(). Кроме того, чтобы не повторять само логирование ответа в каждом месте, вызов log.debug(...) стóит оформить в метод logResult(). Тогда основной метод аспекта (точнее, его окончание) преобразится вот таким образом:

  @Around("within(pro.toparvion.sample.gateway.gatewaydemo..*) " +
      " && execution(@org.springframework.web.bind.annotation.GetMapping * *.*(..))")
  public Object logArgsAndResult(ProceedingJoinPoint pjp) throws Throwable {
    Object[] args = pjp.getArgs();
    String targetMethodName = pjp.getSignature().getName();
    log.debug("Метод '{}' вызван с аргументами: {}", targetMethodName, args);
    Object resultTail = pjp.proceed(args);
    return assignResultLoggingBehavior(targetMethodName, resultTail);   // [1]
  }

1️⃣ Теперь здесь нет явного логирования результата. Вместо него вызывается некое назначение (декорирование) ответа, которое на самом деле может сводиться и к непосредственному логированию. Это видно в коде нового метода assignResultLoggingBehavior:

  private Object assignResultLoggingBehavior(String targetMethodName, Object tail) {
    if (tail instanceof Mono<?> monoResult) {         // [1]
      tail = monoResult.doOnSuccess(result -> logResult(targetMethodName, result));
    } else if (tail instanceof Flux<?> fluxResult) {  // [2]
      tail = fluxResult.doOnNext(result -> logResult(targetMethodName, result));
    } else {
      logResult(targetMethodName, tail);              // [3]
    }
    return tail;                                      // [4]
  }

1️⃣ Используем Pattern Matching (JDK 16+) для сокращения записи.
2️⃣ В случае с HTTP-вызовом результат в виде Flux соответствует стриминговому ответу сервера, например, SSE (просто FYI).
3️⃣ Если результат не реактивный, просто логируем результат сразу.
4️⃣ Возвращаем результат, чтобы на него можно было подписаться, либо сразу вернуть клиенту.

В таком виде аспект сможет воевать на два фронта: например, будучи оформленным в инфраструктурную библиотеку, его можно будет использовать как с привычными сервлетными приложениями, так и с новомодными реактивными. Однако важно помнить, что в import‘ах у него есть классы из Project Reactor, а значит, при использовании этого класса в runtimeClasspath должна присутствовать библиотека io.projectreactor:reactor-core, даже если аспект применяется к сервлетному приложению.

Разумеется, такой аспект далёк от состояния production-ready, хотя бы потому, что:

  • не учитывает, что аргументы тоже могут быть реактивными;
  • не обрабатывает исключения, летящие из жерла целевого метода;
  • декларирован как @Around, хотя далеко не всегда требуется именно двусторонняя обёртка (часто достаточно обойтись аннотациями @Before или @After).

Однако в качестве отправной точки его должно быть достаточно.

Полный код аспекта можно найти в прилагаемом демо-проекте .

Попутное резюме

Эта часть была посвящена одну из самых “магических” механизмов вкрапления в поведение приложений. Теперь и он сможет работать на любом из двух стеков. В следующей части речь пойдёт о гораздо более явном инструменте – декларативных HTTP-клиентах.

 


О картинке
RIA Novosti archive, image #342604 / Sergey Pyatakov / CC-BY-SA 3.0, CC BY-SA 3.0, через Викисклад
Владимир Плизгá
Владимир Плизгá
Ведущий инженер

Любимая технология: здравый смысл