ポート
先ほどのふたつのページでは、Elmプログラムを開始するのに必要な JavaScript コードと、その初期化のときにフラグを渡す方法について見てきました。
// 初期化
var app = Elm.Main.init({
node: document.getElementById('elm')
});
// フラグを使った初期化
var app = Elm.Main.init({
node: document.getElementById('elm'),
flags: Date.now()
});
フラグを使うと JavaScript から Elm プログラムに情報を渡すことができますが、この方法が使えるのはプログラムを開始するときだけです。 プログラムの実行中に JavaScript と対話するにはどのようにしたらいいのでしょうか?
メッセージの受け渡し
Elm では、ポートを通じて Elm と JavaScript のあいだのメッセージの受け渡しを行います。HTTPで見たようなリクエスト/レスポンスの組とは異なり、メッセージはポートを通じて一方向に送信されます。まるで手紙を郵送するみたいにです。例えば、アメリカ合衆国にある銀行は事前の承諾なしに私に大量の手紙を送り、やがて幸せになれますからと私を丸め込んで銀行からお金を借りさせようとします。これらのメッセージはまったくもって一方通行です。すべての手紙は、まさにこのようになっています。私が友達に手紙を送ったとき、彼女はそれに対して返信するときもしないときもありますが、それと同じように、メッセージのやり取りにおいて常にリクエスト/レスポンスを組にしないといけないという決まりがあるわけではありません。ここで言いたいのは、Elm と JavaScript は、ポートを通じて互いに一方的に送信を行うことで、通信をすることができるということです。
外向きのメッセージ
何らかの情報のキャッシュをするために、localStorage
を使いたいとしましょう。それを実現するには、JavaScript へと情報を送出するポートを用意します。
Elm 側では、次のように port
を定義するという意味です。
port module Main exposing (..)
import Json.Encode as E
port cache : E.Value -> Cmd msg
もっとも重要な行は、port
の宣言のところです。ここでは cache
関数を作成しており、JavaScript へ Json.Encode.Value
を送信する、cache (E.int 42)
のようなコマンドを作成できるようになります。
JavaScript 側では通常のようにプログラムを初期化しますが、そのあとで外向きの cache
メッセージをすべて待ち受ける ( subscribe ) ようにします。
var app = Elm.Main.init({
node: document.getElementById('elm')
});
app.ports.cache.subscribe(function(data) {
localStorage.setItem('cache', JSON.stringify(data));
});
cache (E.int 42)
のようなコマンドは、JavaScript でこの cache
ポートに対して待ち受けている相手にその値を送信します。JavaScript コードは data
として 42
を受け取り、それを localStorage
にキャッシュします。
このように情報をキャッシュしようとするプログラムは、ほとんどの場合は次のふたつの経路を通じて通信を行います。
- 初期化時に、キャッシュ済みのデータをフラグを通じて JavaScript 側から Elm 側に渡します。
- キャッシュを更新する必要が生じれば、そのたびにデータを Elm 側から JavaScript 側へ送出します。
このため、JavaScript でこのような相互作用を行うときには、外向きのメッセージだけが存在します。また、私はこの Elm と JavaScript の境界を超えるデータを最小化することにこだわりすぎないようにしています。なるべくシンプルに保つようにして、複雑にするのは実際にどうしてもその必要があるときにだけにしてください!
Note 1: ここではポートを
setItem
関数へと結び付けているわけではありません! それはよくある間違いです。重要なのは、LocalStorage API のそれぞれの関数を一対一でポートに対応させないということです。このポートは何らかのキャッシュを行うように要求するためのものです。この JavaScript コードは LocalStorage や IndexedDB、WebSQL など、どれでも選んで使うことができます。そこでは、「JavaScriptの関数それぞれをポートにするべきだろうか?」と考えるのではなく、「JavaScriptで要求を達成するには何が必要か?」を考えるようにしてください。ここではキャッシュについて考えてきましたが、おしゃれなレストランでも同じようなことが言えます。あなたは自分が食べたいものを決めることができますが、その料理をどのようにして準備するかまで事細かに指定したりはしないでしょう。あなたの大まかなメッセージ(あなたの注文)がキッチンへと伝えられ、 その結果として具体的なメッセージ(飲み物や前菜、主菜、デザートなど)を受け取ります。うまく設計されたポートは関心をきれいに分離するというのがポイントです。JavaScript 側のキャッシュの仕組みとは無関係に Elm はビューを管理し、Elmがどのようにビューを表示しているかに関係なく JavaScript はキャッシュを管理する、というように関心を分離できるのです。Note 2: Elm の LocalStorage パッケージは今のところ存在しませんので、LocalStorage を Elm から使う方法の現時点でのお勧めは、まさにここで説明したようにポートを使うことです。Elm で直接サポートをする予定がないものだろうかと気になっている人はいますし、それを強く切望している人もいます! そのことについては、ここに書いているつもりです。
Note 3: いったん外向きのメッセージの
subscribe
を行うと、同様に待ち受けを停止するunsubscribe
もできるようになります。これはaddEventListener
やremoveEventListener
と似たような動作をし、unsubscribe
するとsubscribe
で登録した関数のうち参照が等価なものの待ちうけが停止されます。
内向きのメッセージ
JavaScript でチャットルームを作っているとして、ほんの一部でもいいのでぜひとも Elm の導入を試してみたいと思っているとしましょう。現在 Elm を使っているほとんどの企業では、まずはひとつのビューの要素だけを Elm に置き換えて試すことから始めています。これはうまくいくのでしょうか? あなたのチームはこれを気に入るでしょうか? もしうまくいったのであれば、それは素晴らしいことです。もっと他のいろんな要素についても Elm への置き換えを試してみてください。置き換えがうまくいかなかったとしても、それはたいしたことはありません。元に戻して、あなたにとって最もうまくいく技術を使ってください!
それではこのチャットルームアプリケーションについて見ていきますが、アクティブなユーザの一覧を表示する要素を Elm へと置き変えることを決意したとしましょう。これはつまり、アクティブなユーザの一覧に起こるすべての変更を、Elm は知る必要があるということです。それはポートを通じて行われるのです!
Elm 側では、次のように port
を定義するということです。
port module Main exposing (..)
import Json.Encode as E
type Msg
= Searched String
| Changed E.Value
port activeUsers : (E.Value -> msg) -> Sub msg
繰り返しになりますが、重要な行はこの port
の宣言のところです。この宣言は activeUsers
関数を作りますが、activeUsers Changed
のようにしてサブスクリプションを登録しておくと、誰かが JavaScript から値を送信するたびに Msg
を受け取ることになります。
JavaScript 側ではいつもと同じようにプログラムを初期化しますが、これで activeUsers
サブスクリプションに対していつでもメッセージを送信できるようになっています。
var activeUsers = // ここがどのように定義されていたとしても
var app = Elm.Main.init({
node: document.getElementById('elm'),
flags: activeUsers
});
// 誰かが入室したり退室したりしたら、以下を実行する
app.ports.activeUsers.send(activeUsers);
既知のアクティブユーザの情報を使ってこの Elm プログラムを開始し、アクティブユーザの一覧が変更されたときは毎回、activeUsers
ポートを通じてリスト全体を送信します。
ここで疑問に思った人もいるかもしれませんが、一覧の全体を送信するのはなぜなのでしょうか。なぜ誰かが入室したり退室したということだけを伝えるのではないのでしょうか? そのようなアプローチは一見良さそうに見えますが、実は同期エラーを作り出す危険があるのです。JavaScript は 20 のアクティブユーザがいると考えているが、Elm は 25 のアクティブユーザがいると考えている、ということがあったとしましょう。このとき、バグがあるのは Elm のコードのほうでしょうか? それともJavaScriptのほうでしょうか? ポートを通じて退室メッセージを送信するのを忘れてしまったのでしょうか? このようなバグに対処するのはとても厄介で、原因を見つけ出そうとしてついには数時間や数日といった時間を棒に振るということもあり得ます。
私ならそうしたアプローチをとるのはやめて、そのような同期エラーがそもそも起こりえないような設計を選びます。JavaScript が状態を管理します。Elmコードが行うのは、完全なリストを受け取り、それを表示することだけです。もし何らかの理由で Elm コードがリストを変更する必要があったとしても、それは不可能です! 状態を管理しているのは JavaScript なのです。その代わりに、何らかの変更を起こすようにというメッセージを JavaScript へと送信するようにします。状態は Elm か JavaScript のどちらか一方だけが管理するべきで、その両方が管理してはいけない、というのがポイントです。これは同期エラーの危険を劇的に小さくします。ポートでつまずいている人たちの多くが、誰が状態を管理するのかを明確にしないというこの罠に陥っています。油断しないでください!
補足
先ほど見てきた例について、ここで幾つか補足を付け加えておきたいと思います。
- すべての
port
はport module
の中で宣言されなくてはいけません。 ひとつのport module
の中にすべてのポートをまとめてしまうのがおそらく最善で、ひとつの場所にすべての JavaScript とのインターフェイスがあったほうがより把握しやすいでしょう。
- ポートを通じて送信するのは
Json.Decode.Value
がお勧めですが、それが送信できる唯一のデータ型というわけではありません。 フラグで見てきたように、elm/core
に含まれる型のなかにもポートを通じて渡すことができるものがあります。これは Elm に JSON デコーダーが導入される前から存在しているもので、それについて詳しくはこちらを読んでみてください。
- ポートはアプリケーションのためのものです。
port module
はアプリケーションでは使えますが、パッケージでは使えません。このことは、アプリケーションの作者が必要な柔軟性を持つ一方で、パッケージエコシステムは全体が Elm で書かれていることを保証します。長い目で見ると、これがとても強固なエコシステムとコミュニティを構築するのを助けてくれるということをこちらで述べています。
- ポートは強い境界を作成するようなものです。 必要なすべての JavaScript 関数についてポートを作成しようするのは、絶対にやめてください。あなたは本当に Elm が大好きで、どんなコストを支払ってでもすべてを Elm でやろうとしているのかも知れませんが、ポートはそのようには設計されていないのです。その代わりに、「この状態を所有しているのは誰?」というような問題に焦点を合わせて、ひとつかふたつのポートを使ってメッセージを送受信してみてください。もしあなたがもっと複雑なシナリオに直面しているのであれば、送信しようとしている情報のすべてのバリアントについてのタグを持つ、
{ tag: "active-users-changed", list: ... }
のような JavaScript を送信することで、Msg
の値をシミュレートしてみてもいいでしょう。
この情報が既存の JavaScript に Elm を埋めこむ方法を見つける助けになれば幸いです! すべてを Elm で書き直す方法ほど魅力的ではないでしょうが、一部に Elm を埋め込むほうがより効果的な戦略であるということは歴史が示しています。
余談:設計についての検討事項
言語の歴史の中でも、ポートは特別な存在です。言語間の相互作用については次のようなふたつの戦略がありましたが、Elmはこのどちらも選びませんでした。
- 完全な後方互換性。 例えば C++ は C のスーパーセットであり、TypeScript は JavaScript のスーパーセットです。これはとても寛大なアプローチであり、極めて効果的な手法であることも実証されています。スーパーセットであるということは、誰もがその言語をすでに使っているということです。
- 外部関数インターフェイス(Foreign Function Interface, FFI)。 これは母体となる言語の関数への直接のバインディングを可能にします。例えば、Scala は Java の関数を直接呼ぶことができます。Clojure/Java や Python/C、Haskell/C などの多くの言語が同様のアプローチをとっています。繰り返しになりますが、これもとても効果的であることが実証されています。
これらの方針は魅力的ですが、主に次のふたつの理由により、Elm にとってはどちらも理想的とはいえませんでした。
- 安全性の保証の欠如。 Elmの最も良いところのひとつは、さまざまな問題についてそれをまるごと心配しなくていいということです。しかし、もしどんなパッケージでも JavaScript を直接使うことができるとしたら、その安全性はすべて台無しになってしまいます。このパッケージは実行時エラーを起こすでしょうか? それはどんなとき? こちらが渡したデータをそのパッケージが変更することはあるのでしょうか? その変更を検出する必要はあるのでしょうか? そのパッケージは副作用を持つのでしょうか? サードパーティのサーバへとメッセージを送信することはあるのでしょうか? 多くのユーザが Elm に特に惹きつけられているのは、これ以上このようなことに頭を悩ませる必要がないからなのです。
- パッケージの氾濫。 JavaScript の API を Elm へ直接複製したいという要求は極めて強いです。
elm/html
が導入される前の2年のあいだ、もしそれが可能であれば、きっと誰かが jQuery のバインディングを作るだろうと確信していました。より伝統的な相互作用の設計を採用している静的型付け関数型言語では、すでにパッケージの氾濫が起こっています。私が知る限り、パッケージの氾濫は JavaScript へとコンパイルする言語に特有の現象です。たとえば Python のような言語ではこのような圧力はそれほど高くないことから、私が思うに、この欠点を生み出しているのは JavaScript のその独自の文化やエコシステムの歴史ではないでしょうか。これらの落とし穴を考えると、Elm の最も良い部分を維持しつつ、JavaScript でやってきたことも可能にするポートは、とても魅力的ではないかと思います。素晴らしいでしょう! その一方で Elmは、JavaScript のエコシステムを取り込むことで、より迅速にライブラリを増やすというようなことはできません。長い目で見たとしたら、このことは Elm の強さの要になるのではないかと考えています。結果として次のことが言えます。
- パッケージは Elm のために設計されます。 Elm コミュニティのメンバーがより経験を積んで信頼できるようになったので、The Elm Architecture とエコシステム全体をシームレスに動作させる、レイアウトとデータ可視化への新しいアプローチが散見されるようになってきています。このほかの様々な種類の問題についても、このような革新が起こり続けるように期待しています!
- パッケージはポータブルです。 もしいつかコンパイラが x86 や WebAssembly を出力するようになったとしたら、エコシステム全体は引き続き動作し続けますが、実行速度は速くなります! ポートの仕組みはすべてのパッケージが全体を Elm で書かれていることを保証しており、JavaScript 以外のコンパイラターゲットも実現可能であるように Elm は設計されているのです。
これが長く険しい道のりになることは間違いありませんが、プログラミング言語は30年以上に渡って使われ続けるものです。数十年のあいだチームや企業をサポートする必要があるので、20年から30年先に Elm がどうなっているのかを見据えて考えたとき、このポートによるトレードオフはとても期待できるのではないかと思っています! 私の講演、What is Success?はちょっとスロースタートですが、この事についてより深く踏み込んでいます。