مقدمة عملية لحقن التبعية
نشرت: 2022-03-10يعتبر مفهوم حقن التبعية ، في جوهره ، مفهومًا بسيطًا في الأساس. ومع ذلك ، يتم تقديمه بشكل شائع بطريقة جنبًا إلى جنب مع المفاهيم الأكثر نظرية لعكس التحكم ، وانقلاب التبعية ، ومبادئ SOLID ، وما إلى ذلك. لتسهيل بدء استخدام Dependency Injection قدر الإمكان والبدء في جني فوائدها ، ستبقى هذه المقالة إلى حد كبير في الجانب العملي من القصة ، حيث تصور أمثلة توضح بدقة فوائد استخدامها ، بطريقة رئيسية مطلق من النظرية المرتبطة به.
سنقضي وقتًا قصيرًا جدًا في مناقشة المفاهيم الأكاديمية التي تحيط بحقن التبعية هنا ، لأن الجزء الأكبر من هذا التفسير سيتم حجزه للمقالة الثانية من هذه السلسلة. في الواقع ، يمكن كتابة كتب كاملة ، وقد تمت كتابتها ، مما يوفر معالجة أكثر عمقًا وصرامة للمفاهيم.
هنا ، سنبدأ بشرح بسيط ، ثم ننتقل إلى بعض الأمثلة الواقعية ، ثم نناقش بعض المعلومات الأساسية. ستناقش مقالة أخرى (لمتابعة هذا المقال) كيف يتناسب حقن التبعية مع النظام البيئي العام لتطبيق الأنماط المعمارية لأفضل الممارسات.
شرح بسيط
"حقن التبعية" مصطلح شديد التعقيد لمفهوم بسيط للغاية. في هذه المرحلة ، ستكون بعض الأسئلة الحكيمة والمعقولة "كيف تحدد" التبعية "؟" ، "ماذا يعني أن يتم" حقن "التبعية؟" ، "هل يمكنك حقن التبعيات بطرق مختلفة؟" و "لماذا هذا مفيد؟" قد لا تعتقد أنه يمكن تفسير مصطلح مثل "حقن التبعية" في جزأين من التعليمات البرمجية وكلمتين ، ولكن للأسف ، يمكن تفسير ذلك.
إن أبسط طريقة لشرح المفهوم هي أن تظهر لك.
هذا ، على سبيل المثال ، ليس حقن التبعية:
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
(كما فعلت في المثال الأول) ، في المثال الثاني ، كان لدى Car
مثيل من Engine
تم تمريره - أو تم حقنه - من مستوى أعلى من التحكم إلى مُنشئه. هذا هو. في جوهره ، هذا هو كل حقن التبعية - فعل حقن (تمرير) تبعية في فئة أو وظيفة أخرى. أي شيء آخر ينطوي على مفهوم حقن التبعية هو ببساطة اختلاف في هذا المفهوم الأساسي والبسيط. بعبارة تافهة ، حقن التبعية هو أسلوب يتلقى من خلاله الكائن كائنات أخرى يعتمد عليها ، تسمى التبعيات ، بدلاً من إنشائها بنفسه.
بشكل عام ، لتحديد ماهية "التبعية" ، إذا كانت بعض الفئات A
تستخدم وظيفة الفئة B
، فإن B
هي تبعية لـ A
، أو بعبارة أخرى ، A
لها تبعية على B
بالطبع ، لا يقتصر هذا على الفئات ويحمل الوظائف أيضًا. في هذه الحالة ، تعتمد فئة Car
على فئة Engine
، أو يعتمد Engine
على Car
. التبعيات هي ببساطة متغيرات ، تمامًا مثل معظم الأشياء في البرمجة.
يستخدم حقن التبعية على نطاق واسع لدعم العديد من حالات الاستخدام ، ولكن ربما يكون أكثر الاستخدامات الصارخة هو السماح بإجراء اختبار أسهل. في المثال الأول ، لا يمكننا الاستهزاء بسهولة engine
لأن فئة Car
تقوم بتجسيده. يتم استخدام المحرك الحقيقي دائمًا. ولكن ، في الحالة الأخيرة ، لدينا سيطرة على Engine
المستخدم ، مما يعني ، في الاختبار ، أنه يمكننا تصنيف فئة فرعية Engine
وتجاوز أساليبها.
على سبيل المثال ، إذا أردنا أن نرى ما يفعله Car.startEngine()
إذا engine.fireCylinders()
خطأً ، فيمكننا ببساطة إنشاء فئة FakeEngine
، وجعلها توسع فئة Engine
، ثم تجاوز fireCylinders
لجعلها تتسبب في حدوث خطأ . في الاختبار ، يمكننا حقن هذا الكائن FakeEngine
في مُنشئ Car
. نظرًا لأن FakeEngine
هو Engine
من خلال الوراثة ، فإن نظام كتابة TypeScript راضٍ. لن يكون استخدام الميراث وتجاوز الطريقة بالضرورة أفضل طريقة للقيام بذلك ، كما سنرى لاحقًا ، لكنه بالتأكيد خيار.
أريد أن أوضح تمامًا أن ما تراه أعلاه هو المفهوم الأساسي لحقن التبعية. Car
، في حد ذاتها ، ليست ذكية بما يكفي لمعرفة المحرك الذي تحتاجه. المهندسون الذين يصنعون السيارة هم فقط من يفهمون متطلبات محركاتها وعجلاتها. وبالتالي ، فمن المنطقي أن يوفر الأشخاص الذين يصنعون السيارة المحرك المحدد المطلوب ، بدلاً من ترك Car
نفسها تختار المحرك الذي تريد استخدامه.
أستخدم كلمة "بناء" على وجه التحديد لأنك تبني السيارة عن طريق استدعاء المُنشئ ، وهو المكان الذي يتم فيه إدخال تبعيات المكان. إذا صنعت السيارة أيضًا إطاراتها الخاصة بالإضافة إلى المحرك ، فكيف نعرف أن الإطارات المستخدمة آمنة للدوران بأقصى سرعة يمكن للمحرك إخراجها؟ لكل هذه الأسباب وأكثر ، يجب أن يكون من المنطقي ، ربما بشكل حدسي ، ألا يكون Car
أي علاقة بتحديد Engine
Wheels
التي تستخدمها. يجب توفيرها من مستوى أعلى من التحكم.
في المثال الأخير الذي يصور حقن التبعية أثناء العمل ، إذا تخيلت أن Engine
فئة مجردة وليست ملموسة ، فيجب أن يكون هذا أكثر منطقية - تعرف السيارة أنها بحاجة إلى محرك وتعرف أن المحرك يجب أن يكون لديه بعض الوظائف الأساسية ، ولكن كيف يتم إدارة هذا المحرك وما هو التنفيذ المحدد له محفوظ ليتم تحديده وتقديمه بواسطة جزء من الكود الذي ينشئ (يبني) السيارة.
مثال من العالم الحقيقي
سنلقي نظرة على بعض الأمثلة العملية التي نأمل أن تساعد في شرح سبب فائدة حقن التبعية ، مرة أخرى بشكل حدسي. نأمل ، من خلال عدم العزف على النظرية وبدلاً من الانتقال مباشرة إلى المفاهيم القابلة للتطبيق ، يمكنك أن ترى بشكل كامل الفوائد التي يوفرها حقن التبعية ، وصعوبات الحياة بدونها. سنعود إلى معالجة "أكاديمية" أكثر قليلاً للموضوع لاحقًا.
سنبدأ ببناء تطبيقنا بشكل طبيعي ، وبطريقة شديدة الاقتران ، دون استخدام حقن التبعية أو التجريد ، حتى نصل إلى رؤية الجوانب السلبية لهذا النهج والصعوبة التي يضيفها للاختبار. على طول الطريق ، سنعيد البناء تدريجيًا حتى نصحح جميع المشكلات.
للبدء ، افترض أنك قد تم تكليفك ببناء فئتين - موفر بريد إلكتروني وفصل لطبقة وصول إلى البيانات يجب استخدامها بواسطة بعض 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 هو كائن نقل البيانات - إنه كائن يعمل كحقيبة خصائص لتحديد شكل بيانات قياسي أثناء انتقاله بين نظامين خارجيين أو طبقتين من التطبيق. يمكنك معرفة المزيد حول DTOs من مقالة Martin Fowler حول هذا الموضوع ، هنا. في هذه الحالة ، يحدد IRegisterUserDto
عقدًا لما يجب أن يكون عليه شكل البيانات عند ظهورها من العميل. لدي فقط خاصيتين - id
email
. قد تعتقد أنه من الغريب أن DTO الذي نتوقعه من العميل لإنشاء مستخدم جديد يحتوي على معرف المستخدم على الرغم من أننا لم ننشئ مستخدمًا بعد. المعرّف هو UUID وأنا أسمح للعميل بإنشائه لعدة أسباب خارج نطاق هذه المقالة. بالإضافة إلى ذلك ، يجب أن تقوم الدالة 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 بدلاً من ذلك؟ وبالمثل ، ماذا يحدث عندما نريد اختبار أساليبنا على حدة - هل سنستخدم قاعدة البيانات الحقيقية في الاختبارات؟ والأسوأ من ذلك ، هل سنرسل بالفعل رسائل بريد إلكتروني حقيقية إلى عناوين بريد إلكتروني يحتمل أن تكون حقيقية وندفع ثمنها أيضًا؟
في النظام البيئي التقليدي لجافا سكريبت ، تكون طرق فئات اختبار الوحدة في ظل هذا التكوين محفوفة بالتعقيد والإفراط في الهندسة. يقوم الأشخاص بإحضار مكتبات كاملة لمجرد توفير وظائف stubbing ، والتي تضيف جميع أنواع طبقات المراوغة ، والأسوأ من ذلك ، يمكنهم ربط الاختبارات مباشرة بتنفيذ النظام قيد الاختبار ، في حين أن الاختبارات في الواقع يجب ألا تعرف أبدًا كيف النظام الحقيقي يعمل (وهذا ما يعرف باختبار الصندوق الأسود). سنعمل على التخفيف من هذه المشكلات بينما نناقش المسؤولية الفعلية UserService
ونطبق تقنيات جديدة لحقن التبعية.
ضع في اعتبارك ، للحظة ، ما تفعله UserService
. الهدف الكامل من وجود UserService
هو تنفيذ حالات استخدام محددة تشمل المستخدمين - تسجيلهم ، وقراءتهم ، وتحديثهم ، وما إلى ذلك. إنها أفضل ممارسة للفئات والوظائف أن يكون لها مسؤولية واحدة فقط (SRP - مبدأ المسؤولية الفردية) ، وتتمثل مسؤولية UserService
في التعامل مع العمليات المتعلقة بالمستخدم. لماذا إذن ، UserService
مسؤولة عن التحكم في عمر UserRepository
و SendGridEmailProvider
في هذا المثال؟
تخيل لو كان لدينا فئة أخرى مستخدمة بواسطة 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
الملموس. هذه العلاقة جامدة للغاية - نحن عالقون في الحاجة إلى تمرير كائن ما هو UserRepository
وهو SendGridEmailProvider
.
هذا ليس رائعًا لأننا نريد أن تكون 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(...) } }
وحدد واحدًا لموفر البريد الإلكتروني ، ونفذه أيضًا:
// 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(...); } }
ملاحظة: هذا هو نمط المحول من عصابة أنماط التصميم الأربعة.
الآن ، يمكن أن تعتمد 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); } }
إذا كانت الواجهات جديدة بالنسبة لك ، فقد تبدو معقدة للغاية. في الواقع ، قد يكون مفهوم إنشاء برامج غير محكمة الاقتران جديدًا عليك أيضًا. فكر في أوعية الحائط. يمكنك توصيل أي جهاز بأي وعاء طالما أن القابس يناسب المنفذ. هذا اقتران فضفاض في العمل. لا يتم توصيل جهاز تحميص الخبز الخاص بك بالجدار ، لأنه إذا كان كذلك ، وقررت ترقية جهاز تحميص الخبز الخاص بك ، فلن يحالفك الحظ. بدلاً من ذلك ، يتم استخدام المنافذ ، ويحدد المنفذ الواجهة. وبالمثل ، عندما تقوم بتوصيل جهاز إلكتروني بمقبس الحائط الخاص بك ، فأنت لست مهتمًا بجهد الجهد ، أو أقصى سحب للتيار ، وتردد التيار المتردد ، وما إلى ذلك ، فأنت تهتم فقط إذا كان القابس مناسبًا للمأخذ. قد يكون لديك كهربائي يأتي ويغير جميع الأسلاك الموجودة خلف هذا المنفذ ، ولن تواجه أي مشاكل في توصيل محمصة الخبز ، طالما أن هذا المنفذ لا يتغير. علاوة على ذلك ، يمكن تبديل مصدر الكهرباء الخاص بك ليأتي من المدينة أو من الألواح الشمسية الخاصة بك ، ومرة أخرى ، لا تهتم طالما أنه لا يزال بإمكانك توصيل هذا المنفذ.

