分享一个通用的 Spring AOP 日志切面组件

119 阅读3分钟

背景

看到有些框架在实现日志切面时,直接编写一个切面类,在 @Aspect 注解指定好 package 路径,然后通过 @EnableAspectJAutoProxy 开启 AOP。业务代码引入这个框架,必须按照框架约定好的 package 路径匹配,才能生效。这样的框架对业务代码有较大的侵入性,不适合所有场景。

目标

实现一个可配置的日志切面路径组件,对业务代码零侵入性。

实现

实现切面路径自定义的方式有两种:Annotation 注解和 AutoConfiguration 自动装配,前者对代码有较小的侵入性。先说说 Annotation 注解怎么实现。

以日志切面为例,先设计配置属性类 AccessLogConfig

@EqualsAndHashCode
@ToString
@Setter
@Getter
public class AccessLogConfig {

    /* 是否保存到 MDC */
    private boolean enabledMdc = true;

    /* 需要输出日志的包名 */
    private String expression;

    /* 日志输出采样率 */
    private double sampleRate = 1.0;

    /* 是否输出入参 */
    private boolean logArguments = true;

    /* 是否输出返回值 */
    private boolean logReturnValue = true;

    /* 是否输出方法执行耗时 */
    private boolean logExecutionTime = true;

    /* 最大长度 */
    private int maxLength = 500;

    /* 慢日志 */
    private long slowThreshold = 1000;
}

定义一个注解 @EnableAccessLog

@Import(AccessLogImportSelector.class)
@Documented
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.TYPE})
public @interface EnableAccessLog {

    /** 是否开启 CGLIB 代理 */
    boolean proxyTargetClass() default false;

    /** PointcutAdvisor 代理模式 */
    AdviceMode mode() default AdviceMode.PROXY;

    /** PointcutAdvisor 优先级 */
    int order() default Ordered.LOWEST_PRECEDENCE;

    /** AspectJ 切面表达式 */
    String expression() default "";
}

编写 AccessLogConfiguration 配置类。

@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Slf4j
@Configuration(proxyBeanMethods = false)
public class AccessLogConfiguration implements ImportAware {

    private AnnotationAttributes annotation;

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

    // 关键代码实现
    @Bean
    public AccessLogAdvisor accessLogAdvisor(ObjectProvider<AccessLogConfig> configs, AccessLogInterceptor interceptor) {
        AccessLogAdvisor advisor = new AccessLogAdvisor();
        String expression = getAccessLogConfig(configs).getExpression();
        advisor.setExpression(expression); // 指定您要匹配的包名
        advisor.setAdvice(interceptor); // 指定您的拦截器
        if (annotation != null) {
            advisor.setOrder(annotation.getNumber("order")); // 指定拦截次序
        }
        return advisor;
    }

    @Bean
    public AccessLogInterceptor accessLogInterceptor(ObjectProvider<AccessLogConfig> configs) {
        return new AccessLogInterceptor(getAccessLogConfig(configs));
    }

    private AccessLogConfig getAccessLogConfig(ObjectProvider<AccessLogConfig> accessLogConfigs) {
        return accessLogConfigs.getIfUnique(() -> {
            AccessLogConfig config = new AccessLogConfig();
            config.setExpression(annotation.getString("expression"));
            return config;
        });
    }
}

对应的 AccessLogInterceptor 拦截器代码片段如下。

@RequiredArgsConstructor
@Slf4j
public class AccessLogInterceptor implements MethodInterceptor {

    private final AccessLogConfig config;

    @Override
    public Object invoke(@NotNull MethodInvocation invocation) throws Throwable {
        // 排除代理类
        if (AopUtils.isAopProxy(invocation.getThis())) {
            return invocation.proceed();
        }

        // 判断是否需要输出日志
        if (!AccessLogHelper.shouldLog(config.getSampleRate())) {
            return invocation.proceed();
        }

        Instant start = Instant.now();
        Object result = null;
        Throwable throwable = null;
        try {
            result = invocation.proceed();
            return result;
        } catch (Throwable t) {
            throwable = t;
            throw t;
        } finally {
            long duration = Duration.between(start, Instant.now()).toMillis();
            AccessLogHelper.log(invocation, result, throwable, duration,
                config.isEnabledMdc(), config.getMaxLength(), config.getSlowThreshold());
        }
    }
}

使用 @Import(AccessLogImportSelector.class) 导入配置类 AccessLogConfiguration,代码如下。

public class AccessLogImportSelector extends AdviceModeImportSelector<EnableAccessLog> {

    @Override
    protected String[] selectImports(AdviceMode adviceMode) {
        switch (adviceMode) {
            case PROXY:
                return new String[]{
                    AutoProxyRegistrar.class.getName(),
                    AccessLogConfiguration.class.getName()
                };
            case ASPECTJ:
                return new String[]{
                    AccessLogConfiguration.class.getName()
            };
        }
        return new String[0];
    }
}

当业务代码使用 @EnableAccessLog(expression="您的包名"),底层通过 @Import(AccessLogImportSelector.class) 调用 AccessLogConfiguration 配置类,完成自动配置。

但使用 @EnableAccessLog 注解对代码还是有一定的侵入性,最好的办法就是彻底改为 AutoConfiguration,优化如下。

@ConditionalOnProperty(prefix = "logging.access", name = "enabled", havingValue = "true")
@EnableConfigurationProperties(AccessLogProperties.class)
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Slf4j
@Configuration(proxyBeanMethods = false)
@EnableAccessLog
public class AccessLogAutoConfiguration {

}

@EqualsAndHashCode(callSuper = true)
@ToString
@Setter
@Getter
@ConfigurationProperties(prefix = "logging.access")
public class AccessLogProperties extends AccessLogConfig {

	/* 是否开启日志切面 */
	private boolean enabled = false;
}

当业务代码配置如下内容,就会自动开启日志切面,不需要在代码里面编写 @EnableAccessLog 注解。

logging:
  access:
    enabled: true
    expression: within(org.ylzl.eden.demo.adapter.*.web..*)

根据上述配置的切面路径,测试下 HTTP 请求,可以从控制台日志看到切面打印了接口请求的入参、返回值、耗时。

产出

为研发团队提供统一的日志打印模板,便于后期统一维护日志内容的格式解析工作,在引入这个日志切面组件后,研发团队只需要微调自己的项目 package 就完成了集成工作,对业务代码没有任何侵入性。

本文涉及的代码完全开源,感兴趣的伙伴可以查阅 eden-spring-framework 自定义注解实现和 eden-spring-boot 自动装配实现。