SpringBoot开发日志注解记录字段变更内容

2,879 阅读7分钟

正在公司摸鱼的我突然接到了大佬给我的一个人任务,开发一个日志注解,来记录当方法中的每一个参数的名字,并记录每次的参数修改的值信息。接到任务的我瑟瑟发抖。

1. 数据库选择MongoDb


因为MongoDb具有以下特点

  • MongoDb为文档型数据库。操作起来比较简单和容易。
  • Mongo支持丰富的查询表达式。查询指令使用JSON形式的标记,可轻易查询文档中内嵌的对象及数组。
  • MongoDb采用Bson(类json的一种二进制形式的存储格式)存储数据,跟新和查询的速度很快 当然了MongoDb还有很多优点我就不一一赘述了,大家可以去官网看看文档。

2. 开发实体类保存到数据库中


@Data
public class SysLogEntity implements Serializable {

	private static final long serialVersionUID = 1L;

	private Long id;

	// 用户名
	private String username;

	// 用户操作
	private String operation;

	// 请求方法
	private String method;

	// 请求参数
	private String params;

	// 执行时长(毫秒)
	private Long time;

	// IP地址
	private String ip;

	// 创建时间
	private Date createDate;

	//所有参数名称 以逗号分割
	private String parametersName;

	//修改内容
	private String modifyContent;

	//操作类型
	private String operationType;

	//key值
	private String logKey;

}
  • 在数据库中保存的数据格式大体是这样的

3. 思考问题


当时我在做这一个功能的时候在想,你无法确定方法的入参到底是什么类型,有可能为实体类型,有可能为Map类型,有可能为String类型,如果参数为实体类型的需要去进行反射获取其中的所有字段,这是一个很耗时的操作,那么我为什么不拿出来在SpringBoot初始化的时候来做这个事情呢,于是就有了下面的操作。

  • 首先创建一个实体类用来保存当前方法中的参数名,参数类型,字段值,和当前字段值对应的具体位置
@Data
public class MethodParametersInfo {
	/**
	 *  参数名称
	 */
	String parameterName;
	/**
	 *  参数类型
	 */
	Class<?> classType;
	/**
	 *字段
	 */
	Field[] fields;
	/**
	 *  字段
	 */
	Integer position;
}
  • 开发日志注解,
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Documented
public @interface ModifyLog {
	/**
	 *  方法描述
	 * @return
	 */
	String value() default "";

	/**
	 * 方法类型
	 * @return
	 */
	LogTypeEnum type();
	/**
	 * @return 是否需要默认的改动比较
	 */
	boolean needDefaultCompare() default false;

	/**
	 *  key值可以根据spel表达式来填写
	 * @return
	 */
	String key();

}
public enum LogTypeEnum {
	/**
	 * 保存
	 */
	Save("save"),
	/**
	 * 修改
	 */
	Update("update"),
	/**
	 * 删除
	 */
	Delete("delete"),
	/**
	 * 保存或修改
	 */
	SaveOrUpdate("saveOrUpdate");

	LogTypeEnum(String key){
		this.key = key;
	}

	public String getKey() {
		return key;
	}


	private final String key;


}
  • 将实体类需要保存的字段进行细分,于是便又开发了一个注解,用来确定实体类中具体需要把那些字段信息保存到日志中
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.FIELD})
@Documented
public @interface DataName {
    /**
     * @return 字段名称
     */
    String name() default "";

 }
  • 在Springboot进行初始化的时候把方法中的参数进行缓存,若参数为实体类,进行反射获取其中所有的字段属性
@Component
public class ModifyLogInitialization {

	@Autowired
	private RequestMappingHandlerMapping mapping;

	public static Map<String,Map<String, MethodParametersInfo>> modifyLogMap = new HashMap<>();


