Kesinの知見置き場

知見を共有していきたいじゃないですか

VSCode Remote Containersに自分のdotfilesを持ち込む

ついにarm版のmacOSが発表されてしまったので、自分はこれを機に開発機をmacから乗り換えようと考えています。そのために注目しているのがVSCode Remote Containerです。

Remote Containersで使うコンテナ環境は.devcontainerというファイルに定義します。ブラウザからでも使うことができるVSCode Codespace(VSCode Onlineからリネームされた)や、今年に発表されたGitHub Codespaceでも同じように実行環境を.devcontainerで設定できるようです。

これを知るまではmacからWindowsのWSL2環境に移行しようかと考えていたのですが、コンテナであれば緩やかに移行できるし、VSCodeベースのオンラインエディタでも同じ環境を再現できることからWSL2に移行するよりも筋が良いように思いました。

Remote Containersを使ってみる

まずはドキュメントを見ながらお試しで始めてみます。手順自体は公式ドキュメントにスクショ付きで丁寧な解説があるのでそちらを見てください。ぽちぽち進めるだけでVSCodeが.devcontainerやDockerfileまで生成してくれるのでイチから手書きで用意していく必要はありませんでした。

最初のコンテナをビルドするところで時間が少々かかりますが、それさえ終わればあとは普段の環境と遜色ないでしょう。ターミナルを開けば見慣れたLinuxの環境です。

指定したコンテナの中で作業をしているため、言語のバージョンも当然コンテナでセットアップ済みのものが使えます。○○envみたいなバージョンを切り替えるツールは不要です。さらに、いかなる技術によるものかVSCode上で操作するgitだけではなく、ターミナル上でも自分のgitの設定や認証情報が引き継がれています。コミットをすればちゃんとauthorは自分になりますし、そのままGitHubにpushもできます。

一見するとローカル環境と比べても何不自由ない状態なのですが、普段どおりにターミナルを使おうとすると不便さに気が付きます。ls -lエイリアスとしてよく使われるllも使えません。当然laも無いし、そもそもlsの結果に色がついていない。さらにプロンプトも自分のお気に入りの表示ではありません。これでは普段使いには厳しい。

(本題)自分のdotfilesをRemote Containersに持ち込む

MSさんはこのあたりを分かっているので、自分のdotfilesをコンテナの中に持ち込む方法をVSCodeに用意してくれています。流石ですね。

Personalizing with dotfile repositories

公式ドキュメントではこのあたりなのですが、中身が少なくて意外にハマりポイントが多かったので解説していきたいと思います。

まずは「Remote-Containers: Settings」を呼び出して設定画面に行きます。設定する項目が3つ存在し、それぞれ以下のような意味です。

  • Dotfiles: Install Command
  • Dotfiles: Repository
    • git cloneするdotfilesのリポジトリ。owner/repositoryの書き方でOK
  • Dotfiles: Target Path
    • git cloneする先のパス
    • Remote Containersの初期セットアップ時に選択したコンテナの場合、おそらくrootユーザーで起動するので~/dotfilesの実際のパスは/root/dotfilesとなる

自分のdotfilesのリポジトリにセットアップ用のinstall.shを用意しておき、こういう感じに設定します。

f:id:Kesin:20200710003623p:plain
Remote Containersの設定

install.shにセットアップ処理を書いていきます。最低限、普段使いしているaliasの追加とlsに色だけは付いて欲しいのでこのような設定を書いてみます。

#!/bin/bash

# alias
alias rm='rm -i'
alias cp='cp -i'
alias mv='mv -i'

# ls
export LS_OPTIONS='--color=auto'
alias ls='ls $LS_OPTIONS'
alias ll='ls $LS_OPTIONS -hlrt'
alias la='ls $LS_OPTIONS -hlrta'
export CLICOLOR=1

これをpushしたら実際にdotfilesを読み込んでもらうためにコンテナを作り直します。VSCodeの左下の緑のボタンを押して「Remote-Containers: Rebuild Container」を実行するとコンテナを作り直してdotfilesを読み込み直してくれます。

さて、読み込みが終わったのでこれで解決・・・と思いきや、aliasもlsの色付けも反映されていません。ドキュメントにも難しいことは書いていなかったのになぜ?

Remote Containersとdotfilesのセットアップの仕組み

上手くできなかった理由を考えます。そもそもこのRemote Containersがどういう手順で起動しているのかを推測してみました。

  1. .devcontainerに従ってDockerfileからコンテナをビルド
  2. .devcontainerに書かれているextensionsをインストール
  3. docker run —mount type=bind,src=$(pwd),dst=/workspace/{REPO_NAME} {CONTAINER}に近いコマンドでVSCodeから開いているディレクトリを丸ごとコンテナにmountして実行
  4. dotfilesをgit clone
  5. 指定したスクリプトを実行(install.sh)
  6. コンテナ内でシェルを起動
  7. debian系のコンテナだと/root/.profileが存在するのでこれがシェル起動時に実行される
  8. .profileの最後で.bashrcを読み込む(. ~/.bashrc)

ログが出ているわけではないので自分の推測と観測した結果ですが、おそらくこういう手順で実行されています。最後の.profileが.barshrcを .(source)で読み込む のに対して、5のinstall.shは 普通のスクリプトとして実行 されます。

install.shに書いたaliasが反映されていないのはこれが原因です。普通のスクリプトの実行と.(source)の違いについてはこのあたりの記事を参照してください。

https://www.atmarkit.co.jp/ait/articles/1712/21/news015.html

install.shで行うべきこと

8にあるようにコンテナの起動時に.profile→.bashrcの順序で読み込まれていることは分かっています。ということは、もともと存在する.bashrcをシンボリックリンクで置き換えることにより自前の.bashrcを読み込ませることが可能です。

#!/bin/bash
cd ~/
ln -fs ~/dotfiles/.bashrc .

先程のaliasなどは自前の.bashrcに移し、install.shではシンボリックリンクを貼るだけにします。これを再度pushし、再びRebuild Containerを実行しましょう。

f:id:Kesin:20200710003703p:plain
install.shが正しく設定できたターミナル

できました!自分はaliasとlsの色付けに加えてプロンプトにgitのブランチを表示させるように変更してみましたが、コンテナの起動時に自動的に反映されています。
これでファイル操作をターミナルで行う硬派なユーザーでも心置きなくRemote Containers環境でコーディングできるようになりました。後はみなさん各自で最強のdotfilesを用意してください。

最後に現時点での自分のdotfilesを貼っておきます。自分の場合はinstall_vscode_container.shという名前にしたのでこちらを参照してください。

自分はaliasとlsの色付けに加えて、プロンプトにgitのブランチ名を表示させたりgitのdiffを改良するdiff-highlightを使えるようにしています。

https://github.com/Kesin11/dotfiles/blob/08617cb952817883199d7d4f676b9cb2a5bc15f0/install_vscode_container.sh

おまけ install.shの効率的なデバッグ方法

install.shを修正するたびにgit pushしてVSCodeからRebuild Containerするのは時間がかかるので、デバッグはもっと効率的な方法で行いましょう。

dotfilesのリポジトリが置いてあるディレクトリ上でこのdocker runを実行してください。dotfilesをそのままコンテナ内の/home/dotfilesの位置にマウントした状態でbashが起動します。使うコンテナは.devcontainersとDockerfileで自分が使いたいコンテナの名前に変更してください。

docker run --entrypoint=bash --rm -it --mount type=bind,src=$(pwd),dst=/home/dotfiles mcr.microsoft.com/vscode/devcontainers/typescript-node:0-12

