Posted at

ElmでHttpをわかってしまおう

Elmの入門者が増えHttpを扱う需要が増えてきたように感じます。ElmのHttpモジュールは2.0.0にバージョンが上がり1.0.0のコードは参考にしにくく、そもそもHttpを扱うにはTaskと呼ばれる非同期処理を扱う仕組みを理解しないといけなく少々敷居が高くなります。Taskは公式ガイドには触れる程度しか書かれていないため、これも障壁となるため かいつまんで説明出来ればなと思います。


課題(前提)

今回扱うAPIは以下のような仕様とします。



  • /test というルーティング

  • クエリで page には数字を渡すことができます。ページャを表す

  • 1-3ページまでがアクセス可能

  • それ以外のページはNot Found

  • レスポンスとして、result(string)とisNext(bool)を返す

  • resultが今回欲しい文字列

  • isNextは次のページが存在すればtrueを返す

Webpack起動時にExpressで書かれたサーバを仕込んでいます。

app.get("/test", function(req, res) {

const pageNum = Number(req.query.page);
const maxPageNum = 3

if(pageNum < 1 || maxPageNum < pageNum) {
res.status(404).send('Page Not found');
} else {
res.json({
result: `page=${pageNum}`,
isNext: pageNum < maxPageNum
});
}
});

今回欲しい結果は、1-3ページのresultをカンマ区切りにした文字列、

page=1, page=2, page=3

とします。


実装


Taskを利用しない(Msg再帰)パターン

まずはTaskを利用せずにCmdで頑張ってみた例を紹介します。CmdでAPIを叩き、Msgで受け取りModelを更新します。そして、その中身を見て再びCmdでAPIを叩くか終了するかを判断します。つまり、update関数の再帰をするという考え方になります。

まずはModelとデータ構造の定義です。APIを叩いた中身を溜め続けるresultChunkと現在の見ているページcurrentPageです。Modelは、空のリストと1ページ目で初期化しておきます。APIのレスポンスの型を先程のExpressのAPIと合わせます。

type alias Model =

{ resultChunk : List String
, currentPage : Int
}

init : () -> ( Model, Cmd Msg )
init _ =
( { resultChunk = [], currentPage = 1 }, Cmd.none )

type alias Response =
{ result : String, isNext : Bool }

ここが本命です!KickTestServerでAPIを叩き始めます。後述するgetTestServerResponseWithPage関数にページ数(1ページ)を渡して呼び出します。このコマンドは、GotServerResponseでレスポンスを受け取りパターンマッチでResult型の分岐をします。OkのときisNextを確認してTrueであれば、resultChunkresultを積んでcurrentPageページをインクリメントした後に再びAPIを叩きます(GotServerResponseの再帰)。isNextがFalseのときCmd.noneつまり再帰を停止します。

type Msg

= KickTestServer
| GotServerResponse (Result Http.Error Response)

update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ resultChunk, currentPage } as model) =
case msg of
KickTestServer ->
( { model | resultChunk = [], currentPage = 1 }, getTestServerResponseWithPage 1 )

GotServerResponse res ->
case res of
Ok { result, isNext } ->
if isNext then
( { model | resultChunk = result :: resultChunk, currentPage = currentPage + 1 }, getTestServerResponseWithPage currentPage )

else
( model, Cmd.none )

Err err ->
( { model | resultChunk = [ "Error: " ++ httpErrorToString err ] }, Cmd.none )

Http.getはCmdを返します。

getTestServerResponseWithPage : Int -> Cmd Msg

getTestServerResponseWithPage pageNum =
let
expect =
Http.expectJson GotServerResponse responseDecoder
in
Http.get { url = "/test?page=" ++ String.fromInt pageNum, expect = expect }

responseDecoder : Decode.Decoder Response
responseDecoder =
Decode.map2 Response
(Decode.field "result" Decode.string)
(Decode.field "isNext" Decode.bool)

httpErrorToString : Http.Error -> String
httpErrorToString err =
case err of
BadUrl _ ->
"BadUrl"

Timeout ->
"Timeout"

NetworkError ->
"NetworkError"

BadStatus _ ->
"BadStatus"

BadBody s ->
"BadBody: " ++ s

view : Model -> Html Msg
view { resultChunk } =
div [ class "container" ]
[ button [ onClick <| KickTestServer ] [ text "getPages" ]
, p [] [ text <| String.join ", " (resultChunk |> List.reverse) ]
]


