Bağımlılık Enjeksiyonuna Pratik Bir Giriş

Yayınlanan: 2022-03-10
Kısa özet ↬ Bu makale, Dependency Injection'a teori tarafından engellenmeden birçok faydasını hemen gerçekleştirmenize izin verecek şekilde pratik bir giriş sağlayan yeni bir dizinin ilk bölümüdür.

Bağımlılık Enjeksiyonu kavramı, özünde temelde basit bir kavramdır. Bununla birlikte, genellikle, Kontrolün Tersine Çevirilmesi, Bağımlılığın Tersine Çevirilmesi, SOLID İlkeleri ve benzerlerinin daha teorik kavramlarıyla birlikte sunulur. Dependency Injection'ı kullanmaya başlamanızı ve faydalarından yararlanmaya başlamanızı mümkün olduğunca kolaylaştırmak için, bu makale, esas olarak, kullanımının faydalarını tam olarak gösteren örnekleri betimleyerek, hikayenin pratik tarafında kalacaktır. ilişkili teoriden ayrılmıştır.

Burada bağımlılık enjeksiyonunu çevreleyen akademik kavramları tartışmak için çok az zaman harcayacağız, çünkü bu açıklamanın büyük kısmı bu dizinin ikinci makalesine ayrılacaktır. Gerçekten de, kavramların daha derinlemesine ve titiz bir şekilde ele alınmasını sağlayan bütün kitaplar yazılabilir ve yazılmıştır.

Burada basit bir açıklama ile başlayacağız, birkaç gerçek dünya örneğine daha geçeceğiz ve ardından bazı arka plan bilgilerini tartışacağız. Başka bir makale (bunu takip edecek), Dependency Injection'ın en iyi uygulama mimari modellerini uygulamanın genel ekosistemine nasıl uyduğunu tartışacaktır.

Basit Bir Açıklama

"Bağımlılık Enjeksiyonu", son derece basit bir kavram için aşırı karmaşık bir terimdir. Bu noktada akılcı ve mantıklı sorulardan bazıları “bağımlılığı nasıl tanımlarsınız?”, “bağımlılığın 'enjekte edilmesi' ne anlama gelir?”, “bağımlılıkları farklı şekillerde enjekte edebilir misiniz?” şeklinde olabilir. ve “bu neden yararlıdır?” “Bağımlılık Enjeksiyonu” gibi bir terimin iki kod parçacığı ve birkaç kelimeyle açıklanabileceğine inanmayabilirsiniz, ama ne yazık ki olabilir.

Kavramı açıklamanın en basit yolu size göstermektir.

Bu, örneğin, bağımlılık enjeksiyonu değildir :

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

Ancak bu bağımlılık enjeksiyonudur:

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

Tamamlandı. Bu kadar. Serin. Son.

Ne değişti? Car sınıfının Engine örneğini başlatmasına izin vermek yerine (ilk örnekte olduğu gibi), ikinci örnekte Car , daha yüksek bir kontrol seviyesinden yapıcısına bir Engine örneğini aktardı - veya enjekte etti. Bu kadar. Özünde, bunların hepsi bağımlılık enjeksiyonudur - bir bağımlılığı başka bir sınıfa veya işleve enjekte etme (geçme) eylemidir. Bağımlılık enjeksiyonu kavramını içeren başka herhangi bir şey, bu temel ve basit kavramın basit bir varyasyonudur. Basitçe söylemek gerekirse, bağımlılık enjeksiyonu, bir nesnenin bağımlı olduğu diğer nesneleri (bağımlılık adı verilen) kendisi oluşturmak yerine aldığı bir tekniktir.

Genel olarak, bir "bağımlılığın" ne olduğunu tanımlamak için, eğer bazı A sınıfı B sınıfının işlevselliğini kullanıyorsa, o zaman B , A için bir bağımlılıktır veya başka bir deyişle, A , B bağımlıdır. Tabii ki, bu sınıflarla sınırlı değildir ve işlevler için de geçerlidir. Bu durumda, Car sınıfının Engine sınıfına bağımlılığı vardır veya Engine , Car öğesinin bağımlılığıdır. Bağımlılıklar, programlamadaki çoğu şey gibi, basitçe değişkenlerdir.

