跳到内容

6.3 DSL 设计:语法、语义、工具链与安全边界

团队想让运营人员配置促销规则,于是最初提供了几个 JSON 字段。半年后,JSON 里出现条件嵌套、变量引用、优先级和表达式字符串——一个没有正式语法、没有类型系统、也没有调试器的语言已经悄悄诞生。

DSL(Domain-Specific Language)的难点不是写出一段漂亮语法,而是为一个特定领域定义可维护的语言产品。

先判断是否真的需要一门语言

下面的需求不一定需要 DSL:

  • 只有少量固定选项:配置对象或表单即可;
  • 主要使用者是开发者:普通库和有类型 API 往往更好;
  • 变化只是参数,不是控制结构:数据配置足够;
  • 规则必须由代码评审和部署控制:直接使用宿主语言可能更透明。

当用户需要组合条件、表达计算、复用规则,并且这些变化必须独立于应用发布时,DSL 才可能值得其长期成本。

内部 DSL 与外部 DSL

内部 DSL

内部 DSL 使用宿主语言的语法和类型:

java
Policy policy = Policy.when(
        all(country("CN"), orderAmountAtLeast("200.00")))
    .then(applyDiscount("20.00"))
    .otherwise(noDiscount());

优点是复用编译器、IDE、调试器和模块系统;缺点是表达受宿主语法限制,非开发者仍要面对代码环境,并且调用方可能绕开期望的组合约束。

外部 DSL

text
rule summer-sale priority 20 {
  when country == "CN" and order.amount >= 200.00 CNY
  then discount 20.00 CNY
}

外部 DSL 可以贴近领域语言并独立版本化,但你需要提供完整工具链:解析、类型检查、错误信息、格式化、编辑器支持、调试、迁移和安全限制。

从文本到执行要经过明确阶段

text
源文本
  ↓ Lexer / Parser
语法树 AST
  ↓ 名称解析与类型检查
已验证模型
  ↓ 降级 / 规划
中间表示 IR
  ↓ 解释、编译或翻译
执行结果

把阶段分开能避免解析器直接执行业务动作。解析只回答“结构是否合法”;语义分析再回答“变量是否存在、类型是否匹配、单位能否相加”;执行器只接收已经验证的模型。

text
200.00 CNY + 10 %

语法上可能合法,语义上却不能直接相加。好的 DSL 应在部署规则时报告类型错误,而不是交易执行到一半才失败。

语法要为错误信息服务

设计语法时不要只展示成功示例,还要列出最常犯的错误:

  • 括号缺失;
  • 未知字段;
  • 字符串和数值比较;
  • 重复规则名称;
  • 无法到达的分支;
  • 递归引用或循环依赖。

错误消息应包含位置、原因和可执行的修复建议:

text
promotion.rules:8:21
order.amount 的类型是 Money<CNY>,不能与整数 200 比较。
可改为:order.amount >= 200.00 CNY

“parse error near token” 对语言作者有用,对领域用户几乎没有用。

把语义放进领域模型

不要让解释器到处判断字符串操作名:

java
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,还是三者及版本;
  • 回滚应用时能否读取新规则。

建议为每份规则保存语言版本和内容哈希,并把编译后的产物视为可再生缓存,而不是唯一真相。

测试一门小语言

至少覆盖四层:

  1. 解析测试:合法与非法语法、错误位置;
  2. 语义测试:类型、名称、单位、冲突;
  3. 执行测试:边界值、短路、确定性;
  4. 兼容测试:历史规则语料在新版本下的行为。

还可以用属性测试验证:格式化后重新解析语义不变;优化前后 IR 结果一致;执行预算一定能终止恶意深层表达式。

完成检查

为促销规则设计一个最小 DSL,只支持国家、订单金额和折扣:

  1. 给出三条合法规则和五条非法规则;
  2. 定义 Money 与百分比的类型规则;
  3. 画出源码到 AST、IR、执行结果的转换;
  4. 写出冲突规则的确定性选择方式;
  5. 规定执行预算、版本字段和迁移策略。

如果只有一段“看起来像自然语言”的成功示例,还没有完成 DSL 设计。

参考资料

Built with VitePress | Software Systems Atlas