本記事はsudachiclone-rs開発の続編であり、Rustのコードをwasmにし、WebWorker内で状態を持ったまま使いつつ、Typescriptで開発する、ということをやる場合にハマりそうなところに悉くハマった気がするので、その備忘録です。
成果物
レポジトリ: Rustで開発したsudachiclone-rsをwasmにコンパイルし、WebWorker内で動くようにしたもののデモ。
デモページ: 上記をhostingしています。pipから辞書を取ってきてブラウザ上のみでわかち書きできるデモ。
使ったツール群
Rustのstructやfunctionからtypescriptのd.ts
を生成してくれる優れもの。crates.ioにあるのもを使っていないのは今のRustでは動かないから。(詳しくはissue参照)
色んな人が解説してるので詳しくは割愛。wasmではそのまま扱えない文字列やObjectのやりとりを手助けしてくれるやつ。
デバッグでめっちゃお世話になりました。Rust内で起こったエラーをconsole.error
に良い感じに吐き出してくれる。
これも詳しくは割愛。webpackのbuild時にrustのbuildをしてくれる。
これもまた詳しくは割愛。WebWorkerとmain threadは通常postMessageでやりとりする必要があり、これを書くのが面倒だが、このライブラリはその辺良い感じにラップしてくれる。
WebWorkerをロードするためのパスをwebpackのビルドに合わせて良い感じに変えてくれるやつ。
cargo wasi build
とかcargo wasi test
とかできるようにしてくれる。これのおかげで実際のブラウザなしでテストなどが可能になっている。
ハマったポイントその1 < wasmで動かないrustのコード編 >
まず最初にやることは、既存のRustのコードがwasm/wasiにコンパイルしてみてテストする、ということ。
sudachiclone-rsはローカルに置いてある辞書ファイルのあるディレクトリにシンボリックリンクを張り読み込む、ということをしていたのですが、これに使っていたsymlinkは内部でcfg
を使い、target_os
によってコードを切り替えることで各OSで動くようになっていました。
wasm/wasiにコンパイルすると、target_os
がwasm32/wasiになり、#[cfg(target_od = unix))]
みたいなコードはコンパイルされなくなります。
つまり、use symlink::{remove_symlink_dir, symlink_dir};
としようとしてもunresolved imports
が起きてしまいます。
ブラウザ上で動かす時はremove_symlink_dir
やsymlink_dir
が呼ばれることはないので、実際には問題ないのですが、コンパイルは通す必要があります。
そこでこんな感じで適当にエラーを返す関数を置いておくことで、target_os
がsymlink内の関数をコンパイルしない場合にも見た目上同じ型の関数があるようになり、コンパイルが通せました。
#[cfg(any(target_os = "redox", unix, windows))]
use symlink::{remove_symlink_dir, symlink_dir};
#[cfg(not(any(target_os = "redox", unix, windows)))]
fn remove_symlink_dir<P: AsRef<Path>>(_path: P) -> Result<(), IOError> {
Err(IOError::new(
IOErrorKind::Other,
"can't call remove_symlink_dir",
))
}
#[cfg(not(any(target_os = "redox", unix, windows)))]
fn symlink_dir<P: AsRef<Path>, Q: AsRef<Path>>(_src: P, _dst: Q) -> Result<(), IOError> {
Err(IOError::new(IOErrorKind::Other, "can't call symlink_dir"))
}
ハマったポイントその2 < wasm-bindgenが生成する`d.ts`編 >
wasm-bindgenはデフォルトで下記のようなd.ts
を生成してくれます。
/* tslint:disable */
/* eslint-disable */
/**
* return TokenizeResult
* @param {string} text
* @returns {any}
*/
export function tokenize(text: string): any;
/**
* return ErrorValue
* @param {Uint8Array} system_dict_bin
* @param {Uint8Array} char_def_bin
* @returns {any}
*/
export function read_from_bin(system_dict_bin: Uint8Array, char_def_bin: Uint8Array): any;
見ての通り、返り値がany
になってしまっています。これだと何もわかりません。
本来jsから見たwasmの世界はArrayBufferしかありません。これをwasm-bindgenが良い感じにラップしてくれるのですが、やっていることはserdeを使ったserialize/deserializeです。
wasmとして露出する関数の返り値をstructにしたい場合、JsValue
で包んでやる必要があり、これがany
として解釈されます。
#[wasm_bindgen]
pub fn read_from_bin(system_dict_bin: Vec<u8>, char_def_bin: Vec<u8>) -> JsValue {
#[cfg(feature = "console_error_panic_hook")]
console_error_panic_hook::set_once();
let result = ErrorValue {
error: _read_from_bin(system_dict_bin, char_def_bin)
.map_err(|e| format!("{}", e))
.err(),
};
JsValue::from_serde(&result).unwrap()
}
理想的な解決としては、
-
ErrorValue
のtypeが定義される -
read_from_bin
の返り値の型としてErrorValue
のtypeが使われる
この両方が実現することですが、これは僕では無理でした。
なのでここれは、typescript-definition
によって1のみ実現することとしました。
#[derive(Serialize, TypescriptDefinition)]
pub struct ErrorValue {
error: Option<String>,
}
これだけです。
@wasm-tool/wasm-pack-plugin
は型定義ファイルを生成しますが、modeがrelease(webpackのproduction mode)だと#[wasm_bindgen]
の付いていないものは型定義ファイルにも出力されません。
では、出力したいstructにも#[wasm_bindgen]
付けて、プロパティにもpub
を付ければ、となるのですが、
the trait `wasm_bindgen::convert::traits::FromWasmAbi` is not implemented for `std::boxed::Box<[std::string::String]>`
という感じで怒られます。これはwasm-bindgen
がstructをそのままjsに露出する場合、アクセスにはwasmのABI boundaryから(ArrayBufferから)値を復元できることが必要になり、全てのプロパティにこれが必要になってしまうせいです。
今回の目的はstructのプロパティに直接jsからアクセスすることではなく、あくまで返り値の型定義が欲しいだけなので、そこまでする必要はありません。
なので、extraArgs
に--no-typescript
を追加し。
その上で、型定義を出力するためにyarn run build-tsd
をこんな感じで作り、webpackを走らせる前にビルドしてやります。
これでrust側の準備は完了です。
ハマったポイントその3 < comlink & WebWorker編 >
これについてはComlink + Rust で言語とスレッドの垣根を越えた WebAssembly 開発という大変優れた記事があるので、こちらを読んでみてください。
wasmのimportはPromiseを返すためawaitしないといけない部分なんかは、top levelでのawaitができるようになればだいぶ書くのが楽になりそうな気がするのですが、今回はinitializeされたかどうかのフラグを1つ作って管理しています。
あとは、typescriptで書く上でwasmの持つ関数(今回はread_from_binとtokenize)をComlink.expose
に渡すオブジェクトのプロパティとして持たせ、ここに型を持たせるためthisの型を指定しているところくらいでしょうか。
wasmの持つ型はwasm-bindgenの生成したd.ts
になるのですが、先述したように返り値がanyになってしまっているため、ここでtypescript-definition
で出力した型定義を返り値の型として指定することで、これ以降気にする必要がなくなります。
ハマりポイント番外編その1 < lazy_staticがSyncを要求してくる >
今回の本題とは逸れるのですが、作業量としてはこれの対応に一番時間がかかったハマりポイント。
後述する理由でTokenizerオブジェクトをwasm側でglobalなオブジェクトとして持っておく必要があり、lazy_staticを使おうとしたところ、TokenizerがSync
を満たす必要があり、つまり大体のものがSync
を満たす必要がでてきてしまいました。
今回はpythonからコピーした関係でRc<RefCell<T>>
をそこら中で使っており、これはSync
を満たしません。
そこで全てのRc<RefCell<T>>
をArc<Mutex<T>>
に変換し、.borrow()
を.lock()
に。
ここでdeadlockの問題が発生し、.lock()
を.try_lock()
に書き換えて2回目のロックを見つけ…というデバッグ作業を行いました。
(lock
は解放をひたすら待つが、try_lock
はその実行時に既に別にロックがされていた場合エラーを吐く)
ハマりポイント番外編その2 < untar編 >
sudachiの辞書をブラウザに持ってくるため、pypiに登録されているpipパッケージをfetchすることにしました。
pipパッケージはtar.gz形式で配布されとり、gzipはpakoで解凍できるのですが、tarはブラウザで動くものでtypescript型定義もあるものがなく、js-untarをtypescriptでコピーする必要がありました。
成果物はこちら。
感想
色々と嵌ったり作り方で迷ったりしたものの、ツールは大変充実してきており、ちょっとしたものでもrustで書いてWebWorkerに乗せるような開発は十分実用的な段階に来てる感じがありました。
wasmのコンパイル後のサイズですが、dev buildでは5MBあり、あーやっぱりこんなもんなのかーと思ったのですが、release buildではなんと400KBしかありません。(デモページで確認してみてください。)
デモページのフロントエンドはreact
でmaterial-ui
をちょっと使っただけのもので、これが90KBくらいなので、これならちょっと大きいフロントのライブラリくらいと強弁できるのではないでしょうか。
そして肝心のsudachiclone-rsのwasm版の実用性としては…皆無でした。
公開されている辞書のうち一番小さいsudachiclone_small
が40MBほどあり、これのfetchに手元で40秒弱かかります(そしてパースにも10秒ほど…)。
パースはコード側の問題もありそうなのですがfetchはどうにもなりません、とても実用的とは言えないでしょう。
その他
Q: なぜwasmをWebWorkerの中で使う必要があったのか
A: 今回ブラウザで動かしたかったsudachiclone-rsは数十MBある辞書を読み込み、与えられた日本語のテキストをわかち書きするというもの。
この辞書の読み込みやわかち書きの処理はmain threadで実行すると他の処理をブロックしてしまい、ページが完全に動かなくなってしまうため、処理を別スレッドに移したかった。
Q: なぜlazy_static? Tokenizer自体を毎度main pageに転送すれば良いのでは?
A: 辞書を読み込んだTokenizerオブジェクトはそれ自体がMB単位のサイズになってしまうため、これをtokenizeの度にwasmに転送し、そこからwasmに渡して、オブジェクトをパースして再構成するコストが厳しそうだった。(というのは言い訳で実際には試してはおらず、FromWasmAbiを満たすの難しそう…ということで諦めました)