依存性注入の実用的な紹介

公開: 2022-03-10
簡単な要約↬この記事は、理論に邪魔されることなく多くの利点をすぐに実現できるように、依存性注入の実用的な紹介を提供する次のシリーズの最初の部分です。

依存性注入の概念は、基本的に単純な概念です。 ただし、一般的には、制御の反転、依存性逆転、SOLID原則などのより理論的な概念と一緒に提示されます。 依存性注入の使用をできるだけ簡単に開始してその利点を享受できるようにするために、この記事は、主にその使用の利点を正確に示す例を示して、ストーリーの実用的な側面にとどまります。関連する理論から離婚した。

ここでは、依存性注入を取り巻く学術的概念について説明する時間はごくわずかです。その説明の大部分は、このシリーズの2番目の記事のために予約されています。 確かに、本全体は、概念のより詳細で厳密な取り扱いを提供するように書かれている可能性があり、書かれています。

ここでは、簡単な説明から始めて、さらにいくつかの実際の例に移り、次にいくつかの背景情報について説明します。 別の記事(これに続く)では、依存性注入がベストプラクティスのアーキテクチャパターンを適用するエコシステム全体にどのように適合するかについて説明します。

簡単な説明

「依存性注入」は、非常に単純な概念を表す非常に複雑な用語です。 この時点で、いくつかの賢明で合理的な質問は、「「依存関係」をどのように定義しますか?」、「依存関係が「注入される」とはどういう意味ですか?」、「さまざまな方法で依存関係を注入できますか?」です。 と「なぜこれが便利なのですか?」 「依存性注入」などの用語が2つのコードスニペットと2つの単語で説明できるとは思わないかもしれませんが、残念ながらそれは可能です。

概念を説明する最も簡単な方法は、あなたに見せることです。

たとえば、これは依存性注入ではありません

 import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }

しかし、これ依存性注入です。

 import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }

終わり。 それでおしまい。 いいね。 終わり。

何が変わったの? CarクラスがEngineをインスタンス化できるようにするのではなく(最初の例のように)、2番目の例では、 CarEngineのインスタンスが、より高いレベルの制御からコンストラクターに渡されるか、注入されました。 それでおしまい。 基本的に、これはすべての依存性注入です—依存性を別のクラスまたは関数に注入(渡す)する行為です。 依存性注入の概念を含む他のすべてのものは、この基本的で単純な概念の単なるバリエーションです。 簡単に言えば、依存性注入は、オブジェクトがそれ自体を作成するのではなく、依存関係と呼ばれる、依存する他のオブジェクトを受け取る手法です。

一般に、「依存関係」とは何かを定義するために、あるクラスAがクラスBの機能を使用する場合、 BAの依存関係です。つまり、 ABに依存関係があります。 もちろん、これはクラスに限定されず、関数にも当てはまります。 この場合、クラスCarEngineクラスに依存しているか、 EngineCarに依存しています。 依存関係は、プログラミングのほとんどのものと同じように、単なる変数です。

依存性注入は、多くのユースケースをサポートするために広く使用されていますが、おそらく最も露骨な使用法は、より簡単なテストを可能にすることです。 最初の例では、 Carクラスがエンジンをインスタンス化するため、 engineを簡単にモックアウトすることはできません。 実際のエンジンは常に使用されています。 ただし、後者の場合、使用するEngineを制御できます。つまり、テストでは、 Engineをサブクラス化し、そのメソッドをオーバーライドできます。

たとえば、engine.fireCylinders()がエラーをスローした場合にengine.fireCylinders() Car.startEngine()が何をするかを確認したい場合は、 FakeEngineクラスを作成し、 Engineクラスを拡張してから、 fireCylindersをオーバーライドしてエラーをスローすることができます。 。 テストでは、そのFakeEngineオブジェクトをCarのコンストラクターに注入できます。 FakeEngineは継承を意味するEngineであるため、TypeScript型システムが満たされます。 後で説明するように、継承とメソッドのオーバーライドを使用することが必ずしもこれを行うための最良の方法であるとは限りませんが、それは確かにオプションです。

