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

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

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

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

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


目次

概要

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

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

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

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

qiita.com

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

Dockerfile

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

FROM clojure:lein-2.8.1-alpine

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

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

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

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

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

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

Emacsの設定

設定自体について

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

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

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

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

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

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

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

実際に使うとき

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

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

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

FROM eshamster/clj-devel:latest

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

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

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

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

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

RUN apk add --no-cache openssl-dev

setenv

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

run.sh

#!/bin/bash

set -eu

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

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

name="clj_web_devel"

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

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

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

Common Lisp開発環境 on Dockerの現状

目次

前書き

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

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

何がどう変わったか

eshamster.hatenablog.com

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

eshamster.hatenablog.com

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

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

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

Dockerfile

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

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

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

FROM eshamster/cl-base:2.3

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

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

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

# --- make work directory --- #

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

# --- user settings --- #

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

ARG dev_dir=/root/work

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

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

# --- install HyperSpec --- #

ARG hyperspec=HyperSpec-7-0

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

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

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

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

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

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

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

実際に使うとき

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

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

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

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

FROM eshamster/cl-devel2:3.5B

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

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

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

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

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

RUN apk add --no-cache openssl-dev

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

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

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

#!/bin/bash

set -eu

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

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

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

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


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