Runner in the High

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

elm-firestoreにおける型安全なパス組み立ての設計

elm-firestoreのパス組み立て周りの実装を型安全な設計にしてみたのでその話。 github.com

Firestoreにおけるパスの仕様

まず大前提としてFirestoreにはリソースの場所を示すパスという概念があり、ざっくりパスは以下のようなルールに基づいている。

  • ルートにはコレクションのみが所属する
  • ドキュメントは必ずどこかのコレクションに所属する
  • コレクションはドキュメントの配下にも所属できる(サブコレクション)

つまり、必ずコレクションとその配下に複数のドキュメントがあり、ドキュメント配下ではまたコレクションを起点とした入れ子構造が形成される。例外的にルートには直接コレクションが所属するが、これはルート自体をひとつの更新/削除不可能な疑似ドキュメントであるとして考えればなんとなくしっくりくる。

elm-firestoreにおける表現

さて、上記の親子関係ルールに基づくパスの組み立て処理はelm-firestoreではパイプの構文を用いて以下のように表現される。

firestore
    |> Firestore.root                  -- ルート
    |> Firestore.collection "users"    -- コレクション
    |> Firestore.document "user1"      -- ドキュメント
    |> Firestore.subCollection "books" -- サブコレクション
    |> Firestore.document "book1"      -- ドキュメント

これが例えば、ドキュメントの配下にコレクションが来たり、サブコレクションの配下にルートが来たりするとコンパイルエラーがでる。

というのも、これらパスビルダ関数のシグニチャは次のようになっているからだ。Firestoreのパスが持つルールに基づいて、関数ごとにパイプでつなげる値の型が型変数で指定されている。

root : Firestore -> Path RootType

collection : String -> Path RootType -> Path CollectionType

document : String -> Path CollectionType -> Path DocumentType

subCollection : String -> Path DocumentType -> Path CollectionType

上記で使われているPath型の実装は以下。

type Path pathType
    = Path (List String) Firestore

この型が持つ pathType という型変数は幽霊型であり、この型でルート、コレクション、サブコレクション、ドキュメントの呼び出し関係をコンパイル時にチェックする仕組みになっている。

クエリ関数

ひとつ変わり種としてFirestoreには runQuery というオペレーションがある。これは少し特殊で、パス指定にはルートまたはドキュメントのみを指定する必要がある。それ以外のパスを指定すると、サーバーからエラーが返ってきてしまう。

上のパスビルダのシグニチャを考慮すると、関数のインターフェースとしては Path RootTypePath DocumentType の両方を受け入れるような設計にしたい。しかしElmにおいては型でそのようなORの構造は表現できない。

ではどうするかというと、レコード型を用いて呼び出し可能なオペレーションの許可リストのようなものを表現することで解決する。

type Specified
    = Specified


type alias RootType =
    { collectionPath : Specified
    , queriablePath : Specified
    }


type alias DocumentType =
    { documentPath : Specified
    , queriablePath : Specified
    }


--  いくつか本実装から省略...


type alias QueriablePath a =
    { a | queriablePath : Specified }

ここでポイントになるのは queriablePath というフィールドで、レコード型にすることによってRootTypeDocumentTypeが部分的に一致する条件を作り出している。

Path型を受け取る関数では型変数へExtensible Recordを当てはめることで部分一致をチェックする。たとえば、runQueryを実行する関数はQueriablePathというExtensible Recordを受け取る型として指定することでRootTypeDocumentTypeが受け取り可能になる。

runQuery : Decoder a -> Query.Query -> Path (QueriablePath b) -> Task Error (List (Query a))

このようにレコード型を幽霊型に用いると型レベルでのOR条件のようなものが表現できる。この手法を知っていればある程度複雑な型レベルでの状態遷移の仕様もコンパイラにチェックさせることが可能だし、特にFirestoreのパスビルダではまさにうってつけの方法だ。

デメリットがあるとすれば、やはり初見理解するのが難しいことと、コンパイルエラーがとてもじゃないがわかりやすいとは言えないところ。やっていることもまあまあ複雑なのでしょうがないと言えばしょうがないが。

なお、内部実装的には以下の記事で紹介している状態遷移パターンの実装とやっていることはほとんど同じである。 izumisy.work

