为现有的 Rust 项目(Tiny API 压缩图片)添加 WebAssembly 支持
本文中我们来看看如何为现有的 rust 项目添加 wasm 支持,当然这个”现有项目“指的是现在才开始创建的项目,主打一个从 0 到 1,看看整个过程中又会碰到哪些问题以及如何解决。
我们将新建一个 rust library,其提供了一个图片压缩函数,压缩功能将调用我们大概率都用到过的图片压缩网站 TinyPng 所提供的HTTP 接口来实现,我倒也尝试过使用一些 rust 的图片压缩库,但是压缩率确实不如 TinyPNG,由于它还提供了每个月 500 次的免费调用,很满足个人需求。
Rust 功能实现
现在我们先不关注 wasm 的相关内容,看看如何在 rust 里实现这个功能:
# 初始化项目
cargo new rs-image-compress --lib
# 使用 vscode 打开
code rs-image-compress
rust 中最常用的 http 请求库是 reqwest,这里我们也将使用它来调用接口,先安装相关的依赖包:
# 我们将使用同步版而非异步版的请求,并且开启 json 格式的支持
cargo add reqwest --features blocking,json
# reqwest 使用该库处理 json 内容,我们需要用到它
cargo add serde_json
然后在 lib.rs 中编写下述内容:
// lib.rs
use reqwest::blocking::Client;
use std::{fs::File, io::Write};
pub fn compress_image(file_path: &str, store_at: &str) -> Result<(), Box<dyn std::error::Error>> {
let file = File::open(file_path)?;
let client = Client::new();
// 向 tinypng 发送压缩请求
let res = client
.post("https://api.tinify.com/shrink")
// 此处的 api key 可前往 https://tinypng.com/developers 自行获取后替换
.basic_auth("api", Some("YOUR_API_KEY"))
// 请求体是我们的文件
.body(file)
.send()?;
// 将响应内容解析为 json 格式
let json = res.json::<serde_json::Value>()?;
// tinypng 会返回一个压缩后的图片地址
let url = json["output"]["url"].as_str().unwrap();
// 再次发送请求来下载压缩后的图片
let res = client.get(url).send()?;
// 将其保存到本地
let mut store_path = File::create(store_at)?;
let bytes = res.bytes()?;
store_path.write_all(&bytes)?;
Ok(())
}
上述代码涉及到了多个 rust 语言特性,若对 rust 了解不多看起来会略有费劲,对于不懂的地方就停下来网上查找吧,这也是学习的乐趣之一吧。
现在看看如何调用这个功能。由于我们初始化的是一个 lib(library) 项目,没有 main 入口函数,但不用担心要做什么额外配置,直接在 src 目录下新建一个 main.rs 文件就行了:
// main.rs
use rs_image_compress::compress_image;
fn main() {
// 请将你要压缩的图片放到项目根目录下
compress_image("./d.png", "d-compressed.png").unwrap();
}
现在使用 cargo run 命令运行,若一切正常则在根目录会得到压缩后的图片。
我们的项目便算是写好了,接下来可以看看如何给它添加 wasm 的支持。
添加 wasm 支持
浏览器环境
现在我们想要将这个功能提供给 web 浏览器环境,首先自然是要看看自己的核心逻辑,比如用到的第三方包对 wasm 的支持:
我们的核心用到的是 reqwest 这个包,从其文档上方的 Platform 下拉框里可以看到它是支持 wasm target 的。其次是我们的函数使用 std::fs 读写文件,但 wasm 本身并不支持 IO 相关操作,这些操作需要宿主环境来提供。
在 web 浏览器中,我们通过 <input type="file" 来选择文件,然后通过 onchange 事件获取到 File 对象,而 web-sys 可以使我们很方便的在 rust 里调用 web api,同时也支持获取 js 侧传过来的对象引用。
虽然 reqwest支持 wasm,但其 body() 并不支持 web_sys::File 数据类型,通过查看其函数签名可知它支持传入一个 u8 类型的数组,即最原始的字节数据。
因此现在我们只需要研究下如何将 web_sys::File 转成 raw data 就行了,通过查阅网上资料和 web_sus::File 的文档,我们找到了转换写法:
let js_buffer = JsFuture::from(file.array_buffer())
.await.unwrap()
.dyn_into::<ArrayBuffer>().unwrap();
let raw = Uint8Array::new(&js_buffer).to_vec();
上述代码中,file.array_buffer() 返回的是 Promise 类型,在 rust 里对应的数据结构是 JsFuture,这里涉及到了 rust 异步编程的一些知识点,但暂不必要深入研究,熟悉 js 的 async/await 那就能理解这里的写法。
rust 里使用 .await 获取从 js 角度来说的 Promise.resolve 的值,由于会获取失败,所以这里使用 unwrap() 来人为认为始终会成功,接着使用 dyn_into() 将值转换为具体的数据类型,接着又转换为了 Uint8Array ,Uint8Array 则提供了 to_vec() 会返回 rust 的数据类型。
这一连串的转换看似繁琐,倘若使用 js 代码来实现这里的获取 File 的 raw data 逻辑,会发现代码思路也是一样的。
既然能够转换成所需类型,表面技术上可行,可以继续下一步。现在看看如何调整我们的项目吧,浏览一番官方文档的"如何给 crate 添加 wasm 支持"进行初步的熟悉后,我们先需要安装相关依赖:
cargo add wasm_bindgen js-sys
cargo add web-sys --features File,Blob
# Promise/JsFuture 的支持需要使用此包
cargo add wasm-bindgen-futures
然后调整下我们的目录结构以便更加易读和可扩展:
-
新建
src/compress/file.rs来存放std::File的逻辑,将之前lib.rs的内容挪到这里 -
新建
src/compress/wasm.rs来存放 wasm 的逻辑,其内容如下:use reqwest::Client; use wasm_bindgen::prelude::*; use wasm_bindgen::JsCast; use wasm_bindgen_futures::{ js_sys::{ArrayBuffer, Uint8Array}, JsFuture, }; #[wasm_bindgen] pub async fn compress_image(file: &web_sys::File) -> web_sys::File { let js_buffer = JsFuture::from(file.array_buffer()) .await .unwrap() .dyn_into::<ArrayBuffer>() .unwrap(); let raw = Uint8Array::new(&js_buffer).to_vec(); let raw = request_buf(&raw).await; let js_buf = Uint8Array::from(&raw[..]); let bytes = js_sys::Array::new(); bytes.push(&js_buf); // File 参数一是个数组,可不能直接把 js_buf 传进去导致生成的文件内容不对.. // https://github.com/rustwasm/wasm-bindgen/issues/1693#issuecomment-926879272 web_sys::File::new_with_u8_array_sequence(&bytes, "compressed.png").unwrap() } async fn request_buf(buf: &Vec<u8>) -> Vec<u8> { let vec = buf.to_vec(); let client = Client::new(); let res = client .post("https://api.tinify.com/shrink") .basic_auth("api", Some("YOUR_API_KEY")) .body(vec) .send() .await .unwrap(); let json = res.json::<serde_json::Value>().await.unwrap(); let url = json["output"]["url"].as_str().unwrap(); let res = client.get(url).send().await.unwrap(); res.bytes().await.unwrap().to_vec() } -
新建
src/compress/mod.rs表示入口文件,其内容如下:pub mod file; pub mod wasm; -
修改
lib.rs:pub mod compress; -
修改
main.rs:fn main() { rs_image_compress::compress::file::compress_image("./d.png", "d-compressed.png").unwrap(); }
现在我们使用之前提到过的 wasm-pack 来编译吧,兴冲冲运行 wasm-pack build --target web 后发现有个报错,提示我们需要给 Cargo.toml 添加下述内容:
[lib]
crate-type = ["cdylib", "rlib"]
然后重新运行,又报错了 ”unresolved import reqwest::blocking“,从报错信息来看是把 file.rs 里的内容也编译了,但是 wasm target 并不支持 std::File 的内容,那么如果才能不让其被编译呢?这时候便体现出上述目录结构的好处了,我们只需要修改一下 mod.rs,使用 rust 提供的 cfg! 宏:
#[cfg(target_arch = "wasm32")]
pub mod wasm;
#[cfg(not(target_arch = "wasm32"))]
pub mod file;
顺便再调整下 main.rs:
fn main() {
#[cfg(not(target_arch = "wasm32"))]
rs_image_compress::compress::file::compress_image("./d.png", "d-compressed.png").unwrap();
}
然后重新执行 wasm-pack build --target web ,编译通过了!
在进行验证之前,下面是补充内容:
-
wasm-pack build时有可能会卡住,解决方法是: 1. 确保安装最新版本(如已安装则重新安装一遍便会自动装最新版), 2. 命令执行期间可能会下载其他依赖项,所以可能是网络卡顿导致,使用RUST_LOG=debug wasm-pack build xxx可以看到详细日志信息 -
上述配置了
#cfg后,会发现我们的wasm.rs代码不会触发 rust-analyzer 的检查了,此时需要手动在根目录下新建.cargo/config.toml文件并写入下述内容:[build] target = "wasm32-unknown-unknown"那么现在 rust-analyzer 会仅分析涉及到该 target 的代码了,如果需要编写其他 target 代码,则修改此处值即可。
-
通过在
Cargo.toml里给不同 target 指定不同的依赖项,而非全部写在一起,这样当我们编译非 wasm target 时便不会同时编译其不需要的依赖项了,对编译时间也是有所提升的:[dependencies] reqwest = { version = "0.11.22", features = ["blocking", "json"] } serde_json = "1.0.108" [target.'cfg(target_arch = "wasm32")'.dependencies] js-sys = "0.3.65" wasm-bindgen = "0.2.88" wasm-bindgen-futures = "0.4.38" web-sys = { version = "0.3.65", features = ["File", "Blob"] }
现在我们开始验证生成的 wasm 内容是否能正常在浏览器端使用,新建一个 index.html:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Document</title>
</head>
<input type="file" id="file" />
<body>
<script type="module">
import init, { compress_image } from "./pkg/rs_image_compress.js";
await init();
const $file = document.getElementById("file");
$file.addEventListener("change", async (ev) => {
const file = ev.target.files[0];
const f = await compress_image(file);
// 触发文件下载
const a = document.createElement("a");
a.href = window.URL.createObjectURL(f);
a.download = "right.png";
a.click();
});
</script>
</body>
</html>
然后使用 http 服务器打开该文件,在页面中我们选择一张图片,发现并没有文件下载,怎么回事?
打开控制台,竟然发现是 -- 跨域报错了,哈哈,这真是令人哭笑的一幕,原来 tinypng 的 api 并不支持跨域,可能也是因为请求里包含了 api key 吧,直接在前端调用并不安全。
难道 wasm 之路就此夭折了吗?当然不!我们可以写一个代理服务器,将 wasm 的调用放到服务端执行便能解决跨域问题了,毕竟没法要求 tinypng 去做调整,于是现在我们顺理成章的开始进行对 nodejs 环境的支持。
Nodejs 环境
当然为了文章的精简,写代理服务器的过程便省略了,现在我们的目标单纯是支持在 nodejs 环境里调用 wasm 实现图片的压缩。
由于 nodejs 环境里没有 web api,所以 web_sys 下的内容自然都是无法使用的,不过浏览器端和 node 端都是运行 js 的环境,自然都支持 js_sys 下的内容,具体点来说就是都 Uint8Array。
此时查看我们的 compress_image 函数,会发现它的封装是比较不妥的,将其改为下述的签名格式,让调用方传入 Uint8Array 格式的数据而非在工具库内部处理不同目标直接的差异,这才是更加漂亮的逻辑抽象与封装:
pub async fn compress_image(buf: &js_sys::Uint8Array) -> js_sys::Uint8Array {
// ...
}
我们暂不修移除原逻辑,在 wasm.rs 新增一个函数:
#[wasm_bindgen]
pub async fn compress_image_js(buf: &js_sys::Uint8Array) -> js_sys::Uint8Array {
let raw = request_buf(&buf.to_vec()).await;
Uint8Array::from(&raw[..])
}
现在我们运行 wasm-pack build --target nodejs 进行编译。
然后在根目录下新建一个 node-test.js 文件进行测试,其内容如下:
const fs = require("node:fs");
const wasm = require("./pkg");
const buf = fs.readFileSync("./d.png");
wasm
.compress_image_js(buf)
.then((buf) => {
fs.writeFileSync("./d-compressed.png", buf);
console.log("done");
})
.catch((err) => {
console.log(err);
});
运行 node node-test.js,会发现有的人能执行成功了,有的人就报错 RuntimeError: unreachable... 了,这是为何?
解释之前,先补充下如何让报错信息打印的更加完善,而非只有一个不明所以的 RuntimeError: unreachable... 信息:
-
我们需要安装
console_error_panic_hook,可手动在Cargo.toml里添加:[target.'cfg(target_arch = "wasm32")'.dependencies] # ... console_error_panic_hook = "0.1.7" -
然后编辑
wasm.rs,新增一个导出:#[wasm_bindgen] pub fn init_panic_hook() { console_error_panic_hook::set_once(); } -
重新编译 wasm
-
然后更新
node-test.js,在顶部调用该函数:const fs = require("node:fs"); const wasm = require("./pkg"); wasm.init_panic_hook(); // ...
现在重新执行 node node-test.js,会发现你的报错信息更加详细了。
查看报错信息会发现里面有个 "Headers is not defined ...",一番思索并查看了 reqwest 的代码后,才明白它内部是调用了 fetch 进行网络请求,但是对于 v18 以前的 nodejs 来说并不支持 fetch,而 v18 以后已经自动启用了该 api,所以不会报错。
那对于低版本要如何处理呢?或许可以 polyfill 一下 fetch,或许找一个支持 nodejs 环境的 rust 请求库,再或许尝试使用 node addon/napi 而非 wasm,此文便不再深入了。
小结
至此,我们的 wasm 改造之旅便告一段落了。整个过程中我们是基于 0 到 1 的思路折腾着,所以碰到了很多没有提前想到的问题,但也因为这一过程,我们在未来进行技术考量时,便多了一些经验与从容,也能够从 1 到 0 的去进行思考吧。