6.3 DSL 设计:语法、语义、工具链与安全边界
促销配置一旦需要嵌套条件、变量引用、优先级和表达式,就已经在承担一门小语言的工作。外壳还是 JSON,也不会自动免掉语法和语义的维护。
DSL(Domain-Specific Language,领域特定语言)要让用户在有限范围内准确表达意图。漂亮的成功示例只是开头,错误消息和旧规则兼容通常更考验设计。
先判断是否真的需要一门语言
下面的需求不一定需要 DSL:
- 只有少量固定选项:配置对象或表单即可;
- 主要使用者是开发者:普通库和有类型 API 往往更好;
- 变化只是参数,不是控制结构:数据配置足够;
- 规则必须由代码评审和部署控制:直接使用宿主语言可能更透明。
当用户需要组合条件、表达计算或复用规则时,可以权衡 DSL 的长期成本;规则需要独立发布时,这种价值尤其明显。内部 DSL 也可以随应用一起编译,并不以独立发布为必要条件。
内部 DSL 与外部 DSL
内部 DSL
内部 DSL 使用宿主语言的语法和类型。下面是待设计 API 的用法示意,Policy 和各构造函数需要自行实现,并约定默认货币或要求显式传入:
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
↓ 名称解析与类型检查
已验证模型
↓ 降低抽象层次(lowering)/ 规划
中间表示 IR
↓ 解释、编译或翻译
执行结果把阶段分开能避免解析器直接执行业务动作。解析只回答“结构是否合法”;语义分析再回答“变量是否存在、类型是否匹配、单位能否相加”;执行器只接收通过静态检查的模型,但仍需处理实际输入缺失、外部服务失败和资源耗尽等运行时错误。
200.00 CNY + 10 %语法上可能合法,语义上却不能直接相加。好的 DSL 应在部署规则时报告类型错误,而不是交易执行到一半才失败。
语法要为错误信息服务
设计语法时不要只展示成功示例,还要列出最常犯的错误:
- 括号缺失;
- 未知字段;
- 字符串和数值比较;
- 重复规则名称;
- 无法到达的分支;
- 递归引用或循环依赖。
错误消息应包含位置、原因和可执行的修复建议:
promotion.rules:8:21
order.amount 的类型是 Money<CNY>,不能与整数 200 比较。
可改为:order.amount >= 200.00 CNY“parse error near token” 对语言作者有用,对领域用户几乎没有用。
把语义放进领域模型
用明确的节点类型表达操作。下面是 Java 17 的模型声明,可放在同一个类中,并导入 java.math.BigDecimal 与 java.util.*;它还不是解析器或完整解释器:
sealed interface Condition permits All, Any, CountryIs, AmountAtLeast {}
record All(List<Condition> children) implements Condition {
All { children = List.copyOf(children); }
}
record Any(List<Condition> children) implements Condition {
Any { children = List.copyOf(children); }
}
record CountryIs(String countryCode) implements Condition {
CountryIs {
if (countryCode == null || !countryCode.matches("[A-Z]{2}")) {
throw new IllegalArgumentException("two uppercase country letters required");
}
}
}
record Money(BigDecimal amount, Currency currency) {
Money {
Objects.requireNonNull(amount, "amount");
Objects.requireNonNull(currency, "currency");
}
}
record AmountAtLeast(Money minimum) implements Condition {
AmountAtLeast {
Objects.requireNonNull(minimum, "minimum");
if (minimum.amount().signum() < 0) {
throw new IllegalArgumentException("negative minimum");
}
}
}这些构造器先检查局部约束,并复制子节点列表,防止解析后被外部修改。国家代码只检查了两个大写字母,是否在业务允许列表内、货币是否匹配和整棵树的规模仍需单独验证。
解析器先构造结构化 AST,名称解析和类型检查后可生成带类型信息的模型,再转换为更简单的 IR。这样可以在不改变用户语法的情况下优化执行器,也可以让多个前端语法共享同一语义核心。
例如,可为当前促销模型规定:金额比较只允许相同货币,以 BigDecimal.compareTo 比较数值;All 与 Any 从左到右短路求值;空 All 为真,空 Any 为假。于是 200.0 CNY 能达到 200.00 CNY 门槛,而 200.00 USD 不能通过默默换汇来参与比较。
语言还应明确:
- 数字精度、舍入和货币单位;
- 时间区间和时区;
null或缺失字段的传播;- 规则冲突与优先级;
- 求值顺序和短路;
- 外部数据读取是否允许、失败如何表达。
执行不可信规则必须有预算
永远不要把用户提供的表达式直接交给宿主语言的 eval。即使禁掉几个函数名,反射、对象图和资源消耗也可能形成逃逸路径。Python 的 ast.literal_eval 不执行任意表达式,但仍可能因恶意输入耗尽内存或栈,也不是不可信输入的资源沙箱。
受控解释器至少需要:
- 允许的语法与内置函数白名单;
- 最大 AST 深度和源文件大小;
- 指令步数、递归深度或执行时间预算;
- 内存与结果大小限制;
- 无网络、文件和进程权限的默认环境;
- 可取消的执行;
- 审计日志和规则版本。
预算要覆盖解析、语义检查和执行,不能等深层输入已经撑爆解析器后才检查 AST 深度。执行器可在访问每个节点前扣减步数,但内置函数本身也必须有界或可取消;把无限工作藏进一个节点,会让步数限制形同虚设。
若规则来自不可信租户,进程内超时往往不足以构成强隔离。应考虑独立进程、容器或专用沙箱,并为宿主与执行器定义窄协议。
版本演进是 DSL 的主要成本
一旦规则被保存,旧语法就成为数据格式。发布新版本时要回答:
- 旧规则是否继续执行;
- 废弃语法怎样警告和自动迁移;
- 运行结果能否重放;
- 同一规则在不同解释器版本是否产生相同结果;
- 线上记录的是原始文本、AST、IR,还是三者及版本;
- 回滚应用时能否读取新规则。
为每份规则保存语言版本和内容哈希;若把编译产物视为可再生缓存,还必须能取得匹配的编译器、选项和依赖。重放时涉及的输入、外部数据版本与时钟也要固定,仅保存规则文本不足以保证同样结果。
测试一门小语言
至少覆盖四层:
- 解析测试:合法与非法语法、错误位置;
- 语义测试:类型、名称、单位、冲突;
- 执行测试:边界值、短路、确定性;
- 兼容测试:历史规则语料在新版本下的行为。
还可以用属性测试寻找反例:
- 格式化后重新解析,语义不变;
- 优化前后 IR 结果一致;
- 各种深度和形状的表达式都在预算内结束或被明确拒绝。
有限测试不能证明任意输入都会终止。还要检查每条递归或循环路径是否消耗预算、每个原语是否有界,并验证失败时不会留下已执行一半的业务副作用。
完成检查
为促销规则设计一个最小 DSL,只支持国家、订单金额和折扣:
- 给出三条合法规则和五条非法规则;
- 定义 Money 与百分比的类型规则;
- 画出源码到 AST、IR、执行结果的转换;
- 写出冲突规则的确定性选择方式;
- 规定执行预算、版本字段和迁移策略。
如果只有一段“看起来像自然语言”的成功示例,还没有完成 DSL 设计。