WASI:如何在浏览器外运行WebAssembly代码

669 阅读12分钟

WASI:如何在浏览器外运行WebAssembly代码

WASI:如何在浏览器外运行WebAssembly代码?WebAssembly最初是一种技术,用于编写在浏览器中运行的应用程序而不使用JavaScript。现在,由于WASI的存在,它也可以用来在浏览器之外运行应用程序。在这篇文章中,我们将看到这有什么用处,我们如何在浏览器外编写和运行WebAssembly应用程序,并讨论这为跨平台应用程序提供了哪些机会。

我们将创建一个简单的库来创建lexers,然后我们将展示如何使用该库在桌面上创建一个Console应用程序。本教程的代码可以在GitHub上找到:https://github.com/Strumenta/wasi-tutorial。

什么是词库?词典是处理文本并将其分割成被称为 "标记 "的单个元素的组件,如标识符、运算符、小括号等。它们通常用于实现语法高亮,是解析器的第一阶段。我们的库将允许你创建自己的词典,指定你的词典将识别哪种标记。

什么是WebAssembly?

WebAssembly是一种二进制指令格式。换句话说,它是一种定义指令的格式,一个适当的解释器可以有效地执行。需要注意的是,WebAssembly 解释器是与所有现代网络浏览器一起提供的。这意味着WebAssembly允许用不同于JavaScript的语言创建在浏览器中运行的应用程序。确实有可能为现有的或新的编程语言创建编译器,产生WebAssembly文件,而不是机器代码,然后在浏览器中运行这些代码。

在浏览器外运行WebAssembly

WebAssembly开始被用于创建网络应用程序,但最终有人想,为什么要把WebAssembly限制在浏览器中?如果能够写一个程序,把它编译成WebAssembly,并在任何地方运行它:在浏览器中和在浏览器外,不是很好吗?人们对此进行了反思,并得出结论:是的,这将是很酷的。

为了实现这个目标,我们只需要在浏览器外运行的WebAssembly解释器,对吗?嗯,不完全是。事情变得更加复杂,因为是的,要执行代码就需要一个解释器。或者一个虚拟机(VM),因为我们倾向于称二进制格式的解释器。但仅有一个解释器是不够的:还需要一些系统API来与系统交互。当然,WebAssembly定义了如何进行计算或如何调用一个函数,但到目前为止,它被期望在浏览器中执行,在那里它可以通过在DOM上的操作将结果传达给用户,以绘制一些东西或对浏览器显示的页面上发生的用户事件做出反应。如果我们在浏览器外运行WebAssembly代码,我们需要以不同的方式与系统对接。我们需要读和写文件,在控制台上写,对用户事件作出反应,等等。

我们怎样才能做到这一点呢?通过定义一些系统API,我们可以期望我们的Wasm VM提供。然后,我们可能有多个Wasm VM实现,都提供相同的系统API,因此我们可以期望我们的WebAssembly代码在所有这些Wasm VM上运行而不改变。这套系统API的存在,被称为WebAssembly系统接口或WASI

WASI规定了什么?目前还没有那么多。

虽然暴露的系统API不多,但最有用的都在那里,所以人们可以在控制台读写,也可以读写文件。

为什么让WebAssembly到处运行是有用的?

在有些情况下,我们想在不同的平台上重复使用一些代码。应用程序的跨平台可移植性是一个古老的目标,我们的开发者已经有很长一段时间了,并且找到了大量不同的解决方案。然而,当有人想在浏览器和桌面上运行同一段代码时,会有一些挑战。

例如,我们可能想为C#语言创建一个解析器。这意味着一个能够识别C#代码的解析器,但这并不意味着解析器本身应该用C#编写。例如,你可能想在一个基于网络的编辑器中使用该解析器,比如Monaco,它是一个TypeScript应用程序,所以你可能希望能够从TypeScript中使用你的解析器。然后我们可能想在一个桌面应用程序中使用相同的C#解析器,从我们的C#代码中生成一些图表。你可能更喜欢用Python或Java编写桌面应用程序,在该应用程序中你可能想调用相同的解析器。如果我们想只写一次C#解析器,我们应该用哪种语言来写,以便我们可以从TypeScript(在浏览器中)、Python和Java(在桌面上)调用它?

