Runner in the High

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

転職活動でいろんな会社のマイクロサービスと組織を見聞きして思ったこと

転職活動でいろんな会社のエンジニアの人と話して思ったことをマイクロサービスの観点で備忘録がてらメモしておく。

よくあるマイクロサービスの分割軸として、業務機能、ユースケース(動詞)、リソース(名詞)あたりが一般的だが、これらどれもがドメインを構成する要素であるため、マイクロサービスに分割してしまうと結果的にソフトウェアの形がビジネスルールの変化を制限してしまうケースの方が多い気がしていた。実際、規模の小さい開発組織でマイクロサービスやってみました〜からのツラミはそういうのが多いイメージで、よくある「マイクロサービスやったけど逆に開発遅くなった」みたいなとこは上で挙げたような粒度の切り方をしている印象がある。

この件に関して、去年末の転職活動のタイミングでいろいろな人に話聞いてみた結果、実際にマイクロサービスでうまくやっていそうな組織は、上で書いたようなドメインを構成する要素でマイクロサービスをやるのではなく、プロダクト開発のための内製プラットフォームを作るような粒度としてのマイクロサービスを取り扱ってそうな印象を持った。

あとは、そもそもアプリケーションを分離しているパターンもそこそこあったが、これはマイクロサービスと言っていいのか謎なので一旦無視する。

内製プラットフォームとは

雑にいうならばドメイン固有ではない範囲を分離してマイクロサービスにしているイメージ。

これは、いわゆるDDDでいうところの汎用サブドメインに相当するものになる。例えばアプリケーションで使っているサードパーティSDKクラウドリソースがあるとして、それを社内で独自のチームを組成して作らせるような雰囲気。メガベンチャーだとそういうチームが大抵ある気がしていて、例えばLINEのVerdaとかはプラットフォーム超えてクラウドなのですごい。

内製プラットフォームを作るようなチームの存在を維持するためには、それ相応のリソースが必要であり、そこに投資できるだけのプロダクト価値と顧客のニーズも必要。なんなら開発組織の規模に比例すると思う。プロダクトがうまくいっていて、開発に投資できる組織はやはり規模が大きいし、だからこそ内製にアドバンテージが出る。

逆に、無駄に早いフェーズでこういうチームやマイクロサービスを組成して抽象度を間違えると、開発組織の中で無駄にコミュニケーションが必要になったり単なる受発注をするだけのチームがいくつも生まれて地獄絵図になる。マイクロサービスの切り方もおかしくなる。

個人的な解釈

プロダクトとしての独自性を作るために業務機能、ユースケース、リソースが変わることは頻繁にあるので、この変化に技術的な都合で対応できないのは本末転倒。正直なところ、プロダクトが持つ機能の独自性で競合とバトルしている時点でそのプロダクトはジリ貧だと思うが、大抵のプロダクトはそうならざるを得ないのでドメインが定まらないのは仕方ないし、エンジニアリングの面でも対応するしかない。この状況ではドメインは常に不安定なのでマイクロサービスとして分割するには適さない。おそらく、業界のプラットフォーマーと言えるくらいのレベルにならないと安定することはない気がしている。

マイクロサービス・アーキテクチャ1版でもドメインへの理解が低い場合にはマイクロサービスは適さない」と書かれていたが、そもそもドメインが"理解できた"と言えるようなエウレカ的瞬間はプロダクト開発においては形を変えつつ小刻みに連続して現れるのであって、ゴールテープのような"究極の理解"のタイミングがあるわけではない。マイクロサービスであることが連続的に発生するドメイン理解と変化のスピードを遅くする原因になっているのならば、それはむしろマイクロサービスがプロダクト成長の邪魔をしている状態と言っていい。そのような事態を避けて、明確に切り離せそうな単位でマイクロサービスの分割をするならドメインかそれ以外か」くらいの粒度になるわけで、そうなればマイクロサービスとして切り出せる選択肢は汎用サブドメイン以外ない。

みんながみんな内製プラットフォーム的なものを作るチームを持つべきかというと、それは当然なんか違う気がしていて、高度な抽象化をするためにはそれ相応のメリットを感じるだけの規模と価値を持つプロダクトとビジネスの存在が前提になる。あと一般に汎用サブドメインといえば認証や通知*1とかがよく出てくるが、そういうのはサードパーティのプラットフォームを使うだけで十分なことも多い。

