DSL 设计实战:内部 DSL、外部 DSL 与解析器构建

系统讲解领域特定语言(DSL)设计:内部 DSL(fluent API、运算符重载)vs 外部 DSL(语法设计、ANTLR)、表达式求值与 AST、类型安全 DSL、真实案例(SQL/正则/Gradle)与设计原则。

引言

DSL(领域特定语言)是「为某个领域定制的迷你语言」——SQL 为数据库、正则为你写的文本模式、Gradle 为构建。好 DSL 让领域专家直接表达意图,而不是让所有人学会通用语言再翻译。本文先讲内部/外部 DSL 的分野与取舍,再给表达式求值、AST、类型安全的完整落地路径,最后用案例拆解「何时值得做 DSL」。

前置:/bnf-backus-naur-form/(形式文法)、/regex-deep-dive/(文本模式语言)。语言实现基础见 [[cs-fundamentals]]。


目录


1. DSL 是什么:为什么值得造语言

DSL:为特定领域设计的迷你语言,牺牲通用性换取表达力与可读性。

何时该做 DSL(信号):

信号说明
配置/规则频繁变把变更从代码迁移到配置/语言
领域专家要参与让业务人员能读能写
重复的样板逻辑用语言抽象共性
校验复杂语法级约束比函数式校验更可读

何时别做 DSL(反信号):项目小、变更少、团队不熟悉编译器 → 先写普通函数/配置,够了就不造语言。

心智:DSL 是一种「投资」——前期建语言,后期省表达。值不值取决于领域变化频率。


2. 内部 DSL:宿主语言里的优雅

内部 DSL:用宿主语言的语法糖(链式调用、运算符、lambda)搭出「像语言」的 API。

Fluent API(Java):

Query query = Query.from(User.class)
    .where(field("age").gt(18))
    .orderBy(field("id").desc())
    .limit(20);

运算符重载(Scala/Kotlin):

// 用运算符构建表达式
val expr = a > 18 && (b < 100 || c == "x")

Lambda/块语法(Groovy/Kotlin DSL):

docker {
    image("nginx")
    port(8080 to 80)
    restart("always")
}

内部 DSL 优劣:

优势劣势
复用宿主类型系统/工具链受宿主语法约束
编译期检查可能滥用运算符
零解析成本表达受限

记忆:内部 DSL = 借宿主的语法外壳,代价是「语言味」受限于宿主。


3. 外部 DSL:从语法到解析

外部 DSL:独立语法 + 自建解析器,完全自由但成本高。

经典外部 DSL:SQL、正则、HCL(Terraform)、YAML 超集(k8s)、sed/awk。

建一个外部 DSL 的最小路径:

文本输入
  → 词法分析(Lexer:拆 token)
  → 语法分析(Parser:token → AST)
  → 语义/求值(Interpreter 或编译到目标)
  → 输出结果

示例:一个极简报表 DSL

report sales by region filter revenue > 10000 sort revenue desc
# 手写递归下降(示意)
def parse_report(tokens):
    assert tokens.pop() == "report"
    metric = tokens.pop()
    assert tokens.pop() == "by"
    dimension = tokens.pop()
    filters = []
    while tokens and tokens[0] == "filter":
        tokens.pop(0)
        field = tokens.pop(0)
        op = tokens.pop(0)
        val = tokens.pop(0)
        filters.append((field, op, val))
    return {"metric": metric, "dimension": dimension, "filters": filters}

外部 DSL 优劣:

优势劣势
语法完全自由、可读性最强解析器要自己维护
跨语言共享错误信息要精心设计
领域语义精确调试工具少

4. 语法设计:BNF 与文法选择

用 BNF/EBNF 描述语法(见 /bnf-backus-naur-form/):

report   := "report" metric "by" dimension filter* sort?
filter   := "filter" field op value
op       := ">" | "<" | ">=" | "<=" | "==" | "!="
sort     := "sort" field ("asc" | "desc")?
field    := IDENT
metric   := IDENT
dimension := IDENT
value    := NUMBER | STRING

