外部函数接口(FFI)#

我们已经介绍的是纯粹的计算。在现实中,需要与真实世界互动。然而,对于每个后端(C、JS、Wasm、WasmGC),“世界”是不同的,并且基于运行时(Wasmtime、Deno、浏览器等)。

当嵌入到浏览器,或通过 Wasmtime 嵌入到命令行应用程序中时,可以通过外部函数接口(FFI)在 MoonBit 中使用外部函数与宿主运行时进行交互。

init 函数#

对于 WebAssembly 后端,它被编译为 start 函数,这意味着它将在实例可用之前执行,并且依赖于实例的导出的外部函数接口(FFI)在这个阶段不能使用;对于 JavaScript 后端,这意味着它将在导入阶段执行。

声明外部引用#

您可以像这样声明一个外部引用类型:

type Canvas_ctx

这将是一个表示对外部对象的引用的类型,在这个例子中,它是由宿主 JavaScript 运行时持有的 CanvasRenderingContext2D 对象。

声明外部函数#

您可以通过模块名和函数名导入一个函数,也可以编写一个内联函数。

导入函数#

您可以像这样声明一个外部函数:

fn cos(d : Double) -> Double = "Math" "cos"

它与普通函数定义类似,只是函数体被替换为两个字符串。

对于 Wasm(GC) 后端,这两个字符串用于从 Wasm 导入对象中识别特定函数,第一个字符串是模块名,第二个字符串是函数名。

对于 JS 后端,这两个字符串用于调用全局命名空间中的静态函数。上面的例子类似于 const cos = (d) => Math.cos(d)

内联函数#

您还可以声明内联函数,其中函数体被替换为一个字符串。

对于 Wasm(GC) 后端,您可以将其声明为一个没有名称的 Wasm 函数(稍后将生成名称):

extern "wasm" fn abs(d : Double) -> Double =
  #|(func (param f64) (result f64))

对于 JS 后端,您可以将其声明为一个 lambda 表达式:

extern "js" fn abs(d : Double) -> Double =
  #|(d) => Math.abs(d)

声明后,您可以像普通函数一样使用外部函数。

对于多后端项目,您可以在以 .wasm.mbt .wasm-gc.mbt.js.mbt 结尾的文件中实现特定于后端的代码。有关详细信息,请查看链接选项

您还可以声明一个外部函数,该函数将通过使用外部引用类型在外部对象上调用,如下所示:

fn begin_path(self: Canvas_ctx) = "canvas" "begin_path"

然后将其应用于以前拥有的引用,例如 context.begin_path()

导出函数#

如果函数既不是方法也不是多态函数,则可以导出它们,前提是它们是公共的,并且链接配置出现在包的 moon.pkg.json 中:

{
  "link": {
    "wasm": {
      "exports": [ "add", "fib:test" ]
    },
    "wasm-gc": {
      "exports": [ "add", "fib:test" ]
    },
    "js": {
      "exports": [ "add", "fib:test" ],
      "format": "esm"
    }
  }
}

每个后端都有单独的定义。对于 JS 后端,format 选项用于指定生成的 JavaScript 文件是作为 ES 模块 (esm)、CommonJS 模块 (cjs) 还是立即调用的函数表达式 (iife) 发布。

上面的例子将导出函数 addfib,函数 fib 将以 test 的名称导出。

使用编译后的 Wasm#

要使用编译后的 Wasm,您需要使用宿主函数初始化 Wasm 模块,以满足外部函数的需求,然后使用 Wasm 模块提供的导出函数。

提供宿主函数#

要使用编译后的 Wasm,您必须在 Wasm 导入对象中提供所有声明的外部函数。

例如,在 JavaScript 中使用上面代码片段编译的 wasm:

WebAssembly.instantiateStreaming(fetch("xxx.wasm"), {
  Math: {
    cos: (d) => Math.cos(d),
  },
});

查看文档,例如 MDN 或您用于嵌入 Wasm 的运行时的手册。

示例:笑脸#

让我们通过一个完整的示例来使用 Canvas API 在 MoonBit 中绘制一个笑脸。假设您使用 moon new draw 创建了一个新项目

// 首先声明一个表示 canvas 上下文的类型
type Canvas_ctx
{
  "link": {
    "wasm": {
      "exports": ["draw", "display_pi"]
    },
    "wasm-gc": {
      "exports": ["draw", "display_pi"]
    }
  }
}

使用 moon build 构建项目。我们建议尽可能使用 Wasm GC(这是默认值)。如果环境不支持 GC 特性,请改用 --target wasm 选项。

