Spring Boot, AspectJ. Введение в аспектно-ориентированное программирование

Привет, меня зовут Николай Пискунов,  я руководитель направления Big Data и автор медиа вАЙТИ. Недавно мне в руки попался старый проект, написанный на Spring Boot. В нем я нашел пару десятков эндпоинтов, в которых метод обрамлен logger. Логирование в таком виде — это важный элемент как для ПО, так и для безопасности, но оно делает код объемнее, его становится сложнее читать. 

В идеальном случае методы можно сократить буквально до пары строк, но для этого нужно отделить бизнес-логику от остального кода. Сделать это можно с помощью аспектно-ориентированного программирования (AOP) — языка для определения именованного аспекта. О нем и пойдет речь в этой статье.

Spring Boot, AspectJ. Введение в аспектно-ориентированное программирование

Spring Boot, AspectJ. Введение в аспектно-ориентированное программирование

Что такое аспектно-ориентированное программирование

Для начала приведу пример кода, о котором говорил выше:

@PostMapping("/employees")
Employee newEmployee(@RequestBody Employee newEmployee) {
   if (logger.isDebugEnabled()) {
      logger.debug("call /employees - add new employee - {}", newEmployee);
   } else {
      logger.info("call /employees - add new employee");
   }
   Employee saved = repository.save(newEmployee);
   if (logger.isDebugEnabled()) {
      logger.debug("call /employees - new employee saved - {}", saved;
   } else {
      logger.info("call /employees - new employee saved");
   }
   return saved;
}

Как видно, код сложно воспринимается и как минимум занимает много места на экране. В нем явно нарушен один из основных принципов программирования — DRY (Don«t Repeat Yourself). Эта аббревиатура так и переводится: «Не повторяйся». Она призывает не повторять одинаковые фрагменты программы в нескольких местах. Вместо этого нужно абстрагировать повторяющиеся блоки и использовать их несколько раз через общую функцию или метод. Это делает код более чистым, легким для поддержки и уменьшает вероятность ошибок. DRY помогает сделать код более модульным и упрощает внесение изменений в приложение.

Но надо отдать должное, в последних добавленных эндпоинтах разработчик попытался работать по принципу DRY. Он создал абстракцию, которая принимала в себя текст сообщения и в зависимости от того, включен дебаг или нет, выводила соответствующее сообщение в лог. Код последних методов стал намного чище:

@PostMapping("/job")
Job newJob(@RequestBody Jon newJob) {
   toLog("call /employees - add new job", newJob);
   Job saved = repository.save(newJob);
   toLog("call /employees - new job saved", saved);
   return saved;
}

Хотя принцип DRY даже в этом случае отчасти нарушен. И хотелось бы сократить метод до одной строчки, где это возможно:

@PostMapping("/job")
Job newJob(@RequestBody Jon newJob) {
   return repository.save(newJob);
}

Здесь нам поможет AOP, которое сделает код более чистым, модульным и гибким. Аспектно-ориентированное программирование позволяет разделить повторяющийся код на аспекты (например, логирование, аудит или безопасность) и применять их ко множеству классов с помощью простого указания имени. В Java для этого используется библиотека AspectJ. Она добавляет в Java возможность использования концепции обертки и прокси, что упрощает рефакторинг и улучшение существующего кода. 

Как написать собственный аспект по принципам AOP

Согласно концепции AOP, мы продумаем архитектуру нашего логирования.

1. Аспект — это модульная часть кода, она определяет повторяющуюся логику, которую можно применить ко множеству классов. Как мы выяснили выше, в нашем случае это вывод записей в лог.

Чтобы создать аспект в Spring Boot, достаточно создать новый класс и пометить его аннотациями — это особый тип интерфейсов, которые определяют, когда и как аспект должен быть применен к коду. Аннотации содержат ключевые слова, которые указывают на то, что они должны быть связаны с определенными методами или классами. В нашем случае нужно указать @Aspect и @Component:

@Aspect
@Component
public class RestLoggingAspect {
}

2. Перехват— это механизм для внедрения аспекта в целевой класс или метод. Он предоставляет возможность изменять вызовы и возвраты от этих методов. Чтобы Java поняла, с реализацией какого метода мы хотим поработать, данный метод следует пометить нужной нам аннотацией, например @PostMapping.

Реализуем собственную аннотацию:

/**
* Помечать только рест-запросы и рест-фильтры
*/
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface RestLogging {
}

3. Ключевые слова— это специальные строки, которые определяют, когда аспект должен быть применен к коду. Грубо говоря, это те же аннотации, которые применяются к методам внутри аспекта. Благодаря им Java понимает, когда применить функционал обертки.

В классическом AspectJ методы класса-аспекта могут быть помечены следующими аннотациями:

@Before — определяет, что аспект должен быть применен перед вызовом оригинального метода.

@Before("execution(* main(..))")
public void logCall() {
   // do something before the method
}

@After — определяет, что аспект должен быть применен после возврата от оригинального метода.

@After("execution(* main(..))")
public void logReturn() {
   // do something after the method
}

@Around — аспект должен быть применен вокруг вызова и возврата от оригинального метода.

@Around("execution(* main(..))")
public void logCall() throws Throwable {
   // do something before the method
   proceed();
   // do something after the method
}

Для решения нашей задачи подойдет как раз @Around. Создадим метод, который обогатит эндпоинты логированием:

public class RestLoggingAspect {
   @Around("@annotation(RestLogging)")
   public Object profileAllMethods(ProceedingJoinPoint proceedingJoinPoint) 
                       throws Throwable {
   }
}

В нашем коде Java будет искать все методы, помеченные аннотацией RestLogging (@annotation (RestLogging)), и записывать в лог.

4. Обертка — это способ добавить дополнительную логику, чтобы модифицировать вызовы методов и возвратов от них, не меняя исходный код. Обертки используются для замены вызовов и возвратов на основе определенных критериев, добавления или изменения до или после оригинального метода.

Пример аспекта для логирования методов

В первом приближении аспект у меня получился следующий:

@Slf4j
@Aspect
@Component
@RequiredArgsConstructor
public class RestLoggingAspect {
   private static final String X_REQUEST_ID_HEADER = "x-request-id";
   /*
   Согласованные тексты логов
   */
   private static final String PROCESS_START_LOGGER_PATTERN = 
      "[{}] {}.{}.{} started. {} request to url {} ";
   private static final String PROCESS_START_WITH_BODY_LOGGER_PATTERN = 
      "[{}] {}.{}.{} started. {} request to url {}. {}";
   private static final String PROCESS_END_LOGGER_PATTERN = 
      "[{}] {}.{}.{} completed. {} request to url {}nExecution time :: {} ms";
   private static final String PROCESS_END_WITH_BODY_LOGGER_PATTERN = 
      "[{}] {}.{}.{} completed. {} request to url {}. {}nExecution time :: {} ms";
   @Value("${spring.application.name}")
   private String appName;
   private final RequestScopeObject requestScopeObject;
   @Around("@annotation(RestLogging)")
   public Object toLog(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
  /*
       Выполняется ДО кода метода, помеченного аннотацией RestLogging
       */
       MethodSignature methodSignature = (MethodSignature) proceedingJoinPoint.getSignature();
       ObjectMapper mapper = new ObjectMapper();
       String className = methodSignature.getDeclaringType().getSimpleName();
       String methodName = methodSignature.getName();
       HttpServletRequest request =
               ((ServletRequestAttributes) Objects.requireNonNull(RequestContextHolder
                  .getRequestAttributes()))
                       .getRequest();
       if (StringUtils.isBlank(requestScopeObject.getRequestId())) {
           String requestId = request.getHeader(X_REQUEST_ID_HEADER);
           if (StringUtils.isBlank(requestId)) {
               requestId = UUID.randomUUID().toString().toUpperCase().replace("-", "");
           }
           requestScopeObject.setRequestId(requestId);
       }
       if (StringUtils.isBlank(requestScopeObject.getOperationId())) {
           String operationId = UUID.randomUUID().toString().toUpperCase();
           requestScopeObject.setOperationId(operationId);
       }
       String rqUrl = Optional.ofNullable(request.getQueryString()).isPresent() ?
               request.getRequestURL().toString() + "?" + 
                        request.getQueryString() : request.getRequestURL().toString();
       if (!LOGGER.isDebugEnabled()) {
           LOGGER.info(PROCESS_START_LOGGER_PATTERN, requestScopeObject.getRequestId(), 
                  appName, className, methodName, request.getMethod(), rqUrl);
       } else {
           if (!"DELETE".equalsIgnoreCase(request.getMethod())) {
               JsonNode node = mapper.valueToTree(proceedingJoinPoint.getArgs());
               LOGGER.debug(PROCESS_START_WITH_BODY_LOGGER_PATTERN, 
                       requestScopeObject.getRequestId(), 
                       appName, className, methodName, request.getMethod(), 
                       rqUrl, node.toPrettyString());
           } else {
               LOGGER.debug(PROCESS_START_LOGGER_PATTERN, requestScopeObject.getRequestId(), 
                       appName, className, methodName,
                       request.getMethod(), rqUrl);
           }
       }
       final StopWatch stopWatch = new StopWatch();
       stopWatch.start();
       /*
       Выполняем код метода, помеченного аннотацией RestLogging. Результат 
       метода записываем в переменную result
       */
       Object result = proceedingJoinPoint.proceed();
       /*
       Выполняется ПОСЛЕ кода метода, помеченного аннотацией RestLogging
       */
       stopWatch.stop();
       if (!LOGGER.isDebugEnabled()) {
           LOGGER.info(PROCESS_END_LOGGER_PATTERN,
                   requestScopeObject.getRequestId(), appName, className, methodName,
                   request.getMethod(), rqUrl, stopWatch.getTotalTimeMillis());
       } else {
           if (!"DELETE".equalsIgnoreCase(request.getMethod()) && Objects.nonNull(result)) {
               LOGGER.debug(PROCESS_END_WITH_BODY_LOGGER_PATTERN,
                       requestScopeObject.getRequestId(), appName, className, methodName,
                       request.getMethod(), rqUrl, mapper.valueToTree(result).toPrettyString(),
                       stopWatch.getTotalTimeMillis());
           } else {
               LOGGER.debug(PROCESS_END_LOGGER_PATTERN,
                       requestScopeObject.getRequestId(), appName, className, methodName,
                       request.getMethod(), rqUrl, stopWatch.getTotalTimeMillis());
           }
       }
       return result;
   }
}

Этот аспект используется для оборачивания логированием методов, помеченных аннотацией @RestLogging. Для сериализации объектов в JSON используется библиотека Jackson.

С помощью аннотации @Around я определил точку присоединения, которая вызывает метод toLog перед и после любого метода, помеченного аннотацией @RestLogging.

Запрос и ответ я добавлял в дебаг-логи в виде JSON с помощью библиотеки Jackson, в частности класса JsonNode. Также я использую класс StopWatch из Spring Framework для отслеживания времени выполнения метода.

И так как ранее были определены request id в отдельном классе, я решил эти данные вынести в контекст спринга в RequestScope, для того чтобы иметь доступ к ним в рамках запроса из любой точки приложения. 

@Configuration
public class CommonConfig {
   @Bean
   @RequestScope
   public RequestScopeObject requestScopedBean() {
       return new RequestScopeObject();
   }
}

и

@Getter
@Setter
@RequiredArgsConstructor
public class RequestScopeObject extends ScopeObject {
}
@Getter
@Setter
@FieldDefaults(level = AccessLevel.PRIVATE)
public abstract class ScopeObject {
   String requestId;
   String operationId;
}

Теперь, чтобы однотипно логировать любой эндпоинт, достаточно добавить одну аннотацию:

@RestLogging
@PostMapping("/job")
Job newJob(@RequestBody Jon newJob) {
   return repository.save(newJob);
}

В качестве дальнейших шагов можно выводить время выполнения методов в Grafana, а также нужно отрефакторить сам аспект, чтобы было проще читать не только код методов, которые его оборачивают, но и код самого аспекта =)

В итоге все методы и запросы, помеченные аннотацией @RestLogging, будут покрыты логами, а также можно будет увидеть время выполнения определенного метода, что упростит профилирование приложения на проде.

В следующей статье я расскажу, как еще можно решить данную проблему с помощью фильтров.

вАЙТИ — DIY-медиа для ИТ-специалистов. Делитесь личными историями про решение самых разных ИТ-задач и получайте вознаграждение.

Другие статьи по теме

#Разработка

Автоматизация типовых задач в таск-трекере: инструкция по применению
Как найти процессы для автоматизации в любой отрасли, какие выгоды это принесет компании и как не ошибиться

Перевели крупный холдинг с монолита на микросервисы и сразу увидели улучшения
Микросервисы — это дороже, дольше и сложнее, чем монолит, но позволяет бизнесу достичь нужных показателей

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

Как пересобрать сложную разработку в облачное решение и начать работу с малым и средним бизнесом
Мы переделали кастомный логистический продукт в облачное SaaS-решение «Курьерика» и привлекли сотни клиентов.

© Habrahabr.ru