10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

More than 5 years have passed since last update.

ElmでのAPIハンドラの書き方を考えてみる

Last updated at Posted at 2018-12-03

はじめに

Elmでは、TEAと呼ばれるフレームワークに基づいて、イベント補足とモデル更新を行います。複数画面を持ち、複数ファイルにviewを持つアプリケーションの更新処理を一箇所で記述すると、コードの保守性が下がってしまいます。特に外部APIとのアクセス時には、記述量が多くなってしまうため、わかりにくさが顕著になってしまいます。

この記事では、こういった問題の整理と、自分なりに考えてみた対策案を考えてみます。案については「これが正解」ということではないと思うので、この手の話題の参考になれば、というところです。

なお、本記事はElm0.18ベースで書きますが、話の本質は0.19でも同じかと思います。自分の案件の0.19化が完了して気が向いたら0.19向けに書き直す、かもしれません。

それから、本記事はアドベントカレンダー4日目(序盤!)ということで、勢いだけで書いてみました。いろいろ荒いですがご容赦ください。

Update処理の分散を考える

イベントの補足とモデルの更新はupdate関数で行います...と書くと不正確ですね。アプリケーション起動時に「update」という名前の関数をNavigation.program(Elm0.19ではBrowser.elementなど)に登録することで、このupdate関数がモデルの更新の役割を持つことになります。

さて、アプリケーションで「A」「B」という2つの画面をそれぞれ、ViewA.elm、ViewB.elmというファイルに実装したとしましょう。各画面にはボタンが1つずつあるとします。例えばこんな感じになるでしょうか。

Types.elm
type alias Model =
   { counter1 : Int
   , counter2 : Int
   }

type Msg = MsgViewAButton1
         | MsgViewBButton2
ViewA.elm
button [onClick MsgViewAButton1] [text "button 1"]
ViewB.elm
button [onClick MsgViewBButton2] [text "button 2"]
Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        MsgViewAButton1 -> ({model | counter1 = model.counter1 + 1}, Cmd.none)
        MsgViewAButton2 -> ({model | counter2 = model.counter2 + 1}, Cmd.none)

動作としては何の問題もないのですが、アプリケーションの規模が大きくなってくると、「viewはViewA.elmにあるが、updateはMain.elmにある」という、記述の場所が離れていることが気になってきます。

updateもViewA.elmに記述するための手段として、「ViewA.elm内にupdate関数を実装し、Main.elmでは『ViewA向けのMsgであれば、ViewA.updateを呼び出す』」というのがあります。

実装したいものによりますが、原則としてはElm のコンポーネント論争とは何かにも書かれているように、あまり筋がいい話ではないことが多いかと思います。「書かなくても済むなら書かない」という考え方もありますが、個人的な見解としては「各画面内でグローバルな値にアクセスする必要が出る」「Elmでは循環インポートが許されていない」などの事情により、各画面の実装時には、コンポーネント化はうまくはまらない、と思っています。

Viewで更新後のModelを書く

Elmをやり始めて、ある程度慣れてくると「Elmの記述は全て関数で構成される」「関数の型があえば、どんな関数でもはめられる」というところから、「buttonのonClickの引数に、単純なMsgではないものを入れてやってもいいのでは?」と気づくかと思います。その中でも一番単純なものは「更新後のModelの値をいれてやる」というものです。今回の例でいくと

Types.elm
type Msg = MsgModel Model
ViewA.elm
button [onClick <| MsgModel <| {model | counter1 = model.counter1 + 1}] [text "button 1"]
ViewB.elm
button [onClick <| MsgModel <| {model | counter2 = model.counter2 + 1}] [text "button 2"]
Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        MsgModel new_model -> (new_model, Cmd.none)

こうすると、わざわざViewA.elm, ViewB.elm内にupdate関数を実装しなくても、それぞれのView実装の中で更新処理までできるようになりました。各ボタンでどういう処理をするのかが見えやすくなってよかったですね。

...といいたいところですが、このやり方には若干の問題があります。

