[Common Lisp] ros templateの紹介
cl-web-2d-game *1 のようなWebアプリ向けのライブラリなどを作っていると、使うまでに色々とサーバ側の設定コードが必要で、中々気軽にプロジェクトを起こせなかったりします(単にインタフェースが悪いのではという議論は置いておきます)。そうした課題の解決方法としてプロジェクトテンプレートがあると思います。Common Lispにも汎用的なものとしてはCL-Projectがありますし、WebフレームワークであるCaveman2にもテンプレート(skelton)が用意されています(CL-Projectベース)。ただ、自分で気軽にテンプレートを量産したり、もしくは人の書いた色々なテンプレートを使ったりしたいと思うと、テンプレートを統一的に管理する仕組みが欲しくなります。
そういうことをするのであればRoswellの周りだろう…と思って、まずは既存のテンプレートシステムがないのかと見てみると、ros template
というサブコマンドがあることが分かりました。ただ、ほぼアンドキュメントな状態で、ソースを見ながら使い方を探る必要がありました。また、使うにあたってこういう機能も欲しいというものもあったので、ポツポツとプルリク出したりしてました。その辺りも含めて使い方の紹介をする記事です。
目次
コマンドの一覧
ros template
コマンドの一覧は空でコマンドを打てば(もしくはros template help
で)下記のように見ることができます。他、ドキュメントとしてはdocuments/ros-template.mdがあります。プルリク出してクイックスタートを追加したので少し充実しました。
$ ros template Usage: ros template [subcommand] init Create new template deinit Remove a template list List the installed templates checkout Checkout default template to edit. add Add files to template. cat Show file contents edit Edit file contents rm Remove (delete) files from template. delete Remove (delete) files from template. type Set template type for a file. chmod Set mode for a file. rewrite Set path rewrite rule for a file export Export template to directory import Import template help Print usage and subcommands description
なお、deinit
, export
, import
は最近プルリクを入れてもらったものです。またedit
も最近入ったものなので、これらの利用には最新版(master)が必要です。
基本的な利用方法
テンプレートの作成やテンプレートエンジンの適用方法など基本的な使い方を見ていきます。
テンプレートを作成する
まずはinit
サブコマンドで空のテンプレートを作成します。
$ ros template init sample
特に出力はありませんが、checkout
サブコマンドを空で打ってみると確かに作成されていることが分かります。
$ ros template checkout current default is "default" candidates: default sample # ← これ
作成したテンプレートはros init <template名> <プロジェクト名>
のようにして利用することができます。ただし、まだ空なので何もできません。
$ ros init sample some-project ; compiling file "/root/dev/roswell/lisp/util-template.lisp" (written 07 FEB 2018 11:55:59 AM): # ~以下略~ $ ls # まだ何もない
ファイルの追加
空のままではしょうがないので、ファイルの追加を行っていきます。
ほとんどのサブコマンドは第1引数に対象とするテンプレート名をとります。ただし、事前にcheckout
サブコマンドでテンプレートを指定しておくと第1引数を省略できます。例えば下記のようにすると、以降ファイルの追加や削除、その他の操作はsample
テンプレートに対してなされるようになります(なお、default
テンプレートに実体はありません。したがってcurrent default is "default"
は未選択と同義です)。
$ ros template checkout sample $ ros template checkout # デフォルトがsampleになっていることを確認 current default is "sample" candidates: default sample
次にファイルの追加ですが、ひとまず追加するファイルtest
を適当に作っておきます。
$ echo "Hello Template!!" > test
add
サブコマンドで今作ったファイルをテンプレートに追加します(フォルダの指定はできません)。
$ ros template add test
list
サブコマンドでテンプレートの内部を概観してみます。すると、先ほど指定したtest
が無事追加されていることが分かります。copy
については後で触れます。
$ ros template list
copy test
また、cat
サブコマンドで中を見てみると、確かに先ほど作成したファイルと同じ内容であることが分かります。
$ ros template cat test
Hello Template!!
さて、ここで改めてros init
コマンドでテンプレートを起こしてみます。
$ mkdir sample $ cd sample/ $ ros init sample some-project $ ls test $ cat test Hello Template!!
以上で、ひとまず自作テンプレートからプロジェクトを作成することができました。
テンプレート変数の利用
ファイルを追加して取り出せるようにはなったものの、変数を利用した部分的な書き換えることができないことにはテンプレート機構としては不十分です。
ros template
ではDjulaをテンプレートエンジンとして利用した書き換えをサポートしています。Djulaは中々機能が豊富でほとんど把握できていないのですが…ここでは"{{ variable }}
"といった形式で変数を埋め込むことができるという所だけ抑えておけば十分です。
ファイル名とファイル内容では変数の適用方法が異なるのでそれぞれ見ていきます。
ファイル名への変数適用:ros template rewrite
ファイル名については、rewrite
サブコマンドを使うことで、リライトルールに変数を埋め込むことができます。
例えば、先ほどのtest
というファイルを<プロジェクト名>.txt
という形式で出力したい場合は次のように設定します。
$ ros template rewrite test "{{ name }}.txt" $ ros template list # ルールが設定されていることの確認 copy test -> "{{ name }}.txt"
name
はデフォルトで利用可能な変数でros init <template> <project name>
としたときに<project name>
が入ります。デフォルトで利用できる変数は下記の4つです。
name
: プロジェクト名author
: 作者名。git config
から拾われます(なければ$(whoami)
)email
: メールアドレス。git config
から拾われます(なければ$(whoami)@$(hostname)
)get_universal_time: 実行時点の時間。[
get-universal-time`](http://clhs.lisp.se/Body/f_get_un.htm)関数の結果です
さて、実際にros init
すると、リライトルールに沿ってファイル名の書き換えが行われていることが分かります。なお、リライトルールにdoc/{{ name }}.txt
のようにフォルダパスを含めると、フォルダを作成した上でその配下に置いてくれます。
# ※以降、ros initは適当な空ディレクトリで実行しているものとします $ ros init sample sample-project $ ls sample-project.txt $ cat sample-project.txt Hello Template!!
ファイルの中身に対する変数適用:ros template type
次にファイルの中身での変数適用を見るため、まずはtest
ファイルを次の内容に更新しておきます。
$ cat<<EOF > test Hello {{ sample }}!! name: {{ name }} author: {{ author }} email: {{ email }} universal time: {{ universal_time }} EOF $ ros template add test # testファイルを上書き $ ros template cat test Hello {{ sample }}!! name: {{ name }} author: {{ author }} email: {{ email }} universal time: {{ universal_time }}
name
, author
, email
, universal_time
は上記で触れたようにデフォルトの変数として利用できます。sample
のような独自の変数はros init
の引数として--sample value
(間に"="を入れるのはNG)のようにして指定できます(ファイルのリライトルールでも同じように独自の変数を利用できます)。
実際に試してみますが・・・
$ ros init sample some-project --sample Ikaruga $ ros template cat test Hello {{ sample }}!! name: {{ name }} author: {{ author }} email: {{ email }} universal time: {{ universal_time }}
このままでは変数は適用されません。ここで関係してくるのが、先ほどlist
サブコマンドの表示の中で説明を飛ばしたcopy
です。
$ ros template list copy test -> "{{ name }}.txt"
これはtype
サブコマンドで指定できるもので、copy
とdjula
の2種類があります。デフォルトのcopy
はその名の通りファイルをそのままコピーします。一方のdjula
は、単にコピーするのではなくDjula
で処理をしたものを書き出します*2。
$ ros template type djula test $ ros template list djula test -> "{{ name }}.txt"
この状態で改めてros init
してみると、意図通り変数の書き換えが行われました。
$ ros init sample some-project --sample Ikaruga $ ros template cat test Hello Ikaruga!! name: some-project author: eshamster email: hamgoostar@gmail.com universal time: 3727259379
なお、デフォルトのタイプはcopy
になっていますが、テンプレートごとに変更することもできます。そのためには、type
サブコマンドにファイル名を与えずに実行します。設定したデフォルトタイプは、以降に新規追加するファイルに影響します*3。
$ ros template type # 引数なしで現在の設定を確認 current default type is "copy" $ ros template type djula $ ros template type current default type is "djula"
テンプレートのエクスポート・インポート
ros template
は基本的にテンプレートの情報を内部的に管理するように作られています。そのため、Gitでテンプレートを管理したかったり、それを人に配布したかったりといった用途では少々不便です。そこで、テンプレートの実体をローカルに取り出したり、逆にローカルのテンプレートを一式取り込むためのサブコマンドが、export
やimport
になります。
まず、export
サブコマンドは指定した(もしくはチェックアウトしている)テンプレートを一式ローカルフォルダに持ってきます。なお、同名のファイルが存在する場合は上書きします。一方で、テンプレート内に存在しないファイルがあった場合は単に無視します。
# 適当な空のディレクトリ $ ros template checkout sample $ ros template export $ ls roswell.init.sample.asd test $ cat test Hello {{ sample }}!! name: {{ name }} author: {{ author }} email: {{ email }} universal time: {{ universal_time }}
sampleテンプレート内のファイルtestを取り出せたことが分かります。なお、roswell.init.sample.asd
はテンプレートの管理実体 兼 作成スクリプト片です。中身は次のようになっていて、*params*
内でパラメータリストとして様々な情報を管理しています。
$ cat roswell.init.sample.asd (DEFPACKAGE :ROSWELL.INIT.SAMPLE (:USE :CL)) (IN-PACKAGE :ROSWELL.INIT.SAMPLE) (DEFVAR *PARAMS* '(:COMMON (:DEFAULT-METHOD "djula") :FILES ((:NAME "test" :METHOD "djula" :REWRITE "{{ name }}.txt")))) (DEFUN SAMPLE (_ &REST R) (ASDF/OPERATE:LOAD-SYSTEM :ROSWELL.UTIL.TEMPLATE :VERBOSE NIL) (FUNCALL (READ-FROM-STRING "roswell.util.template:template-apply") _ R *PARAMS*))
そして、import
サブコマンドは指定されたフォルダ内のroswell.init.xxx.asd
に従ってテンプレートを作成します。このとき、同名(名称はroswell.init.xxx.asd
のxxx
を抽出)のテンプレートがあった場合は上書きされるので注意が必要です。
# まだテンプレートがない別のマシンにexportしたファイルを持ってきた想定 $ ros template checkout current default is "default" candidates: default $ ls downloaded/ roswell.init.sample.asd test $ ros template import downloaded/ $ ros template list sample 0600 djula test -> "{{ name }}.txt"
このように、export
, import
サブコマンドを使うことで、自分で作ったテンプレートをGitで管理したり、人の作ったテンプレートを落としてきて試してみる、ということが(それなりに)気楽にできるようになりました。
その他
その他のコマンド
ここまでで説明していないコマンドには下記があります…が名前と説明からおおむね推測がつくと思いますので割愛します。
deinit Remove a template edit Edit file contents rm Remove (delete) files from template. delete Remove (delete) files from template. chmod Set mode for a file.
余談:テンプレートの実体の管理場所
余談ですが、作成したテンプレートは~/.roswell/local-projects/templates/<テンプレート名>
というフォルダで管理されます。
追加したファイルは同フォルダ内の<テンプレート名>-template
フォルダに入っています。下記のようにファイル名はエンコーディングされています。
$ ls -F ~/.roswell/local-projects/templates/sample/ roswell.init.sample.asd sample-template/ $ ls ~/.roswell/local-projects/templates/sample/sample-template/ %38%2T%37%38 $ cat ~/.roswell/local-projects/templates/sample/sample-template/%38%2T%37%38 Hello {{ sample }}!! name: {{ name }} author: {{ author }} email: {{ email }} universal time: {{ universal_time }}
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開発により近いので悩ましくもあるのですが。
Common Lispでホットローディングを試しに作る (1) 使い方について
前回記事「[Clojure] Figwheel + cljsjs/threeでホットローディングを試す - eshamster’s diary」ではClojureScriptでホットローディングをお試ししました。そして、おおむね次のような機構で動いていることが推測されました。
- なんらかの契機でLispコードの変更を検知してJavaScriptコードにコンパイルする
- WebSocketを通して1のJavaScriptコードをブラウザに送る
- このWebSocketはページ読み込み時に開いておく
- ブラウザ側では2で送られたコードを評価(
eval
)する
構成要素はこの通り単純なので、プロトタイプレベルであれば簡単に作れそうだし面白そうだ、ということでCommon Lispで作ってみました。今回の記事ではその使い方について、次回の記事で実装について書きたいと思います。
実装部分の概要のみ述べると、JSコードへのコンパイルにはParenscriptを利用しています。また、JSコード送信の契機は次のようになっています。
- 上記1の「契機」は評価時としている
- ブラウザからの接続前に定義済みの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.hl
はCommon 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
を利用してホットローディングができます。
- ホットローディング用のWebSocket機能をWebサーバに組み込む
- 下記を行うようなWebサーバを用意する *2
- コンパイル済みのJavaScriptファイル(上記で言うと
src/js/main.js
)を送信する - 次のことを行うJavaScriptファイルを(作成・)送信する
- ロード時にWebSocketでサーバにつなぐ
- WebSocketから送られたJavaScriptコードを
eval
する
- コンパイル済みのJavaScriptファイル(上記で言うと
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); }
使い方としては以上になります。
[Clojure] Figwheel + cljsjs/threeでホットローディングを試す
前回記事「Clojure + Emacsな開発環境を作った on Docker - eshamster’s diary」でひとまずClojure開発環境を整えたので、前々から気になっていたClojureScriptを試してみます。
これまでもThree.js(WebGL上に構築された3Dライブラリ)を触ってきたので、まずはThree.jsの簡単なサンプルを動かすことを目指します。また、ブラウザの更新なしにコードの変更を反映させる、いわゆるホットローディング機構も一緒に試してみたいと思います。
今回構築したサンプルコードのリポジトリは以下になります。
利用するライブラリ
- Figwheel
- ClojureScriptソースの自動ビルドやホットローディングなどを提供してくれるLeiningenプラグインです
- cljsjs/three
- Three.jsのClojureScript用ラッパーです
簡単なWebアプリ with ClojureScriptを作るときのデファクトがまだよく分からないのですが、少し調べた範囲ではFigwheelのテンプレートから始めるのが簡単そうでした。また、READMEが非常に充実しており、ClojureScriptのQuickStartにもリンクを貼るなど、初心者もがっちり取り込もうという意志を感じます。
ホットローディングのデモ
まずは出来上がったものでホットローディングを試してみます。ベースとなるものは一つのキューブがくるくる回っているだけの簡単なThree.jsサンプルです。ついでに定期的に色が変わったり、地味にカメラが遠ざかっていたりしています*1。
ページが重くなりそうなのでリンクにしますが、下記がホットローディングのデモ(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
の方も定義自体は再評価されるのですが、再度呼び出される機会がないため、変更を反映させるにはブラウザのリロードが必要になります。
ホットローディングの基本的な仕組みについて
こうした動作から考えると、ホットリローディングの基本的な仕組み自体は下記のように非常にシンプルなものと推測されます。とはいえ、依存性解決やブラウザとの連携、開発環境の整備、エラー処理などなど周辺を整えることこそ大変なのだとは思いますが…。
- ファイルの更新を検知したら(依存ファイルも含め?)ファイル全体をJSコードにコンパイルする
- このとき、
defonce
された部分は再評価されないようなコードにしておく
- このとき、
- それを一通りブラウザ側へ送りつける
- 通信路にはページロード時に確立したWebSocketを使う
- ブラウザではそれを丸々評価(
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動画(↓)が貼られています。こんなものを見せられたら一発で興味が湧くに決まっています。
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 Lispのrestart-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]:
Clojure + Emacsな開発環境を作った on Docker
前回記事「Common Lisp開発環境 on Dockerの現状 - eshamster’s diary」の冒頭で少し書いていたように、Clojure開発用のDocker環境を作ってみました。そろそろいい加減にClojureScriptをいじってみようかというのが主な動機です。
Docker上に開発環境を起こすのはCommon Lispに続いてようやく2言語目なので、言語ごとの開発環境を量産するための知見(主にEmacsの設定をどう管理するか)を得るという副目標もあります。
先に完成品のリンクを貼っておきます。
目次
概要
次のような構成を目指します。
- Alpine Linux:軽量なLinux
- Leiningen:Clojureプロジェクトの色々な管理をするソフト(Node.jsで言うnpmのような位置付け)
- Emacs
1, 2に関しては公式のDockerイメージが提供されているので、DockerfileでFROM clojure:lein-2.8.1-alpine
のようにするだけです。
3のEmacsの設定については下記を参考…というよりClojure部分についてはほぼそのままです。
そんなわけなので、正直余り書くこともなかったりします…。
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-mode
(auto-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
の中で、共通設定*1とClojureの設定を分離しています(前述のように88行目前後が分かれ目)。もう少し踏み込んで、言語別のDocker環境(用のEmacs設定)を管理するためには下記のようにするのが良さそうだというのが今の考えです(実際やってみると変わりそうですが)。
- Emacs設定ファイル側
- 共通設定と言語別設定を別ファイルに分ける
- これらのロードには
init-loader
(るびきちさんの紹介記事リンク)を利用するinit.el
自体を触ることなく、特定の場所に(特定の形式で)追加の設定ファイルを置くだけで読み込み対象を変えられる、というのはDockerfileの記述と相性が良さそうです
- これらはDockerfile用リポジトリとは独立したリポジトリ上で管理する
- 複数言語を同時に利用するような環境が欲しくなった場合のことを考えると、共通設定だけでなく言語別設定もまとめて管理した方が良さそうです
- Dockerfile周辺側
実際に使うとき
実際に開発環境として利用する際にはもう少しローカルな設定が必要になりますが、これこそ以前書いたCommon Lisp用環境記事の同名の章と変わらないので、ポイントのコピペとファイルの貼り付けだけで終わりにします*2。
- ポイント
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な記事を見直していたのですが、現状と合わないままになっていることに気付きました。そのままにして新しい環境について書くのも寝覚めが悪いので、アップデート状況を簡単に書きます。
何がどう変わったか
最初にDocker上での開発環境を作った時のものが上記の記事でした。ここではCentOS7ベースで作っていました。cl-devel (DockerHub)としてDockerHubにも上げています。
次に、上記の記事ではCommon Lispの実行環境(not 開発環境)をAlpine Linux*1ベースで作成しました。こちらはcl-base
と名付けています。
この記事で、開発環境もこの上に作り直したい…ということを言っていたのですが、現状はその通りになっています。つまり、Alpine + Common Lisp (with Roswell) + Emacs on Dockerな環境になっています。ただ、当時DockerHubに慣れていなかったばかりに、上述のcl-devel
はGitHubとの連動ができていなかったので、下記のように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
以上です。やはり簡単に環境作り直せるのはいいですね。なんだかんだでアップデートの時は何かと引っかかってそれなりに苦労しますが…。
Parenscript上でシンボルのインポートやエクスポートを模倣する
前書き
Lisp Advent Calendar 2017の20日目の記事です。
Common LispのサブセットコードをJavaScriptに変換するParenscriptというライブラリの上でそれらしい名前空間を導入してみた記事です。考え方や実装の整理・メモという意味合いが強いので、果たしてこれを読んで何かの役に立つのかは疑問ですが、Common Lispのパッケージ・シンボルシステムを利用したメタプログラミングについて考える一つの題材になる…かもしれません。
今回の話の発端ですが、まずParenscriptで不便に思ったところを適当に拡張した自作ライブラリps-experimentというものを作っています(quicklispリポジトリ未登録)。
かれこれ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.ps
やdefun.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) 名前空間の分離を行うため、各パッケージ内の定義をクロージャで囲います。これで、例えば両パッケージに存在する
internalFn
(internal-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が定義されるときにそのシンボルを記録する
- 幸いType Specifierが定義されるタイミングは限られています。まあ、現行のParenscript + ps-experimentでサポートしているのはdefstructだけですね…
- 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-fn
をJavaScript関数として書き出せます。
;; *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))))
判別部分は次を見てプレフィックス(パッケージ名)をつけるべきか判定しています。
- 自パッケージのシンボルではないこと(
(not (eq *original-package* package))
) - そのシンボルをインポートしていないこと(
(or ...)
)- 直接に判断する方法がないので次の2つの条件を見ています
- 自パッケージから同名のシンボルが見えない(
(null same-name-symbol)
)、もしくは、 - 別パッケージの同名のシンボルが見えている(
(not (eq symbol same-name-symbol))
)
- 自パッケージから同名のシンボルが見えない(
- 直接に判断する方法がないので次の2つの条件を見ています
*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.ps
やdefun.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をしないという地道な改善は可能です。いざとなったら、インポートの模倣はあきらめて、外部パッケージのシンボルはすべてパッケージプレフィックス付きで呼ぶようにすれば、いくらかは短くなるかもしれません。
ひとまず、当面はまだ見ぬ問題に怯えながら使っていってみようと思います。
関連過去記事
- Parenscriptで遊んで見る (1) defun編 - eshamster’s diary:ps-experiment黎明期の記事です((5)まであります)
- 「Parenscript」でググると割と上の方に来たりするのですが、気になったところをつまんでいるだけで余り役に立たないこれが上に来てしまうのはなんか申し訳ない気分になります…
- 木構造の親子関係を考慮したソート - eshamster’s diary:パッケージ間の依存性解決コードを書くため、問題を一般化して考えてみた記事
- なお、同記事内では循環参照をエラーにする方法と適当にやり過ごす方法を書きましたが、ps-experiment上では前者までを実装しています
- Three.jsなWebアプリをCommon Lispで書く話 - eshamster’s diary:昨年のAdvent Calendar記事。「終わりに」で触れたcl-ps-ecsやcl-web-2d-gameについて
- [Common Lisp] システム内のパッケージ間の関係をグラフ化 - eshamster’s diary:最初に
do-symbols
を使ったのはこの頃でした
付録:最終的な出力例
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ではエクスポートの有無に関わらず
*7:なお、*original-package*のおかげでifのelse側に元の実装を残せたのですが、これは副産物でした