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

はじめに

「ElmでPhoenixSocketを使用したカウンターを作成する」の3回目です。
2回目で作成したライブラリを使用するElmのクライアント用コードを作成していきます。

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

知識の準備

これからの実装に必要になると思われる事柄を以下に記述します。

Portについて

知識の準備として、Port について説明します。
Port は、ElmJavaScript と連携するための仕組みです。
ElmJavaScriptPort を通じて非同期に通信を行います。

Cmd msg
subscribe
send
Sub msg
Elm
Port
JavaScript

説明のため、以下のケースをもとに記述します。

関数名連携の方向役割
fooElm -> JavaScriptJSONで記述したデータをJavaScript側へ送る
barElm <- JavaScriptJSON文字列をElm側へ送る

上記ケースを図で表すと以下になります。

foo JE.Value
ports.foo.subscribe
ports.bar.send param
bar
Elm
Port
JavaScript

準備

JavaScript側の準備

JavaScript 側では、以下のように初期化を行います。
nodeElm 用に割り当ててある DOM を渡して初期化します。

var app = Elm.Main.init({
  node: document.getElementById('elm')
});

返り値 app は、後のために保持しておきます。

Elm側の準備

Port 関数の定義を行います。

port module Main exposing(..)

import Json.Encode as JE

-- Elm -> JavaScript
port foo : JE.Value -> Cmd msg

-- JavaScript --> Elm
port bar : (String -> msg) -> Sub msg

Elm側からJavaScirpt側へ

Elm側の記述

fooElm 側から JavaScript 側へメッセージを送る関数です。
この関数に JSON.Value を渡すと Cmd msg 型となります。
以下では update 関数の中で foo をJSONにエンコードした値とともに送信する例です。

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SomeMessage ->
          (model, foo encodedJsonValue)
JavaScript側の記述

保持した app を使用してPortの設定を行います。
以下がその例です。
subscribe には関数を指定します。
param には Elm 側からの値が入っています。
以下は、Elm 側から関数 foo を通して渡ってきた値を、ローカルストレージに保存する例です。

app.ports.foo.subscribe( param => {
  const { hoge } = param;
  localStorage.setItem('hogehoge', hoge);
});

Javascript側からElm側へ

JavaScript側の記述

send 関数を使います。
以下は bar を通して Elm 側へ値を通知する例です。

app.ports.bar.send( anotherParam );
Elm側の記述

barJavaScript 側からメッセージを受け取る関数です。 実際にデータを受けるまでに以下のステップを行います。

  • メッセージの定義
  • subscriptionsという関数で関数とメッセージの結びつけ
  • update 関数でメッセージをハンドリング

上記の具体的な記述が以下になります。
ハイライトの部分が該当する箇所になります。

type Msg
    = BarMessage String    |  :

subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.batch
        [ bar BarMessage        ]

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        BarMessage value ->          ...

上記 update 関数の BarMessage から value という値が受け取れるようになりました。
これで Port の説明は終わりです。

各実装の説明

実際のコードがどのように組まれているかを、関数毎に順を追って記述していきます。

メッセージの流れ

Portを通じて以下の相互通信が行われます。
それをElmで作っていきます。

ElmJavaScriptnew Elm2PhxChannelPortsElm.Main.initInitaddChannelchannelAddedjoinChannelchannelJoinedregistRcvMessageInitialize CompletedIncrement | Decrement Clicked!push2ChannelrcvMessageModel updated!ElmJavaScript

main関数

ローカルカウンタの場合は Browser.sandbox を使用しましたが、今回は Browser.element を使用します。
この事により、新たに subscriptions を指定できるようになり、JavaScript との相互接続も可能になります。

-- assets/elm/src/Main.elm
main : Program Flags Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }

また、ページ表示時に JavaScript 側から token が指定されます。
これは、Elmflag という仕組みを用います。 アプリ作成時の以下の記述により、JavaScript 側から token が渡ってきます。

