「ElmでPhoenixSocketを使用したカウンターを作成する」の2回目です。
Phoenix.Channel と連携する JavaScript ライブラリを作成していきます。
以下の手順で実装していきます。
まずライブラリの機能要件を決めます。
counter.jsの実装を参考にして、ここでの実装から以下の機能が必要であることがわかります。
これらを実現できるように関数の定義をしていきます。
チャンネルの接続からメッセージの送受信までに必要な手順は以下のようになります。
これらの要件を土台として、実装していきます。
Elm側で、Portでの相互接続のための関数定義を行います。
JavaScript側からは全て文字列でメッセージが送られてきますが、これはJSON文字列を想定しています。
この定義をもとに、まずはJavaScript側から連携部分を実装していきます。
また、Portを使用する関数をここではPort関数と呼ぶことにします。
-- assets/elm/src/PhoenixChannel.elm
port module PhoenixChannel exposing (..)
import Json.Encode as JE
--Elm -> JS
port addChannel : JE.Value -> Cmd msg
port joinChannel : JE.Value -> Cmd msg
port push2Channel : JE.Value -> Cmd msg
port registRcvMessage : JE.Value -> Cmd msg
--JS -> Elm
port channelAdded : (String -> msg) -> Sub msg
port channelAddFailed : (String -> msg) -> Sub msg
port channelJoined : (String -> msg) -> Sub msg
port channelJoinFailed : (String -> msg) -> Sub msg
port channelPushed : (String -> msg) -> Sub msg
port channelPushFailed : (String -> msg) -> Sub msg
port rcvMessage : (String -> msg) -> Sub msg
実装を始める前に、ひとつライブラリを追加します。
クラスを作成しメンバ関数を以下のようにアロー関数で実装するので、plugin-proposal-class-properties を使用します。
class Foo {
constructor() {
}
bar = (baz) => { return qux; }};
npmから目的のものをインストールします。
$ npm i --save @babel/plugin-proposal-class-properties
インストールしたものを設定します。
// assets/.babelrc
{
"presets": [
"@babel/preset-env"
],
"plugins": [
"@babel/plugin-proposal-class-properties" ]
}
これで実装の準備は完了です。
Elm2PhxChannelPortsというクラスを作成し、そこに機能を実装していきます。
インスタンスを作成するときに以下を指定するようにします。
これらを使ってセットアップします。
ハイライトされている部分がポイントとなるところです。
// assets/js/elm_to_phxchannel_ports.js
export class Elm2PhxChannelPorts {
constructor({ target, socketPath, token }) {
if (!target || !token)return;
this.channels = {};
this.socket = this._createSocket({ socketPath, token }); this.app = this._initElm({ target, token }); if (!this.app) {
console.error('Elm initialization failed.');
return;
}
this._setupPorts(this.app.ports); };
Socketを作成します。
セッション情報(token)付きでインスタンスを作成し、接続後そのソケットを返します。
// assets/js/elm_to_phxchannel_ports.js
_createSocket = ({ socketPath, token }) => {
let socket = new Socket(socketPath, { params: {token: token } });
socket.connect();
return socket;
};
socketは、Phoenixとの通信で使用するので保持しておきます。
コンストラクト時に指定されたtargetを起点としてDOMを構築します。
返却値はElm側とのPortの連携等で使用します。
また、flagsを通してElm側にtokenを渡します。
flagとは、Elmアプリのinit時にJavaScript側からElm側に値を送ることのできる仕組みです。
Elm側で受け取るためには、アプリを Browser.sandbox
ではなく、Browser.element
で作成する必要があります。
実際のmain関数は、このような定義になります。
// assets/js/elm_to_phxchannel_ports.js
_initElm = ({target, token}) => {
return Elm.Main.init({
node: target,
flags: { token }
});
};
返ってきた値は、Portの設定に使用するので保持しておきます。
ElmからJavaScriptへのPortの設定を行います。
以下の4つのポート用の入り口の関数を作成します。
関数 | 内容 |
---|---|
addChannel | チャンネル名を指定してチャンネルを追加 |
joinChannel | 指定したチャンネルに参加 |
push2Channel | 指定したチャンネルにメッセージとデータを送信 |
registRcvMessage | 指定したチャンネルからのメッセージの受信登録 |
上記関数分以下のような実装を行っていきます。
this.app.ports.foo.subscribe(param => {
// Elm側から受け取った値を用いて何かをする
})
これに関してより詳しい記述は「Portについて」に記載しています。
実際の処理の記述は以下になります。
// assets/js/elm_to_phxchannel_ports.js
_setupPorts = (ports) => {
if (!ports) {
console.error('There is no app.ports.');
return;
}
this._subscribeIfExist(ports.addChannel, this.addChannel, 'addChannel');
this._subscribeIfExist(ports.joinChannel, this.joinChannel, 'joinChannel');
this._subscribeIfExist(ports.push2Channel, this.push2Channel, 'push2Channel');
this._subscribeIfExist(ports.registRcvMessage, this.registRcvMessage, 'registRcvMessage');
};
上記部分を詳しく見ていきます。
全てのPort関数を実装しなくても良いように、関数が実装されている場合は所定の処理を行い、実装されていない場合はコンソールにエラーの内容を出力するようにします。
同じような処理がsubscribe時とsend時に必要になるので、まずは _doIfExist
で処理の骨格を記述します。
そして、実際の処理を記述した無名関数を引数で与えそれぞれの処理をさせるようにしています。
_subscribeIfExist = (obj, arg, functionName) => {
this._doIfExist(obj, arg, functionName, (o, a) => o.subscribe(a))};
_sendIfExist = (obj, arg, functionName) => {
this._doIfExist(obj, arg, functionName, (o, a) => o.send(a))};
_doIfExist = (obj, arg, functionName, func) => { if (obj) { func(obj, arg); } else { console.error(`Function "${functionName}" does not exit.`); }};
チャンネル追加の処理です。
指定したチャンネル名で初期化したチャンネルを作成します。
成功か失敗かをPort関数を通じてElm側へ通知します。
addChannel = ({ channelName }) => {
const { ports } = this.app;
const tmpChannel = this.socket.channel(channelName);
this.channels[channelName] = tmpChannel;
if (tmpChannel) {
this._sendIfExist(ports.channelAdded, channelName, 'channelAdded');
} else {
this._sendIfExist(ports.channelAddFailed, `Channel (${channelName}) add failed.`, 'channelAddFailed');
}
};
成功した場合の実際の呼び出しは以下のような感じです。
app.ports.channelAdded.send( jsonData );
「指定したチャンネルが存在したらAという処理を行い、存在しなければBという処理を行う。」という処理が頻発するので、その部分を一つの関数とします。
パラメータ名 | 役割 |
---|---|
channelName | チャンネル名 |
succeededFunc | チャンネルの取得が成功したら呼ばれる関数 |
failedFunc | チャンネルの取得が失敗したら呼ばれる関数 |
params | 成功したら呼ばれる関数に引き渡すパラメータ |
以下のようになります。
_findChannelAndProc = (channelName, params) => {
const { succeededFunc, failedFunc, params: p } = params;
const channel = this.channels[channelName];
if (channel) {
succeededFunc(channel, p);
} else {
failedFunc();
}
};
joinChannelでの使用例を以下に記述します。
パラメータを設定したあと、_findChannelAndProc
を呼んでいます。
joinChannel = ({ channelName }) => {
const joinParams = {
channelName,
succeededFunc: this._joinChannel,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, joinParams);
};
成功した場合は以下の関数が呼ばれます。
_joinChannel = (channel) => {
const { ports } = this.app;
channel.join()
.receive('ok', resp => {
const strJson = JSON.stringify(resp);
this._sendIfExist(ports.channelJoined, strJson, 'channelJoined');
})
.receive('error', reason => {
this._sendIfExist(ports.channelJoinFailed, 'Channel joined fail.', 'channelJoinFailed');
});
};
join()の結果によってそれぞれのPort関数が呼ばれます。
例えばjoin()の結果が成功だった場合は以下のようになります。
this.app.ports.channelJoined.send( jsonData );
失敗した場合は以下の関数が呼ばれます。
_channelNotFound = () => {
this._sendIfExist(this.app.ports.channelNotFound, 'Channel not found.', 'channelNotFound');
};
基本的にはpush2Channelも同じです。
違いは、push2Channelの場合はメッセージ部分が含まれていることです。
push2Channel = ({ channelName, params }) => {
const pushParams = {
channelName,
params,
succeededFunc: this._push2Channel,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, pushParams)
};
チャンネルの取得に成功したら以下の関数が呼ばれます。
push2Channelにはmessageが含まれているのが前提になります。
payoadに関しては、存在しない場合は空のオブジェクトを補充します。
_push2Channel = (channel, { message, payload }) => {
if (!payload) {
payload = {};
}
const { ports } = this.app;
channel.push(message, payload)
.receive('ok', r => {
this._sendIfExist(ports.channelPushed, 'Channel pushed.', 'channelPushed');
})
.receive('error', e => {
this._sendIfExist(ports.channelPushFailed, 'Channel push failed.', 'channelPushFailed');
});
};
メッセージを受信する指定を行います。
registRcvMessage = ({ channelName, params }) => {
const registRcvMsgParams = {
channelName,
params,
succeededFunc: this._registRcvMessage,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, registRcvMsgParams);
};
メッセージを受信したら呼び出す関数を指定します。
_registRcvMessage = (channel, { message }) => {
channel.ref = channel.on(message, param => {
const strJson = JSON.stringify({message, param});
this._sendIfExist(this.app.ports.rcvMessage, strJson, "rcvMessage");
});
};
上記では、取得したメッセージとパラメータを以下の形式でJSONオブジェクトを作成し、文字列に変換しています。
{ "msesage": メッセージ名, "params": パラメータ }
以下でElm側に贈ります。
this.app.ports.rcvMessage.send( JsonString );
一連の処理のソースコードは以下になります。
// assets/js/elm_to_phxchannel_ports.js
'use strict';
import {Socket} from "phoenix"
import {Elm} from "../elm/src/Main.elm";
export class Elm2PhxChannelPorts {
constructor({ target, socketPath, token }) {
if (!target || !token)return;
this.channels = {};
this.socket = this._createSocket({ socketPath, token });
this.app = this._initElm({ target, token });
if (!this.app) {
console.error('Elm initialization failed.');
return;
}
this._setupPorts(this.app.ports);
};
_initElm = ({target, token}) => {
return Elm.Main.init({
node: target,
flags: { token }
});
};
_initSocket = () => {
this.socket = new Socket(this.socketPath, {params: {token: this.token}});
this.socket.connect();
};
_setupPorts = () => {
const { ports } = this.app;
if (!ports) {
console.error('There is no app.ports.');
return;
}
this._subscribeIfExist(ports.addChannel, this.addChannel, "addChannel");
this._subscribeIfExist(ports.joinChannel, this.joinChannel, "joinChannel");
this._subscribeIfExist(ports.push2Channel, this.push2Channel, "push2Channel");
this._subscribeIfExist(ports.registRcvMessage, this.registRcvMessage, "registRcvMessage");
};
addChannel = ({ channelName }) => {
const { ports } = this.app;
const tmpChannel = this.socket.channel(channelName);
this.channels[channelName] = tmpChannel;
if (tmpChannel) {
this._sendIfExist(ports.channelAdded, channelName, "channelAdded");
} else {
this._sendIfExist(ports.channelAddFailed, `Channel (${channelName}) add failed.`, "channelAddFailed");
}
};
joinChannel = ({ channelName }) => {
const joinParams = {
channelName,
succeededFunc: this._joinChannel,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, joinParams);
};
registRcvMessage = ({ channelName, params }) => {
const registRcvMsgParams = {
channelName,
params,
succeededFunc: this._registRcvMessage,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, registRcvMsgParams);
};
push2Channel = ({ channelName, params }) => {
const pushParams = {
channelName,
params,
succeededFunc: this._push2Channel,
failedFunc: this._channelNotFound
};
this._findChannelAndProc(channelName, pushParams)
};
_joinChannel = (channel) => {
const { ports } = this.app;
channel.join()
.receive('ok', resp => {
const strJson = JSON.stringify(resp);
this._sendIfExist(ports.channelJoined, strJson, "channelJoined");
})
.receive('error', reason => {
this._sendIfExist(ports.channelJoinFailed, "Channel joined fail.", "channelJoinFailed");
});
};
_registRcvMessage = (channel, { message }) => {
channel.ref = channel.on(message, param => {
const strJson = JSON.stringify({message, param});
this._sendIfExist(this.app.ports.rcvMessage, strJson, "rcvMessage");
});
};
_push2Channel = (channel, { message, payload }) => {
if (!payload) {
payload = {}
}
const { ports } = this.app;
channel.push(message, payload)
.receive('ok', r => {
this._sendIfExist(ports.channelPushed, "Channel pushed.", "channelPushed");
})
.receive('error', e => {
this._sendIfExist(ports.channelPushFailed, "Channel push failed.", "channelPushFailed");
});
};
_channelNotFound = () => {
this._sendIfExist(this.app.ports.channelNotFound, "Channel not found.", "channelNotFound");
};
_findChannelAndProc = (channelName, params) => {
const { succeededFunc, failedFunc, params: p } = params;
const channel = this.channels[channelName];
if (channel) {
succeededFunc(channel, p);
} else {
failedFunc();
}
};
_subscribeIfExist = (obj, arg, functionName) => {
this._doIfExist(obj, arg, functionName, (o, a) => o.subscribe(a))
};
_sendIfExist = (obj, arg, functionName) => {
this._doIfExist(obj, arg, functionName, (o, a) => o.send(a))
};
_doIfExist = (obj, arg, functionName, func) => {
if (obj) {
func(obj, arg);
} else {
console.error(`Function "${functionName}" does not exit.`);
}
};
};
上記で作成したクラスを app.js
にて以下のように呼び出します。
// assets/js/app.js
import { Elm2PhxChannelPorts } from './elm_to_phxchannel_ports';
const target = document.getElementById('elm-main');
if(target) {
const elm2pc = new Elm2PhxChannelPorts({target, socketPath: '/socket', token: window.userToken});
}
これで、JavaScript側で必要な処理は全て実装しました。
ちょっと長くなってしまいましたが、そこそこ汎用的に使えるJavaScriptクラスを作ることができました。
これを用いてElmとの接続を行います。
Elmのコードは次回になります。