后端“零代码“,前端全买单?一个动态筛选器的架构反思与重构
我接手了一套"优雅"的动态筛选方案,后端两行代码接入,支持 17 种操作符。直到前端同事找我:「你这个 field 到底是驼峰还是下划线?要不要加表别名?」——问了 5 轮我才意识到,我借用脚手架"零代码"是以别人的"高认知负担"为代价的。

引子:前端代码里的四行注释
有次 Review 一个列表页的筛选功能,注意到前端代码里有这样一段注释:
// 字段名问后端确认:supplier_name(不是 supplierName)
// 操作符用 in(不是 meq,也不是 eq)
// 别名用 B.(不是 A.,因为这里 JOIN 了供应商表)
// 数据源接口:/api/supplier/list(记得传 status=1
四行注释,记录了四次和后端沟通的结果。
这让我开始思考:这套方案到底是降低了系统复杂度,还是把复杂度从后端搬到了前端?
一、这套方案本身设计得很好
先说说原有方案。核心思路很干净:
前端传一段 JSON(含字段名、操作符、值),后端自动解析为 ORM 的 Where 条件。
伪代码大概是这样:
// 前端提交
POST /api/orders?filters={"groupOp":"AND","rules":[
{"field":"name","op":"cn","data":"苹果"},
{"field":"status","op":"eq","data":"2"}
]}
// 后端自动解析
Controller:
searchable = queryVo.toSearchable()
result = mapper.selectPage(searchable.getWrapper(), searchable.getPage())
// 生成 SQL
WHERE name LIKE '%苹果%' AND status = 2
从后端视角看:零配置、参数化安全、支持递归嵌套、操作符全覆盖。Controller 两行代码就搞定了。
客观地说,这套方案的抽象能力一流——SearchFilter 树支持任意嵌套,QueryWrapper 转换链类型安全,操作符覆盖全面。作为"通用查询引擎",它的技术设计无可挑剔。
二、问题不在技术,在协作
方案的设计哲学是「后端提供通用能力,前端自由组合」。技术层面没有问题,问题出在前后端协作层面——所有关键信息都锁在后端开发的脑子里,前端没有任何自助获取的途径。
问题一:前端不知道该传什么
后端没有提供任何"字段元数据"接口。前端面对一个列表页,需要自行回答:这个字段在数据库里叫什么?驼峰还是下划线?用 17 种操作符里的哪一个?有没有表别名前缀?下拉选项从哪个接口拿?
这些信息全部需要口头询问后端开发。
问题二:每个筛选字段都要 3-5 轮沟通
一个真实场景——前端要加一个"供应商"筛选:
|
轮次 |
前端问 |
后端答 |
|---|---|---|
|
第 1 轮 |
field 叫什么? |
supplier_name |
|
第 2 轮 |
传了 supplierName 不生效 |
要下划线...不对,驼峰也行,框架自动转 |
|
第 3 轮 |
用 eq 还是 cn? |
用 in 吧可以多选 |
|
第 4 轮 |
下拉选项接口是哪个? |
/api/supplier/list,记得传 status=1 |
|
第 5 轮 |
还是报错,列名不明确 |
哦这个 SQL 有 JOIN,字段要加 B. 前缀... |
每个字段 3-5 轮,一个列表页 10 个筛选就是 30-50 轮沟通。这不是技术问题,是信息不对称问题。
问题三:框架对初级前端门槛过高
团队有资深前端也有初级前端。这套方案要求前端理解 JSON 三层嵌套格式、驼峰与下划线转换规则、17 种操作符的语义差异、什么时候字段要带表别名。
资深前端能搞定但觉得麻烦;初级前端直接懵了。结果是部分前端宁愿退回传统固定参数,也不愿用这套"通用方案"。
问题四:子查询包装带来的隐性性能税
为了让 Where 条件能引用 JOIN 表字段,通常需要子查询包装:
SELECT * FROM (
SELECT a.*, b.supplier_name, b.contact, b.phone, b.region
FROM t_order a
LEFT JOIN t_supplier b ON a.supplier_id = b.id
) t
WHERE t.supplier_name IN ('张三', '李四')
前端可能只筛 supplier_name,但 contact/phone/region 也被迫 SELECT 出来了——因为"将来可能要筛"。数据到百万级、列数到 30+ 时,每次分页多拉 20 个无用列的 I/O 开销不可忽视。
问题五:setAlias() 只能覆盖主表
JOIN 时不同表有同名列,MySQL 报 Column 'name' is ambiguous。解决方案是统一加前缀:
queryVo.setAlias("A");
但这硬编码了别名、只能筛主表字段、JOIN 表的列做不到。
三、换个角度看:问题出在哪一层?
这套方案技术上没问题,问题出在它的设计假设上:
前端开发需要理解后端的数据模型,才能使用筛选功能。
如果推翻这个假设呢?如果前端不需要知道字段叫什么、用什么操作符、数据从哪来——这些信息全部由后端配置好,前端只负责「渲染配置 → 收集输入 → 原样提交」?
|
改造前 |
改造后 |
|
|---|---|---|
|
前端需要知道 |
字段名、操作符、数据源接口 |
什么都不需要知道 |
|
信息来源 |
后端开发口头传达 |
配置表 → 元数据 API 自动同步 |
|
新增筛选字段 |
前后端联调 + 前端编码 |
后端 INSERT 一行配置,前端自动生效 |
|
前端开发模式 |
每个列表页手动写筛选逻辑 |
一个通用组件,所有页面复用 |
核心转变:把「筛选字段的认知」从人脑转移到数据库配置表,让机器代替人去对齐信息。
四、配置驱动的整体架构

