作者:木色 / 后期编辑:张汉东


飞书在做WASM的适配,分享下关系型数据库SQLite适配WASM的历程。

SQLite是一个跨平台的关系型数据库,广泛使用于客户端开发,飞书也使用SQLite作为数据持久化存储;同时为了方便上层使用,采用了diesel作为orm与SQLite进行交互,整体使用方式为:

  1. rust code -> diesel orm -> sqlite ffi

调用情况如图:

1.png

为了将SQLite移植到WEB上,我们需要做两部分内容:

  1. 将sqlite编译到wasm平台
  2. 封装wasm平台的跨模块调用接口给diesel使用

考虑到WEB上持久化存储机制的脆弱,以及业务形态考量,在WEB上并不需要做持久化,暂时只做一个内存中的关系型数据库;确定好这几个特点要求,我们SQLite的WASM移植之路开始了,Let’s Go!

WASM的工作模式

目前WASM实际有三种工作模式:Emscripten模式、WASI模式和无任何依赖的纯粹模式,在Rust语言中,分别对应wasm32-unknown-emscriptenwasm32-wasiwasm32-unknown-unknown三种编译目标;前两种模式的wasm产物分别需要宿主提供posix接口和wasi接口功能,最后一种模式完全没有外部依赖

对于这三种模式,对C/C++代码的友好度:Emscripten>Wasi>>Unknown

rust社区的生态基本是围绕着wasm32-unknown-unknownwasm32-wasi构建的,如wasm-bindgen工具等;不过考虑到unknown环境对外部依赖少,所以sdk中的rust代码我们就先确定了,优先使用wasm32-unknown-unknown模式,wasm32-wasi模式次之。而对于sqlite部分,我们则将三种wasm工作模式都尝试了:

Emscripten模式适配

Emscripten是用于帮助将C/C++代码编译到WASM目标格式的工具链,并且提供posix相关调用的模拟功能。

编译出emscripten产物

