BacklogチームのLeoです。一昨年、フロントエンド開発を楽しくする関数型プログラミング言語 Elm を使って Typetalk クライアントアプリを開発した記事を書いたのですが、Elmがパージョン0.19になって多少変わった分があって、TypetalkのAPIも少しだけ変わったので、記事も更新したいと思って、その結果はこちらの改良バージョンです。(ただし内容自体は多く変わらないのでご了承ください)
さて、 Elm とは英語でニレ(楡)属の樹木の総称、ハルニレの通称です。そしてプログラミング言語でもあります。そんなプログラミング言語の Elm を使った開発、気になりませんか?
目次
- Elm言語
- こんにちは世界樹 Hello WorldでElmの構文とElmアーキテクチャー入門
- Typetalkの世界へようこそ ElmでTypetalkクライエントを作る
- おまけ
- 結論
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のことを耳にして実際使ってみたらすぐハマりました。
Elmは2012年生まれでまだ若く、純粋関数型言語です。Haskellに影響を受けており、見た目もいつものC系言語とちょっと違う味がします。それなのに、慣れたらとても楽しい言語です。Reduxの作者が言う通り、プロジェクトに使うつもりがなくても少しだけ遊んで使ってみることに価値があります。(以前、関数型言語に挫折した方なら尚更です。なぜなら、Elmはモナドとかがわからなくても楽しめる言語ですから)
ネット上のElm入門には実用性の低い例が多いので、今回はElmを使って実際に使うことのできるTypetalkクライアントアプリを作ります。
Elm は何が美味しい?
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)、つまりModelのViewです。UIのすべてがこの関数にあるから、関心の分離 (separation of concerns)の漏れが発生しにくいし、モデルの一貫性を保てばViewの一貫性も保証されています。
- ViewはModelを(HTMLなどに)可視化する「関数」です。ここでは関数がポイントです。表示されるのはモデルでありながらModelではなく、View(Model)、つまりデータだけでできているModelを人間界に写す投影です。まぁつまり関数ですね。UIのすべてがこの関数にあるから、関心の分離 (separation of concerns)の漏れが発生しにくいし、モデルの一貫性を保てばViewの一貫性も保証されています。
- 最後に、見えないが、そのすべての上に浮んで見守るのはElm Runtimeです。Elm Runtimeがイベント(コマンド)によりUpdateを呼び出し、Virtual DOMを通してブラウザの本物DOMを更新します。ただしこの部分の細かいところはElmプログラマーはあまり気にしなくても問題ありません。
- インストールも設定も簡単。
- コンパイラが速くて、出力する.jsファイルが小さい。それがバージョン0.19での一番大きい改良点です。
では早速使ってみましょう。
こんにちは世界樹
プログラミング言語入門なら、まずHello Worldプログラムを書かないと始まりませんね(違)。インストールをせずに、Ellieというオンラインエディターを使ってブラウザで書くこともできますが、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を参照してください。
インストールが終わったら、適切なところに新しいフォルダを作って、その中ににelm initというコマンドを実行しましょう。(elm.json作るかという質問にただエンターキーを押して肯定的に答えれば良い)。
実行が終わったら(あっという間に終わります)、(野生の?)elm.jsonというファイルと、srcとelm-stuffというフォルダが現れました。どういう生き物なんでしょうか。
- elm.jsonは依存するパッケージなどを書く設定ファイルです。npmのpackage.jsonと似た役割を果たします。(※Elmでは標準ライブラリーのパッケージも明記しなければなりません。現に
elm/core、elm/browserなどが既に書いてあります) - elm-stuffは無視しても問題ない存在なので、今は無視して良いです(gitにもその旨を伝えましょう)。コンパイラの中間ファイルやパッケージのキャッシュが入っています。
- おわかりだと思いますが、srcがソースコードを入れるフォルダです。
そのsrcフォルダの中に以下の内容のhello.elmというファイルを作って、elm reactorコマンドを実行してみてください。
module Main exposing (main)
import Html
main =
Html.text "こんにちは世界樹"
実行してからブラウザでhttp://localhost:8000/hello.elmを開いたら、「こんにちは世界樹」という文字がブラウザに出ます。めでたしめでたし。
さて、hello.elmに戻りましょう。
一行目のmodule Main exposing (main)はモジュール宣言で、モジュールの名前、そして外からアクセスできるものを宣言します。ただし、今回書くアプリは全部一つのモジュールに収まりますので、これから省略します。(ないときはelm-formatが勝手に入れますのでご了承ください。)
複数のファイルで出来てるアプリを作ったら必要ですので、その時はElmガイド (英語)などを参照してください。
import HtmlはHtmlパッケージを使えるようにします。インポートできるパッケージは同じフォルダ(あるいはサブフォルダ)にあるのとelm.jsonに宣言されるパッケージです。
最後に、main = Html.text "こんにちは世界樹"はmain関数と言う、アプリ開始に呼ばれる関数です。
Elm は関数で出来ている
Elmにとって大事なもの、それは関数です。変数(定数)と関数の壁が曖昧になってるほど関数が大事ですのでmain関数をもう少し詳しく見ましょう。
main = Html.text "こんにちは世界樹"
mainは関数の名前で、Html.text "こんにちは世界樹"が関数の本体です。この本体ではHtml.textという別の関数を呼び出し"こんにちは世界樹"を引数として渡します。見ての通り、関数の呼び出しに括弧を書きません(曖昧な場合を除く。そしてつけてもHtml.text("hello")ではなく、(Html.text "hello")のように関数と引数の全体を括弧に囲みます)。ちなみに不要な括弧はelm-formatが勝手に消します。優しいですね(?)。
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 -> Stringは関数ですが Intや Stringと同じ、「型」です。
– 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)です。
(型を一目でわかるようになるのはElmの勉強のちょっとした壁ですが、Elmのシンプル且つ強力な型システムを享受するために必要なので、そこは少し頑張るしかないです)
※ Int -> String -> Stringをもう一回見て見ましょう。A -> Bは「AをBにする関数」の説明がわかりやすいが「矢印が二つあるのは一体なんなの?整数を文字列にして文字列にする関数?真ん中の文字列はどうしたの?」と思うかもしれませんが、違います。
正解は「整数を(文字列を文字列にする関数)にする関数」です。つまりIntだけで呼び出したら返ってくるのはString -> String型の関数です。これをまた文字列で呼び出して最終結果の文字列が出ます。
どうしてそうなったのかは、コードを見ればわかりやすいと思います。Elmの標準ライブラリの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")
curriedFunctionが23で呼び出され、(String -> String -> Int)の関数を返す。次にそれが"hello"で呼び出され(String -> Int)の関数を返し、最後にそれが"world"で呼び出されIntの値を返します。(むりやり非カリー化風関数を作るには引数を組(Tuple)にすればできます。ただし0.19から組の要素数の上限が3つです)
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.span、Html.h1などもあります。必要な関数が無い場合は部分適用を使ってHtml.nodeから簡単に作れます)の引数は二つのリスト(List)です。
一つ目のリストはHTML属性(attribute)、ここでは空のリストの[]です。二つ目のリストは要素の内容(要素の子供たち)で、この場合は文字を出力するHtml.text関数だけです。
もう少し複雑な例を見ましょう。
import Html
import Html.Attributes
main =
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 (..)
main =
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 ) -- 配列も集合もリストから作られる。 import Array import Set array = Array.fromList list set = Set.fromList list -- ディクショナリはタプルのリストから import Dict 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 Browser
import Html exposing (..)
import Html.Attributes exposing (..)
import Html.Events exposing (..)
main : Program () Model Msg
main =
Browser.sandbox
{ init = ""
, update = update
, view = view
}
-- MODEL
type alias Model =
String
-- UPDATE
type Msg
= SayHello String
| SayBye
update : Msg -> Model -> Model
update msg model =
case msg of
SayHello greeted ->
"こんにちは" ++ greeted
SayBye ->
"さようなら"
-- VIEW
view : Model -> Html Msg
view model =
div []
[ button [ onClick (SayHello "世界樹") ] [ text "hello" ]
, button [ onClick SayBye ] [ text "bye" ]
, span [ class "blue" ] [ text model ]
]
お馴染みのmain関数がありますが、今回は型注釈を持っています。それはどこから来るのでしょうか?mainが呼び出すBrowser.sandbox関数の型がドキュメントにこう書いてあります:
sandbox :
{ init : model, view : model -> Html msg, update : msg -> model -> model }
-> Program () model msg
「モジュールと型の名前は大文字、関数と変数の名前は小文字」というのを覚えていますか?
sandboxの引数の型を見てみましょう:
init : model modelはmodel?小文字?型が大文字で始まるはずなのに・・・
view : model -> Html msg viewは関数ですね。modelをmsgを持つHtmlにする関数らしいですが、modelとmsgがまた小文字・・・。
update : msg -> model -> model modelも関数で、引数がmsgとmodel、返り値はmodel。sandboxの返り値はProgramで、その定義を見たらtype Program flags model msgという、謎(?)の文字が書いてます。
心当たりがあると思いますが、謎の正解は「型変数」です。Javaのジェネリックと似たもので、具体的な型じゃなくて、関数の呼び出し手が決める抽象的な型です。
上記ではmodelの型が「Model」、msgの型が「Msg」になります。ModelとMsgがこんな風に定義されています:
type alias Model = String(ModelをStringに定義する、つまらない定義です。もっと面白いのに書き換えたい気持ちがわかりますが、少し我慢してください)。
type Msg = SayHello String | SayByeの方が興味深いですね。それはカスタム型(Custom Type)というもので、必ずSayHelloかSayByeのいずれかを持つもので、コンパイラがそれを保証します。Elmではnilもnullもundefinedも存在しません(※ユニット型はありますが、jvmに縛られてるScalaみたいにそれを頻繁にnullのように使うことはありません)
おまけにSayHelloが文字列を持っています。それがちゃんと渡されるのもコンパイラが保証します。このコンパイラの保護を受けたカスタム型がElmの神器の一つです。
では一旦main関数に戻りましょう。三つの要素のレコードを引き渡してBrowser.sandboxを呼び出します。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 ]
]
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から卒業して本物のアプリを作るときが近くなってきました。今までは勉強用のBrowser.sandboxを使いましたが、これからは補助輪を外してBrowser.elementに書き換えます。それほど変わらないので、心配は要りません。
まずはmain関数と、そこにできた新しい部分を見ましょう。
main : Program () Model Msg
main =
Browser.element
{ 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の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)になって、モデルは更新しますが、「次やること」はないので、Cmd.noneを返します。
ひとまず、ここまではいかがでしょうか?
JavaScriptなどとの違いが多くて、見慣れてないコードが多いですが、だいたい読めるようになったと思います。Elmは、ただ読むだけよりも実際に書いた方が絶対に勉強になりますので、ぜひ実験してみてください。Elmアプリを拡張するとき、コンパイラがどれだけ助けになるのが経験しないとわかりませんが、説明してみると以下のような流れが多いです:
モデルに新しいフィールドを追加
→initでエラーが出るからinitを調整
→modelに新しいエレメントを追加(onClickなどを含む)
→onClickに使った新しいメッセージが見つからないエラーが発生するので、Msgに追加
→update関数のcase式が未完成になったので、その分岐を追加
→コンパイルが通った。なんと!正しく動きます ←魔法の瞬間
(途中でのタイポや型の相違などもコンパイラが指摘します)
ところで、Browser.sandboxとBrowser.elementの他、2つのメイン関数がありますのでその4つの違いを簡単に説明します:
- Browser.sandbox (Elm 0.18までHtml.beginnerProgramと呼ばれた)
- 勉強用。Elmの外の世界とのやりとりがかなり制限されていますし、現在の時刻や乱数なども得られません。
- Browser.element (Elm 0.18までHtml.programと呼ばれた)
- Elmが管理する、htmlに埋め込む一個の要素を作るため。(それはそのページ内の唯一の要素でも構いません。)
- Browser.document(Elm 0.19に新しく追加された)
- Elm 0.19の新メンバーの一人。HTMLの<title>、そして<body>に入る中身を全部Elmで操られます。
- Browser.application(こちらもElm 0.19に新しく入った新メンバー)
- urlも簡単に管理できるElmアプリでElm 0.19の大きな新機能です。これでElmでSPAがだいぶ簡単に作れるようになりました(そして私はElmをプロダクションでも使っていいよと思うようになった理由の一つです。)
これで準備が整いました。Hello Worldから卒業して、広い世界へ旅立つときが来ました。
Typetalkの世界へようこそ
これからTypetalkのAPIを使って、初歩的なTypetalkクライアントを作ります。ただし、今まではアプリをブラウザで動かしましたが、大人の世界では色々事情があって、セキュリティ上の理由でブラウザからTypetalkのAPIにアクセスできません。
そこではelectronの出番です。知ってる人が多いと思いますが、electronは簡単に言えば、ウェブアプリをデスクトップアプリとして動かす道具です。Electronアプリはこちらの説明を沿って作りますが、以下のGitHubレポジトリをクローンして、Main.elmを編集すれば簡単に使えます。
https://github.com/leo-nu/elm-intro
Electronもnpmでインストールします。Elm reactorはelectronでは使えないので、Elmファイルが変わったらchokidarというツールで簡単に自動コンパイルできますので、それもnpmでインストールします。
npm i -g electron chokidar-cli
chokidarか次のコマンドで実行して、
chokidar '**/*.elm' -c 'elm make src/Main.elm --output electron/elm.js'
Electronアプリはelectronフォルダからnpmで動かします。動いたらブラウザと同じようにリロードできます。
npm start
最後にTypetalkのClient IDが必要です。Typetalkの設定でデベロッパーの項目で「新規アプリケーション」ボタンを押して、新しいアプリを登録します。名前は任意で、Grant TypeはClient Credentialsにします。
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" spaceKey : String spaceKey = "AbcAbcAbc"
もう一個secrets.elmに入れるのはspaceKeyという、TypetalkやBacklogで、所属する組織を識別するための文字列です。本当は組織を選択できるのがいいのですが、作業がこれからする作業と変わらないので、重複回避のため割愛していただきます。
というわけで、Typetalkの右上の組織セレクターで、「組織設定」を開いてください。そこから、
組織設定。まったく関係ありませんが、Käseはドイツ語で「チーズ」という意味で、私が個人で使っているTypetalkとBacklogです。
https://apps.nulab.com/spaces/AbcAbcAbc/membersのようなURLのページが開かれると思います。その中のAbcAbcAbcをsecrets.elmにコピペしたら終了です。(実際の値はAbcAbcAbcと異なります)
それと、Main.elmに三つのimportを追加しましょう。名前でバレバレですが、HttpはHTTPリクエストのためのモジュールです。Decoderは型が保証されているElm次元に、JSONデータを安全に入れられる仕組みです。Elm標準ライブラリーのJSON.Decoderだけ使えば若干厄介ですが今回APIが返すデータで使う分がまだ簡単です。あと、as Decodeというのはエイリアスで、毎回Json.DecodeではなくDecodeって書くので済む仕組みです。
import Http import Json.Decode as Decode import Secrets
ただしDecodeが使えるようになるため、まずはパッケージをインストールしなければならないので、こちらのコマンドを実行してください:
elm install elm/json
今回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"
)
in
Http.post
{ url = url
, body = body
, expect = Http.expectJson GotAccessToken accessTokenDecoder
}
accessTokenDecoder : Decode.Decoder String
accessTokenDecoder =
Decode.field "access_token" Decode.string
初めてのletブロックです。letブロックは関数内に変数が定義される場所です。getAccessTokenではurlとbodyという、Typetalk APIのURLと、それに投げるパラメータの二つの変数が定義されます。
Json.DecodeはJSONをElmのデータに解読するパッケージです。as Decodeでインポートするので、Json.Decode.DecoderをDecode.Decoderに省略できます。
accessTokenDecoderは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.post関数はrequestを飛ばし、decoderでそのデータを解読して、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を追加しましょう。(ついでに不要になったSayHelloにお別れしよう。ありがとうございました)
type Msg
= GetAccessToken
| GotAccessToken (Result Http.Error String)
update : Msg -> Model -> ( Model, Cmd Msg )
update msg model =
case msg of
GetAccessToken ->
( { model | message = "getting access token..." }, getAccessToken )
GotAccessToken (Err e) ->
( { model | message = Debug.toString e }, Cmd.none )
GotAccessToken (Ok token) ->
( { model | accessToken = token, message = "got token" }, Cmd.none )
Resultには二つの可能性が秘められていますので、updateでその両方を対処しないとコンパイラに怒られます。エラーの場合は今は単純ににエラーをStringとしてmessageに出力します※。オッケーの場合はモデルのaccessTokenをAPIからもらったtokenにして、messageに成功したことを書きます。
これで初めての対話が成立しました。文明にとってこの出会いは、いかなる発展をもたらすのでしょうか。
※Debug.toStringはデバッグ用なので、プロダクションコードには使えません。それはDebugの他の関数も同じです。今Elm.todoという名の以前Elm.crashと呼ばれた関数があって、その名の通り例外を発生させる関数です。とくにruntime exceptionが起こらない事を誇るElmではプロダクションに入れたくないですね。
トピックリスト表示
トピックの取得はトークンの取得と大きくは変わりません。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 = Debug.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 = Debug.toString e }, Cmd.none )
GotTopics (Ok topics) ->
( { model | topics = topics, message = "got topics" }, Cmd.none )
getTopicsにはmodel.accessTokenを渡しますが、それも普通の関数の呼び出しです。
トピックの取得自体は、POSTじゃなくてGETですが、それ以外は前と同じです。デコーダーが少し複雑になりますが。
getTopics : String -> Cmd Msg
getTopics accessToken =
let
url =
"https://typetalk.com/api/v2/topics"
++ "?access_token="
++ accessToken
++ "&spaceKey="
++ Secrets.spaceKey
in
Http.get
{ url = url
, expect = Http.expectJson GotTopics decodeTopics
}
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 = Debug.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 = Debug.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 = Debug.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/"
++ String.fromInt topic.id
++ "?access_token="
++ accessToken
in
Http.get
{ url = url
, expect = Http.expectJson GotPosts decodePosts
}
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関数は次の通り。時間をフォーマットするのは若干面倒で、0.19になってからTypetalkを始め多くのAPIが返すISO 8601のフォーマットがパースできなくなったので、別のパッケージをインストール必要があります。なので、まずこのコマンドを実行しなければなりません。
elm install elm/time (標準ライブラリーの時間や日付の処理)
elm install rtfeldman/elm-iso8601-date-strings (ISO 8601データを読み込むため)
import Iso8601
import Time
-- (...)
post : Post -> Html Msg
post postData =
div []
[ div [ class "avatar" ]
[ img [ src postData.imageUrl ] []
]
, div [ class "message-main" ]
[ div []
[ span [ class "author-name" ] [ text postData.author ]
, span [ class "message-time" ] [ text (formatDate postData.createdAt) ]
]
, div [ class "message-text" ] (newlinesToBr postData.message)
]
, hr [ class "clear msgSep" ] []
]
formatDate : String -> String
formatDate str =
let
parsedDate =
Iso8601.toTime str
jstTimezone =
Time.customZone (9 * 60) []
in
case parsedDate of
Ok date ->
String.fromInt (Time.toYear jstTimezone date)
++ "-"
++ Debug.toString (Time.toMonth jstTimezone date)
++ "-"
++ String.fromInt (Time.toDay jstTimezone date)
++ " "
++ String.fromInt (Time.toHour jstTimezone date)
++ ":"
++ String.fromInt (Time.toMinute jstTimezone date)
++ ":"
++ String.fromInt (Time.toSecond jstTimezone date)
Err error ->
str
(月の名前が数字じゃなくて、Jan、Febなど文字列そして表示されます。手動で数字にするのが簡単ですが、場所取るのでここでは割愛します。ぜひ自分でチャレンジしてください。
日付のややこしさ以外はHTMLを出すものなので、特に難しいものがないです。
APIから来る投稿は改行が入ってますが、それはHTMLで表示するには<br>タグに変更しなければなりません。
newlinesToBr : String -> List (Html Msg)
newlinesToBr str =
String.lines str
|> List.map (\s -> text s)
|> 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の投稿を自作クライアントで読めるようになりました!
おまけ
Typetalk読めるのは嬉しいけど、投稿できないとちょっと寂しいなぁ・・・と言うあなたに朗報です。すでに長くなったので細かい説明はしませんが、一応書いておきました。コードはリポジトリのMain.elmにあります。ぜひ読んでみてください。
結論
いかがでしたか?440行未満でTypetalkに投稿まで出来るアプリを作りました。しかもその440行の殆どが短くて、しかも4分の1は空行です。Elmの構文に慣れるのは少し時間がかかるので、Hello Worldの部分はゆっくり、じっくり進めましたが、そのおかげでElmtalkの分は速く書けました。
Elmで「基礎の部分は時間かかったけど、そこが出来るようになったら、アプリが全部できたっ」というのは多いです。難しいと思った部分が簡単で、簡単な部分が冗長に感じます(ただし、普段難しいところも隠れています。Elmでは難しくないから気づかないだけです。)
今更の告白ですが、(Elm 0.18バージョンの)Typetalkクライアントは私にとって初めてのElmアプリでした。
ElmでHello Worldを写してから、Typetalkに投稿できるまでは8時間もかかりませんでした(しかも検索ミスでかなりの時間を無駄にした。Elmドキュメントを検索すれば古いバージョンが上に出るのが多いのでご注意ください)。つまりElmは1日以内書けるようになるといっても過言ではありません。
関数型言語の考え方に慣れるだけで、短時間で開発できる Elm で何か一緒に作ってみませんか?ヌーラボでは開発者を募集しています。


