TypeScriptを使ってDIについて説明する
DIとは?
Dependency Injectionの略です。
依存性の注入などとも呼びます。
コード例
まず、記事( Article
)の永続化に関して責任を持つ ArticleRepository
という interface
を定義します。
typescriptinterface ArticleRepository {
add(article: Article): Promise<void>;
get(id: ArticleID): Promise<Article>;
}
次に、
SQLiteをベースに永続化機能を実装した
SQLiteArticleRepository
を用意します。この
SQLiteArticleRepository
は上記の
ArticleRepository
の
interface
を実装します。
typescript export class SQLiteArticleRepository implements ArticleRepository {
readonly #database: Database;
constructor(database: Database) {
this.#database = database;
}
add(article: Article): Promise<void> {
...
}
get(id: ArticleID): Promise<Article> {
...
}
}
これらの interface
と class
をベースに説明していきます
先程登場した ArticleRepository
に依存する ArticleService
というクラスがあったとします。このクラスの constructor
内では ArticleRepository
の実装である SQLiteArticleRepository
が直接生成されています。
typescriptclass ArticleService {
readonly #articleRepository: ArticleRepository;
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
して利用しているようなケースでも同様の問題が発生します。
typescriptimport { sqliteArticleRepository } from "@/infra";
class ArticleService {
async addArticle(article: Article) {
// 別のファイルでexportされているSQLiteArticleRepositoryを直接使用している
await sqliteArticleRepository.add(article);
// ...
}
}
先程の
DIが利用されていない例で登場したコードを以下のように変更してみましょう。
typescriptclass ArticleService {
readonly #articleRepository: ArticleRepository;
constructor(articleRepository: ArticleRepository) {
this.#articleRepository = articleRepository;
}
}
DIを利用しないコードにおいては
ArticleRepository
を実装した
SQLiteArticleRepository
を
ArticleService
で直接生成または依存する形になっていました。これは
ArticleService
が
SQLiteArticleRepisoty
の
実装の詳細に強く依存した状態です。上記のコードでは
constructor
を介して
ArticleRepository
の
interface
を受け取る形へ代わっています。こうすることで、
ArticleService
が
SQLiteArticleRepisoty
という詳細ではなく、
ArticleRepository
という抽象に依存した形になります。
あるオブジェクトの生成方法というのは
実装の詳細に当たるものです。
ArticleService
は
ArticleRepository
がどのように実装されているかを知る必要はなく、どのように生成すべきかについても知っておく必要はありません。このように分離することで、実装の詳細を切り離すことができ、
結合度を低下させられます
また、こうすることで、 ArticleRepository
を実装さえしていればどのようなオブジェクトでも渡せるため、用途に応じて実装を柔軟に差し替えることもできるようになります
例えば、テストコードを記述する際はフェイク実装やスタブなどに差し替えることもできます
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
を定義しておきます
typescriptexport 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
を受け取らせます。
typescriptexport class SQLiteArticleRepository implements ArticleRepository {
readonly #database: Database;
readonly #cache: Cache
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
(抽象)に依存しており、具体的な実装には依存していません。そのため、状況などに応じて柔軟に実装を差し替えることもできます。
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
などの具体的な実装ではなく、 Cache
interfaceに依存させるとよいでしょう。
デメリット
小規模なアプリなどの場合、やや面倒に感じるかもしれません
DIを採用すると、そうでない場合と比較して記述が面倒になってしまうのは事実だと思います
ただし、
メンテナンス性を向上させるための様々な手法やライブラリというのは、ここで紹介したDIも含めて、大抵の場合、面倒になりがちなものだと思います
例:
ただし、こういったメンテナンス性を改善するための手法やライブラリなどは大抵、きちんとした背景があってそのようになっている場合がほとんどだと思います
そのため、アプリケーションや組織の規模などに応じて、採用する/しないを判断すると良いと思います
そのため、アプリケーションの規模などに応じて採用する/しないは判断するのがよいと思います
例えば、
使い捨てのスクリプトなどを書く際はDIを使わなくてもまったく問題はありません特定のフレームワークや言語においては、
DIは一般的ではないかと思います
例えば
Railsを使う場合は、素直にRails wayに従う方がよいかと思います
逆に、フレームワークの機能として
DIが提供されているケースもあります。そのような場合は、積極的に活用していくと良いのではないかと思います
有名なところで言うと、以下のようなフレームワークなどで
DIが提供されています
DIの手法
手動DI
このページで示したコード例における手法です
主にアプリケーションのエントリポイントなどで、依存関係をあらかじめ手動でまとめて注入します
typescript:src/main.tsasync 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メソッドを介して依存オブジェクトを注入する手法です
typescriptconst parser = new Parser();
const lexer = new Lexer();
parser.setLexer(lexer); // setterを介して注入する
基本的にこの方法は推奨されません
setterを公開することにより注入を許可すると、そのオブジェクトの
実装の詳細を外部に晒すことになってしまうためです (
カプセル化によるメリットが損なわれてしまう)
特別な理由が無い限りはコンストラクタインジェクションを利用するとよいでしょう
typescriptconst parser = new Parser(new Lexer()); // コンストラクターを介して注入する
プロパティインジェクション
特定のプロパティに対して依存関係を注入する方法です
typescript@injectable()
class SQLiteUserRepository implements UserRepository {
@inject(TYPES.DB_CONNECTION) private connection: DBConnection;
}
これについてもsetterインジェクションと同様の利用で推奨はされず、コンストラクタインジェクションを利用するのが望ましいでしょう
個人的にはsetterインジェクションやプロパティインジェクションというのは、既存の
DIが導入されていないアプリケーションにおいて、段階的に
DIを導入していきたいようなケースで活用すると良いのではないかと思っています
その他のアプローチ・類似テクニック
あるオブジェクトや関数が依存するものは基本的に引数を介してやり取りさせるというのは、
DIに限らず、
結合度を低下させるための基本的な手法だと思います
ファクトリ関数を使ったテクニック
javascriptimport { 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においては、こういった特定のオブジェクトなどを生成する役割を持つ関数のことを
ファクトリ関数などと呼ぶケースがよく見かけられます)
typescriptimport 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
アクションに対して依存オブジェクトなどを注入できます
javascriptimport { 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(...)
};
}
関連ページ
以下のあたり本にもっと詳しく書かれています