Runner in the High

技術のことをかくこころみ

isucon10に出て予選敗退した

iguchi1124hayashikunとisucon10に出た。一番伸びたスコアは1300くらい。

f:id:IzumiSy:20200913175854p:plain

やったこと

覚えていることベースでやったことを書いてみる。

自分が覚えているのは基本アプリケーションサイドの変更ばっかり。

マシン構成の変更

hayashikunとiguchi1124がAP2台+DB1台の構成に変更した。

index張り

使われてそうなとこにいくつか張った

create index char_stock on isuumo.chair (stock);
create index estate_latitude_longitude on isuumo.estate (latitude, longitude);
create index estate_height_width on isuumo.estate (door_height, door_width);
create index estate_rent on isuumo.estate (rent);

nginxでクライアントキャッシュ

hayashikunが椅子と物件の詳細情報をクライアントサイドキャッシュするようにした。効果は不明。

よく考えたら椅子は在庫数みたいなデータがあるのでキャッシュさせたらまずそうだがスコアには影響はなかった。

なぞって検索の処理をAPサーバでやらせる

ポリゴンの内外判定系をSQLでN+1してやっているということがわかったのでpolyclip-goというライブラリを使ってST系関数の呼び出し部分をアプリケーションサイドに寄せた。

スコアは10くらい伸びた気がする。

LowPriced系のオンメモリキャッシュ

APサーバで結果をキャッシュするようにした。これはめちゃくちゃ効いて400くらい伸びた。

DBのアプリケーションの最大コネクション数を変更

10→50にしてみた。効果があったのかは不明。

botリクエストの排除

UAを見てbotだったら503返すようにするあれ。効果は不明。

ランキング処理

任意の椅子にあう部屋を取得するエンドポイントで使われているSQLがorを5つ使っているのでそのぶぶんのorをひとつに。

椅子の縦横奥行きのうち最長辺を除いた二辺でドアの縦横幅と比較すればorをひとつにできた。

   var estates []Estate
    w := chair.Width
    h := chair.Height
    d := chair.Depth

    // 短い短辺ふたつをとりだす
    lengths := []int64{w, h, d}
    sort.Slice(lengths, func(i, j int) bool { return lengths[i] < lengths[j] })
    len1 := lengths[0]
    len2 := lengths[1]

    query = `
        select *
        from estate
        where (door_width > ? and door_height > ?)
            or (door_width > ? and door_height > ?)
        ORDER BY popularity DESC, id ASC
        LIMIT ?
    `
    err = db.Select(&estates, query, len1, len2, len2, len1, Limit)

featureテーブル

これはやろうとしてできなかった。

LIKEを発行してchairとestateのfeaturesカラムを舐めているクエリがいくつかあったので、それに目星をつけてchairとestate用のfeatureテーブルを別で用意してみることに。これがうまくいけば検索クエリが改善するので購入ボリュームが底上げされると思っていた。

しかし、実際にはLIKEを発行するようなクエリは全体でみるとほんの数パーくらいしか占めておらず、改善する意味はほとんどがほとんどないということが終わってから分かる。また、意味がない割にfeatureテーブルを作ろうとするとめちゃくちゃ難しく、後半はここで時間をかなり使ってしまった。

思ったこと

用意されているDBテーブルがふたつだけ、そしてアプリケーション実装コードも短く、いままで以上にシンプルな構成のアプリケーション、という感じだった。

ベンチを動かしてみると一番負荷が集中するのは結局DBで、いかにキャッシュを効かせてDBへのアクセスを減らすか、いかにDB設計を考えて負荷を分割するか、という部分がポイントだったのかなという印象。しかし我々のチームはDBの分割などの戦略までは到達できなかったので、上位のチームがどのようなDB構成の戦略をとっていたのかが気になる。