Taskを利用するパターン

Taskを利用するパターンです。先ほどとの違いはupdateの再帰は行わず、Task自身で最終的に欲しい形を組み立てます。そのためGotServcerResponseの成功時の型がReponseからList Stringとなります。さらにTask自身で再帰をするためModelからはcurrentPageが必要無くなります。TaskをCmdにするには、Task.attempt関数を使います。

type Msg

= KickTestServer
| GotServerResponse (Result Http.Error (List String))

update : Msg -> Model -> ( Model, Cmd Msg )
update msg ({ resultChunk, currentPage } as model) =
case msg of
KickTestServer ->
( model, Task.attempt GotServerResponse <| chunkResultTask 1 [] )

GotServerResponse res ->
case res of
Ok rc ->
( { model | resultChunk = rc }, Cmd.none )

Err err ->
( { model | resultChunk = [ "Error: " ++ httpErrorToString err ] }, Cmd.none )

Http.Taskを利用するとHttpの戻り値をTaskにすることができますが、Http.getなどと比べるとローレイヤの仕組みなので少々面倒なパラメータを設定します。特にHttpのJSON DecoderとResponseを処理するResolverは厄介です。jsonResolverがライブラリには定義されていないためライブラリのresolve関数を参考にして組み立てます。これはよく使うコードなのでResolverは、いつでも参照できるようにしておくと良いかも知れません。

getTestServerResponseWithPageTask : Int -> Task Http.Error Response

getTestServerResponseWithPageTask pageNum =
Http.task
{ method = "GET"
, headers = []
, url = "/test?page=" ++ String.fromInt pageNum
, body = Http.emptyBody
, resolver = jsonResolver responseDecoder
, timeout = Nothing
}

jsonResolver : Decode.Decoder a -> Http.Resolver Http.Error a
jsonResolver decoder =
Http.stringResolver <|
\response ->
case response of
Http.BadUrl_ url ->
Err (Http.BadUrl url)

Http.Timeout_ ->
Err Http.Timeout

Http.NetworkError_ ->
Err Http.NetworkError

Http.BadStatus_ metadata body ->
Err (Http.BadStatus metadata.statusCode)

Http.GoodStatus_ metadata body ->
case Decode.decodeString decoder body of
Ok value ->
Ok value

Err err ->
Err (Http.BadBody (Decode.errorToString err))

最後にTaskの再帰方法です。単に受け取った結果を元に再びAPIを叩くと、Task (Task Response)のような感じでTaskがネストしてしまいます。それをフラットにする関数がTask.andThenになります。再帰方法はCmdを利用したときと同じ形になります。最終結果は固定値をTask.succeedで返します。

chunkResultTask : Int -> List String -> Task Http.Error (List String)

chunkResultTask currentPage resultChunk =
getTestServerResponseWithPageTask currentPage
|> Task.andThen
(\{ result, isNext } ->
if isNext then
chunkResultTask (currentPage + 1) (result :: resultChunk)

else
Task.succeed (result :: resultChunk)
)


ページ数があらかじめ わかっているとき

最後は取得すべきページ数があらかじめわかっているときです。1-3の数をList.mapでTask Reponseにします。欲しいのはその中のresultなので、Task.map .resultList (Task x a)という型の値にします。実際欲しいのはTaskのListではなく、Task x (List String)の形にします。これはよくある変換なので、Task.sequenceという変換の関数が用意されています。

KickTestServer ->

let
getResultTask =
getTestServerResponseWithPageTask >> Task.map .result
in
( model
, Task.attempt GotServerResponse <| (List.range 1 3 |> List.map getResultTask |> Task.sequence)
)


ソースコード

ソースコードは、こちら


まとめ

連続でAPIを叩きたい場合、updateの再帰を利用することで実現することを学びました。しかしその場合は、Modelで一時的な変数を持つ必要があったり再帰のコードがupdateに混ざり複雑になりがちです。Taskを利用することでTaskの中の再帰として解決することができupdateの中身がシンプルになります。つまりこれは、Cmdと比べてTaskは変換や合成などの加工がしやすい非同期処理を扱う型だとあることがわかります。これらの道具を使いこなすと一気にElmの世界が楽しくなるので是非マスターしてみましょう!