Practical Reader Conditionals & Transit format

Lisp Meet Up presented by Shibuya.lisp #35 で 1 時間近く話したのでその話。

スライドはこれ。

Practical Reader Conditionals & Transit format

このときのデモ用に使ったコードはこの辺。

ayato-p/shibuya-lisp-demo-35

これを git clone して、 lein repl から (go) でとりあえず起動出来ると思う(デモ用なので開発用の色々とか説明に使う部分以外はほとんど実装してないです / project.clj も多少汚いのはそういう事情…)。 そのわりには Component を綺麗に実装していたりと謎なことしているけど気にしない。

何を伝えたかったのか

Reader Conditinals や Transit format もっと活用してもいいんじゃないのということが少しだけ言いたかった。 それから Clojure をこれから勉強する人とか、興味がある人に Clojure にはこういう強みがあるよ、というのを説明したくて今回頑張った(最終的に今回の Meet Up では Clojurian よりも Common Lisper が多くてアウェイだったけど / 過去に Java や JS の勉強会でも発表しているのでアウェイ耐性◎です)。

Reader Conditionals の活用方法

スライド中でも言及しているように Reader Conditionals 自体はかなり使い勝手が良い。昨今の JavaScript 界隈で騒がれている Isomorphic というのをある程度体現できている(と思う)。

ルーティング定義の共有

例えば bidi というライブラリを使っていれば、次のようにルーティングを定義出来る。

(def main
  ["/" {"" {"" :home
            "cart" :cart}
        "api" {"/book" {"/search" :api/search-book}}}])

気付いたと思いますが、これ自身は Clojure のただのデータ型だけで作られているので、 Clojure/ClojureScript の両方から読み込むことが出来ます。 実際にはこのマップデータを次のように bidi の関数に渡します。