elm-firestore v10.0.0をリリースした

引き続きチビチビと作り続けていたelm-firestoreだが、とうとうv10をリリースした。

elm-firestoreとはなんぞや、という記事はここで書いていた。

izumisy.work

変更点

Experimentalなライブラリということもあって毎バージョンいつもドラスティックな変更を加えているが、今回特筆すべきは以下の3つ。

  • トランザクション関連関数の整備
  • クエリオペレーションのサポート
  • Firestore Emulatorとの統合テストの追加

特にクエリオペレーションに関してはissueでも要望されており、たしかにFirestoreでクエリ系が使えないと検索処理も実装できずプロダクトでは使い物にならないところがあった。

また、トランザクション関連の関数に関しても Transaction 型は存在しているものの、それを使った更新処理を書くことが実際的には用意されている関数のインターフェイス上不可能というかなりちぐはぐな状態であった。しかし今回ようやく関連する関数群を整備&Firestore Emulatorとの結合テストも追加したことでトランザクション周りの実装まで動作を担保した状態になったと言える。

トランザクション関連関数

FirestoreのREST APIにおけるトランザクション周りのインターフェイスはUnit of Work*1的なものになっているのでelm-firestoreでもその雰囲気を踏襲した。パイプで更新/削除をトランザクションに積み上げて最後にコミットするようなスタイル。

    model.firestore
        |> Firestore.commit
            (transaction
                |> Firestore.updateTx "users/user1" newUser1
                |> Firestore.updateTx "users/user2" newUser2
                |> Firestore.deleteTx "users/user3"
                |> Firestore.deleteTx "users/user4"
            )
        |> Task.attempt Commited

また、更新系以外にも getTx, listTx, runQueryTx などのTxな取得系関数も増やした。

以下は統合テストからの抜粋になるが「usersコレクション配下から一覧取得したuserドキュメントの名前をアトミックに全更新する」みたいな取得系関数と組み合わせたコードは以下のようになる。

model
    |> Firestore.begin
    |> Task.andThen
        (\transaction ->
            model
                |> Firestore.path "users"
                |> Firestore.listTx transaction (Codec.asDecoder codec) ListOptions.default
                |> Task.map (\{ documents } -> ( transaction, documents ))
        )
    |> Task.andThen
        (\( transaction, documents ) ->
            Firestore.commit
                (List.foldr
                    (\{ name, fields } ->
                        Firestore.updateTx
                            ("users/" ++ Firestore.id name)
                            (Codec.asEncoder codec
                                { name = fields.name ++ "txUpdated"
                                , age = fields.age
                                }
                            )
                    )
                    transaction
                    documents
                )
                model
        )
    |> Task.attempt RanTestListTx

クエリオペレーション

とりあえず CompositeFilter, FieldFilter, UnaryFilter の3つをサポートした。

この中ではCompositeFilterのインタフェースのみ特殊で、いわゆるNon-empty list的なインタフェースにしている。これはFirestoreのCompositeFilterの前提条件が「少なくともひとつはフィルタを与えなければならない」ため。

-- usersコレクションからageが10以上かつ30より小さなメンバをクエリ
Query.new
    |> Query.from "users"
    |> Query.where_
        (Query.compositeFilter Query.And
            (Query.fieldFilter "age" Query.GreaterThanOrEqual (Query.int 10))
            [ Query.fieldFilter "age" Query.LessThan (Query.int 30) ]
        )

もしフィルタのパラメタを単なる配列にしてしまうと、フィルタとして空リストを与えた場合にエラーが返ってきてしまう。Elmなので実行時エラーにはならずResult型にはなるものの、型やインタフェース実装で守れるところはこのようにできるだけ守っておくほうが設計としては望ましい。

Firestore Emulatorとの統合テスト

Firebaseの製品のほとんどはエミュレータ用いてローカルで動かすことができるので、それを利用してelm-firestoreの統合テストを追加した。

やっていることはシンプルで、elm-firestoreを利用したWorkerという小さなテスト用のElmアプリケーションを別都用意し、テストランナー(ava.js)で実行とアサーションをやっている。Elmには Platform#worker というヘッドレスアプリケーション用のランタイム実行関数が用意されているため、それを使ってテスト用アプリを作る。あとはポート経由でテスト用アプリと通信しテストの実行と結果の受け取りをする。詳しい実装はリポジトリintegration_tests ディレクトリ配下にすべてある。

