本記事はElmとPhoenixを用いて、如何にスマートにアプリ開発を行えるかを試みた記録です。断片的な技術の紹介ではなく、1個の完全なアプリ開発を目指したものです。ElmとPhoenixを勉強し始めて半年ぐらいです。特にElmに関してですが大変気に入っているのですが、果たして現実的なプログラムに耐えうるものなのか、少し疑問を抱いていました。今は結構使えると実感を持ち始めています。
Elmの相方にはPhoenix(Elixir)を選びました。純粋でないにしても関数型言語ですし、スケーラビティに優れているし、扱いやすい。特にPhoenixのChannelは、かつてMeteorに求めていたリアルタイム性の代替になる、魅力的なものでした。
Elm + Phoenixの組み合わせは大変魅力的です。今回のアプリは紹介するには少し大きすぎるのですが、そのボリューム感も含めてお伝えできればいいかなと思っています。
#1.マルチユーザ対応geolocationアプリ
本アプリは、スマホを持ち歩いて位置情報を発信し、家のPCでそれを受信しマップ上に表示するものです。同時に位置情報はDBに保存され、後にデータ解析に使われます。歩いた距離や時間、平均速度や最高速度などを知ることができます。
これらの位置データはユーザ単位で管理されますので、自分のデータは知ることができますが、他人のデータを知ることはできません。ですから本アプリを使うにはユーザ登録を行い、ログインする必要があります。
一度ログインすれば、ログイン情報はブラウザのlocalstorageに保存されますので、次回からは自動的にログイン状態になります。localstorageのデータは、明示的に消去しない限り、永続的に使うことが可能です。但し実際にはログイントークンの有効期限が1か月なので、1か月に一回再ログインする必要があります。
##1-1.画像でアプリ紹介
###4.発信モード
「発信開始」ボタンを押すと発信モードになります。
発信モードのスマホを持ち歩くと、現在位置を受信機に知らせ、受信機のマップと現在位置を同期します。
同時にサーバ上に現在位置の情報を蓄えていきます。
###5.再現モード
「検索ページ」をクリックすると再現ページに移動します。
時間を指定して「再現」ボタンを押すと、歩いた軌跡をマップ上に再現し、複数のマーカで時刻を表示します。
同時のデータ解析を行い、距離や時間、速度を表示してくれます。
##1-2.プログラム設計(HTTP, Channel)
全体的な構成要素は大雑把に言って以下のように、JavaScriptとElm、Phoenixの3つになります。JavaScriptとElmはPortsで会話をします。ElmとPhoenixはHtmlとAPI、Channelの3種類を使い分けます。
JavaScript -- (Ports) -- Elm -- |--(HTTP Html)--| -- Phoenix
|--(HTTP API) --|
|--(Channel) --|
Pageはトップページと再現ページの2画面あります。それぞれのパスは "/" と"/page2" で表示します。それぞれ専用のElmアプリを読み込み、以下のような役割を果たします。
"/" トップページ App.elm ユーザ登録・ログイン・geolocation発信機・受信機
"/page2" 再現ページ Page2.elm geolocation再現機・データ解析
上のそれぞれのページから、以下のREST APIが呼ばれます。
"/users" ユーザ登録 トップページ
"/sign_in" ログイン トップページ
"/points" geolocation取得 再現ページ
またトップページからはChannelを利用して、HTTPではできないリアルタイムの送受信を行っています。
"room"+user_name new_msg geolocationの発信 トップページ
"room"+user_name new_point geolocationの受信 トップページ
##1-3.プログラム設計(Ports - elmとJavaScriptの会話)
クライアントコードのほとんどはElmで書くのですが、一部JavaScriptを利用する必要があります。ElmからはPortsを通してそれらの機能にアクセスします。
leaflet.js -- 地図を表示し、現在位置にマーカを置き、歩いた軌跡を描きます
navigator.geolocation -- 現在位置を取得します
localstorage -- 認証トークン(JWT)を永続的に保存し、ページ間で共有します。
トップページでは以下のようなPortsが定義されます。
-- OUTGOING PORT
portInitCurLocation :(発信・受信)ElmからJavaScriptにlocation初期値を渡します
portSetCurLocation :(発信)ElmからJavaScriptに現在のlocationを取得し、地図上にマーカーを置き、geoデータをElmに返すように指示します。
(受信)chnnelで受信したgeoデータを渡し、地図上にマーカを置くよう指示します。
port portSetToken : JWTをlocalstorageに保存する
port portGetToken : localstorageのJWTを返すように依頼する
-- INCOMING PORT
port portGetCurLocation : 現在のlocationを返す
port portResToken : JWTを返す。NULLだったら未ログインと判断する。
再現ページでは以下のようなPortsが定義されます。
-- OUTGOING PORT
portLocations : 歩いた軌跡のラインを描きます
portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
portGetToken : localstorageのJWTを返すように依頼する
-- INCOMING PORT
portResNeo : 2地点の距離の計算結果を返します。
portResToken : JWTを返す。NULLだったら未ログインと判断する。
##1-4.プログラム設計(DB設計)
ログイン認証用のユーザテーブルです。passwordはハッシュ値を保存します。
create table(:users) do
add :email, :string
add :name, :string
add :password_hash, :string
timestamps()
end
位置情報(geolocation)を保存しているテーブルです。user_idでタグ付けされています。
create table(:point) do
add :user_id, :integer
add :lat, :float
add :lng, :float
add :time, :integer
end
##1-5.プログラム設計(SSLと認証)
Phoenixにおいては以下のようにSSLと認証が必要になります。Elm側でも対応が必要になりますが、後でElmの説明の時に説明します。
SSL - JavaScriptでnavigator.geolocationを使うために必要
認証 - Guardian(JWT)とComeonin(ハッシュ)
#2.Phoenix環境構築
##2-1.SSLの設定
mix phx.new multi_leaflet
cd multi_leaflet
秘密キー(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
#
config :multi_leaflet, MultiLeafletWeb.Endpoint,
http: [port: 4009],
https: [port: 4443,
otp_app: :multi_leaflet,
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 MultiLeafletWeb.UserSocket do
use Phoenix.Socket
## Channels
channel "room:*", MultiLeafletWeb.RoomChannel
#
room_channel.exを以下のように作成します。これは後のElmクライアントを実装に符合しています。
defmodule MultiLeafletWeb.RoomChannel do
use MultiLeafletWeb, :channel
def join("room:"<>subtopic, %{"user_name" => user_name}, socket) do
IO.puts ("subtopic=#{subtopic}")
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, "user_id" => user_id, "time" => time}, socket) do
user_name = socket.assigns[:user_name]
[lat,lng] = Poison.decode!(point) # string -> float
itime = Poison.decode!(time) # string -> int
id = Poison.decode!(user_id) # string -> int
broadcast(socket, "new_point", %{lat: lat, lng: lng, time: itime, user_name: user_name})
second = Kernel.trunc(itime/1000) # DBへは秒で保存
# p1 = %MultiLeaflet.Point{user_id: id, lat: lat, lng: lng, time: second}
# MultiLeaflet.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
以下のように"room:"<>subtopicで受けています。つまりサブトピック名は変数となっており、任意の文字列がマッチします。これは後にElmクライアントから、ログインユーザ名が渡されます。つまりユーザ名毎にチャットルームができていくイメージですね。同じユーザ名でログインした受信機と発信機がgeolocation情報を共有し、マップの再現を行えます。
def join("room:"<>subtopic, %{"user_name" => user_name}, socket)
terminate/2は、例えばブラウザを閉じた時にとかのsocket通信がクローズした時に呼ばれます。
room_channel.exでは外部データ(Json)をElixirに取り込む(Decode)ためにpoisonを使いますので、インストールします。
#
defp deps do
[
#
{:poison, "~> 3.1"}
]
end
#
以下のコマンドでインストールします。
mix deps.get
以上でChannelの設定が終了です。
##2-3.Ecto.Repo(PostgreSQL)の設定
Elixirで設定する時は以下の過去記事にあるように多少ステップが複雑ですが、Phoenixでは初期設定でほとんど行われており、よりシンプルです。特にEcto.Repoの設定が楽になります。詳細を知りたいときは過去記事を参照してください。
Elixir Ecto のまとめ - Qiita
Repo(Repositories)はdata storeのラッパーで、データストアに対するAPIを提供します。EctoではRepository を通して create, update, destroy や query を発行することができます。 ですからRepository は、データベースとコミュニケーションするための adapter や credentialsを知る必要があります。
Phoenixの場合は自動で様々な設定が行われているので、Ecto.Repoに関しては以下の設定ファイルの登録だけでokです。
#
config :multi_leaflet, MultiLeaflet.Repo,
adapter: Ecto.Adapters.Postgres,
database: "multi_leaflet_repo",
username: "postgres",
password: "postgres",
hostname: "localhost"
#
以上で準備は終わっているのでDBを作成します。以下のコマンドを打ちます。
$ mix ecto.create
##2-4. Point テーブル&Schemaの作成
(1) Point テーブルの作成
geolocationデータを蓄えておくテーブルの作成を行います。まず以下のmixコマンドを打ちます。
$ mix ecto.gen.migration create_point
* creating priv/repo/migrations
* creating priv/repo/migrations/20180506003818_create_point.exs
ファイル priv/repo/migrations/20180506003818_create_point.exs を編集して以下のようにします。
defmodule MultiLeaflet.Repo.Migrations.CreatePoint do
use Ecto.Migration
def change do
create table(:point) do
add :user_id, :integer
add :lat, :float
add :lng, :float
add :time, :integer
end
end
end
最後に上のmigrationファイルを走らせて、テーブルを作成します。
$ mix ecto.migrate
もしこの時migrationに失敗したら、mix ecto.rollbackでこのchangeをundoできます。
(2) Point Schemaの作成
Ecto.Schema はDB テーブルを Elixir struct に map するために使います。
LeafletChannel.Point schemaは以下のような構文で作られ、Elixir structに落とし込まれます。schemaはデフォルトでidというintegrのfieldが自動追加されます。field macro はname と type で field を定義します。
defmodule MultiLeaflet.Point do
use Ecto.Schema
schema "point" do
field :user_id, :integer
field :lat, :float
field :lng, :float
field :time, :integer
end
end
room_channel.exの初期設定の段階では以下の2行はコメントアウトしていましたが、LeafletChannel.Point Schemaが定義されたのでコメントを外してください。
#
p1 = %MultiLeaflet.Point{user_id: id, lat: lat, lng: lng, time: second}
MultiLeaflet.Repo.insert(p1)
#
schemaファイルを作ったらiex -S mixでElixir shellを立ち上げ、schemaを使ってみます。直接PostgreSQLのコマンドを叩くことはなく、Ectoレベルでのコマンドで十分確認できます。
iex -S mix
p1 = % MultiLeaflet.Point{user_id: 99, lat: 12.3, lng: 23.4, time: 12345}
MultiLeaflet.Repo.insert(p1)
import Ecto.Query, only: [from: 2]
MultiLeaflet.Repo.all(from p in MultiLeaflet.Point, where: p.user_id > 0)
MultiLeaflet.Repo.delete_all(MultiLeaflet.Point)
(3) Pointリソースの定義
PointテーブルへアクセスするAPIを定義します。"/points"パスでアクセスします。
defmodule MultiLeafletWeb.Router do
#
scope "/api", MultiLeafletWeb do
pipe_through :api
get "/points", PointController, :index
end
end
contorollerではEcto.Queryを使ってDBからデータを取得しています。問い合わせの定義は、通常のSQLをラッピングしてElixir構造体で表現しています。
defmodule MultiLeafletWeb.PointController do
use MultiLeafletWeb, :controller
alias MultiLeaflet.Point
# Imports only from/2 of Ecto.Query
import Ecto.Query, only: [from: 2]
def index(conn, %{"start" => start, "stop" => stop} ) do
resource = MultiLeaflet.Guardian.Plug.current_resource(conn)
id = resource.id
#IO.inspect resource
start = Timex.parse!(start<>" Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z" , :strftime) |> Timex.to_unix
stop = Timex.parse!(stop<>" Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z" , :strftime) |> Timex.to_unix
points = MultiLeaflet.Repo.all(from p in Point, where: p.user_id == ^id and p.time > ^start and p.time < ^stop, order_by: [desc: p.time])
render(conn, "index.json", points: points)
end
end
Timex.parse!でTime Zoneを指定しないと9時間ずれてしまうので注意してください。%Z で指定します。
iex(23)> t=Timex.parse!("2013-03-05 12:30:45 Asia/Tokyo", "%Y-%m-%d %H:%M:%S %Z", :strftime)
#DateTime<2013-03-05 12:30:45+09:00 JST Asia/Tokyo>
iex(24)> t |> Timex.to_unix
1362454245
viewではElmクライアントに返すJsonを定義します。
defmodule MultiLeafletWeb.PointView do
use MultiLeafletWeb, :view
alias MultiLeafletWeb.PointView
def render("index.json", %{points: points}) do
%{data: render_many(points, PointView, "point.json")}
end
def render("point.json", %{point: point}) do
%{id: point.id,
lat: point.lat,
lng: point.lng,
time: point.time}
end
end
##2-5.Userリソースの作成
マルチユーザ対応アプリなので、Userリソースを作成します。Pointテーブルでは手動でテーブルとSchemaを作成しました。ここではmix phx.gen.jsonというGeneraterを使い、JSON resource(User)のテーブルやSchemaだけでなく、controllerやviews, context も自動生成します。
mix phx.gen.json Users User users email:string name:string password_hash:string
第1引数のUsersがcontextで、第2引数のUserがリソース名(単体)、第3引数のusersがリソース名(複数)です。
routerに以下の行を追加します。
defmodule MultiLeafletWeb.Router do
#
# Other scopes may use custom stacks.
scope "/api", MultiLeafletWeb do
pipe_through :api
resources "/users", UserController, except: [:new, :edit]
get "/points", PointController, :index
end
end
このresourcesのルートで、以下のようなREST APIが確立します。但し:new と :editについては明示的に除外されています。
https://hexdocs.pm/phoenix/routing.html#resources
user_path GET /users HelloWeb.UserController :index
user_path GET /users/:id/edit HelloWeb.UserController :edit
user_path GET /users/new HelloWeb.UserController :new
user_path GET /users/:id HelloWeb.UserController :show
user_path POST /users HelloWeb.UserController :create
user_path PATCH /users/:id HelloWeb.UserController :update
PUT /users/:id HelloWeb.UserController :update
user_path DELETE /users/:id HelloWeb.UserController :delete
mix phx.gen.jsonコマンドは以下のファイルを生成してくれます。
lib/multi_leaflet/users/user.ex --- schema ,with an users table
lib/multi_leaflet/users/users.ex --- context module ,for the users API
lib/multi_leaflet_web/controllers/user_controller.ex --- controller
lib/multi_leaflet_web/views/user_view.ex --- view
またmix phx.gen.jsonコマンドは以下のようなmigrationファイルも生成してくれます。
priv/repo/migrations/20180506032759_create_users.exs
以下のコマンドでmigrationファイルからテーブルを作成します。ここまでで"/api/users"というpathに対するContorollerやView、Schema、テーブルなどの基本的なフレームが出来上がりです。簡単でいいですね。
mix ecto.migrate
##2-6.再現ページのリソース
再現ページのリソースを設定しておきます。
router.exにパス"/page2"を追加します。
#
scope "/", MultiLeafletWeb do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
get "/page2", Page2Controller, :redraw
end
#
controllerです。
defmodule MultiLeafletWeb.Page2Controller do
use MultiLeafletWeb, :controller
def redraw(conn, _params) do
render conn, "page2.html"
end
end
viewです。
defmodule MultiLeafletWeb.Page2View do
use MultiLeafletWeb, :view
end
テンプレートについては、クライアントの再現ページのところで示します。とりあえずこのままで先に進みます。
3.ユーザ認証
マルチユーザ対応なのでユーザ認証が必要になります
##3-1.Guardian1.0の設定
PhoenixでJWTを扱うために、ueberauth/guardian ライブラリを使います。
まずシークレットキーを作成しておきます。
mix phx.gen.secret
EM5heTVxaLpqUa4DgG9mU4S5RMQirYofwpaBYdxdTdhmyvrTetGUFSHEg1J65jQy
次に以下のような、Implementation Moduleを実装します。これはserializationをハンドリングします。 ここがGuardian1.0で変わったところでもあります。
subject_for_tokenはtokenに含めるsubjectの値を返す関数で、token作成時に呼ばれます。何の値でも構わないのですが、一般的にResourceにユニークな値が良いです。ここではuser id(user tableのid)を返します。resource_from_claimsは引数のclaimsの中のsubject(上の場合で言えばuser id)をkeyとして、resourceを取得して返します。ここではsub=idなので、userを取得して返しています。
defmodule MultiLeaflet.Guardian do
use Guardian, otp_app: :multi_leaflet
alias MultiLeaflet.Repo
alias MultiLeaflet.Users.User
def subject_for_token(resource, _claims) do
{:ok, to_string(resource.id)}
# tokenをdecodeすると、tokenの文字列の一部としてsub=xxxとidが表示される。
# resource_from_claimsの中でclaims["sub"]としてidが取り出されている。
end
def resource_from_claims(claims) do
id = claims["sub"]
resource = Repo.get(User, id)
{:ok, resource}
end
end
次にconfigファイルにGuardianの設定を書きます。ここのシークレットキーに最初に生成したものを設定します。以下の行を末尾に追加します。
#
config :multi_leaflet, MultiLeaflet.Guardian,
issuer: "multi_leaflet",
secret_key: "EM5heTVxaLpqUa4DgG9mU4S5RMQirYofwpaBYdxdTdhmyvrTetGUFSHEg1J65jQy"
#
mix.exsの deps に guardian を追加します。面倒なので後で使うライブラリもここで登録しておきます。
#
defp deps do
[
{:phoenix, "~> 1.3.0"},
{:phoenix_pubsub, "~> 1.0"},
{:phoenix_ecto, "~> 3.2"},
{:postgrex, ">= 0.0.0"},
{:phoenix_html, "~> 2.10"},
{:phoenix_live_reload, "~> 1.0", only: :dev},
{:gettext, "~> 0.11"},
{:cowboy, "~> 1.0"},
{:poison, "~> 3.1"}, # これを追加
{:timex, "~> 3.1"}, # これを追加
{:guardian, "~> 1.0"}, # これを追加
{:comeonin, "~> 3.0"} # これを追加
]
end
#
次のコマンドでguardianをインストールします。
mix do deps.get, compile
以上でGuardianの設定は終了です。
##3-2.Comeoninの設定
JWTはGuardianで扱いますが、パスワードのハッシュ化などはComeoninで扱います。
まずphoenix.gen.jsonコマンドで作成したlib/multi_leaflet/users/user.exを調整します。テーブルのfieldとしてpassword_hashを作成しました。しかしユーザからの入力は平文のpasswordで受け取りますので、その受け皿として、Elixir structにpasswordの項目を追加します。この項目はテンポラリなものです。入力を受け取りハッシュを計算するまで必要ですが、最終的なテーブルには必要ないものなのでvirtualとします。以下のようになります。
passwordはテーブルに保存する時に、Comeonin.Bcrypt.hashpwsaltでハッシュ化したものを保存します。
また認証時にはクライアントから送られてきたpasswordをComeonin.Bcrypt.checkpwでハッシュ化し、保存してある予めハッシュ化されたpasswordと比較することで行います。
defmodule MultiLeaflet.Users.User do
use Ecto.Schema
import Ecto.Changeset
schema "users" do
field :email, :string
field :name, :string
field :password, :string, virtual: true # これを追加
field :password_hash, :string
timestamps()
end
@doc false
def changeset(user, attrs) do
user
|> cast(attrs, [:email, :name, :password])
|> validate_required([:email, :name, :password])
|> validate_changeset
end
defp validate_changeset(user) do
user
|> validate_length(:email, min: 5, max: 255)
|> validate_format(:email, ~r/@/)
|> unique_constraint(:name)
|> validate_length(:password, min: 8)
|> validate_format(:password, ~r/^(?=.*[a-z])(?=.*[A-Z])(?=.*\d).*/, [message: "Must include at least one lowercase letter, one uppercase letter, and one digit"])
|> generate_password_hash
end
defp generate_password_hash(changeset) do
case changeset do
%Ecto.Changeset{valid?: true, changes: %{password: password}} ->
put_change(changeset, :password_hash, Comeonin.Bcrypt.hashpwsalt(password))
_ ->
changeset
end
end
alias MultiLeaflet.Repo
alias MultiLeaflet.Users.User
def find_and_confirm_password(name, password) do
case Repo.get_by(User, name: name) do
nil ->
{:error, :not_found}
user ->
if Comeonin.Bcrypt.checkpw(password, user.password_hash) do
{:ok, user}
else
{:error, :unauthorized}
end
end
end
end
ここのchangesetはcreate_user/1から以下のようにして呼ばれ、最終結果がDBに挿入されます。このusers.exもphx.gen.jsonコマンドで自動生成されたものです。
#
def create_user(attrs \\ %{}) do
%User{}
|> User.changeset(attrs)
|> Repo.insert()
end
#
またfind_and_confirm_password/2という関数を作りましたが、これはnameとパpasswordでユーザ認証を行う関数です。Comeonin.Bcrypt.checkpw(password, user.password_hash)でチェックしてokかerrorかを決めます。このレベルではjwtは関係ありません。
ここで、以前にmix phx.gen.jsonで自動生成した/migrationsファイルを確認しておきます。上のSchemaに対応するテーブルを作成するためのものです。
defmodule MultiLeaflet.Repo.Migrations.CreateUsers do
use Ecto.Migration
def change do
create table(:users) do
add :email, :string
add :name, :string
add :password_hash, :string
timestamps()
end
create unique_index(:users, [:name])
end
end
comeoninライブラリは既にインストール済みなので、以上でComeoninの設定を終わりとなります。
ここでテストユーザを登録しておきます。別の端末からmix phx.serverでサーバを立ち上げ、以下のコマンドを打ちます。
curl -H 'Content-Type: application/json' -X POST -d '{"user": {"email": "hello@world.com","name": "John","password": "MyPass55"}}' http://localhost:4009/api/users
curl -X GET "http://localhost:4009/api/users"
##3-2.ログイン(sign_in -- Session)
さてログインのために "/sign_in"というパスを振り分け、SessionControllerで対応することにします。router.exを修正します。
defmodule MultiLeafletWeb.Router do
#
# Other scopes may use custom stacks.
scope "/api", MultiLeafletWeb do
pipe_through :api
post "/sign_in", SessionController, :sign_in
resources "/users", UserController, except: [:new, :edit]
get "/points", PointController, :index
end
end
SessionControllerを実装し、sign_in/2を定義します。またコメントアウトしましたけど、decode_and_verify/1で得られたjwtの中身を確認できます。確かにidが含まれていることが確認できます。これは注意点かな、と思うのですが、Phx13Gdn10.Guardian.decode_and_verifyは、このようにフルで指定してください。Guardian.decode_and_verifなどと誤って省略してしまうと、コンパイルは通りますが実行エラーとなり嵌ります。ネットでも嵌っている方がいたようなので、要注意です。
defmodule MultiLeafletWeb.SessionController do
use MultiLeafletWeb, :controller
alias MultiLeaflet.Users.User
def sign_in(conn, %{"session" => %{"name" => name, "password" => password}}) do
case User.find_and_confirm_password(name, password) do
{:ok, user} ->
{:ok, jwt, _full_claims} = MultiLeaflet.Guardian.encode_and_sign(user)
# {:ok, claims} = MultiLeaflet.Guardian.decode_and_verify(jwt)
# IO.inspect(claims)
conn
|> render("sign_in.json", user: user, jwt: jwt)
{:error, _reason} ->
conn
|> put_status(401)
|> render("error.json", message: "Could not login")
end
end
end
次にviewを作成します。
defmodule MultiLeafletWeb.SessionView do
use MultiLeafletWeb, :view
def render("sign_in.json", %{user: user, jwt: jwt}) do
%{"token": jwt, "name": user.name}
end
def render("error.json", %{message: msg}) do
%{"error": msg}
end
end
ログイン設定が完了したので、以下のcurlコマンドでログインを行います。tokenが長すぎて見づらいですが、望みの結果が表示されます。tokenを得たブラウザはログイン状態に入ります。次のリクエストからtokenをヘッダーにつけて渡すことになります。
curl -H 'Content-Type: application/json' -X POST -d '{"session": {"name": "John","password": "MyPass55"}}' http://localhost:4009/api/sign_in
{"token":"eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhdWQiOiJtdWx0aV9sZWFmbGV0IiwiZXhwIjoxNTI5MjE5MDY4LCJpYXQiOjE1MjY3OTk4NjgsImlzcyI6Im11bHRpX2xlYWZsZXQiLCJqdGkiOiJhNmY3ZmJhMi02MDkyLTQ0ZjMtOGUyMi05MmNhOGI1NjIzYmQiLCJuYmYiOjE1MjY3OTk4NjcsInN1YiI6IjEiLCJ0eXAiOiJhY2Nlc3MifQ.VdhAnIDEIpKalU1HpCYl7I8c9c8LUet3BIWY-076dlG-m3d_5U6Zu6vLsLqNiFTHT2PmHXmAaZUD5emxw4av4A","name":"John"}[root@www13134uf multi_leaflet]#
念のために、ログイン失敗のケースも見ておきましょう。ユーザ名とパスワードを少し変えてログインしてみます。返ってくるエラーメッセージも期待通りです。
curl -H 'Content-Type: application/json' -X POST -d '{"session": {"name": "Paul","password": "MyPass56"}}' http://localhost:4009/api/sign_in
{"error":"Could not login"}
##3-3.アクセス制限(Authorization)
さて現状では、以下のユーザ一覧取得のコマンドは、非ログイン状態でも成功します。これをログインユーザのみに制限したいと思います。
curl -X GET "http://localhost:4009/api/users"
{"data":[{"password_hash":"$2b$12$S10Ls1YwQlrR/xLzsKT64OIYHaLxvopgHNC6ttfIhdv01V/SM3.pa","name":"John","id":1,"email":"hello@world.com"}]}
ログインした時だけ "/api/users" のアクセスを許可(Autherization)するように変更します。
Authentication schemeを構築するために、AuthPipeline moduleを作って、関連のあるplugを集めます。またAuthErrorHandler moduleを作って、エラーハンドラーを定義します。
defmodule MultiLeaflet.Guardian.AuthPipeline do
@claims %{typ: "access"}
use Guardian.Plug.Pipeline, otp_app: :MultiLeaflet,
module: MultiLeaflet.Guardian,
error_handler: MultiLeaflet.Guardian.AuthErrorHandler
plug Guardian.Plug.VerifySession, claims: @claims
plug Guardian.Plug.VerifyHeader, claims: @claims, realm: "Bearer"
plug Guardian.Plug.EnsureAuthenticated
plug Guardian.Plug.LoadResource, ensure: true
end
defmodule MultiLeaflet.Guardian.AuthErrorHandler do
import Plug.Conn
def auth_error(conn, {type, reason}, _opts) do
body = Poison.encode!(%{message: to_string(type)})
send_resp(conn, 401, body)
end
end
Guardian.Plug.VerifySessionはsessionの中のtokenを見つけ正しいかvalidateします。今回は特にsessionを利用していないので無視されます。Guardian.Plug.VerifyHeaderはAuthorization headerを見て、tokenが存在し改竄が無いことを確認します。一般的に、jwtはidを内包しているだけでなく、改竄チェックが行えるという優良な性質を持っています。またrealm: "Bearer"を明示的に指定する必要があります。これ無しだとElmのJWTパッケージを通してアクセスし他時に認証が通りませんでした。。このような操作を経て、Guardian.Plug.EnsureAuthenticatedは正しいtokenの存在を確認し、存在しなければauth_errorを呼んで:unauthenticatedを返します。Guardian.Plug.LoadResource は改竄チェックを終えたtokenからリソース(user)をロードします
Authenticationの仕上げに、router.exに2か所追加をします。
defmodule MultiLeafletWeb.Router do
use MultiLeafletWeb, :router
pipeline :browser do
plug :accepts, ["html"]
plug :fetch_session
plug :fetch_flash
plug :protect_from_forgery
plug :put_secure_browser_headers
end
pipeline :api do
plug :accepts, ["json"]
end
pipeline :authenticated do # (1)3行追加
plug MultiLeaflet.Guardian.AuthPipeline
end
scope "/", MultiLeafletWeb do
pipe_through :browser # Use the default browser stack
get "/", PageController, :index
get "/page2", Page2Controller, :redraw
end
# Other scopes may use custom stacks.
scope "/api", MultiLeafletWeb do
pipe_through :api
post "/sign_in", SessionController, :sign_in
resources "/users", UserController, except: [:new, :edit]
pipe_through :authenticated # (2)pointsへのアクセス制限
get "/points", PointController, :index
end
end
ここで特に注意しておきたいのは、/pointsは(1)と(2)で設定されたAuthenticationによって、ログインユーザのアクセスのみに制限されるようになることです。
#4.トップページ
##4-1.トップページ(JavaScriptプログラム)
Phoenixのテンプレートをみます。まずレイアウトファイルですが、以下のようなシンプルなものに置き換えます。
<!DOCTYPE html>
<%= render @view_module, @view_template, assigns %>
このHtmlではApp.elmとLeaflet.jsのコンテナを提供するとともに、前に説明したトップページのPortsの実装を行っています。
<html>
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<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>
<br />
<div id="elm-area"></div>
<br /><br />
<div align="center">
<div id="mapid"></div>
</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.portSetToken.subscribe( (tn) => {
if( tn && tn[0] && tn[1] ) {
localStorage.setItem("token",tn[0]);
localStorage.setItem("name",tn[1]);
} else { // tn[0]=="" or tn[1]==""
localStorage.removeItem("token");
localStorage.removeItem("name");
}
} )
app.ports.portGetToken.subscribe( (key) => {
// key : not used
const token = localStorage.getItem("token");
const name = localStorage.getItem("name");
if( token && name ) {
app.ports.portResToken.send([token,name]);
} else {
app.ports.portResToken.send(["",""]);
}
})
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>
##4-2.Elm環境設定
まずはstaticファイルの設定を行います。onlyをコメントアウトします。
#
plug Plug.Static,
at: "/", from: :multi_leaflet, gzip: false
# only: ~w(css fonts images js favicon.ico robots.txt)
#
次に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", "Page2.elm"], // Elmプログラム
outputFolder: "../static/js/vendor" // Elmの実行形をstaticに吐き出す
}
},
#
(※重要)outputFolderにはvendorサブディレクトリが指定されていることに注意してください。brunchにおいてはvendorにあるfileが優先的にロードされるようです。つまりElmのjsファイルが安全にロードされます。単にoutputFolder を "../static/js"と vendor抜きで指定すると、かなりの確率でElmのjsファイルのロードに失敗してしまいます。
必要なパッケージをelm-package.jsonに追加します。
{
#
"dependencies": {
"elm-lang/core": "5.1.1 <= v < 6.0.0",
"elm-lang/html": "2.0.0 <= v < 3.0.0",
"elm-lang/http": "1.0.0 <= v < 2.0.0",
"justinmimbs/elm-date-extra": "3.0.0 <= v < 4.0.0",
"rundis/elm-bootstrap": "4.0.0 <= v < 5.0.0",
"saschatimme/elm-phoenix": "0.3.0 <= v < 1.0.0",
"simonh1000/elm-jwt": "5.3.0 <= v < 6.0.0"
},
#
}
rundis/elm-bootstrapでBootstrapを使います。スマホ用の画面を作ります。Elm Bootstrapについて - Qiita
saschatimme/elm-phoenixでPhoenixのChannelを使います。Phoenix Channelとelm-phoenixについて - Qiita
simonh1000/elm-jwtでJWTを使えるようにします。Phoenix1.3+Guardian1.0でJWT - Qiita
以下のコマンドで一括インストールします。
elm-github-install
この時elm-phoenixのexampleもインストールされ、cssファイルなどが勝手にロードされてしまうので、削除します。
rm -rf ./assets/elm/elm-stuff/packages/saschatimme/elm-phoenix/0.3.2/example/
以上でElmクライアントの環境設定を終わります。
##4-3.Elmの全コード
port module App exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (onClick,onSubmit,onInput)
--import Date exposing (Date, hour, minute, second)
import Date exposing (..)
import Date.Extra as Date
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 Http
import Json.Encode as JE
import Json.Decode as JD exposing (Decoder,field,string)
import Jwt exposing (..)
import Bootstrap.Alert as Alert
import Bootstrap.Form as Form
import Bootstrap.Form.Input as Input
import Bootstrap.Button as Button
import Bootstrap.Card as Card
import Bootstrap.Card.Block as Block
import Bootstrap.Grid as Grid
import Bootstrap.Grid.Col as Col
import Bootstrap.Grid.Row as Row
import Bootstrap.Text as Text
import Bootstrap.Utilities.Spacing as Spacing
-- OUTGOING PORT
port portInitCurLocation : Geo -> Cmd msg
port portSetCurLocation : Geo -> Cmd msg
port portSetToken : (String, String) -> Cmd msg --(token,name)
port portGetToken : String -> Cmd msg
-- INCOMING PORT
port portGetCurLocation : (Geo -> msg) -> Sub msg
port portResToken : ((String,String) -> msg) -> Sub msg --(token,name)
registerUrl =
"/api/users"
authUrl =
"/api/sign_in"
lobbySocket : String
lobbySocket =
"wss://www.mypress.jp:4443/socket/websocket"
main : Program Never Model Msg
main =
program
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Point =
List Float -- [lat, lng]
type alias Geo = -- PortsでJavaScript(Leaflet.js)とやり取りするデータ
{ player : Bool --- 発信モード=>True、受信モード=>False
, zoom : Int
, point : Point
, time : Time
}
type alias Cred = --ログインとユーザ登録で使用
{ name : String
, email : String
, password : String
, password2 : String
}
type alias NameEmail = -- only for Decoder at User Register
{ name : String
, email : String
}
type alias TokenName =
{ token : String
, name : String
}
type alias JwtToken =
{ aud : String
, exp : Int
, iat : Int
, sub : String
}
type LoginStatus
= LoginOff
| LoginFail
| LoginSuccess
| RegisterSuccess
| RegisterFail
| RegisterEmailFail
showLoginStatus : LoginStatus -> String
showLoginStatus status =
case status of
LoginOff -> "まだログインしていません"
LoginFail -> "ログインに失敗しました"
LoginSuccess -> "ログイン中です"
RegisterSuccess -> "ユーザ登録に成功しました"
RegisterFail -> "ユーザ登録に失敗しました"
RegisterEmailFail -> "確認用パスワードが一致しません"
type alias Model =
{ geo : Geo
, connectionStatus : ConnectionStatus -- Socket接続状況
, currentTime : Time -- 現在時間
, messageFailed : Bool -- Channel書込時のサーバエラー
, messageCnt : Int -- Channel書込失敗カウンタ
, inputCred : Cred
, tokenName : Maybe TokenName -- Nothing=>ログイン画面
, jwtSub : String -- user_id
, jwtExp : Time -- JWTの有効期限
, loginStatus : LoginStatus
, userRegister : Bool -- True=>ユーザ登録画面
}
point0 : Point
point0 = [35.7102, 139.8132] --lat, lng
initMessageCnt = 5
initCred =
Cred "" "" "" ""
initSub : String
initSub = "0"
initExp : Time
initExp = 0
initModel : Model
initModel =
Model (Geo False 15 point0 0) Disconnected 0 False initMessageCnt initCred Nothing initSub initExp LoginOff False
init : ( Model, Cmd Msg )
init =
initModel ! [ portInitCurLocation initModel.geo
, portGetToken "token"
]
-- UPDATE
type ConnectionStatus
= Connected
| Disconnected
| ScheduledReconnect { time : Time }
type Field
= Fname
| Femail
| Fpass
| Fpass2
type Msg = GetCurLocation Geo
| Tick Time
| Tick5 Time
| Increment
| Decrement
| TogglePlayer
| NewMsg JD.Value
| SocketClosedAbnormally AbnormalClose
| ConnectionStatusChanged ConnectionStatus
| MessageFailed JD.Value
| MessageArrived
| Login
| FormInput Field String
| LoginResult (Result Http.Error TokenName)
| ResToken (String, String)
| RemoveToken
| ToggleRegister
| Register
| RegisterResult (Result Http.Error NameEmail)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
RemoveToken -> -- Log out
model ! [portSetToken ("", ""), portGetToken "token"]
Tick time -> --現在時間 socket再接続のカウントダウンに使われる。tokenの有効期限の確認も行う。
case model.currentTime > (model.jwtExp * 1000) of
True ->
{ model | currentTime = time } ! [portSetToken ("", ""), portGetToken "token"]
False ->
{ model | currentTime = time } ! []
Tick5 _ -> -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
(model, portSetCurLocation model.geo)
GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
case model.tokenName of
Nothing ->
model ! []
Just tn ->
let
push =
Push.init ("room:"++tn.name) "new_msg"
|> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
, ( "user_id" , JE.string <| model.jwtSub )
, ( "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)
ToggleRegister -> -- ユーザ登録モードを切り替える
({ model| userRegister = not model.userRegister }, 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 } ! []
FormInput inputId val ->
case inputId of
Fname ->
let
oldCred = model.inputCred
newCred = { oldCred | name=val }
in
{ model | inputCred = newCred } ! []
Fpass ->
let
oldCred = model.inputCred
newCred = { oldCred | password=val }
in
{ model | inputCred = newCred } ! []
Fpass2 ->
let
oldCred = model.inputCred
newCred = { oldCred | password2=val }
in
{ model | inputCred = newCred } ! []
Femail ->
let
oldCred = model.inputCred
newCred = { oldCred | email=val }
in
{ model | inputCred = newCred } ! []
Login ->
model ! [ submitCredentials model ]
LoginResult res ->
case res of
Ok tn ->
let
newmodel =
case (decodeToken tokenDecoder tn.token) of
Ok jwt ->
{model | tokenName = Just tn, jwtSub = jwt.sub, jwtExp=(toFloat jwt.exp)}
_ -> model
in
{ newmodel | loginStatus = LoginSuccess} ! [portSetToken (tn.token, tn.name)]
Err err ->
{ model | loginStatus = LoginFail } ! []
Register ->
case model.inputCred.password == model.inputCred.password2 of
True -> model ! [ submitRegister model ]
False -> { model | loginStatus = RegisterEmailFail } ! []
RegisterResult res ->
case res of
Ok ne ->
let
_ = Debug.log "name : " ne.name
_ = Debug.log "email : " ne.email
in
{ model | userRegister=False, loginStatus = RegisterSuccess } ! []
Err err ->
{ model | loginStatus = RegisterFail } ! []
ResToken (token, name) ->
case (String.length token > 0) && (String.length name > 0) of
False -> { model | tokenName = Nothing, jwtSub=initSub, jwtExp=initExp, loginStatus=LoginOff } ! []
True ->
case (decodeToken tokenDecoder token) of
Ok jwt ->
{ model | tokenName = Just (TokenName token name), jwtSub=jwt.sub, jwtExp=(toFloat jwt.exp), loginStatus = LoginSuccess } ! []
_ -> model ! []
submitRegister : Model -> Cmd Msg
submitRegister model =
let
body = Http.jsonBody <| JE.object [ ( "user", userJson model ) ]
in
Http.send RegisterResult <| Http.post registerUrl body nameEmailDecoder
userJson model =
JE.object
[ ( "name", JE.string model.inputCred.name )
, ( "email", JE.string model.inputCred.email )
, ( "password", JE.string model.inputCred.password )
]
submitCredentials : Model -> Cmd Msg
submitCredentials model =
JE.object
[ ( "session", crdentialJson model ) ]
|> authenticate authUrl tokenNameDecoder
|> Http.send LoginResult
crdentialJson model =
JE.object
[ ( "name", JE.string model.inputCred.name )
, ( "password", JE.string model.inputCred.password )
]
-- token decoder
nameEmailDecoder =
JD.map2 NameEmail
(JD.field "name" JD.string)
(JD.field "email" JD.string)
tokenNameDecoder =
JD.map2 TokenName
(JD.field "token" JD.string)
(JD.field "name" JD.string)
tokenDecoder =
JD.map4 JwtToken
(JD.field "aud" JD.string)
(JD.field "exp" JD.int)
(JD.field "iat" JD.int)
(JD.field "sub" JD.string)
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 =
case model.tokenName of -- ログイン済み?
Just tn -> viewMain model tn -- ログイン済、メイン画面
Nothing -> -- 未ログイン
case model.userRegister of -- ユーザ登録画面?
False -> viewLogin model -- ログイン画面
True -> viewRegister model -- ユーザ登録画面
viewLogin : Model -> Html Msg
viewLogin model =
Grid.container []
[ Grid.row [ Row.centerXs ]
[ Grid.col [Col.xs12, Col.lg8]
[ Card.config []
|> Card.block []
[ Block.titleH4 [] [ text "Login Form" ]
, Block.custom <|
Grid.container []
[ Grid.row []
[ Grid.col [Col.xs12, Col.lg4] [ Input.text [ Input.attrs [ placeholder "Name" ]
, Input.value model.inputCred.name
, Input.onInput (FormInput Fname)
]
]
, Grid.col [Col.xs12, Col.lg4] [ Input.text [ Input.attrs [ placeholder "Password" ]
, Input.value model.inputCred.password
, Input.onInput (FormInput Fpass)
]
]
, Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick Login ] [ text "Login"]
]
]
]
]
|> Card.block [ Block.align Text.alignXsRight ]
[ Block.link [ href "#" , onClick ToggleRegister ] [ text "ユーザ登録" ]
]
|> Card.block []
[ Block.text []
[ text <| showLoginStatus model.loginStatus ]
]
|> Card.view
]]]
viewRegister : Model -> Html Msg
viewRegister model =
let
myStyle = style [("height", "5px")]
in
Grid.container []
[ Grid.row [ Row.centerXs ]
[ Grid.col [Col.xs12, Col.lg8]
[ Card.config []
|> Card.block []
[ Block.titleH4 [] [ text "Register Form" ]
, Block.custom <|
Grid.container []
[ Grid.row []
[ Grid.col [Col.xs12, Col.lg12]
[ Input.text [ Input.attrs [ placeholder "Name" ]
, Input.value model.inputCred.name
, Input.onInput (FormInput Fname)
]
]
, Grid.colBreak []
, Grid.col [] [ div [ myStyle ] [] ]
]
, Grid.row []
[ Grid.col [Col.xs12, Col.lg12]
[ Input.text [ Input.attrs [ placeholder "Mail" ]
, Input.value model.inputCred.email
, Input.onInput (FormInput Femail)
]
]
, Grid.colBreak []
, Grid.col [] [ div [ myStyle ] [] ]
]
, Grid.row []
[ Grid.col [Col.xs12, Col.lg12]
[ Input.text [ Input.attrs [ placeholder "Password" ]
, Input.value model.inputCred.password
, Input.onInput (FormInput Fpass)
]
]
, Grid.colBreak []
, Grid.col [] [ div [ myStyle ] [] ]
]
, Grid.row []
[ Grid.col [Col.xs12, Col.lg12]
[ Input.text [ Input.attrs [ placeholder "確認のためPasswordを再入力" ]
, Input.value model.inputCred.password2
, Input.onInput (FormInput Fpass2)
]
]
, Grid.colBreak []
, Grid.col [] [ div [ myStyle ] [] ]
]
, Grid.row []
[ Grid.col [Col.xs6, Col.lg4]
[ Button.button [ Button.primary, Button.onClick Register ] [ text "Regist"]
]
]
]
]
|> Card.block [ Block.align Text.alignXsRight ]
[ Block.link [ href "#" , onClick ToggleRegister ] [ text "ログイン" ]
]
|> Card.block []
[ Block.text []
[ text <| showLoginStatus model.loginStatus ]
]
|> Card.view
]]]
viewMain : Model -> TokenName -> Html Msg
viewMain model tn =
Grid.container []
[ Grid.row [ Row.centerXs ]
[ Grid.col [Col.xs12, Col.lg8]
[ viewReconnectAlert model
, Card.config
[ Card.light
, Card.textColor Text.primary
, Card.attrs [ Spacing.mb3
--, style [ ( "width", "600px" ) ]
]
]
|> Card.headerH3 [] [ text "Map Controller"
-- , a [href "/page2"] [text "検索ページ"]
]
|> Card.block []
[ Block.custom <|
Grid.container []
[ Grid.row [ Row.betweenXs ]
[ Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick TogglePlayer ] [ text <| playerButton model.geo.player ]]
, Grid.col [Col.xs6, Col.lg4] [ Button.button [ Button.primary, Button.onClick RemoveToken ] [ text "Log out" ] ]
]
]
]
|> Card.block []
[ Block.custom <|
Grid.container []
[ Grid.row [ Row.betweenXs ]
[ Grid.col [Col.xs6, Col.lg4]
[ Button.button [ Button.primary, Button.onClick Increment ] [ text " + "]
, span [] [ text <| " " ++ (toString model.geo.zoom) ++ " " ]
, Button.button [ Button.primary, Button.onClick Decrement ] [ text " - "]
]
, Grid.col [Col.xs6, Col.lg5]
[ text <| viewPointTime model
]
]
]
]
|> Card.block []
-- [ Block.text [] [ text <| (showLoginStatus model.loginStatus) ++ "( name = " ++ tn.name ++ ", user_id = " ++ (toString model.jwtSub) ++ " )"]
[ Block.text [] [ text <| (showLoginStatus model.loginStatus) ++ " ( name = " ++ tn.name ++ " )"]
-- , Block.custom <| statusMessage model
-- , Block.text [] [ text <| " token有効期限=" ++ (toString model.jwtExp) ++ " 現在時間=" ++ (toString model.currentTime) ]
-- , Block.text [] [ text <| " token = " ++ tn.token ]
]
|> Card.block [ Block.align Text.alignXsRight ]
[ Block.link [ href "/page2" ] [ text "検索ページ" ]
]
|> Card.view
, viewChannelAlert model
]]]
playerButton : Bool -> String
playerButton player =
case player of
True -> "発信停止"
False -> "発信開始"
viewPointTime : Model -> String
viewPointTime model =
case model.geo.time > 0 of
False -> "ポイント時間:--:--:--"
True ->
let
date = Date.fromTime model.geo.time
in
"ポイント時間:" ++ (Date.toFormattedString "HH:mm:ss" date)
viewChannelAlert model =
case model.geo.player of
False -> Alert.simplePrimary [] [ text "--- 受信中 ---" ]
True ->
case model.messageCnt > 0 of
False -> Alert.simpleDanger [] [ text <| "xxx 発信不良 xxx" ++ (getMessageCnt model)]
True ->
case model.messageFailed of
True -> Alert.simpleDanger [] [ text <| "xxx 発信良好 <> サーバエラー xxx" ++(getMessageCnt model) ]
False -> Alert.simpleSuccess [] [ text <| "--- 発信良好 <> サーバ良好 ---" ++ (getMessageCnt model)]
getMessageCnt : Model -> String
getMessageCnt model = " cnt = " ++ toString model.messageCnt
viewReconnectAlert 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 [ class "status-message" ] [ Html.text reconnectStatus ]
Alert.simpleWarning [] [ text reconnectStatus ]
_ ->
Html.text ""
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 [ 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
, portResToken ResToken
]
False ->
Sub.batch
[ phoenixSubscription model
, Time.every Time.second Tick
, portResToken ResToken
]
{--- 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:"++userName)
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.withDebug
False ->
Channel.init ("room:"++userName)
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.on "new_point" (\msg -> NewMsg msg)
|> Channel.withDebug
phoenixSubscription model =
case model.tokenName of
Just tn -> Phoenix.connect socket [ lobby model tn.name ]
_ -> Sub.none
以下説明していきますが、JavaScriptとのインターフェースであるPortsについて再度説明しておきます。これを念頭に置いていただければと思います。
-- OUTGOING PORT
portInitCurLocation :(発信・受信)ElmからJavaScriptにlocation初期値を渡します
portSetCurLocation :(発信)ElmからJavaScriptに現在のlocationを取得し、地図上にマーカーを置き、geoデータをElmに返すように指示します。
(受信)chnnelで受信したgeoデータを渡し、地図上にマーカを置くよう指示します。
port portSetToken : JWTをlocalstorageに保存する
port portGetToken : localstorageのJWTを返すように依頼する
-- INCOMING PORT
port portGetCurLocation : 現在のlocationを返す
port portResToken : JWTを返す。NULLだったら未ログインと判断する。
##4-4.viewのメインロジック
Elmのプログラムを書いていて気持ちがいいのは、このようなメインロジックが宣言的にクリアーに書けることです。modelの値でどの画面を表示するかを切り替えています。
-- VIEW
view : Model -> Html Msg
view model =
case model.tokenName of -- ログイン済み?
Just tn -> viewMain model tn -- ログイン済、メイン画面
Nothing -> -- 未ログイン
case model.userRegister of -- ユーザ登録画面?
False -> viewLogin model -- ログイン画面
True -> viewRegister model -- ユーザ登録画面
##4-5.subscriptionsのメインロジック
subscriptionsはイベントリスナーを定義する場所ですが、これもmodelの値に応じて定義を動的に変更できます。model.geo.playerで発信モードと受信モードを切り替えていますが、それに応じてsubscriptionsの定義も動的に変えています。
-- 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
, portResToken ResToken
]
False -> -- 受信モード
Sub.batch
[ phoenixSubscription model
, Time.every Time.second Tick
, portResToken ResToken
]
##4-6.ユーザ登録
Phoenixのrouter.exでの定義により、Usersリソースのcreate(ユーザ登録)は以下のパスでアクセスできます。
registerUrl =
"/api/users"
以下のsubmitRegister関数でユーザ登録を行います。Httpパッケージを使います。
submitRegister : Model -> Cmd Msg
submitRegister model =
let
body = Http.jsonBody <| JE.object [ ( "user", userJson model ) ]
in
Http.send RegisterResult <| Http.post registerUrl body nameEmailDecoder
userJson model =
JE.object
[ ( "name", JE.string model.inputCred.name )
, ( "email", JE.string model.inputCred.email )
, ( "password", JE.string model.inputCred.password )
]
userJson modelでencodeされたUserオブジェクトは、以下のelixirのcreate関数のuser_paramsで受けるようになります。
defmodule MultiLeafletWeb.UserController do
use MultiLeafletWeb, :controller
alias MultiLeaflet.Users
alias MultiLeaflet.Users.User
#
def create(conn, %{"user" => user_params}) do
with {:ok, %User{} = user} <- Users.create_user(user_params) do
conn
|> put_status(:created)
|> put_resp_header("location", user_path(conn, :show, user))
|> render("show.json", user: user)
end
end
#
ユーザ登録はcreate関数で行われますが、最後にrender("show.json", user: user)を呼んで、Elmに返答を返します。
defmodule MultiLeafletWeb.UserView do
use MultiLeafletWeb, :view
alias MultiLeafletWeb.UserView
#
def render("show.json", %{user: user}) do
%{"name": user.name, "email": user.email}
end
#
つまり%{"name": user.name, "email": user.email}がElmに返されるのですが、これをElmではnameEmailDecoderで受け取ります。
##4-7.ログイン
ログインも大枠はユーザ登録と同じです。
submitCredentials : Model -> Cmd Msg
submitCredentials model =
JE.object
[ ( "session", crdentialJson model ) ]
|> authenticate authUrl tokenNameDecoder
|> Http.send LoginResult
crdentialJson model =
JE.object
[ ( "name", JE.string model.inputCred.name )
, ( "password", JE.string model.inputCred.password )
]
以下の行に注目します。
|> authenticate authUrl tokenStringDecoder
authenticate関数は、JWTパッケージで以下のように定義されていて、 login credentialsに基づいてHttp.Request を作ります。
authenticate : String -> Decoder a -> Value -> Request a
##4-8.geolocation発信機
発信モードの時にsubscriptionsでは、5秒ごとにTick5 MSGを発行します。
Time.every (5 * Time.second) Tick5
つまり5秒ごとにportSetCurLocationを呼んでJavaScriptに現在位置を取得して返してくれるように依頼します。返り値はGetCurLocation MSGで受け取ります。その位置情報をChannelに書き込みます。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
#
Tick5 _ -> -- 【発信】5秒ごとにJavaScriptに現在値を取得する指令を出す
(model, portSetCurLocation model.geo)
GetCurLocation newgeo -> -- 【発信】JavaScriptからの現在位置をchannelに発信する
case model.tokenName of
Nothing ->
model ! []
Just tn ->
let
push =
Push.init ("room:"++tn.name) "new_msg"
|> Push.withPayload (JE.object [ ( "point", JE.string <| toString <| newgeo.point )
, ( "user_id" , JE.string <| model.jwtSub )
, ( "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 ]
#
ここで特に注意してほしいのが以下の行です。サブトピック名にユーザ名を指定してChannelにJoinしているのがわかります。
Push.init ("room:"++tn.name) "new_msg"
##4-9.geolocation受信機
受信モードの時にsubscriptionsでは、以下の定義により、発信機から発信された位置情報を受け取ります。
phoenixSubscription model
位置情報を受信するとNewMsg MSGが発行されます。受け取った値をdecodeNewMsgでdecodeし、portSetCurLocationでJavaScriptに渡し、自分(受信機)の地図の現在位置マーカを更新します。これによって発信機と受信機のマーカの同期が実現します。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
#
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 ! []
#
ここで特に注意してほしいのが以下のlobbyの定義です。サブトピック名にユーザ名を指定してChannelにJoinしているのがわかります。このサブトピックに書かれた情報のみを受信して上のNewMsgで処理されます。
lobby : Model -> String -> Channel Msg
lobby model userName =
case model.geo.player of
True ->
Channel.init ("room:"++userName) -- サブトピック名=userName
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.withDebug
False ->
Channel.init ("room:"++userName)
|> Channel.withPayload (JE.object [ ( "user_name", JE.string userName ) ])
|> Channel.on "new_point" (\msg -> NewMsg msg)
|> Channel.withDebug
##4-10.エラー時の再接続
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
(注意)
DBに蓄えたデータを見ていたら、5秒おきに蓄えられているはずなのに、はるかに多い、しかも不規則なデータを確認しました。一見するとJavaScript側かElmで、予想外のイベントやcallbackが生じているように思えました。爽やかな季節の休日にデバッグにいそしんだ結果、もっと基本的な不具合を見つけました。TimeはFloatとして定義されているのに、他言語のunixtimeの扱いのようにIntとして扱っている箇所がありました。toFloatとかを使って、無理やりコンパイラを通している箇所もありました。
type alias Time =
Float
この基本的な不具合の結果、上記の不具合が発生していたらしいのです。まじめに型を適切なものに修正したら、DBのデータもキチンと5秒ごとに揃いました。今回の件で明示的なエラーが発生せず、不規則なイベントが発生しているかのような現象になるのが怖いと思いました。コンパイラの型エラーはまじめに取り組まないといけませんね。
#5.再現ページ
再現ページのクライアントプログラムでは、以下のような仕事をします。
(1) DBに指定した時刻幅のgeoデータをリクエスト(API)する
(2) 歩いた地図を描き、軌跡を赤ラインで描画する
(3) 抜粋したポイントの時刻をマーカで表示する
(4)トータルの距離や時間、速度、最高速度などのデータ解析を行う
(1)はPhoenixにhttpsリクエスト(API)を発行しています。
(2) - (4) は(1)で得られた情報を加工してJavaScriptのleaflet.jsに指示を出しています。
##5-1.再現ページ(JavaScriptプログラム)
このHtmlではPage2.elmとLeaflet.jsのコンテナを提供するとともに、前に説明した再現ページのPortsの実装を行っています
<html>
<head>
<!-- Required meta tags -->
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1, shrink-to-fit=no">
<!-- Bootstrap CSS -->
<link rel="stylesheet" <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/4.0.0/css/bootstrap.min.css" integrity="sha384-Gn5384xqQ1aoWXA+058RXPxPg6fy4IWvTNh0E263XmFcJlSAwiGgFAW/dAiS6JXm" crossorigin="anonymous">
<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>
<br />
<div id="elm-area"></div>
<br /><br />
<div align="center">
<div id="mapid"></div>
</div>
<script src="/js/vendor/page2.js"></script>
<script>
const app = Elm.Page2.embed(document.getElementById("elm-area"));
const point0 = [35.7102, 139.8132];
let polyline = null;
let grp = null;
let zoom = 15;
let dist = -1;
let latlng1, latlng2;
const 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);
mymap.setView(point0, zoom);
app.ports.portGetToken.subscribe( (key) => {
// key : not used
const token = localStorage.getItem("token");
const name = localStorage.getItem("name");
if( token && name ) {
app.ports.portResToken.send([token,name]);
} else {
app.ports.portResToken.send(["",""]);
}
})
app.ports.portReqGeo.subscribe( (geogeos) => {
var obj = [];
geogeos.forEach(function(geogeo) {
latlng1 = L.latLng(geogeo[0].lat, geogeo[0].lng);
latlng2 = L.latLng(geogeo[1].lat, geogeo[1].lng);
dist = latlng1.distanceTo(latlng2);
obj.push ({geo1: geogeo[0], geo2: geogeo[1], dist: dist});
})
app.ports.portResNeo.send(obj);
})
app.ports.portLocations.subscribe( (latlngs) => {
if( polyline ) {
mymap.removeLayer(polyline);
}
if( latlngs.length > 0 ) {
let p1 = latlngs[0];
mymap.setView(p1, zoom);
polyline = L.polyline(latlngs, {color: 'red'}).addTo(mymap);
} else {
mymap.setView(point0, zoom);
}
})
app.ports.portMarkers.subscribe( (geos) => {
if( grp ) {
grp.clearLayers()
}
grp = L.layerGroup([]);
geos.forEach(function(geo) {
var time = unixTime2ymd(geo.time);
//var marker = L.marker([geo.lat,geo.lng]).addTo(mymap).bindPopup(time).openPopup();
var marker = L.marker([geo.lat,geo.lng]).bindPopup(time).openPopup();
grp.addLayer(marker);
});
grp.addTo(mymap);
});
function unixTime2ymd(intTime){
// var d = new Date( intTime );
var d = new Date( intTime * 1000 );
var year = d.getFullYear();
var month = d.getMonth() + 1;
var day = d.getDate();
var hour = ( '0' + d.getHours() ).slice(-2);
var min = ( '0' + d.getMinutes() ).slice(-2);
var sec = ( '0' + d.getSeconds() ).slice(-2);
return( year + '-' + month + '-' + day + ' ' + hour + ':' + min + ':' + sec );
}
</script>
</body>
</html>
時刻表示のマーカですが、複数表示するのでgroup化します。描き直すときにクリアーしますが、group毎一括して行えるようにするのが目的です。
app.ports.portMarkers.subscribe( (geos) => {
if( grp ) {
grp.clearLayers()
}
grp = L.layerGroup([]);
geos.forEach(function(geo) {
#
grp.addLayer(marker);
});
grp.addTo(mymap);
});
leaflet.jsの関数 latlng1.distanceTo(latlng2) を利用して、2ポイント間の距離を計算し、データ解析を行います。
dist = latlng1.distanceTo(latlng2);
##5-2.Elmの全コード
port module Page2 exposing (..)
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
import Task exposing (Task)
import Http
import Json.Encode as JE
import Json.Decode as JD exposing (..)
import Jwt exposing (..)
import Date exposing (..)
import Date.Extra as Date
import Time exposing (Time)
import Bootstrap.Alert as Alert
import Bootstrap.Form.Input as Input
import Bootstrap.Button as Button
import Bootstrap.Card as Card
import Bootstrap.Card.Block as Block
import Bootstrap.Grid as Grid
import Bootstrap.Grid.Col as Col
import Bootstrap.Grid.Row as Row
import Bootstrap.Text as Text
import Bootstrap.Utilities.Spacing as Spacing
minSpeed : Float
minSpeed = 50.0 -- 分速のMin。これ以下は異常値。
maxSpeed : Float
maxSpeed = 300.0 -- 分速のMax。これ以上は異常値。
-- OUTGOING PORT
port portLocations : List (List Float) -> Cmd msg
port portMarkers : List Geo -> Cmd msg
port portReqGeo : List (Geo, Geo) -> Cmd msg
port portGetToken : String -> Cmd msg
-- INCOMING PORT
port portResNeo : (List Neo -> msg) -> Sub msg
port portResToken : ((String,String) -> msg) -> Sub msg --(token,name)
geosUrl =
"/api/points"
main =
Html.program
{ init = init
, update = update
, view = view
, subscriptions = subscriptions
}
-- MODEL
type alias Geo =
{ id : Int
, lat : Float
, lng : Float
, time : Time
}
type alias Neo =
{ geo1 : Geo
, geo2 : Geo
, dist : Float
}
type alias TokenName =
{ token : String
, name : String
}
type alias JwtToken =
{ aud : String
, exp : Int
, iat : Int
, sub : String
}
type alias Model =
{ geos : List Geo -- viewで使わないならModelに置く必要はない?
, neos : List Neo
, start : String
, stop : String -- Do not use end! keyword in elixir
, tokenName : Maybe TokenName
, jwtSub : String
, date : Maybe Date
}
start : String
start = "2018-05-01 06:30:00"
stop : String
stop = "2020-05-01 06:30:00"
initSub = "0"
init : ( Model, Cmd Msg )
init =
Model [] [] start stop Nothing initSub Nothing
! [ portGetToken "token", getDate ]
-- UPDATE
type Msg
= GetGeos
-- | MsgNewGeos (Result Http.Error (List Geo))
| MsgNewGeos (Result JwtError (List Geo))
| ResNeo (List Neo)
| ChangeStart String
| ChangeStop String
| ResToken (String, String)
| GetDate
| SetDate (Maybe Date)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GetGeos ->
(model, (getGeosList model) )
MsgNewGeos (Ok newgeos) ->
let
len = List.length newgeos
_ = Debug.log "list len=" len
in
{ model | geos=newgeos } !
[ portLocations <| List.map latlng <| newgeos
, portMarkers <| List.filterMap isGeoSelect <| List.indexedMap (,) <| newgeos
, portReqGeo <| List.map2 (,) newgeos (List.drop 1 newgeos) -- [(g1,g2),(g2,g3),(g3,g4)...]
]
MsgNewGeos (Err _) ->
(model, Cmd.none)
ResNeo neos ->
let
len = List.length neos
_ = Debug.log "neos len=" len
in
{model | neos=neos} ! []
ChangeStart start ->
{ model | start=start } ! []
ChangeStop stop ->
{ model | stop=stop } ! []
ResToken (token, name) ->
case (String.length token > 0) && (String.length name > 0) of
False -> model ! []
True ->
case (decodeToken tokenDecoder token) of
Ok jwt ->
{ model | tokenName = Just (TokenName token name), jwtSub=jwt.sub } ! []
_ -> model ! []
GetDate ->
model ! [ getDate ]
SetDate jdate ->
case jdate of
Nothing -> model ! []
Just date ->
let
stop = Date.toFormattedString "y-MM-dd HH:mm:ss" date
date0 = Date.add Date.Hour -1 date
start = Date.toFormattedString "y-MM-dd HH:mm:ss" date0
in
{ model | start = start, stop = stop, date = jdate } ! []
getDate : Cmd Msg
getDate =
Task.perform (Just >> SetDate) Date.now
tokenDecoder =
JD.map4 JwtToken
(JD.field "aud" JD.string)
(JD.field "exp" JD.int)
(JD.field "iat" JD.int)
(JD.field "sub" JD.string)
isGeoSelect (n, geo) =
case n%18==0 of
True -> Just geo
False -> Nothing
latlng : Geo -> List Float
latlng geo =
{--let
_ = Debug.log "geo time=" geo.time
in--}
[ geo.lat, geo.lng ]
getGeosList : Model -> Cmd Msg
getGeosList model =
-- Http.send MsgNewGeos (requestGeos model)
case model.tokenName of
Just tn ->
let
pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
in
Jwt.get tn.token pointsUrl ( JD.field "data" ( JD.list geo ) )
|> Jwt.send MsgNewGeos
Nothing ->
Cmd.none
requestGeos : Model -> Http.Request (List Geo)
requestGeos model =
let
pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
in
Http.get pointsUrl ( JD.field "data" ( JD.list geo ) )
geo : JD.Decoder Geo
geo =
JD.map4 toGeo
(JD.field "id" JD.int)
(JD.field "lat" JD.float)
(JD.field "lng" JD.float)
(JD.field "time" JD.float)
toGeo : Int -> Float -> Float -> Time -> Geo
toGeo id lat lng time =
Geo id lat lng time
-- VIEW
view : Model -> Html Msg
view model =
case model.tokenName of
Just tn -> viewMain model tn.token tn.name
--Nothing -> div [] [text "Error: missing token!!!!!"]
Nothing -> Card.config []
|> Card.block []
[ Block.link [ href "/"] [ text "ログインページへ" ]
]
|> Card.view
viewMain : Model -> String -> String -> Html Msg
viewMain model token name =
Grid.container []
[ Grid.row [ Row.centerXs ]
[ Grid.col [Col.xs12, Col.lg8]
[ Card.config
[ Card.light
, Card.textColor Text.primary
, Card.attrs [ Spacing.mb3 ]
--, Card.attrs [ Spacing.mb3, style [ ( "width", "600px" ) ] ]
]
|> Card.headerH3 [] [ text "Map Reproduction" ]
|> Card.block []
[ Block.custom <|
Grid.container []
[ Grid.row []
[ Grid.col [Col.xs12, Col.lg5] [ Input.text [ Input.attrs [ placeholder "start" ]
, Input.value model.start
, Input.onInput ChangeStart
]
]
, Grid.col [Col.xs12, Col.lg5] [ Input.text [ Input.attrs [ placeholder "stop" ]
, Input.value model.stop
, Input.onInput ChangeStop
]
]
, Grid.col [Col.xs3, Col.lg2] [ Button.button [ Button.primary, Button.onClick GetGeos ] [ text "再現" ]
]
]
]
]
|> Card.block []
[ Block.custom <|
Grid.container []
[ Grid.row [ Row.rightXs ]
[ Grid.col [Col.xs3, Col.lg2] [ Button.button [ Button.secondary, Button.onClick GetDate ] [ text "現在時刻" ]
]
]
]
]
-- |> Card.block []
-- [ Block.text [] [ text <| "user_id = " ++ model.jwtSub ++ " name = " ++ name ]
-- , Block.text [] [ text <| " token = " ++ token ]
-- [ Block.custom <| viewNeos model
-- ]
|> Card.block [ Block.align Text.alignXsRight ]
[ Block.link [ href "/" ] [ text "トップページ" ]
]
|> Card.view
, viewNeos model
]]]
viewNeos model =
case (List.length model.geos)<2 || (List.length model.neos)<2 of
True -> div [] []
False -> Alert.simpleSuccess [] [ viewNeos2 model ]
viewNeos2 model =
let
neos = List.filter (\neo -> neo.dist>0) model.neos
dist = List.foldr (\neo acc -> neo.dist+acc) 0 neos
diff = getTimeDiff(model.geos)
speed = if diff==0 then -1 else (dist/diff)*60 -- 分速
speeds = groupAve 18 <| List.filter (\s -> s>minSpeed && s<maxSpeed) <| List.map getSpeed neos
ave = case List.isEmpty(speeds) of
True -> -1
False -> (List.sum speeds) / toFloat(List.length(speeds))
max = case List.maximum speeds of
Just m -> m
Nothing -> -1
min = case List.minimum speeds of
Just m -> m
Nothing -> -1
-- _ = Debug.log "speeds=" speeds
in
div []
[ h3 [] [text "解析結果"]
, p [] [text <| "距離(m) = "++(toString dist)]
, p [] [text <| "時間(分) = "++(toString (diff/60))]
, p [] [text <| "速度(分速) = "++(toString speed)]
, p [] [text <| "速度(有効平均) = "++(toString ave)]
, p [] [text <| "最高速度 = "++(toString max)]
, p [] [text <| "最低速度 = "++(toString min)]
-- , ul [] (List.map viewNeo model.neos)
]
getTimeDiff geos =
let
t1 = headTime geos
t9 = headTime <| List.reverse geos
in
t1 - t9
headTime geos =
case List.head geos of
Just geo -> geo.time
Nothing -> 0
getSpeed neo =
case neo.geo1.time > neo.geo2.time of
True -> (neo.dist / (neo.geo1.time - neo.geo2.time)) * 60
False -> -1
-- ある程度まとまった時間の分速を求める=> 10秒x18=3分毎の速度
groupAve : Int -> List Float -> List Float
groupAve n speeds =
case List.isEmpty speeds of
True -> []
False -> let
head = List.take n speeds
ave = (List.sum head) / toFloat(List.length(head))
tail = List.drop n speeds
aves = groupAve n tail
in
ave::aves
viewNeo neo =
li []
[ text <| toString <| neo.geo1.time
, text " - "
, text <| toString <| neo.geo2.time
, text " - "
, text <| toString <| neo.dist
]
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.batch [ portResNeo ResNeo
, portResToken ResToken
]
以下説明していきますが、JavaScriptとのインターフェースであるPortsについて再度説明しておきます。これを念頭に置いていただければと思います。
-- OUTGOING PORT
portLocations : 歩いた軌跡のラインを描きます
portMarkers : ポイントを抜粋して時刻を表示するマーカを描きます
portReqGeo : 2地点の位置情報から距離を計算するためのリクエストです。
portGetToken : localstorageのJWTを返すように依頼する
-- INCOMING PORT
portResNeo : 2地点の距離の計算結果を返します。
portResToken : JWTを返す。NULLだったら未ログインと判断する。
##5-3.geolocation再現機
以下のコードが3つのOUTGOING port呼び出し、歩いた軌跡のラインを描き、複数のマーカを置き、2点の距離を計算するようにJavaScriptに依頼している部分です。
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
#
MsgNewGeos (Ok newgeos) ->
let
len = List.length newgeos
_ = Debug.log "list len=" len
in
{ model | geos=newgeos } !
[ portLocations <| List.map latlng <| newgeos
, portMarkers <| List.filterMap isGeoSelect <| List.indexedMap (,) <| newgeos
, portReqGeo <| List.map2 (,) newgeos (List.drop 1 newgeos) -- [(g1,g2),(g2,g3),(g3,g4)...]
]
#
以下が基本的なデータ定義です。GeoはDBから取得したpoint情報を入れるためのものです。Neoは、Geoをもとにleaflet.jsに計算させた2point間の距離を入れるためのものです。
type alias Geo =
{ id : Int
, lat : Float
, lng : Float
, time : Time
}
type alias Neo =
{ geo1 : Geo
, geo2 : Geo
, dist : Float
}
##5-4.データ解析
まずデータ解析を行うデータを取得する部分の説明です。
以下の部分が、PhoenixのRest APIにリクエストを投げて、DBの検索結果を受け取る部分です。インターフェースをできるだけシンプルに設計すれば、この程度ならDecoderもシンプルに書けます。
#
getGeosList : Model -> Cmd Msg
getGeosList model =
Http.send MsgNewGeos (requestGeos model)
requestGeos : Model -> Http.Request (List Geo)
requestGeos model =
let
pointsUrl = geosUrl ++ "?start=" ++ model.start ++ "&stop=" ++ model.stop
in
Http.get pointsUrl ( Decode.field "data" ( Decode.list geo ) )
geo : Decode.Decoder Geo
geo =
Decode.map4 toGeo
(Decode.field "id" Decode.int)
(Decode.field "lat" Decode.float)
(Decode.field "lng" Decode.float)
(Decode.field "time" Decode.int)
toGeo : Int -> Float -> Float -> Int -> Geo
toGeo id lat lng time =
Geo id lat lng time
#
次にデータ解析の部分です。
以下がGeoリストとNeoリストを元に簡単なデータ解析を行い、表示している部分です。VIEWにおいて行っています。geo情報は、歩いたり止まったりしていて、必ずしもきれいな情報になっていない部分もあります。またブラウザの制限かなとも思いますが、環境によっては、そもそも常に正常なgeolocationが取得できるとも限らないようです。実際には異常値を取り除いてより正確な「速度」を割り出す工夫も必要なのでしょう。面白い課題だと思います。
-- VIEW
view : Model -> Html Msg
view model =
div []
[ input [ value model.start, onInput ChangeStart ] []
, input [ value model.stop, onInput ChangeStop ] []
, button [ onClick GetGeos ] [ text "リスト取得" ]
, div [] ( viewNeos model )
]
viewNeos model =
case (List.length model.geos)<2 || (List.length model.neos)<2 of
True -> []
False -> viewNeos2 model
viewNeos2 model =
let
neos = List.filter (\neo -> neo.dist>0) model.neos
dist = List.foldr (\neo acc -> neo.dist+acc) 0 neos
diff = getTimeDiff(model.geos)
speed = if diff==0 then -1 else (dist/diff)*60 -- 分速
speeds = groupAve 18 <| List.filter (\s -> s>minSpeed && s<maxSpeed) <| List.map getSpeed neos
ave = case List.isEmpty(speeds) of
True -> -1
False -> (List.sum speeds) / toFloat(List.length(speeds))
max = case List.maximum speeds of
Just m -> m
Nothing -> -1
min = case List.minimum speeds of
Just m -> m
Nothing -> -1
-- _ = Debug.log "speeds=" speeds
in
[ h3 [] [text "解析結果"]
, p [] [text <| "距離(m) = "++(toString dist)]
, p [] [text <| "時間(分) = "++(toString (diff/60))]
, p [] [text <| "速度(分速) = "++(toString speed)]
, p [] [text <| "速度(有効平均) = "++(toString ave)]
, p [] [text <| "最高速度 = "++(toString max)]
, p [] [text <| "最低速度 = "++(toString min)]
-- , ul [] (List.map viewNeo model.neos)
]
getTimeDiff geos =
let
t1 = headTime geos
t9 = headTime <| List.reverse geos
in
t1 - t9
headTime geos =
case List.head geos of
Just geo -> geo.time
Nothing -> 0
getSpeed neo =
case neo.geo1.time > neo.geo2.time of
True -> (neo.dist / (neo.geo1.time - neo.geo2.time)) * 60
False -> -1
-- ある程度まとまった時間の分速を求める=> 10秒x18=3分毎の速度
groupAve : Int -> List Float -> List Float
groupAve n speeds =
case List.isEmpty speeds of
True -> []
False -> let
head = List.take n speeds
ave = (List.sum head) / toFloat(List.length(head))
tail = List.drop n speeds
aves = groupAve n tail
in
ave::aves
#6.付録
##6-1.DB関連のコマンド
mix ecto.drop
mix ecto.create
mix ecto.migrate
##6-2.
参照した過去記事
Elmとleafletでつくるgeolocationの発信機・受信機 - Qiita
Elmとleafletでつくるgeolocationの再現機とデータ解析 - Qiita
Phoenix1.3+Guardian1.0でJWT - Qiita
複数のElmアプリで小さなデータを共有する - Local storage - Qiita
Phoenix Channelで作る最先端Webアプリ - topic-subtopic編 - Qiita
/////