Help us understand the problem. What is going on with this article?

ClojureScript による SPA のモジュール分割

More than 1 year has passed since last update.

本記事では ClojureScript 製 SPA をモジュール分割するためのパターンを紹介します。
サンプルプロジェクトは下記。

https://github.com/223kazuki/cljs-dapp

背景

最近 Blockchain に興味があり、Ethereum DApp(分散型アプリケーション)を ClojureScript + re-frame で開発していました。
スマートコントラクト開発の知見は別途まとめたいと思いますが、フロント開発を振り返ると Ethereum エコシステム周りに起因する様々なつらみが存在しました。

  • 使うべきライブラリが多い。
  • それぞれに初期化パラメータが存在し、環境によって切り分けが必要。
  • 生成したインスタンスに紐づく状態が変化し得る。
  • インスタンス間の依存関係も存在。
    • uPort に繋いだら web3 インスタンスを初期化し直さないといけなかったり。

肝心のコントラクト自体は複雑でなかったのにフロントの開発で何回か発狂しそうになりました。
これらの問題を解決するため、下記の様な開発基盤が欲しいなあと思いながら開発していました。

  • アプリをモジュールに分割出来ること。
  • モジュールの状態を管理出来ること。
  • モジュールごとにライフサイクルを定義出来ること。
  • 宣言的にモジュール設定・依存関係を定義できること。

これはまさに Clojure/Script の状態管理フレームワーク integrant が実現したい世界です。
ClojureScript における integrant 活用については以前 ayato-p さんがまとめてくれています。
integrant を導入することで基本的にこの記事の通りのメリットが期待できますが、今回は同時に re-frame アプリという前提があり、re-frame が管理する状態、ハンドラとどう折り合いを付けるか考える必要がありました。

結局、実際の開発では試行錯誤しながらもあまりいい感じに出来ませんでした。
しかしその後、まさに cljs-web3 を開発している district0x 社が同様に状態管理フレームワーク mount と re-frame によるパターンをまとめているのを発見しました。
名付けて re-mount です。

re-mount

まさにこれがやりたかった!
このパターンをそのまま利用しても良かったのですが、mount は個人的に「宣言的」という面が弱そうだと感じるのと使い慣れていないため、integrant に焼き直したらどうなるか考えてみました。

こういう背景のため、re-frame + integrant という構成で考えます。

  • 簡単な用語解説
    • ClojureScript ... プログラミング言語 Clojure のサブセットである AltJS 言語。
    • reagent ... React.js の ClojureScript ラッパー。
    • re-frame ... reagent アプリで状態を管理する Redux の様なフレームワーク。
      • UI の状態を一つの "DB" という形で管理し、それを UI から監視・更新するための"ハンドラ"を提供する。
    • integrant ... Clojure/Script をモジュール分割して管理するためのフレームワーク。
    • Ethereum DApp ... Ethereum ブロックチェーン上で動作するアプリケーション。
      • 主に、ブロックチェーン上で実行されるコントラクトと呼ばれるプログラムとフロントエンドで構成される。

作ったもの

以下が実際に作ったサンプルです。

https://github.com/223kazuki/cljs-dapp

動かすには Ethereum スマートコントラクト開発環境も必要になります。
README に従ってセットアップしてください。

構成

Truffle プロジェクトと混ぜてしまい分かりづらくなってしまっているため、cljs アプリとしての構成を抜き出すと下記の様になります。

.
├── project.clj
├── resources
│   ├── config.edn
│   └── public
│       ├── css
│       │   └── site.css
│       └── index.html
└── src
    └── cljs_dapp
        ├── core.cljs
        ├── module
        │   ├── app.cljs
        │   ├── router.cljs
        │   └── web3.cljs
        ├── utils.cljc
        └── views.cljs

config.edn

config.edn には各モジュールの初期化設定を記述しています。

