「ElmでPhoenixSocketを使用したカウンターを作成する」の3回目です。
2回目で作成したライブラリを使用するElmのクライアント用コードを作成していきます。
これからの実装に必要になると思われる事柄を以下に記述します。
知識の準備として、Port について説明します。
Port は、Elm が JavaScript と連携するための仕組みです。
Elm と JavaScript は Port を通じて非同期に通信を行います。
説明のため、以下のケースをもとに記述します。
関数名 | 連携の方向 | 役割 |
---|---|---|
foo | Elm -> JavaScript | JSONで記述したデータをJavaScript側へ送る |
bar | Elm <- JavaScript | JSON文字列をElm側へ送る |
上記ケースを図で表すと以下になります。
JavaScript 側では、以下のように初期化を行います。
node に Elm 用に割り当ててある DOM を渡して初期化します。
var app = Elm.Main.init({
node: document.getElementById('elm')
});
返り値 app
は、後のために保持しておきます。
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
foo
が Elm 側から JavaScript 側へメッセージを送る関数です。
この関数に JSON.Value
を渡すと Cmd msg
型となります。
以下では update
関数の中で foo
をJSONにエンコードした値とともに送信する例です。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
SomeMessage ->
(model, foo encodedJsonValue)
保持した app
を使用してPortの設定を行います。
以下がその例です。
subscribe
には関数を指定します。
param
には Elm 側からの値が入っています。
以下は、Elm 側から関数 foo
を通して渡ってきた値を、ローカルストレージに保存する例です。
app.ports.foo.subscribe( param => {
const { hoge } = param;
localStorage.setItem('hogehoge', hoge);
});
send
関数を使います。
以下は bar
を通して Elm 側へ値を通知する例です。
app.ports.bar.send( anotherParam );
bar
が JavaScript 側からメッセージを受け取る関数です。
実際にデータを受けるまでに以下のステップを行います。
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で作っていきます。
ローカルカウンタの場合は 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 が指定されます。
これは、Elm の flag という仕組みを用います。
アプリ作成時の以下の記述により、JavaScript 側から token が渡ってきます。
// assets/js/elm_to_phxchannel_ports.js
cons app = Elm.Main.init({
node: target,
flags: { token }});
以下のように 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つの事を行います。
Model
の定義は以下です。
-- assets/elm/src/Main.elm
type alias Model =
{ val : Int
, token : String
}
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型の定義です。
ハイライトされているものは、ブラウザのイベントです。
ハイライトされていないものは Port 用です。
-- assets/elm/src/Main.elm
type Msg
= Increment | Decrement | RcvMessage String
| ChannelJoined String
| ChannelAdded String
JavaScript 側から送られてくるメッセージをここに記載しています。
簡単のため、エラーメッセージについては受けていません。
ここに関数の記述をしなかった場合、作成した JavaScript ライブラリはコンソールにエラーを出力します。
-- assets/elm/src/Main.elm
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.batch
[ PC.rcvMessage RcvMessage
, PC.channelJoined ChannelJoined
, PC.channelAdded ChannelAdded
]
ここで、PC
は PhoenixChannel
です。
プログラムの初めの方で以下のようにインポートしています。
ポート関数関連は全てここで記述しています。
-- assets/elm/src/Main.elm
import PhoenixChannel as PC
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 )
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"
先程説明にあった関数です。
関数とメッセージを指定すると、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" } } )
init
時に addChannel
を呼んでいるので、その結果が帰ってきます。
addChannel
に成功したら ChannelAdded
メッセージが来ます。
そこで、メッセージの流れに示されているように、そのチャンネルに対して joinChannel
を行います。
-- assets/elm/src/Main.elm
ChannelAdded _ ->
( model, PC.joinChannel <| makeListObject [ channelNameNamedObject ] )
<|
は以下のシグネチャを持つ関数です。
<| : (a -> b) -> a -> b
パイプライン演算子とも言います。
これを使わない場合カッコを使って以下のように書けますが、上記演算子を使えばカッコを使わないで書けます。
PC.joinChannel (makeListObject [ channelNameNamedObject ])
カッコが無いのがカッコ良いというわけです。
channelJoin
に成功したら、このメッセージが来ます。
次にメッセージの流れに示されているように、受信メッセージの登録を行います。
ChannelJoined _ ->
( model
, Cmd.batch [ simpleChannelMessage PC.registRcvMessage Defs.updateMsg ]
)
上記は、JavaScript 側に以下のような形で渡ります。
registRcvMessage({ "channelName": "counter:regular", "params" : { "message": "counter_updated" } })
これで、サーバー側からカウンター値が更新されてくるようになります。
上記で登録したメッセージをサーバーから受信します。
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
パイプライン演算子ともいいます。
「x
に f
を適用したものに g
を適用する。」というケースを考えた場合、以下のように書けます。
x |> f |> g
これは関数を数珠つなぎにし、前の関数の結果を次の関数の引数として適用する場合によく用います。
因みに、Elixirでも同様の記述方法があります。
ただし大きな違いがあります。
「前の関数の結果を次の関数に適用する」場合、「複数の引数をとる関数」ではElmは最後の引数として渡されてきますが、Elixirでは最初の引数として渡されてきます。
2つの言語を同時に扱っていると間違えがちなポイントです。
ソースコードは以下の3ファイルになります。
全部ここに載せときます。
将来的にGithubに置くかも知れません。
-- 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 "+" ]
]
-- 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"
-- 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のライブラリは、他のプロジェクトでも使っていこうと思います。