動態生成 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 代碼。

工具

讓我們討論一些用於代碼生成的工具,然後使用它們來實現我們自己的資源捆綁器。

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 程序帶來高級功能。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/MMf09DVSuEVVPdbM7niY7A