Elm 言語 が楽しすぎたので Typetalk のクライアントアプリを作りました。

BacklogチームのLeoです。ドイツ出身ですが、ゲーム、マンガ、時間通りの電車、日本語、納豆、カラオケが好きで日本に移住してきました。私はBacklogのGrowthチームですが、今日はプログラミング言語 Elm を使って Typetalk クライアントアプリを開発したお話をします

さて、 Elm とは英語でニレ(楡)属の樹木の総称、ハルニレの通称です。そしてフロントエンド開発を楽しくするプログラミング言語でもあります。そんなプログラミング言語の Elm を使った開発、気になりませんか?

目次

Elm 言語

約一年前ReactとReduxで作られたウェブアプリを触ったとき初めてElmという言葉を知りました。(Reduxがドキュメントに「Even if you don’t plan to use Elm, you should read about the Elm architecture, and play with it.」まで書くほどElmに影響を受けています)。そのときは特に気にしませんでしたが、時が流れ、数回Elmのことを耳にして実際使ってみたらすぐハマりました。

Elmは2012年に生まれで若く、純粋関数型言語です。Haskellに影響を受けており、見た目もいつものC系言語とちょっと違う。それなのに、慣れたらとても楽しい言語です。Reduxの作者が言う通り、プロジェクトに使うつもりがなくても、少しだけ遊んで使ってみることに価値があります。(以前、関数型言語に挫折した方なら尚更です。なぜなら、Elmはモナドとかがわからなくても楽しめる言語ですから)

ネット上のElm入門には実用性の低い例が多いので、今回はElmを使って実際に使うことのできるTypetalkクライアントアプリを作ります

これからElmで作るTypetalkクライアント

Elm は何が美味しい?

まず、Elmの良いところを挙げてみます

  • 静的型付け言語なのに簡潔でわかりやすい
  • 困った時にコンパイラが助けてくれます。エラーメッセージがわかりやくて便利です。コンパイラと型システムのおかげでElmでは普通、実行時例外 (runtime exception)が起こりません。
  • リファクタリングが簡単で、コンパイルが通ればまた正確に動きます。最初に書くときもそれが多いです。コンパイルで問題がなければバグが驚くほど少ないです。
  • テストしやすい。純粋(つまり副作用がない)関数でテストしやすく、Elmアーキテクチャーならアプリの状態が簡単に再現できます。
  • JavaScriptとの連帯でもテストしやすい。Elmはまだ若い言語なので、膨大なJavaScriptエコシステムのライブラリを使いたくなる時がやってきます。そんな時は、Elmとの接点をしっかり定義しましょう。Elmで書いた部分を明確にすることでElmの強さも維持できますし、JSとのやりとりもテストしやすくなります。
  • 正式なフォーマットとそれを強制するツールがあります。Elmは、タブvsスペースなどのちっぽけな論争に既に神様の審判が下りたので、人間同士で争う必要がありません。
  • Elmアーキテクチャーでアプリを構成するのが一般的です。Elmアーキテクチャーではアプリが主に3つの部分に構成されています。
    • Modelはアプリのすべてを持つデータ構造です。アプリの全ての真実がModelに書いてあります。
    • UpdateはそのModelを更新する関数です。Updateがメッセージをもらって、そのメッセージの内容によりModelを更新されたModelに置き換えます。
    • ViewはModelを(HTMLなどに)可視化する「関数」です。ここでは関数がポイントです。表示されるのはモデルでありながらModelではなく、View(Model)です。UIの全てがこの関数にあるから、関心の分離 (separation of concerns)の漏れが発生しにくいし、モデルの一貫性を保てばViewの一貫性も保証されています。
    • 最後に、見えないが、その全ての上に浮んで見守るのはElm Runtimeです。Elm Runtimeがイベント(コマンド)によりUpdateを呼び出し、Virtual DOMを通してブラウザの本物DOMを更新します。ただしこの部分の細かいところはElmプログラマーはあまり気にしなくても問題ありません。

こんにちは世界樹

プログラミング言語入門なら、まずHello Worldプログラムを書かないと始まりませんね(違)。インストールをせずに、Elm Online Editorを使ってブラウザで書くこともできますが、Typetalkクライアントの実装に入ると使えなくなります。なので、以下の手順はインストールすることを前提に記述します。npm (Node package manager)が使えるなら

npm i -g elm elm-format@exp

でインストールできます(ついでにelm-formatと言うコードをきれいに整理するフォーマッターも)

エディターの設定はVisual Studio Codeの場合はElm拡張機能をインストールして、ユーザ設定に"elm.formatOnSave": trueという一行を追加すれば、Elmファイルを保存する度、自動的にフォーマットされてコンパイラのエラーが表示されます。

npmでインストールできない・したくない場合、別のエディターについてはElm Install Guideを参照してください。

インストールが終わったら、以下の内容のhello.elmというファイルを作りましょう。

module Main exposing (..)

import Html


main =
    Html.text "こんにちは世界樹"