config.edn
{:cljs-dapp.module/router
 ["/" {""       :home
       "about"  :about}]

 :cljs-dapp.module/web3
 {:network-id 1533140371286
  :contract #json "build/contracts/Simplestorage.json"}

 :cljs-dapp.module/app
 {:mount-point-id "app"
  :routes #ig/ref :cljs-dapp.module/router
  :web3 #ig/ref :cljs-dapp.module/web3}}

今回は3つのモジュールに分割しています。

  • :cljs-dapp.module/app ... React.js(reagent) アプリモジュール
  • :cljs-dapp.module/router ... History API(pushy) によるルーターモジュール
  • :cljs-dapp.module/web3 ... Web3 モジュール

app モジュールは最後に初期化したいため、router, web3 に依存させています。
依存関係があれば integrant が初期化順序を自動で制御してくれます。

ClojrueScript(ブラウザ)ではリソース読み込み出来ないのに何故 EDN ファイルが出てくる?と気づかれるかもしれませんが、実現方法は後述します。
正直に言うと一手間必要なのでわざわざ EDN ファイルにする必要はないのですが...。

モジュール

次に肝要のモジュール実装です。

router.cljs
;; リローダブルにするために、re-frame ハンドラはモジュール初期化時に登録。
;; reg-subs, reg-event-fxs は複数ハンドラ登録用の独自ユーティリティ。
(defn- load-subs []
  (reg-subs
   {::active-panel
    (fn [db]
      (::active-panel db))}))

(defn- load-events []
  (reg-event-fxs
   {::init ;; re-frame db の初期化イベントハンドラ
    (fn-traced [{:keys [:db]} _]
               {:db
                (assoc db ::active-panel :none)})

    ::halt ;; re-frame db の破棄イベントハンドラ
    (fn-traced [{:keys [:db]} _]
               ;; db からモジュールに紐づく値のみを削除する。
               {:db (clear-re-frame-db db (namespace ::module))})

    ::set-active-panel
    (fn-traced [{:keys [:db]} [panel-name]]
               {:db
                (assoc db ::active-panel panel-name)})}))

;; ...略

;; モジュール初期化
(defmethod ig/init-key :cljs-dapp.module/router
  [_ routes] ;; config.edn に定義された初期設定
  (js/console.log (str "Initializing " (pr-str ::module)))
  ;; re-frame event, subscription ハンドラの読み込み
  (load-subs)
  (load-events)
  ;; re-frame db 初期化ハンドラの同期呼び出し
  (re-frame/dispatch-sync [::init])
  ;; Html5History 初期化後、レコードを返す。→ 停止時に受け渡される。
  (app-routes routes))

;; モジュール停止
(defmethod ig/halt-key! :cljs-dapp.module/router
  [_ {:keys [history]}]
  (js/console.log (str "Halting " (pr-str ::module)))
  ;; re-frame db 破棄ハンドラの同期呼び出し
  (re-frame/dispatch-sync [::halt])
  ;; モジュールに紐づく re-frame ハンドラを削除する。
  (clear-re-frame-handlers (namespace ::module))
  ;; Html5History の停止
  (pushy/stop! history))

モジュールはそれぞれ、re-frame db 上の状態とそれを監視、操作するハンドラを持ちます。
リローダブルに開発したいため、ハンドラ登録は関数に閉じ込めてモジュール初期化時に実行します。

ハンドラ登録後、re-frame db 上に初期状態を登録します。
これは ::init イベントハンドラを同期呼び出しすることで実現します。
router モジュールは、::active-panel を状態として管理します。

re-frame db、ハンドラはモジュール名前空間付きのキーワードで登録しているため、モジュールに緩く紐付きます。

  • DB
    • :cljs-dapp.module.router/active-panel = :none
  • Subscription ハンドラ
    • :cljs-dapp.module.router/active-panel
  • Event ハンドラ
    • :cljs-dapp.module.router/init
    • :cljs-dapp.module.router/halt
    • :cljs-dapp.module.router/set-active-panel

初期化の終わりに Html5History を初期化し、インスタンス(レコード)を返り値とします。
Html5History のライフサイクル管理には pushy というライブラリを使っています。

停止時には re-frame db からモジュールに紐づく状態を削除(::halt)してからモジュールに紐づくハンドラを削除し、その後、Html5History を停止 (pushy/stop! history) します。
停止対象のレコードは integrant が受け渡してくれるため管理が楽ですね。

モジュールが満たすべき仕様

re-mount が言及している様に、これは疎結合なゆるいパターンに過ぎないため、新たなモジュールを開発する際は毎回モジュールとしての仕様を満たすことを確認しなければなりません。
モジュールは下記の仕様を満たす必要があります。

  • 初期化
    • 自身に紐づく re-frame db、ハンドラはモジュールの名前空間付きキーワードで登録すること。
    • re-frame ハンドラは初期化時に登録すること。
    • re-frame db で自身に関わるデータを登録(assoc)するハンドラ(::init)を同期呼び出しすること。(必要な場合のみ)
    • 生成したインスタンスやリスナーは、初期化関数の返り値とすること。
  • 停止時
    • re-frame db 上で自身に関わるデータを破棄(dissoc)するハンドラ(::halt)を同期呼び出しすること。(必要な場合のみ)
    • 自身が登録した re-frame ハンドラを削除すること。
    • 生成したインスタンスやリスナーを停止すること。

言葉でまとめるとそれほど複雑ではありませんが、私の Clojure 力の低さからサンプルの実装は割と複雑になってしまっています。
utils.cljc に無理を押し込めているので、強い人は PR 下さい。

views.cljs

views.cljs は app モジュールの初期化時にマウントします。
View から re-frame ハンドラを呼び出す際は、ID が各モジュール名前空間付きキーワードとなっているためエイリアス require して解決(::web3/my-address)します。

views.cljs
(require '[soda-ash.core :as sa]
         '[cljs-dapp.module.router :as router]
         '[cljs-dapp.module.web3 :as web3])

;; ::web3/my-address -> :cljs-dapp.module.web3/my-address

(defn home-panel []
  (let [my-address (re-frame/subscribe [::web3/my-address]) ;; subscription ハンドラ
        data (re-frame/subscribe [::web3/data])] ;; require したエイリアス経由で参照できる
    (reagent/create-class
     {:component-will-mount
      #(re-frame/dispatch [::web3/get-data]) ;; event ハンドラも同様

      :reagent-render
      (fn []
        [:div
         [sa/Segment
          [sa/Table {:celled true}
           [sa/TableBody
            [sa/TableRow
             [sa/TableCell {:style {:width "200px" :background-color "#F9FAFB"}}
              "Your address"]
             [sa/TableCell @my-address]]
            [sa/TableRow
             [sa/TableCell {:style {:background-color "#F9FAFB"}} "Stored data"]
             [sa/TableCell @data]]]]
          [sa/Divider {:hidden true}]
          (when @data
            [data-form {:configs {:initial-data @data}
                        :handlers {:update-handler
                                   #(re-frame/dispatch [::web3/set-data %])}}])]])})))

今回のパターンに直接関係ありませんがサンプルでは Semantic UI React とそれを reagent から使い易くする soda-ash というライブラリを使っています。

core.cljs

最後に core.cljs です。サーバサイドで integrant を使う場合と殆ど同じです。
エントリーポイントは init、Figwheel の on-jsload では reset を呼び出します。

core.cljs
(defonce system (atom nil))

(defn start []
  (reset! system (ig/init (read-config "resources/config.edn"))))

(defn stop []
  (ig/halt! @system)
  (reset! system nil))

(defn reset []
  (stop)
  (start))

(defn ^:export init []
  (dev-setup)
  (start))

start 関数内で config.edn を読み込んでいますね。
cljs(ブラウザ)では slurp(ファイル読み込み) が使えないはずなのに何故 read-config 出来ているのでしょうか?
動かしてもらうと解ると思いますが、これはちゃんと動作します。

ネタばらしすると、この read-config は integrant が提供する read-config ではなく、Clojure マクロです。

utils.cljc
(defmacro read-config [file]
  #?(:clj (ig/read-string
           {:readers {'json #(-> %
                                 slurp
                                 (json/read-str :key-fn keyword))}}
           (slurp file))))

ClojureScript は単なる Altjs ですが、ビルド時に JVM 上で Clojure マクロが実行可能です。
上記マクロにより ClojureScript ビルド時に resources/config.edn を読み込んで start 関数内にデータを展開することで、EDN ファイルに定義した設定を使えるようにしているのです。(つまりブラウザ上で読み込んでいるわけではない)
魔法みたいですが、まさに Clojure のサブセットとして開発される ClojureScript の本領発揮といったところですね。

まあ正直 config は cljs コード上に直接記述した方が簡単なのですが、もう一箇所同じ方法を利用して #json という integrant の独自リーダを定義しています。
これは、指定したパスから JSON ファイルを読み込んで Clojure データに変換してインライン展開しています。

スマートコントラクトに接続するためにはコントラクトの定義ファイル(SimpleStorage.json)を読み込む必要があり、今回このリーダはそれを対象としています。
この定義ファイルはコントラクトのコンパイルごとに内容が変わるため、通常 Ajax 等で取得するようにしますが、巨大なコントラクトになると数 MB というサイズになるため、ビルド時に展開されていると嬉しかったりします。
そもそも初期化に関わるファイルのため、Ajax で非同期に取得しようとすると色々辛く、特に今回のパターンではありがたみが出てきます。

開発

では、このパターンで作ったアプリの開発はどのようになるでしょうか。
下記のコマンドで Figwheel サーバが起動し cljs-repl が開きます。

% lein dev
Figwheel: Cutting some fruit, just a sec ...
Figwheel: Validating the configuration found in project.clj
Figwheel: Configuration Valid ;)
Figwheel: Starting server at http://0.0.0.0:3449
Figwheel: Watching build - dev
Figwheel: Cleaning build - dev
Compiling build :dev to "resources/public/js/compiled/app.js" from ["src"]...
Successfully compiled build :dev to "resources/public/js/compiled/app.js" in 38.744 seconds.
Figwheel: Starting CSS Watcher for paths  ["resources/public/css"]
Launching ClojureScript REPL for build: dev
Figwheel Controls:
          (stop-autobuild)                ;; stops Figwheel autobuilder
          (start-autobuild id ...)        ;; starts autobuilder focused on optional ids
          (switch-to-build id ...)        ;; switches autobuilder to different build
          (reset-autobuild)               ;; stops, cleans, and starts autobuilder
          (reload-config)                 ;; reloads build config and resets autobuild
          (build-once id ...)             ;; builds source one time
          (clean-builds id ..)            ;; deletes compiled cljs target files
          (print-config id ...)           ;; prints out build configurations
          (fig-status)                    ;; displays current state of system
          (figwheel.client/set-autoload false)    ;; will turn autoloading off
          (figwheel.client/set-repl-pprint false) ;; will turn pretty printing off
  Switch REPL build focus:
          :cljs/quit                      ;; allows you to switch REPL to another build
    Docs: (doc function-name-here)
    Exit: :cljs/quit
 Results: Stored in vars *1, *2, *3, *e holds last exception object
Prompt will show when Figwheel connects to your application
[Rebel readline] Type :repl/help for online help info
ClojureScript 1.10.339
dev:cljs.user=>

Figwheel のホットリロードにより、ソースコードを編集して保存すると cljs ビルドが走り、ビルド結果がブラウザにプッシュされ、reset 関数が実行されます。

今回のパターンを取らない場合、re-frame により View の反映はうまく出来ても、外部ライブラリのリスナーやインスタンスの再初期化に失敗して結局画面をリロードしてしまうことが多いと思います。
今回のパターンを取ることで、モジュールが適切に再初期化されるようになり、よりスムーズな開発が期待できます。

開発中に設定の変更が必要になった場合は config.edn を書き換えて Figwheel にビルドをキックさせれば自動で設定が反映されます。

更に re-mount が触れているようにこの構成で作ったモジュールは(トートロジーですが)完全に modulable です。
district0x 社は GraphQL client モジュールなどを切り出してライブラリとして公開しています。

https://github.com/district0x/district-ui-graphql

ライブラリまで行かなくても、疎結合で再利用性が高いコードとして開発できます。

まとめ

今回紹介した integrant+re-frame によるパターンを取ることで、SPA を独自の状態、ライフサイクルを持つモジュールに分割して開発することが出来ます。
DApp 開発で困った点については下記の通り解決します。

  • 使うべきライブラリが多い。
    • ▶ モジュール化して管理。
  • それぞれに初期化パラメータが存在し、環境によって切り分けが必要。
    • ▶ config.edn に宣言的に定義することで管理し易く。
  • 生成したインスタンスに紐づく状態が変化し得る。
    • ▶ 各モジュールに紐づけて re-frame が管理。
  • インスタンス間の依存関係も存在。
    • ▶ モジュール間の依存として定義。

このパターンの課題としては下記が挙げられます。

  • 開発時に各モジュールが仕様(前述)を満たすことを確認しなければならないこと。
  • モジュールごとの re-frame db, ハンドラの初期化・破棄で煩雑なコードが必要となってしまうこと。
  • integrant の初期化は同期的に行われるため、終わるまでレンダリングがブロックされてしまうこと。

課題はありますが、DApp の様な発狂寸前な SPA を開発するのであれば、採用してもいいかも知れません。
自分は SPA 開発好きだけど Javascript は書きたくない人(支離滅裂な思考)なので、ClojureScript を使う前提ならこの様に解決出来るよ、という提案でした。

Why not register and get more from Qiita?
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away
Comments
Sign up for free and join this conversation.
If you already have a Qiita account
Why do not you register as a user and use Qiita more conveniently?
You need to log in to use this function. Qiita can be used more conveniently after logging in.
You seem to be reading articles frequently this month. Qiita can be used more conveniently after logging in.
  1. We will deliver articles that match you
    By following users and tags, you can catch up information on technical fields that you are interested in as a whole
  2. you can read useful information later efficiently
    By "stocking" the articles you like, you can search right away