Wasmtime 是来自 字节码联盟 的 WebAssembly 运行时,最近添加了 .NET Core(微软免费、开源且跨平台的应用程序运行时)API 的早期预览版。此 API 使开发人员能够以编程方式直接从其 .NET 程序加载和执行 WebAssembly 代码。
.NET Core 已经是跨平台运行时,那么 .NET 开发人员为什么要关注 WebAssembly 呢?
如果您是 .NET 开发人员,那么 WebAssembly 有几个值得兴奋的原因,例如跨平台共享相同的可执行代码、能够安全地隔离不受信任的代码以及与即将推出的 WebAssembly 接口类型提案无缝互操作。
跨平台共享更多代码
.NET 程序集已经可以构建为跨平台使用,但使用 *原生库*(例如,用 C 或 Rust 编写的库)可能很困难,因为它需要原生互操作并为每个受支持平台分发库的特定于平台的构建。
但是,如果将原生库编译为 WebAssembly,则可以在许多不同的平台和编程环境(包括 .NET)中使用相同的 WebAssembly 模块;这将简化库和依赖于它的应用程序的分发。
安全隔离不受信任的代码
.NET Framework 尝试使用 *代码访问安全* 和 *应用程序域* 等技术来沙盒化不受信任的代码,但最终这些技术都未能正确隔离不受信任的代码。因此,微软弃用了它们用于沙盒化,并最终从 .NET Core 中删除了它们。
您是否曾经想过在您的应用程序中加载不受信任的插件,但找不到方法来阻止插件调用任意系统调用或直接读取您进程的内存?您可以使用 WebAssembly 来做到这一点,因为它专为 Web 设计,在 Web 环境中,每次您访问网站时都会执行不受信任的代码。
WebAssembly 模块只能调用它从主机环境显式导入的外部函数,并且只能访问主机赋予它的内存区域。我们也可以利用这种设计在 .NET 程序中沙盒化代码!
改进与接口类型的互操作性
WebAssembly 接口类型提案 引入了一种方法,使 WebAssembly 能够更好地与编程语言集成,方法是减少在主机应用程序和 WebAssembly 模块之间来回传递更复杂类型所需的粘合代码量。
当 Wasmtime for .NET API 最终实现对接口类型的支持时,它将为在 WebAssembly 和 .NET 之间交换复杂类型提供无缝的体验。
深入探讨从 .NET 使用 WebAssembly
在本文中,我们将深入探讨如何使用用 Rust 编写的并编译为 WebAssembly 的库,并通过 Wasmtime for .NET API 从 .NET 使用它,因此,对 C# 编程语言有一些了解将有助于理解本文内容。
此处描述的 API 级别相当低。这意味着对于概念上简单的操作(例如传递或接收字符串值)需要大量的粘合代码。
将来,我们还将提供一个基于 WebAssembly 接口类型 的更高级别的 API,这将大大减少相同操作所需的代码。使用该 API 将使与 .NET 中的 WebAssembly 模块交互变得像与 .NET 程序集交互一样容易。
另请注意,API 仍在积极开发中,并且会以向后不兼容的方式发生变化。我们的目标是在稳定 Wasmtime 本身的同时稳定它。
如果您正在阅读本文并且您不是 .NET 开发人员,没关系!请查看 Wasmtime 演示 存储库,其中也包含 Python、Node.js 和 Rust 的对应实现!
创建 WebAssembly 模块
我们将首先构建一个 Rust 库,该库可用于将 Markdown 渲染为 HTML。但是,我们不会为您的处理器架构编译 Rust 库,而是将其编译为 WebAssembly,以便我们可以从 .NET 使用它。
您无需熟悉 Rust 编程语言 即可理解本文内容,但如果您想构建 WebAssembly 模块,则需要安装 Rust 工具链。请查看 Rustup 的主页,了解一种简单的方法来安装 Rust 工具链。
此外,我们将使用 cargo-wasi,这是一个为 Rust 针对 WebAssembly 做准备的命令。
cargo install cargo-wasi
接下来,克隆 Wasmtime 演示存储库
git clone https://github.com/bytecodealliance/wasmtime-demos.git
cd wasmtime-demos
此存储库包含 `markdown` 目录,其中包含一个 Rust 库。该库包装了一个知名的 Rust crate,可以将 Markdown 渲染为 HTML。(*注意 .NET 开发人员:从某种意义上说,crate 类似于 NuGet 包*)。
让我们使用 `cargo-wasi` 构建 `markdown` WebAssembly 模块
cd markdown
cargo wasi build --release
现在,`target/wasm32-wasi/release` 目录中应该有一个 `markdown.wasm` 文件。
如果您对 Rust 实现感到好奇,请打开 `src/lib.rs`;它包含以下内容
use pulldown_cmark::{html, Parser};
use wasm_bindgen::prelude::*;
#[wasm_bindgen]
pub fn render(input: &str) -> String {
let parser = Parser::new(input);
let mut html_output = String::new();
html::push_html(&mut html_output, parser);
return html_output;
}
Rust 库仅导出一个函数 `render`,该函数以字符串(Markdown)作为输入并返回字符串(渲染后的 HTML)。解析 Markdown 并将其转换为 HTML 所需的所有代码都由 pulldown-cmark crate 提供。
让我们退一步,简单地欣赏一下这里即将发生的事情。我们正在获取一个流行的 Rust crate,用几行代码将其包装起来,将功能公开为 WebAssembly 函数,然后将其编译为一个 WebAssembly 模块,我们可以从 .NET 加载它,而无论我们运行的平台是什么。这多么酷啊!?
窥探 WebAssembly 模块的内部
现在我们有了要使用的 WebAssembly 模块,它需要主机提供什么才能正常工作,它为主机提供了什么功能?
为了弄清楚这一点,让我们使用来自 WebAssembly 二进制工具包 的 `wasm2wat` 工具将模块反汇编为文本表示形式,并将其保存到名为 `markdown.wat` 的文件中
wasm2wat markdown.wasm --enable-multi-value > markdown.wat
注意:`--enable-multi-value` 选项启用对返回多个值的函数的支持,并且需要反汇编 `markdown` 模块。
模块需要主机提供什么
模块的导入定义了主机应该为模块工作提供什么。
以下是 `markdown` 模块的导入
(import "wasi_unstable" "fd_write" (func $fd_write (param i32 i32 i32 i32) (result i32)))
(import "wasi_unstable" "random_get" (func $random_get (param i32 i32) (result i32)))
这告诉我们,模块将需要主机提供的两个函数:`fd_write` 和 `random_get`。这些实际上是 WebAssembly 系统接口 (WASI) 函数,它们具有明确定义的行为:`fd_write` 用于将数据写入文件描述符,而 `random_get` 将用随机数据填充缓冲区。
稍后我们将为 .NET 主机实现这些函数,但重要的是要理解 **此模块只能从主机调用这些函数**;主机可以决定如何以及是否实现这些函数。
模块为主机提供什么
模块的导出定义了它为主机提供了哪些功能。
以下是 `markdown` 模块的导出
(export "memory" (memory 0))
(export "render" (func $render_multivalue_shim))
(export "__wbindgen_malloc" (func $__wbindgen_malloc))
(export "__wbindgen_realloc" (func $__wbindgen_realloc))
(export "__wbindgen_free" (func $__wbindgen_free))
...
(func $render_multivalue_shim (param i32 i32) (result i32 i32) ...)
(func $__wbindgen_malloc (param i32) (result i32) ...)
(func $__wbindgen_realloc (param i32 i32 i32) (result i32) ...)
(func $__wbindgen_free (param i32 i32) ...)
首先,模块导出一个 *内存*。WebAssembly 内存是模块可访问的线性地址空间;**它将是模块可以从中读取或写入的唯一内存区域**。由于模块无法直接访问主机的任何其他地址空间区域,因此导出的内存是主机与 WebAssembly 模块交换数据的地方。
其次,模块导出我们在 Rust 中实现的 `render` 函数。但是等一下,当 Rust 实现只有一个参数和一个返回值时,为什么它有两个参数并返回两个值?
在 Rust 中,字符串切片(`&str`)和拥有字符串(`String`)在编译为 WebAssembly 时都表示为地址和长度(以字节为单位)对。因此,Rust 函数的 WebAssembly 版本接受 Markdown 输入字符串的地址-长度对,并返回渲染后的 HTML 字符串的地址-长度对。在这里,地址表示为导出的内存中的整数偏移量。
请注意,由于 Rust 代码返回一个 `String`,它是一个 *拥有* 类型,因此 `render` 的调用者将负责释放包含渲染字符串的返回内存。
在 .NET 主机的实现过程中,我们将讨论其余的导出。
创建 .NET 项目
我们将需要 .NET Core SDK 来创建一个 .NET Core 项目,因此请确保您已安装 **3.0 或更高版本** 的 SDK。
首先为项目创建一个目录
mkdir WasmtimeDemo
cd WasmtimeDemo
接下来,创建一个新的 .NET Core 控制台项目
dotnet new console
最后,添加对 Wasmtime NuGet 包 的引用
dotnet add package wasmtime --version 0.8.0-preview2
就是这样!现在,我们可以使用 Wasmtime for .NET API 加载并执行 `markdown` WebAssembly 模块了。
从 WebAssembly 导入 .NET 代码
从 WebAssembly 导入 .NET 函数就像在 .NET 中实现 IHost
接口一样简单。这只需要一个公共 Instance
属性,该属性将表示主机绑定的 WebAssembly 模块实例。
Import
属性随后用于将函数和字段标记为 WebAssembly 模块的导入。
如前所述,模块需要主机提供的两个导入:`fd_write` 和 `random_get`,因此让我们为这些函数创建实现。
在项目目录中创建一个名为 `Host.cs` 的文件,并添加以下内容
using System.Security.Cryptography;
using Wasmtime;
namespace WasmtimeDemo
{
class Host : IHost
{
// These are from the current WASI proposal.
const int WASI_ERRNO_NOTSUP = 58;
const int WASI_ERRNO_SUCCESS = 0;
public Instance Instance { get; set; }
[Import("fd_write", Module = "wasi_unstable")]
public int WriteFile(int fd, int iovs, int iovs_len, int nwritten)
{
return WASI_ERRNO_NOTSUP;
}
[Import("random_get", Module = "wasi_unstable")]
public int GetRandomBytes(int buf, int buf_len)
{
_random.GetBytes(Instance.Externs.Memories[0].Span.Slice(buf, buf_len));
return WASI_ERRNO_SUCCESS;
}
private RNGCryptoServiceProvider _random = new RNGCryptoServiceProvider();
}
}
`fd_write` 实现只是返回一个错误,指示不支持该操作。模块使用它将错误写入 `stderr`,在本例中不会发生。
`random_get` 实现使用随机字节填充请求的缓冲区。它对表示模块整个导出内存的 Span
进行切片,以便 .NET 实现可以直接写入请求的缓冲区,而无需执行任何中间复制。`random_get` 函数由 Rust 标准库中 `HashMap` 的实现调用。
这就是使用 Wasmtime for .NET API 将 .NET 函数公开给 WebAssembly 模块所需做的全部工作。
但是,在我们可以加载 WebAssembly 模块并从 .NET 使用它之前,我们需要讨论如何将字符串从 .NET 主机作为参数传递给 `render` 函数。
做一个好的主机
根据模块的导出,我们知道它导出一个 *内存*。从主机的角度来看,可以将 WebAssembly 模块的导出内存视为已授予对外部进程地址空间的访问权限,即使模块 *共享* 主机自身的相同进程。
如果您随机写入外部地址空间的数据,则会出现 *糟糕的事情*™,因为很容易破坏其他程序的状态并导致未定义的行为,例如崩溃或宇宙的完全质子反转。那么主机如何以安全的方式将数据传递给 WebAssembly 模块呢?
在内部,Rust 程序使用 *内存分配器* 来管理其内存。因此,对于 .NET 来说,要成为 WebAssembly 模块的良好主机,它在分配和释放 WebAssembly 模块可访问的内存时也必须使用 *相同的* 内存分配器。
谢天谢地,Rust 程序用来将其自身导出为 WebAssembly 的 wasm-bindgen 也为该目的导出了两个函数:`__wbindgen_malloc` 和 `__wbindgen_free`。这两个函数本质上是来自 C 的 `malloc` 和 `free`,只是 `__wbindgen_free` 除了内存地址之外还需要先前分配的大小。
考虑到这一点,让我们用 C# 为这些导出函数编写一个简单的包装器,以便我们可以轻松地分配和释放 WebAssembly 模块可访问的内存。
在项目目录中创建一个名为 `Allocator.cs` 的文件,并添加以下内容
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using Wasmtime.Externs;
namespace WasmtimeDemo
{
class Allocator
{
public Allocator(ExternMemory memory, IReadOnlyList<ExternFunction> functions)
{
_memory = memory ??
throw new ArgumentNullException(nameof(memory));
_malloc = functions
.Where(f => f.Name == "__wbindgen_malloc")
.SingleOrDefault() ??
throw new ArgumentException("Unable to resolve malloc function.");
_free = functions
.Where(f => f.Name == "__wbindgen_free")
.SingleOrDefault() ??
throw new ArgumentException("Unable to resolve free function.");
}
public int Allocate(int length)
{
return (int)_malloc.Invoke(length);
}
public (int Address, int Length) AllocateString(string str)
{
var length = Encoding.UTF8.GetByteCount(str);
int addr = Allocate(length);
_memory.WriteString(addr, str);
return (addr, length);
}
public void Free(int address, int length)
{
_free.Invoke(address, length);
}
private ExternMemory _memory;
private ExternFunction _malloc;
private ExternFunction _free;
}
}
这段代码看起来很复杂,但它所做的只是根据名称从模块中找到所需的导出函数,并用更易于使用的接口对其进行包装。
我们将使用此辅助 `Allocator` 类为导出的 `render` 函数分配输入字符串。
现在,我们可以渲染一些 Markdown 了。
渲染 Markdown
打开项目目录中的 `Program.cs`,并将其替换为以下内容
using System;
using System.Linq;
using Wasmtime;
namespace WasmtimeDemo
{
class Program
{
const string MarkdownSource =
"# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!";
static void Main()
{
using var engine = new Engine();
using var store = engine.CreateStore();
using var module = store.CreateModule("markdown.wasm");
using var instance = module.Instantiate(new Host());
var memory = instance.Externs.Memories.SingleOrDefault() ??
throw new InvalidOperationException("Module must export a memory.");
var allocator = new Allocator(memory, instance.Externs.Functions);
(var inputAddress, var inputLength) = allocator.AllocateString(MarkdownSource);
try
{
object[] results = (instance as dynamic).render(inputAddress, inputLength);
var outputAddress = (int)results[0];
var outputLength = (int)results[1];
try
{
Console.WriteLine(memory.ReadString(outputAddress, outputLength));
}
finally
{
allocator.Free(outputAddress, outputLength);
}
}
finally
{
allocator.Free(inputAddress, inputLength);
}
}
}
}
让我们逐步了解代码的作用。它一步一步地
- 创建一个
Engine
。引擎表示 Wasmtime 运行时本身。运行时是使能够从 .NET 加载和执行 WebAssembly 模块的功能。 - 然后它创建一个
Store
。存储是保存所有 WebAssembly 对象(如模块及其实例)的地方。一个引擎中可以有多个存储,但它们的关联对象无法相互交互。 - 接下来,它从磁盘上的 `markdown.wasm` 文件创建一个
Module
。`Module` 表示 WebAssembly 模块本身的数据,例如它导入和导出的内容。一个模块可以有一个或多个 *实例*。实例是 WebAssembly 模块的 *运行时* 表示。它将模块的 *WebAssembly* 指令编译为 *当前 CPU 架构* 的指令,分配模块可访问的内存,并绑定来自主机的导入。 - 它使用我们之前实现的 `Host` 类的实例实例化模块,将 .NET 函数绑定为导入。
- 找到模块导出的内存。
- 创建一个分配器,然后为我们要渲染的 Markdown 源分配一个字符串。
- 通过将实例转换为
dynamic
,使用输入字符串调用render
函数。这是 C# 中的一个特性,它允许在运行时动态绑定函数;简单地将其理解为搜索导出的render
函数并调用它的快捷方式。 - 通过读取 WebAssembly 模块导出内存中返回的字符串来输出渲染后的 HTML。
- 最后,它释放了分配的输入字符串和 Rust 程序让我们拥有的返回字符串。
实现部分到此结束;接下来开始实际运行代码!
运行 .NET 程序
在运行程序之前,我们需要将markdown.wasm
复制到项目目录,因为我们将在该目录中运行程序。您可以在构建它的target/wasm32-wasi/release
目录中找到markdown.wasm
文件。
从上面的Program.cs
源代码中,我们可以看到程序硬编码了一些要渲染的 Markdown。
# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!
运行程序将其渲染为 HTML。
dotnet run
如果一切按计划进行,结果应该如下所示。
<h1>Hello, <code>.NET</code>! Welcome to <strong>WebAssembly</strong> with <a href="https://wasmtime.dev">Wasmtime</a>!</h1>
Wasmtime for .NET 的下一步是什么?
实现这个演示需要如此大量的 C# 代码,是不是很惊讶?
我们计划推出两个主要功能来简化此过程。
- 将 Wasmtime 的 WASI 实现公开给 .NET(和其他语言)
在我们上面实现的
Host
中,我们必须手动实现fd_write
和random_get
,它们是 WASI 函数。Wasmtime 本身有一个 WASI 实现,但目前它无法访问 .NET API。
一旦 .NET API 能够访问和配置 Wasmtime 的 WASI 实现,.NET 宿主将不再需要提供自己的 WASI 函数实现。
-
为 .NET 实现接口类型
如前所述,WebAssembly 接口类型使得 WebAssembly 与宿主编程语言的集成更加自然。
一旦 .NET API 实现接口类型提案,就不需要像我们实现的那样创建
Allocator
类了。相反,使用
string
等类型的函数应该可以直接工作,而无需在 .NET 中编写任何粘合代码。
因此,希望将来从 .NET 实现此演示可能如下所示。
using System;
using Wasmtime;
namespace WasmtimeDemo
{
interface Markdown
{
string Render(string input);
}
class Program
{
const string MarkdownSource =
"# Hello, `.NET`! Welcome to **WebAssembly** with [Wasmtime](https://wasmtime.dev)!";
static void Main()
{
using var markdown = Module.Load<Markdown>("markdown.wasm");
Console.WriteLine(markdown.Render(MarkdownSource));
}
}
}
我想我们都可以同意这看起来好多了!
总结!
这是在 Web 浏览器之外从许多不同的编程环境(包括 Microsoft 的 .NET 平台)使用 WebAssembly 的激动人心的开始。
如果您是 .NET 开发人员,我们希望您能加入我们的旅程!
本文中的 .NET 演示代码可以在Wasmtime 演示存储库中找到。
关于 Peter Huene
Peter 是一位软件开发人员,在 Mozilla 从事 Wasmtime、Cranelift 和 WASI 的工作。
24 条评论