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

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

コメント

このブログの人気の投稿

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 (大文字なことに注意) ブロックが自分の宣言されたスコープ内で投げられた例外を捕らえる...

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 ...

開発環境の構築に 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 を使えば...

(multi-)term-mode に dirtrack させる zsh の設定

TL;DR .zshrc に以下を書けば良い: # Enable dirtrack on (multi-)term-mode. if [[ " $TERM " = eterm * ]]; then chpwd() { printf '\032/%s\n' " $PWD " } fi 追記 (May 14, 2025): oh-my-zsh を使っていれば emacs プラグインが勝手にやってくれる: plugins = ( emacs ) 仔細 term-mode は Emacs 本体に付属する端末エミュレータである。基本的には Emacs 内でシェルを起動するために使うもので、古い shell-mode よりも端末に近い動きをするので便利なのだが、一つ問題がある。シェル内でディレクトリを移動しても Emacs バッファの PWD がそのままでは追従しない点だ。 こういう追従を Emacs では Directory Tracking (dirtrack) と呼んだりするが、 shell-mode や eshell ではデフォルトで提供しているのに term-mode だけそうではない。 要するにシェル内で cd してもバッファの PWD は開いた時点のもの (基本的には直前にアクティヴだったバッファの PWD を継承する) のままなので、移動したつもりで C-x C-f などをするとパスが違ってアレっとなることになる。 実は term-mode にも dirtrack 機能自体は存在しているのだが、これは シェルがディレクトリ移動を伴うコマンドを実行したときに特定のエスケープシーケンスを含んだ行を印字することで Emacs 側に通知するという仕組み になっている。 Emacs と同じく GNU プロジェクトの成果物である bash は Emacs 内での動作を検出すると自動的にこのような挙動を取るが、zsh は Emacs の事情なんか知ったことではないので手動で設定する必要がある。 まずもって「ディレクトリ移動のコマンドをフックする」必要がある訳だが、zsh の場合これは簡単で cd / pushd / popd のようなディレクトリ...

去る6月に Perl 5.32.0 がリリースされたので差分を把握するために perldelta を読んだ件

要旨 Perl 5 メジャーバージョンアップの季節がやって来たのでまともな Perl プログラマの嗜みとして perldelta を読んだ。 今回は有り体に言えばルーティン的なリリースで、言語コアの拡張は他言語にも見られる構文が実験的に入ったくらいで大きな変化はない。新機能は RegExp の拡充が主である。 比較的重要と思われる変更点を抜粋する。 新機能 isa 演算子 実験的機能。Python とか Java における isinstance とか instanceof 。 これまでも UNIVERSAL::isa があったが、これはメソッドなのでレシーバにオブジェクトでもクラスでもない値 (i.e., 未定義値 / bless されていないリファレンス) を置くと実行時エラーが起きるのが問題だった: package Foo { use Moo; } package Bar { use Moo; extends ' Foo ' ; } package Baz { use Moo; } use feature qw/ say / ; sub do_something_with_foo_or_return_undef { my ( $foo ) = @_ ; # Returns safely if the argument isn't an expected instance, in mind. return unless $foo -> isa ( ' Foo ' ); ...; } # OK. do_something_with_foo(Bar->new); # |undef| is expected in mind, but actually error will be thrown. do_something_with_foo( undef ); これを避けるために今までは Scalar::Util::blessed を併用したりしていたわけだが、 isa 演算子は左辺が何であっても意味のある値を返すのでよりシンプルになる: # True +( bless +{} ...