go WASM 技术在 Obsidian 中的 MVP 验证

背景

想在 Obsidian 插件中引入使用 WASM,主要是出于 Obsidian 插件使用 Typescript 这限制了一些系统调用特别是文件相关的操作,例如监控文件的状态根据文件状态触发相关的操作。当前 Obsidian API 支持的文件监控仅有如下几个接口:

/**
 * Called when a file is created.
 * This is also called when the vault is first loaded for each existing file
 * If you do not wish to receive create events on vault load, register your event handler inside {@link Workspace.onLayoutReady}.
 * @public
 */
on(name: 'create', callback: (file: TAbstractFile) => any, ctx?: any): EventRef;
/**
 * Called when a file is modified.
 * @public
 */
on(name: 'modify', callback: (file: TAbstractFile) => any, ctx?: any): EventRef;
/**
 * Called when a file is deleted.
 * @public
 */
on(name: 'delete', callback: (file: TAbstractFile) => any, ctx?: any): EventRef;
/**
 * Called when a file is renamed.
 * @public
 */
on(name: 'rename', callback: (file: TAbstractFile, oldPath: string) => any, ctx?: any): EventRef;

例如我需要根据文件修改的时间来更新 frontmatter 中元数据时,一个正在编辑的文档会无限触发on('modify') 事件,js 会消耗大量的资源处理这些事件,另外如果 vault 中还有其他的文件更新例如 dataview、templater 等常见插件引发的文件更新,会大大增加事件数量,这对于性能敏感的手机端甚至会造成严重发烫等问题。

由此可见 Typescript 进行系统底层相关的操作还是有非常大的劣势的,所以可以考虑在插件中采用更加擅长这类操作的语言来实现,例如 go、Rust。正是这样类似的 Web 性能相关的场景诞生了 WebAssembly 技术,微信、支付宝中每天用到的小程序就是类似的技术,所以不妨了解一下 WebAssembly 技术,尝试看看是否可以在 Obsidian 中使能 go WASM 技术。

WebAssembly 原理

计算机都是由电子元件组成,为了方便处理电子元件只存在开闭两种状态,对应着 0 和 1,也就是说计算机只认识 0 和 1,数据和逻辑都需要由 0 和 1 表示,也就是可以直接装载到计算机中运行的机器码。机器码可读性极差,因此人们通过高级语言 C、C++、Rust、Go 等编写再编译成机器码。由于不同的计算机 CPU 架构不同,机器码标准也有所差别,常见的 CPU 架构包括 x86、AMD64、ARM, 因此在由高级编程语言编译成可执行代码时需要指定目标架构。

WebAssembly 字节码是一种抹平了不同 CPU 架构的机器码,WebAssembly 字节码不能直接在任何一种 CPU 架构上运行,但由于非常接近机器码,可以非常快的被翻译为对应架构的机器码,因此 WebAssembly 运行速度和机器码接近。

相对于 JS,WebAssembly 有如下优点:

  • 体积小:由于浏览器运行时只加载编译成的字节码,一样的逻辑比用字符串描述的 JS 文件体积要小很多;
  • 加载快:由于文件体积小,再加上无需解释执行,WebAssembly 能更快的加载并实例化,减少运行前的等待时间;
  • 兼容性问题少:WebAssembly 是非常底层的字节码规范,制订好后很少变动,就算以后发生变化,也只需在从高级语言编译成字节码过程中做兼容。可能出现兼容性问题的地方在于 JS 和 WebAssembly 桥接的 JS 接口。

当代编译器已经可以做到不要让每个高级语言都去实现源码到不同平台机器码转换之类的重复工作,高级语言只需要生成底层虚拟机(LLVM)认识的中间语言(LLVM IR),LLVM 就能实现 LLVM IR 到不同 CPU 架构机器码的生成以及机器码编译时性能和大小优化。通常负责把高级语言翻译到 LLVM IR 的部分叫做编译器前端,把 LLVM IR 编译成各架构 CPU 对应机器码的部分叫做编译器后端,现在越来越多的高级编程语言选择 LLVM 作为后端,高级语言只需专注于如何提供开发效率更高的语法同时保持翻译到 LLVM IR 的程序执行性能。

LLVM 也实现了 LLVM IR 到 WebAssembly 字节码的编译功能,也就是说只要高级语言能转换成 LLVM IR,就能被编译成 WebAssembly 字节码,目前能编译成 WebAssembly 字节码的高级语言有:

  • AssemblyScript:语法和 TypeScript 一致,对前端来说学习成本低
  • C/C++:官方推荐的方式,详细使用见文档;
  • Rust:语法复杂、学习成本高,对前端来说可能会不适应。详细使用见文档;
  • Kotlin:语法和 Java、JS 相似,语言学习成本低,详细使用见文档;
  • Golang:语法简单学习成本低,详细使用见文档