また、今回のアプリケーションisuumoの特徴として

  • レコードの入稿がCSVを用いたバルクで行われる&頻度少なめ
  • 物件テーブルには更新がない(ユーザーの行動も資料請求で終わる)
  • 検索クエリが極めて複雑

など、実際のsuumoのようなプロダクトにものすごく近い状況が再現されており、おそらくベンチマーカーの挙動も 検索→商品詳細閲覧→資料請求or購入 という流れをたどるように作られていたように思える。つまり、パーチェスファネルのようにまずは検索部分のボリュームを稼がないとスコアに直接響く資料請求や購入につながらない、という感じ。

なので、早い段階で検索より先の部分(商品詳細とか購入とか)を改善しても、結局検索部分で詰まってトラフィックが増えないため結局スコアが伸びなかったのは極めて自然に思える。実際、トップページの商品表示で使われているLowPriced系APIをキャッシュした結果スコアがかなり伸びたりもしたので、アプリケーションの仕様からユーザーストーリー的な部分を把握して改善優先度を決定する、というのがISUCONで最も必要な考え方なんだろうなと感じた。

前回だか前々回だかのisucariでは「購入されやすい椅子」や「購入が多いユーザー」などのように、ベンチによるユーザーごとに特性を再現するギミックが仕込まれていたので今回も購買傾向を分析したりしたいと考えていたが、購入者の情報がUA程度でしか識別できないなどの理由がありやらできなかった。が、もしかしたらそこにもヒントがあったかもしれない。

isuconのアプリケーションチューニングにおいて優先度を決定するには、やはりまずアプリケーションの仕様や傾向を把握することから始めないといけない。gistの資料を読んだり、ログをとったり実装を読んだり。これは仕事でソフトウェア開発をするうえでも同じだし、こういう部分を無視してスコアが稼げないように問題設計がされているのはすごくおもしろい。

「チェアdeスクっと」は名前はダサいが気軽にスタンディングデスクを導入できる最強の製品

いわゆるコロナ禍で自分も在宅ワークを余儀なくされているが、やはりなんといっても座りっぱなしで問題になるのは腰。腰がイカれてくる。

Fitbitで歩数を見ても腰が壊れるのは自明で、自分はかなり通勤経路が長めの人間だったということもありこれまでは余裕で10k歩ほどあるけていたものが、ここ最近では歩いてもせいぜい5k程度にしかならない。歩かないとどうなるかというと、それは下半身のとくにハムストリングスが十分に伸びなくなるため腰痛に悩まされる。下半身を動かさない人間はだれでもみな腰痛に苦しむことになる。

これは座っていることがほとんどの原因なので、これを解決するために自分も据え置き型のスタンディングデスクの導入を検討していた。しかし、いかんせん値段が高い上に置くと机のほとんどのスペースをとられてしまう。昇降デスクも部屋狭いので置く場所がない。

というわけで見つけたのが「チェアdeスクっと」という製品。名前がめちゃくちゃダサい。

この製品はハイバックチェアの背もたれの部分に取り付けることで椅子をスタンディングデスクにできるというもの。

これなら机の上に据え置きにする必要もないし、昇降デスクを買う必要もない。

f:id:IzumiSy:20200905173502j:plain
天板の雰囲気

天板の大きさ的にはMacbook Proの15inchを置いてまあまあいっぱいという程度。

背もたれには以下のようにして固定されている。

f:id:IzumiSy:20200905173434j:plain
背もたれ部分に引っ掛けて固定されている

天板は角度を変えられるようになっている。

自分は若干傾斜があるのが好きなので、以下のような設置角にしている。

f:id:IzumiSy:20200905173446j:plain
横から見た雰囲気

固定の仕組みがかなりよくできているので、相当重いものを置かない限り落ちることはない。とはいえ、当たり前だがアームを付けたりディスプレイを置いたりするのは止めたほうがいい。

一つ気になるのは、椅子の背もたれが動くものだとタイピングをしている最中にすこしグラグラすること。しかし、このあたりは使う分には慣れる。

