要旨
フロントエンド開発に Elm は堅くて速くてとても良いと思う。昨今の Flux 系アーキテクチャは代数的データ型と相性が良い。ところで工数を減らすためにはバックエンドも同じ言語で書いてあわよくば isomorphic にしてしまいたいところだが、Elm はバックエンドを書くには現状適していない。
OCaml なら js_of_ocaml でエコシステムを丸ごとブラウザに持って来れるのでフロントエンドもバックエンドも無理なく書けるはずである。まず The Elm Architecture を OCaml で実践できるようにするため Caelm というライブラリを書いている。俺の野望はまだまだこれからだ (未完)
Elm と TEA について
Elm というプログラミング言語がある。いわゆる AltJS の一つである。 ミニマリスティクな ML 系の関数言語で、型推論を持ち、型クラスを持たず、例外機構を持たず、変数の再代入を許さず、正格評価され、代数的データ型を持つ。 言語も小綺麗で良いのだが、何より付属のコアライブラリが体現する The Elm Architecture (TEA) が重要である。
TEA は端的に言えば Flux フロントエンド・アーキテクチャの変種である。同じく Flux の派生である Redux の README に TEA の影響を受けたと書いてあるので知っている人もいるだろう。 ビューなどから非同期に送信される Message (Redux だと Action) を受けて状態 (Model; Redux だと State) を更新すると、それに対応して Virtual DOM が再構築されビューがよしなに再描画され人生を書き換える者もいた——という一方向の流れはいずれにせよ同じである。 差異はオブジェクトではなく関数で構成されていることと、アプリケーション外部との入出力は非同期メッセージである Cmd / Sub を返す規約になっていることくらいだろうか。
後者は面白い特徴で、副作用のある処理はアプリケーションの外で起きて結果だけが Message として非同期に飛んでくるので、内部は純粋に保たれる。つまり Elm アプリケーションが相手にしないといけない入力は今現在のアプリケーションの完全な状態である Model と、時系列イベントである Message の二種類の不変データ型のみである。
もちろんこれは Redux も (ミドルウェアを使えば非同期処理も含めて) 同様だが、このアーキテクチャでアプリケーションが大規模化していくと Message の種類数は十から千程度のオーダに増加していく。状態更新関数 (update; Redux だと Reducer) はそのすべてに応じて Model を更新しなければならない。対応に抜けがあったり Message のペイロードに存在しない値を参照したりすれば即ち実行時エラーである。 JavaScript で書いた状態更新関数が実行時エラーを起こさないためには、プログラマの非常に精緻な注意力か網羅的なテストが必要になる。 一方 Elm には代数的データ型があるので Message を Union Type として定義することができる。コンパイラは状態更新関数の網羅性を静的に検証し、プログラマに決して足を撃たせない。
公平のために言うと、TypeScript であれば同じく Union Type を持っているので少々冗長だが Discriminated Unions として定義できる。確認していないが FlowType の型アノテーションでも恐らく同じことができるだろう。 じゃあ TypeScript で良いかというと、構文の親しみ易さや型推論の貧弱さ (ポジティヴに言い換えるとコードのドキュメント性の高さ)、言語的には可能だが Redux が規約で禁じている事項を守るコストといった要素のトレードオフである。逆に言うと Elm は TEA から逸脱できないように出来ている。
ともかく、代数的データ型と不変データ構造を持った言語は Flux 系のフロントエンド・アーキテクチャと相性が良いと言えるだろう。
Isomorphic
Isomorphic JavaScript というバズワードがある。要するにフロントエンドもバックエンドも同じアプリケーション・ソースコードを共有しましょうという提唱だと理解しているが、これはサーバサイドレンダリングの利点に加えて、フォームの検証器や API レスポンスのデータ構造など明らかに共用できる実装の二度手間や齟齬をなくすことができるから単純で良い考えである。 特に趣味やなんかで一人でアプリケーション全体を書くなら工数は減らしたいところだろう。
問題は "Isomorphic" ではなく "JavaScript" の方で、それが Web ブラウザの Lingua Franca である以上 JavaScript あるいは AltJS でしか実践できないのは仕方がないのだが、サーバサイドでは JavaScript は選択肢の一つに過ぎないという点である。node.js のイベント駆動モデルが C10K 問題の解決策としてもてはやされた時期はあるが、これはプラットフォームの特性であって言語の強みとは言い難いし、今となってはイベントループのライブラリはどの言語にもある。
先述の通り、フロントエンドを今時のアーキテクチャで書くなら静的型付で代数的データ型を持つ関数言語を選びたい。これらの特徴はバックエンドでも品質と開発効率に寄与するだろう。
OCaml と js_of_ocaml について
OCaml は ML の方言である。Standard ML との差は日本語族における琉球語と吉里吉里語くらいの違いである。
なんだかんだ ML なのであまり小難しいこともなく、hello world するのに IO モナドは要らないし式の評価戦略は正格だし可変データ構造もあるし Pascal 式の for ループもある。怖くない。 関数言語の中では比較的メジャで、何故か Facebook とか金融業界で産業利用されているので開発環境も一通り整備されており、パッケージマネージャとか REPL とかライブラリ・ブラウザとか Emacs の編集モードとかいったものがある。 OPAM の中央レポジトリには約 1,700 個のパッケージが登録されている。Hackage は約 11,500 個、NPM は公称 475,000 個なのでパッケージの粒度はさておき数では大分見劣りするが、rizo/awesome-ocaml を見ると RDBMS や Redis へのバインディングやら Web アプリケーション・フレームワークやら JSON コーデックやら HTML ライタやら HTTP サーバ及びクライアントやら、Web 開発に欲しいものは案外揃っている。
Isomorphic という目論見にとって特に重要なのは js_of_ocaml (jsoo) というコンパイラの存在である。これはその名の通り JavaScript コードを出力するのだが、入力は OCaml ソースコードではない。コンパイル済の OCaml バイトコードである。 これは他の AltJS との大きな違いで、例えば BuckleScript (bs) は OCaml / Reason ソースコードから人間可読な JavaScript を生成するトランスパイラだが、NPM のエコシステムに乗っているので OPAM でインストールしたパッケージは使えない。もちろんソースコードを持ってきてトランスパイルすれば使えるが、普通の OCaml ライブラリは bs 用のビルド設定を用意していないので自分でビルドチェインを作る必要がある。 一方 jsoo なら OCaml のビルドチェインで作成したバイトコードをそのまま JavaScript にコンパイルできる。要するにビルドチェインの最後段に非侵襲的に追加することができるので、バックエンド用にビルドしたライブラリをそのままフロントエンドに流用できる。
念の為に言っておくと、OPAM ではなく NPM ライブラリの利用やビルドチェインとの統合なら bs の方がずっと簡単にできる。OPAM と NPM どちらのエコシステムに乗るかによるトレードオフである。
Caelm
というわけで本題。Caelm は名前の通り OCaml で TEA を実践するために開発中のライブラリである。
実際にこのライブラリで書かれたアプリケーションはデモを参照のこと (デモのリポジトリも。) UI の描画を含めて250行程度の規模である。
実際のところ OCaml で Virtual DOM の操作とか Flux 系のフレームワークを書こうという発想は新しくはない。 少なくとも janestreet/virtual_dom と LexiFi/ocaml-vdom の2つのライブラリがある。前者は janestreet/incr_dom というライブラリの Virtual DOM として使われており、後者はそれ自身が TEA の実装を持っている。
virtual_dom は異様に速いことで有名な Matt-Esch/virtual-dom のバインディングで、以前は Elm もこれを使っていた。 ocaml-vdom は Virtual DOM も自前で実装している。JavaScript バインディングの書き方に互換性がない js_of_ocaml と BuckleScript の両方で使えるようにしようという目論見があるようだ。
一方で Caelm は React.js を使っている。これの利点はそれが事実上の標準であるという一点である。開発が止まって久しい virtual-dom よりも圧倒的にライブラリやツールのサポートが充実している。 React Component として公開されているコンポーネントは無数にあるので、これらを OCaml で書いたアプリケーションで利用できれば素晴らしい (現時点ではまだできないが、そんなに手間はないと思う。) またイベントの処理が簡単なのも React.js の利点である。
状態管理には FRP を使っている。余談だがこのための OCaml ライブラリの名前も React なので大変にややこしい。 Elm は FRP からアクタ・モデルに移行したというので、最初は協調スレッド (aka. ファイバ) である Lwt を使って同様の実装を試みたのだが、jsoo が bind による末尾再帰をループに展開できないので取り止めた。別に jsoo の最適化がタコなわけではない。ホスト環境が JavaScript なので実行時に解決される関数呼出をジャンプに置き換えるのはどうやっても難しい。実際 Elm の実装を見ても適当な回数処理したら残りをスタックに積んで一度再帰から抜けてやり直す、というような涙ぐましい努力をしている。 アプリケーションの立場からは TEA が破綻しないならランタイムの実装がどうなっていても構わないので、FRP でも問題はないだろう。
使い方
アーキテクチャはそのまんま TEA だが、OCaml なのでファンクタを使った API になっている。状態とビューをそれぞれモジュールとして定義し、ファンクタに作用させることでアプリケーションのモジュールが得られる。
例としてよくあるカウンターを作ってみよう。
まずは状態と可能な操作と更新関数を定義する。状態は現在のカウントを示す int で、可能な操作はカウンタの増加と減少で良いだろう:
module State = struct
type t = int
type message = Incr | Decr
type command = message Lwt.t
let initial = 0
let update count = function
| Incr -> count + 1, None
| Decr -> count - 1, None
end
これだけ。command 型は使っていないが宣言は必要である。update の結果の型は t * command option なので、第二要素に None
をつけている。 initial
は初期状態である。必須ではないが初期値が決まっているならこのモジュールに定義しておくのが良い習慣である。
次に UI の描画を行うビューを定義する。このモジュールに必要なものは State.t を受けて描画結果の物理 DOM のルート要素を返す render
関数だけである:
module View = struct
module Reactjs = Caelm.Reactjs.Make (
struct
let scope = Js.Unsafe.global
let var_name = function
| `React -> "React"
| `ReactDOM -> "ReactDOM"
end)
module Tyxml = Caelm_reactjs_tyxml.Make (Reactjs)
module Wrapper = Caelm.Reactjs_wrapper.Make (Reactjs)
open Tyxml.Html
let render ~send ~container count =
div [ button ~a:[ a_onclick @@ fun _ -> send State.Incr ] [ pcdata "^" ]
; pcdata @@ string_of_int count
; button ~a:[ a_onclick @@ fun _ -> send State.Decr ] [ pcdata "v" ]
]
|> to_react_element
|> Wrapper.render ~container
end
必要なのは render
関数だけだと言ったな。あれは嘘だ。
いや嘘ではないが、JavaScript ライブラリである React.js とのバインディングなのでセットアップはそれなりに面倒である。
まず JavaScript の React / ReactDOM オブジェクトをどこから参照するか指定する。その設定をやっているのが Caelm.Reactjs.Make
ファンクタで、この例の場合はグローバル変数 React
と ReactDOM
からオブジェクトを取得してラッパーを作成する。script 要素などで React / ReactDOM を事前にロードしておくのはプログラマの責任である。 面倒なら require
関数を使う Caelm.Reactjs.Make_with_require
もあるのでこちらを使っても良い。(script 要素を書く手間が Browserify でバンドルする手間になるだけだが。)
こうして得た Reactjs モジュールは非常に低レベルで型制約が効かないので、もう一枚抽象を被せる。Caelm_reactjs_tyxml.Make
は Virutal DOM を構築する型安全なコンビネータを持つモジュール Tyxml
を作っている。render
関数内で使っている div
とか button
とかである。これらを使って得た値を to_react_element
関数に渡すと React の Virtual DOM に変換される。 2つのボタンには onClick イベントハンドラが設定されている。イベントハンドラは状態を更新するために send
で Message を投げている。ちなみにイベント発火時に渡される引数は React.js の Synthetic Event だが、使っていないので _
で潰している。
Virtual DOM が出来たら残るは実際の描画、つまり ReactDOM.render
の呼び出しである。Caelm.Reactjs_wrapper.Make
で得られたモジュール Wrapper
の render
関数が文字通りラッパーになっている。
これで状態の更新の仕方と、状態を与えられたときの UI の描画方法が定義できた。 これらを使ってアプリケーションの状態管理を行うモジュールを生成する:
module App = Caelm_lwt.Make (State) (View)
App は渡された状態とビューを使うように設定された状態管理ランタイムである。
中身を気にする必要は特にない。描画するべき DOM 要素と初期状態を指定して App.run
を呼ぶと実行が開始される:
let () =
let container = Dom_html.getElementById "app-root" in
let app = App.run ~container State.init in
Js.export "terminateApp" @@ Js.wrap_callback (fun () -> App.terminate app)
これで "app-root" という id を持つ要素の中にアプリケーションの UI を描画して実行が始まる。 また JavaScript の関数としてエクスポートされた terminateApp
をブラウザの開発者コンソールから呼び出すとアプリケーションは応答を止める。
課題
現在のところ大きな課題は二点ある。Cmd と Sub が抽象化し切れていないことと、Virtual DOM 再構築の非効率である。
Elm の場合 Cmd と Sub は Effect Manager という副作用を扱うシングルトンに処理を登録したときに貰えるトークンのようなもので、処理が終わって Message を投げたり失敗した後始末なんかも Effect Manager が行う。
Caelm の場合 Effect Manager の実装をサボっていて、現在のところ Cmd は (通常は Lwt の) スレッド、Sub は React の Event そのものである。 Cmd は有限時間内に何らかの Message を返す必要がある。これはまだ実装でなんとでもなるが、Sub は TEA 的に好ましくない。Event は状態を持つ値だからだ。 つまり解決するには API を変えないといけないので、現時点で Sub を使うのは止めておいた方が良い。
Virtual DOM の非効率については簡単で、再描画時に毎回 Virtual DOM 全体を再構築しているので無駄があるということである。 正直なところ素直に FRP していれば済む話で、実際前述の incr_dom などは (FRP ではなくて SAC 即ち自己適応計算だが) Incr.t でラップされた値を持ち回る API になっているのだが、Sub と同じで状態を持った値が API に露出するのは TEA の観点から良くないので悩ましい。
まとめ
Web フロントエンドに関数言語を適用する機運が見えてきた。これに乗じて Isomorphic OCaml の野望を達成すべく、その取っかかりとして作成している Caelm ライブラリを紹介した。 まだ API とパフォーマンスに課題はあるが動いているし、これを以ってフロントエンド・プログラマを名乗っても多分良かろうと思う。
コメント
コメントを投稿