	/**
	* @Author: lin
	* @Description: 初始化Controller层上带有 @ModifyLog 注解的方法 缓存到map中
	* @DateTime: 2020/12/25 15:52
	* @Params: [event]
	* @Return void
	*/
	@EventListener
	public void initializationMethod(WebServerInitializedEvent  event){


		//获取所有方法
		Map<RequestMappingInfo, HandlerMethod> handlerMethods = mapping.getHandlerMethods();

		handlerMethods.forEach((k,v) -> {
			//判断Controller层方法上注解
			if(v.getMethodAnnotation(ModifyLog.class)!=null){
				Class<?> beanType = v.getBeanType();
				//获取方法对象
				Method method = v.getMethod();
				//方法参数
				MethodParameter[] parameters = v.getMethodParameters();
				//参数名作为key 缓存参数信息
				HashMap<String, MethodParametersInfo> methodMap = new HashMap<>();
				//类路径加方法名作为key
				String methodKey = beanType.getName()+"."+method.getName()+"()";
				modifyLogMap.put(methodKey,methodMap);

				int i = 0;
				//循环方法参数
				for (MethodParameter parameter : parameters) {
					MethodParametersInfo info = new MethodParametersInfo();
					//参数位置
					info.setPosition(i);
					//参数名称
					String parameterName =  parameter.getParameter().getName();
					info.setParameterName(parameterName);
					//参数类型
					Class<?> parameterType = parameter.getParameterType();
					info.setClassType(parameterType);

					//获取所有字段
					Field[] fields = parameterType.getDeclaredFields();
					if(!parameterType.isAssignableFrom(String.class) & !parameterType.isAssignableFrom(Map.class)){
						info.setFields(fields);
					}
					//加入到Map中
					methodMap.put(parameterName,info);
					i++;
				}
			}

		});
	}

}

用监听的方式监听WebServerInitializedEvent在启动的时候做缓存,RequestMappingHandlerMapping可以获取所有Controllec层标注@RequestMapping的方法

4. 用Aop来保存参数内容


  • 可以用spel表达式加入到注解中,这样就可以在aop中去解析获取关键值,可以参考 juejin.cn/post/684490… 篇文章

  • 为了免去使用if和else来判断参数类型我使用了适配器模式,并把每一个参数类的解析抽出取来,这样当你添加不同参数类型的解析的时候可以避免代码的侵入性

  • 下面是一个类型判断的接口,可以判断当前参数的类型,并且去调用当前参数类型的解析器

public interface TypeAdapter {
	/**
	* @Author: lin
	* @Description: 判断支持类型
	* @DateTime: 2021/1/4 9:16
	* @Params: [classType]
	* @Return boolean
	*/
	boolean supprot(Class<?> classType);

	/**
	* @Author: lin
	* @Description: 获取文本
	* @DateTime: 2021/1/4 9:16
	* @Params: [sb, k, v, oldObjectList, newObjectList]
	* @Return void
	*/
	void getContent(StringBuilder sb, String k, MethodParametersInfo v, List<Object> oldObjectList, List<Object> newObjectList);

  • 这是map的类型判断并且实现map类型的参数解析
@Component
public class ClassTypeAdapter implements TypeAdapter {

	@Autowired
	ContentParse classParse;

	@Override
	public boolean supprot(Class<?> classType) {
		return !classType.isAssignableFrom(String.class) && !classType.isAssignableFrom(Map.class);
	}

	@Override
	public void getContent(StringBuilder sb, String k, MethodParametersInfo v, List<Object> oldObjectList, List<Object> newObjectList) {
		classParse.getDifferentContent(sb,k,v,oldObjectList,newObjectList);
	}
}
  • 下面是一个参数类型的解析接口
public interface ContentParse {

	/**
	* @Author: lin
	* @Description: 根据字段不同类型进行解析
	* @DateTime: 2020/12/28 15:13
	* @Params: [sb, k, v]
	* @Return void
	*/
	void getDifferentContent(StringBuilder sb, String k, MethodParametersInfo v, List<Object> oldObjectList,List<Object> newObjectList);

}
  • 参数类型解析接口的实现
@Component("classParse")
public class ClassParse implements ContentParse {
	@Override
	public void getDifferentContent(StringBuilder sb, String k, MethodParametersInfo v, List<Object> oldObjectList, List<Object> newObjectList) {
		//获取所有字段
		Field[] fields = v.getFields();
		//根据字段名称跟字段映射成Map
		Map<String, List<Field>> fieldMap = Arrays.stream(fields).collect(Collectors.groupingBy(Field::getName));
		//记录位置
		Integer position = v.getPosition();
		//取出当前位置的 参数对应的值
		Object oldObject = oldObjectList.get(position);
		Object newObject = newObjectList.get(position);
		//转换为Map
		Map<String, Object> oldMap = JSONUtil.parseObj(oldObject);
		Map<String, Object> newMap = JSONUtil.parseObj(newObject);
		oldMap.forEach((oldK,oldV) -> {
			Object newV = newMap.get(oldK);
			if(!newV.equals(oldV)){
				//值不相等,根据当前字段名(k值)取出当前字段
				List<Field> fieldList = fieldMap.get(oldK);
				Field field = fieldList.get(0);
				//有没有DataName注解
				if(field.isAnnotationPresent(DataName.class)){
					sb.append("[参数: ").append(k)
							.append("中属性").append(field.getName()).append("]从[")
							.append(oldV).append("]改为了[").append(newV).append("];");
				}
			}
		});
	}
}
  • 之后我们就可以写一个Util类用来获取数据库中根据当前类路径跟方法名所对应的参数值,并跟当前传的新值作比较,判断那个参数的值有修改
@Component
public class ModifyLogUtil {