// assets/js/elm_to_phxchannel_ports.js
cons app = Elm.Main.init({
    node: target,
    flags: { token }});

init関数

以下のように init 関数に flag が渡されます。
ハイライトされている行は Port 関連の関数です。
メッセージの流れに示されているように、init 時に addChannel を呼びます。

-- assets/elm/src/Main.elm
init : Flags -> ( Model, Cmd Msg )
init flags =
    ( initialModel flags.token
    , PC.addChannel <| makeListObject [ channelNameNamedObject ]    )

PC という記述がありますが、Port 関連の関数は PhoenixChannel.elm に記述しています。
そのモジュールを読み込んでおきます。

-- assets/elm/src/Main.elm
import PhoenixChannel as PC

flags から token を取り出し、initialModel に渡します。

-- assets/elm/src/Main.elm
initialModel : String -> Model
initialModel str =
    { val = 0
    , token = str
    }

initialModel では次の2つの事を行います。

  • valを0にセット
  • tokenにはflagsから渡ってきたtokenをセット

Model の定義は以下です。

-- assets/elm/src/Main.elm
type alias Model =
    { val : Int
    , token : String
    }

view関数

view では Bootstrap を用い見た目を整えています。
また、インラインスタイルも用いています。
その分記述量が多くなっていますが、基本的な構成は同じです。 ハイライトの部分に注目していただければ、同じだということがわかると思います。

-- assets/elm/src/Main.elm
view : Model -> Html Msg
view model =
    div [ Html.Attributes.style "margin" "16px" ]
        [ CDN.stylesheet
        , h1 [] [ text <| "The count is: " ++ String.fromInt model.val ]        , Button.button            [ Button.primary
            , Button.large
            , Button.onClick Decrement            , Button.attrs [ Html.Attributes.style "margin" "4px" ]
            ]
            [ text "-" ]        , Button.button            [ Button.primary
            , Button.large
            , Button.onClick Increment            , Button.attrs [ Html.Attributes.style "margin" "4px" ]
            ]
            [ text "+" ]        ]

Msg型

Msg型の定義です。
ハイライトされているものは、ブラウザのイベントです。
ハイライトされていないものは Port 用です。

-- assets/elm/src/Main.elm
type Msg
    = Increment    | Decrement    | RcvMessage String
    | ChannelJoined String
    | ChannelAdded String

subscription関数

JavaScript 側から送られてくるメッセージをここに記載しています。
簡単のため、エラーメッセージについては受けていません。
ここに関数の記述をしなかった場合、作成した JavaScript ライブラリはコンソールにエラーを出力します。

-- assets/elm/src/Main.elm
subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.batch
        [ PC.rcvMessage RcvMessage
        , PC.channelJoined ChannelJoined
        , PC.channelAdded ChannelAdded
        ]

ここで、PCPhoenixChannel です。
プログラムの初めの方で以下のようにインポートしています。
ポート関数関連は全てここで記述しています。

-- assets/elm/src/Main.elm
import PhoenixChannel as PC

update関数

update関数はこのようになっています。

-- assets/elm/src/Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( model, simpleChannelMessage PC.push2Channel Defs.incMsg )

        Decrement ->
            ( model, simpleChannelMessage PC.push2Channel Defs.decMsg )

        ChannelAdded _ ->
            ( model, PC.joinChannel <| makeListObject [ channelNameNamedObject ] )

        ChannelJoined _ ->
            ( model
            , Cmd.batch [ simpleChannelMessage PC.registRcvMessage Defs.updateMsg ]
            )

        RcvMessage value ->
            let
                newVal =
                    JD.decodeString cmDecoder value
            in
            case newVal of
                Ok decodedValue ->
                    ( { model | val = decodedValue.param.val }, Cmd.none )

                Err error ->
                    ( model, Cmd.none )

Increment/Decrement