上に表示されているのは、依存性注入の中心的な概念であることを非常に明確にしたいと思います。 Car自体は、必要なエンジンを知るのに十分なほど賢くはありません。 車を製造するエンジニアだけが、エンジンとホイールの要件を理解しています。 したがって、車を製造する人が、使用したいエンジンをCar自体に選択させるのではなく、必要な特定のエンジンを提供することは理にかなっています。

「構築する」という言葉を使用するのは、依存関係が注入される場所であるコンストラクターを呼び出して車を構築するためです。 車がエンジンに加えて独自のタイヤも作成した場合、使用されているタイヤがエンジンが出力できる最大RPMで安全に回転できることをどのようにして知ることができますか? これらすべての理由とそれ以上の理由から、おそらく直感的に、 CarはどのEngineとどのWheelsを使用するかを決定することとは何の関係もないはずです。 それらは、より高いレベルの制御から提供されるべきです。

依存性注入の動作を示す後者の例では、 Engineが具体的なクラスではなく抽象クラスであると想像すると、これはさらに理にかなっているはずです。車はエンジンが必要であることを認識しており、エンジンには基本的な機能が必要であることを認識しています。 、しかし、そのエンジンがどのように管理され、その特定の実装が何であるかは、車を作成(構築)するコードによって決定および提供されるために予約されています。

ジャンプした後もっと! 以下を読み続けてください↓

実際の例

依存性注入が有用である理由をもう一度直感的に説明するのに役立つと思われる、さらにいくつかの実用的な例を見ていきます。 うまくいけば、理論にとらわれず、代わりに適用可能な概念に直接移行することで、依存性注入が提供する利点と、それなしでの生活の難しさをより完全に理解できます。 後で、トピックのもう少し「アカデミック」な扱いに戻ります。

まず、依存性の注入や抽象化を利用せずに、高度に結合された方法でアプリケーションを構築することから始めます。これにより、このアプローチの欠点と、テストに追加される難しさがわかります。 その過程で、すべての問題を修正するまで徐々にリファクタリングします。

まず、2つのクラス(電子メールプロバイダーと、一部のUserServiceで使用する必要のあるデータアクセス層のクラス)の構築を担当しているとします。 データアクセスから始めますが、どちらも簡単に定義できます。

 // UserRepository.ts import { dbDriver } from 'pg-driver'; export class UserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }

注:ここでの「リポジトリ」という名前は、データベースをビジネスロジックから切り離す方法である「リポジトリパターン」に由来しています。 リポジトリパターンについて詳しく知ることができますが、この記事では、データベースをカプセル化するクラスと見なすことができるため、ビジネスロジックでは、データストレージシステムは単なるメモリ内として扱われます。コレクション。 リポジトリパターンを完全に説明することは、この記事の範囲外です。

これは通常、動作することを期待する方法であり、 dbDriverはファイル内にハードコードされています。