結局、ギリギリまで外部のサードパーティ・プラットフォームを使い続けて、とうとうそれがプロダクトのニーズ似合わなくなってきた... という時に初めて、ようやく自前でマイクロサービスとして内製するというレベルでよいのではないか。そして、ある程度うまくやっていそうなところはそういうやり方をしていそうな印象を持った。

PS: この記事書いてて思ったが、過去に書いた以下の記事とかなり内容が近かった。

izumisy.work

*1:なお、通知をマイクロサービスとして抽象的に切り出そうとすると意外に難易度が高いという話がある 通知マイクロサービスはアリ?ナシ? - Speaker Deck

2022年を振り返る

とうとう今年も終わりますね

生活

2021年の5月ごろにノリと勢いで土地を買って家を建て始めていたが、今年の2月にようやく完成。今年は入居して新居を楽しんだ。

家と言えば、そもそも家を建てていた2021-2022年の時点でウッドショックと半導体不足がフィーチャーされていて、それだけでも家を建てるという観点ではそこそこ痛手だったが、そこに輪をかけてインパクトを与えるいまの米国経済の状況を見ると、改めて未来ってどうなるか分からんな... という感想。

とりあえず今のインフレをリセットするために2023年春まで米国では今の景気状況が続くという話が多いが、いずれにしても未来のことは予想できないので今できる最善の選択をする以外ないと思う。

仕事

Uniposを11月で退職してTailorにJOINした www.tailor.tech

「日本からYコンビネータに採択されている」「創業者の経歴が強い*1」「プラットフォームプロダクト」とこれからの未来しか感じられないスタートアップだったので、ここに次の自分の人生をbetする以外の選択肢は無かった。基本的に過去や現在には興味がなくて、未来に可能性があるかどうかしか考えてない。

やっている内容をざっくりかいつまむと、TailorはTailorプラットフォームという「プロダクトを作るためのプロダクト」を提供していて、現在自分はその機能拡充をフィードバックするためのPoCをやっている。ソフトウェア・エンジニアとしての面白さはやはり「プラットフォームであること」に起因するところがあり、通常のソフトウェア開発よりも1段抽象度の高い思考を求められていて楽しい。

なんとなくではあるが、OSSなライブラリだったりミドルウェア開発っぽいことをしてみたいという人には合っているような予感がする。とはいえもちろん抽象度が高いぶん仕様も複雑なので、とりあえずどうにかできる胆力は求められると思うが。

とりあえず気になった方は弊社採用ページからどうぞ。Twitter DMも歓迎です。

まとめ

Uniposのときもそうだが「俺が育ててる、俺と仲間たちで育ててる*2」と言いたいと思えるようなプロダクトに自分の人生を費やしたほうがいい。

1日8時間やってもまだ足りないと思える仕事じゃなければやる意味がない。

*1:これがなぜ重要かについてはベンチャーキャピタル全史を読むと早い

*2:https://www.youtube.com/watch?v=zaBp1Jh3Bkc

コードレビューとオーナーシップ、できる限りコードレビューをしない

前職でフロントエンドチームのスペシャリストとして開発プロセスを考えるポジションにいた。そのときに試してみて良かったなと思えたのはAutoApproveという仕組みで、これは任意のPRにAutoApproveというラベルが付くとGithub Actions経由でPRがGithub BotにApproveされコードレビューなしでマージできるというもの。

コードレビューでは、プロダクトの規模が大きくなっていくにつれ認知的・時間的なコストが嵩む。レビュワーも広汎なドメインや設計レベルの知識が求められたり、人が増えると「これレビューする意味ある?」みたいなPRもレビュワーにバンバン飛んでくる。そうなると、全体的にさらっと見てApproveしたりする。これはもちろんレビューをされる側もそうで、コードレビューという仕組みがあるとなんとなくレビューをゲートキーピング的に捉えてしまいがちなところがある。

なんのためにコードレビューをするか、という意思統一が抜本的に図れていればいいが、それであっても「レビューが通ってるからOKでしょ」みたいな、ある意味自分のコードのオーナーシップを軽く見がちな傾向がコードレビューに対する向き合い方次第では醸成されやすい。これは 「その変更で何かが起きたらひとりで責任を取れ」という話ではなく、ひとりのソフトウェア・エンジニアとしてある程度のレベルがあるなら自律して品質に責任を持てる状態になっていることを期待できるでしょ、という話。