Msg を個々に見ていきます。
まず出てくるのが、「+」ボタンと「ー」ボタンが押されたときに対応している以下です。

-- assets/elm/src/Main.elm
Increment ->
    ( model, simpleChannelMessage PC.push2Channel Defs.incMsg )

Decrement ->
    ( model, simpleChannelMessage PC.push2Channel Defs.decMsg )

ここではmodelに変更は加えていません。
その代わり Cmd Msg でJavaScript側にメッセージを送っています。
simpleChannelMessage には、関数とメッセージを渡しています。
Increment を例にとると、関数には PC.push2Channel をメッセージには Defs.incMsg を渡しています。
ここで Defs.incMsg は、"increment" という文字列を返す関数です。
他の言語では以下のように表している文字列定数ですが、

const INC_MSG = "increment";

Elmでは以下のように引数無しでStringを返す関数として表します。

-- assets/elm/src/Defines.elm
incMsg : String
incMsg =
    "increment"
simpleChannelMessage

先程説明にあった関数です。 関数とメッセージを指定すると、couter:regularというチャンネルにメッセージを送ります。
ただし、送るのはメッセージ名のみで、メッセージに付随するパラメータ等は送りません。

-- assets/elm/src/Main.elm
simpleChannelMessage : (JE.Value -> Cmd msg) -> String -> Cmd msg
simpleChannelMessage portFunc message =
    portFunc <|
        makeListObject
            [ channelNameNamedObject
            , makeNamedObject Defs.paramsKey <|
                makeListObject <|
                    [ makeNamedStrObject Defs.msgKey message ]
            ]

メッセージに"increment"を指定した場合、この関数では以下のJSONオブジェクトを作成します。

{
  "channelName": "counter:regular",
  "params" : {
    "message": "increment"
   }
}

JavaScript 風に言うと、push2Channel 関数を渡した場合は、以下の関数を実行している事になります。

push2Channel( { "channelName": "counter:regular", "params" : { "message": "increment" } } )

ChannelAdded

init 時に addChannel を呼んでいるので、その結果が帰ってきます。
addChannel に成功したら ChannelAdded メッセージが来ます。
そこで、メッセージの流れに示されているように、そのチャンネルに対して joinChannel を行います。

-- assets/elm/src/Main.elm
ChannelAdded _ ->
    ( model, PC.joinChannel <| makeListObject [ channelNameNamedObject ] )
「<|」について

<| は以下のシグネチャを持つ関数です。

<| : (a -> b) -> a -> b

パイプライン演算子とも言います。
これを使わない場合カッコを使って以下のように書けますが、上記演算子を使えばカッコを使わないで書けます。

PC.joinChannel (makeListObject [ channelNameNamedObject ])

カッコが無いのがカッコ良いというわけです。

channelJoined

channelJoin に成功したら、このメッセージが来ます。
次にメッセージの流れに示されているように、受信メッセージの登録を行います。

ChannelJoined _ ->
    ( model
    , Cmd.batch [ simpleChannelMessage PC.registRcvMessage Defs.updateMsg ]
    )

上記は、JavaScript 側に以下のような形で渡ります。

registRcvMessage({ "channelName": "counter:regular", "params" : { "message": "counter_updated" } })

これで、サーバー側からカウンター値が更新されてくるようになります。

RcvMessage

上記で登録したメッセージをサーバーから受信します。

RcvMessage value ->
    let
        newVal =
            JD.decodeString cmDecoder value
    in
    case newVal of
        Ok decodedValue ->
            ( { model | val = decodedValue.param.val }, Cmd.none )

        Err error ->
            ( model, Cmd.none )

value にはJSONで表現された以下の形式の文字列になっています。

{
  "message" : "counter_updated",
   "param" : {
     "val": number
   }
}

これをElm側でデコードして、型をつけていきます。
デコードのやりやすさのため、NoRedInk/elm-json-decode-pipelineというパッケージを使用します。
そのために、assets/elm に移動し、以下のコマンドを使ってインストールします。

