Go の形態素解析器を wasm で利用する

10 min read読了の目安(約9000字

概要

Go 製の形態素解析器 kagome を wasm にビルドして動かしてみます。サンプルとしてブラウザだけで(というか gh-pages で)形態素解析できるページを作ります。

これは昔、kagome.ipadic で作成したやつの kagome v2 版です。

手順

開発用にサーバ立ち上げたり、細かなことはスクラップに記録しておきました。ここではメインとなる Go のプログラムと、HTML のページについてを中心に説明していきます。

開発の手順は大まかに以下のようになります。

  1. Go で js から呼び出す関数(今回は形態素解析する関数)を作成
  2. 作成した Go のプログラムを wasm 形式でビルド
  3. HTML から js で呼び出して利用

js から呼び出すプログラムを Go で作成する

完成品がこちらになります。まずこれを示して作成の手順を示します。

package main

import (
	"strings"
	"syscall/js"

	"github.com/ikawaha/kagome-dict/ipa"
	"github.com/ikawaha/kagome/v2/tokenizer"
)

func igOK(s string, _ bool) string {
	return s
}

func tokenize(_ js.Value, args []js.Value) interface{} {
	if len(args) == 0 {
		return nil
	}
	t, err := tokenizer.New(ipa.Dict(), tokenizer.OmitBosEos())
	if err != nil {
		return nil
	}
	var ret []interface{}
	tokens := t.Tokenize(args[0].String())
	for _, v := range tokens {
		//fmt.Printf("%s\t%+v%v\n", v.Surface, v.POS(), strings.Join(v.Features(), ","))
		ret = append(ret, map[string]interface{}{
			"word_id":       v.ID,
			"word_type":     v.Class.String(),
			"word_position": v.Start,
			"surface_form":  v.Surface,
			"pos":           strings.Join(v.POS(), ","),
			"base_form":     igOK(v.BaseForm()),
			"reading":       igOK(v.Reading()),
			"pronunciation": igOK(v.Pronunciation()),
		})
	}
	return ret
}

func registerCallbacks() {
	_ = ipa.Dict()
	js.Global().Set("kagome_tokenize", js.FuncOf(tokenize))
}

func main() {
	c := make(chan struct{}, 0)
	registerCallbacks()
	println("Kagome Web Assembly Ready")
	<-c
}

関数を作成する

func(this Value, args []Value) interface{} をインターフェースとする任意の関数を作成できます。今回は、tokenize(_ js.Value, args []js.Value) interface{} という関数を作成しました。

注意したいのは var ret []interface{} を返値をまとめあげて返していますが、この型は実質的には []map[string]string です。関数の返値の型は interface{} なので、最初から var ret []map[string]string などとしてもよさそうに思えますが、この型で宣言すると、js から呼び出した際に js.ValueOf() でエラーになります。js.ValueOf() は Go のデータと js のデータを変換するような関数で、次のように定義されています。

ブラウザで実行したときに ValueOf: invalid value とメッセージが出ている場合は変換で何か起こっている可能性が高いです。この js.ValueOf() で変換の場合分けにないような型の利用をしないことでエラーを回避できるようです。[1]

また Go の error を返すと ValueOf: invalid value が発生してしまうようなので適当なメッセージとかを返すようにする必要がありそうです。[2]

func ValueOf(x interface{}) Value {
	switch x := x.(type) {
	case Value: // should precede Wrapper to avoid a loop
		return x
	case Wrapper:
		return x.JSValue()
	case nil:
		return valueNull
	case bool:
		if x {
			return valueTrue
		} else {
			return valueFalse
		}
	case int:
		return floatValue(float64(x))
	case int8:
		return floatValue(float64(x))
	case int16:
		return floatValue(float64(x))
	case int32:
		return floatValue(float64(x))
	case int64:
		return floatValue(float64(x))
	case uint:
		return floatValue(float64(x))
	case uint8:
		return floatValue(float64(x))
	case uint16:
		return floatValue(float64(x))
	case uint32:
		return floatValue(float64(x))
	case uint64:
		return floatValue(float64(x))
	case uintptr:
		return floatValue(float64(x))
	case unsafe.Pointer:
		return floatValue(float64(uintptr(x)))
	case float32:
		return floatValue(float64(x))
	case float64:
		return floatValue(x)
	case string:
		return makeValue(stringVal(x))
	case []interface{}:
		a := arrayConstructor.New(len(x))
		for i, s := range x {
			a.SetIndex(i, s)
		}
		return a
	case map[string]interface{}:
		o := objectConstructor.New()
		for k, v := range x {
			o.Set(k, v)
		}
		return o
	default:
		panic("ValueOf: invalid value")
	}
}

作成した関数を js から呼び出せるように登録しておく

	js.Global().Set("kagome_tokenize", js.FuncOf(tokenize))

このようにしておくと、tokenize 関数が js から kagome_tokenize という関数名で呼び出せます。

メインの書き方

これは定型なのでこうしておけばいいです。チャンネルはプログラムが終了してしまわないように待機するために利用されています。
ちなみに println() すると(ブラウザの)コンソールに出力されます。console.log() と同じ効果があります。

func main() {
	c := make(chan struct{}, 0)
	registerCallbacks()
	println("Kagome Web Assembly Ready")
	<-c
}

本当に console.log() を呼びたい場合には次のように出来ます。

    console := js.Global().Get("console")
    console.Call("log", "hello, goodbye")

Go を WASM としてビルドする

GOOS=js GOARCH=wasm go build -trimpath -o kagome.wasm main.go

HTML の js から呼び出す

Web ページから呼び出すためには、ビルドした wasm ファイル(kagome.wasm)だけでなく、Go が配布している wasm_exec.js も読み込んでおく必要があります。wasm_exec.js は Go をインストールしたディレクトリの misc/wasm 下にあります(私の環境では ~/sdk/go1.16.2/misc/wasm でした)。これを HTML と同じフォルダに放り込んでおきます。

$ ls -l ~/sdk/go1.16.2/misc/wasm/
total 56
-rwxr-xr-x  1 ikawaha  staff    441  3 12 02:15 go_js_wasm_exec*
-rw-r--r--  1 ikawaha  staff   1303  3 12 02:15 wasm_exec.html
-rw-r--r--  1 ikawaha  staff  18147  3 12 02:15 wasm_exec.js

今回作成した HTML は次のようになります(一部省略)。→ 完全版

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="utf-8">
  <title>Kagome WebAssembly Demo - Japanese morphological analyzer</title>
</head>
<body>

<!-- loading... -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/pace/1.0.2/pace.min.js"></script>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/pace/1.0.2/themes/black/pace-theme-corner-indicator.min.css" />

<!-- wasm kagome -->
<script src="wasm_exec.js"></script>
<script>
    if (!WebAssembly.instantiateStreaming) { // polyfill
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
            const source = await (await resp).arrayBuffer();
            return await WebAssembly.instantiate(source, importObject);
        };
    }
    const go = new Go();
    WebAssembly.instantiateStreaming(fetch("kagome.wasm"), go.importObject).then((result) => {
        go.run(result.instance);
    }).catch((err) => {
        console.error(err);
    });
