WebAssembly を使って自作言語をブラウザで動かしてみよう

今日 Google の開発者ブログで WebAssembly の記事が載っていました.どうやら最新の Chrome では WebAssembly が動くようです.

googledevjp.blogspot.jp

自作言語のコンパイラLLVM フロントエンドとしてつくっているので,これは試さないわけにはいきません.

github.com

というわけで,さっそく試してみます.

準備

1. Chrome

直接 V8 をビルドするのは億劫なので Chrome のバイナリを落としてきて使います.Chrome 51.0.2677.0 以降であれば OK です.Canary 版をダウンロードしてきてインストールします. 次に chrome:flags にアクセスして WebAssembly を有効にします.

2. LLVM

WebAssembly のためのアセンブリを吐くには LLVM の experimental な WebAssembly backend を有効にしてビルドする必要があります.Homebrew のフォーミュラをいじって使います.

$ brew edit llvm
# ここで cmake の引数に -DLLVM_EXPERIMENTAL_TARGETS_TO_BUILD=WebAssembly を追加
$ brew install llvm --HEAD

3. binaryen

binaryenLLVM IR などを WebAssembly 形式に落とすコンパイラです.適当に clone してきて cmakemake でビルドすると bin ディレクトリ内にいくつかのコマンドが生成されます.

4. Dachs

誰も試さないと思いますが一応… clone してきて cmakemake すると dachs というバイナリができているはずです.

いざ試してみる

Dachs は Boehm GC を使って配列を割り当てています.本当は BrainFxxk のサンプルを動かしたいなぁと思っていたのですが,バッファを static に取るとうまく動かなくなったので諦めました.

今回はシンプルな sqrt2.dcs を使います.

func abs(n)
    ret if n > 0.0 then n else -n end
end

func sqrt'(p, z, x)
    ret z if abs(p-z) < 0.00001
    ret sqrt'(z, z-(z*z-x)/(2.0*z), x)
end

func sqrt(x)
    ret sqrt'(0.0, x, x)
end

func main
    print(sqrt(10.0))
end

まずはコンパイルします.アセンブリをそのまま吐くオプションは無いので,まずは LLVM IR にコンパイルします.

$ dachs sqrt2.dcs --emit-llvm > sqrt2.ll

ここで少し sqrt2.ll を修正します.

  • Dachs は単体で動作する汎用言語のため main 関数がある前提ですが,WebAssembly は LLVM IR の module をコンパイルして生成するため不要な エントリポイントである main 関数を削除します.
  • mangling をサボっているので Dachs のコンパイラが吐く LLVM IR の関数名がひどくそのままでは使えないので少し関数名を修正します(C と同じ mangle 名).

sqrt2.ll

さらに llc を使ってアセンブラ形式に落とします.

$ /usr/local/opt/llvm/bin/llc sqrt2.ll -march=wasm32
$ cat sqrt2.s

sqrt2.s

これで準備ができました.binaryen を使ってアセンブリを wasm32 形式にコンパイルします.まずは WebAssembly の AST をテキスト+S式で表現した wast 形式にコンパイルします.

$ s2wasm sqrt2.s > sqrt2.wast

sqrt2.wast

中を見てみるとS式形式に変換された LLVM IR みたいなものが吐き出されているのが分かります.