Bağımlılık Enjeksiyonu, birçok kullanım örneğini desteklemek için yaygın olarak kullanılmaktadır, ancak belki de en bariz kullanım, daha kolay testlere izin vermektir. İlk örnekte, Car sınıfı onu somutlaştırdığı için engine kolayca taklit edemeyiz. Gerçek motor her zaman kullanılıyor. Ancak, ikinci durumda, kullanılan Engine üzerinde kontrole sahibiz, bu da bir testte Engine alt sınıflara ayırabilir ve yöntemlerini geçersiz kılabileceğimiz anlamına gelir.

Örneğin, eğer engine.fireCylinders() bir hata atarsa ​​Car.startEngine()'in ne Car.startEngine() görmek istersek, basitçe bir FakeEngine sınıfı oluşturabilir, onun Engine sınıfını genişletmesini sağlayabilir ve ardından bir hata atmasını sağlamak için fireCylinders geçersiz kılabilirdik. . Testte, o FakeEngine nesnesini Car için yapıcıya enjekte edebiliriz. FakeEngine , kalıtım yoluyla bir Engine olduğundan, TypeScript türü sistem karşılanır. Daha sonra göreceğimiz gibi, kalıtım ve yöntem geçersiz kılmanın kullanılması, bunu yapmanın en iyi yolu olmayabilir, ancak bu kesinlikle bir seçenektir.

Yukarıda gördüğünüz şeyin, bağımlılık enjeksiyonunun temel kavramı olduğunu çok ama çok açıklığa kavuşturmak istiyorum. Bir Car kendi başına hangi motora ihtiyacı olduğunu bilecek kadar akıllı değildir. Sadece arabayı yapan mühendisler , motorlarının ve tekerleklerinin gereksinimlerini anlar. Bu nedenle, bir Car kullanmak istediği motoru seçmesine izin vermek yerine, arabayı yapan kişilerin gerekli belirli motoru sağlaması mantıklıdır.

Yer bağımlılıklarının enjekte edildiği yapıcıyı çağırarak arabayı inşa ettiğiniz için özellikle “inşa” kelimesini kullanıyorum. Araba, motora ek olarak kendi lastiklerini de yarattıysa, kullanılan lastiklerin motorun verebileceği maksimum RPM'de döndürülmesinin güvenli olduğunu nasıl bileceğiz? Tüm bu nedenlerle ve daha fazlası için, Car hangi Engine ve hangi Wheels kullanacağına karar vermekle hiçbir ilgisi olmaması, belki de sezgisel olarak mantıklı olmalıdır. Daha yüksek bir kontrol seviyesinden sağlanmalıdırlar.

Bağımlılık enjeksiyonunu çalışırken gösteren ikinci örnekte, Engine somut bir sınıf yerine soyut bir sınıf olduğunu hayal ederseniz, bu daha da mantıklı olacaktır - araba bir motora ihtiyacı olduğunu biliyor ve motorun bazı temel işlevlere sahip olması gerektiğini biliyor , ancak bu motorun nasıl yönetildiği ve bunun özel uygulamasının ne olduğu, arabayı oluşturan (inşa eden) kod parçası tarafından kararlaştırılmak ve sağlanmak üzere ayrılmıştır.

Atlamadan sonra daha fazlası! Aşağıdan okumaya devam edin ↓

Gerçek Dünyadan Bir Örnek

Bağımlılık enjeksiyonunun neden yararlı olduğunu yine sezgisel olarak açıklamaya yardımcı olacak birkaç pratik örneğe bakacağız. Umarım, teorik olana takılıp kalmayıp bunun yerine doğrudan uygulanabilir kavramlara geçerek, bağımlılık enjeksiyonunun sağladığı faydaları ve onsuz yaşamın zorluklarını daha tam olarak görebilirsiniz. Konunun biraz daha “akademik” bir şekilde ele alınmasına daha sonra döneceğiz.

Uygulamamızı, bağımlılık enjeksiyonu veya soyutlamalar kullanmadan, yüksek düzeyde bağlantılı bir şekilde normal şekilde oluşturarak başlayacağız, böylece bu yaklaşımın olumsuz taraflarını ve teste kattığı zorluğu görebiliriz. Yol boyunca, tüm sorunları giderene kadar kademeli olarak yeniden düzenleme yapacağız.

