Runner in the High

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

elm-multi-waitableで非同期処理の完了待ちをModelに表現する

Elmアプリケーションにおいて非同期処理の完了待ち実装をいい感じにするelm-multi-waitableというモジュールを公開した

github.com

内部的には状態遷移のタイミングでデータを受け取って保持するステートマシンのようなものだが、これを使うことでフロントエンド・アプリケーションによくある「非同期ないし直列に複数データの取得を行い完了待ちをする」というような処理の実装を改善できる。

非同期処理をModelで表現する難しさ

例えば、以下のようにLocalStorageへのアクセスと複数のWebAPIへのリクエストの完了待ちが必要になるケースがあるとする。

また、すべてのリクエストが完了するまではローディング中画面を出し、すべてが完了したらローディング完了画面を出すという仕様がある。

f:id:IzumiSy:20200711234003j:plain

この仕様のModelを最も簡単に表現するのであれば、データの取得が完了している/完了していないをMaybeで素朴に表現することになる。

すべてがJustになっていればview関数でローディング完了画面を出すといった具合になる。

type alias Model =
    { auth : Maybe Auth
    , user : Maybe User
    , option : Maybe Option
    }

これはこれで一旦動くものは作れるが、以下の問題点を含んでいる。

  • データを扱うのに毎回Maybeを剥がす処理が必要になりコードが冗長になる
  • 直列にすべてのデータを取得する実装ではデータの取得順が変わる度に完了チェックの場所が変わる(UserとOptionの取得順を入れ替えるような場合には都度完了チェックのタイミングも変える必要がある)
  • 並列にすべてのデータを取得する実装では個別のデータ取得完了毎に完了チェックの処理が必要になる(どの順番でデータの取得が完了するか分からない&完了チェック自体が抜け漏れる可能性がある)

そもそも、値をMaybe型で包んでいるのはローディング画面を出したいという仕様のためだけであって、ローディング画面が終了したら存在しているものはMaybeである必要がない。可能であればMaybeではない型であってほしい。できれば、以下のようなデータ構造であってほしい。

type Model 
    = Loading -- ローディング中
    | Loaded Auth User Option -- ローディング完了

しかし、このコードは理想論であって実際には実現できない。なぜならローディング中には非同期的にLocalStorageやWebAPIからのデータが取得されていくため、取得過程のデータも保持する必要があるからだ。

ここでelm-multi-waitableを使うともう少しだけ近い形で実現することができる。具体的には、以下のようなModelの設計にすることができる。

type Model
    = Loading (MultiWaitable.Wait3 Msg Auth User Option)
    | Loaded Auth User Option

elm-multi-waitableの使い方

まずinit関数でLoadingの初期化を行う

init : Model
init =
    Loading <| MultiWaitable.init3 Done

MultiWaitable.initN関数は待ち合わせているすべての非同期処理が完了した際に発行されるMsgを第一引数に取る。

第一引数で指定されているDoneは完了待ち対象となるデータの型(今回の例ではAuth, User, Option)をタグに持つもので、Msg型は以下のような設計になる。

type Msg
    = AuthFetched Auth
    | UserFetched User
    | OptionFetched Option
    | Done Auth User Option

上記のMsgをハンドリングするupdate関数は以下。

update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case (model, msg) of
        ( Loading waitable, AuthFetched auth ) ->
            waitable
                |> MultiWaitable.wait3Update1 auth
                |> Tuple.mapFirst Loading

        ( Loading waitable, UserFetched user ) ->
            waitable
                |> MultiWaitable.wait3Update2 user
                |> Tuple.mapFirst Loading

        ( Loading waitable, OptionFetched option ) ->
            waitable
                |> MultiWaitable.wait3Update3 option
                |> Tuple.mapFirst Loading

        ( Loading _, Done auth uesr option ) ->
            ( Loaded auth user option, Cmd.none )

MultiWaitableはupdateのための関数としてwaitNUpdateN関数群を提供しており、たとえばそのうちwait3Update1関数は以下のようなシグニチャになっている。

wait3Update1 : a -> Wait3 msg a b c -> ( Wait3 msg a b c, Cmd msg )

この関数はaを適用して更新されたWait3型の値と、完了時であればinit3関数で与えられた完了Msgの発行コマンドをタプルで返している。

MultiWaitableモジュールを使えば、update関数においてはWaitN型の更新だけをすればよく、待ち合わせ中のデータがすべて揃ったかどうかを開発者自身が都度気にする必要はない。また、機能追加による改修の際にも、Modelの設計からローディング/ローディング完了という状態が存在しているとひと目で分かることや、init関数の初期化部分で完了時のMsgが把握できることなどから可読性も高くなる。

Elmにおいて複数の非同期処理の完了待ちはModelの設計が比較的難しい部分であるが、elm-multi-waitableを使うことでModelのデータ構造をシンプルかつ分かりやすく表現させることができる。

関連パッケージ

elm-multi-waitableは複数Taskの完了待ちを支援するelm-task-parallelというパッケージから大いに影響を受けている。

izumisy.work

また、非同期処理の完了待ちに関してはリチャード・フェルドマンもelm Europe 2018の"Make Data Structures"で同様の話をしている。

f:id:IzumiSy:20200712105918j:plain "Make Data Structures" by Richard Feldman

彼のトークスライドはIndexeDBとWebAPIの両方からデータの取得を行うケースでのModel設計ではあるが、Maybeという「ある/なし」しか表現できない文脈の少ない型をできるだけ減らしていこう、という方向性では同じく参考になるだろう。