场景:经常需要校验某张表的某个字段是否唯一。比如王者荣耀取名老是提示 名称已存在,这就是一种唯一性校验。
条件:
springboot已经提供了很多的校验,比如@NotNull、@NotEmpty 等。这些其实是JSR303规范。
那么,如果自定义注解实现我的需求呢?
本博文内容依赖 mybatis-plus、反射工具类。
一、已有资源
1、表 act_cx_category
CREATE TABLE `act_cx_category` (
`category_id` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8_general_ci NOT NULL COMMENT '分类id',
`category_name` varchar(32) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '分类名称',
`parent_id` varchar(64) CHARACTER SET utf8mb4 COLLATE utf8mb4_general_ci DEFAULT NULL COMMENT '父id',
`icon` varchar(255) CHARACTER SET utf8mb3 COLLATE utf8_general_ci DEFAULT NULL COMMENT '图标',
`sort` double DEFAULT NULL COMMENT '排序',
`app_id` varchar(255) DEFAULT NULL COMMENT '应用id',
PRIMARY KEY (`category_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb3 ROW_FORMAT=DYNAMIC COMMENT='分类表';
2、对应的实体类
import com.baomidou.mybatisplus.annotation.IdType;
import com.baomidou.mybatisplus.annotation.TableField;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import java.io.Serializable;
import lombok.Data;
/**
* 分类表
* @TableName act_cx_category
*/
@TableName(value ="act_cx_category")
@Data
public class ActCxCategoryEntity implements Serializable {
/**
* 分类id
*/
@TableId(type= IdType.ASSIGN_ID)
private String categoryId;
/**
* 分类名称
*/
private String categoryName;
/**
* 父id
*/
private String parentId;
/**
* 图标
*/
private String icon;
/**
* 排序
*/
private Double sort;
/**
* 应用id
*/
private String appId;
@TableField(exist = false)
private static final long serialVersionUID = 1L;
}
3、对应的mapper
import com.jxctjt.workflow.domain.entity.ActCxCategoryEntity;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
/**
* @description 针对表【act_cx_category(分类表)】的数据库操作Mapper
* @Entity com.jxctjt.workflow.domain.entity.ActCxCategoryEntity
*/
public interface ActCxCategoryMapper extends BaseMapper<ActCxCategoryEntity> {
}
4、controller层
@PostMapping("/test")
public Result test(@Validated(AddGroup.class) @RequestBody CategoryBo bo){
log.info(bo.toString());
// 逻辑
return Result.ok("测试成功!");
}
5、需要校验的Bo对象
import com.jxctjt.workflow.common.validate.group.AddGroup;
import com.jxctjt.workflow.common.validate.group.EditGroup;
import com.jxctjt.workflow.common.validate.group.anno.UniqueExtendAppIdImpl;
import com.jxctjt.workflow.common.validate.group.anno.UniqueField;
import com.jxctjt.workflow.common.validate.group.anno.UniqueType;
import com.jxctjt.workflow.domain.entity.ActCxCategoryEntity;
import com.jxctjt.workflow.mapper.ActCxCategoryMapper;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.io.Serializable;
import javax.validation.constraints.NotNull;
import lombok.Data;
/**
* @author xgz
*/
@Data
@ApiModel("分类参数对象")
public class CategoryBo implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("分类id")
@NotNull(message = "分类id不能为空", groups = {EditGroup.class})
private String categoryId;
/**
* 分类名称
*/
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
/**
* 图标
*/
@ApiModelProperty("图标")
private String icon;
/**
* 排序
*/
@ApiModelProperty("排序")
@NotNull(message = "排序不能为空", groups = {AddGroup.class, EditGroup.class})
private Double sort;
}
二、实现
1、UniqueType 注解
通过 @Constraint(validatedBy = {UniqueValidator.class}) 指定这个注解使用 UniqueValidator进行校验。
这个注解只能标注在类上,当@Validated注解标注在 请求实体上时,就会进入到
UniqueValidator 里面。
import static java.lang.annotation.ElementType.TYPE;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
import javax.validation.Constraint;
import javax.validation.Payload;
/**
* 校验参数唯一
* 需要和 UniqueField 注解 搭配使用
* 场景:要求 用户名唯一,但是数据库用户名没有设置唯一键。
* @author xgz
*/
@Documented
@Constraint(validatedBy = {UniqueValidator.class})
@Target({TYPE})
@Retention(RUNTIME)
public @interface UniqueType {
Class<?> entity();
// 访问数据库的服务
Class<? extends BaseMapper> mapper();
/**
* 是否开启校验
*
* @return 是否强制校验的boolean值
*/
boolean required() default true;
String message() default "{数据库已存在}";
Class<?>[] groups() default {};
Class<? extends Payload>[] payload() default {};
/** 不得更改默认值 **/
Class<? extends UniqueExtend> extend() default UniqueExtend.class;
}
2、UniqueField 注解
这个注解只能标注在字段上。
UniqueType 注解必须同时搭配UniqueField注解,才会生效。原因是UniqueValidator的isValid
方法 的实现逻辑决定的。
import static java.lang.annotation.ElementType.FIELD;
import static java.lang.annotation.RetentionPolicy.RUNTIME;
import java.lang.annotation.Documented;
import java.lang.annotation.Retention;
import java.lang.annotation.Target;
/**
* 数据库唯一校验
* 场景:比如要求 用户名唯一,但是数据库用户名没有设置唯一键。
* @author xgz
*/
@Documented
@Target({FIELD})
@Retention(RUNTIME)
public @interface UniqueField {
/**
* 是否开启校验
*
* @return 是否强制校验的boolean值
*/
boolean required() default true;
String message();
/**
* 拓展类 比如 SQL还需要拼接上 appId 字段进行查询
* 不得更改extend的默认值
* **/
Class<? extends UniqueExtend> extend() default UniqueExtend.class;
}
3、UniqueValidator 校验类
当标注了 @UniqueType 注解的参数类,执行校验时,会首先 执行 initialize方法,这里是初始化的意思,可以获取到 @UniqueType 的参数。
然后会执行 isValid 方法。isValid方法返回true表示校验通过,放行。返回false则表示失败,会抛出异常,默认message是@UniqueType注解的参数message。
import cn.hutool.core.text.CharSequenceUtil;
import cn.hutool.core.util.ReflectUtil;
import cn.hutool.extra.spring.SpringUtil;
import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.baomidou.mybatisplus.core.conditions.query.QueryWrapper;
import com.baomidou.mybatisplus.core.mapper.BaseMapper;
import com.jxctjt.workflow.common.constant.SepConstant;
import com.jxctjt.workflow.common.exception.SysException;
import com.jxctjt.workflow.common.util.ReflectUtils;
import java.lang.reflect.Field;
import java.util.Objects;
import javax.validation.ConstraintValidator;
import javax.validation.ConstraintValidatorContext;
import lombok.extern.slf4j.Slf4j;
import org.jetbrains.annotations.Nullable;
/**
* @author xgz
*/
@Slf4j
public class UniqueValidator implements ConstraintValidator<UniqueType, Object> {
/**
* 是否强制校验
*/
private boolean required;
/**
* 操作数据库的服务类
**/
private Class<? extends BaseMapper> mapper;
private Class entity;
private UniqueExtend extend;
/**
* 实体类中 主键名称
* <p>
*/
private String primaryKeyName;
/**
* 实体类中 主键值
*/
private Object primaryKeyValue;
@Override
public void initialize(UniqueType anno) {
this.required = anno.required();
this.mapper = anno.mapper();
if (null == mapper) {
throw new SysException("service不能为空");
}
this.entity = anno.entity();
Class<? extends UniqueExtend> defaultVal = anno.extend();
this.extend = getUniqueExtend(defaultVal);
}
/**
* 返回true 表示验证成功 返回false 表示验证失败,会抛出异常
**/
@Override
public boolean isValid(Object obj, ConstraintValidatorContext context) {
if (required) {
BaseMapper bean = SpringUtil.getBean(mapper);
if (null == bean) {
throw new SysException("Unique 注解的 mapper 必须是Bean");
}
return doValid(bean, obj, context);
}
return true;
}
private boolean doValid(BaseMapper mapper, Object obj, ConstraintValidatorContext context) {
// 尝试获取表的主键名 和传递过来的主键值。通过是否有主键值判断是新增还是更新。
getPrimaryNameAndValue(obj);
Field[] fields = ReflectUtils.getFields(obj.getClass());
for (Field field : fields) {
if (field.isAnnotationPresent(UniqueField.class)) {
UniqueField anno = field.getAnnotation(UniqueField.class);
String message = anno.message();
String name = field.getName();
Class<? extends UniqueExtend> filedExtend = anno.extend();
UniqueExtend uniqueExtendObj = getUniqueExtend( filedExtend);
String dbField = toDbField(name);
Object value = ReflectUtils.invokeGetter(obj, name);
if (null == value){
continue;
}
Object entity = ReflectUtils.newInstance(this.entity);
AbstractWrapper wrapper = new QueryWrapper(entity).eq(true, dbField, value)
.ne(Objects.nonNull(primaryKeyValue), toDbField(primaryKeyName), primaryKeyValue);
// 执行拓展
try {
if (uniqueExtendObj == null){
if (this.extend != null){
wrapper = this.extend.apply(wrapper, entity, obj, field);
}
}else{
wrapper = uniqueExtendObj.apply(wrapper, entity, obj, field);
}
}catch (Exception e){
log.error("执行拓展出错!");
wrapper = new QueryWrapper(entity).eq(true, dbField, value)
.ne(Objects.nonNull(primaryKeyValue), toDbField(primaryKeyName), primaryKeyValue);
}
int i = mapper.selectCount(wrapper).intValue();
if (i > 0){
log.error(message);
//禁用默认message值,不禁用会在原有默认的message的基础上进行拼接
context.disableDefaultConstraintViolation();
context.buildConstraintViolationWithTemplate(message).addConstraintViolation();
return false;
}
}
}
return true;
}
/**
* 获取 拓展类对象
* 优先在 容器中获取
* **/
@Nullable
private static UniqueExtend
getUniqueExtend(Class<? extends UniqueExtend> extendClass){
UniqueExtend uniqueExtendObj = null;
if (!UniqueExtend.class.equals(extendClass)) {
uniqueExtendObj = SpringUtil.getBean(extendClass);
if (null == uniqueExtendObj){
uniqueExtendObj = ReflectUtil.newInstance(extendClass);
}
}
return uniqueExtendObj;
}
private void getPrimaryNameAndValue(Object obj) {
Field[] fields = ReflectUtils.getFields(entity);
for (Field field : fields) {
if (field.isAnnotationPresent(TableId.class)) {
this.primaryKeyName = field.getName();
primaryKeyValue = ReflectUtils.invokeGetter(obj, this.primaryKeyName);
break;
}
}
}
private String toDbField(String name) {
if (CharSequenceUtil.isBlank(name)) {
throw new SysException("要验证的字段名不能为空");
} else {
if (name.contains(SepConstant.UNDERLINE)) {
return name;
} else {
return CharSequenceUtil.toUnderlineCase(name);
}
}
}
}
4、拓展接口
UniqueExtend
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import java.lang.reflect.Field;
public interface UniqueExtend {
/**
* 应用拓展 修改查询SQL
* @author xgz
* @date 2023/10/28
* @param wrapper 默认的 warpper
* @param entity 表对应的实体对象,注意这是一个空对象。
* 但是可以通过反射获取 表的字段名等信息。
* @param obj Bo对象,有各个前端传入的值
* @param field 当前需要校验的字段,是Bo对象标注了@UniqueField 注解的字段。
* @return com.baomidou.mybatisplus.core.conditions.Wrapper
**/
AbstractWrapper apply(AbstractWrapper wrapper, Object entity, Object obj, Field field);
}
5、拓展接口的一个实现类
import cn.hutool.core.text.CharSequenceUtil;
import com.baomidou.mybatisplus.core.conditions.AbstractWrapper;
import com.jxctjt.workflow.common.util.ReflectUtils;
import java.lang.reflect.Field;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
/**
* 拓展加入appId条件判断
* **/
@Slf4j
@Component
public class UniqueExtendAppIdImpl implements UniqueExtend{
@Resource
private HttpServletRequest request;
private final String THIRD_PART_NAME_HEADER = "app-id";
/**
* 应用拓展 修改查询SQL
*
* @param wrapper 默认的 warpper
* @param entity 表对应的实体对象,注意这是一个空对象。 但是可以通过反射获取 表的字段名等信息。
* @param obj Bo对象,有各个前端传入的值
* @param field 当前需要校验的字段,是Bo对象标注了@UniqueField 注解的字段。
* @return com.baomidou.mybatisplus.core.conditions.Wrapper
* @author xgz
* @date 2023/10/28
**/
@Override
public AbstractWrapper apply(AbstractWrapper wrapper, Object entity, Object obj, Field field) {
// 首先从 obj中获取 appId,如果没有 再从 request的header 获取appId
Field appIdField = ReflectUtils.getField(obj.getClass(), "appId");
Object appId = null;
if (appIdField != null){
appId = ReflectUtils.invokeGetter(obj, appIdField.getName());
}
if (null == appId){
String header = request.getHeader(THIRD_PART_NAME_HEADER);
if (CharSequenceUtil.isNotBlank(header)){
appId = header;
}
}
if (appId == null){
log.info("无appId!");
return wrapper;
}else {
return wrapper.eq(true, "app_id", appId);
}
}
}
三、使用
1、controller层
@PostMapping("/test")
public Result test(@Validated(AddGroup.class) @RequestBody CategoryBo bo){
log.info(bo.toString());
// 逻辑
return Result.ok("测试成功!");
}
2、需要校验的Bo对象
import com.jxctjt.workflow.common.validate.group.AddGroup;
import com.jxctjt.workflow.common.validate.group.EditGroup;
import com.jxctjt.workflow.common.validate.group.anno.UniqueExtendAppIdImpl;
import com.jxctjt.workflow.common.validate.group.anno.UniqueField;
import com.jxctjt.workflow.common.validate.group.anno.UniqueType;
import com.jxctjt.workflow.domain.entity.ActCxCategoryEntity;
import com.jxctjt.workflow.mapper.ActCxCategoryMapper;
import io.swagger.annotations.ApiModel;
import io.swagger.annotations.ApiModelProperty;
import java.io.Serializable;
import javax.validation.constraints.NotNull;
import lombok.Data;
/**
* @author xgz
*/
@Data
@UniqueType(entity = ActCxCategoryEntity.class, mapper = ActCxCategoryMapper.class, groups = {
AddGroup.class, EditGroup.class})
@ApiModel("分类参数对象")
public class CategoryBo implements Serializable {
private static final long serialVersionUID = 1L;
@ApiModelProperty("分类id")
@NotNull(message = "分类id不能为空", groups = {EditGroup.class})
private String categoryId;
/**
* 分类名称
*/
@UniqueField(message = "分类名称已存在")
// @UniqueField(message = "分类名称已存在", extend = UniqueExtendAppIdImpl.class)
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
/**
* 图标
*/
@ApiModelProperty("图标")
private String icon;
/**
* 排序
*/
@ApiModelProperty("排序")
@NotNull(message = "排序不能为空", groups = {AddGroup.class, EditGroup.class})
private Double sort;
}
注意1:
@Data
@UniqueType(entity = ActCxCategoryEntity.class, mapper = ActCxCategoryMapper.class, groups = {
AddGroup.class, EditGroup.class})
@ApiModel("分类参数对象")
public class CategoryBo implements Serializable {略}
注意2:
/**
* 分类名称
*/
@UniqueField(message = "分类名称已存在")
// @UniqueField(message = "分类名称已存在", extend = UniqueExtendAppIdImpl.class)
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
这是为了 校验 CategoryBo 接收的 categoryName 在数据库中不能重复。
3、测试
测试数据:
CASE 1: 更新场景 参数有表主键
参数:
{
"categoryId": "1f03b4e8bd920c6991cdc48d32b96cb40",
"categoryName": "项目管理系统",
"sort": 0
}
请求头: app-id :1691701502841065473
结果:
{
"code": 500,
"message": "......; default message [分类名称已存在]] ",
"result": null,
"success": false
}
1、Bo 的是这样写时,不使用拓展
/**
* 分类名称
*/
@UniqueField(message = "分类名称已存在")
// @UniqueField(message = "分类名称已存在", extend = UniqueExtendAppIdImpl.class)
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
后台执行的Sql:
SELECT COUNT( * ) FROM act_cx_category
WHERE
(category_name = '项目管理系统'
AND category_id <> '1f03b4e8bd920c6991cdc48d32b96cb40';
2、使用拓展
/**
* 分类名称
*/
//@UniqueField(message = "分类名称已存在")
@UniqueField(message = "分类名称已存在", extend = UniqueExtendAppIdImpl.class)
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
后台执行的sql
SELECT COUNT( * )
FROM act_cx_category
WHERE (category_name = '项目管理系统'
AND category_id <> '1f03b4e8bd920c6991cdc48d32b96cb40'
AND app_id = '1691701502841065473');
CASE 2: 新增场景、参数没有表主键
参数:
{
"categoryName": "项目管理系统",
"sort": 0
}
请求头: app-id :1691701502841065473
1、不使用拓展
/**
* 分类名称
*/
@UniqueField(message = "分类名称已存在")
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
后台执行SQL
SELECT
COUNT( * )
FROM
act_cx_category
WHERE
(category_name = '项目管理系统')
2、使用拓展
/**
* 分类名称
*/
@UniqueField(message = "分类名称已存在", extend = UniqueExtendAppIdImpl.class)
@ApiModelProperty("分类名称")
@NotNull(message = "分类名称不能为空", groups = {AddGroup.class, EditGroup.class})
private String categoryName;
后台执行的sql
SELECT
COUNT( * )
FROM
act_cx_category
WHERE
(category_name = '项目管理系统' AND app_id = '1691701502841065473')
四、说明
本文使用2个自定义注解+1个扩展接口,实现 使用注解方式,校验数据库的字段是否重复。
2个注解需要搭配使用。拓展接口的实现类可以自己定义,实现符合自己逻辑的校验规则。