実際、主観的ではあるがAutoApproveの仕組みがあったことで、ある程度本質的にレビューが必要なものだけが厳選されて飛んでくるようになった感覚があった。たとえばtypo修正だったり、内部的になツールだったり、ビューや設計のちょっとした変更であればみんなさっさと入れてしまうし、あとから共有とともにその良し悪しを議論するほうが圧倒的にスピード感があった。

特に前職のフロントエンドはElmで実装されていたこともあり、言語的な特徴*1から仮に大きな変更と言ってもそのバリエーションには限界があることがかなり予測できていた。このあたりは、言語やフレームワークの自由度に大きく依存する。

若干エクストリームな思想ではあるが、そもそもエンジニア採用の時点でレビューが頻繁に必要になるエンジニアを入れないほうがいいというか、前提としてコードレビューがなくても信頼できてガンガンコミットするのを許せるような人材を集めたほうが、プロダクト開発の生産性は高いんじゃないかという気持ちもある。とくにベンチャーやスタートアップならなおさらそうで、組織の最高速度を上げられるなら無限に上げたほうが良いはずだと思っている。

とはいえ、やっぱり人間はある程度の責任を背負わないと成長できない。

余談

とはいえ、冷静に考えるとフロントエンドという領域だからできたみたいな話もある気がする。

言い方には気を付けたいが、バックエンドに比べてフロントエンドのほうが(セキュリティは別として)インシデントのインパクトが小さい傾向はある。たとえばバックエンドで不整合なデータを生み出すようなコードが混入すると、そこそこ重めの復旧作業が発生することが考えられるが、フロントエンドでそういった尾を引くインシデントを起こすリスクは少ないのではないか。

壊れたら気軽にロールバックできるようなリリースプロセスを作り、E2Eほどではないにしてもスモークレベルの自動化テストを用意して、コアの機能以外は壊れても後から余裕をもって修正をリリースできるようにすればいい。もちろんプロダクトやビジネスの性質にもよるが、それでもフロントエンドのほうがやっぱりアグレッシブになれるような気がしている。

*1:言語仕様が小さくてできることが少ない、フレームワークと言語が統合されている、型システムでテクいことができない、とかそういう特徴を言っている。

クラウドバンクを3年間続けた結果

2020年ごろにこんな記事を書いていた。

izumisy.work

しばらくの間ずっとほったらかして運用し続けていた。

2022年10月現在では結果的に累計で60万ちょっとくらいの利益が累計で出たらしい。

2022年10月ごろの状況

いまは少しづつ投資信託のほうに配分を移しているのだが、基本的に300-400万円くらいを定常的にクラウドバンクの運用に回していたので、それくらいの額で結果的に60万レベルのリターンがあったと考えるとそんなに悪い投資ではなかったかな、という印象。

しかし年間の利回りだけで考えると、正直クラウドバンクよりも投資信託のほうが断然いいものが多いので、それでもクラウドバンクで運用をし続けようという理由が自分のなかでは特になくなってしまった。とはいえ、一部のソーシャルレンディングで印象の悪いものがちらほらいたものの、結果的にクラウドバンクは一番信用できるソーシャルレンディングのプラットフォームだったかな、とは思う。

ブラウザ自動化のツールとその周辺知識に関する備忘録

業務でE2Eテストの導入を進めており、ブラウザ自動化のためのツールに関して調べる必要があったので備忘録的に書き残しておく。

自分のブラウザ自動化周りの知識といえばはるか昔に大学生のころインターンSeleniumを用いたテストの自動化をやったくらいで止まっており、その頃の記憶からブラウザの自動化はあまり信頼のおけない挙動ばっかりする... というイメージがあった。

とはいえ、現在は当時よりも様々な選択肢があるらしいので、少しづつ過去の歴史に触れつつキャッチアップしてみる。

Selenium Remote Control (Selenium 1)

おそらく自分が大学生のときに触ったのはこのRCの世代だと思われる。

www.selenium.dev

