AWS LambdaでDockerHubの定期ビルドを設定したときのメモ

DockerHubに登録しているCommon Lisp実行環境eshamster/cl-baseと、それをベースとした開発環境eshamster/cl-devel2ですが、RoswellやQuicklispリポジトリがそれなりの頻度で更新されるので、latestに対しては定期的に更新をかけておきたかったです。そのために、AWS Lambdaをcron代わりに設定したときのメモです。

単なるメモなので過不足たくさんで、特にまとまってもいません。

想定読者

  • AWSアカウント作ったは良いものの特に使ってない人
  • それcronで良くない?と言わない人 *1

想定シチュエーション

  • 大体の操作はWebコンソールで実施する
    • 特に高度な使い方はしていないので、メモは極薄です
  • 言語には取りあえずNode.jsを選択する(執筆時点で最新のNode.js 8.10)
  • Node.jsのモジュールを含めたいので関数の実体は手元の開発機で作成する
    • 開発機がリモートにあってWebコンソールではzipの移動が面倒なので、アップロードだけはawsコマンドで実施する


開発環境の用意(on Docker)

Node.jsの開発やAWSへのアップロードを行うための開発環境を作っておきます。ということで、まずDockerfileを用意します。

lessは最初から入っていますが、デフォルトではaws helpでエラーになってしまうので、アップデートしておきます。ついでに、デフォルトのviだけでは物寂しいのでvimを入れておきます。

FROM node:8.10.0-alpine

RUN apk --update add py-pip && \
    pip install awscli &&\
    apk --update add less groff && \
    apk --update add vim

WORKDIR /root

RUN mkdir /root/.aws

COPY credentials config /root/.aws/
COPY .vimrc /root/

毎回 aws configure するのも面倒なので、同コマンドで生成されるファイルを用意しておいて、docker build時にコピーしてしまいます。後から思うに、この辺りは環境変数の設定でやる方が賢かった気がします。

$ cat config
[default]
output = json
region = us-west-2
$ cat credentials
[default]
aws_access_key_id = XXXXXXXXXXXXX
aws_secret_access_key = XXXXXXXXXXXXX

.vimrcですが、普段はEmacsを使っていて特にこだわりの設定もないので、タブの設定だけしておきます。好み8割、AWS LambdaのWebエディタのデフォルト設定に合わせて置きたい気持ちが2割です。

$ cat .vimrc
set expandtab
set tabstop=4
set shiftwidth=4

ここまでのものはcredentials.gitignoreした上でGitHubに上げました。

github.com


IAMの設定

  • 適当にユーザを用意します
    • (credentialsの設定をしているので済のはずですが)
  • 適当にグループを用意して上記のユーザを所属させます
  • グループにインラインポリシーを設定します

「AWS Lambda でアイデンティティベースのポリシー (IAM ポリシー) を使用する」あたりも見つつ、必要なActionだけを登録していく…つもりだったのですが、面倒になってlamdba:*としてAWS Lambda系の関数を全許可しています*2

なお、アップロードしたファイルはS3上に置かれるようなので、ダウンロードしようと思うとS3系の権限もいるのかもしれません(今回は一方的なアップロードしかしていないので試していません)。

{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Sid": "AllLambdaFunctions",
            "Effect": "Allow",
            "Action": "lambda:*",
            "Resource": "*"
        }
    ]
}

反映には数分かかるようなので他にすることがなければ待ちます。

# Dockerコンテナ上
$ while : ; do aws lambda list-functions ; if [ $? -eq 0 ]; then break ; fi ; sleep 10; done

関数を作成する

AWS Lambdaに関数を追加する

Webコンソール上で適当に作成してNode.js 8.10を選んでおきます。以上。

関数の実体を作成する

Dockerコンテナ上での作業です。Docker HubのビルドをトリガするためのNode.jsファイルを作成します。

準備として、フォルダを用意してcurl代わりのrequestモジュールをインストールしておきます。

$ mkdir sample
$ cd sample
$ npm install request

