Posted at
ClojureDay 16

fulcroについて調べてみた


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 を表示させると、

スクリーンショット 2018-12-16 19.00.17.png

の状態となり、

スクリーンショット 2018-12-16 19.00.32.png

Builds の中から main を選んで

スクリーンショット 2018-12-16 19.00.50.png

[start watch] をクリックすると、cljs をビルド(watch)してくれます。

ここまでくれば、http://localhost:3000 を開くと、サンプルの画面が表示されます。

(下図は、後述するカウンターコンポーネントを追加した後の状態です)

スクリーンショット 2018-12-16 19.01.47.png


サンプルコンポーネント解説

基本的なクライアントの構造を理解するため、説明対象を絞って説明していきます。

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 は状態を持たないので、queryinitial-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等で初期状態を与える、といったことをしていたかと思いますが、defscinitial-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 にあげておきました。

以上です。