最近諸事情あり業務で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
パッケージにおいて initializeApp
はSDKのAPIとして公開されている関数であり、そこが実質的な処理のエントリポイントとなる。
この関数では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
というファイルで定義されたグローバルなシングルトン・オブジェクトである。
_apps
は initializeApp
からのみ値が追加されるが _component
は _registerComponent
という関数からも追加される可能性がある。これはDatabaseなど個別の機能パッケージから利用されるコンポーネント登録のための関数である。
export const _apps = new Map<string, FirebaseApp>();
export const _components = new Map<string, Component<any>>();
@paramcomponent
@returnswhether
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);
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);
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を取得する動きになっている
@paramapp
@paramname
@returnsthe
export function _getProvider<T extends Name>(
app: FirebaseApp,
name: T
): Provider<T> {
return (app as FirebaseAppImpl).container.getProvider(name);
}
Providerクラスは主にComponentクラス・インスタンスのライフサイクルを管理する責務を担っており getImmediate
でその挙動をみることができる。
@paramoptions.identifier
@paramoptions.optional
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 {
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 {
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);
this.invokeOnInitCallbacks(instance, instanceIdentifier);
if (this.component.onInstanceCreated) {
try {
this.component.onInstanceCreated(
this.container,
instanceIdentifier,
instance
);
} catch {
}
}
}
return instance || null;
}
なんとなく Component
はインスタンスの生成手段や生成タイミングを保持するだけのデータ構造という雰囲気が強く、ファクトリで生成されたインスタンスのライフサイクルを管理するのが Provider
であるように見える。
非常に典型的なDIコンテナの実装という感じ。
NameServiceMapping
に関して
たとえば上記の getOrInitializeService
メソッドだったり InstanceFactory
は NameServiceMapping
という型を返す実装になっている。
@linkProvider
@linkProvider
export type InstanceFactory<T extends Name> = (
container: ComponentContainer,
options: InstanceFactoryOptions
) => NameServiceMapping[T];
この型は以下のような実装になっている。
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登録の処理では name
は database
になるため、該当するインスタンスの型が導きだされることが分かる。
export class Component<T extends Name = Name> {
@paramname
@paraminstanceFactory
@paramtype
constructor(
readonly name: T,
readonly instanceFactory: InstanceFactory<T>,
readonly type: ComponentType
) {}
}