
1. 项目缘起从重复的“体力活”到统一的“方法论”做后台管理系统的朋友对分页查询这个功能一定再熟悉不过了。无论是用户列表、订单记录、日志审计还是商品库存几乎每一个带列表的页面背后都离不开分页查询。我经历过很多项目也看过不少团队的代码发现一个普遍存在的现象每个列表页面几乎都有一套自己独立的查询逻辑。比如用户管理模块前端传过来pageNum,pageSize,username,status等参数后端在 Controller 里接收在 Service 里组装查询条件调用 Mapper 查询总数和分页数据最后封装成一个PageResult对象返回。到了订单模块又来一遍接收pageNum,pageSize,orderNo,startTime,endTime组装条件查询封装。看起来没什么问题功能都能实现。但项目做久了模块多了问题就来了。首先是代码冗余每个 Service 里都有大同小异的查询代码。其次是维护成本高哪天产品经理说所有列表查询都要支持按创建时间倒序或者都要加一个模糊搜索的通用逻辑你就得一个个模块去改。更头疼的是规范不统一A 模块的分页返回字段叫total和listB 模块可能叫count和data给前端联调也带来了不必要的麻烦。后来接触到 MyBatis-Plus 这类增强工具它的Page对象和分页插件确实简化了操作不用手动写count语句和limit了。但本质上它解决的是ORM 层面的分页执行问题而不是业务逻辑层的查询参数处理与封装问题。你依然需要在每个业务方法里手动从各种 DTO 或 VO 里提取参数去构建QueryWrapper这个过程依然是重复的。所以我就想能不能把这件事再抽象一层用一个共通的、强大的“材料”来管理所有分页查询的输入和输出这个“材料”就是标题里说的viewModel。它不是一个简单的参数包装类而是一个承载了查询意图、组装逻辑、执行控制和结果规范的完整框架。目标很简单让开发者在写一个新的分页查询接口时只需要关注最核心的、与众不同的那部分业务逻辑其他通用的、繁琐的步骤全部交给框架自动完成。2. 核心设计ViewModel 如何成为“万能材料”这个框架的核心就是这个“共通的 viewModel”。它不是指 MVVM 模式里的那个 ViewModel而是借用了其“承载视图数据与逻辑”的思想。在这里它专门用来承载一次分页查询请求的所有信息。2.1 ViewModel 的四层结构设计一个合格的分页查询 ViewModel我认为应该包含四个层次的信息我把它设计成了一个继承体系第一层基础分页参数 (BasePageViewModel)这是所有分页查询的基石包含那些绝对通用的字段。public abstract class BasePageViewModel { /** * 当前页码从1开始 */ NotNull(message 页码不能为空) Min(value 1, message 页码最小为1) private Integer pageNum 1; /** * 每页条数 */ NotNull(message 每页条数不能为空) Min(value 1, message 每页条数最小为1) Max(value 500, message 单页查询不得超过500条) private Integer pageSize 10; /** * 排序字段格式createTime desc,id asc */ private String orderBy; }这一层解决了最共性的问题页码、大小和排序。通过注解统一进行校验避免了在每个 Controller 里重复写if (pageNum null || pageNum 1)...这样的代码。第二层通用查询条件 (GenericQueryViewModel)这一层引入业务系统中那些高频出现的查询条件。public abstract class GenericQueryViewModel extends BasePageViewModel { /** * 主键ID用于精确查询 */ private Long id; /** * 名称/标题的模糊查询关键词 */ private String keyword; /** * 状态通常用于枚举查询 */ private Integer status; /** * 创建时间范围查询开始时间 */ DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private Date createTimeStart; /** * 创建时间范围查询结束时间 */ DateTimeFormat(pattern yyyy-MM-dd HH:mm:ss) private Date createTimeEnd; /** * 数据所属组织/部门ID用于数据权限过滤 */ private Long deptId; }像keyword模糊搜索、状态过滤、时间范围、数据权限字段这些在80%的列表查询中都会用到。把它们放到这一层子类直接继承就有了这些字段无需重复声明。DateTimeFormat注解也统一了前端传参的时间格式。第三层模块级查询条件 (ModuleXXXQueryViewModel)这一层是针对特定业务模块的。比如用户模块Data EqualsAndHashCode(callSuper true) public class UserQueryViewModel extends GenericQueryViewModel { // 继承来的字段pageNum, pageSize, keyword, status, createTimeStart... // 用户模块特有的字段 private String phone; private String email; private Integer userType; private Long roleId; }订单模块public class OrderQueryViewModel extends GenericQueryViewModel { private String orderSn; private BigDecimal minAmount; private BigDecimal maxAmount; private Integer payType; }到了这一层ViewModel 开始具备具体的业务语义。它清晰地告诉开发者查询用户时你可以用这些字段来过滤。第四层特殊场景扩展自定义对于一些极其复杂、条件组合灵活的查询比如动态表单查询、高级筛选器我们可以在第三层的基础上增加一个MapString, Object extraParams字段用于接收不确定的动态参数或者让具体的 ViewModel 实现某个CustomConditionBuilder接口自定义条件组装逻辑。这保证了框架的扩展性不会因为某个复杂场景而破坏整体结构。2.2 与 MyBatis-Plus 的 Page 对象的关系这里必须澄清一个常见的误解。很多人看了标题会以为我要再造一个 MyBatis-Plus 的Page轮子。完全不是。MyBatis-Plus 的Page它的核心职责是作为执行分页查询的载体与QueryWrapper结合通过拦截器在 SQL 执行时自动加上LIMIT和COUNT。它关注的是“怎么查”。我们的ViewModel它的核心职责是作为描述查询需求的载体。它包含了前端传来的所有过滤、排序、分页信息。它关注的是“查什么”。它们的关系是协作而非替代。流程是这样的前端请求 - Controller 接收并校验UserQueryViewModel。Service 层调用一个通用的查询方法传入UserQueryViewModel。在通用方法内部框架组件会根据ViewModel的字段信息自动构建出 MyBatis-Plus 所需的QueryWrapper。同时根据ViewModel中的pageNum和pageSize自动创建一个 MyBatis-Plus 的Page对象。执行page(page, queryWrapper)查询。将查询结果Page对象中的records和total自动封装成统一的响应体PageResult。所以ViewModel是“原材料”通用服务是“加工流水线”Page和QueryWrapper是流水线上的“标准模具”最终产出统一的“产品”PageResult。开发者只需要提供“原材料”定义ViewModel而不用关心模具怎么运作。3. 核心实现自动化的条件组装与查询执行框架的魔力就在于“自动”。如何把包含丰富条件的 ViewModel自动变成数据库查询语句这是实现的关键。3.1 基于反射与注解的字段映射策略最朴素的想法是遍历 ViewModel 的所有字段如果字段值不为空就认为它是一个查询条件然后根据字段名去匹配数据库列名。但这太粗糙了无法处理“模糊查询”、“范围查询”、“枚举转换”等复杂场景。我们的策略是“约定大于配置注解细化规则”。首先定义一套默认约定Convention字段名驼峰转下划线即为数据库列名。例如userName-user_name。基本类型及其包装类、String 类型的非空字段默认使用EQ等于查询。字段名以Keyword结尾的字符串字段默认使用LIKE模糊查询。字段名以Start结尾的认为是某字段的查询开始值使用GE大于等于。字段名以End结尾的认为是查询结束值使用LE小于等于。然后通过自定义注解来覆盖约定或处理特殊情况Target(ElementType.FIELD) Retention(RetentionPolicy.RUNTIME) public interface QueryField { /** * 映射的数据库列名默认按约定转换 */ String column() default ; /** * 查询操作符EQ, NE, LIKE, GT, GE, LT, LE, IN, BETWEEN... */ Operator operator() default Operator.AUTO; /** * 是否忽略该字段不参与条件构建 */ boolean ignore() default false; /** * 自定义值转换器用于处理枚举、状态码等转换 */ Class? extends FieldValueConverter converter() default DefaultConverter.class; }在GenericQueryViewModel中我们就可以这样用public abstract class GenericQueryViewModel extends BasePageViewModel { QueryField(operator Operator.LIKE) private String keyword; // 明确指定使用 LIKE QueryField(column create_time) // 明确指定列名 private Date createTimeStart; QueryField(column create_time) private Date createTimeEnd; QueryField(ignore true) // 此字段不参与SQL构建可能用于业务逻辑判断 private Boolean isExport; }3.2 通用查询服务GenericQueryService的实现有了映射规则接下来就是实现一个“万能”的查询服务。这里我采用泛型加策略模式的设计。public interface GenericQueryServiceE, V extends BasePageViewModel { /** * 执行分页查询 * param viewModel 查询视图模型 * return 统一分页结果 */ PageResultE page(V viewModel); /** * 执行列表查询不分页 * param viewModel 查询视图模型 * return 实体列表 */ ListE list(V viewModel); }它的一个基于 MyBatis-Plus 的通用实现核心如下Service public class GenericQueryServiceImplE, V extends BasePageViewModel implements GenericQueryServiceE, V { Autowired private BaseMapperE baseMapper; // 依赖注入实体对应的Mapper Override public PageResultE page(V viewModel) { // 1. 参数校验 (利用JSR-303在Controller层已完成) // 2. 构建QueryWrapper QueryWrapperE queryWrapper buildQueryWrapper(viewModel); // 3. 处理排序 handleOrderBy(viewModel, queryWrapper); // 4. 创建MyBatis-Plus Page对象 PageE mpPage new Page(viewModel.getPageNum(), viewModel.getPageSize()); // 5. 执行查询 PageE resultPage baseMapper.selectPage(mpPage, queryWrapper); // 6. 转换为统一PageResult return PageResult.of(resultPage.getRecords(), resultPage.getTotal()); } /** * 核心根据ViewModel构建QueryWrapper */ protected QueryWrapperE buildQueryWrapper(V viewModel) { QueryWrapperE queryWrapper new QueryWrapper(); Class? clazz viewModel.getClass(); // 获取所有字段包括父类 ListField fields getAllFields(clazz); for (Field field : fields) { field.setAccessible(true); try { Object value field.get(viewModel); if (shouldIgnore(field, value)) { continue; // 忽略空值或被QueryField(ignoretrue)标记的字段 } // 获取字段上的注解信息 QueryField queryField field.getAnnotation(QueryField.class); // 确定数据库列名 String columnName determineColumnName(field, queryField); // 确定操作符 Operator operator determineOperator(field, queryField); // 对值进行转换如枚举转数字 Object convertedValue convertValue(value, queryField); // 根据操作符调用QueryWrapper的不同方法 applyCondition(queryWrapper, columnName, operator, convertedValue); } catch (IllegalAccessException e) { // 记录日志跳过此字段 } } // 处理数据权限等全局过滤条件可通过ThreadLocal或AOP注入 applyDataPermission(queryWrapper); return queryWrapper; } // ... 其他辅助方法determineColumnName, determineOperator, applyCondition等 }这个buildQueryWrapper方法就是框架的引擎。它通过反射解析 ViewModel根据规则和注解智能地拼接出QueryWrapper。这样一来对于普通的增删改查CRUD模块其 Service 可以简化到极致Service public class UserServiceImpl extends GenericQueryServiceImplUser, UserQueryViewModel implements UserService { // 只需要处理User特有的、无法通过字段映射的复杂逻辑即可 // 99%的标准分页查询直接调用父类的 page() 方法就够了 public PageResultUser queryUsers(UserQueryViewModel viewModel) { // 可以在调用父类方法前后添加一些特定逻辑比如记录日志 return super.page(viewModel); } }3.3 统一响应封装PageResult输出也必须统一。我们定义一个通用的PageResult类。Data NoArgsConstructor AllArgsConstructor public class PageResultT { /** * 状态码 */ private Integer code; /** * 提示信息 */ private String msg; /** * 总记录数 */ private Long total; /** * 当前页数据列表 */ private ListT rows; /** * 当前页码 */ private Integer pageNum; /** * 每页条数 */ private Integer pageSize; /** * 总页数 */ private Integer totalPage; // 成功静态工厂方法 public static T PageResultT success(ListT rows, Long total) { PageResultT result new PageResult(); result.setCode(200); result.setMsg(success); result.setRows(rows); result.setTotal(total); // 这里可以根据需要计算pageNum, pageSize, totalPage通常从ThreadLocal或额外参数传入 return result; } // ... 其他失败或带分页信息的工厂方法 }在 Controller 层返回类型直接就是PageResult前端拿到的是一个结构完全固定的响应联调效率大大提升。4. 实战应用以用户查询为例的端到端流程理论说再多不如看一个完整的例子。我们来实现一个用户分页查询接口。第一步定义 ViewModelData EqualsAndHashCode(callSuper true) public class UserQueryViewModel extends GenericQueryViewModel { // 从GenericQueryViewModel继承pageNum, pageSize, keyword, status, createTimeStart, createTimeEnd QueryField(column phone_number) private String phone; // 指定映射到phone_number列使用默认EQ操作符 QueryField(operator Operator.LIKE) private String email; // 对email进行模糊查询 QueryField(converter UserTypeConverter.class) // 使用自定义转换器 private Integer userType; // 前端传“ADMIN”转换器转成数据库存的数字1 private Long roleId; // 默认EQ查询字段名role_id映射到列role_id }这里展示了注解的灵活使用指定列名、指定操作符、使用值转换器。第二步编写 ControllerRestController RequestMapping(/api/user) public class UserController { Autowired private UserService userService; PostMapping(/page) public PageResultUser getUserPage(Valid RequestBody UserQueryViewModel queryViewModel) { // 参数校验通过Valid自动完成 return userService.queryUsers(queryViewModel); } }极其简洁。Valid注解会触发 JSR-303 校验如果pageNum为0或负数请求在进入 Service 前就会被拦截并返回标准错误。第三步编写 Servicepublic interface UserService { PageResultUser queryUsers(UserQueryViewModel queryViewModel); } Service public class UserServiceImpl extends GenericQueryServiceImplUser, UserQueryViewModel implements UserService { Override public PageResultUser queryUsers(UserQueryViewModel queryViewModel) { // 方案A直接使用父类通用能力覆盖90%场景 // return super.page(queryViewModel); // 方案B在通用查询前后添加特定逻辑 log.info(开始查询用户参数: {}, queryViewModel); // 可以在这里对viewModel进行一些预处理比如填充当前用户部门ID if (queryViewModel.getDeptId() null) { queryViewModel.setDeptId(getCurrentUserDeptId()); } PageResultUser result super.page(queryViewModel); log.info(用户查询完成共{}条, result.getTotal()); // 可以对结果进行后处理比如敏感信息脱敏 result.getRows().forEach(this::maskSensitiveInfo); return result; } private void maskSensitiveInfo(User user) { user.setPassword(null); // 对手机号、邮箱等脱敏 if (user.getPhone() ! null) { user.setPhone(user.getPhone().replaceAll((\\d{3})\\d{4}(\\d{4}), $1****$2)); } } }第四步前端调用前端不再需要为每个接口定义不同的参数结构它只需要知道调用列表接口就传一个对应模块的ViewModelJSON 对象。// POST /api/user/page { pageNum: 1, pageSize: 20, keyword: 张, // 模糊搜索姓名 status: 1, createTimeStart: 2023-01-01 00:00:00, createTimeEnd: 2023-12-31 23:59:59, phone: 13800138000, // 精确匹配手机号 userType: ADMIN // 转换器会将此字符串转为对应的数字编码 }响应永远是{ code: 200, msg: success, total: 150, rows: [...], pageNum: 1, pageSize: 20, totalPage: 8 }整个流程下来后端开发在新增一个标准的分页查询接口时真正需要编写的代码只有定义一个继承GenericQueryViewModel的XxxQueryViewModel类如果需要特殊字段。在 Controller 中加一个方法参数是这个 ViewModel。在 Service 中调用父类的page()方法或稍作包装。原先需要写的大量参数接收、校验、QueryWrapper组装、结果封装代码全部消失了。这就是“一个共通的 viewModel 搞定所有分页查询”带来的效率提升。5. 高级特性与边界情况处理一个成熟的框架不能只处理阳光大道还得能走崎岖小径。在实际使用中我们遇到了不少边界情况并逐一给出了解决方案。5.1 复杂查询条件的处理自定义条件构建器自动映射解决了80%的简单等式、模糊、范围查询。但总有一些复杂场景比如(status 1 AND create_time ‘xxx’) OR (status 2 AND create_time ‘xxx’)联表查询的字段过滤。对某个字段进行函数计算后再比较如DATE(create_time) ‘2023-10-01’。对于这些框架提供了“逃生舱”机制。我们在GenericQueryViewModel中预留了一个扩展钩子public abstract class GenericQueryViewModel extends BasePageViewModel { // ... 其他通用字段 /** * 自定义条件构建器函数式接口 * 当自动构建无法满足时可在此Lambda中直接操作QueryWrapper */ JsonIgnore // 避免序列化到前端 private ConsumerQueryWrapper? customConditionBuilder; }在GenericQueryServiceImpl.buildQueryWrapper方法的最后会检查并执行这个构建器protected QueryWrapperE buildQueryWrapper(V viewModel) { QueryWrapperE queryWrapper new QueryWrapper(); // ... 自动构建过程 // 执行自定义构建逻辑如果存在 if (viewModel.getCustomConditionBuilder() ! null) { viewModel.getCustomConditionBuilder().accept(queryWrapper); } return queryWrapper; }使用起来非常灵活UserQueryViewModel viewModel new UserQueryViewModel(); viewModel.setKeyword(张); viewModel.setCustomConditionBuilder(wrapper - { // 构建复杂条件 wrapper.and(w - w.eq(dept_id, 1).gt(create_time, 2023-01-01)) .or(w - w.eq(dept_id, 2).lt(create_time, 2023-06-01)); // 或者添加联表条件 wrapper.inSql(role_id, select id from sys_role where role_code ADMIN); });这样框架的自动构建能力与手写复杂 SQL 的能力就完美结合了既保证了通用场景的便捷又为特殊场景留足了灵活性。5.2 多表关联查询的适配这是另一个常见痛点。我们的 ViewModel 和自动构建默认是针对单张实体表的。对于需要联表查询的场景比如“查询用户及其角色名称”有两种主流方案方案一使用 MyBatis-Plus 的联表查询功能如TableField(select false)配合自定义 SQL。此时我们的 ViewModel 需要能够映射到多张表的字段。我们可以在字段注解中通过column属性指定带表别名alias的列名。public class UserWithRoleViewModel extends GenericQueryViewModel { QueryField(column u.username) // 映射到 user 表的 username private String username; QueryField(column r.role_name) // 映射到 role 表的 role_name private String roleNameKeyword; // 对角色名进行模糊查询 }在对应的GenericQueryServiceImpl子类中需要重写buildQueryWrapper方法在构建之初就设置好QueryWrapper的from部分如果 MP 支持或者确保自动构建的列名都带有正确的表别名前缀。这需要对基础框架做一定改造使其能识别和处理带别名的列。方案二使用视图View或自定义结果集对象。这是更清晰、对框架侵入更小的方式。为复杂的联表查询创建一个数据库视图或者直接定义一个UserWithRoleDTO类。然后为这个 DTO 创建对应的ViewModel和Mapper。框架的自动构建机制完全适用因为它只关心ViewModel里的字段如何映射到当前查询对象可以是实体也可以是DTO的对应列上。MyBatis-Plus 的Page对象也支持对自定义DTO进行分页查询。这种方式将联表的复杂性转移到了 SQL 定义Mapper.xml 或注解中而查询条件构建层保持干净。5.3 性能考量与最佳实践反射会不会影响性能这是被问得最多的问题。在构建QueryWrapper时确实用到了反射来遍历和读取字段值。但我们需要理性分析频率一次 HTTP 请求通常只调用一次buildQueryWrapper。开销反射调用的开销与一次网络 I/O、数据库查询相比几乎可以忽略不计。在常规的 Web 应用中这不会成为性能瓶颈。缓存优化我们可以对反射的元数据如类的字段列表、注解信息进行缓存。第一次解析某个ViewModel类时将它的字段映射规则缓存起来后续直接使用缓存避免重复反射。更重要的性能考量其实在数据库层面索引匹配自动构建的QueryWrapper生成的 SQL 条件顺序可能与你的索引最左前缀原则不匹配。例如你有一个(status, create_time)的联合索引但自动构建可能先加了keyword LIKE条件。这时需要在QueryField注解中增加一个priority属性让高优先级的条件那些能命中索引的先被添加到QueryWrapper中或者重写buildQueryWrapper方法调整条件添加顺序。N1 查询问题如果你的ViewModel包含关联对象 ID 的查询如roleId而你的 Service 在查询出User列表后又循环查询每个User的Role信息就会产生 N1 问题。框架本身不解决这个问题这需要你在 Service 层使用join查询或MyBatis-Plus的TableField(select false)配合一次查询来解决。框架负责高效地构建出“查询条件”而“查询执行”的优化需要开发者根据业务场景自行处理。最佳实践建议对于超高性能、超高并发的核心接口如果经过压测确实发现条件构建是瓶颈可以针对该接口单独实现一个高度优化的QueryWrapper构建逻辑而不走通用框架。框架提供的是“普惠”的便利不排斥在关键位置进行“特例”优化。6. 踩坑实录那些年我们遇到的“坑”与解决方案任何框架在落地过程中都不会一帆风顺。分享几个我们踩过的典型坑希望能帮你绕过去。坑一字段名映射的“幽灵”冲突我们约定字段名驼峰转下划线作为列名。大部分时候工作良好直到遇到一个字段叫uId用户ID。按照规则它会被转换成u_id。但数据库里实际的列名是user_id。自动构建出的 SQL 就成了WHERE u_id ?导致报错“列不存在”。解决方案永远不要完全依赖约定。对于这种缩写或与数据库列名差异较大的字段必须使用QueryField(column “user_id”)显式指定。我们在团队规范中强制要求所有ViewModel字段都必须添加QueryField注解即使使用默认规则也要写成QueryField这迫使开发者思考映射关系提前发现问题。坑二空字符串与 NULL 的“哲学”问题前端传参时一个输入框清空后提交的是空字符串“”。对于模糊查询keyword我们是想忽略这个条件还是想查询keyword “”的记录默认的shouldIgnore逻辑可能只判断value null空字符串会被当成有效值构建出WHERE keyword LIKE ‘%%’这个条件虽然不影响结果但多余且不优雅。解决方案在GenericQueryViewModel的基类中对keyword这类模糊查询字段在setter方法中进行 trim 处理如果 trim 后为空则直接设为null。public void setKeyword(String keyword) { if (keyword ! null keyword.trim().isEmpty()) { this.keyword null; } else { this.keyword keyword; } }同时在shouldIgnore方法中增加对空字符串的判断逻辑可以根据字段类型和注解自定义忽略规则。坑三继承带来的“字段污染”UserQueryViewModel继承了GenericQueryViewModel而GenericQueryViewModel又继承了BasePageViewModel。在反射获取字段时我们会获取到父类所有的public和protected字段。如果某个父类中有一个内部使用的、不希望被当成查询条件的字段比如一个transient字段或者工具方法它也会被遍历到可能导致意外的查询条件被添加。解决方案在getAllFields方法中过滤掉被QueryField(ignore true)标记的字段。更严格一点可以只处理当前ViewModel类及其父类中明确声明需要查询的字段或者通过一个基类注解来标记“查询模型根类”只从这个类开始向下收集字段。坑四排序字段的 SQL 注入风险BasePageViewModel中的orderBy字段如果直接拼接到ORDER BY后面如queryWrapper.orderBySql(“create_time desc”)当这个值来自前端且未经验证时存在 SQL 注入风险。比如前端传orderBy“1; DROP TABLE users --”。解决方案对orderBy进行严格的校验和清洗。我们实现了一个OrderByParser工具类只允许字母、数字、下划线和逗号、空格并且会检查字段名是否在白名单内白名单可以配置或者通过实体类的属性反射获取。protected void handleOrderBy(V viewModel, QueryWrapperE queryWrapper) { String orderBy viewModel.getOrderBy(); if (StringUtils.isNotBlank(orderBy)) { ListOrderItem orderItems OrderByParser.parseSafe(orderBy, entityClass); queryWrapper.orderBy(true, true, orderItems); // 使用MyBatis-Plus的安全排序方法 } }绝对不要相信任何来自前端的、用于直接拼接 SQL 的输入。坑五与 MyBatis-Plus 版本升级的兼容性我们严重依赖 MyBatis-Plus 的QueryWrapperAPI。如果 MP 进行大版本升级某些 API 变动比如方法名、参数顺序可能会导致我们的框架代码需要修改。解决方案将框架中所有直接调用 MPQueryWrapperAPI 的地方封装到一层ConditionBuilder接口后面。这样MP 的QueryWrapper只是这个接口的一个实现。如果未来 MP 接口变化或者我们想切换到其他 ORM虽然概率很小只需要提供一个新的ConditionBuilder实现即可核心的ViewModel解析逻辑无需变动。这是经典的“依赖倒置”原则的应用。7. 框架的扩展与生态建设一个好的框架不应该是一个黑盒而应该提供足够的扩展点让使用者能够根据自身业务进行定制。扩展点一自定义字段值转换器FieldValueConverter前面提到的UserTypeConverter就是一个例子。它实现了FieldValueConverter接口负责将前端传来的枚举字符串如“ADMIN”转换为数据库存储的数字编码如1。你可以为任何需要特殊处理的字段类型注册转换器比如将前端传来的“2023-10-01”字符串转换为LocalDate对象或者将逗号分隔的字符串“1,2,3”转换为List用于IN查询。public class UserTypeConverter implements FieldValueConverter { Override public Object convert(Object sourceValue) { if (sourceValue instanceof String) { String type (String) sourceValue; // 这里可以从配置或枚举类中获取映射关系 if (ADMIN.equalsIgnoreCase(type)) return 1; if (USER.equalsIgnoreCase(type)) return 2; } return sourceValue; // 无法转换则原样返回 } }扩展点二全局数据权限拦截器AOP几乎所有的企业系统都有数据权限需求。我们可以在GenericQueryServiceImpl.applyDataPermission方法中实现。更优雅的方式是使用 Spring AOP定义一个切面拦截所有GenericQueryService.page()方法的执行在buildQueryWrapper之后、执行查询之前向QueryWrapper中动态注入数据权限条件如dept_id IN (1,2,3)。Aspect Component public class DataPermissionAspect { Around(execution(* com.yourcompany.framework.service.*QueryService.page(..))) public Object aroundPageQuery(ProceedingJoinPoint joinPoint) throws Throwable { Object[] args joinPoint.getArgs(); BasePageViewModel viewModel (BasePageViewModel) args[0]; // 1. 从ThreadLocal或SecurityContext获取当前用户权限 UserPermission permission getCurrentUserPermission(); // 2. 将权限信息附加到viewModel中或通过其他方式传递 viewModel.setDataPermission(permission); // 3. 执行原方法 return joinPoint.proceed(args); } }然后在applyDataPermission方法中读取viewModel中的权限信息构造条件添加到QueryWrapper。这样数据权限的控制就与具体的业务查询逻辑完全解耦了。扩展点三查询结果后置处理器ResultPostProcessor有时我们希望在查询出数据后进行一些统一处理比如敏感信息脱敏如手机号、邮箱、身份证号。数据字典翻译将状态码1翻译成“启用”。关联数据填充虽然不建议在循环中查库但可以通过ResultPostProcessor进行一次性的批量查询来填充关联数据。 我们可以定义一个处理器链在GenericQueryServiceImpl.page()方法返回结果前遍历处理器链对PageResult中的rows进行加工。生态建设代码生成器当框架稳定后可以开发一个配套的代码生成器Code Generator。输入数据库表名自动生成实体类Entity。继承了GenericQueryViewModel的XxxQueryViewModel类并根据表字段注释、类型自动添加合理的QueryField注解。Controller 和 Service 的骨架代码。 这样一来开发一个标准的分页查询接口从建表到接口可用可能只需要几分钟真正实现“配置即开发”。8. 总结与个人体会回顾这个“材料管理框架”的构建过程其核心思想并不复杂将变化的部分查询条件封装起来将不变的部分分页逻辑、条件组装、结果封装抽象并固化。它带来的价值是显而易见的开发效率飞跃新接口开发从“小时级”降到“分钟级”。代码质量提升消除了重复代码统一了编码规范降低了 Bug 率。维护成本降低通用逻辑的修改在一处进行全局生效。团队协作顺畅前后端对接口格式有稳定预期联调沟通成本大幅下降。但任何框架都有其适用边界。它非常适合中后台管理系统中那些标准的、基于单表或简单视图的 CRUD 分页查询。对于极其复杂的多维度分析查询、大数据量的聚合查询、或者对性能有极端要求的核心交易查询可能仍然需要手写定制化的 SQL 和 Service 逻辑。这时候框架应该扮演一个“提供基础工具”的角色而不是“必须遵守的枷锁”。我们可以在特殊场景下直接使用 MyBatis-Plus 的原生 API或者甚至直接使用 MyBatis 的 XML Mapper框架对此应保持开放态度。我个人最大的体会是技术方案的选择永远是在“规范性”、“效率”、“灵活性”三者之间寻找最佳平衡点。这个分页查询框架就是我们在经历了无数个重复的列表开发后为了提升“规范性”和“效率”而引入的。它牺牲了一点“灵活性”对于简单查询你需要遵循它的规则但换来了项目整体工程质量的巨大提升。当团队所有人都熟悉这套规范后阅读和维护彼此的代码将变得非常轻松这才是框架带来的最大红利。最后一个小技巧在项目初期如果业务模型还不稳定不必急于引入完整的框架。可以先定义一个最基础的BasePageViewModel和手写一个简单的QueryWrapper构建工具类让大家先用起来感受统一封装的好处。等模式得到验证、团队形成共识后再逐步迭代到如今这个相对完善的框架形态。渐进式的演进往往比一步到位的革命更容易成功。