Başlamak için, iki sınıf oluşturmakla görevlendirildiğinizi varsayalım - bir e-posta sağlayıcısı ve bazı UserService tarafından kullanılması gereken bir veri erişim katmanı için bir sınıf. Veri erişimiyle başlayacağız, ancak her ikisi de kolayca tanımlanabilir:

 // 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(...) } }

Not: Buradaki "Depo" adı, veritabanınızı iş mantığınızdan ayırma yöntemi olan "Depo Modeli"nden gelmektedir. Depo Kalıbı hakkında daha fazla bilgi edinebilirsiniz, ancak bu makalenin amaçları doğrultusunda, onu veritabanınızı içine alan bir sınıf olarak düşünebilirsiniz, böylece iş mantığına göre veri depolama sisteminiz yalnızca bir bellek içi olarak ele alınır. Toplamak. Depo Modelini tam olarak açıklamak bu makalenin kapsamı dışındadır.

Normalde işlerin böyle yürümesini bekleriz ve dbDriver dosyanın içinde sabit kodlanmıştır.

UserService sınıfı içe aktarır, somutlaştırır ve kullanmaya başlarsınız:

 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); } }

Bir kez daha, her şey normal kalır.

Kısa bir kenara: DTO bir Veri Aktarım Nesnesidir — iki harici sistem veya bir uygulamanın iki katmanı arasında hareket ederken standartlaştırılmış bir veri şekli tanımlamak için bir özellik çantası görevi gören bir nesnedir. Martin Fowler'ın konuyla ilgili makalesinden DTO'lar hakkında daha fazla bilgi edinebilirsiniz. Bu durumda, IRegisterUserDto , istemciden gelen verilerin şeklinin nasıl olması gerektiğine dair bir sözleşme tanımlar. Sadece iki özellik içeriyor - id ve email . Henüz bir kullanıcı oluşturmamış olmamıza rağmen, istemciden yeni bir kullanıcı oluşturmasını beklediğimiz DTO'nun kullanıcının kimliğini içermesinin tuhaf olduğunu düşünebilirsiniz. Kimlik bir UUID'dir ve istemcinin bu makalenin kapsamı dışında kalan çeşitli nedenlerle onu oluşturmasına izin veriyorum. Ek olarak, findUserById işlevi, User nesnesini bir yanıt DTO'su ile eşlemelidir, ancak kısa olması için bunu ihmal ettim. Son olarak, gerçek dünyada, fromDto yöntemi içeren bir User etki alanı modelim olmazdı. Bu alan saflığı için iyi değil. Bir kez daha, amacı burada kısalıktır.

Ardından, e-postaların gönderilmesini halletmek istiyorsunuz. Bir kez daha, normal olarak, bir e-posta sağlayıcı sınıfı oluşturabilir ve bunu UserService .

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

UserService içinde:

 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); } }

Artık tam anlamıyla bir işçi sınıfına sahibiz ve hiçbir şekilde test edilebilirliği veya temiz kod yazmayı hiç umursamadığımız bir dünyada ve teknik borcun olmadığı ve sinir bozucu program yöneticilerinin olmadığı bir dünyada. Son teslim tarihleri ​​​​belirleyin, bu gayet iyi. Ne yazık ki, bu, içinde yaşama avantajına sahip olduğumuz bir dünya değil.

E-postalar için SendGrid'den taşınmamız ve bunun yerine MailChimp kullanmamız gerektiğine karar verdiğimizde ne olur? Benzer şekilde, yöntemlerimizi birim test etmek istediğimizde ne olur - testlerde gerçek veritabanını mı kullanacağız? Daha da kötüsü, potansiyel olarak gerçek e-posta adreslerine gerçek e-postalar gönderecek ve bunun için de ödeme yapacak mıyız?

Geleneksel JavaScript ekosisteminde, bu yapılandırma altındaki birim test sınıflarının yöntemleri, karmaşıklık ve aşırı mühendislikle doludur. İnsanlar tüm kitaplıkları basitçe, her türlü dolaylı katman ekleyen ve daha da kötüsü, testleri test edilen sistemin uygulanmasıyla doğrudan birleştirebilen saplama işlevselliği sağlamak için getirir, gerçekte, testler asla nasıl olduğunu bilmemelidir. gerçek sistem çalışır (bu, kara kutu testi olarak bilinir). UserService asıl sorumluluğunun ne olduğunu tartışırken ve yeni bağımlılık enjeksiyon teknikleri uygularken bu sorunları azaltmak için çalışacağız.

