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:なんでハッシュにしなかったんだっけ、と思いましたが、定義順を保存するためにリストにしたのでした

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:ここは現状ライブラリ側で十分サポートできていない部分です

[Clojure] Figwheel + cljsjs/threeでホットローディングを試す

前回記事「Clojure + Emacsな開発環境を作った on Docker - eshamster’s diary」でひとまずClojure開発環境を整えたので、前々から気になっていたClojureScriptを試してみます。

これまでもThree.jsWebGL上に構築された3Dライブラリ)を触ってきたので、まずはThree.jsの簡単なサンプルを動かすことを目指します。また、ブラウザの更新なしにコードの変更を反映させる、いわゆるホットローディング機構も一緒に試してみたいと思います。

今回構築したサンプルコードのリポジトリは以下になります。

github.com

利用するライブラリ

  • Figwheel
    • ClojureScriptソースの自動ビルドやホットローディングなどを提供してくれるLeiningenプラグインです
  • cljsjs/three
    • Three.jsのClojureScript用ラッパーです

簡単なWebアプリ with ClojureScriptを作るときのデファクトがまだよく分からないのですが、少し調べた範囲ではFigwheelのテンプレートから始めるのが簡単そうでした。また、READMEが非常に充実しており、ClojureScriptのQuickStartにもリンクを貼るなど、初心者もがっちり取り込もうという意志を感じます。

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

まずは出来上がったものでホットローディングを試してみます。ベースとなるものは一つのキューブがくるくる回っているだけの簡単なThree.jsサンプルです。ついでに定期的に色が変わったり、地味にカメラが遠ざかっていたりしています*1

Basic movement

ページが重くなりそうなのでリンクにしますが、下記がホットローディングのデモ(GIFアニメーション)です。画面左にブラウザ、右にエディタ*2を表示しています。エディタ側でファイルを変更して保存すると、少し間をおいてブラウザ側に反映される様子が分かると思います。楽しいです。

使い方

下記のようにすれば、ブラウザからhttp://localhost:3449につないでデモに示した画面を見ることができます。

$ git clone https://github.com/eshamster/sample-clj-three-js
$ cd sample-clj-three-js
$ lein figwheel

ただし、lein figwheelによる立ち上げではEmacs(Cider)と連携できません。連携させるためには、代わりにEmacsを立ち上げてM-x cider-jack-in(結構時間がかかります)した後、REPL上で下記を実行します。これで、Clojureソース内だけでなく、ClojureScript内でも定義ジャンプなどが有効になります*3

> (use 'figwheel-sidecar.repl-api)
> (start-figwheel!)
> (cljs-repl)

これについては、公式Wikiの"Using the Figwheel REPL within NRepl"が詳しいです。なお、そこで解説されている下記の設定ですが、Figwheelのテンプレートからプロジェクトを起こせば最初からproject.cljに書かれています。そのため、上記の手順のみでEmacsと連携することができます。

;; ~前略~
  ;; Setting up nREPL for Figwheel and ClojureScript dev
  ;; Please see:
  ;; https://github.com/bhauman/lein-figwheel/wiki/Using-the-Figwheel-REPL-within-NRepl
  :profiles {:dev {:dependencies [[binaryage/devtools "0.9.4"]
                                  [figwheel-sidecar "0.5.14"]
                                  [com.cemerick/piggieback "0.2.2"]]
                   ;; need to add dev source path here to get user.clj loaded
                   :source-paths ["src" "dev"]
                   ;; for CIDER
                   ;; :plugins [[cider/cider-nrepl "0.12.0"]]
                   :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]}
                   ;; need to add the compliled assets to the :clean-targets
                   :clean-targets ^{:protect false} ["resources/public/js/compiled"
                                                     :target-path]}})

コードについて

今回書いたコードについて

コードについては、まず下記のようにFigwheelのテンプレートからプロジェクトを起こしました。

$ lein new figwheel sample-clj-three-js

構成は下記のとおりです。特にファイルの追加などは行っていないのでテンプレートままです。デモで触っていたClojureScriptファイルはsrc/sample_clj_three_js_figwheel/core.cljsです。

├── README.md
├── dev
│   └── user.clj
├── project.clj
├── resources
│   └── public
│       ├── css
│       │   └── style.css
│       └── index.html
└── src
    └── sample_clj_three_js_figwheel
        └── core.cljs

その.../core.cljsの中身は次のようになっています。

(ns sample-clj-three-js-figwheel.core
    (:require cljsjs.three))

(enable-console-print!)

(defonce frame-counter (atom 0))

(defn change-color [mesh new-color]
  (aset mesh "material" "color" (js/THREE.Color. new-color))
  (aset mesh "material" "needsUpdate" true))

(defn update-mesh [mesh]
  (aset mesh "rotation" "x" (- (.-x (.-rotation mesh)) 0.001))
  (aset mesh "rotation" "z" (- (.-z (.-rotation mesh)) 0.02))
  (let [color-list [0xff0000 0x00ff00 0xaaaaff]
        interval 120]
    (when (= (mod @frame-counter interval) 0)
      (let [div (/ (mod @frame-counter
                        (* interval (count color-list)))
                   interval)]
        (change-color mesh (nth color-list div))))))

(defn update-camera [camera]
  (let [z (.-z (.-position camera))]
    (when (< z 1000)
      (aset camera "position" "z" (+ 0.4 z)))))