所感

この製品は極めて日本的だと思う。誰かの記事でも読んだが、そもそも日本は部屋が狭い。そういう部屋に住んでいる人たちのための製品だ。

現状、スタンディングデスクを導入するには机の上に据え置くタイプか別に昇降デスクを購入するしかほとんど選択肢がない。しかし、チェアdeスクっとであれば不要なときはどこかにしまっておけばいい。必要なときだけ場所を取らずにスタンディングデスクにできる。

いい製品だと思う。

なお、余談だが正座をやるようにしたらそこそこ腰痛が改善した。

スタンディングデスク+正座を組み合わせれば腰痛フリーになれるだろうか...

「朝30秒の正座」で腰痛が治る

「朝30秒の正座」で腰痛が治る

Elmアプリケーションにおける型と抽象化手法

Elmは機能を絞った言語であるためScalaHaskellのような高度な抽象化は言語機能上できないが、使う使わないに関わらず知識として「やれること」「やれないこと」を知っておくと、コード表現に幅が出る。

Extensible Record

ざっくり言うとレコードのプロパティを抽象化することができる。

例えば以下のNamableレコードは、nameというプロパティを持つレコードすべてを対象にする。

{-| 特定のレコードに依存しない抽象的なnameというプロパティを持つレコードを対象とする
-}
type alias Namable a
    = { a | name : String }


getName : Namable a -> String
getName { name } =
    name

つまり、以下のようなデータ構造があるとしても、データ構造としては受け付けるものの直接的な依存はしなくなる。

type alias User =
    { name : String
    , email : String
    , age : Int
    }


-- getName関数はUserレコードに依存しないがUserレコードを受け付ける
getName : Namable a -> String
getName { name } =
    name

上記のようなインターフェースになることで、getName関数はUserレコードの構造を持つ値を受け取るとしてもUserレコードのデータ全てに関心があるわけではなく、ただ単にnameという属性のみに関心があるということがインターフェースレベルで表明できる。また、関数の実装も特定のレコードに依存しないものになるため抽象化できる。

一方で「ageやemailの値によってgetName関数の挙動を変える」などの仕様が別とある場合には、それは間違いなく仕様レベルで"getName関数はUserレコードに依存しているべきだ"ということが分かる。なので、そのようなケースではあえてExtensible Recordではなく直接的にレコード名を指定することで関数のインターフェースに依存を表明させるべきだろう。

Narrowing Types

関数が受け取るインターフェースを特定の型に依存させるというのがNarrowing Typesの考え方にあたる。

たとえば以下のようにUserというカスタム型の各バリアントの情報をHTMLにする関数があるとする。

type User
    = UserLoggedIn LoggedInInfo
    | UserSuspended SuspendedInfo
    | UserBanned BannedInfo

viewLoggedIn : User -> Html msg

viewSuspended : User -> Html msg

viewBanned : User -> Html msg

受け取るデータがUser型に限定されている点ではまずまずだが、それでもまだ上記の関数はインタフェースの設計が適切とは言えない。なぜなら、User型はカスタムタイプであるため、コード上で実際に渡る値がバリアントのうちどれであるかはインタフェースからは判断できないからである。こうなると関数をさらに読んでいかなくては実際の挙動が把握できない。

なので、正しくは上記の関数は以下のように、User型の各バリアントが持つデータに依存させるべきだ。こうすることで、さらに関数の依存を限定でき、インタフェースから関数の挙動が推測しやすくなる。

viewLoggedIn : LoggedInInfo -> Html msg 

viewSuspended : SuspendedInfo -> Html msg

viewBanned : BannedInfo -> Html msg

ではこの考え方を抽象化でどのように使うかというと、異なるデータ構造において共通となる型を用意し、抽象化したい関数はその共通の型だけに依存させる。考え方としては若干Extensible Recordに近い。