テストランナーにava.jsを使っているのは、Firestoreとの通信をモックせずIOを発生させるテストが多いため並列実行との相性がいいテストランナーを選択したかったという背景から。最初はmochaを使っていたがthisコンテキスト関連の黒魔術が多くあまり体験が良くなかったこともあり乗り換えた。

ところでElmとは話が逸れるが、エミュレータREST APIにはバグがまだたくさんあるようで、elm-firestoreのテストを書く過程でクリティカルなバグに2件遭遇している。本番では動くので問題ないが、2021年4月時点ではエミュレータとプロダクションの挙動が完全に一致しているとは思わないほうが無難だと思われる。

github.comトランザクション内でドキュメントの取得系メソッドを呼ぶとエミュレータが実行時エラーで止まる

github.com ↑ドキュメントの一覧取得でページサイズ指定してもページネーショントークンが返されない

Acer C720のSSDを換装してUbuntu 20.04 LTSをクリーンインストールする

Acer C720というChromebookが手元にあるのだが、しばらく前にサポートが切れたので気合を入れてUbuntuクリーンインストールする。

ついでにSSDも換装する。

acer ChromeBook C720 (日本正規品)

acer ChromeBook C720 (日本正規品)

  • 発売日: 2014/11/13
  • メディア: Personal Computers

SeaBIOSを有効化

USBメモリからUbuntuをインストールしたいのでSeaBIOSを有効にしたい。そのために、まずは開発者モードに入ってchronosユーザーで以下のコマンドを実行する。

# USBブートを有効にする
$ sudo crossystem dev_boot_usb=1

# CTRL+LでSeaBIOSが起動できるようにする
$ sudo crossystem dev_boot_legacy=1

# CTRL+Lを押さなくても勝手にUbuntuが起動するようにする
$ sudo /usr/share/vboot/bin/set_gbb_flags.sh 0x489

SSD換装

以下のSSDを購入して換装した。安い割に普通に動いているのでイイ。

換装の仕方は他にも記事がたくさんあるので省略。

Write-Protect Screwの除去

C720ではディスクへの書き込みを防止するビスみたいなやつを外さないと直接SSDへ書き込みができないようになっているので、ラップトップの裏を開けてボードからそのビスを外す。

以下の7番がそれ。

f:id:IzumiSy:20210314192214p:plain

Ubuntuの起動

天板を閉じるとシャットダウンしちゃうなどの問題はあるが、それ以外は特に問題なくいい感じで動いている。

f:id:IzumiSy:20210314193941j:plain

20.04だからなのかは分からないが、ログイン画面から起動時のセッションで "Ubuntu on Wayland" のほうを選ばないと画面がチラついて使い物にならなかった。

USBブートから起動したときは問題なかったのにディスクに書き込んでから起動するとこのトラブルが起きた。理由は不明。

追記: GPUのハング対策

"Ubuntu on Wayland" を選択したとしてもアプリケーションを起動すると画面がハングしてしまう。

dmesg でログを見てみるとどうやらGPUがハングしている様子。ログで調べてみた結果、どうやらi915のGPUドライバ周りにカーネルのバグがあるらしく5.7以降のカーネルにしてしまうとAcer C720ではLinuxがマトモに動かないとのこと。ワークアラウンドもあったりなかったりでよく分からない。

gitlab.freedesktop.org

雑な解決策として、一旦カーネルバージョンを適当に 5.4.0-26-generic にして使うことにした。このバージョンであれば問題なく動く。

追記: サスペンドの有効化

天板を閉じるとサスペンドせずシャットダウンしてしまう件についてはGRUBの設定に以下を追記することで対応できた。

GRUB_CMDLINE_LINUX_DEFAULT="tpm_tis.interrupts=0"

elm-typedパッケージを使えばもっと型で仕様を表現できるかも

elm-typedというパッケージを作った。詳しくは以下のGithubのREADMEに書いている。

github.com