作成したら、コマンドラインで同じフォルダにelm-reactorというコマンドを実行してからブラウザでhttp://localhost:8000/hello.elmを開いたら、次のことが起こります:

  • elm-reactorが必要なパッケージをダウンロードして、hello.elmをコンパイルします。出来たら「こんにちは世界樹」という文字がブラウザに出ます。めでたしめでたし。
  • hello.elmが入ってるフォルダに少しだけ目を離したら(野生の?)elm-package.jsonファイルとelm-stuffフォルダが現れました!どういう生き物なんでしょうか。

elm-package.jsonは依存するパッケージなどを書く設定ファイルです。名前がnpmのpackage.jsonと似てるのは偶然ではありません。(※Elmでは標準ライブラリーのパッケージも明記しなければなりません。現にelm-lang/coreelm-lang/htmlが既に書いてあります)
elm-stuffは無視しても問題ない存在なので、今は無視して良いです(gitにもその旨を伝えましょう)。コンパイラの中間ファイルやパッケージのキャッシュが入っています。

hello.elmに戻りましょう。

一行目のmodule Main exposing (..)はモジュール宣言で、モジュールの名前、そして外からアクセスできるものを宣言します。ただし、今回書くアプリは全部一つのモジュールに収まりますので、これから省略します。(なかったらelm-formatが勝手に入れますのでご了承ください。ちなみにElm Online Editorでは不要で、書いたらエラーになります)。
複数のファイルで出来てるアプリを作ったら必要ですので、その時はElmガイド (英語)Elmチュートリアル (日本語など)を参照してください。

import HtmlはHtmlパッケージを使えるようにします。インポートできるパッケージは同じフォルダ(あるいはサブフォルダ)にあるのとelm-package.jsonに宣言されるパッケージです。

最後に、main = Html.text "こんにちは世界樹"はmain関数と言う、アプリ開始に呼ばれる関数です。

Elm は関数で出来ている

Elmにとって大事なもの、それは関数です。変数(定数)と関数の壁が曖昧になってるほど関数が大事です。main関数をもう少し詳しく見ましょう。

main = Html.text "こんにちは世界樹"
mainは関数の名前で、Html.text "こんにちは世界樹"が関数の本体です。この本体ではHtml.textという別の関数を呼び出し"こんにちは世界樹"を引数として渡します。見ての通り、関数の呼び出しに括弧が書かない(曖昧な場合を除く。この場合はHtml.text(“hello”)ではなく、(Html.text “hello”)のように関数と引数の全体を括弧に囲みます)。

Htmlパッケージの関数たちはHtmlを作るためのものです。その中にtextは単に文字を出す、その中で一番単純な関数です。Htmlパッケージは後でもう少し詳しく見ます。

それではもう一個の関数を定義しましょう。

import Html

main =
    Html.text greeting

greeting : String
greeting =
    "こんにちは世界樹"

mainの内容は基本変わりませんが、Html.textに渡される引数が文字数から「文字数を返す関数」に変わりました。6行目のgreeting : Stringは関数のを示す型注釈 (type annotation)です。A : Bは「Aの型はBです」という意味です。Elmコンンパイラーは型推論 (type inference)をしてくれますので型注釈は任意ですが、コンパイラのためより、プログラマーのために書くドキュメントと考えたほうが良いです。

関数の型はStringやIntなどの単純な型以外に、以下のようながあります:
Int -> String は整数を文字列にする関数です。関数型言語では当たり前のことですが、念の為もう一度言っておきます。 Int -> StringIntStringと同じ、「型」です。
Int -> String -> String Int型のとString型の、2つの引数があって、Stringを返す関数※
Int -> (String, Int) StringとIntの組(Tuple)を返す関数(ここではElmでは珍しくコンマが必要)
List String 文字列のリスト。コンマがないことに注意。
(Int -> String) -> String は(Int -> String)型、つまり関数型の引数があって、Stringを返す関数。関数を引数として受け入れるいわゆる高階関数/(higher order function)です。

Int -> String -> Stringをもう一回見て見ましょう。A -> Bは「AをBにする関数」の説明がわかりやすいが「矢印が二つあるのは一体なんなの?整数を文字列にして文字列にする関数?真ん中の文字列はどうしたの?と思うかもしれませんが、違います。

正解は「整数を(文字列を文字列にする関数)にする関数です)」。つまりIntだけで呼び出したら返ってくるのはString -> Stringという型の関数です。これをまた文字列で呼び出して最終結果の文字列が出ます。

どうしてそうなったのかは、コードを見ればわかりやすいと思います。String.repeatがちょうどInt -> String -> String型ですのでそれを見ましょう。(ところで、Elmのコメントは--で始まります)

repeatTwice = String.repeat 2
-- 返り値: <function> : String -> String
repeatTwice "hello"
-- 返り値: "hellohello"

-- 一つの行にしたら、まずString.repeatを2で呼び出して、
-- 次その結果を"hello"で呼び出す
sayHelloTwice = ((String.repeat 2) "hello")
-- 返り値: "hellohello"

-- 括弧はいらないから保存すればelm-formatに消される。
sayHelloTwice =
    String.repeat 2 "hello"

-- ところで、引数の関数はこのように書きます
duplicate : Int -> Int
duplicate i =
    2 * i

