無駄と文化

実用的ブログ

JavaScript から wasm に文字列を渡す with TinyGo

JavaScript から wasm の関数に文字列を渡して呼び出す方法を書き残します。

この記事は二本立てです

後編はこちら。

blog.mudatobunka.org

前後編のコードを全部まとめたリポジトリがここにあります。

github.com

前提知識

wasm に文字列型は無い

wasm で扱えるデータ型は i32, i64, f32, f64 のみです。ざっくりと整数型と浮動小数点数型です。つまりそもそも wasm 世界で文字列型を直接扱うことは出来ないわけです。
そのため整数型や浮動小数点数型よりも複雑なデータを扱おうとすると、ポインタデータの長さ の長さの組みを使って メモリ上の連続した領域 としてデータを扱うことになります。

文字列のバイト列表現は言語によってさまざま

さて wasm がメモリ上の連続した領域しか扱えないとのことなので「じゃあ文字列をメモリ上の連続した領域として扱うか」となる訳ですが。次に困るのは文字列のバイト列表現がプログラミング言語によってまちまちということです。

バイト列表現は文字コードとエンコード形式によって決まります。例を見てみましょう。

Unicode (UTF-8 形式)

Go の string や Rust の &str は Unicode を UTF-8 エンコードしたものを内部表現としています。
例えば 'Hello, 世界🌍' という文字列を UTF-8 エンコードして16進数で表示すると、

48 65 6C 6C 6F 2C 20 E4
B8 96 E7 95 8C F0 9F 8C
8D

こんな感じになります。

Unicode (UTF-16 形式)

JavaScript の string や Java の String は Unicode を UTF-16 エンコードしたものを内部表現としています。
例えば 'Hello, 世界🌍' という文字列を UTF-16 エンコードして16進数で表示すると、

00 48 00 65 00 6C 00 6C
00 6F 00 2C 00 20 4E 16
75 4C D8 3C DF 0D

こんな感じになります。

コンパイル元の言語と文字列のバイト列表現を揃える必要がある

そんなわけで「とあるメモリ上の連続した領域」に「文字列をとある形式でバイト列表現したもの」を書いたり読んだりして文字列を受け渡すことになります。
そのとき wasm を呼び出す側と wasm のコンパイル元とで文字列のバイト列表現の形式を示し合わせておかなければいけません。それをせず、例えば UTF-16 エンコードしたバイト列を UTF-8 デコードして読み出そうとしてしまうと正しく文字列を受け渡せません。

「WebAssembly の規格として定めれた表現で受け渡せばそのような事故はおこらないのでは?」と思いますよね。はい、wasm を介して異なる言語間でデータをやりとるするための規格化は The Wasm Interface Type (WIT) として進められています。が、2025年1月現在はまだ策定中で "定められた表現" というものは存在していません。

JavaScript から wasm に文字列を渡す

前置きが長くなりましたが JavaScript から wasm に文字列を渡しましょう。

今回は TinyGo を使ってコンパイルした wasm バイナリを前提とします。なぜ TinyGo かと云うと //go:wasmimport ディレクティブが使えるからです。
他の言語でも理屈は同じですが前述したように、その言語がどんなバイト列を文字列として認識できるか気にする必要があります。

JavaScript コードの全文を見せます。

gist.github.com

wasm モジュールの読み込み (loadModule 関数) 、文字列を線形メモリに書き込む (writeStringToMemory 関数)、本題の関数の呼び出し (wasm.exports.printString の呼び出し) に処理を分けています。
それぞれ見ていきましょう。

wasm モジュールの読み込み (loadModule 関数)

wasm の読み込みについては TinyGo の公式ドキュメントに解説を譲ります。

tinygo.org

import './wasm_exec.js'; でインポートしている JS ファイルは TinyGo をインストールすると同梱されているものです。TinyGo で wasm をコンパイルするチュートリアルをやれば目にするはず。

文字列を線形メモリに書き込む (writeStringToMemory 関数)

// JavaScript はデフォルトで UTF-16 形式で文字列を扱う
// 今回は UTF-8 形式でやりとりしたいのでエンコードしておく
let utf8str: Uint8Array = new TextEncoder().encode(str);

