ElmでPhoenixSocketを使用したカウンターを作成する(JavaScript編)

はじめに

「ElmでPhoenixSocketを使用したカウンターを作成する」の2回目です。
Phoenix.Channel と連携する JavaScript ライブラリを作成していきます。

  1. ElmでPhoenixSocketを使用したカウンターを作成する(準備編)
  2. ElmでPhoenixSocketを使用したカウンターを作成する(JavaScript編) <- 本記事
  3. ElmでPhoenixSocketを使用したカウンターを作成する(Elm編)

手順

以下の手順で実装していきます。

  • 要件の定義
  • 要件からのElm側でのPort用関数定義
  • JavaScriptライブラリの実装
  • Elm側の実装(別記事)

要件の定義

機能要件

まずライブラリの機能要件を決めます。
counter.jsの実装を参考にして、ここでの実装から以下の機能が必要であることがわかります。

  • 参加したいチャンネルの追加
  • チャンネルへの参加
  • チャンネルへメッセージ送信
  • 受信したいメッセージの登録
  • チャンネルからのメッセージ受信

これらを実現できるように関数の定義をしていきます。

連携に必要な手順の洗い出し

チャンネルの接続からメッセージの送受信までに必要な手順は以下のようになります。

ElmJavaScriptaddChannelchannelAddedchannelAddedFailalt[ succeeded ][ failed ]joinChannelchannelJoinedchannelJoinedFailchannelNotFoundalt[ succeeded ][ failed ][ channel not found ]registRcvMessagechannelNotFoundopt[ not found ]loop[ as you need ]Initialize Completedpush2ChannelchannelPushedchannelPushedFailalt[ succeeded ][ failed ]rcvMessageElmJavaScript

これらの要件を土台として、実装していきます。

Elm側のPortの定義

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

babelへの設定

インストールしたものを設定します。

// assets/.babelrc
{
    "presets": [
        "@babel/preset-env"
    ],
    "plugins": [
      "@babel/plugin-proposal-class-properties"    ]
}

これで実装の準備は完了です。

Elm2PhxChannelPortsの作成

クラスの作成

Elm2PhxChannelPortsというクラスを作成し、そこに機能を実装していきます。
インスタンスを作成するときに以下を指定するようにします。

  • Elmを構築するNode
  • 接続するSocketのパス
  • セッション及び認証用トークン

これらを使ってセットアップします。
ハイライトされている部分がポイントとなるところです。

// 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の作成

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との通信で使用するので保持しておきます。

ElmAppの作成

コンストラクト時に指定された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の設定に使用するので保持しておきます。

各種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.`);    }};

adddChannel

チャンネル追加の処理です。
指定したチャンネル名で初期化したチャンネルを作成します。
成功か失敗かを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 );

_findChannelAndProc

「指定したチャンネルが存在したら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

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の場合はメッセージ部分が含まれていることです。

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

メッセージを受信する指定を行います。

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

上記で作成したクラスを 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のコードは次回になります。