6.3 DSL 设计:语法、语义、工具链与安全边界
团队想让运营人员配置促销规则,于是最初提供了几个 JSON 字段。半年后,JSON 里出现条件嵌套、变量引用、优先级和表达式字符串——一个没有正式语法、没有类型系统、也没有调试器的语言已经悄悄诞生。
DSL(Domain-Specific Language)的难点不是写出一段漂亮语法,而是为一个特定领域定义可维护的语言产品。
先判断是否真的需要一门语言
下面的需求不一定需要 DSL:
- 只有少量固定选项:配置对象或表单即可;
- 主要使用者是开发者:普通库和有类型 API 往往更好;
- 变化只是参数,不是控制结构:数据配置足够;
- 规则必须由代码评审和部署控制:直接使用宿主语言可能更透明。
当用户需要组合条件、表达计算、复用规则,并且这些变化必须独立于应用发布时,DSL 才可能值得其长期成本。
内部 DSL 与外部 DSL
内部 DSL
内部 DSL 使用宿主语言的语法和类型:
Policy policy = Policy.when(
all(country("CN"), orderAmountAtLeast("200.00")))
.then(applyDiscount("20.00"))
.otherwise(noDiscount());优点是复用编译器、IDE、调试器和模块系统;缺点是表达受宿主语法限制,非开发者仍要面对代码环境,并且调用方可能绕开期望的组合约束。
外部 DSL
rule summer-sale priority 20 {
when country == "CN" and order.amount >= 200.00 CNY
then discount 20.00 CNY
}外部 DSL 可以贴近领域语言并独立版本化,但你需要提供完整工具链:解析、类型检查、错误信息、格式化、编辑器支持、调试、迁移和安全限制。
从文本到执行要经过明确阶段
源文本
↓ Lexer / Parser
语法树 AST
↓ 名称解析与类型检查
已验证模型
↓ 降级 / 规划
中间表示 IR
↓ 解释、编译或翻译
执行结果把阶段分开能避免解析器直接执行业务动作。解析只回答“结构是否合法”;语义分析再回答“变量是否存在、类型是否匹配、单位能否相加”;执行器只接收已经验证的模型。
200.00 CNY + 10 %语法上可能合法,语义上却不能直接相加。好的 DSL 应在部署规则时报告类型错误,而不是交易执行到一半才失败。
语法要为错误信息服务
设计语法时不要只展示成功示例,还要列出最常犯的错误:
- 括号缺失;
- 未知字段;
- 字符串和数值比较;
- 重复规则名称;
- 无法到达的分支;
- 递归引用或循环依赖。
错误消息应包含位置、原因和可执行的修复建议:
promotion.rules:8:21
order.amount 的类型是 Money<CNY>,不能与整数 200 比较。
可改为:order.amount >= 200.00 CNY“parse error near token” 对语言作者有用,对领域用户几乎没有用。
把语义放进领域模型
不要让解释器到处判断字符串操作名:
sealed interface Condition permits All, Any, CountryIs, AmountAtLeast {}
record All(List<Condition> children) implements Condition {}
record CountryIs(String countryCode) implements Condition {}
record AmountAtLeast(Money minimum) implements Condition {}解析器先构造有类型 AST,语义验证后可降级成更简单的 IR。这样可以在不改变用户语法的情况下优化执行器,也可以让多个前端语法共享同一语义核心。
语言还应明确:
- 数字精度、舍入和货币单位;
- 时间区间和时区;
null或缺失字段的传播;- 规则冲突与优先级;
- 求值顺序和短路;
- 外部数据读取是否允许、失败如何表达。
执行不可信规则必须有预算
永远不要把用户提供的表达式直接交给宿主语言的 eval。即使禁掉几个函数名,反射、对象图和资源消耗也可能形成逃逸路径。
受控解释器至少需要:
- 允许的语法与内置函数白名单;
- 最大 AST 深度和源文件大小;
- 指令步数、递归深度或执行时间预算;
- 内存与结果大小限制;
- 无网络、文件和进程权限的默认环境;
- 可取消的执行;
- 审计日志和规则版本。
若规则来自不可信租户,进程内超时往往不足以构成强隔离。应考虑独立进程、容器或专用沙箱,并为宿主与执行器定义窄协议。
版本演进是 DSL 的主要成本
一旦规则被保存,旧语法就成为数据格式。发布新版本时要回答:
- 旧规则是否继续执行;
- 废弃语法怎样警告和自动迁移;
- 运行结果能否重放;
- 同一规则在不同解释器版本是否产生相同结果;
- 线上记录的是原始文本、AST、IR,还是三者及版本;
- 回滚应用时能否读取新规则。
建议为每份规则保存语言版本和内容哈希,并把编译后的产物视为可再生缓存,而不是唯一真相。
测试一门小语言
至少覆盖四层:
- 解析测试:合法与非法语法、错误位置;
- 语义测试:类型、名称、单位、冲突;
- 执行测试:边界值、短路、确定性;
- 兼容测试:历史规则语料在新版本下的行为。
还可以用属性测试验证:格式化后重新解析语义不变;优化前后 IR 结果一致;执行预算一定能终止恶意深层表达式。
完成检查
为促销规则设计一个最小 DSL,只支持国家、订单金额和折扣:
- 给出三条合法规则和五条非法规则;
- 定义 Money 与百分比的类型规则;
- 画出源码到 AST、IR、执行结果的转换;
- 写出冲突规则的确定性选择方式;
- 规定执行预算、版本字段和迁移策略。
如果只有一段“看起来像自然语言”的成功示例,还没有完成 DSL 设计。