TypeScriptを使ってDIについて説明する
DIとは?
Dependency Injectionの略です。
依存性の注入などとも呼びます。
コード例
言葉で説明するとわかりにくいと思うので、実際にコード例を載せてみます。
code:typescript
interface ArticleRepository {
add(article: Article): Promise<void>;
get(id: ArticleID): Promise<Article>;
}
class SQLiteArticleRepository implements ArticleRepository {
constructor(database: Database) {
this.#database = database;
}
add(article: Article): Promise<void> {
...
}
get(id: ArticleID): Promise<Article> {
...
}
}
ArticleRepositoryに依存するArticleServiceというクラスがあったとします。
このクラスのconstructor内でArticleRepositoryの実装を生成しています。
code:typescript
class ArticleService {
constructor() {
this.#articleRepository = new SQLiteArticleRepository(new Database("/path/to/db"));
}
addArticle() {
...
}
}
このようにすると、ArticleServiceがSQLiteArticleRepositoryの生成方法などの実装の詳細に関する知識を持つことになるため、結合度が高くなってしまいます。 以下のように変更してみましょう。
code:typescript
class ArticleService {
constructor(articleRepository: ArticleRepository) {
this.#articleRepository = articleRepository;
}
}
こうすることで、ArticleServiceが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のメリット/デメリット
メリット
結合度が低下し、モジュールのテスタビリティや再利用性の向上、変更による影響範囲の局所化などに繋がります デメリット
小規模なアプリなどの場合、やや面倒に感じるかもしれないです
そういった手法は大抵、きちんとした背景があってそのようになっている場合がほとんどだと思います
そのため、アプリケーションの規模などに応じて採用する/しないは判断するのがよいと思います
例えば、使い捨てのスクリプトなどを書く際は使わなくてもまったく問題はありません
特定のフレームワークや言語においては、この手法は一般的ではないと思います
例えばRailsを使う場合は、素直にRails wayに従う方がよいかと思います DIの手法
手動DI
このページで示したコード例における手法です
主にアプリケーションのエントリポイントなどで、依存関係をあらかじめ手動でまとめて注入します
code:typescript:src/main.ts
async function bootstrap() {
const config = await loadConfig();
const database = new Database(config.database.path);
const articleRepository = new SQLiteArticleRepository(database);
const articleService = new ArticleService(articleRepository);
const articleController = new ArticleController(articleService);
// ...
}
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インジェクションと同様に推奨はされず、コンストラクタインジェクションを利用するのが望ましいでしょう
その他のアプローチ・類似テクニック
ファクトリ関数を使ったテクニック
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インスタンスに直接依存しています
これを次のように変更してみます (いわゆるファクトリ関数)
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(...)
};
}
関連ページ
この本を読むと、もっと詳しく書かれています