コンテナのbashが起動したらコンテナの中でinstall.shを実行し、続けて. ~/.profile を実行することでVSCodeのRemote Containers起動手順を模倣できます。dotfilesはホスト側のディレクトリをマウントしているので、ホスト側で編集すればコンテナ内にも即反映されます。望みの状態になるまで各種ファイルを編集しながら読み込み直してデバッグしていきましょう。

もしもまっさらな状態からやり直したくなったときは一度コンテナから抜けます。—rmオプションを付けているので、再びdocker runするとまっさらな状態に戻っています。最終確認としてまっさらな状態から全てが正しく設定できているか確認するといいでしょう。

継続的デリバリーを2020年に読了した感想

業務で大規模なCI/CDパイプラインを構築することになったので、良書という話をよく耳にしていた継続的デリバリーを購入して読了しました。

普段は書籍を読んでもメモを残すことまではほとんどしないのですが、この本はかつて無いほど得るものが多かったので自分にしては珍しくメモを取りました。
自分と同じように大規模なパイプラインを設計することに迫られる人は世にそれほど多くないでしょうから、せっかくなので公開したいと思います。

全体まとめ

この本の構成は1章で重要なことをほぼ全て述べて、あとの章でそれらをさらに詳しく解説している。同じことを後の章で何度も繰り返すので、まさに「大事なことなのでN回言いました」を体現している。 そういうわけでこの本は1章に価値が詰まっている。言い換えると、2章以降は読まなくても何とかなる。ページ数が非常に多いので、自分が特に気になった事柄の章から読んでいけば良い。

この本の原著が書かれたのは2010年頃であり、紹介されているツールは2020年現在の今では名前も聞かなくなってしまったものが多い。時代としてはクラウドAWSが流行り始めたばかり、バージョン管理はGitよりまだSubversionの方が主流、VMは使われているがVagrantもまだ登場しておらず当然dockerも存在しない、構成管理のOSSはPuppetが主流の時代で、CI/CDもマネージドサービスはまだほとんど登場しておらずCluserControlやJenkinsが主流の時代(細かいところは微妙に違うかもしれない)。

それにも関わらず、この本で紹介されていることの多くは10年経った2020年においても非常に有用だと感じるものが多かった。当時は難しかった、あるいは高価だった方法も2020年現在ではクラウドOSSの発展により誰でも簡単かつ安価に実現できるようになったものも多い。10年も前にそれらを既に実用化させていた人たちがいたことには非常に驚いた。

自分が読み進めていて途中から感じたのは、この本で紹介している大規模なパイプラインにおいて重要ないくつかのことは2020年現在でも残念ながら多くのCI/CDサービスではサポートされていないこと。

ジョブ情報の可視化、デプロイはビルド済みのバイナリを使う、gated checkin、パイプラインの承認フローなど。自分の知る限り、これらの機能を全てフルセットで提供しているのはAzure Pipelineを除いて他にはない。Azure Pipelinesはあまり流行っている印象がないが、実は大規模なパイプラインにおいて必要なものを全方位でカバーしているすごいサービスだということを改めて発見した。

印象的だった項目

自分が読んでいて印象的だった項目のメモを残す。それぞれについて細かく解説はしないので、基本的にこの本を読んだことがある人 or 手元にある人を前提としています。

もしも自分のコメントで内容が気になった人がいたら、ぜひ購入してほしい。相当のページ数で分厚い本なので、個人的には電子書籍版を購入することをオススメする。

5.3.1 バイナリをビルドするのは1回限りとせよ

同じソースコードを再度ビルドして作られるバイナリは基本的に同一になるが(そうでなければならない)、デプロイのために再度コンパイルするのは時間がかかるし、それは厳密には受け入れテストを通ったバイナリとは異なるので異物が紛れ込むリスクを冒している。

バイナリはコミットステージでビルドされたものを保存しておき、再利用すればよい。


今日のCI/CDでこれを達成できている現場はどれぐらいあるだろうか?そもそもビルド→自動テストまでがせいぜい10分程度の小規模なパイプラインであれば、pull-reqとmasterにpushされたときの自動リリースもパイプラインはほとんどが共通で、最後にリリースをするかしないかの違いしかないことが多いと思う。

そもそも、現代のCIサービスで前にビルドした成果物を別のパイプラインで取り出して使うという工程をサポートしているものはほとんど無い気がする。これを公式にサポートしているのはAzure Pipelineのartifactsぐらいではないだろうか。

一方で、dockerやk8s界隈ではこの規則を守ったリリースフロー行っている話を聞くことがある。ビルドしたコンテナにコミットハッシュなど一意に特定できるtagを付けておき、そのコンテナをデプロイした検証環境で動作確認、確実に同じtagを指定して本番用のk8sにデプロイという流れ。

5.3.5 各変更は直ちにパイプライン全体を通り抜けなければならない

ビルドは1hごと、受け入れテストは夜中、キャパシティテストは週末のように異なるスケジュールを行ってはならないということ。

ビルドやユニットテストは短時間で終わる一方、受け入れテストは時間がかかるもの。そこで、ビルド1に対しての受け入れテストが終わるまでの間にビルド2,3,4まで終わっていた場合、次の受け入れテストはビルド4のものを使う。


これは図がないと説明が難しいが、今日ではCircleCIやBitriseなどで同ブランチへのpushがキューに積まれた場合、次回動くときに最新のコミット以外はキューに積まれたビルド予定が破棄されるというオプションが最も近い挙動。

これが目指すところは、イテレーションサイクルをなるべく短く保つことで、受け入れテストが失敗したときにどのビルドが原因だったのかを突き止めやすくすることが目的である。

Jenkinsでもあると便利そうなのだけど、これを実現するようなJenkinsプラグインの話を聞いたことがないので、簡単に実現することはできなさそう。

6.5.2 環境設定をテストする

ビルド環境やデプロイ先の環境が正しく設定されているかもテストする必要があるので、簡単なスモークテストなどを用意しましょうということ。

6.6.1 常に相対パスを使え

特に語る必要がない。絶対パスにするとビルドマシンのユーザー名などにも影響されてしまうし、Jenkinsにおいてはジョブはworkspaceのディレクトリ毎に隔離されているだけなので、相対パスを使わないと簡単に破綻してしまう。

6.6.5 テストが失敗してもビルドは続けよ

ビルドシステムはタスクが失敗した時点で全てを終了させるのがデフォルト挙動だが、これはもったいないのでそのタスクが失敗したという事実を記録して後段のタスクも実行しましょうということ。

例えば、ユニットテスト、インテグレーションテスト、スモークテストが存在した場合、ユニットテストが失敗した場合に、次にコミットされるまでインテグレーションテストが実行されないのは時間の無駄である。


というのが本の主張なのだけど、Lintはともかくユニットテストが落ちていたら後段のインテグレーションテストも落ちるはずなので実行するのはかえってビルドマシンの無駄使いな気もするが?

7.2.2 どういうときにコミットステージを止めるべきか?

コンパイルエラーやテストが失敗したときにビルドがfailになるのは当然だが、コード品質やテストのカバレッジが低い場合でもそれを通すのか?という問題。

例えばテストカバレッジが60%未満ならコミットステージとしてはfailさせ、80%未満なら通過はさせるがステータスは黄色にしておくという戦略がある。ただし、そのような理由でfailさせる場合には、チームの合意がちゃんと得られていないとかえって継続的インテグレーションが破綻してしまうことに注意。


今日ではGitHubと連携できるDangerや、Codecovのようなカバレッジサービスを使うことで簡単に実現できるようになっている。

7.3.1 成果物リポジトリ

テストレポートやバイナリといったコミットステージのアウトプットは後で再利用できるようにどこかに格納しておく。gitなどにコミットする方法もあるが、基本的に同じソースコードでビルドした場合には同じ成果物が得られるはずなのでわざわざコミットするのは無駄。


