最近諸事情あり業務で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など個別の機能パッケージから利用されるコンポーネント登録のための関数である。
/** * @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
メソッドだったり InstanceFactory
は NameServiceMapping
という型を返す実装になっている。
/** * 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登録の処理では name
は database
になるため、該当するインスタンスの型が導きだされることが分かる。
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 ) {} // ... }