はじめに
あけましておめでとうございます!年始一発目のQiita記事はやっぱりElmでした🤟
今回はElmの設計のお話です。
当方まだ新卒1年目の駆け出しElmエンジニアで、バグ修正とか小さめの機能開発はまぁできるようになったけど、大きめの新規機能開発で新規ページや新しい基盤が増えるような規模のものになると手戻りが発生しやすくて悩んでいました(昨年末)。
実装がなかなかスムーズにいかなかったり、いざ完成してコードレビューに出すと大きい指摘をもらって修正に時間がかかってしまったり...。
なぜ規模の大きい機能開発になるとうまくいかないのか、考えてみたところ設計がそもそもちゃんとできてないのでは?というところに落ち着いたので、業務で実践しつつ自分なりの設計指針を見出すに至りました。
あくまで自分がやってみてよかった話の共有なので、色々ある設計論の1つとしてみていただければと思います。
まず結論から
私が見出したElmの設計指針はズバリ、
画面のイメージ(デザイン、ワイヤー、アクティビティ図など)からModelとMsgを定義する。
Elmの特徴
ElmのModelは、フロント側のアプリケーションの状態を表現するものと考えられる。
例えば、「TODOが3つある」「TODO追加フォームが開いている」というのも、アプリケーションの状態だ。
フロントエンドの領域では、アプリケーションの状態は画面の状態と密接に結びついていることが多い。
Elmではview関数が画面への表示の責務を担っており、Modelを元に画面の表示を行う。
ここで注意したいのは、view関数が画面の更新を行ってるのではなく、現在のModelを元に画面を作っているだけということ。
『update関数でModelを更新することによりアプリケーションの状態が変化し、Modelを画面に反映するview関数によって画面が変わっていく』というサイクルを回していくのがElmのセオリーだ。
viewが更新ロジックを持ってしまうと、updateでもviewでもModelの更新が行われることになり複雑になってしまうので、viewは表示の責務に専念するように意識すべきである。
viewとModelの関係性からモデリングへ
viewは表示の責務に専念すべきであり、viewがModelを元に画面を作るべきだと考えると、Modelは画面のイメージから定義できるのではないか?と考えた。
私の所属チームの開発フローでは、エンジニアが実装に入る前にデザイナーがワイヤーやアクティビティー図を完成させていることを必須にしている。どのように画面が変化するか、エラーはどう処理するのか、どこで通信をするのか、待ち状態が発生するのか、などかなりの情報をくみ取ることができる。また作るもののイメージが湧きやすい。
かなり詳細まで詰まった画面のイメージが開発着手時点で手元に出揃っているので、これを元にモデリングできないか考えてみた。
画面のイメージからModelを定義してみる例
簡単なTODOリストのイメージ図を作ってみたので、これを題材に画面イメージ(デザイン)からのモデリングをやってみよう。
状態1から順番にみていこう。まず、状態1ではすでに追加済みのTODOが表示されている。Modelは追加済みのTODOのデータを持っている必要があるととれる。またTODOにはタイトルが付いているので、タイトルも保持しておく必要がある。
type alias Model =
{ todos : List Todo }
type alias Todo =
{ title : String }
状態2に進む。状態2ではTODO追加フォームが新しく出現している。追加フォームは常に表示されているわけではないので、追加フォームを表示するかしないかの状態をもつ必要がある。また、追加フォーム内のInputに入力されたテキストも持っておかないといけない。
type alias Model =
{ todos : List Todo
, isShowAddForm : Bool
, newTodoTitle : String
}
type alias Todo =
{ title : String }
続いて状態3では、通信中でボタンやInputがdisabledになっている状態を示している。どうやら追加フォームの状態は表示/非表示だけでなく「通信中」という状態ももつようだ。
type alias Model =
{ todos : List Todo
, addFormS : AddFormS
, newTodoTitle : String
}
type alias Todo =
{ title : String }
type AddFormS
= Open
| Adding
| Close
状態4はTODOの数が増えただけの状態なので、一見新たな状態は特にないように見える。しかし、新規TODOが一番上に追加されるという仕様があるので、TODOの作成時刻を持っておいた方が良さそうだ。
type alias Model =
{ todos : List Todo
, addFormS : AddFormS
, newTodoTitle : String
}
type alias Todo =
{ title : String
, createdAt : Time
}
type AddFormS
= Open
| Adding
| Close
このように1つ1つの状態の画面をみていき状態を洗い出すことで、アプリケーションが必要な状態を漏れなくModelに持たせることができる。
Modelの見直し
ここでModelの見直しをする。
まだ状態を値出してModelに当てはめただけなので、その型定義が本当に適切か?を考える必要がある。
まず、新規TODOのタイトルが必要なのは追加フォームが開いている時のみである。
Modelに直接newTodoTitleを持たせたままでは、Closeの時でも読み取ったり書き換えたりすることができてしまう。
そこで以下のように書き換えてみる。
type alias Model =
{ todos : List Todo
, addFormS : AddFormS
}
type alias Todo =
{ title : String
, createdAt : Time
}
type AddFormS
= Open String
| Adding String
| Close
入力中のテキストは、AddFormS == Openの時は表示可能+更新可能であるのに対し、AddFormS == Addingの時は通信中は入力テキストの更新ができない仕様なので、表示可能+更新不可となる。Openの時しか更新できないという仕様をコードで表現していこう。
ここで出てくるのがOpaqueTypeだ。内包したいロジックがあるときや、外部から勝手に書き換えられては困る時は、OpaqueTypeで型定義するといい。ただし全部を全部OpaqueTypeにしてしまうと、かえっていちいちgetterやsetterを書いたりしなきゃいけない手間が発生するので、複雑なロジックを持っていない場合や今後変更が入る見込みがなさそうな場合公開してしまった方が記述量少なく済む。
module AddFormS exposing
( AddFormS -- Open/Adding/Closeは公開しない
, init
, isAdding
, isShowForm
, maybeNewTodoTitle
, toAdding
, toOpen
, updateNewTodoTitle
)
type AddFormS
= Open String
| Adding String
| Close
init : AddFormS
init =
Close
toOpen : AddFormS -> AddFormS
toOpen addFormS =
case addFormS of
Close ->
-- フォームを開いた直後はInputは空
Open ""
_ ->
addFormS
toAdding : AddFormS -> AddFormS
toAdding addFormS =
case addFormS of
Open newTodoTitle ->
Adding newTodoTitle
_ ->
addFormS
updateNewTodoTitle : String -> AddFormS -> AddFormS
updateNewTodoTitle inputValue addFormS =
case addFormS of
Open _ ->
-- Openの時のみフォームの入力テキストの更新ができる
Open inputValue
_ ->
addFormS
isAdding : AddFormS -> Bool
isAdding addFormS =
case addFormS of
Adding _ ->
True
_ ->
False
isShowForm : AddFormS -> Bool
isShowForm addFormS =
not <| addFormS == Close
maybeNewTodoTitle : AddFormS -> Maybe String
maybeNewTodoTitle addFormS =
case addFormS of
Open newTodoTitle ->
Just newTodoTitle
Adding newTodoTitle ->
Just newTodoTitle
Close ->
Nothing
少しコード量は多くなってしまうが、これだけでグッとModelが堅牢になる。
Elmは型安全ゆえに仕様を正しく型定義に落とし込むことで恩恵をより得れるので、多少記述量が多くなったりモデリングに時間がかかったりしても、Modelの定義を検討することは価値があることだと思う。
画面のイメージからMsgを定義してみる例
さて、ここまででModelの定義ができた。
Modelができたら、次は状態の更新について考える。先ほどのイメージ図の状態1 → 状態2 → ...といった画面状態の変化を起こすには、viewを更新するのではなく、Modelを更新しなければならない。ここでも、先ほどのイメージ図を材料にしてMsgの定義をやってみよう。
まず状態1 → 状態2にするには、+ボタンをクリックするというユーザー操作が発生する。
そしてそのユーザー操作によりTODO追加フォームが表示される。
type Msg
= ClickedOpenFormBtn
update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
case msg of
ClickedOpenFormBtn ->
( { model | addFormS = toOpen model.addFormS }, Cmd.none )
続いて状態2の間に、Input入力のユーザー操作が発生する。
追加フォームには「追加」ボタンと「キャンセル」ボタンがあり、それぞれ状態3, 状態1へと遷移するため、この変化に対してもMsgを定義する。
type Msg
= ClickedOpenFormBtn
| InputNewTodoTitle String
| ClickedDoAddTodo
| ClickedCancelAddTodo
update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
case msg of
ClickedOpenFormBtn ->
( { model | addFormS = AddFormS.toOpen model.addFormS }, Cmd msg )
InputNewTodoTitle inputValue ->
( { model | addFormS = AddFormS.updateNewTodoTitle model.addFormS }, Cmd msg )
ClickedDoAddTodo ->
( { model | addFormS = AddFormS.toAdding model.addFormS }, Cmd msg )
ClickedCancelAddTodo ->
( { model | addFormS = AddFormS.init }, Cmd msg )
状態3では通信の完了を待っている。完了のレスポンスを受け取ったら状態4になる。
type Msg
= ClickedOpenFormBtn
| InputNewTodoTitle String
| ClickedDoAddTodo
| ClickedCancelAddTodo
| CompletedAddTodo -- HttpやPortなどでレスポンスを受け取る
update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
case msg of
ClickedOpenFormBtn ->
( { model | addFormS = AddFormS.toOpen model.addFormS }, Cmd msg )
InputNewTodoTitle inputValue ->
( { model | addFormS = AddFormS.updateNewTodoTitle model.addFormS }, Cmd msg )
ClickedDoAddTodo ->
( { model | addFormS = AddFormS.toAdding model.addFormS }, Cmd msg )
ClickedCancelAddTodo ->
( { model | addFormS = AddFormS.init }, Cmd msg )
CompletedAddTodo ->
let
nextTodos =
case maybeNewTodoTitle of
Just newTodoTitle ->
-- 本来ならTime取得してくる部分作らないといけないけど今回は省略
({ title = newTodoTitle, createdAt = xxx } :: model.todos)
-- 新しいものが上にくるようソート
|> List.sortby .createdAt
|> List.reverse
Nothing ->
model.todos
in
( { model | addFormS = AddFormS.init, todos = nextTodos }, Cmd msg )
Msgの定義とupdateの記述もこれで完了だ。
あとはもうModelに対応するviewをどんどん書いていけば画面はすぐにできる。
ModelとMsgの定義ができたところで一度設計レビューを出す
ElmはModelとMsgの定義が設計の肝になるので、私はModelとMsgをまず定義して、updateを簡易的に(実装を詳細に書いてしまわず、いったんコメントアウトで「ここでModelのこれを更新する」みたいな説明を入れておく程度)書いた状態で、設計レビューに出すようにしている。
ModelとMsgの段階でレビュワーと設計・実装に合意を取ることをするようにしたら、コードレビュー時の大きな手戻りがおこらなくなりスムーズに開発が進むようになった。
まとめ
Elmの状態更新はとってもシンプル。
ただ、シンプルさを保つには、それぞれの責務をちゃんと意識していないといけない。
- Model = アプリケーションの状態をもつ責務
- update = 状態(Model)の更新の責務
- view = 状態(Model)を元に画面を表示する責務
画面があるということはModelがあるということ。
画面が変わるということはupdate関数でModelが更新されるということ。
なので私は画面イメージベースの設計指針が良いかなと思いました!
長くなりましたが以上です。本年もどうぞよろしくお願いします😌