我接手了一套"优雅"的动态筛选方案,后端两行代码接入,支持 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上,有兴趣的可以关注我的同名公众号,交流。
Logo

一站式 AI 云服务平台

更多推荐