Bir an için bir UserService ne yaptığını düşünün. UserService varlığının tüm amacı, kullanıcıları içeren belirli kullanım durumlarını yürütmektir - onları kaydetmek, okumak, güncellemek, vb. Sınıfların ve işlevlerin yalnızca bir sorumluluğa sahip olması en iyi uygulamadır (SRP - Tek Sorumluluk İlkesi), ve UserService sorumluluğu, kullanıcıyla ilgili işlemleri yürütmektir. Öyleyse, bu örnekte UserRepository ve SendGridEmailProvider kullanım ömrünü kontrol etmekten UserService neden sorumludur?

Uzun süredir devam eden bir bağlantı açan UserService tarafından kullanılan başka bir sınıfımız olup olmadığını hayal edin. UserService bu bağlantının atılmasından da sorumlu olmalı mı? Tabii ki değil. Tüm bu bağımlılıkların kendileriyle ilişkili bir ömrü vardır - bunlar tekil olabilir, geçici olabilir ve belirli bir HTTP İsteği kapsamında olabilir, vb. Bu ömürlerin kontrolü, UserService . Bu sorunları çözmek için, daha önce gördüğümüz gibi, tüm bağımlılıkları enjekte edeceğiz.

 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); } }

Harika! Artık UserService önceden oluşturulmuş nesneleri alır ve hangi kod parçası çağırırsa ve yeni bir UserService oluşturursa, bağımlılıkların ömrünü kontrol etmekten sorumlu kod parçasıdır. Kontrolü UserService alıp daha yüksek bir düzeye çıkardık. Bağımlılık enjeksiyonunun temel kiracısını açıklamak için yalnızca yapıcı aracılığıyla bağımlılıkları nasıl enjekte edebileceğimizi göstermek isteseydim, burada durabilirdim. Tasarım açısından hala bazı sorunlar var, ancak bunlar düzeltildiğinde, bağımlılık enjeksiyonu kullanımımızı daha da güçlü hale getirmeye hizmet edecek.

İlk olarak, UserService e-postalar için UserService kullandığımızı neden biliyor? İkinci olarak, her iki bağımlılık da somut sınıflardadır - somut UserRepository ve somut SendGridEmailProvider . Bu ilişki çok katı - UserRepository ve SendGridEmailProvider olan bir nesneyi geçmek zorunda kaldık.

Bu harika değil çünkü UserService bağımlılıklarının uygulanmasında tamamen agnostik olmasını istiyoruz. UserService bu şekilde kör olmasını sağlayarak, hizmeti hiçbir şekilde etkilemeden uygulamaları değiştirebiliriz - bu, SendGrid'den taşınmaya ve bunun yerine MailChimp'i kullanmaya karar verirsek, bunu yapabileceğimiz anlamına gelir. Ayrıca, testler için e-posta sağlayıcısını taklit etmek istiyorsak, bunu da yapabiliriz demektir.

Yararlı olan, bazı genel arabirimleri tanımlayabilmemiz ve gelen bağımlılıkların bu arabirime uymasını zorlayabilmemiz ve yine de UserService uygulama ayrıntılarına karşı agnostik olmasını sağlamamızdır. Başka bir deyişle, UserService gerçek somut bağımlılıklarına değil, yalnızca bağımlılıklarının bir soyutlamasına bağlı olmaya zorlamamız gerekiyor. Bunu arayüzler aracılığıyla yapabiliriz.

UserRepository için bir arayüz tanımlayarak başlayın ve uygulayın:

 // 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(...) } }

Ve e-posta sağlayıcısı için bir tane tanımlayın ve onu da uygulayın:

 // 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(...); } }

Not: Bu, Dört Tasarım Modeli Çetesinden Adaptör Modelidir.

Artık UserService , bağımlılıkların somut uygulamalarından ziyade arayüzlere bağlı olabilir:

 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); } }

