基于Mybatis-plus的字段类型处理

1.写在前面

在实际项目中,我们经常会遇到对数据库的固定字段进行特殊处理的场景:

  1. 新增或修改时,需要对操作人和操作时间的这类固定字段进行填充
  2. 对敏感字段(手机号码和密码)进行加解密脱敏
  3. 删除记录时使用假删除且查询的时候自动过滤结果

......

那么,要怎样优雅的实现这些功能呢?

2.技术选型

2.1数据库触发器

数据库的触发器确实可以实现自动插入时间的功能,但它存在很多问题:

  • 只能填充当前时间,无法填充操作人
  • 加解密无法调用自定义方法
  • 相比于代码层面的配置,缺乏直观性,哪些表配了,哪些没配,不能一目了然
  • 缺乏全局性,表的数量少还好,表一多就要每张表都进行处理,非常繁琐

因此数据库的触发器的方案我们不考虑。

2.2Mybatis拦截器+自定义注解

网上有很多关于Mybatis拦截器里处理请求的方案,它的优点是可以全局配置,一次对所有表应用,但是缺点是当你同时要实现上文提到的三种功能时,就需要在拦截器连续判断,这导致代码非常臃肿,可读性很差。比如说像这样:

java 复制代码
@Override
  public Object intercept(Invocation invocation) throws Throwable {
    //获取参数
    MappedStatement mappedStatement = (MappedStatement) invocation.getArgs()[0];
    Object object = invocation.getArgs()[1];
 
    //获取sql类型 :insert/update
    SqlCommandType sqlCommandType = mappedStatement.getSqlCommandType();
 
    //获取填充规则集合
    Map<String, Method> fillRuleMap = MybatisInterceptorVariable.getFillRuleMap();
 
    //存在ColumnFill注解的字段进行填充
    //无法直接获取父类,getDeclaredFields只能获取本类的字段列表,所以要先获取父类字段
    Field[] superFields = object.getClass().getSuperclass().getDeclaredFields();
    Field[] subFields = object.getClass().getDeclaredFields();
    Field[] allFields = ArrayUtil.addAll(superFields, subFields);
 
    for (Field field : allFields) {
      //获取标记ColumnFill自定义注解的字段
      ColumnFill columnFill = field.getAnnotation(ColumnFill.class);
      if (columnFill != null) {
        //存在注解则执行以下内容
        //获取注解属性值,判断拦截的类型insert/update
        if (sqlCommandType.equals(columnFill.fillType())) {
          field.setAccessible(true);
          //填充规则匹配
          if (fillRuleMap.containsKey(columnFill.name())) {
            field.set(object, fillRuleMap.get(columnFill.name()).invoke(MybatisFillRuleUtil.class.newInstance(), null));
          }
        }
      }
    }
    return invocation.proceed();
  }

因此,从代码的优雅性和可读性上考虑,我最后放弃了这一思路。

2.3Mybatis-plus

在mybatis-plus中,官方提供了注解、字段类型处理器(typeHandler )两种策略来帮助开发者优雅的对特殊字段进行处理(官网地址:baomidou.com/),下面将使用mybatis-plus 来解决开头提到的三种需求。

3.环境准备

项目基础配置:
JDK: java 8
SpringBoot :2.7.5
mybatis-plus :3.4.2

我这里建立一张人员表进行演示,数据库设计如下:

代码地址在附录

4.代码

4.1固定字段填充

4.11需求

A 新增时对于以下字段进行自动填充:创建时间(create_at)、创建人(create_by)、假删除(is_delete);
B 修改时对以下字段进行填充:修改时间(update_at)、修改人(update_by)。

4.12实现原理

mybatis-plus 官网中,可以使用@TableField注解和元对象处理方法MetaObjectHandler 进行处理

在@TableField注解中有个fill 的参数,可以规定触发填充的动作,官方提供的有:新增、更新、默认、新增或更新四种