Jenkinsなどのインテグレーションサーバーは自前で成果物リポジトリの機能を提供しているので、基本的にはそれを使えばよい。Jenkinsにはfingerprintという機能があるので、1つのパイプライン上で前段の成果物を後段で間違いなく取り出すのは難しくなかったはず。

もう少し発展させると、Jenkinsを使う使わないに限らずS3やGCSなどのクラウドオブジェクトサービスを成果物リポジトリとして使うことも可能。Jenkinsに成果物を置くとディスクスペースを圧迫するので、容量に対して従量課金制のサービスを使うほうが現代では便利である。

もしくは、2018年頃からGitHub, Azure, GCP, AWSなどがこぞって各言語のプライベートパッケージリポジトリサービスを提供するようになったので、これらを活用するとさらに簡単に実現できるかもしれない。

8.5.2 プロセス境界・カプセル化・テスト

テストのためだけのバックドアはなるべく作らないべきであり、代わりにテストではスタブなどのテストダブルに置き換えるべき。

バックドアやテスト用コンポーネントはこまめなメンテが必要になってしまうし、本番にデプロイされないように注意する必要があるというデメリットがあるため。

8.5.4 テストダブルを利用する。

受け入れテストでも外部システムを使う部分などはなるべくテストダブルに置き換えるべき。自動受け入れテストはユーザー受け入れテストとは違う。

外部システムは「制御可能」なものでないので、テストは不安定になりがちでで外部システムに負荷をかけすぎてしまうこともある。あらかじめ、外部システムとの接合点をテストダブルに置き換えられるような設計とするべきである。

8.6.0 受け入れテストステージ

受け入れテストが失敗したらそれはデプロイしてはならない。受け入れテストが失敗した場合はすぐに原因を突き止めて修復する必要がある。

これを少し楽にする方法として、テスト実行中の画面を録画しておき、テストレポートと一緒に成果物として保存しておくと役に立つ。


今日ではSeleniumやモバイルのE2Eテスト基盤を提供するサービスの多くがテスト中の録画を提供してくれているので、そういったサービスを選択すればお手軽に達成できる。

8.6.2 デプロイメントテスト

受け入れテストを行う環境は疑似本番環境になるので、デプロイ戦略が正しく動作するかも確認することが可能になる。受け入れテストは一般的に実行時間が相当かかるので、このデプロイ確認もデプロイテストとしておき、デプロイテストが失敗した場合は受け入れテストの完了を待たずに終了させることで時間を節約できる。


これは一般的なwebサーバーなら可能な話。今日ではVMやDockerなど仮想化技術がかなり進んだので、デプロイテストを行うのも格段に楽になっているはず。

11.3.1 基盤へのアクセスを制御する

テスト環境も本番環境も、何か起きたときに誰がどういう変更を行ったのか追跡できないと原因の特定が難しいという話。もちろんテスト環境の変更については本番よりも簡単に申請が通るようになっているべきである。


この話題はJenkinsのビルドマシンあるある話。申請が必要なほどガチガチに固める必要はないと思うが、せめてどういう作業をしたのか記録は残してほしい。

11.3.2 基盤へ変更を加える

変更はチケットで管理して、変更内容もあとからログで監査できるようにしておく。デプロイの記録は残し、まずはテスト用の疑似本番環境でテストしてから壊れないことを確認する。

変更はPuppetなどのツールで行い、変更のためのスクリプトはバージョン管理をするべき。


ビルドマシンへのAnsibleの適用でどのマシンにどこまでの変更を適用したかは問題になることがたまにあるが、頭で覚えていてなんとか運用している状態に近いので耳が痛い。そもそもAnsibleはTerraformなどのように状態に収束させるものではなくてあくまで単なるスクリプトなので、「設定を入れる」場合は普通に書けばいいが、「設定を外す」場合も外すための処理をわざわざ記述してから実行する必要があるので本当にめんどくさい。

やはりデプロイ先の環境は毎回まっさらな状態からプロビジョニングを行う、あるいはVMやコンテナなどの仮想環境である方が圧倒的に楽。

11.4.2 進行中のサーバー管理

新しいマシンが来たら構成を勝手に変えられることがないように、まず管理者以外のログインを制限してから自動化スクリプトでセットアップを行う。あとはPuppetなどのツールが各マシンを管理下において変更が適用済みの状態かどうかを監視してくれる。


自分は使ったことがないが、現代だとAnsible Towerなど中央集権的にスクリプトを実行したり監視の役割もしてくれるのかも?自前でやるのであれば、Serverspecみたいなマシンの状態をテストするためのスクリプトを用意してデプロイ後やcronなどで定期的に各マシンがちゃんとスクリプトで定義された意図した状態になっているかどうかの確認を行う方法ができそう。

11.5.4 設定APIを探せ

DBなどのミドルウェアをテスト用に自動で設定する方法の選択肢の文脈。理想のミドルウェアはバイナリではないテキストフォーマットの設定ファイルで全てが設定できるタイプだが、それができない場合の妥協案として設定APIさえ存在するのであれば設定DSLを自前で作ることにより、設定ファイルによる構成管理を自分の手元に取り戻すことができる。


今日ではAnsibleやTerraformなどがこの手のアプローチによってAWSGCPの構成管理をそれぞれのDSLで書くことができるようになっている。

11.7.1 仮想環境を管理する

仮想マシンのイメージが単一ファイルで扱えることの利点の話。稼働中のVMを簡単にコピーできることだけが利点ではなく、ツールによっては物理マシンのスナップショットをディスクイメージに変換することも可能なので実際の本番環境のスナップショットをVM化することで継続的インテグレーションやテストに使うことができる。

他にもいくつか利点がある。

  • 新しい環境のプロビジョニングとしてVMをそのまま使える
  • VMに対して自動でソフトウェアのインストールや設定をした後の状態をさらにテンプレートVMとして保存して使い回せる
  • 手動で設定されてしまっている環境をまずはVM化することで、少しずつ自動構築にシフトする過程の中でまずは動くことが保証されている状態からスタートできる
  • インストールや設定が自動化できないソフトウェアもVM化することで、設定済みの状態を複製することが可能になる

今日では、GitHub ActionsBitriseなどのCIサービスが実行環境として提供しているVMがこの類の話を上手く活用している。様々な言語が使用するツールやSDKが全部入りの状態で保存されたVMを提供してくれているので、ユーザー側はたいていの場合において各ツールのインストールから行う必要はない。

特にBitriseにおけるmacOSのVM作成アプローチはとても効率的で、macOSVMにおそらくは手動でXcodeをインストールした状態をベースイメージとして保存しておき、そのVMに対してAnsibleでその他のツールをインストールした完成形イメージをユーザーに提供している。XcodeAppleが公式のインストールツールを提供していないせいで自動インストールを行うのは大変な労力を伴うので、そこだけ手動でインストールを行うのは完全に理にかなっている。

12.4.2 アプリケーションのデプロイをデータベースのマイグレーションから分離する

アプリケーションのバージョンを上げる際に、DBスキーマと深く関係していると両方を同時に更新する必要があるがそうすると両方を共にロールバックさせるのが難しくなるのでその対策の話。

方法としては、アプリケーションのコード側で新旧のDBスキーマ両方で動作するように対応をしておき、アプリケーション→DBスキーマの順番でデプロイをするというもの。アプリケーションをデプロイした時点でなにか問題があればアプリケーションだけをロールバックさせればよい。アプリケーションが安定したと判断できればDBをデプロイする。

アプリケーションとDBのデプロイを分割する方法は、ノーメンテでアプリケーションのバージョンを上げようとするときにも使われる方法かもしれない。自分が過去に経験したプロジェクトでは逆順のDB→アプリケーションでだったような記憶があるが、やりたいことや思想は同じはず。

