使用 Spring Boot 优雅实现用户操作日志审计

背景

在现代应用系统中,事件审计是一个至关重要的功能。通过记录用户的操作行为,我们可以追踪问题、分析用户行为,甚至在出现安全问题时提供关键证据。由于目前没有较好的事件审计框架,笔者决定实现一套可扩展的事件审计组件,要求对业务低侵入性,可以轻松获取前后变更的内容。

目标

提供自定义注解给业务侧,实现开箱即用的事件审计存储功能。

实现

审计

我们定义了 @EventAuditor 注解,关键字段包括 operator(操作人)、content(操作内容模板)、bizScenario(事件发生的场景)等。

@Documented
@Inherited
@Retention(RetentionPolicy.RUNTIME)
@Target({ElementType.METHOD})
public @interface EventAuditor {

	/**
	 * 操作对象,支持 SpEL 表达式
	 *
	 * @return 操作对象
	 */
	String operator() default Strings.EMPTY;

	/**
	 * 操作角色,支持 SpEL 表达式
	 * <br/> 用于区分运营人员和用户
	 *
	 * @return 操作角色
	 */
	String role() default Strings.EMPTY;

	/**
	 * 业务场景,支持 SpEL 表达式
	 * <br/> 推荐风格:产品线+用例+场景
	 *
	 * @return 业务场景
	 */
	String bizScenario() default Strings.EMPTY;

	/**
	 * 记录内容
	 *
	 * @return 记录内容,支持 SpEL 表达式
	 */
	String content() default Strings.EMPTY;

	/**
	 * 额外信息
	 * <br/> 防止记录的内容过长无法展示,额外序列化保存
	 *
	 * @return 额外信息,支持 SpEL 表达式
	 */
	String extra() default Strings.EMPTY;

	/**
	 * 触发条件
	 *
	 * @return 触发条件,为空表示默认触发
	 */
	String condition() default Strings.EMPTY;

	/**
	 * 调用方法之前解析 SpEL 参数
	 * <br/>
	 *
	 * @return 是否提前解析
	 */
	boolean evalBeforeInvoke() default true;

	/**
	 * 是否记录返回值
	 * <br/> 防止记录查询类返回的 List 大对象导致 OOM
	 *
	 * @return 是否记录
	 */
	boolean recordReturnValue() default false;
}

通过 Spring 的 MethodInterceptor,我们实现了 EventAuditorInterceptor 来捕获 @EventAuditor 注解,并在方法调用前后进行审计记录。

@RequiredArgsConstructor
@Slf4j
public class EventAuditorInterceptor implements MethodInterceptor {

	private static final String RETURN = "_return";

	private static final String ERROR_MSG = "_errorMsg";

	private final EventAuditorConfig eventAuditorConfig;

	private AsyncTaskExecutor asyncTaskExecutor;

	/**
	 * 方法调用拦截处理
	 *
	 * @param invocation 方法调用元信息
	 * @return 返回值
	 * @throws Throwable 异常
	 */
	@Override
	public Object invoke(@NotNull MethodInvocation invocation) throws Throwable {
		if (AopUtils.isAopProxy(invocation.getThis())) {
			return invocation.proceed();
		}

		LocalDateTime now = LocalDateTime.now();
		Method method = invocation.getMethod();
		EventAuditor[] eventAuditors;
		try {
			eventAuditors = method.getAnnotationsByType(EventAuditor.class);
		} catch (Throwable throwable) {
			// 如果解析异常,直接执行返回
			return invocation.proceed();
		}

		List<AuditingEvent> events = Lists.newArrayList();
		events.addAll(parseList(invocation, eventAuditors, true));

		Object result = null;
		boolean success = true;
		long executionCost = 0L;
		String errorMsg = null;
		// 执行耗时
		String watchId = invocation.getClass() + Strings.DOT + invocation.getMethod().getName();
		StopWatch stopWatch = new StopWatch(watchId);
		stopWatch.start();
		try {
			result = invocation.proceed();
		} catch (Throwable e) {
			if (stopWatch.isRunning()) {
				stopWatch.stop();
				executionCost = stopWatch.getTotalTimeMillis();
			}
			success = false;
			errorMsg = e.getMessage();
			SpelEvaluationContext.setVariable(ERROR_MSG, errorMsg);    // 异常信息
			throw e;
		} finally {
			if (stopWatch.isRunning()) {
				stopWatch.stop();
				executionCost = stopWatch.getTotalTimeMillis();
			}
			SpelEvaluationContext.setVariable(RETURN, result);    // 返回值处理
			events.addAll(parseList(invocation, eventAuditors, false));
			for (AuditingEvent event : events) {
				event.setSuccess(success);
				event.setOperateDate(now);
				event.setExecutionCost(executionCost);
				if (errorMsg != null) {
					event.setThrowable(errorMsg);
				}
			}

			send(events);
			SpelEvaluationContext.remove(); // 清理当前线程变量
		}
		return result;
	}