例えば、以下はMember型とAdmin型に共通なName型を用意し、関数のインタフェースをName型に依存させる例。

-- Member.elm

type Member =
    Member Name


-- Admin.elm

type Admin =
    Admin Name


-- Name.elm

type Name
    = Name String

こうすることで、データとしてName型を持っているものともっていないものを区別できる。また、NameモジュールにはMemberとAdminが必要とする「名前に関するロジック」を凝集させることができるため、Nameという軸でMemberとAdminを抽象化できる。

ここで重要なのがName型を本当に必要な型だけに与えることだ。もしAdminとMemberで名前に関する仕様が異なるのであれば、それは同じName型に依存させてはだめで、AdminとMemberでそれぞれ異なるName型を用意するべきだ。同じ型に依存しているということは、仕様が同じであるということを意味する。

これはプリミティブ型の扱いにも同じことが言える。通常、Stringというのはアプリケーションの中でもっとも頻繁に表れる型だが、Stringへの依存は文字列というほぼ制限のない仕様に対する依存を表明している。しかし、我々が作るアプリケーションにはメールアドレス、ユーザー名、ニックネーム、などの"仕様を持つ文字列"が存在しており、関数やデータ構造はその仕様に特化した型に依存することでインターフェースとして仕様を表現することが望ましい。型と仕様が見つかれば、それはモジュールとして抽象化して抽出できる。

また「ある型が特定の型をもつかどうか」というのは幽霊型というテクニックにもつながる。幽霊型を使うころで状態遷移のパターンを型で制限できたりもする。 izumisy.work

Tagger

Elmはカスタム型もレコードもすべて関数として扱うことができるため、それを利用して任意の値を持つカスタム型のバリアントを受け付けることができる。ライブラリなどを作っていると使う場面がでてくるが、普通のアプリケーション開発ではあまり使う場面があるとは言えない。

type Msg
    = UpdateEmail Email
    | UpdateName Name
    | NoOp


{-| この関数はUpdateEmailのバリアントだけを引数で受け付けることができる
-}
emailUpdating : (Email -> a) -> Cmd msg

つまりタグを持つバリアントというのは、タグを受け付ける関数とイコールである。このような関数をElmにおいてはTagger(タグする関数)と呼ぶ。

あるのか分からないが、たとえば使いどころとして特定のMsgバリアントだけを受け取ってupdateを実行するような関数を実装したいときなどが挙げられる。その場合にはTaggerを使えば、上のemailUpdatingの例のようにUpdateEmailだけを受ける関数が作れる。

抽象化の観点では言えば、上のemailUpdating関数はUpdateEmailに限らずEmail型をタグとして持つものであればなんでも受け入れることができる。したがって、タグの型は限定するが、タグの型が同じでさえあれば値はなんでもよい。

移譲、DIP

DIPに関する記事はここに書いていた。 izumisy.work

また、英語になるがDEVにもモジュールの実装を移譲モジュールで抽象化する件の記事も書いていた。 izumisy.work

所感

書いてて思ったけどこれ全部Elmというよりは静的型付言語に共通する手法という感じがする。

でもElm書いてると細かい言語文法とかライブラリの使い方みたいな場当たり的なものじゃなくて、型を中心にした設計方針みたいなのを考えるほうに気が向くからいいですね。

メルカリでiMac 21inchを売るときのあれこれ(発送とか初期化とか)

先日2019年モデルのiMac 21inchをメルカリで売った。

下取りとか買取で調べると6-7万くらいで買い叩かれる&一度発送しての査定なのでかなり不安(送ったら1000円とかにされたりするかもしれないし)だが、メルカリであれば先に値段が決まるので嬉しい。

手数料は高いが仕方ない。

初期化について

オークション等で譲渡する場合にはiMac本体の初期化は必須。

手順に関しては公式が用意しているものがあるので、これを参照するのがよい。

support.apple.com