(defn update-others []
  (swap! frame-counter #(+ % 1)))

(defn init []
  (let [scene (js/THREE.Scene.)
        screen-width 500
        screen-height 500
        p-camera (js/THREE.PerspectiveCamera.
                  45 (/ screen-width screen-height) 1 10000)
        box (js/THREE.BoxGeometry. 200 200 200)
        mat (js/THREE.MeshBasicMaterial.
             (js-obj "color" 0xff0000
                     "wireframe" true))
        mesh (js/THREE.Mesh. box mat)
        renderer (js/THREE.WebGLRenderer.)]
    ;; Change the starting position of cube and camera
    (aset p-camera "name" "p-camera")
    (aset p-camera "position" "z" 500)
    (aset mesh "rotation" "x" -20)
    (aset mesh "rotation" "y" 0)
    (.setSize renderer screen-width screen-height)
    ;; Add camera, mesh and box to scene and then that to DOM node.
    (.add scene p-camera)
    (.add scene mesh)
    (.appendChild js/document.body (.-domElement renderer))
    ;; Kick off the animation loop updating
    (defn render []
      (update-others)
      (update-mesh mesh)
      (update-camera p-camera)
      (.render renderer scene p-camera))

    (defn animate []
      (.requestAnimationFrame js/window animate)
      (render))

    (animate)))

(defonce to-init-once
  (atom (init)))

initの中身はほぼcljsjs/threeのREADMEのサンプルままです。ホットローディングを試すため、init内部で定義しているrender(これが毎フレーム呼び出される関数です)において、外で定義した各種update用関数を呼び出すようにしています。

ホットローディングを考える上で重要なのはdefonceです。FigwheelのREADMEの"Writing Reloadable Code"で説明されていますが、(defonce <name> <value>)の形で書いておいたものは、最初のロード時のみ評価され、ホットロード時には評価されないようになります。逆に言うと、それ以外のものはホットロード時にすべて再評価されます。

上記のコードでは、フレーム数をカウントするframe-counter変数の定義と、初期化関数であるinitの呼び出しが再評価されないようにしています。update-meshなどの各種アップデート関数の定義は再評価されるため、ホットリローディングを行った次のフレーム以降で動作が変わることになります。initの方も定義自体は再評価されるのですが、再度呼び出される機会がないため、変更を反映させるにはブラウザのリロードが必要になります。

ホットローディングの基本的な仕組みについて

こうした動作から考えると、ホットリローディングの基本的な仕組み自体は下記のように非常にシンプルなものと推測されます。とはいえ、依存性解決やブラウザとの連携、開発環境の整備、エラー処理などなど周辺を整えることこそ大変なのだとは思いますが…。

  1. ファイルの更新を検知したら(依存ファイルも含め?)ファイル全体をJSコードにコンパイルする
    • このとき、defonceされた部分は再評価されないようなコードにしておく
  2. それを一通りブラウザ側へ送りつける
    • 通信路にはページロード時に確立したWebSocketを使う
  3. ブラウザではそれを丸々評価(eval?)する

せっかくなので、1に関連して、生成されたJavaScriptコードのdefonce相当部分を見てみると、下記のようになっていました(resources/public/js/compiled/out/sample_clj_three_js_figwheel/core.js)。シンプルに、defonceされた変数がundefinedかどうかで読み込みの有無を決めているようです。このため、意図的にundefinedを入れた場合*4や、返り値がundefinedな関数の呼び出しは再評価の対象になってしまうことが分かります。initの呼び出しはまさにその後者のケースであるため、atomで囲うことで再評価対象から外しています。

/* frame-counterの定義部分 */
if(typeof sample_clj_three_js_figwheel.core.frame_counter !== 'undefined'){
} else {
sample_clj_three_js_figwheel.core.frame_counter = cljs.core.atom.call(null,(0));
}
/* ~中略~ */
/* init関数の呼び出し部分 */
if(typeof sample_clj_three_js_figwheel.core.to_init_once !== 'undefined'){
} else {
sample_clj_three_js_figwheel.core.to_init_once = cljs.core.atom.call(null,sample_clj_three_js_figwheel.core.init.call(null));
}

余談:Figwheelを試していて印象的だった点

まだまだClojureについては新参者であるため色々驚く部分もあったわけですが、今回Figwheelを触っていて特に印象的だった点を3つほどメモしておきます。

利用しているClojureのバージョン

テンプレートから起こした時点で、project.cljから参照されているClojureのバージョンが 1.9.0 Beta4でした。これはFigwheeというよりClojure全体でなのかもしれませんが、正式前のバージョンも積極的に使っていく文化なのですね。

Flappy bird demo of figwheel

READMEの冒頭にFlappy birdでホットリローディングをデモするYouTube動画(↓)が貼られています。こんなものを見せられたら一発で興味が湧くに決まっています。

www.youtube.com

Configurationエラーメッセージの詳細さ

READMEの最初の方でFigwheelの特徴を並べているのですが、その中の一つに"First Class Configuration Error Reporting"と、設定エラーメッセージの詳細さについて述べたものがあります。これを見たときは、書いてある通り設定エラーって結構心折れるし、そこが詳細なのは良さそうだねー、と軽く読み流していました。が、実際にエラーを目の当たりにしてみると、その詳細さや有用さにかなり驚かされました。

実際に遭遇したWebSocket関連の設定エラーでの例を見てみます。

WebSocket connection to 'ws://localhost:3449/figwheel-ws/dev' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED figwheel$client$socket$open @ socket.js:sourcemap:97

まず、最初に試そうとしたときにJavaScript側で上記のようなエラーになりました。開発環境がDocker上にあるため、コンテナ内で見せているアドレスlocalhost:3449とブラウザからWebSocketでアクセスすべきアドレスが食い違っていたことが原因です。

このような場合は、project.clj内にWebSocketがつなぐべきアドレスを:websocket-urlというパラメータで明に設定しておく必要があります。これを置けそうに見えた箇所が2つほどあったので、適当に片方に置いてみたところ、そちらは間違いでした。そのときに出力されたエラーが詳細かつ非常に有用なもので、次のような要素を含んでいました。最早これ以上調べることはないぐらいの情報を提示してくれています。

  • どこがエラーであったかの表示
  • その場所に本来置けるキーワードの一覧
  • そのキーワード(:websocket-url)をどこに置くべきかのサジェスト
  • そのキーワードについての一般的な説明

設定エラーというと良くてエラー箇所を示してくれるぐらいの印象でしたし、Clojure一般はエラーメッセージが不親切な印象(Javaの長いスタックトレース!)でしたし、まさかこんなに有用なものが表示されるとは完全に想定外で、非常に驚きました。

長いですが一通り貼り付けます。

------ Figwheel Configuration Error ------

The key :websocket-url at (:figwheel) is on the wrong path.

/root/work/sample-clj-three-js/project.clj:55:13
  50                            :pretty-print false}}]}
  51
  52   :figwheel {;; :http-server-root "public" ;; default and assumes "resources"
  53              :server-port 3449
  54              ;; :server-ip "127.0.0.1"
  55              :websocket-url "ws://localhost:8080/figwheel-ws/dev"
                  ^---  The key :websocket-url has been misplaced
  56
  57              :css-dirs ["resources/public/css"] ;; watch and update CSS
  58
  59              ;; Start an nREPL server into the running figwheel process
  60              ;; :nrepl-port 7888

The :websocket-url key should probably be placed like so:

  {:cljsbuild
   {:builds
    [{:figwheel
      {:websocket-url
       "ws://localhost:8080/figwheel-ws/dev"
       ^---- The key :websocket-url should probably be placed here
       }}]}}


-- Docs for key :websocket-url --
You can override the websocket url that is used by the figwheel client
by specifying a :websocket-url

The value of :websocket-url is usually
  :websocket-url "ws://localhost:3449/figwheel-ws"

The :websocket-url is normally derived from the :websocket-host option.
If you supply a :websocket-url the :websocket-host option will be ignored.

The :websocket-url allows you to use tags for common dynamic values.
For example in:
  :websocket-url "ws://[[client-hostname]]:[[server-port]]/figwheel-ws"

Figwheel will fill in the [[client-hostname]] and [[server-port]] tags

Available tags are:
  [[server-hostname]] ;; supplies the detected server hostname
  [[server-ip]]       ;; supplies the detected server ip
  [[server-port]]     ;; supplies the figwheel server port
  [[client-hostname]] ;; supplies the current hostname on the client
  [[client-port]]     ;; supplies the current hostname on the client

------------------------------------------
Figwheel: There are errors in your configuration file - project.clj

あとは、Common Lisprestart-caseのように下記のような選択しを出してくれるのも良いです。

Figwheel: Would you like to ...
(f)ix the error live while Figwheel watches for config changes?
(q)uit and fix your configuration?
(s)tart Figwheel anyway?
Please choose f, q or s and then hit Enter [f]:

*1:カクカクしているのは単にGifを15FPSでとったからで、実際には60FPSで滑らかに動いています

*2:CygwinからSSHVPSマシンにつないで、その上で動かしているDocker上のCUIEmacsを使う…という環境です

*3:念のため、ホットローディングを試すだけであれば、lein figwheelで立ち上げても問題ありません(cljsファイル更新の検知がトリガであるため)

*4:そのようなケースでは普通nullを入れるでしょうから、余りないとは思いますが…

Clojure + Emacsな開発環境を作った on Docker

前回記事「Common Lisp開発環境 on Dockerの現状 - eshamster’s diary」の冒頭で少し書いていたように、Clojure開発用のDocker環境を作ってみました。そろそろいい加減にClojureScriptをいじってみようかというのが主な動機です。

Docker上に開発環境を起こすのはCommon Lispに続いてようやく2言語目なので、言語ごとの開発環境を量産するための知見(主にEmacsの設定をどう管理するか)を得るという副目標もあります。

先に完成品のリンクを貼っておきます。


目次

概要

次のような構成を目指します。

  1. Alpine Linux:軽量なLinux
  2. LeiningenClojureプロジェクトの色々な管理をするソフト(Node.jsで言うnpmのような位置付け)
  3. Emacs

1, 2に関しては公式のDockerイメージが提供されているので、DockerfileでFROM clojure:lein-2.8.1-alpineのようにするだけです。

3のEmacsの設定については下記を参考…というよりClojure部分についてはほぼそのままです。

qiita.com

そんなわけなので、正直余り書くこともなかったりします…。

Dockerfile

Dockerfileの記述ですが、上記の通りClojure (Leiningen) の設定については公式リポジトリからFROMするだけですし、Emacsについてもapkの標準リポジトリからとってくるだけで新しいもの(現在はバージョン25.3.1でした)がとれますし、特筆すべき点がないです…。

FROM clojure:lein-2.8.1-alpine

RUN apk update --no-cache
RUN apk add --no-cache emacs git

# --- install wget with certificate --- #
RUN apk add --no-cache ca-certificates wget openssh && \
    update-ca-certificates

# --- user settings --- #
ARG emacs_home=/root/.emacs.d
ARG site_lisp=${emacs_home}/site-lisp
ARG dev_dir=/root/work

RUN mkdir ${emacs_home} && \
    mkdir ${site_lisp} && \
    mkdir ${dev_dir}

# --- run emacs for installing packages --- #
COPY init.el ${emacs_home}
RUN emacs --batch --load ${emacs_home}/init.el

# --- miscs --- #
WORKDIR /root

Emacsの設定

設定自体について

前述のようにClojure部分の設定については、ほぼ「新: Emacs を使うモダンな Clojure 開発環境 - Qiita」のままです。最低限使いそうなところだけピックアップして言った感じなので、差異や不足のある部分には大して意味はありません。意図的に変えたのは下記程度です(細かい話ですが…)。

  • company-modeauto-completeの代替)のキーバインド
    • 個人的にはM-p, M-nでの候補選択に慣れているので、C-p, C-nへの割り当てはしていません
    • 個人的にはC-iはデフォルトの用途で頻繁に利用するので、候補の強制呼び出しのキーバインドは代わりにC-c C-iとしました
  • RainbowDelimiters
    • 記事中にも「そこまでの効果はない」とありますが、実際昔使ってみたときに嬉しさを感じられなかったので外しました

全体は少し長いので現時点の設定へのリンクだけ貼っておきます。88行目の;; ----- Clojure ----- ;;というコメント以降("auto settings"以降を除く)がClojure関連の設定になります。

https://github.com/eshamster/docker-clj-devel/blob/a3addf8c5dd58d1f2b6fbbd08a75b54aad4a9071/init.el

言語別環境の量産に向けた簡単な考察

さて、Common Lisp用環境を作るときは余り意識していませんでしたが、今回init.elの中で、共通設定*1Clojureの設定を分離しています(前述のように88行目前後が分かれ目)。もう少し踏み込んで、言語別のDocker環境(用のEmacs設定)を管理するためには下記のようにするのが良さそうだというのが今の考えです(実際やってみると変わりそうですが)。

  • Emacs設定ファイル側
    • 共通設定と言語別設定を別ファイルに分ける
    • これらのロードにはinit-loaderるびきちさんの紹介記事リンク)を利用する
      • init.el自体を触ることなく、特定の場所に(特定の形式で)追加の設定ファイルを置くだけで読み込み対象を変えられる、というのはDockerfileの記述と相性が良さそうです
    • これらはDockerfile用リポジトリとは独立したリポジトリ上で管理する
      • 複数言語を同時に利用するような環境が欲しくなった場合のことを考えると、共通設定だけでなく言語別設定もまとめて管理した方が良さそうです
  • Dockerfile周辺側
    • 上記Emacs設定ファイルリポジトリをsubtreeないしsubmoduleとして取り込む
    • Dockerファイル上では必要なものを~/.emacs.d/inits/配下にCOPYコマンドで持っていく