在注解标注后,我们可以通过实现接口类MetaObjectHandler 中的两个方法:insertFillupdateFill来实现对标注内容的填充,这里有官方的案例:

java 复制代码
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler {

    @Override
    public void insertFill(MetaObject metaObject) {
        log.info("start insert fill ....");
        this.strictInsertFill(metaObject, "createTime", LocalDateTime.class, LocalDateTime.now()); // 起始版本 3.3.0(推荐使用)
        // 或者
        this.strictInsertFill(metaObject, "createTime", () -> LocalDateTime.now(), LocalDateTime.class); // 起始版本 3.3.3(推荐)
        // 或者
        this.fillStrategy(metaObject, "createTime", LocalDateTime.now()); // 也可以使用(3.3.0 该方法有bug)
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        log.info("start update fill ....");
        this.strictUpdateFill(metaObject, "updateTime", LocalDateTime.class, LocalDateTime.now()); // 起始版本 3.3.0(推荐)
        // 或者
        this.strictUpdateFill(metaObject, "updateTime", () -> LocalDateTime.now(), LocalDateTime.class); // 起始版本 3.3.3(推荐)
        // 或者
        this.fillStrategy(metaObject, "updateTime", LocalDateTime.now()); // 也可以使用(3.3.0 该方法有bug)
    }
}

4.13重要代码

  1. 添加@TableField注解

在上文提到的需要自动填充的属性中添加@TableField注解,并且指定fill动作:

java 复制代码
/**
     * 创建时间
     */
    @TableField(value = "create_at",fill = FieldFill.INSERT)
    private String createAt;

    /**
     * 创建人
     */
    @TableField(value = "create_by",fill = FieldFill.INSERT)
    private String createBy;

    /**
     * 更新时间
     */
    @TableField(value = "update_at",fill = FieldFill.UPDATE)
    private String updateAt;

    /**
     * 更新人
     */
    @TableField(value = "update_by",fill = FieldFill.UPDATE)
    private String updateBy;

    /**
     * 假删除的标志
     */
    @TableField(value = "is_delete",fill =FieldFill.INSERT)
    @TableLogic(value = "0",delval = "1")
    private Integer isDelete;
  1. 自定义填充方法

代码如下:

java 复制代码
@Slf4j
@Component
public class MyMetaObjectHandler implements MetaObjectHandler { 
    /**
     * 没有被逻辑删除的标志
     */
    private static final int NOT_DELETE_FLAG = 0;
    @Override
    public void insertFill(MetaObject metaObject) {
        this.strictInsertFill(metaObject, "createAt", String.class,DateTools.getCurrentTime());
        this.strictInsertFill(metaObject, "createBy", String.class,ContextUtil.getCurrentUserId());
        this.strictInsertFill(metaObject, "isDelete", Integer.class,NOT_DELETE_FLAG);
    }

    @Override
    public void updateFill(MetaObject metaObject) {
        this.fillStrategy(metaObject, "updateAt", DateTools.getCurrentTime());
        this.fillStrategy(metaObject, "updateBy", ContextUtil.getCurrentUserId());
    }
}
  1. 实现新增功能

controller里我简单写了新增和更新的代码,具体如下:

java 复制代码
/**
     * 新增用户
     *
     * @param userInsertDTO 用户信息
     * @return 新增结果
     */
    @PostMapping("/insert")
    @Transactional(rollbackFor = Exception.class)
    public ResponseResult<String> insert(@RequestBody @Valid UserInsertDTO userInsertDTO) {
        ResponseResult<String> responseResult = new ResponseResult<>();
        User user = userInsertDTO.translate2Entity();
        boolean insertResult = userService.save(user);
        return insertResult ? responseResult.setHttpResultEnum(HttpResultEnum.SUCCESS) : responseResult.setHttpResultEnum(HttpResultEnum.INSERT_FAIL);
    }