12.5.3 テストの分離

個々のテストがお互いに影響を与えないように保証する、これをDBを使う場合でも実現する方法。RDBMSを使っているのであれば、トランザクション機能を使うのがもっとも簡単。

テスト開始時点でトランザクションを作成し、テスト中のDB操作は全てトランザクション内で行い、テストが終わったときにトランザクションロールバックする。これだけで他のテストがお互いのDBに影響を与えないようにできる。

別の方法としては、何からかの機能や規則を使ってテスト毎のデータを分離すること。


これは、この手のテストDBの分離に関する発想があるかどうかとそれを見越したアプリケーション設計になっているか次第。

RDBMSを使っていても1リクエスト中で複数回トランザクションをしている場合は何らかの工夫が必要だろう。そもそもトランザクションをサポートしていないNoSQLなどを使っている場合はデータベースそのもののインスタンスを分離するとか、テーブル名で分離できるようなコード設計になっているかなどアプリの初期設計から考慮できていないと実現は難しい。

13.2.3 抽象化によるブランチ

ブランチを切るということは本質的に継続的インテグレーションを難しくするという文脈において、ブランチを使わないアプローチの話。

ブランチを切って大規模にコードを改変させる例としてリファクタリングがある。ブランチを切らずに行う場合は、まず最初に抽象レイヤを作成し、その機能を呼び出す別のコードを抽象に依存させる。そしてリファクタリングを施す新しい実装を裏で作成しておき、完成したときに抽象レイヤを書き換えて新しい実装を使うように変更するという方法。


今日ではこの話はいわゆる「feature flag」や「feature toggle」の考え方に使われているように思う。この手の方法を実現するために最も簡単なものはIF分岐だが、よりスマートな方法を考えると自然にこの抽象レイヤの手法にたどり着くと思う。

「feature flag」が必要になってきた理由も、ブランチを切って大規模開発を行うとマージ作業のコストや検証コストが上がるためいっそ1ブランチで運用するというものだったと記憶しているので、まさにこの本に書かれているブランチを切るコストの話と一致している。

13.5.4 慎重な楽天主義

あるビルドの依存物は、それもまたビルドが必要でなにかに依存している。つまりA → B → Cのような依存グラフを作ることができる。それぞれの依存物のバージョンが固定可能だとして、どうやって安定したビルドとバージョンアップを両立させるかという戦略の話。

依存関係にstaticとfluidというラベルを分ける。staticは常にバージョンが固定されている。一方でfluidはバージョンが流動的なので常に変わる。A(static v1) & B(fluid v2) → Cという関係でビルドされるとき、仮にBがv3に上がったトリガーでビルドされた場合にビルドが失敗するとBは"guarded"というラベルに変化し、v2で固定される。guardedは前のバージョンであればビルドに成功するが、新しいバージョンだと何かが原因でビルドに失敗するということなので早急な調査が必要という意味になる。


概念としてはややこしいが、今日では割と一般的な概念になっている気がする。各言語のパッケージ管理ツールでは一般的に依存のバージョンを"=", "≥"などの等号・不等号で固定化したり流動的に変えたりしている。あるいはgitのsubmoduleで依存リポジトリのコミットハッシュを固定するか、submoduleを使わないなどの方法で常に最新のコミットを取ってくるかなど。

ただ、CIと連動して"guarded"の概念まで実現できているツールやサービスは見たことがない。強いて言えば、DependabotRenovateのように依存パッケージを自動でメンテしてくれるツールと、package-lock.jsonのように言語側で依存バージョンを固定するツールが提供されていると"gurarded"の概念に近いことは達成できているように思う。

v7をリリースしたので改めてfirestore-simpleを紹介します

firestore-simpleの過去のエントリ

以前から作り続けている、Firestoreを使いやすくするためのラッパーであるfirestore-simpleがv7になりました 🎉 (v6は事実上の欠番扱い)

firestore-simple

v7で以前から実現したかったjsのweb SDKに対応できました。admin/web SDKの両方に対応できたのは1つの節目だと感じているので、今回の修正点に加えて、そもそものfirestore-simpleのコンセプトを再紹介しようと思います。

BREAKING CHANGES

パッケージが今までのfirestore-simpleから、admin SDK用の@firestore-simple/adminとweb SDK用の@firestore-simple/webと2つに別れました。今までのfirestore-simpleはここでDEPRECATEDになります。

もしv6までのfirestore-simpleをお使いだった方は 、以下のようにマイグレーションをお願いします。

npm install @firestore-simple/admin
// old
import { FirestoreSimple } from 'firestore-simple'

// new
import { FirestoreSimple } from '@firestore-simple/admin'

追加機能

firestore-simpleは今までadmin SDKしか対応していなかったのですが、v7からweb SDKにも対応しました。これでadmin SDKを使ってFirestoreを使うサーバーやCloud Functions環境に加えて、クライアントからFirestoreにアクセスするコードでもfirestore-simpleを使うことができるようになりました。

インストールとimportは以下のようになります。

npm install @firestore-simple/web
import { FirestoreSimple } from '@firestore-simple/web'

exampleを見てもらえると分かるのですが、web SDKの方もAPIは基本的に今までのadmin SDK用のfirestore-simpleと全く一緒になっています。

ただし、web SDKの一部のメソッドが持つadmin SDKには存在しないオプションにまだ対応できていないところがあります。例えばget()が受け取れるGetOptionsなどです。これらは今後対応していく予定です。

改めてfirstore-simpleの機能紹介

ここまで細々と約2年ほど作り続けてきたので、v1から比べるとjsからTypeScriptベースに変更したこともあり昔のコードは全て置き換わりました。ですが「Firestoreをよりjsから扱いやすくする」というコンセプトは変わっていません。

v1から比べるとアピールしたいポイントもいくつか変わりましたので、改めてfirestore-simpleの代表的な特徴を紹介します。

データの取得と更新を簡潔に

素のFirestoreで1つのドキュメントを追加したり、データを取得する場合、このようなコードになります。

const id = await firestore.collection('users').add({ userId: 'alice' })

const doc = await firestore.collection('users').doc(id).get()
const data = doc.data()

正直、1つのドキュメントを追加したり、取得するために毎回このコードを書くのは面倒くさいと感じています。加えて、毎回collectionを指定する必要があるためコレクション名をtypoしてしまうだけで容易にバグとなります。

さらに、この短いコードの中だけでCollectionReference, DocumentReference, DocumentSnapshotという3つのFirestoreのクラスが登場しており、慣れるまでは複雑に感じるでしょう。ちなみに、data()のメソッドを持っているのはDocumentSnapshotですね。

これらに対して自分が初期に取ったアプローチは、以下のようにコレクション毎にラッパークラスを作ってメソッド1つでデータを取得できるようにしていました。

class UserCollection {
  constructor() {
    this.collection = firestore.collection('users')
  }
  async fetch(userId) {
    const docRef = await this.collection.doc(userId).get()
    return docRef.data()
  }
}

const userCollection = new UserCollection()
const user = await userCollection.fetch('alice') // id='alice'を渡すだけ

このようなクラスを自前で用意すると、CollectionReference, DocumentReference, DocumentSnapshotの区別を意識することなく、単純にdocumentのidだけを渡して中のデータを取得することが可能になります。実にシンプルです。

firestore-simpleはまさにこれを汎用化したものであり、このようなクラスをコレクション毎に自作する必要がなくなります。先述のコードと同等の機能はfirestore-simpleだとこのようになります。

const firestoreSimple = new FirestoreSimple(firestore)
const userCollection = firestoreSimple.collection<User>({ path: 'users' })

