この記事は Elm Advent Calendar 2015の14日目の記事として書かれました。
tl;dr
Elmからコールバックを要求するJavaScript APIモジュールの実装方法について検討し、
そのProof of ConceptとしてWebSocketのモジュールを作った。
ついでにElectronのipcRenderer APIを叩く際の例も記述した。
目標
世に数多あるコールバックを要求するJavascript APIを
ElmのNative moduleでラップしてElmの世界で自由自在に使いたい。
対象読者
前提知識として基本的なElm Architectureについて理解してる人が対象読者です。
Nativeモジュールの書き方を理解していると尚いいです。
対象バージョン
Elm Platform 0.16.0
免責
ここで紹介している方法はelm-lang/core
のソースを読み解いたりしながら独自研究した方法なので
将来においてずっと使えるかはわかりません。
コールバックを要求するJavaScriptAPIの例
NativeなWebSocket
var ws = new WebSocket("ws://localhost:8000/");
ws.onopen = function() {
...
};
ws.onmessage = function(e) {
...
};
ws.onerror = function(e) {
...
};
ws.onclose = function() {
...
};
問題
いつものように Native moduleを書いていこうとすると、
**どうやってJSのコールバックにElmの処理を渡せばいいのか?**という疑問に当たる。
例えばElmの関数をJSの世界で実行する関数は実はruntimeの中に用意されてる。
これを使うとなると、
- 初期化時にコールバックで実行するためのElmの関数を渡し、
- それを
A2()
など使ってラップしたJSの関数をコールバックとして渡す
というような手順を踏む事になると思うが、以下のような幾つかの問題がある
- 初期化時に様々なコールバックとそれに食べさせる為のJSON Decoder(後述)を喰わせる事になり、引数が多くなり使いづらいモジュール(関数)になってしまう
- コールバックに渡された値を再びElmの世界に持ってこれない
そこで、今回はこれから記述するようにコールバック関数にElmの処理を渡し、
Signalを通知させる方法で実質的なコールバックを実現する。
コールバックで渡される値をSignalで通知する
以下、今回作成したWebSocket用モジュールを例に見ていくが、
ソースコードはそんなに長くないのでNativeモジュール書いた事のある人は
見るだけで理解できるかもしれない。
以下にポイントを挙げる。
Native.Signalの準備
ElmのNative moduleにはSignalを作る為のJSの関数が用意されており、
Native
のSignal
モジュールをmake
する事により使用可能になる。
Elm.Native.WebSocket = {};
Elm.Native.WebSocket.make = function(localRuntime) {
localRuntime.Native = localRuntime.Native || {};
localRuntime.Native.WebSocket = localRuntime.Native.WebSocket || {};
if (localRuntime.Native.WebSocket.values)
return localRuntime.Native.WebSocket.values;
var NS = Elm.Native.Signal.make(localRuntime);
var Utils = Elm.Native.Utils.make(localRuntime);
ここでvar NS = Elm.Native.Signal.make(localRuntime);
の行で
NS
というNative.Signal
オブジェクトをmakeしている。
ついでにUtils
というモジュールもmakeしている。
Signal.Inputの作成
Signal.InputはInputとなるSignal型を作成する為の関数となる
NS.input
の正確な使い方は、ドキュメンテーションされていないが、
elm-lang/core/src/Native/Mouse.jsあたりをみると類推する事が出来る。要は
- 第1引数にそのSignalの名前となる文字列
- 第2引数にデフォルトで返す値
を渡せばいいらしい。
値が必要なく、通知だけしたい場合は()
を値として指定する。
()
はJSの世界ではUtils.Tuple0
を渡せばいい。
NS.input('WebSocket.onopen', Utils.Tuple0)
以上のように呼び出す事により、Elmの世界で使用可能なSignal ()
型のオブジェクトを生成できる。
正確にはWebSocket.elm内で型アノテーションしてやる必要がある。
onopen : Socket -> Signal ()
onopen = Native.WebSocket.onopen
値が必要な場合は以下のようになる。
NS.input('WebSocket.onmessage', "")
文字列型のデフォルトとして空文字列をデフォルトで宣言している。
これをelmの関数から使う際は
socket = WebSocket.socket
signals = Signal.mergeMany [ actions.signal
, Signal.map DecodeMessage <| WebSocket.onmessage socket
, Signal.map DecodeError <| WebSocket.onerror socket
]
のように、Elm ArchitectureのSignal Actions
にSignal.map
を用いて変換した上で
mergeMany
でマージする。
コールバックの登録
Signal.Inputで作成したオブジェクトをmergeManyしただけでは、
いつまで待ってもSignalは飛んで来ない。
Signalを飛ばす為にはlocalRuntime.notify
を使う。引数は、
- 第1引数に
Signal.Input
で作ったオブジェクトのid
を渡し、 - 第2引数に
Signal
と共に渡したい値を渡す
そして、localRuntime.notify
をラップした関数をコールバックとして渡す。
socket.conn.onopen = function(e) {
localRuntime.notify(socket.onopen.id, Utils.Tuple0);
};
以上で、コールバックを要求するAPIのJS側の実装は一通り出来る。
使用法
このように書かれたモジュールがどのような使用感になるかは
https://github.com/yasuyuky/elm-websocket/blob/master/example.elm
で確認して欲しい。
以下は余談。
JSの世界とのインタラクションの為のJson.Decode
/Json.Encode
elmはcoreにJson.Decode
/Json.Encode
モジュールを備えているが、
これは単純にJSON形式の文字列を解釈するためというよりは、
HTML/JS世界のオブジェクトとElmの世界のデータを相互に変換するための機構という
意味合いが強いような気がする。
つまり、JSのAPIでうけとるJSの値をJson.DecodeでElmの世界に持ってくる
もしくはその逆をする為にcoreにわざわざ入れているのだと思う。
実際にevancz/elm-htmlのon関数とを見てみたりすると、
Json.Decoder
を引数としてとるような関数の作り方をしている。
今回、WebSocketの実装においてはonmessage
はSignal String
型となっており、
JSONのデコードは呼び出し手に任せているが、
elm側で定義したdecoderを初期化時にJS側に引数として渡し、
API側でデコードさせる作りも考えられるかと思う。
(と、言うか型の事とか考えるとそっちのほうが良さそう。
ただ現在のところElmのString型に関してはJSの文字列と
互換という事もあり問題になってない)
ElectronのIPCの例
みんな大好きElectronでは、Desktopアプリの為の多様なAPIが用意されている。
ElmでElectronのアプリを作る方法については以前紹介したが、
そこで紹介した方法ではElectronで動かすプロセス
- MainProcess(BrowserProcess)
- RendererProcess
のうち、RendererProcessの部分をElmで実装するものだった。
しかし、RendererProcessで使えるElectron APIは限られており、
Electronのポテンシャルを引き出すにはMainProcessとの通信が必須となる。
そこで使われるのがipcRenderer APIだが、
ipcRenderer APIはコールバックを前提としており、
WebSocketのライブラリと同様に実装できる。
以下にElectron ipcRenderer APIの実装例を示す。
Elm.Native.Ipc = {};
const ipcRenderer = require('electron').ipcRenderer;
Elm.Native.Ipc.make = function(localRuntime) {
localRuntime.Native = localRuntime.Native || {};
localRuntime.Native.Ipc = localRuntime.Native.Ipc || {};
if (localRuntime.Native.Ipc.values) return localRuntime.Native.Ipc.values;
var NS = Elm.Native.Signal.make(localRuntime);
var Utils = Elm.Native.Utils.make(localRuntime);
function ipc(c,encoder,decoder) {
var ipc = {
channel: c,
on: NS.input('Ipc.on', Utils.Tuple0),
encoder: encoder,
decoder: decoder,
};
ipcRenderer.on(c, function(e, v){
localRuntime.notify(ipc.on.id, ipc.decoder(v));
})
return ipc
}
function send(ipc,arg) {
var ret = ipcRenderer.send(ipc.channel,ipc.encoder(arg));
console.log(ret);
return Utils.Tuple0
}
function sendSync(ipc,arg) {
var ret = ipcRenderer.send(ipc.channel, ipc.encoder(arg));
return ret
}
function on(ipc) {
return ipc.on
}
return localRuntime.Native.Ipc.values = { // Export
ipc: F3(ipc),
send: F2(send),
sendSync: F2(sendSync),
on: on
};
};
module Ipc where
import Native.Ipc
import Json.Encode
import Json.Decode
import Task
{-| Ipc struct -}
type Ipc = Ipc
{-| ipc constructor -}
ipc : String -> (a -> Json.Encode.Value) -> Json.Decode.Decoder b -> Ipc
ipc channel enc dec = Native.Ipc.ipc channel enc dec
{-| send ipc async -}
send : a -> Ipc -> Task.Task x ()
send v c = Native.Ipc.send v c |> Task.succeed
{-| send ipc sync -}
sendSync : a -> Ipc -> Task.Task x a
sendSync v c = Native.Ipc.sendSync v c |> Task.succeed
{-| wait on Signal a -}
on : Ipc -> Signal a
on = Native.Ipc.on
まとめ
JavaScriptにおけるコールバックを要求するAPIを
Elmの世界で使う際にSignalを使って実装する方法について
- WebSocket API
- Electron ipcRenderer API
を例に説明した。
みんなで色々なラッパーモジュール書いてフロントエンドはElmでやる世界を作ろう!