ひとつ注意点があり、以下のようなiMacのスペックのダイアログを画像として商品説明に載せる場合には、かならず初期化の前に写真として撮っておく必要あり。

f:id:IzumiSy:20200821102752j:plain
iMacのスペックのダイアログ

一度初期化してしまうと、再セットアップせずにはスペックを見ることができないので、またセットアップからの初期化が必要になってしまう。

シリアルナンバーに関してはそれ用いて保証期間をAppleの公式サイトでチェックしたりする用途で使うようだが、公開してしまうと偽造などに利用されてしまうとかなんとかで念のため隠している。

本当に隠す意味があるのかは不明。

保証について

コメント欄で「この商品は保証書はありますか?」とか聞かれることがあるが、それに関しては「ないが、シリアルナンバーから保証期間を確認できる。この製本の保証期間は〇〇までです」と伝えておけばよい。

www.apple.com

Appleの公式ストアで買うと確実についてこないが、家電量販店などで購入した場合にはその量販店用の保証書がついてくることはあると思う。

発送について

Apple製品というのは箱にも価値があるとか言われるので、人によっては発送するに当たってiMacの箱を包むダンボールをさらに用意したりする。あるいは、iMacを買うと純正の化粧箱を覆うダンボールがさらについてくるが、自分はそれは処分してしまっていた。

だが、自分の場合は追加でダンボール買うのもだるい(あと値段も上げて交渉合戦になりたくない)ので、説明欄に「この商品は純正の箱そのままで送ります」としっかり書いて出品することにした。

この記事を書いた時点でiMac 21inch 2019年モデルはヤマトの140サイズ扱いになる。自分はらくらくメルカリ便で送った。

www.mercari.com

Ubuntu 20.04でUSBのWiFiアダプタ(WDC-300SU2SBK)を認識させる方法

インターネットで調べたところ、どうやらUbuntuがサポートするrtl8192cuというチップとして認識させれば動くとのこと。

というわけで、rtl8192cuのモジュールをmodprobeでロードし、lsusbで分かったベンダIDとプロダクトIDをカーネルに認識させる。

$ sudo modprobe rtl8192cu
$ echo "056E 4009" | sudo tee /sys/bus/usb/drivers/rtl8192cu/new_id

こんな感じで、対象となるモジュール名の配下に置かれている new_id ファイルへデバイスの情報を書き込むとドライバの対象として未登録の機器であっても認識させることができる。つまり、実質的にどんな野良WiFiアダプタであっても中のチップセットの種類さえサポートされているものであれば、この方法で動かすことができる。

あとはudevを使ったり /etc/rc.local に上のコマンドを書いておくなりして、起動時にWiFiアダプタ用のモジュールをロードし自動的にネットワークへ繋がるようにすればよい。

askubuntu.com

追記(2021/02/06)

USB2.0のポートに刺すとダウンロードがめちゃくちゃに遅く、Googleスピードテストをしてみると1Mbpsかそれくらいしかでないことがある。

理由はわからないが、これはUSB3.0のポートに刺し変えることで解決できる。製品自体は2.0用と書かれているが、Ubuntuにおいては2.0に刺すと使い物にならない。逆にWindowsでは3.0に刺すと接続が安定しないことがあったが、Windowsに関してはElecom公式から提供されているWDC-300SU2S Driverをインストールすることで解決された。

特に理由がなければUSB2.0ではなくUSB3.0のポートに刺しておくのがいいと思われる。

BigQueryのDATE関数とタイムゾーン周りの挙動

よく分からんところがあるので整理した。

SELECT 
  date("2020-7-1T23:59:59+0900", "Asia/Tokyo") as tzDateWithTzString, 
  date("2020-7-1T23:59:59", "Asia/Tokyo") as dateWithTzString,
  date("2020-7-1T23:59:59+0900") as tzDate,
  date("2020-7-1T23:59:59") as date_

上記のSQLを実行すると以下の結果になる

