Mybatis Plus的一种查询构造方案

Mybatis Plus的一种查询构造方案

! 重要: 遐(瞎)想的思路, 希望各位多多建议

record为jdk17写法, 使用class也不会有问题

背景

身为资深程序员, 上班最重要的事当然是增删改查(bushi).

比如今天, 组长甩给了我一个表, 告诉我根据学号/性别/生日范围/受表扬次数写个接口.

create table test_student(
    id                 bigint primary key comment 'id',
    stu_num            varchar(20) not null comment '学号',
    sex                varchar(10) not null comment '性别, 字典: sex',
    birthday           date        not null comment '生日',
    star_count         int         not null comment '受表扬次数'
) comment '测试用_学生信息';

用的mybatis plus, 有了如下代码, 于是我今天的工作任务就完成了

// 检索对象
/**
 * 学生检索
 *
 * @param stuNum      学号
 * @param sex         性别
 * @param birthdayMin 最小生日
 * @param birthdayMax 最大生日
 * @param starCount   受表扬次数
 */
public record TestStudentQuery(
        String stuNum,
        String sex,
        LocalDate birthdayMin,
        LocalDate birthdayMax,
        Integer starCount
) {
}

// service search方法
@Override
public Page<TestStudent> search(TestStudentQuery query) {
    return this.lambdaQuery()
        // 学号不为空, 则模糊查询
        .like(StringUtils.isNotEmpty(query.stuNum()), TestStudent::getStuNum, query.stuNum())
        // 性别不为空则全等查询
        .eq(StringUtils.isNotEmpty(query.sex()), TestStudent::getSex, query.sex())
        // 生日最小/最大时间均不为空则进行范围查询
        .between(Objects.nonNull(query.birthdayMax()) && Objects.nonNull(query.birthdayMin()),
                 TestStudent::getBirthday, query.birthdayMin(), query.birthdayMax())
        // 受表扬次数不为空则全等查询
        .eq(Objects.nonNull(query.starCount()), TestStudent::getStarCount, query.starCount())
        // 分页
        .page(Paging.startPage());
}

看起来除了有点乱之外, 功能基本是正常的

但是写起来是不是太麻烦了, 这么多判断, 搞不好明天还让我写这玩意, 乏味

构思

问题有了, 想想思路

  1. query对象的字段名, 除了between查询外, 均一致
  2. 如果query的范围查询, 都以Min/Max结尾来表示最大值/最小值, 那么也有迹可循
  3. 查询条件只有query对象中的属性, 如果query对象中添加一些注解, 然后检索时解析这些注解, 好像是可行的
  4. 所以只需要写一个工具类, 来解析query中的注解就行了呀

基于上面的想法, 把我想要的写法写了出来:

query对象:

public record TestStudentQuery(
        @MbpQuery(type = QueryTypeEnum.LIKE)
        String stuNum,
        @MbpQuery(type = QueryTypeEnum.EQ)
        String sex,
        @MbpQuery(type = QueryTypeEnum.BETWEEN)
        LocalDate birthdayMin,
        @MbpQuery(type = QueryTypeEnum.BETWEEN)
        LocalDate birthdayMax,
        @MbpQuery(type = QueryTypeEnum.EQ)
        Integer starCount
) {
}

service中写法:

@Override
public Page<TestStudent> anotherSearch(TestStudentQuery query) {
    // 用法1: 自动解析query对象, 整理为mybatis plus理解的代码, 只构建查询条件, 不进行自动分页查询, 便于后续查询
    MbpUtil.buildSearch(this, query)
    // 用法2: 相对于buildSearch, 并自动解析分页
    return MbpUtil.page(this, query);
}

实现

  1. 定义查询类型

    列举一下项目中用的到的检索类型, 篇幅有限, 删掉了注释, 注释版可见github

    public enum QueryTypeEnum {
        EQ,
        NE,
        GT,
        GE,
        LT,
        LE,
        BETWEEN,
        NOT_BETWEEN,
        LIKE,
        NOT_LIKE,
        LIKE_LEFT,
        LIKE_RIGHT,
        NOT_LIKE_LEFT,
        NOT_LIKE_RIGHT,
        IN,
        NOT_IN
    }
    
  2. 定义注解

    @Retention(RetentionPolicy.RUNTIME)
    @Target({ElementType.FIELD})
    public @interface MbpQuery {
        QueryTypeEnum type();
    }
    
  3. 定义工具类

    @SuppressWarnings("AlibabaConstantFieldShouldBeUpperCase")
    public class MbpUtil1 {
        /**
         * between字段区间开始标识
         */
        private static final String betweenMin = "Min";
        /**
         * between字段区间结尾标识
         */
        private static final String betweenMax = "Max";
    
        /**
         * 用于快速构建检索条件
         *
         * @param service service对象
         * @param query   检索条件, query对象
         * @param <M>     mapper interface
         * @param <T>     实体类
         * @return 构建完成的检索条件
         */
        public static <M extends BaseMapper<T>, T> LambdaQueryChainWrapper<T> buildSearch(ServiceImpl<M, T> service, Object query) {
            QueryChainWrapper<T> result = service.query();
            // switch (query 的字段){
            //     找到各种注解, 根据不同的注解去调用不同的方法并判断非空
            // }
            // 这里返回lambda/还是非lambda更合适?
            return result;
        }
    
        /**
         * 快速构建检索条件并分页
         * @param service service对象
         * @param query   检索条件, query对象
         * @param <M>     mapper interface
         * @param <T>     实体类
         * @return 分页结果
         */
        public static <M extends BaseMapper<T>, T> Page<T> page(ServiceImpl<M, T> service, Object query) {
            return buildSearch(service, query).page(Paging.startPage());
        }
    }
    
  4. 卡住了

    这里如果使用mybatis plus的QueryChain好像没啥事, 但是这里需要返回半成品的查询对象, 那么外面继续添加检索条件时, 就无法使用lambda方式了

    字符串里面写东西不好, 不好排查, 没有代码提示, 不能查找引用

    那么还是要使用lambda方式构建查询

