gitbook/陈天 · Rust 编程第一课/docs/481359.md
2022-09-03 22:05:03 +08:00

27 KiB
Raw Permalink Blame History

加餐|宏编程(上):用最“笨”的方式撰写宏

你好,我是陈天。

学过上一讲,相信你现在应该理解为什么在课程的第 6 讲我们说,宏的本质其实很简单,抛开 quote/unquote宏编程主要的工作就是把一棵语法树转换成另一颗语法树而这个转换的过程深入下去不过就是数据结构到数据结构的转换。

那在Rust里宏到底是如何做到转换的呢

接下来,我们就一起尝试构建声明宏和过程宏。希望你能从自己撰写的过程中,感受构建宏的过程中做数据转换的思路和方法,掌握了这个方法,你可以应对几乎所有和宏编程有关的问题。

如何构建声明宏

首先看声明宏是如何创建的。

我们 cargo new macros --lib 创建一个新的项目,然后在新生成的项目下,创建 examples 目录,添加 examples/rule.rs代码

#[macro_export]
macro_rules! my_vec {
    // 没带任何参数的 my_vec我们创建一个空的 vec
    () => {
        std::vec::Vec::new()
    };
    // 处理 my_vec![1, 2, 3, 4]
    ($($el:expr),*) => ({
        let mut v = std::vec::Vec::new();
        $(v.push($el);)*
        v
    });
    // 处理 my_vec![0; 10]
    ($el:expr; $n:expr) => {
        std::vec::from_elem($el, $n)
    }
}

fn main() {
    let mut v = my_vec![];
    v.push(1);
    // 调用时可以使用 [], (), {}
    let _v = my_vec!(1, 2, 3, 4);
    let _v = my_vec![1, 2, 3, 4];
    let v = my_vec! {1, 2, 3, 4};
    println!("{:?}", v);

    println!("{:?}", v);
    //
    let v = my_vec![1; 10];
    println!("{:?}", v);
}

上一讲我们说过对于声明宏可以用 macro_rules! 生成。macro_rules 使用模式匹配,所以你可以提供多个匹配条件以及匹配后对应执行的代码块。

看这段代码我们写了3个匹配的rules。

第一个 () => (std::vec::Vec::new()) 很好理解,如果没有传入任何参数,就创建一个新的 Vec。注意由于宏要在调用的地方展开我们无法预测调用者的环境是否已经做了相关的 use所以我们使用的代码最好带着完整的命名空间。

这第二个匹配条件 ($($el:expr),*),需要详细介绍一下

在声明宏中,条件捕获的参数使用 $ 开头的标识符来声明。每个参数都需要提供类型,这里 expr 代表表达式,所以 $el:expr 是说把匹配到的表达式命名为 $el$(...),* 告诉编译器可以匹配任意多个以逗号分隔的表达式,然后捕获到的每一个表达式可以用 $el 来访问。

由于匹配的时候匹配到一个 $(...)* (我们可以不管分隔符),在执行的代码块中,我们也要相应地使用 $(...)* 展开。所以这句 $(v.push($el);)* 相当于匹配出多少个 $el就展开多少句 push 语句。

理解了第二个匹配条件,第三个就很好理解了:如果传入用冒号分隔的两个表达式,那么会用 from_element 构建 Vec。

在使用声明宏时,我们需要为参数明确类型,哪些类型可用也整理在这里了:

  • item,比如一个函数、结构体、模块等。
  • block,代码块。比如一系列由花括号包裹的表达式和语句。
  • stmt,语句。比如一个赋值语句。
  • pat,模式。
  • expr,表达式。刚才的例子使用过了。
  • ty,类型。比如 Vec。
  • ident,标识符。比如一个变量名。
  • path,路径。比如:foo::std::mem::replacetransmute::<_, int>
  • meta,元数据。一般是在 #[...] 和 #![...] 属性内部的数据。
  • tt,单个的 token 树。
  • vis,可能为空的一个 Visibility 修饰符。比如 pub、pub(crate)。

声明宏构建起来很简单,只要遵循它的基本语法,你可以很快把一个函数或者一些重复的语句片段转换成声明宏

比如在处理 pipeline 时,我经常会根据某个返回 Result 的表达式的结果,做下面代码里这样的 match使其在出错时返回 PipelineError 这个 enum 而非 Result