tzDateWithTzString dateWithTzString tzDate date_
2020-07-01 2020-07-02 2020-07-01 2020-07-01

注目すべきはdateWithTzStringで、ISO8601形式における時差指定がない場合にはdate関数に指定されたタイムゾーン分の時差が時間に適用されている。

タイムスタンプの時差とタイムゾーン文字列の両方が指定されている場合の挙動

上の結果におけるtzDateはタイムスタンプ側でも+9時間の時差が指定されているが、同時に Asia/Tokyo というタイムゾーンも指定されている。

結果だけ見るとdateWithTzStringとは違いタイムゾーンの指定が無視され時差の適用が行われていないように見えるが、タイムスタンプにおける時差指定とタイムゾーンの時差が異なる場合を見てみると実際には適用が行われているらしいことが分かる。

以下はその例となるクエリ。

select 
  date("2020-7-1T23:59:59+0900", "Asia/Vladivostok") as date1,
  date("2020-7-1T23:59:59+0900", "Asia/Ulaanbaatar") as date2,
  date("2020-7-1T23:59:59+0900", "Asia/Tokyo") as date3

結果は以下になるが、タイムスタンプ側で時差指定がすべて+9時間であるにも関わらずタイムゾーンによって結果となる日付が変動していることが分かる。

date1 date2 date3
2020-07-02 2020-07-01 2020-07-01

この挙動を見るに、おそらく時差指定を含んだタイムスタンプ文字列とタイムゾーンの両方が指定されている場合の内部的な挙動はまずタイムスタンプ時間をUTCへ変換し、その上で改めてタイムゾーンの時差を適用するという動きになっているのではないかということが推測できる。

ウランバートルは時差が+8時間であるためタイムスタンプをUTC変換した上で時差適用しても 22:59:59 にしかならない。一方で Asia/Vladivostok は時差が+10時間なため、タイムスタンプがUTC変換された上で時差が適用されると翌日の 00:59:59 になる。

Interpreter Pattern in Elm: 副作用をテスタブルにする

Elmは純粋関数型プログラミング言語なので基本的にはアプリケーションを構成するすべての関数はテスタブルであるが、唯一Cmd型だけはテストすることができない。

たとえば以下のようなupdate関数においてPersistToStorageのメッセージが渡された際に必ずストレージへの保存を実行するCmdが発行されているかどうかをテストしたいとする。

update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case msg of
         PersistToStorage value ->
               ( Saving, persistToStorage value )

         -- ...


-- ports


port persistToStorage : Encode.Value -> Cmd msg

ElmアプリケーションにおいてCmdは一度ラップされてしまうと、中のmsgを取り出すことはできない。またカスタム型であるためelm-testにおいて等価チェックのアサーションも使うことができない。以上の理由からCmdはテスタブルな型ではないということが分かる。

もちろん、CmdをテストせずModelの状態のみをチェックすることで実質副作用が発行されていることと同義とすることも可能である。Elmアプリケーションにおいて副作用というのはアプリケーションの状態に直接的に影響を与えるものではないし、そもそも「副作用が発行されているかどうかをテストするべきなのか?」という疑問は常に持っておくべきだと言える。

しかし、どうしても副作用をテストしたいという場合にはInterpreter Patternを用いることで副作用をテスタブルにすることができる。

Interpreter Patternとは

Interpreter Patternはざっくりいうと「アプリケーションの処理と実装を分離する」ためのパターンにあたる。ScalaHaskellなどの関数型プログラミング言語の文脈においては有名なパターンで、計算機の実装などがよくある例である。

www.youtube.com

より具体的な実装方法で言うと「アプリケーションがどう動くか」を型など純粋なもので表現し、その型を解釈(Interpret)する別の関数に副作用の発行を移譲することで副作用のある部分と純粋な部分を切り離すという形になる。

ElmにおけるInterpreter Pattern