Selenium 1はブラウザ上での動作を実行するSelenium Coreとそれに対して操作処理を送信するRemote Controlから構成され、RCサーバがHTTPリクエストを用いてCoreに命令を送信し、Coreがそれを解釈しブラウザ上でJSを実行する形でブラウザの操作を行う。

Selenium WebDriver (Selenium 2)

それまでSelenium RCとして実現されていたリモート・コントロールプロトコルを抽象化/標準化したものがW3C WebDriverであり、それを用いたのがこのSelenium WebDriver (Selenium 2)である。

www.selenium.dev

基本的なメカニズムとしてはSelenium 1とほぼ変わらず、HTTPリクエストによってWebDriverプロトコルを実装したブラウザのドライバの操作が行われる。よって、1と2の違いはWebDriverという通信プロトコルを使うようになったくらいだと思われる。

WebDriverに関しては以下の記事が参考になった。ちょっと古いが、情報は多い。 vividcode.hatenablog.com

CypressとWebDriver

E2Eテスト界隈でそこそこ話題になったCypressはテスト実行においてWebDriverを使っていないため、テストの不安定性(flakiness)が低いとされている。

これは、Cypressのテストがブラウザ上で実行されるひとつのアプリケーションとして動作するため。

medium.com

そもそもブラウザ自動化においてWebDriverとは抽象化されたブラウザとの通信プロトコルでしかない。

WebDriverを使っている限りブラウザとの通信には必ずHTTP通信が必要であり、そのオーバーヘッドがない分Cypressのテスト実行は安定する。もちろん、安定性と引き換えにブラウザ毎のWebDriver実装は使えないため、自前でテストの実行エンジンを実装するしかないわけだが、そこはとにかく実装で頑張っているんだろう*1と思われる。Cypressと同様のアーキテクチャを採用しているのはTestCafeというE2Eツールだが、こちらはIEもサポートしていて結構頑張っているっぽい。

なお、CypressはWebDriverとは異なりひとつのJavaScriptアプリケーションとして動くため、それ相応の制限を受ける。たとえばタブの操作やiframeに対する操作などがそれにあたり、これはCypressのアーキテクチャに由来する制限である。

Chrome Devtools Protocol (CDP)とChromeDriver

chromedriver.chromium.org

ChromeDriverというのはその名の通りChromeのためのWebDriver実装にあたるが、内部的にはWebDriverのプロトコルで送られてきた操作をChrome Devtools Protocol (CDP)へ変換することでWebDriverを実装している。

Chrome Devtools Protocolとはその名の通りChromiumChromeなどBlinkベースのブラウザを外部から操作するためのAPIであり、WebDriverと異なりWebSocketで通信を行う。WebDriverと比べて取得できるデータや操作の種類が多くWebSocketで双方向的に通信ができるため、WebDriverよりも複雑な自動化操作を行えるようなった。詳しくはCDPのAPIドキュメントを見るといろいろ分かる。 chromedevtools.github.io

PuppeteerとPlaywright

ここ最近はブラウザ自動化といえばPuppeteerだ! みたいな記事を見かけることが多い気がするが、その理由はPuppeteerが内部的にWebDriverを使わずCDPを用いて直接ブラウザを操作するからだと思われる。ChromeDriverは テストツール --(WebDriver)--> ChromeDriver --(CDP)--> Chrome という経路で操作を行うため諸々のオーバーヘッドが大きかったが、これがCDPだけになればHTTP通信分のオーバーヘッドがなくなり処理が安定化する。つまりWebDriverよりCDPをメインで操作する自動化ツールのほうがいい、ということになる。

PuppeteerとPlaywrightの違いは調べてみたがよくわからなかった。どこで見たか忘れたがPuppeteerの開発者がPlaywrightのほうに移動しているので、これからはPlaywrightのほうがいいぞみたいな記事がRedditにあった気がする。

WebDriver BiDi (BiDirectional)とSelenium 4

WebDriverのように標準化されたプロトコルでありながらもCDPのように自動化だけじゃなくブラウザの情報とかなんかいろいろ取れるようにしたいよね、というポストWebDriverのポジションとしてWebDriver BiDi (BiDirectional)とかいうやつが標準化されようとしているらしい。

developer.chrome.com

まだDraftらしいがSelenium 4は先んじてこれに対応しようとしている。たぶんPlaywrightとかCypressも将来的にはCDPじゃなくてWebDriver BiDiを使うようになるんだろうな。分からんけど。