match result {
    Ok(v) => v,
    Err(e) => {
        return pipeline::PlugResult::Err {
            ctx,
            err: pipeline::PipelineError::Internal(e.to_string()),
        }
    }
}

但是这种写法,在同一个函数内,可能会反复出现,我们又无法用函数将其封装,所以我们可以用声明宏来实现,可以大大简化代码:

#[macro_export]
macro_rules! try_with {
    ($ctx:ident, $exp:expr) => {
        match $exp {
            Ok(v) => v,
            Err(e) => {
                return pipeline::PlugResult::Err {
                    ctx: $ctx,
                    err: pipeline::PipelineError::Internal(e.to_string()),
                }
            }
        }
    };
}

如何构建过程宏

接下来我们讲讲如何构建过程宏。

过程宏要比声明宏要复杂很多,不过无论是哪一种过程宏,本质都是一样的,都涉及要把输入的 TokenStream 处理成输出的 TokenStream

要构建过程宏,你需要单独构建一个 crate在 Cargo.toml 中添加 proc-macro 的声明:

[lib]
proc-macro = true

这样,编译器才允许你使用 #[proc_macro] 相关的宏。所以我们先在今天这堂课生成的 crate 的 Cargo.toml 中添加这个声明,然后在 lib.rs 里写入如下代码:

use proc_macro::TokenStream;

#[proc_macro]
pub fn query(input: TokenStream) -> TokenStream {
    println!("{:#?}", input);
    "fn hello() { println!(\\"Hello world!\\"); }"
        .parse()
        .unwrap()
}

这段代码首先声明了它是一个 proc_macro并且是最基本的、函数式的过程宏。

使用者可以通过 query!(...) 来调用。我们打印传入的 TokenStream,然后把一段包含在字符串中的代码解析成 TokenStream 返回。这里可以非常方便地用字符串的 parse() 方法来获得 TokenStream是因为 TokenStream 实现了 FromStr trait感谢Rust。

好,明白这段代码做了什么,我们写个例子尝试使用一下,来创建 examples/query.rs并写入代码

use macros::query;

fn main() {
    query!(SELECT * FROM users WHERE age > 10);
}

可以看到,尽管 SELECT * FROM user WHERE age > 10 不是一个合法的 Rust 语法,但 Rust 的词法分析器还是把它解析成了 TokenStream提供给 query 宏。

运行 cargo run --example query,看 query 宏对输入 TokenStream 的打印:

TokenStream [
    Ident {
        ident: "SELECT",
        span: #0 bytes(43..49),
    },
    Punct {
        ch: '*',
        spacing: Alone,
        span: #0 bytes(50..51),
    },
    Ident {
        ident: "FROM",
        span: #0 bytes(52..56),
    },
    Ident {
        ident: "users",
        span: #0 bytes(57..62),
    },
    Ident {
        ident: "WHERE",
        span: #0 bytes(63..68),
    },
    Ident {
        ident: "age",
        span: #0 bytes(69..72),
    },
    Punct {
        ch: '>',
        spacing: Alone,
        span: #0 bytes(73..74),
    },
    Literal {
        kind: Integer,
        symbol: "10",
        suffix: None,
        span: #0 bytes(75..77),
    },
]

这里面TokenStream 是一个 Iterator里面包含一系列的 TokenTree

pub enum TokenTree {
    Group(Group),
    Ident(Ident),
    Punct(Punct),
    Literal(Literal),
}

后三个分别是 Ident标识符、Punct标点符号和 Literal字面量。这里的Group是因为如果你的代码中包含括号比如{} [] <> () ,那么内部的内容会被分析成一个 Group。你也可以试试把例子中对 query! 的调用改成这个样子:

query!(SELECT * FROM users u JOIN (SELECT * from profiles p) WHERE u.id = p.id and u.age > 10);

再运行一下 cargo run --example query,看看现在的 TokenStream 长什么样子,是否包含 Group。

好,现在我们对输入的 TokenStream 有了一个概念,那么,输出的 TokenStream 有什么用呢?我们的 query! 宏返回了一个 hello() 函数的 TokenStream这个函数真的可以直接调用么

你可以试试在 main() 里加入对 hello() 的调用,再次运行这个 example可以看到久违的 “Hello world!” 打印。