(require '[bidi.bidi :as bidi])
(bidi/path-for main :api/search-book) ;; -> "/api/book/search"

bidi 自身も Reader Conditionals を使って良く書かれているので、とても都合がいいですね。

サーバーサイドレンダリング

昨今 React.js だとかなんとか js だとかという名前をよく聞きますが、ほとんどのなんとか js フレームワークで SPA を作ったときに問題になっているのが最初の画面描画までに時間がかかってしまうことです。 これを解決するためにサーバーサイドレンダリングなんていう言葉が注目を浴びているようです。 そして多くの場合、 Node.js などサーバー側で動作する JavaScript ランタイムを使い実現するようです。 Java も実は JavaScript エンジンを飼っていて簡単に使うことが出来ます。 勿論 Clojure もその恩恵にあずかれるので簡単に使えるのですが、 JavaScript エンジンなんて大層なものが何の代償も支払わずに簡単に使えるわけがありません。

まず JavaScript エンジンのインスタンスを作るコストが高いですし、それをプールに投げ込んでおくなどして管理しておかないとパフォーマンス問題に繋がります。 それとこれは開発時の問題ですが、 Figwheel を開発時に使うためには少々面倒なことをしないといけません。詳しくは以下の記事を参照してください。

Isomorphic JavaScript (with ClojureScript) for pre-rendering single-page-applications, part1

などという問題があるのでサーバーサイドレンダリングを実現するのは地味にコストが高かったりします。ですが、 Clojure には良く出来たライブラリが幾つもあるのでサーバーサイドレンダリングを比較的容易に低いコストで実現することが出来ます。

どういうことかというと Hiccup という Clojure のデータ型だけでで HTML を表現出来るというテンプレートエンジンがあるのですが、この Clojure のデータ型だけで HTML を表現するというアイデアはとても素晴らしいもので、他のテンプレートエンジンなどでも好んで使われることがあります。 そして Reagent という ClojureScript の React.js ラッパーでもやはりこのアイデアが採用されているので、 HTML 部分を Hiccup と同様のシンタックス(つまり Clojure のデータ型)で書くことが出来ます。

例えば次に示す例のように直感的な書き方が出来るようになっています。 Clojure のベクターでネストさせて行くことが出来るので、 HTML のように閉じタグを意識する必要がないのはとてもよいですね。

[:div [:h1#head.red-font "Hello, Hiccup"]] ;;=> "<div><h1 class=\"red-font\" id=\"head\">Hello, Hiccup</h1></div>"

ここまでで勘のいい人は次のように考えたと思います。「もしこの HTML をレンダリングする部分だけサーバーサイドとフロントエンドで共有することが出来たら JS のランタイムを使わなくてもサーバーサイドレンダリング出来るんじゃないのか…?」。その通りです。 Reader Conditionals 以後、そういうことが容易に出来るようになりました。

例えば次のように書けます。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/view/component/cart.cljc
(defn item-list-component []
  (let [cart (subscribe [:transit-demo.sub.cart/cart])]
    (when (not-empty (:items #?(:clj cart :cljs @cart)))
      [:table
       [:thead
        [:tr [:th "商品"] [:th "価格"] [:th "数量"]]]
       [:tbody
        (for [[k v] (group-by :title (:items #?(:clj cart :cljs @cart)))
              :let [{:as i :keys [title price]} (first v)]]
          ^{:key k}
          [:tr
           [:td title]
           [:td (impl/tax-include-price i)]
           [:td (count v)]])]])))

(defn cart-result-coomponent []
  (let [cart (subscribe [:transit-demo.sub.cart/cart])]
    [:span "合計: " (impl/tax-include-price #?(:clj cart :cljs @cart))]))

(defn cart-component []
  [:div
   [:h2 "カートの中身"]
   [item-list-component]
   [cart-result-coomponent]])

これは実際に発表したときに作ったデモコードなので、綺麗に動きます。 Reagent と re-frame を使っている場合、 Event Handler, Subscription Handler, View Component のように綺麗にコードを分割出来ます。 そうすると View Component は上記のようにほぼビューだけを意識すればよくなるので、コードを共有しやすくなります。

Event Handler, Subscription Handler は完全に画面側のロジックなので、共有する必要はなく cljs のディレクトリに入れてしまい、 View Component のみ共有しやすいように cljc のディレクトリ( Reader Conditionals を使っている場合、明確に分けていたほうがプロジェクトとしてまとめやすいので分けることが多い)へと入れておきます。 上記のコードも cljc ディレクトリへと入っています。

Reader Conditionals を使えるのでほぼほぼ同じコードをそのまま共有出来るのですが、実は一部だけそれが出来ない部分があります。例えば [cart-result-coomponent] などと書いている部分は通常の関数呼び出しのように丸括弧を使うのではなく、角括弧を使っています(こう書く理由についてはここでは触れませんが、 re-frame の Wiki に詳しいので そちら を参照してください)。 これは Reagent 特有のもので、オリジナルの Hiccup ではこれを処理することが出来ません。なので、こういった Hiccup にはない書き方をされている部分を Hiccup として扱えるように変換をかけてやる必要があります。それが以下の render 関数です。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src/transit_demo/util/reframe.clj
(defn react-id-str [react-id]
  (assert (vector? react-id))
  (str "." (str/join "." react-id)))

(defn set-react-id [react-id element]
  (if-not (= (first element) :input)
    (update element 1 merge {:data-reactid (react-id-str react-id)})
    element))

(defn normalize [component]
  (if (map? (second component))
    component
    (into [(first component) {}] (rest component))))

(defn render
  ([component] (render [0] component))
  ([id component]
   (cond
     (fn? component) (render (component))

     (not (coll? component)) component

     (coll? (first component))
     (map-indexed #(render (conj id %1) %2) component)

     (keyword? (first component))
     (let [[tag opts & body] (normalize component)]
       (->> body
            (map-indexed #(render (conj id %1) %2))
            (into [tag opts])
            (set-react-id id)))

     (fn? (first component))
     (render id (apply (first component) (rest component))))))

この render 関数は以下の記事を参考にして実装しています。

RENDERING REAGENT ON THE SERVER USING HICCUP

ここで実装した render 関数を用いることで以下のようにサーバーサイドでレンダリングすることが出来ます。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src/transit_demo/view/cart.clj
(ns transit-demo.view.cart
  (:require [transit-demo.util.reframe :as ur]
            [transit-demo.view.component.cart :as component]
            [transit-demo.view.layout :as layout]))

(defn cart []
  (layout/common-layout
   [:h1 "Demo App"]
   [:div#cart-app
    (ur/render (component/app-component))])) ;; ここに注目

フロントエンド側のコードはどうなるかというと次のようになります。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljs/transit_demo/main.cljs
(ns transit-demo.main
  (:require [re-frame.core :refer [dispatch-sync]]
            [transit-demo.handler.cart]
            [transit-demo.sub.cart]
            [transit-demo.view.component.cart :as c]
            [reagent.core :as reagent]))

(defn main []
  (when-let [elm (js/document.getElementById "cart-app")]
    (dispatch-sync [:transit-demo.handler.cart/initialize])
    (js/setInterval #(reagent/render [c/app-component] elm) 300))) ;; サーバーサイドと同じコンポーネントを呼び出して、 Reagent の render 関数でレンダリングしてやるだけ / setInterval の理由は API 叩いた結果が返ってきてデータストアに入ってないと render 関数が空の状態を描画してしまうのでそれ防止

これが Reader Conditionals を用いたサーバーサイドレンダリングの全貌です。少々サーバーサイドレンダリングを実現するために余分なコードが必要ですが、比較的簡単に実現出来るのが理解出来たと思います。

ここからはこのサーバーサイドレンダリング方法に残る課題です。次のコード辺を見てください。

(defn cart-result-coomponent []
  (let [cart (subscribe [:transit-demo.sub.cart/cart])]
    [:span "合計: " (impl/tax-include-price #?(:clj cart :cljs @cart))]))

ここで Reagent と re-frame を使っている人は subscribe 関数の存在に気づくと思います。この関数は re-frame の関数なので ClojureScript 側で必要ですが、 Clojure 側では必要ありません。その代わり、サーバーサイドレンダリング時に何かの値を初期値として持ってくる処理がこのとき必要です。

デモではこの問題を次のように解決しています。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/view/component/cart.cljc
(ns transit-demo.view.component.cart
  (:require [transit-demo.impl :as impl]
            #?@(:clj [[transit-demo.db :as db]]
                :cljs [[reagent.core :as reagent :refer [atom]]
                       [re-frame.core :refer [subscribe dispatch]]])))

#?(:clj
   (def fn-table
     {:books db/find-book
      :cart (fn [& args])}))

#?(:clj
   (defn subscribe [[kw & args]]
     (let [f (get fn-table (keyword (name kw)))]
       (apply f args))))

Clojure から呼び出されたら予め定義しておいた subscribe 関数を呼び出すようにしているのですね。簡単なデモなのでこの実装でも充分耐えうるのですが、ビューの中でこれを呼び出しているのは気持ちのいいものではありません。幾つか代替案はあるのですが、どれもちょっと綺麗に見えないなぁという実装になってしまうのでここに関してはまだ改善の余地があります(ひとつ原因として re-frame の流儀に乗っかるとデータストアを大きくひとつ作って、その中にひとつの画面で使うデータを全て入れてしまうからというのがあります)。 また React の作りうるビューをサーバーサイドで無理やり作っているので、画面の差異が若干発生してしまい最初に画面を表示しきったときに警告が出てしまうのが難点です(例えば何かしらのボタンの on-change 属性に Clojure 側で適切な値を設定することが出来ない、など)。

バリデーション

これはあまり説明する必要無いかなと思いますが、例えば Prismatic/schema のようなライブラリを使っていると bidi 同様に同じモデルを共有しておいて同じようにバリデーションをかけることが出来ます。

(require '[schema.core :as sc])
(sc/defrecord Product [name :- sc/Str, price :- sc/Int])
(def p (Product. :foo 10.1))
(sc/validate Product p)
;; => ExceptionInfo Value does not match schema: {:name (not (instance? java.lang.String :foo)), :price (not (integer? 10.1))}  schema.core/validator/fn--10599 (core.clj:151)

ちなみに Prismatic/schema は Reader Conditionals ではなく、 cljx を使って実装されていますが使う側ではあまりそれらの違いを意識する必要はないです。

Transit format の活用方法

Transit format というのは Clojure 界隈では空気のように使われていると想像しますが、 JSON や MessagePack より使い勝手が良いデータフォーマットです。日本語でこれに触れているのは残念ながら(?)、 @tnoda_ さんのブログしかないのですが簡単に説明されているのでもし知らない方は是非読んでからこの先を読んでください。

cognitect/transit-format

Clojure と ClojureScript 間でのコミュニケーション

Transit は Clojure 用のデータフォーマットというわけではありませんが、 Clojure と ClojureScript のコミュニケーションに使うことによってその素晴らしさがより実感出来るようになります。

例えば通常の Web アプリケーションなどでは Book というユーザー定義型があったときにこれを JSON にして、フロントエンドへと送り JS でこの JSON を JS で定義した Book オブジェクトへと変換したりするという処理が存在することが多いと思います。 Transit を使えばこの変換する処理というのを完全に無視することが出来るようになります。何故ならほとんどのデータ型がそのまま Clojure と ClojureScript 間で同じように送受信出来るからです(実際には Ratio 型などが鬼門)。 つまり、何かしらの API を叩いたとしてそれが Transit のデータであるなら結果は何も変換せずともそのまま使えるわけですね( Clojure 側で自動的に Transit format に変換して、 ClojureScript 側で Transit format を受け取る用意をする必要はありますが)。 また Transit format はユーザー定義型の送受信を行えるように拡張することが出来ます。例えば Book のようなユーザー定義型でもカスタムライター/リーダーを噛ませておくことによってシームレスな送受信が出来るようになります。以下に例を示します。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/domain/book.cljc
(defrecord Book [id title author publisher price])

(defn new-book [id title author price publisher]
  (map->Book {:id id
              :title title
              :author author
              :price price
              :publisher publisher}))

このような Book というレコード型が存在したとして、これを解釈出来るように Transit のライター/リーダーを拡張します。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/util/transit.cljct
(ns transit-demo.util.transit
  (:require [cognitect.transit :as transit]
            [transit-demo.domain.book :refer [new-book #?(:cljs Book)]])
  #?(:clj (:import [transit_demo.domain.book Book])))

(def custom-write-handlers
  ;; Book という型に対応したライターを定義する
  {Book (transit/write-handler
         (constantly "book") ;; これがデータ型のタグ名になる
         (fn [book] ((juxt :id :title :author :price :publisher) book)))})

(def custom-read-handlers
  ;; book というタグ名を見つけたときのリーダーを定義
  {"book" (transit/read-handler
           (fn [book] (apply new-book book)))})

このようにすることで Transit format で Book 型を送受信出来るようになりました。そして、この Book 型は Reader Conditionals の機能を使って( .cljc ファイルで)定義しているため Clojure/ClojureScript のどちらからも読み取ることが出来ます。 さらに、 Transit のライター/リーダーも Book 型と同様に Reader Conditionals の機能を使って定義しているためこの拡張したライター/リーダーは Clojure/ClojureScript どちらからも使うことが出来ます。

実際に Clojure 側ではこれを次のように使います。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src/transit_demo/middleware_set.clj
(defn wrap-api-suite [handler prefix]
  (-> handler
      (if-url-start-with prefix #(-> %
                                     (transit/wrap-transit-body {:keywords? true}) ;; :opts {:handlers ut/custom-read-handlers} として渡しておけばリーダーとしてカスタムリーダーが使える
                                     (transit/wrap-transit-response {:encoding :json :opts {:handlers ut/custom-write-handlers}})))))

これは Ring ミドルウェアですが、このミドルウェアは特定の prefix を持つ URI でリクエストが来たときにそのリクエストを Transit format として解釈し、レスポンスも Transit format として返すようにしています。このときにレスポンスを返すときは Transit の拡張したライターを使うように設定しています。

次に ClojureScript 側では次のように設定しています。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljs/transit_demo/handler/cart.cljs
(defn book-search [callback]
  (GET (r/href :api/search-book)
      {:handler callback
       :response-format (transit-response-format {:handlers ut/custom-read-handlers
                                                  :raw true})}))

API を叩いてその結果は Transit format として来ることを期待しているのでこのように設定します。こうすることで ClojureScript 側で特殊な変換処理を書かなくても、すぐに Book 型のオブジェクトを扱うことが出来ます。

では実際にこのようにシームレスにデータ型を送受信出来て何が嬉しいのかというと、ロジックの共有が出来るところにあります。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/proto.cljc
(defprotocol TaxInclude
  (-tax-include-price [x]))

このように .cljc でプロトコルを定義しておきます。

;; https://github.com/ayato-p/shibuya-lisp-demo-35/blob/master/src-cljc/transit_demo/impl.cljc
(defn ceil [x]
  #?(:clj (Math/ceil x)
     :cljs (.ceil js/Math x)))

(extend-protocol p/TaxInclude
  Book
  (-tax-include-price [self]
    (ceil (* (:price self) 1.08))))

(defn tax-include-price [x]
  (p/-tax-include-price x))

実際に Book 型をプロトコルで拡張することでこの処理はサーバーサイド、フロントエンド問わずに利用することが出来ます。実際にデモ中ではこのお陰でサーバーサイドレンダリングのときのサーバー側での消費税込み額計算とフロントエンド側での消費税込み額計算を統一することに成功しています。同じユーザー定義型をどちらでも使いたいというシーンは多くはないかもしれませんが、このように実装出来るというのは他の言語にはあまり見ることが出来ない強みだと思います。

現実的には Transit format は Date 型などを簡単にプログラム間で受け渡すことが出来るので他の言語で使ったとしても充分に活躍出来ると思います。

まとめ

Reader Conditionals と Transit format の現実的な活用方法について説明しました。 Reader Conditionals は特にライブラリなどの開発で使われることが多く、実際にそのようなときにしか使い道がないと思っている人もいるかもしれませんが、 Clojure と ClojureScript を併用する場合はかなり使い勝手が良い機能だと思うので積極的に使っていくと良いのではないでしょうか。 また Transit format に関しても、 JSON でやりとりするより利便性が高いので同様に現実のアプリケーションへと使っていきましょう。