SQLite是一个C库,用emscripten很方便地就可以将SQLite编译为wasm,这个过程很简单,使用emcc就可以直接编译(可以参考:https://github.com/sql-js/sql.js/blob/master/Makefile)

第一步编译sqlite到wasm轻松搞定:我们将sqlite编译为一个emscripten target的wasm实例,由前端负责加载;然后在sdk侧,通过wasm的abi接口调用sqlite wasm实例提供的接口。

SQLite接口调用

但是到第二步,提供wasm的ffi给diesel时,我们遇到了麻烦:默认diesel使用的libsqlite-sys提供的是C abi的ffi,在native环境,SQLite库和ffi的使用者共享同一个内存空间,所以很多事情都比较容易处理,比如内存分配或者指针的直接操作等;但是如果sqlite编译成一个单独的wasm实例,ffi部分作为一个独立的wasm实例调用sqlite时,两个wasm实例在不同的内存空间,不能直接使用指针等依赖相同内存空间的操作,这就导致emscripten target下的ffi调用流程都需要全新实现。

使用类似动态库方式调用

具体表现为:先异步启动sqlite wasm实例,并将实例导出的接口挂在js全局对象window上,然后在rust中通过wasm-bindgen来绑定这些js接口。比如sqlite连接db创建db连接时传入db路径的操作,wasm环境需要调用wasm的内存分配函数分配内存,并写入数据:

  1. // native版操作
  2. pub fn establish(raw_database_url: &str) -> ConnectionResult<Self> {
  3. let mut conn_pointer = ptr::null_mut();
  4. let database_url = CString::new(raw_database_url.trim_start_matches("sqlite://"))?;
  5. let connection_status = unsafe { ffi::sqlite3_open(database_url.as_ptr(), &mut conn_pointer) };
  6. ...
  7. }
  8. // wasm版操作
  9. #[wasm_bindgen]
  10. extern "C" {
  11. // sqliteBindings是挂在window上的全局对象
  12. // allocateUTF8、stackAlloc是emscripten wasm导出的字符串、栈内存分配接口
  13. #[wasm_bindgen(js_namespace = sqliteBindings, js_name = allocateUTF8)]
  14. pub fn allocate_utf8(s: &str) -> *const i8;
  15. #[wasm_bindgen(js_namespace = sqliteBindings, js_name = stackAlloc)]
  16. pub fn stack_alloc_sqlite3(size: usize) -> *mut *mut ffi::sqlite3;
  17. }
  18. pub fn establish(raw_database_url: &str) -> ConnectionResult<Self> {
  19. let conn_pointer = stack_alloc_sqlite3(0);
  20. let database_url_ptr = allocate_utf8(raw_database_url.trim_start_matches("sqlite://"));
  21. let connection_status = unsafe { ffi::sqlite3_open(database_url_ptr, conn_pointer) };
  22. ...
  23. }

对于diesel中用到sqlite的地方都类似添加wasm支持,我们实现了在emscripten模式下工作的diesel+sqlite,其数据流动方式为:

2.png

这种工作模式下,sqlite是一个独立的wasm实例,其他的lark sdk代码是一个实例,实际运行时,先由js代码加载sqlite的wasm实例,再加载sdk的wasm实例,之后sdk中的diesel代码通过封装好的交互接口调用sqlite实例的功能。

这个工作模式下,每次sqlite的调用都涉及到两个wasm实例间数据的拷贝(不同wasm实例的内存空间是独立的),对于db这种高频率数据调用场景来说开销过大。

因此我们考虑:能否能将sqlite实例和其他sdk实例代码合并生成一个wasm实例?如果sqlite是一个emscripten模式的wasm,sdk其他代码就也必须打成emscripten模式,但是如前面所述,rust的wasm生态的核心是wasm32-unknown-unknownwasm32-wasi,所以如果想做到一个实例包含sdk代码和sqlite,就不能使用wasm32-unknown-emscripten模式。另外,在wasm32-wasiwasm32-unknown-unknown模式下,我们可以使用C的abi,也就是不需要如emscripten模式的wasm接口封装,可以类似native平台下的方式从rust调用sqlite。

WASI模式适配

在优化sdk和sqlite为一个实例的实践中,我们排除了Emscripten模式的使用;而在wasi和unknown模式中,wasi是一个对C/C++代码更加友好的平台,wasi标准中的接口和posix比较接近。

但是wasi目前一般是在非WEB平台执行的,想要在web上跑就需要提供wasi需要的对应功能的模拟,幸运的是,社区已经有了对应功能:https://github.com/wasmerio/wasmer-js/tree/master/packages/wasi

运行的宿主环境搞定了,我们来看sqlite本身;目前sqlite是没有提供wasi的官方支持的,但是sqlite有一个非常灵活的架构:

3.png

SQLite将所有平台相关的操作都封装在了OS对应模块中,并且通过VFS的方式抽象了平台功能使用,那么只要我们实现一个在WASI模式下工作的vfs即可

直接参考官方的实现https://www.sqlite.org/src/doc/trunk/src/test_demovfs.c, 编译时打开SQLITE_OS_OTHER选项,并链接到我们对应的C语言实现的vfs,配合wasmer-js的wasi模拟,终于,我们将sqlite和sdk其他代码都打成一个wasm32-wasi模式的wasm实例。

但是。。。

一次升级rust版本后,发现wasm-bindgen不再工作了。。。详情见:https://github.com/rustwasm/wasm-bindgen/issues/2471, 问题原因是2021年1月13日,rust合并了一个更改wasi模式下abi格式的提交,在这个之前rust下wasi模式和unknown模式的abi是一致的,但是这个提交之后,两者分叉了,而且wasm-bindgen官方也没有适配wasi的计划。。。

所以现在留给我们的只剩下一条路了:wasm32-unknown-unknown

Unknown模式适配

unknown模式是对C/C++最不友好的模式:没有头文件声明、没有字符串操作方法、没有fd相关方法甚至连malloc都没有。。。不过只有这一条路了,见山开山,遇海填海

有三个功能需要提供在Unknown模式下工作的实现:内存分配器、用到的C函数、VFS实现

内存分配器适配

C语言在wasm32-unknown-unknown模式下是没有提供malloc的封装的,但是rust里面有内存相关的封装,那么我们可以在rust中实现malloc方法供sqlite链接后调用:

  1. // 为了最小影响,更改了malloc的调用名
  2. #[cfg(all(target_arch = "wasm32", target_os = "unknown"))]
  3. mod allocator {
  4. use std::alloc::{alloc, dealloc, realloc as rs_realloc, Layout};
  5. #[no_mangle]
  6. pub unsafe fn sqlite_malloc(len: usize) -> *mut u8 {
  7. let align = std::mem::align_of::<usize>();
  8. let layout = Layout::from_size_align_unchecked(len, align);
  9. let ptr = alloc(layout);
  10. ptr
  11. }
  12. const SQLITE_PTR_SIZE: usize = 8;
  13. #[no_mangle]
  14. pub unsafe fn sqlite_free(ptr: *mut u8) -> i32 {
  15. let mut size_a = [0; SQLITE_PTR_SIZE];
  16. size_a.as_mut_ptr().copy_from(ptr, SQLITE_PTR_SIZE);
  17. let ptr_size: u64 = u64::from_le_bytes(size_a);
  18. let align = std::mem::align_of::<usize>();
  19. let layout = Layout::from_size_align_unchecked(ptr_size as usize, align);
  20. dealloc(ptr, layout);
  21. 0
  22. }
  23. #[no_mangle]
  24. pub unsafe fn sqlite_realloc(ptr: *mut u8, size: usize) -> *mut u8 {
  25. let align = std::mem::align_of::<usize>();
  26. let layout = Layout::from_size_align_unchecked(size, align);
  27. rs_realloc(ptr, layout, size)
  28. }
  29. }

libc功能提供

打开SQLITE_OS_OTHER开关后,因为不再使用系统的接口,对libc的依赖已经少了很多,但是还有几个基础非系统函数依赖:

  1. strcspn
  2. strcmp/strncmp
  3. strlen
  4. strchr/strrchr
  5. qsort

字符串的几个函数非常简单,直接自己实现就行;而对最后一个qsort函数,拷贝许可证宽松的三方实现就行

VFS实现

emscripten和wasi都是利用宿主提供的虚拟文件系统进行操作,在unknown模式下为了不增加外部依赖,我们可以直接在sdk代码内部提供一个memory vfs供sqlite使用。

实现vfs的核心是提供两个结构体实现:

  1. typedef struct sqlite3_vfs sqlite3_vfs;
  2. typedef void (*sqlite3_syscall_ptr)(void);
  3. struct sqlite3_vfs {
  4. int iVersion; /* Structure version number (currently 3) */
  5. int szOsFile; /* Size of subclassed sqlite3_file */
  6. int mxPathname; /* Maximum file pathname length */
  7. sqlite3_vfs *pNext; /* Next registered VFS */
  8. const char *zName; /* Name of this virtual file system */
  9. void *pAppData; /* Pointer to application-specific data */
  10. int (*xOpen)(sqlite3_vfs*, const char *zName, sqlite3_file*,
  11. int flags, int *pOutFlags);
  12. int (*xDelete)(sqlite3_vfs*, const char *zName, int syncDir);
  13. int (*xAccess)(sqlite3_vfs*, const char *zName, int flags, int *pResOut);
  14. int (*xFullPathname)(sqlite3_vfs*, const char *zName, int nOut, char *zOut);
  15. void *(*xDlOpen)(sqlite3_vfs*, const char *zFilename);
  16. void (*xDlError)(sqlite3_vfs*, int nByte, char *zErrMsg);
  17. void (*(*xDlSym)(sqlite3_vfs*,void*, const char *zSymbol))(void);
  18. void (*xDlClose)(sqlite3_vfs*, void*);
  19. int (*xRandomness)(sqlite3_vfs*, int nByte, char *zOut);
  20. int (*xSleep)(sqlite3_vfs*, int microseconds);
  21. int (*xCurrentTime)(sqlite3_vfs*, double*);
  22. int (*xGetLastError)(sqlite3_vfs*, int, char *);
  23. /*
  24. ** The methods above are in version 1 of the sqlite_vfs object
  25. ** definition. Those that follow are added in version 2 or later
  26. */
  27. int (*xCurrentTimeInt64)(sqlite3_vfs*, sqlite3_int64*);
  28. /*
  29. ** The methods above are in versions 1 and 2 of the sqlite_vfs object.
  30. ** Those below are for version 3 and greater.
  31. */
  32. int (*xSetSystemCall)(sqlite3_vfs*, const char *zName, sqlite3_syscall_ptr);
  33. sqlite3_syscall_ptr (*xGetSystemCall)(sqlite3_vfs*, const char *zName);
  34. const char *(*xNextSystemCall)(sqlite3_vfs*, const char *zName);
  35. /*
  36. ** The methods above are in versions 1 through 3 of the sqlite_vfs object.
  37. ** New fields may be appended in future versions. The iVersion
  38. ** value will increment whenever this happens.
  39. */
  40. };
  41. typedef struct sqlite3_io_methods sqlite3_io_methods;
  42. struct sqlite3_io_methods {
  43. int iVersion;
  44. int (*xClose)(sqlite3_file*);
  45. int (*xRead)(sqlite3_file*, void*, int iAmt, sqlite3_int64 iOfst);
  46. int (*xWrite)(sqlite3_file*, const void*, int iAmt, sqlite3_int64 iOfst);
  47. int (*xTruncate)(sqlite3_file*, sqlite3_int64 size);
  48. int (*xSync)(sqlite3_file*, int flags);
  49. int (*xFileSize)(sqlite3_file*, sqlite3_int64 *pSize);
  50. int (*xLock)(sqlite3_file*, int);
  51. int (*xUnlock)(sqlite3_file*, int);
  52. int (*xCheckReservedLock)(sqlite3_file*, int *pResOut);
  53. int (*xFileControl)(sqlite3_file*, int op, void *pArg);
  54. int (*xSectorSize)(sqlite3_file*);
  55. int (*xDeviceCharacteristics)(sqlite3_file*);
  56. /* Methods above are valid for version 1 */
  57. int (*xShmMap)(sqlite3_file*, int iPg, int pgsz, int, void volatile**);
  58. int (*xShmLock)(sqlite3_file*, int offset, int n, int flags);
  59. void (*xShmBarrier)(sqlite3_file*);
  60. int (*xShmUnmap)(sqlite3_file*, int deleteFlag);
  61. /* Methods above are valid for version 2 */
  62. int (*xFetch)(sqlite3_file*, sqlite3_int64 iOfst, int iAmt, void **pp);
  63. int (*xUnfetch)(sqlite3_file*, sqlite3_int64 iOfst, void *p);
  64. /* Methods above are valid for version 3 */
  65. /* Additional methods may be added in future releases */
  66. };

使用rust实现vfs

实现一个memvfs,至少需要一个可动态调整的容器;而C语言官方没有这种容器,如果要使用C语言实现memvfs,那只能自己实现一个类似的HashMap或者LinkedList,稍显麻烦;所以这块逻辑也用rust实现了。

VFS绑定

在rust代码中,提供一个sqlite3_os_init的方法出来,在和sqlite链接时,会自动链接到这个函数

  1. #[no_mangle]
  2. pub unsafe fn sqlite3_os_init() -> std::os::raw::c_int {
  3. let mut mem_vfs = Box::new(super::memvfs::get_mem_vfs());
  4. let mem_vfs_ptr: *mut crate::sqlite3_vfs = mem_vfs.as_mut();
  5. let rc = crate::sqlite3_vfs_register(mem_vfs_ptr, 1);
  6. debug!("sqlite3 vfs register result: {}", rc);
  7. std::mem::forget(mem_vfs);
  8. rc
  9. }

内存数据存储容器

因为要支持多个路径,所以最简单的实现就是提供一个HashMap,使用路径作为key:

  1. struct Node {
  2. size: usize,
  3. data: Vec<u8>,
  4. }
  5. lazy_static! {
  6. static ref FS: RwLock<HashMap<String, Arc<RwLock<Node>>>> = RwLock::new(HashMap::new());
  7. }

数据读写接口:

  1. fn copy_out(&self, dst: *mut raw::c_void, offset: isize, count: usize) -> Option<()> {
  2. if self.size < offset as usize + count {
  3. log::trace!("handle invalid input offset");
  4. return None;
  5. }
  6. let ptr = self.data.as_ptr();
  7. let dst = dst as *mut u8;
  8. unsafe {
  9. let ptr = ptr.offset(offset);
  10. ptr.copy_to(dst, count);
  11. }
  12. Some(())
  13. }
  14. fn write_in(&mut self, src: *const raw::c_void, offset: isize, count: usize) {
  15. let new_end = offset as usize + count;
  16. // 这里注意要根据传入的offset做扩容
  17. let count_extend: isize = new_end as isize - self.data.len() as isize;
  18. if count_extend > 0 {
  19. self.data.extend(vec![0; count_extend as usize]);
  20. }
  21. if new_end > self.size {
  22. self.size = new_end;
  23. }
  24. let ptr = self.data.as_mut_ptr();
  25. unsafe {
  26. let ptr = ptr.offset(offset);
  27. ptr.copy_from(src as *const u8, count);
  28. }
  29. }

VFS实现

sqlite3_vfsxOpen方法实现中注册对应自定义的sqlite3_io_methods

借助上述的工作,我们最终将sqlite编译为wasm32-unknown-unknown模式下的wasm文件,同时上层使用可以直接复用diesel,做到业务代码不用改动。

至此,lark sdk在web上的工作模式为:

4.png

整体工作模式再次和native平台对齐了,无外部依赖,查询时也不需要wasm实例之间的数据拷贝。