Common Lispでホットローディングを試しに作る (1) 使い方について

前回記事「[Clojure] Figwheel + cljsjs/threeでホットローディングを試す - eshamster’s diary」ではClojureScriptでホットローディングをお試ししました。そして、おおむね次のような機構で動いていることが推測されました。

  1. なんらかの契機でLispコードの変更を検知してJavaScriptコードにコンパイルする
  2. WebSocketを通して1のJavaScriptコードをブラウザに送る
    • このWebSocketはページ読み込み時に開いておく
  3. ブラウザ側では2で送られたコードを評価(eval)する

構成要素はこの通り単純なので、プロトタイプレベルであれば簡単に作れそうだし面白そうだ、ということでCommon Lispで作ってみました。今回の記事ではその使い方について、次回の記事で実装について書きたいと思います。

github.com

実装部分の概要のみ述べると、JSコードへのコンパイルにはParenscriptを利用しています。また、JSコード送信の契機は次のようになっています。

  • 上記1の「契機」は評価時としている
    • Emacs + SLIMEな環境であれば定義をC-c C-c等で評価するとブラウザに送られます *1
    • なお、Figwheel (ClojureScript) の場合はファイルの更新を契機にしています
  • ブラウザからの接続前に定義済みのJSコードは、接続時に単一のJSファイルとして送られる

目次

ホットローディングのデモ

ホットローディングを収めたGIF画像デモです。画像左にブラウザ(下半分に開発者ツールのコンソールを表示しています)、画像右にエディタ(Emacs)を表示しています。

エディタ側でC-c C-c (slime-compile-defun) によってカーソル下の定義を評価すると、即座にブラウザ側に反映される様子が分かると思います。画像左真ん中辺りの灰色枠には送られてきたJavaScriptコードを表示しています。

ホットローディングのデモ

※画像左上の白枠にParenscriptコードを書いてSend Parenacript codeボタンを押すと、それをサーバに送ってJavaScriptコードを返してもらう(そして評価する)…ということができるのですが、実装初期の検証に使っていたもので今や死にパーツです…。

使い方

プロトタイプということで、ライブラリ部分と実際に利用して試している部分を共に含んでいます。そのため、ここではそれぞれの部分について節を分けて書いていきます。

実際に利用している部分:ホットローディングお試し環境として

上記のデモ相当のことをするための方法を述べます。まず、git clone https://github.com/eshamster/proto-cl-hot-loads.gitをしてquicklispから認識可能な場所にproto-cl-hot-loadsを設置します。

次に、REPL上でロードし、サーバを立てます。その後、ブラウザからhttp://localhost:5000を開きます。

CL-USER> (ql:quickload :proto-cl-hot-loads)
CL-USER> (proto-cl-hot-loads:start :port 5000)

ホットローディングをお試しするためには、ライブラリのdefvar.hl, defun.hl, defonce.hl, with-hot-loads を利用します(hl = hot loads)。お試しとしては src/playground.lisp を直接いじるのが簡単です。もしくは、proto-cl-hot-loadsパッケージをuseしてREPL上から試すこともできます。順に見ていきます。

  • defvar.hl

defvar.hl は名前の通り defvar のホットローディング版です。例えば開発環境側で下のようなコードを評価すると、ブラウザ側ではvar x = 100;が評価されます.

(defvar.hl x 100)
  • defun.hl

defun.hl も読んで字のごとく defun のホットローディング版です。下記を評価すると、ブラウザ側では function hello(x) { alert("Hello " + x); }; が評価されます.

(defun.hl hello (x)
  (alert (+ "Hello " x)))
  • defonce.hl