Node.jsコードの前に、curlでlatestタグのビルドをトリガする凡例を示すと次のようになります。<image_name>は、例えばeshamster/cl-baseで、<token>はDocker HubのBuild Settingsのページで取得できます(また、同ページでcurlの例を見ることもできます)。

$ curl -H "Content-Type: application/json" --data '{"docker_tag": "latest"}' -X POST https://registry.hub.docker.com/u/<image_name>/trigger/<token>/

requestモジュールを使って、これをNode.js実装に置き換えます。TODOがあったりエラー処理がおざなりだったりしますが見なかったことにします。${event.*}の部分が実行時に与えるパラメータです。docker_tagの指定もパラメータ化した方が良い気もしますが、当面latest以外に適用する見込みもなかったので直に指定しました。

$ cat index.js
exports.handler = (event, context) => {
    const request = require('request');

    /* TODO: Check event.image_name and event.token */

    let options = {
        url: `https://registry.hub.docker.com/u/${event.image_name}/trigger/${event.token}/`,
        method: 'POST',
        headers: {
            'Content-Type': 'application/json',
        },
        body: JSON.stringify({ "docker_tag": "latest" })
    };

    let response = request(options, (err, res, body) => {
        console.log('ERR: ' + err);
    });
    return response;
};

アップロードする

zip化した上で、AWS Lambdaにアップロードします。

zip化ですが、解凍されたときにindex.jsがルートに来るように注意します。

$ cd ~/sample
$ ls
index.js           node_modules       package-lock.json
$ zip -r ../sample.zip *
...

ここまで来れば、後はコマンド1発でアップロード完了です。

$ cd
$ aws lambda update-function-code --zip-file fileb://sample.zip --function-name <function名>

helpが充実しているので、それらしいサブコマンドを aws lambda help で見繕って、さらにそのサブコマンドのhelpを見る、という感じで使い方が分かるのは良いですね。


AWS Lambdaの設定

ここからはまたWebコンソール上での作業です。

タイムアウトの設定変更

DockerHubからレスポンスが返ってくるまで数秒かかるので、デフォルトのタイムアウト(3秒)では心許ないです。10秒にしておきます。

CloudWatchの設定

cron代わりにCloudWatch Eventsを設定します。

イベントソースはcron式のスケジュールを設定します(例. 0 15 ? * FRI * ← 日本時間の土曜0時)。rate式にしなかったのは、 近い時間にcl-basecl-devel2 と実行したかったためです。

入力の設定は「定数 (JSON テキスト)」を選択し、先程のNode.jsソースの${event.*}に対応する値を設定します。

{
  "image_name": "eshamster/cl-base",
  "token": "xxxxxxxxxxxxxxx" 
}

以上で週1でDockerHub上のイメージの更新が走るようになりました(完)


*1:cronにしなかったのは、単にAWS Lambda 使ってみたかったというのが主な理由で、開発機(VM)はできるだけ軽くしておきたいというのがもう一つの理由です

*2:権限が足りない場合、エラーメッセージでどのActionの権限がないか丁寧に教えてくれるので難しいことはないのですが、後述の、反映に数分かかるのが面倒臭く…

[Common Lisp] Obsoletedなエイリアスを定義するマクロ

小さなマクロ1個の小ネタ(+おまけ)です。

ライブラリを書き、ある程度使ったあたりで関数名などの命名のまずさに気付くこともあると思います。かといって、いくつかのプロジェクトで使い始めているので、今さら名前を変更するのも面倒臭い…。そういったときに、旧名はエイリアスとして残しておいて、使われたときには警告を出すというのは常套手段であると思います。

Common Lispにはそういった時にデフォルトで利用できるものが見つからなかったので、6行程度の簡単なマクロを書いてみたメモです。

目次

利用イメージ

(defun bad-name-func (x y)
  (+ x y))

うっかりダメな名前で関数を作ってしまった…。しかも、もう外で使われている…。

(defun good-name-func (x y)
  (+ x y))

(def-obsoleted-alias bad-name-func good-name-func)

関数名を改善する。互換性を保ちたいので、旧名もエイリアスとして残しておく(def-obsoleted-alias の実装は後述)。

