为现有的 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 的去进行思考吧。