发布于 2026-01-06 10 阅读
0

使用 Cargo 构建脚本自动生成 Rust 模块 DEV 的全球展示挑战赛,由 Mux 呈现:展示你的项目!

使用 Cargo 构建脚本自动生成 Rust 模块

由 Mux 主办的 DEV 全球展示挑战赛:展示你的项目!

我刚学会如何使用Cargo 构建脚本。它们真不错。

背景

如果你不关心上下文,这里是构建脚本部分

我正在从零开始重建我的个人网站,并计划将我的开发博客文章重新托管在那里。我已经选择了一个askama库来生成网页的 HTML。这个工具有点像Jinja(或者tera说 Rust 中的 Jinja),但有一个显著的区别——它会对你的模板进行类型检查,并将它们直接编译成应用程序的可执行文件。

例如,这是我的顶级skel.html模板:

<!DOCTYPE html>
<html dir="ltr" lang="en">

<head>
  <meta charset="utf-8" />
  <title>{% block title %}{% endblock %} - deciduously.com</title>
  <meta name="Description" content="Ben Lovy's personal website" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0, minimum-scale=1.0, maximum-scale=5.0" />
  <link rel="icon" type="image/x-icon" href="/favicon.svg" />
  <link rel="stylesheet" href="/main.css" />
  <link rel="manifest" href="/manifest.json" />
</head>

<body>
  <header>
    <nav>
      {% for link in links %}
      <a class="{% if link.target == "/" %}font-extrabold text-lg{% else %}italic{% endif %} px-10"
        href={{ link.target }}>{{ link.name }}</a>
      {% endfor %}
    </nav>
  </header>
  <main>
    {% block content %}{% endblock %}
  </main>
  <footer class="text-xs italic">
    © 2020 Ben Lovy - <a href="https://github.com/deciduously/deciduously-com" target="_blank"
      rel="noreferrer">source</a>
  </footer>
</body>

</html>
Enter fullscreen mode Exit fullscreen mode

您可以使用 来创建子页面extends,然后添加您自己的内容来填充block基础页面中定义的 :

{% extends "skel.html" %}
{% block title %}404{% endblock %}
{% block content %}<h1>NOT FOUND!</h1>{% endblock %}
Enter fullscreen mode Exit fullscreen mode

在 Rust 端,要渲染此标记,您需要创建一个结构体,并将文件直接通过标签传递给它:

#[derive(Template)]
#[template(path = "skel.html")]
pub struct SkelTemplate {
    links: &'static [Hyperlink],
}

impl Default for SkelTemplate {
    fn default() -> Self {
        Self { links: &NAV }
    }
}
Enter fullscreen mode Exit fullscreen mode

模板中指定的值{% for link in links %}具体指的是 Rust 存储在该结构体字段中的内容。要最终提取渲染后的标记,你需要实例化该结构体并调用 `getRenderedMarkup()` 方法render(),该方法askama会自动为我们生成:

pub async fn four_oh_four() -> HandlerResult {
    let template = FourOhFourTemplate::default();
    let html = template.render().expect("Should render markup");
    string_handler(&html, "text/html", Some(StatusCode::NOT_FOUND)).await
}
Enter fullscreen mode Exit fullscreen mode

如果需要注入任何数据,必须将其存储在结构体中,并定义一个构造函数(或其他方法)来添加数据。它的工作方式与任何其他 Rust 代码一样。所有流入此模板的数据都定义在此结构体中,并在到达您的标记之前由编译器进行验证。

这很棒,因为它具备 Rust 类型检查一贯的优点。它的性能也非常出色,因为你的模板会被直接打包到二进制文件中并进行预编译——运行时不会发生任何文件 I/O 操作,而且所有模板操作(例如循环和条件语句)在被调用之前就已经转换成了实际的 Rust 循环和条件语句。简直太棒了

魔法发生在标签里:

#[derive(Template)]
#[template(path = "skel.html")]
Enter fullscreen mode Exit fullscreen mode

这是一个过程宏。代码编译时,它会在任何其他操作之前展开。在本例中,它会解析你的模板,并将生成的 Rust 代码作为impl MyTemplate {}包含render(&self)方法的代码块插入到模块中,然后再开始编译。正是在这个宏展开阶段(而非编译阶段),skel.html才会从文件系统中打开你的实际模板文件(例如 `<template>` 和 `<template>`),它假定所有文件都已存在<crate root>/templates。之后,你的代码不会再次读取这些文件。

