スキップしてメイン コンテンツに移動

OCaml で Web フロントエンドを書く

要旨

フロントエンド開発に 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_domLexiFi/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 ファンクタで、この例の場合はグローバル変数 ReactReactDOM からオブジェクトを取得してラッパーを作成する。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 で得られたモジュール Wrapperrender 関数が文字通りラッパーになっている。

これで状態の更新の仕方と、状態を与えられたときの 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 とパフォーマンスに課題はあるが動いているし、これを以ってフロントエンド・プログラマを名乗っても多分良かろうと思う。

コメント

このブログの人気の投稿

開発環境の構築に asdf が便利なので anyenv から移行した

プロジェクト毎に異なるバージョンの言語処理系やツールを管理するために、pyenv や nodenv など *env の利用はほとんど必須となっている。 これらはほとんど一貫したコマンド体系を提供しており、同じ要領で様々な環境構築ができる非常に便利なソフトウェアだが、それを使うことで別の問題が出てくる: *env 自身の管理である。 無数の *env をインストールし、シェルを設定し、場合によりプラグインを導入し、アップデートに追従するのは非常に面倒な作業だ。 幸いなことにこれをワンストップで解決してくれるソリューションとして anyenv がある。これは各種 *env のパッケージマネージャというべきもので、一度 anyenv をインストールすれば複数の *env を簡単にインストールして利用できる。さらに anyenv-update プラグインを導入すればアップデートまでコマンド一発で完了する。素晴らしい。 そういうわけでもう長いこと anyenv を使ってきた。それで十分だった。 ——のだが、 ここにもう一つ、対抗馬となるツールがある。 asdf である。anyenv に対する asdf の優位性は大きく2つある: 一貫性と多様性だ。 一貫性 “Manage multiple runtime versions with a single CLI tool” という触れ込み通り、asdf は様々な言語やツールの管理について一貫したインタフェースを提供している。対して anyenv は *env をインストールするのみで、各 *env はそれぞれ個別のインタフェースを持っている。 基本的なコマンド体系は元祖である rbenv から大きく外れないにしても、例えば jenv のように単体で処理系を導入する機能を持たないものもある。それらの差異はユーザが把握し対応する必要がある。 多様性 asdf はプラグインシステムを持っている。というより asdf 本体はインタフェースを規定するだけで、環境構築の実務はすべてプラグイン任せである。 そのプラグインの数は本稿を書いている時点でおよそ 300 を数える。これは言語処理系ばかりでなく jq などのユーティリティや MySQL のようなミドルウェアも含むが、いずれにしても膨大なツールが asdf を使えば

Perl 7 より先に Perl 5.34 が出るぞという話