问题是,到目前为止,浏览器只能运行用JavaScript编写的应用程序。所以如果有人想在浏览器和桌面上运行同一段代码,他们基本上有两个选择:

  • 将某种语言的代码转译成JavaScript,这样它就可以在浏览器中运行,然后找到一种能在桌面上运行的该语言的编译器
  • 用JavaScript写代码。它可以很容易地在浏览器上运行,人们可以使用一个JavaScript引擎在桌面上运行它。Node.JS是一个可能的JavaScript引擎的例子。

有了WebAssembly,我们可以使用任何我们想要的语言,只要我们能够建立一个产生WebAssembly字节码的编译器。鉴于WebAssembly是一种低级别的指令格式,编写高效的编译器要比编写产生JavaScript代码的转译器容易得多。

执行WebAssembly

有许多方法来执行WebAssembly代码。有解释器、编译器和库,可以从其他应用程序中执行wasm文件。你可以在这里找到一个列表:https://github.com/appcypher/awesome-wasm-runtimes

他们允许做两件不同的事情:

  • 在桌面上运行仅由WASM汇编文件组成的控制台应用程序
  • 在较大的应用程序中运行WASM模块,用Java、Python或JavaScript等语言编写(在后一种情况下,也在浏览器中运行)。

它们在性能、对WASI版本的支持以及作为库提供给更多或更少的语言方面有所不同。例如,目前Wasm3可以作为Python、Rust、C/C++、Go、Swift、.NET和其他一些平台的库。Wasmer似乎支持更多的语言,包括PHP和Ruby。基于这些差异,你可能想选择一个虚拟机或另一个,但原则上,它们都应该提供一个非常相似的环境。

我们的例子:一个简单的词法分析器

有很多方法可以编写WebAssembly代码。这些是一些:

  • 我们可以写WAT,这是一种文本格式,可以编译成WASM的二进制格式。在这种格式中,人们为WASM VM指定单条指令。WASM虚拟机是一个基于堆栈的机器,原则上类似于JVM,但它提供的指令较少,而且往往是比较基本的。
  • 我们可以直接产生WASM二进制文件。其抽象程度与我们使用WAT时相同,但我们需要生产这些二进制文件,所以会有一些额外的复杂情况。
  • 我们可以使用Rust,它对编译成WASM有很好的支持。
  • 我们可以使用AssemblyScript,这是TypeScript的一种方言,可以编译成WASM。

在我们的例子中,我们将使用AssemblyScript,因为它基本上是TypeScript,一种许多开发者已经熟悉的语言。Rust也是一个不错的选择,但我们对它并不熟悉。手动编写WAT或WASM文件反而需要非常大的努力。直接编写生成WAT或WASM的编译器可能会很有趣,但这已经超出了本文的目标。

要开始使用AssemblyScript,你可以查看AssemblyScript的官方快速入门指南

从本质上讲,你可以通过这几个命令创建一个Node.js项目:

npm init
npm install --save @assemblyscript/loader
npm install --save-dev assemblyscript
npx asinit .

应用程序本身类似于一个普通的TypeScript应用程序。

原始的package.json指定了这些脚本:

"scripts": {
    "test": "node tests",
    "asbuild:untouched": "asc assembly/index.ts --target debug",
    "asbuild:optimized": "asc assembly/index.ts --target release",
    "asbuild": "npm run asbuild:untouched && npm run asbuild:optimized"
  },

我们可以改变它们来指定:

  1. 应用程序使用asc(任务构建)进行编译。这产生了wasm文件
  2. 应用程序使用wasmtime运行,我们选择的wasm解释器。这就执行了wasm文件
  3. 我们也可以只生成库,而不包括针对控制台应用程序的任务(任务compilelib)。虽然在本教程中我们将只执行控制台应用程序,但我们可能想在lexer.wasm中生成库,并从Python、JavaScript或我们选择的WASM虚拟机支持的任何其他语言中使用该库。

我们还可以指定几个额外的依赖。产生的package.json将看起来像这样:

{
 "scripts": {
   "build": "asc assembly/index.ts -b build/index.wasm -t build/index.wat",
   "run": "wasmtime --dir . build/index.wasm",
   "compilelib": "asc assembly/lexer.ts -b build/lexer.wasm -t build/lexer.wat"
 },
 "dependencies": {
   "@assemblyscript/loader": "^0.19.12",
   "as-wasi": "^0.4.6",
   "source-map-support": "^0.5.20"
 },
 "devDependencies": {
   "assemblyscript": "^0.19.12"
 }
}

我们的例子将是关于创建一个描述简单词典的库。我们的词库将识别不同类型的标记物。它是基于我们的一个需求:在网络和桌面上运行词典(和分析器)。

