EmscriptenでビルドしたZstandardをブラウザ環境で動かす
はじめに
前回の投稿で、C言語実装のHelloWorldをNode.js環境で動作させました。 今回はデータ圧縮アルゴリズムZstandardをEmscriptenでビルドしてブラウザ環境で動作させてみます。
データの圧縮/伸張だけだと挙動がわかりにくいので、Zstandard圧縮したファイルをドロップすると データを伸張して画面に表示するクライアントアプリケーションを作成しました。
本記事ではZstandardのブラウザ向けのビルド方法と、クライアントアプリケーションへの組み込み方法を確認したいと思います。
検証環境
- macOS Sierra 10.12.6
- Emscripten 1.37.16
- Zstandard 1.3.1
- Google Chrome 61.0.3163.91 (64ビット)
- webpack 3.5.5
- React 15.6.1
Zstandardをビルド
Emscripten SDK導入済みの前提で説明を進めます。 導入手順については、前回の投稿を参照してください。
ソースコードを入手
Zstandardのリポジトリからソースコードを入手します。
$ wget https://github.com/facebook/zstd/archive/v1.3.1.tar.gz $ tar xvf v1.3.1.tar.gz $ mv zstd-1.3.1{,-emscripten} $ cd zstd-1.3.1-emscripten
Emscriptenでブラウザ向けにビルド
ZstandardのライブラリをEmscriptenでビルドします。
$ # -- (1) Zstandardをビルド $ emmake make lib-release $ # -- (2) ファイルの内容を確認 $ file lib/libzstd.dylib lib/libzstd.dylib: LLVM IR bitcode $ # -- (3) 拡張子変更 $ mv lib/libzstd.{dylib,bc} $ # -- (4) エクスポート用のコードを作成 $ echo 'module.exports = Module;' | cat > export_module.js $ # -- (5) JavaScriptに変換 $ emcc -o zstd.js -O0 --memory-init-file 0 --post-js export_module.js -s EXPORTED_FUNCTIONS="['_ZSTD_isError', '_ZSTD_getFrameContentSize', '_ZSTD_decompress']" lib/libzstd.bc
- (1) emmakeコマンドでEmscriptenのコンパイラ/リンカを使うように設定してビルドします
- (2) fileコマンドでビルド結果を確認します。ネイティブビルドの場合は共有ライブラリとなりますが、Emscriptenビルドの場合はLLVMビットコードになります
- (3) emccコマンドにLLVMビットコードを認識させるため、ファイルの拡張子を
.bc
に変更します - (4) Moduleオブジェクトをエクスポートするためのコードを用意します。理由は後述します
- (5) LLVMビットコードをJavaScriptに変換します。以下オプションの内容です
- -o 出力ファイルの指定
- -O0 最適化レベルの指定。-Oに続く数字が最適化レベル、0の場合は最適化なし 1
- --memory-init-file 0 メモリ初期化ファイルのサイズ指定、0にすると使用しなくなる
- --post-js 変換結果の末尾に追加するJavaScriptソースの指定
- -s EXPORTED_FUNCTIONS エクスポート対象関数名の指定
操作(5)まで完了すると、zstd.jsが生成されます。テキストエディタで中身を確認してみましょう。
'exports'
で検索すると、Moduleオブジェクトのエクスポート処理が見つかりますが、実行環境がNode.jsの場合に限られるため、ブラウザ環境ではエクスポートされません。
そのままだと外部からModuleオブジェクトを利用できないので、操作(4)で強制的にエクスポートするコードを用意して、操作(5)の--post-jsオプションでzstd.jsにコードを追加しています。
今回使用したオプションの他にも様々な機能が用意されています。詳細は公式ドキュメントのBuilding Projectsを確認してください。
クライアントアプリケーションへの組み込み
EmscriptenでビルドしたZstandardを、Reactとwebpackで作成したクライアントアプリケーションに組み込みます。 今回はZstandardで圧縮したデータを伸長するため、以下のAPIを使用します。
/* エラー判定 */ ZSTDLIB_API unsigned ZSTD_isError(size_t code); /* 伸長後データ長取得 */ ZSTDLIB_API unsigned long long ZSTD_getFrameContentSize(const void *src, size_t srcSize); /* 伸長処理 */ ZSTDLIB_API size_t ZSTD_decompress( void* dst, size_t dstCapacity, const void* src, size_t compressedSize);
EmscriptenでビルドしたC言語の関数は、cwrapを使うとJavaScriptの関数から直接呼び出せるようになります。 今回使用するAPIについて、cwrapで指定する型の対応を下表にまとめました。
区分 | C言語の型 | JavaScriptの型 | データ種別 |
---|---|---|---|
入力 | unsigned | number | 数値 |
入力 | unsigned long long | number | 数値 |
入力 | size_t | number | 数値 |
入力 | const void* | array | ポインタ |
出力 | void* | number | ポインタ |
ポインタを指定する場合は注意が必要です。
JavaScriptからC言語にポインタを渡す場合は原則number
でアドレスを指定します。
cwrapで関数をラップして引数の型にarray
を指定した場合のみ、JavaScriptの配列(ArrayおよびTypedArray)を指定できるようになります。
ただし、array
を指定する場合はJavaScriptの配列の内容をC言語のスタック領域(一時的なメモリ)にコピーするので、
C言語の関数の処理結果を受け取ることができません。今回は使用しませんが、string
も同様です。
ZSTD_decompress関数に指定するvoid* dst
のように、処理結果をメモリに出力する場合は_malloc
でC言語のヒープ領域を確保して使用します。
C言語のヒープ領域に出力した伸張データは以下の手順で取得できます。
- C言語のヒープ領域のデータをJavaScriptの配列にコピーする
_free
でヒープ領域を解放する
JavaScriptの配列にコピーする前にC言語のヒープ領域を解放すると、処理結果を取得できなくなるので注意しましょう。
データ伸張処理の実装
公式ドキュメントのInteracting with Codeを参考に、Zstandard APIの呼び出しをラップしたコーデッククラスを作成します。 下記サンプルではES6の構文を使用しています。
class ZstdCodec { constructor() { // cwrapでJavaScriptから呼び出せるようにする this.zstd = require('./zstd.js'); this.ZSTD_isError = this.zstd.cwrap('ZSTD_isError', 'number', ['number']); this.ZSTD_getFrameContentSize = this.zstd.cwrap('ZSTD_getFrameContentSize', 'number', ['array', 'number']); this.ZSTD_decompress = this.zstd.cwrap('ZSTD_decompress', 'number', ['number', 'number', 'array', 'number']); } isError(zstd_rc) { return this.ZSTD_isError(zstd_rc); } getFrameContentSize(zstd_bytes) { // 伸長後サイズ(単位:バイト)を取得する const content_size = this.ZSTD_getFrameContentSize(zstd_bytes, zstd_bytes.length); // Emscriptenの割り当てメモリはデフォルトで16MBなので、 // サンプルアプリケーションでは1MBを超える場合はエラー扱いにする // NOTE: ZSTD_getFrameContentSizeのエラー値はnumber型で表現できない。 // 厳密にエラーチェックする場合は、C言語のレイヤで判定するラッパー関数を用意すること const content_size_limit = 1 * 1024 * 1024; return content_size <= content_size_limit ? content_size : null; } decompress(zstd_bytes) { // 伸張後のデータ長を取得 const content_size = this.getFrameContentSize(zstd_bytes); if (!content_size) return null; // (a) ヒープ領域を確保、伸張データの出力先として使用する const heap = this.zstd._malloc(content_size); try { // (b) 圧縮データを伸長する const decompress_rc = this.ZSTD_decompress(heap, content_size, zstd_bytes, zstd_bytes.length); if (this.isError(decompress_rc) || decompress_rc != content_size) return null; // (c) 伸長データをJavaScriptの配列にコピーする return new Uint8Array(this.zstd.HEAPU8.buffer, heap, content_size); } finally { // (d) 例外発生時に解放漏れしないようにする this.zstd._free(heap); } } }
decompress
メソッドは、引数で受け取ったZstandard圧縮データを伸張します。
下図は処理開始時点のメモリの状態です。
(a)の_malloc
で伸張データの配置領域を確保します。Emscriptenでビルドした場合、_malloc
はヒープ領域先頭からのオフセット値を返します。
(b)のZSTD_decompress
で、確保したヒープ領域に圧縮データを伸張します。
そのままだとJavaScriptの配列として使用できないので、(c)でUint8Arrayに変換しています。
ここまでの処理で、伸張したデータをJavaScriptの配列として参照できるようになりました。 (d)でヒープ領域を解放すると、ヒープ領域は1個目の図の状態に戻ります。
webpackの設定
Zstandardのライブラリ内でFSモジュールを使用していますが、今回は不要なのでCan't resolve 'fs' when bundle with webpack #447を参考に無効化します。
module.exports = { // ... 長いので省略 // 追加 node: { fs: "empty" } }
以上で組み込み準備完了です。
クライアントアプリケーションの動作確認
zstdコマンドの準備
先ほどダウンロードしたZstandardのソースコードを使用して、CLIコマンドをビルドしましょう。
$ tar xvf v1.3.1.tar.gz $ mv zstd-1.3.1{,-native} $ cd zstd-1.3.1-native $ make
テストデータの作成
zstd.hファイルを圧縮してテストデータを用意しましょう。
$ # ZstandardのCLIコマンドで圧縮 $ ./zstd lib/zstd.h lib/zstd.h : 26.51% ( 69076 => 18311 bytes, lib/zstd.h.zst) $ # ファイルサイズを確認 $ ls -lh zstd.* -rw-r--r-- 1 yoshihitoh staff 67K 8 21 04:34 zstd.h -rw-r--r-- 1 yoshihitoh staff 18K 8 21 04:34 zstd.h.zst $ # 16進数でダンプ、ファイルの内容を確認 $ od -x -N 32 zstd.h 0000000 2a2f 200a 202a 6f43 7970 6972 6867 2074 0000020 6328 2029 3032 3631 702d 6572 6573 746e $ od -x -N 32 zstd.h.zst 0000000 b528 fd2f d4a4 010d bd00 023b dc9a 50d1 0000020 a035 a888 136a 8ef2 4443 d232 d2bd 1265
動作確認
zstd.h.zst
をテキストエディタで開くと文字化けして中身を読めなくなっていますが、
サンプルアプリケーションにドロップすると、中身を表示することができました!
おまけ
Emscriptenでビルドするときに、 -s WASM=1
を指定すると、WebAssembly版をビルドできます。
サンプルアプリケーションの場合、以下の対応で動作させることができました。
zstd.js
を更新してリビルドzstd.wasm
をpublicフォルダに配置
$ # WebAssembly版をビルド $ cd <path/to/zstd-1.3.1-emscripten> $ emcc -o zstd.js -O0 --memory-init-file 0 --post-js export_module.js -s WASM=1 -s EXPORTED_FUNCTIONS="['_ZSTD_isError', '_ZSTD_getFrameContentSize', '_ZSTD_decompress']" lib/libzstd.bc $ # zstd.jsを更新 $ cp zstd.js <path/to/sample_app/src> $ # zstd.wasmをHTTPアクセスできる場所にコピー $ cp zstd.wasm <path/to/public_dir> $ # リビルド $ cd <path/to/sample_app> $ yarn run webpack $ # HTTPサーバ起動 $ cd public $ python -m SimpleHTTPServer
おわりに
C言語実装のZstandardライブラリをブラウザ環境で動作させることができました。
今回は特にはまることなくEmscriptenでビルドできましたが、プログラムの内容や ライブラリの構成によっては移植作業が必要になることもあると思います。
そのまま適用できるケースは限られそうですが、うまく活用してブラウザ環境でも 既存の資産や他言語のライブラリを活用していきたいですね!
ソースコード
サンプルアプリケーションのソースコードは下記リポジトリで公開しています。
参考
- Emscripten: Pointers and Pointers
- How to handle passing/returning array pointers to emscripten compiled code?
- Can't resolve 'fs' when bundle with webpack #447
-
'exports'
を検索できるようにするため最適化なしにしています。-O3で最適化した場合もサンプルアプリケーションが問題なく動作することを確認しています。 ↩