为现有的 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() 将值转换为具体的数据类型,接着又转换为了 Uint8ArrayUint8Array 则提供了 to_vec() 会返回 rust 的数据类型。

这一连串的转换看似繁琐,倘若使用 js 代码来实现这里的获取 Fileraw data 逻辑,会发现代码思路也是一样的。

既然能够转换成所需类型,表面技术上可行,可以继续下一步。现在看看如何调整我们的项目吧,浏览一番官方文档的"如何给 crate 添加 wasm 支持"进行初步的熟悉后,我们先需要安装相关依赖:

cargo add wasm_bindgen js-sys
cargo add web-sys --features File,Blob

# Promise/JsFuture 的支持需要使用此包
cargo add wasm-bindgen-futures

然后调整下我们的目录结构以便更加易读和可扩展:

现在我们使用之前提到过的 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 内容是否能正常在浏览器端使用,新建一个 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... 信息:

现在重新执行 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 的去进行思考吧。