الواجهة هي المنفذ ، وتوفر وظيفة "التوصيل والتشغيل". في هذا المثال ، تكون الأسلاك في الحائط ومصدر الكهرباء أقرب إلى التبعيات ومحمصة الخبز الخاصة بك تشبه خدمة UserService
(تعتمد على الكهرباء) - يمكن تغيير مصدر الكهرباء والمحمصة لا تزال تعمل بشكل جيد ولا تحتاج إلى ذلك أن يتم لمسها ، لأن المنفذ ، الذي يعمل كواجهة ، يحدد الوسائل القياسية للتواصل بينهما. في الواقع ، يمكنك القول أن المخرج يعمل بمثابة "تجريد" لأسلاك الحائط ، وقواطع الدائرة ، والمصدر الكهربائي ، إلخ.
إنه مبدأ شائع ومحترم في تصميم البرامج ، للأسباب المذكورة أعلاه ، للتشفير مقابل الواجهات (التجريدية) وليس التطبيقات ، وهو ما فعلناه هنا. عند القيام بذلك ، نمنح حرية تبديل التطبيقات كما يحلو لنا ، لأن هذه التطبيقات مخفية خلف الواجهة (تمامًا مثل الأسلاك الجدارية مخفية خلف المنفذ) ، وبالتالي فإن منطق العمل الذي يستخدم التبعية لا يجب أن يفعل أبدًا تتغير طالما أن الواجهة لا تتغير أبدًا. تذكر أن UserService
يحتاج فقط إلى معرفة الوظيفة التي توفرها تبعياتها ، وليس كيفية دعم هذه الوظيفة خلف الكواليس . لهذا السبب يعمل استخدام الواجهات.
هذان التغييران البسيطان في استخدام الواجهات وحقن التبعيات يصنعان فرقًا كبيرًا في العالم عندما يتعلق الأمر ببناء برامج مقترنة بشكل غير محكم وحل جميع المشكلات التي واجهناها أعلاه.
إذا قررنا غدًا أننا نريد الاعتماد على Mailchimp لرسائل البريد الإلكتروني ، فنحن ببساطة ننشئ فئة Mailchimp جديدة تكرم واجهة IEmailProvider
بدلاً من 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
إلى معرفته (أي لا كيف تعمل التبعيات).
سنقوم بحقن هذين أثناء الاختبارات ، وسوف نجعل عملية الاختبار أسهل بكثير وأكثر وضوحًا مما قد تكون معتادًا عليه عند التعامل مع مكتبات الاستهزاء والاستئصال الكبيرة ، والعمل مع 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); });
ستلاحظ بعض الأشياء هنا: المزيفة المكتوبة بخط اليد بسيطة للغاية. ليس هناك أي تعقيد من محاكاة الأطر التي تعمل فقط على التشويش. كل شيء ملفوف يدويًا وهذا يعني أنه لا يوجد سحر في قاعدة الشفرة. السلوك غير المتزامن مزيف لمطابقة الواجهات. أستخدم غير متزامن / انتظار في الاختبارات على الرغم من أن كل السلوك متزامن لأنني أشعر أنه يتطابق بشكل أكبر مع الطريقة التي أتوقع أن تعمل بها العمليات في العالم الحقيقي ولأنه من خلال إضافة غير متزامن / انتظار ، يمكنني تشغيل نفس مجموعة الاختبار ضد عمليات التنفيذ الحقيقية أيضًا بالإضافة إلى المنتجات المزيفة ، وبالتالي فإن تسليم عدم التزامن بشكل مناسب مطلوب. في الواقع ، في الحياة الواقعية ، على الأرجح لن أقلق حتى من الاستهزاء بقاعدة البيانات ، وبدلاً من ذلك سأستخدم قاعدة بيانات محلية في حاوية Docker حتى كان هناك العديد من الاختبارات التي اضطررت إلى الاستهزاء بها من أجل الأداء. يمكنني بعد ذلك تشغيل اختبارات قاعدة البيانات في الذاكرة بعد كل تغيير فردي والاحتفاظ باختبارات قاعدة البيانات المحلية الحقيقية قبل تنفيذ التغييرات وعلى خادم الإنشاء في خط أنابيب CI / CD.
في الاختبار الأول ، في قسم "الترتيب" ، نقوم ببساطة بإنشاء DTO. في قسم "الفعل" ، نسمي النظام قيد الاختبار وننفذ سلوكه. تصبح الأمور أكثر تعقيدًا قليلاً عند التأكيدات. تذكر ، في هذه المرحلة من الاختبار ، لا نعرف حتى ما إذا تم حفظ المستخدم بشكل صحيح. لذلك ، نحدد الشكل الذي نتوقع أن يبدو عليه المستخدم المستمر ، ثم نسمي المستودع الوهمي ونطلبه للمستخدم بالمعرف الذي نتوقعه. إذا لم UserService
على المستخدم بشكل صحيح ، فسيؤدي ذلك إلى ظهور خطأ NotFoundError
وسيفشل الاختبار ، وإلا فسوف يعيدنا المستخدم. بعد ذلك ، نتصل بمزود البريد الإلكتروني المزيف ونسأله عما إذا كان قد سجل إرسال بريد إلكتروني إلى هذا المستخدم. أخيرًا ، نجعل التأكيدات باستخدام Jest وهذا يختتم الاختبار. إنه معبر ويقرأ تمامًا مثل كيفية عمل النظام بالفعل. لا يوجد أي شيء غير مباشر من الاستهزاء بالمكتبات ولا يوجد اقتران بتنفيذ UserService
.
في الاختبار الثاني ، نقوم بإنشاء مستخدم حالي وإضافته إلى المستودع ، ثم نحاول الاتصال بالخدمة مرة أخرى باستخدام DTO الذي تم استخدامه بالفعل لإنشاء مستخدم واستمراره ، ونتوقع فشل ذلك. نؤكد أيضًا أنه لم تتم إضافة أي بيانات جديدة إلى المستودع.
بالنسبة للاختبار الثالث ، يتكون قسم "الترتيب" الآن من إنشاء مستخدم واستمراره في المستودع الوهمي. بعد ذلك ، نتصل بـ SUT ، وأخيرًا ، تحقق مما إذا كان المستخدم الذي عاد هو المستخدم الذي حفظناه في الريبو سابقًا.
هذه الأمثلة بسيطة نسبيًا ، ولكن عندما تصبح الأمور أكثر تعقيدًا ، فإن القدرة على الاعتماد على حقن التبعية والواجهات بهذه الطريقة تحافظ على نظافة شفرتك وتجعل كتابة الاختبارات أمرًا ممتعًا.
لمحة موجزة عن الاختبار: بشكل عام ، لا تحتاج إلى السخرية من كل تبعية يستخدمها الكود. يدعي الكثير من الناس ، خطأً ، أن "الوحدة" في "اختبار الوحدة" هي وظيفة واحدة أو فئة واحدة. لا يمكن أن يكون أكثر خطأ. تُعرَّف "الوحدة" على أنها "وحدة الوظيفة" أو "وحدة السلوك" ، وليست وظيفة أو فئة واحدة. لذلك إذا كانت وحدة السلوك تستخدم 5 فئات مختلفة ، فلن تحتاج إلى الاستهزاء بكل هذه الفئات ما لم تصل إلى خارج حدود الوحدة. في هذه الحالة ، سخرت من قاعدة البيانات وسخرت من مزود البريد الإلكتروني لأنه ليس لدي خيار آخر. إذا كنت لا أرغب في استخدام قاعدة بيانات حقيقية ولا أريد إرسال بريد إلكتروني ، فلا بد لي من الاستهزاء بهم. لكن إذا كان لدي مجموعة أخرى من الفصول التي لم تفعل أي شيء عبر الشبكة ، فلن أسخر منهم لأنهم تفاصيل تنفيذية لوحدة السلوك. يمكنني أيضًا اتخاذ قرار بعدم الاستهزاء بقاعدة البيانات ورسائل البريد الإلكتروني وإنشاء قاعدة بيانات محلية حقيقية وخادم SMTP حقيقي ، وكلاهما في حاويات Docker. بالنسبة للنقطة الأولى ، ليس لدي مشكلة في استخدام قاعدة بيانات حقيقية وما زلت أسميها اختبار وحدة طالما أنها ليست بطيئة للغاية. بشكل عام ، كنت سأستخدم قاعدة البيانات الحقيقية أولاً حتى تصبح بطيئة جدًا وكان علي أن أسخر منها ، كما تمت مناقشته أعلاه. ولكن ، بغض النظر عما تفعله ، يجب أن تكون عمليًا - إرسال رسائل البريد الإلكتروني الترحيبية ليس عملية ذات مهمة حرجة ، وبالتالي لا نحتاج إلى الذهاب إلى هذا الحد فيما يتعلق بخوادم SMTP في حاويات Docker. عندما أقوم بالسخرية ، من غير المرجح أن أستخدم إطار عمل محاكاة أو أحاول التأكيد على عدد المرات التي تم استدعاؤها أو تمرير المعلمات إلا في حالات نادرة جدًا ، لأن ذلك من شأنه أن يقرن الاختبارات بتنفيذ النظام قيد الاختبار ، وهم يجب أن يكون محايدا لتلك التفاصيل.
تنفيذ حقن التبعية بدون فئات ومنشئين
حتى الآن ، خلال المقالة ، عملنا حصريًا مع الفئات وقمنا بحقن التبعيات من خلال المنشئ. إذا كنت تتبع نهجًا وظيفيًا للتطوير وترغب في عدم استخدام الفئات ، فلا يزال بإمكان المرء الحصول على فوائد حقن التبعية باستخدام وسيطات الوظيفة. على سبيل المثال ، يمكن إعادة هيكلة فئة UserService
أعلاه إلى:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
إنه مصنع يتلقى التبعيات ويبني كائن الخدمة. يمكننا أيضًا حقن التبعيات في وظائف الترتيب الأعلى. من الأمثلة النموذجية إنشاء وظيفة Express Middleware التي تحصل على UserRepository
و ILogger
محقونة:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
في المثال الأول ، لم أقم بتعريف نوع dto
و id
لأننا إذا حددنا واجهة تسمى IUserService
تحتوي على تواقيع الأسلوب للخدمة ، فسيقوم مترجم TS باستنتاج الأنواع تلقائيًا. وبالمثل ، لو قمت بتعريف توقيع دالة لـ Express Middleware ليكون نوع الإرجاع الخاص بـ 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;
- و اكثر.
ابقوا متابعين!