1.写在前面
在实际项目中,我们经常会遇到对数据库的固定字段进行特殊处理的场景:
- 新增或修改时,需要对操作人和操作时间的这类固定字段进行填充
- 对敏感字段(手机号码和密码)进行加解密 和脱敏
- 删除记录时使用假删除且查询的时候自动过滤结果
......
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 中的两个方法:insertFill 和updateFill来实现对标注内容的填充,这里有官方的案例:
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重要代码
- 添加@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;
- 自定义填充方法
代码如下:
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());
}
}
- 实现新增功能
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的注解用于开发者调用假删除的功能。它有两个属性:value 和delval 。
这里是关于这个注解的源码结构:
官网提供的方法需要我们在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测试
- 删除请求
测试参数如下:
json
{
"ids":["78a1806c-6732-40f5-a64a-6d7a143fd282"]
}
数据库的结果如下:
这里可以看到is_delete这个字段从'0'变为了'1'。
- 分页查询请求
测试参数如下:
返回结果如下:
json
{
"code": 200,
"msg": "操作成功",
"data": {
"currentPage": 1,
"dataList": [],
"limit": 5,
"total": 0
}
}
这里可以看到数据库中对应的那一条数据被过滤掉了
- 获取详情请求
我们使用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重要代码
首先,我们先写两个加密的工具类:AESUtil 和SHA2Util。
- 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;
}
}
}
- 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测试
这里我们分两块进行测试:密码和手机号。密码我们在新增的时候去观察是否完成加密;手机号我们观察在新增、修改的时候是否加密且查询的时候是否解密。
- 密码加密
新增的请求参数上文已经展示过了,这里打个断点测试一下:
可以看到在执行我们的加密方法之前数据并未进行任何处理,断点放行后数据库的结果如下:
可以看到新的的时候密码已经完成了自动加密。
b.手机号码加解密
这里直接展示新增人员后的手机号码的加密结果:
针对解密,我们使用获取详情方法和分页请求来测试,结果如下:
A.获取详情请求结果:
B.分页请求结果:
5.总结
整套方法使用下来还是比较流畅的,相比于开头提到的那两种方法,mybatis-plus确实用起来更加舒服,但也有一些问题需要注意:
- 接口测试时,原始的方法比我们这套框架更加快,虽然实际体验上并不明显,如果存在比较大的数据量,框架使用者需要权衡性能与代码的可维护性。
- 要实现真正的全局配置还需要自动生成代码的工具,可以将数据库表直接转化为框架中的entity对象,否则每张表还需要使用者自己配置,这就比较繁琐,所以建议开发者通过mybatis-plus提供的代码生成器进行初始化
- 使用框架一定会带来代码入侵的问题,对于没有本文提到的需求,或者涉及少部分,没有必要霸王硬上弓,简洁的代码一定是好代码
6.附录
gittee地址:gitee.com/inspiration...
git地址:github.com/ThreeBody19...
语雀:www.yuque.com/zhoujianze/... 《基于Mybatis-plus的特殊字段处理方法》