パッケージ自体が根本的に実現したいことはPunie/elm-idとかjoneshf/elm-taggedあたりとほぼほぼ同じ。プリミティブ型をそのまま使わず型エイリアスでもなく、プリミティブ型をラップしたカスタム型を使うことで仕様をもっと型に表現したいというモチベーションから来ている。

実際に使う場合には、以下のようにelm-typedが提供している Typed 型に対して、タグとなる型(MemberIdTypeとかAgeTypeあたり)とその型が内部的に持つプリミティブ型、そして最後にパーミッション(後述)を指定する。

import Typed exposing (Typed, ReadOnly, ReadWrite)


type MemberIdType
    = MemberIdType

type alias MemberId
    = Typed MemberIdType String ReadOnly

type AgeType
    = AgeType

type alias Age
    = Typed AgeType Int ReadWrite

type alias Model =
    { memberId : MemberId
    , age : Age
    }

パーミッションはelm-idとelm-taggedを折衷するための機構で、ある Typed なデータに対して任意のパーミッションを指定することで、Elmアプリケーションにおけるそのデータの作成/更新を制限することができる*1

たとえば、上のサンプルでは MemberIdReadOnly に指定されているため、newやmapなど更新系関数の呼び出しはできない。例外的に ReadOnly であっても encode/decode はできるようになっているが、Elmアプリケーションに値が生まれた時点ではもう書き込み不可である。

newAge : Age
newAge =
    Typed.new 30 -- OK!

newMemberId : MemberId
newMemberId =
    Typed.new "1" -- ReadOnly指定なので新規にデータを作成できない(コンパイルエラーになる)

パーミッションの利点として、フロントエンド・アプリケーションにおいて参照のみで使うデータ(サーバーサイドから取得されるIDなど)をReadOnly指定することで、仕様上更新してはいけないデータを更新してしまうなどのバグを未然に防止できる。また、コードレビューやコードリーディングの際にもModelを読むだけでどのデータが更新される仕様なのかが把握しやすい。

実際に個人開発のElmアプリケーションでこのパッケージをテスト導入をしているが、それまでOpaque Typeなモジュールとして設計していた型が置き換えられていてイイ感じ。カスタム型による仕様の表現だけじゃなく、型の定義でデータのライフサイクルまで制御できるのはわりかし便利だ。

*1:パーミッションごとに呼び出せる関数を幽霊型で制限して定義している

ScalaではIntとStringを比較してもコンパイルエラーにならない

社内で以下のScalaコードがコンパイルエラーにならないというのが盛り上がっていた。

println(1 == "a") // コンパイル通るし結果はfalseになる

普段ElmとGoばっかり書いている&Scalaのことをとりあえず型に厳しい言語だと思いこんでいたこともあり若干意外だったが、調べてみると以下の記事が詳しかった。

stackoverflow.com

IntegerもStringも等価比較の際、内部的には Any 型を比較対象の引数に取る java.lang.Object#equals メソッドが呼ばれるため、比較対象がどんな型であってもコンパイラー的にはエラーにならない。ElmやGoと異なりScalaはこういうものらしい。

ちなみにScastieだと次のような警告がでる。

comparing values of types Int and String using `==` will always yield false

IDEやsbtで出るようなこういう警告を事前にちゃんと潰しておく、あるいは等価比較ではなくパターンマッチを使ってコンパイルエラーにすれば、こういう比較周りの挙動の罠には気づけるかもしれない。

J:COM船橋で2/17から通信障害があったらしい

J:COMから現時点で正式なアナウンスはなにもないが、どうやら千葉県船橋市内一帯で2/17から2/20にかけて通信障害が発生していたらしい。

この障害の影響で、上り速度が1Mbps以下しかでない日が3日間ほど続いた。症状としては上り回線の輻輳状態のような感じで、速度が出てもせいぜい0.3Mbps程度。下りには全く影響がなかった。

f:id:IzumiSy:20210221105438p:plain
スピードチェックのデータ。2/17をさかいにしてのぼりがグラフに出ないレベルの速度になっている。

しばらく様子を見ていたが、2/20の昼頃から改善していったことがグラフで分かる。

f:id:IzumiSy:20210221110757p:plain
2/20から2/21にかけての上り回線速度データ