# assets/elm
$ elm install NoRedInk/elm-json-decode-pipeline

インストールが完了したら、早速インポートします。

-- assets/elm/src/Main.elm
import Json.Decode as JD exposing (Decoder, Value)
import Json.Decode.Pipeline exposing (..)

送られてくるJSONの形式に従って型の定義を行います。

-- assets/elm/src/Main.elm
type alias CounterMsg =
    { message : String
    , param : CounterVal
    }

type alias CounterVal =
    { val : Int }

その型を使ってデコーダーを作成します。

cmDecoder : JD.Decoder CounterMsg
cmDecoder =
    JD.succeed CounterMsg
        |> required "message" JD.string
        |> required "param" countDecoder


countDecoder : JD.Decoder CounterVal
countDecoder =
    JD.succeed CounterVal
        |> required "val" JD.int

このデコーダーを以下のように使用して、型付けされた値を得ます。

newVal =
    JD.decodeString cmDecoder value

デコードが成功すると、Result String a 型で結果が帰ってくるので、"Ok value" で価を取り出し、モデルを更新します。

「|>」について

|> は以下のシグネチャを持つ関数です。

|> :  a -> (a -> b) -> b

パイプライン演算子ともいいます。
xf を適用したものに g を適用する。」というケースを考えた場合、以下のように書けます。

x |> f |> g

これは関数を数珠つなぎにし、前の関数の結果を次の関数の引数として適用する場合によく用います。
因みに、Elixirでも同様の記述方法があります。
ただし大きな違いがあります。
「前の関数の結果を次の関数に適用する」場合、「複数の引数をとる関数」ではElmは最後の引数として渡されてきますが、Elixirでは最初の引数として渡されてきます。
2つの言語を同時に扱っていると間違えがちなポイントです。

ソースコード全体

ソースコードは以下の3ファイルになります。

  • Main.elm
  • Defines.elm
  • PhoenixChannel.elm

全部ここに載せときます。
将来的にGithubに置くかも知れません。

Main.elm

-- assets/elm/src/Main.elm
module Main exposing (main)

import Bootstrap.Button as Button
import Bootstrap.CDN as CDN
import Browser
import Defines as Defs exposing (Key)
import Html exposing (Html, div, h1, text)
import Html.Attributes
import Json.Decode as JD exposing (Decoder, Value)
import Json.Decode.Pipeline exposing (..)
import Json.Encode as JE
import PhoenixChannel as PC


main : Program Flags Model Msg
main =
    Browser.element
        { init = init
        , update = update
        , view = view
        , subscriptions = subscriptions
        }


type alias Model =
    { val : Int
    , token : String
    }


type Msg
    = Increment
    | Decrement
    | RcvMessage String
    | ChannelJoined String
    | ChannelAdded String


type alias Flags =
    { token : String
    }


type alias NamedObject =
    ( Key, JE.Value )


type alias CounterVal =
    { val : Int }


type alias CounterMsg =
    { message : String
    , param : CounterVal
    }


makeNamedObject : Key -> JE.Value -> NamedObject
makeNamedObject k v =
    ( k, v )


makeNamedStrObject : Key -> String -> NamedObject
makeNamedStrObject k v =
    makeNamedObject k <| JE.string <| v


makeListObject : List NamedObject -> JE.Value
makeListObject v =
    JE.object v


channelNameNamedObject : NamedObject
channelNameNamedObject =
    makeNamedStrObject Defs.chNameKey Defs.chName


initialModel : String -> Model
initialModel str =
    { val = 0
    , token = str
    }


init : Flags -> ( Model, Cmd Msg )
init flags =
    ( initialModel flags.token
    , PC.addChannel <| makeListObject [ channelNameNamedObject ]
    )


