WAT (WebAssembly Text Format) と Common Lisp で遊ぶ ~準備編~
lisp Advent Calendar 2020 7日目の記事です。
WebAssemblyを真面目に試そうと思ったらCやらRustやら他の言語からコンパイルして生成するものかと思いますが、せっかくそのテキスト形式 = WAT (WebAssembly Text Format) がS式なので直接書いて(書かせて)ちょっと遊んでみようという記事です。
2, 3回ぐらいに分けて書いていこうかと思いますが、今回は遊ぶ前の準備編で、静的なWATとそれを呼び出すJavaScriptファイルを用意して、(WATはバイナリ形式のWASMに変換した上で)配信するだけのCommon Lispサーバを作るあたりまでを扱います。
色々遊んでいるリポジトリは下記になります。
今回は下記ぐらいのコミット時点の内容になります(所々余分なコードがあるので、本文では適宜整理したものを記載しています)。
https://github.com/eshamster/try-wasm-with-cl/tree/f36397c70bfb8dd3a39d70bffb70e7370a6a87b4
また、下記あたりの記事を参考にしています。
目次
WABT (WebAssembly Binary Toolkit) のインストール
テキスト形式のWATはそのままではブラウザで解釈できないので、バイナリ形式のWASMにする必要があります。そのためのツールである wat2wasm
コマンドが含まれる WABT (WebAssembly Binary Toolkit) をインストールしてパスを通します。
個人的にRoswellとEmacsを入れて開発に使っているDockerイメージ eshamster/cl-devel2 では下記のようにインストールできました。なお、素のAlpine上でも同じようにmakeできました(念のため、gitがないので apk add git
は追加で必要になります)。
FROM eshamster/cl-devel2:latest RUN git clone --recursive https://github.com/WebAssembly/wabt && \ apk add --no-cache cmake clang binutils gcc libc-dev clang-dev build-base && \ cd wabt && \ make ENV PATH /root/wabt/bin:${PATH}
配信するファイル(など)の用意
HTML
HTMLははなから cl-markup を利用して Common Lisp で書いていますが、見れば分かるとは思うのでそのまま貼っておきます。
(html5 (:head (:title "try-wasm-with-cl") (:script :src "js/main.js" nil))
<script>
タグでjs/main.js
をロードする...だけの内容です
JavaScript
JavaScript側は次のようなファイルを用意します。
/* static/js/main.js */ var importObject = { console: { log: console.log } }; WebAssembly.instantiateStreaming(fetch('wasm/main.wasm'), importObject) .then(results => { results.instance.exports.exported_func(); });
- WASM側から
console.log
を呼び出せるようにimportObject
に詰めて受け渡す - WebAssembly.instantiateStreaming を利用してサーバから指定のWASMをロードする
- ロードできたらWSAM側で定義された
exported_func
を呼び出す
ちなみに、次のコミットでJavaScriptファイルは消滅して、Parenscriptと拙作ps-experiment(quiecklispリポジトリ未登録)を利用して Common Lisp コードから生成するようになりますが、本題ではないのでおいておきます。
WAT (WebAssembly Text Format)
実際に配信するのはWATではなくWASMですが、wat2wasm
を呼び出してWASMにする処理はCommon Lisp側でやるので、ここではWATまで用意します。
;; static/wasm/main.wat (module (import "console" "log" (func $log (param i32))) (func (export "exported_func") i32.const 100 call $log))
- JavaScript側から渡される
console.log
関数を$log
という名前でimportする exported_func
という名前で関数をexport & 定義するexported_func
内で$log
関数に定数100を渡す- JavaScriptで
console.log(100)
とするのと同じ
- JavaScriptで
ここからコンソール上でWASMを生成したい場合は wat2wasm main.wat -o main.wasm
のようにすれば main.wasm
が生成されます。
ちなみに、何コミットかするとリポジトリからWATファイルが消滅してCommon Lispから書き出すようになりますが、その辺りの話が次回になります。
サーバ側の用意
lack application の作成
サーバ側は、まず軽量Webアプリケーションフレームワークである ningle を利用して、ルートへのアクセス時に先程のHTMLを返すような lack application を作成します。
(defvar *app* (make-instance 'ningle:<app>)) (setf (ningle:route *app* "/" :method :GET) (lambda (params) (declare (ignore params)) (with-output-to-string (str) (let ((cl-markup:*output-stream* str)) (html5 (:head (:title "try-wasm-with-cl") (:script :src "js/main.js" nil)))))))
これに lack の lack:builder
を利用して2つ程ミドルウェアをくっつけて最終的な lack application を構成します。そして、clack の clack:clackup
で起動して、無事ブラウザからアクセできるようになります(下記の start-server
)。
;; --- 諸々の変数 --- ;; (defvar *app* (make-instance '<app>)) (defvar *server* nil) (defvar *script-dir* (merge-pathnames "static/" (asdf:component-pathname (asdf:find-system :try-wasm-with-cl)))) (defvar *wat-path* (merge-pathnames "wasm/main.wat" *script-dir*)) (defvar *wasm-path* (merge-pathnames "wasm/main.wasm" *script-dir*)) ;; --- lack application の build & 起動する関数 --- ;; (defun start-server (&key (port 5000) (address "0.0.0.0")) (setf *server* (clack:clackup (lack:builder ;; ミドルウェア1: 本文へ (lambda (app) (lambda (env) (let ((res (funcall app env)) (path (getf env :path-info))) (when (scan "\\.wasm$" path) (wat2wasm *wat-path* *wasm-path*) (setf (getf (cadr res) :content-type) "application/wasm")) res))) ;; ミドルウェア2: ;; .js, .wasm へのアクセスは static フォルダ以下の静的ファイルを返す (:static :path (lambda (path) (when (scan "^(?:/js/|/wasm/$)" path) path)) :root *script-dir*) ;; 先程作った lack appalication *app*) :port port :address address)))
ミドルウェア2は単に静的ファイルを返すように設定しているだけです。
ミドルウェア1の方は *.wasm
へのアクセスがあった場合に下記のようなことをします。
- 後述の
wat2wasm
を利用してmain.wat
をmain.wasm
にコンパイルする- 中身は
wat2wasm
コマンドを呼び出しているだけです - 本来は
XXX.wasm
へのアクセスならXXX.wat
->XXX.wasm
のコンパイルをするようにすべきでしょうが、そこは手抜きしてます
- 中身は
- HTTPヘッダの
content-type
をapplication/wasm
に設定する- JavaScript側の
WebAssembly.instantiateStreaming
が同content-type
を要求するためです
- JavaScript側の
WASMファイルの書き出し
ファイルが異なるので一応項を分けましたが、WAT->WASMへの変換は下記の通り wat2wasm
コマンドを呼び出し、wat-path
で指定されたWATファイルをWASMに変換し、wasm-path
で指定されたパスにアウトプットするだけです。
(defun wat2wasm (wat-path wasm-path) (uiop:run-program (format nil "wat2wasm ~S -o ~S" (namestring wat-path) (namestring wasm-path))))
結果
ということで、下記のようにしてサーバを起動し、ブラウザから localhost:5000
にアクセスすれば、開発者コンソールに 100
が表示されるようになります。
> (ql:quickload :try-wasm-with-cl) > (try-wasm-with-cl:start-server :port 5000)
なお、最新版ではquicklispリポジトリ未登録のps-experimentに依存しています。qlfileを置いているのでqlotで取ってくるのが簡単かと思います。
> (ql:quickload :qlot) > (qlot:quickload :try-wasm-with-cl) > (try-wasm-with-cl:start-server :port 5000)