自研查询 DSL:业务代码里看不到物理表名

0 阅读5分钟

这个系列停了一阵子。上一篇写完没多久赶上国庆假期,中间又有些别的事,一拖就是半个月。今天接着写,讲这套系统里我最花心思的一块,查询层。

为什么业务代码里不能出现物理表名

元数据驱动的系统有个麻烦:表是运行时创建的。用户今天建一个"客户"实体,明天建一个"订单"实体,物理表随之多一张。如果代码里写死表名,用户一改配置,代码就得跟着改,那还叫什么元数据驱动。

所以我给业务代码定了一条规矩,只写逻辑名。实体叫 Customer,字段叫 customerName,代码里就这么写。物理上表叫 T__CUSTOMER,列叫 F__CUSTOMER_NAME,这个映射对写业务代码的人是藏起来的。

问题在于 SQL 是字符串,数据库不认识 Customer,只认 T__CUSTOMER。中间必须有人做翻译,这一层就是查询 DSL。

语法是 ANTLR 写的,不是字符串替换

我一开始想的是正则替换,把 from 后面的 Customer 换成 T__CUSTOMER,把 select 后面的字段名挨个查出来换掉。做了两天发现走不通,因为要支持的越来越多:子查询、join、函数、别名,还有后面要加的花括号语法。正则撑不住。

最后上了 ANTLR4,写了一个 LinkerQuery.g4 语法文件,生成 lexer 和 parser。编译入口叫 LinkerQueryCompiler,它把 SQL 字符串喂给 parser,产出一棵语法树,再由自己写的 Visitor 遍历,把每个节点翻成物理 SQL。

这段是整个系统里最有工程感的部分。一条 select,从字符串到最终物理 SQL,中间过词法、语法、语义三层,跟一个迷你编译器差不多。写的时候挺痛苦,写完以后,加新语法的成本就低多了。

逻辑名到物理名,翻译发生在哪

有个细节我想先说清楚:T__CUSTOMER 这个名字,不是查询时临时拼的,而是创建实体那一刻就生成好,存进元数据表里。

建实体的时候,用户填了内部名,物理名就是 T__ 加大写内部名;没填,就用中文名的拼音全大写。字段同理,F__ 前缀。查询编译期要做的,只是从元数据里把表名和列名读出来拼进 SQL。

翻译核心在一个叫 compileFullColumnName 的方法。它拿字段逻辑名去实体上查字段,查不到直接抛异常,查到就把物理列拼成 _T__CUSTOMER.F__CUSTOMER_NAME 这种带表别名的形式。主表别名是下划线加表名。

& 前缀:一条 SQL 把外键的显示值带出来

这是我最喜欢的一个语法。订单上有个"客户"字段,类型是引用,存的是客户 ID。列表页要显示"张三",但库里只有个数字。

常规做法是前端拿到 ID 再发一次请求查名字。我的做法是查询里写 &customer,编译器看到 & 前缀,自动生成一个 LEFT JOIN 把客户表连进来,把客户实体的名字字段带出来,结果列加个 __LABEL 后缀。

写业务的人只要在 select 里多写一个 &,外键的显示值就跟着出来了,不用管 join,也不用二次请求。这套逻辑支持多级引用,字段点字段一路往下跟。

花括号:把 MySQL 原生表达式塞进 DSL

自定义语法覆盖不了所有场景,总会有人要写 DSL 不认识的东西。位运算判断事件时机、json_contains 查 JSON 列,这些我懒得一个个做成语法规则。

于是留了个口子:花括号。花括号里的内容原样透传给 MySQL,只有双花括号 {{字段名}} 会被翻译成物理列。

一个真实例子,判断事件执行器的时机是不是更新:

{ {{eventTiming}} & 2 > 0 }

翻出来是 _T__EVENT_EXECUTOR.EVENT_TIMING & 2 > 0,中间的 & 和数字原样透传,交给 MySQL 做位运算。json_contains 也一样,{ json_contains({{field}}, 'value') } 只把 {{field}} 换成物理列,函数本身不动。

这个设计让 DSL 的边界很清晰:它只管结构,select、from、where、join,值层面的任意表达式留给 MySQL。

权限和软删除,是拿同一套 DSL 编译的

查询层最让我得意的是权限过滤的实现。

每条查询执行前,系统会先拼一条过滤 SQL,形如 select customerId from Customer where 1=1 and deletedBy is null and deletedTime is null and (行级权限)。软删除靠 deletedBy 和 deletedTime 两列都为空来判断,行级权限由 RBACPermissionManage 按当前用户角色拼出来,本人可见就是 ownUser = 123,部门可见就是 ownDept = 2。

关键是这条过滤 SQL 本身也走同一套 DSL 编译。先拼成逻辑 SQL,再进一遍 LinkerQueryCompiler,取它的 where 子句,最后合并进原始查询的 where。权限条件和业务条件,用的是完全相同的翻译管道,没有第二套解析逻辑。

这个"用 DSL 编译 DSL 生成的 SQL"的做法,我是写完之后回头看,才意识到它挺巧。

公式里的查询,也走这条路

第一篇提过 Aviator 表达式。它的 query 函数,底层其实也调这套 DSL。你在公式里写 query("select ... from Customer where ...", 参数),参数塞进 LinkerDB,走一遍编译,再返回结果集。查询能力对公式脚本是开放的,代价是脚本里能查任意实体,权限上要另外兜。

代价

最大的代价是学习成本。新来的开发者要理解逻辑名和物理名的区别,要理解 & 前缀和花括号这两个语法。报错信息也不总是直白,语法解析失败只有一句"SQL语法错误",具体错哪要自己猜。

另一个是调试成本。你写的逻辑 SQL 和最终执行的物理 SQL 之间隔着一层翻译,排查慢查询得先拿到翻译后的 SQL,再看是哪句导致。

还有,这层"编译器"目前只有我最熟。它好用,但也是个隐性的维护负担。

不过整体上,这套 DSL 是值得的。业务代码里没有一句物理表名,加实体加字段不用改一行 Java,全靠这层翻译撑着。这也是整个元数据驱动架构里,我最愿意跟人聊的一块。

如果你也在做类似的系统,可以在评论区聊一聊。