Rust in Everything
基于 topcoat-asset 0.10.0

asset! 与 bundler

主线位置:发现主线的另一支。模块路由靠 inventory 在链接期收集(模块路由与发现);资产则是在编译好的二进制里留字节标记,由 CLI 在构建之后扫出来。本页从 asset! 展开走到浏览器拿到文件为止。

flowchart TB
  decl["asset!:一个 2048 字节的 static,以 TOPCOAT_ASSET 开头"] --> bin["cargo build:被用到的 static 留在二进制里"]
  bin --> scan["topcoat asset bundle:memmem 扫描二进制"]
  scan --> resolve["解析路径:./ 相对源文件,其余相对 CARGO_MANIFEST_DIR,http(s) 下载并缓存"]
  resolve --> hash["SHA-256:可选 checksum 校验,文件名取前 16 位"]
  hash --> out["assets/:带哈希的文件 + manifest.toml"]
  out --> load["运行时 AssetBundle::load:可执行文件旁的 assets/"]
  load --> route["每个文件一条 GET 路由,/_topcoat/assets/,immutable"]
  load --> url["view! 里的 Asset:按 id 查清单得到 URL"]

asset! 展开成什么

asset! 是一个 macro_rules!,不是过程宏:

macro_rules! asset {
    ($path:expr $(, $($ao:tt)*)?) => {{
        const PATH: &str = $path;
        const CRATE_NAME: &str = env!("CARGO_CRATE_NAME");
        const MANIFEST_DIR: &str = env!("CARGO_MANIFEST_DIR");
        const SOURCE_FILE: &str = file!();
        const OPTIONS: $crate::AssetOptions = $crate::asset_options!($($($ao)*)?);
        const ID: $crate::AssetId = $crate::AssetId::new(CRATE_NAME, SOURCE_FILE, PATH, &OPTIONS);

        pub static ENCODED_ASSET: [u8; $crate::ENCODED_ASSET_SIZE] = $crate::RawAsset::encode(
            ID,
            PATH,
            CRATE_NAME,
            MANIFEST_DIR,
            SOURCE_FILE,
            &OPTIONS,
        );

        $crate::Asset::new(ENCODED_ASSET.as_slice())
    }};
}

全部在 const 里完成:

  • AssetId:crate 名、源文件路径、asset! 里写的路径字符串、选项,四者的 64 位 FNV-1a(AssetId::new)。注意不含文件内容。
  • ENCODED_ASSET:一个固定 2048 字节的 static,依次写入 13 字节的前缀 TOPCOAT_ASSET、id、路径、crate 名、CARGO_MANIFEST_DIR、源文件、选项,剩下补零。
  • Asset:只是指向这块字节的 &'static [u8],Copy,可以放进 const。

宏不读文件。路径存在不存在、文件内容是什么,编译器都不管。「解析推迟到 bundler 运行时」就是这个意思。

前缀为什么要 XOR

源码里存的前缀是 SCRAMBLED_PREFIX,每个字节和 0xA7 异或过:

// "TOPCOAT_ASSET" XOR'd byte-by-byte with PREFIX_KEY. Storing the scrambled
// form means the literal marker only appears in binaries that actually carry
// an asset (where `asset_prefix` unscrambles it into the embedded payload),
// not in every binary that just links this crate.

asset_prefix() 是 const fn,只在 RawAsset::encode 里被调用,明文前缀只出现在真正声明了资产的 static 里。否则 topcoat-asset 自己的代码里就有一份明文,每个链接了它的二进制都会被扫出一条假记录。Asset::id() 读这块字节时套了一层 core::hint::black_box,防止优化器把读取折叠掉、顺手把整个 static 删了。

实验:二进制里有什么

一个声明了三个资产、只用了两个的程序(依赖照旧,topcoat =0.10.0 的 full):

const HELLO: Asset = asset!("./data/hello.txt");
#[allow(dead_code)]
const UNUSED: Asset = asset!("./data/unused.txt");
const REMOTE: Asset = asset!("http://127.0.0.1:38299/lib.js", rename: "lib");

#[page]
async fn home() -> Result<impl View> {
  Ok(view! {
    <a href=(HELLO)>"hello"</a>
    <script src=(REMOTE)></script>
  })
}

(REMOTE 指向本机起的一个 python3 -m http.server,用来观察下载。)debug 构建后用一段 Python 在二进制里找 TOPCOAT_ASSET,解出 id 和后面的字符串(本机路径换成了占位符):