Arayüzler sizin için yeniyse, bu çok, çok karmaşık görünebilir. Aslında, gevşek bağlı yazılım oluşturma kavramı sizin için de yeni olabilir. Duvar prizlerini düşünün. Fiş prize uyduğu sürece herhangi bir cihazı herhangi bir prize takabilirsiniz. Bu, hareket halindeki gevşek bağlantıdır. Ekmek kızartma makineniz duvara sabit bir şekilde bağlı değil, çünkü öyleyse ve ekmek kızartma makinenizi yükseltmeye karar verirseniz, şansınız kalmaz. Bunun yerine çıkışlar kullanılır ve çıkış, arayüzü tanımlar. Benzer şekilde, duvar prizinize bir elektronik cihaz taktığınızda, voltaj potansiyeli, maksimum akım çekişi, AC frekansı vb. ile ilgilenmezsiniz, sadece fişin prize takılı olup olmadığıyla ilgilenirsiniz. Bir elektrikçiye gelip o prizin arkasındaki tüm kabloları değiştirebilir ve çıkış değişmediği sürece ekmek kızartma makinenizi fişe takmada herhangi bir sorun yaşamayacaksınız. Ayrıca, elektrik kaynağınız şehirden veya kendi güneş panellerinizden gelecek şekilde değiştirilebilir ve bir kez daha, prize takabildiğiniz sürece umursamıyorsunuz.

Arayüz, “tak-çalıştır” işlevselliği sağlayan çıkış noktasıdır. Bu örnekte, duvardaki kablolama ve elektrik kaynağı bağımlılıklara benzer ve ekmek kızartma UserService benzer (elektriğe bağımlıdır) - elektrik kaynağı değişebilir ve ekmek kızartma makinesi hala iyi çalışıyor ve buna gerek yok dokunulabilir, çünkü arayüz görevi gören çıkış, her ikisinin de iletişim kurması için standart araçları tanımlar. Aslında, prizin duvar kablolarının, devre kesicilerin, elektrik kaynağının vb. bir "soyutlaması" gibi davrandığını söyleyebilirsiniz.

Yukarıdaki nedenlerden dolayı, burada yaptığımız gibi uygulamalara değil arayüzlere (soyutlamalara) karşı kodlamak, yazılım tasarımının yaygın ve iyi bilinen bir ilkesidir. Bunu yaparken, uygulamaları istediğimiz gibi değiştirme özgürlüğüne sahibiz, çünkü bu uygulamalar arayüzün arkasına gizlenir (tıpkı duvar kablolarının prizin arkasına gizlenmesi gibi) ve böylece bağımlılığı kullanan iş mantığı asla zorunda kalmaz. arayüz asla değişmediği sürece değişir. Unutmayın, UserService yalnızca bağımlılıkları tarafından hangi işlevselliğin sunulduğunu bilmelidir, bu işlevselliğin perde arkasında nasıl desteklendiğini değil. Bu yüzden arayüzleri kullanmak işe yarar.

Arayüzlerin kullanılması ve bağımlılıkların enjekte edilmesiyle ilgili bu iki basit değişiklik, konu gevşek bağlı yazılımlar oluşturmaya geldiğinde dünyadaki tüm farkı yaratır ve yukarıda karşılaştığımız tüm sorunları çözer.

Yarın e-postalar için Mailchimp'e güvenmek istediğimize karar verirsek, IEmailProvider arabirimini onurlandıran ve SendGrid yerine onu enjekte eden yeni bir Mailchimp sınıfı oluşturmamız yeterlidir. Yeni bir e-posta sağlayıcısına geçerek sistemimizde çok büyük bir değişiklik yapmış olmamıza rağmen, gerçek UserService sınıfının asla değişmesi gerekmez. Bu kalıpların güzelliği, UserService kullandığı bağımlılıkların perde arkasında nasıl çalıştığından mutlu bir şekilde habersiz kalmasıdır. Arayüz, her iki bileşen arasında mimari bir sınır görevi görür ve onları uygun şekilde ayrıştırılmış halde tutar.

Ek olarak, teste gelince, arayüzlere uyan sahteler oluşturabilir ve bunun yerine onları enjekte edebiliriz. Burada sahte bir depo ve sahte bir e-posta sağlayıcısı görebilirsiniz.

 // 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)); }

