1 业务日志的标准
业务日志,也叫操作日志。主要功能: 记录用户行为,方便业务数据回溯与统计。
1.1 操作日志记录内容
操作日志记录主要内容:用户是谁,在什么时间,对什么数据,做了什么样的更改。
逻辑中必须增加业务日志的位置:
(1)业务数据的变更处(新增、修改、删除)
(2)特别分支条件、边界条件处。
1.2 业务常见的日志记录形式
● 动态的文本记录,比如:2022-03-12 10:00 订单创建,创建用户“小新”,订单号:NO.123456 ”。
● 修改类型的文本,包含修改前和修改后的值,比如:2021-03-12 11:00 用户“小新”修改了订单收货人:“小新”修改成“小花” 。
public Result applyOrder(orderRequest request){
// 业务逻辑blabla...
OperateLogModel operateLogModel = new OperateLogModel();
operateLogModel.setOperateIp(); //1、业务操作IP地址
operateLogModel.serO[erateMis(); //2、操作人mis
operateLogModel.setOperateDegist(); //3、业务操作具体描述
operateLogModel.setUserOperateType(); //4、权限操作类型; 0:增加 1:删除 2:修改
operateLogModel.setCreateTime(); //5、操作日志生成时间
}
2 背景知识
AOP(Aspect-Oriented Programming)中文意思是面向切面编程。通过运行期的动态代理,实现在不修改源代码的情况下给程序动态添加功能的编程范式。可以对业务逻辑的各个模块进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。
2.1 AOP实现的四个关键元素
切面(Aspect):用于封装通用部分的组件(或者模块),比如日志组件。让日志模块可以被切入到其他目标业务方法上。
连接点(JointPoint):程序执行的某个特定位置,是抽象概念,不涉及代码实现。(Spring仅支持方法的连接点,即仅能在方法调用前、方法调用后、方法抛出异常时以及方法调用前后这些程序执行点织入增强)
切入点(PointCut):用于指定哪些组件方法调用方面(共通)处理, 切点相当于查询条件,连接点相当于记录,一个切点可以匹配多个连接点
通知(Advice):常被称为“增强”,满足切入点的一段执行代码。Spring的AOP,会将 advice 模拟为一个拦截器(interceptor),并且在 join point 上维护多个 advice,进行层层拦截。
前置通知(Before):在目标方法调用前调用通知功能;
后置通知(After):在目标方法调用之后调用通知功能,不关心方法的返回结果;
返回通知(AfterReturning):在目标方法成功执行之后调用通知功能;
异常通知(AfterThrowing):在目标方法抛出异常后调用通知功能;
环绕通知(Around):通知方法会将目标方法封装起来,在目标方法调用之前和之后执行自定义的行为。
2.2 切面业务日志的核心实现流程
- 建立日志拦截器,自定义模板
- 创建日志处理切面
- 在业务接口(controller)方法上增加日志注解。
3 切面日志的实现
3.1 创建日志拦截器
修饰符 @interface 注解名 {
属性类型 属性名() default 默认值;
}
在实际工程中,首先建立以下日志注解接口,作为业务日志拦截器。
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
public @interface LogApi {
/** * 用户操作类型 * @return */
String operateType();
}
3.2 统一日志处理切面
从切面的建设来说,通常可能覆盖: 1. 业务服务层 2. 数据持久层 3. 中间件访问层 4. 远程rpc/http服务层 5. 工具层
其中,1是建议建设的一层,业务服务是所有出入口都经过的一层,通常即@Service 2,3,4其实都可以认为是第三方依赖层,建设这一层日志有助于更细级别的追踪,因为通常业务服务可能会组合多个操作 5层暂定是一些工具类、转换类等,需要有统一的特征(可以约定)来通过切面拦截
@Slf4j
@Aspect
@Component
public class LogAspect {
/** * 定义切面 */
@Pointcut("@annotation(com.gitee.theskyone.bird.LogAspect.LogApi)")
public void logPointcut() {
throw new UnsupportedOperationException();
}
@Around("logPointcut()")
public Object handleAround(ProceedingJoinPoint proceedingJoinPoint) throws Throwable {
long start = System.currentTimeMillis();
//获取当前请求对象,记录请求信息
Object[] args = proceedingJoinPoint.getArgs();
Object result = proceedingJoinPoint.proceed();
//获取方法连接点的相关信息
MethodSignature methodSignature = (MethodSignature)proceedingJoinPoint.getSignature();
String methodName = methodSignature.getName();
Method method = methodSignature.getMethod();
LogApi logApi = method.getAnnotation(LogApi.class);
log.info("用户操作类型: {}", logApi.operateType());
log.info("请求方法 : 【{}】", methodName );
log.info("请求参数 : {}", args);
log.info("返回结果 : {}", Objects.isNull(result) ? "" : result);
log.info("方法执行总耗时 : {} ms", System.currentTimeMillis() - start);
return result;
}
/** * 日志记录(实际工程中可改成异步日志持久化) * @param joinPoint * @param userName * @return */
ServiceLog saveLog(ProceedingJoinPoint joinPoint, String userName) {
return ServiceLog.builder()
.traceId(UUID.randomUUID().toString())
.createTime(LocalDateTime.now())
// 暂只支持类名.方法名方式
.operation(joinPoint.getSignature().toShortString())
.operator(userName)
.build();
}
}
3.3 日志注解使用于业务
在项目中做自定义日志切面,业务中以注解方式记录。
(1)在进入 Controller 方法前,打印出请求参数、调用的方法名。
(2) 在方法逻辑执行后,打印出结果以及耗时。
@RestController
@RequestMapping("/log")
public class LogController extends HandlerInterceptorAdapter {
//LoggerFactory是slf4j的日志对象工程
private Logger logger = LoggerFactory.getLogger(this.getClass());
@LogApi(operateType = "create")
@RequestMapping(value = "/userOperation", method = RequestMethod.GET)
public List<String> getUserInfo(@RequestParam(value = "userName") String userName,
@RequestParam(value = "orderNumber") String orderNumber) {
//建立虚拟返回参数
List<String> resultList = new ArrayList<>();
resultList.add("原订单收货人:newBird");
resultList.add("新订单收货人:flyBird");
return resultList;
}
}
补充代码逻辑避坑
(1)Json记录业务日志耗能
json编码对CPU损耗非常大,如果只是日志记录,别用这么重的编码形式,精简日志或者简单字符串拼接会更经济。
(2)代码中的shopInfoVO若为NULL,会引入了异常导致影响业务主流程
catch(Exception e){ **加粗样式**
logger.error("[getShopInfoVo] error shopInfoVO={}",shopInfoVO,e);
}