ちなみにrepeatTwiceで引数2個の関数を引数1個で呼び出し、引数1個の関数にするのを部分適用(partial application)と言い、複数の引数を持つ関数引数1個ずつ持つ関数の連鎖にすることをカリー化(currying)と言います。一見美味しそうに聞こえますが、実は人の名前です。正確にいうと、Haskell Curryという数学者の名前です。因みに(其の二)にElmが構文を初め、非常に多く影響を受けたHaskellと言う関数型プログラミング言語の名前もハスケル・カリー氏に由来します。

Elmの関数は原則としてカリー化関数です。以下の関数を見ましょう:

curriedFunction : Int -> String -> String -> Int
-- 関数の型(括弧は必要ないが明記のため):
Int -> (String -> (String -> Int))
-- 呼び出しの例(括弧は明記のため):
(((curriedFunction 23) "hello") "world")

curriedFunction23で呼び出され、(String -> String -> Int)の関数を返す。次にそれが"hello"で呼び出され(String -> Int)の関数を返し、最後にそれが"world"で呼び出されIntの値を返します。

HTMLの書き方に入る前に、Elmにおけるモノの名前についてもう一つ:
Elmではモジュールと型の名前は大文字で、関数と変数※の名前は小文字で始まります。それ以外の書き方はコンパイラによって厳しく拒否されます。

※Elmの変数を定数(constant)と呼ぶ人もいます。数学での意味の変数ですので、意味合いがプログラミング言語の定数に近いです。関数を呼び出すごとに変わりますが、同じスコープの中で再代入(正確に言うと関数型言語では代入じゃなくて束縛です)はできません。

Elm でHTMLを植える

Elmの名称の由来は樹木にもあり、くだけた発音でなんとなくelementかその略に聞こえることもあるそうです。何のelementかと言うと、それはもちろんHTML要素のことで、そのHTMLもまた木構造です。ここからは木が木を作るメタな話になります。

ElmアプリではHTMLテンプレートやReactのJSXのような書き方をしないで、HTML(正確に言うとDOM)をElmで作ります。慣れてるHTMLを書けないことに抵抗があるかもしれませんが、Elmだからこそコンパイラが助けてくれるし(タグ閉じ漏れで数時間を食うことが起こらない)、UIを出せるためのElmぐらいならすぐに学べます。

上に述べた通り、文字をそのまま出せるなら Html.text "Hello World"を書けばいいです。これを本物のHTMLにするのはまずdiv要素に囲みましょう。

import Html

main =
    Html.div [] [ Html.text "こんにちは世界樹" ]
    -- <div>こんにちは世界樹</div>

Html.div (同様にHtml.spanHtml.h1などもあります。必要な関数が無い場合は部分適用を使ってHtml.nodeから簡単に作れます)の引数は二つのリスト(list)です。

一つ目のリストはHTML属性(attribute)、二つ目のリストは要素の内容(要素の子供たち)です。上記は属性がなくて、内容はテキストだけです。

もう少し複雑な例を見ましょう。

import Html
import Html.Attributes

step2 =
    Html.div []
        [ Html.span [] [ Html.text "こんにちは" ]
        , Html.span [ Html.Attributes.class "red" ] [ Html.text "世界樹" ]
        ]

今回はdiv要素の中にspan要素が2個で、そのspanにそれぞれのテキストがあります。そして、2個目のspanは属性として(cssの)classがあります。これで静的なHTMLはElmでかけるようになりました。

簡単だったでしょう?あと、カオスになりがちなHTMLと違って、elm-formatもちゃんと整理してくれます。ただ、何となく冗長になりました。そこでimport式に一工夫しましょう。

import Html exposing (..)
import Html.Attributes exposing (..)

step3 =
    div []
        [ span [] [ text "こんにちは" ]
        , span [ class "red" ] [ text "世界樹" ]
        ]

スッキリしましたね。import Html exposing (..)はHtmlパッケージの関数を全てパッケージ名書かずに呼び出せるようにインポートします。

import Html exposing (div, text)のように、一部だけパッケージ名なしで呼び出せるようなインポートの書き方もあります。

Elmアーキテクチャー降臨

お待たせしますた。これからはいよいよElmアーキテクチャーを使った本物のElmアプリを作ります。その前にElmに出現するデータ構造を紹介したいと思います。

-- Data Structures
-- 関数型の世界で大活躍するリスト
-- 中身の型が一致しかればなりません。
list = [ 1, 2, 3 ]

-- 複数の値をまとめるタプル
tuple = ( "a", 1 )

-- 配列も集合もリストから作られる
array = Array.fromList list
set = Set.fromList list

-- ディクショナリはタプルのリストから
dictionary = Dict.fromList [ ( "c", 3 ), ( "b", 2 ), tuple ]

Elmでは、オブジェクトはありませんが、データ構造としてレコード(record)というものが代わりに使えます(あくまでデータ構造です。オブジェクト指向言語ではないので、メソッドもないし、変更も一切できません)

record = { name = "elm", firstRelease = 2013 }

-- レコードを更新したレコードを作る(recordは変更ができない)
correctedRecord = { record | firstRelease = 2012 }
-- 返り値:{ name = "elm", firstRelease = 2012 } : { name : String, firstRelease : number }
correctedRecord.firstRelease
-- 返り値:2012 : number