恭喜你!你的第一个过程宏就完成了!

虽然这并不是什么了不起的结果但是通过它我们认识到了过程宏的基本写法以及TokenStream / TokenTree 的基本结构。

接下来,我们就尝试实现一个派生宏,这是过程宏的三类宏中对大家最有意义的一类,也是工作中如果需要写过程宏主要会用到的宏类型。

如何构建派生宏

我们期望构建一个 Builder 派生宏,实现 proc-macro-workshop如下需求proc-macro-workshop是 Rust 大牛 David Tolnay 为帮助大家更好地学习宏编程构建的练习):

#[derive(Builder)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    env: Vec<String>,
    current_dir: Option<String>,
}

fn main() {
    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .build()
        .unwrap();
    assert!(command.current_dir.is_none());

    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .current_dir("..".to_owned())
        .build()
        .unwrap();
    assert!(command.current_dir.is_some());
}

可以看到,我们仅仅是为 Command 这个结构提供了 Builder 宏,就让它支持 builder() 方法,返回了一个 CommandBuilder 结构,这个结构有若干个和 Command 内部每个域名字相同的方法,我们可以链式调用这些方法,最后 build() 出一个 Command 结构。

我们创建一个 examples/command.rs把这部分代码添加进去。显然它是无法编译通过的。下面先来手工撰写对应的代码看看一个完整的、能够让 main() 正确运行的代码长什么样子:

#[allow(dead_code)]
#[derive(Debug)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    env: Vec<String>,
    current_dir: Option<String>,
}

#[derive(Debug, Default)]
pub struct CommandBuilder {
    executable: Option<String>,
    args: Option<Vec<String>>,
    env: Option<Vec<String>>,
    current_dir: Option<String>,
}

impl Command {
    pub fn builder() -> CommandBuilder {
        Default::default()
    }
}

impl CommandBuilder {
    pub fn executable(mut self, v: String) -> Self {
        self.executable = Some(v.to_owned());
        self
    }

    pub fn args(mut self, v: Vec<String>) -> Self {
        self.args = Some(v.to_owned());
        self
    }

    pub fn env(mut self, v: Vec<String>) -> Self {
        self.env = Some(v.to_owned());
        self
    }

    pub fn current_dir(mut self, v: String) -> Self {
        self.current_dir = Some(v.to_owned());
        self
    }

    pub fn build(mut self) -> Result<Command, &'static str> {
        Ok(Command {
            executable: self.executable.take().ok_or("executable must be set")?,
            args: self.args.take().ok_or("args must be set")?,
            env: self.env.take().ok_or("env must be set")?,
            current_dir: self.current_dir.take(),
        })
    }
}

fn main() {
    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .build()
        .unwrap();
    assert!(command.current_dir.is_none());

    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .current_dir("..".to_owned())
        .build()
        .unwrap();
    assert!(command.current_dir.is_some());
    println!("{:?}", command);
} 

这个代码很简单,基本就是照着 main() 中的使用方法,一个函数一个函数手写出来的,你可以看到代码中很多重复的部分,尤其是 CommandBuilder 里的方法,这是我们可以用宏来自动生成的。

那怎么生成这样的代码呢?显然,我们要把输入的 TokenStream抽取出来也就是把在 struct 的定义内部,每个域的名字及其类型都抽出来,然后生成对应的方法代码。

如果把代码看做是字符串的话,不难想象到,实际上就是要通过一个模板和对应的数据,生成我们想要的结果。用模板生成 HTML想必各位都不陌生但通过模板生成 Rust 代码,估计你是第一次。

有了这个思路,我们尝试着用 jinja 写一个生成 CommandBuilder 结构的模板。在 Rust 里,我们有 askma 这个非常高效的库来处理 jinja。模板大概长这个样子

#[derive(Debug, Default)]
pub struct {{ builder_name }} {
    {% for field in fields %}
    {{ field.name }}: Option<{{ field.ty }}>,
    {% endfor %}
}

这里的 fileds / builder_name 是我们要传入的参数,每个 field 还需要 name 和 ty 两个属性,分别对应 field 的名字和类型。我们也可以为这个结构生成方法:

impl {{ builder_name }} {
    {% for field in fields %}
    pub fn {{ field.name }}(mut self, v: impl Into<{{ field.ty }}>) -> {{ builder_name }} {
        self.{{ field.name }} = Some(v.into());
        self
    }
    {% endfor %}

    pub fn build(self) -> Result<{{ name }}, &'static str> {
        Ok({{ name }} {
            {% for field in fields %}
            {% if field.optional %}
            {{ field.name }}: self.{{ field.name }},
            {% else %}
            {{ field.name }}: self.{{ field.name }}.ok_or("Build failed: missing {{ field.name }}")?,
            {% endif %}
            {% endfor %}
        })
    }
}

对于原本是 Option 类型的域,要避免生成 Option<Option>,我们需要把是否是 Option 单独抽取出来,如果是 Option那么 ty 就是 T。所以field 还需要一个属性 optional。

有了这个思路,我们可以构建自己的数据结构来描述 Field

#[derive(Debug, Default)]
struct Fd {
    name: String,
    ty: String,
    optional: bool,
}

当我们有了模板,又定义好了为模板提供数据的结构,接下来要处理的核心问题就是:如何从 TokenStream 中抽取出来我们想要的信息

带着这个问题,我们在 lib.rs 里添加一个 derive macro把 input 打印出来:

#[proc_macro_derive(RawBuilder)]
pub fn derive_raw_builder(input: TokenStream) -> TokenStream {
    println!("{:#?}", input);
    TokenStream::default()
}

对于 derive macro要使用 proce_macro_derive 这个宏。我们把这个 derive macro 命名为 RawBuilder。在 examples/command.rs 中,我们修改 Command 结构,使其使用 RawBuilder注意要 use macros::RawBuilder

use macros::RawBuilder;

#[allow(dead_code)]
#[derive(Debug, RawBuilder)]
pub struct Command {
    ...
}

运行这个 example 后,我们会看到一大片 TokenStream 的打印(比较长这里就不贴了),仔细阅读这个打印,可以看到:

  • 首先有一个 Group包含了 #[allow(dead_code)] 属性的信息。因为我们现在拿到的 derive 下的信息,所以所有不属于 #[derive(...)] 的属性,都会被放入 TokenStream 中。
  • 之后是 pub / struct / Command 三个 ident。
  • 随后又是一个 Group包含了每个 field 的信息。我们看到field 之间用逗号这个 Punct 分隔field 的名字和类型又是通过冒号这个 Punct 分隔。而类型,可能是一个 Ident如 String或者一系列 Ident / Punct如 Vec / < / String / >。

我们要做的就是,把这个 TokenStream 中的 struct 名字,以及每个 field 的名字和类型拿出来。如果类型是 Option那么把 T 拿出来,把 optional 设置为 true。

好,有了这个思路,来写代码。首先在 Cargo.toml 中引入一些依赖:

[dependencies]
anyhow = "1"
askama = "0.11" # 处理 jinjia 模板,模板需要放在和 src 平行的 templates 目录下

akama 要求模板放在和 src 平行的 templates 目录下,创建这个目录,然后写入 templates/builder.j2

impl {{ name }} {
    pub fn builder() -> {{ builder_name }} {
        Default::default()
    }
}

#[derive(Debug, Default)]
pub struct {{ builder_name }} {
    {% for field in fields %}
        {{ field.name }}: Option<{{ field.ty }}>,
    {% endfor %}
}

impl {{ builder_name }} {
    {% for field in fields %}
    pub fn {{ field.name }}(mut self, v: impl Into<{{ field.ty }}>) -> {{ builder_name }} {
        self.{{ field.name }} = Some(v.into());
        self
    }
    {% endfor %}

    pub fn build(self) -> Result<{{ name }}, &'static str> {
        Ok({{ name }} {
            {% for field in fields %}
                {% if field.optional %}
                {{ field.name }}: self.{{ field.name }},
                {% else %}
                {{ field.name }}: self.{{ field.name }}.ok_or("Build failed: missing {{ field.name }}")?,
                {% endif %}
            {% endfor %}
        })
    }
}

然后创建 src/raw_builder.rs记得在 lib.rs 中引入),写入代码,这段代码我加了详细的注释,你可以对着打印出来的 TokenStream和刚才的分析相信不难理解。