	/**
	 * 异步开启后需要设置 AsyncTaskExecutor
	 *
	 * @param asyncTaskExecutor 异步任务执行器
	 */
	public void setAsyncTaskExecutor(AsyncTaskExecutor asyncTaskExecutor) {
		this.asyncTaskExecutor = asyncTaskExecutor;
	}

	/**
	 * 发送审计事件
	 *
	 * @param events 审计事件列表
	 */
	private void send(List<AuditingEvent> events) {
		String senderType = eventAuditorConfig.getSender().getSenderType();
		EventSenderBuilder eventSenderBuilder = ExtensionLoader.getExtensionLoader(EventSenderBuilder.class).getExtension(senderType);
		eventSenderBuilder.setEventAuditorConfig(eventAuditorConfig);
		EventSender eventSender = eventSenderBuilder.build();
		if (eventAuditorConfig.getSender().isAsync() && asyncTaskExecutor != null) {
			asyncTaskExecutor.execute(() -> eventSender.send(events));
		} else {
			eventSender.send(events);
		}
	}

	/**
	 * 解析 {@code AuditingEvent} 列表
	 *
	 * @param invocation       方法调用元信息
	 * @param eventAuditors    审计注解
	 * @param evalBeforeInvoke 是否在调用方法之前提前解析
	 * @return {@code AuditingEvent} 列表
	 */
	private List<AuditingEvent> parseList(@NotNull MethodInvocation invocation, EventAuditor[] eventAuditors,
										  boolean evalBeforeInvoke) {
		List<AuditingEvent> events = Lists.newArrayList();
		for (EventAuditor eventAuditor : eventAuditors) {
			if (eventAuditor.evalBeforeInvoke() == evalBeforeInvoke) {
				AuditingEvent auditingEvent = this.parseModel(eventAuditor, invocation);
				if (auditingEvent != null) {
					events.add(auditingEvent);
				}
			}
		}
		return events;
	}

	/**
	 * 解析模型
	 *
	 * @param eventAuditor 事件审计注解
	 * @param invocation   方法调用元信息
	 * @return 目标模型
	 */
	private AuditingEvent parseModel(EventAuditor eventAuditor, MethodInvocation invocation) {
		EvaluationContext context = SpelEvaluationContext.getContext();
		CustomFunctionRegistrar.register((StandardEvaluationContext) context);
		Method method = invocation.getMethod();
		String[] parameterNames = SpelExpressionEvaluator.getParameterNameDiscoverer().getParameterNames(method);
		Object[] arguments = invocation.getArguments();
		if (parameterNames != null) {
			int len = parameterNames.length;
			for (int i = 0; i < len; i++) {
				context.setVariable(parameterNames[i], arguments[i]);
			}
		}

		ExpressionParser parser = SpelExpressionEvaluator.getExpressionParser();
		if (StringUtils.isNotBlank(eventAuditor.condition())) {
			Expression expression = parser.parseExpression(eventAuditor.condition());
			if (!Boolean.TRUE.equals(expression.getValue(context, Boolean.class))) {
				return null;
			}
		}

		AuditingEvent auditingEvent = new AuditingEvent();
		if (StringUtils.isNotBlank(eventAuditor.operator())) {
			Expression expression = parser.parseExpression(eventAuditor.operator());
			auditingEvent.setOperator(expression.getValue(context, String.class));
		}

		if (StringUtils.isNotBlank(eventAuditor.role())) {
			Expression expression = parser.parseExpression(eventAuditor.role());
			auditingEvent.setRole(expression.getValue(context, String.class));
		}

		if (StringUtils.isNotBlank(eventAuditor.bizScenario())) {
			Expression expression = parser.parseExpression(eventAuditor.bizScenario());
			auditingEvent.setBizScenario(expression.getValue(context, String.class));
		}

		if (StringUtils.isNotBlank(eventAuditor.content())) {
			Expression expression = parser.parseExpression(eventAuditor.content());
			auditingEvent.setContent(expression.getValue(context, String.class));
		}

		if (StringUtils.isNotBlank(eventAuditor.extra())) {
			Expression expression = parser.parseExpression(eventAuditor.extra());
			Object extra = expression.getValue(context, Object.class);
			auditingEvent.setExtra(extra instanceof String ? (String) extra : JSONHelper.json().toJSONString(extra));
		}
		return auditingEvent;
	}
}