-- 元のレコードがまだ残ってる
record.firstRelease
-- 返り値:2013 : number


-- レコード型にtype aliasで名前をつけられます
type alias ProgrammingLanguage = { name : String, firstRelease : Int }

anotherRecord : ProgrammingLanguage
anotherRecord = { name = "JavaScript", firstRelease = 1995 }
-- あるいは
anotherRecord = ProgrammingLanguage "JavaScript" 1995
-- 返り値:{ name = "abc", firstRelease = 23 } : ProgrammingLanguage

それでは、Elmアーキテクチャーの出番です。

import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)

main : Program Never Model Msg
main =
    Html.beginnerProgram
        { model = { message = "" }
        , update = update
        , view = view
        }

-- MODEL

type alias Model =
    { message : String }

-- UPDATE

type Msg
    = SayHello String
    | SayBye


update : Msg -> Model -> Model
update msg model =
    case msg of
        SayHello greeted ->
            { model | message = "こんにちは" ++ greeted }

        SayBye ->
            { model | message = "さようなら" }

-- VIEW

view : Model -> Html Msg
view model =
    div []
        [ button [ onClick (SayHello "世界樹") ] [ text "hello" ]
        , button [ onClick SayBye ] [ text "bye" ]
        , span [ class "blue" ] [ text model.message ]
        ]

お馴染みのmain関数がありますが、今回は型注釈を持っています。それはどこから来るのでしょうか?mainが呼び出すHtml.beginnerProgram関数の型がドキュメントにこう書いてあります:

beginnerProgram
    :  { model : model, view : model -> Html msg, update : msg -> model -> model }
    -> Program Never model msg

「モジュールと型の名前は大文字、関数と変数の名前は小文字」というのを覚えていますか?

beginnerProgramの引数の型を見てみましょう:
model : model modelはmodel?小文字?型が大文字で始まるはずなのに・・・
view : model -> Html msg viewは関数ですね。modelをmsgを持つHtmlにする関数らしいですが、modelとmsgがまた小文字・・・。
update : msg -> model -> model modelも関数で、引数がmsgとmodel、返り値はmodel。beginnerProgramの返り値はProgramで、その定義を見たらtype Program flags model msgという、謎(?)の文字が書いてます。

心当たりがあると思いますが、謎の正解は「型変数」です。Javaのジェネリックと似たもので、具体的な型じゃなくて、関数の呼び出し手が決める抽象的な型です。

上記ではmodelの型が「Model」、msgの型が「Msg」になります。ModelとMsgがこんな風に定義されています:
type alias Model = String(ModelをStringに定義する、つまらない定義です。もっと面白いのに書き換えたい気持ちがわかりますが、少し我慢してください)。
type Msg = SayHello String | SayByeの方が興味深いですね。それはユニオン型というもので、必ずSayHelloかSayByeのいずれかを持つもので、コンパイラがそれを保証します。Elmではnilもnullもundefinedも存在しません(※ユニット型はありますが、jvmに縛られてるScalaみたいにそれを頻繁にnullのように使うことはありません)

おまけにSayHelloが文字列を持っています。それがちゃんと渡されるのもコンパイラが保証します。このコンパイラの保護を受けたユニオン型がElmの神器の一つです。

では一旦main関数に戻りましょう。三つの要素のレコードを引き渡してHtml.beginnerProgramを呼び出します。modelが””(空の文字列)に初期化して、updateとviewの二つの関数が別に定義されるので、これから説明します。

view : Model -> Html Msg
view model =
    div []
        [ button [ onClick (SayHello "世界樹") ] [ text "hello" ]
        , button [ onClick SayBye ] [ text "bye" ]
        , span [ class "blue" ] [ text model.message ]
        ]

viewが前のmainと似てますが、新しいものが二つあります。

一つ目は型注釈ですが、それはHtmlの引数と返り値を明記するだけです。二つ目はHtml.Eventsから来たonClickイベントです。それは名前の通り、クリックしたら起こる事で、Msg型の引数を持っています。このイベントが発生したら、Msg型引数がupdate関数に渡されます。

update : Msg -> Model -> Model
update msg model =
    case msg of
        SayHello greeted ->
            "こんにちは" ++ greeted
        SayBye ->
            "さようなら"

updateがそのMsgに反応して、Msgの内容によってmodelを更新した新しいモデルを返します。

case式がパターンマッチによる分岐式です。ここもマッチされない条件がないことはコンパイラが保証してくれます。

上述の通り、MsgがSayHelloかSayHelloのいずれかであり、SayHalloだったらString型の中身も持ちます。その2つの可能性に応じてModel型の返り値(=今回はただの文字列)を返します。SayHelloの場合は「こんにちは」とそのMsgが持ってる文字列、SayByeの場合は「さようなら」を新しいモデルとして返します。Msgは他の値がありえないのでエラーも起こりません。

世界樹から巣立つ

さてさて、Hello Worldから卒業して本当のアプリを作るときが近くなってきました。今まではより簡潔でわかりやすいHtml.beginnerProgramを使いましたが、これからは補助輪を外してHtml.programに書き換えます。それほど変わらないので、心配は要りません。

まずはmain関数と、そこにできた新しい部分を見ましょう。