关键设计决。唯一的变化在 Service 层——从「解析前端 JSON」变为「解析配置驱动的 DTO 列表」。输出都是同一个 QueryWrapper,下游完全无感知。
五、三张表的设计哲学
表一:字段定义——一个字段的所有信息在一个地方
字段定义表 {
field_code -- 逻辑编码(给人看的,如 supplierName)
field_label -- 显示名(给用户看的,如"供应商")
field_type -- 组件类型(Enum/Interface/DateTime/Number/Text)
field_key -- SQL列表达式(给机器看的,如 b.supplier_name)
filter_condition -- 默认操作符
data_source_url -- 数据源接口
}
核心决策:field_key 直接存含别名的 SQL 列表达式。
因为直接 JOIN 的 WHERE 可以引用任何 JOIN 表的列,而不需要 SELECT 它。这从根本上解决了子查询方案的 SELECT 膨胀问题。
别名是 SQL JOIN 的固有特性,消除不了,只能选择存在最好维护的地方。配置表可热更新、可审计、可追踪——比散落在前端代码注释里强一百倍。
表二:页面绑定——同一字段可以出现在多个页面
页面绑定表 {
route_key -- 前端路由标识(决定哪个页面)
field_id -- 引用字段定义(决定展示哪些筛选项)
sort_order -- 显示顺序
default_show -- 是否默认展开
default_must -- 是否必选(不可移除)
}
这张表实现了字段复用 + 页面差异化。同一个"供应商"字段可以绑定到采购单列表、收货单列表、对账单列表——配置不同的排序和必选策略。
表三:用户预设——让每个
用户预设表 {
user_id -- 谁的视图
route_key -- 哪个页面
preset_name -- 视图名称(如"我的待审核")
is_default -- 是否默认加载
preset_json -- 保存的筛选条件快照
}
运营同事打开页面自动带出她的常用筛选条件,不需要每天重复选一遍。
六、后端核心——FilterBuilder 的实现思路
以下为伪代码,展示设计思路而非生产实现。实际项目中还需要考虑:列名安全校验(防注入)、IN 值数量上限、LIKE 通配符转义、异常容错等。
class FilterBuilder:
static merge(wrapper, conditions):
if conditions is empty: return
for each condition in conditions:
if shouldSkip(condition): continue
applyCondition(wrapper, condition)
static applyCondition(wrapper, condition):
col = condition.column
vals = condition.values
op = condition.operator
match op:
"eq" → wrapper.eq(col, vals[0])
"ne" → wrapper.ne(col, vals[0])
"meq" → wrapper.in(col, vals)
"mne" → wrapper.notIn(col, vals)
"range" → wrapper.between(col, vals[0], vals[1])
"contain" → wrapper.like(col, vals[0])
"gt" → wrapper.gt(col, vals[0])
"lt" → wrapper.lt(col, vals[0])
"empty" → wrapper.isNull(col).or().eq(col, "")
"notEmpty" → wrapper.isNotNull(col).ne(col, "")
业务代码零改动——只需加一个注解:
@AutoFilter // ← 这是唯一的改动
function pageList(dto):
wrapper = buildBaseConditions(dto)
return mapper.selectPage(wrapper, page)
注意这里 Service 方法里没有任何筛选相关代码。@AutoFilter 背后是一个 AOP 切面:拦截方法调用 → 从 DTO 中提取筛选条件 → 自动 merge 到 QueryWrapper → 方法照常执行。开发者不需要在每个 Service 方法里手动调用 FilterBuilder——切面统一处理了。
手动调用 FilterBuilder.merge() 仍然可用,作为不适合加注解的场景(如动态决定是否启用筛选)的兜底方案。但对于 90% 的列表页,一个 @AutoFilter 注解就够了。
Mapper 层和 XML 层完全不需要改动。原因不是"还在用 ${ew.sqlSegment}",而是 MyBatis-Plus 的 selectPage / selectList 天然接收 QueryWrapper 作为参数——AOP 在 Service 层就已经把筛选条件注入到 QueryWrapper 里了,Mapper 拿到的是一个已经组装好的完整查询对象,下游完全无感知。
这才是"零代码"的真正含义:不是"后端写两行代码就搞定",而是"连那两行都不用写"。
七、前端只需要做一件事
前端开发一个通用筛选组件,核心逻辑:
// 页面加载时
fields = GET /filter/fields?routeKey=当前路由
// 根据 fieldType 动态渲染组件
for field in fields:
match field.type:
"Enum" → 渲染下拉多选框,从 dataSourceUrl 拉选项
"Interface" → 渲染搜索选择框,支持远程模糊搜索
"DateTime" → 渲染日期范围选择器
"Number" → 渲染数值输入框
"Text" → 渲染文本输入框
// 用户点击搜索时,收集有值的筛选项提交
filters = fields
.filter(f => f.value is not empty)
.map(f => { column: f.fieldKey, operator: f.filterCondition, values: f.value })
POST /api/orders { page, size, filters }
前端不需要知道 fieldKey 是什么含义,只需要原样回传。它可能是 a.created_at 也可能是 b.supplier_name——对前端来说只是一个不透明的 token。
八、效果对比
|
指标 |
改造前 |
改造后 |
|---|---|---|
|
新增筛选字段耗时 |
1-2 天(含联调) |
5 分钟(INSERT 配置) |
|
前后端沟通轮次 |
3-5 轮/字段 |
0 轮 |
|
前端编码量 |
每个列表页单独写 |
通用组件一次开发 |
|
后端改代码频率 |
每次加筛选都要改 |
INSERT 配置即可 |
|
性能 |
子查询 SELECT 膨胀 |
直接 WHERE 引用,按需 SELECT |
|
JOIN 表筛选 |
只能筛主表 |
任意 JOIN 表的列 |
九、两套方案的共存定位
原方案不废弃。工程中没有银弹,只有适用场景的匹配。
|
场景 |
推荐方案 |
原因 |
|---|---|---|
|
后台管理列表页 |
配置驱动 |
筛选条件多、变更频繁、前端不应关心细节 |
|
数据报表/导出页 |
JSON 通用解析 |
字段组合灵活多变,使用者本身就是开发者 |
|
开发者内部工具 |
JSON 通用解析 |
使用者理解技术语义 |
|
面向运营/业务的界面 |
配置驱动 |
零学习成本 |
十、几条设计感悟
1.「通用」不等于「好用」
一个支持 17 种操作符、递归嵌套的通用框架,技术抽象能力一流。但如果使用者需要 5 轮沟通才能正确使用它——说明它优化了错误的一端。好的框架设计应该让使用者做更少的决定,而不是给他更多的选择。
2. 认知负担是最贵的隐性成本
代码行数可以度量,性能可以 benchmark。但「一个新人上手这个功能需要多久」——这个成本从来不被计入技术方案评估中。改造后新增字段从 1.5 天降到 5 分钟,这不是效率提升,是组织摩擦的消除。
3. 把信息从"人脑"搬到"系统"
所有依赖口口相传的信息,都是系统设计的 Bug。字段名、操作符、数据源——这些确定性信息没有任何理由需要人去记忆和传递。如果一个信息存在后端开发的脑子里,那它就只存在于那一刻的那一个人的脑子里。他休假了、离职了、忘了——信息就丢了。
4. 改造的正确粒度
这次改造没有动 Mapper 层、没有动 XML、没有换 ORM 框架。只改了「谁来构建 Where 条件」这一个点。好的重构应该像手术一样——最小切口,最大效果。不要为了解决一个配置问题就把整个数据访问层推翻重来。
写在最后
这个方案我已经在 3 个项目中使用,累计管理了 200+ 个筛选字段。最大的感受不是"代码变少了",而是前后端的沟通频率断崖式下降——以前每周都有的"这个字段怎么传"的对话,现在完全消失了。
如果你的团队也有类似的协作痛点——后端觉得方案很好、前端觉得太难用——不妨想想:你的"通用",是不是只对你自己通用?
本文记录的是设计思路和架构决策,非完整实现代码。实际项目中还需要处理安全校验、缓存策略、异常容错、前端组件库适配等工程细节。如果对完整实现感兴趣,欢迎交流探讨。成品代码在我私有的github上,有兴趣的可以关注我的同名公众号,交流。
更多推荐



所有评论(0)