AOP + Spring-EL表达式构建优雅的系统操作日志框架

什么是操作日志

区别于"控制台日志", 操作日志的存在是为了让人查看的, 所以控制台日志那种一个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的接口方法上使用注解方式设置切面。

整体流程是这样的:

  1. 当用户请求带有注解的方法时,执行切面方法
  2. 从注解上获取操作类型,主、副实体类型和ID
  3. ProceedingJoinPoint中获取请求参数和返回结果
  4. HttpServletRequest中获取请求的IP,SessionId
  5. 使用权限校验框架获取用户ID
  6. 根据实体类型和操作类型查找匹配的描述生成策略
  7. 将以上信息传递给策略,生成操作描述字段。
  8. 写入数据库

查询日志的方式

可以作为查询条件使用的字段为:

  • 主实体类型:核心条件,应当由查询接口所在的Controller决定,用户不可修改。例如,在订单Controller下的日志查询方法,只能查询订单这一主实体类型相关的日志。
  • 主实体ID:次核心条件,应当由查询接口所在的Controller决定这个参数是留空(所有实体的日志混在一起),或是否允许由用户指定。例如:对于数据库备份服务,这个参数应当留空,因为它不存在对每个备份镜像的内容修改操作。对于用户服务,对一般用户来说这个参数不应由用户指定,因为一般用户只能查询自己的日志,而可以允许管理员指定。
  • 副实体类型:一般来说均允许用户指定
  • 操作类型:一般来说均允许用户指定
  • 时间戳:允许用户指定上下限时间

当某一Controller下的接口方法记录了日志时,该Controller下至少需要提供两个方法用于查询日志:

扫描二维码关注公众号,回复: 15182340 查看本文章
  • 根据该接口指定的主实体类型(和用户指定的主实体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中获取这两个对象,校验它们的返回类型相同之后,先做后续的步骤。

拿到两个相同类型的对象后:

  1. 再次使用反射机制拿到它们各自的所有字段和字段值列表
  2. 把两个列表中的相同字段两两合并为一组,并且把字段值相同的组筛出去。
  3. 合并后的列表也可能需要过滤掉部分的组(抽象方法)
  4. 把剩余的组的字段名和字段值分别进行格式化(抽象方法),以提高可读性;然后按照前述的格式拼接为字符串。

示例抽象类如下:

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值;当然直接定义为单个字符串也是没问题的。
  • preExpsufExp是两组EL表达式,区别在于一个在执行方法之前计算,一个在之后。后计算的一个可以使用#result来获取方法的返回值
  • requestParamresponseResult如上所述是用来指定是否记录请求参数和返回结果,默认记录,如修改密码之类的敏感操作则应设置为false

AOP切面配置

如上所述,我们以环绕方式围绕日志注解设置切面

在执行请求方法之前我们做了如下工作:

  1. 使用静态方法获取当前的HttpServletRequest对象,方法自行百度。通过HttpServletRequest对象获取SessionSessionID
  2. 通过权限框架的静态方法获取当前执行请求的用户ID
  3. 通过ProceedingJoinPoint获取请求方法的参数列表,以及各参数的值(注意在写入requestParam字段时,需要过滤掉部分参数,例如:HttpServletRequest类)
  4. 使用ProceedingJoinPoint创建了计算EL表达式所需的上下文StandardEvaluationContext
  5. 使用上下文计算了 preExp的结果

然后开始执行请求方法(调用pjp.proceed()),执行完毕之后我们做了如下工作:

  1. 把方法执行结果放入StandardEvaluationContext
  2. 计算剩余的几个表达式:sufExp,mainId,subId,至此OperationLogContext需要的部件齐全了,组装成OperationLogContext
  3. 日志字段中,除了操作描述的内容也都齐全了,创建日志对象写入这些字段。
  4. 根据注解给出的实体类型和实体Id,查找匹配的描述生成策略列表
  5. 遍历描述生成策略列表,将OperationLogContext传递给它,生成描述,取第一个非空结果。
  6. 如果所有策略均返回了空值则写入一个默认描述
  7. 将日志写入到数据库。

示例代码如下:

@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;
}

查找匹配策略的方法如下,前文提到过的继承关系匹配在这里体现:

  1. 从Spring容器中获取所有描述生成策略
  2. 筛选策略,条件为LogStrategy注解上给出的类是目标类或目标类的父类
  3. 排序策略,子类在前父类在后
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 节中,我们说到了比较需要的两个对象:

  • 更新操作执行前被操作对象的状态

  • 用户传递过来的修改参数

其中一个是必须在方法执行前获取的,所以我们干脆都在一起获取吧,在某一个更新操作的接口方法上标记注解如下

说明:

  1. 这里我们实际修改的类是用户的个人信息SystemUserInfo,它其实算作是用户的附属类,所以主实体类型是SystemUser
  2. 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实现刚才的抽象类的抽象方法:

  1. 修改前的实体对象就是preExp计算结果的第一个,修改内容对象是第二个
  2. 字段名的格式化依然从字段上的注解中获取,没注解就用字段英文名
  3. 字段值的格式化简单一点,如果是时间戳字段转换成日期时间字符串,否则直接String.valueOf
  4. 字段差异的筛选方法略有点复杂
    • 本例中使用的是Mybatis-plusupdateById方法执行数据库表更新操作
    • 此时每个字段的更新策略由字段上的@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;
    }
}

猜你喜欢

转载自blog.csdn.net/hjg719/article/details/129237048
今日推荐