Common Lispでホットローディングを試しに作る (2) 実装について
前回記事ではCommon Lisp上で実現したホットローディングのプロトタイプのデモや使い方について見ました。
今回はその実装についてです。といってもベースは実に単純なもので、ParenscriptによってCommon LispコードをJavaScriptに変換し、それをWebsocket Driverで立てたWebSocketサーバを通じてクライアント = ブラウザへ送るというだけです。ライブラリとして独立させる場合にサーバ部分のインタフェースをどうするか、という部分は当初ノーアイディアでひとまず動かすことを優先したのですが、最終的にはLackのミドルウェア(後述)として提供するのが良さそうだというところに落ち着いてます。
目次
コンパイラの実装(Parenscript)
Common LispコードからJavaScriptコードへのコンパイル部分は、基本的にParenscriptをそのまま使っているだけなので特筆すべきことはありません。WebSocketサーバの実装の部分で後述しますが、ライブラリとして分離させる際には消えてなくなりそうな部分です。
コード貼り付け:src/compiler.lisp
;; "((defvar x 100) (incf x))" (defun compile-ps-string (str-code) (macroexpand `(ps:ps ,(read-from-string (concatenate 'string "(progn " str-code ")"))))) ;; '((defvar x 100) (incf x)) (defun convert-ps-s-expr-to-str (body) (format nil "~W" `(progn ,@body))) ;; '((defvar x 100) (incf x)) (defun compile-ps-s-expr (body) (compile-ps-string (convert-ps-s-expr-to-str body)))
サーバの実装
WebSocketサーバ
ブラウザへJavaScriptコードを送信する役割を担うWebSocketサーバの実装にはWebsocket Driverを利用しました。
まずはコード貼り付け:src/ws-server.lisp
(defvar *server-instance-list* nil) (defparameter *ws-app* (lambda (env) (let ((server (make-server env))) (push server *server-instance-list*) (on :message server (lambda (ps-code) (format t "~&Server got: ~A~%" ps-code) (send-from-server ps-code))) (lambda (responder) (declare (ignore responder)) (format t "~&Server connected") (start-connection server))))) (defun send-from-server (ps-code) (let ((message (handler-case (compile-ps-string ps-code) (condition (e) (declare (ignore e)) "alert(\"Compile Error!!\");")))) (dolist (server (copy-list *server-instance-list*)) (case (ready-state server) (:open (send server message)) (:closed (setf *server-instance-list* (remove server *server-instance-list*))) ;; otherwise do nothing ))))
*ws-app*
がWebSocketサーバ本体で、(clack:clackup *ws-app*)
のようにすれば単体で立ち上げることもできます。全体的には、WebSocketクライアントとの接続の管理と、送られてきたCommon Lisp(Parenscript)コードのコンパイルが役割です。
前者の接続管理としては、クライアントから新しい接続があったらmake-server
で新しくサーバを起こし、start-connection
で接続を開始します。一方、閉じられたサーバの掃除は送信時に実施します。send-from-server
でready-state
を見ているのがそれで、状態が:closed
になっているサーバを除去しています。実を言うと、複数コネクションの管理方法がこれで正しいのか自信がないので、変なことをしていたら教えていただけると助かります…。
後者のコンパイル部分は、send-from-message
内でmessage
への束縛を行っている部分です。単に上記のコンパイラを呼んでいるだけで、コンパイルエラーが起きた場合は雑にalert
を返しています。
ところで、*ws-app*
の定義で、(on :message ...)
としてクライアントからの送信に反応するようにしています。クライアントから送られてきたCommon LispをJavaScriptにコンパイルして送り返すという内容です。これは前回少し触れた、下図白枠にCommon Lispコードを書いてボタンを押してサーバに送ると、JavaScriptとして送り返されて実行できる、という機能のためにあります。ホットローディングを実現する上では不要な部分で、実際にライブラリ化する際には除いてよいものです。その場合、send-from-message
側でコンパイルをする必然性も薄くなり、同関数の利用者(後述のwith-hot-loads
他)側からJavaScriptを渡すようにする方が良さそうです。すると、そちらではParenscriptを直接使えばよいので、上記のsrc/compiler.lisp
部分はいらなくなる…といった話になってきます。
ミドルウェア
ホットローディングライブラリとして見たとき、サーバ機能は上記の*ws-app*
を直接見せるのではなく、それをラップしたLackのミドルウェアとして提供しています。
Lackにおけるミドルウェアは、定義上はアプリケーションを受け取ってアプリケーションを返す関数というシンプルなものです。これをWebアプリケーション本体の手前に挟み込むことで、ロギング機能を持たせたり静的ファイル配信機能を持たせたりできます。今回は、アプリケーションにホットローディング機能を持たせたいという話なので、ミドルウェアとして実装するのがピッタリではないかと思います。
なお、Lackにおけるアプリケーションも定義は大変シンプルで、各種HTTP情報がKey-Value形式(property list)で入ったenv
を受け取って、決められたれ形式のレスポンスを返すというものです。*ws-app*
もアプリケーションです。
コード貼り付け:src/middleware.lisp
(defun make-hot-load-middleware (&key main-js-path string-url) (lambda (app) (lambda (env) (create-js-file-if-required main-js-path) (let ((uri (getf env :request-uri))) (if (string= uri string-url) (funcall *ws-app* env) (funcall app env))))))
同ミドルウェアを作成する関数make-hot-load-middleware
の実装はこれだけです。主に2つのことをしています。一つは、アクセスがあった際にmake-js-path
で指定されたローカルファイルに、アクセス時点で定義済みのJavaScriptコードを書き出すこと(create-js-file-if-required
:詳細後述)で、もう一つは、string-url
で指定されたURL情報をもとに、*ws-app*
を呼び出してWebSocketを開くことです。
使い方の全体は前回記事参照ですが、例えば*static-app*
というアプリケーションにホットローディング機能を持たせる場合、下記のように利用します。このとき、(start)
としてサーバを開始した後、ws://localhost:5000/ws
にアクセスすることでWebSocket通信を開くことができ、一方で/ws
以外のアドレスにアクセスした場合は*static-app*
側に処理が流れます。
(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))
ホットローディング対象のコードを書くためのインタフェースの実装
ホットローディングなコードを書くためには、前回記事の使い方の章で述べたようにdefun.hl
やdefvar.hl
(hl = hot loads)といったマクロを利用します。これらを評価した時点で、関数や変数の新たな定義がWebSocketを通じてブラウザ側に送信されることになります。
また、一度評価した定義は環境中に残っており、新しく繋いできたブラウザに対しては一通りの定義を書き出したJavaScriptファイルを作成して送ります*1。
さて、defun.hl
やdefvar.hl
の基礎となっているのが、with-hot-loads
マクロです。
コード貼り付け:src/defines.lisp(※以降もこのソース)
(defmacro with-hot-loads ((&key label) &body body) `(progn (add-ps-def ',label ',body) (send-ps-code ',body)))
progn
の中に書かれている2つの関数が上記で述べた役割をそれぞれ担っています。まずは、ホットローディングを担うsend-ps-code
を見ます。
(defun send-ps-code (body) (send-from-server (convert-ps-s-expr-to-str body)))
…これだけです。前述のようにsend-from-server
では文字列で渡されたCommon LispコードをJavaScriptコードにコンパイルしてブラウザへ送信します。そのため、convert-ps-s-expr-to-str
でbody
(S式)を文字列に変換します。
次に、一度定義したものを覚えておいて、JavaScriptファイルに書き出す部分です。このうち、覚えておく部分がwith-hot-loads
で呼ばれているadd-ps-def
になります。
(defstruct ps-def label def) (defstruct ps-def-manager lst last-updated) (defvar *ps-def-manager* (make-ps-def-manager)) (defun add-ps-def (label def) (check-type label symbol) (with-slots (lst last-updated) *ps-def-manager* (setf last-updated (get-universal-time)) (let ((found (find-if (lambda (ps-def) (eq label (ps-def-label ps-def))) lst))) (if found (setf (ps-def-def found) def) (push (make-ps-def :label label :def def) lst)))))
シンボル(label
)と定義(def
)のペアをps-def
構造体として作成し、それをps-def-manager
にリスト*2として保存しておくという程度の関数です。ついでに、更新のあった時だけ書き出すということを実現するため、最終更新日時をlast-updated
に保存しています。
with-hot-loads
を利用する一例として、defun.hl
の定義は下記のようになっています。関数名(シンボル)をラベル、defun
以降全体を定義部分としています。
(defmacro defun.hl (name lambda-list &body body) `(with-hot-loads (:label ,name) (defun ,name ,lambda-list ,@body)))
こうして保管しておいた定義をファイルに書き出すのがcreate-js-file-if-required
です。これは、ミドルウェアの中で利用した関数です。
(defun create-js-file-if-required (file-path) (check-type file-path pathname) (with-slots ((def-lst lst) last-updated) *ps-def-manager* (when (or (not (probe-file file-path)) (< (file-write-date file-path) last-updated)) (let ((dir (directory-namestring file-path))) (ensure-directories-exist dir) (with-open-file (file file-path :direction :output :if-exists :supersede :if-does-not-exist :create) (dolist (def (reverse def-lst)) (princ (compile-ps-s-expr (ps-def-def def)) file) (terpri file)))))))
更新日時を見て必要であれば、保管しておいた定義一通りをcompile-ps-s-expr
でJavaScriptコードに変換してファイルに書き出すだけの関数です。
なお、同時実行を考慮していないので、同時に書き出すケースではおそらく死にます。
終わりに
以上、Common Lispでホットローディングのプロトタイプを作ってみました。核となる部分はシンプルなものでした。といっても、エラー処理やらの周辺を整えるのが大変なのでしょうね…。ホットローディング機能は欲しい場面もあるのですが、当面はプロトタイプのまま塩漬けになりそうです。
なお、今回はdefun.hl
等を評価した時点で送信する方針をとりましたが、現実的にはファイルの更新を監視するClojureScriptのFigwheelのやり方が現実的だろうと思います。やはりファイル単位でないと、最適化やらパッケージングやら定義順序の保証が難しかったりするので。とはいえ、C-c C-c
を押すだけで即座に送信される方が断然楽しいですし、SLIME上でのCommon Lisp開発により近いので悩ましくもあるのですが。