UserServiceで、クラスをインポートしてインスタンス化し、使用を開始します。

 import { UserRepository } from './UserRepository.ts'; class UserService { private readonly userRepository: UserRepository; public constructor () { // Not dependency injection. this.userRepository = new UserRepository(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send a welcome email // ... } public async findUserById(id: string): Promise<User> { // No need for await here, the promise will be unwrapped by the caller. return this.userRepository.findUserById(id); } }

繰り返しますが、すべてが正常なままです。

簡単に言うと、DTOはデータ転送オブジェクトです。これは、2つの外部システム間またはアプリケーションの2つのレイヤー間を移動するときに、標準化されたデータ形状を定義するプロパティバッグとして機能するオブジェクトです。 DTOの詳細については、このトピックに関するMartinFowlerの記事を参照してください。 この場合、 IRegisterUserDtoは、データがクライアントから送信されるときにデータの形状がどうあるべきかについてのコントラクトを定義します。 idemailの2つのプロパティしか含まれていません。 まだユーザーを作成していなくても、クライアントに新しいユーザーを作成するために期待するDTOにユーザーのIDが含まれているのは奇妙だと思うかもしれません。 IDはUUIDであり、この記事の範囲外であるさまざまな理由でクライアントがIDを生成できるようにします。 さらに、 findUserById関数はUserオブジェクトを応答DTOにマップする必要がありますが、簡潔にするためにそれを無視しました。 最後に、現実の世界では、 UserドメインモデルにfromDtoメソッドを含めることはできません。 これはドメインの純度には良くありません。 ここでも、その目的は簡潔にすることです。

次に、メールの送信を処理します。 繰り返しになりますが、通常どおり、メールプロバイダークラスを作成してUserServiceにインポートするだけです。

 // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }

UserService内:

 import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor () { // Still not doing dependency injection. this.userRepository = new UserRepository(); this.sendGridEmailProvider = new SendGridEmailProvider(); } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }

私たちは今、完全に労働者階級を持っており、テスト可能性や定義の方法によってクリーンなコードを書くことをまったく気にしない世界、そして技術的負債が存在せず厄介なプログラムマネージャーがいない世界では期限を設定します。これはまったく問題ありません。 残念ながら、それは私たちが住むことのメリットがある世界ではありません。

メール用にSendGridから移行し、代わりにMailChimpを使用する必要があると判断した場合はどうなりますか? 同様に、メソッドを単体テストしたい場合はどうなりますか?テストで実際のデータベースを使用しますか? さらに悪いことに、実際に実際の電子メールを潜在的に実際の電子メールアドレスに送信し、それに対しても支払いを行うのでしょうか。

従来のJavaScriptエコシステムでは、この構成での単体テストクラスのメソッドは、複雑さと過剰なエンジニアリングに悩まされています。 人々は単にスタブ機能を提供するためにライブラリ全体を持ち込みます。これにより、あらゆる種類の間接層が追加され、さらに悪いことに、テストがテスト対象のシステムの実装に直接結合される可能性があります。実際のシステムは機能します(これはブラックボックステストとして知られています)。 UserServiceの実際の責任について話し合い、依存性注入の新しい手法を適用しながら、これらの問題の軽減に取り組みます。

少しの間、 UserServiceが何をするかを考えてみてください。 UserServiceの存在の要点は、ユーザーが関与する特定のユースケースを実行することです。つまり、ユーザーの登録、読み取り、更新などです。クラスと関数が1つの責任のみを持つことがベストプラクティスです(SRP —単一責任原則)。 UserServiceの責任は、ユーザー関連の操作を処理することです。 では、なぜこの例ではUserServiceUserRepositorySendGridEmailProviderの存続期間を制御する責任があるのでしょうか。

長時間実行される接続を開くUserServiceによって使用される他のクラスがある場合を想像してみてください。 UserServiceは、その接続の破棄にも責任を負う必要がありますか? もちろん違います。 これらの依存関係にはすべて、ライフタイムが関連付けられています。シングルトンの場合もあれば、一時的で特定のHTTPリクエストにスコープされる場合もあります。これらのライフタイムの制御は、 UserServiceの範囲外です。 したがって、これらの問題を解決するために、前に見たように、すべての依存関係をに注入します。

 import { UserRepository } from './UserRepository.ts'; import { SendGridEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: UserRepository; private readonly sendGridEmailProvider: SendGridEmailProvider; public constructor ( userRepository: UserRepository, sendGridEmailProvider: SendGridEmailProvider ) { // Yay! Dependencies are injected. this.userRepository = userRepository; this.sendGridEmailProvider = sendGridEmailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.sendGridEmailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }

すごい! これで、 UserServiceは事前にインスタンス化されたオブジェクトを受け取り、新しいUserServiceを呼び出して作成するコードは、依存関係の存続期間の制御を担当するコードです。 制御をUserServiceからより高いレベルに戻しました。 依存性注入の基本的なテナントを説明するために、コンストラクターを介して依存性を注入する方法を示したいだけの場合は、ここで停止できます。 設計の観点からはまだいくつかの問題がありますが、修正すると、依存性注入の使用がさらに強力になります。

まず、 UserServiceがメールにSendGridを使用していることを知っているのはなぜですか? 次に、両方の依存関係は具象クラス(具象UserRepositoryと具象SendGridEmailProvider )に依存します。 この関係はあまりにも厳格ですSendGridEmailProviderでありUserRepositoryであるオブジェクトを渡す必要があります。

UserServiceが依存関係の実装に完全に依存しないようにしたいので、これは素晴らしいことではありません。 このようにUserServiceをブラインドにすることで、サービスにまったく影響を与えることなく実装を交換できます。つまり、SendGridから移行して、代わりにMailChimpを使用することにした場合は、そうすることができます。 また、テストのために電子メールプロバイダーを偽造したい場合は、それも実行できることを意味します。

いくつかのパブリックインターフェイスを定義し、 UserServiceが実装の詳細に依存しないようにしながら、着信依存関係がそのインターフェイスに従うように強制できると便利です。 言い換えると、 UserServiceが依存関係の抽象化のみに依存するように強制する必要があり、実際の具体的な依存関係には依存しません。 それは、インターフェースを介して行うことができます。

UserRepositoryのインターフェースを定義することから始めて、それを実装します。

 // UserRepository.ts import { dbDriver } from 'pg-driver'; export interface IUserRepository { addUser(user: User): Promise<void>; findUserById(id: string): Promise<User>; existsByEmail(email: string): Promise<boolean>; } export class UserRepository implements IUserRepository { public async addUser(user: User): Promise<void> { // ... dbDriver.save(...) } public async findUserById(id: string): Promise<User> { // ... dbDriver.query(...) } public async existsByEmail(email: string): Promise<boolean> { // ... dbDriver.save(...) } }

また、電子メールプロバイダー用に1つ定義し、それも実装します。

 // IEmailProvider.ts export interface IEmailProvider { sendWelcomeEmail(to: string): Promise<void>; } // SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; import { IEmailProvider } from './IEmailProvider'; export class SendGridEmailProvider implements IEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }

注:これは、4つのデザインパターンのギャングからのアダプターパターンです。

これで、 UserServiceは、依存関係の具体的な実装ではなく、インターフェースに依存できるようになりました。

 import { IUserRepository } from './UserRepository.ts'; import { IEmailProvider } from './SendGridEmailProvider.ts'; class UserService { private readonly userRepository: IUserRepository; private readonly emailProvider: IEmailProvider; public constructor ( userRepository: IUserRepository, emailProvider: IEmailProvider ) { // Double yay! Injecting dependencies and coding against interfaces. this.userRepository = userRepository; this.emailProvider = emailProvider; } public async registerUser(dto: IRegisterUserDto): Promise<void> { // User object & validation const user = User.fromDto(dto); if (await this.userRepository.existsByEmail(dto.email)) return Promise.reject(new DuplicateEmailError()); // Database persistence await this.userRepository.addUser(user); // Send welcome email await this.emailProvider.sendWelcomeEmail(user.email); } public async findUserById(id: string): Promise<User> { return this.userRepository.findUserById(id); } }

インターフェイスが初めての場合、これは非常に複雑に見えるかもしれません。 確かに、緩く結合されたソフトウェアを構築するという概念は、あなたにとっても新しいかもしれません。 壁のレセプタクルについて考えてみてください。 プラグがコンセントに収まる限り、任意のデバイスを任意のコンセントに接続できます。 これは、動作中の緩い結合です。 トースターが壁に固定されていない場合は、固定されていて、トースターをアップグレードすることにした場合、運が悪いことになります。 代わりに、アウトレットが使用され、アウトレットがインターフェイスを定義します。 同様に、電子機器を壁のコンセントに差し込むときは、電位、最大消費電流、AC周波数などは気にせず、プラグがコンセントに合うかどうかだけを気にします。 電気技師に来て、そのコンセントの後ろにあるすべてのワイヤーを交換してもらうことができます。そのコンセントが変わらない限り、トースターを接続するのに問題はありません。 さらに、あなたの電源は都市またはあなた自身のソーラーパネルから来るように切り替えることができます、そしてもう一度、あなたがまだそのコンセントに差し込むことができる限りあなたは気にしません。

インターフェイスはアウトレットであり、「プラグアンドプレイ」機能を提供します。 この例では、壁と電源の配線は依存関係に似ており、トースターはUserServiceに似ています(電気に依存しています)—電源は変更できますが、トースターは正常に機能し、必要はありません。インターフェースとして機能するコンセントは、両方が通信するための標準的な手段を定義しているため、触れてください。 実際、コンセントは壁の配線、回路ブレーカー、電源などの「抽象化」として機能していると言えます。

上記の理由から、実装ではなくインターフェース(抽象化)に対してコーディングすることは、ソフトウェア設計の一般的で評判の良い原則です。これは、ここで行ったことです。 そうすることで、実装を自由に交換できるようになります。これらの実装はインターフェイスの背後に隠されているため(壁の配線がコンセントの背後に隠されているように)、依存関係を使用するビジネスロジックで行う必要はありません。インターフェイスが変更されない限り変更します。 UserServiceは、その機能がバックグラウンドでどのようにサポートされているかではなく、依存関係によって提供される機能のみを知る必要があることを忘れないでください。 そのため、インターフェイスの使用が機能します。

インターフェースの利用と依存性の注入というこれらの2つの単純な変更は、疎結合ソフトウェアの構築に関して世界にすべての違いをもたらし、上記で遭遇したすべての問題を解決します。

明日、メールをMailchimpに依存することにした場合は、 IEmailProviderインターフェイスを尊重する新しいMailchimpクラスを作成し、SendGridの代わりに挿入するだけです。 新しい電子メールプロバイダーに切り替えることでシステムに大幅な変更を加えたばかりであっても、実際のUserServiceクラスを変更する必要はありません。 これらのパターンの利点は、 UserServiceが、使用する依存関係が舞台裏でどのように機能するかを幸いにも認識していないことです。 インターフェイスは、両方のコンポーネント間のアーキテクチャの境界として機能し、適切に分離された状態を維持します。

さらに、テストに関しては、インターフェイスに準拠する偽物を作成し、代わりにそれらを注入することができます。 ここでは、偽のリポジトリと偽の電子メールプロバイダーを見ることができます。

 // Both fakes: class FakeUserRepository implements IUserRepository { private readonly users: User[] = []; public async addUser(user: User): Promise<void> { this.users.push(user); } public async findUserById(id: string): Promise<User> { const userOrNone = this.users.find(u => u.id === id); return userOrNone ? Promise.resolve(userOrNone) : Promise.reject(new NotFoundError()); } public async existsByEmail(email: string): Promise<boolean> { return Boolean(this.users.find(u => u.email === email)); } public getPersistedUserCount = () => this.users.length; } class FakeEmailProvider implements IEmailProvider { private readonly emailRecipients: string[] = []; public async sendWelcomeEmail(to: string): Promise<void> { this.emailRecipients.push(to); } public wasEmailSentToRecipient = (recipient: string) => Boolean(this.emailRecipients.find(r => r === recipient)); }

両方の偽物が、 UserServiceが依存関係を尊重することを期待しているのと同じインターフェースを実装していることに注意してください。 これで、これらの偽物を実際のクラスの代わりにUserServiceに渡すことができ、 UserServiceは賢明ではありません。 まるで本物のように使用します。 それができる理由は、依存関係で使用したいすべてのメソッドとプロパティが実際に存在し、実際にアクセス可能であることを知っているためです(インターフェイスを実装しているため)。これは、 UserServiceが知る必要があるすべてです(つまり、依存関係がどのように機能するか)。

テスト中にこれら2つを注入します。これにより、テストプロセスが、Jest自身の内部で作業する、オーバーザトップのモックおよびスタブライブラリを処理するときに慣れているものよりもはるかに簡単かつ簡単になります。ツーリング、またはモンキーパッチを試みます。

偽物を使用した実際のテストは次のとおりです。

 // Fakes let fakeUserRepository: FakeUserRepository; let fakeEmailProvider: FakeEmailProvider; // SUT let userService: UserService; // We want to clean out the internal arrays of both fakes // before each test. beforeEach(() => { fakeUserRepository = new FakeUserRepository(); fakeEmailProvider = new FakeEmailProvider(); userService = new UserService(fakeUserRepository, fakeEmailProvider); }); // A factory to easily create DTOs. // Here, we have the optional choice of overriding the defaults // thanks to the built in `Partial` utility type of TypeScript. function createSeedRegisterUserDto(opts?: Partial<IRegisterUserDto>): IRegisterUserDto { return { id: 'someId', email: '[email protected]', ...opts }; } test('should correctly persist a user and send an email', async () => { // Arrange const dto = createSeedRegisterUserDto(); // Act await userService.registerUser(dto); // Assert const expectedUser = User.fromDto(dto); const persistedUser = await fakeUserRepository.findUserById(dto.id); const wasEmailSent = fakeEmailProvider.wasEmailSentToRecipient(dto.email); expect(persistedUser).toEqual(expectedUser); expect(wasEmailSent).toBe(true); }); test('should reject with a DuplicateEmailError if an email already exists', async () => { // Arrange const existingEmail = '[email protected]'; const dto = createSeedRegisterUserDto({ email: existingEmail }); const existingUser = User.fromDto(dto); await fakeUserRepository.addUser(existingUser); // Act, Assert await expect(userService.registerUser(dto)) .rejects.toBeInstanceOf(DuplicateEmailError); expect(fakeUserRepository.getPersistedUserCount()).toBe(1); }); test('should correctly return a user', async () => { // Arrange const user = User.fromDto(createSeedRegisterUserDto()); await fakeUserRepository.addUser(user); // Act const receivedUser = await userService.findUserById(user.id); // Assert expect(receivedUser).toEqual(user); });

ここでいくつか気付くでしょう:手書きの偽物はとてもシンプルです。 難読化するだけのフレームワークをモックすることによる複雑さはありません。 すべてが手作業で行われるため、コードベースに魔法はありません。 非同期動作は、インターフェイスに一致するように偽造されています。 すべての動作が同期しているにもかかわらず、テストでasync / awaitを使用します。これは、実際の操作が実際に機能することを期待する方法とより密接に一致していると感じ、async / awaitを追加することで、この同じテストスイートを実行できるためです。偽物に加えて実際の実装に対しても、非同期を適切に処理する必要があります。 実際、実際には、データベースをモックすることを心配することすらなく、パフォーマンスのためにデータベースをモックする必要があるほど多くのテストが行​​われるまで、DockerコンテナーでローカルDBを使用する可能性があります。 次に、変更を加えるたびにメモリ内DBテストを実行し、変更をコミットする直前とCI/CDパイプラインのビルドサーバーで実際のローカルDBテストを予約できます。

最初のテストの「配置」セクションでは、DTOを作成するだけです。 「動作」セクションでは、テスト対象のシステムを呼び出し、その動作を実行します。 アサーションを作成するとき、物事は少し複雑になります。 テストのこの時点では、ユーザーが正しく保存されたかどうかさえわかりません。 したがって、永続化されたユーザーがどのように見えるかを定義し、次に偽のリポジトリを呼び出して、期待するIDを持つユーザーを要求します。 UserServiceがユーザーを正しく永続化しない場合、これはNotFoundErrorをスローし、テストは失敗します。そうでない場合、ユーザーを返します。 次に、偽の電子メールプロバイダーに電話して、そのユーザーへの電子メールの送信を記録したかどうかを尋ねます。 最後に、Jestを使用してアサーションを作成し、テストを終了します。 それは表現力豊かで、システムが実際にどのように機能しているかのように読みます。 ライブラリのモックからの間接参照はなく、 UserServiceの実装への結合もありません。

2番目のテストでは、既存のユーザーを作成してリポジトリに追加し、ユーザーの作成と永続化にすでに使用されているDTOを使用してサービスを再度呼び出しようとしますが、失敗すると予想されます。 また、リポジトリに新しいデータが追加されなかったことも主張します。

3番目のテストでは、「配置」セクションは、ユーザーを作成し、それを偽のリポジトリに永続化することで構成されています。 次に、SUTを呼び出し、最後に、戻ってきたユーザーが以前にリポジトリに保存したユーザーであるかどうかを確認します。

これらの例は比較的単純ですが、状況がさらに複雑になると、このように依存性注入とインターフェースに依存できるため、コードがクリーンに保たれ、テストの作成が楽しくなります。

テストについて簡単に説明します。一般に、コードが使用するすべての依存関係をモックアウトする必要はありません。 多くの人々は、誤って、「ユニットテスト」の「ユニット」は1つの関数または1つのクラスであると主張します。 それはもっと間違っているはずがありません。 「ユニット」は、1つの関数またはクラスではなく、「機能のユニット」または「動作のユニット」として定義されます。 したがって、動作の単位が5つの異なるクラスを使用する場合、モジュールの境界の外側に到達しない限り、これらのクラスをすべてモックアウトする必要はありません。 この場合、選択の余地がないため、データベースをモックし、電子メールプロバイダーをモックしました。 実際のデータベースを使用したくない場合や、電子メールを送信したくない場合は、それらをモックアウトする必要があります。 しかし、ネットワーク全体で何も実行しないクラスがさらにたくさんある場合、それらは動作単位の実装の詳細であるため、それらをモックすることはありません。 また、データベースと電子メールをモックすることをやめ、実際のローカルデータベースと実際のSMTPサーバーを両方ともDockerコンテナーで起動することもできます。 最初の点では、実際のデータベースを使用しても、速度が遅すぎない限り、単体テストと呼んでも問題ありません。 一般に、前述のように、遅くなりすぎてモックを作成するまで、最初に実際のDBを使用します。 ただし、何をするにしても、実用的である必要があります。ウェルカムメールの送信はミッションクリティカルな操作ではないため、Dockerコンテナ内のSMTPサーバーに関してはそれほど遠くまで行く必要はありません。 私がモックを行うときはいつでも、非常にまれな場合を除いて、モックフレームワークを使用したり、呼び出された回数やパラメーターが渡されたりすることを主張しようとすることはほとんどありません。それらの詳細にとらわれないはずです。

クラスとコンストラクターを使用せずに依存性注入を実行する

これまでのところ、この記事全体を通して、クラスのみを扱い、コンストラクターを介して依存関係を注入してきました。 開発に機能的なアプローチを取り、クラスを使用したくない場合でも、関数の引数を使用して依存性注入の利点を得ることができます。 たとえば、上記のUserServiceクラスは次のようにリファクタリングできます。

 function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }

これは、依存関係を受け取り、サービスオブジェクトを構築するファクトリです。 高階関数に依存関係を注入することもできます。 典型的な例は、 UserRepositoryILoggerを注入するExpressミドルウェア関数を作成することです。

 function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }

最初の例では、 dtoidのタイプを定義しませんでした。これは、サービスのメソッドシグネチャを含むIUserServiceというインターフェイスを定義すると、TSコンパイラがタイプを自動的に推測するためです。 同様に、Expressミドルウェアの関数シグネチャをauthProviderの戻り型として定義した場合、そこで引数型を宣言する必要もありませんでした。

電子メールプロバイダーとリポジトリも機能していると見なし、それらをハードコーディングする代わりに特定の依存関係も注入した場合、アプリケーションのルートは次のようになります。

 import { sendMail } from 'sendgrid'; async function main() { const app = express(); const dbConnection = await connectToDatabase(); // Change emailProvider to `makeMailChimpEmailProvider` whenever we want // with no changes made to dependent code. const userRepository = makeUserRepository(dbConnection); const emailProvider = makeSendGridEmailProvider(sendMail); const userService = makeUserService(userRepository, emailProvider); // Put this into another file. It's a controller action. app.post('/login', (req, res) => { await userService.registerUser(req.body as IRegisterUserDto); return res.send(); }); // Put this into another file. It's a controller action. app.delete( '/me', authProvider(userRepository, emailProvider), (req, res) => { ... } ); }

Notice that we fetch the dependencies that we need, like a database connection or third-party library functions, and then we utilize factories to make our first-party dependencies using the third-party ones. We then pass them into the dependent code. Since everything is coded against abstractions, I can swap out either userRepository or emailProvider to be any different function or class with any implementation I want (that still implements the interface correctly) and UserService will just use it with no changes needed, which, once again, is because UserService cares about nothing but the public interface of the dependencies, not how the dependencies work.

As a disclaimer, I want to point out a few things. As stated earlier, this demo was optimized for showing how dependency injection makes life easier, and thus it wasn't optimized in terms of system design best practices insofar as the patterns surrounding how Repositories and DTOs should technically be used. In real life, one has to deal with managing transactions across repositories and the DTO should generally not be passed into service methods, but rather mapped in the controller to allow the presentation layer to evolve separately from the application layer. The userSerivce.findById method here also neglects to map the User domain object to a DTO, which it should do in real life. None of this affects the DI implementation though, I simply wanted to keep the focus on the benefits of DI itself, not Repository design, Unit of Work management, or DTOs. Finally, although this may look a little like the NestJS framework in terms of the manner of doing things, it's not, and I actively discourage people from using NestJS for reasons outside the scope of this article.

A Brief Theoretical Overview

All applications are made up of collaborating components, and the manner in which those collaborators collaborate and are managed will decide how much the application will resist refactoring, resist change, and resist testing. Dependency injection mixed with coding against interfaces is a primary method (among others) of reducing the coupling of collaborators within systems, and making them easily swappable. This is the hallmark of a highly cohesive and loosely coupled design.

The individual components that make up applications in non-trivial systems must be decoupled if we want the system to be maintainable, and the way we achieve that level of decoupling, as stated above, is by depending upon abstractions, in this case, interfaces, rather than concrete implementations, and utilizing dependency injection. Doing so provides loose coupling and gives us the freedom of swapping out implementations without needing to make any changes on the side of the dependent component/collaborator and solves the problem that dependent code has no business managing the lifetime of its dependencies and shouldn't know how to create them or dispose of them. This doesn't mean that everything should be injected and no collaborators should ever be directly coupled to each other. There are certainly many cases where having that direct coupling is no problem at all, such as with utilities, mappers, models, and more.

Despite the simplicity of what we've seen thus far, there's a lot more complexity that surrounds dependency injection.

Injection of dependencies can come in many forms. Constructor Injection is what we have been using here since dependencies are injected into a constructor. There also exists Setter Injection and Interface Injection. In the case of the former, the dependent component will expose a setter method which will be used to inject the dependency — that is, it could expose a method like setUserRepository(userRepository: UserRepository) . In the last case, we can define interfaces through which to perform the injection, but I'll omit the explanation of the last technique here for brevity since we'll spend more time discussing it and more in the second article of this series.

Because wiring up dependencies manually can be difficult, various IoC Frameworks and Containers exist. These containers store your dependencies and resolve the correct ones at runtime, often through Reflection in languages like C# or Java, exposing various configuration options for dependency lifetime. Despite the benefits that IoC Containers provide, there are cases to be made for moving away from them, and only resolving dependencies manually. To hear more about this, see Greg Young's 8 Lines of Code talk.

Additionally, DI Frameworks and IoC Containers can provide too many options, and many rely on decorators or attributes to perform techniques such as setter or field injection. I look down on this kind of approach because, if you think about it intuitively, the point of dependency injection is to achieve loose coupling, but if you begin to sprinkle IoC Container-specific decorators all over your business logic, while you may have achieved decoupling from the dependency, you've inadvertently coupled yourself to the IoC Container. IoC Containers like Awilix by Jeff Hansen solve this problem since they remain divorced from your application's business logic.

結論

This article served to depict only a very practical example of dependency injection in use and mostly neglected the theoretical attributes. I did it this way in order to make it easier to understand what dependency injection is at its core in a manner divorced from the rest of the complexity that people usually associate with the concept.

In the second article of this series, we'll take a much, much more in-depth look, including at:

  • The difference between Dependency Injection and Dependency Inversion and Inversion of Control;
  • Dependency Injection anti-patterns;
  • IoC Container anti-patterns;
  • The role of IoC Containers;
  • The different types of dependency lifetimes;
  • How IoC Containers are designed;
  • Dependency Injection with React;
  • Advanced testing scenarios;
  • もっと。

乞うご期待!