dbt 项目中用 MiniJinja 构建脚本(Build Script)生成 Rust 代码的实战指南
2026/9/15 0:07:40 网站建设 项目流程

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过滤器。于是二者形成默契的分工:

  1. 模板中{{ build_cwd }}(不安全)→ Formatter 走{value:?}分支 → 自动得到带引号的合法 Rust 字符串字面量;
  2. 模板中{{ 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 值——&strVec<(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 Pointconst BUILD_CWD: &strconst 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 工程中广泛适用的构建期代码生成模式:

  1. 依赖声明:在[build-dependencies]中引入 MiniJinja(含serdebuiltinsfeatures),模板引擎只存在于构建期;
  2. 环境配置:在build.rs中创建Environment,按需用set_formatter定制值输出规则——这是解决"Rust 代码转义"问题的通用手段;
  3. 模板与数据分离:模板用include_str!内嵌,数据以render!宏的key => value形式传入,数据变化不触碰模板;
  4. 编译期交接:渲染结果写入OUT_DIR/example.rs,主程序用include!(concat!(env!("OUT_DIR"), "/example.rs"))引入;
  5. 安全值语义:用|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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询