解决问题

那么非要使用lambda的话, 第一个参数怎么传递呢

service中调用this.lambdaQuery().eq方法, 字段名使用SFunction类型接收, 这个参数必须是lambda方式, 否则无法获取方法名, 也就无法获取变量名

让我们来看一眼它是怎么获取方法名的吧, 最终我找到了这个工具方法: com.baomidou.mybatisplus.core.toolkit.LambdaUtils#extract

/**
 * 该缓存可能会在任意不定的时间被清除
 *
 * @param func 需要解析的 lambda 对象
 * @param <T>  类型,被调用的 Function 对象的目标类型
 * @return 返回解析后的结果
 */
public static <T> LambdaMeta extract(SFunction<T, ?> func) {
    // 1. IDEA 调试模式下 lambda 表达式是一个代理
    if (func instanceof Proxy) {
        return new IdeaProxyLambdaMeta((Proxy) func);
    }
    // 2. 反射读取
    try {
        Method method = func.getClass().getDeclaredMethod("writeReplace");
        return new ReflectLambdaMeta((SerializedLambda) ReflectionKit.setAccessible(method).invoke(func));
    } catch (Throwable e) {
        // 3. 反射失败使用序列化的方式读取
        return new ShadowLambdaMeta(com.baomidou.mybatisplus.core.toolkit.support.SerializedLambda.extract(func));
    }
}

最终返回了一个LambdaMeta接口(两个方法, 一个获取实体类名, 一个获取字段名)

查看其注释, 我们要构造一个SFunction的实现类, 其具有writeReplace方法, 返回值是一个SerializedLambda对象

此对象不会是代理, 因为我们无法使用lambda, 也不会反序列化失败, 所以只需要兼容其第二步即可

声明这么一个对象看看效果:

/**
 * 构造mbp的lambda表达式, 用以欺骗mbp
 *
 * @author ly-chn
 * @see com.baomidou.mybatisplus.core.toolkit.support.SFunction
 */
@SuppressWarnings("AlibabaClassNamingShouldBeCamel")
@AllArgsConstructor
public class SFunctionMask<T> implements SFunction<T, Object> {
    private String fieldName;

    @Override
    public Object apply(T t) {
        return null;
    }

    @SuppressWarnings("unused")
    private SerializedLambda writeReplace() {
        return new SerializedLambda(
                null,
                null,
                null,
                null,
                0,
                null,
                "get" + fieldName,
                null,
                "LY" + instantiatedMethodType + ";",
                new Object[0]
        );
    }
}

SFunctionMask的目的很明确, 就是实现SFunction, 且有一个能够返回SerializedLambda对象的writeReplace方法

返回的SerializedLambda只用到了两个内容: 所属类名, 方法名

类名即实体类名, 调用service.getEntityClass()即可获取, 添加"LY"前缀和分号后缀的原因见com.baomidou.mybatisplus.core.toolkit.support.ReflectLambdaMeta#getInstantiatedClass;

方法名需要以get/set开头, mybatis plus会自动去掉

那么, 问题解决了

工具类最终实现

篇幅有限, 删掉注释和导包, 完整代码见github

public class MbpUtil {
    private static final String betweenMin = "Min";
    private static final String betweenMax = "Max";

