geolocation(現在地の緯度経度)の発信機と受信機をElmで作ってみたという記事です。MobileブラウザでElmアプリを立ち上げ発信モードにします。PCでも同じElmアプリを立ち上げ受信モードにします。mobileが移動すると、受信機の地図上でその移動を追跡するというものです。通信にはPhoenix channelを使います。geolocation情報はPostgreSQLに保存して、後の再利用に備えます。
(2018/04/30)追記 - 続編を書きました
Elmとleafletでつくるgeolocationの再現機とデータ解析 - Qiita
#1.システム概略
今回はmobileブラウザのgeolocationを、PCの地図上で追跡するシステムを作ってみました。mobileで5秒おきに現在位置を取得して、PCに送信しています。初めてのお使いで、子供にmobileを持たせ、家では親がPCの地図で子供がどこにいるのかを監視するようなシステムです。
技術的なポイントは以下の通りです。
1.PhoenixでSSL環境を設定する
2.Phoenixでchannelを設定する
3.PhoenixでPostgreSQLを使う
4.PhoenixでElmアプリを使う
5.ElmアプリでPhoenix channelを使う
6.Elmでsocket切断時に自動的(リアクティブ)に再接続を行う
7.ElmプログラムからPortを通してleaflet.jsを使う
8.現在位置を取得するためJavaScriptでnavigator.geolocationを使う
mobileとPCの両方のchromeではElmプログラムが動作していて、Phoenix channelで通信しあっています。ElmプログラムはmobileとPCで同じものが動作していて、mobileは発信モード、PCは受信モードになっています。発信モードでは現在位置取得時に地図上にマーカーを表示し、受信モードでは受信時にマーカーを表示しています。Phoenix channelでの通信はリアルタイムなので、両端末の地図のマーカーの表示は同期します。
mobile chrome --> Phoenix channel --> PC chrome
(5秒ごとに現在位置を発信) (リアルタイムで受信)
chromeブラウザ上で動作するアプリはElmプログラムで作られていますが、leaflet.jsで地図を表示する部分だけはJavaScriptでコーディングしています。
JavaScript(leaflet.js)
↕ port 5秒タイマー Geo情報の更新
Elm(Mobile) --- 発信モード
↓ channel 新Geo情報 push
Phoenix channel → Geo情報をPostgreSQL DBに保存
↓ channel 新Geo情報 broadcast
Elm(PC) --- 受信モード
↓ port
JavaScript(leaflet.js)
今回の話はここまでですが、多分続きがあります。5秒ごとのGeo情報をDBに保存してありますので、このデータの集計して、地図上にルートを再現するとか、速度の平均値を出すとか、いろいろ考えられますので続編のネタにと考えています。
以下は受信モードの画像ですが、発信モードの画像と同期してマーカが描かれますので、両者はほぼ同じ地図画像となります。
以下が再接続中の画像で、再接続までのカウントダウンがリアルタイムに表示されていきます。
#2.Phoenix環境
サーバ側のPhoenixでは大きく3つ設定する必要があります。ブラウザでgeolocationを取得するにはSSL接続が必要になります。またmobileからPCへの通信はPhoenix channelを使って行います。そしてchannelを流れるgeo情報をPostgreSQLに保存することにします。以下それぞれの設定を見ていきます。geo情報を保存するのにPostgreSQLが最適化は疑問の残ろところですが、今回は実験ですのでPhoenixのデフォルトで設定しやすいものを選択しました。
##2-1.SSLの設定
JavaScriptのnavigator.geolocationで現在位置を取得するためには、SSL環境が必要になります。以下のサイトを参考にしてオレオレ証明書をつくり、PhoenixでSSL環境を作ります。
SSL on localhost with Phoenix Framework
プロジェクトを作ります。
mix phx.new leaflet_channel
cd leaflet_channel
秘密キー(localhost.key)と証明書(localhost.cert)を作ります。
openssl genrsa -out localhost.key 2048
openssl req -new -x509 -key localhost.key -out localhost.cert -days 3650 -subj /CN=localhost
秘密キー(localhost.key)と証明書(localhost.cert)を、適切な場所(どこでもよい)に移動します。
mkdir priv/keys
mv localhost.cert localhost.key priv/keys
dev.exsにhttpsの項を追加します。Using SSL - HEX DOC
#
config :leaflet_test, LeafletChannelWeb.Endpoint,
http: [port: 4009],
https: [port: 4443,
otp_app: :leaflet_channel,
keyfile: "priv/keys/localhost.key",
certfile: "priv/keys/localhost.cert"],
debug_errors: true,
code_reloader: true,
check_origin: false,
watchers: [node: ["node_modules/brunch/bin/brunch", "watch", "--stdin",
cd: Path.expand("../assets", __DIR__)]]
#
以上でSSLの設定は終わりです。後で、Ecto(PostgreSQL)とかの設定後に起動するとhttpsの表示が出ますので確認できます。ただしブラウザでアクセスしたときにオレオレ証明書なので注意されますが、例外とかに追加すれば表示できるようになります。あくまで実験ですので今回の目的には十分です。
##2-2.Channelの設定
user_socket.exを修正します。以下のようにchannel行のコメントを外します。
defmodule LeafletChannelWeb.UserSocket do
use Phoenix.Socket
## Channels
channel "room:*", LeafletChannelWeb.RoomChannel
#
room_channel.exを以下のように作成します。これは後のElmクライアントを実装に符合しています。
defmodule LeafletChannelWeb.RoomChannel do
use LeafletChannelWeb, :channel
def join("room:lobby", %{"user_name" => user_name}, socket) do
socket = assign(socket, :user_name, user_name)
{:ok, socket}
end
def join("room:" <> _private_room_id, _params, _socket) do
{:error, %{reason: "unauthorized"}}
end
def handle_in("new_msg", %{"point" => point, "time" => time}, socket) do
user_name = socket.assigns[:user_name]
[lat,lng] = Poison.decode!(point) # string -> float
itime = Poison.decode!(time) # string -> int
broadcast(socket, "new_point", %{lat: lat, lng: lng, time: itime, user_name: user_name})
second = Kernel.trunc(itime/1000) # DBへは秒で保存
# p1 = %LeafletChannel.Point{user_name: user_name, lat: lat, lng: lng, time: second}
# LeafletChannel.Repo.insert(p1)
{:reply, :ok, socket}
end
def terminate(_reason, socket) do
user_name = socket.assigns[:user_name]
IO.puts("terminate user=#{user_name}")
{:noreply, socket}
end
end
terminate/2は、例えばブラウザを閉じた時にとかのsocket通信がクローズした時に呼ばれます。
room_channel.exでは外部データ(Json)をElixirに取り込む(Decode)ためにpoisonを使いますので、インストールします。
#
defp deps do
[
#
{:poison, "~> 3.1"}
]
end
#
以下のコマンドでインストールします。
mix deps.get
以上でChannelの設定が終了です。
##2-3.PostgreSQLの設定
※PostgreSQLの設定は今回の話ではあまり本質的でないので、面倒な場合はスキップしても構いません。
Elixirで設定する時は以下の過去記事にあるように多少ステップが複雑ですが、Phoenixでは初期設定でほとんど行われており、よりシンプルです。特にEcto.Repoの設定が楽になります。詳細を知りたいときは過去記事を参照してください。
Elixir Ecto のまとめ - Qiita
以下分かりやすいように3ステップを踏んで説明します。とても簡単です。
(1) Ecto.Repo
Repo(Repositories)はdata storeのラッパーで、データストアに対するAPIを提供します。EctoではRepository を通して create, update, destroy や query を発行することができます。 ですからRepository は、データベースとコミュニケーションするための adapter や credentialsを知る必要があります。
Phoenixの場合は自動で様々な設定が行われているので、Ecto.Repoに関しては以下の設定ファイルの登録だけでokです。
#
config :leaflet_channel, LeafletChannel.Repo,
adapter: Ecto.Adapters.Postgres,
database: "leaflet_channel_repo",
username: "postgres",
password: "postgres",
hostname: "localhost"
#
(2) DBとテーブルの作成
以上で準備は終わっているのでDBを作成します。以下のコマンドを打ちます。
$ mix ecto.create
Compiling 14 files (.ex)
Generated leaflet_channel app
The database for LeafletChannel.Repo has been created
次にテーブルを作成するための準備をします。まず以下のmixコマンドを打ちます。
$ mix ecto.gen.migration create_point
* creating priv/repo/migrations
* creating priv/repo/migrations/20180422021155_create_point.exs
ファイル 20180422021155_create_point.exs を編集して以下のようにします。
defmodule LeafletChannel.Repo.Migrations.CreatePoint do
use Ecto.Migration
def change do
create table(:point) do
add :user_name, :string
add :lat, :float
add :lng, :float
add :time, :integer
end
end
end
最後に上のmigrationファイルを走らせて、テーブルを作成します。
$ mix ecto.migrate
[info] == Running LeafletChannel.Repo.Migrations.CreatePoint.change/0 forward
[info] create table point
[info] == Migrated in 0.0s
もしこの時migrationに失敗したら、mix ecto.rollbackでこのchangeをundoできます。
(3) Ecto.Schema
Schemas はDB テーブルを Elixir struct に map するために使います。
LeafletChannel.Point schemaは以下のような構文で作られ、Elixir structに落とし込まれます。schemaはデフォルトでidというintegrのfieldが自動追加されます。field macro はname と type で field を定義します。
defmodule LeafletChannel.Point do
use Ecto.Schema
schema "point" do
field :user_name, :string
field :lat, :float
field :lng, :float
field :time, :integer
end
end
room_channel.exの初期設定の段階では以下の2行はコメントアウトしていましたが、LeafletChannel.Point Schemaが定義されたのでコメントを外してください。
#
p1 = %LeafletChannel.Point{user_name: user_name, lat: lat, lng: lng, time: second}
LeafletChannel.Repo.insert(p1)
#
schemaファイルを作ったらiex -S mixでElixir shellを立ち上げ、schemaを使ってみます。
iex -S mix
schemaを使います。
p1 = %LeafletChannel.Point{user_name: "test", lat: 12.3, lng: 23.4, time: 12345}
実際にテーブルに挿入します。
LeafletChannel.Repo.insert(p1)
iexシェルでは以下のように表示されます。
iex(1)> p1 = %LeafletChannel.Point{user_name: "test", lat: 12.3, lng: 23.4, time
: 12345}
%LeafletChannel.Point{
__meta__: #Ecto.Schema.Metadata<:built, "point">,
id: nil,
lat: 12.3,
lng: 23.4,
time: 12345,
user_name: "test"
}
iex(2)> p1.user_name
"test"
iex(3)> LeafletChannel.Repo.insert(p1)
[debug] QUERY OK db=3.1ms queue=0.1ms
INSERT INTO "point" ("lat","lng","time","user_name") VALUES ($1,$2,$3,$4) RETURNING "id" [12.3, 23.4, 12345, "test"]
{:ok,
%LeafletChannel.Point{
__meta__: #Ecto.Schema.Metadata<:loaded, "point">,
id: 1,
lat: 12.3,
lng: 23.4,
time: 12345,
user_name: "test"
}}
挿入されたレコードはコマンドラインから確認します。
$ sudo -u postgres psql leaflet_channel_dev -c 'select * from point'
以上でPostgreSQLの設定と確認が終わりました。
#3.Elmクライアントの環境設定
まずはstaticファイルの設定を行います。onlyをコメントアウトします。
#
plug Plug.Static,
at: "/", from: :multi_pages, gzip: false
# only: ~w(css fonts images js favicon.ico robots.txt)
#
詳細は以下の過去記事を確認してみてください。
PhoenixでStatic Fileをサーブする - Qiita
Phoenix+Elmでマルチページ - Qiita
次にelm-brunchのインストールを行います。またelmディレクトリを作成し必要なパッケージをインストールしておきます。
cd assets/
npm install --save-dev elm-brunch
mkdir elm
cd elm
elm-package install elm-lang/html
brunchの設定を変えて、Elmを扱えるようにします。
#
// Phoenix paths configuration
paths: {
// Dependencies and current project directories to watch
// (1)"elm"を追加
watched: ["static", "css", "js", "elm", "vendor"],
// Where to compile files to
public: "../priv/static"
},
// Configure your plugins
plugins: {
babel: {
// Do not use ES6 compiler in vendor code
ignore: [/vendor/]
},
// (2)elmBrunchを追加
elmBrunch: {
elmFolder: "elm",
mainModules: ["App.elm"], // Elmプログラム
outputFolder: "../static/vendor/js" // Elmの実行形をstaticに吐き出す
}
},
#
(※重要)outputFolderにはvendorサブディレクトリが指定されていることに注意してください。brunchにおいてはvendorにあるfileが優先的にロードされるようです。つまりElmのjsファイルが安全にロードされます。単にoutputFolder を "../static/js"と vendor抜きで指定すると、かなりの確率でElmのjsファイルのロードに失敗してしまいます。
レイアウトファイルを、次の2行にザックリと変えておきます。
<!DOCTYPE html>
<%= render @view_module, @view_template, assigns %>
以下はPhoenix channelを使うための設定です。
elm-phoenixをインストールするために、elm-package.jsonを修正します。
{
#
"dependencies": {
#
"saschatimme/elm-phoenix": "0.3.0 <= v < 1.0.0"
}
#
}
以下のコマンドでelm-phoenixをインストールします。
elm-github-install
この時exampleもインストールされ、cssファイルなどが勝手にロードされてしまうので、削除します。
rm -rf ./assets/elm/elm-stuff/packages/saschatimme/elm-phoenix/0.3.2/example/
以上でElmクライアントの環境設定を終わります。
#4.JavaScriptプログラム
JavaScriptコードの役割はleaflet.jsで地図を表示することと、navigator.geolocation.getCurrentPosition APIで現在の位置情報を取得することです。これらのコードはElmからPortで制御されます。つまりここではPortを、ElmからJavaScriptのleaflet.jsを操るためのAPIと考えます。以下の3つのAPIを用意します。portSetCurLocationは発信モードと受信モードで処理が異なります。portGetCurLocationは受信モードでは使われません。
-- OUTGOING PORT
portInitCurLocation : (発信・受信)ElmからJavaScriptにlocation初期値を渡します。
portSetCurLocation : (発信)ElmからJavaScriptに現在のlacationを取得し、地図上にマーカーを置き、geoデータをElmに返すように指示します。
(受信)chnnelで受信したgeoデータを渡し、地図上にマーカを置くよう指示します。
-- INCOMING PORT
portGetCurLocation : (発信)lacation現在地を返します。
プログラムの流れ的には以下のように図示できます。
portInitCurLocation
portSetCurLocation portGetCurLocation
Elmプログラム --> JavaScriptプログラム --> Elmプログラム
さてJavaScriptプログラムのために、index.html.eexを変えます。leaflet.jsを読み込み、地図を表示します。ElmとのインターフェースであるPortを使います。JavaScriptコードにつきましては以下のサイトを参考にさせていただきました。
leaflet入門6|地図に現在地を表示する2
<html>
<head>
<title>Leaflet AND Elm</title>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.3.1/dist/leaflet.css">
<script src="https://unpkg.com/leaflet@1.3.1/dist/leaflet.js"></script>
<style type="text/css">
<!--
#mapid { height: 400px; width: 600px}
-->
</style>
</head>
<body>
<a href="/page2">ページ2</a>
<br /><br />
<div id="elm-area"></div>
<br /><br />
<div id="mapid"></div>
<script src="/js/vendor/app.js"></script>
<script>
const app = Elm.App.embed(document.getElementById("elm-area"));
var marker=null;
var zoom = 15;
var cgeo = null;
app.ports.portInitCurLocation.subscribe( (geo) => {
console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
zoom = geo.zoom;
cgeo = geo;
mymap.setView(geo.point, zoom);
})
app.ports.portSetCurLocation.subscribe( (geo) => {
console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
zoom = geo.zoom;
cgeo = geo;
if( !geo.player ) {
updateCurLocation(geo)
} else {
setCurLocation()
}
})
var mymap = L.map('mapid')
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 18,
attribution: 'Map data © <a href="http://openstreetmap.org">OpenStreetMap</a> contributors, '
}).addTo(mymap);
function updateCurLocation(geo) {
mymap.setView(geo.point, geo.zoom);
if(marker) {
mymap.removeLayer(marker);
}
var lat = geo.point[0];
var lng = geo.point[1];
console.log("*** updateCurLocation lat="+lat+" lng="+lng);
marker = L.marker([lat,lng]).addTo(mymap).bindPopup('現在地').openPopup();
}
function setCurLocation(){
if (navigator.geolocation == false){
alert('現在地を取得できませんでした。');
return;
}
function success(e) {
var lat = e.coords.latitude;
var lng = e.coords.longitude;
mymap.setView([lat, lng], zoom);
if(marker) {
mymap.removeLayer(marker);
}
marker = L.marker([lat,lng]).addTo(mymap).bindPopup('現在地').openPopup();
var obj = new Object();
obj.player = cgeo.player;
obj.zoom = zoom;
obj.point = [lat, lng];
var now = new Date();
obj.time = now.getTime();
app.ports.portGetCurLocation.send(obj);
};
function error() {
alert('現在地を取得できませんでした。');
};
var options = {
enableHighAccuracy: true, //GPS機能を利用
timeout: 5000, //取得タイムアウトまでの時間(ミリ秒)
maximumAge: 0 //常に新しい情報に更新
};
navigator.geolocation.getCurrentPosition(success, error, options);
}
</script>
</body>
</html>
まずportInitCurLocationはElmプログラムの初期化時にCmdとして呼ばれるものです。portSetCurLocationはElmプログラムからタイマーで5秒おきに呼ばれます。geoはElmから送られてくるレコードで、geo.player=trueなら発信モード、geo.player=falseなら受信モードを意味しています。
app.ports.portInitCurLocation.subscribe( (geo) => {
console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
zoom = geo.zoom;
cgeo = geo;
mymap.setView(geo.point, zoom);
})
app.ports.portSetCurLocation.subscribe( (geo) => {
console.log("app.ports.portSetCurLocation.subscribe n="+geo.point)
zoom = geo.zoom;
cgeo = geo;
if( !geo.player ) {
updateCurLocation(geo)
} else {
setCurLocation()
}
})
successはnavigator.geolocation.getCurrentPositionで現在地取得が成功した時に呼ばれます。現在地[lat, lng]をportGetCurLocationでElm側に返しています。
function success(e) {
#
app.ports.portGetCurLocation.send([lat, lng]);
};
次にメインのElmプログラムに移ります。
#5.Elmプログラム
一般的にサーバプログラムよりはフロントエンドのプログラムの方が複雑になりがちですが、このフロントエンドにHaskellライクなElmが使えるのは全く理に適っていると思います。
Model.geo.playerがtrueかfalseかで、発信モードか受信モードかを決めます。デフォルトで受信モードになっています。JavaScriptコードのところでも説明しましたが、それぞれのモードで使われるportsは以下のようになっています。
Model.geo.player=true
portInitCurLocation
portSetCurLocation portGetCurLocation
Elmプログラム --> JavaScriptプログラム --> Elmプログラム
portInitCurLocation
portSetCurLocation
Model.geo.player=false
##5-1.Elmの全コード
以下がElmの全コードです。自分で書いて読む限りにおいては、JavaScriptと違ってロジックが明確にかける気がします。Elmでメインのロジックを書いて、必要に応じてJavaScriptコードを呼び出すやり方は、良い選択のような気がします
port module App exposing (..)
import Html exposing (Html, button, div, text, program)
import Html.Attributes as Attr
import Html.Events exposing (onClick)
import Date exposing (Date, hour, minute, second)
import Time exposing (Time, second)
import Phoenix
import Phoenix.Channel as Channel exposing (Channel)
import Phoenix.Socket as Socket exposing (Socket, AbnormalClose)
import Phoenix.Push as Push
import Json.Encode as JE
import Json.Decode as JD exposing (Decoder)
-- OUTGOING PORT
port portInitCurLocation : Geo -> Cmd msg
port portSetCurLocation : Geo -> Cmd msg
-- INCOMING PORT
port portGetCurLocation : (Geo -> msg) -> Sub msg
lobbySocket : String
lobbySocket =
"wss://www.mypress.jp:4443/socket/websocket"
elmUserName : String
elmUserName =
"anonymous"
main : Program Never Model Msg
main =
program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Point =
List Float -- better than tupple ad Json Decode
type alias Geo =
{ player : Bool
, zoom : Int
, point : Point
, time : Time
}
type alias Model =
{ geo : Geo
, connectionStatus : ConnectionStatus
, currentTime : Time
, messageFailed : Bool
, messageCnt : Int
}
point0 : Point
point0 = [35.7102, 139.8132] --lat, lng
initMessageCnt = 5
initModel : Model
initModel =
Model (Geo False 15 point0 0) Disconnected 0 False initMessageCnt
init : ( Model, Cmd Msg )
init =
( initModel, portInitCurLocation initModel.geo )
-- UPDATE
type ConnectionStatus
= Connected
| Disconnected
| ScheduledReconnect { time : Time }
type Msg = GetCurLocation Geo
| Tick Time
| Tick5 Time
| Increment
| Decrement
| TogglePlayer
| NewMsg JD.Value
| SocketClosedAbnormally AbnormalClose
| ConnectionStatusChanged ConnectionStatus
| MessageFailed JD.Value
| MessageArrived
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Tick time -> --現在時間 socket再接続のカウントダウンに使われる
{ model | currentTime = time } ! []
Tick5 _ -> -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
(model, portSetCurLocation model.geo)
GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
let
push =
Push.init "room:lobby" "new_msg"
|> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
, ( "time" , JE.string <| toString <| newgeo.time ) ])
|> Push.onOk (\_ -> MessageArrived)
|> Push.onError MessageFailed
_ = Debug.log "NewMsg write : Encoded" newgeo.point
in
{ model | geo=newgeo, messageCnt=model.messageCnt-1 } ! [ Phoenix.push lobbySocket push ]
NewMsg payload -> -- 【受信】発信機の現在位置をchannelから受信する
let
dval = JD.decodeValue decodeNewMsg payload
_ = Debug.log "NewMsg recieved : Decoded" dval
in
case dval of
Ok msg ->
let
oldgeo = model.geo
newgeo = { oldgeo | point = msg.point, time=msg.time }
in
({ model | geo = newgeo}, portSetCurLocation newgeo)
Err err ->
model ! []
Increment -> -- zoomを拡大する
let
oldgeo = model.geo
newgeo = { oldgeo | zoom = oldgeo.zoom + 1 }
in
({ model| geo = newgeo }, Cmd.none)
Decrement -> -- zoomを縮小する
let
oldgeo = model.geo
newgeo = { oldgeo | zoom = oldgeo.zoom - 1 }
in
({ model| geo = newgeo }, Cmd.none)
TogglePlayer -> -- モードを切り替える
let
oldgeo = model.geo
newgeo = { oldgeo | player = not oldgeo.player }
in
({ model| geo = newgeo }, Cmd.none)
SocketClosedAbnormally abnormalClose -> -- socketの状態管理
{ model
| connectionStatus =
ScheduledReconnect
{ time = roundDownToSecond (model.currentTime + abnormalClose.reconnectWait)
}
}
! []
ConnectionStatusChanged connectionStatus -> -- socketの状態管理
let
_ = Debug.log "ConnectionStatusChanged : " connectionStatus
in
{ model | connectionStatus = connectionStatus } ! []
MessageFailed value -> -- channel発信時にサーバエラーを検知
{ model | messageFailed = True } ! []
MessageArrived -> -- channel発信がokだったので、エラーカウントをリセット
{ model | messageCnt = initMessageCnt } ! []
roundDownToSecond : Time -> Time
roundDownToSecond ms =
(ms / 1000) |> truncate |> (*) 1000 |> toFloat
-- Decoder
decodeNewMsg : Decoder { userName : String, point : Point, time : Float }
decodeNewMsg =
JD.map4 (\u lat lng t -> { userName=u, point=[lat, lng], time=t } )
(JD.field "user_name" JD.string)
(JD.field "lat" JD.float)
(JD.field "lng" JD.float)
(JD.field "time" JD.float)
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick TogglePlayer ] [ text <| playerButton model.geo.player ]
, div [] [ text "現在値: "
, text <| toString <| model.geo.point ]
, div [] [ button [ onClick Decrement ] [ text "-" ]
, text (toString model.geo.zoom)
, button [ onClick Increment ] [ text "+" ] ]
, div [] [ text <| viewTime model, text <| viewStatus model ]
, statusMessage model
]
playerButton : Bool -> String
playerButton player =
case player of
True -> "発信停止"
False -> "発信開始"
viewTime : Model -> String
viewTime model =
let
date = Date.fromTime model.geo.time
h = toString <| Date.hour <| date
m = toString <| Date.minute <| date
s = toString <| Date.second <| date
in
"ポイント取得時間:" ++ h ++ ":" ++ m ++ ":" ++ s ++ " "
viewStatus : Model -> String
viewStatus model =
case model.geo.player of
False -> "--- 受信中 ---"
True ->
case model.messageCnt > 0 of
False -> "xxx 発信不良 xxx" ++ (getMessageCnt model)
True ->
case model.messageFailed of
True -> "xxx 発信良好:サーバエラー xxxxx" ++(getMessageCnt model)
False -> "--- 発信良好:サーバ良好 ---" ++ (getMessageCnt model)
getMessageCnt : Model -> String
getMessageCnt model = " cnt = " ++ toString model.messageCnt
statusMessage : Model -> Html Msg
statusMessage model =
case model.connectionStatus of
ScheduledReconnect { time } ->
let
remainingSeconds =
truncate <| (time - model.currentTime) / 1000
reconnectStatus =
if remainingSeconds <= 0 then
"Reconnecting ..."
else
"Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
in
Html.div [ Attr.class "status-message" ] [ Html.text reconnectStatus ]
_ ->
Html.text ""
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
case model.geo.player of
True ->
Sub.batch
[ portGetCurLocation GetCurLocation
, Time.every Time.second Tick
, Time.every (5 * Time.second) Tick5
, phoenixSubscription model
]
False ->
Sub.batch
[ phoenixSubscription model
, Time.every Time.second Tick
]
{--- Initialize a socket with the default heartbeat intervall of 30 seconds ---}
socket : Socket Msg
socket =
Socket.init lobbySocket
|> Socket.onOpen (ConnectionStatusChanged Connected)
|> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
|> Socket.onAbnormalClose SocketClosedAbnormally
--|> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)
|> Socket.reconnectTimer aaa
aaa : Int -> Float
aaa backoffIteration =
let
_ = Debug.log "aaaaaaaaaa : backoffIteration " backoffIteration
in
(backoffIteration + 1) * 5000 |> toFloat
lobby : Model -> String -> Channel Msg
lobby model userName =
case model.geo.player of
True ->
Channel.init "room:lobby"
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.withDebug
False ->
Channel.init "room:lobby"
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.on "new_point" (\msg -> NewMsg msg)
|> Channel.withDebug
phoenixSubscription model =
-- let
-- _ = Debug.log "phoenixSubscription : " model
-- in
Phoenix.connect socket [ lobby model elmUserName ]
##5-3.Model
Model recordを説明します。Geo recordが、JavaScriptとのportsの通信において、使われます。JavaScriptとのやり取りに使う値は、Geo recordに含まれています。それ以外の項目はsocket通信の状態を管理するために使われているものです。geo.playerは発信モードか受信モードのかのフラグに使っています。
-- MODEL
type alias Point =
List Float -- [lat, lng]
type alias Geo =
{ player : Bool
, zoom : Int
, point : Point
, time : Int
}
type alias Model =
{ geo : Geo
, connectionStatus : ConnectionStatus
, currentTime : Time
, messageFailed : Bool
, messageCnt : Int
}
messageCntは位置情報をchannelに発信する時のエラーをカウントします。初期値が5で、発信時に1 decrementします。発信の返答がphoenixから帰ってくれば5にリセットされますが、返答が無ければ4になります。連続して5回返答が無ければ0となり接続以上と判断され、mobileブラウザにエラーと表示されます。この場合ブラウザのリロードで復活させます。
##5-3.update
updateの主要な箇所を確認します。MsgのTickとGetCurLocation、NewMsg payloadが、JavaScriptのやり取りや、channelの送受信を行っている部分です。
発信モードですが、Tickで5秒おきにJavaScriptに現在値を取得する指令を出しているのが、発信モードのメインループとなります。現在地はGetCurLocationで返され、それをchannelに発信します。
受信モードではNewMsgでchannelから発信機の位置が入ってきます。portSetCurLocationを呼ぶことで地図上のマーカーを描き直しています。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
Tick time -> --現在時間 socket再接続のカウントダウンに使われる
{ model | currentTime = time } ! []
Tick5 _ -> -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
(model, portSetCurLocation model.geo)
GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
let
push =
Push.init "room:lobby" "new_msg"
|> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
, ( "time" , JE.string <| toString <| newgeo.time ) ])
|> Push.onOk (\_ -> MessageArrived)
|> Push.onError MessageFailed
_ = Debug.log "NewMsg write : Encoded" newgeo.point
in
{ model | geo=newgeo, messageCnt=model.messageCnt-1 } ! [ Phoenix.push lobbySocket push ]
NewMsg payload -> -- 【受信】発信機の現在位置をchannelから受信する
let
dval = JD.decodeValue decodeNewMsg payload
_ = Debug.log "NewMsg recieved : Decoded" dval
in
case dval of
Ok msg ->
let
oldgeo = model.geo
newgeo = { oldgeo | point = msg.point, time=msg.time }
in
({ model | geo = newgeo}, portSetCurLocation newgeo)
Err err ->
model ! []
#
##5-4.Json Decode
Elmプログラムがchannelから受け取ったデータをdecodeして、Elmのデータに変換する部分です。Json.Decodeを使います。
-- Decoder
decodeNewMsg : Decoder { userName : String, point : Point, time : Int }
decodeNewMsg =
JD.map4 (\u lat lng t -> { userName=u, point=[lat, lng], time=t } )
(JD.field "user_name" JD.string)
(JD.field "lat" JD.float)
(JD.field "lng" JD.float)
(JD.field "time" JD.int)
Json.DecodeでJD.floatやJD.intを使っていますが、これは以下のElixirコードに対応しています。channel moduleではPoison.decode!を使って、latとlngをfloatに変換し、itimeをintに変換してbroadcastしています。Elmではこの型に対応してdecodeしています。この辺はElixirの方が融通が利くので、できるだけElixirで汚れ仕事を行って、Elmにはメインロジックの方を頑張ってもらいたいと思います。
#
def handle_in("new_msg", %{"point" => point, "time" => time}, socket) do
user_name = socket.assigns[:user_name]
[lat,lng] = Poison.decode!(point) # string -> float
itime = Poison.decode!(time) # string -> int
broadcast(socket, "new_point", %{lat: lat, lng: lng, time: itime, user_name: user_name})
#
{:reply, :ok, socket}
end
#
##5-5.subscriptions
Elmではsubscriptionsでイベントリスナーを設定しますが、modelを引数として取り、modelの状態に応じて動的に定義を変更することができます。大切なことなので2度言います。modelが変更されるとsubscriptionsが定義し直されます。
今回は発信モードか受信モードかでリッスンするイベントを変更しています。さらにsocketのステータスもmodelに保存されているので、エラーステータスになって回復した時に、modelが更新されるのでSocketとChannelの再定義が実行され、結果的に自動的にchannelにjoinし直されます。(実際はTickメッセージでmodel.currentTimeが1秒ごとに更新されているので、subscriptionsも1秒ごとに定義し直されます)。このようにとてもシンプルなメカニズムでエラーからの自動回復がなされるのはエレガントですね。
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
case model.geo.player of
True ->
Sub.batch
[ portGetCurLocation GetCurLocation
, Time.every Time.second Tick
, Time.every (5 * Time.second) Tick5
, phoenixSubscription model
]
False ->
Sub.batch
[ Time.every Time.second Tick
, phoenixSubscription model
]
{--- Initialize a socket with the default heartbeat intervall of 30 seconds ---}
socket : Socket Msg
socket =
Socket.init lobbySocket
|> Socket.onOpen (ConnectionStatusChanged Connected)
|> Socket.onClose (\_ -> ConnectionStatusChanged Disconnected)
|> Socket.onAbnormalClose SocketClosedAbnormally
|> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)
lobby : Model -> String -> Channel Msg
lobby model userName =
case model.geo.player of
True ->
Channel.init "room:lobby"
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.withDebug
False ->
Channel.init "room:lobby"
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.on "new_point" (\msg -> NewMsg msg)
|> Channel.withDebug
phoenixSubscription model =
Phoenix.connect socket [ lobby model elmUserName ]
lobby model userNameの定義では、model.geo.playerで場合分けして、発信モードの時は、new_pointイベントをリッスンしないようにしています。不要だからこの方がいいのですが、リッスンすると何故か不安定な動作になるのが疑問ですね?
##5-6.エラー時の再接続
socket切断時の再接続の仕組みをもう少し説明します。完全なドキュメントが存在しないので、大半はexampleコードなどを実行した結果からの知見に基づきます。批判的に読んでいただくことをお勧めします。
socketが何らかのエラーで切断した時は、Socket.reconnectTimerによって再接続されます。この時再接続までの時間をbackoffIterationをもとに計算します。
また再接続までのカウントダウン状況をSocket.onAbnormalCloseとstatusMessageで表示させています。
Socket.reconnectTimer
次に再接続するまでの時間(A)を設定し、再接続を実行する
Socket.onAbnormalClose
次に再接続するまでの時間(A)を引数に、SocketClosedAbnormallyメッセージが送信される。
(A)をもとに次の再接続時刻(B)を計算し、model.connectionStatus = ScheduledReconnect (B) を設定する
statusMessage (view)
ScheduledReconnect (B)をmodel.currentTime(現在時刻)と比較して、
再接続までのカウントダウンを行う。
以下が関係のあるコードの全体です。分かりやすいようにコメントを付けています。時刻と時間(時刻の差分)の言葉の使い方に注意してください。
#
type ConnectionStatus
= Connected
| Disconnected
| ScheduledReconnect { time : Time } -- time: 次に再接続される時刻
type Msg = GetCurLocation Geo
#
| SocketClosedAbnormally AbnormalClose -- AbnormalClose.reconnectWait: 次に再接続されるまでの時間
| ConnectionStatusChanged ConnectionStatus
#
SocketClosedAbnormally abnormalClose -> -- socket異常終了:再接続時刻を設定する
{ model
| connectionStatus =
ScheduledReconnect
{ time = roundDownToSecond (model.currentTime + abnormalClose.reconnectWait)
-- 再接続までの時間 = abnormalClose.reconnectWait <--reconnectTimerで設定される
}
}
! []
ConnectionStatusChanged connectionStatus -> -- socketの状態管理
{ model | connectionStatus = connectionStatus } ! []
#
roundDownToSecond : Time -> Time
roundDownToSecond ms =
(ms / 1000) |> truncate |> (*) 1000 |> toFloat
#
socket : Socket Msg
socket =
Socket.init lobbySocket
#
|> Socket.onAbnormalClose SocketClosedAbnormally -- 再接続の表示に使われる。再接続自体には関係ない。
|> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat) -- 再接続を繰り返しtryする
-- 次に再接続されるまでの時間 ==> AbnormalClose.reconnectWait
#
statusMessage : Model -> Html Msg
statusMessage model =
case model.connectionStatus of
ScheduledReconnect { time } ->
let
remainingSeconds =
truncate <| (time - model.currentTime) / 1000
reconnectStatus =
if remainingSeconds <= 0 then
"Reconnecting ..."
else
"Reconnecting in " ++ (toString remainingSeconds) ++ " seconds"
in
Html.div [ Attr.class "status-message" ] [ Html.text reconnectStatus ]
_ ->
Html.text ""
#
再接続のコードは以下の個所です。再接続がリトライされる度に backoffIteration = 0, 1, 2, 3... と値が変わります。結果的に5秒、10秒、15秒、20秒...後に再接続が試みられていきます。再接続までの時間がだんだん長くなっていきます。
|> Socket.reconnectTimer (\backoffIteration -> (backoffIteration + 1) * 5000 |> toFloat)
-- 再接続tryを繰り返す。次のtryまでの時間 => AbnormalClose.reconnectWait
今回は以上で終わります。
(2018/04/29)追記
DBに蓄えたデータを見ていたら、5秒おきに蓄えられているはずなのに、はるかに多い、しかも不規則なデータを確認しました。一見するとJavaScript側かElmで、予想外のイベントやcallbackが生じているように思えました。爽やかな季節の休日にデバッグにいそしんだ結果、もっと基本的な不具合を見つけました。TimeはFloatとして定義されているのに、他言語のunixtimeの扱いのようにIntとして扱っている箇所がありました。toFloatとかを使って、無理やりコンパイラを通している箇所もありました。
type alias Time =
Float
この基本的な不具合の結果、上記の不具合が発生していたらしいのです。まじめに型を適切なものに修正したら、DBのデータもキチンと5秒ごとに揃いました。今回の件で明示的なエラーが発生せず、不規則なイベントが発生しているかのような現象になるのが怖いと思いました。コンパイラの型エラーはまじめに取り組まないといけませんね。本記事のElmコードも修正しました。
/////