我做的项目中,日志分为两类:登录日志和操作日志
登录日志,顾名思义:记录登录登出的日志内容
操作日志:记录用户对数据的新增,更改,删除的日志记录
登录日志
在登录和登出的界面使用异步线程做日志记录。
操作日志
原理是用过Aop(Aspect Oriented Programming)面相切面编程,使用异步线程,打注解的方式进行操作记录的。
流程图
功能划分
**LogAspect:**日志切面注解逻辑类,该类是在对标记了@Log注解的controller控制层进行逻辑处理,包括对注解的解析,以及日志的插入。
**ThreadPoolConfig:**线程池配置类,日志操作本身为非业务代码,为了不影响性能,并且应该与任务线程可以并行执行的,需要开启异步线程执行。
**AsyncManager:**异步任务管理器,为工具类,用于指定线程池的使用,停止等操作。
**AsyncFactory:**异步任务工厂,就是对于异步任务进行抽象出方法,并使用工厂模式,方便使用。
日志类的设计
在涉及到业务时,需要设计日志对象,SysOperLog为日志类:
/**
* 操作日志记录表 oper_log
*
*/
@Data
@AllArgsConstructor
@NoArgsConstructor
@TableName(TablePool.SYS_OPER_LOG)
public class SysOperLog implements Serializable {
private static final long serialVersionUID = 1L;
/**
* 日志主键
*/
@TableId(value = "oper_id", type = IdType.AUTO)
private Long operId;
/**
* 操作模块
*/
@TableField(value = "title")
private String title;
/**
* 业务类型(0其它 1新增 2修改 3删除)
*/
@TableField(value = "business_type")
private Integer businessType;
/**
* 业务类型数组
*/
@TableField(value = "business_type")
private Integer[] businessTypes;
/**
* 请求方法
*/
@TableField(value = "method")
private String method;
/**
* 请求方式
*/
@TableField(value = "request_method")
private String requestMethod;
/**
* 操作类别(0其它 1后台用户 2手机端用户)
*/
@TableField(value = "operator_type")
private Integer operatorType;
/**
* 操作人员
*/
@TableField(value = "oper_name")
private String operName;
/**
* 部门名称
*/
@TableField(value = "dept_name")
private String deptName;
/**
* 请求url
*/
@TableField(value = "oper_url")
private String operUrl;
/**
* 操作地址
*/
@TableField(value = "oper_ip")
private String operIp;
/**
* 操作地点
*/
@TableField(value = "oper_location")
private String operLocation;
/**
* 请求参数
*/
@TableField(value = "oper_param")
private String operParam;
/**
* 返回参数
*/
@TableField(value = "json_result")
private String jsonResult;
/**
* 操作状态(0正常 1异常)
*/
@TableField(value = "status")
private Integer status;
/**
* 错误消息
*/
@TableField(value = "error_msg")
private String errorMsg;
/**
* 操作时间
*/
@JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
@TableField(value = "oper_time")
private LocalDateTime operTime;
/**
* 消耗时间
*/
@TableField(value = "cost_time")
private Long costTime;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@TableField(exist = false)
private Map<String, Object> params = new HashMap<>();
}
线程池配置
线程池配置代码:
/**
* 线程池配置
**/
@Configuration
public class ThreadPoolConfig {
// 核心线程池大小
private int corePoolSize = 50;
// 最大可创建的线程数
private int maxPoolSize = 200;
// 队列最大长度
private int queueCapacity = 1000;
// 线程池维护线程所允许的空闲时间
private int keepAliveSeconds = 300;
// .......
// 其他bean注入,暂时没有用到
/**
* 执行周期性或定时任务
*/
@Bean(name = "scheduledExecutorService")
protected ScheduledExecutorService scheduledExecutorService() {
return new ScheduledThreadPoolExecutor(corePoolSize,
new BasicThreadFactory.Builder().namingPattern("schedule-pool-%d").daemon(true).build(),
new ThreadPoolExecutor.CallerRunsPolicy()) {
@Override
protected void afterExecute(Runnable r, Throwable t) {
super.afterExecute(r, t);
Threads.printException(r, t);
}
};
}
}
配置的是scheduledExecutorService一个线程池,专门用来执行定时任务/周期任务的,防止日志的插入影响主线程跑任务的性能。
注意:配置时线程池拒绝策略是CallerRunsPolicy(),呼叫线程执行策略,即 当执行的任务数量已经超过最大等待线程数时,就会通过执行异步线程的线程(我这个项目中我是使用main主线程的,虽然可能会影响性能,但日志安全得到保障)来执行
异步任务管理器
代码
/**
* 异步任务管理器
*
*/
public class AsyncManager {
private static AsyncManager ME = new AsyncManager();
/**
* 操作延迟10毫秒
*/
private final int OPERATE_DELAY_TIME = 10;
/**
* 异步操作任务调度线程池
*/
private ScheduledExecutorService executor = SpringUtils.getBean("scheduledExecutorService");
/**
* 单例模式
*/
private AsyncManager() {
}
public static AsyncManager me() {
return ME;
}
/**
* 执行任务
*
* @param task 任务
*/
public void execute(TimerTask task) {
executor.schedule(task, OPERATE_DELAY_TIME, TimeUnit.MILLISECONDS);
}
/**
* 停止任务线程池
*/
public void shutdown() {
Threads.shutdownAndAwaitTermination(executor);
}
}
异步任务工厂
当产生一个操作日志的时候,就调用该工厂类的recordOper()方法
/**
* 异步工厂(产生任务用)
*/
@Slf4j
public class AsyncFactory {
/**
* 操作日志记录
*
* @param operLog 操作日志信息
* @return 任务task
*/
public static TimerTask recordOper(final SysOperLog operLog) {
return new TimerTask() {
@Override
public void run() {
// 通过工具类获取操作请求的IP地址信息
operLog.setOperLocation(AddressTool.getRealAddressByIP(operLog.getOperIp()));
// 操作时间
operLog.setOperTime(LocalDateTime.now());
// 插入到操作日志表中
SpringUtils.getBean(SysOperLogMapper.class).insertSysOperLog(operLog);
}
};
}
}
日志切面注解逻辑类
注意:在写注解逻辑类之前,pom需要引入aspect-j 或者 aspectjweaver的依赖,否则会报错A
若是有starter-aop包含了也是可以的。
创建一个Log注解:
/**
* 自定义操作日志记录注解
*/
@Target({ElementType.PARAMETER, ElementType.METHOD})
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface Log {
/**
* 模块
*/
public String title() default "";
/**
* 功能
*/
public BusinessType businessType() default BusinessType.OTHER;
/**
* 操作人类别
*/
public OperatorType operatorType() default OperatorType.MANAGE;
/**
* 是否保存请求的参数
*/
public boolean isSaveRequestData() default true;
/**
* 是否保存响应的参数
*/
public boolean isSaveResponseData() default true;
/**
* 排除指定的请求参数
*/
public String[] excludeParamNames() default {};
}
在某一个controller上进行@Log()注解,如下:
在此处就是一个切点,当请求该接口时,会触发该切点,从而执行LogAspect.java日志切面的逻辑:
/**
* 操作日志记录处理
*/
@Aspect
@Component
public class LogAspect {
/**
* 排除敏感属性字段
*/
public static final String[] EXCLUDE_PROPERTIES = {"password", "oldPassword", "newPassword", "confirmPassword"};
private static final Logger log = LoggerFactory.getLogger(LogAspect.class);
/**
* 计算操作消耗时间
*/
private static final ThreadLocal<Long> TIME_THREADLOCAL = new NamedThreadLocal<Long>("Cost Time");
/**
* 处理请求前执行
*/
@Before(value = "@annotation(controllerLog)")
public void boBefore(JoinPoint joinPoint, Log controllerLog) {
TIME_THREADLOCAL.set(System.currentTimeMillis());
}
/**
* 处理完请求后执行
*
* @param joinPoint 切点
*/
@AfterReturning(pointcut = "@annotation(controllerLog)", returning = "jsonResult")
public void doAfterReturning(JoinPoint joinPoint, Log controllerLog, Object jsonResult) {
handleLog(joinPoint, controllerLog, null, jsonResult);
}
/**
* 拦截异常操作
*
* @param joinPoint 切点
* @param e 异常
*/
@AfterThrowing(value = "@annotation(controllerLog)", throwing = "e")
public void doAfterThrowing(JoinPoint joinPoint, Log controllerLog, Exception e) {
handleLog(joinPoint, controllerLog, e, null);
}
protected void handleLog(final JoinPoint joinPoint, Log controllerLog, final Exception e, Object jsonResult) {
try {
// 获取当前的用户
LoginUser loginUser = SecurityUtils.getLoginUser();
// *========数据库日志=========*//
SysOperLog operLog = new SysOperLog();
operLog.setStatus(BusinessStatus.SUCCESS.ordinal());
// 请求的地址
String ip = IpUtils.getIpAddr();
operLog.setOperIp(ip);
operLog.setOperUrl(StringUtils.substring(ServletUtils.getRequest().getRequestURI(), 0, 255));
if (loginUser != null) {
operLog.setOperName(loginUser.getUsername());
}
if (e != null) {
operLog.setStatus(BusinessStatus.FAIL.ordinal());
operLog.setErrorMsg(StringUtils.substring(e.getMessage(), 0, 2000));
}
// 设置方法名称
String className = joinPoint.getTarget().getClass().getName();
String methodName = joinPoint.getSignature().getName();
operLog.setMethod(className + "." + methodName + "()");
// 设置请求方式
operLog.setRequestMethod(ServletUtils.getRequest().getMethod());
// 处理设置注解上的参数
getControllerMethodDescription(joinPoint, controllerLog, operLog, jsonResult);
// 设置消耗时间
operLog.setCostTime(System.currentTimeMillis() - TIME_THREADLOCAL.get());
// 保存数据库
AsyncManager.me().execute(AsyncFactory.recordOper(operLog));
} catch (Exception exp) {
// 记录本地异常日志
log.error("异常信息:{}", exp.getMessage());
} finally {
TIME_THREADLOCAL.remove();
}
}
/**
* 获取注解中对方法的描述信息 用于Controller层注解
*
* @param log 日志
* @param operLog 操作日志
* @throws Exception
*/
public void getControllerMethodDescription(JoinPoint joinPoint, Log log, SysOperLog operLog, Object jsonResult) throws Exception {
// 设置action动作
operLog.setBusinessType(log.businessType().ordinal());
// 设置标题
operLog.setTitle(log.title());
// 设置操作人类别
operLog.setOperatorType(log.operatorType().ordinal());
// 是否需要保存request,参数和值
if (log.isSaveRequestData()) {
// 获取参数的信息,传入到数据库中。
setRequestValue(joinPoint, operLog, log.excludeParamNames());
}
// 是否需要保存response,参数和值
if (log.isSaveResponseData() && Objects.nonNull(jsonResult)) {
operLog.setJsonResult(StringUtils.substring(JSON.toJSONString(jsonResult), 0, 2000));
}
}
/**
* 获取请求的参数,放到log中
*
* @param operLog 操作日志
* @throws Exception 异常
*/
private void setRequestValue(JoinPoint joinPoint, SysOperLog operLog, String[] excludeParamNames) throws Exception {
Map<?, ?> paramsMap = ServletUtils.getParamMap(ServletUtils.getRequest());
String requestMethod = operLog.getRequestMethod();
if (MapUtils.isEmpty(paramsMap)
&& (HttpMethod.PUT.name().equals(requestMethod) || HttpMethod.POST.name().equals(requestMethod))) {
String params = argsArrayToString(joinPoint.getArgs(), excludeParamNames);
operLog.setOperParam(StringUtils.substring(params, 0, 2000));
} else {
operLog.setOperParam(StringUtils.substring(JSON.toJSONString(paramsMap, excludePropertyPreFilter(excludeParamNames)), 0, 2000));
}
}
/**
* 参数拼装
*/
private String argsArrayToString(Object[] paramsArray, String[] excludeParamNames) {
String params = "";
if (ArrayUtils.isNotEmpty(paramsArray)) {
for (Object o : paramsArray) {
if (Objects.nonNull(o) && !isFilterObject(o)) {
try {
// 对于敏感词汇进行过滤,如密码,重置密码登数据,是不记录在日志上的
String jsonObj = JSON.toJSONString(o, excludePropertyPreFilter(excludeParamNames));
params += jsonObj.toString() + " ";
} catch (Exception e) {
}
}
}
}
return params.trim();
}
/**
* 忽略敏感属性
*/
public PropertyPreExcludeFilter excludePropertyPreFilter(String[] excludeParamNames) {
return new PropertyPreExcludeFilter().addExcludes(ArrayUtils.addAll(EXCLUDE_PROPERTIES, excludeParamNames));
}
/**
* 判断是否需要过滤的对象。
*
* @param o 对象信息。
* @return 如果是需要过滤的对象,则返回true;否则返回false。
*/
@SuppressWarnings("rawtypes")
public boolean isFilterObject(final Object o) {
Class<?> clazz = o.getClass();
if (clazz.isArray()) {
return clazz.getComponentType().isAssignableFrom(MultipartFile.class);
} else if (Collection.class.isAssignableFrom(clazz)) {
Collection collection = (Collection) o;
for (Object value : collection) {
return value instanceof MultipartFile;
}
} else if (Map.class.isAssignableFrom(clazz)) {
Map map = (Map) o;
for (Object value : map.entrySet()) {
Map.Entry entry = (Map.Entry) value;
return entry.getValue() instanceof MultipartFile;
}
}
return o instanceof MultipartFile || o instanceof HttpServletRequest || o instanceof HttpServletResponse
|| o instanceof BindingResult;
}
}
handlelog()方法业务逻辑方法,对进入doAfterReturning(),插入数据库的日志状态为成功,若进入doAfterThrowing()的状态为失败。
注意:在记录日志的时候,需要排除敏感属性字段,橙色标注的代码即为关键
测试
即拿重置密码的接口为例:
controller层:
1、对【重置密码】功能验证
2、断点进入前置通知
请求接口,首先触发切点,进入LogAspect.java的boBefore()前置通知方法中,
保存方法开始的执行时间
3、进入controller的业务方法
进入controller层,执行重置密码的业务方法
4、进入后置通知
业务执行完毕后,执行LogAspect 的后置通知方法
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-XYIKqAQl-1692945104272)(https://cdn.nlark.com/yuque/0/2023/png/28496166/1692869461262-0f9a3d6f-d085-4a7e-870b-5e538bcf5d5a.png#averageHue=%231c202e&clientId=u746b4af5-9691-4&from=paste&height=283&id=u0b6669df&originHeight=283&originWidth=801&originalType=binary&ratio=1&rotation=0&showTitle=false&size=36178&status=done&style=none&taskId=uff68d167-12e0-4489-9987-fc0021b6a65&title=&width=801)]
进入日志处理逻辑方法
红色框住的是对在controller层@Log注解上参数的处理,里面还有对敏感词汇的过滤处理
进入getControllerMethodDescription()方法,发现joinPoint中含有password,需要进行**脱敏**操作,进入setRe
[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-hmwFYbc1-1692945104272)(https://cdn.nlark.com/yuque/0/2023/png/28496166/1692870158257-df4a8b7e-ec30-4678-88aa-78de0e268399.png#averageHue=%23242633&clientId=u746b4af5-9691-4&from=paste&height=826&id=uf4c844e6&originHeight=826&originWidth=1107&originalType=binary&ratio=1&rotation=0&showTitle=false&size=142295&status=done&style=none&taskId=u0f36e26f-e749-4698-bffd-dc7bda98613&title=&width=1107)]
进入setRequestValue() 方法
进入argsArrayToString()传入一个在主街上手动设置的过滤字段数组
第一个红框中的JSON.toJSONString(o, excludePropertyPreFilter(excludeParamNames))方法,是对字段的拼接,其中excludePropertyPreFilter()方法中是包含默认过滤的属性字段的:
JSON.toJSONString()第二个参数就是过滤参数:
jsonObj中没有password的值,对指定字段脱敏成功:
5、回看查看操作日志记录
看到操作日志中,显示信息**成功,由于我打了断点,故消耗时间很长**