main : Program Never Model Msg
main =
    Html.program
        { init = init
        , update = update
        , subscriptions = subscriptions
        , view = view
        }

-- INIT

init : ( Model, Cmd Msg )
init =
    ( { message = "" }, Cmd.none )

-- SUBSCRIPTIONS

subscriptions : Model -> Sub Msg
subscriptions model =
    Sub.none

updateとviewは変わらないがmainからmodelがなくなってinitとsubscriptionsができました。initはアプリを初期化する関数で、ModelとCmdの組みを返します。Cmd(コマンド)はElmに次やることを指定するもので、initの場合は最初にやることを指定します。今回は特に何もしないので、Cmd.noneを返します。

subscriptionは外から入るイベント(タイマー、キーボード入力、websocketから来るデータなど)を購読するものです。ここも今回はないので、Sub.noneを返します。

modelとviewが変わらないので、最後にupdateを見ましょう。

-- UPDATE

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SayHello greeted ->
            ( { model | message = "こんにちは" ++ greeted }, Cmd.none )

        SayBye ->
            ( { model | message = "さようなら" }, Cmd.none )

ここで変わったのは返り値だけです。initと同じように(Model, Cmd Msg)になって、initと同じように(モデルの更新以外)何もしないので、Cmd.noneを返します。

ひとまず、ここまでいかがでしょうか?

JavaScriptなどとの違いが多くて、見慣れてないコードが多いですが、だいたい読めるようになったと思います。Elmは、ただ読むだけよりも実際に書いた方が絶対に勉強になりますので、ぜひ実験して見てください。Elmアプリを拡張するとき、コンパイラがどれだけ助けになるのが経験しないとわかりませんが、説明してみると以下のような流れが多いです:

モデルに新しいフィールドを追加
→initでエラーが出るからinitを調整
→modelに新しいエレメントを追加(onClickなどを含む)
→onClickに使った新しいメッセージが見つからないエラーが発生するので、Msgに追加
→update関数のcase式が未完成になったので、その分岐を追加
→コンパイルが通った。なんと!正しく動きます ←魔法の瞬間
(途中でのタイポや型の相違などもコンパイラが指摘します)

これで準備が整いました。Hello Worldから卒業して、広い世界へ旅立つときが来ました。

Typetalkの世界へようこそ

TypetalkのAPIを使って、初歩的なTypetalkクライアントを作ります。ただし、今まではアプリをブラウザ(あるいはElm Online Editor)で動かしましたが、大人の世界では色々事情あって、セキュリティ上の理由でブラウザからTypetalkのAPIをアクセスできません。

そこではelectronの出番です。知ってる人が多いと思いますが、electronは簡単に言えば、ウェブアプリをデスクトップアプリとして動かす道具です。Elmを使ったelectronアプリはこちらの説明を沿って作りますが、以下のGitHubレポジトリをクローンして、Main.elmを編集すれば簡単に使えます。

https://github.com/leo-nu/elm-intro

Electronもnpmでインストールします。Elm-reactorはelectronでは使えないので、Elmファイルが変わったらchokidarというツールで簡単に自動コンパイルできますので、それもnpmでインストールします。

npm i -g electron-prebuilt chokidar-cli

chokidarか次のコマンドで実行して、

chokidar '**/*.elm' -c 'elm make Main.elm --output electron/elm.js'

Electronアプリは以下のように動かします。動いたらブラウザと同じようにリロードできます。

electron electron/main.js

最後にTypetalkのClient IDが必要です。Typetalkの設定でデベロッパーの項目で「新規アプリケーション」ボタンを押して、新しいアプリを登録します。名前は任意で、Grant TypeはClient Credentialsにします。

TypetalkでClient IDとSecretを取得

Elmtalkの土台作り

Elmではまずmodelを考えるのが一般的です。今までのモデルにあったmessageはデバッグのために残しておきます。そして第一歩としてTypetalk APIをアクセスするためのaccess token、そしてトピックのリストを追加しましょう。トピックは最低限、名前とidだけ定義しておけば大丈夫です。それらをinitで初期化しましょう。

init : ( Model, Cmd Msg )
init =
    ( { message = ""
      , accessToken = ""
      , topics = [ { name = "Topic 1", id = 1 }, { name = "Topic 2", id = 2 } ]
      }
    , Cmd.none
    )


type alias Model =
    { message : String
    , accessToken : String
    , topics : List Topic
    }


type alias Topic =
    { name : String, id : Int }

次はviewです。GitHubリポジトリに準備しておいたcssファイルに合った簡単なレイアウトのHtmlを返します。ボタンもupdate関数もまだ変わりません。

view : Model -> Html Msg
view model =
    div []
        [ div [ id "left-column" ]
            [ h1 [] [ text "Elmtalk" ]
            , nav []
                [ div [ class "debug" ]
                    [ span [] [ text model.message ]
                    , button [ onClick (SayHello "世界樹") ] [ text "hello" ]
                    , button [ onClick SayBye ] [ text "bye" ]
                    ]
                , div [] (List.map topicListItem model.topics)
                ]
            ]
        , div [ id "right-column" ] [ text "..." ]
        ]


topicListItem : Topic -> Html Msg
topicListItem topic =
    div [ class "topic" ] [ text topic.name ]