Her iki sahtenin de UserService bağımlılıklarının yerine getirmesini beklediği aynı arabirimleri uyguladığına dikkat edin. Artık bu sahteleri gerçek sınıflar yerine UserService geçirebiliriz ve UserService bundan daha akıllıca olmayacaktır; onları gerçek bir anlaşmaymış gibi kullanacak. Bunu yapabilmesinin nedeni, bağımlılıklarında kullanmak istediği tüm yöntemlerin ve özelliklerin gerçekten var olduğunu ve gerçekten erişilebilir olduğunu bilmesidir (çünkü bunlar arabirimleri uygular), tüm UserService gereken budur (yani, bağımlılıklar nasıl çalışır).

Testler sırasında bu ikisini enjekte edeceğiz ve test sürecini, aşırı alay etme ve saptırma kitaplıklarıyla uğraşırken alışık olabileceğinizden çok daha kolay ve çok daha basit hale getirecek, Jest'in kendi dahili alet yapmak veya maymun yama yapmaya çalışmak.

Sahteleri kullanan gerçek testler:

 // 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); });

Burada birkaç şey fark edeceksiniz: Elle yazılmış sahteler çok basit. Yalnızca karartmaya hizmet eden alaycı çerçevelerden kaynaklanan hiçbir karmaşıklık yoktur. Her şey elle yuvarlanır ve bu, kod tabanında sihir olmadığı anlamına gelir. Asenkron davranış, arayüzlerle eşleşmesi için taklit edilir. Tüm davranışlar senkronize olmasına rağmen testlerde async/await kullanıyorum çünkü işlemlerin gerçek dünyada nasıl çalışmasını beklediğime daha yakından uyduğunu hissediyorum ve çünkü async/await ekleyerek aynı test paketini çalıştırabilirim sahtelere ek olarak gerçek uygulamalara karşı da, bu nedenle uygun şekilde asenkron verilmesi gerekir. Aslında, gerçek hayatta, büyük olasılıkla veritabanıyla alay etme konusunda endişelenmezdim ve bunun yerine, performans için alay etmek zorunda kaldığım çok fazla test olana kadar bir Docker kapsayıcısında yerel bir DB kullanırdım. Daha sonra her bir değişiklikten sonra bellek içi DB testlerini çalıştırabilir ve gerçek yerel DB testlerini değişiklik yapmadan hemen önce ve CI/CD boru hattındaki yapı sunucusunda rezerve edebilirim.

İlk testte, “düzenle” bölümünde sadece DTO'yu oluşturuyoruz. “Hareket” bölümünde, test edilen sistemi çağırıyoruz ve davranışını yürütüyoruz. İddialarda bulunurken işler biraz daha karmaşık hale gelir. Unutmayın, testin bu noktasında kullanıcının doğru şekilde kaydedilip kaydedilmediğini bile bilmiyoruz. Böylece, kalıcı bir kullanıcının nasıl görünmesini beklediğimizi tanımlarız ve ardından sahte Depoyu çağırır ve beklediğimiz kimliğe sahip bir kullanıcı için sorarız. UserService kullanıcıyı doğru şekilde devam ettirmediyse, bu bir NotFoundError ve test başarısız olur, aksi takdirde bize kullanıcıyı geri verir. Ardından, sahte e-posta sağlayıcısını ararız ve o kullanıcıya bir e-posta göndermeyi kaydedip kaydetmediğini sorarız. Son olarak, iddiaları Jest ile yapıyoruz ve bu da testi sonlandırıyor. Etkileyicidir ve tıpkı sistemin gerçekte nasıl çalıştığı gibi okur. Alay kitaplıklarından herhangi bir dolaylı yönlendirme yoktur ve UserService uygulamasının uygulanmasına ilişkin hiçbir bağlantı yoktur.

İkinci testte ise var olan bir kullanıcı oluşturup onu depoya ekliyoruz, daha sonra bir kullanıcıyı oluşturmak ve kalıcı kılmak için daha önce kullanılmış bir DTO kullanarak servisi tekrar çağırmaya çalışıyoruz ve bunun başarısız olmasını bekliyoruz. Ayrıca, depoya yeni veri eklenmediğini de iddia ediyoruz.

Üçüncü test için, “düzenle” bölümü artık bir kullanıcı oluşturup onu sahte Depoda kalıcı hale getirmekten oluşuyor. Ardından SUT'u çağırıyoruz ve son olarak geri gelen kullanıcının daha önce depoya kaydettiğimiz kullanıcı olup olmadığını kontrol ediyoruz.