Perl 5 の次期バージョンとして一部後方互換でない変更 (主に間接オブジェクト記法の削除とベストプラクティスのデフォルトでの有効化) を含んだメジャーバージョンアップである Perl 7 がアナウンスされたのは昨年の 6 月 のことだったが、その前に Perl 5 の次期周期リリースである Perl 5.34 が 5 月にリリース予定 である。 現在開発版は Perl 5.33.8 がリリースされておりユーザから見える変更は凍結、4 月下旬の 5.33.9 で全コードが凍結され 5 月下旬に 5.34.0 としてリリース予定とのこと。 そういうわけで事前に新機能の予習をしておく。 8進数数値リテラルの新構文 見た瞬間「マジかよ」と口に出た。これまで Perl はプレフィクス 0 がついた数値リテラルを8進数と見做してきたが、プレフィクスに 0o (zero, small o) も使えるようになる。 もちろんこれは2進数リテラルの 0b や 16進数リテラルの 0x との一貫性のためである。リテラルと同じ解釈で文字列を数値に変換する組み込み関数 oct も` 新構文を解するようになる。 昨今無数の言語に取り入れられているリテラル記法ではあるが、この記法の問題は o (small o) と 0 (zero) の区別が難しいことで、より悪いことに大文字も合法である: 0O755 Try / Catch 構文 Perl 5 のリリース以来 30 年ほど待たれた実験的「新機能」である。 Perl 5 における例外処理が特別な構文でなかったのは予約語を増やさない配慮だったはずだが、TryCatch とか Try::Tiny のようなモジュールが氾濫して当初の意図が無意味になったというのもあるかも知れない。 use feature qw/ try / ; no warnings qw/ experimental::try / ; try { failable_operation(); } catch ( $e ) { recover_from_error( $e ); } Raku (former Perl 6) だと CATCH (大文字なことに注意) ブロックが自分の宣言されたスコープ内で投げられた例外を捕らえる

BuckleScript が ReScript に改称し独自言語を導入した

Via: BuckleScript Good and Bad News - Psellos OCaml / ReasonML 文法と標準ライブラリを採用した JavaScript トランスパイラである BuckleScript が ReScript に改称した。 公式サイトによると改称の理由は、 Unifying the tools in one coherent platform and core team allows us to build features that wouldn’t be possible in the original BuckleScript + Reason setup. (単一のプラットフォームとコアチームにツールを統合することで従来の BuckleScript + Reason 体制では不可能であった機能開発が可能になる) とのこと。要は Facebook が主導する外部プロジェクトである ReasonML に依存せずに開発を進めていくためにフォークするという話で、Chromium のレンダリングエンジンが Apple の WebKit から Google 主導の Blink に切り替わったのと似た動機である (プログラミング言語の分野でも Object Pascal が Pascal を逸脱して Delphi Language になったとか PLT Scheme (の第一言語) が RnRS とは別路線に舵を切って Racket になったとか、割とよくある話である。) 公式ブログの Q&A によると OCaml / ReasonML 文法のサポートは継続され、既存の BuckleScript プロジェクトは問題なくビルドできるとのこと。ただし現時点で公式ドキュメントは ReScript 文法のみに言及しているなど、サポート水準のティアを分けて ReScript 文法を優遇することで移行を推進していく方針である。 上流である OCaml の更新は取り込み、AST の互換性も維持される。将来 ReScript から言語機能が削除されることは有り得るが、OCaml / ReasonML からは今日の BuckleScript が提供する機能すべてにアクセスできる。 現時点における ReScript の

macOS で GUI 版 Emacs を使う設定

macOS であっても端末エミュレータ上で CLI 版 Emacs を使っているプログラマは多いと思うが、端末側に修飾キーを取られたり東アジア文字の文字幅判定が狂ってウィンドウ描画が崩れたりなどしてあまり良いことがない。 それなら GUI 版の Emacs.app を使った方がマウスも使える上に treemacs などはアイコンも表示されてリッチな UI になる。 しかし何事も完璧とはいかないもので、CLI だと問題なかったものが GUI だと面倒になることがある。その最大の原因はシェルの子プロセスではないという点である。つまり macOS の GUI アプリケーションは launchd が起動しその環境変数やワーキングディレクトリを引き継ぐので、ファイルを開こうとしたらホームディレクトリ ( ~/ ) でなくルートディレクトリ ( / ) を見に行くし、ホームディレクトリなり /opt/local なりに好き勝手にインストールしたツールを run-* 関数やら shell やら flycheck やらで実行しようとしてもパスが通っていない。 ワーキングディレクトリに関しては簡単な解決策があり、 default-directory という変数をホームディレクトリに設定すれば良い。ただし起動時にスプラッシュスクリーンを表示する設定の場合、このバッファのワーキングディレクトリは command-line-default-directory で設定されており、デフォルト値が解決される前に適用されてしまうので併せて明示的に初期化する必要がある: (setq default-directory "~/") (setq command-line-default-directory "~/") 次にパスの問題だが、まさにこの問題を解決するために exec-path-from-shell というパッケージがある。これを使うとユーザのシェル設定を推定し、ログインシェルとして起動した場合の環境変数 PATH と MANPATH を取得して Emacs 上で同じ値を setenv する、という処理をやってくれる。MELPA にあるので package-install するだけで使えるようになる。 このパッケージは GUI

C の時間操作関数は tm 構造体の BSD 拡張を無視するという話

久しぶりに C++ (as better C) で真面目なプログラムを書いていて引っかかったので備忘録。 「拡張なんだから標準関数の挙動に影響するわけねえだろ」という常識人は読む必要はない。 要旨 time_t の表現は環境依存 サポートしている時刻は UTC とプロセスグローバルなシステム時刻 (local time) のみで、任意のタイムゾーン間の時刻変換を行う標準的な方法はない BSD / GNU libc は tm 構造体にタイムゾーン情報を含むが、tm -> time_t の変換 ( timegm / mktime ) においてその情報は無視される 事前知識 C 標準ライブラリにおいて時刻の操作に関係するものは time.h (C++ では ctime) ヘッダに定義されている。ここで時刻を表現するデータ型は2つある: time_t と tm である。time_t が第一義的な型であり、それを人間が扱い易いように分解した副次的な構造体が tm という関係になっている。なので標準ライブラリには現在時刻を time_t として取得する関数 ( time_t time(time_t *) ) が先ずあり、そこから time_t と tm を相互に変換する関数が定義されている。 ここで time_t の定義は処理系依存である。C / C++ 標準はそれが算術型であることを求めているのみで (C11 からは実数型に厳格化された)、その実体は任意である。POSIX においては UNIX epoch (1970-01-01T00:00:00Z) からのうるう秒を除いた経過秒数であることが保証されており Linux や BSD の子孫も同様だが、この事実に依存するのは移植性のある方法ではない。 一方で tm は構造体であり、最低限必要なデータメンバが規定されている: int tm_year : 1900 年からの年数 int tm_mon : 月 (0-based; 即ち [0, 11]) int tm_mday : 月初からの日数 (1-based) int tm_hour : 時 (Military clock; 即ち [0, 23]) int tm_min : 分 int tm_sec : 秒 (うるう秒を含み得るので [0