บทนำเชิงปฏิบัติเกี่ยวกับการฉีดพึ่งพา
เผยแพร่แล้ว: 2022-03-10แนวคิดของ Dependency Injection เป็นแนวคิดพื้นฐานที่เรียบง่าย อย่างไรก็ตาม มันถูกนำเสนอโดยทั่วไปในลักษณะควบคู่ไปกับแนวคิดเชิงทฤษฎีมากขึ้นของการผกผันของการควบคุม การผกผันการพึ่งพา หลักการ SOLID และอื่นๆ เพื่อให้ง่ายที่สุดสำหรับคุณในการเริ่มต้นใช้ Dependency Injection และเริ่มเก็บเกี่ยวผลประโยชน์ บทความนี้จะยังคงอยู่อย่างมากในด้านการปฏิบัติของเรื่องราว โดยแสดงตัวอย่างที่แสดงให้เห็นอย่างชัดเจนถึงประโยชน์ของการใช้งานในลักษณะส่วนใหญ่ แยกออกจากทฤษฎีที่เกี่ยวข้อง
เราจะใช้เวลาเพียงเล็กน้อยในการพูดคุยเกี่ยวกับแนวคิดทางวิชาการที่ล้อมรอบการแทรกการพึ่งพาที่นี่ สำหรับคำอธิบายส่วนใหญ่จะสงวนไว้สำหรับบทความที่สองของชุดนี้ แท้จริงแล้ว หนังสือทั้งเล่มสามารถเขียนและเขียนได้เพื่อให้แนวคิดปฏิบัติในเชิงลึกและเข้มงวดยิ่งขึ้น
ในที่นี้ เราจะเริ่มต้นด้วยคำอธิบายง่ายๆ ย้ายไปยังตัวอย่างอื่นๆ ในโลกแห่งความเป็นจริง จากนั้นจึงหารือเกี่ยวกับข้อมูลเบื้องหลัง บทความอื่น (เพื่อติดตามบทความนี้) จะกล่าวถึงว่า Dependency Injection เหมาะสมกับระบบนิเวศโดยรวมของการใช้รูปแบบสถาปัตยกรรมแนวปฏิบัติที่ดีที่สุดอย่างไร
คำอธิบายง่ายๆ
“การฉีดพึ่งพา” เป็นคำที่ซับซ้อนเกินไปสำหรับแนวคิดที่เรียบง่ายอย่างยิ่ง ณ จุดนี้ คำถามที่ฉลาดและสมเหตุสมผลบางคำถามคือ "คุณนิยาม 'การพึ่งพา' อย่างไร", "การ 'ฉีด' การพึ่งพานั้นหมายความว่าอย่างไร", "คุณสามารถฉีดการพึ่งพาด้วยวิธีต่างๆ ได้หรือไม่" และ “ทำไมสิ่งนี้ถึงมีประโยชน์” คุณอาจไม่เชื่อว่าคำศัพท์เช่น "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
การพึ่งพาอาศัยกันเป็นเพียงตัวแปร เช่นเดียวกับสิ่งส่วนใหญ่ในการเขียนโปรแกรม
Dependency Injection ถูกใช้อย่างกว้างขวางเพื่อรองรับกรณีการใช้งานหลายๆ กรณี แต่บางทีการใช้งานที่โจ่งแจ้งที่สุดคือการอนุญาตให้ทำการทดสอบได้ง่ายขึ้น ในตัวอย่างแรก เราไม่สามารถเยาะเย้ย engine
ได้อย่างง่ายดายเพราะคลาส Car
สร้างตัวอย่างให้ เครื่องยนต์จริงถูกใช้งานอยู่เสมอ แต่ในกรณีหลังนี้ เรามีการควบคุม Engine
ที่ใช้ ซึ่งหมายความว่าในการทดสอบ เราสามารถ subclass Engine
และแทนที่เมธอดของมัน
ตัวอย่างเช่น หากเราต้องการดูว่า Car.startEngine()
ทำอะไรหาก engine.fireCylinders()
ส่งข้อผิดพลาด เราก็สามารถสร้างคลาส FakeEngine
ขยายคลาส Engine
แล้วแทนที่ fireCylinders
เพื่อให้เกิดข้อผิดพลาด . ในการทดสอบ เราสามารถฉีดวัตถุ FakeEngine
นั้นเข้าไปใน Constructor for Car
ได้ เนื่องจาก FakeEngine
เป็น Engine
โดยนัยของการสืบทอด ระบบประเภท TypeScript จึงเป็นที่พอใจ การใช้การสืบทอดและการแทนที่เมธอดอาจไม่ใช่วิธีที่ดีที่สุดในการทำเช่นนี้ ดังที่เราจะเห็นในภายหลัง แต่นี่เป็นทางเลือกหนึ่งอย่างแน่นอน
ฉันต้องการทำให้ชัดเจนมาก ๆ ว่าสิ่งที่คุณเห็นข้างต้นเป็นแนวคิดหลักของการฉีดพึ่งพา โดยตัวมันเอง Car
ไม่ฉลาดพอที่จะรู้ว่าต้องใช้เครื่องยนต์อะไร เฉพาะวิศวกรที่ สร้าง รถเท่านั้นที่เข้าใจข้อกำหนดของเครื่องยนต์และล้อ ดังนั้นจึงเป็นเรื่องที่สมเหตุสมผลที่ผู้ สร้าง รถจะจัดหาเครื่องยนต์เฉพาะที่จำเป็น แทนที่จะปล่อยให้ตัว Car
เลือกเครื่องยนต์ที่ต้องการใช้
ฉันใช้คำว่า "สร้าง" โดยเฉพาะเพราะคุณสร้างรถโดยเรียกตัวสร้าง ซึ่งเป็นการเติมการอ้างอิงสถานที่ หากรถยังสร้างยางของตัวเองนอกเหนือจากเครื่องยนต์ เราจะทราบได้อย่างไรว่ายางที่ใช้นั้นปลอดภัยที่จะหมุนที่ RPM สูงสุดที่เครื่องยนต์สามารถส่งออกได้ ด้วยเหตุผลทั้งหมดเหล่านี้และอื่นๆ อาจเป็นเรื่องที่สมเหตุสมผล บางทีโดยสัญชาตญาณว่า 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(...) } }
หมายเหตุ: ชื่อ "พื้นที่เก็บข้อมูล" ที่นี่มาจาก "รูปแบบพื้นที่เก็บข้อมูล" ซึ่งเป็นวิธีการแยกฐานข้อมูลออกจากตรรกะทางธุรกิจของคุณ คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับ Repository Pattern ได้ แต่สำหรับวัตถุประสงค์ของบทความนี้ คุณสามารถพิจารณาว่ามันเป็นคลาสบางคลาสที่แยกฐานข้อมูลของคุณออกไป เพื่อที่ตามตรรกะทางธุรกิจ ระบบจัดเก็บข้อมูลของคุณจะถือว่าเป็นเพียงหน่วยความจำในหน่วยความจำ ของสะสม. การอธิบายรูปแบบพื้นที่เก็บข้อมูลทั้งหมดอยู่นอกขอบเขตของบทความนี้
นี่เป็นวิธีที่เราคาดหวังให้สิ่งต่างๆ ทำงานได้ตามปกติ และ 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 คือ Data Transfer Object ซึ่งเป็นออบเจ็กต์ที่ทำหน้าที่เป็นกระเป๋าคุณสมบัติเพื่อกำหนดรูปร่างของข้อมูลที่เป็นมาตรฐานในขณะที่มันเคลื่อนที่ระหว่างระบบภายนอก 2 ระบบหรือสองชั้นของแอปพลิเคชัน คุณสามารถเรียนรู้เพิ่มเติมเกี่ยวกับ DTO ได้จากบทความของ Martin Fowler ในหัวข้อนี้ ที่นี่ ในกรณีนี้ IRegisterUserDto
กำหนดสัญญาสำหรับรูปร่างของข้อมูลที่ควรจะเป็นตามที่มาจากไคลเอนต์ ฉันมีเพียงสองคุณสมบัติ — id
และ email
คุณอาจคิดว่ามันแปลกที่ 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
คือการดำเนินการกรณีการใช้งานเฉพาะที่เกี่ยวข้องกับผู้ใช้ เช่น การลงทะเบียน อ่าน อัปเดต ฯลฯ เป็นแนวทางปฏิบัติที่ดีที่สุดสำหรับคลาสและฟังก์ชันที่มีความรับผิดชอบเพียงอย่างเดียว (SRP — Single Responsibility Principle) และความรับผิดชอบของ UserService
คือการจัดการการดำเนินงานที่เกี่ยวข้องกับผู้ใช้ เหตุใด UserService
จึงเป็นผู้รับผิดชอบในการควบคุมอายุการใช้งานของ UserRepository
และ SendGridEmailProvider
ในตัวอย่างนี้
ลองนึกภาพถ้าเรามีคลาสอื่นที่ใช้โดย UserService
ซึ่งเปิดการเชื่อมต่อที่ใช้เวลานาน UserService
ควรรับผิดชอบในการกำจัดการเชื่อมต่อนั้นด้วยหรือไม่ แน่นอนไม่ การพึ่งพาเหล่านี้ทั้งหมดมีความเกี่ยวข้องกับพวกเขาตลอดชีวิต — อาจเป็น singletons พวกเขาสามารถชั่วคราวและกำหนดขอบเขตไปยังคำขอ 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); } }
หากอินเทอร์เฟซใหม่สำหรับคุณ อาจดูซับซ้อนมาก อันที่จริง แนวความคิดในการสร้างซอฟต์แวร์ที่เชื่อมต่อกันอย่างหลวมๆ อาจเป็นเรื่องใหม่สำหรับคุณเช่นกัน คิดถึงเต้ารับติดผนัง คุณสามารถเสียบอุปกรณ์ใดๆ เข้ากับเต้ารับใดก็ได้ตราบใดที่ปลั๊กนั้นพอดีกับเต้ารับ นั่นคือการมีเพศสัมพันธ์ที่หลวมในการดำเนินการ เครื่องปิ้งขนมปังของคุณไม่ได้ต่อสายไฟเข้ากับผนัง เพราะถ้าเป็นเช่นนั้น และคุณตัดสินใจอัพเกรดเครื่องปิ้งขนมปัง แสดงว่าคุณโชคไม่ดี จะใช้เต้ารับแทน และเต้ารับจะกำหนดส่วนต่อประสาน ในทำนองเดียวกัน เมื่อคุณเสียบอุปกรณ์อิเล็กทรอนิกส์เข้ากับเต้ารับที่ผนัง คุณจะไม่กังวลกับศักย์ไฟฟ้า กระแสไฟสูงสุด ความถี่ AC ฯลฯ คุณแค่สนใจว่าปลั๊กจะพอดีกับเต้ารับหรือไม่ คุณสามารถให้ช่างไฟฟ้าเข้ามาและเปลี่ยนสายไฟทั้งหมดที่อยู่ด้านหลังเต้ารับนั้น และคุณจะไม่มีปัญหาในการเสียบเครื่องปิ้งขนมปัง ตราบใดที่เต้ารับนั้นไม่เปลี่ยน ยิ่งไปกว่านั้น แหล่งไฟฟ้าของคุณสามารถเปลี่ยนให้มาจากในเมืองหรือแผงโซลาร์เซลล์ของคุณเองได้ และอีกครั้ง คุณก็ไม่สนใจตราบเท่าที่คุณยังสามารถเสียบปลั๊กไฟนั้นได้
อินเทอร์เฟซคือเต้ารับซึ่งมีฟังก์ชัน "plug-and-play" ในตัวอย่างนี้ การเดินสายไฟในผนังและแหล่งไฟฟ้าคล้ายกับการพึ่งพาและเครื่องปิ้งขนมปังของคุณคล้ายกับ 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
ทั้งหมดจำเป็นต้องรู้ (เช่น ไม่ใช่ วิธีการทำงานของการพึ่งพา)
เราจะฉีดสองสิ่งนี้ระหว่างการทดสอบ และมันจะทำให้ขั้นตอนการทดสอบง่ายขึ้นมากและตรงไปตรงมามากกว่าสิ่งที่คุณอาจคุ้นเคยเมื่อจัดการกับไลบรารีที่เยาะเย้ยและขัดจังหวะแบบ over-the-top โดยทำงานกับภายในของ 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); });
คุณจะสังเกตเห็นบางสิ่งที่นี่: ของปลอมที่เขียนด้วยลายมือนั้นง่ายมาก ไม่มีความซับซ้อนใด ๆ จากกรอบการเยาะเย้ยซึ่งทำหน้าที่ทำให้สับสนเท่านั้น ทุกอย่างถูกรีดด้วยมือและนั่นหมายความว่าไม่มีเวทย์มนตร์ใน codebase พฤติกรรมแบบอะซิงโครนัสถูกปลอมแปลงเพื่อให้ตรงกับอินเทอร์เฟซ ฉันใช้ async/await ในการทดสอบแม้ว่าพฤติกรรมทั้งหมดจะเป็นแบบซิงโครนัสเพราะฉันรู้สึกว่ามันตรงกับที่ฉันคาดหวังให้การดำเนินการทำงานในโลกแห่งความเป็นจริงมากขึ้น และเนื่องจากการเพิ่ม async/await ฉันสามารถเรียกใช้ชุดทดสอบเดียวกันนี้ได้ ต่อต้านการใช้งานจริงด้วยนอกเหนือจากของปลอม ดังนั้นจึงจำเป็นต้องมีการแจกแจงแบบอะซิงโครไนซ์อย่างเหมาะสม อันที่จริง ในชีวิตจริง ฉันมักจะไม่ต้องกังวลกับการเยาะเย้ยฐานข้อมูล และแทนที่จะใช้ DB ในเครื่องในคอนเทนเนอร์ Docker จนกว่าจะมีการทดสอบมากมายจนฉันต้องเยาะเย้ยเพื่อประสิทธิภาพ จากนั้นฉันสามารถเรียกใช้การทดสอบ DB ในหน่วยความจำได้หลังจากทุกๆ การเปลี่ยนแปลง และจองการทดสอบ DB ในเครื่องจริงก่อนทำการเปลี่ยนแปลงและสำหรับเซิร์ฟเวอร์บิลด์ในไปป์ไลน์ CI/CD
ในการทดสอบครั้งแรก ในส่วน "การจัดเตรียม" เราเพียงแค่สร้าง DTO ในส่วน "การกระทำ" เราเรียกระบบภายใต้การทดสอบและดำเนินการตามลักษณะการทำงาน สิ่งต่างๆ จะซับซ้อนขึ้นเล็กน้อยเมื่อทำการยืนยัน โปรดจำไว้ว่า ณ จุดนี้ของการทดสอบ เราไม่รู้ด้วยซ้ำว่าผู้ใช้ได้รับการบันทึกอย่างถูกต้องหรือไม่ ดังนั้นเราจึงกำหนดสิ่งที่เราคาดหวังว่าผู้ใช้ที่คงอยู่จะมีหน้าตาเป็นอย่างไร จากนั้นเราเรียกที่เก็บปลอมและขอผู้ใช้ที่มี ID ที่เราคาดหวัง หาก UserService
ไม่ยืนยันผู้ใช้อย่างถูกต้อง การดำเนินการนี้จะโยน NotFoundError
และการทดสอบจะล้มเหลว มิฉะนั้น จะส่งคืนผู้ใช้ให้เรา ต่อไป เราโทรหาผู้ให้บริการอีเมลปลอมและถามว่ามีการบันทึกการส่งอีเมลไปยังผู้ใช้รายนั้นหรือไม่ สุดท้าย เราทำการยืนยันด้วย Jest และนั่นก็เสร็จสิ้นการทดสอบ มันสื่อความหมายและอ่านได้เหมือนกับว่าระบบทำงานจริงอย่างไร ไม่มีทางอ้อมจากการเยาะเย้ยไลบรารีและไม่มีการเชื่อมต่อกับการใช้งาน UserService
ในการทดสอบครั้งที่สอง เราสร้างผู้ใช้ที่มีอยู่และเพิ่มลงในที่เก็บ จากนั้นเราพยายามเรียกใช้บริการอีกครั้งโดยใช้ DTO ที่เคยใช้เพื่อสร้างและคงผู้ใช้ไว้ และเราคาดว่าจะล้มเหลว เรายังยืนยันว่าไม่มีการเพิ่มข้อมูลใหม่ในที่เก็บ
สำหรับการทดสอบครั้งที่สาม ตอนนี้ส่วน "จัดเรียง" ประกอบด้วยการสร้างผู้ใช้และยืนยันไปยังที่เก็บปลอม จากนั้น เราโทรหา มทส. และสุดท้าย ตรวจสอบว่าผู้ใช้ที่กลับมาเป็นผู้ใช้ที่เราบันทึกไว้ใน repo ก่อนหน้านี้หรือไม่
ตัวอย่างเหล่านี้ค่อนข้างง่าย แต่เมื่อสิ่งต่าง ๆ มีความซับซ้อนมากขึ้น ความสามารถในการพึ่งพาการฉีดและอินเทอร์เฟซในลักษณะนี้จะช่วยให้โค้ดของคุณสะอาดและทำให้การทดสอบการเขียนเป็นเรื่องสนุก
ข้อมูลโดยย่อเกี่ยวกับการทดสอบ: โดยทั่วไป คุณไม่จำเป็นต้องล้อเลียนทุกการขึ้นต่อกันที่โค้ดใช้ หลายคนผิดพลาดอ้างว่า "หน่วย" ใน "การทดสอบหน่วย" เป็นฟังก์ชันเดียวหรือหนึ่งคลาส ที่ไม่ถูกต้องมากขึ้น “หน่วย” ถูกกำหนดให้เป็น “หน่วยของการทำงาน” หรือ “หน่วยของพฤติกรรม” ไม่ใช่หนึ่งฟังก์ชันหรือคลาส ดังนั้นหากหน่วยพฤติกรรมใช้ 5 คลาสที่แตกต่างกัน คุณไม่จำเป็นต้องเยาะเย้ยคลาสเหล่านั้นทั้งหมด เว้นแต่จะ ไปถึงนอกขอบเขตของโมดูล ในกรณีนี้ ฉันเยาะเย้ยฐานข้อมูลและเยาะเย้ยผู้ให้บริการอีเมลเพราะฉันไม่มีทางเลือก ถ้าฉันไม่ต้องการใช้ฐานข้อมูลจริงและไม่ต้องการส่งอีเมล ฉันต้องเยาะเย้ยพวกเขา แต่ถ้าฉันมีคลาสอีกจำนวนมากที่ไม่ได้ทำอะไรในเครือข่าย ฉันจะไม่ล้อเลียนพวกเขาเพราะพวกเขาใช้รายละเอียดของหน่วยพฤติกรรม ฉันยังสามารถตัดสินใจไม่ล้อเลียนฐานข้อมูลและอีเมล และสร้างฐานข้อมูลในเครื่องจริงและเซิร์ฟเวอร์ SMTP จริง ทั้งในคอนเทนเนอร์ Docker ในประเด็นแรก ฉันไม่มีปัญหาในการใช้ฐานข้อมูลจริงและยังคงเรียกมันว่าการทดสอบหน่วย ตราบใดที่ไม่ช้าเกินไป โดยทั่วไป ฉันจะใช้ฐานข้อมูลจริงก่อน จนกว่ามันจะช้าเกินไป และฉันต้องเยาะเย้ย ตามที่กล่าวข้างต้น แต่ไม่ว่าคุณจะทำอะไร คุณต้องปฏิบัติจริง การส่งอีเมลต้อนรับไม่ใช่การดำเนินการที่สำคัญต่อภารกิจ ดังนั้นเราจึงไม่จำเป็นต้องไปไกลถึงขนาดนั้นในแง่ของเซิร์ฟเวอร์ SMTP ในคอนเทนเนอร์ Docker เมื่อใดก็ตามที่ฉันล้อเลียน ฉันไม่น่าจะใช้กรอบการเยาะเย้ยหรือพยายามยืนยันจำนวนครั้งที่เรียกใช้หรือพารามิเตอร์ที่ส่งผ่าน ยกเว้นในบางกรณีที่หายากมาก เพราะนั่นจะเป็นการทดสอบควบคู่ไปกับการใช้งานระบบภายใต้การทดสอบ และพวกเขา ควรจะไม่เชื่อเรื่องรายละเอียดเหล่านั้น
ดำเนินการฉีด Dependency โดยไม่มีคลาสและตัวสร้าง
จนถึงตอนนี้ ตลอดทั้งบทความ เราได้ทำงานกับคลาสโดยเฉพาะและแทรกการพึ่งพาผ่านตัวสร้าง หากคุณกำลังใช้แนวทางการทำงานเพื่อการพัฒนาและไม่ต้องการใช้คลาส เรายังคงได้รับประโยชน์จากการฉีดการพึ่งพาโดยใช้อาร์กิวเมนต์ของฟังก์ชัน ตัวอย่างเช่น คลาส UserService
ของเราด้านบนสามารถ refactored เป็น:
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 Compiler จะสรุปประเภทโดยอัตโนมัติ ในทำนองเดียวกัน หากฉันกำหนดลายเซ็นฟังก์ชันสำหรับ 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;
- และอื่น ๆ.
คอยติดตาม!