/**
     * 更新
     * @param userUpdateDTO 用户信息
     * @return  更新结果
     */
    @PutMapping("/update")
    @Transactional(rollbackFor = Exception.class)
    public ResponseResult<String> update(@RequestBody @Valid UserUpdateDTO userUpdateDTO){
        ResponseResult<String> responseResult = new ResponseResult<>();
        User user=userService.getById(userUpdateDTO.getId());
        if(user==null){
            return responseResult.setHttpResultEnum(HttpResultEnum.PARAM_IS_ERROR);
        }
        boolean updateResult = userService.updateById(userUpdateDTO.translate2Entity());
        return updateResult?responseResult.setHttpResultEnum(HttpResultEnum.SUCCESS):responseResult.setHttpResultEnum(HttpResultEnum.UPDATE_FAIL);
    }

在UserInsertDTO和UserUpdateDTO里,我实现了转化为entity的方法,具体如下:

java 复制代码
@Data
@Valid
public class UserInsertDTO {
    /**
     * 用户名
     */
    @NotBlank(message = "用户名称不能为空")
    @Max(value = 45, message = "用户名称长度不能超过45个字符")
    private String userName;
    /**
     * 密码
     */
    @NotBlank(message = "密码不能为空")
    @Max(value = 15, message = "密码长度不能超过45个字符")
    private String password;
    /**
     * 手机号
     */
    @NotBlank(message = "手机号不能为空")
    @Max(value = 15, message = "手机号长度不能超过15个字符")
    private String phone;

    public User translate2Entity() {
        User user = new User();
        user.setUserName(userName);
        user.setPassword(password);
        user.setPhone(phone);
        user.setId(UUID.randomUUID().toString());
        return user;
    }
}
java 复制代码
@Data
@Valid
public class UserUpdateDTO {
    /**
     * 用户id
     */
    @NotBlank(message = "用户id不能为空")
    private String id;
    /**
     * 用户名
     */
    @NotBlank(message = "用户名称不能为空")
    @Max(value = 45, message = "用户名称长度不能超过45个字符")
    private String userName;
    /**
     * 密码
     */
    @NotBlank(message = "密码不能为空")
    @Max(value = 15, message = "密码长度不能超过45个字符")
    private String password;
    /**
     * 手机号
     */
    @NotBlank(message = "手机号不能为空")
    @Max(value = 15, message = "手机号长度不能超过15个字符")
    private String phone;

    public User translate2Entity() {
        User user = new User();
        user.setId(this.id);
        BeanUtils.copyProperties(this, user);
        return user;
    }
}

4.14测试

这里在新增时打个断点,在执行插入语句之前的结果如下:

这里可以看到在调用自动填充之前,字段没有进行任何处理,这里使用apifox 进行测试,测试用例及返回如下:

这里可以看到返回结果正常,我们再去数据库看一下:

这里可以看到插入成功,并且固定字段已经自动填充,同理,我们再进行更新操作。这里直接展示数据库的更新结果:

4.2假删除及过滤

4.2.1需求

A. 执行删除的时候将假删除字段进行标记
B. 查询的时候自动过滤假删除数据

4.2.2实现原理

mybatis-plus 官方提供了@TableLogic的注解用于开发者调用假删除的功能。它有两个属性:valuedelval

这里是关于这个注解的源码结构:

官网提供的方法需要我们在yml中配置对应逻辑删除和未删除的标识,但我实际测试发现只需在注解中对value和delval这两个属性上配置就可以了

4.2.3重要代码

首先我们在个entity中对isDelete这个字段上方添加@TableLogic注解:

java 复制代码
    /**
     * 假删除的标志
     */
    @TableField(value = "is_delete",fill =FieldFill.INSERT)
    @TableLogic(value = "0",delval = "1")
    private Integer isDelete;

我们这里写三个请求:删除请求分页查询请求获取详情请求。具体如下:

