TypeScriptを使ってDIについて説明する
DIとは?
Dependency Injectionの略です。
依存性の注入などとも呼びます。
コード例
まず、記事(Article)の永続化に関して責任を持つArticleRepositoryというinterfaceを定義します。
code:typescript
interface ArticleRepository {
add(article: Article): Promise<void>;
get(id: ArticleID): Promise<Article>;
}
次に、SQLiteをベースに永続化機能を実装したSQLiteArticleRepositoryを用意します。このSQLiteArticleRepositoryは上記のArticleRepositoryのinterfaceを実装します。 code:typescript
export class SQLiteArticleRepository implements ArticleRepository {
constructor(database: Database) {
this.#database = database;
}
add(article: Article): Promise<void> {
...
}
get(id: ArticleID): Promise<Article> {
...
}
}
これらのinterfaceとclassをベースに説明していきます
先程登場したArticleRepositoryに依存するArticleServiceというクラスがあったとします。このクラスのconstructor内ではArticleRepositoryの実装であるSQLiteArticleRepositoryが直接生成されています。
code:typescript
class ArticleService {
constructor() {
// ArticleServiceの内部でSQLiteArticleRepositoryが生成されている
this.#articleRepository = new SQLiteArticleRepository(new Database("/path/to/db"));
}
async addArticle(article: Article) {
await this.#articleRepository.add(article);
// ...
}
}
このようにすると、ArticleServiceがSQLiteArticleRepositoryの生成方法などの実装の詳細に関する知識を持つことになり、結合度も高くなってしまいます。また、カプセル化やポリモーフィズムといったOOPにおける大きなメリットも損なわれてしまっている状態です。 上記の例ではconstructor内でSQLiteArticleRepositoryのインスタンスを直接生成していましたが、以下のように他のファイルからSQLiteArticleRepositoryのインスタンスをimportして利用しているようなケースでも同様の問題が発生します。
code:typescript
import { sqliteArticleRepository } from "@/infra";
class ArticleService {
async addArticle(article: Article) {
// 別のファイルでexportされているSQLiteArticleRepositoryを直接使用している
await sqliteArticleRepository.add(article);
// ...
}
}
先程のDIが利用されていない例で登場したコードを以下のように変更してみましょう。 code:typescript
class ArticleService {
constructor(articleRepository: ArticleRepository) {
this.#articleRepository = articleRepository;
}
}
DIを利用しないコードにおいてはArticleRepositoryを実装したSQLiteArticleRepositoryをArticleServiceで直接生成または依存する形になっていました。これはArticleServiceがSQLiteArticleRepisotyの実装の詳細に強く依存した状態です。上記のコードではconstructorを介してArticleRepositoryのinterfaceを受け取る形へ代わっています。こうすることで、ArticleServiceがSQLiteArticleRepisotyという詳細ではなく、ArticleRepositoryという抽象に依存した形になります。 あるオブジェクトの生成方法というのは実装の詳細に当たるものです。ArticleServiceはArticleRepositoryがどのように実装されているかを知る必要はなく、どのように生成すべきかについても知っておく必要はありません。このように分離することで、実装の詳細を切り離すことができ、結合度を低下させられます また、こうすることで、ArticleRepositoryを実装さえしていればどのようなオブジェクトでも渡せるため、用途に応じて実装を柔軟に差し替えることもできるようになります
例えば、テストコードを記述する際はフェイク実装やスタブなどに差し替えることもできます
code:typescript
// フェイク実装を利用する
const articleRepository = new InMemoryArticleRepository();
const articleService = new ArticleService(articleRepository);
// testdouble.jsを使用したスタブまたはモックへの差し替え
import td from "testdouble";
const articleRepository = td.object<ArticleRepository>();
const articleService = new ArticleService(articleRepository);
基本
上記のように、DIそのものは複雑なテクニックではなくとても単純な仕組みで、特にライブラリやフレームワークなどを利用せずとも導入できます あるオブジェクトが依存するオブジェクトは、自分自身で生成したりグローバル参照を介して依存をさせず、代わりにコンストラクタ引数を介して受け渡すようにします
コンストラクタ引数を介して受け取るオブジェクトの型をinterfaceで宣言しておくことで、結合度を低下させることができ、再利用性やテストの容易性などの改善につながります。 DIのメリット/デメリット
メリット
結合度が低下し、モジュールのテスタビリティや再利用性の向上、変更による影響範囲の局所化などに繋がります これは大規模なアプリケーションにおいては恩恵を受けやすいかと思います
例えば、上記の例で出てきたSQLiteArticleRepositoryにキャッシュの仕組みを導入したいとします
まず、キャッシュに関するinterfaceを定義しておきます
code:typescript
export interface Cache {
get(key: string): Promise<CacheEntry | null>;
set(key: string, value: unknown): Promise<void>;
delete(key: string): Promise<void>;
}
export interface CacheEntry {
key: string;
value: unknown;
}
次に上記のCacheをSQLiteArticleRepositoryに導入します。この際も、constructor経由でCacheのinterfaceを受け取らせます。
code:typescript
export class SQLiteArticleRepository implements ArticleRepository {
constructor(database: Database, cache: Cache) {
this.#database = database;
this.#cache = cache;
}
async get(id: ArticleID): Promise<Article> {
const maybeCacheEntry = await this.#cache.get(id.toString());
if (maybeCacheEntry != null) {
return this.#parseCacheEntry(cacheEntry);
}
const article = await this.#getArticleFromDB(id);
await this.#cache.set(id.toString(), this.#serializeArticle(article));
return article;
}
// 省略...
}
この変更は、キャッシュに依存したSQLiteArticleRepositoryの内部のみで完結しており、SQLiteArticleRepositoryに依存したArticleServiceなどにはまったくコードの変更が行われていません
同様に、キャッシュを削除する場合でも透過的に変更を行うことができます
また、SQLiteArticleRepositoryはCacheのinterface(抽象)に依存しており、具体的な実装には依存していません。そのため、状況などに応じて柔軟に実装を差し替えることもできます。
code:typescript
// 本番用コードではRedisベースのCache実装を使う
const redisCache = new RedisCache(redisClient);
const articleRepository = new SQLiteArticleRepository(
database,
redisCache,
);
const articleService = new ArticleService(articleRepository);
// テストなどではインメモリ実装を使う
const inMemoryCache = new InMemoryCache();
const articleRepository = new SQLiteArticleRepository(
database,
inMemoryCache,
);
DIによって、このように変更の範囲を局所化したり、また実装を柔軟に変更したりすることが可能です ※(補足) 実際のアプリケーションを開発していく上では、上記の例で紹介したようなキャッシュの仕組みはSQLiteArticleRepositoryではなくArticleServiceの方に導入した方が適切なケースもあるかと思います。その場合も、ArticleServiceにはRedisCacheやInMemoryCacheなどの具体的な実装ではなく、Cacheinterfaceに依存させるとよいでしょう。
デメリット
小規模なアプリなどの場合、やや面倒に感じるかもしれません
DIを採用すると、そうでない場合と比較して記述が面倒になってしまうのは事実だと思います ただし、メンテナンス性を向上させるための様々な手法やライブラリというのは、ここで紹介したDIも含めて、大抵の場合、面倒になりがちなものだと思います 例:
ただし、こういったメンテナンス性を改善するための手法やライブラリなどは大抵、きちんとした背景があってそのようになっている場合がほとんどだと思います
そのため、アプリケーションや組織の規模などに応じて、採用する/しないを判断すると良いと思います
そのため、アプリケーションの規模などに応じて採用する/しないは判断するのがよいと思います
例えば、使い捨てのスクリプトなどを書く際はDIを使わなくてもまったく問題はありません 特定のフレームワークや言語においては、DIは一般的ではないかと思います 例えばRailsを使う場合は、素直にRails wayに従う方がよいかと思います 逆に、フレームワークの機能としてDIが提供されているケースもあります。そのような場合は、積極的に活用していくと良いのではないかと思います 有名なところで言うと、以下のようなフレームワークなどでDIが提供されています DIの手法
手動DI
このページで示したコード例における手法です
主にアプリケーションのエントリポイントなどで、依存関係をあらかじめ手動でまとめて注入します
code:typescript:src/main.ts
async function bootstrap() {
const config = await loadApplicationConfig();
const database = new Database(config.database.path);
const articleRepository = new SQLiteArticleRepository(database);
const articleService = new ArticleService(articleRepository);
const articleController = new ArticleController(articleService);
const apiServer = new APIServer(config, articleController);
await apiServer.listen();
}
if (import.meta.main) {
await bootstrap();
}
依存関係を要求せずシンプルに実現ができ、柔軟なことなどがメリットだと思います
DIコンテナ
DIコンテナは依存関係の注入やオブジェクトのライフサイクル管理などを自動化する機能などを提供してくれます
それ以外のフレームワークなどでDIコンテナを使いたいときは、下記パッケージなどの使用を検討してみるとよいでしょう 手動でDIをするかDIコンテナを採用するかは好みの部分なども大きいと思うので、必要に応じて採用は判断するとよいと思います コンストラクタインジェクション
このページで紹介したように、コンストラクタを介して依存オブジェクトを注入する方法です
手動DI/DIコンテナどちらの方法においてもコンストラクタインジェクションは利用可能です setterインジェクション
以下のようにsetterメソッドを介して依存オブジェクトを注入する手法です
code:typescript
const parser = new Parser();
const lexer = new Lexer();
parser.setLexer(lexer); // setterを介して注入する
基本的にこの方法は推奨されません
setterを公開することにより注入を許可すると、そのオブジェクトの実装の詳細を外部に晒すことになってしまうためです (カプセル化によるメリットが損なわれてしまう) 特別な理由が無い限りはコンストラクタインジェクションを利用するとよいでしょう
code:typescript
const parser = new Parser(new Lexer()); // コンストラクターを介して注入する
プロパティインジェクション
特定のプロパティに対して依存関係を注入する方法です
code:typescript
@injectable()
class SQLiteUserRepository implements UserRepository {
@inject(TYPES.DB_CONNECTION) private connection: DBConnection;
}
これについてもsetterインジェクションと同様の利用で推奨はされず、コンストラクタインジェクションを利用するのが望ましいでしょう
個人的にはsetterインジェクションやプロパティインジェクションというのは、既存のDIが導入されていないアプリケーションにおいて、段階的にDIを導入していきたいようなケースで活用すると良いのではないかと思っています その他のアプローチ・類似テクニック
あるオブジェクトや関数が依存するものは基本的に引数を介してやり取りさせるというのは、DIに限らず、結合度を低下させるための基本的な手法だと思います ファクトリ関数を使ったテクニック
code:javascript
import { redisCache } from '@/utils/redis-cache';
const cacheMiddleware = async (ctx, next) => {
const key = generateCacheKey(ctx);
const cachedItem = await redisCache.get(key);
...
};
app.use(cacheMiddleware);
上記コードはcacheMiddlewareがグローバルのredisCacheインスタンスに直接依存しています
これを次のように変更してみます (JavaScriptにおいては、こういった特定のオブジェクトなどを生成する役割を持つ関数のことをファクトリ関数などと呼ぶケースがよく見かけられます) code:typescript
import type { Cache } from "@/types/cache";
// middlewareを作成するファクトリ関数(createCacheMiddleware)を定義しています
// この関数にはCacheインターフェースを実装したオブジェクトを引数として受け取らせます
const createCacheMiddleware = (cache: Cache) => async (ctx, next) => {
const key = generateCacheKey(ctx);
const cachedItem = await cache.get(key);
...
};
const redisCache = new RedisCache(redisClient);
const cacheMiddleware = createCacheMiddleware(redisCache);
app.use(cacheMiddleware);
こうすることで、用途に応じてcacheをRedisベース以外の実装に差し替えたり、複数のミドルウェアを作成したいときなどにも柔軟に対応できます。
この例ではミドルウェアを例に説明しましたが、それ以外でも流用できます。
withExtraArgumentを使うと、thunkアクションに対して依存オブジェクトなどを注入できます
code:javascript
import { createStore, applyMiddleware } from 'redux';
import thunk from 'redux-thunk';
const deps = { api };
const store = createStore(
reducer,
applyMiddleware(thunk.withExtraArgument(deps)),
);
function getArticle(id) {
return (dispatch, getState, { api }) => {
api.getArticle(id)
.then(...)
.catch(...)
};
}
関連ページ
以下のあたり本にもっと詳しく書かれています