Как изящно регистрировать операции?

задняя часть
Как изящно регистрировать операции?

Журналы операций широко существуют в различных системах на стороне B и в некоторых системах на стороне C. Например, служба поддержки клиентов может быстро узнать, кто и какие операции выполнил с рабочим заданием, на основе журнала операций рабочего задания, а затем быстро определить проблему. Журнал операций отличается от системного журнала.Журнал операций должен быть простым и понятным. Так как же сделать так, чтобы операционный журнал не был связан с бизнес-логикой, как сделать содержимое операционного журнала понятным и облегчить доступ к операционному журналу? Выше приведены вопросы, на которые необходимо ответить в этой статье, в основном о том, как «элегантно» записывать журналы операций.

1. Сценарии использования журнала операций

例子

Разница между системным журналом и операционным журналом

Системный журнал: системный журнал в основном служит основой для разработки и устранения неполадок и обычно печатается в файле журнала; читабельность системного журнала не так высока, и журнал будет содержать информацию о коде, такую ​​как печать журнала в определенная линия класса.

Журнал операций: В основном это запись добавления или изменения после добавления или изменения объекта.Журнал операций должен быть более читаемым, потому что он в основном предназначен для просмотра пользователем, например, логистическая информация о заказе, которую пользователь должен знать. Что произошло в какое время. Другим примером является обработка записи информации службы поддержки клиентов о рабочем задании.

Формат записи журнала операций условно делится на следующие виды:

  • Простые текстовые записи, например: 2021-09-16 10:00 создание заказа.
  • Простые динамические текстовые записи, такие как: 2021-09-16 10:00 создание заказа, номер заказа: № 11089999, который включает переменный номер заказа «№ 11089999».
  • Текст типа модификации, включая значения до и после модификации, например: 2021-09-16 10:00 Пользователь Сяомин изменил адрес доставки заказа: с «Сообщество Цзиньканкан» на «Сообщество Иньжанжан», что включает переменные. Первоначальный адрес доставки — «Сообщество Джинканкан», а новый адрес — «Сообщество Иньжанжан».
  • Измените форму, изменяя сразу несколько полей.

2. Реализация

2.1 Используйте Canal для мониторинга базы данных для записи журналов операций

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

Преимущество этого подхода в том, что он полностью отделен от бизнес-логики. Минусы тоже очевидны.Ограничения слишком велики.Возможна запись только журналов операций по изменениям БД.Если модификация связана с вызовами RPC других команд, то нет возможности мониторить БД.Например:отправлять уведомления пользователям,уведомлять сервисы Как правило, это общедоступные компоненты внутри компании, в настоящее время журнал работы по отправке уведомлений может быть записан только вручную при вызове RPC.

2.2 Запись через лог-файлы

log.info("订单创建")
log.info("订单已经创建,订单编号:{}", orderNo)
log.info("修改了订单的配送地址:从“{}”修改到“{}”, "金灿灿小区", "银盏盏小区")

Есть три проблемы, которые необходимо решить при таком способе работы с записями.

Вопрос 1: Как оператор записывает

С помощью класса инструмента MDC в SLF4J оператор помещается в журнал, а затем распечатывается в журнале. Сначала поместите идентификатор пользователя в MDC в перехватчике пользователя.

@Component
public class UserInterceptor extends HandlerInterceptorAdapter {
  @Override
  public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
    //获取到用户标识
    String userNo = getUserNo(request);
    //把用户 ID 放到 MDC 上下文中
    MDC.put("userId", userNo);
    return super.preHandle(request, response, handler);
  }

  private String getUserNo(HttpServletRequest request) {
    // 通过 SSO 或者Cookie 或者 Auth信息获取到 当前登陆的用户信息
    return null;
  }
}

Во-вторых, отформатируйте идентификатор пользователя в журнале и используйте %X{userId}, чтобы получить идентификатор пользователя в MDC.

<pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>

Вопрос 2: Как отличить журнал операций от системного журнала

При настройке файла конфигурации журнала журнал, относящийся к журналу операций, помещается в файл журнала отдельно.

//不同业务日志记录到不同的文件
<appender name="businessLogAppender" class="ch.qos.logback.core.rolling.RollingFileAppender">
    <File>logs/business.log</File>
    <append>true</append>
    <filter class="ch.qos.logback.classic.filter.LevelFilter">
        <level>INFO</level>
        <onMatch>ACCEPT</onMatch>
        <onMismatch>DENY</onMismatch>
    </filter>
    <rollingPolicy class="ch.qos.logback.core.rolling.TimeBasedRollingPolicy">
        <fileNamePattern>logs/业务A.%d.%i.log</fileNamePattern>
        <maxHistory>90</maxHistory>
        <timeBasedFileNamingAndTriggeringPolicy class="ch.qos.logback.core.rolling.SizeAndTimeBasedFNATP">
            <maxFileSize>10MB</maxFileSize>
        </timeBasedFileNamingAndTriggeringPolicy>
    </rollingPolicy>
    <encoder>
        <pattern>"%d{yyyy-MM-dd HH:mm:ss.SSS} %t %-5level %X{userId} %logger{30}.%method:%L - %msg%n"</pattern>
        <charset>UTF-8</charset>
    </encoder>