java 复制代码
/**
     * 批量删除用户
     *
     * @param idsWrapper id集合
     * @return 操作结果
     */
    @PutMapping("/batchDelete")
    @Transactional(rollbackFor = Exception.class)
    public ResponseResult<String> batchDelete(@RequestBody @Valid IdsWrapper idsWrapper) {
        ResponseResult<String> responseResult = new ResponseResult<>();
        boolean deleteResult = userService.removeByIds(idsWrapper.getIds());
        return deleteResult ? responseResult.setHttpResultEnum(HttpResultEnum.SUCCESS) : responseResult.setHttpResultEnum(HttpResultEnum.DELETE_FAIL);
    }

    /**
     * 分页查询用户
     *
     * @param userPageDTO 查询条件
     * @return 用户信息
     */
    @GetMapping("/page")
    public ResponseResult<PageList<UserPageVO>> page(@Valid UserPageDTO userPageDTO) {
        ResponseResult<PageList<UserPageVO>> responseResult = new ResponseResult<>();
        Page<User> page = userService.page(userPageDTO);
        return responseResult.setHttpResultEnum(HttpResultEnum.SUCCESS).setData(new UserPageVO().convert(page));
    }

    /**
     * 获取用户的详情
     * @param id    id
     * @return  详情vo
     */
    @GetMapping("/getUserInfo/{id}")
    public ResponseResult<UserInfoVO> getUserInfo(@PathVariable @Valid @NotBlank(message = "用户id不能为空") String id) {
        ResponseResult<UserInfoVO> responseResult = new ResponseResult<>();
        User user = userService.getById(id);
        if(user==null){
            return responseResult.setHttpResultEnum(HttpResultEnum.PARAM_IS_ERROR);
        }else{
            return responseResult.setHttpResultEnum(HttpResultEnum.SUCCESS).setData(new UserInfoVO(user));
        }
    }

4.2.4测试

  1. 删除请求

测试参数如下:

json 复制代码
{
  "ids":["78a1806c-6732-40f5-a64a-6d7a143fd282"]
}

数据库的结果如下:

这里可以看到is_delete这个字段从'0'变为了'1'。

  1. 分页查询请求

测试参数如下:
返回结果如下:

json 复制代码
{
  "code": 200,
  "msg": "操作成功",
  "data": {
    "currentPage": 1,
    "dataList": [],
    "limit": 5,
    "total": 0
  }
}

这里可以看到数据库中对应的那一条数据被过滤掉了

  1. 获取详情请求

我们使用path写法,请求参数如下:

返回结果如下:

json 复制代码
{
    "code": 402,
    "msg": "参数错误"
}

这里可以看到,被假删除的数据无法被获取到,判空后直接返回参数错误

4.3自动加解密

4.3.1需求

A. 对用户的密码进行不可逆加密(SHA2) ,只在新增的时候加密
B. 对用户的手机号码进行可逆加密(AES) ,新增、修改自动加密,查询自动解密

4.3.2实现原理

mybatis-plus 在实现指定字段的处理方法中,在@TableField注解中提供了typeHandler 这个属性:

它允许我们传入自定义的类型处理对象,我们可以在自定义的class对象中,继承官方的抽象类,实现对应的处理方法,示例如下:

java 复制代码
public class TestTypeHandler extends BaseTypeHandler<String> {
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, String s, JdbcType jdbcType) throws SQLException {

    }

    @Override
    public String getNullableResult(ResultSet resultSet, String s) throws SQLException {
        return null;
    }

    @Override
    public String getNullableResult(ResultSet resultSet, int i) throws SQLException {
        return null;
    }

    @Override
    public String getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
        return null;
    }
}

上述代码中的setNonNullParameter 是写入数据库的前置处理方法,其余的get开头的方法表示查询后的后置处理方法。

4.3.3重要代码

首先,我们先写两个加密的工具类:AESUtilSHA2Util

  1. SHA2Util

SHA2工具类一般用于校验像用户密码这些敏感且不需要解密的字段(因为密码的校验只需要把传入的数据进行同样的加密,再与和数据库中的加密字段进行比较即可,不需要将原密码反显),代码如下:

