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-решение «Курьерика» и привлекли сотни клиентов.


ссылка на оригинал статьи https://habr.com/ru/articles/869602/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *