Pengantar Praktis Untuk Injeksi Ketergantungan
Diterbitkan: 2022-03-10Konsep Injeksi Ketergantungan, pada intinya, merupakan gagasan yang sederhana secara fundamental. Namun, biasanya disajikan dengan cara yang lebih teoretis dari konsep Inversi Kontrol, Inversi Ketergantungan, Prinsip SOLID, dan sebagainya. Untuk memudahkan Anda dalam mulai menggunakan Dependency Injection dan mulai menuai manfaatnya, artikel ini akan tetap banyak di sisi praktis dari cerita, menggambarkan contoh yang menunjukkan secara tepat manfaat penggunaannya, terutama dengan cara dipisahkan dari teori terkait.
Kami hanya akan menghabiskan sedikit waktu untuk membahas konsep akademis yang mengelilingi injeksi ketergantungan di sini, karena sebagian besar penjelasan itu akan disediakan untuk artikel kedua dari seri ini. Memang, seluruh buku dapat dan telah ditulis yang memberikan perawatan konsep yang lebih mendalam dan ketat.
Di sini, kita akan mulai dengan penjelasan sederhana, beralih ke beberapa contoh dunia nyata, dan kemudian mendiskusikan beberapa informasi latar belakang. Artikel lain (untuk mengikuti yang ini) akan membahas bagaimana Injeksi Ketergantungan cocok dengan ekosistem keseluruhan penerapan pola arsitektur praktik terbaik.
Penjelasan Sederhana
"Injeksi Ketergantungan" adalah istilah yang terlalu kompleks untuk konsep yang sangat sederhana. Pada titik ini, beberapa pertanyaan bijak dan masuk akal adalah "bagaimana Anda mendefinisikan 'ketergantungan'?", "Apa artinya ketergantungan 'disuntikkan'?", "Dapatkah Anda menyuntikkan dependensi dengan cara yang berbeda?" dan “mengapa ini berguna?” Anda mungkin tidak percaya bahwa istilah seperti "Injeksi Ketergantungan" dapat dijelaskan dalam dua potongan kode dan beberapa kata, tetapi sayangnya, itu bisa.
Cara paling sederhana untuk menjelaskan konsepnya adalah dengan menunjukkannya kepada Anda.
Ini, misalnya, bukan injeksi ketergantungan:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor () { this.engine = new Engine(); } public startEngine(): void { this.engine.fireCylinders(); } }
Tapi ini adalah injeksi ketergantungan:
import { Engine } from './Engine'; class Car { private engine: Engine; public constructor (engine: Engine) { this.engine = engine; } public startEngine(): void { this.engine.fireCylinders(); } }
Selesai. Itu dia. Dingin. Tamat.
Apa yang berubah? Alih-alih mengizinkan kelas Car
untuk membuat instance Engine
(seperti yang terjadi pada contoh pertama), pada contoh kedua, Car
memiliki instance Engine
yang diteruskan — atau disuntikkan — dari beberapa tingkat kontrol yang lebih tinggi ke konstruktornya. Itu dia. Pada intinya, ini semua adalah injeksi ketergantungan — tindakan menyuntikkan (melewati) ketergantungan ke kelas atau fungsi lain. Hal lain yang melibatkan gagasan injeksi ketergantungan hanyalah variasi dari konsep dasar dan sederhana ini. Sederhananya, injeksi dependensi adalah teknik di mana sebuah objek menerima objek lain yang bergantung padanya, yang disebut dependensi, daripada membuatnya sendiri.
Secara umum, untuk mendefinisikan apa itu "ketergantungan", jika beberapa kelas A
menggunakan fungsionalitas kelas B
, maka B
adalah ketergantungan untuk A
, atau, dengan kata lain, A
memiliki ketergantungan pada B
. Tentu saja, ini tidak terbatas pada kelas dan juga berlaku untuk fungsi. Dalam hal ini, kelas Car
memiliki dependensi pada kelas Engine
, atau Engine
adalah dependensi dari Car
. Dependensi hanyalah variabel, seperti kebanyakan hal dalam pemrograman.
Dependency Injection banyak digunakan untuk mendukung banyak kasus penggunaan, tetapi mungkin penggunaan yang paling mencolok adalah untuk memungkinkan pengujian yang lebih mudah. Pada contoh pertama, kita tidak dapat dengan mudah mengolok-olok engine
karena kelas Car
menginstansiasinya. Mesin yang sebenarnya selalu digunakan. Namun, dalam kasus terakhir, kami memiliki kendali atas Engine
yang digunakan, yang berarti, dalam pengujian, kami dapat mensubklasifikasikan Engine
dan mengganti metodenya.
Misalnya, jika kita ingin melihat apa yang dilakukan Car.startEngine()
jika engine.fireCylinders()
kesalahan, kita cukup membuat kelas FakeEngine
, memintanya memperluas kelas Engine
, lalu menimpa fireCylinders
untuk membuatnya menimbulkan kesalahan . Dalam pengujian, kita dapat menginjeksi objek FakeEngine
ke dalam konstruktor untuk Car
. Karena FakeEngine
adalah Engine
dengan implikasi pewarisan, sistem tipe TypeScript terpenuhi. Menggunakan pewarisan dan penggantian metode tidak selalu menjadi cara terbaik untuk melakukan ini, seperti yang akan kita lihat nanti, tetapi ini tentu saja merupakan pilihan.
Saya ingin membuatnya sangat, sangat jelas bahwa apa yang Anda lihat di atas adalah gagasan inti dari injeksi ketergantungan. Sebuah Car
, dengan sendirinya, tidak cukup pintar untuk mengetahui mesin apa yang dibutuhkannya. Hanya para insinyur yang membuat mobil yang memahami persyaratan untuk mesin dan rodanya. Jadi, masuk akal jika orang yang membuat mobil menyediakan mesin khusus yang dibutuhkan, daripada membiarkan Car
itu sendiri yang memilih mesin mana yang ingin digunakannya.
Saya menggunakan kata "membangun" secara khusus karena Anda membuat mobil dengan memanggil konstruktor, yang merupakan tempat dependensi disuntikkan. Jika mobil juga membuat ban sendiri selain mesin, bagaimana kita tahu bahwa ban yang digunakan aman untuk diputar pada RPM maks yang dapat dihasilkan mesin? Untuk semua alasan ini dan lebih banyak lagi, seharusnya masuk akal, mungkin secara intuitif, bahwa Car
seharusnya tidak ada hubungannya dengan memutuskan Engine
apa dan Wheels
apa yang digunakannya. Mereka harus disediakan dari beberapa tingkat kontrol yang lebih tinggi.
Dalam contoh terakhir yang menggambarkan injeksi ketergantungan dalam tindakan, jika Anda membayangkan Engine
menjadi kelas abstrak daripada kelas konkret, ini akan lebih masuk akal — mobil tahu itu membutuhkan mesin dan tahu mesin harus memiliki beberapa fungsi dasar , tetapi bagaimana mesin itu dikelola dan implementasi spesifik apa yang disediakan untuk diputuskan dan disediakan oleh potongan kode yang menciptakan (membangun) mobil.
Contoh Dunia Nyata
Kita akan melihat beberapa contoh praktis yang diharapkan dapat membantu menjelaskan, sekali lagi secara intuitif, mengapa injeksi ketergantungan berguna. Mudah-mudahan, dengan tidak terpaku pada teori dan alih-alih langsung beralih ke konsep yang berlaku, Anda dapat lebih memahami manfaat yang diberikan injeksi ketergantungan, dan kesulitan hidup tanpanya. Kami akan kembali ke topik yang sedikit lebih "akademik" nanti.
Kita akan mulai dengan membangun aplikasi kita secara normal, dengan cara yang sangat digabungkan, tanpa menggunakan injeksi ketergantungan atau abstraksi, sehingga kita dapat melihat kelemahan dari pendekatan ini dan kesulitan yang ditambahkan pada pengujian. Sepanjang jalan, kami akan secara bertahap refactor sampai kami memperbaiki semua masalah.
Untuk memulai, misalkan Anda telah ditugaskan untuk membangun dua kelas — penyedia email dan kelas untuk lapisan akses data yang perlu digunakan oleh beberapa UserService
. Kami akan mulai dengan akses data, tetapi keduanya mudah ditentukan:
// 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(...) } }
Catatan: Nama "Repositori" di sini berasal dari "Pola Repositori", sebuah metode untuk memisahkan database Anda dari logika bisnis Anda. Anda dapat mempelajari lebih lanjut tentang Pola Repositori, tetapi untuk tujuan artikel ini, Anda dapat menganggapnya sebagai beberapa kelas yang merangkum database Anda sehingga, untuk logika bisnis, sistem penyimpanan data Anda diperlakukan hanya sebagai di dalam memori. koleksi. Menjelaskan Pola Repositori sepenuhnya berada di luar lingkup artikel ini.
Ini adalah bagaimana kami biasanya mengharapkan sesuatu untuk bekerja, dan dbDriver
di-hardcode di dalam file.
Di UserService
Anda, Anda akan mengimpor kelas, membuat instance, dan mulai menggunakannya:
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); } }
Sekali lagi, semua tetap normal.
Singkatnya: DTO adalah Objek Transfer Data — itu adalah objek yang bertindak sebagai tas properti untuk menentukan bentuk data standar saat bergerak di antara dua sistem eksternal atau dua lapisan aplikasi. Anda dapat mempelajari lebih lanjut tentang DTO dari artikel Martin Fowler tentang topik tersebut, di sini. Dalam hal ini, IRegisterUserDto
mendefinisikan kontrak untuk bentuk data yang seharusnya muncul dari klien. Saya hanya memilikinya berisi dua properti — id
dan email
. Anda mungkin berpikir aneh bahwa DTO yang kami harapkan dari klien untuk membuat pengguna baru berisi ID pengguna meskipun kami belum membuat pengguna. ID adalah UUID dan saya mengizinkan klien untuk membuatnya karena berbagai alasan, yang berada di luar cakupan artikel ini. Selain itu, fungsi findUserById
harus memetakan objek User
ke DTO respons, tetapi saya mengabaikannya untuk singkatnya. Akhirnya, di dunia nyata, saya tidak akan memiliki model domain User
yang berisi metode fromDto
. Itu tidak baik untuk kemurnian domain. Sekali lagi, tujuannya adalah singkatnya di sini.
Selanjutnya, Anda ingin menangani pengiriman email. Sekali lagi, seperti biasa, Anda cukup membuat kelas penyedia email dan mengimpornya ke UserService
Anda.
// SendGridEmailProvider.ts import { sendMail } from 'sendgrid'; export class SendGridEmailProvider { public async sendWelcomeEmail(to: string): Promise<void> { // ... await sendMail(...); } }
Dalam 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); } }
Kami sekarang memiliki kelas pekerja penuh, dan di dunia di mana kami tidak peduli tentang testabilitas atau menulis kode bersih dengan cara definisi apa pun, dan di dunia di mana utang teknis tidak ada dan manajer program sial tidak' t menetapkan tenggat waktu, ini baik-baik saja. Sayangnya, itu bukan dunia yang kita manfaatkan untuk ditinggali.
Apa yang terjadi ketika kami memutuskan bahwa kami perlu bermigrasi dari SendGrid untuk email dan menggunakan MailChimp sebagai gantinya? Demikian pula, apa yang terjadi ketika kita ingin menguji unit metode kita — apakah kita akan menggunakan database sebenarnya dalam pengujian? Lebih buruk lagi, apakah kita benar-benar akan mengirim email asli ke alamat email yang berpotensi sebenarnya dan membayarnya juga?
Dalam ekosistem JavaScript tradisional, metode kelas pengujian unit di bawah konfigurasi ini penuh dengan kompleksitas dan rekayasa berlebihan. Orang-orang membawa seluruh perpustakaan hanya untuk menyediakan fungsionalitas mematikan, yang menambahkan semua jenis lapisan tipuan, dan, lebih buruk lagi, dapat secara langsung menggabungkan pengujian dengan implementasi sistem yang sedang diuji, ketika, pada kenyataannya, pengujian seharusnya tidak pernah tahu caranya sistem nyata bekerja (ini dikenal sebagai pengujian kotak hitam). Kami akan bekerja untuk mengurangi masalah ini saat kami mendiskusikan apa tanggung jawab sebenarnya dari UserService
dan menerapkan teknik baru injeksi ketergantungan.
Pertimbangkan, sejenak, apa yang dilakukan UserService
. Inti dari keberadaan UserService
adalah untuk mengeksekusi kasus penggunaan khusus yang melibatkan pengguna — mendaftarkan mereka, membacanya, memperbaruinya, dll. Ini adalah praktik terbaik untuk kelas dan fungsi untuk hanya memiliki satu tanggung jawab (SRP — Prinsip Tanggung Jawab Tunggal), dan tanggung jawab UserService
adalah untuk menangani operasi yang berhubungan dengan pengguna. Lalu, mengapa UserService
bertanggung jawab untuk mengontrol masa pakai UserRepository
dan SendGridEmailProvider
dalam contoh ini?
Bayangkan jika kita memiliki beberapa kelas lain yang digunakan oleh UserService
yang membuka koneksi yang berjalan lama. Haruskah UserService
bertanggung jawab untuk membuang koneksi itu juga? Tentu saja tidak. Semua dependensi ini memiliki masa pakai yang terkait dengannya — mereka bisa saja lajang, mereka bisa bersifat sementara dan dicakup ke Permintaan HTTP tertentu, dll. Pengendalian masa pakai ini jauh di luar lingkup UserService
. Jadi, untuk mengatasi masalah ini, kita akan menyuntikkan semua dependensi, seperti yang kita lihat sebelumnya.
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); } }
Besar! Sekarang UserService
menerima objek yang telah dibuat sebelumnya, dan bagian kode mana pun yang memanggil dan membuat UserService
baru adalah bagian kode yang bertanggung jawab untuk mengontrol masa pakai dependensi. Kami telah membalikkan kontrol dari UserService
dan naik ke level yang lebih tinggi. Jika saya hanya ingin menunjukkan bagaimana kita bisa menyuntikkan dependensi melalui konstruktor untuk menjelaskan penyewa dasar injeksi dependensi, saya bisa berhenti di sini. Namun, masih ada beberapa masalah dari perspektif desain, yang ketika diperbaiki, akan membuat penggunaan injeksi ketergantungan kita menjadi lebih kuat.
Pertama, mengapa UserService
mengetahui bahwa kami menggunakan SendGrid untuk email? Kedua, kedua dependensi berada di kelas konkret — UserRepository
konkret dan SendGridEmailProvider
konkret. Hubungan ini terlalu kaku — kita terjebak harus meneruskan beberapa objek yang merupakan UserRepository
dan merupakan SendGridEmailProvider
.
Ini tidak bagus karena kami ingin UserService
sepenuhnya agnostik terhadap implementasi dependensinya. Dengan membuat UserService
menjadi buta dengan cara itu, kami dapat menukar implementasi tanpa memengaruhi layanan sama sekali — ini berarti, jika kami memutuskan untuk bermigrasi dari SendGrid dan menggunakan MailChimp, kami dapat melakukannya. Itu juga berarti jika kita ingin memalsukan penyedia email untuk tes, kita juga bisa melakukannya.
Apa yang akan berguna adalah jika kita dapat mendefinisikan beberapa antarmuka publik dan memaksa dependensi yang masuk mematuhi antarmuka itu, sementara UserService
tetap agnostik terhadap detail implementasi. Dengan kata lain, kita perlu memaksa UserService
untuk hanya bergantung pada abstraksi dependensinya, dan bukan dependensi konkret yang sebenarnya. Kita bisa melakukannya melalui, yah, antarmuka.
Mulailah dengan mendefinisikan antarmuka untuk UserRepository
dan mengimplementasikannya:
// 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(...) } }
Dan tentukan satu untuk penyedia email, juga terapkan:
// 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(...); } }
Catatan: Ini adalah Pola Adaptor dari Geng Empat Pola Desain.
Sekarang, UserService
kami dapat bergantung pada antarmuka daripada implementasi konkret dari dependensi:
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); } }
Jika antarmuka baru bagi Anda, ini mungkin terlihat sangat, sangat kompleks. Memang, konsep membangun perangkat lunak yang digabungkan secara longgar mungkin juga baru bagi Anda. Pikirkan tentang stopkontak dinding. Anda dapat mencolokkan perangkat apa pun ke stopkontak apa pun selama stekernya pas dengan stopkontak. Itu kopling longgar beraksi. Pemanggang roti Anda tidak terprogram ke dinding, karena jika demikian, dan Anda memutuskan untuk meningkatkan pemanggang roti Anda, Anda kurang beruntung. Sebagai gantinya, outlet digunakan, dan outlet mendefinisikan antarmuka. Demikian pula, ketika Anda mencolokkan perangkat elektronik ke stopkontak dinding Anda, Anda tidak peduli dengan potensi tegangan, tarikan arus maks, frekuensi AC, dll., Anda hanya peduli apakah steker cocok dengan stopkontak. Anda bisa meminta tukang listrik masuk dan mengganti semua kabel di belakang stopkontak itu, dan Anda tidak akan kesulitan mencolokkan pemanggang roti Anda, selama stopkontak itu tidak berubah. Selanjutnya, sumber listrik Anda dapat dialihkan untuk berasal dari kota atau panel surya Anda sendiri, dan sekali lagi, Anda tidak peduli selama Anda masih bisa mencolokkan ke stopkontak itu.
Antarmuka adalah outlet, menyediakan fungsionalitas "plug-and-play". Dalam contoh ini, pengkabelan di dinding dan sumber listrik mirip dengan ketergantungan dan pemanggang roti Anda mirip dengan UserService
(memiliki ketergantungan pada listrik) — sumber listrik dapat berubah dan pemanggang roti masih berfungsi dengan baik dan tidak perlu disentuh, karena outlet, bertindak sebagai antarmuka, mendefinisikan sarana standar bagi keduanya untuk berkomunikasi. Bahkan, Anda dapat mengatakan bahwa outlet bertindak sebagai "abstraksi" dari kabel dinding, pemutus sirkuit, sumber listrik, dll.
Ini adalah prinsip umum dan dianggap baik dari desain perangkat lunak, untuk alasan di atas, untuk kode terhadap antarmuka (abstraksi) dan bukan implementasi, yang telah kami lakukan di sini. Dalam melakukannya, kami diberi kebebasan untuk menukar implementasi sesuka kami, karena implementasi tersebut tersembunyi di balik antarmuka (seperti kabel dinding yang tersembunyi di balik stopkontak), dan logika bisnis yang menggunakan ketergantungan tidak pernah harus berubah selama antarmuka tidak pernah berubah. Ingat, UserService
hanya perlu mengetahui fungsionalitas apa yang ditawarkan oleh dependensinya , bukan bagaimana fungsionalitas tersebut didukung di belakang layar . Itu sebabnya menggunakan antarmuka berfungsi.
Dua perubahan sederhana dalam memanfaatkan antarmuka dan menyuntikkan dependensi ini membuat semua perbedaan di dunia dalam hal membangun perangkat lunak yang digabungkan secara longgar dan menyelesaikan semua masalah yang kami hadapi di atas.
Jika kami memutuskan besok bahwa kami ingin mengandalkan Mailchimp untuk email, kami cukup membuat kelas Mailchimp baru yang menghormati antarmuka IEmailProvider
dan menyuntikkannya sebagai ganti SendGrid. Kelas UserService
yang sebenarnya tidak pernah harus berubah meskipun kami baru saja membuat perubahan besar pada sistem kami dengan beralih ke penyedia email baru. Keindahan dari pola-pola ini adalah bahwa UserService
tetap tidak menyadari bagaimana ketergantungan yang digunakannya bekerja di belakang layar. Antarmuka berfungsi sebagai batas arsitektur antara kedua komponen, menjaga mereka dipisahkan dengan tepat.
Selain itu, dalam hal pengujian, kami dapat membuat pemalsuan yang mematuhi antarmuka dan menyuntikkannya sebagai gantinya. Di sini, Anda dapat melihat repositori palsu dan penyedia email palsu.
// 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)); }
Perhatikan bahwa kedua pemalsuan mengimplementasikan antarmuka yang sama yang diharapkan oleh UserService
untuk dihormati oleh dependensinya. Sekarang, kita dapat meneruskan kepalsuan ini ke UserService
alih-alih kelas nyata dan UserService
tidak akan lebih bijaksana; itu akan menggunakannya seolah-olah mereka adalah real deal. Alasan ia dapat melakukannya adalah karena ia mengetahui bahwa semua metode dan properti yang ingin digunakan pada dependensinya memang ada dan memang dapat diakses (karena mereka mengimplementasikan antarmuka), hanya itu yang perlu diketahui oleh UserService
(yaitu, bukan bagaimana dependensi bekerja).
Kami akan menyuntikkan keduanya selama pengujian, dan itu akan membuat proses pengujian jauh lebih mudah dan jauh lebih mudah daripada apa yang mungkin Anda gunakan ketika berurusan dengan perpustakaan mengejek dan mematikan yang berlebihan, bekerja dengan internal Jest sendiri perkakas, atau mencoba menambal monyet.
Berikut adalah tes aktual menggunakan palsu:
// 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); });
Anda akan melihat beberapa hal di sini: Tulisan tangan palsu sangat sederhana. Tidak ada kerumitan dari kerangka kerja mengejek yang hanya berfungsi untuk mengaburkan. Semuanya digulung dengan tangan dan itu berarti tidak ada keajaiban dalam basis kode. Perilaku asinkron dipalsukan agar sesuai dengan antarmuka. Saya menggunakan async/menunggu dalam tes meskipun semua perilaku sinkron karena saya merasa itu lebih cocok dengan bagaimana saya mengharapkan operasi untuk bekerja di dunia nyata dan karena dengan menambahkan async/menunggu, saya dapat menjalankan test suite yang sama ini terhadap implementasi nyata juga selain yang palsu, sehingga diperlukan asinkroni dengan tepat. Faktanya, dalam kehidupan nyata, saya kemungkinan besar bahkan tidak akan khawatir tentang mengejek database dan sebagai gantinya akan menggunakan DB lokal dalam wadah Docker sampai ada begitu banyak tes sehingga saya harus mengejeknya untuk kinerja. Saya kemudian dapat menjalankan tes DB dalam memori setelah setiap perubahan dan memesan tes DB lokal yang sebenarnya tepat sebelum melakukan perubahan dan untuk server build di pipa CI/CD.
Pada pengujian pertama, pada bagian “arrange”, kita cukup membuat DTO. Di bagian "tindakan", kami memanggil sistem yang sedang diuji dan menjalankan perilakunya. Hal-hal menjadi sedikit lebih kompleks ketika membuat pernyataan. Ingat, pada titik pengujian ini, kami bahkan tidak tahu apakah pengguna telah disimpan dengan benar. Jadi, kami menentukan seperti apa tampilan pengguna yang bertahan, dan kemudian kami memanggil Repositori palsu dan memintanya untuk pengguna dengan ID yang kami harapkan. Jika UserService
tidak mempertahankan pengguna dengan benar, ini akan memunculkan NotFoundError
dan pengujian akan gagal, jika tidak, itu akan mengembalikan pengguna kepada kami. Selanjutnya, kami menghubungi penyedia email palsu dan menanyakan apakah itu tercatat mengirim email ke pengguna itu. Akhirnya, kami membuat pernyataan dengan Jest dan itu mengakhiri tes. Ini ekspresif dan membaca seperti bagaimana sistem sebenarnya bekerja. Tidak ada tipuan dari perpustakaan yang mengejek dan tidak ada hubungan dengan implementasi UserService
.
Pada pengujian kedua, kami membuat pengguna yang ada dan menambahkannya ke repositori, lalu kami mencoba memanggil layanan lagi menggunakan DTO yang telah digunakan untuk membuat dan mempertahankan pengguna, dan kami berharap itu gagal. Kami juga menegaskan bahwa tidak ada data baru yang ditambahkan ke repositori.
Untuk pengujian ketiga, bagian "atur" sekarang terdiri dari membuat pengguna dan menyimpannya ke Repositori palsu. Kemudian, kita panggil SUT, dan terakhir, periksa apakah pengguna yang kembali adalah yang kita simpan di repo tadi.
Contoh-contoh ini relatif sederhana, tetapi ketika hal-hal menjadi lebih kompleks, dapat mengandalkan injeksi ketergantungan dan antarmuka dengan cara ini menjaga kode Anda tetap bersih dan membuat tes menulis menjadi menyenangkan.
Pengecualian singkat tentang pengujian: Secara umum, Anda tidak perlu mengolok-olok setiap ketergantungan yang digunakan kode. Banyak orang, secara keliru, mengklaim bahwa "unit" dalam "pengujian unit" adalah satu fungsi atau satu kelas. Itu tidak bisa lebih salah. "Unit" didefinisikan sebagai "unit fungsionalitas" atau "unit perilaku", bukan satu fungsi atau kelas. Jadi, jika suatu unit perilaku menggunakan 5 kelas yang berbeda, Anda tidak perlu mengejek semua kelas tersebut kecuali jika mereka mencapai di luar batas modul. Dalam hal ini, saya mengejek database dan saya mengejek penyedia email karena saya tidak punya pilihan. Jika saya tidak ingin menggunakan database asli dan saya tidak ingin mengirim email, saya harus mengejek mereka. Tetapi jika saya memiliki lebih banyak kelas yang tidak melakukan apa pun di seluruh jaringan, saya tidak akan mengejeknya karena itu adalah detail implementasi dari unit perilaku. Saya juga dapat memutuskan untuk tidak mengejek database dan email dan memutar database lokal nyata dan server SMTP nyata, keduanya dalam wadah Docker. Pada poin pertama, saya tidak punya masalah menggunakan database nyata dan masih menyebutnya sebagai unit test asalkan tidak terlalu lambat. Umumnya, saya akan menggunakan DB asli terlebih dahulu sampai menjadi terlalu lambat dan saya harus mengejek, seperti yang dibahas di atas. Namun, apa pun yang Anda lakukan, Anda harus pragmatis — mengirim email selamat datang bukanlah operasi yang sangat penting, jadi kami tidak perlu melangkah sejauh itu dalam hal server SMTP dalam wadah Docker. Setiap kali saya melakukan mock, saya akan sangat tidak mungkin menggunakan kerangka kerja mengejek atau mencoba untuk menegaskan berapa kali dipanggil atau parameter dilewatkan kecuali dalam kasus yang sangat jarang, karena itu akan menggabungkan tes dengan implementasi sistem yang sedang diuji, dan mereka harus agnostik terhadap detail tersebut.
Melakukan Injeksi Ketergantungan Tanpa Kelas Dan Konstruktor
Sejauh ini, di sepanjang artikel, kami telah bekerja secara eksklusif dengan kelas dan menyuntikkan dependensi melalui konstruktor. Jika Anda menggunakan pendekatan fungsional untuk pengembangan dan tidak ingin menggunakan kelas, Anda masih dapat memperoleh manfaat injeksi ketergantungan menggunakan argumen fungsi. Misalnya, kelas UserService
kami di atas dapat direfaktor menjadi:
function makeUserService( userRepository: IUserRepository, emailProvider: IEmailProvider ): IUserService { return { registerUser: async dto => { // ... }, findUserById: id => userRepository.findUserById(id) } }
Ini adalah pabrik yang menerima dependensi dan membangun objek layanan. Kami juga dapat menyuntikkan dependensi ke Fungsi Orde Tinggi. Contoh tipikal adalah membuat fungsi Express Middleware yang UserRepository
dan ILogger
:
function authProvider(userRepository: IUserRepository, logger: ILogger) { return async (req: Request, res: Response, next: NextFunction) => { // ... // Has access to userRepository, logger, req, res, and next. } }
Pada contoh pertama, saya tidak mendefinisikan jenis dto
dan id
karena jika kita mendefinisikan antarmuka bernama IUserService
yang berisi tanda tangan metode untuk layanan, maka TS Compiler akan menyimpulkan jenisnya secara otomatis. Demikian pula, seandainya saya mendefinisikan tanda tangan fungsi untuk Express Middleware sebagai tipe pengembalian authProvider
, saya juga tidak perlu mendeklarasikan tipe argumen di sana.
Jika kami menganggap penyedia email dan repositori juga berfungsi, dan jika kami juga menyuntikkan dependensi spesifiknya alih-alih mengkodekannya secara keras, root aplikasi akan terlihat seperti ini:
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.
Kesimpulan
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;
- Dan banyak lagi.
Pantau terus!