zenn.dev

私見

結論、テスト自動化のツールに何を求めるかによって選び方が変わると思った。

以下の図の分類がめちゃくちゃ分かりやすい。 Browser Automation Tools Protocols - Webdriver vs CDP

基本的にはWebDriverは標準化されているだけでflakyテスト多くてつらいのでCDPを採用しているやつを選ぶべきで、ある程度制限があってもいいならCypressやTestCafeあたりを選ぶのが吉、という感じになるのかな。

上の記事によるとCypressに関してはV7からCDPサポートが入ったらしいので、もしかすると自分が先に書いているような制限は実はもうないのかもしれない。

Firebase JS SDKのソースコード・リーディング(初期化処理周り)

最近諸事情あり業務でFirebase JS SDKのDatabase実装周りを読むことがあったので、備忘録的にブログ記事にしてみる。

初期化処理の雰囲気

Databaseまで含めると全体像があまりにでかすぎるので、とりあえず初期化処理周りだけを雰囲気でクラス図にしてみた。staticと書いてあるところはクラスではなくただ単にファイルとそこに定義されている関数であることを示している。

実際にDatabaseそのものの詳細な処理(コネクションハンドリングやら内部での状態管理など)はまた別で解説することとして、この図ではSDKの初期化に関連したクラスとメソッドのみを抜き出すことにした。

Firebase JS SDKの実装ではIoCコンテナとDIが設計において積極的に活用されており、独自のDIコンテナを内部実装している。ComponentというパッケージではDIコンテナの実装となるクラスがまとまっており、AppパッケージにはDIコンテナ(ComponentContainer)の初期化からのコンポーネントの登録など実際のコンテナ操作を行うクラスが多く含まれる。このふたつはFirebase JS SDKにおける基盤的なパッケージであると言える。

今回の図では機能のパッケージとしては Database のみを取り上げたが、Database に限らずそのほかのどの機能のパッケージも上記の図で言うComponentを生成する形で実装されている。新しく機能が増えたとしても、そのパッケージの実装をする側はクラス・インスタンスのライフサイクルをどのように管理するか、などの細かい処理を気にする必要がなく十分に抽象化された基盤を利用しつつ個別の機能パッケージの開発に集中できるようにする思想が垣間見える。

パッケージ間の関係性

App パッケージにおいて initializeAppSDKAPIとして公開されている関数であり、そこが実質的な処理のエントリポイントとなる。

この関数ではFirebase JS SDKにおいて中心的なクラスである FirebaseAppImpl と、それが保持するDIコンテナの実装である ComponentContainerインスタンス化を行っている。

export function initializeApp(
  options: FirebaseOptions,
  rawConfig = {}
): FirebaseApp {
  if (typeof rawConfig !== 'object') {
    const name = rawConfig;
    rawConfig = { name };
  }

  const config: Required<FirebaseAppSettings> = {
    name: DEFAULT_ENTRY_NAME,
    automaticDataCollectionEnabled: false,
    ...rawConfig
  };
  const name = config.name;

  //
  // 省略...
  //

  const container = new ComponentContainer(name);
  for (const component of _components.values()) {
    container.addComponent(component);
  }

  const newApp = new FirebaseAppImpl(options, config, container);

  _apps.set(name, newApp);

  return newApp;
}

特筆すべきはこの関数の中で参照されている _apps_components という変数で、この初期化のタイミングでComponentをコンテナに登録していることがわかる。この二つの変数は packages/app/src/internal.ts というファイルで定義されたグローバルなシングルトン・オブジェクトである。

_appsinitializeApp からのみ値が追加されるが _component_registerComponent という関数からも追加される可能性がある。これはDatabaseなど個別の機能パッケージから利用されるコンポーネント登録のための関数である。

/**
 * @internal
 */
export const _apps = new Map<string, FirebaseApp>();

/**
 * Registered components.
 *
 * @internal
 */
// eslint-disable-next-line @typescript-eslint/no-explicit-any
export const _components = new Map<string, Component<any>>();

//
// 省略...
//

/**
 *
 * @param component - the component to register
 * @returns whether or not the component is registered successfully
 *
 * @internal
 */