java 复制代码
public class SHA2Util {
    /**
     * 计算字符串的 MD5 散列值
     *
     * @param input 输入字符串
     * @return MD5 散列值的十六进制表示
     */
    /**
     * 计算字符串的 SHA-256 散列值
     *
     * @param input 输入字符串
     * @return SHA-256 散列值的十六进制表示
     */
    public static String calculateSHA256(String input) {
        try {
            // 获取 SHA-256 摘要算法实例
            MessageDigest md = MessageDigest.getInstance("SHA-256");

            // 将输入字符串转换为字节数组,并进行摘要计算
            byte[] digest = md.digest(input.getBytes());

            // 将字节数组转换为十六进制字符串
            StringBuilder hexString = new StringBuilder();
            for (byte b : digest) {
                hexString.append(String.format("%02x", b));
            }

            return hexString.toString();
        } catch (NoSuchAlgorithmException e) {
            e.printStackTrace();
            return null;
        }
    }
}
  1. AESUtil

AES工具类一般用于加密手机号码和身份证之类的敏感信息,这类字段的特点是需要反显。代码如下:

java 复制代码
public class AESUtil {
    private static final String ALGORITHM = "AES";
    private static final String TRANSFORMATION = "AES/ECB/PKCS5Padding";
    /**
     * 生产环境请配置到环境变量中
     */
    private static final String AES_KEY = "wzXOw5B19KD1O2hfqcC3dA==";


    /**
     * 生成随机的 AES 密钥
     *
     * @return AES 密钥的 Base64 编码
     */
    public static String generateRandomKey() throws Exception {
        KeyGenerator keyGenerator = KeyGenerator.getInstance(ALGORITHM);
        keyGenerator.init(128); // 128-bit key size for AES
        SecretKey secretKey = keyGenerator.generateKey();
        return Base64.getEncoder().encodeToString(secretKey.getEncoded());
    }

    /**
     * 使用 AES 加密文本
     *
     * @param plaintext 待加密的文本
     * @return 加密后的 Base64 编码字符串
     */
    public static String encrypt(String plaintext) {
        Key secretKey = new SecretKeySpec(Base64.getDecoder().decode(AES_KEY), ALGORITHM);
        try {
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.ENCRYPT_MODE, secretKey);
            byte[] encryptedBytes = cipher.doFinal(plaintext.getBytes());
            return Base64.getEncoder().encodeToString(encryptedBytes);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

    /**
     * 使用 AES 解密文本
     *
     * @param ciphertext 待解密的 Base64 编码字符串
     * @return 解密后的原始文本
     */
    public static String decrypt(String ciphertext) {
        Key secretKey = new SecretKeySpec(Base64.getDecoder().decode(AES_KEY), ALGORITHM);
        try {
            Cipher cipher = Cipher.getInstance(TRANSFORMATION);
            cipher.init(Cipher.DECRYPT_MODE, secretKey);
            byte[] decryptedBytes = cipher.doFinal(Base64.getDecoder().decode(ciphertext));
            return new String(decryptedBytes);
        } catch (Exception e) {
            throw new RuntimeException(e);
        }
    }

}

下面我们写一个继承BaseTypeHandler抽象类的自定义类型处理方法,这里以AES为例:

java 复制代码
/**
 * description AES类型转换器
 *
 * @author 周建泽
 * @date 2024/1/30
 */
public class AESTypeHandler extends BaseTypeHandler<String> {
    @Override
    public void setNonNullParameter(PreparedStatement preparedStatement, int i, String s, JdbcType jdbcType) throws SQLException {
        preparedStatement.setString(i, AESUtil.encrypt(s));
    }

    @Override
    public String getNullableResult(ResultSet resultSet, String s) throws SQLException {
        return AESUtil.decrypt(resultSet.getString(s));
    }

