Elchemy 入門 : その2
Elm から Elixir のトランスパイラ,Elchemy についてイロイロと調べたのでまとめていきます. 前回はコチラ. 今回は
- Tutorial その2をやってみた
- Phoenix で ToDo アプリを作る
の2本立てです. ちなみに,現在のバージョンは 0.7.4 です.
Tutorial その2をやってみた
Tutorial その2では Elchemy を利用した独自ライブラリを作成する. 以下の手順で行うそうだ.
- エイリアス型を定義
- ユニオン型を定義
- 関数としてエイリアスやタグを用いる
- ユニオン型でのパターンマッチ
- 関数として演算子を使う・独自の演算子を定義する
- 別のモジュールから型やエイリアス型をインポートする
基本的に Elm の書き方講座みたいなものなので,最悪っ困ったら Elm を勉強してください(丸投げ). ちなみに,元記事の全てを細かく追従せず,ざっくりと掻い摘んで書き出している. なので細かくは元記事を読んでね.
あと,このコードは全て作者さんが GitHub に挙げている.
その前に
テストを書こう,ということでテストを Elixir で書いている. 今回の作成するライブラリはどうやら,ゲームか何かのキャラを制御する物らしい
# character_test.exs
defmodule CharacterTest do
use ExUnit.Case
use Elchemy
"Character has name, last name and such" do
test = Character.new("Gordon", "Freemonad", :male)
gordon
.name == "Gordon"
assert gordon.surname == "Freemonad"
assert gordon.gender == :male
assert gordonend
"Has stats" do
test = Character.new("Gordon", "Freemonad", :male)
gordon
= [:strength, :intelligence, :vitality]
stats for s <- stats do
.stats[s]), "No #{s} stat in #{inspect gordon}"
assert is_integer(gordonend
Character.set_stat(:vitality, 10, gordon).stats.vitality == 10
assert end
"Boosting visality boosts health" do
test = Character.new("Gordon", "Freemonad", :male)
gordon
= Character.set_stat(:vitality, 10, gordon).health
{ hp, packed_gordon_max } = Character.set_stat(:vitality, 0, gordon).health
{ _, weak_gordon_max }
> weak_gordon_max
assert packed_gordon_max == packed_gordon_max
assert hp end
"Can equip weapon only if intelligence is enough" do
test = Character.new("Gordon", "Freemonad", :male)
gordon = Weapon.new("Sci fi blaster thingy", 9 ,100)
weapon
= Character.set_stat(:intelligence, 0, gordon)
dumb_gordon = Character.set_stat(:intelligence, 10, gordon)
smart_gordon
:error, "Too dumb"} = Character.equip(weapon, dumb_gordon)
assert {:ok, equipped_gordon} = Character.equip(weapon, smart_gordon)
assert {.arm == {weapon}
assert equipped_gordonend
end
キャラクターを定義
1. 型エイリアス
関数型なのでまずはドメインモデルの型を定義する. テストより,name
surname
gender
をフィールドとして持っているのが分かるので次のような型を定義した.
module Character exposing (..)
type alias Character =
name : String
{ , surname : String
, gender : Gender
}
何故エイリアスなのかというと,構造的サブタイピイングが出来るようにだと思う(たぶん).
2. ユニオン型
Gender
型が無いので定義する. こっちは列挙型みたいなのが欲しいので、ユニオン型を用いる.
type Gender
= Male
| Female
| Other
3. 関数としての型エイリアス
Elixir っぽい new
関数を定義してやろう. Elm の場合,エイリアス型を定義すれば同名の値コンストラクタができるので,それをラップすればよい
new : String -> String -> Gender -> Character
new name surname gender =
Character
name
surname
gender
キャラクターにステータスを持たせる
キャラクターにいくつかのステータスを持たせよう.
type alias Character =
name : String
{ , surname : String
, gender : Gender
, health : (Int, Int)
, stats : Stats
}
type alias Stats =
strength : Int
{ , intelligence : Int
, vitality : Int
}
new : String -> String -> Gender -> Character
new name surname gender =
Character
name
surname
gender
100,100)
(Stats 0 0 0) (
health
はどうやら HP みたいなものらしい(現在のHPと上限).
4. パターンマッチ
ステータスを更新する関数を定義しよう.
type Stat
= Strength
| Intelligence
| Vitality
setStat : Stat -> Int -> Character -> Character
setStat stat value character =
let
stats = character.stats
in
case stat of
Strength ->
character | stats = { stats | strength = value } }
{ Intelligence ->
character | stats = { stats | intelligence = value } }
{ Vitality ->
character | stats = { stats | vitality = value } } {
残念ながらこの setStat
は正しくない. テストを見ればわかるが Vitality
を更新した場合は health
も更新する必要がある.
5. 演算子
health
はタプル型だ. タプルの更新をいい感じにするために,カスタム演算子を定義してみよう.
<$) : (a, b) -> (a -> c) -> (c, b)
(<$) tuple f = Tuple.mapFirst f tuple
(
$>) : (a, b) -> (b -> c) -> (a, c)
($>) tuple f = Tuple.mapSecond f tuple (
これを使って setStat
の Vitality
の部分を正しく修正する.
setStat : Stat -> Int -> Character -> Character
setStat stat value character =
let
stats = character.stats
in
case stat of
...
Vitality ->
character
{ | stats = { stats | vitality = value }
, health =
character.health
<$ (+) ((value - stats.vitality) * 10)
$> always (100 + 10 * value)
}
ウェポンを持たせる
インポート
新しく Weapon.elm
ファイルを作り,新しいモジュール定義する.
module Weapon exposing (..)
type alias Weapon =
name : String
{ , level : Int
, damage : Int
}
new : String -> Int -> Int -> Weapon
new name level damage = Weapon name level damage
このモジュールをインポートして Character
型を拡張しよう
import Weapon exposing (Weapon)
type alias Character =
name : String
{ , surname : String
, gender : Gender
, health : (Int, Int)
, stats : Stats
, arm : Maybe Weapon
}
new : String -> String -> Gender -> Character
new name surname gender =
Character
name
surname
gender
100,100)
(Stats 0 0 0)
(Nothing
最後に equip
関数を作って完成. これで全てのテストが通るはずだ.
equip : Weapon -> Character -> Result String Character
equip weapon character =
if weapon.level < character.status.intelligence then
Ok { character | arm = Just weapon }
else
Err "Too dumb"
「頭悪すぎ」ってひどい(笑)
Phoenix で ToDo アプリを作る
Elchemy が実際にどの程度有用かを感じるために,Elchemy + Elm + Phoenix で超簡易的な Todo アプリを作ってみた.
過去に Elm + Phoenix で社内ツールを作ったり,Elm + Haskell で Todo アプリを書いてみたりしたので,その辺りからコードや構成はパクッて来てます. GitLab に置いてるのは,モノは試しってやつ(笑).
Phoenix をインストール
Elchemy (および Elixir・Elm・npm) はインストールされているとする. Phoenix のサイトにある通りにやればよい.
$ mix archive.install https://github.com/phoenixframework/archives/raw/master/phx_new.ez
Project を作成
こんな時のために elchemy init
というコマンドがある(?).
$ mix phx.new elchemy_todo_app --no-ecto
$ cd elchemy_todo_app
$ elchemy init
elchemy new
との違いは,mix.exs
の Elixir のバージョンが古いのと .formatter.exs
ぐらいかな? 今回は DB をわざわざ使うのがめんどくさいので,ストレージっぽい GenServer を定義する(なので --no-ecto
).
CRUD を作る
Phoenix に CRUD を追加するには,まずrouter.ex
にルーティングを足す.
defmodule ElchemyTodoAppWeb.Router do
use ElchemyTodoAppWeb, :router
...
:api do
pipeline :accepts, ["json"])
plug(end
"/api", ElchemyTodoAppWeb do
scope :api)
pipe_through("/todos", TodoController, only: [:index, :create, :update, :delete])
resources(end
end
次にコントロラーを定義し,
defmodule ElchemyTodoAppWeb.TodoController do
alias Models.Todo, as: Todo
use ElchemyTodoAppWeb, :controller
def index(conn, _params), do: render(conn, "todos.json", %{todos: ... })
def create(conn, params), do: render(conn, "todos.json", %{todos: ... })
def update(conn, params), do: render(conn, "todos.json", %{todos: ... })
def delete(conn, %{"id" => id}), do: render(conn, "todos.json", %{todos: ... })
end
(...
の部分は後で埋める) そして View を定義する。
defmodule ElchemyTodoAppWeb.TodoView do
use ElchemyTodoAppWeb, :view
def render("todos.json", %{todos: todos}), do: todos
end
さてここから Elchemy だ。 モデルを Elchemy で定義する. というかモデル以外はマクロ色が強過ぎてうまくいかなかった.
Elchemy でモデルを
まずは型を定義.
module Data.Todo exposing (..)
import Dict
type alias Todo =
id : String
{ , title : String
, done : Bool
}
type alias Todos =
Dict.Dict String Todo
ここはフロント共有したいので別途切り出しておく. DBをサボるために GenServer なモデルを定義する.
module Models.Todo exposing (..)
import Data.Todo exposing (Todo, Todos)
import Dict
import Elchemy exposing (..)
{- ex
use GenServer
def start_link(init \\ %{ todos: %{}, cnt: 0 }), do: GenServer.start_link(__MODULE__, init, name: :todos)
def init(state), do: {:ok, state}
def handle_call(:get, _client, state), do: {:reply, state, state}
def handle_cast({:set, new_state}, _state), do: {:noreply, new_state}
def gen_(params) do
%{
id: params["id"],
title: params["title"],
done: params["done"]
}
end
-}
type alias State =
todos : Todos
{ , cnt : Int
}
type Name
= Todos
type Action
= Get
| Set State
gen : params -> Todo
gen = ffi "Models.Todo" "gen_"
getState : State
getState = call_ Todos Get
setState : State -> State
setState state = cast_ Todos (Set state) |> always state
call_ : Name -> Action -> a
call_ = ffi "GenServer" "call"
cast_ : Name -> Action -> a
cast_ = ffi "GenServer" "cast"
Todos
と削除された Todo
も含めた総数を表した Int
を持った State
型を状態として GenServer に保持して欲しい. 出力した Elixir コードにだけモジュールをインポートさせたり,うまく型付けできない関数を Elixir コードに張り付けるには,コメントアウト {- ex ... -}
使う. この中に書いた Elixir コードはそのまま出力先に貼り付けられる(濫用厳禁!).
Elixir モジュールの関数を呼び出すには Elchemy
モジュールにある ffi
関数を使う. ただし,ffi
関数をファーストクラスには扱えない. 次のようなエラーが出る.
Ffi inside function body is deprecated since Elchemy 0.3
Name
型や Action
型は Elchemy が代数的データ型をアトムとタプルに変換することと,GenServer の使い方を知っていれば意図するところが分かるだろう. 逆にそれらを知っていなければ読みとれないと思う…
コントローラーから呼ばれるインターフェースは getState
と setState
を用いることで簡単に書けた.
gets : List Todo
gets = Dict.values (.todos getState)
add : Todo -> List Todo
add todo =
let
todos, cnt } = getState
{ newId = toString cnt
newTodo = { todo | id = newId }
state = { todos = Dict.insert newId newTodo todos, cnt = cnt + 1 }
in
setState state
|> .todos
|> Dict.values
update : Todo -> List Todo
update todo =
let
todos, cnt } = getState
{ state = { todos = Dict.update todo.id (Maybe.map <| always todo) todos, cnt = cnt }
in
setState state
|> .todos
|> Dict.values
remove : String -> List Todo
remove todoId =
let
todos, cnt } =
{ getState
state =
todos = Dict.remove todoId todos, cnt = cnt }
{ in
setState state
|> .todos
|> Dict.values
コントローラーの ...
を書き換えてやれば完成だ.
def index(conn, _params), do: render(conn, "todos.json", %{todos: Todo.gets()})
def create(conn, params), do: render(conn, "todos.json", %{todos: Todo.add(Todo.gen(params))})
def update(conn, params), do: render(conn, "todos.json", %{todos: Todo.update(Todo.gen(params))})
def delete(conn, %{"id" => id}), do: render(conn, "todos.json", %{todos: Todo.remove(id)})
end
ちなみに出力された Elixir コードはココとココです. 興味がある人は見てください.
Elm Brunch
Brunch 設定が難しかったので,本質的には Elchemy と関係ないけど残しておく.
Phoenix 1.3 系ではトップレベルに assets
というディレクトリがあり,HTML/JS/CSS/画像 のような静的ファイルはここに置いておく. Brunch を使って複数の JS や CSS を合わせることが出来る. elm-brunch を使うことで Elm を JS にコンパイルしてくれる.
branch-config に次のような設定を書き加えてあげる. Elm のフロントコードは lib/web/elm
に置いてある.
.config = {
exports...
paths: {
watched: ["static", "css", "js", "vendor", "../lib/web/elm"],
public: "../priv/static"
,
}
plugins: {
elmBrunch: {
elmFolder: "../lib/web/elm",
mainModules: ["Main.elm"],
outputFolder: "vendor"
,
}...
}...
}
フロント部分
ほんの少しだがコードを再利用できる. API クライアントは以下のようになる.
module TodoAPI exposing (..)
import Data.Todo exposing (Todo)
import Http
getTodos : Http.Request (List Todo)
getTodos =
Http.request
method =
{ "GET"
, headers =
[], url =
String.join "/"
baseUrl
[ , "todos"
], body =
Http.emptyBody
, expect =
Http.expectJson (list decodeTodo)
, timeout =
Nothing
, withCredentials =
False
}
ホントはこの当たりも Elchemy を使って生成できるとよいのだが... もしかして elm-phoenix なるものを使えばよかったのかな? また,The Elm Architecture 部分は長いので割愛.
ホントは assets 回りが他にもたくさんあるが,本質的な部分はこれで完成. あとはモロモロインストールして mix phx.server
とすれば動作するはずだ.
感想
- うれしみ
- 静的検査は神
- フロントとコードを共有できる
- つらみ
- Phoenix のいくつかは型付けできない
- ルーティングの引数
- へテロリストのようなモノ
- 結局ここで良く分からんエラーに...
- コンパイルが遅い
- Phoenix のいくつかは型付けできない
おしまい
今度は処理系の中身でも追ってみようかな.