MVP:Obsidian 中运行 go WASM

在 Obsidian 中运行 go WASM 的 MVP 验证源代码我提交到 GitHub 了,可以直接查看源码:Obsidian go wasm MVP

步骤一:准备 go 模块

准备一个简单的用于编译成 wasm 的 go 模块,主要的功能包括:

  1. 获取和输出文件的属性 getFileAttr()
  2. JS 可调用的函数 jsPI()
//go:build js && wasm
package main

import (
	"fmt"
	"math/rand"
	"os"
	"runtime"
	"syscall"
	"syscall/js"
	"time"
)

func getFileAttr(path string) (string, error) {
	fi, err := os.Stat(path)
	if err != nil {
		return "", err
	}

	return fmt.Sprintf("Mode: %s\nOwner: %d\nGroup: %d\nSize: %d\nModified: %s\nIsDir: %t",
		fi.Mode(),
		fi.Sys().(*syscall.Stat_t).Uid,
		fi.Sys().(*syscall.Stat_t).Gid,
		fi.Size(),
		fi.ModTime().String(),
		fi.IsDir(),
	), nil
}

func main() {
	// **ERROR**: fsnotify is not supported in WASM
	fmt.Println("testing...........")
	_, err := os.Lstat("/tmp/test.txt")
	if err != nil {
		// as expected, lstat is not supported in WASM
		fmt.Println(err.Error())
	}

	ret, err := getFileAttr("/tmp/test.txt")
	if err != nil {
		fmt.Println(err.Error())
	}
	fmt.Println(ret)

	js.Global().Set("jsPI", jsPI())

	js.Global().Call("alert", "this is an alerting!")
	v := js.Global().Get("app")
	fmt.Println(v.Get("title").String())
	fmt.Println(v.Call("getAppTitle", "").String())
	select {}
}

func pi(samples int) float64 {
	cpus := runtime.NumCPU()

	threadSamples := samples / cpus
	results := make(chan float64, cpus)

	for j := 0; j < cpus; j++ {
		go func() {
			var inside int
			r := rand.New(rand.NewSource(time.Now().UnixNano()))
			for i := 0; i < threadSamples; i++ {
				x, y := r.Float64(), r.Float64()

				if x*x+y*y <= 1 {
					inside++
				}
			}
			results <- float64(inside) / float64(threadSamples) * 4
		}()
	}

	var total float64
	for i := 0; i < cpus; i++ {
		total += <-results
	}

	return total / float64(cpus)
}

func jsPI() js.Func {
	return js.FuncOf(func(this js.Value, args []js.Value) any {
		if len(args) != 1 {
			return "Invalid no of arguments passed"
		}
		samples := args[0].Int()

		return pi(samples)
	})
}

步骤二:go wasm 编译

为了能够在浏览器或者 Obsidian 环境中运行 go wasm 文件,首先需要 go 提供的 JS 文件 wasm_exec.js,然后指定编译环境变量 GOOS=js GOARCH=wasm 对 go 模块进行编译,具体命令如下所示:

cp "$(go env GOROOT)/misc/wasm/wasm_exec.js" ./test/
cp "$(go env GOROOT)/misc/wasm/wasm_exec_node.js" ./test/

GOOS=js GOARCH=wasm go build -o ./bin/md-monitor.wasm

步骤三:JS 启动 wasm

可以现在 Obsidian console 环境中测试运行 go wasm:

  1. 导入 go wasm_exec 包,const exec_wasm = require('./test/wasm_exec.js');
  2. 初始化 go wasm 实例,const go = new Go();
  3. 读取 wasm 文件并初始化实例并运行,WebAssembly.instantiate()
  4. 调用 wasm 定义的函数进行测试,jsPI(3)
'use strict'
// Try to execute the following code in browser or electron(Obsidian) console
const exec_wasm = require('./test/wasm_exec.js');
const go = new Go();
const fs = require('fs');
let content = undefined;

fs.readFile("./bin/md-monitor.wasm", (err, data) => { content = data });

WebAssembly.instantiate(content, go.importObject).then((ret) => { go.run(ret.instance); });

// call exported function which define in go module.
jsPI(3);

步骤四:Obsidian 集成 wasm

只需要将步骤三集成到 Obsidian 中运行起来即可,有多种方法进行集成:

  • Obsidian 中运行 JS 脚本
  • 集成到 Obsidian 插件中

