DSL 设计

本页目录

DSL 是面向特定领域的语言。它的价值是让领域概念、约束和组合方式直接出现在表达中,而不只是把普通 API 改成更短的链式调用。

设计语言前,先收集典型任务和错误:使用者要表达什么,哪些组合必须拒绝,规则多久变化,谁维护它,失败如何定位。语法应服务这些需求。

先设计语义:以价格规则为例

考虑规则 order.total >= 100 USD。在选择大括号还是缩进之前,需要说明 total 是含税金额还是税前金额,缺失时是错误还是零,币种不同能否比较,数值采用整数分还是十进制定点数。

若这些没有定义,parser 即使正确接受输入,系统仍然无法给出稳定答案。DSL 的“领域专用”首先体现在类型、约束和执行规则上。

一个规则 DSL:语法之后才开始领域检查 输入规则 order.total >= 100 USD 1 解析结构并记录源码位置 比较表达式:字段、运算符、金额字面量 2 解析名字与检查领域类型 total 是否存在,金额币种是否兼容 3 生成稳定的规则表示 显式处理缺失值、舍入与错误 4 解释或编译执行 预算、诊断与结果追踪保持一致 5 语法是否合法与规则是否有领域意义,是两个独立的检查。 同一条规则,假、真和输入错误是三种结果 order.total >= 100.00 USD 编译为门槛 10000 分,币种 USD 9999 分 USD → false 正常的不满足条件 10000 分 USD → true 边界值满足 ≥ 缺失 / EUR → error 不能暗中改成零或自动换汇 领域契约必须在 parser、可视化编辑器和执行后端中保持相同,不由语法外观决定。

将这条规则落实为一个最小可执行语言

本例只支持“字段 >= 非负金额 币种”。领域模型规定 order.total 是以分计的、已确定的订单总额;只接受 USD,不做汇率转换,缺失总额报告错误。金额最多 9 位整数和 2 位小数,避免把任意长数字与不明确的舍入规则留给执行器。

from dataclasses import dataclass
import re

@dataclass(frozen=True)
class Rule:
    field: str
    cents: int
    currency: str
    span: tuple[int, int]

class RuleError(ValueError):
    pass

PATTERN = re.compile(
    r"(?P<field>[a-z][a-z0-9_.]*)\s*>=\s*"
    r"(?P<whole>[0-9]{1,9})(?:\.(?P<fraction>[0-9]{1,2}))?"
    r"\s+(?P<currency>[A-Z]{3})"
)

def compile_rule(source):
    if len(source) > 256:
        raise RuleError("rule too long")
    match = PATTERN.fullmatch(source)
    if match is None:
        raise RuleError("expected FIELD >= AMOUNT CURRENCY")
    field = match["field"]
    if field != "order.total":
        raise RuleError(f"unknown field at {match.span('field')}: {field}")
    if match["currency"] != "USD":
        raise RuleError(f"unsupported currency at {match.span('currency')}")
    fraction = (match["fraction"] or "").ljust(2, "0")
    cents = int(match["whole"]) * 100 + int(fraction)
    return Rule(field, cents, "USD", match.span())

def evaluate(rule, order):
    if "total_cents" not in order or order["total_cents"] is None:
        raise RuleError("missing order.total")
    amount = order["total_cents"]
    if type(amount) is not int or amount < 0:
        raise RuleError("total must be nonnegative integer cents")
    if order.get("currency") != rule.currency:
        raise RuleError("currency mismatch")
    return amount >= rule.cents

rule = compile_rule("order.total >= 100.00 USD")
assert rule.cents == 10000
assert not evaluate(rule, {"total_cents": 9999, "currency": "USD"})
assert evaluate(rule, {"total_cents": 10000, "currency": "USD"})
assert compile_rule("order.total >= 0.1 USD").cents == 10
for source in ["order.totla >= 100 USD", "order.total >= 1 EUR",
               "order.total >= 0.001 USD", "order.total >= 1 USD trailing"]:
    try:
        compile_rule(source)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid rule accepted")