const id = await userCollection.add({ userId: 'alice' })
const user = await userCollection.fetch(id)

ActiveRecord風でははない

Firestoreに限らずDBのORMでよく見るのはActiveRecordのパターンだと思います。擬似コードですが、以下のようなイメージです。

const user = new User({ userId: 'alice' })
user.age = 20
await user.save() // ここでDBに書き込まれる

const alice = await User.find({ userId: 'alice') // DBからデータ取得

今どきでは非常にありふれたORM風のAPIですが、firestore-simpleでは2つの理由からあえてこのようなAPIにはしていません。

1つ目は単純に素のFirestoreのAPIから離れすぎないようにしたかったからです。素のFirestoreから使い方が離れすぎてしまうと何か問題が起きた場合にコードを追いにくくなってしまうため、あくまで薄いラッパーとなるように意識しています。

2つ目は、js界隈においては何か値を入れておく入れ物として、単なるObject({ foo: bar} のような形)が好まれていると感じているからです。TypeScriptもいかにこの単なるObjectに対して型を付けて扱うか、という点を重視している言語です。ActiveRecordパターンはクラスとインスタンスによって実現されるものであり、この思想と真逆であるためfirestore-simpleではそのようなAPIを提供していません。

強力な型推論と補完

Firestoreの微妙な点として、TypeScriptで使うときにCollectionReferenceやDocumentReferenceなどのFirestore自体には型が付いていますが、取得してきたドキュメントには型が一切付いていません。ほとんどの方はdoc.data() as Userなどと自分で型を付けているのではないでしょうか。

firestore-simpleではコレクションを定義するときにドキュメントの型を渡すことで、fetch()したドキュメント自動的に型が付きますし、add()などの更新系メソッドも型が正しいかチェックしてくれます。

const userCollection = firestoreSimple.collection<User>({ path: 'users' })
const user = await userCollection.fetch(id)
// userは自動的にUser型となる

実は、昨年Firestoreに追加されたwithConverter()を使うことで、素のFirestoreでも自動的に型が付くようになりました。

firestore-simpleはそれに加えて型情報を利用した補完が強力であり、where()update()もドキュメントのキーを補完してくれます。これについてはv4をリリースしたときの記事が詳しいので興味がある人は見てみてください。どのように補完されるのか、v4の記事からGIF動画こちらにも貼っておきます。

where_update.gif

batch系のAPIの使い勝手を向上

さらに素のFirestoreの微妙な点として、TransactionやBatchのAPIの使い勝手の悪さが挙げられます。

通常はdocumentReference.get()なところがTransactionの中ではtransaction.get(documentReference)APIが別物になってしまっているのです。これはBatchの方でも同様です。

さらに、そもそもなぜかTransactionとBatchで使い方が全く異なっています。

// 素のFirestoreを使ったコード

firestore.runTransaction(async(tx) => {
  // Transaction内での処理
}

const batch = firestore.batch()
// set(), update()などbatchでまとめて行いたい処理
await batch.commit()

firestore-simpleではbatchもTransactionと同じ使い勝手を実現するrunBatch()というメソッドを独自に追加しました。

// FirestoreSimpleを使ったコード

await firestoreSimple.runBatch(async (_batch) => {
  await userCollection.set({ userId: 'alice' })
}) // <- runBatchを抜けるタイミングで自動的にbatch.commit()される

お気づきかもしれませんが、素のFirestoreであればbatch.set()と書く必要があるところも、通常通りのset()の呼び出し方でOKです。firestore-simpleを使ったときのrunTransaction()も同様の挙動となるように改良しています。通常状態、Transaction内、Batch内のそれぞれの状態に応じてfirestore-simpleがメソッドを裏で呼び分けてこのような隠蔽を実現しています。

さらに、単に1つのコレクションに対して配列でまとめてadd, setしたいだけというユースケースに便利なbulkAdd(), bulkSet()を用意しました。逆にidの配列を渡すと複数のドキュメントをまとめて削除してくれるbulkDelete()もあります。

await userCollection.bulkAdd([
  { userId: 'alice' },
  { userId: 'bob' },
])

await userCollection.bulkSet([
  { id: '1', userId: 'alice' },
  { id: '2', userId: 'bob' },
])

await dao.bulkDelete(['1', '2'])

TransactionやBatchについてはv5をリリースしたときの記事サンプルコードにもう少し詳しい内容がありますので、気になった方はぜひ見てみてください。

テストはFirestoreのエミュレータを使用

v6まではテストを実行するときに本物のFirestoreを使用していましたが、v7でweb SDKとadmin SDKの両方でエミュレータを使用するように変更しました。本物のFirestoreでは特にonSnapshot系のテストの不安定さに悩まされてきましたが、エミュレータを使うことでだいぶテストが安定しました。

エミュレータによるテストへの置き換えは、CIでの実行方法も含めていくつかワークアラウンドな対応が必要だったので苦労しました。Firestoreのエミュレータを使ったテストに興味がある方はGitHubで公開されているコードのpackage.json__tests__/util.tsなどが参考になると思います。

今後の展望

v7で追加したweb SDKもほとんどの機能は対応済みですが、冒頭の方で書いたように一部のメソッドでadmin SDKには存在しなかったオプションが未対応なので、次のバージョンではこれを利用可能にします。

本家Firestoreが何かしら新機能を発表したらそれらも対応していきたいと考えていますが、そろそろ前々から欲しいと思っていたAPIドキュメントも整備していきたいと思っています。TypeScriptならいい感じのドキュメントが自動生成できるはず・・・?

FirestoreやFirebase自体がまだまだ進化のスピードが落ちていないため、firestore-simpleも引き続き開発・メンテをしていく予定です。firestore-simpleをぜひ使ってみてください!

CircleCI Orbsの美味しいところだけを使おう 〜例えばrestore_cacheを撲滅する〜

はじめに

CircleCI 2.1になってOrbsという新機能が登場しましたが、みなさん活用しているでしょうか? 先駆者の方々がOrbsについてのブログを早速書いてくれていますが、その多くは自分でOrbsを作る記事でした。

Orbsは自分で作成するだけでなく、CircleCI公式や誰かが作成して公開したOrbsも使うことが可能ですが、そのようなサンプルコードや解説記事がまだまだ少ない状況です。実際に自分がCircleCI公式のOrbsを使用しようとしたときにサンプルが少なくて困ったので、自分でOrbsを作らなくても使うだけで十分という人向けに解説をします。

Before & After

まずはサンプルを紹介します。BabelかTypeScriptでnpm run buildを実行するというjs開発では日常的に見られるconfig.ymlです。

version: 2
jobs:
  build:
    docker:
      - image: circleci/node:latest
    working_directory: ~/repo
    steps:
      - checkout
      - restore_cache:
          keys:
          - v1-dependencies-{{ checksum "package.json" }}
          - v1-dependencies-
      - run: npm install
      - run:
          name: build
          command: npm run build
      - save_cache:
          paths:
            - node_modules
          key: v1-dependencies-{{ checksum "package.json" }}
workflows:
  version: 2
  build:
    jobs:
      - build

これが、Orbsを使うとこのように書けます。

version: 2.1
orbs:
  # https://circleci.com/orbs/registry/orb/circleci/node
  node: circleci/node@0.0.8 # circleci/nodeのOrbsを`node`という名前で参照する
jobs:
  build:
    executor:
        # nodeのOrbsによってimage: 'circleci/node:latest'と同じになる
        name: node/node
        tag: "latest"
    working_directory: ~/repo

    steps:
      - checkout
      - node/with-cache:
          # デフォルトは~/project/node_modulesをキャッシュするのでworking_directoryを変更した場合はそれに合わせる
          dir: ~/repo/node_modules
          cache-version: v1
          steps:
          - run: npm install
          - run:
              name: build
              command: npm run build
workflows:
  version: 2
  build:
    jobs:
      - build

Orbsによってrestore_cache, save_cacheが隠蔽されているのが分かるでしょうか。
キャッシュ周りについてはCircleCIのサンプルコードをコピペしてるだけの人が大半ではないでしょうか。本当は暗黙的にキャッシュして欲しいぐらいですが、CircleCI 2.0からはキャッシュコントロールも含めて全てユーザが面倒を見る仕様となったため、みんな面倒くさいと思いつつ毎回コピペするしかありませんでした。

Orbsは再利用可能なパーツを提供できる仕組みであり、自分で作らずとも誰かが作ったOrbsを使わせてもらうことでコピペコードを減らすことができます。

Orbsを知る

npm installのキャッシュ周りだけをOrbsで解決する方法を検索して記事に辿り着いた方は先程のサンプルコードだけで十分かもしれません。
ここからはOrbsを使うために知っておくべき要素を解説します。

OrbsはCommands, Executors, Jobsで構成されています。3つを全て含む必要はなく、Commandsだけで構成されているOrbsもたくさんあります。では実際にドキュメントを見ながらそれぞれについて理解していきましょう。
https://circleci.com/docs/2.0/using-orbs/

Commands

Commandはstepsの集まりです。そしてstepsはrunとかsave_cacheなどのことです。
workflowsで複数のjobに分割していると同じコマンドのコピペだらけになることが多いですが、同じコマンドを繰り返している場合は一連の流れがCommandとして登録されているOrbsを使うことでコピペコードを撲滅できます。

Orbsの中にはパラメータを受け取り、挙動を変えることができるものも存在します。例えば、先程のサンプルのwith-cacheではdir, cache-version, stepsがパラメータです。
ちなみにCommandの中にstepsを入れ子にすることも可能で、with-cacheはそれをうまく活用したものとなっています。詳しくは後述します。

Jobs

CommandとExecutorが一緒になったものがJobです。実はこれは見慣れたものであり、2.0から意識せずとも定義しているjobsの中で実行環境のdockerイメージとstepsを書いているはずです。

workflowsでjobsに定義したものを使用しているのと同じようにして、Orbsで定義されたjobsもまたworkflowsの中で使うことが可能です。

Executors

見慣れない単語ですがこれも実は新しい概念というわけではありません。今までjobsの中でdockerイメージを指定していたものがExecutorです。

個人的にはExecutorは実はOrbsの中で結構重要なものではないかと思います。基本的コピペコードを撲滅するためにOrbsを使うことが多いと思いますが、Orbsの中で定義されているCommandを実行できるかどうかは実行環境に依存しています。

例えば、npmはnodejsが動く環境でしか使えませんし、bundlerrubyが動く環境でしか使えません。npmbundlerであればDocker Hubから公式のイメージを探せばOKでしょうが、例としてHerokuに使用するherokuコマンドを扱うOrbsを見つけたとして、その実行環境にはどのdockerイメージを使うべきでしょうか?

このように、実はCommandだけでは片手落ちなのです。Executorが用意されているOrbsでは作者がCommandの実行に必要な環境を指定しているはずですので、Orbsが提供しているExecutorを指定すれば実行環境について考える必要はなくなるでしょう。

また、Orbsが対応していればExecutorにもパラメータを渡すことが可能です。パラメータをdockerイメージのタグ指定に使うことで、言語やOSのバージョンをある程度コントロール可能にしているものが多いようです。

実際に外部製のOrbsのドキュメントを読んでみよう

では実際にcircleci/node使い方をドキュメントから読み解いてみましょう。

Commands

執筆時点でのバージョン0.0.8ではCommandsが3つありますが、サンプルでも使用したwith-cacheを例に解説します。

f:id:Kesin:20190226003809p:plain
https://circleci.com/orbs/registry/orb/circleci/node

まず、stepsがREQUIREDになっているため、このパラメータは必須であることが分かります。そして、DESCRIPTIONによると、Nodeのキャッシュを使いながら実行するステップを指定するようです。

次にdir, cache-key, cache-versionを見ると、デフォルト値がそれぞれ以下のようになっています。

PARAMETER DEFAULT
dir ~/project/node_modules
cache-key package.json
cache-version v1

ちなみに、Orbsを使う前のconfig.ymlではsave_cacheステップはこのようにしていましたね。これを見ると、これらのパラメータはおそらくsave_cacheのステップで使用する変数をコントロールするものだと推測ができます。

- save_cache:
  paths:
    - node_modules
   key: v1-dependencies-{{ checksum "package.json" }}

では本当に想定した挙動かどうかを確認します。"Show Command Source"と書かれているリンクをクリックするとOrbsの実際のyamlが表示されるので見てみましょう。
まず、細かいパラメータを省略すると全体の流れはこのようになっています。

steps:
  - when:
      condition: << parameters.use-strict-cache >>
      steps:
        - restore_cache:
  - unless:
      condition: << parameters.use-strict-cache >>
      steps:
        - restore_cache:
  - steps: << parameters.steps >>
  - save_cache:

use-strict-cacheのwhen-unlessを一旦横においておくと、restore_cache -> 任意のsteps -> save_cacheという流れになっていることが分かります。つまり、stepsパラメータにキャッシュ周り以外のnpm installnpm run buildといったstepを渡せばいい感じにキャッシュを扱ってくれそうな感じがします。

次にsave_cacheの定義を見てみましょう。keysの文字列を読み解くと、cache-version, ブランチ, cache-keyチェックサムの3つの組み合わせでキャッシュを判定し、dirディレクトリをキャッシュ対象とすることが分かります。

- save_cache:
    key: >-
      node-deps-<< parameters.cache-version >>-{{ .Branch }}-{{ checksum "<<
      parameters.cache-key >>" }}
    paths:
      - << parameters.dir >>

どうでしょうか、ざっと見ただけでもwith-cacheがどのような挙動をするのか大体つかめたのではないかと思います。

Executors

バージョン0.0.8にはExecutorが4種類あります。おそらくdefaultが標準として用意されているものなのでこちらを解説といきたいところですが、実際にコードを見てみるとnodeと全く同一なのでnodeを例にします。

"Show Executor Source"をクリックすると以下の実際のコードが表示されます。非常にシンプルになっており、dockerイメージにはcircleci/nodeを使用し、タグの指定がtagパラメータで可能なことが分かります。 デフォルト値はlatestです。つまり、自分のconfig.yml上でこのOrbsのExecutorを使用すると、実際にはcircleci/nodeのdockerイメージが使用されるということが分かります。

parameters:
  tag:
    type: string
    description: Pick a specific circleci/node image variant.
    default: latest
docker:
  - image: 'circleci/node:<< parameters.tag >>

ここまでドキュメントから読み解いた挙動を元に、再び最初に示したOrbsを使用したサンプルコードを見直してみてください。Orbsの概念、そして使い方もそれほど難しいものではないことが分かるかと思います。

玉石混交のOrbsの中から玉を探す方法

ここまでの解説によって自分でOrbsを作らずとも、外部製のOrbsを活用する方法が理解できたと思います。
次は実際に必要なOrbsを探すことになるのですが、今はOrbsが公開されたばかりということもあって少し見ただけでも以下のように玉石混交の状況です。

  • CircleCI謹製
  • かなり本気で作成したコミュニティ製
  • とりあえずやってみましたレベル

自分が使いたいコマンドで検索して候補が複数存在した場合、あくまで私見ですが以下の点で優れているものを選ぶと良いと思います。

指定可能なパラメータが多いもの

パラメータが多い = 挙動を細かくコントロールできるということ。
自分が使いたいコマンドのオプションをコントロールできるパラメータが存在しない場合、Orbsを使えないことになってしまいます。最初から望む細かい挙動などがあるのならばちゃんとOrbsのコードを確認しておきましょう。

例えば、with-cacheはパラメータの数が豊富だったのでキャッシュ周りで困ることはなさそうですね。

Orbsを利用することでコード量の減少が見込めるもの

例として紹介したwith-cacheはCommandにstepsを渡せるという仕様をうまく活用することでrestore_cache, save_cacheのコードを減らしています。さらに、キャッシュという本来あまり気にしたくはない工程を隠蔽してくれる点で非常に優れていると思います。

他には、パラメータのデフォルト値として妥当なものが設定されているかどうかも重要でしょう。パラメータの数は多いほうが良いと先述しましたが、デフォルト値が微妙だと自分で全て指定することになってしまいます。

executorとなるdockerイメージのタグを柔軟に選択可能なもの

例えば、executorで指定されたdockerイメージがcircleci/node:10とタグが固定されてしまっていた場合、どうなるでしょうか?
その場合、新しいNode.jsのバージョンが将来公開されたとしても、circleci/node:11を指定できないため古いNode.jsを使用し続けるしかなくなってしまいます。

そのOrbsのバージョンが上がることでExecutorsが更新されて新しいNode.jsに対応する可能性もありますが、その場合はExecutors以外にJobsやCommandsに非互換で破壊的な変更が入る可能性も有り得るでしょう。

Executorのパラメータを使ってある程度柔軟にdockerイメージを指定できるOrbsであれば、Orbsのバージョンを固定してもパラメータを更新するだけで新しいdockerイメージを使うことができます。

まとめ

CircleCI 2.1から登場したOrbsはまだサンプルコードもあまりない状況です。そのような状況ですのでドキュメントを見るだけで使い方を読み解けるようにOrbsの使い方を解説しました。

この記事でOrbsの概念とドキュメントの読み方は理解できたと思いますので、ぜひOrbsのドキュメントやコードを読み解きながら活用してみてください。

Firestoreをもっと手軽に使えるfirestore-simpleがバージョン2になりました

追記: v4についてのエントリはこちら

この記事はFirebase Advent Calendar 2018 3日目の記事です

今年の6月にfirestore-simpleというjsからFirestoreを使うときに使いやすくするモジュールをリリースしていました。

Firestoreをもっと手軽に使えるfirestore-simpleを作った
GitHub - Kesin11/Firestore-simple: A simple wrapper for Firestore

リリース後、自分の趣味プロジェクトで使い続けていたのですが、TypeScriptの型付けが中途半端で惜しくも今ひとつ使いにくい感じでした。そこでTypeScriptの型についてちゃんと勉強をして、Genericsなどを活用してもっといい感じに型を扱えるように改良したバージョン2をリリースしました。 🎉

サンプルコードはこんな感じです。ぜひFirestoreのドキュメントと見比べてみてください。素のFirestoreよりもかなりシンプルなコードで書けることが分かると思います。

# install
$ npm i firestore-simple
// 以下のコードはTypeScriptです
const firestore = admin.firestore()
firestore.settings({ timestampsInSnapshots: true })

interface User {
  id: string,
  name: string,
  age: number,
}

const main = async () => {
  // Firestoreからフェッチしたときにマッピングしたい型をGenericsに指定する
  // pathはFirestoreのコレクションパス
  const dao = new FirestoreSimple<User>({ firestore, path: 'user' })

  // addの戻り値はFirestoreによって付与されたidを含むUser型
  const user: User = await dao.add({ name: 'bob', age: 20 })
  console.log(user)
  // { name: 'bob', age: 20, id: '3Y5jwT8pB4cMqS1n3maj' }

  // fetchの戻り値の型は User | undefined
  let bob: User | undefined = await dao.fetch(user.id)
  console.log(bob)
  // { id: '3Y5jwT8pB4cMqS1n3maj', age: 20, name: 'bob' }
  if (!bob) return

  // update
  bob.age = 30
  bob = await dao.set(bob)

  // add or set
  // idがあればset、なければadd
  let alice: User = await dao.addOrSet({ name: 'alice', age: 22 })
  console.log(alice)
  // { name: 'alice', age: 22, id: 'YdfB2rkXoid603nKRX65' }

  alice.age = 30
  alice = await dao.addOrSet(alice)
  console.log(alice)
  // { name: 'alice', age: 30, id: 'YdfB2rkXoid603nKRX65' }

  // delete
  const deletedId = await dao.delete(bob.id)
  console.log(deletedId)
  // 3Y5jwT8pB4cMqS1n3maj

  await dao.delete(alice.id)

  // `buldSet`と`bulkDelete`はWriteBatchを使いやすくしたラッパー
  const _bulkSetBatch = await dao.bulkSet([
    { id: '1', name: 'foo', age: 1 },
    { id: '2', name: 'bar', age: 2 },
  ])

  // コレクションに含まれるデータを全てfetch
  const users: User[] = await dao.fetchAll()
  console.log(users)
  // [
  //   { id: '1', name: 'foo', age: 1 },
  //   { id: '2', age: 2, name: 'bar' },
  // ]

  // fetch by query
  const fetchedByQueryUser: User[] = await dao.where('age', '>=', 1)
                                .orderBy('age')
                                .limit(1)
                                .get()
  console.log(fetchedByQueryUser)
  // [ { id: '1', name: 'foo', age: 1 } ]

  // multi delete
  const _deletedDocBatch = await dao.bulkDelete(users.map((user) => user.id))
}
main()

コンセプト

firestore-simpleのコンセプトはFirestoreをよりjsらしく扱いやすくするというものです。

そもそも作った動機は、素のFirestoreのAPIは単にgetやsetするだけの単純な用途であっても、jsから使いづらいところがあったからです。毎回ラッパーを書くことになりそうだと思ったので、ならば最初にシンプルなAPIで汎用的に使えるものを作ろうと思いました。 このあたりの詳しい内容はバージョン1をリリースしたときのエントリを見てもらえればと思います。

競合との比較

競合として、vue-firestoreや、pring.tsなどが既にあります。

vue-firestoreは特定のライブラリと密結合しているのが個人的には好みではないのと、Cloud FunctionsのようなVue.jsの外の世界では結局使えないところが自分のニーズと合いませんでした。

pring.tsはサンプルコードを見た感じではAPIが簡潔で自分がやりたかったことに近かったのですが、最初にクラスの中でアノテーションを付けてプロパティを定義する方法がSwiftやJavaっぽくて自分のjs感とマッチしませんでした。

firestore-simpleはFirestoreの薄いラッパーであり、メインの機能はFirestoreから取得したドキュメントをjsのプレーンなオブジェクトにするだけです。そのまま使っても便利ですが、いわゆるModelやRepositoryに組み込んで生のFirestoreの代わりに使うのも便利だと思います。自分のように疎結合なクラス設計をしたい人向けだと思います。
他のライブラリと密結合していないため、ブラウザ上のjsからFirestoreを使う場合、nodejsのAdmin SDK、Cloud Functionsのいずれでも利用可能です。

バージョン2で大きく変わったポイント

Genericsを活用して型を扱いやすくしたところがバージョン2の目玉の変更点ですが、それに加えてAPIの破壊的な変更を含めた変更点も紹介します。

1. TypeScriptでGenericsを活用した型情報が付けられるようになった

TypeScriptからFirestoreを使うときに辛かったのが、Firestoreからfetchしたときに型情報が付かないことでした。そのため、fetchしたオブジェクトは即座にクラスやインターフェースにマッピングして型を付けていたのですが、毎回書くのはとてもめんどくさいことでした。

バージョン1ではfirestore-simpleを使ってfetchしてもオブジェクトにidというプロパティが存在していることだけを保証するという中途半端な型情報しかなかったのですが、バージョン2ではGenericsを活用することでfetchしたときに自動的に型情報が付くようになりました!

interface User {
  id: string,
  name: string,
  age: number,
}

const dao = new FirestoreSimple<User>({ firestore, path: 'user' })

// 戻り値はUser型
const user = await dao.fetch('bob')

Firestoreからfetchした結果をクラスにマッピングせず、単にオブジェクトに型が欲しいだけであればこれだけです。インターフェースの型をGenericsで指定するだけで済み、簡潔に書くことができます。

2. Firestoreに保存、Firestoreから取得するときのマッピング処理を自由に書けるようになった

バージョン1ではjs <-> Firestoreを行き来するときのプロパティ名の変換ルール(created_at <-> createdAtなど)だけは定義しておくことができました。バージョン2ではFirestoreに保存するタイミングをencode, Firestoreから取り出すタイミングをdecodeとして、ここにマッピングの処理を自由に書けるようになりました。

class Book {
  public id: string
  public title: string
  public created: Date

  constructor ({ title, created }: { title: string, created: Date }) {
    this.id = title
    this.title = title
    this.created = created
  }
}

const dao = new FirestoreSimple<Book>({ firestore, path: 'user',
  // Firestoreにadd, setするタイミングで呼ばれる
  // 戻り値のオブジェクトのプロパティでFirestoreに保存される
  encode: (book) => {
    return {
      id: book.id,
      book_title: book.title, // Firestoreではbook_titleというプロパティ名になる
      created: book.created,
    }
  },
  // Firestoreからfetchするタイミングで呼ばれる
  // decodeの戻り値の型はGenericsと一致している必要がある
  decode: (doc) => {
    return new Book({
      // Firestore上ではbook_titleというプロパティ名だったが、Bookではtitleというプロパティ名に変換
      title: doc.book_title,
      // Firestoreの仕様上、Dateで保存していた値はfetchしたときにFirestoreのTimestamp型になっているのでDate型に変換
      created: doc.created.toDate(),
    })
  },
})

// マッピング処理がencode/decodeで完結するので実際の処理がシンプルになる
const book = new Book({ title: 'foobar', created: new Date() })
await dao.set(book)
await dao.fetch(book.id)

注意点としては、encodeのときにidはオプショナルで、逆にdecodeのときはidを必須にしていることです。 これは、addするときにはidが必須ではないですが、逆にfetchするにはidが必須であるためです。

このidにまつわる型付けは型プログラミングで頑張って実現しているのですが、コンパイルエラーのエラーメッセージは少し長くなってしまいました。 もしコンパイルエラーになったときには少し注意深くエラーメッセージを読む必要があるかもしれません。

3. サブクラスベースな方法もサポート

ここまでのサンプルコードで示したように、FirestoreSimpleクラスのインスタンスを作成するのが基本的な想定した使い方ですが、IndexdDBのラッパーであるDexie.jsのようにサブクラスを作成して使う方法も可能にしました。

class Book {
  public id: string
  public title: string
  public created: Date

  constructor ({ title, created }: { title: string, created: Date }) {
    this.id = title
    this.title = title
    this.created = created
  }
}

class BookDao extends FirestoreSimple<Book> {
  constructor ({ firestore }: { firestore: Firestore }) {
    super({ firestore, path: 'example/ts_admin/book' })
  }
  // override
  public encode (book: Book) {
    return {
      id: book.id,
      book_title: book.title,
      created: book.created,
    }
  }
  // override
  public decode (doc: {id: string, [props: string]: any}) {
    return new Book({
      title: doc.book_title,
      created: doc.created.toDate(),
    })
  }
}

const dao = new BookDao({ firestore })

const book = new Book({ title: 'foobar', created: new Date() })
await dao.set(book)
await dao.fetch(book.id)

FirestoreSimpleを継承したクラスを定義します。Genericsの型とpathを固定することでBookDaoのインスタンスを作るときに引数をへらすことができます。 さらにencodedecodeも親クラスであるFirestoreSimpleのメソッドをオーバーライドしてしまうことで、コンストラクタで渡す必要がなくなりました。

従来の方法と、サブクラスベースな方法で実現できることに差異はありません。好みの方法を選択してください。

未サポートな機能

firestore-simpleは、自分のFirestoreの使い方に特化してデザインしているところが強いため、残念ながらFirestoreの全ての機能をまだサポートしているわけではありません。

リファレンスとサブコレクションのサポートは(今のところ)無い

firestore-simpleはFirestoreから取得したドキュメントをプレーンなjsのオブジェクトに変換してしまうため、残念ながらリファレンスとサブコレクションについてはサポートしていません。

// userのサブコレクションにitemsがあるとする /user/:user_id/items/:item_id
// このようにfetchしたuserのサブコレクションを簡単に辿ることはできない
const user = await dao.fetch(user_id)
const user_items = await user.items.fetchAll()

自分がリファレンスとサブコレクションを使っていないためにまだその機能をデザインしていない、というのがサポートできていない主な理由です。自分が使うようになったタイミングでサポートする可能性はあると思います。

transactionのサポートは(今のところ)無い

トランザクションが必要なのは複数のコレクションにまたがった操作をアトミックに行いたいケースでしょう。firestore-simpleはそのインスタンスごとに1つのコレクションの操作に特化するようにデザインしてしまったため、transactionをいい感じに扱うAPIのデザインが考えきれておらずまだ実装していません。

自分がFirestoreを使う開発においてtransactionが欲しいと思った場面に直面していないので優先度はあまり高くないのですが、transactionは用途によっては必須レベルだと思いますのでそのうちサポートしたいなとは考えています。

onSnapshotは部分サポート

Firestoreのウリの1つであるonSnapshotですが、これを他のメソッドのように生のFirestoreのAPIをラップするのはかなり大変そうだったため部分サポートとしています。実際にサンプルコードを見てもらうのが早いでしょう。

dao.where('age', '>=', 20)
  .onSnapshot((querySnapshot, toObject) => {
    // querySnapshotはFirestoreの生のonSnapshot()の引数と同じもの
    querySnapshot.docChanges.forEach((change) => {
      if (change.type === 'added') {
        // toObjectはfirestore-simpleが提供する、オブジェクトに変換するためのメソッド
        // changeDocの型はdaoを作るときに指定したGenericsの型と同じになる
        const changedDoc = toObject(change.doc)
      }
    })
  })

Firestoreのリアルタイムアップデートのドキュメントと比較してもらうと、toObjectがコールバックの引数に増えただけというのが分かると思います。

toObjectは、firestore-simpleが生のFirestoreからfetchしたドキュメントをオブジェクトに変換する、内部で使用しているのと同じメソッドです。onSnapshotの処理の中でtoObject()を通すことでfirestore-simpleの他のメソッドの戻り値と同じ型にできます。
他のAPIのように生のFirestoreよりも使い勝手がそれほど良くなるわけではないですが、実用上はそれほど問題ないかなと思います。

まとめ

Firestoreを便利に、そしてシンプルにjs/tsから使うためのfirestore-simpleを紹介しました。

未サポートな機能の節で紹介したように、自分のFirestoreの使い方に特化してデザインしてるため、今のところはユースケースが限られてしまうかもしれません。
一方で、firestore-simpleは非常に小さなライブラリで、本体のコードはまだ200行程度しかありません。必要であれば中身のコードを読んで把握し、継承して独自に拡張してもらうことも容易だと思います。

まだまだ発展途上なところが多いですが、サンプルコードを見ていいじゃん!と思った方はぜひGitHubのスターや、pull-reqを頂けると嬉しいです!