    public static <M extends BaseMapper<T>, T> LambdaQueryChainWrapper<T> buildSearch(ServiceImpl<M, T> service, Object query) {
        LambdaQueryChainWrapper<T> result = service.lambdaQuery();
        HashMap<Field, Object> fieldValueMap = new HashMap<>();

        for (Field field : FieldUtils.getFieldsListWithAnnotation(query.getClass(), MbpQuery.class)) {
            try {
                Object value = FieldUtils.readField(field, query, true);
                if (Objects.isNull(value) || (value instanceof String && StringUtils.isEmpty((String) value))) {
                    continue;
                }
                fieldValueMap.put(field, value);
            } catch (IllegalAccessException ignored) {
            }
        }
        if (fieldValueMap.isEmpty()) {
            return result;
        }
        HashMap<Field, Object> betweenFieldValueMap = new HashMap<>();

        String entityClassName = service.getEntityClass().getName();
        Function<String, SFunctionMask<T>> nameMask = fieldName -> new SFunctionMask<>(fieldName, entityClassName);
        Function<Field, SFunctionMask<T>> mask = field -> new SFunctionMask<>(field.getName(), entityClassName);
        // 处理非between字段
        fieldValueMap.forEach((field, value) -> {
            MbpQuery annotation = field.getAnnotation(MbpQuery.class);
            // noinspection AlibabaSwitchStatement
            switch (annotation.type()) {
                case EQ -> result.eq(mask.apply(field), value);
                case NE -> result.ne(mask.apply(field), value);
                case GT -> result.gt(mask.apply(field), value);
                case GE -> result.ge(mask.apply(field), value);
                case LT -> result.lt(mask.apply(field), value);
                case LE -> result.le(mask.apply(field), value);
                case LIKE -> result.like(mask.apply(field), value);
                case NOT_LIKE -> result.notLike(mask.apply(field), value);
                case LIKE_LEFT -> result.likeLeft(mask.apply(field), value);
                case LIKE_RIGHT -> result.likeRight(mask.apply(field), value);
                case NOT_LIKE_LEFT -> result.notLikeLeft(mask.apply(field), value);
                case NOT_LIKE_RIGHT -> result.notLikeRight(mask.apply(field), value);
                case IN -> result.in(mask.apply(field), value);
                case NOT_IN -> result.notIn(mask.apply(field), value);
                case BETWEEN, NOT_BETWEEN -> betweenFieldValueMap.put(field, value);
                default -> throw new LyException.Panic("架构支持能力不足");
            }
        });
        // 处理between字段
        betweenFieldValueMap.forEach((field, value) -> {
            if (field.getName().endsWith(betweenMin)) {
                String fieldName = field.getName().substring(0, field.getName().length() - betweenMin.length());
                String maxFieldName = fieldName + betweenMax;
                Optional<Field> maxFieldOptional = betweenFieldValueMap.keySet().stream()
                        .filter(it -> it.getName().contentEquals(maxFieldName)).findFirst();
                if (maxFieldOptional.isEmpty()) {
                    return;
                }
                Field maxField = maxFieldOptional.get();
                Object maxValue = betweenFieldValueMap.get(maxField);
                MbpQuery annotation = field.getAnnotation(MbpQuery.class);
                // noinspection AlibabaSwitchStatement
                switch (annotation.type()) {
                    case BETWEEN -> result.between(nameMask.apply(fieldName), value, maxValue);
                    case NOT_BETWEEN -> result.notBetween(nameMask.apply(fieldName), value, maxValue);
                    default -> throw new LyException.Panic("架构支持能力不足");
                }
            }
        });
        return result;
    }

    public static <M extends BaseMapper<T>, T> Page<T> page(ServiceImpl<M, T> service, Object query) {
        return buildSearch(service, query).page(Paging.startPage());
    }
}

跑一下试试

controller:

@RestController
@RequestMapping("/test-student")
@RequiredArgsConstructor
@Slf4j
public class TestStudentController {

    private final TestStudentService service;


    @GetMapping("search")
    public Page<TestStudent> search(TestStudentQuery query) {
        return service.search(query);
    }
}

query:

public record TestStudentQuery(
        @MbpQuery(type = QueryTypeEnum.LIKE)
        String stuNum,
        @MbpQuery(type = QueryTypeEnum.EQ)
        String sex,
        @MbpQuery(type = QueryTypeEnum.BETWEEN)
        LocalDate birthdayMin,
        @MbpQuery(type = QueryTypeEnum.BETWEEN)
        LocalDate birthdayMax,
        @MbpQuery(type = QueryTypeEnum.EQ)
        Integer starCount
) {
}

service impl:

@Override
public Page<TestStudent> search(TestStudentQuery query) {
    return MbpUtil.page(this, query);
}

test.http:

### 全部
GET http://localhost:8937/test-student/search?stuNum=1&sex=man&birthdayMin=2000-01-01&birthdayMax=2022-01-01&starCount=10
### between需要校验最大最小不能为空
GET http://localhost:8937/test-student/search?birthdayMax=2022-01-01

查看一下SQL日志:
在这里插入图片描述

确实是我们想要的

总结

多多少少还是有些弊端的, 如:

  1. 相较于原来, 缺少了实体类与检索pojo类的关联, 导致删字段时不方便查找引用
  2. 暂时还没遇到, 可能性能会有点问题, 我认为影响很小, 尤其是后台管理项目
    t.http:
### 全部
GET http://localhost:8937/test-student/search?stuNum=1&sex=man&birthdayMin=2000-01-01&birthdayMax=2022-01-01&starCount=10
### between需要校验最大最小不能为空
GET http://localhost:8937/test-student/search?birthdayMax=2022-01-01

查看一下SQL日志:

在这里插入图片描述

确实是我们想要的

总结

多多少少还是有些弊端的, 如:

  1. 相较于原来, 缺少了实体类与检索pojo类的关联, 导致删字段时不方便查找引用
  2. 暂时还没遇到, 可能性能会有点问题, 我认为影响很小, 尤其是后台管理项目
  3. 我不太擅长写文章, 没写清楚思路
  • 1
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值