	/**
	* @Author: lin
	* @Description:  获取参数名称保存到数据库中以逗号分割
	* @DateTime: 2020/12/24 14:46
	* @Params: [methodKey]
	* @Return java.lang.String
	*/

	@Autowired
	private  List<TypeAdapter> typeAdapterList;

	public String getParametersName(String methodKey){
		// 类路径跟方法名获取参数信息
		Map<String, Map<String, MethodParametersInfo>> modifyLogMap = ModifyLogInitialization.modifyLogMap;
		//参数信息 key是参数名 value参数信息
		Map<String, MethodParametersInfo> parameterMap = modifyLogMap.get(methodKey);

		StringBuilder sb = new StringBuilder();

		if(parameterMap != null){
			parameterMap.forEach((k,v) ->{
				sb.append(k);
				sb.append(",");
			});
			sb.deleteCharAt(sb.length()-1);
		}

		return sb.toString();
	}

	/**
	* @Author: lin
	* @Description: 根据参数名称获取参数信息进行比对
	* @DateTime: 2020/12/24 15:05
	* @Params: [methodKey, parameter]
	* @Return java.lang.String
	*/
	public String getContentName(String methodKey, String params, ProceedingJoinPoint joinPoint){
		//解析旧数据
		List<Object> oldObjectList = parseOldObject(params);
		//解析新数据
		List<Object> newObjectList = parseNewObject(joinPoint);

		// 类路径跟方法名获取参数信息
		Map<String, Map<String, MethodParametersInfo>> modifyLogMap = ModifyLogInitialization.modifyLogMap;
		//参数信息 key是参数名 value参数信息
		Map<String, MethodParametersInfo> parameterMap = modifyLogMap.get(methodKey);

		//记录
		StringBuilder sb = new StringBuilder();
		parameterMap.forEach((k,v) ->{
			Class<?> classType = v.getClassType();

			typeAdapterList.stream().filter(typeAdapter -> typeAdapter.supprot(classType)).findFirst()
					.get().getContent(sb,k,v,oldObjectList,newObjectList);
		});
		log.info("参数改变的值为: {}",sb.toString());
		return sb.toString();
	}
	/**
	* @Author: lin
	* @Description: 解析旧参数
	* @DateTime: 2020/12/25 14:02
	* @Params: [params]
	* @Return java.util.List<java.lang.Object>
	*/
	public  List<Object> parseOldObject(String params){
		JSONArray array = JSONUtil.parseArray(params);
		return new ArrayList<>(array);
	}

	/**
	* @Author: lin
	* @Description: 解析新参数
	* @DateTime: 2020/12/25 14:02
	* @Params: [joinPoint]
	* @Return java.util.List<java.lang.Object>
	*/
	public  List<Object> parseNewObject(ProceedingJoinPoint joinPoint){
		Object[] args = joinPoint.getArgs();
		return new ArrayList<>(Arrays.asList(args));
	}
}
  • 接下来就是在aop中使用这个util工具
@Slf4j
@Aspect
@Component
public class SysLogAspect {

	@Autowired
	private KeyResolver keyResolver;

	@Autowired
	private ModifyLogUtil modifyLogUtil;

	@Autowired
	MongoTemplate mongoTemplate;




	@Around("@annotation(modifyLog)")
	public Object modifyLogAround(ProceedingJoinPoint point,ModifyLog modifyLog) throws Throwable {
		long beginTime = System.currentTimeMillis();
		// 执行方法
		Object result = point.proceed();
		// 执行时长(毫秒)
		long time = System.currentTimeMillis() - beginTime;

		saveUpdateSysLog(point,time,modifyLog);

		return result;
	}