topicListItem関数で見ての通り、viewの中にもElmが使えます。(List.map topicListItem model.topics)がTopicのリストをHtmlのリストにします。
topicListItemを匿名関数に書き換えたら
(List.map (\topic -> div [ class "topic" ] [ text topic.name ]) model.topics)
になります。

(ちなみに\a -> a * 2という匿名関数の書き方がHaskellから来て、\がλ(ラムダ)に似てるからそうなったらしいです。)

ファーストコンタクト

さてさて、長年、一人で宇宙を漂った我がアプリにはいよいよ異文明と対話するときが来ました。

先ほどTypetalkのClient IDとClient Secretを取得しました。それを今使いますが、大事な情報なので、間違ってgitにチェックインしてGitHubなどで公開しないように対策をしましょう。秘密のキーなどを入れるsecrets.elmを作って、それを.gitignoreに追加しましょう(elmのコンパイル先のelm.jsにも入るから、それも.gitignoreに)

module Secrets exposing (..)

clientId : String
clientId = "aAaAaAaAaAaAaAaAaAaAaAaAaAaAaAaA"

clientSecret : String
clientSecret = "bBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbBbB"

それと、Main.elmに二つのimportを追加しましょう。

import Http
import Json.Decode as Decode
import Secrets

名前でバレバレですが、HttpはHTTPリクエスト、Json.DecodeはJSONをElmのデータに解読するパッケージです。as Decodeでインポートすれば、Json.Decode.DecoderDecode.Decoderに略せます。

今回viewで変わるのはボタン1個だけです。

, button [ onClick GetAccessToken ] [ text "ログイン" ]

そのGetAccessTokenメッセージをupdateに入れたら

type Msg
    = SayHello String
    | GetAccessToken

update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SayHello greeted ->
            ( { model | message = "こんにちは" ++ greeted }, Cmd.none )

        GetAccessToken ->
            ( { model | message = "getting access token..." }, getAccessToken )

になります。GetAccessTokenでupdateを呼び出すとき、まずまモデルでmessageが変わるのですが、それより、今までCmd.noneしか書かなかった所に別のやつがやって来ました。

getAccessTokenというのは宇宙から投げて来た奇跡ではなくて、ただの未定義の関数ですので、これから定義しましょう。

getAccessToken : Cmd Msg
getAccessToken =
    let
        url =
            "https://typetalk.com/oauth2/access_token"

        body =
            Http.stringBody "application/x-www-form-urlencoded"
                ("client_id="
                    ++ Secrets.clientId
                    ++ "&client_secret="
                    ++ Secrets.clientSecret
                    ++ "&grant_type=client_credentials"
                    ++ "&scope=my,topic.read,topic.post"
                )

        request =
            Http.post url body decodeAccessToken
    in
    Http.send GotAccessToken request


decodeAccessToken : Decode.Decoder String
decodeAccessToken =
    Decode.field "access_token" Decode.string

初めてのletブロックです。letブロックは関数内に変数が定義される所です。getAccessTokenではurl、body、requestの三つの変数が定義されます。urlとbodyはTypetalk APIのURLと、それに投げるパラメータです。requestはurlとbodyからPOSTリクエストを作ります。

あとrequestに必要なのはDecoderです。Decoderは型が保証されているElm次元に、JSONデータを安全に入れられる仕組みです。Elm標準ライブラリーのJSON.Decoderだけ使えば若干厄介ですが今回APIが返すデータで使う分がまだ簡単です。

decodeAccessTokenはAPIのJSONの{ "access_token": "YOUR_ACCESS_TOKEN", "token_type": "Bearer", "expires_in": 3600, "refresh_token": "YOUR_REFRESH_TOKEN" }というJSONデータからaccess_tokenだけをStringとして解読するデコーダーを返す関数です。

letブロックはinキーワードで終了します。Http.send関数はrequestを飛ばし、終わったら、GotAccessTokenというメッセージを持つコマンドを実行します(その結果としてupdateが呼び出されます)。そして、MsgにGotAccessTokenを追加しなければなりませんが、そこにResultという独特な型が付いています。Resultの定義をみましょう

type Result error value
    = Ok value
    | Err error

Resultの値はOkかErrいずれかです。Okだったらvalue、Errだったらerrorを持っていて、今回のHTTPリクエストの場合、errorの型はHttp.Error、valueの型はStringです。MsgにそのようなGotAccessTokenを追加しましょう。