export function _registerComponent<T extends Name>(
  component: Component<T>
): boolean {
  const componentName = component.name;
  if (_components.has(componentName)) {
    logger.debug(
      `There were multiple attempts to register component ${componentName}.`
    );

    return false;
  }

  _components.set(componentName, component);

  // add the component to existing app instances
  for (const app of _apps.values()) {
    _addComponent(app as FirebaseAppImpl, component);
  }

  return true;
}

このようなシングルトン・オブジェクトが必要な理由はFirebase v8で下記のように機能ごとのパッケージの初期化ができるため。

import firebase from "firebase/app";
import "firebase/database";

上記のようにDatabaseパッケージがimportされると packages/database/src/index.ts から呼ばれる形で下記の registerDatabase 関数が実行される。

import {
  _registerComponent,
  registerVersion,
  SDK_VERSION
} from '@firebase/app';
import { Component, ComponentType } from '@firebase/component';

import { name, version } from '../package.json';
import { setSDKVersion } from '../src/core/version';

import { repoManagerDatabaseFromApp } from './api/Database';

export function registerDatabase(variant?: string): void {
  setSDKVersion(SDK_VERSION);
  _registerComponent(
    new Component(
      'database',
      (container, { instanceIdentifier: url }) => {
        const app = container.getProvider('app').getImmediate()!;
        const authProvider = container.getProvider('auth-internal');
        const appCheckProvider = container.getProvider('app-check-internal');
        return repoManagerDatabaseFromApp(
          app,
          authProvider,
          appCheckProvider,
          url
        );
      },
      ComponentType.PUBLIC
    ).setMultipleInstances(true)
  );
  registerVersion(name, version, variant);
  // BUILD_TARGET will be replaced by values like esm5, esm2017, cjs5, etc during the compilation
  registerVersion(name, version, '__BUILD_TARGET__');
}

_registerComponent 関数が呼ばれることにより _components にComponentが追加され initializeApp 関数の呼び出し時にDIコンテナへのComponentの登録が自動で行われる。ここでComponentクラスのコンストラクタ第2引数として渡されている無名関数はインスタンス生成処理のIFにあたる InstanceFactory の実装であり、なんとなくファクトリがいい感じで遅延実行される雰囲気を感じられる。