1Mbps以下になってしまうと、普通にオンラインMTGで顔を出すことができなくなってしまうレベルなので非常に厳しい。

2/20ごろに電話で問い合わせたときには「こちらで確認できる障害は発生していない」とのことだったが、今日の電話でなぜか「障害が発生していたが復旧した」と伝えられた。しかし、現時点でもまだ障害情報が更新されていない。

Elmアプリケーションにおけるモジュールレベルでの詳細設計

Elmアプリケーションで比較的モジュール多めなアプリケーションの機能開発をするときに同僚とトライしている手法について。

言語的なElmのテクニックみたいな話ではなく、どちらかといえばもっと抽象的なハナシ。

1. 画面からざっくりとモジュールを見つけ出す

基本的に新しく機能を設計するときには画面設計みたいなものがデザインレベルで上がってきているはずなので、それを元に画面を構成するモジュールを分解する。この時点では画面ベースでやる。

自分がエンジニアHubで寄稿したElm記事でも、まずは画面をベースにして主要なモジュールを見つけ出している。

https://cdn-ak.f.st-hatena.com/images/fotolife/b/blog-media/20200302/20200302130323.png

2. モジュールを分類する

見つけ出したElmのモジュールを"TEAなモジュール""そうでないモジュール"で分類する。依存関係も見つける。

"TEAなモジュール"とはMsg型とupdate関数を持つTEA的なライフサイクルに乗るモジュール。"そうでないモジュール"はその名の通りそれ以外のもの。

TEAなモジュールを見つけ出すことはかなり大事で、ElmアプリケーションにおけるTEAなモジュールは必ず大元になるApp.elmだとかMain.elmみたいな上位のモジュールのupdate関数から自分のupdate関数に処理を移譲してもらう必要がある。なので、依存関係をあぶり出す段階でTEAモジュールの依存元になるモジュールにTEAなものが無ければ設計は破綻しており、この時点でモジュールの依存関係がミスっているということが分かる。

意外と見落としがちなのがPorts周り。update関数だけではなくsubscriptions関数も依存元モジュールにTEAなモジュールが必要であることに注意。また、Ports経由でデータの取得依頼とその結果の受け取り待ちをするような機能がある場合には、それらの処理のPortsどのようなポリシーの元でどのモジュール内に定義するかを考えておく。Ports定義はTEAなモジュールの中だけに限定するなど。

ElmにおいてPortsはかなり飛び道具的な機能であるため、ここのルールがガバると複数人開発ではすぐカオスになる危険性がある。

サードパーティー・モジュールの取り扱い

サードパーティーのモジュールを導入する際には移譲するためのラッパー・モジュールを用意する。

このモジュールはサードパーティー側のインタフェース設計によらずTEAなモジュールであることが望ましい。理由は、依存先のモジュールが変更されても最大公約数的に影響範囲を抑えられるようなインターフェイスを提供するため。詳しくは以下の記事で解説している。

izumisy.work

3. モジュールのインターフェイスを設計する

見つけ出したモジュールのインターフェイスをざっくりと書き出していく。

細かい実装までは書かない。

module Page exposing (Model, Msg, init, update, view)

-- model

type Model
    = Model

init : Model

-- update

type Msg
    = Msg

update : Msg -> Model -> ( Model, Cmd msg )

-- view

view : Model -> Html msg

上記はTEAなモジュールのインタフェース設計の例だが、ここでは外部のモジュールから依存される関数や型を意識する。外部のモジュールはどういう風にデータを取り出したいだろうか?というのを考えて関数を作っていく。これを考え始めると自ずとModelの設計が必要になるため必要に応じてModelも詳細化していく。なお、この時点ではモジュール内部でしか使わない非公開関数は書いても書かなくてもいい。

このインタフェース設計の時点でカプセル化したモジュールのインターフェイスを追求していこうとすると、ちらほら「結局exposingですべてのデータも関数も公開しなくちゃダメだ」みたいなモジュールが現れる。exposingで全公開 exposing(..) するようなモジュールは前提としてNG。これは、その実装をモジュールへ分割する意味がないモジュールの責務が薄いことを示している。そのようなモジュールは解体して他モジュールへ統合するか、依存元から責務を移動してみる*1。モジュールを作る理由がなければ作らなくてもいい。