我们库的用户将实例化我们的词典类的实例。我们将能够指定我们的词典能够识别哪些关键词。然后,我们将能够向我们的词典提供一个输入字符串,并得到一个 "识别 "对象。识别对象告诉我们输入的哪一块被识别,以及它是哪种类型的标记。例如,如果我们的语言识别关键字 "hello "和 "world",并且我们用输入 "helloworld "来调用词法分析器,我们将得到第一个识别实例,告诉我们 "hello "被识别为一个 "关键字hello "类型的标记。然后我们就可以在剩余的字符串上调用词法分析器。

现在,我们将只建立我们的词典库的几个核心功能,因为创建一个完整的词典库已经远远超出了本文的目标。请把它看作是一个例子,请原谅我们在实现过程中的一些简化。

在内部,我们的词库将使用一个自动机。如果你想了解更多关于它们的信息,那么我们将在这里解释,你可以查看这个关于有限状态机的条目。

词典将从初始状态开始,当它收到输入字符时,它将在不同的状态下移动。有些状态将与识别某种标记类型相关。

例如,这可能是一个能够识别关键词 "bar "的自动机:

而这将是一个可以识别关键词 "bar "或关键词 "baz "的自动机:

我们的代码将需要定义状态:

class State {
   automaton: Automaton
   name: string
   transitions: Transition[]
   epsilonTransitions: State[] = []
   
}

状态知道它们是哪个自动机的一部分,有一个名字,然后有一系列的过渡。过渡可以是正常的过渡,需要消耗一个符号,也可以是特殊的过渡,称为Epsilon过渡。埃普西隆过渡对于表明我们可以在不消耗一个符号的情况下移动到另一个状态是很有用的。假设我们想识别一个由一个或多个数字组成的标记。我们可以用这个自动机做到这一点。

一旦我们得到了第一个数字,我们可以继续接收数字,或者在任何时候我们都可以宣布我们识别了这个数字并停止消耗更多的字符。

epsilon转换的另一种用法是在识别了标记之后,回到初始状态。通过这种方式,我们可以识别一连串的代币:

一个过渡基本上会告诉我们它所消耗的符号和它所指向的状态:

class Transition {
   symbol: string
   destination: State
   constructor(symbol: string, destination: State) {
       this.symbol = symbol
       this.destination = destination
   }
}

一个状态将能够处理符号以决定哪个应该是下一个状态:

class State {
… 
tryToProcessSymbol(symbol: string) : State | null {
   let foundTransition : Transition | null = null
   for (let i=0;i<this.transitions.length && foundTransition == null;i++) {
       const t = this.transitions[i]
       if (t.symbol == symbol) {
           foundTransition = t
       }
   }
   if (foundTransition == null) {
       return null
   }
   return foundTransition.destination
}

processSymbol(symbol: string) : State {
   let nextState = this.tryToProcessSymbol(symbol)
   if (nextState == null) {
       throw new Error(`no transition from ${this.name} on symbol ${symbol}`)
   }
   return nextState as State
}
… 
} // class State

现在让我们来看看我们的自动机类:

class Automaton {
   states: State[]
   currentState: State | null

   constructor() {
       this.states = []
   }

   clone(startState: State) : Automaton { … }
   createState(name: string | null = null) : State { … }
   processSymbols(symbols: string[]) : void { … }
   processSymbol(symbol: string) : void { … }
   tryToProcessSymbol(symbol: string) : State | null { … }
   processString(string: string) : void { … }
}

最初,自动机没有状态。我们可以创建一个空的状态,指定它的名字或者让它生成名字(createState)。我们还可以克隆自动机,只改变当前状态。一个自动机处理符号,我们可以把一个字符串看作是一个符号序列。所以processString和processSymbols只是为每个单一的符号/字符调用processSymbol。ProcessSymbol反过来只是调用currentState上的同名方法。如果符号不能被消耗(因为没有有效的过渡),将返回一个错误,否则将返回新的currentState。

这段代码是如何被词法学家使用的呢?

我们可以使用 "nextToken "来处理一个输入。在这段代码中,我们探索不同的分支,最后我们将决定保留哪一个分支。这是为什么呢?

