记一次对 Rust Embed 压缩的探索

事情的起因是偶然发现 bili-sync 的编译产物二进制过大，达到了惊人的 26M。由于我前段时间使用 Rust Embed（以下简称 embed） 将前端生成的静态文件打包到二进制中，自然怀疑是前端文件过大导致的。经过简单排查，发现自己编写的前端占用不到 1M，而嵌入的 Swagger-UI 却占用了 10+M。不过无论占比如何，这两者都是通过 embed 打包进二进制程序的，因此需要探索 embed 的压缩方案。

embed 官方提供的压缩

查看 embed 的官方 README，可以发现它提供了 compression feature flag：

compression: Compress each file when embedding into the binary. Compression is done via include-flate.

那么问题到此解决了吗？并非如此。查看相关说明，embed 的 compression 是通过 include-flate 实现的，我们来看看 include-flate 的介绍：

A variant of include_bytes!/include_str! with compile-time deflation and runtime lazy inflation.
一个带有编译时压缩与运行时懒解压的 include_bytes! / include_str! 变体。

我们知道，像 embed 这类嵌入方案本质上都是对 include_bytes! / include_str! 的封装，这些宏在编译时展开后会将文件数据转换为静态数据结构包含在二进制文件中。include-flate 是这两者的变体，从介绍可以推测其运行原理：

编译时将文件内容压缩，使用 include_bytes! 存储压缩后的数据；

运行时访问，如果文件已解压则直接返回，否则取出压缩数据，解压后缓存并返回。

这种方案本质上是 "透明压缩"，向外界提供与 include_bytes! 相同的 API，使用者无需感知内部的解压过程。但代价是内存浪费。

程序运行时既需要存储二进制中的压缩数据，又需要在文件首次访问时存储解压出的原始数据。对比未压缩方案：

程序体积：原始数据 → 压缩后数据（减小）

内存占用：原始数据 → 原始数据 + 压缩后数据（增加）

虽然我想压缩程序体积，但并不想浪费更多内存。要想既减小程序体积又缩小内存占用，需要寻找新的解决方案。

Content-Encoding

众所周知，HTTP 本身支持对响应体进行压缩，并提供 Content-Encoding 头来标识采用的压缩算法。考虑到使用 embed 的场景是托管静态文件，我们可以预先压缩静态文件，程序中仅打包压缩后的文件，当浏览器请求时直接返回压缩文件并设置相应的 Content-Encoding 头，让浏览器自行处理解压。这样就能两全其美，既缩小程序体积，又减少内存占用。

最初我想 fork 一份 embed 进行修改，但一堆裸露的 cfg! 判断与宏拼接让我萌生退意：

幸运的是，经过搜索我发现了一个与我需求类似的项目：rust-embed-for-web。该项目在 embed 基础上预先打包 gzip 和 br 两种压缩格式的文件，原始文件、gzip 压缩、br 压缩分别通过 .data()、.data_gzip()、.data_br() 访问，其中 .data() 必定存在，另外两个返回 Option 可通过宏参数控制。可以看出该项目同样会占用额外空间，只是采用预编译方式优化静态文件传输。要达到节约空间的目的，只需将 .data() 的返回值也改为 Option，并修改过程宏以支持是否存储源文件的配置。

这个项目的代码比 embed 清晰许多，修改起来非常方便。在修改过程中我还顺便用 enum_dispatch 将动态派发替换为静态派发，应该会带来一些性能提升，具体变更可查看这个提交。

一点题外话：发现这个项目对 path => file 映射的实现是将宏展开为：

Copy
match path {
path1 => file1,
path2 => file2,
...
}

而原始 embed 则展开为：

Copy
const ENTRIES: &'static [(&'static str, EmbeddedFile)] = [(path1, file1), (path2, file2), ...];
let position = ENTRIES.binary_search_by_key(&path.as_str(), |entry| entry.0);
position.ok().map(|idx| ENTRIES[idx].1)

我不太了解 match 语句的具体匹配机制，不确定哪种方式性能更好，如果有大佬读到可以在评论中指点一二。

一个例外

细心的朋友应该注意到，我上面的改动除了支持 "是否存储源文件" 开关外，还添加了一个 "except" 条件。这是因为 Swagger-UI 有一个特殊文件需要服务端动态替换：

Copy
pub fn serve<'a>(
path: &str,
config: Arc<Config<'a>>,
) -> Result<Option<SwaggerFile<'a>>, Box<dyn Error>> {
let mut file_path = path;

if file_path.is_empty() || file_path == "/" {
file_path = "index.html";
}

if let Some(file) = SwaggerUiDist::get(file_path) {
let mut bytes = file.data;
// 罪魁祸首 "swagger-initializer.js"，可恶啊！
if file_path == "swagger-initializer.js" {
let mut file = match String::from_utf8(bytes.to_vec()) {
Ok(file) => file,
Err(error) => return Err(Box::new(error)),
};

file = format_config(config.as_ref(), file)?;

if let Some(oauth) = &config.oauth {
match oauth::format_swagger_config(oauth, file) {
Ok(oauth_file) => file = oauth_file,
Err(error) => return Err(Box::new(error)),
}
}

bytes = Cow::Owned(file.as_bytes().to_vec())
};

Ok(Some(SwaggerFile {
bytes,
content_type: mime_guess::from_path(file_path)
.first_or_octet_stream()
.to_string(),
}))
} else {
Ok(None)
}
}

由于 swagger-initializer.js 需要服务端实时替换模板内容，必须保留原始文件。为了兼容这个特殊文件，只能添加一下 preserve_source_except 处理。

合并回 bili-sync

完成上述工作后，就可以将改动合并回 bili-sync 了。首先修改 Asset 的参数：

Copy
#[derive(RustEmbed)]
#[preserve_source = false] // 不保留原始文件
#[gzip = false] // 不开启 gzip（仅开启 br）
#[folder = "../../web/build"]
struct Asset;

然后在静态资源访问部分添加 Content-Encoding 头：

Copy
let Some(content) = Asset::get(path) else {
return (StatusCode::NOT_FOUND, "404 Not Found").into_response();
};
Response::builder()
.status(StatusCode::OK)
.header(
header::CONTENT_TYPE,
content.mime_type().as_deref().unwrap_or("application/octet-stream"),
)
.header(header::CONTENT_ENCODING, "br")
// safety: `RustEmbed` will always generate br-compressed files if the feature is enabled
.body(Body::from(content.data_br().unwrap()))
.unwrap_or_else(|_| {
return (StatusCode::INTERNAL_SERVER_ERROR, "500 Internal Server Error").into_response();
})

经过多次编译测试，结果如下：

| 不包含前端 | 包含 Swagger-UI | 包含 Swagger-UI + 前端
压缩前 | 13M | 25M | 26M
压缩后 | 13M | - | 16M

效果立竿见影，压缩效果显著。