上述代码中有 3 个关键类,分别是:

  1. SpelExpressionEvaluator :处理 SpEL 表达式。
  2. CustomFunctionRegistrar:存储自定义函数和 Java 方法的关联关系,解析自定义函数时,通过缓存找到对应的 Java 方法反射调用。
  3. EventSenderBuilder :将事件内容往其他渠道发送,例如消息队列、数据库,便于存储。

接下来依次展开说明。

SpEL 表达式解析实现

SpEL 是 Spring 特有的表示解析格式,内部通过 StandardEvaluationContext 实现,我们可以在这个基础上进行封装。

public class SpelExpressionEvaluator {

	private static final ExpressionParser PARSER = new SpelExpressionParser();

	private static final ParameterNameDiscoverer DISCOVERER = new LocalVariableTableParameterNameDiscoverer();

	public static ExpressionParser getExpressionParser() {
		return PARSER;
	}

	public static ParameterNameDiscoverer getParameterNameDiscoverer() {
		return DISCOVERER;
	}

	/**
	 * 解析 SpEL 表达式
	 *
	 * @param expressionString SpEL 表达式
	 * @param method           方法
	 * @param arguments        参数
	 * @return 解析后的内容
	 */
	public static String parseExpression(String expressionString, Method method, Object[] arguments) {
		String[] params = DISCOVERER.getParameterNames(method);
		if (params != null && params.length > 0) {
			for (int i = 0; i < params.length; i++) {
				SpelEvaluationContext.setVariable(params[i], arguments[i]);
			}
		}

		Expression expression = PARSER.parseExpression(expressionString);
		return expression.getValue(SpelEvaluationContext.getContext(), String.class);
	}
}

public class SpelEvaluationContext {

	private static final TransmittableThreadLocal<EvaluationContext> VARIABLES =
		new TransmittableThreadLocal<EvaluationContext>() {

			@Override
			protected EvaluationContext initialValue() {
				return new StandardEvaluationContext();
			}
		};

	public static EvaluationContext getContext() {
		return VARIABLES.get();
	}

	public static Object lookupVariable(String key) {
		EvaluationContext context = getContext();
		return context.lookupVariable(key);
	}

	public static void setVariable(String key, Object value) {
		EvaluationContext context = getContext();
		context.setVariable(key, value);
		VARIABLES.set(context);
	}

	public static void remove() {
		VARIABLES.remove();
	}
}

自定义函数解决上下文历史

通过 MethodInterceptor 方法拦截只能获取入参和返回值,对于修改内容的场景,没办法获取原始记录。我们引入了 CustomFunctionRegistrar 自定义函数,由用户来决定调用哪个 Java 方法。

在应用启动时,基于 Spring 自动扫描所有标记了 CustomFunctionRegistrar 的代码,通过 AOP 生成代理,将自定义函数和代理方法的映射存到缓存中,这样就可以通过自定义函数找到代理方法,调用业务指定的逻辑了。

@Slf4j
public class CustomFunctionRegistrar implements ApplicationContextAware {

	private static final Map<String, Method> FUNCTION_CACHE = new ConcurrentHashMap<>();

