WebAssembly Component Model介绍及如何与go语言结合

Posted by     "Taction" on Monday, August 21, 2023

TL;DR 本文介绍使用WebAssembly Component Model定义的ABI,如何实现其go语言的sdk,包括guest和host的sdk。

基本介绍

WebAssembly 组件模型是一个WebAssembly 提案,旨在通过定义模块在应用程序或库中如何组合来构建核心 WebAssembly 标准。简单来说这是一个如何定义组件的标准。WASI现在正在使用Component Model所定义的格式来定义自己的ABI接口。由于这是一个WebAssembly社区关于组件标准的提案,所以它可能更具有生命力,成为一个最终标准。本文就主要介绍它是什么以及在go语言中该如何使用。

从开发WebAssembly说起

WebAssembly(Wasm)是一种通用字节码技术,可以通过其他编程语言(如 Go、Rust、C/C++ 等)的程序代码编译为字节码程序。WebAssembly 天生具备安全、可移植、高效率,轻量化等特点,所以非常适于应用安全沙箱场景。除了浏览器领域,WebAssembly 还得到了容器、函数计算、IoT / 边缘计算等社区的广泛关注。

Wasm虚拟机提供沙箱和内存隔离机制,允许Wasm程序调用运行时(Host)或者其他Wasm模块提供的函数,可以类比于正常程序的系统调用。我们可以将Wasm大致分为两个部分:Host也就是其运行时,Guest也就是Wasm二进制程序。

当我们把Wasm看作是在虚拟机中运行时纯逻辑函数时,它需要跟操作系统或者其他功能函数交互,比如网络或者存储。我们假定这些函数都由Host提供,那么我们就需要定义这些函数的ABI,使得Guest能够调用这些函数,反之亦然。而且Wasm只支持4种基本类型int32、int64、float32、float64,所有的数据结构都需要转化为这4种基本类型。

当我们需要自己定义一组ABI函数的时候,一个典型的开发流程是,首先定义ABI格式,然后分别实现Guest和host的sdk,在sdk中实现方法调用以及参数的序列化。通常对于guest sdk来说需要考虑实现多种语言的SDK,因为Wasm程序支持从多种语言编译而来。

目标

定义一个可移植的、加载和运行时高效的二进制格式,用于从WebAssembly核心模块构建的单独编译的组件,这些组件支持可移植的、跨语言组合。

支持可移植、可虚拟化、可静态分析、能力安全、语言无关的接口的定义,尤其是WASI定义的接口。

维护和提升WebAssembly独特的价值主张:

  • 语言中立:避免将组件模型偏向于仅一种语言或语言族。
  • 嵌入性:设计要嵌入到各种主机执行环境中的组件,包括浏览器、服务器、中介、小型设备和数据密集型系统。
  • 可优化性:最大限度地提高Aahe-of-Time编译器可用的静态信息,以最小化实例化和启动的成本。
  • Web平台集成:通过扩展现有的WebAssembly集成点,确保组件能够在浏览器中获得原生支持:JS API、Web API和ESM集成。在实现本机支持之前,确保组件可以通过提前编译到当前支持的浏览器功能,在浏览器中进行多变量填充。

WebAssembly规范定义了一种体系结构、平台和语言无关的可执行代码格式。WebAssembly模块可以从主机运行时导入函数、全局变量等,也可以将这些项导出到主机。然而,在运行时没有标准的方式来合并模块,也没有标准的方式来跨模块边界传递高级类型(例如字符串和map)。

实际上,缺乏模块组合意味着模块必须在构建时静态链接,而不能在运行时动态链接,并且它们不能以标准、便携的方式与其他模块通信。组件模型通过在核心WebAssembly之上主要提供了以下功能或特点:

  • 接口类型:一种语言无关的方式,用于基于高级类型(如字符串、记录、集合等)定义模块接口。
  • 规范ABI,它指定高级类型如何以核心WebAssembly的低级类型来表示。
  • 模块和组件链接:一种动态组合模块成为组件的机制。这些组件本身可以组合在一起形成更高级的组件。
  • 语言中立和跨语言:用于从WebAssembly核心模块构建的单独编译的组件,这些组件支持可移植的、跨语言组合。避免将组件模型偏向于仅一种语言或语言族。