这需要根据 wasm 的复杂度判断采用哪种方法。

一些思考

Obsidian 运行 wasm 遇到的问题

步骤三种运行 go wasm 实例的时候有报错:

> WebAssembly.instantiate(content, go.importObject).then((ret) => { go.run(ret.instance); });

Error: not implemented

经过定位分析,发现 go wasm 不支持文件系统的相关操作:

if (!globalThis.fs) {
	let outputBuf = "";
	globalThis.fs = {
		constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
		writeSync(fd, buf) {
			outputBuf += decoder.decode(buf);
			const nl = outputBuf.lastIndexOf("\n");
			if (nl != -1) {
				console.log(outputBuf.substring(0, nl));
				outputBuf = outputBuf.substring(nl + 1);
			}
			return buf.length;
		},
		write(fd, buf, offset, length, position, callback) {
			if (offset !== 0 || length !== buf.length || position !== null) {
				callback(enosys());
				return;
			}
			const n = this.writeSync(fd, buf);
			callback(null, n);
		},
		chmod(path, mode, callback) { callback(enosys()); },
		chown(path, uid, gid, callback) { callback(enosys()); },
		close(fd, callback) { callback(enosys()); },
		fchmod(fd, mode, callback) { callback(enosys()); },
		fchown(fd, uid, gid, callback) { callback(enosys()); },
		fstat(fd, callback) { callback(enosys()); },
		fsync(fd, callback) { callback(null); },
		ftruncate(fd, length, callback) { callback(enosys()); },
		lchown(path, uid, gid, callback) { callback(enosys()); },
		link(path, link, callback) { callback(enosys()); },
		lstat(path, callback) { callback(enosys()); },
		mkdir(path, perm, callback) { callback(enosys()); },
		open(path, flags, mode, callback) { callback(enosys()); },
		read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
		readdir(path, callback) { callback(enosys()); },
		readlink(path, callback) { callback(enosys()); },
		rename(from, to, callback) { callback(enosys()); },
		rmdir(path, callback) { callback(enosys()); },
		stat(path, callback) { callback(enosys()); },
		symlink(path, link, callback) { callback(enosys()); },
		truncate(path, length, callback) { callback(enosys()); },
		unlink(path, callback) { callback(enosys()); },
		utimes(path, atime, mtime, callback) { callback(enosys()); },
	};
}

经过分析发现这不是 go wasm 的缺陷,it's an on purpose design 即 JS Security 设计引入的将浏览器的操作限制在 sandbox 中不允许操作本地文件,进程相关的 API 也有类似的问题。针对这个问题有别的方法 workaround,通过 sys/js 包中的 CopyBytesToGo 进行本地文件的读取,但是 lstat 这样的系统调用是无法做到的。

Javascript 运行 go 的技术选择

go wasm 目前还是一个相对比较新的技术,它颇具吸引力的地方就是安全、性能。go wasm 只是在 Obsidian 中发挥 go 语言特点的技术手段之一,我认为这样的技术手段还包括:

1. go 转换成 JS 代码

go 转换成 JS 代码目前有个非常热门的库:gopherjs,但是由于 JavaScript 没有并发的概念(Web Worker 除外,但它们隔离得太严格,无法用于 Goroutine)。因此,JavaScript 中的指令永远不会阻塞。阻塞调用会有效地冻结网页的响应能力,因此使用带有回调参数的调用。GopherJS 做了一些繁重的工作来解决这个限制:每当一条指令阻塞时(例如,与尚未准备好的通道通信),整个堆栈将展开(unwind)即所有函数返回并且 goroutine 将进入睡眠状态。然后,另一个准备恢复的 goroutine 会被选中并且其包含所有局部变量的堆栈将被恢复。总结来说,GopherJS 并不能将 go 与 JS 做完美的「翻译」。

2. 基于 gRPC/RPC 的方法

基于 gRPC/RPC 的方法,其实就是 fork go 进程以 gRPC/RPC 的方式进行通讯,只不过这个通讯仅限本地(或者也可以用 unix socket 进行约束),可以参考我的另外一个 MVP 验证,这里就不赘述了。

后续计划

go wasm in Obsidian 进行框架化,利用该框架解决 Obsidian 中高效监控文件状态变化并记录 metadata/frontmatter 的问题。

References

  1. A compiler from Go to JavaScript for running Go code in a browser
  2. JavaScript Security - Wikipedia
  3. How to read file from disk and pass it to WebAssembly using Go? - Stack Overflow