	/**
	 * 初始化
	 *
	 * @param applicationContext ApplicationContext
	 * @throws BeansException 初始化Bean异常
	 */
	@Override
	public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
		initialize(applicationContext);
	}

	/**
	 * 初始化自定义函数
	 *
	 * @param applicationContext ApplicationContext
	 */
	private void initialize(ApplicationContext applicationContext) {
		Map<String, Object> components = applicationContext.getBeansWithAnnotation(Component.class);
		components.values().forEach(component -> {
			Method[] methods = component.getClass().getMethods();
			if (methods.length == 0) {
				return;
			}

			Object targetObject = AopUtils.getDynamicProxyTargetObject(component);
			for (Method method : methods) {
				CustomFunction annotation = AnnotatedElementUtils.findMergedAnnotation(method, CustomFunction.class);
				if (annotation == null) {
					continue;
				}
				if (ReflectionUtils.isStaticMethod(method)) {
					cache(annotation, method);
					continue;
				}
				ClassPool pool = ClassPool.getDefault();
				Class<?> targetClass = targetObject.getClass();
				String staticallyClassName = targetClass.getName() + "_Statically";
				Class<?> delegateClass;
				CtClass ctClass = pool.getOrNull(staticallyClassName);
				try {
					if (ctClass == null) {
						ctClass = constructCtClass(method, pool, targetClass, staticallyClassName);
						delegateClass = ctClass.toClass();
					} else {
						delegateClass = ctClass.getClass().getClassLoader().loadClass(staticallyClassName);
					}
					Object proxy = delegateClass.getConstructor(targetClass).newInstance(component);
					Method[] proxyMethods = proxy.getClass().getDeclaredMethods();
					Arrays.stream(proxyMethods).forEach(proxyMethod -> {
						if (Arrays.equals(method.getParameterTypes(), proxyMethod.getParameterTypes()) &&
							method.getName().equals(proxyMethod.getName())) {
							cache(annotation, proxyMethod);
						}
					});
				} catch (Exception e) {
					throw new RuntimeException(e);
				}
			}
		});
	}

	/**
	 * 注册自定义函数到 SpEL解析上下文
	 *
	 * @param context SpEL解析上下文
	 */
	public static void register(StandardEvaluationContext context) {
		FUNCTION_CACHE.forEach(context::registerFunction);
	}

	private static void cache(CustomFunction annotation, Method method) {
		String registerName = StringUtils.hasText(annotation.value()) ? annotation.value() : method.getName();
		FUNCTION_CACHE.put(registerName, method);
		log.info("Register custom function '{}' as name '{}'", method, registerName);
	}

	private CtClass constructCtClass(Method method, ClassPool pool, Class<?> targetClass, String staticallyClassName)
		throws NotFoundException, CannotCompileException {
		CtClass ctClass = pool.makeClass(staticallyClassName);
		ctClass.addInterface(pool.get(Serializable.class.getName()));

		CtField field = new CtField(pool.get(targetClass.getName()), "delegating", ctClass);
		field.setModifiers(javassist.Modifier.STATIC | javassist.Modifier.PROTECTED);
		ctClass.addField(field);

		CtConstructor constructor = new CtConstructor(new CtClass[]{pool.get(targetClass.getName())}, ctClass);
		constructor.setBody("{delegating = $1;}");
		ctClass.addConstructor(constructor);

		CtMethod getterMethod = new CtMethod(pool.get(targetClass.getName()), "getDelegating", new CtClass[]{}, ctClass);
		getterMethod.setModifiers(javassist.Modifier.PUBLIC);
		getterMethod.setBody("{return delegating;}");
		ctClass.addMethod(getterMethod);

		int modifier = method.getModifiers();
		modifier |= javassist.Modifier.STATIC;
		String methodName = method.getName();
		Class<?> returnType = method.getReturnType();
		StringBuilder builder = new StringBuilder();
		builder.append(chooseModifier(modifier)).append(" ")
			.append(returnType.getName()).append(" ")
			.append(methodName).append("(");

		Class<?>[] parameterType = method.getParameterTypes();
		StringBuilder params = null;
		for (int i = 0; i < parameterType.length; i++) {
			builder.append(parameterType[i].getName()).append(" ");
			builder.append("$_").append(i).append(",");
			if (params == null) {
				params = new StringBuilder();
			}
			params.append("$_").append(i).append(",");
		}
		if (params != null) {
			builder.delete(builder.length() - 1, builder.length());
			params.delete(params.length() - 1, params.length());
		}
		builder.append(")");
		builder.append("{");
		if (!returnType.equals(void.class)) {
			builder.append("return").append(" ");
		}
		builder.append("delegating.").append(methodName).append("(");
		if (params != null) {
			builder.append(params);
		}
		builder.append(")").append(";");
		builder.append("}");

		CtMethod ctMethod = CtMethod.make(builder.toString(), ctClass);
		ctClass.addMethod(ctMethod);
		return ctClass;
	}

	private String chooseModifier(int modifier) {
		StringBuilder builder = new StringBuilder();
		if ((modifier & javassist.Modifier.PUBLIC) == javassist.Modifier.PUBLIC) {
			builder.append("public").append(" ");
		}
		if ((modifier & javassist.Modifier.PRIVATE) == javassist.Modifier.PRIVATE) {
			builder.append("private").append(" ");
		}
		if ((modifier & javassist.Modifier.PROTECTED) == javassist.Modifier.PROTECTED) {
			builder.append("protected").append(" ");
		}
		if ((modifier & javassist.Modifier.ABSTRACT) == javassist.Modifier.ABSTRACT) {
			builder.append("abstract").append(" ");
		}
		if ((modifier & javassist.Modifier.STATIC) == javassist.Modifier.STATIC) {
			builder.append("static").append(" ");
		}
		if ((modifier & javassist.Modifier.FINAL) == javassist.Modifier.FINAL) {
			builder.append("final").append(" ");
		}
		return builder.toString();
	}
}