这些功能共同允许在没有静态链接的重复和安全漏洞的情况下打包和重用代码。这种重用特别适用于多租户云计算,其中成千上万个模块的代码重复会增加昂贵的存储和内存开销。

组件模型的接口描述语言 - WIT

WIT(Wasm Interface Type) 是WebAssembly组件模型的一种IDL(接口描述语言),其主要作用在于以下两方面:

  • WIT是一种对开发人员友好的格式,用于描述组件的导入和导出。它易于读写,并为从Guest语言生成组件以及在Host语言使用组件提供了基础。
  • WIT包是组件生态系统中共享类型和定义的基础。开发者可以在生成组件时从其他WIT包导入类型,发布表示主机嵌入的WIT包,或协作定义跨平台共享API集的WIT定义。

WIT包是在同一目录中的文件中定义的WIT interfaceworld 的集合,这些文件都使用文件扩展名 wit ,例如 foo.wit 。文件被编码为有效的utf-8字节。类型可以在包内的接口之间导入,也可以通过ID从其他包导入。

本文不对wit语法结构进行过多介绍,在后文中会以案例来介绍使用wit定义的接口如何与go语言结合。如果你有兴趣了解更多可以查看

在go语言中如何使用

我们可以通过wit-bindgen来快捷的生成guest sdk的代码,目前支持c\c++、rust、java、go等语言。你可以通过命令cargo install --git https://github.com/bytecodealliance/wit-bindgen wit-bindgen-cli进行安装,或者到releases中手动下载安装。

我们先通过一个简单的例子,它具有两个导出函数,其中swap将输入的两个数字交换次序输出,add将输入的两个数字相加(这是wasm代码需要实现的功能函数);以及一个导入log函数打印日志(这是host代码需要实现的功能函数,以供wasm代码进行调用)。

package learnwit:basic

world demo {
  // 导出swap函数,交换入参并返回。wit不限制返回值数量,目前针对wasm单返回值限制,采用的是将所有返回值写入某块内存,返回其地址
  export swap: func(x: u32, y: u32) -> (a: u32, b: u32)
  // 导出add函数,将参数x和y相加
  export add: func(x: u32, y: u32) -> u32
  // 导入log函数
  import host: interface {
    log: func(param: string)
  }
}

我们创建文件wit/demo.wit并将以上内容写入。然后执行wit-bindgen tiny-go --out-dir guest/demo wit,我们就可以看到在guest/demo文件夹下创建了demo.c demo.go demo.h demo_component_type.o这几个文件。

Guest code

接下来我们实现自己的wasm代码,在guest文件夹下创建demo.go文件并拷贝以下内容。然后执行go mod init guest && go mod tidy。接下来执行tinygo build -o main.wasm --no-debug -target=wasi demo.go就可以将其编译为wasm。

package main

import (
	"guest/demo"
)

func init() {
	a := Demo{}
	demo.SetDemo(a)
}

type Demo struct {
}

func (e Demo) Add(x uint32, y uint32) uint32 {
	return x + y
}

func (e Demo) Swap(x uint32, y uint32) (uint32, uint32) {
	demo.HostLog("wasm function Swap is called")
	return y, x
}

//go:generate wit-bindgen tiny-go ../wit --out-dir=demo
func main() {}

这个文件非常简单,我们实现了在guest/demo中定义的Demo接口,接口中正是我们定义的两个导出函数,AddSwap。整体上来说,bindgen将我们定义的wit格式的接口定义,转化为go语言sdk。我们在编写wasm代码时,只需要导入生成的sdk包,实现对应的接口就可以了。

type Demo interface {
  Swap(x uint32, y uint32) (uint32, uint32) 
  Add(x uint32, y uint32) uint32 
}