Bu örnekler nispeten basittir, ancak işler daha karmaşık hale geldiğinde, bu şekilde bağımlılık enjeksiyonuna ve arayüzlere güvenebilmek kodunuzu temiz tutar ve test yazmayı bir keyif haline getirir.

Test hakkında kısa bir bilgi: Genel olarak, kodun kullandığı her bağımlılığı alaya almanıza gerek yoktur. Birçok kişi, hatalı olarak, bir "birim testindeki" bir "birimin" bir işlev veya bir sınıf olduğunu iddia eder. Bu daha yanlış olamazdı. "Birim", bir işlev veya sınıf değil, "işlevsellik birimi" veya "davranış birimi" olarak tanımlanır. Bu nedenle, bir davranış birimi 5 farklı sınıf kullanıyorsa , modülün sınırlarının dışına çıkmadıkça tüm bu sınıflarla alay etmenize gerek yoktur . Bu durumda, başka seçeneğim olmadığı için veritabanıyla alay ettim ve e-posta sağlayıcısıyla alay ettim. Gerçek bir veritabanı kullanmak istemiyorsam ve bir e-posta göndermek istemiyorsam, onlarla alay etmeliyim. Ancak ağ üzerinde hiçbir şey yapmayan bir sürü sınıfım daha olsaydı, davranış biriminin uygulama detayları oldukları için onlarla alay etmezdim. Ayrıca veritabanı ve e-postalarla alay etmemeye karar verebilir ve her ikisi de Docker kapsayıcılarında gerçek bir yerel veritabanı ve gerçek bir SMTP sunucusu çalıştırabilirim. İlk noktada, gerçek bir veritabanı kullanmakta ve çok yavaş olmadığı sürece hala birim testi olarak adlandırmakta sorunum yok. Genel olarak, çok yavaşlayana kadar önce gerçek DB'yi kullanırdım ve yukarıda tartışıldığı gibi alay etmek zorunda kaldım. Ancak, ne yaparsanız yapın, pragmatik olmalısınız - hoş geldiniz e-postaları göndermek kritik bir işlem değildir, bu nedenle Docker kapsayıcılarındaki SMTP sunucuları açısından o kadar ileri gitmemize gerek yok. Ne zaman alay etsem, alaycı bir çerçeve kullanmam veya çok nadir durumlar dışında çağrılan veya geçirilen parametrelerin sayısı üzerinde iddiada bulunma olasılığım çok düşük olurdu, çünkü bu, testleri test edilen sistemin uygulanmasıyla eşleştirir ve onlar bu ayrıntılara agnostik olmalıdır.

Sınıflar ve Oluşturucular Olmadan Bağımlılık Enjeksiyonu Gerçekleştirme

Şimdiye kadar, makale boyunca, yalnızca sınıflarla çalıştık ve bağımlılıkları yapıcı aracılığıyla enjekte ettik. Geliştirmeye işlevsel bir yaklaşım getiriyorsanız ve sınıfları kullanmak istemiyorsanız, yine de işlev argümanlarını kullanarak bağımlılık enjeksiyonunun faydalarını elde edebilirsiniz. Örneğin, yukarıdaki UserService sınıfımız şu şekilde yeniden düzenlenebilir:

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

Bağımlılıkları alan ve hizmet nesnesini oluşturan bir fabrikadır. Ayrıca Yüksek Dereceli Fonksiyonlara bağımlılıkları da enjekte edebiliriz. Tipik bir örnek, bir UserRepository ve bir ILogger enjekte edilen bir Express Middleware işlevi oluşturmak olabilir:

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

İlk örnekte, dto ve id tipini tanımlamadım çünkü hizmet için yöntem imzalarını içeren IUserService adlı bir arabirim tanımlarsak, TS Derleyicisi türleri otomatik olarak çıkaracaktır. Benzer şekilde, Express Middleware için geri dönüş türü olarak authProvider için bir işlev imzası tanımlamış olsaydım, burada argüman türlerini de bildirmek zorunda kalmazdım.

E-posta sağlayıcısının ve deposunun da işlevsel olduğunu düşünseydik ve bunları sabit kodlamak yerine belirli bağımlılıklarını da enjekte edersek, uygulamanın kökü şöyle görünebilir:

 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.

Çözüm

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;
  • Ve dahası.

Bizi izlemeye devam edin!