Noz Wu

sqlite-wasm-rs 简介

项目地址https://github.com/Spxg/sqlite-wasm-rs

SQLite 有很好的可移植性,它可以跑在绝大部分的平台上,包括 Web。官方开发了 sqlite-wasm, 它基于 wasm32-unknown-emscripten,提供了 JS APIs 给 Web 程序使用,还有持久化的 VFS。

Rust 这边有 wasm32-unknown-unknown 目标,配合 wasm-bindgen 可以让 Rust 程序跑在 Web 上。但是多年过去,SQLite 还没有被编译到这个目标上,主要有两个原因:

  1. 它没有 C 标准库和头文件
  2. 它的 CABI 不是标准的(特指 Rust 的目标,而不是 LLVM)

第一点意味着我们不能在没有任何工作的情况下,直接使用 cc 来编译 SQLite,这是主要要解决的问题。第二点对 SQLite 没有影响,但是它引入了很多不确定性。

好在从 1.89.0 开始,它的 CABI 是标准的了,现在我们可以放心的与 C 进行交互。

补全头文件

为了编译 SQLite,我们需要补全编译需要的头文件,现在编译就像这样:

$ clang sqlite3.c -target wasm32-unknown-unknown
sqlite3.c:15667:10: fatal error: 'stdio.h' file not found
 15667 | #include <stdio.h>
       |          ^~~~~~~~~
1 error generated.

好消息由于 ABI 兼容,我们可以去 emscripten 去拷贝所需的 musl 源码。不过在此之前,我们需要设置 SQLITE_OS_OTHER=1SQLITE_THREADSAFE=0 编译参数,前者是将 SQLite 编译到自定义的平台上,需要自己实现 VFS,后者是关闭了多线程支持。这两个选项使得 SQLite 对符号的依赖达到最小,比如不再需要 pthread。

补全符号

补全头文件后,编译就能通过了,但是还缺少很多必要的符号,如 strcmp。摆在我们面前有两个选择,一是用 Rust 实现它们,二是继续拷贝 musl 的源码。我选择了后者,musl 的实现久经考验,无疑是最好的选择,用 Rust 实现会存在一些问题,因为实现它需要声明一个 extern "C" 的符号:

#[unsafe(no_mangle)]
unsafe extern "C" strcmp(s1: *const c_char, s2: *const c_char) -> c_int;

这样编译成 wasm 会导致 strcmp 被无脑导出,我希望最终的产物是干净的,没有多余的外部符号。

不过还有一些符号是需要用 Rust 和 wasm-bindgen 实现的,如 mallocfreelocaltime

重命名符号

实现完符号后,我们还需要重命名这些符号(灵感来自 zstd-sys),比如将 strcmp 重命名成了 rust_sqlite_wasm_strcmp。因为 wasm32-unknown-unknown 目标并没有 C 标准库,也就是说如果有另一个库也需要和 C 交互,它大概率也需要实现这些符号,如果大家都没有进行符号重命名,一起编译,最后链接则会发生冲突。

重命名符号很简单,我们只需要引入一个全局的头文件,如 wasm-shim.h

#define strcmp rust_sqlite_wasm_strcmp
int rust_sqlite_wasm_strcmp(const char *l, const char *r);

然后用 clang -include wasm-shim.h 编译 SQLite,他的作用是在编译每个源文件之前,先自动包含我们声明的头文件,宏就能将所有符号重命名。

VFS 实现

我们编译开启了 SQLITE_OS_OTHER=1,意味着我们需要实现自己的 VFS 并声明 sqlite3_os_init 符号来初始化它。对于 Web,FileSystemSyncAccessHandle 是最好的选择,它能够同步的读写文件,不足的是创建文件是一个异步操作,这里不做过多介绍,详细可以参考 SQLite 官方文档。总之,持久化存储有各种各样的环境限制,其中 opfs-sahpool 在我看来是最优解,因此我将它移植到了 Rust 实现。

唯一没有环境限制的则是 Memory VFS,顾名思义就是将文件存在内存中,它有超高的 IO 性能,它的持久化可以通过导出和导入数据库来解决,但是不能写入大量数据,因为它受网页内存限制。我根据 SQLite 的特性,设计了一个结构,减少内存分配:SQLite 的对于 main 数据库的读写是按照 PAGE_SIZE 读写的(除了第一次读 100 字节来获取 HEADER 数据),我们可以在他第一次写的时候确定写的块的大小, 然后按块进行读写:

pub struct MemChunksFile {
    chunks: Vec<Vec<u8>>,
    chunk_size: Option<usize>,
    file_size: usize,
}

impl MemChunksFile {
    fn read(&self, buf: &mut [u8], offset: usize) {
        if let Some(chunk_size) = self.chunk_size {
            buf.copy_from_slice(&self.chunks[offset / chunk_size]);
        } else {
            buf.fill(0);
        }
    }

    fn write(&mut self, buf: &[u8], offset: usize) {
        let chunk_size = if let Some(chunk_size) = self.chunk_size {
            chunk_size
        } else {
            let size = buf.len();
            self.chunk_size = Some(size);
            size
        };
        for _ in self.chunks.len()..offset / chunk_size {
            self.chunks.push(vec![0; chunk_size]);
        }
        if let Some(buffer) = self.chunks.get_mut(offset / chunk_size) {
            buffer.copy_from_slice(buf);
        } else {
            self.chunks.push(buf.to_vec());
        }
    }
}

相较于直接使用 Vec<u8> ,它不需要在空间不足时额外的内存分配,这在大数据写入时是灾难,当已经写入 1G 的数据时发现空间不够用,则会开辟一个新的通常大于等于 2G 的空间,并将数据拷贝过去。考虑到目前 WASM 只有 memory.grow 指令,没有 memory.shrink 这样的指令,这会导致内存无限增长,一般网页内存上限是 4G(wasm32 寻址最大也才 4G),网页很容易就会崩溃退出。

实现 VFS 要数十个接口,还要处理各种边界条件,于是我抽象了方法,现在可以方便的实现 VFS。

总结

libsqlite3-sys 一样, sqlite-wasm-rs 提供了 SQLite 的 binding,它已经集成到 dieselrusqlite 中,这意味着可以在 Web 上开箱即用 SQLite,毕竟直接使用 C API 还是有点繁琐。

我还写了个类似 RustPlayground 的网站,你可以直接在这里尝试(不是个截图):

#wasm #sqlite

Reply to this post by email ↪