Rust と WebAssembly で爆速な Markdown Editor を作ってみる

11 min read読了の目安(約10700字

Bench tab of Markdown Editor

TypeScript でフロントエンドな皆さん、今日も型パズルしていますか?
型が好きなら Rust ですよね。ということで、いまいち使い所が思いつかない WebAssembly と Rust でなにか面白いことできないかな、と爆速(JS比2倍)の Markdown Editor を作ってみました(面倒な人は直接 GitHub みてください)

この記事は iCARE Advent Calendar 2020 の 8日目の記事になります。昨日は アオキタカユキ さんの 【adobe XD】線型、円型のグラデーションを引く方法 という記事でした

Webアプリケーションを作る

まず、利用する側の Web アプリケーションを作成します
どんな構成でもいいんですが、タイミング的にもちょうど良い Vue3 + TypeScript で環境を作りました

$ npm install -g @vue/cli
$ vue create my-wasm-rust-project
$ cd my-wasm-rust-project
$ npm install
$ npm run serve

これで localhost:8080 にアクセスできると思います。爆速

Rust環境を作る

多分、あなたの Rust 環境は古くなっているのでアップデートします(もしくは インストール してください)

$ rustup update

cargo new

最新になったら、早速 Rust を開発していきましょう
今回は Webアプリケーションから利用するので、同じレポジトリでディレクトリを切って開発していくことにしましょう

$ cargo new --lib wasm-markdown
$ cd wasm-markdown
$ cargo build

さくっとビルドできたと思います。爆速

wasm-pack

さくさくと WebAssembly の開発環境を整えていきましょう

Rust は通常は cargo コマンドでビルドを行いますが、 WebAssembly については便利にいろんなことをよしなにしてくれる wasm-pack というツールがあるのでインストールしていきましょう

$ curl https://rustwasm.github.io/wasm-pack/installer/init.sh -sSf | sh

環境によって違うので、詳しくは 公式 を見てください

Cargo.toml

次に、必要なライブラリを追加してきます
JavaScript における package.json に当たるのがこの Cargo.toml です
自動生成されているので、エディタで編集していきます

...

[dependencies]
js-sys = "0.3.40" # wasm-bindgen 経由で JavaScript の API を使うためのライブラリ
wasm-bindgen = "0.2.63" # WebAssembly 経由で Rust を呼び出せるようにするためのブリッジを提供する

...

[dependencies.web-sys] # wasm-bindgen 経由で Web API を使うためのライブラリ
features = [
  'Window',
]
version = "0.3.4"

...

[dependencies] に利用したいライブラリを記載します
[dependencies.web-sys] はオプションを指定して web-sys を使うよ、という宣言で、ここでは Window 機能を使うよーと言っています。セクションを分けずに web-sys = {version = "0.3.40", features = ['Window']} を直接 [dependencies] に記載しても大丈夫です。 web-sys の features は増えていくので、分割しておいた方がいいという心遣いだと思います

ライブラリを探す

早速、マークダウンを処理する Rust製のライブラリを探します。まだ一行もコードを書いていない気がしますが、気にしません
普通に "rust markdown parser" とかで検索したらいいですが、 npm みたいな crates.io というレジストリがあるので、検索したらいい感じの物が見つかると思います

https://crates.io/keywords/markdown

ダントツ人気の pulldown-cmark を使ってみることにします

...

[dependencies]
pulldown-cmark = {version = "0.8", default-features = false}

...

先ほどと同様に Cargo.toml に追加します
Rust 界隈はみんなとても親切なので、 README.md を読むと丁寧に使い方を書いてくれています
ここでは、「バイナリ出力必要なかったらこのオプションつけてね」と書いてったのでそのようにしていますが、わからなかった気にせずに crate名 = バージョン番号 で問題ないです

ビルドしてみる

一通り必要な物が揃ったので、ビルドしてみましょう

$ wasm-pack build
Error: crate-type must be cdylib to compile to wasm32-unknown-unknown. Add the following to your Cargo.toml file:

[lib]
crate-type = ["cdylib", "rlib"]

何か怒られました。おまじないとしてエラーメッセージの最後の2行を Cargo.toml に追加しましょう

$ wasm-pack build

今度はうまくいきました、追加されたライブラリを取得してビルドするので結構遅いです(特に wasm-bindgen 関連)。ビルドが重いのが Rust の辛み。 Not 爆速

RustとWebアプリケーションをつなげる

pkg

ビルドがうまくいくと、 pkg/ というディレクトリができているはずです
現時点ではコードを何も書いていないので中身はほぼ空で、 .wasm ファイルを import して export しているだけのファイルとなっています

$ tree pkg
pkg
├── package.json
├── wasm_markdown.d.ts
├── wasm_markdown.js
├── wasm_markdown_bg.js
├── wasm_markdown_bg.wasm
└── wasm_markdown_bg.wasm.d.ts

0 directories, 6 files

@wasm-tool/wasm-pack-plugin

フロントエンドのビルド時に wasm-pack を走らせていい感じにしてくれるプラグインを導入します

$ npm install --save-dev @wasm-tool/wasm-pack-plugin

webpack.config にも追加します。今回は Vue3 で作ったので vue.config.jsconfigureWebpack に以下を追記しました

plugins: [
  new WasmPackPlugin({
    crateDirectory: path.join(__dirname, 'wasm-markdown/pkg'),
    withTypeScript: true,
  }),
],

Rust側の実装を行う

ここまでコードは一行も書いていませんが、ようやく準備ができました
大変そうだなぁ、、、という感じかもしれませんが、初回の wasm-pack の実行以外はどれも爆速で終わります。ビルドに関しても 2回目以降はそこまで遅くないので安心しましょう

では早速、 Rust 側の src/lib.rs にコードを書いていきます

use pulldown_cmark::{html, Options, Parser};
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub fn pulldown_cmark(source_text: &str) -> String {
    let markdown_input = source_text;

    // Set up options and parser. Strikethroughs are not part of the CommonMark standard
    // and we therefore must enable it explicitly.
    let mut options = Options::empty();
    options.insert(Options::ENABLE_STRIKETHROUGH);
    let parser = Parser::new_ext(markdown_input, options);
    // Write to String buffer.
    let mut html_output = String::new();
    html::push_html(&mut html_output, parser);
    html_output
}

ちなみに、関数の中は README.md を丸パクリです
別のライブラリだとさらに短くて一行で済みます。爆速ですね

#[wasm_bindgen]
pub fn markdown_rs(source_text: &str) -> String {
    markdown::to_html(source_text)
}

では早速。。。

$ cd wasm-markdown
$ wasm-pack build

....

Fatal: error in validating input
Error: failed to execute `wasm-opt`: exited with exit code: 1
  full command: "/Users/your.name/Library/Caches/.wasm-pack/wasm-opt-a528729925722b63/wasm-opt" "/Users/your.name/tmp/sample/pkg/wasm_markdown_bg.wasm" "-o" "/Users/your.name/tmp/sample/pkg/wasm_markdown_bg.wasm-opt.wasm" "-O"
To disable `wasm-opt`, add `wasm-opt = false` to your package metadata in your `Cargo.toml`.

ビルド失敗?!

これでビルドしようとすると怖い画面が表示された上で、エラーでビルド失敗します。 Rust 怖い。。。
ぼくはめちゃくちゃはまったんですが、皆さんは気にせず、以下のおまじないを Cargo.toml に記載してください

[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-mutable-globals"]

[package.metadata.wasm-pack.profile.dev]
wasm-opt = ["-Oz", "--enable-mutable-globals"]

以下の環境で発生。 issue は立っており、そのうち修正されるみたいです
cargo 1.48.0 (65cbdd2dc 2020-10-14)
wasm-bindgen = "0.2.63"
pulldown-cmark = "0.8"

wasm-bindgen

では、ビルドしてみましょう

$ wasm-pack build

先ほどの pkg/ の中が更新されたと思うので更新された型定義ファイルを見てみましょう

# pkg/wasm_markdown.d.ts
/* tslint:disable */
/* eslint-disable */
/**
* @param {string} source_text
* @returns {string}
*/
export function pulldown_cmark(source_text: string): string;

これ、先ほど Rust 側で実装した以下の関数と対応しています

pub fn pulldown_cmark(source_text: &str) -> String { ... }

このように、関数に対して #[wasm_bindgen] マクロを指定すると、引数と返り値を受け渡すブリッジ関数を自動で用意してくれます
lib.rs の引数と返り値をいろいろな値に変更して試してみてください。なお、互換性がある型とない型があるので間違った型を指定するとビルドエラーが発生します(参考: 引数で使える型, 返り値で使える型 )

ちなみに関数の実体はこんな感じですが、読む必要はないかと思います

/**
* @param {string} source_text
* @returns {string}
*/
export function pulldown_cmark(source_text) {
    try {
        const retptr = wasm.__wbindgen_export_0.value - 16;
        wasm.__wbindgen_export_0.value = retptr;
        var ptr0 = passStringToWasm0(source_text, wasm.__wbindgen_malloc, wasm.__wbindgen_realloc);
        var len0 = WASM_VECTOR_LEN;
        wasm.pulldown_cmark(retptr, ptr0, len0);
        var r0 = getInt32Memory0()[retptr / 4 + 0];
        var r1 = getInt32Memory0()[retptr / 4 + 1];
        return getStringFromWasm0(r0, r1);
    } finally {
        wasm.__wbindgen_export_0.value += 16;
        wasm.__wbindgen_free(r0, r1);
    }
}

import

記事は長くなってきましたが、ここまでに書いたコードは数行+コピペですね

最後に Webアプリケーション側の実装です
App.vue とかがあると思うので以下の内容を書きます

<template>
  <textarea @change="convert" v-model="inputText"></textarea>
</div>
</template>

<script lang="ts">
import { defineComponent, ref } from 'vue';
import { pulldown_cmark } from '../wasm-markdown/pkg/wasm_markdown';

let wasmContainer: { pulldown_cmark: typeof pulldown_cmark };
import('../wasm-markdown/pkg').then(wasm => (wasmContainer = wasm));

export default defineComponent({
  name: 'App',
  components: {},
  setup() {
    const inputText = ref('');
    const convert = () => {
      console.log(wasmContainer?.pulldown_cmark(inputText.value));
    };
    return { convert, inputText };
  },
});
</script>

解説

import { pulldown_cmark } from '../wasm-markdown/pkg/wasm_markdown';

let wasmContainer: { pulldown_cmark: typeof pulldown_cmark };
import('../wasm-markdown/pkg').then(wasm => (wasmContainer = wasm));

ここで読み込んで

console.log(wasmContainer?.pulldown_cmark(inputText.value));

ここで実行

流石にちょっと荒っぽい書き方なので、本来はエントリーポイントで import して Vue インスタンスに provide で差し込むなりするのが良いと思います(参考

注意事項があって WebAssembly のバイナリを含んだ JavaScript ファイルは動的に読み込む必要があります 。具体的には import('path/to/wasm).then(wasm => { ... } で記述する必要があり、こうしておかないとビルド時に怒られます
ちなみにぼくは、色々試している中でコンポーネントで直接 import 書いたのを忘れてビルドできないー!?と数時間を溶かしました

これで完成です。ブリッジ部分はほぼ Rust 1行 + TypeScript 1行で済みましたね

結果は?

そのほかゴニョゴニョして完成したのが このアプリケーション です
で、結局どれくらい早くなったの???というと Markdown の変換処理に関しては 2倍 でした
これなら死ぬほど長いマークダウンファイルをリアルタイムにレンダリングしても辛くないですね

Markdown Editor

以下にライブラリ別のパフォーマンスのスナップショットをまとめました。ぜひ皆さんも試してみてください
ソースコード一式は このレポジトリ にあります

処理 言語 パフォーマンス(ms)
100万文字の逆順変換 JavaScript 100.44999999809079
100万文字の無変換受け渡し Rust 12.214999995194376
100万文字の逆順変換 Rust 24.069999999483116
marked によるマークダウン変換 Javascript 8.369999995920807
pulldown-cmark によるマークダウン変換 Rust 4.19000000692904
markdown.rs によるマークダウン変換 Rust 58.185000001685694
comrakによるマークダウン変換 Rust 6.7149999958928674

逆順変換が鬼のように速いですね。マルチバイトコードなどについて考慮していないので当然といえば当然ですが
Rust のライブラリについてもすべてが速いわけではなく、 markdown.rs では大きく下回っています。 comrak についてもタイミング次第では marked と同じくらいのスピードの場合も多く、一概に Rust なら速いというわけにはいかないようです

最後に

今回のように「呼び出して返り値を受け取るだけのライブラリを利用する」ケースであれば rust-wasm の導入はかなり気軽にできることが分かりました。パフォーマンスを考えてライブラリの選定を行わなければいけない時に Rust製のライブラリを候補に入れることができるとかなり可能性を広げることができるのではないでしょうか

文字列のバインディングを行うということで、受け渡しのパフォーマンスが悪いかも、、、と懸念していましたが、そんなこともなく(サンプルを見ていただくと分かりますが、 100万文字を単純に渡して受け取るだけの関数は 15ms くらいで実行できています)

JS reverse = 416.69999998714775, Rust strait = 15.830000004731119, Rust reverse = 19.849999996833503

パフォーマンスについては、この記事の準備をしている途中で頭を抱えたちょっとした罠があるのですが、そこだけ気をつければ Rust + WebAssembly はフロントエンドエンジニアにとって、今日にでも使える技術だということが分かりました

明日は masaya さんの記事です。乞うご期待!
iCAREテックブログ もよろしくお願いします!