#Elm0.19でSPAしたい。
こちらをきっかけに、ただいま、Elmに週末入門中。
前回は1時間で、噂(?)の仮想DOMを軽く体験する程度だったので、
今回は2時間ほどの持ち時間で、elm SPA(シングルページアプリケーション)を体験してみたい。SPAを書いた経験が乏しいので、2時間でどこまでできるか心配だったが、github上に完成度の高い例題をアップしてくださっている方がいたおかげで、そこそこ満足な結果に。ということで、今回も備忘録。
要するに、2つのボタンを押すたびに、elm <--> play間でJSONがやり取りされて、数値が±1するというなんちゃってSPA。いちおう、elmでcssもいじくってみている。
#サーバ側はPlay/Akka-Httpとしたい。
どうせなら、サーバサイドもElmとしたいところだけれど、 現状、Elmの開発はブラウザ上の安定動作に注力されており、Node.js上で動作させることは重視されていないとのこと(可能ではある)。
ということで、サーバ側は、自分が使ったことがあり、かつ、SPAを得意とするreactiveなwebフレームワークとしたい。
第一候補は、Scalaのplay2.6(with Akka)あたり。オルタナティブは、pythonのTornadoかな。
以下、scala/play知識は前提だけれど一旦導入できれば、環境は快適なので、ふだん、scala使ってない人も軽く眺めてもらってもいいかも(playはjavaでも書ける)。
#playもelmもほぼ最新版でお試しできる《sbt-elm》
ググること十数分、良さげな例題を発見:
https://github.com/choucrifahed/sbt-elm
elmスタンドアロンの開発とplayと組み合わせての開発をsbt上で行えるすぐれもの。作成者は、Finstackなるフランスのfintechスタートアップ企業のCTO&フルスタックエンジニアの方らしい。ありがたや。
フォルダ構成と初回コンパイル
git cloneした後にplay向け例題のフォルダに移る。
https://github.com/choucrifahed/sbt-elm/tree/master/examples/play
はじめに、フォルダ構成を眺めておくと、何ができそうか見当がつく。
$ tree .
.
├── README.md
├── app
│ ├── Module.scala
│ ├── assets
│ │ └── elm -- elmのファイル
│ │ └── ServerCounter.elm -- ②
│ ├── controllers -- PlayFrameworkのコントローラー層(フロント側)
│ │ ├── CountController.scala -- ③
│ │ └── HomeController.scala
│ ├── services -- PlayFrameworkのサービス層(モデルなど)
│ │ └── Counter.scala -- ④
│ └── views
│ └── main.scala.html -- ①
├── build.sbt
├── conf
│ ├── application.conf
│ ├── logback.xml
│ └── routes -- ⑤PlayFrameworkのルーティング情報
├── elm.json
├── project
│ ├── build.properties
│ └── plugins.sbt
├── public
│ ├── images
│ │ └── favicon.png
│ └── stylesheets
│ └── main.css
└── test
├── ApplicationSpec.scala
└── IntegrationSpec.scala
上の①~⑤のファイルを、順に書き換えてお試しを進めていく。
さて、はじめに、このディレクトリでsbtを実行して依存するライブラリなどを持ってくる。sbtあるあるのひとつ(?)に、初回実行時はエラーがで止まったりということがある(動作環境のメモリ周りの要求がシビアなのかも)。
そこは広い心で、コンパイルが成功するまでsbt compileを何回か試すことをおすすめする。今回は3回目のコンパイルで成功した。。。
[error] at java.lang.Thread.run(Thread.java:748)
[error] (update) sbt.librarymanagement.ResolveException: download failed: com.typesafe.akka#akka-stream_2.12;2.5.11!akka-stream_2.12.jar
[error] download failed: xalan#xalan;2.7.2!xalan.jar
[error] Total time: 179 s, completed 2018/09/15 12:12:47
[INFO] [09/15/2018 12:12:47.616] [Thread-5] [CoordinatedShutdown(akka://sbt-web)] Starting coordinated shutdown from JVM shutdown hook
$ sbt compile
[info] Loading settings for project root from plugins.sbt ...
[info] Loading settings for project sbt-elm-build from plugins.sbt ...
[info] Loading project definition from /mnt/d/deb/sbts/sbt-elm/project
[info] Loading settings for project sbt-elm from build.sbt ...
[info] Loading project definition from /mnt/d/deb/sbts/sbt-elm/examples/play/project
[info] Loading settings for project root from build.sbt ...
[info] Set current project to play-elm-example (in build file:/mnt/d/deb/sbts/sbt-elm/examples/play/)
[info] Executing in batch mode. For better performance use sbt's shell
[info] Updating ...
[info] downloading https://repo1.maven.org/maven2/com/typesafe/akka/akka-stream_2.12/2.5.11/akka-stream_2.12-2.5.11.jar ...
[info] downloading https://repo1.maven.org/maven2/xalan/xalan/2.7.2/xalan-2.7.2.jar ...
[info] [SUCCESSFUL ] xalan#xalan;2.7.2!xalan.jar (28699ms)
[info] [SUCCESSFUL ] com.typesafe.akka#akka-stream_2.12;2.5.11!akka-stream_2.12.jar (37896ms)
[info] Done updating.
[warn] There may be incompatibilities among your library dependencies.
[warn] Run 'evicted' to see detailed eviction warnings
[info] Compiling 9 Scala sources and 1 Java source to /mnt/d/deb/sbts/sbt-elm/examples/play/target/scala-2.12/classes ...
[info] Done compiling.
[success] Total time: 64 s, completed 2018/09/15 12:14:44
[INFO] [09/15/2018 12:14:44.435] [Thread-5] [CoordinatedShutdown(akka://sbt-web)] Starting coordinated shutdown from JVM shutdown hook
SPAなコーディングを体験
①Htmlを編集(cssフレームワークの導入)
以下のように編集してみた。CSSも試したかったので、siimpleなる軽量CSSフレームワークを導入してみた。
@()
<!DOCTYPE html>
<html lang="en">
<head>
<title>Play with Elm!</title>
<link rel="stylesheet" media="screen" href="@routes.Assets.versioned("stylesheets/main.css")">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/siimple">
<link rel="shortcut icon" type="image/png" href="@routes.Assets.versioned("images/favicon.png")">
<script src="@routes.Assets.versioned("elmMain.js")" type="text/javascript"></script>
</head>
<body>
<h1>「Play2.6 <--> Elm0.19」のテスト</h1>
<h2>HTMLはサーバ側でレンダリングされます。</h2>
</div>
<h3>※ 以下のボタンの挙動は、Elmでコントロール。</h3>
<div id="app">ここが書き換わります。</div>
<h3>※ 上のグレー部分はサーバ側からの応答を受け、Elmが表示を更新。</h3>
<div class="siimple--bg-warning siimple--color-white">(付記) CSSは'siimple'を使用。)
<script type="text/javascript">
Elm.ServerCounter.init({
node: document.getElementById("app")
})
</script>
</body>
</html>
「
②Elmを編集(ボタンの追加)
IncrementServerCounter(+1するカウンター)から、
見よう見まねで、DecrementServerCounter(-1するカウンター)を追加してみた。対応するボタンなどもelmで作成した。ElmでSPAの雰囲気を知りたい方は、自前で『✕ボタン』とか『÷ボタン』とかを作りつつコードを追いかけてみると良いかも。
module ServerCounter exposing (..)
import Browser
import Html exposing (..)
import Html.Events exposing (onClick)
import Html.Attributes exposing (class,id)
import Http
import Json.Decode as Json
main =
Browser.element
{ init = init
, view = view
, update = update
, subscriptions = subscriptions
}
-- MODEL
type alias Model =
{ counter : Int
, error : Maybe String
}
init : () -> ( Model, Cmd Msg )
init _ =
( Model 0 Nothing, incrementCounterServer )
-- UPDATE
type Msg
= IncrementServerCounter | DecrementServerCounter --Decrementを追加
| ServerCounterUpdated (Result Http.Error Int)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
IncrementServerCounter ->
( model, incrementCounterServer )
DecrementServerCounter ->
( model, decrementCounterServer )
ServerCounterUpdated (Ok newCounter) ->
( { model | counter = newCounter, error = Nothing }, Cmd.none )
ServerCounterUpdated (Err newError) ->
( { model | error = Just <| (httpErrorToString newError) }, Cmd.none )
httpErrorToString: Http.Error -> String
httpErrorToString err =
case err of
Http.BadUrl msg -> "BadUrl " ++ msg
Http.Timeout -> "Timeout"
Http.NetworkError -> "NetworkError"
Http.BadStatus _ -> "BadStatus"
Http.BadPayload msg _ -> "BadPayload " ++ msg
-- VIEW
view : Model -> Html Msg
view model =
div [][
button
[class "siimple-btn siimple-btn--navy", onClick IncrementServerCounter ]
[ text "Increment、求む。" ]
,
button
[class "siimple-btn siimple-btn--light", onClick DecrementServerCounter ]
[ text "Decrement、求む。" ]
, hr[] []
, div [class "siimple--bg-dark siimple--color-white"] [
text ("サーバ側からの返信 : " ++ String.fromInt model.counter)
]
, div [] [ text (Maybe.withDefault "" model.error) ]
]
-- SUBSCRIPTIONS
subscriptions : Model -> Sub Msg
subscriptions model =
Sub.none
-- HTTP
incrementCounterServer : Cmd Msg
incrementCounterServer =
Http.send ServerCounterUpdated (Http.get "/count" decodeCounter)
decrementCounterServer : Cmd Msg
decrementCounterServer =
Http.send ServerCounterUpdated (Http.get "/prev" decodeCounter)
decodeCounter : Json.Decoder Int
decodeCounter =
Json.at [ "counter" ] Json.int
elmは、classやidなどの属性も簡単に書けるのがいいね。
③PlayFrameworkのコントローラー層を編集(コマンドの追加)
PlayFramework側は、RESTベースで単純なコントローラーを提供しているだけなので、
編集はすぐに終わる。
package controllers
import javax.inject._
import play.api._
import play.api.mvc._
import services.Counter
@Singleton
class CountController @Inject()(counter: Counter) extends InjectedController {
def count = Action { //元ネタ
val count = counter.nextCount()
Ok(s"""{ "counter": $count }""")
}
def prev = Action { //追加
val count = counter.prevCount()
Ok(s"""{ "counter": $count }""")
}
}
もともとのcountアクションも、追加されたprevアクションも、実処理は、
サービス層のCounterで行われる。
④PlayFrameworkのサービス層(モデルの編集)
ということで、サービス層。
package services
import java.util.concurrent.atomic.AtomicInteger
import javax.inject._
trait Counter {
def nextCount(): Int
def prevCount(): Int //追加
}
@Singleton
class AtomicCounter extends Counter {
private val atomicCounter = new AtomicInteger()
override def nextCount(): Int = atomicCounter.getAndIncrement()
override def prevCount(): Int = atomicCounter.getAndDecrement() //追加
}
ポイントは、シングルトンのAtomicCounterがjava.util.concurrent.atomicのAtomicIntegerを使っているところ。Elm関係ないけど、リアクティブなplay ではスレッドセーフじゃなきゃだめよ、ということね。
##⑤PlayFrameworkのルーティング情報の更新
最後に、ルーティング情報を更新して、play <--> elm間の接続を確立する。
GET / controllers.HomeController.index
# ルーティング
GET /count controllers.CountController.count
GET /prev controllers.CountController.prev
# Map static resources from the /public folder to the /assets URL path
GET /assets/*file controllers.Assets.versioned(path="/public", file: Asset)
#実行(sbt run)
ソースコードを書きかえ終わったら、実行だ。
$ sbt run
[info] Loading settings for project root from plugins.sbt ...
[info] Loading settings for project sbt-elm-build from plugins.sbt ...
[info] Loading project definition from /mnt/d/deb/sbts/sbt-elm/project
[info] Loading settings for project sbt-elm from build.sbt ...
[info] Loading project definition from /mnt/d/deb/sbts/sbt-elm/examples/play/project
[info] Loading settings for project root from build.sbt ...
[info] Set current project to play-elm-example (in build file:/mnt/d/deb/sbts/sbt-elm/examples/play/)
--- (Running the application, auto-reloading is enabled) ---
[info] p.c.s.AkkaHttpServer - Listening for HTTP on /0:0:0:0:0:0:0:0:9000
(Server started, use Enter to stop and go back to the console...)
[info] Compiling 1 Scala source to /mnt/d/deb/sbts/sbt-elm/examples/play/target/scala-2.12/classes ...
[info] Done compiling.
[info] p.a.h.EnabledFilters - Enabled Filters (see <https://www.playframework.com/documentation/latest/Filters>):
play.filters.csrf.CSRFFilter
play.filters.headers.SecurityHeadersFilter
play.filters.hosts.AllowedHostsFilter
[info] play.api.Play - Application started (Dev)
...playフレームワークの特徴として、ひとたびsbt runすると開発モードにあり、リロードされるたびに、配下にあるファイルが更新/リコンパイルされる。なので、はじめに、sbt runしてしまい、.elm/.scala/.hmtlを書き換えるたびにリロードしてガンガン確認していくのが良い(elm/scala両方のエラーがブラウザ上で教えてくれる。)
#終わりに。
この数値が±1するだけのなんちゃってSPA、一応動作するのだが、2つのボタンを押してみると期待とちょっと異なる挙動をする。直しを入れる前に時間切れとなったので、今日はここまでとする。どなたか、お直ししてほしいな。
そのうち、自分でお直ししてもう少し何か意味ある機能を付け加えられたら、githubに上げるかも。