さて、ElmにおいてInterpreter Patternはどのような実装になるかというと、ただ単にupdate関数の副作用を自前で定義したカスタム型で置き換えるだけである。こうすることでupdate関数は完全にテスタブルな型のみを返すようになる。

update : Msg -> Model ( Model, ExCmd )
update msg model =
    case msg of
        PersistToStorage value ->
              ( Saving, ExPersistToStorage value )

        -- ...


type ExCmd
      = ExPersistToStorage Encode.Value

そして、ExCmdを解釈してCmd型の発行を行うInterpreterとなるtoCmd関数を用意し、main関数の呼び出し時にupdate関数と接続する

toCmd : ExCmd -> Cmd msg
toCmd exCmd =
    case exCmd of
         ExPersistToStorage value ->
               persistToStorage value


main : Program () Model Msg
main =
    Browser.element
        { init = init
        , view = view
        , update = (\msg model -> Tuple.mapSecond toCmd <| update msg model) 
        , subscriptions = \_ -> Sub.none
        }

これでCmd型に依存するのはtoCmd関数だけになったため、もともとの目的であった「update関数はPersistToStorageのメッセージでストレージへの保存を実行するCmdを発行しているか」を以下のようにテストすることができる。

suite : Test
suite =
    test "update" <|
        \_ ->
            init
                |> update (PersistToStorage "this is an apple")
                |> Tuple.second
                |> (\exCmd ->
                    case exCmd of
                        ExPersistToStorage _ ->
                            True

                        _ ->
                            False
                )
                |> Expect.equal True

これでupdate関数を完全にテスタブルなものにすることができた。

余談

Elmにおいてはアプリケーションでランタイムエラーが起きることはないため、どちらかといえばテスタビリティを上げるという観点でInterpreter Patternのような設計を行うことが多い。

見方を変えれば、Elmアプリケーションの実行を司るElmカーネルそのものがJavaScriptとの連携を行うInterpreterとしての役割を果たしているため、他の言語におけるInterpreter Patternのように「実行時エラーが起きる可能性があるレイヤとそうでないレイヤを分離する」というような実行時安全性を維持するための分離はElmにおいて全くもって必要ない。

もちろんJavaScriptやTypeScriptでは必要ではあるが。

elm-multi-waitableで非同期処理の完了待ちをModelに表現する

Elmアプリケーションにおいて非同期処理の完了待ち実装をいい感じにするelm-multi-waitableというモジュールを公開した

github.com

内部的には状態遷移のタイミングでデータを受け取って保持するステートマシンのようなものだが、これを使うことでフロントエンド・アプリケーションによくある「非同期ないし直列に複数データの取得を行い完了待ちをする」というような処理の実装を改善できる。

非同期処理をModelで表現する難しさ

例えば、以下のようにLocalStorageへのアクセスと複数のWebAPIへのリクエストの完了待ちが必要になるケースがあるとする。

また、すべてのリクエストが完了するまではローディング中画面を出し、すべてが完了したらローディング完了画面を出すという仕様がある。

f:id:IzumiSy:20200711234003j:plain

この仕様のModelを最も簡単に表現するのであれば、データの取得が完了している/完了していないをMaybeで素朴に表現することになる。

すべてがJustになっていればview関数でローディング完了画面を出すといった具合になる。

type alias Model =
    { auth : Maybe Auth
    , user : Maybe User
    , option : Maybe Option
    }

これはこれで一旦動くものは作れるが、以下の問題点を含んでいる。

  • データを扱うのに毎回Maybeを剥がす処理が必要になりコードが冗長になる
  • 直列にすべてのデータを取得する実装ではデータの取得順が変わる度に完了チェックの場所が変わる(UserとOptionの取得順を入れ替えるような場合には都度完了チェックのタイミングも変える必要がある)
  • 並列にすべてのデータを取得する実装では個別のデータ取得完了毎に完了チェックの処理が必要になる(どの順番でデータの取得が完了するか分からない&完了チェック自体が抜け漏れる可能性がある)