for order in [{}, {"total_cents": None},
              {"total_cents": True, "currency": "USD"},
              {"total_cents": 10000, "currency": "EUR"}]:
    try:
        evaluate(rule, order)
    except RuleError:
        pass
    else:
        raise AssertionError("invalid input accepted")

这个受限语言没有嵌套表达式,所以完整匹配一个规则的正则足以表达当前语法;加入括号、and/or 与优先级后,应设计明确语法和 parser,不能不断拼接正则来掩盖结构。fullmatch 也确保规则后没有被忽略的尾部文本。

金额解析直接从十进制字符得到整数分,0.1 解释为 10 分,0.001 被拒绝而不偷偷舍入。执行时使用 type(amount) is int,是因为 Python 的 bool 是 int 的子类,而本领域不接受 true 当作一分钱。这里的规则语义由例子明确定义,不借用宿主语言的隐式类型转换。

诊断精度与语言能力一起扩展

当前字段或币种错误保留了匹配区间,但语法错误只有整体提示;它是最小执行器,不是完整编辑器诊断。面向交互编辑时,可以将 token 与节点都保留 span,区分“>= 后缺金额”“币种不支持”“规则未写完”,再把诊断映射回源码。

输入应在哪层处理为什么不能都返回 false
总额 99.99 USD执行得到 false有效数据,规则不匹配
order.totla名字检查拒绝拼写错误会让规则永远不生效
0.001 USD当前金额语法拒绝精度政策尚不允许这种输入
总额缺失执行输入错误缺数据与低于门槛含义不同
总额币种 EUR领域兼容性错误不存在隐式换汇规则

如果业务希望缺失值变成 unknown,并让 and/or 使用三值逻辑,就应把它作为另一项明确语言语义。不能仅在某个后端 catch 所有异常返回 false,否则两个后端会在相同规则上产生不同答案。

解析成功,为什么还不能把所有失败都当成 false?

一个可编辑的规则比四个“成功/失败”按钮更能检验语言边界。下面输入会实际经过完整匹配、字段检查、币种检查和执行输入检查,再生成受限的 CompareAmount 表示。修改拼写、写入三位小数、让总额缺失,应该落在不同错误类别;只有合法金额低于门槛时才返回 false。

正在呈现知识画面
解析成功,为什么还不能把所有失败都当成 false?

语法、字段、单位和运行时数据是不同契约。99.99 未达门槛是 false,未知字段或缺失数据是错误;金额直接转整数分,不经浮点舍入。

内部 DSL:借用宿主语法与工具

内部或嵌入式 DSL 利用函数、运算符、类型、闭包等宿主机制表示领域操作。例如查询构造器可以把字段比较组合为一个表达式树,最后由后端生成参数化 SQL。

宿主语言的类型检查是基础能力,但领域检查不是免费的。只有把字段类型、操作约束和状态转换编码进 API,编译器才能帮助拒绝相应误用;直接接收任意字符串的 where(string) 不会自动获得字段名检查或 SQL 安全性。

构造器既可以立即执行,也可以保存中间表示后解释或编译。方法链只是表面语法,不能从链式写法推断它在构建 AST。

表达式模板:延迟求值也需要正确的代价模型

矩阵库可以用表达式模板保留 B + C * D 的结构,选择循环融合或优化求值顺序。但矩阵乘法、别名和复用可能使临时结果更正确或更高效;不能宣称所有表达式都不分配临时矩阵或自动得到最优代码。Eigen 延迟求值与别名

宏还可以在宿主语言内嵌入需要单独解析的 token 语法。因此“内部 DSL 必然没有自己的 parser”也不是通则。其语义检查发生在宏阶段还是后续类型检查阶段,要看接口设计。

外部 DSL:拥有自己的输入语言

