本文讨论的是从其他Rust代码生成Rust代码,而不是Rust编译器的代码生成步骤。源代码生成的另一个术语是元编程,但这里将其称为动态代码生成。
桌面应用程序通常把一个嵌入到Rust二进制文件中的web前端交付给终端用户。像Tauri这样的项目通过编写Rust代码,生成了更多的Rust代码,来实现嵌入代码的生成。
假设web前端的输出是这样的:
dist
├── assets
│ ├── script-44b5bae5.js
│ ├── style-48a8825f.css
├── index.html
让我们使用include_str!宏将这些文件嵌入到Rust项目中,它将指定的文件内容添加到二进制文件中。它看起来像这样:
use std::collections::HashMap;
fn main() {
let mut assets = HashMap::new();
assets.insert(
"/index.html",
include_str!("../dist/index.html")
);
assets.insert(
"/assets/script-44b5bae5.js",
include_str!("../dist/assets/script-44b5bae5.js")
);
assets.insert(
"/assets/style-48a8825f.css",
include_str!("../dist/assets/style-48a8825f.css")
);
}
非常简单,现在我们可以直接从最终二进制文件中获取这些资源了!然而,如果我们并不总是提前知道资源的文件名,该怎么办呢?假设我们在前端项目上做了更多的工作,现在它的输出是这样的:
dist
├── assets
│ │ # script-44b5bae5.js previously
│ ├── script-581f5c69.js
│ │
│ │ # style-48a8825f.css previously
│ ├── style-e49f12aa.css
├── index.html
我们资源的文件名已经改变了,因为我们的前端打包器使用了缓存破坏机制。在我们修复其中的文件名之前,Rust代码不再编译。
如果我们每次更改前端都必须更新Rust代码,这将是一种糟糕的开发体验——想象一下,如果我们有几十个资源!
Tauri使用动态代码生成来避免这种情况,它在编译时查找资源,并生成调用正确资源的Rust代码。
让我们讨论一些用于代码生成的工具,然后使用它们来实现我们自己的资源捆绑器。
Rust代码生成通常发生在构建脚本或宏中,我们将使用构建脚本构建简单的资源捆绑器,因为我们将访问磁盘。
让我们从创建一个新的Rust库开始:
cargo new --lib asset-bundler
我们希望为使用该库的应用程序创建一种获取资源的方法,所以让我们首先创建它。
加入phf依赖项:
cargo add phf --features macros
在src/lib.rs文件中,写入以下代码:
pub use phf; // 重新导出phf,以便我们以后使用
type Map = phf::Map<&'static str, &'static str>;
/// 用于编译时嵌入资源的容器
pub struct Assets(Map);
impl From<Map> for Assets {
fn from(value: Map) -> Self {
Self(value)
}
}
impl Assets {
/// 获取指定资源
pub fn get(&self, path: &str) -> Option<&str> {
self.0.get(path).copied()
}
}
对于Assets结构体,我们不需要太多的功能。在这里我们创建了一个关于phf::Map的封装器和一个让调用者获得内容的方法。
现在,我们创建一个在构建脚本中使用的库,以生成代码。
因为我们将在同一个项目中拥有多个crate,因此需要将项目转换为cargo workspace。修改Cargo.toml文件,如下:
[workspace]
members = ["codegen"]
[package]
name = "asset-bundler"
version = "0.1.0"
edition = "2021"
[dependencies]
phf = { version = "0.11.2", features = ["macros"] }
在项目根目录下,运行以下命令来创建codegen项目并加入依赖项:
cargo new --lib codegen --name asset-bundler-codegen
cargo add quote walkdir --package asset-bundler-codegen
在codegen项目中,我们需要完成的功能如下:
最后要提到的是,我们希望通过传递一个相对路径来获取资源。我们想要的是assets.get("index.html"),而不是assets.get(". /dist/index.html"),这意味着我们需要跟踪传入函数的base目录。
让我们把这些需求写在codegen/src/lib.rs的代码中:
use std::path::{Path, PathBuf};
/// 生成Rust代码
pub fn codegen(path: &Path) -> std::io::Result<String> {
// canonicalize 会检查路径是否存在
let base = path.canonicalize()?;
let paths = gather_asset_paths(&base);
Ok(generate_code(&paths, &base))
}
/// 递归地查找传递目录中的所有文件
fn gather_asset_paths(base: &Path) -> Vec<PathBuf> {
todo!()
}
/// 生成代码
fn generate_code(paths: &[PathBuf], base: &Path) -> String {
todo!()
}
让我们先来看一下gather_assets_path函数,我们将使用walkdir从传入的base目录中递归地抓取所有文件。这里使用了flatten(),它删除了嵌套迭代器。代码实现如下:
/// 递归地查找传递目录中的所有文件
fn gather_asset_paths(base: &Path) -> Vec<PathBuf> {
let mut paths = Vec::new();
for entry in WalkDir::new(base).into_iter().flatten() {
// 我们只关心文件,忽略目录
if entry.file_type().is_file() {
paths.push(entry.into_path())
}
}
paths
}
现在我们有了一个应该在二进制文件中包含的所有资源文件的列表。
接下来,我们需要从所有路径中去掉我们之前解析的base前缀,代码如下:
/// 将路径转换为适合的相对路径
fn keys(paths: &[PathBuf], base: &Path) -> Vec<String> {
let mut keys = Vec::new();
for path in paths {
if let Ok(key) = path.strip_prefix(base) {
keys.push(key.to_string_lossy().into()
}
}
keys
}
下面实现代码生成函数generate_code:
/// 生成代码
fn generate_code(paths: &[PathBuf], base: &Path) -> String {
let keys = keys(paths, base);
let values = paths.iter().map(|p| p.to_string_lossy());
// 双括号使其成为块表达式
let output = quote! {{
use ::asset_bundler::{Assets, phf::{self, phf_map}};
Assets::from(phf_map! {
#( #keys => include_str!(#values) ),*
})
}};
output.to_string()
}
在这里,我们使用了quote库的宏,它允许我们同时使用键和值两个集合。
我们刚刚制作了一个简单的资源捆绑器,现在是时候使用它了!我们将从创建一个新的example项目开始,以使用我们刚刚创建的两个库。
首先,修改根目录下的Cargo.toml文件:
[workspace]
members = ["codegen", "example"]
然后,我们创建example二进制文件并添加依赖项:
cargo new --bin example
cargo add asset-bundler --path . --package example
cargo add --build asset-bundler-codegen --path codegen --package example
touch example/build.rs
mkdir -p example/assets/scripts
让我们从构建脚本example/build.rs开始,我们需要调用前面创建的codegen函数来获取生成的代码。代码如下:
use std::path::Path;
fn main() {
let assets = Path::new("assets");
let codegen = match asset_bundler_codegen::codegen(assets) {
Ok(codegen) => codegen,
Err(err) => panic!("failed to generate asset bundler codegen: {err}"),
};
let out = std::env::var("OUT_DIR").unwrap();
let out = Path::new(&out).join("assets.rs");
std::fs::write(out, codegen.as_bytes()).unwrap();
}
我们最终将代码写入$OUT_DIR/assets。
我们需要创建一些资源,让我们假设这些资源是用于web服务器的,而这些文件是提供给浏览器的。运行以下命令创建它们:
echo -n "scripts/loader-a1b2c3.js" > example/assets/index.html
echo -n "scripts/dashboard-f0e9d8.js" > example/assets/scripts/loader-a1b2c3.js
echo -n "console.log('dashboard stuff')" > example/assets/scripts/dashboard-f0e9d8.js
然后在example/src/main.rs文件中,写入以下代码:
fn main() {
// 包含构建脚本创建的资源
let assets = include!(concat!(env!("OUT_DIR"), "/assets.rs"));
println!("-------->assets = {:?}", assets);
let index = assets.get("index.html").unwrap();
let loader = assets.get(index).unwrap();
let dashboard = assets.get(loader).unwrap();
assert_eq!(dashboard, "console.log('dashboard stuff')");
}
像Tauri框架就广泛地使用代码生成技术来执行代码注入、压缩和验证绑定的资源。动态代码生成是一个强大的工具,可以为Rust程序带来高级功能。
Copyright© 2013-2020
All Rights Reserved 京ICP备2023019179号-8