今回の例のようなシンプルなケースではいいのですが、より複雑なアプリケーションでは「Modelが与えられた時(=画面が描画された時)から、ボタンを押されるまでの間にModelが変化しないとは限らない」のです。もしModelが更新された後にボタンが押された場合、update処理によって「Model更新前の値の基づいた更新処理」となってしまい、場合によっては意図しない動作となります。

Viewで「Modelを更新する関数」を書く

上記の問題は「buttonのonClickにModelの値ではなく『Modelを更新する関数』を与える」「updateでは、その関数を使ってModelを更新する」ことで解決できます。こんな感じですね。

Types.elm
   type Msg = MsgModelFnc (Model -> Model)
ViewA.elm
button [onClick <| MsgModelFnc (\model -> {model | counter1 = model.counter1 + 1})] [text "button 1"]
ViewB.elm
button [onClick <| MsgModelFnc (\model -> {model | counter2 = model.counter2 + 1})] [text "button 2"]
Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        MsgModelFnc update_model_fnc -> (update_model_fnc model, Cmd.none)

外部APIへのアクセスに対応する

狙い

ここまでが長い前振りです。多くのElmアプリケーションでは、外部APIアクセスにアクセスし、戻ってきた値に応じて「Modelを更新する and/or Cmdを発行する」というケースが頻発すると思います。APIアクセスは多くは「URLが変わった」「submitボタンが押された」などのUI操作によって引き起こされますから、「UIイベントの受け取り=APIアクセス発行」→「APIが戻ってきた=Model更新」と、少なくとも2回のupdate処理が必要です。これを全てMain.elmに記述するのは、精神衛生上いいものではありません。

目標は、JavaScript(およびそれを軸にしているフレームワーク)で書く、下記のようなコードの雰囲気にすることです。

  // getCounter()がpromiseを返すAPIコール関数、だとして
  getCounter().then(
     {value => model.counter = value});

以下の説明のための前提として、Intを受け取るAPIがこのように用意されてる、とします。この関数は「API戻り値(Result Http.Error Int)を受け取りMsgを生成する関数」を引数にとり、それをそのままHttp.sendに渡しています。

ApiAccess.elm
getCounter : (Result Http.Error Int -> msg) -> Cmd msg
getCounter msg =
    let request = Http.request { method = "GET"
                               , headers = []
                               , url = "dummy"
                               , body = Http.emptyBody
                               , expect = Http.expectJson (D.int)
                               , timeout = Nothing
                               , withCredentials = False
                               }
    in Http.send msg request

Modelに依存しないCmd生成

前振りでは、Msgとして「Modelを更新する関数」を考えましたが、「Cmdを発行する関数」も同様に用意できます。一番簡単な例として、Modelの値に依存しないCmdの例です。以下のようなMsgとupdateを定義します。

Types.elm
   type Msg = MsgCmd (Cmd Msg)
Main.elm
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        MsgCmd cmd -> (model, cmd)

そうすると、ボタンのコールバックとAPIのコールバックは下記のようになります。前振りではボタンのコールバックに「Modelを更新する関数」を設定していましたが、その役割がAPIコールバック側にまわります。ボタンのコールバックは単純にCmdを発行するだけとなるため、buttonOnClickの型も「Cmd Msg」となります。getCounterCallbackがJavaScriptの例として書いたところの「.then()相当」となります。これでAPIアクセスの発行と結果の受け取りの両方が同じ箇所に記述できるようになりました。

ViewA.elm
-- view(抜粋)
button [onClick <| MsgCmd buttonOnClick ] [text "update counter"]

-- ボタンコールバック
buttonOnClick : Cmd Msg
buttonOnClick = getCounter <| MsgModelFnc << getCounterCallback

-- APIコールバック
getCounterCallback : Result Http.Error Int -> Model -> Model
getCounterCallback res model =
    case res of
        Ok new_count -> {model | counter1 = new_count}
        Err _ -> model -- エラー処理は必要に応じて

Modelに依存するCmd生成

先のgetCounterというAPIアクセス関数をアレンジして、modelの中の値を引数にとる「getCounterWithInt」という関数を使うものとします。modelの値を使用するため、buttonOnClickは「Modelを引数にとってCmdを生成する」となります。getCounterCallbackの方は変更ありません。

