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