(module
  (memory 1)
  (export "memory" memory)
  (export "abs" $abs)
  (export "sqrt2" $sqrt2)
  (export "sqrt" $sqrt)
  (func $abs (param $$0 f64) (result f64)
    (block $label$0
      (br_if $label$0
        (i32.or
          (f64.gt
            (get_local $$0)
            (f64.const 0)
          )
          (f64.ne
            (get_local $$0)
            (get_local $$0)

;; ...以下略

次にこのS式をシリアライズしたバイナリフォーマット wasm に変換します.ここで最初は llvm-as を使ってコンパイルしてみたのですがうまくいきませんでした.ちょっと探してみると先人の知恵があったので,sexpr-wasm を使うとうまくいくと分かりました.

$ sexpr-wasm sqrt2.wast -o sqrt2.wasm
0000000: 0061 736d 0a00 0000 140a 7369 676e 6174  .asm......signat
0000010: 7572 6573 0201 0404 0304 0404 0418 1366  ures...........f
0000020: 756e 6374 696f 6e5f 7369 676e 6174 7572  unction_signatur
0000030: 6573 0300 0100 0a06 6d65 6d6f 7279 0101  es......memory..
0000040: 0120 0c65 7870 6f72 745f 7461 626c 6503  . .export_table.
0000050: 0003 6162 7301 0573 7172 7432 0204 7371  ..abs..sqrt2..sq
0000060: 7274 7e0f 6675 6e63 7469 6f6e 5f62 6f64  rt~.function_bod
0000070: 6965 7303 2000 0102 0700 0048 9b0e 000c  ies. ......H....
0000080: 0000 0000 0000 0000 980e 000e 000f 0090  ................
0000090: 0e00 140e 0039 0001 0207 0000 9c12 008a  .....9..........
00000a0: 0e00 0e01 0cf1 68e3 88b5 f8e4 3e14 0e01  ......h.....>...
00000b0: 1412 010e 0189 0e01 8c8a 8b0e 010e 010e  ................
00000c0: 028b 0e01 0c00 0000 0000 0000 c00e 0211  ................
00000d0: 0014 1201 0c00 0000 0000 0000 000e 000e  ................
00000e0: 00

あとはこれを適当に Uint8Array に突っ込む JavaScript のコードを書き,HTML ファイルを書きます.

<!DOCTYPE html>
<html>
  <head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, minimum-scale=1.0, initial-scale=1, user-scalable=yes" />
  </head>
  <body>
    <div id="test"></div>
  </body>
  <script>
    const buf = new Uint8Array([0x00, 0x61, 0x73, 0x6d, ...]);
    const module = window.Wasm.instantiateModule(buf);
    console.log(module);

    const result = module.exports.sqrt(314);

    document.getElementById('test').innerText = `Result: ${result}`;
  </script>
</html>

index.html

最後に Canary 版の Chrome で index.html を開いてみます.

f:id:rhysd:20160324235304p:plain

画像内の DevTools のコンソールにあるように,Wasm.instantiateModule を使って JavaScript で実行できるモジュールに変換できます.今回はニュートン法平方根が期待通り計算できていることが分かりました.

まとめ

自作言語がブラウザ上で動いているのはちょうたのしい.

  • 追記

勢い余って Vim の wast filetype 対応プラグインつくりました.

github.com

Electron で Chrome のページ内検索機能を使う

比較的最近,Electron に Chrome のページ内検索を JavaScript から行える API が入りました.最近そのことを知ったので,今日昨日あたりでその機能を使って Shiba に Markdown プレビュー内の検索機能を実装しました.案外手こずってしまったのでまとめてみます.

f:id:rhysd:20160321232118g:plain

github.com

実装コード

今回 Shiba 向けに実装したコードは この diff が全てです.僕の説明よりも実際に動いているコードを見て理解したいという方はそちらを見てください.

ページ内検索 API 概要

WebContents オブジェクトに findInPage()stopFindInPage() の2つのメソッドが生えています.また,WebContents.on() で飛んでくるイベントの中に found-in-page というイベントが追加されています.使う API はコレで全てです.なお,<webview> にもほぼ同じ API が生えています.ですので,<webview> の中の検索をしたい場合はそれらを代わりに使います.今回は BrowserWindow 内の検索を実装しました.

実際に動くコードは上で示した diff を見ていただくとして,概要はこのようになっています.

const previous_text = '';
const webcontents = browser_window.webContents;
webcontents.on('found-in-page', (event, result) => {
    if (result.activeMatchOrdinal) {
        // マッチした箇所を覚えておく
        this.active = activeMatchOrdinal;
    }

    if (result.finalUpdate) {
        // M個のマッチ中 N 番目がアクティブな時,N/M という文字列をつくる
        this.result_string = `${this.active}/${result.matches}`;
    }
});

function search(text) {
    if (previous_text === text) {
        // 前回の検索時とテキストが変わっていないので次のマッチを検索
        webcontents.findInPage(text, {findNext: true});
    } else {
        // 検索開始
        previous_text = text;
        webcontents.findInPage(text);
    }
}

const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        search(input.value);
    }
})

const stop_button = document.querySelector('button');
stop_button.addEventListener('click', () => {
    // マッチした部分のハイライトを消して検索終了
    webcontents.stopFindInPage('clearSelection');
});

found-in-page イベントは1回の検索で2度飛んできます.

  • アクティブな(オレンジにハイライトされる)検索結果が見つかった時点で1回発火
    • この時 activeMatchOrdinalresult に入ってくる
  • ページ全体の検索が完了した時点で1回発火
    • この時マッチの総数を表す matchesresult に入ってくる

これによって JavaScript 側で検索結果の詳細を取れます.ちょっと stateful で使いにくいですが,使えなくはないです.マッチした箇所も result.selectionArea で取れます.

次に例えば <input> 要素にテキストを入力させて Enter キーで検索開始するとします.新規に検索する場合も前回の検索を継続する場合も findInPage() メソッドを使います.これもちょっと stateful です.でも前回の検索テキストを持っておけば上記のように Chrome っぽい挙動で検索が行えます.

最後に検索を終了したい場合は stopFindInPage() メソッドを使います.引数に渡す文字列で検索結果のハイライトを消すかどうかを決められます.

これで終われれば「面倒なページ内検索が Chrome の力を借りて一瞬で実装できる!」で終わったのですが,残念ながらそうはなりませんでした.

ページ内検索 API のハマりどころ

Chrome ではページ内検索はブラウザの UI として C++ で実装されています.ですが,Electron は Chromium のガワを取り払ったブラウザのため検索ウィンドウはありません.そこで JavaScript から触れるようにしようというのが上記 API でした. ですが,ここには1つ実は大きな問題があります.

上記のように,「検索窓」UI を自前で提供してやる必要があります.そこで,<input> タグ(Shiba の場合は Polymer を使っているのでデザインを揃えるために <paper-input>)を使って UI を HTML で書いていきます.いざ完成し,テキストを入れ Enter を押すと異変に気づきます.

最初の検索はうまくいき,最初のマッチがハイライトされますが,次の Enter を入力してもアクティブなマッチが次のマッチに移動しません.ここで僕は remote モジュール越しにレンダラプロセスから API を呼んでいたこともあり,違う箇所を疑いだしてハマってしまいました.

実際の挙動はこうでした.

  1. 最初の検索をする.
  2. ページ内検索はテキストエリアを含めた全ての文字列を検索するので,検索窓の中の <input> のテキストも含めて検索する
  3. 次の検索をしようとして検索窓にカーソルがある状態で Enter を押す.
  4. 検索窓内の <input> のテキストエリアの文字列が更新される(Enter 入力なので見かけ上は変更はない.が,内部的には更新される)
  5. ページ内のコンテンツが更新されると Chrome 組み込みの検索機能はページの最初から検索をやり直す
  6. 結果としてマッチは最初の場所から動かない(毎回 Enter 入力で検索がリセットされてしまう)

Electron はほぼ全ての UI を HTML/CSS でページ内に描画するため,入力フォームも HTML/CSS で書くのが自然です.ですが,ページ内検索機能は上記のように機能しなくなります.これを解決するにはフォームをページ外に持っていくしかありません(もっと良い方法あればぜひ教えて下さい).

さっと思いつく方法は次のどちらかです.

  • 新しい小さい検索窓用の BrowserWindow をつくり,そっちに検索ワードを入力させる
  • <webview> タグで検索窓をラップし埋め込む

どちらが良いかはアプリケーションによると思います.今回 Shiba では後者を採用しました.ウィンドウ右上の検索窓内の <paper-input><webview> でラップされた別のプロセスで動いています.よってページ内検索機能への影響がありません.

欠点としては <webview> を導入するとフォーカスの処理がやや煩雑になります.(ページ内検索をするとフォーカスが <input> から外れてしまうので <webview> の中の <input> にフォーカスを戻す必要がある)

ハマりどころ対応版の実装は JavaScript 部分では下記のようになりました.

本体側

const previous_text = '';
const webview = document.querySelector('webview');
webview.src = 'file:///path/to/search-box.html';
webview.on('ipc-message', event => {
    if (event.channel === 'search:start') {
        const text = event.args[0];
        if (previous_text === text) {
            remote.getCurrentWebContents().findInPage(text, {findNext: true});
        } else {
            remote.getCurrentWebContents().findInPage(text);
        }
    }
});

<webview> で読み込まれた HTML 側

const ipc = require('electron').ipcRenderer;
const input = document.querySelector('input');
input.addEventListener('keydown', event => {
    if (event.code === 'Enter') {
        // send() じゃないので注意!
        ipc.sendToHost('search:start', input.value);
    }
});

まとめ

冒頭にも書きましたが,思っていたより手こずってしまいました.しかし,一度分かってしまえば1時間足らずぐらいでページ内検索を実装してしまえそうです.お手軽感はあります.

飽くまで Chrome のページ内検索 APIJavaScript に露出させているだけです.なので,検索マッチのハイライトを変えられなかったりなど細かい制御は効きません.最初から React.js などの SPA 向けライブラリを使っている場合はどっちが良いか,アプリ次第になるかなと思います.

Issue と PR のテンプレートジェネレータつくった

screenshot

出張の帰りのフライトが10時間以上あったので簡単なツールをつくってみました.

余談ですが Windows PC しかなかったので,Windows PC + golang + gVim で書いてみました.Go 言語は標準ライブラリだけでもぐりぐり書けてなかなか良いですね.久々の Windows でのコーディングでしたが特に問題ありませんでした.

GitHub の issue/PR テンプレート機能

最近 issue / PR のテンプレート機能が実装されたみたいです.リポジトリ直下か .github ディレクトリのどちらかに ISSUE_TEMPLATE.md および PULL_REQUEST_TEMPLATE.md を置いておくと,issue や PR 作成時にそのテンプレートが入力フォームにデフォルトでセットされます.

Issue and Pull Request templates - GitHub Blog

また,以前からある CONTRIBUTING.md.github ディレクトリの中に置けるようです.

.github ディレクトリを生成するコマンド dot-github

github.com

前々からほしいなーと思っていた機能だったのでさっそく使いたいところですが,毎度似たようなテンプレートを各リポジトリにつくるのも手間だなぁということでテンプレートジェネレータをつくりました.これで元になるファイルだけ dotfiles なりで管理しておけば,他の PC などからでも一発でテンプレートが生成できます.

下記のように go get するとインストールできると思います.

$ go get github.com/rhysd/dot-github

テンプレートは ~/.github 内に置きます.他の場所に置きたいときは $DOT_GITHUB_HOME 環境変数をセットすることで置き場所を変更できます.

~/.github 内に置けるテンプレート生成ファイルは次の通りです.ファイルが存在しない場合は単純に生成をスキップします.

ファイル名 説明
ISSUE_TEMPLATE.md Issue 用に使われるテンプレート
PULL_REQUEST_TEMPLATE.md PR 用に使われるテンプレート
ISSUE_AND_PULL_REQUEST_TEMPLATE.md Issue および PR で上記のテンプレートが無い場合に使われる共通のテンプレート
CONTRIBUTING.md コントリビュートガイドライン用のテンプレート

dot-github コマンドによって上記のテンプレートファイルからリポジトリ内に .github が自動生成されます.

$ cd your-repo
$ dot-github

.github 内に生成されるファイルは Go 言語標準の text/template テンプレートを使ってテンプレート生成ファイルから生成されます.

Package template - The Go Programming Language

標準の機能に加えて,下記の変数が使用可能です.

変数名 説明
.IsIssue boolean ISSUE_TEMPLATE.md として展開される時に True
.IsPullRequest boolean PULL_REQUEST_TEMPLATE.md として展開される時に True
.IsContributing boolean CONTRIBUTING.md として展開される時に True
.RepoName string リポジトリ
.RepoUser string リポジトリ所持者の名前

text/template{{if}} などを使って重複を省いて issue と PR 共用のテンプレートを書いても良いですし,最初から別々にテンプレートを書いても良いです.

生成例

使用するテンプレート

  • ~/.github/ISSUE_AND_PULL_REQUEST_TEMPLATE.md
{{if .IsIssue}}
### Expected Behavior


### Actual Behavior


{{end}}
{{if .IsPullRequest}}
### Fix or Enhancement?


- [ ] All tests passed
{{end}}

### Environment
- OS: Write here
- Go version: Write here
  • ~/.github/CONTRIBUTING.md
Thank you for contributing to {{.RepoName}}!
=========================================

Please follow issue/PR template.

生成されたファイル

  • /path/to/your-repo/.github/ISSUE_TEMPLATE.md
### Expected Behavior


### Actual Behavior


### Environment
- OS: Write here
- Go version: Write here
  • /path/to/your-repo/.github/PULL_REQUEST_TEMPLATE.md
### Fix or Enhancement?


- [ ] All tests passed

### Environment
- OS: Write here
- Go version: Write here
  • /path/to/your-repo/.github/CONTRIBUTING.md
Thank you for contributing to my-project!
=========================================

Please follow issue/PR template.

まとめ

GitHub に追加された issue / PR テンプレート機能をさっと使えるようにジェネレータをつくってみました.Go 言語の流儀に則ってちゃんとテストも書いてみたので,僕みたいにリポジトリつくりまくって毎度わざわざテンプレート書くのがめんどいみたいな人はぜひともお試しください.

yak shaving 気味ですが,勢いがあったのでつくってしまいました.新幹線内とか飛行機内だと割と手が動きやすいのはどうしてなんでしょうね…