在企业系统里,有一个很普通、但很容易把后端查询复杂度拉满的需求:客户希望表格里的每一列都能筛选。
这类需求一开始看起来像前端功能。表头下面加输入框,字符串列输入关键字,日期列选范围,数字列填最小值和最大值,枚举列做下拉多选。
但真正落到后端时,问题会变成:这些输入到底用什么结构传?后端怎么知道某一列允许什么操作符?怎么知道它对应哪个查询字段?怎么保证用户只能筛自己有权看到的字段?怎么把排序、分页、导出、保存查询条件也一起处理掉?
这就是 DSL 开始出现的位置。
这里说的 DSL,不是为了显得高级,也不是要让业务开发者再学一门语言。它更像一个查询契约:前端、后端、AI、审计日志,都围绕同一份结构化查询对象说话。
固定 Form 参数会越来越别扭
早期做列表查询,接口参数通常很直接:
public class WaybillQueryForm {
private String waybillNo;
private String customerNo;
private LocalDateTime inboundTimeStart;
private LocalDateTime inboundTimeEnd;
private BigDecimal stockQtyMin;
private BigDecimal stockQtyMax;
}
这种写法没问题。页面条件少、业务稳定、查询入口只有一个时,它很好理解。
但如果表格有二三十列,并且客户要求每一列都能筛选,Form 会不断膨胀。
新增一个“出库操作人”,Form 里加字段。新增一个“在库时长范围”,Form 里加两个字段。新增一个“当前库存数量范围”,再加两个字段。再过一段时间,导出、保存查询、默认筛选、表头筛选、快捷筛选都要复用这些条件。
最后接口参数会变成一个很长的查询参数容器。业务含义反而不清楚了:哪些是页面筛选,哪些是系统权限,哪些是默认条件,哪些是临时排序,哪些只是某个表格列的筛选方式。
更麻烦的是,很多列并不适合直接变成 Java 字段。
比如“在库时长”可能不是数据库里的物理列,而是由入库时间和当前时间计算出来的表达式。“运达网点”在前端是一列,在后端可能是一个维度 caption。“入库操作人”显示的是人名,但真正筛选时可能要走人员 ID 或组织关系。
如果这些东西都靠 Form 字段和 Service 里的 if 分支硬接,页面越多,重复映射越多。
直接传 SQL 也不是答案
有人会说,既然筛选条件这么动态,那前端把 SQL 片段传给后端不就行了?
这条路短期看很省事,长期基本不可接受。
第一,字段边界会失控。前端或外部调用方一旦能传 SQL 片段,就很难保证它只引用当前表格允许暴露的字段。
第二,权限边界会变模糊。用户输入的条件是业务筛选,系统注入的条件是访问边界。两者不能混成一段自由 SQL。
第三,审计很难稳定。你能记录一段 SQL,但很难从里面稳定还原“用户筛了哪几列”“哪些条件来自系统”“哪些字段被返回”“这次查询为什么允许执行”。
第四,数据库方言会泄漏到上层。今天是 MySQL,明天接 PostgreSQL 或 Mongo,前端和 AI 都不应该直接承担底层方言差异。
所以 DSL 的目标不是替代 SQL 的执行能力,而是把 SQL 从外部输入变成内部编译结果。
外部提交的是结构化意图,系统负责校验、补权限、选择执行方式,再生成 SQL 或 Mongo 查询。
DSL 更像一份查询订单
一个最小的查询 DSL 可以很朴素:
{
"page": 1,
"pageSize": 20,
"param": {
"columns": [
"waybillNo",
"customerNo",
"inboundTime",
"stockQty"
],
"slice": [
{
"field": "waybillNo",
"op": "like",
"value": "WX"
},
{
"field": "inboundTime",
"op": "[)",
"value": ["2026-06-01", "2026-07-01"]
},
{
"field": "stockQty",
"op": ">",
"value": 0
}
],
"orderBy": [
{
"field": "inboundTime",
"dir": "desc"
}
]
}
}
它表达的东西并不复杂:
- 返回哪些列;
- 对哪些字段加过滤;
- 每个过滤使用什么操作符;
- 分页是多少;
- 按什么字段排序。
但这几个字段一旦固定下来,很多事情就能前置处理。
后端可以先检查 waybillNo、inboundTime、stockQty 是否属于当前查询模型允许暴露的字段。可以检查 inboundTime 是日期字段,所以允许范围操作;stockQty 是数字字段,所以允许大于、小于、范围;某些字段只允许返回,不允许筛选。
也可以在 DSL 进入执行前,把系统权限条件补进去。
比如当前用户只能看某些网点,后端不需要相信前端传来的 siteId。它可以在编译查询前追加一段系统 slice,或者在更底层的权限上下文里统一注入。
这样一来,用户筛选和系统权限就能分清楚。
用户表达的是:
我想看这些列,并按这些条件过滤。
系统补充的是:
你只能在当前权限范围内看这些数据。
两者最终会一起进入查询执行,但来源和责任不同。
对 AI 来说,DSL 也比 SQL 更稳
到了 AI 辅助开发或 AI 查询场景,这个差异会更明显。
LLM 很擅长写 SQL,但“擅长”不等于“适合直接把 SQL 当生产入口”。如果让它直接写 SQL,它要同时处理表名、字段名、JOIN、权限条件、数据库方言、分页、排序和边界情况。
而如果让它生成 DSL,任务会小很多。
它只需要在当前查询模型允许的字段里选择列,组合过滤条件,选择操作符,给出分页和排序。至于真实 SQL 怎么生成,权限怎么注入,字段是否可见,底层走 SQL 还是 Mongo,应该由引擎处理。
这对人工审查也更友好。
一段 SQL 可能很长,还夹着 JOIN、函数、别名、子查询。DSL 则可以直接看到:这次查了哪些列,筛了哪些字段,用了什么操作符。
对于企业系统来说,这比“看起来能跑的 SQL”更重要。
DSL 不是语义层的全部
需要说清楚的是,DSL 本身还不是完整语义层。
如果只有 DSL,没有字段定义、类型定义、可见性、权限上下文和执行证据,它也只是另一种格式的查询参数。
所以 DSL 必须和查询模型一起看。
查询模型负责说明:
- 哪些字段可以被查询;
- 哪些字段可以被筛选或排序;
- 字段是什么类型;
- 字段来自物理列、维度 caption、计算表达式,还是指标;
- 当前用户是否有权看到它。
DSL 负责表达一次具体查询:
- 我要哪些列;
- 我要哪些过滤条件;
- 我要如何分组、排序和分页。
两者合在一起,才有稳定边界。
这也是为什么从动态 SQL 模板往后走时,我们没有直接把模板暴露成外部接口,而是逐渐把“字段、操作符、筛选条件、排序、分页”抽成结构化查询契约。
SQL 模板解决的是后端怎么把复杂查询写得更清楚。
DSL 解决的是外部调用方怎么把查询意图说清楚,并且让系统有机会在执行前校验它。
这一步并不华丽,但很关键。
因为从这里开始,查询不再只是某个 Service 方法里的几段 if 拼接,而是一份可以校验、可以记录、可以回放、可以让前端和 AI 共用的结构化请求。
再往后,MCP tool、自然语言查询、权限前置、审计和 provenance,才有一个比较稳的落点。
相关代码:foggy-dataset Java 引擎模块
相关文档:JSON Query DSL 语法参考