Rust + wasmでJS向けの形態素解析ライブラリを作ってnpmで公開する
この記事はRust Advent Calendar 2021 (カレンダー1)の8日目の代打記事です。
この記事でわかること
- 💪
wasm-pack
最強💪 - lindera-jsというライブラリを作った経験を通じて、既存のRust製ライブラリをnpmのパッケージにして公開するまでの手順がわかります
- lindera-jsはパッケージサイズの問題があるのでまだ、限られたケース(読み込みの時間が気にならないケース)でしか使えなさそう
出来上がったもの
Rust製の形態素解析ライブラリであるLinderaをjavascript/typescriptから呼び出すことが出来るライブラリを作ってnpmで公開しました。
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でもいけます)
参照:
npmに公開する
これも簡単で
wasm-pack login
を行って
wasm-pack publish
で公開できます。
npmのバージョンはCargo.tomlに書いてあるバージョンと同一になります。
参照:
テストを書く
#[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
とかでやれば出来ます。
参照:
課題点と今後やりたいこと
パッケージサイズがデカい
これをみておわかりでしょうか。
なんとパッケージサイズが73.1MBとなっています。
この状態では、このパッケージを含んだ途端に配布ファイルのサイズがデカくなってしまうため、toC
向けのサービスでは厳しい可能性が高いです。
原因はlindera
は辞書を内部に埋め込む為です。
もちろん、linderaが悪いわけではなく今回のような使い方をするなら、以下の工夫を行っていかないといけないと考えます。
- 辞書の圧縮
- 辞書の遅延ロード
- 辞書の分割とかデバイスに毎に読み分ける
これは今後の課題として取り組んでいこうかと思っています。
typescriptと融和したTokenの返り値型を定義できていない
現状だとあくまでkuromoji.js
コンパチの結果を返して、それに型をつけただけです。
ですので、Typescriptの型システムに融和するような返り値型を定義していきたいと思っています。
ベンチマークを取ってみたい
今回は(将来的な)利便性の観点(+趣味)からkuromoji.js
を利用せずに自作して公開しました。
速度的な利点もあるなら、嬉しいので速度を比較してみたいと思っています。
lindera
はもともとはkuromoji-rs
という名前で公開されていたらしいのでアルゴリズム的には近いのではないかと思っているので、わりと良い感じにフェアで実用的なコードでのJS vs WASMでの比較になってくれるかと思います。
Discussion