问题

我想用 Markdown 而不是 HTML 来撰写文章。这意味着我需要在发布文章之前将 Markdown 转换为 HTML。没问题——我是在用编程语言。这个问题可以用三行代码解决pulldown-cmark

let parser = pulldown_cmark::Parser::new("# THE BEST HEADING");
let mut html = String::new();
html::push_html(&mut html, parser);
println!("{}", html); // <h1>THE BEST HEADING</h1>
Enter fullscreen mode Exit fullscreen mode

不过,生成的标记也需要继承skel.html模板代码,才能使其看起来像是同一个网站的一部分。这很简单,我只需要为每个文件创建一个新模板即可。

如果这是我的 Markdown 代码,那么稍微放大一点:

---
title: "COOL POST"
---
# THE BEST HEADING

But _nothing_ compared to this intro!
Enter fullscreen mode Exit fullscreen mode

这是我的标记代码:

{% extends "skel.html" %}
{% block title %}COOL POST{% endblock %}
{% block content %}<h1>THE BEST HEADING</h1>
<p>But <em>nothing</em> compared to this intro!</p>{% endblock %}
Enter fullscreen mode Exit fullscreen mode

这是一个字符串操作问题——再说一遍,我们是在开发一种编程语言,所以我对此没有意见:

fn write_template(title: &str, html: &str, file: &mut std::fs::File) -> Result<(), std::io::Error> {
    writeln!(file, "{{% extends \"skel.html\" %}}")?;
    writeln!(file, "{{% block title %}}{}{{% endblock %}}", title)?;
    writeln!(file, "{{% block content %}}{}{{% endblock %}}", html)?;
    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

你可能已经猜到问题出在哪里了。为了将这些 Askama 模板从 Markdown 中提取出来并写入磁盘,我们需要执行一些代码。然而,当我们有机会运行这段代码时,所有的模板宏都已经展开了。

为了实现这一点,我们需要在宏展开阶段之前自动生成这些模板文件和相应的结构体——正如我们之前讨论过的,宏展开阶段发生在所有其他步骤之前。糟糕!

修复方案

我最初处理这个问题的时候……其实我根本没处理。我为我的可执行文件创建了一个单独的内置命令行命令来处理这个问题,所以我有两个publish模式serve。你需要publish在构建生产二进制文件之前调用它。它确实有效,但我很讨厌这种方式。

另一种选择是放弃 Askama,直接使用前面提到的工具tera,它能在运行时完成工作。它快速简便,而且完全能够胜任,你或许应该就这么做。不过,这样你就失去了类型检查和独立二进制文件的功能。而且我这个人也比较固执。

幸好,我们有构建脚本!

构建脚本部分

build.rs文件可以放在 crate 的根目录下,位于 `<crate>` 标签之外src它不是 crate 的一部分。如果存在该文件,程序会在执行 crate之前cargo编译并运行它。

文档链接中给出的示例是针对 FFI 的:

// Example custom build script.
fn main() {
    // Tell Cargo that if the given file changes, to rerun this build script.
    println!("cargo:rerun-if-changed=src/hello.c");
    // Use the `cc` crate to build a C file and statically link it.
    cc::Build::new()
        .file("src/hello.c")
        .compile("hello");
}
Enter fullscreen mode Exit fullscreen mode

该脚本会检查是否hello.c已更改,如有必要,会在编译您的 crate 之前重新构建它。

令人恼火的是,你只能通过向`:`cargo写入内容来从脚本内部与 `:` 通信。这个路径不会递归遍历目录,所以如果你想监视例如 `:` 中的每个模板的更改,你需要为其中的每个文件向 `:` 写入单独的行。stdoutprintln!("cargo:rerun-if-changed=src/hello.c");templates/stdout

由于这是一个普通的 Rust 程序,所以这并不是什么问题——我们可以读取目录并println!()为找到的每一行生成一条语句:

#[derive(Debug, Default)]
pub struct Blog {
    pub posts: Vec<BlogPost>,
}

impl Blog {
    fn new() -> Self {
        let mut ret = Blog::default();
        // scrape posts
        let paths = std::fs::read_dir("blog").expect("Should locate blog directory");
        for path in paths {
            let path = path.expect("Could not open blog post").path();
            let post = BlogPost::new(ret.total(), path);
            ret.posts.push(post);
        }
        ret
    }
    fn total(&self) -> usize {
        self.posts.len()
    }
}

fn main() {
    let blog = Blog::new();
    println!("cargo:rerun-if-changed=blog");
    for p in &blog.posts {
        println!("cargo:rerun-if-changed=blog/{}.md", p.url_name);
    }
}
Enter fullscreen mode Exit fullscreen mode

这样就可以了。既然我们可以使用 Rust,就可以像上面生成 Askama 模板那样使用它。为什么不直接用 Rust 写代码呢std::fs::Filewriteln!()

fn write_link_info_type(file: &mut std::fs::File) -> Result<(), std::io::Error> {
    writeln!(file, "#[derive(Debug, Clone, Copy)]")?;
    writeln!(file, "pub struct LinkInfo {{")?;
    writeln!(file, "    pub id: usize,")?;
    writeln!(file, "    pub url_name: &'static str,")?;
    writeln!(file, "    pub title: &'static str,")?;
    writeln!(file, "}}\n")?;
    Ok(())
}

fn generate_module() -> Result<(), std::io::Error> {
    let mut module = std::fs::File::create(&format!("src/{}.rs", "blog"))?;
    write_link_info_type(&mut module)?;
    Ok(())
}

fn main() {
    if let Err(e) = generate_module() {
        eprintln!("Error: {}", e);
    }
}
Enter fullscreen mode Exit fullscreen mode

src/blog.rs此构建脚本会在你的 crate 目录中生成一个类似这样的文件:

#[derive(Debug, Clone, Copy)]
pub struct LinkInfo {
    pub id: usize,
    pub url_name: &'static str,
    pub title: &'static str,
}
Enter fullscreen mode Exit fullscreen mode

看起来像是可以运行的 Rust 代码!你只需要确保把它添加到 `<path>`main.rs或 `<path> ` 中即可lib.rs

mod blog;
Enter fullscreen mode Exit fullscreen mode

砰!一个全新的模块就完成了。更棒的是,你不仅可以使用 Rust 标准库,还可以使用任何你能找到的库。你甚至可以专门为构建阶段cargo添加依赖项:Cargo.toml

[build-dependencies]
pest = "2.1"
pest_derive = "2.1"

[build-dependencies.pulldown-cmark]
default-features = false
version = "0.6"
Enter fullscreen mode Exit fullscreen mode

这里定义的所有内容都不能用于你的 crate,只能用于其他 crate build.rs。如果你想在两者中都使用某些内容,需要将其添加到此文件的两个部分。唯一不能在这里使用的就是你自己的 crate,因为它尚未构建。除此之外,一切正常。

我决定要对 Markdown 标题到 Rust 处理程序和模板的流程进行更精细的控制,所以我过去常常pest自己编写一个博客文章解析器来遍历标题:

header = { header_guard ~ attribute{3,6} ~ header_guard }
    header_guard = _{ "-"{3} ~ NEWLINE }
    attribute = { key ~ ": " ~ value ~ NEWLINE }
        key = { (ASCII_ALPHANUMERIC | "_")+ }
        value = { (ASCII_ALPHANUMERIC | PUNCTUATION | " " | ":" | "/" | "+")* }

body = { ANY* }

draft = { SOI ~ header ~ body? ~ EOI }
Enter fullscreen mode Exit fullscreen mode

这意味着我可以直接在构建脚本中解析并生成博客文章的结构:

// Compiles drafts to templates and generates struct
#[derive(Parser)]
#[grammar = "draft.pest"]
struct Draft;

#[derive(Debug, Default, Clone)]
pub struct BlogPost {
    pub cover_image: Option<String>,
    pub description: Option<String>,
    pub edited: Option<String>, // only if published
    pub id: usize,
    pub published: bool,
    pub markdown: String,
    pub url_name: String,
    pub title: String,
}
Enter fullscreen mode Exit fullscreen mode

我可以在这里使用 Pest 解析器来处理 markdown 文件:

impl BlogPost {
    fn new(id: usize, path: PathBuf) -> Self {
        // Init empty post
        let mut ret = Self::default();
        ret.id = id;
        ret.url_name = path.file_stem().unwrap().to_str().unwrap().to_string();

        // fill in struct from draft
        let md_file = fs::read_to_string(path.to_str().unwrap()).expect("Could not read draft");
        let parse_tree = Draft::parse(Rule::draft, &md_file)
            .expect("Could not parse draft")
            .next()
            .unwrap();
        // cycle through each attribute
        // unwrap is safe - if it parsed, there are between 3 and 6
        let mut parse_tree_inner = parse_tree.into_inner();

        // set header
        let header = parse_tree_inner.next().unwrap();
        let attributes = header.into_inner();
        for attr in attributes {
            let mut name: &str = "";
            let mut value: &str = "";
            for attr_part in attr.into_inner() {
                match attr_part.as_rule() {
                    Rule::key => name = attr_part.as_str(),
                    Rule::value => value = attr_part.as_str(),
                    _ => unreachable!(),
                }
            }
            match name {
                "cover_image" => ret.cover_image = Some(value.to_string()),
                "description" => ret.description = Some(value.to_string()),
                "edited" => ret.edited = Some(value.to_string()),
                "published" => {
                    ret.published = match value {
                        "true" => true,
                        _ => false,
                    }
                }
                "title" => ret.title = value.to_string(),
                _ => {}
            }
        }

        // set body
        let body = parse_tree_inner.next().unwrap();
        ret.markdown = body.as_str().to_string();

        // done
        ret
    }
}
Enter fullscreen mode Exit fullscreen mode

现在构建脚本的内存中已经包含了每篇博客文章及其正确组织的元数据,我们可以告诉它如何填充我们需要的模板:

    fn write_template(&self) -> Result<(), std::io::Error> {
        let mut file = std::fs::File::create(&format!("templates/post_{}.html", self.url_name))?;
        let parser = pulldown_cmark::Parser::new(&self.markdown);
        let mut html = String::new();
        html::push_html(&mut html, parser);
        writeln!(file, "{{#  This file was auto-generated by build.rs #}}")?;
        writeln!(file, "{{% extends \"skel.html\" %}}")?;
        writeln!(file, "{{% block title %}}{}{{% endblock %}}", self.title)?;
        writeln!(file, "{{% block content %}}{}{{% endblock %}}", html)?;
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

驱动代码只需遍历所有抓取的帖子并调用此方法即可。不过,我们也需要一个结构体供 Askama 渲染——只要我们能生成 Rust 模块,就能生成这些结构体:

    fn struct_name(&self) -> String {
        format!("Blog{}Template", self.id)
    }
    fn write_template_struct(&self, file: &mut std::fs::File) -> Result<(), std::io::Error> {
        writeln!(file, "#[derive(Template)]")?;
        writeln!(file, "#[template(path = \"post_{}.html\")]", self.url_name)?;
        writeln!(file, "pub struct {} {{", &self.struct_name())?;
        writeln!(file, "    links: &'static [Hyperlink],")?;
        writeln!(file, "}}")?;
        writeln!(file, "impl Default for {} {{", &self.struct_name())?;
        writeln!(file, "    fn default() -> Self {{")?;
        writeln!(file, "        Self {{ links: &NAV }}")?;
        writeln!(file, "    }}")?;
        writeln!(file, "}}\n")?;
        Ok(())
    }
Enter fullscreen mode Exit fullscreen mode

这将弹出类似这样的内容src/blog.rs

#[derive(Template)]
#[template(path = "post_cool-post.html")]
pub struct Blog0Template {
    links: &'static [Hyperlink],
}
impl Default for Blog0Template {
    fn default() -> Self {
        Self { links: &NAV }
    }
}
Enter fullscreen mode Exit fullscreen mode

我使用相同的writeln!()策略自动生成了一个包含多个匹配臂的处理程序,每个结构体对应一个匹配臂:

pub async fn blog_handler(path_str: &str) -> HandlerResult {
    match path_str {
        "/cool-post" => {
            string_handler(
                &Blog0Template::default()
                    .render()
                    .expect("Should render markup"),
                "text/html",
                None,
            )
            .await
        }
        // etc ...
        _ => four_oh_four().await,
    }
}
Enter fullscreen mode Exit fullscreen mode

除了抓取一些元数据以构建包含信息的静态值,从而创建帖子列表页面之外:

lazy_static! {
    pub static ref LINKINFO: BlogLinkInfo = {
        let mut ret = BlogLinkInfo::default();
        ret.posts.push(LinkInfo {
            id: 0,
            title: "Cool Post",
            url_name: "cool-post",
        });
        // etc...
}
Enter fullscreen mode Exit fullscreen mode

把所有内容整合起来,看起来就像一堆 Rust 代码,你知道,它本来就是 ​​Rust 代码——以下是部分代码片段:

fn generate_handler(blog: &Blog, file: &mut std::fs::File) -> Result<(), std::io::Error> {
    writeln!(file, "pub async fn blog_handler(path_str: &str) -> HandlerResult {{")?;
    writeln!(file, "    match path_str {{")?;
    for p in &blog.posts {
        p.write_handler_match_arm(file)?;
    }
    writeln!(file, "        _ => four_oh_four().await,")?;
    writeln!(file, "    }}")?;
    writeln!(file, "}}")?;
    Ok(())
}

fn generate_module(blog: &Blog) -> Result<(), std::io::Error> {
    let mut module = fs::File::create(&format!("src/{}.rs", MODULE_NAME))?;

    write_imports(&mut module)?;

    write_link_info_type(&mut module)?;
    write_blog_link_info_type(&mut module)?;

    generate_blog_link_info(blog, &mut module)?;
    generate_template_structs(blog, &mut module)?;
    generate_posts(blog)?;
    generate_handler(blog, &mut module)?;

    Ok(())
}
Enter fullscreen mode Exit fullscreen mode

现在,当 askama 的过程宏在编译你的实际 crate 开始时启动时,所有模板文件templates/以及你在项目中使用每个*.md文件所需的 Rust 代码都已生成,可以从 crate 的其余部分调用:

// src/blog.rs
// this module was auto-generated by build.rs
use crate::{
    config::NAV,
    handlers::{four_oh_four, string_handler, HandlerResult},
    types::Hyperlink,
};
use askama::Template;
use lazy_static::lazy_static;

#[derive(Debug, Clone, Copy)]
pub struct LinkInfo {
    pub id: usize,
    pub url_name: &'static str,
    pub title: &'static str,
}

#[derive(Debug, Default)]
pub struct BlogLinkInfo {
    pub posts: Vec<LinkInfo>,
}

lazy_static! {
    pub static ref LINKINFO: BlogLinkInfo = {
        let mut ret = BlogLinkInfo::default();
        ret.posts.push(LinkInfo {
            id: 0,
            title: "Cool Post",
            url_name: "cool-post",
        });
        ret.posts.push(LinkInfo {
            id: 1,
            title: "Kind Of Alright Post",
            url_name: "honestly-meh",
        });
        ret
    };
}

#[derive(Template)]
#[template(path = "post_cool-post.html")]
pub struct Blog0Template {
    links: &'static [Hyperlink],
}
impl Default for Blog0Template {
    fn default() -> Self {
        Self { links: &NAV }
    }
}

#[derive(Template)]
#[template(path = "post_honestly-meh.html")]
pub struct Blog1Template {
    links: &'static [Hyperlink],
}
impl Default for Blog1Template {
    fn default() -> Self {
        Self { links: &NAV }
    }
}

pub async fn blog_handler(path_str: &str) -> HandlerResult {
    match path_str {
        "/cool-post" => {
            string_handler(
                &Blog0Template::default()
                    .render()
                    .expect("Should render markup"),
                "text/html",
                None,
            )
            .await
        }
        "/honestly-meh" => {
            string_handler(
                &Blog1Template::default()
                    .render()
                    .expect("Should render markup"),
                "text/html",
                None,
            )
            .await
        }
        _ => four_oh_four().await,
    }
}
Enter fullscreen mode Exit fullscreen mode

每次您更改此目录中的文件时,构建脚本都会重新生成此文件以匹配,因此您只需担心 markdown 文件即可管理您的博客。

你知道,就像那种静态网站之类的东西。太疯狂了。

构建脚本功能非常强大——用它们做过什么?

照片由 Scott Blake 拍摄,来自 Unsplash

文章来源:https://dev.to/decidously/automatically-generate-rust-modules-with-cargo-build-scripts-157h