🦀

Rust + wasmでJS向けの形態素解析ライブラリを作ってnpmで公開する

2021/12/24に公開

この記事はRust Advent Calendar 2021 (カレンダー1)の8日目の代打記事です。

この記事でわかること

  • 💪wasm-pack最強💪
  • lindera-jsというライブラリを作った経験を通じて、既存のRust製ライブラリをnpmのパッケージにして公開するまでの手順がわかります
  • lindera-jsはパッケージサイズの問題があるのでまだ、限られたケース(読み込みの時間が気にならないケース)でしか使えなさそう

出来上がったもの

Rust製の形態素解析ライブラリであるLinderaをjavascript/typescriptから呼び出すことが出来るライブラリを作ってnpmで公開しました。

https://github.com/higumachan/lindera-js

https://www.npmjs.com/package/lindera-js

npmからインストールしたら、

import * as lindera from "lindera-js";

console.log(lindera.tokenize("関西国際空港限定トートバッグ"));

こんな感じで簡単にJSでトークナイズが出来ます。
一応typescriptにも対応してると思います。(未確認)

動機

この記事を読んで最近のフロント開発と融和しやすい形態素解析ライブラリがあると良さそう。
そのなかで、Javascriptで動く形態素解析のライブラリが知っている限りだとkuromoji-jsしかなくて、TS対応とかがまだだったりしたのでせっかくだからWASMで新しいのを公開して見ようかと思って作り始めました。

作り方

ライブラリ選定

linderaは昔twitterで見たので覚えていたので調査をはじめました。
WASMで動くかどうかの確認はバンクシさんの公開していたリポジトリ で確認が取れたのでnpmのパッケージにするだけで良さそうという感じになりました。

wasm-packのテンプレートを持ってくる

Rust+wasmのプロジェクトを立ち上げ方はここを参考にしてはじめました。

cargo generate --git https://github.com/rustwasm/wasm-pack-template

コードを書く

今回書くコード自体はライブラリを利用した簡単なRustのコードなので難しくありません。

lazy_static! {
    static ref TOKENIZER: Mutex<Tokenizer> = Mutex::new(Tokenizer::new().unwrap());
}

pub fn tokenize(input_text: &str) -> JsValue {
    console_error_panic_hook::set_once();
    let tokens = TOKENIZER.lock().unwrap().tokenize(input_text).unwrap();

    JsValue::from_serde(
        &tokens
            .iter()
            .enumerate()
            .map(|(i, x)| detail_to_kuromoji_js_format(i as u32, &x))
            .collect::<Vec<_>>(),
    )
    .unwrap()
}

こんな感じで書きました。
工夫が必要だったポイントは、

  • TOKENIZERを毎回生成するのではなくて、lazy_staticで最初に生成して使い回す。(最近のおすすめのonce_cellはうまく行かなかったので引き続き検証)
  • Tokenizer::tokenize&mut selfを要求するためMutexで囲む

という感じにすることで、毎回の生成コストを下げながら呼び出し側をシンプルに保つようにしました。

返り値としては以下のようにkuromoji-jsと同じ形のObjectを返すようにしました。

#[derive(Serialize)]
pub struct KuromojiJSFormatToken<'a> {
    word_id: Option<u32>,
    word_type: &'a str,
    word_position: u32,
    surface_form: &'a str,
    pos: &'a str,
    pos_detail_1: &'a str,
    pos_detail_2: &'a str,
    pos_detail_3: &'a str,
    conjugated_type: &'a str,
    conjugated_form: &'a str,
    basic_form: &'a str,
    reading: &'a str,
    pronunciation: &'a str,
}

typescriptの型をつける

Rust+Wasmのライブラリを作るときにtypescriptの型定義ファイルを作る方法はいくつかあるのですが、いろいろ試した結果ズルめの方法を利用して楽をしました。

#[wasm_bindgen(typescript_custom_section)]
const TS_KUROMOJI_JS_TOKEN: &'static str = r#"
interface KuromojiJSToken {
    word_id: number | null,
    word_type: string,
    word_position: number,
    surface_form: string,
    pos: string,
    pos_detail_1: string,
    pos_detail_2: string,
    pos_detail_3: string,
    conjugated_type: string,
    conjugated_form: string,
    basic_form: string,
    reading: string,
    pronunciation: string,
}
export function tokenize(input_text: string): KuromojiJSToken;
"#;