审计事件内容存储实现

拦截到审计信息后,接下来要做的事情就是存储。我们定义了 EventSender 接口和 AuditingEvent 审计事件模型。

public interface EventSender {

	/**
	 * 发送审计事件列表
	 *
	 * @param events 审计事件列表
	 */
	void send(List<AuditingEvent> events);
}

@Accessors(chain = true)
@Builder
@NoArgsConstructor
@AllArgsConstructor
@EqualsAndHashCode
@ToString
@Data
public class AuditingEvent {

	/** 操作对象 */
	private String operator;

	/** 操作角色 */
	private String role;

	/** 操作时间 */
	private LocalDateTime operateDate;

	/** 业务场景 */
	private String bizScenario;

	/** 记录内容 */
	private String content;

	/** 额外信息 */
	private String extra;

	/** 返回值 */
	private String returnValue;

	/** 执行耗时 */
	private Long executionCost;

	/** 执行是否成功 */
	private Boolean success;

	/** 异常信息 */
	private String throwable;
}

在前面的 EventAuditorInterceptor#send() 方法,就是调用了这个接口。

关于接口的实现,我们预留了扩展点,可以本地日志打印、可以发送到 MQ 或者数据库,例如,本地日志打印实现代码。

@RequiredArgsConstructor
@Slf4j
public class LoggingEventSender implements EventSender {

	public static final String AUDITING_EVENT = "Auditing event: {}";

	private final Level level;

	/**
	 * 发送审计事件列表
	 *
	 * @param events 审计事件列表
	 */
	@Override
	public void send(List<AuditingEvent> events) {
		if (CollectionUtils.isEmpty(events)) {
			return;
		}

		List<String> contents = events.stream()
			.map(AuditingEvent::getContent).collect(Collectors.toList());
		switch (level) {
			case DEBUG:
				log.debug(AUDITING_EVENT, contents);
				break;
			case INFO:
				log.info(AUDITING_EVENT, contents);
				break;
			case WARN:
				log.warn(AUDITING_EVENT, contents);
				break;
		}
	}
}

为了简化配置,我们基于 Spring Boot 的自动装配机制进一步封装。

@ConditionalOnProperty(
	prefix = EventAuditorProperties.PREFIX,
	name = Conditions.ENABLED,
	havingValue = Conditions.TRUE,
	matchIfMissing = true
)
@AutoConfigureAfter(AsyncTaskExecutionAutoConfiguration.class)
@EnableEventAuditor
@EnableConfigurationProperties(EventAuditorProperties.class)
@Slf4j
@Role(BeanDefinition.ROLE_INFRASTRUCTURE)
@Configuration(proxyBeanMethods = false)
public class EventAuditorAutoConfiguration {

}

