跳到内容

第 9 卷 / 第 6 章 / 第 3 课

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

促销配置一旦需要嵌套条件、变量引用、优先级和表达式,就已经在承担一门小语言的工作。外壳还是 JSON,也不会自动免掉语法和语义的维护。

DSL(Domain-Specific Language,领域特定语言)要让用户在有限范围内准确表达意图。漂亮的成功示例只是开头,错误消息和旧规则兼容通常更考验设计。

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

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

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

当用户需要组合条件、表达计算或复用规则时,可以权衡 DSL 的长期成本;规则需要独立发布时,这种价值尤其明显。内部 DSL 也可以随应用一起编译,并不以独立发布为必要条件。

内部 DSL 与外部 DSL ​

内部 DSL ​

内部 DSL 使用宿主语言的语法和类型。下面是待设计 API 的用法示意,Policy 和各构造函数需要自行实现,并约定默认货币或要求显式传入:

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
  ↓ 名称解析与类型检查
已验证模型
  ↓ 降低抽象层次(lowering)/ 规划
中间表示 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 17 的模型声明,可放在同一个类中,并导入 java.math.BigDecimal 与 java.util.*;它还不是解析器或完整解释器:

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

为每份规则保存语言版本和内容哈希;若把编译产物视为可再生缓存,还必须能取得匹配的编译器、选项和依赖。重放时涉及的输入、外部数据版本与时钟也要固定,仅保存规则文本不足以保证同样结果。

测试一门小语言 ​

至少覆盖四层:

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

还可以用属性测试寻找反例:

  • 格式化后重新解析,语义不变;
  • 优化前后 IR 结果一致;
  • 各种深度和形状的表达式都在预算内结束或被明确拒绝。

有限测试不能证明任意输入都会终止。还要检查每条递归或循环路径是否消耗预算、每个原语是否有界,并验证失败时不会留下已执行一半的业务副作用。

完成检查 ​

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

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

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

参考资料 ​

Built with VitePress | Software Systems Atlas