type Msg
    = SayHello String
    | GetAccessToken
    | GotAccessToken (Result Http.Error String)


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        SayHello greeted ->
            ( { model | message = "こんにちは" ++ greeted }, Cmd.none )

        GetAccessToken ->
            ( { model | message = "getting access token..." }, getAccessToken )

        GotAccessToken (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotAccessToken (Ok token) ->
            ( { model | accessToken = token, message = "got token" }, Cmd.none )

Resultには二つの可能性が秘められていますので、updateでその両方を対処しないとコンパイラに怒られます。エラーの場合は単にエラーをStringとしてmessageに出力します。オッケーの場合はモデルのaccessTokenをAPIからもらったtokenにして、messageに成功したことを書きます。

これで初めての対話が成立しました。文明にとってこの出会いは、いかなる発展をもたらすのでしょうか。

トピックリスト表示

トピックの取得はトークンの取得と大きくは変わりません。modelがまた変わらないし、viewには

, button [ onClick GetTopics ] [ text "トピックを読み込む" ]

というボタンを追加するだけです。updateに追加する部分もtokenのときとほとんど変わらないのです。

type Msg
    = GetAccessToken
    | GotAccessToken (Result Http.Error String)
    | GetTopics
    | GotTopics (Result Http.Error (List Topic))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GetAccessToken ->
            ( { model | message = "getting access token..." }, getAccessToken )

        GotAccessToken (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotAccessToken (Ok token) ->
            ( { model | accessToken = token, message = "got token" }, Cmd.none )

        GetTopics ->
            ( { model | message = "getting topics..." }, getTopics model.accessToken )

        GotTopics (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotTopics (Ok topics) ->
            ( { model | topics = topics, message = "got topics" }, Cmd.none )

getTopicsにはmodel.accessTokenを渡しますが、それも普通の関数の呼び出しです。(あと、SayHelloが不要になったのでupdateから消しました)

トピックの取得自体は、POSTじゃなくてGETですが、それ以外は前と同じです。デコーダーが少し複雑になりますが

getTopics : String -> Cmd Msg
getTopics accessToken =
    let
        url =
            "https://typetalk.com/api/v1/topics"
                ++ "?access_token="
                ++ accessToken

        request =
            Http.get url decodeTopics
    in
    Http.send GotTopics request


decodeTopics : Decode.Decoder (List Topic)
decodeTopics =
    Decode.at [ "topics" ] (Decode.list decodeTopic)


decodeTopic : Decode.Decoder Topic
decodeTopic =
    Decode.map2 Topic
        (Decode.at [ "topic", "name" ] Decode.string)
        (Decode.at [ "topic", "id" ] Decode.int)

decodeTopics(複数形)はTopicのリストをデコードします。そのため、decodeTopic(単数形)を呼び出し、decodeTopicがJSONをTopicレコードに解読します。

APIが返すJSONはこれよりずっと情報が入ってますが、
{"topics": [{"topic": {"name": "lorem ipsum", "id": 1}}], ...}
以外は無視します。Decode.map2はJSONを二つの要素を持つレドードにするデコーダーを返します。

投稿を読み込む

トピック取得は成功しましたが、Typetalkでは投稿が見えないと殆ど意味がありません。ここからは投稿を表示する機能を追加しましょう。

まずはModelに投稿リストを入れて、initで初期化しましょう

init : ( Model, Cmd Msg )
init =
    ( { message = ""
      , accessToken = ""
      , topics = []
      , posts = []
      }
    , getAccessToken
    )

type alias Model =
    { message : String
    , accessToken : String
    , topics : List Topic
    , posts : List Post
    }

type alias Topic =
    { name : String, id : Int }

type alias Post =
    { message : String
    , author : String
    , createdAt : String
    , imageUrl : String
    }

投稿と別にinitに追加した、getAccessTokenに気づきましたか?これでアプリが開いたらすぐトークンを取得しようとします。ボタンを押す必要がなくなりました。トピックも自動的に取得するようにしますので、initでtopicsに偽トピックを入れるのをやめましょう。

updateは以下の通りです。

type Msg
    = GetAccessToken
    | GotAccessToken (Result Http.Error String)
    | GetTopics
    | GotTopics (Result Http.Error (List Topic))
    | GetPosts Topic
    | GotPosts (Result Http.Error (List Post))


update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
    case msg of
        GetAccessToken ->
            ( { model | message = "getting access token..." }, getAccessToken )

        GotAccessToken (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotAccessToken (Ok token) ->
            { model | accessToken = token, message = "got token" } |> update GetTopics

        GetTopics ->
            ( { model | message = "getting topics..." }, getTopics model.accessToken )

        GotTopics (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotTopics (Ok topics) ->
            ( { model | topics = topics, message = "got topics" }, Cmd.none )

        GetPosts topic ->
            ( { model | message = "getting posts..." }, getPosts model.accessToken topic )

        GotPosts (Err e) ->
            ( { model | message = toString e }, Cmd.none )

        GotPosts (Ok posts) ->
            ( { model | posts = posts, message = "got posts" }, Cmd.none )

GotAccessTokenが成功したら、すぐにまたupdateを呼び出すようになって、トピックを取得します。(|>というオペレータはviewのところに説明します)。GetPostsとGotPostsはもう説明しなくていいですね。GetPostsとDecodePost(s)もトピックの時と似たものです。

getPosts : String -> Topic -> Cmd Msg
getPosts accessToken topic =
    let
        url =
            "https://typetalk.com/api/v1/topics/"
                ++ toString topic.id
                ++ "?access_token="
                ++ accessToken

        request =
            Http.get url decodePosts
    in
    Http.send GotPosts request

decodePosts : Decode.Decoder (List Post)
decodePosts =
    Decode.at [ "posts" ] (Decode.list decodePost)

decodePost : Decode.Decoder Post
decodePost =
    Decode.map4 Post
        (Decode.at [ "message" ] Decode.string)
        (Decode.at [ "account", "fullName" ] Decode.string)
        (Decode.at [ "createdAt" ] Decode.string)
        (Decode.at [ "account", "imageUrl" ] Decode.string)

投稿が見える

今回一番変わるのはviewです。

view : Model -> Html Msg
view model =
    div []
        [ div [ id "left-column" ]
            [ h1 [] [ text "Elmtalk" ]
            , nav []
                [ div [ class "debug" ]
                    [ span [] [ text model.message ]
                    , button [ onClick GetAccessToken ] [ text "ログイン" ]
                    , button [ onClick GetTopics ] [ text "トピックを読み込む" ]
                    ]
                , div [] (List.map topicListItem model.topics)
                ]
            ]
        , div [ id "right-column" ]
            [ div [ class "messages" ] (List.map post model.posts)
            ]
        ]

topicListItem : Topic -> Html Msg
topicListItem topic =
    div [ class "topic", onClick (GetPosts topic) ] [ text topic.name ]

トピックをクリックするとGetTopicが呼び出され、そして右に投稿が入るエリアができました。

これを表示するpost関数はこれです。

post : Post -> Html Msg
post post =
    div []
        [ div [ class "avatar" ]
            [ img [ src post.imageUrl ] []
            ]
        , div [ class "message-main" ]
            [ div []
                [ span [ class "author-name" ] [ text post.author ]
                , span [ class "message-time" ] [ text (formatDate post.createdAt) ]
                ]
            , div [ class "message-text" ] (newlinesToBr post.message)
            ]
        , hr [ class "clear msgSep" ] []
        ]


formatDate : String -> String
formatDate str =
    let
        date =
            Date.fromString str
    in
    case date of
        Ok date ->
            toString (Date.year date)
                ++ "-"
                ++ toString (Date.month date)
                ++ "-"
                ++ toString (Date.day date)
                ++ " "
                ++ toString (Date.hour date)
                ++ ":"
                ++ toString (Date.minute date)
                ++ ":"
                ++ toString (Date.second date)

        Err error ->
            str

殆どHTMLを出すものなので、特に難しいものがないです。日付は別の関数に入れましたが、それもただ文字列として出すだけです(import Dateをしなければならないので、それを忘れず)。

APIから来る投稿は改行が入ってますが、それはHTMLで表示するには<br>タグに変更しなければなりません。

newlinesToBr : String -> List (Html Msg)
newlinesToBr str =
    str
        |> String.lines
        |> List.map (\str -> text str)
        |> List.intersperse (br [] [])

あらためて|>が出ましたね。それはパイプ演算子という、左の式を右の関数の最後の引数として渡す演算子です。わかりやすい例で言うと

"abc,def,ghi" |> String.toUpper
-- 返り値:"ABC,DEF,GHI"。さらに
"abc,def,ghi" |> String.toUpper |> String.split "," |> String.join " "
-- 返り値:"ABC DEF GHI"

元の”abc, def,ghi”が全部大文字にされて、コンマで切られてまた空白で結合されています。JavaScriptで書けば

"abc,def,ghi".toUpperCase().split(",").join(" ")

です。

上のnewlinesToBrはまずStringを行のリストに変更して、次にリストのStringがHtmlエレメントに変更され、最後に行の間にbrタグが挟まれます。

おめでとうございます🎉

これでTypetalkの投稿を自作クライアントで読めるようになりました!

Elmで作ったTypetalkクライアント

おまけ

Typetalk読めるのは嬉しいけど、投稿できないとちょっと寂しいなぁ・・・と言うあなたに朗報です。すでに長くなったので細かい説明はしませんが、一応書いておきました。コードはリポジトリのMain.elmにあります。ぜひ読んでみてください。

結論

いかがでしたか?370行未満でTypetalkに投稿まで出来るアプリを作りました。しかもその370行の殆どが短くて、しかも4分の1は空行です。Elmの構文に慣れるのは少し時間がかかるので、Hello Worldの部分はゆっくり、じっくり進めましたが、そのおかげでElmtalkの分は速く書けました。

Elmで「基礎の部分は時間かかったけど、そこが出来るようになったら、アプリが全部できたっ」というのは多いです。難しいと思った部分が簡単で、簡単な部分が冗長に感じます(ただし、普段難しいところも隠れています。Elmでは難しくないから気づかないだけです。) 

今更の告白ですが、Typetalkクライアントは私にとって初めてのElmアプリでした。

ElmでHello Worldを写してから、Typetalkに投稿できるまでは8時間もかかりませんでした(しかも検索ミスでかなりの時間を無駄にした。Elmドキュメントを検索すれば古いバージョンが最初に出るのが多いのでご注意ください)。つまりElmは1日以内書けるようになるといっても過言ではありません。

関数型言語の考え方に慣れるだけで、短時間で開発できる Elm で何か一緒に作ってみませんか?ヌーラボでは開発者を募集しています。

レビュー履歴

2017/11/16 17:30 カリー化と部分適用の混同を指摘するレビューをいただきました。「カリー化」を「部分適用」に修正して、カリー化の説明を追加しました。ありがとうございました!

開発メンバー募集中

より良いチームワークを生み出す

チームの創造力を高めるコラボレーションツール

製品をみる