考虑到我们有一种语言有各种关键词,包括 "bar "和 "barrumba"。当我们处理一个以 "bar "开始的输入时,我们可以停止并告诉用户我们识别了 "bar",或者继续处理,寻找完成 "barrumba"。原则上,在这种情况下,我们需要提前查看接下来的5个字符来做出决定。在我们的代码中,我们反而会探索这两种可能性,然后再决定。所以我们会有两个分支:

  • 我们从一个单一分支开始。
  • 对于每个分支,我们尝试处理下一个字符,获得所有分支的更新版本。一些分支将被淘汰,因为它们无法处理下一个字符
  • 我们看一下更新后的分支,看看是否有任何分支处于对应于一个被认可的标记的状态。如果是这样,我们就在 "recognized "列表中添加一个候选识别。
  • 最后,我们看一下所有的候选识别,并挑选最长的一个。如果有不同的候选人具有相同的最大长度,我们就抛出一个错误,因为我们有一个模糊的案例。

为了处理整个输入,我们不断调用 nextToken,直到我们得到有效的标记。然后,我们删除已识别的输入部分,对剩余的输入调用nextToken。

在词典识别关键词 "bar "和 "barrumba "的情况下,如果我们通过输入 "barrumba",我们将达到 "bar "并将其添加到我们的识别标记列表中。然后我们将继续进行,直到 "barrumba "被识别。最终我们将同时识别 "bar "和 "barrumba",因此我们将返回 "barrumba",因为它更长。

假设我们把 "barbar "传递给我们的词库。我们将首先识别 "bar"。然后我们将继续看它是否能识别 "barrumba",然而它将不会得到正确的字符,所以自动机将不知道如何继续,分支将终止。因此,我们将只识别 "bar",我们将返回该字符。然后我们将再次调用nextToken来消耗剩余的字符。

在一个真正的词典中,我们可能还想为空白和标识符添加规则,所以这里的例子显然是简化的。

用法1:以控制台应用程序的形式运行词法分析器

我们现在可以使用这个非常简单的库来创建一个控制台应用程序,我们可以在我们的计算机上启动它。这个应用程序将要求我们插入一个输入。输入将由词法分析器处理,它将打印出识别的标记,然后要求我们提供更多的输入。当我们插入一个空的输入时,应用程序将终止:

import {Console} from "as-wasi";
import {Lexer, Recognition} from "./lexer";

function createLexer() : Lexer {
 const lexer = new Lexer();
 lexer.recognizeKeyword("foo")
 lexer.recognizeKeyword("bar")
 lexer.recognizeKeyword("barrumba")
 lexer.recognizeKeyword("zum")
 return lexer
}

function processText(lexer: Lexer, text: string, start: boolean = true) : void {
 if (start) {
   Console.log(`\nProcessing "${text}"`)
 }
 let nextToken = lexer.nextToken(text)
 if (nextToken == null) {
   Console.error(` - No token recognized at "${text}"`)
   Console.log("")
   return
 }
 const r = nextToken as Recognition
 Console.log(`  - Token recognized: ${r.describe()}`)
 if (text.length == r.text.length) {
   // end of text reached
   Console.log("")
 } else {
   this.processText(lexer, text.substr(r.text.length), false)
 }
}

let lexer = createLexer()
let done = false
while (!done) {
 Console.log("insert input to process:")
 let input = Console.readLine();
 if (input != null && (input as string).length > 0) {
   processText(lexer, input as string)
 } else {
   Console.log("no input specified, terminating")
   done = true
 }
}

我们的应用程序的代码相当简单,因为复杂的功能被包裹在一个库中。在这个例子中,我们看到如何使用在WASM中编译的控制台应用程序的库,并使用WASI运行到WASM虚拟机中,但相同的库可以在浏览器或使用Java、Python、Ruby和许多其他语言的桌面应用程序中重复使用,并有一个或多个WASM虚拟机可用。

这是一个应用程序运行的例子:

接下来的步骤

接下来的步骤将是看看如何在浏览器中和用Python或Java等语言编写的大型桌面应用程序中使用我们的词典库。我们可以使用WASM库,但传递和接收数值并不容易,因为WASM只直接支持基本的数字类型,而所有其他类型都是通过引用来传递的,其方式并不明显。也许我们可以在以后的文章中研究一下这个问题。

希望随着时间的推移,这种整合会变得更容易。

总结

WebAssembly正在超越浏览器的边界,它正在成为一个非常有趣的选择,可以编写我们可能想在浏览器和桌面应用程序中使用的库。到现在为止,创建编译成WASM的库越来越容易,但使用它们似乎仍然有点困难。我们希望能尽快看到这方面的进展,因为这似乎是阻碍WebAssembly成为跨平台应用的绝佳选择的唯一障碍。