LoginSignup
26
30

More than 3 years have passed since last update.

RustでwasmでWebWorkerでTypescriptな開発

Posted at

本記事は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_dirsymlink_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()
}

理想的な解決としては、

  1. ErrorValueのtypeが定義される
  2. 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しかありません。(デモページで確認してみてください。)
デモページのフロントエンドはreactmaterial-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を満たすの難しそう…ということで諦めました)

26
30
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
26
30