> (bad-name-func 1 2)
; Warning: "BAD-NAME-FUNC" is obsoleted. Please use "GOOD-NAME-FUNC" instead.
; While executing: BAD-NAME-FUNC, in process repl-thread(13).
3

引き続き旧名も使える、が怒られる。

実装

def-obsoleted-alias の実装は次の通りです。エイリアスとして旧名でマクロを生成します。生成されたマクロは利用時(コンパイル時)に警告を出力します。

(defmacro def-obsoleted-alias (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    `(defmacro ,obsoleted-name (&rest ,rest)
       (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                      obsoleted-name alter-fn))
       `(,',alter-fn ,@,rest))))

未だにマクロを生成するマクロはじっくり見ていると良く分からなくなってくるので、一例展開してみると次のようになります。生成されたbad-name-macroマクロは、利用箇所で単なるgood-name-funcの呼び出しに展開されるため、実行時のオーバーヘッドはありません。

(def-obsoleted-alias bad-name-func good-name-func)
;; ->
(defmacro bad-name-func (&rest #:g346681)
  (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
  `(good-name-func ,@#:g346681))

次に書く問題はあるのですが、お手軽なのでちょっとした用途には十分かと思います。

その問題ですが、bad-name-funcが関数からマクロに変わってしまったため、applyしているなど明示的に関数扱いしているコードに対しては互換性を保てないというものです。後ろのおまけで関数生成バージョンも試してみますが、マクロ生成バージョンではコンパイル時に警告を出せるのに対し、関数生成バージョンでは実行時まで警告を出せません。差し引きで(簡易利用用途としては)マクロ生成バージョンの方が良いだろうと思っています。

おまけ

関数生成バージョン

エイリアスをマクロとしてではなく関数として生成してみます。

前述の通り、警告の出力タイミングは実行時になります。頻繁に利用する関数で何度も警告を出すと応答不可になりかねないので、一度警告が出された関数はハッシュテーブルに記録して二度は出ないようにしています*1

(defvar *table-output-obsoleted-warning* (make-hash-table))
(defun has-output-obsoleted-warning-p (obsoleted-name)
  (gethash obsoleted-name *table-output-obsoleted-warning*))
(defun register-output-obsoleted-warning (obsoleted-name)
  (setf (gethash obsoleted-name *table-output-obsoleted-warning*) t))

(defmacro def-obsoleted-fun (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    `(defun ,obsoleted-name (&rest ,rest)
       (unless (has-output-obsoleted-warning-p ',obsoleted-name)
         (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                        obsoleted-name alter-fn))
         (register-output-obsoleted-warning ',obsoleted-name))
       (apply #',alter-fn ,rest))))

展開例は次のようになります。

(def-obsoleted-fun bad-name-func good-name-func)
;; ->
(defun bad-name-func (&rest #:g346722)
  (unless (has-output-obsoleted-warning-p 'bad-name-func)
    (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
    (register-output-obsoleted-warning 'bad-name-func))
  (apply #'good-name-func #:g346722))

アノテーションにしてみる

関数のObsolete化というと、C#Obsolete属性や Java@deprecated アノテーションのように、アノテーション的にやるイメージがあるので、試しに cl-annotを使ってアノテーション化してみます。

(ql:quickload :cl-annot)
(use-package :cl-annot)
(enable-annot-syntax)

(defannotation obsoleted-alias ((&rest obsoleted-names) definition-form) (:arity 2)
  `(progn ,@(mapcar (lambda (name)
                      `(def-obsoleted-alias
                           ,name
                           ,(cl-annot.util:definition-form-symbol definition-form)))
                    obsoleted-names)
          ,definition-form))

次のように使います。一応、obsoletedな名前はカッコ内に複数並べて書けるようにしています。これで何度下手な名付けをしても安心です :-)

@obsoleted-alias (bad-name-func)
(defun good-name-func (x y)
  (+ x y))

本格的にcl-annot と連携しようと思うと、 @export (など?)との兼ね合いも考えないといけないので、これでは足りないのでしょうね…。

追記:コンパイラマクロ利用版

コメントでコンパイラマクロを利用する方法を教えて頂きました。「コンパイル時に何か(警告出力)したい」という話なので確かにコンパイラマクロが適任ですね。頭になかったです……。

(defmacro def-obsoleted-alias (obsoleted-name alter-fn)
  (let ((rest (gensym)))
    (flet ((make-body ()
             (if (macro-function alter-fn)
                 ``(,',alter-fn ,@,rest)
                 `(apply #',alter-fn ,rest))))
      `(progn (,(if (macro-function alter-fn) 'defmacro 'defun) ,obsoleted-name (&rest ,rest)
                ,(make-body))
              (define-compiler-macro ,obsoleted-name (&rest ,rest)
                (warn ,(format nil "\"~A\" is obsoleted. Please use \"~A\" instead."
                               obsoleted-name alter-fn))
                ,(make-body))))))

REPLでの利用時はコンパイルが走らないため警告が出ませんが、最終的にコードに落とす段階で気付けるので実用上の問題はないと思われます。

さらに追記:最初 def-obsoleted-fun の代替として関数対応版を書いたのですが、せっかくなので関数・マクロ両用版に書き直しました。

; ; 準備
(defun good-name-func ())
(defmacro good-name-macro ())

;; 関数の場合
(def-obsoleted-alias bad-name-func good-name-func)
;; ->
(progn (defun bad-name-func (&rest #:g347909)
         (apply #'good-name-func #:g347909))
       (define-compiler-macro
         bad-name-func
         (&rest #:g347909)
         (warn "\"bad-name-func\" is obsoleted. Please use \"good-name-func\" instead.")
         (apply #'good-name-func #:g347909)))

;; マクロの場合
(def-obsoleted-alias bad-name-macro good-name-macro)
;; ->
(progn (defmacro bad-name-macro (&rest #:g347910)
         (list* 'good-name-macro #:g347910))
       (define-compiler-macro
         bad-name-macro
         (&rest #:g347910)
         (warn "\"bad-name-macro\" is obsoleted. Please use \"good-name-macro\" instead.")
         (list* 'good-name-macro #:g347910)))

*1:流石に1回出力ではどこで利用されているかの追跡が困難なので、キーにパッケージも加えて、1つのパッケージで1回までなどとした方が良さそうです

XBLA版, Steam版の斑鳩におけるアナログスティックの挙動について

XBLA版, Steam版*1斑鳩におけるアナログスティックの挙動について、↓のような雑なツイートをしました。

個人的にはこのアナログスティックの挙動は大変素晴らしいものだと思っていて、また決して偶然にできるものではなく、アナログな操作感とデジタルな精密さを両立すべく良く練られたものだと思っています。…という内容や気持ちを伝えるにはツイッターでは余白が狭すぎたので、記事にしてみた次第です*2

目次

前段:8方向の場合

ツイートでは図の見方も説明できていなかったので、その説明も兼ねて8方向のシンプルな場合について考えます。

十字キーやアーケードスティックで自機を操作する場合、上下左右と斜め4方向の、計8方向に動かすことができます。このとき、それぞれの方向の速さを考えてみます。特別な理由がなければ上下左右の4つの速さは等しくするでしょうし、同じく斜め方向の4つの速さも等しくするでしょう。しかし、前者の速さと後者の速さの関係については概ね2つの選択肢が考えられます。

1つはどちらも同じ速さにするというものです。横方向・縦方向の速さを軸とした平面上にこれらを置くと下図のように円周上に並びます。

f:id:eshamster:20180602144720p:plain:w450

もう1つは、斜め移動の場合でも、縦方向の速度、横方向の速度をともに維持するというものです。言い方を変えると、単純に横方向の速度と縦方向の速度を合成したものになります。同じく平面上に置くと下図のように四角形の上に並び、斜め方向は長い= 速さが大きいことになります。

f:id:eshamster:20180602145608p:plain:w450

物理的(?)にはどの方向にも等速で動く円形型が正しいですが、ゲームにおいてどちらが適しているかは場合によると思います。実際、斑鳩では後者の四角形型を採用しています。理由は推測するしかないですが、画面の広い範囲を動く傾向が強い斑鳩においては、速度を落とす選択肢を取りたくないといったことや、上下方向もしくは左右方向の速度は常に一定にしたいといったことなどが考えられそうです。

アナログスティックの挙動

本題のアナログスティックの挙動です。

特に弾幕系のような狭い隙間を正確に抜ける瞬間があるSTGでは、確実に真っ直ぐ動くことができるということは死活的に重要です。そのため、あえてアナログな挙動を突き詰める意味がない場合が多いと思われます。しかし、斑鳩というゲームにおいては数ドット単位での正確な立ち回り、という種類の精密さが求められることは皆無に等しく、そうした挙動を突き詰める余地があると言えます。それでも真っ直ぐに動けることの重要性は依然大きい訳ですが、XBLA版においてはこの辺りのバランスをきちんと詰めて来ました *3

上下左右の移動

とりあえずは斜め方向は無視して、上下左右方向の移動がどうなっているかを見てみます。

ここで考えるべきことは単純で、例えば右なら右方向の速さを何段階に分けるかという点です。斑鳩において10段階や20段階に分けても嬉しくなさそうなことは直感的に想像できます。狙って速さを調整できる範囲を考えると、選択肢としては2段階~4段階程度が妥当と思われます。そして斑鳩では3段階を選んでいます*4

f:id:eshamster:20180602184118p:plain:w450

調整の結果そうなったのだろう…だけでは詰まらないので少し理由を考えてみます。実際のところ、斑鳩ではほとんど3段階目=一番スティックを倒した状態しか使わないのですが、1段階目や2段階目にも特定の場面で使い所があります。それは敵レーザーに押されるときです。自機の最大速度はレーザーに押される速さより大きいので、アーケードスティック等で位置を維持するには細かくスティックを入れる・離すの繰り返しが必要になります。アナログスティックにおいてはその忙しさを解消しようとする意図が感じられます。特に1段階目は明らさまで、レーザーに押される速さと同じ速さに設定されています。そして2段階目はレーザーを僅かに押し返す速さに設定されています。こうした1段階目による静止、2段階目による微調整という辺りがアナログスティックで持ち込みたかった操作感なのかなと考えています。このためには、最低でも3段階が必要です。一方、これ以上細かく調整できても細か過ぎて有効には使えない…という辺りで3段階に落ち着いたように思います。

斜め方向の移動

ようやく冒頭のツイートで言及していた斜め方向の移動についての話題です。

8方向版を単純に拡張した場合の挙動

さて、アーケードスティックのような8方向の移動においては、斑鳩では単純に縦方向と横方向の合成して斜め方向の速度とする、四角形型のモデルを採用していました。一方、アナログスティックでの上下左右の移動としては、一方向に3段階の速さ持つ形を採用していました。ここでは、その2つを単純に組み合わせた場合に斜め移動がどうなるかを考えてみます。

斜め右上の方向について図にしてみます。

f:id:eshamster:20180602230416p:plain:w450

上方向に3段階、右方向に3段階あるため、これらを単純に組み合わせると、斜め方向の速度は3×3マスのグリッドの各頂点に相当します。したがって、スティックを目一杯倒して操作することを考えると、斜めには赤色の矢印で示した5方向に動けることになります。同じことが右下、左下、左上についても言えるので、斜めには5×4=20方向、これに上下左右を合わせて全部で24方向に動けます(無視した青い矢印も含めれば32方向)。

しかし、実装的には8方向版の自然な拡張であるこの挙動を、XBLA斑鳩では採用していません。

実際の挙動

実際に採用されている挙動について、同じく斜め右上の方向を図示します。

f:id:eshamster:20180603013028p:plain:w450

先程と同様に計算すると、斜めには3×4=12方向、これに上下左右を合わせて全部で16方向となりました*5。結論だけを見ると元の8方向を2倍細かくしただけのように見えます。しかし、図を見ての通り、8方向で採用したモデル = 四角形型のモデルの単なる延長にあるものではないことから、明確な意図と綿密な調整の基に選びとられた仕様だと見るべきではないでしょうか。

これがいかに「自然」で馴染む操作になっているかの一つの証左として、(個人の感想でしかないですが…)少なくとも、自身はXBLA版をプレイしている最中にこの工夫に気づくことはなかった、ということを挙げたいと思います。同様の調整がなされていなかったSteam初期版を触って、初めてXBLA版での工夫に思い至ったのです。

終わりに

XBLAは海外で広く普及したハードであり、斑鳩を初めて触る人に届く可能性は大いに考えられたと思います。とすると、まずは手元の純正コントローラのアナログスティックで触ってみる人が多くいると想像できます。そのため、初対応で正解のない中で、アナログな挙動を突き詰めることには合理性があったのだと思います。

一方、Steam初期版は挙動が細か過ぎて真っ直ぐ進むことすら難しい(少なくとも自分の技量では)調整になってしまっていたことも、ある面では仕方のないものなのかと納得していました。というのは、パターンをNAOMI版に寄せることを優先していたように見えますし、複数解像度やキーボード操作への対応など泥くさい調整が多くあったようですし*6、またPCでは特定のコントローラを想定することが難しいため、アナログ挙動の調整の優先度が下がるのは止むを得ないと思えたからです。むしろ、アップデートによって、ニッチと思われる操作系を調整して頂けたのは大変ありがたいことだったと思っています。

あとは愚痴と妄想ですが、去る5月30日に販売の始まったNintendo Switch版、またもや真っ直ぐ進むことも難しい調整となっていました*7Nintendo Switchの広まり方を考えれば、新たな客層にもそれなりに届くでしょうし、となるとまずは公式コントローラのアナログスティックでの操作が試みられるでしょう。そこに対してこの調整…。トレジャーはここ数年新作も移植も出しておらず、事実上解散状態にしか見えませんでしたが、余り深く考えないようにしてました。そして、移植とはいえ久々の作品で、優先しても良さそうなアナログスティックの操作、それもかつてはできていたもの、がおざなりな状態で出てきたのは、トレジャーの現状を突き付けてくるようで見るのが辛かったです。…というのが全部考え過ぎの妄想であればいいなと願っています。

追記:8月8日配信のパッチでSwitch版にも調整が入りました。ありがとうございます!


*1: 2014年5月9日のアップデート後

*2:なお、XBLA版と修正後Steam版の挙動が同じ前提で書いていますが、きちんと裏を取っている訳ではないです…。実機確認はSteam版でやってます

*3:この辺りまで書いてから、そういえばDCやGCもアナログスティックついているけれど、アナログの挙動に対応していなかったのだろうか…とようやく疑問に思いました。実物を触ったことがないので確信を持てないのですが、一応ウィキペディアの記事を見る限りはXBLA版が初出のようですが

*4:なお、図では便宜上単純に3等分していますが、実際の速さはそうではないように思います

*5:青色の矢印で示した方向の速さが複数段階あるかは検証できていません

*6:参考: 『斑鳩』がSteamで近日配信 なぜいまSteamなのかをトレジャーに直撃 - ファミ通.com

*7:試した限り、上下左右方向が1つ増えて4段階で、しかもそれをそのまま斜めに拡張したように見えました

[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サブコマンドで指定できるもので、copydjulaの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でテンプレートを管理したかったり、それを人に配布したかったりといった用途では少々不便です。そこで、テンプレートの実体をローカルに取り出したり、逆にローカルのテンプレートを一式取り込むためのサブコマンドが、exportimportになります。

まず、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.asdxxxを抽出)のテンプレートがあった場合は上書きされるので注意が必要です。

# まだテンプレートがない別のマシンに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 }}

*1:まだREADMEすら書いていない…

*2:rewriteサブコマンドとは引数が逆順で少々戸惑いますが、rewriteは常に1ファイルずつ処理する、一方typeは複数を同時に処理する場合がある、ということによる差だと思われます

*3:デフォルトタイプの変更機能は最近プルリクしたものなので最新版が必要です

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を入れるでしょうから、余りないとは思いますが…