另外我们可以发现,生成的sdk中存在.c文件,可以看到.go文件中是使用了cgo的。这是因为在目前的实现版本中,通过c做了一层桥接。由于对c语言支持比较早,所以在初版实现的时候是实现c语言格式的abi,然后调用go中定义的函数,go通过cgo来使用c中的结构体和一些方法,相当于在go语言外裹了一层c语言的适配。由于复用了c的部分代码,bindgen对go支持的编程复杂度会降低,但这会导致wasm二进制文件膨胀和额外的结构体转换的开销,这应该也是bindgen后续需要进行优化的地方之一。

Host code

我们已经有了wasm二进制文件了,但是如何才能让这个文件正常执行呢?别忘了wasm函数还用到了host提供的Log功能函数。

wit-bingen并没有提供host sdk的支持,而且不同的wasm运行时在定义导入函数时可能有不同的方式及函数签名。所以目前我们需要自己实现host的sdk。这里的sdk是指与业务逻辑无关的部分,就像guest的sdk一样,是一个胶水层。wazero是一个纯go语言的wasm运行时,接下来就以wazero为例介绍应该如何来运行此wasm程序。

我们将以下代码拷贝到host/sdk.go中。在这个文件中,我们实现了导入函数log。通过Instantiate函数可以实现此函数的“动态链接”。

通过Log函数我们可以看到在传递wasm没有原生支持的数据结构时,此例中为string,是通过传递其指针和在内存中占用长度来进行的。通过对wasm中内存的读取来获取传递的message。

package main

import (
	"context"
	"log"

	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/api"
)

const ModuleName = "host"

func Instantiate(ctx context.Context, rt wazero.Runtime) error {
	_, err := rt.NewHostModuleBuilder(ModuleName).
		NewFunctionBuilder().WithFunc(Log).Export("log").
		Instantiate(ctx)
	return err
}

func Log(ctx context.Context, m api.Module, ptr, length uint32) {
	// read the wasm memory at ptr for length bytes
	message, ok := m.Memory().Read(ptr, length)
	if !ok {
		// handle error
		return
	}
	log.Println(string(message))
}

将以下内容拷贝到host/main.go文件中:

// main.go
package main

import (
	"context"
	"crypto/rand"
	_ "embed"
	"log"
	"os"

	"github.com/tetratelabs/wazero"
	"github.com/tetratelabs/wazero/api"
	"github.com/tetratelabs/wazero/imports/wasi_snapshot_preview1"
)

//go:embed main.wasm
var wasmBytes []byte

func main() {
	ctx := context.Background()
	runtime := wazero.NewRuntime(ctx)
	defer runtime.Close(ctx)

	// todo detect wasm module valid
	wasmModule, err := runtime.CompileModule(ctx, wasmBytes)
	handleErr(err)
	defer wasmModule.Close(ctx)
	wasi_snapshot_preview1.Instantiate(ctx, runtime)
	Instantiate(ctx, runtime)

	ins, err := runtime.InstantiateModule(ctx, wasmModule,
		wazero.NewModuleConfig().
			WithRandSource(rand.Reader).
			WithStdout(os.Stdout).
			WithSysNanotime().
			WithSysWalltime().
			WithSysNanosleep())
	handleErr(err)

	runAdd(ctx, ins)
	runSwap(ctx, ins)

	ins.Close(ctx)
}

func runAdd(ctx context.Context, m api.Module) {
	fn := m.ExportedFunction("add")
	if fn == nil {
		panic("function add not found")
	}
	res, err := fn.Call(ctx, 1, 2)
	handleErr(err)
	log.Printf("success call wasm function add(1, 2) = %d \n", res[0])
}

func runSwap(ctx context.Context, m api.Module) {
	fn := m.ExportedFunction("swap")
	if fn == nil {
		panic("function swap not found")
	}
	res, err := fn.Call(ctx, 1, 2)
	handleErr(err)
	// res[0] is pointer to result, 4 bytes for x, 4 bytes for y
	x, _ := m.Memory().ReadUint32Le(uint32(res[0]))
	y, _ := m.Memory().ReadUint32Le(uint32(res[0] + 4))
	log.Printf("success call wasm function swap(1, 2) = %d, %d \n", x, y)
}