実際に使うとき

実際に開発環境として利用する際にはもう少しローカルな設定が必要になりますが、これこそ以前書いたCommon Lisp用環境記事の同名の章と変わらないので、ポイントのコピペとファイルの貼り付けだけで終わりにします*2

  • ポイント
    • gitの設定やSSH鍵のコピーなどパーソナルな設定をするためのDockerfileを作る
    • ポートやボリュームの設定を記述した設定ファイルを作る(setenv
    • 上記を利用して起動するためのスクリプトを作る(run.sh
      • ./run.shでコンテナをビルド・起動した後は、コンテナ内の/root/workフォルダで作業します

Dockerfile: ※横に利用するSSH鍵を置いておくこと

FROM eshamster/clj-devel:latest

# --- git settings --- #
RUN git config --global user.name "eshamster" && \
    git config --global user.email "hamgoostar@gmail.com"

# --- ssh settings --- #
ARG user=root

ARG SSH_HOME=/root/.ssh
RUN mkdir ${SSH_HOME} && \
    chmod 700 ${SSH_HOME}

USER root
COPY id_rsa ${SSH_HOME}
COPY id_rsa.pub ${SSH_HOME}
RUN chown ${user}:${user} ${SSH_HOME}/* && \
    chmod 600 ${SSH_HOME}/*

# --- --- #
USER ${user}
RUN echo "export LANG=ja_JP.UTF-8" >> ${HOME}/.bashrc

RUN apk add --no-cache openssl-dev

setenv

export HOST_PORT=17381
export GUEST_PORT=18616
export RUN_NAME=clj
export VOLUME=${HOME}/work/clojure

run.sh

#!/bin/bash

set -eu

. "${1:-$(dirname ${0})/setenv}"

docker rmi $(docker images | awk '/^<none>/ { print $3 }') || echo "ignore rmi error"
docker rm `docker ps -a -q` || echo "ignore rm error"

name="clj_web_devel"

docker build -t ${name} .
docker run --name=${RUN_NAME} -p ${HOST_PORT}:${GUEST_PORT} \
  -e "OPEN_PORT=${GUEST_PORT}" \
  -e "HOST_PORT=${HOST_PORT}" \
  -v ${VOLUME}:/root/work \
  -it ${name} /bin/sh

*1:Common Lisp環境のものと実質は同じですが、use-packageを使ってリファクタリングしました

*2:これはこれでリポジトリ起こして管理した方が良いのだろうとは思っています。ただ、今回のように「(DBなしの)Web開発用の最低限の設定」程度であれば共通化できるのですが、例えば、PostgreSQLを使う環境用、Redisを使う環境用…と個別のアプリ向け設定が必要になってきたときにどう管理すべきか考えられてないので足踏みしてます

Common Lisp開発環境 on Dockerの現状

目次

前書き

あけましておめでとうございます。昨年は5件しか投稿していませんでしたが、今年はもっと増やす所存であります。

Clojureの開発環境をDocker上に作ったので記事にしようかと思い、1年以上前に書いたCommon Lisp + Emacs環境 on Dockerな記事を見直していたのですが、現状と合わないままになっていることに気付きました。そのままにして新しい環境について書くのも寝覚めが悪いので、アップデート状況を簡単に書きます。

何がどう変わったか

eshamster.hatenablog.com

最初にDocker上での開発環境を作った時のものが上記の記事でした。ここではCentOS7ベースで作っていました。cl-devel (DockerHub)としてDockerHubにも上げています。

eshamster.hatenablog.com

次に、上記の記事ではCommon Lispの実行環境(not 開発環境)をAlpine Linux*1ベースで作成しました。こちらはcl-baseと名付けています。

この記事で、開発環境もこの上に作り直したい…ということを言っていたのですが、現状はその通りになっています。つまり、Alpine + Common Lisp (with Roswell) + Emacs on Dockerな環境になっています。ただ、当時DockerHubに慣れていなかったばかりに、上述のcl-develGitHubとの連動ができていなかったので、下記のようにcl-devel2として新しく作っています(作成後の連携は無理そうでした)。分かりにくくなるのでよろしくないのですが、念のため元の方も残しておこうかと…。

主には以上です。以下細部ですが、以前書いたものとそれほど変更はないです。

Dockerfile

Dockerファイルをべったり貼り付けておきます。入れているものなどはCentOSベースの時と変わっていません。詳細は非Dockerな開発環境を作った「 Common Lisp開発環境を新規に作ったのでメモ - eshamster’s diary (非Docker)」で書いていたものと大体同じです。

細かな部分で目立った違いとして、apkの標準リポジトリは割と新しいEmacsが入っているので、自力でEmacsをビルドする必要がなくなった点は結構助かりました。あとは、(ソース中にもコメントしてますが)Slime 2.20がうまく動かせそうになかったため、Slime 2.19を明示的に入れていますね…。どういう環境で起きるものなのかなど、きちんと調査できてないです。

init.elは余り変わっていないので省略します。Clojure環境を作るときにいくらか整理(リファクタリング)したので、そのうち反映する…つもりです。

FROM eshamster/cl-base:2.3

RUN apk update --no-cache
RUN apk add --no-cache emacs git w3m

# --- install wget with certificate --- #

RUN apk add --no-cache ca-certificates wget openssh && \
    update-ca-certificates

# --- make work directory --- #

ARG work_dir=/tmp/work
RUN mkdir ${work_dir}

# --- user settings --- #

ARG emacs_home=/root/.emacs.d
ARG site_lisp=${emacs_home}/site-lisp
ARG emacs_docs=${emacs_home}/docs

ARG dev_dir=/root/work

RUN mkdir ${emacs_home} && \
    mkdir ${site_lisp} && \
    mkdir ${emacs_docs}

RUN ln -s ${HOME}/.roswell/local-projects ${dev_dir}

# --- install HyperSpec --- #

ARG hyperspec=HyperSpec-7-0

RUN cd ${work_dir} && \
    wget -O - ftp://ftp.lispworks.com/pub/software_tools/reference/${hyperspec}.tar.gz | tar zxf - && \
    mv HyperSpec ${emacs_docs}

# --- install slime-repl-color --- #

RUN cd ${site_lisp} && \
    wget https://raw.githubusercontent.com/deadtrickster/slime-repl-ansi-color/master/slime-repl-ansi-color.el

# --- run emacs for installing packages --- #

# In slime 2.20, slime-restart-inferior-lisp fails when using ccl-bin.
# If changing lexical-binding in slime.el to nil, it could be solved.
# But in the settings, it fails when using sbcl-bin...
# So I decided to downgrade slime to 2.19
RUN cd ${emacs_home}/site-lisp && \
    wget -O - https://github.com/slime/slime/archive/v2.19.tar.gz | tar zxf - && \
    wget -O - https://github.com/purcell/ac-slime/archive/0.8.tar.gz | tar zxf -
COPY init.el ${emacs_home}

RUN emacs --batch --load ${emacs_home}/init.el

# --- miscs --- #
WORKDIR /root

実際に使うとき

Common Lisp開発環境 on Docker - eshamster’s diary 」の同名の章からあまり変わっていないです。ポイントは下記でした。

  • gitの設定やSSH鍵のコピーなどパーソナルな設定をするためのDockerfileを作る
  • ポートやボリュームの設定をした設定ファイルを作る(setenv
  • 上記を利用して起動するためのスクリプトを作る(run.sh
    • ./run.shでコンテナをビルド・起動した後は、コンテナ内の/root/work/lispフォルダで作業します

以下、変更点だけコメントして貼り付けていきます。

Dockerfile: ベースイメージを変更した点と、よく使うものをros installしておくようにした点が主な変更です。build前に、利用するSSH鍵を横に置いてください(こちらは変更点ではないですが、念のため)。

FROM eshamster/cl-devel2:3.5B

# --- git settings --- #
RUN git config --global user.name "<ユーザ名>" && \
    git config --global user.email "<メールアドレス>"

# --- ssh settings --- #
ARG user=root

ARG SSH_HOME=/root/.ssh
RUN mkdir ${SSH_HOME} && \
    chmod 700 ${SSH_HOME}

USER root
COPY id_rsa ${SSH_HOME}
COPY id_rsa.pub ${SSH_HOME}
RUN chown ${user}:${user} ${SSH_HOME}/* && \
    chmod 600 ${SSH_HOME}/*

# --- --- #
USER ${user}
RUN ros install prove && \
    ros install qlot && \
    ros install ccl-bin && \
    ros use ccl-bin
RUN echo "export PATH=${HOME}/.roswell/bin:${PATH}" >> ${HOME}/.bashrc
RUN echo "export LANG=ja_JP.UTF-8" >> ${HOME}/.bashrc

RUN apk add --no-cache openssl-dev

setenv: VOLUMEの設定をこちらに持ってきました

export HOST_PORT=17380
export GUEST_PORT=18616
export RUN_NAME=cl
export VOLUME=${HOME}/work/lisp

run.sh: 特に変更なし。横に一時環境を起こしたいときは、設定ファイルをもう一つ作って第一引数でそちらを指定すればよいです。

#!/bin/bash

set -eu

. "${1:-$(dirname ${0})/setenv}"

docker rmi $(docker images | awk '/^<none>/ { print $3 }') || echo "ignore rmi error"
docker rm `docker ps -a -q` || echo "ignore rm error"

docker build -t cl .
docker run --name=${RUN_NAME} -p ${HOST_PORT}:${GUEST_PORT} -e "OPEN_PORT=${GUEST_PORT}" -e "HOST_PORT=${HOST_PORT}" -v ${VOLUME}:/root/work/lisp -it cl /bin/sh

以上です。やはり簡単に環境作り直せるのはいいですね。なんだかんだでアップデートの時は何かと引っかかってそれなりに苦労しますが…。


*1:※軽量なLinuxでDockerコンテナのベースとして広く使われています

Parenscript上でシンボルのインポートやエクスポートを模倣する

前書き

Lisp Advent Calendar 2017の20日目の記事です。

Common LispのサブセットコードをJavaScriptに変換するParenscriptというライブラリの上でそれらしい名前空間を導入してみた記事です。考え方や実装の整理・メモという意味合いが強いので、果たしてこれを読んで何かの役に立つのかは疑問ですが、Common Lispのパッケージ・シンボルシステムを利用したメタプログラミングについて考える一つの題材になる…かもしれません。

今回の話の発端ですが、まずParenscriptで不便に思ったところを適当に拡張した自作ライブラリps-experimentというものを作っています(quicklispリポジトリ未登録)。

github.com

かれこれ2年ほどいじっていて、割合この上で快適にプログラムを書けるようになってきたのですが、ふと名前の衝突を気にしながら書いていることに気付きました。というのも、グローバル変数や関数の定義をパッケージでグループ化するという程度の簡易パッケージシステムは導入していたものの、名前空間の分割までは行っていなかったためです。そこで、Common Lispのパッケージ・シンボルが持つ情報やJavaScriptクロージャを使えば案外簡単にJavaScript上でシンボルのインポートやエクスポートを模倣できるのではないか、と思って試したところ意外と苦労したというような話です。

誤解のないように強調しておくと、今回の話はParenscript上に独自のパッケージ管理構文を作りこむような話ではなく、あくまで既存のCommon Lispのパッケージ・シンボルの情報を利用することで名前空間が分離されたJavaScriptコードを吐き出すという話です。

なお、該当のコミットは"cef5d6: Change to split namespace by package"になります(コミットログが割と本記事の要約)。

前提知識

Parenscript

超簡易紹介

ParenscriptはCommon Lispの(サブセット)コードをJavaScriptコードに変換してくれるライブラリです。下のように ps:ps マクロの中にCommon Lispコードを書くとJavascriptコードを文字列として出力してくれます。

CL-USER> (ql:quickload :parenscript :silent t)
(:PARENSCRIPT)
CL-USER> (ps:ps (test-func 10 20))
"testFunc(10, 20);"
CL-USER> (ps:ps (funcall (lambda (a b) (+ a b))
                         10
                         20))
"(function (a, b) {
    return a + b;
})(10, 20);"

参考:Parenscriptの持つ名前空間システム

参考程度の話ですが、Parenscript自身も名前空間システムを持っています。ps:ps-package-prefixというマクロを利用して、パッケージとそれに対応するプレフィックスを登録しておくというものです。すると、登録されたパッケージ下のシンボルをJavaScriptコードとして出力する際に、プレフィックスが付与されます。

実際のところ、これに素直に乗っかれば今回引っかかったような各種問題は発生しないはずです。が、下記の通り、該当するあらゆるシンボルにプレフィックスがついてしまうため余り見た目が良くない*1ですし、また曲がりなりにもJavaScriptにも名前空間を分ける仕組み(クロージャ)はあるにも関わらずそれを使っていない…という辺りが悶々とします。

CL-USER> (defpackage test-pack (:use :cl :parenscript))
#<Package "TEST-PACK">
CL-USER> (in-package :test-pack)
#<Package "TEST-PACK">
TEST-PACK> (setf (ps-package-prefix "TEST-PACK") "some_prefix_")
"some_prefix_"
TEST-PACK> (ps (defvar x 100)
               (defun test (y)
                 (let ((temp (+ x y)))
                   (+ temp 100))))
"var some_prefix_x = 100;
function some_prefix_test(some_prefix_y) {
    var some_prefix_temp = some_prefix_x + some_prefix_y;
    return some_prefix_temp + 100;
};"

ps-experimentにおける従来のパッケージの扱い

もともとps-experimentでもパッケージの情報を少し使っていたのですが、せいぜいシンボルのグループ化と、package-use-listを利用した依存性解決程度でした。

簡単なRoswellスクリプトで利用イメージを書くと下のような感じです。defvar.psdefun.psはパッケージ配下にJavaScript用の定義をひもづけるためにps-experimentで定義しているマクロです(なお、defvar.ps+defun.ps+とするとCommon Lisp用のコードも同時に出力されます)。最後にwith-use-ps-packマクロを利用して指定されたパッケージ(とそこから再帰的にuseされるパッケージ)配下のJavaScriptの定義を吐き出します。

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(ql:quickload :ps-experiment)

(defpackage pack-a
  (:use :cl :ps-experiment))
(defpackage pack-b
  (:use :cl :ps-experiment))

;; ----- Package A ----- ;;
(in-package :pack-a)

(defvar.ps *num* 0)

(defun.ps inc-num (x)
  (incf *num* x))

(defun.ps add (x y)
  (+ x y))

;; ----- Package B ----- ;;
(in-package :pack-b)

;; *num* in pack-a is not guarded
(defun.ps dec-num (x) 
  (incf *num* x))

;; :this = :pack-b
(defun main (&rest argv)
  (declare (ignorable argv))
  (print
   (with-use-ps-pack (:pack-a :this)
     (inc-num (dec-num 10)))))

結果は下記の通りです。見ての通り、名前空間の情報は失われてフラットに関数を並べているだけです。

var NUM = 0;
function incNum(x) {
    return NUM += x;
};
function add(x, y) {
    return x + y;
};
function decNum(x) {
    return NUM -= x;
};
incNum(decNum(10));

大まかなアイディアについて

まずは、実装を抜きにして順次アイディアを検討していきます。実装はその後まとめて見ていきます。

※用語について:JavaScriptコードになった時点で「パッケージ」や「シンボル」は存在しないため、JavaScript側の説明では「Common Lispコードにおいてはパッケージ(シンボル)であったもの」などと呼ぶのが正確ですが、冗長なので特に区別せずに「パッケージ」、「シンボル」と呼びます(微妙に違和感はありますが、たぶん混乱はないだろう…)

基本的なアイディア

最初の方向性として、まずは下記を満たすことを考えます。

  • パッケージ間での名前空間の分離
  • シンボルのインポートの模倣
    • インポートした別パッケージのシンボルを、プレフィックスなしに参照できる
  • シンボルのエクスポートの模倣
    • 自パッケージのエクスポートしたシンボルを、他パッケージから(プレフィックスつきで)参照できる

例えば、下記のパッケージ2つの単純なケースを考えます。

;; --- パッケージA --- ;;
(in-package :cl-user)
(defpackage :temp.pack-a
  (:use :cl :ps-experiment)
  (:export :ex-a1 :ex-a2))
(in-package :temp.pack-a)

(defun.ps+ internal-fn (x) x)

(defun.ps+ ex-a1 ()
  (internal-fn 100))

(defun.ps+ ex-a2 ()
  (internal-fn 200))

;; --- パッケージB --- ;;
(defpackage :temp.pack-b
  (:use :cl :ps-experiment)
  (:import-from :temp.pack-a
                :ex-a1)
  (:export :ex-b))
(in-package :temp.pack-b)

(defun.ps+ internal-fn (x) (* x 2))

(defun.ps+ ex-b ()
  (+ (ex-a1) (internal-fn 200)))

パッケージBに注目すると、パッケージAでエクスポートされたシンボルex-a1をインポートして利用しており、かつ、internal-fnというパッケージAにも(internalに)存在する名称の関数を定義しています。これを次のようにJavaScriptに変換すれば良さそうです。

/* --- パッケージA --- */
var temp_packA = (function() { /* (1) */
  /* --- define objects --- */
  function testA2() {
      return internalFn(200);
  };
  function internalFn(x) {
      return x;
  };
  function exA1() {
      return internalFn(100);
  };
  function exA2() {
      return internalFn(200);
  };
  /* --- extern symbols --- */ /* (2) */
  return {
    'exA1': exA1,
    'exA2': exA2,
  };
})();

/* --- パッケージB --- */
var temp_packB = (function() {
  /* --- import symbols --- */ /* (3) */
  var exA1 = temp_packA.exA1;
  /* --- define objects --- */
  function internalFn(x) {
      return x * 2;
  };
  function exB() {
      return exA1() + internalFn(200);
  };
  /* --- extern symbols --- */
  return {
    'exB': exB,
  };
})();
  • (1) 名前空間の分離を行うため、各パッケージ内の定義をクロージャで囲います。これで、例えば両パッケージに存在するinternalFninternal-fn)の名前空間が分離されます
  • (2) シンボルのエクスポートを模倣するため、シンボルと同名の文字列をキー、シンボル自身を値としたハッシュを返します。これによって、例えばtemp_packAパッケージ内のexA1にはtemp_packA.exA1として外部からアクセスできるようになります。
  • (3) シンボルのインポートを模倣するため、クロージャの冒頭で同名のローカル変数に外部パッケージでエクスポートされた変数を入れておきます。これで、パッケージB内からはプレフィックスなしにtemp.pack-a:exA1にアクセスできるようになります。

ここまでは(実装的にも)順調です。

インポートしていないシンボルの参照

上記はエクスポートしたシンボルをインポートして参照するだけのお行儀の良い例でした。しかし、周知の通りCommon Lispにおいては<パッケージ名>:<シンボル名>としてインポートしなくても(エクスポートされた)シンボルを参照できますし、<パッケージ名>::<シンボル名>とすればインターナルなシンボルに触ることもできます。特にここまでのアイディアでは後者を実現する手段がないため、対処が必要です。

インターナルなシンボルに触るコードを直接書くケースは余りないと思いますが、他パッケージのマクロを利用する場合、自パッケージでインポートしていないシンボルが展開されるということが普通に起こり得ます。

(defpackage :temp.pack-a
  (:use :cl)
  (:export :ex-macro))
(in-package :temp.pack-a)

(defun internal-fn (x) x)
(defmacro ex-macro (x) `(internal-fn ,x))

(defpackage :temp.pack-b
  (:use :cl)
  (:import-from :temp.pack-a
                :ex-macro))
(in-package :temp.pack-b)

(defun internal-fn (y) (+ y 20))

(print (macroexpand-1 '(ex-macro (internal-fn 200))))
;; -> (TEMP.PACK-A::INTERNAL-FN (INTERNAL-FN 200))

表面上パッケージBはインポートしたマクロex-macroを利用しているだけですが、実際にはマクロ展開時にインポートしていないシンボルtemp.pack-a::internal-fnがパッケージB上に展開されてしまいます。もちろん、Common Lispではprint結果のように、適切にパッケージが考慮されていることが分かります。

Parenscript上で同様のことを実現するためには次の2つの要素が足りません。

  • 未エクスポートのシンボルを外から触れるようにする
  • 未インポートのシンボルを識別して↑のシンボルを触るようなJavaScriptコードを出力する

イメージとしては次のようになります。まずはParenscript側のコード。

(in-package :cl-user)
(defpackage :temp.pack-a
  (:use :cl :ps-experiment)
  (:export :ex-macro :ex-fn))
(in-package :temp.pack-a)

(defun.ps+ internal-fn (x) x)
(defun.ps+ ex-fn (x) x)
(defmacro.ps+ ex-macro (x) `(internal-fn ,x))

(in-package :cl-user)
(defpackage :temp.pack-b
  (:use :cl :ps-experiment)
  (:import-from :temp.pack-a
                :ex-macro))
(in-package :temp.pack-b)

(defun.ps+ internal-fn (y) (+ y 20))

(defun.ps+ hoge () (ex-macro (internal-fn 200)))

(print (with-use-ps-pack (:temp.pack-a :temp.pack-b)))

これが下記のようにJavaScriptに変換されれば良さそうです。

var temp_packA = (function() {
  /* --- define objects --- */
  function internalFn(x) {
      return x;
  };
  function exFn(x) {
      return x;
  };
  /* --- extern symbols --- */
  return {
    'exFn': exFn,
    /* ★internalなシンボルを触れるようにする */
    '_internal': {
      'internalFn': internalFn,
    }
  };
})();

var temp_packB = (function() {
  /* --- define objects --- */
  function internalFn(y) {
      return y + 20;
  };
  function hoge() {
      /* ★外部のシンボルを識別してprefixをつける */
      return temp_packA._internal.internalFn(internalFn(200));
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      'internalFn': internalFn,
      'hoge': hoge,
    }
  };
})();

