最終更新: a few seconds ago Remove Netlify-related code from main (grafted, HEAD)
ServiceLoader
ServiceLoder クラスは、CIRCUS プロジェクトで使用する DI コンテナの実装。依存サービスとして 'dicomImageRepository' などの文字列を指定しておくと、対応するインスタンスを自動的に注入 (inject) してくれるようになる。
これは DependentModuleLoader の後継版であり、よりいわゆる普通の「DI コンテナ」「IoC コンテナ」に近づけたもの。DependentModuleLoader の欠点として、サービスごとに長いローダ関数を作って登録しないといけないため、1 ファイル内に互いに無関係なサービスの繋ぎ込みコードが.register() の羅列として大量に現れてしまう、ということがあった。
ServiceLoder 導入に伴い、DependentModuleLoader は廃止する。
bottlejs など既存の DI コンテナも調べたが、不必要に重かったり必要な機能がなかったりしたので結局自作することに。短いのでコード読んで理解してください。
基本的な使い方
interface Animal {
sayHello: () => void;
}
interface Services {
animal: Animal;
}
// 実際は設定ファイルなどから取得するコンフィグの内容
const config = {
animal: {
options: { name: 'Shiro' }
}
};
const createAnimal: FunctionService<Animal> = async ({ name: string }) => {
return {
sayHello: () => console.log(`My name is ${name}`)
};
};
const loader = new ServiceLoader<Services>(config);
loader.register('animal', createAnimal);
// 実際は createAnimal を別モジュールで default export して
// registerModule を使う方がよい
const shiro = await loader.get('animal');
サービスの登録のやり方には 4 種類ある。
-
register(name: string, service: Service): サービスファクトリ関数またはクラスを直接登録する。上記は例なので使っているが実際は
registerModuleを使う方がよい。 -
registerModule(name: string, module: string): 上記の発展版。モジュール(*.ts ファイル)自体のファイルパスを指定し、該当モジュールから default export されているものを使ってサービスを構築する。利点として、当該サービスが実際に必要になるまで遅延読み込みされるので少しパフォーマンスが良いはず。複数の実装を使い分けるようなものでないが単純に責務の分割のためにモジュールを分割している系のものは、基本これを使うこと。
-
registerDirectory(name: string, directory: string): 上記のさらなる発展版。config 内の type を見てどの実装を読み込むのかを自動的に決める。Logger や ImageEncoder や DicomFileRepository など複数の実装を設定ファイルで使い分ける系のものはこれを使うこと。例は後述。
-
registerFactory(name: string, factory: (config: any) => Promise): カスタムファクトリ関数を直接登録する。register 部分が肥大化する原因になるので可能な限り使わないこと。(過去の
DependentModuleLoaderはregisterFactoryにあたるものしかなかったためローダーが互いに無関係なコードで肥大化していく原因になっていた。)
特徴
registerDirectory による自動 import と実装の選択
registerDirectory を使うと、設定ファイルの内容から type 値を読みとってそれにより具象クラスの選択ができる。例えば以下のような設定があった場合、
// 設定ファイル
{
dicomFileRepository: {
type: "StaticDicomFileRepository",
options: { path: 'path/to/dicom/dir' }
}
}
以下のコードで具象クラスを選択できる。
const loader = new ServiceLoader(config); // config は上記設定ファイルの内容
loader.registerDirectory(
'dicomFileRepository', // サービス名
'@utrad-ical/circus-lib/lib/dicom-file-repository', // 具象クラス群の配置場所
'MemoryDicomFileRepository' // デフォルト
);
const repo = await loader.get('dicomFileRepository');
// returns new StaticDicomFileRepository({ path: 'path/to/dicom/dir' }, {})
設定ファイル等からの option を渡すことができる
設定ファイルに書かれている options: {...} の内容は、サービスを作成する際の第 1 引数として渡されるので、サービスの内容をカスタマイズするのに使うことができる。上記の例で StaticDicomFileRepository のコンストラクタにファイルパスが渡されているのがそれ。
依存はサービスそのものに記述する
依存サービスは後述するように、クラス/ファクトリ関数そのものに指定する。つまり、loader.register('ninja', Ninja, ['shuriken', 'katana']) のようにする代わりに、以下のようにNinja サービス自体に自分の依存を宣言する。
// クラス型のサービスの場合
class Ninja {
constructor(options, { shuriken, katana }) {
this.shuriken = shuriken;
this.katana = katana;
}
static dependencies = ['shuriken', 'katana'];
}
// ファクトリ関数型のサービスの場合
const createNinja = async (options, { shuriken, katana }) => {
// ...
};
createNinja.dependencies = ['shiriken', 'katana'];
こうすることであらゆるサービス同士の依存関係を 1 ファイルにごちゃ混ぜに宣言する必要をなくす。
サービスの作り方
ServiceLoader によって作られるサービス(クラスまたは関数)は以下に述べる特定の規約に従う必要がある。(これにどうしても従えないものがある場合は registerFactory を使うこともできるが、他の場所で以下の規約に従うラッパを作って registerModule として利用することが遥かに望ましい)
interface Injectable {
dependencies?: string[];
}
export interface FunctionService<S, D = any> extends Injectable {
(options: any, deps: D): Promise<S>;
}
export interface ClassService<S, D = any> extends Injectable {
new (options: any, deps: D): S;
}
export type Service<S, D = any> = FunctionService<S, D> | ClassService<S, D>;
- 基本的にファクトリー関数ベースのサービスを推奨。 記述が短くなり、中で await も使えるなど自由度も高い。
- ClassService の場合はコンストラクタの、FunctionService の場合はその関数の、第 1 引数に config で指定された オプション(あれば)が、第 2 引数に依存 (dependency) の含まれたオブジェクトが渡される。
registerModule/Directoryで自動 import されるモジュールの場合、そのクラス/関数はdefault exportされていること。- 終了時処理(データベースの切断など)が必要な場合は
dispose: () => Promise<void>をメソッドとして実装すること。これが存在する場合はServiceLoader.dispose()から終了処理の一環として呼び出される。 - 他の既存の DI コンテナはここで decorator などを使っておりより複雑だが今回はより原始的な方法を使う。
クラスベースのサービスの例:
export default class Ninja {
constuctor(options, { shuriken }) {
this.shuriken = shuriken;
this.currentHealth = options.health;
}
useWeapon() {
console.log(`Used ${this.shuriken.name()}.`);
}
static dependency = ['shuriken'];
}
関数ベースの例(推奨):
const createNinja = (options, { shuriken }) => {
let currentHealth = options.health;
return {
useWeapon: () => console.log(`Used ${shuriken.name()}.`);
};
};
createNinja.dependency = ['shuriken'];
export default createNinja;
型付きの例については ServiceLoader.test.ts を参照。
アンチパターン
末端のサービスに loader を注入するような実装はしないこと! つまり loader 自体を依存にした何かを作らないこと!
個々のサービスは上記の特定の規約で実装し、依存している他サービスがあるならそれを dependencies = [...] という文字列配列の形で宣言しておく必要はある。が、ServiceLoader そのものを知っていたり使ったりすることがないようにすること。