WIP ElmのHttp処理部をスタブする

2016-10-17 / [elm]

Elm Advent Calendar 2016 2日目の記事です。

問題

Elmでアプリケーション書いているとリモートのWeb APIへリクエストを飛ばす部分がでてくる。 けれどチュートリアルの章を読むとHttpパッケージの関数を直接埋め込んで埋め込んである。この流儀に従ってそのまま実装していくと、elm-reactorで動かした際に、Web APIのエンドポイントに本当にリクエストを飛ばしてしまう

Web APIの実装がまだない場合や、テストでダミーのJSONを渡したい場合はこれだと困る。

できればテスト用に動かす時はHttp処理部分のスタブがほしい。

解法

なにか大仰なモックライブラリが必要か、とか悩んでしまったけど、簡単な話だった。

HTTPリクエスト処理部の関数だけ抜き出して、呼び出し元から渡せるようにすればいい。つまり 高階関数 で解決する。 Httpライブラリを呼び出す関数またはダミー用データを返す関数、いずれかを後から注入する感じ。

サンプルケース

たとえばWeb APIエンドポイントと通信して"Hello"という文字列を取り出してくるようなサンプルを考える。

サンプルは下記の環境で書いている。

    "dependencies": {
        "elm-lang/core": "4.0.0 <= v < 5.0.0",
        "elm-lang/html": "1.0.0 <= v < 2.0.0",
        "evancz/elm-http": "3.0.1 <= v < 4.0.0"
    },
    "elm-version": "0.17.0 <= v < 0.18.0"

では前提となるビューやモデルについて提示しておく。

モデル(状態を表す)

外部から受け取った文字列を保存するだけのモデル。

type alias AppState = { out : String }

初期状態ではただ空の文字列を出力するだけとしておく。

init : ( AppState, Cmd Msg )
init = ( { out = "" }, Cmd.none )

ビュー

say helloボタンを押すとFetchHelloというメッセージをトリガする。 モデルが保持する文字列をテキストとして書き出す。

view : AppState -> Html Msg
view model =
  div [ ]
      [ button
          [ onClick FetchHello ]
          [ text "say hello" ]
      , span
          []
          [ text model.out ]
      ]

メッセージ

このあたりから本題。

type Msg
  = FetchHello
  | HalloDone String
  | HelloFail Http.Error
  • FetchHello: Web APIに"Hello"という文字列を送ってもらう要求。これの応答をスタブしたい
  • HalloDone: 文字列を 正常に 受信した場合の応答。
  • HelloFail: 文字列を受信に 失敗した 場合の応答。

メッセージのハンドラ: update関数

update : Msg -> AppState -> ( AppState, Cmd Msg )
update msg model =
  case msg of
    -- FIXME: 普通だったらここで Http.get とかやるけど。。。。
    FetchHello -> Http.get string "http://hogefuga/hello" |> Task.perform HelloFail HalloDone

    -- ここ以降はWeb APIからのデータ受信後の動作なのであまり重要ではない
    HalloDone s -> { model | out = s } ! []
    HelloFail e -> { model | out = "failure!" } ! []

ようやく改善したいポイントに到着。

FetchHelloのパターンマッチに注目。 チュートリアルだとここでHttpモジュールの関数を呼び出して、 Web APIの呼び出しをおこなうTaskが発生する。

しかしここにHTTPリクエストの発行を埋め込んでしまうと、メッセージを受信した瞬間にWeb APIの呼び出しが走る。 そのため呼び出し先のHTTP APIがどこかで起動している必要がある。これが困る。 開発中はelm-reactorで気軽に動作確認したいのに、外部API呼び出しが走るのは避けたい。

そこだけ呼び出し元から制御できるようにしよう。

update : Cmd Msg -> Msg -> AppState -> ( AppState, Cmd Msg )
update helloRequest msg model =
  case msg of
    FetchHello -> (model, helloRequest)
    HalloDone s -> { model | out = s } ! []
    HelloFail e -> { model | out = "failure!" } ! []

updateの第一引数がFetchHelloメッセージを受信した時のアクションになる。 そこだけ呼び出し元から受け取れるようにしておく。

テスト用と実際のAPI呼び出し用の関数を用意する。

-- | 実際に外部APIを呼び出してリモートからデータを受け取る
fetchHello : Cmd Msg
fetchHello =
  Http.get "http://hogefuga/hello" string
    |> Task.perform HelloFail HalloDone

-- | ダミーのデータを返す
dummyHello : Cmd Msg
dummyHello =
  Http.succeed "Hello"
    |> Task.perform HelloFail HalloDone

Productionで動かす時は

update fetchHello : Msg -> AppState -> (AppState, Cmd Msg)

開発環境で動かす時は

update dummyHello : Msg -> AppState -> (AppState, Cmd Msg)

のように渡しわけることになる。

さらなる改善

複数のHTTPリクエストに対応するための準備

