[Clojure] Figwheel + cljsjs/threeでホットローディングを試す
前回記事「Clojure + Emacsな開発環境を作った on Docker - eshamster’s diary」でひとまずClojure開発環境を整えたので、前々から気になっていたClojureScriptを試してみます。
これまでもThree.js(WebGL上に構築された3Dライブラリ)を触ってきたので、まずはThree.jsの簡単なサンプルを動かすことを目指します。また、ブラウザの更新なしにコードの変更を反映させる、いわゆるホットローディング機構も一緒に試してみたいと思います。
今回構築したサンプルコードのリポジトリは以下になります。
利用するライブラリ
- Figwheel
- ClojureScriptソースの自動ビルドやホットローディングなどを提供してくれるLeiningenプラグインです
- cljsjs/three
- Three.jsのClojureScript用ラッパーです
簡単なWebアプリ with ClojureScriptを作るときのデファクトがまだよく分からないのですが、少し調べた範囲ではFigwheelのテンプレートから始めるのが簡単そうでした。また、READMEが非常に充実しており、ClojureScriptのQuickStartにもリンクを貼るなど、初心者もがっちり取り込もうという意志を感じます。
ホットローディングのデモ
まずは出来上がったものでホットローディングを試してみます。ベースとなるものは一つのキューブがくるくる回っているだけの簡単なThree.jsサンプルです。ついでに定期的に色が変わったり、地味にカメラが遠ざかっていたりしています*1。
ページが重くなりそうなのでリンクにしますが、下記がホットローディングのデモ(GIFアニメーション)です。画面左にブラウザ、右にエディタ*2を表示しています。エディタ側でファイルを変更して保存すると、少し間をおいてブラウザ側に反映される様子が分かると思います。楽しいです。
使い方
下記のようにすれば、ブラウザからhttp://localhost:3449
につないでデモに示した画面を見ることができます。
$ git clone https://github.com/eshamster/sample-clj-three-js $ cd sample-clj-three-js $ lein figwheel
ただし、lein figwheel
による立ち上げではEmacs(Cider)と連携できません。連携させるためには、代わりにEmacsを立ち上げてM-x cider-jack-in
(結構時間がかかります)した後、REPL上で下記を実行します。これで、Clojureソース内だけでなく、ClojureScript内でも定義ジャンプなどが有効になります*3。
> (use 'figwheel-sidecar.repl-api) > (start-figwheel!) > (cljs-repl)
これについては、公式Wikiの"Using the Figwheel REPL within NRepl"が詳しいです。なお、そこで解説されている下記の設定ですが、Figwheelのテンプレートからプロジェクトを起こせば最初からproject.clj
に書かれています。そのため、上記の手順のみでEmacsと連携することができます。
;; ~前略~ ;; Setting up nREPL for Figwheel and ClojureScript dev ;; Please see: ;; https://github.com/bhauman/lein-figwheel/wiki/Using-the-Figwheel-REPL-within-NRepl :profiles {:dev {:dependencies [[binaryage/devtools "0.9.4"] [figwheel-sidecar "0.5.14"] [com.cemerick/piggieback "0.2.2"]] ;; need to add dev source path here to get user.clj loaded :source-paths ["src" "dev"] ;; for CIDER ;; :plugins [[cider/cider-nrepl "0.12.0"]] :repl-options {:nrepl-middleware [cemerick.piggieback/wrap-cljs-repl]} ;; need to add the compliled assets to the :clean-targets :clean-targets ^{:protect false} ["resources/public/js/compiled" :target-path]}})
コードについて
今回書いたコードについて
コードについては、まず下記のようにFigwheelのテンプレートからプロジェクトを起こしました。
$ lein new figwheel sample-clj-three-js
構成は下記のとおりです。特にファイルの追加などは行っていないのでテンプレートままです。デモで触っていたClojureScriptファイルはsrc/sample_clj_three_js_figwheel/core.cljsです。
├── README.md ├── dev │ └── user.clj ├── project.clj ├── resources │ └── public │ ├── css │ │ └── style.css │ └── index.html └── src └── sample_clj_three_js_figwheel └── core.cljs
その.../core.cljs
の中身は次のようになっています。
(ns sample-clj-three-js-figwheel.core (:require cljsjs.three)) (enable-console-print!) (defonce frame-counter (atom 0)) (defn change-color [mesh new-color] (aset mesh "material" "color" (js/THREE.Color. new-color)) (aset mesh "material" "needsUpdate" true)) (defn update-mesh [mesh] (aset mesh "rotation" "x" (- (.-x (.-rotation mesh)) 0.001)) (aset mesh "rotation" "z" (- (.-z (.-rotation mesh)) 0.02)) (let [color-list [0xff0000 0x00ff00 0xaaaaff] interval 120] (when (= (mod @frame-counter interval) 0) (let [div (/ (mod @frame-counter (* interval (count color-list))) interval)] (change-color mesh (nth color-list div)))))) (defn update-camera [camera] (let [z (.-z (.-position camera))] (when (< z 1000) (aset camera "position" "z" (+ 0.4 z))))) (defn update-others [] (swap! frame-counter #(+ % 1))) (defn init [] (let [scene (js/THREE.Scene.) screen-width 500 screen-height 500 p-camera (js/THREE.PerspectiveCamera. 45 (/ screen-width screen-height) 1 10000) box (js/THREE.BoxGeometry. 200 200 200) mat (js/THREE.MeshBasicMaterial. (js-obj "color" 0xff0000 "wireframe" true)) mesh (js/THREE.Mesh. box mat) renderer (js/THREE.WebGLRenderer.)] ;; Change the starting position of cube and camera (aset p-camera "name" "p-camera") (aset p-camera "position" "z" 500) (aset mesh "rotation" "x" -20) (aset mesh "rotation" "y" 0) (.setSize renderer screen-width screen-height) ;; Add camera, mesh and box to scene and then that to DOM node. (.add scene p-camera) (.add scene mesh) (.appendChild js/document.body (.-domElement renderer)) ;; Kick off the animation loop updating (defn render [] (update-others) (update-mesh mesh) (update-camera p-camera) (.render renderer scene p-camera)) (defn animate [] (.requestAnimationFrame js/window animate) (render)) (animate))) (defonce to-init-once (atom (init)))
init
の中身はほぼcljsjs/threeのREADMEのサンプルままです。ホットローディングを試すため、init
内部で定義しているrender
(これが毎フレーム呼び出される関数です)において、外で定義した各種update用関数を呼び出すようにしています。
ホットローディングを考える上で重要なのはdefonce
です。FigwheelのREADMEの"Writing Reloadable Code"で説明されていますが、(defonce <name> <value>)
の形で書いておいたものは、最初のロード時のみ評価され、ホットロード時には評価されないようになります。逆に言うと、それ以外のものはホットロード時にすべて再評価されます。
上記のコードでは、フレーム数をカウントするframe-counter
変数の定義と、初期化関数であるinit
の呼び出しが再評価されないようにしています。update-mesh
などの各種アップデート関数の定義は再評価されるため、ホットリローディングを行った次のフレーム以降で動作が変わることになります。init
の方も定義自体は再評価されるのですが、再度呼び出される機会がないため、変更を反映させるにはブラウザのリロードが必要になります。
ホットローディングの基本的な仕組みについて
こうした動作から考えると、ホットリローディングの基本的な仕組み自体は下記のように非常にシンプルなものと推測されます。とはいえ、依存性解決やブラウザとの連携、開発環境の整備、エラー処理などなど周辺を整えることこそ大変なのだとは思いますが…。
- ファイルの更新を検知したら(依存ファイルも含め?)ファイル全体をJSコードにコンパイルする
- このとき、
defonce
された部分は再評価されないようなコードにしておく
- このとき、
- それを一通りブラウザ側へ送りつける
- 通信路にはページロード時に確立したWebSocketを使う
- ブラウザではそれを丸々評価(
eval
?)する
せっかくなので、1に関連して、生成されたJavaScriptコードのdefonce
相当部分を見てみると、下記のようになっていました(resources/public/js/compiled/out/sample_clj_three_js_figwheel/core.js
)。シンプルに、defonce
された変数がundefined
かどうかで読み込みの有無を決めているようです。このため、意図的にundefined
を入れた場合*4や、返り値がundefined
な関数の呼び出しは再評価の対象になってしまうことが分かります。init
の呼び出しはまさにその後者のケースであるため、atom
で囲うことで再評価対象から外しています。
/* frame-counterの定義部分 */ if(typeof sample_clj_three_js_figwheel.core.frame_counter !== 'undefined'){ } else { sample_clj_three_js_figwheel.core.frame_counter = cljs.core.atom.call(null,(0)); } /* ~中略~ */ /* init関数の呼び出し部分 */ if(typeof sample_clj_three_js_figwheel.core.to_init_once !== 'undefined'){ } else { sample_clj_three_js_figwheel.core.to_init_once = cljs.core.atom.call(null,sample_clj_three_js_figwheel.core.init.call(null)); }
余談:Figwheelを試していて印象的だった点
まだまだClojureについては新参者であるため色々驚く部分もあったわけですが、今回Figwheelを触っていて特に印象的だった点を3つほどメモしておきます。
利用しているClojureのバージョン
テンプレートから起こした時点で、project.clj
から参照されているClojureのバージョンが 1.9.0 Beta4でした。これはFigwheeというよりClojure全体でなのかもしれませんが、正式前のバージョンも積極的に使っていく文化なのですね。
Flappy bird demo of figwheel
READMEの冒頭にFlappy birdでホットリローディングをデモするYouTube動画(↓)が貼られています。こんなものを見せられたら一発で興味が湧くに決まっています。
Configurationエラーメッセージの詳細さ
READMEの最初の方でFigwheelの特徴を並べているのですが、その中の一つに"First Class Configuration Error Reporting"と、設定エラーメッセージの詳細さについて述べたものがあります。これを見たときは、書いてある通り設定エラーって結構心折れるし、そこが詳細なのは良さそうだねー、と軽く読み流していました。が、実際にエラーを目の当たりにしてみると、その詳細さや有用さにかなり驚かされました。
実際に遭遇したWebSocket関連の設定エラーでの例を見てみます。
WebSocket connection to 'ws://localhost:3449/figwheel-ws/dev' failed: Error in connection establishment: net::ERR_CONNECTION_REFUSED figwheel$client$socket$open @ socket.js:sourcemap:97
まず、最初に試そうとしたときにJavaScript側で上記のようなエラーになりました。開発環境がDocker上にあるため、コンテナ内で見せているアドレスlocalhost:3449
とブラウザからWebSocketでアクセスすべきアドレスが食い違っていたことが原因です。
このような場合は、project.clj
内にWebSocketがつなぐべきアドレスを:websocket-url
というパラメータで明に設定しておく必要があります。これを置けそうに見えた箇所が2つほどあったので、適当に片方に置いてみたところ、そちらは間違いでした。そのときに出力されたエラーが詳細かつ非常に有用なもので、次のような要素を含んでいました。最早これ以上調べることはないぐらいの情報を提示してくれています。
- どこがエラーであったかの表示
- その場所に本来置けるキーワードの一覧
- そのキーワード(
:websocket-url
)をどこに置くべきかのサジェスト - そのキーワードについての一般的な説明
設定エラーというと良くてエラー箇所を示してくれるぐらいの印象でしたし、Clojure一般はエラーメッセージが不親切な印象(Javaの長いスタックトレース!)でしたし、まさかこんなに有用なものが表示されるとは完全に想定外で、非常に驚きました。
長いですが一通り貼り付けます。
------ Figwheel Configuration Error ------ The key :websocket-url at (:figwheel) is on the wrong path. /root/work/sample-clj-three-js/project.clj:55:13 50 :pretty-print false}}]} 51 52 :figwheel {;; :http-server-root "public" ;; default and assumes "resources" 53 :server-port 3449 54 ;; :server-ip "127.0.0.1" 55 :websocket-url "ws://localhost:8080/figwheel-ws/dev" ^--- The key :websocket-url has been misplaced 56 57 :css-dirs ["resources/public/css"] ;; watch and update CSS 58 59 ;; Start an nREPL server into the running figwheel process 60 ;; :nrepl-port 7888 The :websocket-url key should probably be placed like so: {:cljsbuild {:builds [{:figwheel {:websocket-url "ws://localhost:8080/figwheel-ws/dev" ^---- The key :websocket-url should probably be placed here }}]}} -- Docs for key :websocket-url -- You can override the websocket url that is used by the figwheel client by specifying a :websocket-url The value of :websocket-url is usually :websocket-url "ws://localhost:3449/figwheel-ws" The :websocket-url is normally derived from the :websocket-host option. If you supply a :websocket-url the :websocket-host option will be ignored. The :websocket-url allows you to use tags for common dynamic values. For example in: :websocket-url "ws://[[client-hostname]]:[[server-port]]/figwheel-ws" Figwheel will fill in the [[client-hostname]] and [[server-port]] tags Available tags are: [[server-hostname]] ;; supplies the detected server hostname [[server-ip]] ;; supplies the detected server ip [[server-port]] ;; supplies the figwheel server port [[client-hostname]] ;; supplies the current hostname on the client [[client-port]] ;; supplies the current hostname on the client ------------------------------------------ Figwheel: There are errors in your configuration file - project.clj
あとは、Common Lispのrestart-case
のように下記のような選択しを出してくれるのも良いです。
Figwheel: Would you like to ... (f)ix the error live while Figwheel watches for config changes? (q)uit and fix your configuration? (s)tart Figwheel anyway? Please choose f, q or s and then hit Enter [f]: