fulcro について調べてみた
当初、「たまに使う道具としてのClojure(仮)」のタイトルとしていたのですが、どうにもまとまらなさそうなので、内容を変更させていただきました。
om.next から fulcro へ
om は、React の ClojureScript Wrapper ライブラリで、当初はそれなりに勢いがあったように思いますが、途中から実装方針が変更となってom.next と呼ばれるようになってからは、どういうわけか開発ペースが落ちてしまったようで、今では開発がほとんどストップしているような状況です。
自分は、om.next の面白さ、可能性を信じてウォッチしていたのですが、なかなか動きがありませんでした(1.0.0-alpha3 が 2015/10/16 に出て、2018/06/02 に 1.0.0-beta4 がやっと出た位の勢いです...)。
そんな om.next ですが、一方でひょんなことから fulcro (WebSite)というものを知りました。fulcro は当初 om.next の add on library からスタートしたようで、om.next の血(?)を受け継ぎながら改良が進み現在も活発に開発が進んでいるようです。
そこで、fulcro について実際にコードを書きつつ少し調べてみたので、(自分用メモの意味も含め)記しておきたいと思います。
Getting started
それでは早速触ってみます。
前提条件
- leiningen (>= 2.8.1)
- node、npm (> ? 最近のやつならたぶん大丈夫)
サンプルプロジェクト作成
leiningen のテンプレートがあるので、サンプルプロジェクトを作ってみます。
lein new fulcro fulcro-sample-app
fulcro では ClojureScript のコンパイルに shadow-cljs を使用しているので、npm install
も忘れずに。
cd fulcro-sample-app
npm install
開発環境の起動
開発環境を起動するには、
- ClojureScript のコンパイル環境(shadow-cljs)の起動
- Backend となる http server の起動
を行う必要があります。
npx shadow-cljs server
# or
# node ./node_modules/.bin/shadow-cljs server
に続いて、別端末から
lein repl
user=> (start)
を実行、でOKです。この状態で、http://localhost:9630 を表示させると、
の状態となり、
Builds の中から main を選んで
[start watch] をクリックすると、cljs をビルド(watch)してくれます。
ここまでくれば、http://localhost:3000 を開くと、サンプルの画面が表示されます。
(下図は、後述するカウンターコンポーネントを追加した後の状態です)
サンプルコンポーネント解説
基本的なクライアントの構造を理解するため、説明対象を絞って説明していきます。
src
├── main
│ └── fulcro_sample_app
│ ├── client.cljs ...(1)
│ └── ui
│ ├── components.cljs ...(3)
│ └── root.cljs ...(2)
(1) client.cljs
クライアント側のエントリーポイントになります。ここから次の(2)に定義された Root コンポーネントをマウントしています。
(2) root.cljs
Root コンポーネントをdefsc
マクロで定義しています。
(ns fulcro-sample-app.ui.root
(:require
[fulcro.client.dom :as dom :refer [div]]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro-sample-app.ui.components :as comp]))
(defsc Root [this {:keys [root/message]}]
{:query [:root/message]
:initial-state {:root/message "Hello!"}}
(div :.ui.segments
(div :.ui.top.attached.segment
(div :.content
"Welcome to Fulcro!"))
(div :.ui.attached.segment
(div :.content
(comp/ui-placeholder {:w 50 :h 50}) ;; *1
(div message)
(div "Some content here would be nice.")))))
このコードでは、次のことを定義しています。
- コンポーネントの名前:
Root
- コンポーネントの props(
[this {:keys [root/messages]}]
のroot/messages
が propsとなる。) - このコンポーネントの状態(state)を取り出すための
query
と、初期状態initial-state
(省略可能) - このコンポーネントの(Reactでいう) render で返すもの:
(div ...)
の内容
Rootコンポーネントから (3) components.cljs に定義されている ui-placeholder
が呼び出されています(*1の部分)。
(3) components.cljs
(ns fulcro-sample-app.ui.components
(:require
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.dom :as dom]))
(defsc PlaceholderImage
[this {:keys [w h label]}]
(let [label (or label (str w "x" h))]
(dom/svg #js {:width w :height h}
(dom/rect #js {:width w :height h :style #js {:fill "rgb(200,200,200)"
:strokeWidth 2
:stroke "black"}})
(dom/text #js {:textAnchor "middle" :x (/ w 2) :y (/ h 2)} label))))
(def ui-placeholder (prim/factory PlaceholderImage))
(2) の Root コンポーネントの render 内では ui-placeholder
が参照されていますが、これは defsc
で定義されたものを、prim/factory
で作成したインスタンス、となります。
(2) の Root コンポーネントでは、状態を持っていたため、query
とかinitial-state
を定義していましたが、(3) PlaceholderImage は状態を持たないので、query
、initial-state
は定義していません。
ここまで、leiningen のテンプレートで作成されたコンポーネントについて説明してきました。
om.next との違い
これまでのところでの om.next との違いについて軽く触れておきます。
状態の取り扱い
om.next だと、状態に対する読み取り(read)および更新(mutation)を、Reconciler
管理下の parser としてコーディングしていましたが、defsc
マクロでうまく隠蔽されるようになっています(mutation については後述)。
React の lifecycle method
om.next だと、defui
の中で render
を記述していましたが、defsc
マクロにおいては単純に dom tree を宣言的に記述しておくだけ、となりました。
同様に、状態の初期化についても、従来だと componentDidmount
等で初期状態を与える、といったことをしていたかと思いますが、defsc
の initial-state
に記述しておくだけでよくなりました。
どちらも、より宣言的になりコードがすっきり書けるようになったといえるかと思います(ただし、従来どおり lifecycle method を呼びたいというニーズに答えるために、defsc
マクロでも記述できるようになっているようです)。
応用編 Counter Component
ここからは応用編その1として、ボタンを押したらカウントアップするだけのコンポーネントを作ってみます。カウントした値はデータベースに保存します。
この例では、
- 状態全体の構造の考え方と query の記述
- mutation
- remoting
について示したいと思います。
関連ソース
src
├── main
│ ├── config
│ │ ├── defaults.edn ...(1)
│ └── fulcro_sample_app
│ ├── client.cljs ...(6)
│ ├── server_components
│ │ ├── database.clj ...(2)
│ │ └── middleware.clj ...(3)
│ └── ui
│ ├── counter.clj ...(7)
│ ├── counter.cljs ...(4)
│ └── root.cljs ...(5)
DB関連
fulcro の template で生成されたソースでは、コンポーネントの状態管理に mount を使用するようになっていますので、データベース関連のコードを以下のように追加していきます。
- defaults.edn (1)
データベース接続情報を追記(好みに合わせて適当に)。
{
;; 以下を追加
;; datasource configuration option
:database {;; : snip
:adapter "postgresql" ;; or other database
:username "db-user-name"
:password "db-password"
:database-name "db-database-name"
:server-name "db-server-hostname"
:port-number 5432
;; : snip
}}
- database.clj (2)
データソース取得を hikari-cp
で行うようにしました。mount
を使って状態管理するので、mount.core/defstate
で宣言しておきます。
(ns fulcro-sample-app.server-components.database
(:require
[mount.core :refer [defstate]]
[hikari-cp.core :refer [make-datasource]]
[fulcro-sample-app.server-components.config :refer [config]]))
(defstate database
:start (make-datasource (:database config))
:stop (.close database))
- middleware.clj (3)
作成したデータソースは、fulcro-sample-app.server-components.database/database
という var で参照できるので、middleware 経由で参照できるようにしておきます。
(ns fulcro-sample-app.server-components.middleware
(:require
;; 中略
[fulcro-sample-app.server-components.database :refer [database]]
[mount.core :refer [defstate]]
[fulcro.server :as server]
;; 中略
))
;; 中略
;; ================================================================================
;; Replace this with a pathom Parser once you get past the beginner stage.
;; This one supports the defquery-root, defquery-entity, and defmutation as
;; defined in the book, but you'll have a much better time parsing queries with
;; Pathom.
;; ================================================================================
(def server-parser (server/fulcro-parser))
(defn wrap-api [handler uri]
(fn [request]
(if (= uri (:uri request))
(server/handle-api-request
;; Sub out a pathom parser here if you want to use pathom.
server-parser
;; this map is `env`. Put other defstate things in this map and they'll be
;; in the mutations/query env on server.
{:config config
:datasource database ;; ★add
}
(:transit-params request))
(handler request))))
server side の実装は、上記コードの server-parser
を経由して、 defquery-root
/ defquery-entity
/ defmutation
で宣言した関数を呼び出す形となります。
具体的な実装例は (7) で示します。
client側の実装
- counter.cljs (4)
Counter コンポーネントを追加します。
(ns fulcro-sample-app.ui.counter
(:require
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro.client.mutations :refer [defmutation]]
[fulcro.client.dom :as dom]
[sablono.core :refer [html]]))
(defmutation bump-number [ignored] ;; ④
(action [{:keys [state] :as param}] ;; ⑤
(swap! state update-in [:root/counter :counter/cnt] inc))
(remote [env] true)) ;; ⑥
(defsc Counter
"simple counter example component"
[this {:keys [counter/cnt]}] ;; ①
{:query [:counter/cnt] ;; ②
:initial-state {:counter/cnt 1}}
(html
[:button {:on-click #(prim/transact! this `[(bump-number {})])} ;; ③
"You've clicked this button [" cnt "] times."]))
(def ui-counter (prim/factory Counter))
このコンポーネントでは、以下の点について記述しています。
- ① 現在のカウント状態は
:counter/cnt
キーに格納 - ② それを取り出す query は
:counter/cnt
で表現される - ③ ボタンクリック時に
prim/transact
が呼び出され、そのパラメータで指定された query が実行される。ここでは[(bump-number {})]
となっているので、bump-number
という名の mutation (引数なし) が実行される - ④ mutation は
defmutation
マクロで宣言(引数なしなのでたまたま[ignored]
となっているが、実際には[{:keys [param1 param2]}]
のような形となる)。 - ⑤ mutation による処理本体は、
action
の中に記述。このコンポーネントでは、状態は:counter/cnt
に格納されているが、root からは:root/counter
にぶらさげる予定なので、root からみたカウントの値は{:root/counter {:counter/cnt 値}}
の形となり、update-in
で更新(ここではinc) する形となる。 - ⑥
(remote [env] true)
を記述しておくと、この mutation が実行された際に remote の mutation を実行するようになる。remote の mutation 実装については (7)に記載。
- root.cljs (5)
Root コンポーネントから Counter コンポーネントを参照するようにします。
(ns fulcro-sample-app.ui.root
(:require
[fulcro.client.dom :as dom :refer [div]]
[fulcro.client.primitives :as prim :refer [defsc]]
[fulcro-sample-app.ui.components :as comp]
[fulcro-sample-app.ui.counter :as counter] ;; ★追加
[taoensso.timbre :as log]
[sablono.core :refer [html]]))
(defsc Root [this {:keys [root/message
root/counter]}] ;; ★追加
{:query [:ui/react-key :root/message
{:root/counter (prim/get-query counter/Counter)}] ;; ★追加
:initial-state (fn [params]
{:root/message "Hello!"
:root/counter (prim/get-initial-state counter/Counter {})})} ;; ★追加
(html
[:div.ui.segments
;; 中略
;; counter example ★追加
[:div.ui.attached.segment
[:div.content
"Counter example" [:span " "]
(counter/ui-counter counter)]]]))
ここでは、query と initial-state について補足しておきます。
query
について::root/counter
というキーに、Counter コンポーネントの状態をまるっとぶら下げる、という時に {:root/counter ...}
のように map で表します(これをquery の join と呼ぶ)。また、コンポーネントがどんな query を持っているかは、prim/get-query
を使って取得できます。
initial-state
について:initial-state には、map による固定値の他に、map を返却する関数を指定できます。コンポーネント自身の状態あるいはぶら下がるコンポーネントの状態(のtree)は、initial-state で初期化しておくべきです(初期化していない場合におかしな動きをするケースがあり、少しはまりました)。
- client.cljs (6)
Counter コンポーネントの query は、:counter/cnt
でした。ただ今回はサーバ上の値を読みに行かないといけないので、その設定をしてあげる必要があります。そのための設定が、下記 :started-callback
の指定となります。
(ns fulcro-sample-app.client
(:require [fulcro.client :as fc]
[fulcro-sample-app.ui.root :as root]
[fulcro-sample-app.ui.counter :as counter] ;; ★ add
[fulcro.client.network :as net]
[fulcro.client.data-fetch :as df]))
;; 中略
(defn ^:export init []
(reset! app (fc/new-fulcro-client
;; This ensures your client can talk to a CSRF-protected server.
;; See middleware.clj to see how the token is embedded into the HTML
:networking {:remote (net/fulcro-http-remote
{:url "/api"
:request-middleware secured-request-middleware})}
:started-callback (fn [app] ;; ★add
(df/load app :root/counter counter/Counter))))
(start))
ここまでのコードで、client 側の実装は完了しました。次からサーバ側の実装となります。
backend側の実装
- counter.clj (7)
Counter コンポーネントのサーバ側実装について説明します。
(ns fulcro-sample-app.ui.counter
(:require
[fulcro.server :refer [defmutation defquery-root defquery-entity]]
[clojure.java.jdbc :as j]))
(defn- get-curval
[conn]
;; 中略: DB上のカウント状態を保持したテーブルの値を読み出し返す
)
(defquery-root :root/counter ;; ①
(value [{:keys [parser query datasource] :as env} params] ;; ②
(j/with-db-connection [conn {:datasource datasource}]
{:counter/cnt (get-curval conn)}))) ;; ③
(defmutation bump-number [ignored] ;; ④
(action [{:keys [datasource] :as env}] ;; ⑤
(j/with-db-transaction [t-conn {:datasource datasource} {:read-only? false}]
;; 中略:カウント状態を 1増やす
{:counter/cnt (get-curval t-conn)}))) ⑥
- ①
defquery-root
で query 時に呼び出される関数を定義します。:root/counter
は、query のキーとなります。 - ②
value
の中に実際の処理を記述します。 - ③ここで注意すべきは、戻り値の型です。クライアント側の Counter コンポーネントの状態は、root から見たときに、
{:root/counter {:counter/cnt 値}}
という形でした。このvalue
が返すべきものは、{:root/counter ...}
の...
の部分となるので、{:counter/cnt 値}
を返す必要があります(説明が下手ですが)。 - ④サーバ側の mutation を定義します。ここでは
bump-number
という symbol が来たときの mutation 定義となります。 - ⑤
action
の中に実際の処理を記述します。「middleware.clj (3)」で、:datasource
にデータソースを埋め込んでいるので、env から取得できます。 - ⑥ カウントアップした後に戻り値を返します。これも同様に
{:counter/cnt 更新後の値}
を返します。
om.next との違い(remoting)
om.next では、query / mutation ともに「特定の様式」での multi method を定義し、reconciler にサーバ送受信コードを記述してやることで、remoting は実行できました。
以下は、om.next での client side コードの例を示します(今回の例とは異なります。また一部抜粋です)。
(defmethod read :root/text
[{:keys [state query ast] :as env} k params]
(if-let [v (get @state k)]
{:value (om/db->tree query v @state) :remote true}
{:value "not-found"}))
(defmethod mutate 'root/update-text
[{:keys [state] :as env} _ {:keys [text]}]
{:value {:keys [:root/textinput]}
:action (fn [] (swap! state assoc-in [:root/textinput :text] text))})
(def parser (om/parser {:read read :mutate mutate}))
(def reconciler
(om/reconciler
{:state app-state
:normalize true
:parser parser
:send (util/transit-post "/api")}))
(defn transit-post [url]
(fn [edn callback-fn]
(.send XhrIo url
(fn [e]
(this-as this
(
;; 中略
)))
"POST" (t/write (t/writer :json) edn)
#js {"Content-Type" "application/transit+json"})))
om.next では read / mutate のマルチメソッド内でボイラープレートコードをたくさん書く必要があったのが、fulcro ではそれがほとんどなくなり、ずいぶんスッキリしたように思います。
所感
om.next では、状態の管理(read / mutate) が UI と完全に分離していて、慣れればソースがきれいに書けたように思いますが、ボイラープレートコードがじゃまをしてせっかくのコンセプトもいまひとつ生かされていない印象でした。fulcro はその点うまく整理できているように思います。
あと、fulcro を使いこなす上で避けて通れないのが query 関連だと(個人的には)思っています。query を除けばほぼ Clojure/Sciprt、React、の知識だけでだいたいなんとかなる、のですが...。query に関しては Developer Guide を読んで理解していくしかなさそうです。
GraphQL といい、こういうのって流行りなのでしょうか。関連しそうなものとして、
- EQL - EDN query language
- Pathom - A Clojure library designed to help you write Clojure(script) graph query processing parsers for the query notation used by EQL
- Walkable - Clojure(script) SQL library for building APIs
とかあるのですが、使える(自分のモノになる)かどうかは別として、面白そうなのは間違いないですね。今後ぼちぼち追っかけてみたいと思います。
今回検証したコード(プラスアルファ)は、Github にあげておきました。
以上です。