@Setter
@Getter
@ConfigurationProperties(prefix = EventAuditorProperties.PREFIX)
public class EventAuditorProperties extends EventAuditorConfig {

	public static final String PREFIX = "event-auditor";

	private boolean enabled;
}

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

	private AnnotationAttributes enableEventAuditor;

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

	@Bean
	public EventAuditorInterceptor eventAuditorInterceptor(ObjectProvider<EventAuditorConfig> eventAuditorConfig,
														   ObjectProvider<AsyncTaskExecutor> asyncTaskExecutor) {
		EventAuditorInterceptor interceptor = new EventAuditorInterceptor(eventAuditorConfig.getIfUnique(EventAuditorConfig::new));
		asyncTaskExecutor.ifAvailable(interceptor::setAsyncTaskExecutor);
		return interceptor;
	}

	@Bean
	public EventAuditorPointcutAdvisor eventAuditorPointcutAdvisor(EventAuditorInterceptor eventAuditorInterceptor) {
		EventAuditorPointcutAdvisor pointcutAdvisor = new EventAuditorPointcutAdvisor();
		pointcutAdvisor.setAdviceBeanName("eventAuditorPointcutAdvisor");
		pointcutAdvisor.setAdvice(eventAuditorInterceptor);
		if (enableEventAuditor != null) {
			pointcutAdvisor.setOrder(enableEventAuditor.getNumber("order"));
		}
		return pointcutAdvisor;
	}

	@Bean
	public CustomFunctionRegistrar customFunctionRegistrar() {
		return new CustomFunctionRegistrar();
	}
}

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

	private final Sender sender = new Sender();

	@EqualsAndHashCode
	@ToString
	@Setter
	@Getter
	public static class Sender {

		private String senderType = "logging";

		private boolean async = true;

		private final Logging logging = new Logging();

		private final Kafka kafka = new Kafka();

		private final RocketMQ rocketMQ = new RocketMQ();

		@EqualsAndHashCode
		@ToString
		@Setter
		@Getter
		public static class Logging {

			private Level level = Level.INFO;
		}

		@EqualsAndHashCode
		@ToString
		@Setter
		@Getter
		public static class Kafka {

			private String topic;
		}

		@EqualsAndHashCode
		@ToString
		@Setter
		@Getter
		public static class RocketMQ {

			private String topic;

			private String namespace;

			private String tags;

			private String keys;
		}
	}
}

从配置类可以看到,我们为消息存储提供了 3 个选项: Logging 本地日志、RocketMQKafka

代码使用示例

假设我们在 UserService#modifyUser() 修改用户信息,需要记录修改了哪些内容。

@Service("userService")
public class UserServiceImpl implements UserService {

	//...


	/**
	 * 修改用户
	 *
	 * @param cmd
	 */
	@EventAuditor(bizScenario = "'demo.users.getUserById'", operator = "#operator",
		content = "'用户' + #cmd.login + '修改了邮箱,从' + #queryOldEmail(#cmd.id) + '修改为' + #cmd.email")
	@Transactional(rollbackFor = Exception.class)
	@Override
	public Response modifyUser(UserModifyCmd cmd) {
		return userModifyCmdExe.execute(cmd);
	}

	/**
	 * 自定义函数
	 *
	 * @param id 用户ID
	 * @return 数据库值
	 */
	@CustomFunction("queryOldEmail")
	public String queryOldEmail(Long id) {
		return this.getUserById(UserByIdQry.builder().id(id).build()).getData().getEmail();
	}
}

对应的 application.yaml 配置文件设置如下。

event-auditor:
  enabled: true
  sender:
    type: logging
	# type: kafka
	# kafka:
	#   topic: demo
	# type: rocketmq
	# rocketmq:
	#   topic: demo
	#   namespace: test  

因为笔者配置了 event-auditor.sender.type=logging,所以,审计内容输出到控制台日志,如下图,当调用用户信息更新接口,在控制台打印了 用户admin修改了邮箱 字眼。

产出

对业务代码的侵入性极小,扩展性很高,可以轻松实现事件审计的记录,并存储到你想要的位置。

本文涉及的代码完全开源,感兴趣的伙伴可以查阅 eden-event-auditoreden-event-auditor-spring-boot-starter

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

ranhongdejiedao

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值