#[wasm_bindgen(skip_typescript)]
pub fn tokenize(input_text: &str) -> JsValue {
    /* ... */
}

このような感じで、tokeninzeのシグニチャ(であってるかな?)をベタ書きしました。
具体的には

#[wasm_bindgen(skip_typescript)]wasm_bindgenが型定義ファイルを書くときにtokenizeを無視させて、#[wasm_bindgen(typescript_custom_section)]を利用して用意したシグニチャを型定義ファイルに書き出すという方法です。

ビルドする

ビルドするのは簡単で

wasm-pack build

で大丈夫です。
これを行うと、以下のようなファイルが出来ます。
このディレクトリがすでにnpmで取り扱えるpackageになっています。

pkg
├── LICENSE_MIT
├── README.md
├── lindera_js.d.ts
├── lindera_js.js
├── lindera_js_bg.js
├── lindera_js_bg.wasm
├── lindera_js_bg.wasm.d.ts
└── package.json

ローカルでインストールする場合は

npm i `path/to/lindera-js/pkg`

というふうにすれば特定のnpmパッケージにインストールすることが出来ます。(多分yarnでもいけます)

参照:

https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/building-your-project.html

npmに公開する

これも簡単で

wasm-pack login

を行って

wasm-pack publish

で公開できます。
npmのバージョンはCargo.tomlに書いてあるバージョンと同一になります。

参照:

https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/packaging-and-publishing.html

テストを書く

#[cfg(test)]
mod tests {
    use super::*;
    use serde_json::Value;
    use wasm_bindgen_test::wasm_bindgen_test;

    #[wasm_bindgen_test]
    fn test_tokenize() {
        let t = tokenize("関西国際空港限定トートバッグ");
        let tokens: Vec<Value> = t.into_serde().unwrap();

        assert_eq!(tokens.len(), 3);
        assert_eq!(tokens[0].get("surface_form").unwrap(), "関西国際空港");
        assert_eq!(tokens[1].get("surface_form").unwrap(), "限定");
        assert_eq!(tokens[2].get("surface_form").unwrap(), "トートバッグ");
    }
}```

こんな感じでいつもの`#[test]`の代わりに`#[wasm_bindgen_test]`に変えるだけでテストが書けます。

テストの実行は

```bash
wasm-pack test --node

で実行できます。

ブラウザベースでテストしたいなら

wasm-pack test --chrome

とかでやれば出来ます。

参照:

https://rustwasm.github.io/docs/wasm-bindgen/wasm-bindgen-test/index.html
https://rustwasm.github.io/docs/wasm-pack/tutorials/npm-browser-packages/testing-your-project.html

課題点と今後やりたいこと

パッケージサイズがデカい

これをみておわかりでしょうか。

なんとパッケージサイズが73.1MBとなっています。
この状態では、このパッケージを含んだ途端に配布ファイルのサイズがデカくなってしまうため、toC向けのサービスでは厳しい可能性が高いです。

原因はlinderaは辞書を内部に埋め込む為です。
もちろん、linderaが悪いわけではなく今回のような使い方をするなら、以下の工夫を行っていかないといけないと考えます。

  • 辞書の圧縮
  • 辞書の遅延ロード
  • 辞書の分割とかデバイスに毎に読み分ける

これは今後の課題として取り組んでいこうかと思っています。

typescriptと融和したTokenの返り値型を定義できていない

現状だとあくまでkuromoji.jsコンパチの結果を返して、それに型をつけただけです。
ですので、Typescriptの型システムに融和するような返り値型を定義していきたいと思っています。

ベンチマークを取ってみたい

今回は(将来的な)利便性の観点(+趣味)からkuromoji.jsを利用せずに自作して公開しました。

速度的な利点もあるなら、嬉しいので速度を比較してみたいと思っています。
linderaはもともとはkuromoji-rsという名前で公開されていたらしいのでアルゴリズム的には近いのではないかと思っているので、わりと良い感じにフェアで実用的なコードでのJS vs WASMでの比較になってくれるかと思います。

Discussion