事情的起因是偶然發現 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 映射的實現是將宏展開為:
match path {
path1 => file1,
path2 => file2,
...
}
而原始 embed 則展開為:
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 有一個特殊文件需要服務端動態替換:
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 的參數:
#[derive(RustEmbed)]
#[preserve_source = false] // 不保留原始文件
#[gzip = false] // 不開啟 gzip(僅開啟 br)
#[folder = "../../web/build"]
struct Asset;
然後在靜態資源訪問部分添加 Content-Encoding
頭:
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 |
效果立竿見影,壓縮效果顯著。