现在我们可以从 JavaScript 中使用它。

<html lang="en">
  <body>
    <canvas id="canvas" width="150" height="150"></canvas>
  </body>
  <script>
    // 用于定义 FFI 的导入对象
    const importObject = {
      // TODO
    }

    const canvas = document.getElementById("canvas");
    if (canvas.getContext) {
      const ctx = canvas.getContext("2d");
      WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
        (obj) => {
          // 将 JS 对象作为参数传递以绘制笑脸
          obj.instance.exports["draw"](ctx);
          // 显示 PI 的值
          obj.instance.exports["display_pi"]();
        }
      );
    }
  </script>
</html>

对于导入对象,我们需要提供先前定义的程序中使用的所有 FFI:canvas 渲染 API、math API 和最后,用于 printlnprint 函数的打印到控制台的 API。

至于 canvas 渲染 API 和 math API,我们可以使用以下代码将对象的方法转换为接受对象作为第一个参数的函数调用,并将对象的常量属性转换为返回值的函数:

function prototype_to_ffi(prototype) {
  return Object.fromEntries(
    Object.entries(Object.getOwnPropertyDescriptors(prototype))
      .filter(([_key, value]) => value.value)
      .map(([key, value]) => {
        if (typeof value.value == 'function')
          return [key, Function.prototype.call.bind(value.value)]
        // TODO: it is also possible to convert properties into getters and setters
        else
          return [key, () => value.value]
      })
  );
}

const importObject = {
  canvas: prototype_to_ffi(CanvasRenderingContext2D.prototype),
  math: prototype_to_ffi(Math),
  // ...
}

至于打印服务,我们可以提供以下闭包,以便它缓冲字符串的字节,直到需要将其记录到控制台:

const [log, flush] = (() => {
  var buffer = [];
  function flush() {
    if (buffer.length > 0) {
      console.log(new TextDecoder("utf-16").decode(new Uint16Array(buffer).valueOf()));
      buffer = [];
    }
  }
  function log(ch) {
    if (ch == '\n'.charCodeAt(0)) { flush(); }
    else if (ch == '\r'.charCodeAt(0)) { /* noop */ }
    else { buffer.push(ch); }
  }
  return [log, flush]
})();

const importObject = {
  // ...
  spectest: {
    print_char: log
  },
}

// ...
WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
  (obj) => {
    // ...
    flush()
  }
);

现在,我们将它们放在一起,这是我们最终的完整 index.html

<!DOCTYPE html>
<html>

<head></head>

<body>
  <canvas id="canvas" width="150" height="150"></canvas>
  <script>
    function prototype_to_ffi(prototype) {
      return Object.fromEntries(
        Object.entries(Object.getOwnPropertyDescriptors(prototype))
          .filter(([_key, value]) => value.value)
          .map(([key, value]) => {
            if (typeof value.value == 'function')
              return [key, Function.prototype.call.bind(value.value)]
            else
              return [key, () => value.value]
          })
      );
    }

    const [log, flush] = (() => {
      var buffer = [];
      function flush() {
        if (buffer.length > 0) {
          console.log(new TextDecoder("utf-16").decode(new Uint16Array(buffer).valueOf()));
          buffer = [];
        }
      }
      function log(ch) {
        if (ch == '\n'.charCodeAt(0)) { flush(); }
        else if (ch == '\r'.charCodeAt(0)) { /* noop */ }
        else { buffer.push(ch); }
      }
      return [log, flush]
    })();



    const importObject = {
      canvas: prototype_to_ffi(CanvasRenderingContext2D.prototype),
      math: prototype_to_ffi(Math),
      spectest: {
        print_char: log
      },
    }

    const canvas = document.getElementById("canvas");
    if (canvas.getContext) {
      const ctx = canvas.getContext("2d");
      WebAssembly.instantiateStreaming(fetch("target/wasm-gc/release/build/lib/lib.wasm"), importObject).then(
        (obj) => {
          obj.instance.exports["draw"](ctx);
          obj.instance.exports["display_pi"]();
          flush()
        }
      );
    }
  </script>
</body>

</html>

确保 draw.wasmindex.html 在同一个文件夹中,然后在此文件夹中启动一个 http 服务器。例如,使用 Python:

python3 -m http.server 8080

在浏览器中转到 http://localhost:8080,屏幕上应该有一个笑脸,控制台上应该有一个输出:

带有笑脸的浏览器开发工具的网页