func handleErr(err error) {
	if err != nil {
		panic(err)
	}
}

上文中runAddrunSwap两个函数都是调用wasm中的导出函数。首先可以看到其一般流程为:通过导出函数名称获取到导出函数,然后调用其Call方法进行调用,第一个参数固定为ctx这是wazero的特点,它会贯穿函数整个的调用链,可以用来存一些值。然后是函数的实际调用参数,有多少传多少。

对于本例来说,首先是add函数,我们可以通过查看guest sdk的代码来确定参数数量和类型:

__attribute__((__export_name__("add")))
int32_t __wasm_export_demo_add(int32_t arg, int32_t arg0) {
  uint32_t ret = demo_add((uint32_t) (arg), (uint32_t) (arg0));
  return (int32_t) (ret);
}

__wasm_export_demo_add接受两个参数,然后返回一个参数,返回值即为结果。对于所有单返回值且返回值类型为wasm支持的类型时,其返回值就是结果。

对于swap函数来说,其对应的代码为:

static uint8_t RET_AREA[8];
__attribute__((__export_name__("swap")))
int32_t __wasm_export_demo_swap(int32_t arg, int32_t arg0) {
  uint32_t ret;
  uint32_t ret1;
  demo_swap((uint32_t) (arg), (uint32_t) (arg0), &ret, &ret1);
  int32_t ptr = (int32_t) &RET_AREA;
  *((int32_t*)(ptr + 0)) = (int32_t) (ret);
  *((int32_t*)(ptr + 4)) = (int32_t) (ret1);
  return ptr;
}

__wasm_export_demo_swap函数同样是两个参数,然后返回一个参数。两个参数同样就是函数参数。但是返回值是一块指向8字节数组的指针。前4个字节是返回值1,后4个字节是返回值2。这也是我们在runAddrunSwap函数中使用不同的方式来读取wasm函数调用返回值的原因。

最后执行go mod init host && go mod tidy。将guest下的wasm文件拷贝到host目录中。接下来执行go run .,就可以执行wasm程序了,你将看到控制台输出以下内容:

2023/09/03 16:01:39 success call wasm function add(1, 2) = 3 
2023/09/03 16:01:39 wasm function Swap is called
2023/09/03 16:01:39 success call wasm function swap(1, 2) = 2, 1 

你可以在这里找到本文所涉及到的所有代码。

注:与WASI区别

从层次上来说WASI是在组件模型之上的,组件模型提供了用于定义WASI接口的基本构建块,包括:

  • 定义可以在WASI接口中使用的类型的语法;
  • WASI可以假定的链接功能用于组成单独的代码模块,隔离它们的能力并虚拟化WASI接口;
  • 当针对WASI时,核心WASM工具链可以将其编译为WASM ABI。

与传统操作系统的比较,组件模型起到了定义操作系统的进程模型的角色(定义进程如何启动和相互通信),而WASI起到了定义操作系统的许多I/O接口的角色。

然而,使用WASI并不强制用户使用组件模型。任何核心wasm开发者都可以简单地使用通过组件模型定义的任意WASI接口的wasm ABI方法签名。这种方法会遇到许多已经由组件模型解决了的问题,特别是当涉及多个WASM模块时。但对于单模块场景或高度自定义的场景,这可能是合适的。

参考:

[1] WebAssembly Component Model https://github.com/WebAssembly/component-model

[2] Component Model High-Level Goals: https://github.com/WebAssembly/component-model/blob/main/design/high-level/Goals.md

[3] WebAssembly High-Level Goals: https://github.com/WebAssembly/design/blob/main/HighLevelGoals.md

[4] The wit format: https://github.com/WebAssembly/component-model/blob/main/design/mvp/WIT.md

[5] Component Model Use Cases: https://github.com/WebAssembly/component-model/blob/main/design/high-level/UseCases.md

[6] Fermyon The WebAssembly Component Model: https://www.fermyon.com/blog/webassembly-component-model

[7] Code: https://github.com/Taction/wit-go-example/tree/master/basic