写一个领域特定语言(Domain-Specific Language, DSL),本质上是做三件事:定义语法、解析成抽象语法树(AST)、把 AST 解释或编译成可执行逻辑。Clojure 因为「代码即数据」(Homoiconicity),天然适合承载 DSL——很多场景甚至不需要字符串解析,直接用宏在数据结构层面扩展语言。
但当你需要解析外部文本(配置文件、查询语言、日志格式、表达式)时,就需要真正的解析器。Instaparse 让你用 EBNF 描述语法、几行代码得到解析器;而解析器组合子(Parser Combinator)则在需要精细控制时提供另一种选择。本文从语法定义讲到 AST 求值,串起一条完整的 DSL 实现链路。
1. 三种 DSL 实现路径
| 路径 | 输入 | 工具 | 典型场景 |
|---|---|---|---|
| 宏(Macro) | Clojure 形式 | defmacro | 内部 DSL、语言扩展 |
| 数据驱动 | EDN/数据结构 | 解释器 | 规则引擎、配置 |
| 文本解析 | 字符串 | Instaparse、组合子 | 外部语法、查询语言 |
宏路线的原理见 Clojure 宏编程 ;本文聚焦第三条——把字符串解析成 AST。
2. Instaparse 基础
2.1 引入与最小示例
(ns myapp.expr
(:require [instaparse.core :as insta]))
(def arithmetic
"expr = add
add = add <'+'> mult | mult
mult = mult <'*'> num | num
num = #'[0-9]+'")
(def parse (insta/parser arithmetic))
(parse "1+2*3")
;; => [:add [:num "1"] [:mult [:num "2"] [:num "3"]]]
insta/parser 返回一个函数,输入字符串、输出解析树(解析树就是嵌套的 Clojure vector)。<'+'> 里的尖括号表示隐藏该终结符,让它不出现在结果里——这正是把「1+2」变成 [:add [:num "1"] [:num "2"]] 而非 [:add [:num "1"] [:op "+"] ...] 的关键。
2.2 EBNF 语法要素
| 语法 | 含义 | 示例 |
|---|---|---|
= | 规则定义 | expr = add |
| | 或 | a = b | c |
* | 零或多次 | list = item* |
+ | 一或多次 | digits = #'[0-9]'+ |
? | 零或一次 | sign = '-'? |
<...> | 隐藏 | <'+'> |
#'...' | 正则终结符 | #'[0-9]+' |
(...) | 分组 | (a b)+ |
! | 负向前瞻(PEG) | !(x) y |
2.3 大小写与规则命名
Instaparse 默认大小写敏感。终结符(字符串字面量、正则)保留原样,非终结符的规则名会成为解析树的标签。规则名建议用 kebab-case:
(def config-grammar
"config = section+
section = <'['> name <']'> entry*
entry = key <'='> value
key = #'[a-zA-Z][a-zA-Z0-9_.]*'
value = #'[^\\n]*'
name = #'[a-zA-Z0-9_]+'")
3. CFG 与 PEG:两种解析模式
3.1 上下文无关文法(CFG)
默认模式是 CFG,允许歧义(Ambiguity)。当输入有多个合法解析时,Instaparse 返回一个 :instaparse.gll/failure 之外的特殊结构——用 insta/parses 可以看到全部解析:
(def ambiguous
"expr = expr expr | #'[a-z]+'")
(insta/parses ambiguous "abc def ghi")
;; => 返回多个等价的解析树(结合方式不同)
CFG 模式(GLL 算法)能处理左递归,代价是速度较慢、歧义需消解。
3.2 PEG 模式
加上 :parser :peg 切换到解析表达式文法(Parsing Expression Grammar),它用有序选择消除歧义——第一个匹配成功的分支胜出:
(def parse-peg
(insta/parser
"stmt = assign | expr
assign = ident <'='> expr
expr = ident
ident = #'[a-z]+'"
:parser :peg))
(parse-peg "x = y")
;; => [:assign [:ident \"x\"] [:ident \"y\"]]
PEG 更快、无歧义,但不支持左递归,且规则顺序敏感(assign 必须排在 expr 前,否则 x = y 会被 expr 匹配掉 x 就停)。
3.3 如何选
| 需求 | 推荐 |
|---|---|
| 自然语言/易歧义语法 | CFG(GLL) |
| 编程语言、性能敏感 | PEG |
| 需要左递归的表达式 | CFG |
| 想要确定性、可预测 | PEG |
4. 把解析树变成 AST
4.1 转换(transform)
Instaparse 的 transform 用 map 描述「标签 → 处理函数」:
(require '[instaparse.core :as insta])
(def to-ast
(insta/transform
{:add (fn [a b] (list '+ a b))
:mult (fn [a b] (list '* a b))
:num (fn [s] (Long/parseLong s))}))
(to-ast (parse "1+2*3"))
;; => (+ 1 (* 2 3))
注意结果 (+ 1 (* 2 3)) 恰好是合法的 Clojure 代码——因为它就是一棵 list 组成的树。
4.2 直接求值
既然 AST 是 Clojure 数据,求值器只需几行:
(defn eval-ast [ast env]
(cond
(number? ast) ast
(symbol? ast) (get env ast)
(seq? ast)
(let [[op & args] ast
vs (map #(eval-ast % env) args)]
(case op
+ (apply + vs)
* (apply * vs)
(throw (ex-info "未知运算符" {:op op}))))
:else (throw (ex-info "非法节点" {:node ast}))))
(eval-ast (to-ast (parse "1+2*3")) {})
;; => 7
生产环境里更安全的做法是用 clojure.core/eval 配合白名单符号,或直接复用 clojure.spec / malli 校验 AST 形状后再解释——模式校验的思路见 spec 与 malli 数据校验
。
4.3 处理变量与函数调用
扩展语法支持标识符与函数调用:
(def grammar
"expr = add
add = add <'+'> mult | mult
mult = mult <'*'> atom | atom
atom = call | num | ident | <'('> expr <')'>
call = ident <'('> (expr <','>)* expr <')'>
num = #'[0-9]+'
ident = #'[a-zA-Z_][a-zA-Z0-9_]*'")
转换时把 :call 映射成 (f a b) 形式,求值时从 env 里取函数即可。
5. 错误定位与诊断
5.1 失败对象
解析失败时返回 insta/failure? 为真的对象,包含行列位置与期望集合:
(def result (parse "1+*2"))
(insta/failure? result)
;; => true
(insta/get-failure result)
;; {:index 2
;; :line 1
;; :column 3
;; :reason "Expected one of: ..."
;; :text "1+*2"}
5.2 友好报错
把失败信息包装成面向用户的提示:
(defn parse-or-throw [s]
(let [r (parse s)]
(if (insta/failure? r)
(let [{:keys [line column reason]} (insta/get-failure r)]
(throw (ex-info (format "第 %d 行第 %d 列解析失败:%s" line column reason)
{:line line :column column :input s})))
r)))
5.3 可视化
Instaparse 支持输出多种格式便于调试:
(insta/visualize (parse "1+2*3")) ;; 打开浏览器显示树
(println (insta/transform {:num identity} (parse "1+2*3")))
;; 或转成 hiccup / graphviz
6. 解析器组合子:不用语法的选择
当语法较小、或者需要与现有代码交织时,手写组合子(Combinator)更直接。核心思想是「解析器即函数」:输入字符串,返回 [结果 剩余输入] 或失败。
6.1 最小组合子
(defn run-parser [p s]
(p s))
(defn result [v] (fn [s] [v s]))
(defn bind [p f]
(fn [s]
(let [[v rest] (p s)]
((f v) rest))))
(defn satisfy [pred]
(fn [s]
(if (and (seq s) (pred (first s)))
[(first s) (subs s 1)]
(throw (ex-info "解析失败" {:at s})))))
6.2 组合出具体解析器
(def digit (satisfy #(Character/isDigit %)))
(def digits (fn [s] (let [ds (take-while #(Character/isDigit %) s)
n (count ds)]
[(apply str ds) (subs s n)])))
(defn char-parser [c]
(satisfy #(= c %)))
;; 解析 "12+34"
((bind digits (fn [a] (bind (char-parser \+) (fn [_] (bind digits (fn [b] [(Long/parseLong a) (Long/parseLong b)])))))) "12+34")
;; => [[12 34] ""]
6.3 何时用组合子
| 场景 | Instaparse | 组合子 |
|---|---|---|
| 语法大、要文档化 | 优 | 差 |
| 需要回溯/前瞻 | 内置 | 手写 |
| 与既有流式处理结合 | 弱 | 强 |
| 性能极致 | 中 | 高(可特化) |
| 依赖 | 引入库 | 零依赖 |
组合子适合「小语法 + 想完全掌控」;Instaparse 适合「语法是核心资产、需要可读的 EBNF」。
7. 完整案例:迷你查询语言
7.1 语法
(def query-grammar
"query = select from where?
select = <'select '> field (<','> field)*
from = <' from '> table
where = <' where '> cond
cond = cond <' and '> cmp | cmp
cmp = field op value
op = '=' | '>' | '<'
field = #'[a-zA-Z][a-zA-Z0-9_.]*'
table = #'[a-zA-Z][a-zA-Z0-9_]*'
value = #'\\d+' | #'\\'[^\\']*\\''")
7.2 转换到 SQL
(defn to-sql [tree]
(insta/transform
{:query (fn [sel from where]
(str "SELECT " sel " FROM " from (when where (str " WHERE " where))))
:select (fn [& fs] (clojure.string/join ", " fs))
:from identity
:where (fn [c] c)
:cond (fn [a b] (str a " AND " b))
:cmp (fn [f op v] (str f " " op " " v))
:op identity
:value identity}
tree))
(to-sql (insta/parser query-grammar))
真实项目里,SQL 一定用参数化而非字符串拼接防注入;这里只是演示「语法 → AST → 目标代码」的编译链路。
7.3 与数据操作的衔接
解析出的 AST 最终往往落到数据处理上:过滤、映射、聚合。这些操作与 Clojure 数据操作
中的 filter/map/reduce 组合天然对应,AST 解释器可以直接转成 transducer 流水线,做到边解析边执行、避免中间集合。
8. 小结
Clojure 做解析与 DSL 的优势在于「解析树就是数据」——AST 是嵌套 vector,转换是普通函数,求值是模式匹配。
- 语法可读、需要文档化:用 Instaparse,EBNF 描述、
transform转 AST; - 语法小、要极致控制:手写组合子,解析器就是
String -> [a String]; - 歧义:CFG 模式允许、需消解;PEG 模式有序选择、更确定但禁左递归;
- 诊断:永远把
get-failure的行列信息转成用户能看懂的提示。
最后一条经验:先用 EBNF 把语法写清楚,再动手写代码。语法是整个 DSL 的契约,它稳定了,解析、校验、求值都只是围绕它的机械工作。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。