use anyhow::Result;
use askama::Template;
use proc_macro::{Ident, TokenStream, TokenTree};
use std::collections::VecDeque;

/// 处理 jinja 模板的数据结构,在模板中我们使用了 name / builder_name / fields
#[derive(Template)]
#[template(path = "builder.j2", escape = "none")]
pub struct BuilderContext {
    name: String,
    builder_name: String,
    fields: Vec<Fd>,
}

/// 描述 struct 的每个 field
#[derive(Debug, Default)]
struct Fd {
    name: String,
    ty: String,
    optional: bool,
}

impl Fd {
    /// name 和 field 都是通过冒号 Punct 切分出来的 TokenTree 切片
    pub fn new(name: &[TokenTree], ty: &[TokenTree]) -> Self {
        // 把类似 Ident("Option"), Punct('<'), Ident("String"), Punct('>) 的 ty
        // 收集成一个 String 列表,如 vec!["Option", "<", "String", ">"]
        let ty = ty
            .iter()
            .map(|v| match v {
                TokenTree::Ident(n) => n.to_string(),
                TokenTree::Punct(p) => p.as_char().to_string(),
                e => panic!("Expect ident, got {:?}", e),
            })
            .collect::<Vec<_>>();
        // 冒号前最后一个 TokenTree 是 field 的名字
        // 比如executable: String,
        // 注意这里不应该用 name[0],因为有可能是 pub executable: String
        // 甚至,带 attributes 的 field
        // 比如:#[builder(hello = world)] pub executable: String
        match name.last() {
            Some(TokenTree::Ident(name)) => {
                // 如果 ty 第 0 项是 Option那么从第二项取到倒数第一项
                // 取完后上面的例子中的 ty 会变成 ["String"]optiona = true
                let (ty, optional) = if ty[0].as_str() == "Option" {
                    (&ty[2..ty.len() - 1], true)
                } else {
                    (&ty[..], false)
                };
                Self {
                    name: name.to_string(),
                    ty: ty.join(""), // 把 ty join 成字符串
                    optional,
                }
            }
            e => panic!("Expect ident, got {:?}", e),
        }
    }
}

impl BuilderContext {
    /// 从 TokenStream 中提取信息,构建 BuilderContext
    fn new(input: TokenStream) -> Self {
        let (name, input) = split(input);
        let fields = get_struct_fields(input);
        Self {
            builder_name: format!("{}Builder", name),
            name: name.to_string(),
            fields,
        }
    }

    /// 把模板渲染成字符串代码
    pub fn render(input: TokenStream) -> Result<String> {
        let template = Self::new(input);
        Ok(template.render()?)
    }
}

/// 把 TokenStream 分出 struct 的名字,和包含 fields 的 TokenStream
fn split(input: TokenStream) -> (Ident, TokenStream) {
    let mut input = input.into_iter().collect::<VecDeque<_>>();
    // 一直往后找,找到 struct 停下来
    while let Some(item) = input.pop_front() {
        if let TokenTree::Ident(v) = item {
            if v.to_string() == "struct" {
                break;
            }
        }
    }

    // struct 后面,应该是 struct name
    let ident;
    if let Some(TokenTree::Ident(v)) = input.pop_front() {
        ident = v;
    } else {
        panic!("Didn't find struct name");
    }

    // struct 后面可能还有若干 TokenTree我们不管一路找到第一个 Group
    let mut group = None;
    for item in input {
        if let TokenTree::Group(g) = item {
            group = Some(g);
            break;
        }
    }

    (ident, group.expect("Didn't find field group").stream())
}

/// 从包含 fields 的 TokenStream 中切出来一个个 Fd
fn get_struct_fields(input: TokenStream) -> Vec<Fd> {
    let input = input.into_iter().collect::<Vec<_>>();
    input
        .split(|v| match v {
            // 先用 ',' 切出来一个个包含 field 所有信息的 &[TokenTree]
            TokenTree::Punct(p) => p.as_char() == ',',
            _ => false,
        })
        .map(|tokens| {
            tokens
                .split(|v| match v {
                    // 再用 ':' 把 &[TokenTree] 切成 [&[TokenTree], &[TokenTree]]
                    // 它们分别对应名字和类型
                    TokenTree::Punct(p) => p.as_char() == ':',
                    _ => false,
                })
                .collect::<Vec<_>>()
        })
        // 正常情况下,应该得到 [&[TokenTree], &[TokenTree]],对于切出来长度不为 2 的统统过滤掉
        .filter(|tokens| tokens.len() == 2)
        // 使用 Fd::new 创建出每个 Fd
        .map(|tokens| Fd::new(tokens[0], &tokens[1]))
        .collect()
}

