Runner in the High

技術のことをかくこころみ

ElmでPromise.all的なことをしたいときに便利なelm-task-parallel

github.com

背景

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
    )