	/**
	* @Author: lin
	* @Description: 当方法为Update类型的时候要获取其中不同项
	* @DateTime: 2020/12/22 15:51
	* @Params: [joinPoint, time]
	* @Return void
	*/
	private void saveUpdateSysLog(ProceedingJoinPoint joinPoint, long time,ModifyLog modifyLog){
		//日志实体类
		SysLogEntity currentSysLogEntity = new SysLogEntity();
		currentSysLogEntity.setId(SnowflakeUtil.snowflakeId());

		MethodSignature signature = (MethodSignature) joinPoint.getSignature();
		//获取key值
		String key = keyResolver.resolver(modifyLog, joinPoint);
		currentSysLogEntity.setLogKey(key);
		log.info("key值{}",key);

		//日志类型
		String logType = modifyLog.type().getKey();
		// 注解上的描述
		currentSysLogEntity.setOperation(modifyLog.value());
		//操作类型
		currentSysLogEntity.setOperationType(logType);
		//设置实体类字段
		commonMethod(joinPoint, time, signature, currentSysLogEntity);
		//根据类名加方法名去缓存中查找
		String parametersName = modifyLogUtil.getParametersName(currentSysLogEntity.getMethod());
		//方法中参数名称
		currentSysLogEntity.setParametersName(parametersName);
		//当前类型为save则保存
		if(Constant.MODIFY_LOG_SAVE.equals(logType)){
			mongoTemplate.insert(currentSysLogEntity);
			return;
		}
		//当前类型为删除记录删除的字段值
		if(Constant.MODIFY_LOG_DELETE.equals(logType)){
			Object[] args = joinPoint.getArgs();
			String deleteParam = JSONUtil.toJsonStr(args);
			currentSysLogEntity.setModifyContent("当前删除的参数信息是:" + deleteParam);
			mongoTemplate.insert(currentSysLogEntity);
			return;
		}
		//当前类型为保存或修改
		if(Constant.MODIFY_LOG_SAVE_OR_UPDATE.equals(logType)){

			SysLogEntity oldSysLogEntity = getEntity(currentSysLogEntity);
			//没有查询到就插入信息
			if(oldSysLogEntity == null){
				mongoTemplate.insert(currentSysLogEntity);
				return;
			}
			//有信息则判断是否需要插入修改字段的信息
			if(modifyLog.needDefaultCompare()){
				String content = modifyLogContent(joinPoint,oldSysLogEntity);
				//设置修改字段属性
				currentSysLogEntity.setModifyContent(content);
			}
			mongoTemplate.insert(currentSysLogEntity);
		}
	}


	/**
	* @Author: lin
	* @Description: 设置实体类属性公用方法
	* @DateTime: 2020/12/22 16:07
	* @Params: [joinPoint, time, signature, sysLogEntity]
	* @Return void
	*/
	private void commonMethod(ProceedingJoinPoint joinPoint, long time, MethodSignature signature, SysLogEntity sysLogEntity) {
		//请求的类名
		String className = joinPoint.getTarget().getClass().getName();
		//方法名
		String methodName = signature.getName();
		sysLogEntity.setMethod(className + "." + methodName + "()");

		//请求的参数
		Object[] args = joinPoint.getArgs();

		String argsStr = JSONUtil.toJsonStr(args);

		//保存参数
		sysLogEntity.setParams(argsStr);
		// 获取request
		HttpServletRequest request = HttpContextUtils.getHttpServletRequest();
		// 设置IP地址
		sysLogEntity.setIp(IPUtils.getIpAddr(request));

		//用户名
		String username = ((SysUserEntity) SecurityUtils.getSubject().getPrincipal()).getUsername();
		sysLogEntity.setUsername(username);
		sysLogEntity.setTime(time);
		sysLogEntity.setCreateDate(new Date());
	}

	/**
	* @Author: lin
	* @Description: 是update操作则进行比对判断是那些 字段值有改变
	* @DateTime: 2020/12/24 14:55
	* @Params: []
	* @Return void
	*/
	private String  modifyLogContent(ProceedingJoinPoint joinPoint,SysLogEntity oldSysLogEntity){

		//实际参数
		String params = oldSysLogEntity.getParams();
		//取出修改的参数属性
		String contentName = modifyLogUtil.getContentName(oldSysLogEntity.getMethod(), params, joinPoint);

		return contentName;
	}

	private SysLogEntity getEntity(SysLogEntity sysLogEntity){
		Query query = new Query();
		Criteria criteria = new Criteria();
		criteria.and("logKey").is(sysLogEntity.getLogKey());
		criteria.and("method").is(sysLogEntity.getMethod());
		query.addCriteria(criteria);
		//根据日期排序
		query.with(Sort.by(Sort.Order.desc("createDate")));
		query.limit(1);
		SysLogEntity one = mongoTemplate.findOne(query, SysLogEntity.class);
		return one;

	}

}

5. 小结


  • 作为一个刚入门半年的菜鸟我还是又很多地方需要学习,这个方法肯定还能在进一步的优化,希望各位大佬能给指点一下。希望大家共同进步。