dbt 项目中用 MiniJinja 构建脚本(Build Script)生成 Rust 代码的实战指南
【免费下载链接】dbtdbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.项目地址: https://gitcode.com/GitHub_Trending/db/dbt
导读
本文以 dbt 仓库中crates/dbt-jinja/examples/build-script示例为骨架,系统讲解如何把 MiniJinja 模板引擎用于 Rust 构建脚本(build script):在编译期渲染模板、生成.rs源码文件,再由main.rs通过include!内联编译。你将掌握自定义 Formatter、|safe过滤器、OUT_DIR输出机制等关键技能,并理解这套"模板即代码生成器"模式在 dbt 工程中的落地方式。
一、示例概览:一个完整的构建期代码生成闭环
crates/dbt-jinja/examples/build-script/README.md开篇即点明这个示例的核心目的:演示如何将 MiniJinja 用于构建脚本。它通过一个自定义 Formatter,自动把模板中的所有 Rust 值以Debug格式输出,模板本身无需关心格式化细节;随后再借助|safe过滤器,让值以合法的 Rust 表达式形式嵌入生成的代码。
整个示例由四个文件构成一个完整闭环:
| 文件 | 职责 |
|---|---|
| build.rs | 构建脚本:配置 MiniJinja 环境、渲染模板、写入OUT_DIR |
| src/example.rs.jinja | 模板:生成 Rust 源码的骨架 |
| src/main.rs | 主程序:include!引入生成的文件并使用其中的常量 |
| Cargo.toml | 声明minijinja为构建依赖(build-dependency) |
运行方式很简单,在示例目录下执行:
$ cargo run二、Cargo 配置:把 MiniJinja 变成构建依赖
构建脚本要能使用模板引擎,首先需要在 Cargo.toml 中把minijinja声明为build-dependencies(而非普通 dependencies),并显式启用两个 feature:
[package] name = "build-script" version = "0.1.0" edition = "2021" publish = false [build-dependencies] minijinja = { path = "../../minijinja", default-features = false, features = [ "serde", "builtins", ] }这里的要点:
path = "../../minijinja"表示直接引用 dbt 仓库内嵌的 MiniJinja 源码(即crates/dbt-jinja/minijinja),属于工作区内联依赖;default-features = false关闭默认特性,仅按需启用serde(提供值序列化/反序列化能力)与builtins(内置过滤器、测试与函数,|safe过滤器即来自这里);publish = false表明这是仓库内部的示例工程,不会发布到 crates.io。
Cargo 会先编译 build-dependencies,再执行build.rs,最后才编译主 crate,因此构建脚本阶段使用 MiniJinja 不会污染最终产物的运行时依赖。
三、build.rs:自定义 Formatter 是灵魂
build.rs 是整套模式的发动机,全文如下:
use std::path::Path; use std::{env, fs}; use minijinja::{render, Environment}; fn main() { // This environment has a formatter that formats unsafe values in Rust's // debug format, and safe values as normal strings. let mut env = Environment::new(); env.set_formatter(|out, _state, value| { if !value.is_safe() { write!(out, "{value:?}")?; } else { write!(out, "{value}")?; } Ok(()) }); // render the template and write it into the file that main.rs includes. fs::write( Path::new(&env::var("OUT_DIR").unwrap()).join("example.rs"), render!( in env, include_str!("src/example.rs.jinja"), struct_name => "Point", points => vec![ (1.0, 2.0), (2.0, 2.5), (4.0, 1.0), ], build_cwd => env::current_dir().unwrap() ), ) .unwrap(); }3.1 自定义 Formatter 做了什么
MiniJinja 默认的 Formatter 会依据值的类型做格式化。而这个示例通过Environment::set_formatter替换了默认行为,其核心逻辑是:
- 非安全值(unsafe):用 Rust 的
Debug格式化({value:?})输出——例如1.0会输出为1.0,字符串"/build/..."会输出为带引号与转义的合法 Rust 字符串字面量; - 安全值(safe):按普通字符串原样输出。
从源码看,set_formatter的签名要求一个闭包:Fn(&mut Output, &State, &Value) -> Result<(), Error>,它被存入环境的Arc<F>,在每次值输出时被调用。这正是"模板不需要关心 Rust 转义"的关键——格式化职责被整体上移到了构建脚本里。
3.2is_safe()与|safe过滤器的底层机制
Formatter 里调用的value.is_safe()并非黑魔法。在 value/mod.rs 中可以看到它的实现:
/// Returns `true` if this value is safe. pub fn is_safe(&self) -> bool { matches!(&self.0, ValueRepr::String(_, StringType::Safe)) }也就是说,只有被标记为StringType::Safe的字符串才被认为是"安全值"。而把普通字符串标记为 Safe 的入口,正是模板中使用的|safe过滤器。于是二者形成默契的分工:
- 模板中
{{ build_cwd }}(不安全)→ Formatter 走{value:?}分支 → 自动得到带引号的合法 Rust 字符串字面量; - 模板中
{{ struct_name|safe }}(安全)→ Formatter 走普通输出分支 → 原样输出Point这个合法的 Rust 标识符。
这样一来,"哪些内容需要被当作代码原样输出(如类型名)、哪些内容需要被当作数据转义(如路径字符串)"这个语义,被清晰地编码在了模板的过滤器选择里。
3.3render!宏与模板装载
渲染入口使用了 MiniJinja 的render!宏:
render!( in env, include_str!("src/example.rs.jinja"), struct_name => "Point", points => vec![(1.0, 2.0), (2.0, 2.5), (4.0, 1.0)], build_cwd => env::current_dir().unwrap() )in env指定使用上面配置好自定义 Formatter 的环境;- 模板文本通过
include_str!在编译期直接嵌入到构建脚本二进制中,无需运行时读取文件、也不依赖模板文件是否部署到目标机器; - 后面的
key => value语法会被宏展开为context! { ... }再调用env.render_str(...),因此这里传入的是任意实现了相应序列化约定的 Rust 值——&str、Vec<(f32, f32)>、PathBuf均可直接传入,这正是serdefeature 的价值所在。
3.4 输出到 OUT_DIR
渲染结果最终被写入OUT_DIR:
fs::write( Path::new(&env::var("OUT_DIR").unwrap()).join("example.rs"), ... )OUT_DIR是 Cargo 为每个 crate 构建提供的唯一输出目录,main.rs可以稳定地通过env!("OUT_DIR")引用它。构建脚本在编译期把模板渲染结果落地为.rs文件,主程序在编译期把这个文件include!进来,两段代码在编译期完成了交接。
四、模板源码:把 Rust 代码写进 Jinja 骨架
src/example.rs.jinja 是生成 Rust 代码的模板,与 README 中展示的模板内容完全一致:
// This file is auto generated from a MiniJinja template struct {{ struct_name|safe }} { pub x: f32, pub y: f32, } const BUILD_CWD: &str = {{ build_cwd }}; const POINTS: [{{ struct_name|safe }}; {{ points|length }}] = [ {% for x, y in points %} {{ struct_name|safe }} { x: {{ x }}, y: {{ y }} }, {% endfor %} ];逐段解读其中的模板语法:
{{ struct_name|safe }}:输出结构体名Point,|safe告诉 MiniJinja 这是可信代码片段,原样输出(不转义、不被 Formatter 的 Debug 分支处理);{{ build_cwd }}:不加|safe,因此走自定义 Formatter 的 Debug 分支,自动转义为合法的 Rust 字符串字面量;{{ points|length }}:使用length过滤器输出数组长度,用来声明const POINTS: [Point; N]的数组大小;{% for x, y in points %}:Jinja 的元组解构循环,逐个展开Point { x: ..., y: ... }字面量。注意points的元素是(f32, f32)元组,模板里直接用x, y解构,体现 MiniJinja 对 Rust 元组的原生支持。
这里的模板化设计思路值得借鉴:把"数据"与"代码形状"分离——数据结构(坐标点)变化时,只需改构建脚本传入的数据,模板与生成代码的骨架保持稳定。
五、main.rs:消费生成代码
src/main.rs 展示了主程序如何消费生成的文件:
// include the generated file include!(concat!(env!("OUT_DIR"), "/example.rs")); fn main() { println!("build cwd: {BUILD_CWD}"); for point in POINTS { println!("({}, {})", point.x, point.y); } }include!(concat!(env!("OUT_DIR"), "/example.rs"))在编译期把build.rs生成的文件展开进当前 crate;- 生成文件中的
struct Point、const BUILD_CWD: &str、const POINTS: [Point; 3]全部对main.rs可见,直接以普通 Rust 标识符使用。
六、运行结果:模板渲染后的产物
README 给出了 build.rs 传入上述数据后渲染出的完整输出(对应于运行cargo run前生成文件的实际内容):
struct Point { pub x: f32, pub y: f32, } const BUILD_CWD: &str = "/build/minijinja/examples/build-script"; const POINTS: [Point; 3] = [ Point { x: 1.0, y: 2.0 }, Point { x: 2.0, y: 2.5 }, Point { x: 4.0, y: 1.0 }, ];与模板对比可以直观看到 Formatter 与|safe的分工成果:
BUILD_CWD对应的/build/minijinja/examples/build-script被自动加上了双引号并转义为合法字符串字面量(Formatter 的 Debug 分支);Point作为安全值原样输出(|safe分支);[Point; 3]中的3由{{ points|length }}计算得到;- 三个
Point字面量由{% for %}循环生成。
运行cargo run后,程序会打印build cwd: /build/minijinja/examples/build-script以及三个坐标点,证明生成的常量确实参与了程序逻辑。
七、从示例到实战:构建期代码生成的可复用模式
这个 40 行左右的示例,实际上概括了一套在 Rust 工程中广泛适用的构建期代码生成模式:
- 依赖声明:在
[build-dependencies]中引入 MiniJinja(含serde、builtinsfeatures),模板引擎只存在于构建期; - 环境配置:在
build.rs中创建Environment,按需用set_formatter定制值输出规则——这是解决"Rust 代码转义"问题的通用手段; - 模板与数据分离:模板用
include_str!内嵌,数据以render!宏的key => value形式传入,数据变化不触碰模板; - 编译期交接:渲染结果写入
OUT_DIR/example.rs,主程序用include!(concat!(env!("OUT_DIR"), "/example.rs"))引入; - 安全值语义:用
|safe标记"本就是要输出成代码"的片段,其余值交给 Formatter 自动转义,杜绝手写转义导致的低级错误。
八、延伸阅读
- 示例 README:crates/dbt-jinja/examples/build-script/README.md
- 构建脚本实现:build.rs
- 生成代码模板:src/example.rs.jinja
- 主程序消费方式:src/main.rs
- MiniJinja 环境与 Formatter API:environment.rs
render!/context!宏定义:macros.rs- 安全值(Safe String)判定实现:value/mod.rs
- MiniJinja 整体文档与更多示例:crates/dbt-jinja/minijinja/README.md
如果希望深入了解 MiniJinja 在普通应用(而非构建脚本)中的渲染、继承与测试能力,dbt 仓库中的 examples 目录还提供了大量可直接运行的示例可供对照学习。
【免费下载链接】dbtdbt enables data analysts and engineers to transform their data using the same practices that software engineers use to build applications.项目地址: https://gitcode.com/GitHub_Trending/db/dbt
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考