ast 16495064 markers: 2
  id 2606606636602382821 ['./data/hello.txt', 'ast3', '<本机路径>', 'src/bin/ast.rs'] zero tail bytes: 1935
  id 17126378102906764844 ['http://127.0.0.1:38299/lib.js', 'ast3', '<本机路径>', 'src/bin/ast.rs'] zero tail bytes: 1916
rt 20135664 markers: 1
  id 2869131892692345930 ['browser/dist/index.js', 'topcoat_runtimeV', '<本机路径>', '<本机路径>', 'topcoat'] zero tail bytes: 1790
ctx 15420952 markers: 0
  • UNUSED 没进二进制:const 没被使用,里面的 static 也就没被实例化。文档说「unused handles may be removed during compilation」,debug 构建里就已经如此。
  • rt 是 05 章用了 topcoat::runtime::script() 的程序,带着运行时脚本那一条(声明在 topcoat-runtime crate 里);ctx(03 章)没用任何资产,一条都没有,明文前缀也找不到。
  • 每条记录 2048 字节,实际内容一百来字节,其余是零。
  • 记录里带着 CARGO_MANIFEST_DIR 的绝对路径(构建机上的项目目录、cargo registry 目录)。发布的二进制里会留下构建机的目录结构。

bundler

topcoat asset bundle 的流程(CLI 的 run_bundle):cargo build --message-format=json-diagnostic-rendered-ansi 找到可执行文件,整个读进内存,交给 topcoat-asset 的 Bundler::bundle(放在 spawn_blocking 里,因为它做阻塞的文件和网络 I/O):

  1. RawAsset::find_in_binary:memchr::memmem 找所有前缀,逐条解码。
  2. 每条记录 prepare,默认 8 个线程并行:
    • 路径是 http(s):// 就走缓存下载,否则按 resolved_path 解析:./、../ 开头相对调用 asset! 的源文件所在目录,其他相对路径相对 crate 的 CARGO_MANIFEST_DIR,绝对路径原样。
    • 读文件,算 SHA-256。有 checksum 选项就比对(只支持 sha256: 前缀)。
    • 文件名:<stem>-<SHA-256 前 16 位十六进制>.<ext>,rename、extension 选项可以改 stem 和扩展名。
    • Content-Type:content_type 选项,否则按扩展名用 mime_guess 猜。同一个输出文件名被两条记录用不同的类型声明,直接报错。
    • 和旧清单里同 id 的条目比较,哈希和文件名都没变、文件也在,就不重写。
  3. 旧清单里有、这次没有的文件删掉。
  4. 写 manifest.toml。
$ topcoat asset list --bin ast
<crate>/src/bin/data/hello.txt
http://127.0.0.1:38299/lib.js

$ topcoat asset bundle --bin ast -o assets-ast
bundled assets into assets-ast
version = 1

[[assets]]
id = 2606606636602382821
file = "hello-84ba19f79faacff0.txt"
hash = "84ba19f79faacff0cfa1b992693129dfbb0e18da60c7df0f51bf9a562d1c28f6"
content_type = "text/plain"

[[assets]]
id = 17126378102906764844
file = "lib-36acbfca6cbe78f7.js"
hash = "36acbfca6cbe78f75e622544fd0a00f10e53ed3535d8245a1d0403ec98a8a85a"
content_type = "text/javascript"

shasum -a 256 两个源文件,结果和 hash 一致,文件名里是前 16 位。本机 HTTP 服务器的日志里有一条 "GET /lib.js HTTP/1.1" 200。

远程资产的缓存

Cache::fetch 的缓存键是 URL 的 SHA-256 前 32 位加上 URL 里的扩展名,目录是 <cargo target>/topcoat/cache/assets/。实验里的文件名是 de6d51fa62b7b28328c7ba9d568fe5e4.js,和 printf 'http://127.0.0.1:38299/lib.js' | shasum -a 256 的前 32 位相同。下载先写到一个带进程号和序号的临时文件,完整了再 rename,所以半截下载不会被当成命中。默认超时:总时长 1 分钟、连接 10 秒(ureq)。

缓存只看 URL、不看内容:命中就不再请求。关掉本机 HTTP 服务器重新打包,照样成功。好处是离线也能重复打包;代价是 URL 里带 @latest 这类浮动版本时,缓存里是第一次下载的那份,换一台机器或清掉 target 目录,拿到的可能是另一份。要确定性,就在 asset! 上写 checksum。

失败的样子