核心的就是 get_struct_fields() 方法,如果你觉得难懂,可以想想如果你要把一个 a=1,b=2 的字符串切成 [[a, 1], [b, 2]] 该怎么做,就很容易理解了。

好,完成了把 TokenStream 转换成 BuilderContext 的代码,接下来就是在 proc_macro 中使用这个结构以及它的 render 方法。我们把 lib.rs 中的代码修改一下(注意添加相关的 use

#[proc_macro_derive(RawBuilder)]
pub fn derive_raw_builder(input: TokenStream) -> TokenStream {
    BuilderContext::render(input).unwrap().parse().unwrap()
}

保存后你立刻会发现VS Code 抱怨 examples/command.rs 编译不过,因为里面有重复的数据结构和方法的定义。我们把之前手工生成的代码全部删掉,只保留:

use macros::RawBuilder;

#[allow(dead_code)]
#[derive(Debug, RawBuilder)]
pub struct Command {
    executable: String,
    args: Vec<String>,
    env: Vec<String>,
    current_dir: Option<String>,
}

fn main() {
    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .build()
        .unwrap();
    assert!(command.current_dir.is_none());

    let command = Command::builder()
        .executable("cargo".to_owned())
        .args(vec!["build".to_owned(), "--release".to_owned()])
        .env(vec![])
        .current_dir("..".to_owned())
        .build()
        .unwrap();
    assert!(command.current_dir.is_some());
    println!("{:?}", command);
}

运行之,我们撰写的 RawBuilder 宏起作用了!代码运行一切正常!

小结

这一讲我们简单介绍了 Rust 宏编程的能力,并撰写了一个声明宏 my_vec! 和一个派生宏 RawBuilder。通过自己手写核心就是要理解清楚宏做数据转换的方法如何从 TokenStream 中抽取需要的数据,然后生成包含目标代码的字符串,最后再把字符串转换成 TokenStream。

在构建 RawBuilder 的过程中,我们还了解了 TokenStream 和 TokenTree虽然这两个数据结构是 Rust 下的结构,但是 token stream / token tree 这样的概念是每个支持宏的语言共有的,如果你理解了 Rust 的宏编程,那么学习其他语言的宏编程就很容易了。

在手写的过程中,你可能会觉得宏编程过于繁琐,这是因为解析 TokenStream 是一个苦力活,要和各种各样的情况打交道,如果处理不好,就很容易出错。

那在Rust生态下有没有人已经做过这个苦力活了呢我们下节课继续……

思考题

最后出个思考题给你练练手。工作中,有很多场景我们需要通过第三方的 schema 来生成 Rust 数据结构,比如 protobuf 的定义到 Rust struct/enum 的转换。这些转换如果手工撰写的话,是纯粹的体力活,我们可以通过宏来简化这个操作。

假设你的公司维护了大量的 openapi v3 spec需要你通过它来生成 Rust 类型,比如这里的 schema 定义(来源

{
  "type": "object",
  "properties": {
    "id": {
      "type": "integer",
      "format": "int64"
    },
    "petId": {
      "type": "integer",
      "format": "int64"
    },
    "quantity": {
      "type": "integer",
      "format": "int32"
    },
    "shipDate": {
      "type": "string",
      "format": "date-time"
    },
    "status": {
      "type": "string",
      "description": "Order Status",
      "enum": [
        "placed",
        "approved",
        "delivered"
      ]
    },
    "complete": {
      "type": "boolean",
      "default": false
    }
  },
  "xml": {
    "name": "Order"
  }
}

你可以试着使用今天所学内容,撰写一个 generate! 宏,接受一个包含 schema 定义的文件名,生成 schema。如果你遇到问题卡壳了可以参考B站上我live coding的视频

欢迎在留言区讨论你的想法,如果觉得有收获,也欢迎你分享给身边的朋友,邀他一起讨论。我们下节课见。