JavaScript では内部的に UTF-16 形式で文字列を取り扱います。今回は文字列を受け取る側 (Golang) の都合に合わせて UTF-8 形式でメモリに書き込んであげたので、まずは文字列を UTF-8 形式のバイト列にエンコードします。

// wasm モジュールは線形メモリを exports.memory として露出している
// このメモリをやりとりに使う
const memory = new Uint8Array(wasm.exports.memory.buffer);

書き込み先は exports.memory です。wasm モジュールは外界とデータをやりとりするために線形メモリを exports.memory としてモジュールの外に露出しています。メモリを露出させて直接書き換えさせるなんてワイルドですね。
さて問題は exports.memoryどこに 書き込むかということです。何も考えず memory[0] から書き込みを始めると wasm モジュールが使用しているメモリを破壊してしまうかもしれません。

// TinyGo が export してくれる関数
// 引数で指定したバイト数のメモリ領域を確保し、その先頭アドレスを返す
const ptr = wasm.exports.malloc(utf8str.length);

メモリ破壊を避けるため、書き込み可能なメモリ領域を wasm モジュール側で 確保してもらい、確保済みのメモリの先頭のアドレスを教えてもらいます。TinyGo でコンパイルした wasm においては exports.malloc がその仕事をしてくれます。
exports.malloc は全ての環境で提供されるものではありません。例えば Go の標準コンパイラで吐いた wasm は malloc を export していません。場合によっては malloc 相当の関数をユーザーが定義する必要があります。

// 確保したメモリ領域に UTF-8 形式のバイト列を書き込む
memory.set(utf8str, /* offset: */ ptr);

JavaScript の TypedArray は Array に無い便利なメソッドを実装してくれています。Uint8Array.prototype.set は ptr の位置から先に utf8str の値を書き込んでくれます。

さて exports.memory は JavaScript 世界と Wasm 世界で共有されているので、ここまでで memory の参照は手放してしまって問題ありません。
大事なのは ptr の値と utf8str.length で、この数値の組みを手がかりにして wasm 側で線形メモリのどこからどこまで文字列が書き込まれているのかを知ることができます。

// ポインタとバイト列の長さの組みを返す
// この組みを wasm の関数に渡すことで文字列が書き込まれた領域を知らせる
return [ptr, utf8str.length];

というわけで [ptr, utf8str.length] を return すれば writeStringToMemory 関数でするべき仕事は完了です。

wasm 関数の呼び出し (wasm.exports.printString の呼び出し)

無事に文字列を線形メモリに書き込めたので、文字列を渡して wasm 関数を呼び出しましょう。
今回は渡された文字列を標準出力に印字するだけの関数 printString を呼び出します。ちなみにこの関数はユーザー定義関数で、今回のデモのためにちゃちゃっと実装したものです。

// ユーザー定義関数 `printString(ptr: i32, len: i32)` を呼び出す
// ポインタとバイト列の長さの組みを渡すことで文字列を書き込んだ領域を知らせる
wasm.exports.printString(ptr, len); // => Hello, 世界🌍

はい、「文字列を渡して」と言いつつ (ptr, len) を渡しています。前述したとおり wasm には文字列型が無いのでこのようにする以外に無いですね。

実行結果

デモコードは deno で実行することを想定して書きました。deno で実行してみます。

$ deno run --allow-read passStringToWasm.ts
Hello, 世界🌍

はい、うまく動きました。
おそらくですが型情報を剥がして JavaScript にすれば Node.js やブラウザでも動きます。

Go 側の実装

今回のデモでは Go で実装した printString を呼び出しました。Go 側の実装も見てみましょう。

package main

import "fmt"

func main() {
    c := make(chan struct{})
    <-c
}

//go:wasmexport printString
func printString(str string) {
    fmt.Println(str)
}

printString のシグネチャは func(string) です。これを wasm にコンパイルして WebAssembly テキスト形式 (wat) を見てみると、

  (func $main.printString#wasmexport.command_export (type 0) (param i32 i32)
    ...

となっています。 コンパイル時に (string)(i32, i32) と引数が変換されているようですね。wasm 世界に文字列形式が無いためにこのような変換をしているのでしょう。

まとめ

JavaScript から wasm へ文字列を渡す方法を見てきました。 大したコード量ではないですが、自前でエンコードしたりメモリに書き込んだりするの面倒くせぇですね。

 

 

私からは以上です。