    @Override
    public String getNullableResult(ResultSet resultSet, int i) throws SQLException {
        return AESUtil.decrypt(resultSet.getString(i));
    }

    @Override
    public String getNullableResult(CallableStatement callableStatement, int i) throws SQLException {
        return AESUtil.decrypt(callableStatement.getString(i));
    }
}

这里我们在setNonNullParameter方法中调用了加密方法,其余方法执行了解密,然后我们需要去User对象的指定字段添加类型处理属性:

java 复制代码
/**
     * 密码
     */
    @TableField(value = "password",typeHandler = SHA2TypeHandler.class)
    private String password;

这里我们需要注意的是,在User对象的@TableName注解中,我们需要填写'autoResultMap = true'这个配置:

java 复制代码
@TableName(value ="user",autoResultMap = true)
@Data
public class User implements Serializable {
    
}

这个属性表示开启映射,如果不开启可能会导致数据处理有问题。实际测试中未添加此属性时存在某些方法不生效的问题,所以autoResultMap=true必须添加。

4.3.4测试

这里我们分两块进行测试:密码和手机号。密码我们在新增的时候去观察是否完成加密;手机号我们观察在新增、修改的时候是否加密且查询的时候是否解密。

  1. 密码加密

新增的请求参数上文已经展示过了,这里打个断点测试一下:

可以看到在执行我们的加密方法之前数据并未进行任何处理,断点放行后数据库的结果如下:

可以看到新的的时候密码已经完成了自动加密。
b.手机号码加解密

这里直接展示新增人员后的手机号码的加密结果:

针对解密,我们使用获取详情方法和分页请求来测试,结果如下:
A.获取详情请求结果:

B.分页请求结果:

可以看到已经解密已经自动完成。

5.总结

整套方法使用下来还是比较流畅的,相比于开头提到的那两种方法,mybatis-plus确实用起来更加舒服,但也有一些问题需要注意:

  1. 接口测试时,原始的方法比我们这套框架更加快,虽然实际体验上并不明显,如果存在比较大的数据量,框架使用者需要权衡性能与代码的可维护性。
  2. 要实现真正的全局配置还需要自动生成代码的工具,可以将数据库表直接转化为框架中的entity对象,否则每张表还需要使用者自己配置,这就比较繁琐,所以建议开发者通过mybatis-plus提供的代码生成器进行初始化
  3. 使用框架一定会带来代码入侵的问题,对于没有本文提到的需求,或者涉及少部分,没有必要霸王硬上弓,简洁的代码一定是好代码

6.附录

gittee地址:gitee.com/inspiration...

git地址:github.com/ThreeBody19...

语雀:www.yuque.com/zhoujianze/... 《基于Mybatis-plus的特殊字段处理方法》

相关推荐
大神薯条老师5 分钟前
Python从入门到高手4.3节-掌握跳转控制语句
后端·爬虫·python·深度学习·机器学习·数据分析
2401_8576226643 分钟前
Spring Boot新闻推荐系统:性能优化策略
java·spring boot·后端
AskHarries1 小时前
如何优雅的处理NPE问题?
java·spring boot·后端
计算机学姐2 小时前
基于SpringBoot+Vue的高校运动会管理系统
java·vue.js·spring boot·后端·mysql·intellij-idea·mybatis
程序员陆通3 小时前
Spring Boot RESTful API开发教程
spring boot·后端·restful
无理 Java3 小时前
【技术详解】SpringMVC框架全面解析:从入门到精通(SpringMVC)
java·后端·spring·面试·mvc·框架·springmvc
cyz1410014 小时前
vue3+vite@4+ts+elementplus创建项目详解
开发语言·后端·rust
liuxin334455664 小时前
大学生就业招聘:Spring Boot系统的高效实现
spring boot·后端·mfc
向上的车轮5 小时前
ASP.NET Zero 多租户介绍
后端·asp.net·saas·多租户
yz_518 Nemo5 小时前
django的路由分发
后端·python·django