</appender>
        
<logger name="businessLog" additivity="false" level="INFO">
    <appender-ref ref="businessLogAppender"/>
</logger>

Затем запишите бизнес-журналы отдельно в коде Java.

//记录特定日志的声明
private final Logger businessLog = LoggerFactory.getLogger("businessLog");
 
//日志存储
businessLog.info("修改了配送地址");

Вопрос 3: Как создать удобочитаемую копию журнала

Вы можете использовать метод LogUtil или метод аспекта для создания шаблонов журналов, которые будут представлены позже. Таким образом, журналы могут быть сохранены в отдельный файл, а затем журналы могут быть сохранены в Elasticsearch или в базе данных через сбор журналов.Далее давайте посмотрим, как создать читаемые журналы операций.

2.3 Ведение журнала с помощью LogUtil

  LogUtil.log(orderNo, "订单创建", "小明")模板
  LogUtil.log(orderNo, "订单创建,订单号"+"NO.11089999",  "小明")
  String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”"
  LogUtil.log(orderNo, String.format(tempalte, "小明", "金灿灿小区", "银盏盏小区"),  "小明")

Вот объяснение того, почему OrderNo привязывается при записи журнала операций, потому что журнал операций записывает: какие «вещи», определенное «время», «кто» сделал «что». При запросе журнала операций предприятия будут запрашиваться все операции для этого заказа, поэтому в код добавляется OrderNo, а оператор необходимо записать при записи журнала операций, поэтому передается оператор «Сяо Мин».

Вышеупомянутое не кажется большой проблемой.В методе бизнес-логики модификации адреса строка кода используется для записи журнала операций.Далее рассмотрим более сложный пример:

private OnesIssueDO updateAddress(updateDeliveryRequest request) {
    DeliveryOrder deliveryOrder = deliveryQueryService.queryOldAddress(request.getDeliveryOrderNo());
    // 更新派送信息,电话,收件人,地址
    doUpdate(request);
    String logContent = getLogContent(request, deliveryOrder);
    LogUtils.logRecord(request.getOrderNo(), logContent, request.getOperator);
    return onesIssueDO;
}

private String getLogContent(updateDeliveryRequest request, DeliveryOrder deliveryOrder) {
    String template = "用户%s修改了订单的配送地址:从“%s”修改到“%s”";
    return String.format(tempalte, request.getUserName(), deliveryOrder.getAddress(), request.getAddress);
}

Вы можете видеть, что в приведенном выше примере используются два кода метода, а также функция getLogContent для записи журнала операций. Когда бизнес усложняется, размещение журнала операций в бизнес-коде приведет к усложнению бизнес-логики, и, наконец, вызов метода LogUtils.logRecord() существует во многих бизнес-кодах, а такие методы, как getLogContent(), также разбросаны по разным бизнес-классы — это катастрофа для удобочитаемости и ремонтопригодности кода. Вот как избежать этой катастрофы.

2.4 Аннотации методов реализуют журналы операций

Чтобы решить вышеуказанные проблемы, АОП обычно используется для записи журналов, чтобы отделить журналы операций от бизнес-логики.Далее давайте рассмотрим простой пример журнала АОП.

@LogRecord(content="修改了配送地址")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Мы можем записать фиксированную копию в аннотированный журнал операций, чтобы можно было разделить бизнес-логику и бизнес-код, сделав наш бизнес-код чистым. Некоторые учащиеся могли заметить, что, хотя описанный выше метод разделяет код журнала операций, записанная копия не соответствует нашим ожиданиям.Копия является статической и не содержит динамической копии, потому что журнал операций, который нам нужно записать, имеет следующий вид: User % s изменил адрес доставки заказа с «%s» на «%s». Далее мы расскажем, как использовать АОП для элегантного создания журналов динамических операций.

3. Изящно поддерживает АОП для создания журналов динамических операций.

3.1 Динамические шаблоны