そもそも、値をMaybe型で包んでいるのはローディング画面を出したいという仕様のためだけであって、ローディング画面が終了したら存在しているものはMaybeである必要がない。可能であればMaybeではない型であってほしい。できれば、以下のようなデータ構造であってほしい。

type Model 
    = Loading -- ローディング中
    | Loaded Auth User Option -- ローディング完了

しかし、このコードは理想論であって実際には実現できない。なぜならローディング中には非同期的にLocalStorageやWebAPIからのデータが取得されていくため、取得過程のデータも保持する必要があるからだ。

ここでelm-multi-waitableを使うともう少しだけ近い形で実現することができる。具体的には、以下のようなModelの設計にすることができる。

type Model
    = Loading (MultiWaitable.Wait3 Msg Auth User Option)
    | Loaded Auth User Option

elm-multi-waitableの使い方

まずinit関数でLoadingの初期化を行う

init : Model
init =
    Loading <| MultiWaitable.init3 Done

MultiWaitable.initN関数は待ち合わせているすべての非同期処理が完了した際に発行されるMsgを第一引数に取る。

第一引数で指定されているDoneは完了待ち対象となるデータの型(今回の例ではAuth, User, Option)をタグに持つもので、Msg型は以下のような設計になる。

type Msg
    = AuthFetched Auth
    | UserFetched User
    | OptionFetched Option
    | Done Auth User Option

上記のMsgをハンドリングするupdate関数は以下。

update : Msg -> Model -> ( Model, Cmd msg )
update msg model =
    case (model, msg) of
        ( Loading waitable, AuthFetched auth ) ->
            waitable
                |> MultiWaitable.wait3Update1 auth
                |> Tuple.mapFirst Loading

        ( Loading waitable, UserFetched user ) ->
            waitable
                |> MultiWaitable.wait3Update2 user
                |> Tuple.mapFirst Loading

        ( Loading waitable, OptionFetched option ) ->
            waitable
                |> MultiWaitable.wait3Update3 option
                |> Tuple.mapFirst Loading

        ( Loading _, Done auth uesr option ) ->
            ( Loaded auth user option, Cmd.none )

MultiWaitableはupdateのための関数としてwaitNUpdateN関数群を提供しており、たとえばそのうちwait3Update1関数は以下のようなシグニチャになっている。

wait3Update1 : a -> Wait3 msg a b c -> ( Wait3 msg a b c, Cmd msg )

この関数はaを適用して更新されたWait3型の値と、完了時であればinit3関数で与えられた完了Msgの発行コマンドをタプルで返している。

MultiWaitableモジュールを使えば、update関数においてはWaitN型の更新だけをすればよく、待ち合わせ中のデータがすべて揃ったかどうかを開発者自身が都度気にする必要はない。また、機能追加による改修の際にも、Modelの設計からローディング/ローディング完了という状態が存在しているとひと目で分かることや、init関数の初期化部分で完了時のMsgが把握できることなどから可読性も高くなる。

Elmにおいて複数の非同期処理の完了待ちはModelの設計が比較的難しい部分であるが、elm-multi-waitableを使うことでModelのデータ構造をシンプルかつ分かりやすく表現させることができる。

関連パッケージ

elm-multi-waitableは複数Taskの完了待ちを支援するelm-task-parallelというパッケージから大いに影響を受けている。

izumisy.work

また、非同期処理の完了待ちに関してはリチャード・フェルドマンもelm Europe 2018の"Make Data Structures"で同様の話をしている。

f:id:IzumiSy:20200712105918j:plain "Make Data Structures" by Richard Feldman

彼のトークスライドはIndexeDBとWebAPIの両方からデータの取得を行うケースでのModel設計ではあるが、Maybeという「ある/なし」しか表現できない文脈の少ない型をできるだけ減らしていこう、という方向性では同じく参考になるだろう。