Common Lispでホットローディングを試しに作る (2) 実装について

前回記事ではCommon Lisp上で実現したホットローディングのプロトタイプのデモや使い方について見ました。

eshamster.hatenablog.com

今回はその実装についてです。といってもベースは実に単純なもので、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-serverready-stateを見ているのがそれで、状態が:closedになっているサーバを除去しています。実を言うと、複数コネクションの管理方法がこれで正しいのか自信がないので、変なことをしていたら教えていただけると助かります…。

後者のコンパイル部分は、send-from-message内でmessageへの束縛を行っている部分です。単に上記コンパイラを呼んでいるだけで、コンパイルエラーが起きた場合は雑にalertを返しています。

ところで、*ws-app*の定義で、(on :message ...)としてクライアントからの送信に反応するようにしています。クライアントから送られてきたCommon LispJavaScriptコンパイルして送り返すという内容です。これは前回少し触れた、下図白枠にCommon Lispコードを書いてボタンを押してサーバに送ると、JavaScriptとして送り返されて実行できる、という機能のためにあります。ホットローディングを実現する上では不要な部分で、実際にライブラリ化する際には除いてよいものです。その場合、send-from-message側でコンパイルをする必然性も薄くなり、同関数の利用者(後述のwith-hot-loads他)側からJavaScriptを渡すようにする方が良さそうです。すると、そちらではParenscriptを直接使えばよいので、上記src/compiler.lisp部分はいらなくなる…といった話になってきます。

f:id:eshamster:20180203000537j:plain

ミドルウェア

ホットローディングライブラリとして見たとき、サーバ機能は上記*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.hldefvar.hl(hl = hot loads)といったマクロを利用します。これらを評価した時点で、関数や変数の新たな定義がWebSocketを通じてブラウザ側に送信されることになります。

また、一度評価した定義は環境中に残っており、新しく繋いできたブラウザに対しては一通りの定義を書き出したJavaScriptファイルを作成して送ります*1

さて、defun.hldefvar.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-strbody(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-exprJavaScriptコードに変換してファイルに書き出すだけの関数です。

なお、同時実行を考慮していないので、同時に書き出すケースではおそらく死にます。

終わりに

以上、Common Lispでホットローディングのプロトタイプを作ってみました。核となる部分はシンプルなものでした。といっても、エラー処理やらの周辺を整えるのが大変なのでしょうね…。ホットローディング機能は欲しい場面もあるのですが、当面はプロトタイプのまま塩漬けになりそうです。

なお、今回はdefun.hl等を評価した時点で送信する方針をとりましたが、現実的にはファイルの更新を監視するClojureScriptのFigwheelのやり方が現実的だろうと思います。やはりファイル単位でないと、最適化やらパッケージングやら定義順序の保証が難しかったりするので。とはいえ、C-c C-cを押すだけで即座に送信される方が断然楽しいですし、SLIME上でのCommon Lisp開発により近いので悩ましくもあるのですが。


*1:正確には書き出すまでが役割で、ファイルを返すのはサーバ実装者の責務になっています

*2:なんでハッシュにしなかったんだっけ、と思いましたが、定義順を保存するためにリストにしたのでした