defonce.hlCommon Lispに対応するものがないですが、 ホットローディング時の再評価を抑制するためのものです。Figwheelのdefonceを真似てみました(Figwheelの方は "Writing reloadable code" in Figwheel's README に詳しいです)。下記のコードの場合、最初の評価時のみブラウザ側でvar y = 200;が評価されますが、ここを書き換えたりしてもう一度評価しても(リロードしない限り)ブラウザ側では何もしません。

(defonce.hl y 200)
  • with-hot-loads

with-hot-loads上記defxxx.hl 群のベースになっているマクロです。基本的にはdefxxx.hl系を利用すれば良いのですが、トップレベルなフォームをホットローディング対象するためにはこれを直接利用する必要があります。例えば、下記を評価すると"Hello 300"というアラートがブラウザ側に表示されます(※hello, x, y上記で定義している前提)。labelには一意の任意なシンボルを与えてください。

(with-hot-loads (:label some-label)
  (hello (+ x y)))

また、ホットーローディングだけでなく、ブラウザ接続前の定義済みコードを送信する機能も持っています。つまり上記で定義したものは、ブラウザからのアクセス時にsrc/js/main.jsというファイルにJavaScriptコードとして出力され、ブラウザ側に送られます(現状定義を取り消すインタフェースがないので、一度評価してしまったものはREPLを初期化しない限り残り続けます…)。

例えば、src/playground.lispにはデフォルトで下記の定義を記述しています。

(defvar.hl x 888)

(defonce.hl once 100)

(defun.hl my-log (text)
  ((ps:@ console log) text))

(with-hot-loads (:label sample)
  (my-log (+ x ": Hello Hot Loading!!")))

このときブラウザ側からアクセスすると、src/js/main.jsが下記の内容で出力され、ブラウザ側へ送信されることになります。

var x = 888;
if (typeof x !== 'undefined') {
    var once = 100;
};
function myLog(text) {
    return console.log(text);
};
myLog(x + ': Hello Hot Loading!!');

ライブラリ部分

ライブラリとして利用する上では下記の2つの準備が必要です。これらが完了した後に、defxxx.hl系やwith-hot-loadsを利用してホットローディングができます。

  1. ホットローディング用のWebSocket機能をWebサーバに組み込む
  2. 下記を行うようなWebサーバを用意する *2
    • コンパイル済みのJavaScriptファイル(上記で言うとsrc/js/main.js)を送信する
    • 次のことを行うJavaScriptファイルを(作成・)送信する
      • ロード時にWebSocketでサーバにつなぐ
      • WebSocketから送られたJavaScriptコードをevalする

1のWebSocketサーバ機能は lackミドルウェアとして組み込むことができます。そのミドルウェアを生成する関数がmake-hot-load-middlewareです。下記のように、lack:builderを利用して、Webサーバ本体である*static-app*の手前にミドルウェアとして組み込みます。パラメータとしては、JavaScriptコードの出力先としてmain-js-path、WebSocketの接続先としてstring-url(下記の場合、ws://localhost:5000/wsでWebSocket接続を受け付けます)の2つを取ります。

(defun start (&key (port 5000))
  (clack:clackup
   (lack:builder (make-hot-load-middleware
                  :main-js-path (merge-pathnames
                                 "src/js/main.js"
                                 (asdf:component-pathname
                                  (asdf:find-system :proto-cl-hot-loads)))
                  :string-url "/ws")
                 *static-app*)
   :port port))

*static-app*はWebサーバ本体で、冒頭の2を担う部分です。下記はマイクロWebフレームワークであるningleを利用した場合の例です。適宜インラインで解説を書いています。

(use 'cl-markup)

(defvar *ningle-app* (make-instance 'ningle:<app>))

(setf (ningle:route *ningle-app* "/" :method :GET)
      (lambda (params)
        (declare (ignorable params))
        (with-output-to-string (str)
          (let ((*output-stream* str))
            ;; (cl-markupによるHTML生成部分。cl-markupを知らなくても雰囲気は分かると思います)
            (html5 (:head
                    (:title "A sample of hot loads on Common Lisp")
                    ;; WebSocket周りを扱う簡単なJavaScriptコード。コードは後述
                    (:script :src "js/hot_loads.js" nil)
                    ;; make-hot-load-middlewareのmain-js-pathで指定したもの。
                    ;; ロード時点で定義済みのコードはここに出力される
                    (:script :src "js/main.js" nil))
                   (:body
                    (:div "Hello hot loads!!")))))))

(defvar *static-app*
  (lack:builder
   ;; (JavaScriptファイル配信のためにstaticミドルウェアを利用)
   (:static :path (lambda (path)
                    (if (ppcre:scan "^(?:/js/)" path)
                        path
                        nil))
            :root (merge-pathnames "src/"
                                   (asdf:component-pathname
                                    (asdf:find-system :proto-cl-hot-loads))))
   *ningle-app*))

上記js/hot_loads.jsの定義は次のようになります。WebSocketでサーバにつなぎ、メッセージ受信時にJavaScriptコードとして評価するだけです。若干注意が必要なのが、evalの呼び出し方法です。単に呼び出すと下記function (e) { ... }内のローカルな環境で評価されてしまう(例えば、定義した関数に後から触ることができない)ので、グローバルな環境で評価させるためにeval.call(windows, ...)として呼び出しています。

let ws_socket = new WebSocket('ws://' + window.location.host + '/ws')

ws_socket.onmessage = function (e) {
    eval.call(window, e.data);
}

使い方としては以上になります。


*1:C-c C-cでホットローディングされたら面白そうだということでそうしてみましたが、名前空間分割やら最適化やらを考えるとファイル単位で行うのが合理的なのだろうなと思います

*2:ここは現状ライブラリ側で十分サポートできていない部分です