</script>


<div id="center">
  <h1>Kagome WebAssembly Demo</h1>
  <!--
  <a href="https://github.com/ikawaha/kagome.ipadic/blob/gh-pages/wasm_sample.go">=>source code</a>
  -->
  <form class="frm" oninput="tokenize()">
    <div id="box">
      <textarea id="inp" class="txar" rows="3" name="s" placeholder="Enter Japanese text below."></textarea>
    </div>
  </form>

  <table class="tbl">
    <thread><tr>
      <th>Surface</th>
      <th>Part-of-Speech</th>
      <th>Base Form</th>
      <th>Reading</th>
      <th>Pronunciation</th>
    </tr></thread>
    <tbody id="morphs">
    </tbody>
  </table>
</div>

<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.0/jquery.min.js"></script>
<script>
    function tokenize() {
        var s = document.getElementById("inp").value;
        ret = kagome_tokenize(s)
        $("#morphs").empty();
        $.each(ret, function(i, val) {
            console.log(val);
            var pos = "*", base = "*", reading = "*", pronoun = "*";
            $("#morphs").append(
                "<tr>"+"<td>" + val.surface_form + "</td>" +
                "<td>" + val.pos + "</td>"+
                "<td>" + val.base_form + "</td>"+
                "<td>" + val.reading + "</td>"+
                "<td>" + val.pronunciation + "</td>"+
                "</tr>"
            );
        });
    }
</script>

</body>
</html>

wasm をロードする部分

<!-- wasm kagome -->
<script src="wasm_exec.js"></script>
<script>
    if (!WebAssembly.instantiateStreaming) { // polyfill
        WebAssembly.instantiateStreaming = async (resp, importObject) => {
            const source = await (await resp).arrayBuffer();
            return await WebAssembly.instantiate(source, importObject);
        };
    }
    const go = new Go();
    WebAssembly.instantiateStreaming(fetch("kagome.wasm"), go.importObject).then((result) => {
        go.run(result.instance);
    }).catch((err) => {
        console.error(err);
    });
</script>

Go のライブラリに入っていた wasm_exec.html からコピーして来たコード。元のコードは async/await だったけど、これで最低限は大丈夫そうだ(js なにもわからない)[3]

用意した形態素解析を呼び出す部分

入力された文字列を逐次形態素解析して、DOM に加えていきます。形態素解析の関数は、Go 側で定義した kagome_tokenize() という関数が利用できます。返値の形式は []map[string]string になってるのでそれを利用します。

<script type="text/javascript" src="https://ajax.googleapis.com/ajax/libs/jquery/1.6.0/jquery.min.js"></script>
<script>
    function tokenize() {
        var s = document.getElementById("inp").value;
        ret = kagome_tokenize(s)
        $("#morphs").empty();
        $.each(ret, function(i, val) {
            console.log(val);
            var pos = "*", base = "*", reading = "*", pronoun = "*";
            $("#morphs").append(
                "<tr>"+"<td>" + val.surface_form + "</td>" +
                "<td>" + val.pos + "</td>"+
                "<td>" + val.base_form + "</td>"+
                "<td>" + val.reading + "</td>"+
                "<td>" + val.pronunciation + "</td>"+
                "</tr>"
            );
        });
    }
</script>

gh-pages にアップする

必要ファイル一式をリポジトリの docs フォルダにおいて Setting から指定すれば公開されます。

デモ:https://ikawaha.github.io/kagome/

最初、kagome.wasm を読み込むのに時間がかかりますが(15MBほどある)、読み込まれたあとは割とキビキビ動きました。

Happy hacking!

脚注
  1. たぶん ↩︎

  2. たぶん ↩︎

  3. コメントいただけるとありがたいです。 ↩︎