今までのコードだとFetchHelloしかスタブ処理に対応できない。 きっと他にもHTTPリクエストは発生するだろうからこれに対処する。

なのでちょっとデータ型に調整を入れる。

type Msg
  = FetchHello
  | HalloDone String
  | HelloFail Http.Error

これを

type Msg
  = Req Request
  | Res Response

type Request
  = FetchHello

type Response
  = HelloDone String
  | HelloFail Http.Error

こうする。

  • Msg: アプリケーション全体で発生しうるメッセージを表す
  • Request: HTTPリクエストを発行せよ、という命令を表す
  • Response: HTTPレスポンスで受け取ったデータを保持するコンテナを表す

要求を表すメッセージと応答を表すメッセージをデータ型として分離している。 これにより今回スタブしたい部分の型をより正確に表現できるようになる。

Request -> Cmd Response

なんらかのHTTPリクエストを発行する要求を受け取って、HTTPレスポンスを返すような関数だ。

FetchHello以外のメッセージが増えた場合にはこんな風に書けるだろう。

dispatch : Request -> Cmd Response
dispatch req =
  case req of
    FetchHello -> ...
    FetchHola -> ...
    FetchHallo -> ...

上手く複数パターンのHTTPリクエストに対応できそうだ。

updateの対応

これに合わせてupdate関数はこのように書き直される。

update : (Request -> Cmd Response) -> Msg -> AppState -> ( AppState, Cmd Msg )
update disp msg model =
  case msg of

    Req request ->
      (model, (disp request |> Cmd.map Res))

    Res response ->
      case response of
        HelloDone s -> { model | out = s } ! []
        HelloFail e -> { model | out = "failure!" } ! []

大事なポイントを抜粋してみる。

    Req request ->
      (model, (disp request |> Cmd.map Res))

何かしらのHTTPリクエストを発行した場合には、この分岐に入る。

ちょっと型が見えにくいので整理しておこう。

request : Request
disp : Request -> Cmd Response

requestの値はFetchHelloかもしれないしFetchHolaかもしれない。 いずれにしてもdisprequestに適用することでHTTPレスポンスを得ることができる。

disp request : Cmd Response

これだけで終わりではなくて、Cmd.mapという関数も使っている。

Cmd.map Res : Cmd Response -> Cmd Msg

Cmd.map Resの型はCmd Response -> Cmd Msgを返すのでこれでupdate関数の型に合わせることができる。 これらを組み合わせて適用すると下記のような型になるはずだ。

disp request |> Cmd.map Res : Cmd Msg

これでupdate関数の返り値の型に整合したのでコンパイルエラーはなくなる。

ダミーデータを返す

開発用ではダミーデータを返す必要がある。 これはTaskモジュールの関数succeedを活用することで実現できる。

dispatch : Request -> Cmd Response
dispatch req =
  case req of
    FetchHello -> 
      Task.succeed "Hello"
        |> Task.perform HelloFail HelloDone

これでupdateに渡す関数dispatchが出来上がった。

本当にHTTPリクエストを発行したい場合は、 同じ型だけど実際にHttpモジュールの関数を使ってHTTP通信を行うような新しい関数を用意してあげればいい。

関数を組み合わせてAppを作る

updatedispatch関数を組み合わせるのは、Programを作る段階で行う。


main : Program Never
main =
  Html.App.program
    { init = init
    , view = view
    , update = update dispatch
    , subscriptions = subscriptions
    }

今回はMain.elmという1ファイルに全て詰め込んだけれど、 開発用と商用で分けるならmain関数定義だけそれぞれのファイルに書いてやる。

  • Main.elm
  • Test.elm

それぞれdispatch関数を開発用と商用で呼び分けるようにしておくといい。

振り返り

とりあえずこれで開発用にHTTP通信部をスタブしたり、本当にWeb APIを呼ぶように切り替えたりできるようになった。

実際に自分がElmのコードを書く時はこんな風にしてスタブを作りながらelm-reactorで表示を確認している。

こんなこと書いておいて何だけど「いや実はもっとまともなモック用のライブラリがあってね。。。」という情報あったら是非教えてほしい。

この方法のメリット

  • HTTP通信部をスタブ処理で置き換えることができる

この方法のデメリット

  • 直接HTTP通信部を埋め込むよりも1サイクルだけupdate関数の呼び出しが増えるのでもしかしたらパフォーマンスに影響があるかも

コード全体はGistにあげました。

雑感 (読み飛ばしていい)

「なんだやってることは当たり前で普通じゃないか」と思うかもしれない。

普通に高階関数を使っているだけだ。 でもそれでいい。それがいい

複雑な概念の導入や技法もなく関数を組み合わせるだけで要求を満たせてしまう。 そういう関数型プログラミングの良さを実感できたのが今回の収穫だった。

もちろんElm Architectureがとても良くできていたおかげでもあるんだけど。