モジュール設計に「機能による区分」を積極的に採用してしまっている場合にも、責務の薄さが表れることがある。モジュールの区分軸の話は過去記事を参照のこと。

izumisy.work

ここまで事前に設計段階で詰めておけば、残りは画面と具体のロジックを実装することに集中できる。また、作業の分担などを行ったり引き継ぎを行う際も、設計まで固められているので知識レベルを揃える工数を少なくできる。

あとはこれを関係者にレビューしてもらうなどして設計の妥当性を高めていく。

思うこと

Elmは他のフレームワークと比べて「できないこと」が多い分、設計の際になにをどこに置くべきか、みたいな観点が明確になる。設計段階でモジュール設計に時間を使って矛盾点をあぶり出すことで「この機能って本当にこの設計で作れるのか?」みたいな部分が事前に見つけ出される。これは、モジュールの設計がTEAによる具体的な制約を受けているからだ。制約の具体性と予測可能性(Predictability)は比例の関係であり、これはElmのTEAに限った話ではない*2

また、先回りしてモジュール設計をレビュワーやメンバと握っておくことで「このモジュールはなんのための存在か?」みたいな実装の大前提になるような知識を共有できる。地味にこの部分の齟齬がでかいとレビューで時間を食ったり、アプリケーション全体でモジュール設計の方針がめちゃくちゃになっていったりする。

小さい機能であればここまでやらなくてもいいが、新しく大きな機能開発でモジュールを作ったりする場合にはこれくらいやっておくと工期や品質の安定感が違う。

*1:とはいえ、単なる定数的な型とか関数のあつまりにしかならないようなモジュールが生まれることは現実世界ではあるっちゃあり、ここはベストエフォートでって感じ。こういうモジュールもちゃんと仕様を分析すれば適切なモジュールに統合して凝集性を高められたりするが、仕事でやるとなると工数的にやっていられない場合もある。

*2:たとえばクリーン・アーキテクチャもFluxアーキテクチャも、わざわざアプリケーション設計に制約を与えることでモジュールの構成や責務を予測しやすくしている。

2021年に思うこと

2020年内はいろいろ忙しくて振り返りとか書けなかったので、代わりに2021の今思っていることを。

ソフトウェア・エンジニアとしてのキャリアについて

去年はマネージャロールへの転向という自分の中での転換点の年であり、対外的な活動よりもいったん自分の会社に100%を注いでみようと決意した年でもあった。

12月にはひとつ大きな機能開発をチームでやりきり、チームのメンバや同じくマネージャロールの人々に支えられて「マネージャという仕事も悪くないな」と感じた。チームというのはひとりで成し遂げられないことをするためにある。 izumisy.work

技術的なキャッチアップに関してはかなり適当にやっていた2020年ではあったが、自分の中では「世の中の先端技術を追従しなければ」みたいなよくある焦りは全くと言っていいほどない。

技術に興味がなくなったというわけではないが、自分の中でソフトウェア・エンジニアという職業の捉え方が変わったのがひとつの理由ではないかと思う。自分の解釈では、ソフトウェア・エンジニアは研究職のようなごく少数の人々を除いてコードが書けるビジネスマンのような存在である。

「ビジネスマン」と表現しているのは、会社の利益のためであればコードを書く以外の業務もやるが、強みとしてコーディングがあるというだけの存在であるということを意味している。これは特に自分がベンチャー企業で働いているということから来ている解釈でもあると思う。企業規模が大きくなれば「効率性のための分業が云々」みたいな話が出てきて解釈は変わってくるだろう。

逆に、組織とソフトウェア・アーキテクチャの合理性みたいな部分には逆に興味が出てきている。 izumisy.work

プライベートについて

猫と一緒に生活をするようになった。

f:id:IzumiSy:20210101191109j:plain
寝る猫

人間と違って完全に生理的欲求だけに生きているので見ていておもしろい。

猫を見ていると、自然界の脅威からは解き放たれた代わりに労働という重荷を背負うことになった我々人間が、果たして猫と同じくらい幸せなのかが疑わしい。改めて人間としての生き方を問うてくる存在だと思う。