const HELLO: Asset = asset!("./data/hello.txt", checksum: "sha256:0000000000000000000000000000000000000000000000000000000000000000");
failed to bundle assets: hash mismatch for asset at <crate>/src/bin/data/hello.txt: expected sha256:0000000000000000000000000000000000000000000000000000000000000000, got sha256:84ba19f79faacff0cfa1b992693129dfbb0e18da60c7df0f51bf9a562d1c28f6

远程地址连不上:

failed to bundle assets: failed to download asset from http://127.0.0.1:38298/missing.js: io: Connection refused

两种都让 CLI 以退出码 1 结束。多条记录同时出错时只报第一条。

运行时:AssetBundle

AssetBundle::load 只在一个地方找:std::env::current_exe() 的父目录下的 assets/manifest.toml。没有就是 NotFound。直接 cargo run、没跑过 bundler:

called `Result::unwrap()` on an `Err` value: Custom { kind: NotFound, error: "no asset bundle at <target>/debug/assets" }

CLI 默认也把包写到可执行文件旁边的 assets/,两者对得上。-o 写到别处,就要用 AssetBundle::load_dir(dir) 加载。部署时可执行文件和 assets/ 必须来自同一次构建:id 由路径和源文件算出,文件名由内容算出,换了构建,两边就可能对不上。

.assets(bundle) 把清单里每个文件注册成一条只接受 GET 的路由(AssetRoute),路径是 /_topcoat/assets/<文件名>:

let response = if let Ok(bytes) = tokio::fs::read(&self.file).await {
    let mut response = Response::new(Body::from(bytes));
    let headers = response.headers_mut();
    headers.insert(CONTENT_TYPE, self.content_type.clone());
    headers.insert(CACHE_CONTROL, CACHE_CONTROL_VALUE);
    response
} else {
    // 404
};
$ curl -i http://127.0.0.1:38204/_topcoat/assets/hello-84ba19f79faacff0.txt
HTTP/1.1 200 OK
content-type: text/plain
cache-control: public, max-age=31536000, immutable
content-length: 12

hello asset

每次请求都把整个文件读进内存,没有 ETag、没有 Range;文件名带内容哈希,immutable 让浏览器一年内不再验证,大多数请求根本不会到服务器。POST 同一个地址是 405。

在 view! 里,Asset 实现了属性值和节点的渲染:按 id 在清单里找到文件名,拼上前缀(默认 /_topcoat/assets,AssetConfig::hosted_at 可以换成 CDN 地址,这时不注册任何路由)。找不到就 panic:用一个缺了这条资产的包(05 章的 assets-rt)启动这个程序,请求页面得到 500,服务器打印:

failed to resolve asset Asset { id: AssetId(2606606636602382821) } in the asset catalog

panic 发生在渲染时,不是启动时。启动时 AssetBundle::load_dir 只读清单,不检查二进制里声明了哪些资产。

和 Dioxus 的 manganis 比

Dioxus 0.7 的 asset! 由 manganis 实现。按 dioxus 卷的源码地图,它「编译期把资源描述写进二进制的链接段,CLI 处理后回填最终 URL」。思路和 topcoat 一致:编译器负责「哪些资产被用到」,构建工具负责「把文件放到哪里」,都不需要 build.rs,都把「编译产物 + 资产目录」绑成一次构建的输出。

差别在 URL 什么时候确定。manganis 由 CLI 把最终 URL 写回二进制,运行时不需要再查表;topcoat 的二进制里只有 id,运行时读 manifest.toml 查出文件名,所以同一个二进制配不同的清单、或者用 hosted_at 换一个 CDN 前缀,都不用重新构建,代价就是二进制和清单必须配套,配错了到渲染时才 panic。

小结

  • asset! 在 const 里算出 id(crate、源文件、路径、选项的 FNV-1a,不含内容),把一块 2048 字节、以 TOPCOAT_ASSET 开头的记录放进 static;宏本身不读文件。
  • 前缀在源码里 XOR 混淆,只有真正声明了资产的二进制里才有明文;没被用到的 asset! 不进二进制,也就不进包。记录里带着构建机的绝对路径。
  • bundler 扫描二进制、解析路径、下载远程文件(按 URL 哈希缓存,不看内容)、算 SHA-256、按前 16 位命名、写 manifest.toml,并删掉不再需要的旧文件。
  • 运行时 AssetBundle::load 只找可执行文件旁的 assets/;每个文件一条 GET 路由,public, max-age=31536000, immutable;渲染一个清单里没有的 Asset 会 panic。

💬 关联讨论

发起讨论
加载中...