Elytra
Blog编程语言

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 ≠ AST

Procedural Macro 的接口并不是:

fn macro(input: String) -> String

而是:

fn macro(input: TokenStream) -> TokenStream

Rust 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 处在一个很有意思的位置:

Loading diagram…

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_user

3.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 → AST

5从 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 TokenStream

6一次完整的 Proc Macro 编译流程

现在可以把整个过程连起来:

Loading diagram…

因此,说 Procedural Macro 是“代码生成”当然没有错。

但更完整的理解应该是:

Procedural Macro 允许我们在 Rust 的编译流程中插入一个 TokenStream → TokenStream 的编译阶段。

这个阶段内部完全可以拥有:

Parser
   ↓
AST
   ↓
Semantic Analysis
   ↓
Code Generation

也就是说,它已经具备了一个小型编译器的基本结构。

编译器概念routes! 中的对应物
TokenRust TokenTree
GrammarRoute Grammar
Parsersyn::Parse
ASTRoute / Routes
Semantic AnalysisMethod、路径等规则检查
Code Generationquote!
输出语言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 中创造出了一门小语言。


注释

  1. Rust Reference,Procedural macros。官方说明 procedural macro 操作的是 token stream 而不是编译器 AST,并将其视为较稳定的宏接口。 ↩

  2. Rust Standard Library,proc_macro::TokenStream。TokenStream 是 procedural macro 的主要输入输出类型,本质上是一系列 TokenTree。 ↩

  3. Syn Documentation,syn::parse::Parse。Parse 的核心接口为 fn parse(input: ParseStream) -> Result<Self>,可用于组合 Rust 语法节点或定义自定义语法。 ↩

  4. Quote Documentation,quote!。quote! 支持 #var 插值和 #(...)* 重复,并生成 proc_macro2::TokenStream。 ↩

  5. Rust Reference,Procedural macro Span。每个 token 都关联 Span,主要用于表示源代码位置和错误报告。 ↩

  6. Syn Documentation,Syn。Syn 的解析模型围绕 ParseStream -> Result<T> 展开,并保留 token 的位置信息以支持更准确的诊断。 ↩

  7. Rust Reference,Declarative macro tokens and procedural macro tokens。官方规定多字符运算符进入 procedural macro 时会拆为单字符 punctuation,并在输出时按规则重新组合。 ↩

  8. Cargo Reference,Cargo Targets — proc-macro。[lib] proc-macro = true 用于声明 procedural macro library target。 ↩

  9. Rust Reference,Macros by Example。macro_rules! 使用 matcher 与 transcriber 声明式定义语法扩展,并支持 expr、ident、path、tt 等 fragment specifier。 ↩

CONTINUE EXPLORING

沿着这篇文章继续

在地图中查看

On this page