実際にファクトリとなる関数が呼びされるのは、コンポーネントの取得時(クラス図における _getProvider 関数の呼び出し時)になる。例えばDatabaseパッケージは getDatabase 関数の中でその呼び出しを行う。

 * Returns the instance of the Realtime Database SDK that is associated
 * with the provided {@link @firebase/app#FirebaseApp}. Initializes a new instance with
 * with default settings if no instance exists or if the existing instance uses
 * a custom database URL.
 *
 * @param app - The {@link @firebase/app#FirebaseApp} instance that the returned Realtime
 * Database instance is associated with.
 * @param url - The URL of the Realtime Database instance to connect to. If not
 * provided, the SDK connects to the default instance of the Firebase App.
 * @returns The `Database` instance of the provided app.
 */
export function getDatabase(
  app: FirebaseApp = getApp(),
  url?: string
): Database {
  return _getProvider(app, 'database').getImmediate({
    identifier: url
  }) as Database;
}

_getProvider の実装は FirebaseApp からComponentContainerを経由し該当のProviderを取得する動きになっている

/**
 *
 * @param app - FirebaseApp instance
 * @param name - service name
 *
 * @returns the provider for the service with the matching name
 *
 * @internal
 */
export function _getProvider<T extends Name>(
  app: FirebaseApp,
  name: T
): Provider<T> {
  // ...
  return (app as FirebaseAppImpl).container.getProvider(name);
}

Providerクラスは主にComponentクラス・インスタンスのライフサイクルを管理する責務を担っており getImmediate でその挙動をみることができる。

  /**
   *
   * @param options.identifier A provider can provide mulitple instances of a service
   * if this.component.multipleInstances is true.
   * @param options.optional If optional is false or not provided, the method throws an error when
   * the service is not immediately available.
   * If optional is true, the method returns null if the service is not immediately available.
   */
  getImmediate(options: {
    identifier?: string;
    optional: true;
  }): NameServiceMapping[T] | null;
  getImmediate(options?: {
    identifier?: string;
    optional?: false;
  }): NameServiceMapping[T];
  getImmediate(options?: {
    identifier?: string;
    optional?: boolean;
  }): NameServiceMapping[T] | null {
    // if multipleInstances is not supported, use the default name
    const normalizedIdentifier = this.normalizeInstanceIdentifier(
      options?.identifier
    );
    const optional = options?.optional ?? false;

    if (
      this.isInitialized(normalizedIdentifier) ||
      this.shouldAutoInitialize()
    ) {
      try {
        return this.getOrInitializeService({
          instanceIdentifier: normalizedIdentifier
        });
      } catch (e) {
        if (optional) {
          return null;
        } else {
          throw e;
        }
      }
    } else {
      // In case a component is not initialized and should/can not be auto-initialized at the moment, return null if the optional flag is set, or throw
      if (optional) {
        return null;
      } else {
        throw Error(`Service ${this.name} is not available`);
      }
    }
  }

shouldAutoInitialize はComponentの初期化タイミングを制御するフラグであるらしく、実装によれば LAZY, EAGER, EXPLICIT の3つから選択される。SDKにおけるComponentのデフォルト値もLAZYが指定されているため、基本的にはすべての機能のクラスがこの getImmediate のタイミングでファクトリの呼び出しによって実体化されると考えていいだろう。

実際の初期化処理は getOrInitializeService メソッドの中で行われ、ここでようやくComponentに登録された instanceFactory が実行されるのが分かる。すでにファクトリが実行されている場合には初期化処理をスキップする。

  private getOrInitializeService({
    instanceIdentifier,
    options = {}
  }: {
    instanceIdentifier: string;
    options?: Record<string, unknown>;
  }): NameServiceMapping[T] | null {
    let instance = this.instances.get(instanceIdentifier);
    if (!instance && this.component) {
      instance = this.component.instanceFactory(this.container, {
        instanceIdentifier: normalizeIdentifierForFactory(instanceIdentifier),
        options
      });
      this.instances.set(instanceIdentifier, instance);
      this.instancesOptions.set(instanceIdentifier, options);

      /**
       * Invoke onInit listeners.
       * Note this.component.onInstanceCreated is different, which is used by the component creator,
       * while onInit listeners are registered by consumers of the provider.
       */
      this.invokeOnInitCallbacks(instance, instanceIdentifier);

      /**
       * Order is important
       * onInstanceCreated() should be called after this.instances.set(instanceIdentifier, instance); which
       * makes `isInitialized()` return true.
       */
      if (this.component.onInstanceCreated) {
        try {
          this.component.onInstanceCreated(
            this.container,
            instanceIdentifier,
            instance
          );
        } catch {
          // ignore errors in the onInstanceCreatedCallback
        }
      }
    }

    return instance || null;
  }

なんとなく Componentインスタンスの生成手段や生成タイミングを保持するだけのデータ構造という雰囲気が強く、ファクトリで生成されたインスタンスのライフサイクルを管理するのが Provider であるように見える。

非常に典型的なDIコンテナの実装という感じ。

NameServiceMapping に関して

たとえば上記の getOrInitializeService メソッドだったり InstanceFactoryNameServiceMapping という型を返す実装になっている。

/**
 * Factory to create an instance of type T, given a ComponentContainer.
 * ComponentContainer is the IOC container that provides {@link Provider}
 * for dependencies.
 *
 * NOTE: The container only provides {@link Provider} rather than the actual instances of dependencies.
 * It is useful for lazily loaded dependencies and optional dependencies.
 */
export type InstanceFactory<T extends Name> = (
  container: ComponentContainer,
  options: InstanceFactoryOptions
) => NameServiceMapping[T];

この型は以下のような実装になっている。

/**
 * This interface will be extended by Firebase SDKs to provide service name and service type mapping.
 * It is used as a generic constraint to ensure type safety.
 */
export interface NameServiceMapping {}

export type Name = keyof NameServiceMapping;
export type Service = NameServiceMapping[Name];

上記のコメントに書いてある通りこれは型安全性を担保するためのもので、Databaseの場合にはindex.tsに以下のような形で関連する実装がある。

declare module '@firebase/component' {
  interface NameServiceMapping {
    'database': Database;
  }
}

あとはComponentのコンストラクタを見ればわかりやすい。DatabaseパッケージのComponent登録の処理では namedatabase になるため、該当するインスタンスの型が導きだされることが分かる。

export class Component<T extends Name = Name> {
  // ...

  /**
   *
   * @param name The public service name, e.g. app, auth, firestore, database
   * @param instanceFactory Service factory responsible for creating the public interface
   * @param type whether the service provided by the component is public or private
   */
  constructor(
    readonly name: T,
    readonly instanceFactory: InstanceFactory<T>,
    readonly type: ComponentType
  ) {}

  // ...
}

PlaywrightでGithub ActionのJob Summary用レポータを作るといい感じ

Github ActionにJob Summaryという機能があり、これを使うとActionの結果をmarkdownに対して出力できる。

github.blog

所属しているチームではPlaywrightをテストに使用しているのだが、毎回Actionの実行でエラーがおきたときにStepを開いて見に行く必要があり、まあまあ面倒。第一に見ずらい。

そこで、playwrightが提供しているReporterでJob Summary用の実装を用意すると、こんな感じでテスト結果をいい感じに表示できる。

エラーがあるときは各リトライの結果を表示させるようしている。

import * as fs from 'fs';
import {FullConfig, TestError} from '@playwright/test';
import {FullResult, Reporter, Suite, TestCase, TestResult} from '@playwright/test/reporter';

interface Error {
  title: string;
  error: TestError;
}

export default class GithubSummaryReporter implements Reporter {
  outputFile?: fs.WriteStream;
  errors: Error[];

  onBegin(config: FullConfig, suite: Suite) {
    const summaryFile = process.env.GITHUB_STEP_SUMMARY
    if (summaryFile) {
      this.outputFile = fs.createWriteStream(summaryFile, {flags: 'a'});
    }

    this.errors = [];
    this.writeOut("## Summary\n")
    this.writeOut("Case|Status|Duration")
    this.writeOut("----|------|--------")
  }

  onEnd(result: FullResult) {
    if (this.errors.length > 0) {
      let errorDetails = "\n## Errors\n"
      this.errors.forEach(({title, error}, attempt) => {
        errorDetails += `\n#### ${title} (#${attempt} attempt)`;
        errorDetails += "\n```diff\n";
        errorDetails += error.message.replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
        errorDetails += "\n```\n";
      })
      this.writeOut(errorDetails)
    }

    if (this.outputFile) {
      this.outputFile.end();
      this.outputFile.close();
    }
  }

  onTestEnd(test: TestCase, result: TestResult) {
    const title = test.titlePath().filter(v => v !== '').join(' > ');
    const status = result.status === 'passed' ? ':white_check_mark:' : ':x:';
    if (result.status !== 'passed') {
      result.errors.forEach(error => {
        this.errors.push({title, error})
      })
    }
    this.writeOut(`${title} (#${result.retry})|${status}|${result.duration}ms`)
  }

  private writeOut(value: string) {
    if (this.outputFile) {
      this.outputFile.write(value + "\n");
    } else {
      console.log(value)
    }
  }
}

細かい話だが、エラーメッセージのsyntaxをdiffにすると差分がいい感じで表示できる。

あとはreplaceでANSIエスケープシーケンスを取り除くところもポイントで、playwrightはレポータに渡してくるエラーメッセージの文字列にカラーコードがつけっぱなしなのでレポータ側で外してやらないといけない。

Firebase Database REST API Streamingの挙動メモ

複雑すぎるのでまとめる

ベースとなるJSONの構造は以下。すべてFirebase Database Emulatorで試している。

posts配下へのフィールドの追加

{
  type: 'put',
  data: '{"path":"/book3","data":"awesome book"}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

posts配下フィールド(book2)の更新

{
  type: 'put',
  data: '{"path":"/book2","data":"great book (updated)"}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

posts配下フィールドの削除(book3)

 {
  type: 'put',
  data: '{"path":"/book3","data":null}',
  lastEventId: '',
  origin: 'http://localhost:9000'
}

いろいろ試してみたがpatchが飛んでくるケースが見つけられなかった。

あとputとpatchの違いも謎で、一見すると更新系ならpatchでも良さそうだがそれもputで飛んでくる。もしかして孫フィールドの更新ならどうだ? と思い、それぞれのbookに { name: "nice book" } のようなObjectっぽい構造を持たせて更新を試してみたが、そうすると今度はpathが /book2/name になった状態でputが送信されてくる。