後ろで見るように、実装する上で、一通りのシンボルをエクスポートする部分は何ということはないのですが、未インポートシンボルの識別ではダーティな対処が必要になっています…。

(今のところ)最後の関門:Type Specifier

唐突ですが、よく知られているように、Common Lispにおいて変数と関数の名前空間は分離されています(Lisp-2)。また、それ以外に型(Type Specifier)の名前空間も分離されています。

;; ※Clozure CLで実行(各 def... の出力は省略)
CL-USER> (defparameter x 100)
CL-USER> (defun x ())
CL-USER> (defstruct x)
CL-USER> (describe 'x)
X
Type: SYMBOL
Class: #<BUILT-IN-CLASS SYMBOL>
;; --- 変数であり、関数であり、Type Specifierでもある ---
Special Variable, Function, Type Specifier, Class Name
INTERNAL in package: #<Package "COMMON-LISP-USER">
Print name: "X"
Value: 100
Function: #<Compiled-function X #x30200190257F>
Arglist: NIL
Plist: NIL
Class: #<STRUCTURE-CLASS X>
; No value

Common Lispにおいては下記のようにして使い分けられます。

x    ; 変数としてのアクセス
(x)  ; 関数としてのアクセス
;; Type specifierとしてのアクセス(の一例)
(let ((xx 'x))
  (typep (make-x) xx)) ;; ※直接'xを書くこともできる

これらをParenscriptにかけると下記のようになります(ただし、Parenscriptでtypepは実装されていないため、ps-experiment側で(Parensript用マクロとして)補ったものです)。なお、JavaScript上では変数と関数の名前空間が分離されていないため、同時にはどれか一つしか成立しません。が、これは目をつむることにします*2

x;   /* 変数としてのアクセス */
x(); /* 関数としてのアクセス */
/* Type specifierとしてのアクセス */
(function () {
    var xx = 'x';
    return (makeX() instanceof (typeof xx === 'string' ? eval(xx) : xx));
})();

さて、typepが変換されたものを見てみると非常に怪しげなものがあります。そう、evalです。なぜこんなものが必要になるのか…。Common Lispにおいて、プログラマが直接Type Specifierを指定する手段はシンボルの利用になります。一方で、Parenscriptを通すとシンボルはただの文字列に変換されます。しかし、JavaScriptの文字列は型を指示するものではありません。このため、evalによって型の情報にひもづけられた変数を取り出すことが必要になります。

これがどのように問題になるのかを次の例で考えてみます*3

;; defpackage 省略
(in-package :pack-a)
(defvar.ps+ *some-symbol-list* nil)
(defun.ps+ find-typed-symbol (type-specifier)
  (find (lambda (sym) (typep sym type-specifier)) *some-symbol-list*))

(in-package :pack-b)
(defstruct.ps+ test-st-b)
(push (make-test-st-b) pack-a::*some-symbol-list*)
(pack-a::find-typed-symbol 'test-st-b)

pack-a::find-typed-symbol内のtypepがポイントです。Common Lispとしては特に問題のないコードです。pack-bから'test-st-bというシンボルが渡ってきますが、シンボル自身がパッケージの情報を持っているため、問題なくpack-b::test-st-bにたどり着けます。一方、ここまで説明した形でJavaScriptに変換すると、'test-st-bシンボルは'test-st-b'という文字列に変換され、パッケージの情報が失われます。find-typed-symbolはこれを受け取るわけですが、pack-a名前空間にはtest-st-bなどというものは定義されていませんので、eval(type-specifier) すなわち eval('test-st-b')はエラーとなります。

なお、名前空間の分離をしていなかった世界では、test-st-bはグローバルに見えていたためこの問題は生じませんでした。また、Parenscriptの名前空間機能を素直に使った場合、'test-st-b'にはprefixがつけられてグローバルに見える名前になるため、やはりこの問題は生じません。

さて、この対処の方針は次のようになります。こうすると、上記の例で言えばfind-typed-symbolには型の実体が渡されるようになるため、evalが不要になります。

  • Type Specifierが定義されるときにそのシンボルを記録する
  • JavaScriptへの変換時に、Type Specifierを見つけたら問答無用でquoteを剥ぎ取る
    • 例. 'test-st-b →(quoteをとる)→ test-st-b →(JavaScriptへ変換)→ testStB(文字列ではなく変数として出力される)

これは、今までは変数+関数の名前空間からなんとなく遊離していたType Specifierを明確に同一の名前空間に引き込む行為と解釈できそうです。解決方法として場当たり的すぎないか悩ましかったのですが、そう考えればそれなりに理のある対処ではなかろうかと思います。…結果だけ書くと割とシンプルなのですが、これを解決することに正当性があるのか(≒根本的に何が問題なのか)、実装の方針をどうすべきなのかという部分が非常に悩ましく、あやうく本稿のタイトルが「~模倣しようとしてうまくいかなかった話」になりかけた程のものではありました。

実装について

ここから先、上記で述べたアイディアの実装の話ですが、必要と思われる部分をかいつまんで説明していきます。小さな落とし穴が色々あることや、Parenscriptの関数を上書き(ps-experiment側で同じ関数を再定義)するなどというダーティな実装をしていることに目をつむれば、Common Lispのシンボルが持つ情報を利用することでベースは非常にシンプルに作れることが分かると思います。

なお、実装はおおむねps-experiment/src/package.lisp配下で行っています。また、該当のコミットは"cef5d6: Change to split namespace by package"になります(再掲)。

前置き:ps-experimentにおけるこれまでのパッケージ管理

※今回の変更の前に色々いじったので実は以下の説明と完全に一致するコミットは存在しなかったりします…。後ろの話につなげるための細部は架空な実装です。

まずは、単なるグループ化のためだけにパッケージを利用していた頃のパッケージ管理方法の概要について述べます。ポイントとなるのは、with-use-ps-packで出力するためのシンボルを独自に管理しておくという点です*4。この部分はこの後も同じです。

(defparameter *ps-func-store* (make-hash-table))

管理主体はこの*ps-func-store*です。これはパッケージをキーとしたハッシュになっており、値は次のような構造体のリストです。

(defstruct ps-func 
    name-keyword  ; シンボルを同名のキーワードにしたもの
    (func (lambda () "")))  ; これをfuncallするとJavaScriptコード(文字列)が返される
  • 細かい話
    • name-keywordでキーワードでなく、シンボル自身を登録しておけば、(シンボル自身がパッケージの情報を持っているので)キーとしてのパッケージは不要です。が、今回はあった方が扱いやすそうです
    • ps-funcでいうname-keywordを2段目のキー、funcをその値とするような2重ハッシュとしないのは、できるだけ元の定義順を保存しておきたいためです*5

実装は省略しますが、defun.psなどはロード時に*ps-func-store*への登録を行います。このため、

(in-package :test-pack)
(defun.ps test-fn (x) x)

としておくと、下記のようにしてtest-fnJavaScript関数として書き出せます。

;; *ps-func-store*のパッケージプレフィックス略
(funcall (ps-func-func
          (find (lambda (ps-func)
                  (eq (ps-func-name-keyword ps-func) :test-fn))
                (gethash (find-package "TEST-PACK") *ps-func-store*))))
;; --- 以下出力 --- ;;
"function testFn(x) {
    return x;
};"

例えば他にも、TEST-PACKパッケージ配下のものをすべて出力したければ下記のようにすればよいことになります。

(dolist (ps-func (gethash (find-package "TEST-PACK")
                          *ps-func-store*))
  (print (funcall (ps-func-func ps-func))))

基本的なインポート・エクスポート

まずは、基本的なインポート・エクスポート部分の実装です。「基本的アイディア」節の内容に加え、「インポートしていないシンボルの参照」節で述べたうちの「未エクスポートのシンボルを外から触れるようにする」まではまとめてやってしまいます。

基礎知識:シンボルからの情報取り出し

以降で主に利用するものは、シンボルの持つパッケージについての情報と「状態」についての情報の2つだけです。

パッケージの情報の取り出しは単にsymbol-package関数を利用するだけです(※以降、REPLの出力は適宜省略します)。

CL-USER> (defvar some-var 100)
CL-USER> (symbol-package 'some-var)
#<PACKAGE "COMMON-LISP-USER">

この情報は、あるパッケージから見えているシンボルが、自パッケージで定義したものか他パッケージからインポートしてきたものかを識別するのに使います。

CL-USER> (defpackage test-pack (:use :cl) (:export :aaa))
CL-USER> (defvar test-pack:aaa 100)
CL-USER> (import 'test-pack:aaa)
CL-USER> (symbol-package 'aaa)
#<PACKAGE "TEST-PACK"> ; ← CL-USERパッケージの外から来たことが分かる

次に、シンボルの「状態」ですが、こちらはfind-symbol関数で簡単に取り出せます。

CL-USER> (defvar var-a 100)
CL-USER> (defvar var-b 100)
CL-USER> (export 'var-b)
CL-USER> (find-symbol "VAR-A")
VAR-A
:INTERNAL ; ← これ
CL-USER> (find-symbol "VAR-B")
VAR-B
:EXTERNAL ; ← これも

エクスポートしていない'var-aの状態は:internalで、エクスポートしている'var-bの状態は:externalとなっています。これを利用して、エクスポートされているシンボルなのかを判別します。なお、他パッケージからインポートしたシンボルも:internalとして扱われるため、「状態」を見てシンボルが自パッケージのものかを判別することはできません。このため、その判別はsymbol-packageを利用して行います(useしたものは、もう1つの状態である:inheritedとなるため判別はつくのですが、あえて:internalと分けて考える意味もないので、ここでは:externalか否かだけに注目します)。

インポート・エクスポートの実装

前置きが長かったですが、「基本的なアイディア」節で目標としていた次のものを実装していきます。

  • パッケージ間での名前空間の分離
  • シンボルのインポートの模倣
  • シンボルのエクスポートの模倣

ついでに、「インポートしていないシンボルの参照」節で述べたうちの下記も実現しておきます。

  • 未エクスポートのシンボルを外から触れるようにする

さて肝心の実装ですが、ここまでのsymbol-package, find-symbol、あるパッケージ内の全シンボルでループするdo-symbolsマクロさえ知っていればあとは簡単です。そのため、以降はコードを並べて中に必要なコメントを追記して流していきます。

まずは、インポート部分の実装です。今後もformatが(特にスペースの数や改行が決め打ちだったりで)汚いのですが読み流してください。

#|
Create string like the following (The sort order is not stable):
var symA = packageA.symA;
var symB = packageA.symB;
var symC = packageB.symC;
|#
(defun make-imported-js-symbols (pack)
  (let ((imported-lst nil))
    ;; インポート対象シンボルの抽出
    (do-symbols (sym pack)
      (let* ((target-pack (symbol-package sym))
             ;; find-ps-funcは*ps-func-store*を探索する関数
             (ps-func (find-ps-func sym target-pack)))
        ;; ★インポートすべきシンボルであるかの判定
        ;;         ;; 1. 他パッケージのシンボルか?
        (when (and (not (eq target-pack pack))
                   ;; 2. *ps-func-store*に登録されたシンボルか?
                   ps-func
                   ;; 3. (詳細略:Top LevelなFormを実現するためにps-funcに追加した情報)
                   (ps-func-require-exporting-p ps-func)) 
          (push sym imported-lst))))
    ;; formatで頑張って文字列化(汚い)
    (format nil "~{~{var ~A = ~A.~A;~}~%~}"
            (mapcar (lambda (sym)
                      ;; symbol-to-js-stringは読んで字のごとく。Parenscriptの持ち物
                      (let ((js-sym (symbol-to-js-string (make-keyword sym))))
                        (list js-sym
                              ;; package-to...も読んでの通り。こちらはps-experiment実装
                              (package-to-js-string (symbol-package sym))
                              js-sym)))
                    imported-lst))))

次はエクスポート部分の実装です*6

#|
Create string like the following (The sort order is not stable):
return {
  'externalSymA': externalSymA,
  'externalSymB': externalSymB,
  _internal: {
    'internalSymA': internalSymA,
    'internalSymB': internalSymB,
  }
};
|#
(defun make-exported-js-symbols (pack)
  (let ((extern-lst nil)
        (internal-lst nil))
    (flet ((keyword-to-js-string (key)
             (check-type key keyword)
             (symbol-to-js-string key)))
      ;; 自パッケージで定義したものが対象と分かっているので、
      ;; do-symbolsを使わず*ps-func-store*から直接候補を取り出す
      (let ((ps-func-lst (gethash pack *ps-func-store*)))
        (dolist (ps-func ps-func-lst)
          (when (ps-func-require-exporting-p ps-func)
            (let ((key (ps-func-name-keyword ps-func)))
              ;; ★エクスポートされたシンボルかどうかのチェック
              ;; get-symbol-statusは内部でfind-symbolを呼んでいる
              (if (eq (get-symbol-status key pack) :external)
                  (push (keyword-to-js-string key) extern-lst)
                  (push (keyword-to-js-string key) internal-lst)))))))
    ;; 出力  ※(defvar *internal-symbol-prefix* "_internal")
    (format nil
            "return {
~{  '~A': ~:*~A,~%~}  '~A': {
~{    '~A': ~:*~A,~%~}  }
};"
            extern-lst *internal-symbol-prefix* internal-lst)))

後は、これらと定義本体を合わせて出力すれば、1パッケージの完成です。仕上げ部分なので実装を載せますが、うわ汚い、と思ってスクロールするのが吉です。

(defun make-packaged-js (pack)
  (let ((ps-funcs (gethash pack *ps-func-store*)))
    (unless ps-funcs
      (return-from make-packaged-js nil))
    (let ((js-pack-name (package-to-js-string pack))
          ;; 定義本体。ここだけを出力すると、名前空間がなかった時の出力と同じになる
          (js-body (format nil "~{~A~%~}"
                           (mapcar (lambda (ps-func)
                                     (funcall (ps-func-func ps-func)))
                                   (reverse ps-funcs)))))
      ;; ★(function() { ... })();とクロージャで囲うことで名前空間を分離している
      (format nil "var ~A = (function() {~%~{~A~%~}})();~%"
              js-pack-name
              (mapcar (lambda (str)
                        (ppcre:regex-replace
                         "\\s*$"
                         (ppcre:regex-replace-all (ppcre:create-scanner "^" :multi-line-mode t)
                                                  str
                                                  "  ")
                         ""))
                      (list "/* --- import symbols --- */" (make-imported-js-symbols pack)
                            "/* --- define objects --- */" js-body
                            "/* --- extern symbols --- */" (make-exported-js-symbols pack)))))))

メイン関数の実装

名前空間が分かれたことで、実はもう一ヶ所考慮が必要な場所があります。

with-use-ps-packは第一引数としてパッケージ名(キーワード)のリストをとり、body部にメインの処理を書くことができます。

(in-package :test-pack)
(defvar.ps some-var 100)

(with-use-ps-pack (:this) ; :this == :test-pack
  (alert some-var))

名前空間の分かれていなかった今までは、単にこのbody部をJavaScriptコードに変換してベタに置いておくだけで良かったです。しかし、名前空間の分かれた今、with-use-ps-packの呼ばれたパッケージの名前空間に明示的に置いてあげる必要があります。そうしないと、上の例では(alert some-var)においてsome-varへアクセスすることができません。

そこで、同パッケージ内に一時的にメイン関数相当の__psMainFunc__という関数を作ることにします。

(defmacro with-use-ps-pack (pack-sym-lst &body body)
  (with-gensyms (pack-lst)
    `(let* ((,pack-lst ... ;; 略:依存性解決をしてパッケージのリストを作る処理
             ))
            ;; グローバル環境を汚さないように*ps-func-store*のコピーを作成
            (*ps-func-store* (copy-ps-func-store)))
       ;; __psMainFunc__ を定義する
       ;; ※defun.psは同名・同引数で内部は空のCL関数を同時に定義してしまうので、defun.ps-onlyを利用
       (defun.ps-only ,(intern "__PS-MAIN-FUNC__" *package*) () ,@body)
       (import-ps-funcs (make-package-list-with-depend ,pack-lst)
                        ;; 末尾で__psMainFunc__を呼び出す
                        (format nil "~A.~A.__psMainFunc__();"
                                (package-to-js-string ,*package*)
                                *internal-symbol-prefix*)))))

例えば、空の状態でwith-use-ps-packを呼び出すと次のようになります。

(in-package :pack-a)
(with-use-ps-pack (:this))
var packA = (function() {
  /* --- import symbols --- */

  /* --- define objects --- */
  function __psMainFunc__() {
      return null;
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      '__psMainFunc__': __psMainFunc__,
    }
  };
})();

packA._internal.__psMainFunc__();

未インポートなシンボルの識別とプレフィックスの付与

「インポートしていないシンボルの参照」節で述べたように、未インポートのシンボルを参照している場合には、「基本的なアイディア」に加えて下記を実装することが必要でした。1つ目については前節で一緒に実装したので、この節では2つ目の実装を考えます。

  • 未エクスポートのシンボルを外から触れるようにする
  • 未インポートのシンボルを識別して↑のシンボルを触るようなJavaScriptコードを出力する

これを実現するためには、シンボルをJavaScript用の文字列に変換している場所で、パッケージ情報などを利用して適切なプレフィックスをつけてあげる必要があります。このためには、Parenscriptで最終的にシンボル名の変換を司っているps:symbol-to-js-stringを再定義するしかないだろうというのが現状の結論です。利用しているライブラリの関数を上書きするなど汚い話なので避けたいのは山々なのですが…。

書き換えたものが下記になります。*original-package*は後述しますが、基本的には*package*と同じく定義場所のパッケージを格納したものです。

(defun ps:symbol-to-js-string (symbol &optional (mangle-symbol-name? t))
  ;; ※let*のsymbol-nameとidentiferは元の実装のまま。残りは追加
  (let* (;  明示的にPSのobfuscationを利用していない限り、単なるシンボル名
         (symbol-name (symbol-name (ps::maybe-obfuscate-symbol symbol)))
         ;; "some-symbol" -> "someSymbol"のように変換されたシンボル名
         (identifier (if mangle-symbol-name?
                         (ps::encode-js-identifier symbol-name)
                         symbol-name))
         (package (symbol-package symbol))
         (same-name-symbol (when *original-package*
                             (find-symbol (symbol-name symbol) *original-package*))))
    (if *original-package*
        ;; こちらが追加の実装
        ;; ★プレフィックスをつけるべきかの判定(詳細本文)
        (if (and (not (eq *original-package* package))
                 ;; Check if it is imported
                 (or (null same-name-symbol)
                     (not (eq symbol same-name-symbol)))
                 ;; Check if it is registered as a ps-function
                 (find-ps-func symbol package))
            (let ((*original-package* nil)
                  (package-name (package-to-js-string package)))
              ;; ★適切なプレフィックスの付与
              (if (eq (get-symbol-status symbol package) :external)
                  (concatenate 'string package-name "." identifier)
                  (concatenate 'string package-name "." *internal-symbol-prefix* "." identifier)))
            identifier)
        ;; こちらはオリジナルの実装
        (aif (ps-package-prefix (symbol-package symbol))
             (concatenate 'string it identifier)
             identifier))))

判別部分は次を見てプレフィックス(パッケージ名)をつけるべきか判定しています。

  1. 自パッケージのシンボルではないこと((not (eq *original-package* package))
  2. そのシンボルをインポートしていないこと((or ...)
    • 直接に判断する方法がないので次の2つの条件を見ています
      • 自パッケージから同名のシンボルが見えない((null same-name-symbol))、もしくは、
      • 別パッケージの同名のシンボルが見えている((not (eq symbol same-name-symbol))
  3. *ps-func-store*で管理しているシンボルであること((find-ps-func symbol package)

後回しにしていた*original-package*ですが、所望の場所では必ず*package*CL-USERパッケージに束縛されてしまうため、代替として用意したものです*7。なぜCL-USERが束縛されているかですが、これはps:psマクロ内で呼び出される出力用関数ps::parenscript-printの中でwith-standard-io-syntaxが利用されているためです。

この*original-package*への束縛を行っているのは、defvar.psdefun.ps等で共通して利用しているps.です。

(defvar *original-package* nil)

(defmacro ps. (&body body)
  `(let ((*original-package* ,*package*))
     (macroexpand '(ps ,@(replace-dot-in-tree body)))))

ps.は元々ドット記法をサポートするためだけに導入したps:psのラッパーでした(参考:過去記事:Parenscriptで少し遊んで見る (2)ドット記法編)が意外なところで役に立ちました。ここで、ps:psを直接呼び出さずmacroexpandを挟んでいる点も今回変更が必要になった部分です。ps:psはマクロ展開時にJavaScriptコードを生成するという少々行儀の悪い作りになっているため、このように処理を遅らせないと*original-package*の束縛前にJavaScriptコードの生成処理が走ってしまいます。なお、この変更で割と不便になった点として、今まではSLIMEのマクロ全展開ショートカット(C-c M-m)で簡単にJavaScriptコードを確認できていたのですが、それができくなったという点があります(確認用の補助関数ぐらいは用意しないと…と思いつつまだしてません)。

Type Specifierの登録とquoteの剥ぎ取り

Type Specifierをパッケージ間で取り回すには下記の実装が必要でした。どちらも方針さえ決まってしまえば実装上難しい部分はありません。

  • Type Specifierが定義されるときにそのシンボルを記録する
  • JavaScriptへの変換時に、Type Specifierを見つけたら問答無用でquoteを剥ぎ取る

まずはType Specifierとなるシンボルの記録です。

(defparameter *ps-type-store* (make-hash-table))

(defun ps-type-p (symbol)
  (gethash symbol *ps-type-store*))

(defun register-ps-type (type-specifier)
  (check-type type-specifier symbol)
  (setf (gethash type-specifier *ps-type-store*) t))

記憶する場所としては*ps-type-store*というハッシュテーブルを用意します。シンボルをキーにgethashすると、Type Specifierであればt、そうでなければnilが返るというだけのものです(ps-type-p)。Type Specifierを生み出す側(現行のps-experimentではdefsturct.ps, defsturct.ps+のみ)はロード時にregister-ps-typeが呼び出されるようにしておくだけです(コード略:defstruct.psの実装は長く良い感じに抜き出せないので…)。

次に、quoteの剥ぎ取りです。自力でコードウォークをしてquoteを正しく探し出す…というのは非常に骨なので、ダーティ覚悟でParenscriptで実装されているquoteを上書きしてしまうことにします。

(ps::define-expression-operator quote (x)
  (flet ((quote% (expr) (when expr `',expr)))
    (ps::compile-expression
     (typecase x
       (cons `(array ,@(mapcar #'quote% x)))
       ((or null (eql [])) '(array))
       (keyword x)
       ;; ★変更点はこのsymbol部分だけ、elseは元の実装のまま
       ;; Type Specifierは文字列化せずに返す。これでquoteを剥いだことになる。
       (symbol (if (ps-type-p x)
                   x
                   (symbol-to-js-string x)))
       (number x)
       (string x)
       (vector `(array ,@(loop for el across x collect (quote% el))))))))

コメントのように変更点は1ヶ所だけです。リストや配列のquoteについても、各要素のquote処理は最終的にここに辿り着くため、これだけで対応できます。

終わりに

以上で、新たな構文を一切付け足すことなく、既存のパッケージ・シンボル情報を利用することで、JavaScript側での名前空間の分離を達成することができました。実際、ps-experimentの上に実装しているEntity Component Systemもどきライブラリのcl-ps-ecsでは一切書き換えは不要でしたし、さらにその上に実装しているWeb向け2Dゲームライブラリcl-web-2d-gameではエクスポート・インポートがいい加減であった部分の修正だけで事足りました(前者は全体がCommon Lispコードとしても動かせるので整っていたのですが、後者はJavaScriptコードとしてしか動かない部分が結構あるのでそこに抜けがありました)。

今回の機能変更で吐き出されるJavaScriptコードの量が一気に増えるので、そこは少々気がかりです(特に、大きいパッケージをuseしたりすると…)。説明を省きましたが、今回useしなくともインポートさえしていれば依存パッケージとみなすような変更もし加えたので、極力インポートを使うようにして不必要なuseをしないという地道な改善は可能です。いざとなったら、インポートの模倣はあきらめて、外部パッケージのシンボルはすべてパッケージプレフィックス付きで呼ぶようにすれば、いくらかは短くなるかもしれません。

ひとまず、当面はまだ見ぬ問題に怯えながら使っていってみようと思います。

関連過去記事

付録:最終的な出力例

Roswellコード:

#!/bin/sh
#|-*- mode:lisp -*-|#
#|
exec ros -Q -- $0 "$@"
|#
(ql:quickload :ps-experiment)

(defpackage pack-a
  (:use :cl :ps-experiment)
  (:export :inc-num :negate))
(defpackage pack-b
  (:use :cl :ps-experiment)
  (:import-from :pack-a
                :inc-num))

;; ----- Package A ----- ;;
(in-package :pack-a)

(defvar.ps *num* 0)

(defun.ps inc-num (x)
  (incf *num* x))

(defun.ps negate (x)
  (* x -1))

;; ----- Package B ----- ;;
(in-package :pack-b)

(defstruct.ps test-st)

(defun.ps dec-num (x)
  (inc-num (pack-a:negate x)))

;; :this = :pack-b
(defun main (&rest argv)
  (declare (ignorable argv))
  (print
   (with-use-ps-pack (:this)
     (list 'test-st 'some-sym pack-a::*num*))))

JavaScriptコード(printの出力抜き出し):

var packA = (function() {
  /* --- import symbols --- */

  /* --- define objects --- */
  var NUM = 0;
  function incNum(x) {
      return NUM += x;
  };
  function negate(x) {
      return x * -1;
  };
  /* --- extern symbols --- */
  return {
    'incNum': incNum,
    'negate': negate,
    '_internal': {
      'NUM': NUM,
    }
  };
})();

var packB = (function() {
  /* --- import symbols --- */
  var incNum = packA.incNum;
  /* --- define objects --- */
  function testSt() {
      return this;
  };
  function makeTestSt() {
      var _js2 = arguments.length;
      for (var n1 = 0; n1 < _js2; n1 += 2) {
          switch (arguments[n1]) {
          };
      };
      var result = new testSt();
      return result;
  };
  function testStP(obj) {
      return (obj instanceof testSt);
  };
  function decNum(x) {
      return incNum(packA.negate(x));
  };
  function __psMainFunc__() {
      return [testSt, 'someSym', packA._internal.NUM];
  };
  /* --- extern symbols --- */
  return {
    '_internal': {
      'testSt': testSt,
      'makeTestSt': makeTestSt,
      'testStP': testStP,
      'decNum': decNum,
      '__psMainFunc__': __psMainFunc__,
    }
  };
})();

packB._internal.__psMainFunc__();

*1:あくまで本体はParenscript側のコードなので、JavaScript側の見た目は重要でないといえばそうなのですが…

*2:えー、と思うかもしれませんが、まずはパッケージ間の名前空間分離が目的ですので…

*3:実を言うと、(symbol-value 'x)として値を取り出すとか、(funcall (symbol-function 'x) )として関数を呼び出すような、シンボルを直接扱う操作をされると変数や関数でも同じ問題が起きます…。が、普通やらないでしょうということで制限としておきます

*4:シンボルを使ってメタなことをやろうとする場合はよくやる手段なのではないかと思います

*5:パッケージ自体の定義順序は、ここで保存しておく必要はありません。出力時に依存性を考慮して並び替えるためです

*6:細かい割りに長い話なので脚注。Common Lispではエクスポートの有無に関わらず :: (コロン2つ)でシンボルにアクセスできます。これに合わせるためには、JavaScript側でもエクスポートするオブジェクトを"internal"の内外両方に置いておく必要があります。しかし、エクスポートされたシンボルについては、コロン1つで参照されたのか2つで参照されたのかを後から知るすべはない(はず…)です。そのため、JavaScript側でわざわざ"internal"内外にそれぞれ用意しておいても、Common Lisp側の(字面上の)記述に合わせて使い分けることができないため、意味がありません

*7:なお、*original-package*のおかげでifのelse側に元の実装を残せたのですが、これは副産物でした

斑鳩における解放の挙動について ~C2虎鶫第二ジェネレータ~

前書き

斑鳩について少し気になることがあったので考察してみた記事です。動機としては、単なる興味が大半なので攻略の役に立つかは分かりません…。

気になったのはこのツイートです。C2の虎鶫(敵の名称、斑鳩内で最もサイズが小さい敵)第二ジェネレータで起こる解放の挙動について言及しています。頻度的にはそれほどでないので無視していましたが、確かに時々起こる挙動で、頭の隅に引っかかってはいました。せっかくこうして動画が手に入ったので少し考えてみた次第です。

解放挙動の基本

※この章は表面的に推測しただけなので間違っている可能性は大いにあります

解放の割り当て

解放の各本がどの敵に割り当たるかというところが今回一番重要な部分だと考えています。結論から言うと、距離の近い敵から特定の順で割り当たっているようです*1

f:id:eshamster:20171203151732p:plain

画像は動画から引き抜いたもので12本のフル解放です。説明のために左下から順に番号を振ります(9と11、10と12がほぼ重なっているので少々見分けづらいですが、よく見ると少し膨らんで見えると思います)。

まず、敵が全員解放1本で倒せる場合を考えると、最も距離の近い敵が1番の解放、次に近い敵が2番の解放…と近い敵から上の順番で解放が割り当たっていくようです。次に、倒すのに複数本必要な敵がいる場合ですが、そうした敵に対しては倒せるまで連続して割り当たるようです(5,6,7,…のように)。

全て割り当てても解放が余っている場合の挙動は分かっていないです…。余った解放に対して、上記の割り当てを再度行うのが自然そうではありますが未検証です。

追尾の挙動

追尾の基本的な挙動としては、対象のいる方向に角度を変えつつ前に進むというシンプルな形がベースのようです。このとき、最大旋回角度は固定で、角度が離れているときは速度を落とす(角度が近いときは逆に上げる)ことでいつまでも敵の周りを回り続けることを回避しているようです*2

今回の挙動というよりは、対策を考えるうえで気に留めておく価値がありそうなのは、発射後一定時間は速度が一定もしくは加速している(上記の速度減少が起こらない)ということと、番号が上の解放ほど初速が大きいというあたりでしょうか*3

余談ですが、上記の挙動はあくまで基本であり、カッコよさとプレイのしやすさのバランスをとるために相当色々な工夫が入っているようです。前に色々調べていたのですが半端なところで放置してしまっています…。

今回の場合

f:id:eshamster:20171203151746p:plain

さて、ようやく今回の場合についてです。上記のように解放の割り当てが一番問題と考えています。そこで、動画を繰り返し見てどの解放がどこに飛んでいるかを見てみました。結果は上の画像の通りです。?がついていないものはほぼ間違いないだろうというもの、ついているものは若干自身がないが正しいだろうというものです*4。少なくとも偶奇(=左右どちらから発射されたか)は正しいと思っています。

ここで重要なのは、例のくるりと解放が回ってしまった敵に9番が割り当たっていることです。9番の解放は左側では上から2番目の解放であり、初速が非常に速いです。このため、減速に時間がかかる→中々敵に照準が合わない→ぐるぐる回るということになったのではないかと推測します。

f:id:eshamster:20171203151741p:plain

なぜ9番という高い番号が割り当たったか、別の言い方をすると、なぜ真ん中のジェネレータの方が若い番号が割り当たったのかを考えてみます。上の画像では解放箇所から広がる同心円に注目しています。これを見ると、例の虎鶫とジェネレータがほぼ同じ距離にあったことが分かります*5。見た目上は正直どちらが近いか判断がつかないですが、「近い敵ほど若い番号が割りつく」ことを考えると、ジェネレータの方がわずかに近かったようです。このため、より若い番号の解放がジェネレータに(2つも!)消費されてしまったと考えられます。

ここからは推測ですが、今回のケースは虎鶫とジェネレータの距離関係が非常に近いという稀なケースで、たいていの場合は虎鶫の方が近いのだと思います。より若い番号の解放を割り当てることだけ考えるのであれば、より左側で解放を行えばよいという結論になります。そうすると、若い番号 = 初速の遅い解放が割り当たるため、今回の現象も起きにくくはなるはずです。ただし、本当にまったく起こらないのか…というところは自身がないです。


*1:本題ではないので脚注ですが、いったん割り当たった敵が変更されることはないようです。そのため、対象の敵が先にショットで倒れたなどの場合はそのまま飛び去ります。ただ、たまに画面外から戻ってくるような挙動を見せることがあるので、疑問は残ります

*2:最大旋回角の方が変わっている可能性もあるのですが、どちらが正しいか決定的なところは見えていないです。見た目の速度が違う解放でも旋回角度は同じように見えたという程度の根拠で、実際に速度が変わっているのだろうと考えた程度の根拠です。

*3:細かいことを言うと、1と2や5と6のように左右対称な解放の初速は同じです

*4:消去法で10番が決まりますが、特に注目していなかったので振っていません

*5:距離の計算を、中心点で行っているのか、より広い範囲で行っているのか確証がなかったのですが、今回の挙動を見ると後者で決まりのようです