目录
什么是操作日志
区别于"控制台日志", 操作日志的存在是为了让人查看的, 所以控制台日志那种一个txt文件混在一起的情况是不符合要求的。
本文使用到的技术栈
加粗的为重要
- Spring boot 3
- Jackson
- AOP + Spring-EL表达式
- Mybatis + Mybatis-plus (查询和更新操作)
- Spring Data JPA (字段名格式化时需要读取它的注解,也可以自定义注解)
- Spring doc (swagger) (字段名格式化时需要读取它的注解,也可以自定义注解)
- Lombok
- 权限校验框架: Shiro 或 Spring Security
本次我们不涉及模糊查询功能,不引入ES等技术栈。
我们想要实现的效果
日志表需要记录的字段
字段 | 非空性 | 说明 |
---|---|---|
时间戳 | ✓ | 精确到秒 |
操作类型 | ✓ | 这应该是一个枚举类型, 至少包括4种典型操作:增删改查; 可能还包括: 重置, 上传,下载,登陆,登出等 |
操作人 | 使用用户ID关联或者直接登记姓名,由权限校验框架提供 | |
操作人IP | 由WEB框架提供 | |
主实体类型 | ✓ | 被操作的实体类型(写包名), 例如登陆登出的主实体类型即为 本系统的用户类 |
主实体ID | ✓ | 被操作实体的唯一编号 |
副实体类型 | ||
副实体ID | ||
使用的策略 | 描述生成策略的包名,后详 | |
请求参数 | 请求中使用的参数 , 因为可能存在敏感信息 , 应当允许选择是否记录 | |
响应结果 | 请求的响应内容 , 因为可能存在敏感信息 , 应当允许选择是否记录 | |
操作描述 | [重要] ,后详 | |
会话ID | 可以通过SessionId看出用户多端登陆的情况, 与IP的含义有所不同 | |
执行耗时 |
其中:
- 副实体类型:有时候我们实际操作的是实体类B , 但是希望该操作登记在实体类A名下,如: 用户个人信息与用户分表存储的情况 。 此时, 主实体类型 = 用户 , 副实体类型 = 用户个人信息
- 操作描述:需要根据一定的规则(即策略)生成一段一般用户能够看懂的代表这一次操作内容的描述信息,策略应当根据被操作的实体的类型和操作类型有所不同。 同时应当有通用的默认策略,避免相同策略重复书写。对于4种典型操作而言,它的默认策略大致应该是这样的
- 添加:
新增 [实体类型] ID:xxx [某些关键字段数据]
- 修改:
修改 xxx 字段,从 aa 更新为 bb
字段名应该有可读性(中文),不记录没有修改的字段;对于一些不太有可读性的字段(与其他表关联的外键xxxId
, 枚举类, 内部约定的表达方式等),应该可以转换为有可读性的描述 - 查询:
查询了 [方法名] 查询参数为 xxx
- 删除:
删除了 xxx
,一般情况下不应该删除主实体类(那样可能就不再能查询到它的日志),而是删除了实体名下的副实体类型
- 添加:
另外,如果日志表的记录数过大会对查询性能有影响,需要分表存储,同时过于古早的日志可能也没有保存价值了可以删除。这里我们进行简单设计:
- 使用两张结构完全相同的表,分别记录比较新的(如半年内的)日志,和历史日志。
- 每天凌晨时段把新表中的过期日志搬运到历史表中。
- 每天凌晨把历史表中过于古早的(如超过2年)的日志删除。
- 查询方法提供一个参数决定从新表还是旧表查询。
写日志的方式
我们希望记录日志的操作与业务逻辑解耦,考虑使用AOP
的方式编写日志框架。同时由于不是所有操作均需要记录日志,考虑在Controller
的接口方法上使用注解
方式设置切面。
整体流程是这样的:
- 当用户请求带有注解的方法时,执行切面方法
- 从注解上获取操作类型,主、副实体类型和ID
- 从
ProceedingJoinPoint
中获取请求参数和返回结果 - 从
HttpServletRequest
中获取请求的IP,SessionId - 使用权限校验框架获取用户ID
- 根据实体类型和操作类型查找匹配的描述生成策略
- 将以上信息传递给策略,生成操作描述字段。
- 写入数据库
查询日志的方式
可以作为查询条件使用的字段为:
- 主实体类型:核心条件,应当由查询接口所在的
Controller
决定,用户不可修改。例如,在订单
的Controller
下的日志查询方法,只能查询订单
这一主实体类型相关的日志。 - 主实体ID:次核心条件,应当由查询接口所在的
Controller
决定这个参数是留空(所有实体的日志混在一起),或是否允许由用户指定。例如:对于数据库备份服务
,这个参数应当留空,因为它不存在对每个备份镜像的内容修改操作。对于用户服务
,对一般用户来说这个参数不应由用户指定,因为一般用户只能查询自己的日志,而可以允许管理员指定。 - 副实体类型:一般来说均允许用户指定
- 操作类型:一般来说均允许用户指定
- 时间戳:允许用户指定上下限时间
当某一Controller
下的接口方法记录了日志时,该Controller
下至少需要提供两个方法用于查询日志:
- 根据该接口指定的主实体类型(和用户指定的主实体ID,如果允许的话),给出可选择的副实体类型,以及选择每个副实体类型时可选的操作类型。
- 根据用户给出的上述查询条件,执行
分页查询
由于在每个Controller
中这两个查询方法的逻辑均相同,考虑使用接口类(interface
)默认实现它们,而Controller
实现这一接口类时,只需要决定主实体类型、主实体ID即可。如果Controller
需要做权限校验,则可以重写这两个方法,加入校验注解或者在方法体中加入校验逻辑。
部分核心代码
操作类型
操作类型是一个枚举类,并且使用@JsonValue
注解来让它被返回到前端时自动转换为对应中文
@RequiredArgsConstructor
public enum OperationType {
ADD("添加"),
DEL("删除"),
UPDATE("修改"),
QUERY("查询"),
LOGIN("登录"),
LOGIN_FAILED("登录失败"),
LOGOUT("登出"),
DOWNLOAD("下载"),
UPLOAD("上传"),
BACKUP("备份"),
RECOVER("还原"),
;
final String name;
@JsonValue
public String getName() {
return name;
}
@Override
public String toString() {
return String.format("%s(%s)", name, name());
}
}
描述生成策略
描述生成策略是一个接口类,它使用上下文来生成一段字符串:
public interface DescriptionStrategy {
String generateDescription(OperationLogContext context);
}
其中上下文OperationLogContext
,是一个记录类,它会由切面方法来构造:
public record OperationLogContext(
//被操作的实体的类型
Class<?> entityClass,
//被操作的实体ID
Long entityId,
// 方法参数和参数值
List<ParamArg> paramArgs,
//方法执行结果
Object result,
//执行方法之前计算的 Spring-EL 表达式 结果
List<Object> preExp,
//执行方法之前计算的 Spring-EL 表达式 结果
List<Object> sufExp,
//操作类型
OperationType type,
//请求
HttpServletRequest request) {
}
前面我们提到过:“策略应当根据被操作的实体的类型和操作类型有所不同” , 即应当使用被操作的实体的类型
和操作类型
来匹配策略,所以我们需要在描述生成策略的实现类
上使用@Component
注解把它交给容器管理,再使用一个注解来标记这两个值:
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface LogStrategy {
/**
* 匹配的实体类型
*/
Class<?> value() default Object.class;
/**
* 匹配的操作类型
*/
OperationType type();
}
注意,如果我们的匹配方式只是等于
的话就太过死板了,类的继承关系意味着具有相同父类的两个子类(也许)可以使用相同的描述生成策略,所以我们应该按照先本类,再逐级往上查找父类的方式来匹配;而Object
类是所有类的父类,所以这里实体类型给了默认值Object.class
,表示当不配置该值时,这个实现类就是通用的默认策略。
例如一个默认的添加
描述策略可以这样写:
import io.swagger.v3.oas.annotations.media.Schema;
@Component
@LogStrategy(type = OperationType.ADD)
public class DefaultAddStrategy implements DescriptionStrategy {
@Override
public String generateDescription(OperationLogContext context) {
// 如果 data 是 vo 类型, 使用 vo 的注解生成描述
if (context.result() instanceof Res<?> res && res.getData() != null && res.getData() instanceof BaseVo vo) {
List<String> des = new ArrayList<>();
ReflectUtils.getAllFieldValues(vo).stream().filter(f -> f.value() != null).forEach(fieldValue -> {
final Field field = fieldValue.field();
final Schema schema = field.getAnnotation(Schema.class);
// 字段标题
final String label = schema != null ? schema.description() : field.getName();
// 字段值
final String value = field.getName().contains("time") && field.getType().equals(Long.class) ? TimeUtils.format(((Long) fieldValue.value())) : String.valueOf(
fieldValue.value());
des.add(String.format("%s: %s", label, value));
});
return String.join(", ", des);
}
return null;
}
}
这里我们假设了添加接口会把被添加的对象返回给前端,当满足这个条件时,使用这个返回对象来生成一段描述。具体操作是:使用反射机制获取这个返回对象的每个字段及其对应值,使用各字段上的Schema
注解的描述属性(它描述了字段含义) + 字段值 ,连接成一个字符串。当然如果你没有使用swagger
或者类似的依赖,自定义一个注解也是完全可以的。
"更新"操作的默认策略 - 1
前面我们提到了更新
操作的策略是特别的,总结一下:
- 需要比较修改了哪些字段(而不仅仅是用户传递了哪些字段),修改前后的值是什么;而不记录没有修改的字段。
- 修改的字段名需要转换为高可读性。
- 修改前、后的值需要转换为高可读性(如果需要)。
比较的时候我们需要两个对象:
-
更新操作执行前被操作对象的状态
-
用户传递过来的修改参数
因为二者通常不会是同一个类(字段无法匹配),我们需要想办法把后者转换成与前者相同的类。这一步我们还不知道该怎么做,或者也有可能不同策略有不同。那么我们先写一个抽象类,用抽象方法从上下文OperationLogContext
中获取这两个对象,校验它们的返回类型相同之后,先做后续的步骤。
拿到两个相同类型的对象后:
- 再次使用反射机制拿到它们各自的所有字段和字段值列表
- 把两个列表中的相同字段两两合并为一组,并且把字段值相同的组筛出去。
- 合并后的列表也可能需要过滤掉部分的组(抽象方法)
- 把剩余的组的字段名和字段值分别进行格式化(抽象方法),以提高可读性;然后按照前述的格式拼接为字符串。
示例抽象类如下:
public abstract class AbstractUpdateStrategy implements DescriptionStrategy {
/**
* 生成描述(比较修改了哪些字段
* @param context 上下文
* @return 描述
*/
@Override
public final String generateDescription(OperationLogContext context) {
final Object beforeEntity = getBeforeEntity(context);
final Object updateEntity = getUpdateEntity(context);
if (beforeEntity == null || updateEntity == null) {
log.warn("两个实体不全, 无法比较, 原实体: {} 修改内容: {}", beforeEntity != null, updateEntity != null);
return null;
}
if (!beforeEntity.getClass().equals(updateEntity.getClass())) {
log.warn("两个实体的类型不同, 无法比较: {} -> {}", beforeEntity.getClass(), updateEntity.getClass());
return null;
}
//获取他们的所有字段和字段值
final List<FieldValue> beforeFieldValues = ReflectUtils.getAllFieldValues(beforeEntity);
final List<FieldValue> updateFieldValues = ReflectUtils.getAllFieldValues(updateEntity);
// 合并好的字段差异(已过滤掉值相同部分)
final List<FieldDifference<Field, Object>> differences = FieldDifference.merge(beforeFieldValues, updateFieldValues);
// 过滤掉部分字段
final List<FieldDifference<Field, Object>> filteredDifferences = filter(differences);
// 字段差异
return filteredDifferences.size() == 0 ? "未做修改" : filteredDifferences.stream()
// 字段差异格式化
.map(dif -> {
final String fieldName = formatField(dif.field());
final String beforeValue = formatValue(dif.field(), dif.beforeValue());
final String updateValue = formatValue(dif.field(), dif.updateValue());
return new FieldDifference<>(fieldName, beforeValue, updateValue);
})
// 连接成字符串
.map(d -> String.format("[%s] 从 '%s' 更新为 '%s'", d.field(), d.beforeValue(), d.updateValue()))
.collect(Collectors.joining(", "));
}
/**
* 过滤字段差异(筛选掉部分字段)
* @param differences 字段差异
* @return 字段差异
*/
public abstract List<FieldDifference<Field, Object>> filter(List<FieldDifference<Field, Object>> differences);
/**
* 格式化字段值
* @param field 字段
* @param value 字段值
* @return 字段值
*/
public abstract String formatValue(Field field, Object value);
/**
* 格式化字段名
* @param field 字段
* @return 字段名
*/
public abstract String formatField(Field field);
/**
* 获取修改内容的实体对象
* @param context 上下文
* @return 修改内容的实体对象
*/
@Nullable
public abstract Object getUpdateEntity(OperationLogContext context);
/**
* 获取修改前的实体对象
* @param context 上下文
* @return 修改前的实体对象
*/
@Nullable
public abstract Object getBeforeEntity(OperationLogContext context);
}
日志注解和AOP切面
日志注解
如上所述,我们需要在Controller
的接口方法上使用注解来标记需要记录日志的操作
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface OpLog {
/**
* 主实体类型
*/
Class<?> mainClass() default Object.class;
/**
* 主实体ID Spring-EL 表达式
*/
String[] mainId() default {"#id", "#result?.data?.id", "#form?.id"};
/**
* 副实体类型
*/
Class<?> subClass() default Object.class;
/**
* 副实体ID Spring-EL 表达式
*/
String[] subId() default {"#id", "#result?.data?.id", "#form?.id"};
/**
* 操作类型
*/
OperationType type();
/**
* 执行方法之前计算的 Spring-EL 表达式
*/
String[] preExp() default {};
/**
* 执行方法之后计算的 Spring-EL 表达式
*/
String[] sufExp() default {"#result?.data"};
/**
* 是否记录请求参数
*/
boolean requestParam() default true;
/**
* 是否记录返回结果
*/
boolean responseResult() default true;
}
其中:
- 主、副实体ID是一个字符串数组,但是实际上我们应该给出单个确定的值,这里的默认值是几种我个人常用的情况,后续逻辑我对应设置为取计算结果中第一个非Null值;当然直接定义为单个字符串也是没问题的。
preExp
和sufExp
是两组EL表达式,区别在于一个在执行方法之前计算,一个在之后。后计算的一个可以使用#result
来获取方法的返回值requestParam
和responseResult
如上所述是用来指定是否记录请求参数和返回结果,默认记录,如修改密码之类的敏感操作则应设置为false
AOP切面配置
如上所述,我们以环绕方式围绕日志注解设置切面
在执行请求方法之前我们做了如下工作:
- 使用静态方法获取当前的
HttpServletRequest
对象,方法自行百度。通过HttpServletRequest
对象获取Session
和SessionID
- 通过权限框架的静态方法获取当前执行请求的用户ID
- 通过
ProceedingJoinPoint
获取请求方法的参数列表,以及各参数的值(注意在写入requestParam字段时,需要过滤掉部分参数,例如:HttpServletRequest
类) - 使用
ProceedingJoinPoint
创建了计算EL表达式所需的上下文StandardEvaluationContext
- 使用上下文计算了
preExp
的结果
然后开始执行请求方法(调用pjp.proceed()
),执行完毕之后我们做了如下工作:
- 把方法执行结果放入
StandardEvaluationContext
中 - 计算剩余的几个表达式:
sufExp
,mainId
,subId
,至此OperationLogContext
需要的部件齐全了,组装成OperationLogContext
- 日志字段中,除了
操作描述
的内容也都齐全了,创建日志对象写入这些字段。 - 根据注解给出的实体类型和实体Id,查找匹配的描述生成策略列表
- 遍历描述生成策略列表,将
OperationLogContext
传递给它,生成描述,取第一个非空结果。 - 如果所有策略均返回了空值则写入一个默认描述
- 将日志写入到数据库。
示例代码如下:
@Around("@annotation(opLog)")
public Object around(ProceedingJoinPoint pjp, OpLog opLog) throws Throwable {
final long start = now();
//静态方法获取 HttpServletRequest
final HttpServletRequest request = WebUtils.getHttpServletRequest();
final HttpSession session = request != null ? request.getSession() : null;
// 权限框架获取 userId
final MyUserDetails userDetails = MySecurityUtils.currentUserDetails();
// 请求参数和参数值
final List<ParamArg> paramArgs = ParamArg.parse(pjp);
final Class<?> mainClass = opLog.mainClass();
// 副类型如果为 object 置为null
final Class<?> subClass = !Object.class.equals(opLog.subClass()) ? opLog.subClass() : null;
// 操作类型
final OperationType type = opLog.type();
final StandardEvaluationContext evaluationContext = SpElUtils.createContext(pjp);
final List<Object> preExp = SpElUtils.getElValues(evaluationContext, opLog.preExp());
final Object result = pjp.proceed();
// SpEl上下文
evaluationContext.setVariable("result", result);
// 计算 SpEl表达式
final List<Object> sufExp = SpElUtils.getElValues(evaluationContext, opLog.sufExp());
final Long mainId = SpElUtils.getElNotnullLong(evaluationContext, opLog.mainId()).stream().findFirst().orElse(null);
final Long subId = subClass != null ? SpElUtils.getElNotnullLong(evaluationContext, opLog.subId()).stream().findFirst().orElse(null) : null;
if (mainId == null) {
log.warn("日志注解配置错误: mainId 计算结果为 null");
return result;
}
// 计算其他表达式
// 匹配描述策略
// 实际操作的实体类对象,用于匹配策略
final Class<?> entityClass = subClass != null ? subClass : mainClass;
final Long entityId = subClass != null ? subId : mainId;
// 上下文
final OperationLogContext context = new OperationLogContext(entityClass, entityId, paramArgs, result, preExp, sufExp, type, request);
// 日志
final SystemOperationLog operationLog = new SystemOperationLog();
operationLog.setSessionId(session != null ? session.getId() : null);
operationLog.setType(type);
operationLog.setUserId(userDetails.getId());
operationLog.setUserIp(WebUtils.getRemoteHost(request));
operationLog.setMainClass(mainClass);
operationLog.setMainId(mainId);
operationLog.setSubClass(subClass);
operationLog.setSubId(subId);
operationLog.setRequestParam(opLog.requestParam() ? getRequestParam(context) : null);
operationLog.setResponseResult(opLog.responseResult() ? getResponseResult(context) : null);
//描述生成策略
final List<DescriptionStrategy> strategies = findStrategies(entityClass, type);
// 如果策略非空,尝试使用策略生成描述
if (!CollectionUtils.isEmpty(strategies)) {
for (DescriptionStrategy strategy : strategies) {
final Class<?> strategyClass = strategy.getClass().getAnnotation(LogStrategy.class).value();
// 生成描述
final String description = strategy.generateDescription(context);
// 输出的描述非空 则应用
if (!ObjectUtils.isEmpty(description)) {
if (!strategyClass.equals(entityClass)) {
log.debug("非专用策略 策略:{} 实体:{}", strategyClass, entityClass);
}
operationLog.setStrategyClass(strategy.getClass());
operationLog.setDescription(description);
operationLog.setTimeCost(now() - start);
logService.write(operationLog);
return result;
}
}
}
final String msg = "未找到匹配的描述策略";
final String des = String.format("%s class:%s type:%s mainId:%s", msg, entityClass, type, mainId);
log.warn(des);
operationLog.setDescription(msg);
operationLog.setTimeCost(now() - start);
logService.write(operationLog);
return result;
}
其中,生成上下文StandardEvaluationContext
的方法如下,这里把请求方法的参数,Spring管理的Bean,和权限框架提供的用户信息均放入了上下文中。
/**
* 生成表达式上下文
* @param joinPoint 接触点
* @return spEl表达式上下文
*/
public static StandardEvaluationContext createContext(JoinPoint joinPoint) {
final ApplicationContext applicationContext = SpringContextUtils.getContext();
final StandardEvaluationContext context = new StandardEvaluationContext(applicationContext);
Object[] args = joinPoint.getArgs();
MethodSignature methodSignature = (MethodSignature) joinPoint.getSignature();
Method targetMethod = methodSignature.getMethod();
StandardReflectionParameterNameDiscoverer parameterNameDiscoverer = new StandardReflectionParameterNameDiscoverer();
String[] parametersName = parameterNameDiscoverer.getParameterNames(targetMethod);
if (args == null || args.length == 0) {
return context;
}
for (int i = 0; i < args.length; i++) {
//noinspection DataFlowIssue
context.setVariable(parametersName[i], args[i]);
}
context.setBeanResolver(new BeanFactoryResolver(applicationContext));
context.setVariable("userDetail", MySecurityUtils.currentUserDetails());
return context;
}
查找匹配策略的方法如下,前文提到过的继承关系匹配在这里体现:
- 从Spring容器中获取所有描述生成策略
- 筛选策略,条件为
LogStrategy
注解上给出的类是目标类或目标类的父类 - 排序策略,子类在前父类在后
private static List<DescriptionStrategy> findStrategies(Class<?> entityClass, OperationType type) {
return SpringContextUtils.getContext().getBeansOfType(DescriptionStrategy.class).values().stream().filter(s -> {
// 过滤出有注解的,操作类型匹配的,实体类型包含的策略
final LogStrategy annotation = s.getClass().getAnnotation(LogStrategy.class);
return annotation != null && type.equals(annotation.type()) && annotation.value().isAssignableFrom(entityClass);
}).sorted((o1, o2) -> {
// 排序 ,子类在前
final Class<?> c1 = o1.getClass().getAnnotation(LogStrategy.class).value();
final Class<?> c2 = o2.getClass().getAnnotation(LogStrategy.class).value();
if (c1.equals(c2)) {
return 0;
}
if (c1.isAssignableFrom(c2)) {
return 1;
}
if (c2.isAssignableFrom(c1)) {
return -1;
}
return 0;
}).toList();
}
ParamArg类如下:
public record ParamArg(Parameter parameter, Object arg) {
public static List<ParamArg> parse(ProceedingJoinPoint pjp) {
//签名
final MethodSignature signature = (MethodSignature) pjp.getSignature();
//方法
final Method method = signature.getMethod();
final Object[] args = pjp.getArgs();
final Parameter[] parameters = method.getParameters();
final ArrayList<ParamArg> list = new ArrayList<>();
for (int i = 0; i < parameters.length; i++) {
final Parameter param = parameters[i];
final Object arg = args[i];
list.add(new ParamArg(param, arg));
}
return list;
}
}
"更新"操作的默认策略 - 2
在 1 节中,我们说到了比较需要的两个对象:
-
更新操作执行前被操作对象的状态
-
用户传递过来的修改参数
其中一个是必须在方法执行前获取的,所以我们干脆都在一起获取吧,在某一个更新操作的接口方法上标记注解如下
说明:
- 这里我们实际修改的类是用户的个人信息
SystemUserInfo
,它其实算作是用户的附属类,所以主实体类型是SystemUser
preExp
的第一个值表示从数据库中查询出当前用户的个人信息(原数据),第二个值表示调用form对象的build方法生成一个SystemUserInfo
对象(新数据),这样原数据和新数据就是同一个类型了
@OpLog(type = OperationType.UPDATE, mainClass = SystemUser.class, mainId = "#userDetail?.id", subClass = SystemUserInfo.class
, preExp = {"@systemUserInfoServiceImpl.getByUserId(#userDetail.id)", "#form.build(#userDetail.id)"}
)
public Res<SystemUserInfoVo> userInfoUpdate(@RequestBody @Validated SystemUserInfoForm form) {
......
}
其中:
@Getter
@Setter
@Schema(description = "用户个人信息表单")
@Validated
public class SystemUserInfoForm {
@Schema(description = "生日(UNIX秒)")
Long birthday;
@Schema(description = "昵称")
@NotEmpty
String nickname;
@Schema(description = "联系电话")
@Phone(nullable = true)
String phone;
public SystemUserInfo build(long userId) {
final SystemUserInfo userInfo = new SystemUserInfo();
BeanUtils.copyProperties(this, userInfo);
userInfo.setUserId(userId);
return userInfo;
}
}
现在我们新建一个类DefaultUpdateStrategy
实现刚才的抽象类的抽象方法:
- 修改前的实体对象就是
preExp
计算结果的第一个,修改内容对象是第二个 - 字段名的格式化依然从字段上的注解中获取,没注解就用字段英文名
- 字段值的格式化简单一点,如果是时间戳字段转换成日期时间字符串,否则直接
String.valueOf
- 字段差异的筛选方法略有点复杂
- 本例中使用的是
Mybatis-plus
的updateById
方法执行数据库表更新操作 - 此时每个字段的更新策略由字段上的
@TableField
注解的updateStrategy
属性指定,默认情况下如果字段值为null
则表示不更新该字段(而不是设置为null
) - 因此我们的过滤逻辑也要和它保持一致
- 本例中使用的是
示例代码如下:
@Component
@LogStrategy(type = OperationType.UPDATE)
public class DefaultUpdateStrategy extends AbstractUpdateStrategy {
/**
* 过滤字段差异(筛选掉部分字段)
* @param differences 字段差异
* @return 字段差异
*/
@Override
public List<FieldDifference<Field, Object>> filter(List<FieldDifference<Field, Object>> differences) {
return differences.stream().filter(dif -> {
// 字段
final Field field = dif.field();
// 修改字段值
final Object updateValue = dif.updateValue();
final TableField tableField = field.getAnnotation(TableField.class);
if (tableField == null || tableField.updateStrategy() == FieldStrategy.DEFAULT || tableField.updateStrategy() == FieldStrategy.NOT_NULL) {
// 默认情况下, 修改值不为 null 则执行更新
return updateValue != null;
} else if (tableField.updateStrategy() == FieldStrategy.NOT_EMPTY) {
// NOT_EMPTY 模式下 修改值不为 空 则执行更新
return !ObjectUtils.isEmpty(updateValue);
} else {
// IGNORED 模式下总是更新 NEVER模式下总是不更新
return tableField.updateStrategy() == FieldStrategy.IGNORED;
}
}).collect(Collectors.toList());
}
/**
* 格式化字段名
* @param field 字段
* @return 字段名
*/
@Override
public String formatField(Field field) {
final Comment comment = field.getAnnotation(Comment.class);
final Schema schema = field.getAnnotation(Schema.class);
// 字段名翻译
return comment != null ? comment.value() : (schema != null ? schema.description() : field.getName());
}
/**
* 格式化字段值
* @param field 字段
* @param value 字段值
* @return 字段值
*/
@Override
public String formatValue(Field field, Object value) {
if (value instanceof Long time) {
final String fieldName = field.getName();
if (fieldName.contains("time") || "birthday".equals(fieldName)) {
// 属于时间戳字段
return TimeUtils.format(time);
}
}
return String.valueOf(value);
}
/**
* 获取修改前的实体对象
* @param context 上下文
* @return 修改前的实体对象
*/
@Nullable
@Override
public Object getBeforeEntity(OperationLogContext context) {
// 从preExp第1个元素传入
final List<Object> list = context.preExp();
return list.size() > 0 ? list.get(0) : null;
}
/**
* 获取修改内容的实体对象
* @param context 上下文
* @return 修改内容的实体对象
*/
@Nullable
@Override
public Object getUpdateEntity(OperationLogContext context) {
// 从preExp第2个元素传入
final List<Object> list = context.preExp();
return list.size() > 1 ? list.get(1) : null;
}
}