スポンサーリンク

『基礎からわかるElm』を写経してみる(7)実践3:ナビゲーションとテスト(複数ページのElmアプリの作り方)

2019年6月3日

以下の本を写経しています。 以下の環境構築の後、elm-formatとVisualStudioCodeのElm拡張機能を用いて、『Alt + Shift + F』と『Ctrl + S』を使用しながらやっています。

今回は、p207の『実践3:ナビゲーションとテスト』を写経して、Elmで複数ページのアプリの作り方を学びたいと思います。

本家サイトのコード
https://github.com/jinjor/elm-book/tree/master/4_7_navigation-github

スポンサーリンク

開発環境

Windows 10 Pro
Chrome
VisualStudioCode 1.32.3
git version 2.20.1.windows.1
nvm 1.1.7
node 10.2.0
npm 6.4.1
elm 0.19.0-bugfix6
elm-format 0.8.1
VisualStudioCodeの拡張機能でelmをインストールして、settings.jsonに以下のようにelmを設定。
(『Alt + Shift + F』と『Ctrl + S』を使用。)

    "[elm]": {
        "editor.formatOnSave": true
    },

elm-live 3.4.1
elm-test 0.19.0-rev6

今回作る予定のアプリの概略

3ページからなるアプリだそうです。

  • GitHubのユーザ名一覧を表示(トップページ : “/")
  • ユーザ名をクリックしたら、ユーザのリポジトリ一覧を表示(2ページ目 : “/{user}")
  • リポジトリをクリックしたら、リポジトリのIssue一覧を表示(3ページ目 : “/{user}/{repo}" )

新規Elmアプリの作成

C:/elm/ フォルダに、 elm-github-viewer1/ フォルダを新規作成し、
C:/elm/elm-github-viewer1/ フォルダをVisualStudioCodeで開き、Ctrl+@でコマンドプロンプトを開き、以下を入力します。

elm init
elm-test init

URLパーサーの実装

URLパーサーって何?って感じですが、

parse 『解析する』

とのことで、ブラウザにURLが入力されたら、そのURLを解析して、

  • URLを"/"などで分割して、どのページを表示するかを決定
  • URLから”ユーザ名”などを取り出して、その『ユーザ名』をGitHub APIに問い合わせて、ユーザ名に対応するリポジトリのリストをとってくる

といった感じに、

URLから意味のあるデータを取り出す

もののようです。

今回は、以下のようなパスを用意することにします。

  • /
  • /{user}
  • /{user}/{repo}

これらのパスを扱うためにRouteモジュールを用意します。(多少言葉が分からなくても、とにかく、『基礎からわかるElm』を写経するのみ。。。)

type Route
    = Top
    | User String
    | Repo String String

トップページ(Top)と、ユーザページ(2ページ目)、リポジトリページ(3ページ目)のRouteを定義しているようです。

URLから、上記のRouteオブジェクト(?)を作成するための関数parse を用意します。いきなり実装は困難なので、とりあえず、Debug.todoと記載しておくようです。

parse : Url -> Maybe Route
parse url =
    Debug.todo "implement parser"

なお、今回は、elm/url パッケージのUrl.Parserモジュールを使います。"elm install elm/url"でパッケージをインストールしておき、Elmファイルでは、"import Url"などをしておく必要があります。
( 『基礎からわかるElm』 p191 )

トップページのRouteのテスト

tests/Example.comを以下のように書き換えます。(
基礎からわかるElm』 p208)

コマンドプロンプトで"elm-test"を実行します。

なんだか、 『基礎からわかるElm』 p208の結果とだいぶ異なるようです。

https://github.com/jinjor/elm-book/blob/master/4_7_navigation-github/elm.json

を見ると、elm/http, elm/json, elm/urlパッケージはインストールした方がよさそうですので、以下を実行してインストールします。

elm install elm/http
elm install elm/json
elm install elm/url

もう一度elm-test してみますが、同じエラー

import するのですね。Route と Urlをimport してみます。
Routeなんてあったかな?

Urlのimportのエラーは消えましたが、Routeがないよと言われます。

“The “source-directories" field of your elm.json tells me to look in directories"

と教えてくれているので、elm.jsonを見ると、

    "source-directories": [
        "src"
    ]

となっているので、というか、いつも通りですが、src/フォルダに、Route.elmを作成します。

src/Route.elmの新規作成

src/Route.elm

module Route exposing (Route(..), parse)

import Url exposing (Url)
import Url.Parser exposing (..)


type Route
    = Top
    | User String
    | Repo String String


parse : Url -> Maybe Route
parse url =
    Debug.todo "implement parse"

elm-test します。

やっとelm-testがまともに動きました。当然Failedですが。

最初のelm-testを通す

src/Route.elmを編集して、テストを通します。

(変更前)

parse url =
    Debug.todo "implement parser"

(変更後)

parse url =
    Just Top

とりあえず最初のテストをPASSED にすることはできました。

さらにtest/Example.elmのテストを増やし、それをPASSするようにsrc/Route.elmを編集していくと、以下のようになるそうです。 (『基礎からわかるElm』 p209 )

ごりごり写経していきます。

ここで、一旦、GitHubに新規リポジトリを作成して、pushしておきます。(その前に、 .gitignoreファイルを作成し、 /elm-stuff  と記載しておきます。)

git init
git add .
git commit -m "first commit"
git remote add origin https://github.com/adash333/elm-github-viewer1.git
git push -u origin master

この時点でのコード
https://github.com/adash333/elm-github-viewer1/tree/2965fc90b3eae3d1d11864c78e80575cbf603418

tests/Example.elmとsrc/Route.elmを編集

基礎からわかるElm』 p209 を写経します。
test/Example.elmを写経してからelm-testすると、以下のようになります。

さらに、src/Route.elmを写経してからelm-testすると、以下のようにテストがPASSED になります。

src/Main.elmの新規作成

src/Main.elmを新規作成します。

SPA(Single Page Application)なので、Elmの初期化方法としては、Browser.applicationを用います。
(参考:@jinjor 2018年12月03日に更新 Elm 0.19 の初期化方法 6 種類

この時点でのソースコード
https://github.com/adash333/elm-github-viewer1/tree/9d95a04b46458dfde3ed274d4812a7f8d4e4cb96

MODELにページを定義する

このアプリが持つ状態としては、ページの種類(トップページ、2ページ目の各ユーザ名のリポジトリのリスト、3ページ目の各リポジトリのリスト、Not Found)でしょうか?

それぞれのユーザ名も『状態』になるのか、よくわかりませんが、写経していきます。

init 初期状態は、トップページにしますが、よくわかりませんが、これも写経しておきます。

type Page
    = NotFound
    | TopPage
    | UserPage (List Repo)
    | RepoPage (List Issue)

UPDATE

UPDATEのmsgとしては、まずは、以下の2つを考える必要があります。

  • リンクをクリックしたとき
  • ブラウザのURLが変更されたとき
type Msg
    = LinkClicked Browser.UrlRequest
    | UrlChanged Url.Url

具体的には、例えば、TopPage(1ページ目)の各ユーザ名をクリックしたときには、

  1. 2ページ目を表示する
  2. クリックされたユーザ名のデータをGitHub APIに送り(HTTPリクエスト)、そのユーザのリポジトリのリストをとってくる
  3. 何らかの原因により、GitHub APIからリポジトリのリストをとってこれなかったときは、エラーがあったことを表示する

といった流れになりそうです。ここで、HTTPリクエストが必要だということがわかるので、Msgに『HTTPリクエストが返ってきたとき(ページがロードされたとき)』を加えます。

type Msg
    = LinkClicked Browser.UrlRequest
    | UrlChanged Url.Url
    | Loaded Page

HTTPリクエストの結果がエラーであったときは、今回は、専用のエラーページに飛ぶこととします。

type Page
    = NotFound
    | TopPage
    | UserPage (List Repo)
    | RepoPage (List Issue)
    | ErrorPage Http.Error

UPDATEは大枠に記載すると以下のようになります。

msgのところを少し追加します。

Msgその1: LinkClicked Browser.UrlRequest

今回はMsgは3つとなります。1個目は、リンクがクリックされたときの、InternalリンクとExternalリンクのお決まりの書き方です。ここは毎回コピペでさそうです。( 『基礎からわかるElm』 p181 )

LinkClicked urlRequest ->
    case urlRequest of
        -- 内部リンクならブラウザのURLを更新する
        -- (SPAなので実際に表示するページはindex.htmlのまま)
        Browser.Internal url ->
            ( model, Nav.pushUrl model.key (Url.toString url) )
        -- 外部リンクなら通常の画面遷移を行う
        Browser.External href ->
            ( model, Nav.load href )

Msgその2: Loaded result

ページの内容を非同期で取得したときの共通処理を記載します。

『非同期』ユーザには便利そうですが、実装する側は大変です。私はよくわかっていません。写経します。でも、これも毎回コピペでやさそうな気がします。

Loaded result ->
    ( { Model
        | page =
            case result of
                Ok page ->
                    Page
                Err e ->
                    -- 失敗したときはエラー用のページ
                    ErrorPage e
        }
    , Cmd.none
    )

Msgその3: UrlChanged Url.Url

移動前のページのリンクがクリックされた後、ブラウザのURLが変更されたときの挙動の実装を行います。

コードがすごく長くなるので、ページの初期化処理を、ヘルパー関数

goTo (Route.parse url) model
( Route.parse url を maybeRoute とします )

に移譲します。goTo関数は、URLを解析したものと、モデルから新しいモデルを作成します。(当たり前か、、、)

(変更前)

(変更後)

maybeRouteがRoute.Userであったとき(2ページ目、各ユーザのリポジトリ一覧を表示するページのとき)は、

  1. GitHub APIに『ユーザ名』を含んだHTTPリクエストを送り、
  2. 『ユーザ名』に対応してリポジトリのリストを受け取り、
  3. 『ページがロードされたというMsg』にリポジトリのリストを送る

httpとか、>> とか、さっぱりわかりませんが、やっぱりゴリゴリ写経です。

-- Route.Userだった場合
Just (Route.User userName) ->
    ( model
    , Http.get
        { url =
            Url.Builder.crossOrigin "https://api.github.com"
                [ "users", userName, "repos"]
                []
        , expect =
            Http.expectJson
                (Result.map UserPage >> Loaded)
                reposDecoder
        }
    )

はい、ここ、分かりません。いろいろ勉強しないと理解できなさそうです。。。まあ、いつか。。。

Decoderは、JSON(文字列)を、Elmが分かるデータに変換する関数だそうです。後の方で、Decoderも定義します。Json.Decodeを用います。

  • Http (2.0.0)
  • Http.get
  • Http.expectJson
  • >>
  • map
  • Decoder (Json)

参考1: https://qiita.com/ababup1192/items/b03fce202e1018bc4992

参考2: https://package.elm-lang.org/packages/elm-lang/core/latest/Basics#(%3E%3E)

maybeRoute(Route.parse url)がRoute.Repoだった場合 は、Route.Userの場合の引数が1個から2個になるだけなので、本質的には同じだと思いますので、目をつむってコピペしておきます。

GitHub APIとのデータのやり取りを行うときの方typeの定義とDecoderの定義を行います。

はい、コピペです。リポジトリRepoの型typeとDecoderは以下のように定義します。

import Json.Decode as D exposing (Decoder)

-- GITHUB

type alias Repo =
    { name: String
    , description: String
    , language: String
    , owner: String
    , fork: Int
    , star: Int
    , watch: Int
    }

reposDecoder : Decoder (List repo)
reposDecoder =
    D.list repoDecoder

repoDecoder : Decoder Repo
repoDecoder =
    D.map7 Repo
        (D.field "name" D.string)
        (D.field "description" D.string)
        (D.maybe (D.field "language" D.string))
        (D.at [ "owner", "login" ] D.string)
        (D.field "forks_count" D.int)
        (D.field "stargazers_count" D.int)
        (D.field "watchers_count" D.int)

出ました、map関数。。。そっとしておきます。通常のパッケージでは、map8 までだそうです。(map9以上を使用する場合は別のパッケージをインストールする必要あり。)

参考:https://qiita.com/ymtszw/items/1cabbdbda4273b4c1978

issuesDeocderは、reposDecoderと本質的に同じなので、目をつむって写経しておきます。

ここまでのソースコード
https://github.com/adash333/elm-github-viewer1/tree/

VIEW

最後に、場合分けしてページを表示します。

  • トップページ
  • 2ページ目(指定したユーザのリポジトリ一覧)
  • 3ページ目(指定したユーザ/リポジトリのissue一覧)
  • NotFoundのページ
  • Errorページ(HTTPリクエストがうまくいかなかったとき?)

なお、今回は、トップページに表示するユーザ名は"elm"と"evancz"(Elmの作者)とします。

写経します。

-- VIEW


view : Model -> Browser.Document Msg
view model =
    { title = "My GitHub Viewer"
    , body =
        [ a [ href "/" ] [ h1 [] [text "My GitHub Viewer1"] ]
        -- 場合分けしてページを表示
        , case model.page of
            NotFound ->
                viewNotFound
            ErrorPage error ->
                viewError error
            TopPage ->
                viewTopPage
            UserPage repos ->
                viewUserPage repos
            RepoPage issues ->
                viewRepoPage issues
        ]
    }

あとは、それぞれのページのVIEWを書いていくことになります。その後、保存するたびに、いろいろコンパイルエラーを指摘されるので、大文字や小文字、タイプミスをいろいろ直していきます。

型の定義を間違えたり、VisualStudioCodeが勝手に文字を大文字にしたり、変換したりしたのを直したり、苦労しますが、なんとかコンパイルエラーをゼロにすることができました。

“type mismatch"のエラーが多く、型typeを明確にすることにより、コンパイルエラーを事前に察知することができるのだなあと、思ったり思わなかったりしました。

elm reactorしてみる

ここまで数時間。。。elm reactorしてみます。elm reactorは複数elmファイルに対応しているのでしょうか?きっと対応していますよね?

elm reactor

ブラウザで、http://localhost:8000 を開きます。

src -> Main.elm とクリックしていくと、一瞬、ユーザ名2つが表示されるのですが、すぐにBadStatus 404と表示されてしまい、一瞬がっくり。

とりあえず、My GitHub Viewer1のリンクをクリックします。
すると、大丈夫そうでした。この後、リポジトリ一覧、issue一覧も表示することができました。

写経しただけなのに、すっごく疲れました。まだHttpやDecoder, map, >> も理解できていないし。。。

しかし、これで、Elmで複数ページのアプリを作るのに一歩近づけた気がします。(IonicとかVueなら超簡単なのに、、、なぜ自分はElmやっているのでしょうか。。。まあ、Elmがなんとなく、楽しそうだからなのですが、、、)

ここまでのソースコード
https://github.com/adash333/elm-github-viewer1/tree/cad6f25cd24d71bd4c7237e402a07b06b53a8ef1

Netlifyにデプロイ

Netlifyにデプロイするために、index.htmlを作成します。

ターミナル画面で以下を入力して、ブラウザで見ながら、Main.elmのVIEWのところをちょこっとBulmaで装飾します。

そのとき気付いたのですが、トップページでevanczをクリックすると、以下のようなエラーになってしまいました。Ctrl+Shift+I でconsoleを見たり、いろいろしましたが、原因は分かりませんでした。がっくし。。。

とりあえず、このときのソースコードを以下に保存しておきます。

https://github.com/adash333/elm-github-viewer1/tree/6fda359287296c8697059d8bb4907828b18daae1

仕方がないので、サポートページのコードのsrc/Main.elmをコピペします。目視でいろいろ差分を見ようとしたのですが、見つけられなかったので、あきらめて『コピーアンドペースト』します。

https://github.com/jinjor/elm-book/blob/master/4_7_navigation-github/src-v1/Main.elm

えっと、結局、同じエラーが出ました。これはあきらめた方がよさそうです。

ちなみに、
https://github.com/jinjor/elm-book/tree/master/4_7_navigation-github/src-v2 のコードをelm reactorで試してみると、evanczのrepoもちゃんと表示できました。

https://github.com/jinjor/elm-book/blob/master/4_7_navigation-github/src-v2/GitHub.elm  を見ながら、 repoDecoder = のところを少し変えてみます。

(変更前)

(変更後)

type alias Repo = のところも、 description : Maybe String にしておきます。そのあと、elm reactorしてからブラウザでsrc/Main.elmを見ると、evanczのrepoもちゃんと表示されました。

GitHub APIから受け取ったJSONの中の

“description"

をデコードするときに、Maybe Stringにしておかないと、上記のようなエラーになるそうです。なぜかは分かりませんが、evanczとelmのdescriptionの違いを見てみると、ピリオドが入っているかとか、そんな感じでしょうか(他にもいろいろ試してみたのですが、descriptionがnullだったり、やはり中にピリオドや` が入っていたりするとダメそうでした。)。

残念ながら、このDecodeのところは、コンパイル時には見つけらないエラーということなのでしょうか。

ちなみに、elmの場合のdescriptionは以下のようになっていました。

やっとエラーが解決されたので、Netlifyへのデプロイに戻ります。

netlify.tomlの新規作成

[[redirects]]
  from = "/*"
  to = "/index.html"
  status = 200

production用main.jsの作成(コンパイル)を行います。

ターミナル画面で以下を入力します。(本当は、さらに、uglify.jsを用いた方が、ファイルサイズが小さくなるそうですが、今回はパスします。)

elm make src/Main.elm --output=main.js --optimize

あ、そういえば、どこかで、–optimizeするときは、コードからDebugを除いておかないとダメって書いてありました。コード内のDebugのところを削除してあら、もう一度、以下を入力します。

elm make src/Main.elm --output=main.js --optimize

この時点でのソースコード
https://github.com/adash333/elm-github-viewer1/tree/cf793f768b2b38b1c5f12918c74062d7c5ea17d4

https://www.netlify.com/ にログインして、New Site from Gitから、上記GitHubのコードを指定してデプロイします。

Build commandとPublish directoryは空欄のままです。

無事、デプロイできました。
https://modest-kalam-a2885a.netlify.com/

ソースコード

ソースコード
https://github.com/adash333/elm-github-viewer1/tree/63ea51facae3f9698cc96766ea11730bc74f4639

DEMOサイト
https://modest-kalam-a2885a.netlify.com/

GitHubモジュールを作る

src/Main.elmが肥大化したので、GitHubに関するロジックを別のファイルGitHub.elmに分離します。

Main.elmに必要なものは、2つの型Issue, Repoと、それらを取得する getIssues, getReposとなります。

src/GitHub.elm を新規作成します。

— TYPESと– DECODERはMain.elmからコピペし、新たにgetReposとgetIssuesを以下のように定義します。

なんか、ここ、難しそうです。

-- API


getRepos : (Result Http.Error (List Repo) -> String -> Cmd msg)
getRepos toMsg userName =
    Http.get
        { url =
            Url.Builder.crossOrigin "https://api.github.com"
                [ "users", userName, "repos" ]
                []
        , expect =
            Http.expectJson toMsg reposDecoder
        }


getIssues : (Result Http.Error (List Issue) -> msg) -> String -> String -> Cmd msg
getIssues toMsg userName projectName =
    Http.get
        { url =
            Url.Builder.crossOrigin "https://api.github.com"
                [ "repos", userName, projectName, "issues" ]
                []
        , expect =
            Http.expectJson toMsg issuesDecoder
        }

上の方で、必要なものをimportしておきます。

import Http
import Json.Decode as D exposing (Decoder)
import Url
import Url.Builder

上記GitHub.elmを用いるために、Main.elmを編集します。

import GitHub exposing (Issue, Repo)

(変更前)

(変更後)

なんかコードがすっきりしています。その他、コンパイルエラーを直して、再度、以下を入力して、ちゃんと動くことを確認したのち、git pushします。

elm-live src/Main.elm --open -- --output=main.js
elm make src/Main.elm --output=main.js --optimize

ソースコードは以下となりました。

https://github.com/adash333/elm-github-viewer1/tree/0a7ecdffd375224a7367c63a7f3a44fbb75a65b8

DEMOサイト 
https://modest-kalam-a2885a.netlify.com/  もちゃんと動いているのを確認して、今回はこれでおしまいです。