subscriptions : Model -> Sub Msg
subscriptions _ =
    Sub.batch
        [ PC.rcvMessage RcvMessage
        , PC.channelJoined ChannelJoined
        , PC.channelAdded ChannelAdded
        ]


simpleChannelMessage : (JE.Value -> Cmd msg) -> String -> Cmd msg
simpleChannelMessage portFunc message =
    portFunc <|
        makeListObject
            [ channelNameNamedObject
            , makeNamedObject Defs.paramsKey <|
                makeListObject <|
                    [ makeNamedStrObject Defs.msgKey message ]
            ]


cmDecoder : JD.Decoder CounterMsg
cmDecoder =
    JD.succeed CounterMsg
        |> required "message" JD.string
        |> required "param" countDecoder


countDecoder : JD.Decoder CounterVal
countDecoder =
    JD.succeed CounterVal
        |> required "val" JD.int


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        Increment ->
            ( model, simpleChannelMessage PC.push2Channel Defs.incMsg )

        Decrement ->
            ( model, simpleChannelMessage PC.push2Channel Defs.decMsg )

        ChannelAdded _ ->
            ( model, PC.joinChannel <| makeListObject [ channelNameNamedObject ] )

        ChannelJoined _ ->
            ( model
            , Cmd.batch [ simpleChannelMessage PC.registRcvMessage Defs.updateMsg ]
            )

        RcvMessage value ->
            let
                newVal =
                    JD.decodeString cmDecoder value
            in
            case newVal of
                Ok decodedValue ->
                    ( { model | val = decodedValue.param.val }, Cmd.none )

                Err error ->
                    ( model, Cmd.none )


view : Model -> Html Msg
view model =
    div [ Html.Attributes.style "margin" "16px" ]
        [ CDN.stylesheet
        , h1 [] [ text <| "The count is: " ++ String.fromInt model.val ]
        , Button.button
            [ Button.primary
            , Button.large
            , Button.onClick Decrement
            , Button.attrs [ Html.Attributes.style "margin" "4px" ]
            ]
            [ text "-" ]
        , Button.button
            [ Button.primary
            , Button.large
            , Button.onClick Increment
            , Button.attrs [ Html.Attributes.style "margin" "4px" ]
            ]
            [ text "+" ]
        ]

Defines.elm

-- assets/elm/src/Defines.elm
module Defines exposing (..)

type alias ChannelName =
    String

type alias Key =
    String

chName : ChannelName
chName =
    "counter:regular"

chNameKey : Key
chNameKey =
    "channelName"

paramsKey : Key
paramsKey =
    "params"

msgKey : Key
msgKey =
    "message"

incMsg : String
incMsg =
    "increment"

decMsg : String
decMsg =
    "decrement"

updateMsg : String
updateMsg =
    "counter_updated"

PhoenixChannel.elm

-- 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 rcvMessage : (String -> msg) -> Sub msg
port channelJoined : (String -> msg) -> Sub msg
port channelAdded : (String -> msg) -> Sub msg
port channelAddFailed : (String -> msg) -> Sub msg
port channelJoinFailed : (String -> msg) -> Sub msg
port channelPushed : (String -> msg) -> Sub msg
port channelPushFailed : (String -> msg) -> Sub msg

おわりに

全ての関数を説明しきれませんでしたが、以上にてElm側の記述は終わりです。
Elmは強い型付けがある関数型言語でありながら、親しみやすく&楽しく実装することができる言語です。
コンパイルエラーもわかりやすくコードの問題に早く気づくことができました。
クライアント側のコードに関しては、本来であればTypeScriptを用いたかったのですが、Phoenixフレームワーク内でうまく稼働させることができず、JavaScriptで実装しました。
Elm/TypeScript/Phoenixでうまく開発できる方法が見つかったら、またそのテーマで記事を書くかも知れません。
ただ、今回作成したJavaScriptのライブラリは、他のプロジェクトでも使っていこうと思います。