文法类型选择:

文法解析复杂度适用
正则线性无嵌套的简单模式
LL(递归下降)O(n)大多数手工 DSL
LR/GLRO(n)复杂表达式、歧义
PEG回溯但直观优先级明确的语法

优先级设计(表达式 DSL 的经典问题):

1 + 2 * 3  →  7(乘法优先)还是 9(从左到右)?
解法:文法分层
expr   := term (("+"|"-") term)*
term   := factor (("*"|"/") factor)*
factor := NUMBER | "(" expr ")"

记忆:先写 BNF 再写解析器;优先级用文法分层,别在代码里硬编码。


5. 解析技术:正则、手写与 ANTLR

技术适合优缺点
正则极简 token 提取快,但无法嵌套
手写递归下降中小型 DSL可控、可读,代码多
ANTLR4大型/复杂文法生成器、可视化、跨语言
解析器组合子函数式语言代码即文法,优雅

ANTLR 示例(极简):

grammar Mini;

report : 'report' metric 'by' dimension filter* sort? ;
metric : ID ;
...
antlr4 -Dlanguage=Python Mini.g4   # 生成解析器
python -c "
from MiniLexer import MiniLexer
from MiniParser import MiniParser
from antlr4 import InputStream, CommonTokenStream
"

手写递归下降模板:

class Parser:
    def __init__(self, tokens):
        self.tokens = tokens
        self.pos = 0
    def peek(self): return self.tokens[self.pos] if self.pos < len(self.tokens) else None
    def match(self, kind):
        t = self.peek()
        assert t and t.kind == kind, f"期望 {kind},实际 {t}"
        self.pos += 1
        return t
    def parse_expr(self):
        left = self.parse_term()
        while self.peek() and self.peek().kind in ("+", "-"):
            op = self.match(self.peek().kind)
            right = self.parse_term()
            left = ("binop", op.value, left, right)
        return left

6. 表达式求值:AST 与解释器

解析得到 AST(抽象语法树),然后解释执行:

输入:  1 + 2 * 3
AST:   BinOp(+, Num(1), BinOp(*, Num(2), Num(3)))
def eval_node(node, env):
    kind = node[0]
    if kind == "num":
        return node[1]
    if kind == "var":
        return env.get(node[1], 0)
    if kind == "binop":
        _, op, left, right = node
        l, r = eval_node(left, env), eval_node(right, env)
        return {"+": l + r, "-": l - r, "*": l * r, "/": l / r}[op]
    raise ValueError(f"未知节点 {kind}")

AST 的价值:

  • 单一结构——求值、优化、校验共用一棵树
  • 可优化——常量折叠、公共子表达式消除
  • 可渲染——把 AST 重新打印成源文本(格式化)

解释器 vs 编译:

路线做法场景
解释AST 直接求值规则引擎、配置
编译到目标AST → 目标代码/字节码SQL→执行计划、DSL→JS
校验 + 执行只做校验,业务仍用宿主最轻量

7. 类型安全 DSL:编译期约束

好 DSL 的进阶:让错误在「编译期」(写的时候)就暴露。

Scala 类型安全 DSL(依赖类型)——维度/单位检查:

// 定义「米」与「秒」两个维度类型
case class Quantity[U](value: Double)

sealed trait Meter; sealed trait Second

val dist = Quantity[Meter](10.0)
val time = Quantity[Second](2.0)

// 只允许相除得到速度
def speed(d: Quantity[Meter], t: Quantity[Second]) =
  Quantity[MeterOverSecond](d.value / t.value)
// speed(dist, time) OK
// 如果传入两个距离(类型不匹配)→ 编译错误

编译期校验 DSL(字符串规则在编译期解析):