独立语法适合希望跨宿主语言使用、面向领域用户或严格限制表达能力的场景。它需要自己处理词法、语法、位置、诊断和版本,但不必生成机器码。

常见后端包括直接解释 AST、降低到规则 IR、生成 SQL 或宿主源码、生成字节码,也可以编译到机器码。选择取决于执行频率、部署、调试、可移植性与限制能力,而非简单的“想快就编译、想调试就转译”。

一段配置可以在加载时检查后反复解释,已经满足性能要求;一个计算密集 DSL 则可能值得投入 IR 优化与专用后端。两者都属于完整而合理的设计。

把前端检查分层

层次示例问题合适的诊断
词法金额字面量中出现非法字符指出具体字符与允许格式
语法比较运算缺少右侧值指出缺失项和当前位置
名字解析order.totla 不存在指出未知字段,可给候选拼写
类型与单位金额和字符串比较说明两侧类型与允许操作
领域约束当前规则不允许访问个人敏感字段说明限制及规则上下文
执行数据缺失或预算耗尽返回可关联源码的受控错误

节点应保留源位置,降低为 IR 时也应保留必要映射。这样生成代码或运行时错误可以回到使用者写的规则,而不是只显示内部临时变量。

类型、效果与资源边界

限制语言表达能力可以使验证更容易。例如禁止任意网络和文件调用,只提供明确定义的纯函数;限制循环或使用有界集合操作;为执行设置步数、时间和内存预算。

但“是 DSL”不等于已经安全隔离。嵌入宿主执行、宏展开或调用外部函数时,仍需明确其权限与副作用。若规则来源不受信任,不能仅把字符串拼接后交给宿主 eval 就称为沙箱。

对于生成 SQL 的 DSL,值应通过绑定参数传递;标识符则按语法、引用与允许集合处理。参数化值不能直接代替任意表名或列名。

中间表示让多种入口共享语义

可以让一个可视化规则编辑器、一个宿主 API 和一个外部文本 parser 都生成同一份领域 IR。类型检查、优化与执行共享该表示,避免三种入口产生不同语义。

例如 IR 节点 CompareAmount(field, amount, currency) 比未解析的字符串片段更容易检查、格式化和序列化。需要导出 IR 时还应定义版本,不要把内部内存结构直接当成长期兼容协议。

版本演进:规则也是长期数据

语言上线后,字段删除、默认值变化、舍入规则调整或关键字新增都可能改变旧规则意义。可以为规则记录语言版本,提供解析与迁移策略,并保留规范化表示和可复现的测试样例。

向后兼容不能只检查“旧文本仍能解析”。旧规则解析后若得到不同结果,同样是语义破坏。回归样例应覆盖边界值、缺失值和错误输入,并比较解释器与编译后端的结果。

迁移前比较结果与错误类别

假设旧版默认缺失金额为零,新版改为报告错误,旧文本仍能解析,却已经改变行为。迁移样例应同时保存输入数据、规则版本、预期结果或错误类别,而不只比较 AST 是否长得相同。

同一组案例可以交给解释器、生成 SQL 的后端与其他编译后端。正常结果要一致,缺失、币种不匹配、精度边界的处理也要一致。规范化格式可以改变空格或金额显示,但不能把 0.1 的精度政策或 >= 的边界悄悄改掉。

选型速查

需求可考虑的起点
开发者在同一语言内使用,普通 API 易表达函数、类型安全构造器或运算符接口
重复生成宿主代码,需要读取语法结构宏或代码生成器
跨语言分发、领域用户编辑、独立版本管理外部语法与稳定语义模型
多个入口共享规则能力统一领域 IR,再接不同前端

内部 DSL 不必最终进化为外部语言,外部语言也不是“更高级”的终点。先用最小表达能力覆盖真实任务,再依据明确需求扩展。

参考

Keywords: DSL, embedded language, fluent API, expression templates, domain semantics, interpreter, code generation, language evolution