背景
JavaScriptだと「起動時にサーバーへA, B, Cのデータの取得を問い合わせて全てデータがそろったら次の処理へ移る」というような実装をPromise.allで作ることがよくある。
これをElmで雑にやろうとすると以下のようなMaybeまみれのコードが生まれたりする。
-- Maybeでデータがロードされいているかいないかを表現している。 -- すべてJustになったら次の処理をする。 type alias Model = { user : Maybe User , posts : Maybe Posts , favorites : Maybe Favorites }
こうなるとupdate関数の中でデータを取り出すたびに毎度パターンマッチをしなければいけなくなり、とても冗長なコードになってしまう。また、毎回すべてのデータがJustかどうかをチェックしないといけなくなったりする。これは事故る可能性も高い。
可能であれば「全てのデータがそろっている/いない」を型で表現できるのが理想である。
elm-task-parallelを使う
ここでelm-task-parallelが使える。
まずはModelをこんな感じで設計する。この時点でひとつもMaybeが出てこない。
type Model = Failed | Loading (Task.Parallel.State3 Msg User Posts Favorites) | Loaded User Posts Favorites
Loading
はデータをロード中であることを表現している。同時に取得したいデータの型の数に合わせて、使うStateN
型を変更する。ここではUser
, Posts
, Favorites
という3つのデータを取得待ちするのでState3
を使っている。
上のModelが用意できたので、取得の開始を行うinit関数を実装する。attemptN
という関数が公開されているので、同時に実行したいTaskの数に応じて数字を変えて呼び出す。なお、最大で9個のタスクまで扱うことができる。
init : () -> ( Model, Cmd Msg ) init _ = Task.Parallel.attempt3 { task1 = Api.fetchUser , task2 = Api.fetchPosts , task3 = Api.fetchFavorites , onUpdates = LoadingUpdated , onFailure = LoadingFailed , onSuccess = LoadingFinished } |> Tuple.mapFirst Loading
続いてMsg型を以下のように実装する。ここでも、Msg3
となっているのは、取得を行うデータの数と同じである。
type Msg = LoadingUpdated (Task.Parallel.Msg3 Http.Error User Posts Favorites) | LoadingFailed Http.Error | LoadingFinished User Posts Favorites
上記のMsg型を踏まえてupdate
関数を以下のように作る。
update : Msg -> Model -> ( Model, Cmd Msg ) udpate msg model = case model of Loading loadingState -> case msg of LoadingUpdated loadingMsg -> Task.Parallel.update3 loadingState loadingMsg |> Tuple.mapFirst Loading LoaadingFailed _ -> ( Failed, Cmd.none ) LoadingFinished user posts favorites -> ( Loaded user posts favorites ) _ -> ( model, Cmd.none )
LoadingUpdated
バリアントは、最初に一気に実行されたTaskの結果を受け取るたびに呼び出され、Loading
バリアントが持つデータを逐次更新していく。パッケージを使う側である我々は、いまどのデータが揃っているのかを気にする必要はない。
最後にLoadedFinished
バリアントが飛んでくれば、ModelをLoaded
に更新するために必要なデータが全部揃う、という流れである。これでもうMaybe型を登場させる必要はない。
内部の実装はどうなっているのか
読んでみると分かるがそんなに難しいことはしていない。
例えば、3つのデータを持つState3
型はこんな感じの実装になっている。
type State3 msg a b c = State3 (a -> b -> c -> msg) (Maybe a) (Maybe b) (Maybe c)
つまりパッケージの実装でMaybeを隠してくれているという感じである。
あとはupdateN
関数の中で、失敗状態(FailedStateN
)になっていない限り、ひとつづつMaybe型のデータが更新されていく、という流れになっている。
update3 : State3 msg a b c -> Msg3 a b c -> ( State3 msg a b c, Cmd msg ) update3 (State3 onSuccess a b c) msg = let next a_ b_ c_ = ( State3 onSuccess a_ b_ c_, Maybe.map3 onSuccess a_ b_ c_ |> toCmd ) in case msg of LoadedA3 data -> next (Just data) b c LoadedB3 data -> next a (Just data) c LoadedC3 data -> next a b (Just data)
Taskモジュールのmap関数やらandThe関数を使ってつなげていくスタイルだと、どうしても逐次実行で一つづつ結果を待つことになるが、elm-task-parallelは一度Cmdに変換してElmランタイムへ投げることによって本当に並列実行が行われている。
実際にCmd.batch
でTaskの実行が一気に行われているのがattemptN
関数の実装から見て取れる。
attempt3 : { task1 : Task x a , task2 : Task x b , task3 : Task x c , onUpdates : Msg3 a b c -> msg , onSuccess : a -> b -> c -> msg , onFailure : x -> msg } -> ( State3 msg a b c, Cmd msg ) attempt3 { task1, task2, task3, onUpdates, onSuccess, onFailure } = ( State3 onSuccess Nothing Nothing Nothing , [ task1 |> routeTo (onUpdates << LoadedA3) onFailure , task2 |> routeTo (onUpdates << LoadedB3) onFailure , task3 |> routeTo (onUpdates << LoadedC3) onFailure ] |> Cmd.batch )