DSL 之后,语义层到底要设计什么

7 阅读7分钟

上一篇写到,表格每一列都要能筛选以后,固定 Form 参数会越来越吃力,所以需要一套结构化查询 DSL。

DSL 能把一次查询说清楚:

  • 我要哪些列;
  • 对哪些字段加条件;
  • 每个条件用什么操作符;
  • 怎么分页;
  • 怎么排序。

但 DSL 本身还不够。

因为 DSL 里会出现一个很关键的东西:field

比如:

{
  "field": "inboundTime",
  "op": "[)",
  "value": ["2026-06-01", "2026-07-01"]
}

这里的 inboundTime 到底是什么?

它是数据库里的字段名吗?是前端表格列名吗?是 Java Form 里的参数名吗?它能不能筛选?能不能排序?为什么可以用范围操作符?

如果这些问题没有一个统一答案,DSL 也只是把原来散落在 Form 和 SQL 里的混乱,换成了 JSON 里的混乱。

这就是语义层开始变得必要的地方。

表格列不等于数据库字段

业务表格里的列,经常不是数据库字段的一一映射。

比如一个库存或运单表格里可能有这些列:

  • 运单号;
  • 入库时间;
  • 在库时长;
  • 当前库存数量;
  • 开单网点;
  • 入库操作人;
  • 目的地。

“运单号”可能就是一个物理列。

“入库时间”也可能是物理列,但前端展示的是格式化后的时间,筛选时要走日期范围。

“在库时长”就不一定是物理列了。它可能是当前时间减去入库时间算出来的结果。

“开单网点”前端显示的是网点名称,底层可能存的是网点 ID,查询时要关联网点表,或者走一个维度 caption。

“入库操作人”显示的是人名,但真正筛选时,可能要按用户 ID、组织关系或人员维度查。

也就是说,用户看到的是一列,后端真正执行时可能是物理列、关联字段、显示名、计算表达式、枚举映射或指标。

如果每个页面都自己维护一份映射,问题很快会出现:

  • 页面显示叫“入库时间”,后端参数叫 startDate/endDate
  • 导出逻辑又用另一套字段名;
  • SQL 模板里写的是 stock.inbound_time
  • AI 或外部查询看到的又是 inboundTime
  • 后续还可能要和权限、导出、审计等逻辑关联。

这些名字和含义一旦漂移,维护成本就会上来。

先对齐几个词

这里顺手解释几个后面会反复出现的词,不展开成数仓或 BI 教程。

属性,可以理解成一条记录上的普通字段,比如运单号、状态、入库时间、操作人。

维度,是用来观察、筛选或分组数据的业务方向,比如客户、仓库、网点、公司、日期。很多维度底层是外键,展示给用户时通常要变成名称,也就是 caption。

度量,是可以被统计的数值,比如数量、金额、时长、次数。它通常会和 sumavgcountmax 这类聚合方式一起出现。

level 是维度里的层级,比如日期里的年、月、日,组织里的公司、部门、人员,地区里的省、市、区。

这些概念继续展开可以讲很多,但在这条主线里先够用就行:它们只是帮助系统判断,一个字段到底是普通展示字段,还是可以分组的维度,还是可以汇总的指标。

语义层先做一件简单的事

先不把语义层想得太复杂。在这个阶段,它要做的第一件事其实很简单:

给查询字段建一份受控目录。

这份目录至少要回答几个问题。

第一,这个字段叫什么。

这里不是只给数据库列起别名,而是要有一个稳定的查询字段名。前端、DSL、AI、审计日志都用它,不再每个地方各叫各的。

第二,这个字段展示给用户叫什么。

比如 inboundTime 展示为“入库时间”,stockQty 展示为“当前库存数量”。显示名可以改,但查询字段名要稳定。

第三,这个字段是什么类型。

字符串、数字、日期、枚举、人员、组织、布尔值,对应的输入方式和操作符都不一样。

日期字段可以做范围筛选。数字字段可以大于、小于、区间。字符串字段可以等于、包含、前缀匹配。枚举字段适合单选或多选。人员和组织字段可能还要接字典或树结构。

第四,这个字段允许做什么。

一个字段能显示,不代表能筛选。能筛选,不代表能排序。能排序,不代表适合分组。

比如“在库时长”可以显示,也可以做范围筛选,但如果它是实时计算出来的表达式,是否允许排序就要看执行成本和底层实现。

“操作人名称”可以显示和筛选,但分组时可能更应该按操作人 ID,而不是按名称。

这些能力不能只靠前端猜,也不能让每个后端接口自己写一套判断。

一个小例子

可以把字段定义想象成这样:

field: inboundTime
caption: 入库时间
type: datetime
filterable: true
sortable: true
operators: [range, isNull, isNotNull]
expr: stock.inbound_time

再看“在库时长”:

field: inventoryAge
caption: 在库时长
type: number
filterable: true
sortable: false
operators: [range, >, <]
expr: now() - stock.inbound_time

这个示例不是完整语法,只是说明语义层要保存什么信息。

它把几个原本分散的问题放在一起:

  • 前端知道用什么控件;
  • DSL 知道可以引用哪个 field;
  • 后端知道怎么转成执行表达式;
  • 审计日志知道用户到底筛了哪个业务字段;
  • 后续的导出、审计、权限逻辑也有稳定的字段入口。

这比“前端传一个字段名,后端拼一段 SQL”稳得多。

语义层不是让查询更自由

这里容易有一个误解:做语义层,是不是为了让用户或 AI 想查什么就查什么?

不是。

至少在企业系统里,语义层更重要的作用是收窄边界。

它告诉系统:

  • 哪些字段可以出现在查询里;
  • 哪些字段只能展示,不能筛选;
  • 哪些字段可以排序或分组;
  • 哪些操作符对这个字段是合法的;
  • 这个字段最终应该如何执行;
  • 后续导出、审计、权限逻辑应该引用哪个字段入口。

这样 DSL 才有校验目标。

如果没有语义层,DSL 里的 field 就只是一个字符串。系统只能在执行时再去猜它对应什么,或者在 SQL 生成失败后才发现问题。

有了语义层,很多问题可以提前处理:

  • 字段不存在,直接拒绝;
  • 字段不可筛选,直接拒绝;
  • 日期字段用了字符串包含操作,直接拒绝;
  • 计算字段成本太高,限制排序或导出。

这一步看起来不华丽,但它决定了后面查询能不能稳定扩展。

从写 SQL 到设计查询字段

到这里,查询开发的重心已经开始变化。

早期我们关心的是 SQL 怎么写得不乱,所以做了模板、helper、权限条件注入。

后来客户要求每一列都能筛选,我们开始需要 DSL,让前端和后端用同一种结构表达查询条件。

再往后,仅有 DSL 还不够。我们需要明确每个字段的业务含义、类型、能力和执行方式。

也就是说,问题从“怎么拼查询”变成了“哪些东西可以被查询,以及应该怎么被查询”。

这就是语义层最早要设计的东西。

它不是一上来就做复杂 BI,也不是把所有数据库能力都包装起来。它先把表格列、DSL 字段和执行表达式收敛到同一份字段定义里。

等字段目录稳定以后,下一个问题就会浮出来:

这份字段目录怎么落到工程里?

也就是:哪些东西放在表模型里,哪些东西放在查询模型里。底层表结构、关联关系、维度 caption、计算字段、页面可见列,不应该继续混在一份 SQL 模板或一组接口参数里。下一篇就从这里继续。

相关代码:foggy-dataset Java 引擎模块

github.com/foggy-proje…

相关文档:QM 语法手册

foggy-projects.github.io/foggy-data-…