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-runtimecrate 里);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):
RawAsset::find_in_binary:memchr::memmem找所有前缀,逐条解码。- 每条记录
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 的条目比较,哈希和文件名都没变、文件也在,就不重写。
- 路径是
- 旧清单里有、这次没有的文件删掉。
- 写
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。