引言
DSL(领域特定语言)是「为某个领域定制的迷你语言」——SQL 为数据库、正则为你写的文本模式、Gradle 为构建。好 DSL 让领域专家直接表达意图,而不是让所有人学会通用语言再翻译。本文先讲内部/外部 DSL 的分野与取舍,再给表达式求值、AST、类型安全的完整落地路径,最后用案例拆解「何时值得做 DSL」。
前置:/bnf-backus-naur-form/(形式文法)、/regex-deep-dive/(文本模式语言)。语言实现基础见 [[cs-fundamentals]]。
目录
- 1. DSL 是什么:为什么值得造语言
- 2. 内部 DSL:宿主语言里的优雅
- 3. 外部 DSL:从语法到解析
- 4. 语法设计:BNF 与文法选择
- 5. 解析技术:正则、手写与 ANTLR
- 6. 表达式求值:AST 与解释器
- 7. 类型安全 DSL:编译期约束
- 8. 真实案例拆解:SQL、正则与 Gradle
- 9. DSL 设计原则与反模式
- 10. 决策清单与速查表
- 延伸阅读
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/GLR | O(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 与宏的宿主语言
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。