// 用宏在编译期校验规则语法(见 [[scala]] 元编程)
inline def rule(inline expr: String): Rule = ${ ... 编译期解析 ... }
手段效果
类型类/泛型维度、单位、状态机约束
枚举/联合类型合法值受限
宏(编译期解析)外部 DSL 校验前置
智能构造器非法态不可表达

记忆:类型安全 DSL = 「写错的东西编译不过」——类型系统是免费的校验器。


8. 真实案例拆解:SQL、正则与 Gradle

SQL(外部 DSL,成熟极致):

SELECT name, SUM(amount)
FROM orders
WHERE created_at >= '2026-01-01'
GROUP BY name
HAVING SUM(amount) > 1000
ORDER BY name
  • 词法/语法:标准定义
  • 执行:AST → 逻辑计划 → 物理计划 → 执行器
  • 启示:语法稳定 + 执行优化分离

正则(模式语言 DSL):

^\d{4}-\d{2}-\d{2}$
  • 文法:字符类 + 量词 + 断言
  • 执行:编译到 NFA/DFA 自动机
  • 启示:小而精的语言可以极致优化(见 /regex-deep-dive/)

Gradle(内部 DSL,Groovy/Kotlin):

plugins { java }
dependencies { implementation("org.slf4j:slf4j-api:2.0.9") }
tasks.test { useJUnitPlatform() }
  • 启示:内部 DSL + 构建模型,语法自由但享受宿主工具

案例对比表:

DSL类型关键技术你的启发
SQL外部文法 → 执行计划解析与执行分离
正则外部编译到自动机小语言大优化
Gradle内部fluent + 模型借宿主语法
规则引擎内部/外部AST + 求值配置即代码

9. DSL 设计原则与反模式

设计原则:

原则说明
最小表达力只加领域需要的语法,别泛化
语法即文档读起来像人话,而非编程
快速失败语法错误信息精确到位置
单一目标一个 DSL 解决一类问题
可迁移好 DSL 有明确宿主/解释器边界

反模式(大忌):

反模式危害
图灵完备 DSL复杂度爆炸、难维护
语法糖泛滥新人学不会
解析器与业务耦合改一行业务动全解析
错误信息「syntax error」用户无法自救
反复造轮子能用的成熟 DSL 别重写

错误信息设计(决定成败):

❌  Syntax error at line 3
✅  第 3 行第 5 列:期望数字,实际是关键词 "filter"。
    完整语句:report sales by region filter revenue > 10000
    提示:filter 后需要「字段 操作符 数值」,如 filter age > 18

10. 决策清单与速查表

做不做 DSL 的检查清单:

□ 领域规则多久变一次?(高频才值)
□ 谁要读写?(专家/非开发者才值)
□ 是否只是几个函数能解决?(是就别做)
□ 团队能维护解析器吗?(不能选内部 DSL)
□ 有现成 DSL 可用吗?(有就别造)

速查表:

需求路线
简单链式配置内部 DSL(fluent)
跨语言共享语法外部 DSL + ANTLR
规则引擎外部 DSL + AST 解释
编译期校验类型系统/宏
表达式 DSL文法分层 + 递归下降
格式化/还原AST 双向
性能敏感编译到目标(NFA/执行计划)

一句话记忆:规则高频变才值得造语言;内部 DSL 借宿主、外部 DSL 建解析;先写 BNF 再写 Parser,AST 管求值,类型系统管校验,错误信息决定生死。


延伸阅读

  • /bnf-backus-naur-form/ — 形式文法与 EBNF 描述语言
  • /regex-deep-dive/ — 最小 DSL 的极致优化案例
  • /serialization-formats-compare/ — 配置类 DSL 的格式载体
  • [[cs-fundamentals]] — 编译器前端:词法/语法分析
  • [[scala]] — 内部 DSL 与宏的宿主语言

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「others」更多文章

  1. 通配符与 Glob 匹配:与正则的分野与落地
  2. 算法复杂度速查:Big-O、空间复杂度与工程直觉
  3. 正则表达式深层解析:引擎、回溯与灾难性回溯