Elm でマテリアルデザインなウェブアプリを作った
アプリ概要
アプリとしてはニッチな用途で、写真用のモノクロネガフィルムを自家現像するときに便利なタイマーアプリです。下記は埋め込まれた実際に動くアプリです。
Photo Film Dev で公開しています。
時間配分をレシピと呼んでいますが、レシピはサーバーに保存されるためログインすれば複数の端末でアプリを使用できます。
今はレシピは自分のアカウントでしか見れないですが別のアカウントに共有する機能が作れたらなと考えています。
技術構成
ソースコードは GitHub に GPL3 で提供しています。
アプリの技術構成としては次の通りです。
- クライアント
- HTML/CSS/JS
- Elm
- Material Design
- Progressive Web Applications
- サーバー
- GitHub Pages
- Firebase
- Authentication
- Cloud Firestore
Elm
Elm は言語としては Haskell 様の文法を採用した alternative HTML/CSS/JS です。The Elm Architecture がいい感じです。チュートリアルを読めば書けるようになります。
モジュール分割
最初は1ファイルに全部書いて始めたのですがわりとすぐにどの辺りに何を書いたか分からなくなったのと同じ名前を付けたいものが出てきたのでモジュール分割をしました。詳細は後述します。
モジュール | 役割 |
---|---|
Model |
Msg 以外のデータ型データ同士の変換関数など |
Model.Foo |
enum として定義する Foo 型get ・set など関連する関数 |
Msg |
Msg のデータ型 |
Msg.Foo |
入れ子になる Msg のデータ型update 関数と対応する |
Port |
port 関数とその引数になる型 その型と内部で使用する型との変換関数 |
Cmd |
Port で定義した関数を組み合わせた Cmd を生成する関数 |
Sub |
Port で定義した関数を組み合わせた Sub を生成する関数 |
Update |
直和型として定義した Msg 型のそれぞれの値構築子に対応する update 関数 |
View |
画面要素ごとに分割した view 関数 |
Text |
多言語テキスト |
Enum
Elm にはアドホック多相が組み込み関数にしかありません。例えば Step
という段階を表す型を作って Haskell であれば Enum
型クラスにするところですができないのでモジュールを分けて toInt
・fromInt
・compare
などを定義することになります。標準ライブラリーの型でも Maybe
など Monad
になるものはモジュールごとに andThen
を定義しているので正当な方法だと思います。
module Model.Step exposing (..) type Step = Soak | Dev | Stop | … toInt : Step -> Int toInt step = case step of Soak -> 0 Dev -> 1 Stop -> 2 … fromInt : Int -> Maybe Step fromInt value = case value of 0 -> Just Soak 1 -> Just Dev 2 -> Just Stop … _ -> Nothing compare : Step -> Step -> Order compare step0 step1 = Basics.compare (toInt step0) (toInt step1)
Dict
様レコードでの DRY
フィールドの型が全部同じで辞書(マップ)として使用するような下記のようなレコードの場合フィールドごとに同じような処理を書こうとしてもそのままでは実装を複製するしかありません。Dict
にする手もありますが存在することが分かっているのに不必要な Maybe
だらけになってしまいます。
type alias TimeSpans = { soak : TimeSpan , dev : TimeSpan , stop : TimeSpan , fix : TimeSpan , rinse : TimeSpan , wet : TimeSpan }
そこで先の enum として作った型をキーとして get
・set
関数を作ります。
module Model.Step exposing (..) … get : Step -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a } -> a get step = case step of Soak -> .soak Dev -> .dev Stop -> .stop … set : Step -> a -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a } -> { soak : a, dev : a, stop : a, fix : a, rinse : a, wet : a } set step value record = case step of Soak -> { record | soak = value } Dev -> { record | dev = value } Stop -> { record | stop = value } …
これで下記のようにアクセスできます。
timeSpans |> get step timeSpans |> set step value
これで don't repeat yourself にのっとって処理をまとめることができます。
Msg
と update
の分割
Msg
と update
の分割のしかたは対応させています。
-- Msg.elm type Msg = Drawer Visible | SelectRecipe Recipe | GoRun … -- Main.elm update : Msg -> Model Msg -> ( Model Msg, Cmd Msg ) update msg model = case msg of Msg.Drawer visible -> Update.drawer visible model Msg.SelectRecipe recipe -> Update.selectRecipe recipe model Msg.GoRun -> Update.goRun model …
Msg.Drawer
には Update.drawer
というように対応させています。入れ子になったメッセージである Visible
は Msg.Visible
モジュールに定義してあります。Model
の状態による条件分岐は各々の update
の中で行っています。
Port
と Cmd
と Sub
Model
モジュールに Recipe
は下記のような型で定義しているのですがこれは port 関数で JavaScript に渡したり JavaScript から渡されたりすることができません。なので Port
モジュールに同名の Recipe
を作り変換関数も Port
に定義しました。
-- Model.elm type alias Recipe = { id : UUID , name : String , timeInputs : TimeInputs } -- Port.elm type alias Recipe = { id : String , name : String , timeInputs : Model.TimeInputs }
Port.Recipe
から Model.Recipe
への変換には elm-form-decoder を使いました。フォームからの変換以外でも外界からデータを取り込む場合に便利です。
Port.setRecipeCmd
は Port.Recipe
に依存していますが Cmd.setRecipe
は Model.Recipe
に依存するように Port
の変換関数を使って変換しています。Sub.changeRecipe
も同じく Port.changeRecipeSub
から変換していますが、msg
の構築子が2つあるのは変換の成功時・失敗時を表現するためです。
-- Port.elm port setRecipeCmd : Recipe -> Cmd msg -- ここの Recipe は Port.Recipe -- Cmd.elm setRecipe : Recipe -> Cmd msg -- ここの Recipe は Model.Recipe
-- Port.elm port changeRecipesSub : (Array Recipe -> msg) -> Sub msg -- ここの Recipe は Port.Recipe -- Sub.elm changeRecipes : (List RecipeDecoderError -> msg) -> (Dict String Recipe -> msg) -> Sub msg -- ここの Recipe は Model.Recipe
Material Design
Material Design を採用しライブラリーとしては elm-mdc を使用しました。UI センスがなくても Material Design のガイドラインにしたがえばそれっぽくなるので便利です。
Elm のライブラリーは Elm しか内包できません。このライブラリーは Elm の他に JavaScript なども含むため Git リポジトリーとして公開され、サブモジュールとして依存します。
elm.json
と package.json
は下記のようになります。
// elm.json { "type": "application", "source-directories": [ "src", "elm-mdc/src" ], "elm-version": "0.19.1", "dependencies": { … }, … }
// package.json { "dependencies": { "elm-mdc": "file:elm-mdc", … }, … }
ビルドタスクは GNU Make で管理しているのですが Parcel を使用しているにもかかわらず上記事情のため elm-mdc の面倒も見ているので少々複雑になっています。
多言語テキスト
Text
モジュールでは下記の型の値を列挙しています。といっても今のところ日本語のみですが。
type alias Text = Language -> String
モジュール依存関係
モジュールの依存関係を下図に示します。Text
は図から省略しています。
Text
は他のいずれにも依存しません。Model
グループは Text
にのみ依存します。黄色で Model
や Msg
を囲っているものは単に矢印の数を削減するためのもので、黄色枠への矢印もしくは黄色枠からの矢印はその中の1個以上への矢印もしくはその中の1個以上からの矢印と読んでください。Msg
は Model
と Text
にのみ依存します。他に特記すべきは Port
は Cmd
と Sub
のみから依存され必ずラップされたものが他から参照されます。
Firebase
サーバーは自前で書くつもりだったのですが Firebase をさわってみたら認証とストレージだけで十分だったのでサーバーは自前では立てないことになりました。local storage を使ってスタンドアローンで動くところまで作っていましたが Firestore がオフラインの面倒も見てくれるので local storage の実装は消しました。
先に書いたように Elm のライブラリーは Elm のみに限定されるので、必然的に JavaScript に依存する Firebase のラッパーライブラリーはありません。とはいえ書くのはグルーコードなので大した問題ではありません。
クレジットページ
頒布をともなう場合、多くのオープンソースライセンスはコピーライト表記とライセンス表記を求めます。なのでクレジットページを作成しました。これは自動生成されるようにしています。NPM License Checker と Elm License Checker を使用して生成しています。Elm License Checker はなかったので作りました。(Elm License Checker は PureScript 製です。作成時 Elm で CLI を作れることを知らなかったので。)
謝辞
Discord の Elm-jp サーバーのみなさまにはお世話になりました。