Rust 宏如何创造 DSL:从 TokenStream 到自定义语法
Rust Procedural Macro 为什么能接受不像 Rust 的语法?从一个 routes! 示例出发,理解 TokenStream、Parser、AST 与代码生成之间的关系。
Rust 的 Procedural Macro 通常被用来做编译期代码生成,但它的能力不只是“少写一些重复代码”。
它的核心接口其实非常简单:
接收一段
TokenStream,在编译期处理它,再返回一段新的TokenStream。
关键在于,中间的“处理”完全可以包含自己的 Grammar、Parser、AST 和语义检查。
因此,Procedural Macro 不仅能生成 Rust 代码,还能让我们在 Rust 中定义一套小型的领域语法,也就是 DSL(Domain-Specific Language,领域特定语言)。
例如:
routes! {
GET /users => list_users;
POST /users => create_user;
GET /users/:id => get_user;
}其中:
GET /users/:id => get_user;并不是合法的普通 Rust 表达式。
那么问题就来了:
Rust 为什么允许这种“不是普通 Rust”的语法出现在代码里?routes! 又是如何理解它,并最终生成合法 Rust 代码的?
1从一个 routes! DSL 开始
假设我们希望用下面的方式声明 HTTP 路由:
routes! {
GET /users => list_users;
POST /users => create_user;
GET /users/:id => get_user;
}如果把其中一行单独拿出来:
GET /users/:id => get_user;Rust 并没有这样的表达式语法:
GET不是这里的 Rust 关键字;/users/:id不是合法路径表达式;=>也不能以这种方式出现在普通表达式中。
但我们的目标并不是让 Rust 本身理解这段语法,而是让 routes! 把它翻译成 Rust。
例如宏最终可以展开成:
&[
("GET", "/users", list_users as fn()),
("POST", "/users", create_user as fn()),
("GET", "/users/:id", get_user as fn()),
]于是职责被分成两部分:
routes! 负责理解路由语言
Rust 编译器负责理解生成后的 Rust这里已经出现了 DSL 的核心设计思想:
领域语言只负责表达领域问题,再由某个转换层将它翻译成宿主语言。
2Proc Macro 到底拿到了什么?
理解 Rust Macro DSL,最重要的是先区分三个东西:
Source Code ≠ TokenStream ≠ ASTProcedural Macro 的接口并不是:
fn macro(input: String) -> String而是:
fn macro(input: TokenStream) -> TokenStreamRust Reference 将 TokenStream 描述为一系列 TokenTree,它也是 procedural macro 与编译器之间的输入、输出接口。1
一个 function-like procedural macro 最基本的形式就是:
use proc_macro::TokenStream;
#[proc_macro]
pub fn routes(input: TokenStream) -> TokenStream {
// ...
}当用户写:
routes! {
GET /users/:id => get_user;
}宏拿到的已经不是原始字符,而是一系列类似这样的 token:
Ident(GET)
Punct(/)
Ident(users)
Punct(/)
Punct(:)
Ident(id)
Punct(=)
Punct(>)
Ident(get_user)
Punct(;)proc_macro 中的 TokenTree 主要由四种节点组成:
Group
Ident
Punct
Literal例如:
foo + 42大致可以表示成:
Ident("foo")
Punct('+')
Literal(42)因此 TokenStream 处在一个很有意思的位置:
Rust 已经帮 Macro 完成了词法分析的一部分工作,但还没有强制这些 token 必须组成某种普通 Rust expression。
这正是 DSL 可以进入的地方。
TokenStream 既不是字符串,也不是已经解析好的 Rust AST。
它仍然保留着 Ident、Punct、Literal、Group 等结构,以及每个 token 对应的 Span 信息,因此比重新解析字符串更适合宏系统。2
2.1为什么“非普通 Rust 语法”可以存在?
Rust 对 macro invocation 的语法本身只要求:
some_macro!( TokenTree... )也就是说,在:
routes! {
...
}这一层边界内,Rust 首先需要确认的是:
里面能否形成合法的 Token Tree?
而不是:
里面是不是一个合法 Rust expression?
于是:
GET /users/:id => get_user;虽然不是普通 Rust expression,却仍然可以成为一组合法 token,再交给 routes! 自己解释。
这就是 Proc Macro DSL 最关键的能力。
3给 DSL 定义语法
既然 Rust 不负责理解:
GET /users/:id => get_user;那么就必须由我们自己规定它意味着什么。
可以先写一份非常简单的 Grammar:
Routes := Route*
Route := Method Path "=>" Handler ";"
Method := Identifier
Path := "/" Segment ("/" Segment)*
Segment := Identifier | ":" Identifier
Handler := RustPath于是:
GET /users/:id => get_user;就不再是一串零散字符,而具有明确结构:
Route
├── Method
│ └── GET
├── Path
│ ├── Static("users")
│ └── Parameter("id")
└── Handler
└── get_user3.1为什么还需要 AST?
最简单的实现当然可以直接保存:
struct Route {
method: String,
path: String,
handler: Path,
}但 Parser 更重要的职责不是“把文本重新存起来”,而是:
把语法转换成具有领域意义的数据结构。
因此更合理的 AST 可以是:
enum Method {
Get,
Post,
}
enum PathSegment {
Static(String),
Parameter(String),
}
struct Route {
method: Method,
path: Vec<PathSegment>,
handler: syn::Path,
}
struct Routes {
items: Vec<Route>,
}现在:
GET /users/:id => get_user;已经不再只是文本,而变成:
Route
├── method: Get
├── path
│ ├── Static("users")
│ └── Parameter("id")
└── handler: get_user这正是 AST 的意义:
丢掉不重要的表面形式,保留程序真正表达的结构。
4TokenStream 是怎样被解析的?
Rust 生态中通常会使用 syn 来处理 procedural macro 的解析。
syn 的 Parse trait 非常简单:3
pub trait Parse: Sized {
fn parse(input: ParseStream<'_>) -> Result<Self>;
}可以把 ParseStream 想象成:
一个指向当前 token 的、不断向前移动的解析光标。
例如开始解析:
▼
GET / users / : id => get_user ;先读取 Method:
let method_ident: Ident = input.parse()?;光标就向前移动:
▼
GET / users / : id => get_user ;然后读取 /:
input.parse::<Token![/]>()?;再读取:
users发现下一段是:
:就知道这里不是普通静态路径,而是一个参数:
Parameter("id")Parser 的工作,本质上就是:
查看下一个 Token
↓
判断它是否符合 Grammar
↓
消费 Token
↓
构造 AST
↓
继续向前4.1实现 Route Parser
对应代码可以写成:
use syn::{
parse::{Parse, ParseStream},
Ident, Path, Result, Token,
};
impl Parse for Route {
fn parse(input: ParseStream) -> Result<Self> {
let method_ident: Ident = input.parse()?;
let method = match method_ident.to_string().as_str() {
"GET" => Method::Get,
"POST" => Method::Post,
_ => {
return Err(syn::Error::new(
method_ident.span(),
"expected GET or POST",
));
}
};
// 路径必须以 / 开头
input.parse::<Token![/]>()?;
let mut path = Vec::new();
while !input.peek(Token![=>]) {
if !path.is_empty() {
input.parse::<Token![/]>()?;
}
let is_parameter = if input.peek(Token![:]) {
input.parse::<Token![:]>()?;
true
} else {
false
};
let segment: Ident = input.parse()?;
let name = segment.to_string();
path.push(if is_parameter {
PathSegment::Parameter(name)
} else {
PathSegment::Static(name)
});
}
input.parse::<Token![=>]>()?;
let handler: Path = input.parse()?;
input.parse::<Token![;]>()?;
Ok(Route {
method,
path,
handler,
})
}
}其中最值得注意的并不是具体 API,而是:
input.peek(...)和:
input.parse()?分别承担两种最基础的 Parser 操作:
peek → 看看下一个 Token 是什么
parse → 按某种规则消费 Token只要不断组合这两个操作,就可以逐渐描述出越来越复杂的 Grammar。
4.2从一条 Route 到多条 Route
Routes 本身更简单:
impl Parse for Routes {
fn parse(input: ParseStream) -> Result<Self> {
let mut items = Vec::new();
while !input.is_empty() {
items.push(input.parse::<Route>()?);
}
Ok(Routes { items })
}
}于是整个输入:
routes! {
GET /users => list_users;
POST /users => create_user;
GET /users/:id => get_user;
}就会被转换成:
Routes
├── Route
├── Route
└── Route至此,我们已经完成了一次真正意义上的:
TokenStream → Parser → AST5从 AST 生成 Rust
解析只是前半程。
Rust 编译器最终并不认识我们的:
Method::Get
PathSegment::Parameter("id")所以还必须把 DSL AST 翻译回真正的 Rust。
这里通常使用 quote!。quote! 支持使用 #value 插入 token,并返回 proc_macro2::TokenStream。4
例如:
quote! {
(#method, #path, #handler as fn())
}它看起来很像字符串模板,但两者有一个关键区别:
quote!生成的是 TokenStream,而不是 String。
5.1完整的代码生成
use proc_macro::TokenStream;
use proc_macro2::Span;
use quote::quote;
use syn::{
parse::{Parse, ParseStream},
parse_macro_input,
Ident, LitStr, Path, Result, Token,
};
enum Method {
Get,
Post,
}
enum PathSegment {
Static(String),
Parameter(String),
}
struct Route {
method: Method,
path: Vec<PathSegment>,
handler: Path,
}
struct Routes {
items: Vec<Route>,
}
impl Parse for Route {
fn parse(input: ParseStream) -> Result<Self> {
let method_ident: Ident = input.parse()?;
let method = match method_ident.to_string().as_str() {
"GET" => Method::Get,
"POST" => Method::Post,
_ => {
return Err(syn::Error::new(
method_ident.span(),
"expected GET or POST",
));
}
};
input.parse::<Token![/]>()?;
let mut path = Vec::new();
while !input.peek(Token![=>]) {
if !path.is_empty() {
input.parse::<Token![/]>()?;
}
let is_parameter = if input.peek(Token![:]) {
input.parse::<Token![:]>()?;
true
} else {
false
};
let segment: Ident = input.parse()?;
let name = segment.to_string();
path.push(if is_parameter {
PathSegment::Parameter(name)
} else {
PathSegment::Static(name)
});
}
input.parse::<Token![=>]>()?;
let handler: Path = input.parse()?;
input.parse::<Token![;]>()?;
Ok(Route {
method,
path,
handler,
})
}
}
impl Parse for Routes {
fn parse(input: ParseStream) -> Result<Self> {
let mut items = Vec::new();
while !input.is_empty() {
items.push(input.parse::<Route>()?);
}
Ok(Routes { items })
}
}
#[proc_macro]
pub fn routes(input: TokenStream) -> TokenStream {
let routes = parse_macro_input!(input as Routes);
let generated = routes.items.iter().map(|route| {
let method = match route.method {
Method::Get => "GET",
Method::Post => "POST",
};
let method = LitStr::new(method, Span::call_site());
let mut path = String::from("/");
for (index, segment) in route.path.iter().enumerate() {
if index > 0 {
path.push('/');
}
match segment {
PathSegment::Static(name) => {
path.push_str(name);
}
PathSegment::Parameter(name) => {
path.push(':');
path.push_str(name);
}
}
}
let path = LitStr::new(&path, Span::call_site());
let handler = &route.handler;
quote! {
(#method, #path, #handler as fn())
}
});
quote! {
&[
#(#generated),*
]
}
.into()
}这里:
#(#generated),*表示把一系列生成结果重复展开,并使用逗号分隔。
于是:
routes! {
GET /users => list_users;
POST /users => create_user;
GET /users/:id => get_user;
}最终变成近似:
&[
("GET", "/users", list_users as fn()),
("POST", "/users", create_user as fn()),
("GET", "/users/:id", get_user as fn()),
]真正的 Web Framework 当然不会只生成 tuple;它可能生成 Router 注册、类型信息甚至辅助代码。
但原理没有变化:
DSL AST
↓
Code Generation
↓
Rust TokenStream6一次完整的 Proc Macro 编译流程
现在可以把整个过程连起来:
因此,说 Procedural Macro 是“代码生成”当然没有错。
但更完整的理解应该是:
Procedural Macro 允许我们在 Rust 的编译流程中插入一个
TokenStream → TokenStream的编译阶段。
这个阶段内部完全可以拥有:
Parser
↓
AST
↓
Semantic Analysis
↓
Code Generation也就是说,它已经具备了一个小型编译器的基本结构。
| 编译器概念 | routes! 中的对应物 |
|---|---|
| Token | Rust TokenTree |
| Grammar | Route Grammar |
| Parser | syn::Parse |
| AST | Route / Routes |
| Semantic Analysis | Method、路径等规则检查 |
| Code Generation | quote! |
| 输出语言 | Rust |
这也是 Rust Proc Macro DSL 最值得理解的地方。
7Proc Macro DSL 能做的不只是语法糖
如果 DSL 的作用仅仅是:
少写几个括号那么它通常并不值得付出一套 Parser 的复杂度。
真正有价值的是:
当代码已经被转换成自己的 AST 后,我们可以在编译期理解它所表达的领域含义。
7.1DSL 自己的语法错误
例如:
routes! {
GET /users -> list_users;
}Grammar 要求这里必须是:
=>Parser 因此可以直接拒绝输入。
7.2DSL 自己的语义错误
例如我们规定只支持:
GET
POST那么:
routes! {
DELETE /users => delete_users;
}就可以产生:
expected GET or POST这已经不是 Rust 自己的语法规则,而是:
由这个 DSL 定义的规则。
7.3Rust 类型系统仍然负责最后检查
Macro 并不会取代 Rust 编译器。
假如 DSL 最终生成:
("GET", "/users", some_handler as fn())而:
some_handler根本不是一个符合要求的函数,生成后的 Rust 仍然会被 Rust 类型系统拒绝。
因此实际存在三层检查:
DSL Syntax
↓
DSL Semantic Rules
↓
Rust Type System这也是 Embedded DSL 很有价值的一点:
领域规则和宿主语言的类型系统可以同时工作。
7.4Span:错误为什么还能指回源码?
Token 并不仅仅记录:
GET
users
id它们还关联 Span。
Rust Reference 将 Span 描述为一段源代码区域,主要用于错误报告;syn 也会保留 token 的位置信息,因此 Parser 可以把错误指向用户真正写错的位置。56
例如:
routes! {
DELETE /users => delete_users;
^^^^^^
}理想情况下,错误应该指向 DELETE,而不是只告诉用户:
macro expansion failed这也是为什么直接处理 token 往往比:
input.to_string()再自己解析字符串更合理。
8Proc Macro DSL 的边界
8.1它绕开的不是整个 Rust 语法系统
一句常见但不够准确的说法是:
“Macro 里面可以写任意语言。”
更准确的说法是:
Function-like Proc Macro 可以自己解释输入 TokenStream,而不需要让这些 token 先组成普通 Rust expression。
但源码仍然首先经过 Rust 的词法与 Token Tree 规则。
例如:
()
{}
[]这样的 delimiter 仍然必须形成合法结构。
Procedural macro 中的标点也依然来自 Rust 所能表示的 punctuation token。Rust Reference 还特别规定,多字符运算符传入 procedural macro 时会拆成单字符 punctuation,例如 => 在底层可能表现为 = 与 >;syn 的 Token![=>] 帮我们屏蔽了这层细节。7
Proc Macro 提供的是一个强大的 Token 级语法扩展点,而不是一个可以直接接管任意原始字符流的 Lexer 插件。
如果你的语言需要完全独立于 Rust 词法规则的语法,通常更适合把它放进字符串、独立文件,或者使用真正独立的 Parser。
8.2Proc Macro 还需要专门的 crate
Procedural macro 必须定义在 proc-macro 类型的 library crate 中。Cargo 对应配置为:8
[lib]
proc-macro = true
[dependencies]
syn = "3"
quote = "1"
proc-macro2 = "1"通常会形成:
my_app/
├── app/
└── route_macros/宏定义在 route_macros 中,再由应用 crate 使用。
这也是一个现实代价:Proc Macro 比普通 Builder API 的工程结构更复杂。
8.3什么时候不应该创造 DSL?
例如:
Router::new()
.get("/users", list_users)
.post("/users", create_user);如果已经足够清楚,就没有必要强行设计:
routes! {
GET /users => list_users;
POST /users => create_user;
}因为后者意味着你还要维护:
Grammar
Parser
AST
Diagnostics
Code Generation
IDE / Tooling Experience设计 DSL 的理由不应该只是“这种语法看起来很酷”。
真正值得创建 DSL 的情况,是宿主语言的普通 API 已经无法自然表达某个领域,而新的语法能够显著降低这种表达阻抗。
简单的 token 模式匹配也未必需要 Procedural Macro。Rust 的 macro_rules! 本身就是声明式语法扩展机制;只有当 Grammar、AST、检查逻辑逐渐复杂时,真正编写 Parser 才更有价值。9
9小结:Rust 为什么特别适合嵌入 DSL?
再回到最开始的代码:
routes! {
GET /users/:id => get_user;
}现在它已经不再神秘。
Rust 首先把内容转换成:
TokenStream然后:
routes!获得这些 token,并按照自己的 Grammar 解析:
TokenStream
↓
Parser
↓
Route AST接着执行领域检查,再通过:
quote!生成真正的 Rust:
Route AST
↓
Code Generation
↓
Rust TokenStream最后 Rust 编译器继续完成:
Parsing
Type Checking
Code Generation完整链条就是:
Rust Source
↓
Rust Lexer
↓
TokenStream
↓
DSL Parser
↓
DSL AST
↓
Semantic Analysis
↓
quote!
↓
Rust TokenStream
↓
Rust Compiler因此,Rust Procedural Macro 最有意思的地方并不只是“可以生成很多代码”。
它实际上开放了一个非常特殊的编译期接口:
库作者可以在 Rust 的 TokenStream 与 Rust 编译器之间,插入自己的 Parser、AST、领域规则和代码生成阶段。
而当这套 Parser 开始拥有自己的 Grammar 和语义时,我们实际上已经在 Rust 中创造出了一门小语言。
注释
-
Rust Reference,Procedural macros。官方说明 procedural macro 操作的是 token stream 而不是编译器 AST,并将其视为较稳定的宏接口。 ↩
-
Rust Standard Library,
proc_macro::TokenStream。TokenStream是 procedural macro 的主要输入输出类型,本质上是一系列TokenTree。 ↩ -
Syn Documentation,
syn::parse::Parse。Parse的核心接口为fn parse(input: ParseStream) -> Result<Self>,可用于组合 Rust 语法节点或定义自定义语法。 ↩ -
Quote Documentation,
quote!。quote!支持#var插值和#(...)*重复,并生成proc_macro2::TokenStream。 ↩ -
Rust Reference,Procedural macro
Span。每个 token 都关联Span,主要用于表示源代码位置和错误报告。 ↩ -
Syn Documentation,Syn。Syn 的解析模型围绕
ParseStream -> Result<T>展开,并保留 token 的位置信息以支持更准确的诊断。 ↩ -
Rust Reference,Declarative macro tokens and procedural macro tokens。官方规定多字符运算符进入 procedural macro 时会拆为单字符 punctuation,并在输出时按规则重新组合。 ↩
-
Cargo Reference,Cargo Targets —
proc-macro。[lib] proc-macro = true用于声明 procedural macro library target。 ↩ -
Rust Reference,Macros by Example。
macro_rules!使用 matcher 与 transcriber 声明式定义语法扩展,并支持expr、ident、path、tt等 fragment specifier。 ↩
CONTINUE EXPLORING