最近因为工作上接到一个任务,需要为不同的基于springboot实现的业务组件,提供一个通用的日志处理能力。这个其实实现起来并不难。6年前也写相关文档及实现《AOP实现后台管理系统日志管理》,一种方式直接把该方案根据任务需要,改造成spring-boot-starter,半天一天就搞定。另一种方式,看下这么多年有没有比较好的通用开源实现,直接拿来使用,能用轮子用轮子。
但是github上逛了一圈, 感觉都不太能满足需求, 和之前实现的差异不大。贴一些star比较高的项目
如果一个新项目, 只是实现操作日志,上面两款应该可以使用,活跃度还不错,当然也可以自己实现。但是这次接到任务是为所有业务组件提供日志能力。所有业务组件, 意味着有老项目也有新项目,新项目好说,老项目涉及到改造,甚至已经集成了自己aop日志实现, 换一款实现,有什么价值吗?即便是统一规范,在业务组件研发同学看来,使用好好的东西,是不是折腾呢。
所以我们思考一个问题,使用aop切面可以运用场景不少,比如spring提供的缓存切面,事务切面。为什么这些,大家没有想着自己设计aop实现缓存, aop实现事务。因为人家spring封装已经非常好用了,引入依赖就好,大部分的使用场景都覆盖了。日志切面业务性质太强,而spring是一个通用技术能力提供者,肯定没法提供。所以大家只能八仙过海各显神通了。 难道真的没法抽象出一个较为通用的日志框架吗, 还是要回到日志的概念去。
不同的日志提法只是在不同使用场景下细分,因为服务的对象身份不同,对于数据处理会有不同的侧重点。 但从技术实现角度看,都是可以通过spring aop技术实现。
日志描述 | 关注点 |
---|---|
系统日志 | 异步/超时/请求参数/请求方法, 关注日常问题的排查 |
操作日志 | 谁什么时间做了什么事, 必须有较好的可读性 |
审计日志 | 谁什么时间做了什么事,产生了什么结果, 是否合理,甚至能否恢复 |
既然不同日志的提法,只是数据侧重点和使用者关注点不同。一定可以抽象出一个模型来适配这些东西. 至少降低不同项目每次重复实现的成本。
那么既然要抽象出一个模型,或者是设计出一个框架。首先要定义一个蓝图,它不是具体实现的功能, 而是贯穿整个模型设计的理念,这样才能保证我们最后做出来的东西。不是另一个"普普通通的轮子"。
以下两条我笔者思考认为在设计这个框架需要追求的目标
1.约定规范比功能实现更重要
2.少就是多: 日志注解是必须的吗?
约定规范比功能实现更重要, 这句话转化成更好理解的表述,就是我们常说的“好的接口设计比实现功能更重要”。先来举一个反例来说明什么是功能实现, 我们就拿日志记录为例。
aop实现日志,常用思路就是注解加切面,首先定义一个注解
@Target({ ElementType.PARAMETER, ElementType.METHOD })
@Retention(RetentionPolicy.RUNTIME)
public @interface SystemControllerLog {
/** 描述业务操作 例:Xxx管理-执行Xxx操作*/
String description() default "";
}
然后定义一个切面实现日志记录逻辑
/**
* 系统日志切面类
*/
@Aspect
@Component
public class SystemLogAspect {
private static final Logger logger = LoggerFactory.getLogger(SystemLogAspect. class);
/**
* Controller层切点 注解拦截
*/
@Pointcut("@annotation(com.xx.SystemControllerLog)")
public void controllerAspect(){}
/**
* 前置通知 用于拦截Controller层记录用户的操作的开始时间
*/
@Before("controllerAspect()")
public void doBefore(JoinPoint joinPoint) throws InterruptedException{
// 模拟日志记录前处理
}
/**
* 后置通知 用于拦截Controller层记录用户的操作
*/
@SuppressWarnings("unchecked")
@After("controllerAspect()")
public void doAfter(JoinPoint joinPoint) {
// 模拟日志记录前处理
}
}
日志注解结合spring提供的@Aspect切面处理。我们实现了最小日志记录能力, 将业务日志记录与业务执行分离开来。看似完成一个"框架"能力, 但是本质上还是一个日志实现。无论我们在SystemControllerLog 补充多少自定义字段和额外功能,依然是绑定一个指定的注解及对应切面实现的"通用小功能",不能称之为"框架"。
那么如何解耦让它看起来是要给框架呢. 我们得忘记功能, 先从约定规范开始. 回想下,我们为什么会定义日志注解,除了有标记作用, 剩下注解里得属性定义就是最重要的,就是注解里的属性,本质其实就是一种元数据, 用来描述日志信息的属性。因此我们要先约定日志属性,把它设计成接口。
用一个接口来约定日志拥有的元数据, 暂时先不用特别关心里面定义元数据,合理不合理。重要的是有这个属性接口,约定每个日志记录点或者是标记一定要有一些框架需要的基础信息。
public interface LogAttribute {
/** 日志标题*/
String title();
/** 日志处理器*/
String handler();
/** 日志spel模板*/
String template();
/** 日志操作人*/
String operator();
/** 是否异步处理日志*/
boolean async();
/** 日志标签用于使用着扩展属性*/
Map<String, String> tags();
/**是否活跃:非活跃的忽略增强处理*/
Boolean active();
}
定义的日志记录点的属性后, 还需要定义这个日志元数据是那里来的, 谁能够提供这些元数据,我们认为它是一个记录点。因此我们需要定义一个日志属性源,来获取日志属性。
日志属性来源接口的入参,为了保证尽可能满足用户多种方式提取日志属性,使用了method 和targetClass。
public interface LogAttributeSource {
LogAttribute getLogAttribute(Method method, Class<?> targetClass);
}
以前单一日志注解本质上是从手动标记方法提取日志属性. 但是实际上获取日志属性渠道其实远远不止于此。这里列以下可能性
这些组合使用以前面向固定的功能实现的方式是很难实现的。这个就是接口抽象带来的好处
能够提供日志属性的方法即为日志增强的点
日志属性源切入点依然还是定义成抽象类,继承AOP的静态方法匹配的StaticMethodMatcherPointcut,是否匹配进行aop日志增强处理, 换成成通过能否获取日志属性去判断。
public abstract class LogAttributeSourcePointcut extends StaticMethodMatcherPointcut implements Serializable {
/**
* 是否作为切点的判断--通过被增强的方法和目标类是否能够提取出日志属性。
* 将是否能够提取出日志属性能力通过接口暴露出去
* @param method
* @param targetClass
* @return
*/
@Override
public boolean matches(Method method, Class<?> targetClass) {
LogAttributeSource source = getLogAttributeSource();
return (source == null || source.getLogAttribute(method, targetClass) != null);
}
@Nullable
protected abstract LogAttributeSource getLogAttributeSource();
}
通过这样的转换, 能够触发aop增强的方式就有很强的适配性, 依赖抽象而非具体
有了切入点就可以组合成Advisor执行通知者,选择AbstractBeanFactoryPointcutAdvisor作为父类,可以提供在运行时选择不同的bean作为内部核心的扩展点。
public class BeanFactoryLogAttributeSourceAdvisor extends AbstractBeanFactoryPointcutAdvisor {
@Nullable
private LogAttributeSource logAttributeSource;
private final LogAttributeSourcePointcut logAttributeSourcePointcut = new LogAttributeSourcePointcut() {
@Override
protected LogAttributeSource getLogAttributeSource() {
return logAttributeSource;
}
};
@Override
public Pointcut getPointcut() {
return this.logAttributeSourcePointcut;
}
public void setLogAttributeSource(LogAttributeSource logAttributeSource) {
this.logAttributeSource = logAttributeSource;
}
}
advisor 需要配置一个MethodInterceptor 方法拦截器(本质是一个advice,内部逻辑不是本文重点),去完成实际的增强能力. 传入一个属性源作为是否触发"增强的判断"。
@Configuration
@EnableConfigurationProperties(EasyLogProperties.class)
public class EasyLogConfiguration {
@Bean
public Advisor easyLogStaticMethodMatcherPointcutAdvisor(LogAttributeSource logAttributeSource, LogMethodInterceptor logMethodInterceptor, EasyLogProperties easyLogProperties) {
//Advisor 是 Spring AOP 对 Advice 和 Pointcut 的抽象,可以理解为“执行通知者”,一个 Pointcut (一般对应方法)和用于“增强”它的 Advice 共同组成这个方法的一个 Advisor
BeanFactoryLogAttributeSourceAdvisor advisor = new BeanFactoryLogAttributeSourceAdvisor();
advisor.setLogAttributeSource(logAttributeSource);
advisor.setAdvice(logMethodInterceptor);
//设置自定义优先级值越小优先级越高
if (easyLogProperties.getAspectOrder() != null) {
advisor.setOrder(easyLogProperties.getAspectOrder());
}
return advisor;
}
@Bean
public LogMethodInterceptor easyLogMethodInterceptor(LogAttributeSource logAttributeSource,
@Qualifier("easyLogThreadPoolTaskExecutor") ThreadPoolTaskExecutor taskExecutor,
@Qualifier("easyLogDataHandler") LogDataHandler logDataHandler,
ObjectProvider<OperatorProvider> operatorProvider) {
// 逻辑省略
return logMethodInterceptor;
}
}
到这里我们演示通过设计一套规范约定(接口/抽象类),完成aop日志框架的核心封装。 接下来说明第二个原则
大部分aop日志,都会在自己项目中定义一个日志注解来标记是否开启日志记录。首先这么设计并没有问题,但是我觉得一个好的日志框架这么设计远远是不够的。设想下以下几个需求
上面的几个需求都指向我们不能只捆绑一个具体的日志注解。因此我们在默认日志属性源补充了适配器能力
public class AnnotationLogAttributeSource extends AbstractCacheLogAttributeSource {
private final List<LogAttributeMappingAdapter> mappingAdapters;
public AnnotationLogAttributeSource(LogAttributeCache logAttributeCache, EasyLogProperties easyLogProperties, List<LogAttributeMappingAdapter> mappingAdapters) {
super(logAttributeCache, easyLogProperties);
this.mappingAdapters = mappingAdapters;
}
@Override
public LogAttribute doGetLogAttribute(Method method, Class<?> targetClass) {
EasyLog easyLog = method.getAnnotation(EasyLog.class);
// EasyLog注解优先级最高
if (easyLog != null) {
String title = StringUtils.isEmpty(easyLog.title()) ? easyLog.value() : easyLog.title();
// 如果都没有定义标题使用默认标题
if (StringUtils.isEmpty(title)) {
title = LogUtils.createDefaultTitle(method, targetClass);
}
return DefaultLogAttribute.builder()
.title(title)
.handler(easyLog.handler())
.template(easyLog.template())
.operator(easyLog.operator())
.async(async)
.tags(tagMap)
.build();
}
// 从映射适配器中获取自定义的日志属性
if (CollectionUtils.isEmpty(mappingAdapters)) {
return null;
}
for (LogAttributeMappingAdapter mappingAdapter : mappingAdapters) {
LogAttribute logAttribute = mappingAdapter.getLogAttribute(method, targetClass);
if (logAttribute != null) {
return logAttribute;
}
}
return null;
}
}
通过日志属性适配器 LogAttributeMappingAdapter 我们可以处理日志注解之外的日志增强需求。下面具体例子
controller类及方法其实本身就带着各自的注解,利用这些特征isControllerPublicMethod很容易提取出日志属性源
public class ControllerLogAttributeMapping implements LogAttributeMappingAdapter {
private static final Logger LOGGER = LoggerFactory.getLogger(ControllerLogAttributeMapping.class);
private final EasyLogProperties easyLogProperties;
public ControllerLogAttributeMapping(EasyLogProperties easyLogProperties) {
LOGGER.info("[easy-log]启动controller bean日志增强");
this.easyLogProperties = easyLogProperties;
}
@Override
public LogAttribute getLogAttribute(Method method, Class<?> targetClass) {
if (easyLogProperties.getScanController().getEnabled() && isControllerPublicMethod(method, targetClass)) {
String title = LogUtils.createDefaultTitle(method, targetClass);
return DefaultLogAttribute.builder()
.title(title)
.async(easyLogProperties.getAsync())
.build();
}
return null;
}
private boolean isControllerPublicMethod(Method method, Class<?> targetClass) {
return (targetClass.getAnnotation(Controller.class) != null && (method.getAnnotation(ResponseBody.class) != null || method.getReturnType() == ResponseEntity.class))
||
(targetClass.getAnnotation(RestController.class) != null && !Modifier.isStatic(method.getModifiers())
&& Modifier.isPublic(method.getModifiers()));
}
}
/**
* 从service bean日志属性提取
*/
public class ServiceLogAttributeMapping implements LogAttributeMappingAdapter{
private static final Logger LOGGER = LoggerFactory.getLogger(ServiceLogAttributeMapping.class);
private final EasyLogProperties easyLogProperties;
public ServiceLogAttributeMapping(EasyLogProperties easyLogProperties) {
LOGGER.info("[easy-log]启动service bean日志增强");
this.easyLogProperties = easyLogProperties;
}
@Override
public LogAttribute getLogAttribute(Method method, Class<?> targetClass) {
// 如果找不到EasyLog 检查是否开启server-debug模式
if (isServicePublicMethod(method, targetClass)) {
String title = LogUtils.createDefaultTitle(method, targetClass);
return DefaultLogAttribute.builder()
.title(title)
.async(easyLogProperties.getAsync())
.build();
}
return null;
}
private boolean isServicePublicMethod(Method method, Class<?> targetClass) {
// 如果方法来自于Object对象忽略处理
if (method.getDeclaringClass().equals(Object.class)) {
return false;
}
return easyLogProperties.getScanService().getEnabled() && targetClass.getAnnotation(Service.class) != null
&& !Modifier.isStatic(method.getModifiers())
&& Modifier.isPublic(method.getModifiers());
}
}
能够记录controller/service所有接口日志,大部分都是在开发测试阶段使用,因此这个功能应该给可以给使用者一个开关的选项
spring:
easy-log:
scan-service:
enabled: true #是否记录service中的公开方法 默认:false
scan-controller:
enabled: true #是否记录controller中的公开方法 默认:false
日志框架中通过补充适配器就实现无需日志注解实现日志记录的功能,且对业务代码完全没有入侵。同时还可以给框架或者框架接入者,扩展支持更多典型的场景。默认日志属性适配器无法满足时候, 直接定义自己的日志属性源。
如: 项目经常会使用swagger/knife4j来定义api的接口文档,已经定义的接口描述。@ApiOperation实际上也可以作为特殊"日志注解"
public class SwaggerLogAttributeSource extends AbstractCacheLogAttributeSource {
private static final Logger LOGGER = LoggerFactory.getLogger(SwaggerLogAttributeSource.class);
final private LogAttributeSource logAttributeSource;
public SwaggerLogAttributeSource(LogAttributeCache logAttributeCache, LogAttributeSource logAttributeSource, EasyLogProperties easyLogProperties) {
super(logAttributeCache, easyLogProperties);
LOGGER.info("[easy-log]启动Swagger日志增强");
this.logAttributeSource = logAttributeSource;
}
@Override
public LogAttribute doGetLogAttribute(Method method, Class<?> targetClass) {
ApiOperation apiOperation = method.getAnnotation(ApiOperation.class);
if (apiOperation != null && method.getAnnotation(EasyLog.class) == null) {
LogAttribute logAttribute = DefaultLogAttribute.builder()
.title(apiOperation.value())
.async(easyLogProperties.getAsync())
.build();
return logAttribute;
}
return logAttributeSource.getLogAttribute(method, targetClass);
}
}
这里特别注意,swagger日志属性源SwaggerLogAttributeSource内部还持有一个默认logAttributeSource,这里运用到了装饰器的设计模式, 实现了对既有功能的"修改关闭对扩展开放"。 这样在开启swagger增强处理后, 如果没有接口没有标记@ApiOperation,那么依然可以走默认日志属性源的逻辑。
## 全局异常处理依赖国际化处理
spring:
easy-log:
scan-swagger:
enabled: true #是否将swagger的@ApiOperation接口注解标识作为日志记录 默认:false
有些读者可能发现, 如果我把swagger的接口注解标记@ApiOperation, 不就可以替换项目中存在的日志注解如@LogRecord等, 也就实现了aop无缝切换了吗。没错,只要你愿意你可以继续套娃, 用自己的日志属性源来覆盖框架默认的配置。
@Configuration
@EnableEasyLog
@AutoConfigureAfter(RedisAutoConfiguration.class)
@ConditionalOnProperty(value = "spring.easy-log.enabled", havingValue = "true", matchIfMissing = true)
public class EasyLogAutoConfiguration {
@Configuration(proxyBeanMethods = false)
@ConditionalOnMissingBean(LogAttributeSource.class)
@Import({LogAttributeSourceConfiguration.SwaggerSource.class, LogAttributeSourceConfiguration.EasyLogSource.class})
protected static class ChooseLogAttributeSourceConfiguration {
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(value = "spring.easy-log.scan-service.enabled", havingValue = "true")
public ServiceLogAttributeMapping serviceLogAttributeMapping(EasyLogProperties easyLogProperties) {
return new ServiceLogAttributeMapping(easyLogProperties);
}
@Bean
@ConditionalOnMissingBean
@ConditionalOnProperty(value = "spring.easy-log.scan-controller.enabled", havingValue = "true")
public ControllerLogAttributeMapping controllerLogAttributeMapping(EasyLogProperties easyLogProperties) {
return new ControllerLogAttributeMapping(easyLogProperties);
}
}
}
本文通过结合项目日志记录需求及市面上日志组件分析, 分析总结了一些, 设计一个日志框架的理念及基本思路。也作为日志easy-log框架设计系列文章的开篇。文中设计到的源码,都可以从git开源项目中获取到.
easy-log使用文档: https://easycode8.github.io/easy-log
github地址: https://github.com/easycode8/easy-log