ViewA.elm
-- ボタンコールバックでは、Modelを引数にとるように修正
buttonOnClickModel : Model -> Cmd Msg
buttonOnClickModel model = getCounterWithInt model.counter1 <| MsgModelFnc << getCounterCallback

-- APIコールバックは変更なし
getCounterCallback : Result Http.Error Int -> Model -> Model
getCounterCallback res model =
    case res of
        Ok new_count -> {model | counter1 = new_count}
        Err _ -> model

スピナー対応編

ここまでの話だと「そんなのみんな知ってるよ」「ここに書いてるよ」となりそうな気もするので、スピナー対応まで書いてみます(知ってるよ、であればまだいい方で、「筋が悪い」という可能性も...)。

APIアクセス中は、アクセス中であることを表示するスピナー(くるくる、って呼ばれたりするアレですね)を表示したいことが多いと思います。Modelに「showSpinner : Bool」なるものを用意しておき、この値を変えることでスピナー表示制御をすることがほとんどでしょう。スピナーの中身はelm-spinnerを使うのでも、CSSアニメーションスピナーを使うのでも、何でも構いません(詳細は触れませんが、elm-spinnerだとModelがずっと更新されるので、CSSアニメーションが好みです)。

Msgとスピナー制御のためのヘルパー関数を以下のように定義したとします。

Types.elm
   type Msg = MsgModelFnc (Model -> Model)
            | MsgModelCmdFnc (Model -> (Model, Cmd Msg))
ApiAccess.elm
showSpinner : cmd -> Model -> (Model, cmd)
showSpinner cmd model = ({model | showSpinner = True}, cmd)

hideSpinnerModel : Model -> Model
hideSpinnerModel model = {model | showSpinner = False}

hideSpinnerModelFnc : (Model -> Model) -> (Model -> Model)
hideSpinnerModelFnc fnc = fnc << hideSpinnerModel

こうすると、ボタンコールバックbuttonOnClickSpinnerは以下のようになります。getCounter自体はModelは不要なのですが、スピナー制御の関数でModelの更新をするため、全体としてはModelを引数にとった処理となります。このケースでも変更するのはボタンコールバックのみで、APIコールバック側は変更なしです。

ViewA.elm
-- view(一部)
button [onClick <| MsgModelCmdFnc buttonOnClickSpinner ] [text "update counter"]

-- ボタンコールバックにスピナー表示制御を入れる(ON、OFFともに)
buttonOnClickSpinner : Model -> (Model, Cmd Msg)
buttonOnClickSpinner = showSpinner << getCounter <| MsgModelFnc << hideSpinnerModelFnc << getCounterCallback

-- APIコールバックはスピナーなしバージョンと同じ
getCounterCallback : Result Http.Error Int -> Model -> Model
getCounterCallback res model =
    case res of
        Ok new_count -> {model | counter1 = new_count}
        Err _ -> model

まとめ

上述のような実装をすることで、viewとupdate相当の処理を同じファイルに実装することができるようになりました。

なったのですが、正直、型を合わせるのが大変です。型さえ合えばまず期待通りに動くので、いいといえばいいのですが...なんとなく「簡単なことを難しく考えている」ような気がしないでもないです。

こういう話題は「デザインパターン」というものなのでしょうか?なかなか情報が探しづらいという感想もあります。

[2018/12/4 追記]

Twitterで教えてもらいましたが、Elm作者を中心に「Msgには関数を入れるべきではない」という論調になっているようです。

理由はいろいろありますが、具体的な弊害としては「Msgヒストリが役にたたない」です。これは私も感じていて、今回のような実装をすると、Debug.logとかでMsgを単純に表示しても中身が見えなくなりました(Msgに含まれる関数はいつも同じ、ですからね)。Modelの中身が更新されたらどうする問題については、「Modelの扱いに注意すれば」という話っぽいです(わからないではないけど...)

あと、上記リンク中のコメントで「多少コードは大きくなるかもしれないが」ともありまして、「多少、か?!」と思うところも正直ありますけどね..

10
7
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
10
7

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?