動態生成 Rust 代碼
本文討論的是從其他 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 代碼。
工具
讓我們討論一些用於代碼生成的工具,然後使用它們來實現我們自己的資源捆綁器。
-
quote crate:使我們能夠編寫轉換成數據的 Rust 代碼,然後生成語法正確的 Rust 代碼。這個 crate 在 Rust 生態系統中無處不在,用於代碼生成。
-
walkdir crate:提供了一種簡單的方法來遞歸地抓取目錄中的所有項
-
phf crate:使用完美的散列函數實現 HashMap
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 項目中,我們需要完成的功能如下:
-
將一個資源路徑傳遞給我們的函數,我們將其稱爲 base。
-
檢查 base 是否存在
-
遞歸地收集 base 中的所有文件路徑。
-
生成嵌入所有文件路徑的代碼。
最後要提到的是,我們希望通過傳遞一個相對路徑來獲取資源。我們想要的是 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 程序帶來高級功能。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/MMf09DVSuEVVPdbM7niY7A