Когда дело доходит до динамических шаблонов, это позволяет переменным анализировать шаблоны с помощью заполнителей, чтобы достичь цели записи журналов операций с помощью аннотаций. Существует множество способов парсинга шаблонов, и здесь используется SpEL (Spring Expression Language, Spring Expression Language). Мы можем сначала записать желаемый способ ведения журнала, а затем посмотреть, сможем ли мы добиться такой функции.

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Ссылаясь на параметры метода через выражение SpEL, переменные можно заполнить в шаблоне для получения динамического текстового содержимого журнала операций. Но осталось решить еще несколько вопросов:

  • Журнал операций должен знать, какой оператор изменил адрес доставки заказа.
  • Журнал операций по изменению адреса доставки заказа должен быть привязан к заказу доставки, чтобы все операции над заказом доставки можно было запрашивать на основе номера заказа доставки.
  • Чтобы записать в аннотации предыдущий адрес доставки, в сигнатуру метода добавляется переменная oldAddress, не имеющая отношения к бизнесу, что не элегантно.

Чтобы решить первые две проблемы, нам нужно изменить форму ожидаемого использования журнала операций следующим образом:

@LogRecord(
     content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
     operator = "#request.userName", bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Измененный код добавляет в аннотацию два параметра, один из которых является оператором, а другой — объектом, к которому необходимо привязать журнал операций. Однако в обычных веб-приложениях информация о пользователе хранится в статическом методе в контексте потока, поэтому оператор обычно записывается таким образом (при условии, что способ получить текущего вошедшего в систему пользователя — это UserContext.getCurrentUser()).

operator = "#{T(com.meituan.user.UserContext).getCurrentUser()}"

В этом случае оператор для каждой аннотации @LogRecord представляет собой такой длинный список. Чтобы избежать слишком большого количества повторяющегося кода, мы можем сделать параметр оператора в аннотации необязательным, чтобы пользователь мог ввести оператор. Однако, если пользователь не заполнится, мы возьмем пользователя из UserContext (далее будет описано, как взять пользователя). В итоге простейший лог становится таким:

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”", 
           bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request, String oldAddress){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Далее нам нужно решить третью проблему: добавление переменной oldAddress для записи записи бизнес-операции, это все равно не очень хороший способ ее реализации, поэтому далее нам нужно удалить переменную oldAddress из сигнатуры метода модификации адрес удалить. Но в журнале операций нужна переменная oldAddress, что делать?

Или иметь ПК с продакт-менеджером и попросить продакт-менеджера изменить копию с «изменен адрес доставки заказа: с xx на yy» на «изменен адрес доставки заказа на: yy». Но с точки зрения пользовательского опыта первая копия более удобна для пользователя, очевидно, ПК у нас не получится. Затем мы должны запросить этот старый адрес и использовать его для журнала операций. Другое решение — поместить этот параметр в контекст потока журнала операций для шаблона аннотации. Мы следуем этой идее, а затем меняем код реализации журнала операций.

@LogRecord(content = "修改了订单的配送地址:从“#oldAddress”, 修改到“#request.address”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldAddress", DeliveryService.queryOldAddress(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

В настоящее время вы можете видеть, что LogRecordContext решает проблему использования переменных, отличных от параметров метода, в шаблоне журнала операций и в то же время позволяет избежать изменения сигнатуры метода для записи журнала операций. Хотя это лучше, чем предыдущий код, все же необходимо добавить строку кода, не связанную с бизнес-логикой в ​​​​бизнес-коде.Если у вас «обсессивно-компульсивное расстройство», вы можете продолжить его читать.Далее мы объясним пользовательская функция решение. Вот еще один пример:

@LogRecord(content = "修改了订单的配送员:从“#oldDeliveryUserId”, 修改到“#request.userId”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Последнее записанное содержимое этого шаблона журнала операций имеет следующий формат: Доставщик, изменивший заказ: с «10090» на «10099», очевидно, что пользователь не понимает этот журнал операций. Пользователь не знает, является ли идентификатор пользователя 10090 или 10099. Пользователь ожидает увидеть: доставщик, который изменил заказ: с «Чжан Сан (18910008888)» на «Сяо Мин (13910006666)». Что интересует пользователя, так это имя и номер телефона курьера. Но в нашем методе передается только идентификатор курьера, а имени вызываемого курьера нет. Мы можем запросить имя пользователя и номер телефона с помощью вышеуказанного метода, а затем реализовать его с помощью LogRecordContext.

Однако «OCD» — это код, который не ожидает, что журнал операций будет встроен в бизнес-логику. Далее мы рассмотрим другую реализацию: пользовательские функции. Если мы можем преобразовать идентификатор пользователя в имя пользователя и номер телефона через пользовательскую функцию, то эта проблема может быть решена.Согласно этой идее, мы модифицируем шаблон до следующего вида:

@LogRecord(content = "修改了订单的配送员:从“{deliveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.userId}}”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Среди них deliveryUser – пользовательская функция. Используйте фигурные скобки для заключения выражения SpEL в Spring. Преимущества этого заключаются в следующем: во-первых, можно отделить SpEL (язык выражений Spring, язык выражений Spring) от пользовательских функций для облегчения анализа; во-вторых, если шаблоны которые не требуют синтаксического анализа выражения SpEL, могут быть легко идентифицированы, сокращая синтаксический анализ выражения SpEL и повышая производительность. В это время мы обнаружили, что приведенный выше код также можно оптимизировать до следующей формы:

@LogRecord(content = "修改了订单的配送员:从“{queryOldUser{#request.deliveryOrderNo()}}”, 修改到“{deveryUser{#request.userId}}”",
        bizNo="#request.deliveryOrderNo")
public void modifyAddress(updateDeliveryRequest request){
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Таким образом, нет необходимости устанавливать старого курьера через LogRecordContext.putVariable() в методеmodifyAddress.Прямо добавляя параметр пользовательской функции queryOldUser() для передачи заказа на доставку, вы можете найти предыдущего курьера, только его необходимо, чтобы разрешение метода выполнялось до выполнения метода modifyAddress(). Таким образом, мы снова делаем бизнес-код чистым и в то же время делаем так, чтобы «обсессивно-компульсивное расстройство» больше не вызывало дискомфорта.

4. Анализ реализации кода

4.1 Структура кода

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

4.2 Введение в модуль

С помощью приведенного выше анализа мы пришли к ожидаемому способу ведения журнала, поэтому давайте посмотрим, как реализовать приведенную выше логику. Реализация в основном делится на следующие этапы:

  • Логика перехвата АОП
  • логика разбора
    • Разбор шаблона
    • Логика LogContext
    • Логика оператора по умолчанию
    • логика пользовательской функции
  • Логика сохранения журнала по умолчанию
  • Стартер инкапсулирует логику

4.2.1 Логика перехвата АОП

Эта логика в основном является перехватчиком, который анализирует журнал операций для записи в соответствии с аннотацией @LogRecord, а затем сохраняет журнал операций.Здесь аннотация называется @LogRecordAnnotation. Далее давайте посмотрим на определение аннотаций:

@Target({ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Inherited
@Documented
public @interface LogRecordAnnotation {
    String success();

    String fail() default "";

    String operator() default "";

    String bizNo();

    String category() default "";

    String detail() default "";

    String condition() default "";
}

В дополнение к параметрам, упомянутым выше, в аннотации также добавляются такие параметры, как сбой, категория, подробности и условия Эти параметры должны соответствовать конкретным сценариям, и конкретные примеры будут приведены позже.

имя параметра описывать Требуется ли
success Текстовый шаблон для журнала действий да
fail Текстовая версия ошибки журнала операции нет
operator исполнитель журнала операций нет
bizNo Идентификатор бизнес-объекта, привязанный к журналу операций да
category Типы журналов операций нет
detail Расширенные параметры для записи сведений об изменениях в журнале операций. нет
condition Условия регистрации нет

Для простоты у компонента есть только два обязательных параметра. Большая часть логики АОП в бизнесе реализована с использованием аннотации @Aspect, но совместимость АОП на основе аннотаций в Spring boot 1.5 проблематична.Чтобы быть совместимым с версией Spring boot 1.5, мы вручную реализуем логику АОП Spring.

выбор граниAbstractBeanFactoryPointcutAdvisorДля достижения pointcut проходит черезStaticMethodMatcherPointcutматч содержитLogRecordAnnotationМетод аннотации. путем реализацииMethodInterceptorВ интерфейсе реализована расширенная логика журнала операций.

Ниже приведена логика pointcut перехватчика:

public class LogRecordPointcut extends StaticMethodMatcherPointcut implements Serializable {
    // LogRecord的解析类
    private LogRecordOperationSource logRecordOperationSource;
    
    @Override
    public boolean matches(@NonNull Method method, @NonNull Class<?> targetClass) {
          // 解析 这个 method 上有没有 @LogRecordAnnotation 注解,有的话会解析出来注解上的各个参数
        return !CollectionUtils.isEmpty(logRecordOperationSource.computeLogRecordOperations(method, targetClass));
    }

    void setLogRecordOperationSource(LogRecordOperationSource logRecordOperationSource) {
        this.logRecordOperationSource = logRecordOperationSource;
    }
}

Основным кодексом логики улучшения аспекта является следующим:

@Override
public Object invoke(MethodInvocation invocation) throws Throwable {
    Method method = invocation.getMethod();
    // 记录日志
    return execute(invocation, invocation.getThis(), method, invocation.getArguments());
}

private Object execute(MethodInvocation invoker, Object target, Method method, Object[] args) throws Throwable {
    Class<?> targetClass = getTargetClass(target);
    Object ret = null;
    MethodExecuteResult methodExecuteResult = new MethodExecuteResult(true, null, "");
    LogRecordContext.putEmptySpan();
    Collection<LogRecordOps> operations = new ArrayList<>();
    Map<String, String> functionNameAndReturnMap = new HashMap<>();
    try {
        operations = logRecordOperationSource.computeLogRecordOperations(method, targetClass);
        List<String> spElTemplates = getBeforeExecuteFunctionTemplate(operations);
        //业务逻辑执行前的自定义函数解析
        functionNameAndReturnMap = processBeforeExecuteFunctionTemplate(spElTemplates, targetClass, method, args);
    } catch (Exception e) {
        log.error("log record parse before function exception", e);
    }
    try {
        ret = invoker.proceed();
    } catch (Exception e) {
        methodExecuteResult = new MethodExecuteResult(false, e, e.getMessage());
    }
    try {
        if (!CollectionUtils.isEmpty(operations)) {
            recordExecute(ret, method, args, operations, targetClass,
                    methodExecuteResult.isSuccess(), methodExecuteResult.getErrorMsg(), functionNameAndReturnMap);
        }
    } catch (Exception t) {
        //记录日志错误不要影响业务
        log.error("log record parse exception", t);
    } finally {
        LogRecordContext.clear();
    }
    if (methodExecuteResult.throwable != null) {
        throw methodExecuteResult.throwable;
    }
    return ret;
}

Поток логики перехвата:

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

4.2.2 Логика синтаксического анализа

Разбор шаблона

Spring 3 предоставляет очень мощную функцию: Spring EL, SpEL — это основной базовый модуль оценки выражений в продуктах Spring, и его можно использовать независимо от Spring. Например:

public static void main(String[] args) {
        SpelExpressionParser parser = new SpelExpressionParser();
        Expression expression = parser.parseExpression("#root.purchaseName");
        Order order = new Order();
        order.setPurchaseName("张三");
        System.out.println(expression.getValue(order));
}

Этот метод напечатает «Чжан Сан». Диаграмма классов, анализируемая LogRecord, выглядит следующим образом:

Разобрать основной класс:LogRecordValueParserОн инкапсулирует пользовательские функции и классы разбора SpEL.LogRecordExpressionEvaluator.

public class LogRecordExpressionEvaluator extends CachedExpressionEvaluator {

    private Map<ExpressionKey, Expression> expressionCache = new ConcurrentHashMap<>(64);

    private final Map<AnnotatedElementKey, Method> targetMethodCache = new ConcurrentHashMap<>(64);

    public String parseExpression(String conditionExpression, AnnotatedElementKey methodKey, EvaluationContext evalContext) {
        return getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);
    }
}

LogRecordExpressionEvaluatorунаследовано отCachedExpressionEvaluatorclass, в этом классе есть две карты, одна — expressionCache, а другая — targetMethodCache. Как вы можете видеть в приведенном выше примере, SpEL будет проанализирован в выражение выражения, а затем будет получено соответствующее значение в соответствии с входящим объектом, поэтому expressionCache должен кэшировать соответствующую связь между методами, выражениями и выражением SpEL, и пусть метод annotate Выражение SpEL, добавленное выше, анализируется только один раз. Приведенный ниже targetMethodCache предназначен для кэширования объекта, переданного в выражение Expression. Основная логика синтаксического анализа — последняя строка кода выше.

getExpression(this.expressionCache, methodKey, conditionExpression).getValue(evalContext, String.class);

getExpressionМетод получит экземпляр выражения синтаксического анализа выражения в аннотации @LogRecordAnnotation из expressionCache, а затем вызоветgetValueметод,getValueПередача evalContext аналогична передаче объекта заказа в приведенном выше примере. Реализация Context будет описана ниже.

реализация контекста журнала

В следующем примере переменные помещаются в LogRecordContext, а затем выражение SpEL может плавно анализировать параметры, которые не существуют в методе.Из приведенного выше примера SpEL мы видим, что параметры метода и переменные в LogRecordContext должны быть помещенным в SpEL'sgetValueТолько в Объекте метода можно плавно разобрать значение выражения. Вот как это сделать:

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
            bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

EvaluationContext создается в LogRecordValueParser для анализа параметров и переменных метода в Context для SpEL. Соответствующий код выглядит следующим образом:


EvaluationContext evaluationContext = expressionEvaluator.createEvaluationContext(method, args, targetClass, ret, errorMsg, beanFactory);

вызывается при разбореgetValueПараметр evalContext, передаваемый методом, является указанным выше объектом EvaluationContext. Ниже представлена ​​иерархия наследования объекта LogRecordEvaluationContext:

LogRecordEvaluationContext делает три вещи:

  • Поместите параметры метода в RootObject, проанализированный SpEL.
  • Поместите переменные в LogRecordContext в RootObject.
  • Поместите как возвращаемое значение метода, так и ErrorMsg в RootObject.

Код для LogRecordEvaluationContext выглядит следующим образом:

public class LogRecordEvaluationContext extends MethodBasedEvaluationContext {

    public LogRecordEvaluationContext(Object rootObject, Method method, Object[] arguments,
                                      ParameterNameDiscoverer parameterNameDiscoverer, Object ret, String errorMsg) {
       //把方法的参数都放到 SpEL 解析的 RootObject 中
       super(rootObject, method, arguments, parameterNameDiscoverer);
       //把 LogRecordContext 中的变量都放到 RootObject 中
        Map<String, Object> variables = LogRecordContext.getVariables();
        if (variables != null && variables.size() > 0) {
            for (Map.Entry<String, Object> entry : variables.entrySet()) {
                setVariable(entry.getKey(), entry.getValue());
            }
        }
        //把方法的返回值和 ErrorMsg 都放到 RootObject 中
        setVariable("_ret", ret);
        setVariable("_errorMsg", errorMsg);
    }
}

Ниже приведена реализация LogRecordContext. Этот класс поддерживает стек через переменную ThreadLocal. Внутри стека находится карта, которая соответствует имени переменной и значению переменной.

public class LogRecordContext {

    private static final InheritableThreadLocal<Stack<Map<String, Object>>> variableMapStack = new InheritableThreadLocal<>();
   //其他省略....
}

InheritableThreadLocal используется выше, поэтому при использовании LogRecordContext в сценарии пула потоков возникнут проблемы.Если пул потоков поддерживается, можно использовать инфраструктуру TTL с открытым исходным кодом Alibaba. Так почему бы не установить объект ThreadLocal> прямо здесь, а вместо этого установить структуру стека? Давайте посмотрим на причины этого.

@LogRecord(content = "修改了订单的配送员:从“{deveryUser{#oldDeliveryUserId}}”, 修改到“{deveryUser{#request.getUserId()}}”",
        bizNo="#request.getDeliveryOrderNo()")
public void modifyAddress(updateDeliveryRequest request){
    // 查询出原来的地址是什么
    LogRecordContext.putVariable("oldDeliveryUserId", DeliveryService.queryOldDeliveryUserId(request.getDeliveryOrderNo()));
    // 更新派送信息 电话,收件人、地址
    doUpdate(request);
}

Поток выполнения приведенного выше кода выглядит следующим образом:

Вроде бы нет проблем, но когда другой метод, использующий LogRecordAnnotation, вложен в метод, использующий LogRecordAnnotation, процесс принимает следующий вид:

Видно, что после того, как метод 2 выполняет переменную освобождения, он продолжает выполнять логику logRecord метода 1. В это время карта ThreadLocal> была освобождена во время синтаксического анализа, поэтому метод 1 не может получить соответствующую переменную. Метод 1 и метод 2 совместно используют переменную Map. Другая проблема заключается в том, что если метод 2 устанавливает ту же переменную, что и метод 1, переменные двух методов будут перезаписаны друг другом. Таким образом, жизненный цикл конечной переменной LogRecordContext должен иметь следующую форму:

Каждый раз, когда LogRecordContext выполняет метод, карта помещается в стек, а карта извлекается после выполнения метода, что позволяет избежать проблемы совместного использования и перезаписи переменных.

Логика оператора по умолчанию

Интерфейс IOperatorGetService в LogRecordInterceptor, этот интерфейс может получить текущего пользователя. Вот определение интерфейса:

public interface IOperatorGetService {

    /**
     * 可以在里面外部的获取当前登陆的用户,比如 UserContext.getCurrentUser()
     *
     * @return 转换成Operator返回
     */
    Operator getUser();
}

Ниже приведен пример получения пользователя из пользовательского контекста:

public class DefaultOperatorGetServiceImpl implements IOperatorGetService {

    @Override
    public Operator getUser() {
    //UserUtils 是获取用户上下文的方法
         return Optional.ofNullable(UserUtils.getUser())
                        .map(a -> new Operator(a.getName(), a.getLogin()))
                        .orElseThrow(()->new IllegalArgumentException("user is null"));
        
    }
}

Когда компонент анализирует оператор, он определяет, является ли оператор в аннотации пустым, если он не указан в аннотации, мы получаем его из метода getUser IOperatorGetService. Если его невозможно получить, будет сообщено об ошибке.

String realOperatorId = "";
if (StringUtils.isEmpty(operatorId)) {
    if (operatorGetService.getUser() == null || StringUtils.isEmpty(operatorGetService.getUser().getOperatorId())) {
        throw new IllegalArgumentException("user is null");
    }
    realOperatorId = operatorGetService.getUser().getOperatorId();
} else {
    spElTemplates = Lists.newArrayList(bizKey, bizNo, action, operatorId, detail);
}

логика пользовательской функции

Диаграмма классов пользовательской функции выглядит следующим образом:

Ниже приведено определение интерфейса IParseFunction:executeBeforeФункция показывает, анализируется ли пользовательская функция перед выполнением бизнес-кода и изменяется ли содержимое перед упомянутым выше запросом.

public interface IParseFunction {

  default boolean executeBefore(){
    return false;
  }

  String functionName();

  String apply(String value);
}

Код ParseFunctionFactory относительно прост, его функция заключается в внедрении всех IParseFunctions в фабрику функций.

public class ParseFunctionFactory {
  private Map<String, IParseFunction> allFunctionMap;

  public ParseFunctionFactory(List<IParseFunction> parseFunctions) {
    if (CollectionUtils.isEmpty(parseFunctions)) {
      return;
    }
    allFunctionMap = new HashMap<>();
    for (IParseFunction parseFunction : parseFunctions) {
      if (StringUtils.isEmpty(parseFunction.functionName())) {
        continue;
      }
      allFunctionMap.put(parseFunction.functionName(), parseFunction);
    }
  }

  public IParseFunction getFunction(String functionName) {
    return allFunctionMap.get(functionName);
  }

  public boolean isBeforeFunction(String functionName) {
    return allFunctionMap.get(functionName) != null && allFunctionMap.get(functionName).executeBefore();
  }
}

Логика DefaultFunctionServiceImpl заключается в том, чтобы найти соответствующую IParseFunction в соответствии с именем входящей функции functionName, а затем передать параметры в IParseFunction.applyМетод возвращает последнее значение функции.

public class DefaultFunctionServiceImpl implements IFunctionService {

  private final ParseFunctionFactory parseFunctionFactory;

  public DefaultFunctionServiceImpl(ParseFunctionFactory parseFunctionFactory) {
    this.parseFunctionFactory = parseFunctionFactory;
  }

  @Override
  public String apply(String functionName, String value) {
    IParseFunction function = parseFunctionFactory.getFunction(functionName);
    if (function == null) {
      return value;
    }
    return function.apply(value);
  }

  @Override
  public boolean beforeFunction(String functionName) {
    return parseFunctionFactory.isBeforeFunction(functionName);
  }
}

4.2.3 Логика сохранения журнала

ILogRecordService также упоминается в коде LogRecordInterceptor, который в основном содержит интерфейс ведения журнала.

public interface ILogRecordService {
    /**
     * 保存 log
     *
     * @param logRecord 日志实体
     */
    void record(LogRecord logRecord);

}

Предприятия могут внедрить этот интерфейс сохранения и сохранять журналы на любом носителе. Вот пример сохранения в файлах журналов через log.info, представленный в Разделе 2.2.Компания может настроить сохранение как асинхронное или синхронное и может быть помещена в транзакцию с бизнесом, чтобы обеспечить согласованность журнала операций и бизнеса. или новый Откройте транзакцию, чтобы убедиться, что ошибки журнала не влияют на бизнес-транзакции. Компании могут храниться в Elasticsearch, базах данных или файлах, а пользователи могут реализовывать соответствующую логику запросов на основе структуры журнала и хранилища журналов.

@Slf4j
public class DefaultLogRecordServiceImpl implements ILogRecordService {

    @Override
//    @Transactional(propagation = Propagation.REQUIRES_NEW)
    public void record(LogRecord logRecord) {
        log.info("【logRecord】log={}", logRecord);
    }
}

4.2.4 Инкапсуляция логики пускателя

Вышеприведенный логический код был введен, затем эти компоненты должны быть собраны, а затем использованы пользователями. При использовании этого компонента вам нужно только добавить аннотацию @EnableLogRecord(tenant = "com.mzt.test") к записи Springboot. где арендатор представляет арендатора и используется для мультитенантности.

@SpringBootApplication(exclude = DataSourceAutoConfiguration.class)
@EnableTransactionManagement
@EnableLogRecord(tenant = "com.mzt.test")
public class Main {

    public static void main(String[] args) {
        SpringApplication.run(Main.class, args);
    }
}

Посмотрите еще раз на код EnableLogRecord, Импорт находится в кодеLogRecordConfigureSelector.class,существуетLogRecordConfigureSelectorкласс выставленLogRecordProxyAutoConfigurationсвоего рода.

@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Documented
@Import(LogRecordConfigureSelector.class)
public @interface EnableLogRecord {

    String tenant();
    
    AdviceMode mode() default AdviceMode.PROXY;
}

LogRecordProxyAutoConfigurationЭто основной класс для сборки вышеуказанных компонентов.Код выглядит следующим образом:

@Configuration
@Slf4j
public class LogRecordProxyAutoConfiguration implements ImportAware {

  private AnnotationAttributes enableLogRecord;


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordOperationSource logRecordOperationSource() {
    return new LogRecordOperationSource();
  }

  @Bean
  @ConditionalOnMissingBean(IFunctionService.class)
  public IFunctionService functionService(ParseFunctionFactory parseFunctionFactory) {
    return new DefaultFunctionServiceImpl(parseFunctionFactory);
  }

  @Bean
  public ParseFunctionFactory parseFunctionFactory(@Autowired List<IParseFunction> parseFunctions) {
    return new ParseFunctionFactory(parseFunctions);
  }

  @Bean
  @ConditionalOnMissingBean(IParseFunction.class)
  public DefaultParseFunction parseFunction() {
    return new DefaultParseFunction();
  }


  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public BeanFactoryLogRecordAdvisor logRecordAdvisor(IFunctionService functionService) {
    BeanFactoryLogRecordAdvisor advisor =
            new BeanFactoryLogRecordAdvisor();
    advisor.setLogRecordOperationSource(logRecordOperationSource());
    advisor.setAdvice(logRecordInterceptor(functionService));
    return advisor;
  }

  @Bean
  @Role(BeanDefinition.ROLE_INFRASTRUCTURE)
  public LogRecordInterceptor logRecordInterceptor(IFunctionService functionService) {
    LogRecordInterceptor interceptor = new LogRecordInterceptor();
    interceptor.setLogRecordOperationSource(logRecordOperationSource());
    interceptor.setTenant(enableLogRecord.getString("tenant"));
    interceptor.setFunctionService(functionService);
    return interceptor;
  }

  @Bean
  @ConditionalOnMissingBean(IOperatorGetService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public IOperatorGetService operatorGetService() {
    return new DefaultOperatorGetServiceImpl();
  }

  @Bean
  @ConditionalOnMissingBean(ILogRecordService.class)
  @Role(BeanDefinition.ROLE_APPLICATION)
  public ILogRecordService recordService() {
    return new DefaultLogRecordServiceImpl();
  }

  @Override
  public void setImportMetadata(AnnotationMetadata importMetadata) {
    this.enableLogRecord = AnnotationAttributes.fromMap(
            importMetadata.getAnnotationAttributes(EnableLogRecord.class.getName(), false));
    if (this.enableLogRecord == null) {
      log.info("@EnableCaching is not present on importing class");
    }
  }
}

Этот класс наследует ImportAware, чтобы получить свойства арендатора в EnableLogRecord.Этот класс использует переменные logRecordAdvisor и logRecordInterceptor для сборки AOP и внедряет пользовательские функции в logRecordAdvisor.

Внешний класс расширения: соответственноIOperatorGetService,ILogRecordService,IParseFunction. Бизнес может реализовать соответствующий интерфейс самостоятельно.Поскольку @ConditionalOnMissingBean настроен, класс реализации пользователя переопределит реализацию по умолчанию в компоненте.

5. Резюме

В этой статье рассказывается об общем методе записи журнала операций и о том, как сделать реализацию журнала операций проще и понятнее; через четыре модуля компонента представлена ​​конкретная реализация компонента. Для введения вышеуказанного компонента, если у вас есть какие-либо вопросы, оставьте сообщение в конце статьи, и мы ответим на ваши вопросы.

6. Об авторе

Чжантун присоединился к Meituan в 2020 году в качестве инженера базовой платформы исследований и разработок/отдела качества и эффективности исследований и разработок.

7. Ссылки

8. Информация о наборе

Отдел качества и эффективности исследований и разработок Meituan стремится создать первоклассную платформу непрерывной доставки в отрасли.В настоящее время он набирает инженеров, связанных с направлением основных компонентов, расположенных в Пекине/Шанхае. Заинтересованные студенты могут присоединиться. Отправьте свое резюме по адресу:chao.yu@meituan.com(Пожалуйста, укажите тему письма: Отдел исследований и разработок Meituan, отдел качества и эффективности).

Прочтите другие подборки технических статей от технической команды Meituan

внешний интерфейс | алгоритм | задняя часть | данные | Безопасность | Эксплуатация и техническое обслуживание | iOS | Android | контрольная работа

|Ответьте на ключевые слова, такие как [акции 2020 г.], [акции 2019 г.], [акции 2018 г.], [акции 2017 г.] в диалоговом окне строки меню общедоступной учетной записи, и вы сможете просмотреть коллекцию технических статей технической группы Meituan в течение годы.

| Эта статья подготовлена ​​технической командой Meituan, авторские права принадлежат Meituan. Добро пожаловать на перепечатку или использование содержимого этой статьи в некоммерческих целях, таких как обмен и общение, пожалуйста, укажите «Содержимое воспроизводится технической командой Meituan». Эта статья не может быть воспроизведена или использована в коммерческих целях без разрешения. Для любой коммерческой деятельности, пожалуйста, отправьте электронное письмо по адресуtech@meituan.comПодать заявку на авторизацию.