.NET Framework
Beroende på injektion
Sök…
Anmärkningar
Problem som löses genom beroendeinsprutning
Om vi inte använde beroendeinjektion kan Greeter
klassen se mer ut så här:
public class ControlFreakGreeter
{
public void Greet()
{
var greetingProvider = new SqlGreetingProvider(
ConfigurationManager.ConnectionStrings["myConnectionString"].ConnectionString);
var greeting = greetingProvider.GetGreeting();
Console.WriteLine(greeting);
}
}
Det är en "kontrollfreak" eftersom den kontrollerar att skapa klassen som ger hälsningen, den styr var SQL-anslutningssträngen kommer från, och den styr utdata.
Med användning av beroendeinjektion avstår Greeter
klassen det ansvaret till förmån för ett enda ansvar och skriver en hälsning som den tillhandahåller.
Dependency Inversion Principen föreslår att klasser bör bero på abstraktioner (som gränssnitt) snarare än på andra konkreta klasser. Direkt beroende (koppling) mellan klasser kan göra underhållet svårt. Beroende på abstraktioner kan minska den kopplingen.
Beroende injektion hjälper oss att uppnå den beroende inversion eftersom det leder till att skriva klasser som är beroende av abstraktioner. Greeter
klassen "vet" ingenting alls om implementeringsdetaljerna för IGreetingProvider
och IGreetingWriter
. Den vet bara att de injicerade beroenden implementerar dessa gränssnitt. Det betyder att förändringar i de konkreta klasserna som implementerar IGreetingProvider
och IGreetingWriter
kommer att påverka Greeter
. Inte heller kommer att ersätta dem med helt olika implementationer. Endast ändringar i gränssnitten kommer. Greeter
är frånkopplad.
ControlFreakGreeter
är omöjligt att korrekt testa enhet. Vi vill testa en liten kodenhet, men istället skulle vårt test inkludera anslutning till SQL och utföra en lagrad procedur. Det skulle också inkludera att testa konsolutgången. Eftersom ControlFreakGreeter gör så mycket är det omöjligt att testa isolerat från andra klasser.
Greeter
är lätt att enhetstest eftersom vi kan injicera håliga implementationer av dess beroenden som är lättare att utföra och verifiera än att ringa en lagrad procedur eller läsa utgången från konsolen. Det kräver ingen anslutningssträng i app.config.
De konkreta implementeringarna av IGreetingProvider
och IGreetingWriter
kan bli mer komplexa. De kan i sin tur ha sina egna beroenden som sprutas in i dem. (Till exempel injicerar vi SQL-anslutningssträngen i SqlGreetingProvider
.) Men den komplexiteten är "dold" från andra klasser som bara beror på gränssnitten. Det gör det lättare att ändra en klass utan en "rippeleffekt" som kräver att vi gör motsvarande ändringar av andra klasser.
Beroende på injektion - Enkelt exempel
Denna klass kallas Greeter
. Dess ansvar är att ge en hälsning. Det har två beroenden . Den behöver något som ger den hälsningen att matas ut, och sedan behöver den ett sätt att mata ut den hälsningen. Dessa beroenden beskrivs båda som gränssnitt, IGreetingProvider
och IGreetingWriter
. I det här exemplet "injiceras" dessa två beroenden i Greeter
. (Ytterligare förklaring enligt exemplet.)
public class Greeter
{
private readonly IGreetingProvider _greetingProvider;
private readonly IGreetingWriter _greetingWriter;
public Greeter(IGreetingProvider greetingProvider, IGreetingWriter greetingWriter)
{
_greetingProvider = greetingProvider;
_greetingWriter = greetingWriter;
}
public void Greet()
{
var greeting = _greetingProvider.GetGreeting();
_greetingWriter.WriteGreeting(greeting);
}
}
public interface IGreetingProvider
{
string GetGreeting();
}
public interface IGreetingWriter
{
void WriteGreeting(string greeting);
}
Greeting
beror på både IGreetingProvider
och IGreetingWriter
, men det är inte ansvarigt för att skapa förekomster av endera. Istället kräver det dem i sin konstruktör. Oavsett vad som skapar en instans av Greeting
måste ge dessa två beroenden. Vi kan kalla det att "injicera" beroenden.
Eftersom beroenden tillhandahålls klassen i dess konstruktör, kallas detta också "konstruktörsinjektion."
Några vanliga konventioner:
- Konstruktören sparar beroenden som
private
fält. Så snart klassen är instanserad är dessa beroenden tillgängliga för alla andra icke-statiska metoder i klassen. - De
private
fälten ärreadonly
. När de är inställda i konstruktören kan de inte ändras. Detta indikerar att dessa fält inte bör (och inte kan) ändras utanför konstruktören. Det säkerställer vidare att dessa beroenden är tillgängliga under klassens livstid. - Beroenden är gränssnitt. Detta är inte strikt nödvändigt, men är vanligt eftersom det gör det lättare att ersätta en implementering av beroendet med en annan. Det tillåter också att tillhandahålla en hånad version av gränssnittet för enhetsteständamål.
Hur beroende injektion gör enhetstest enklare
Detta bygger på det föregående exemplet på Greeter
klassen som har två beroenden, IGreetingProvider
och IGreetingWriter
.
Den faktiska implementeringen av IGreetingProvider
kan hämta en sträng från ett API-samtal eller en databas. Implementeringen av IGreetingWriter
kan visa hälsningen i konsolen. Men eftersom Greeter
har sina beroenden injicerat i sin konstruktör, är det lätt att skriva ett enhetstest som injicerar håliga versioner av dessa gränssnitt. I det verkliga livet kanske vi använder ett ramverk som Moq , men i det här fallet kommer jag att skriva de förlöjliga implementeringarna.
public class TestGreetingProvider : IGreetingProvider
{
public const string TestGreeting = "Hello!";
public string GetGreeting()
{
return TestGreeting;
}
}
public class TestGreetingWriter : List<string>, IGreetingWriter
{
public void WriteGreeting(string greeting)
{
Add(greeting);
}
}
[TestClass]
public class GreeterTests
{
[TestMethod]
public void Greeter_WritesGreeting()
{
var greetingProvider = new TestGreetingProvider();
var greetingWriter = new TestGreetingWriter();
var greeter = new Greeter(greetingProvider, greetingWriter);
greeter.Greet();
Assert.AreEqual(greetingWriter[0], TestGreetingProvider.TestGreeting);
}
}
IGreetingProvider
och IGreetingWriter
är inte relevanta för detta test. Vi vill testa att Greeter
får en hälsning och skriver den. Utformningen av Greeter
(med beroendeinjektion) gör att vi kan injicera håliga beroenden utan komplicerade rörliga delar. Allt vi testar är att Greeter
interagerar med de beroenden som vi förväntar oss att göra.
Varför vi använder behållare för beroende av injektion (IoC-behållare)
Beroende injektion betyder att skriva klasser så att de inte kontrollerar deras beroenden - istället tillhandahålls deras beroenden till dem ("injiceras.")
Det här är inte samma sak som att använda ett ramar för injektion av beroende (ofta kallat en "DI-behållare", "IoC-behållare" eller bara "behållare") som Castle Windsor, Autofac, SimpleInjector, Ninject, Unity eller andra.
En behållare underlättar bara injektionsinjektion. Anta till exempel att du skriver ett antal klasser som förlitar sig på beroendeinjektion. En klass beror på flera gränssnitt, klasserna som implementerar dessa gränssnitt beror på andra gränssnitt och så vidare. Vissa beror på specifika värden. Och bara för skojs skull, implementerar några av dessa klasser IDisposable
och måste kasseras.
Varje enskild klass är välskrivet och lätt att testa. Men nu finns det ett annat problem: Att skapa en instans av en klass har blivit mycket mer komplicerad. Anta att vi skapar en instans av en CustomerService
klass. Det har beroenden och dess beroenden har beroenden. Att bygga en instans kan se ut så här:
public CustomerData GetCustomerData(string customerNumber)
{
var customerApiEndpoint = ConfigurationManager.AppSettings["customerApi:customerApiEndpoint"];
var logFilePath = ConfigurationManager.AppSettings["logwriter:logFilePath"];
var authConnectionString = ConfigurationManager.ConnectionStrings["authorization"].ConnectionString;
using(var logWriter = new LogWriter(logFilePath ))
{
using(var customerApiClient = new CustomerApiClient(customerApiEndpoint))
{
var customerService = new CustomerService(
new SqlAuthorizationRepository(authorizationConnectionString, logWriter),
new CustomerDataRepository(customerApiClient, logWriter),
logWriter
);
// All this just to create an instance of CustomerService!
return customerService.GetCustomerData(string customerNumber);
}
}
}
Du kanske undrar, varför inte sätta hela jättekonstruktionen i en separat funktion som bara returnerar CustomerService
? En orsak är att eftersom beroenden för varje klass injiceras i den, är en klass inte ansvarig för att veta om dessa beroenden är IDisposable
eller bortskaffa dem. Den använder dem bara. Så om a vi hade en GetCustomerService()
-funktion som returnerade en helt konstruerad CustomerService
, kan den klassen innehålla ett antal engångsresurser och inget sätt att komma åt eller bortskaffa dem.
Och bortsett från att bortskaffa IDisposable
, vem vill ringa en serie kapslade konstruktörer som det någonsin? Det är ett kort exempel. Det kan bli mycket, mycket värre. Återigen betyder det inte att vi skrev klasserna på fel sätt. Lektionerna kan vara individuellt perfekta. Utmaningen är att komponera dem tillsammans.
En behållare för injektionsinjektion förenklar det. Det tillåter oss att specificera vilken klass eller värde som ska användas för att uppfylla varje beroende. Detta något förenklade exempel använder Castle Windsor:
var container = new WindsorContainer()
container.Register(
Component.For<CustomerService>(),
Component.For<ILogWriter, LogWriter>()
.DependsOn(Dependency.OnAppSettingsValue("logFilePath", "logWriter:logFilePath")),
Component.For<IAuthorizationRepository, SqlAuthorizationRepository>()
.DependsOn(Dependency.OnValue(connectionString, ConfigurationManager.ConnectionStrings["authorization"].ConnectionString)),
Component.For<ICustomerDataProvider, CustomerApiClient>()
.DependsOn(Dependency.OnAppSettingsValue("apiEndpoint", "customerApi:customerApiEndpoint"))
);
Vi kallar detta "att registrera beroenden" eller "konfigurera behållaren." Översatt, detta berättar vår WindsorContainer
:
- Om en klass kräver
ILogWriter
, skapa en instans avLogWriter
.LogWriter
kräver enLogWriter
. Använd detta värde frånAppSettings
. - Om en klass kräver
IAuthorizationRepository
, skapa en instans avSqlAuthorizationRepository
. Det kräver en anslutningssträng. Använd detta värde från avsnittetConnectionStrings
. - Om en klass kräver
ICustomerDataProvider
, skapar du enCustomerApiClient
och tillhandahåller den sträng som den behöver frånAppSettings
.
När vi begär ett beroende från behållaren kallar vi att "lösa" ett beroende. Det är dålig praxis att göra det direkt med behållaren, men det är en annan historia. För demonstrationsändamål kan vi nu göra detta:
var customerService = container.Resolve<CustomerService>();
var data = customerService.GetCustomerData(customerNumber);
container.Release(customerService);
Containern vet att CustomerService
beror på IAuthorizationRepository
och ICustomerDataProvider
. Den vet vilka klasser den behöver skapa för att uppfylla dessa krav. Dessa klasser har i sin tur fler beroenden, och behållaren vet hur man kan uppfylla dessa. Det skapar varje klass som den behöver tills den kan returnera en instans av CustomerService
.
Om det kommer till en punkt där en klass kräver ett beroende som vi inte har registrerat, som IDoesSomethingElse
, så när vi försöker lösa CustomerService
kommer det att kasta ett tydligt undantag som säger att vi inte har registrerat något för att uppfylla detta krav.
Varje DI-ramverk uppför sig lite annorlunda, men vanligtvis ger de oss viss kontroll över hur vissa klasser instanseras. Till exempel vill vi att den ska skapa en instans av LogWriter
och tillhandahålla den till varje klass som beror på ILogWriter
, eller vill vi att den ska skapa en ny varje gång? De flesta containrar har ett sätt att specificera det.
Vad IDisposable
om klasser som implementerar IDisposable
? Det är därför vi kallar container.Release(customerService);
i slutet. De flesta containrar (inklusive Windsor) kommer att gå tillbaka genom alla beroenden som skapats och Dispose
de som behöver kasseras. Om CustomerService
är IDisposable
kommer den att bortskaffa det också.
Att registrera beroenden som ses ovan kanske bara ser ut som mer kod att skriva. Men när vi har massor av klasser med massor av beroenden, så lönar det sig verkligen. Och om vi var tvungna att skriva samma klasser utan att använda beroendeinjektion, skulle samma applikation med massor av klasser bli svårt att underhålla och testa.
Detta repar ytan till varför vi använder behållare för beroendeinsprutning. Hur vi konfigurerar vår applikation för att använda ett (och använda det på rätt sätt) är inte bara ett ämne - det är ett antal ämnen, eftersom instruktionerna och exemplen varierar från en behållare till nästa.