.NET Framework
Wstrzykiwanie zależności
Szukaj…
Uwagi
Problemy rozwiązane przez wstrzyknięcie zależności
Jeśli nie użyjemy wstrzykiwania zależności, klasa Greeter
może wyglądać bardziej tak:
public class ControlFreakGreeter
{
public void Greet()
{
var greetingProvider = new SqlGreetingProvider(
ConfigurationManager.ConnectionStrings["myConnectionString"].ConnectionString);
var greeting = greetingProvider.GetGreeting();
Console.WriteLine(greeting);
}
}
To „maniak kontroli”, ponieważ kontroluje tworzenie klasy zapewniającej powitanie, kontroluje skąd pochodzi ciąg połączenia SQL i kontroluje dane wyjściowe.
Korzystając z zastrzyku zależności, klasa Greeter
zrzeka się tych obowiązków na rzecz pojedynczej odpowiedzialności, pisząc pozdrowienie.
Zasada inwersji zależności sugeruje, że klasy powinny zależeć od abstrakcji (jak interfejsy), a nie od innych konkretnych klas. Bezpośrednie zależności (sprzęganie) między klasami mogą stopniowo utrudniać utrzymanie. W zależności od abstrakcji może zmniejszyć to sprzężenie.
Wstrzykiwanie zależności pomaga nam osiągnąć odwrócenie zależności, ponieważ prowadzi do pisania klas zależnych od abstrakcji. Klasa Greeter
„nic” nie wie o szczegółach implementacji IGreetingProvider
i IGreetingWriter
. Wie tylko, że wstrzykiwane zależności implementują te interfejsy. Oznacza to, że zmiany w konkretnych klasach, które implementują IGreetingProvider
i IGreetingWriter
nie wpłyną na Greeter
. Nie zastąpi ich też zupełnie innymi implementacjami. Będą tylko zmiany w interfejsach. Greeter
jest oddzielony.
ControlFreakGreeter
nie jest w stanie poprawnie przetestować jednostki. Chcemy przetestować jedną małą jednostkę kodu, ale zamiast tego nasz test obejmowałby połączenie z SQL i wykonanie procedury składowanej. Obejmuje to również testowanie danych wyjściowych konsoli. Ponieważ ControlFreakGreeter robi tyle, nie można testować w oderwaniu od innych klas.
Greeter
jest łatwy do testowania jednostkowego, ponieważ możemy wstrzykiwać kpinę implementacje jego zależności, które są łatwiejsze do wykonania i weryfikacji niż wywołanie procedury składowanej lub odczytanie danych wyjściowych konsoli. Nie wymaga ciągu połączenia w app.config.
Konkretne implementacje IGreetingProvider
i IGreetingWriter
mogą stać się bardziej złożone. Oni z kolei mogą mieć własne zależności, które zostaną im wstrzyknięte. (Na przykład wstrzyknęliśmy ciąg połączenia SQL do SqlGreetingProvider
.) Ale ta złożoność jest „ukryta” przed innymi klasami, które zależą tylko od interfejsów. Ułatwia to modyfikowanie jednej klasy bez „efektu falowania”, który wymaga od nas wprowadzenia odpowiednich zmian w innych klasach.
Wstrzykiwanie zależności - prosty przykład
Ta klasa nazywa się Greeter
. Jego zadaniem jest wyślij powitanie. Ma dwie zależności . Potrzebuje czegoś, co da mu powitanie, a następnie potrzebuje sposobu, aby przekazać to pozdrowienie. Zależności te są opisane jako interfejsy, IGreetingProvider
i IGreetingWriter
. W tym przykładzie te dwie zależności są „wstrzykiwane” do Greeter
. (Dalsze wyjaśnienia na przykładzie).
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);
}
Klasa Greeting
zależy zarówno od IGreetingProvider
jak i IGreetingWriter
, ale nie jest odpowiedzialna za tworzenie instancji żadnego z nich. Zamiast tego wymaga ich w swoim konstruktorze. Cokolwiek tworzy przykład Greeting
musi zapewniać te dwie zależności. Możemy to nazwać „wstrzykiwaniem” zależności.
Ponieważ zależności są dostarczane klasie w jej konstruktorze, jest to również nazywane „wstrzykiwaniem konstruktora”.
Kilka popularnych konwencji:
- Konstruktor zapisuje zależności jako pola
private
. Po utworzeniu instancji klasy zależności te są dostępne dla wszystkich innych metod niestatycznych klasy. -
private
pola sąreadonly
. Po ustawieniu w konstruktorze nie można ich zmienić. Oznacza to, że tych pól nie należy (i nie można) modyfikować poza konstruktorem. To dodatkowo zapewnia, że te zależności będą dostępne przez cały czas istnienia klasy. - Zależności są interfejsami. Nie jest to absolutnie konieczne, ale jest powszechne, ponieważ ułatwia zastąpienie jednej implementacji zależności inną. Pozwala także na udostępnienie fałszywej wersji interfejsu na potrzeby testów jednostkowych.
Jak wstrzykiwanie zależności ułatwia testowanie jednostkowe
Opiera się to na poprzednim przykładzie klasy Greeter
która ma dwie zależności: IGreetingProvider
i IGreetingWriter
.
Rzeczywista implementacja IGreetingProvider
może pobrać ciąg z wywołania API lub bazy danych. Implementacja IGreetingWriter
może wyświetlać powitanie w konsoli. Ale ponieważ Greeter
ma swoje zależności wstrzyknięte do konstruktora, łatwo jest napisać test jednostkowy, który wstrzykuje fałszywe wersje tych interfejsów. W prawdziwym życiu możemy użyć frameworku takiego jak Moq , ale w tym przypadku napiszę te kpiny.
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);
}
}
Zachowanie IGreetingProvider
i IGreetingWriter
nie ma znaczenia dla tego testu. Chcemy sprawdzić, czy Greeter
otrzymuje pozdrowienie i je pisze. Konstrukcja Greeter
(wykorzystująca wstrzykiwanie zależności) pozwala nam wstrzykiwać fałszywe zależności bez żadnych skomplikowanych ruchomych części. Greeter
to, że Greeter
wchodzi w interakcje z tymi zależnościami, tak jak się tego spodziewamy.
Dlaczego używamy pojemników do wstrzykiwań zależnych (pojemniki IoC)
Wstrzykiwanie zależności oznacza pisanie zajęć, aby nie kontrolowali swoich zależności - zamiast tego udostępniane są im zależności („wstrzykiwane”).
Nie jest to to samo, co użycie szkieletu wstrzykiwania zależności (często nazywanego „kontenerem DI”, „kontem IoC” lub po prostu „kontenerem”), takiego jak Castle Windsor, Autofac, SimpleInjector, Ninject, Unity lub inne.
Pojemnik po prostu ułatwia wstrzykiwanie zależności. Załóżmy na przykład, że piszesz wiele klas opartych na wstrzykiwaniu zależności. Jedna klasa zależy od kilku interfejsów, klasy implementujące te interfejsy zależą od innych interfejsów i tak dalej. Niektóre zależą od określonych wartości. I dla zabawy, niektóre z tych klas implementują IDisposable
i muszą zostać usunięte.
Każda klasa jest dobrze napisana i łatwa do przetestowania. Ale teraz jest inny problem: tworzenie instancji klasy stało się znacznie bardziej skomplikowane. Załóżmy, że tworzymy instancję klasy CustomerService
. Ma zależności, a jego zależności mają zależności. Konstruowanie instancji może wyglądać mniej więcej tak:
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);
}
}
}
Można się zastanawiać, dlaczego nie umieścić całej gigantycznej konstrukcji w osobnej funkcji, która zwraca usługę CustomerService
? Jednym z powodów jest to, że ponieważ wstrzykiwane są do niej zależności dla każdej klasy, klasa nie jest odpowiedzialna za to, czy te zależności są IDisposable
czy się je pozbywa. Po prostu ich używa. Jeśli więc mieliśmy funkcję GetCustomerService()
która zwróciła w pełni skonstruowaną CustomerService
, klasa ta może zawierać pewną liczbę zasobów jednorazowych i nie ma możliwości uzyskania do nich dostępu lub usunięcia.
A poza pozbywaniem się IDisposable
, kto chce nazwać taką serię zagnieżdżonych konstruktorów? To krótki przykład. Mogło być znacznie, znacznie gorzej. Znów nie oznacza to, że napisaliśmy zajęcia w niewłaściwy sposób. Zajęcia mogą być indywidualnie idealne. Wyzwanie polega na ich skomponowaniu.
Pojemnik do wstrzykiwania zależności upraszcza to. Pozwala nam określić, którą klasę lub wartość należy zastosować w celu spełnienia każdej zależności. Ten nieco uproszczony przykład wykorzystuje 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"))
);
Nazywamy to „rejestrowaniem zależności” lub „konfigurowaniem kontenera”. Przetłumaczone, mówi nasz WindsorContainer
:
- Jeśli klasa wymaga
ILogWriter
, utwórz instancjęLogWriter
.LogWriter
wymaga ścieżki pliku. Użyj tej wartości zAppSettings
. - Jeśli klasa wymaga
IAuthorizationRepository
, utwórz instancjęSqlAuthorizationRepository
. Wymaga ciągu połączenia. Użyj tej wartości z sekcjiConnectionStrings
. - Jeśli klasa wymaga
ICustomerDataProvider
, utwórzCustomerApiClient
i podaj wymagany ciąg zAppSettings
.
Kiedy żądamy zależności od kontenera, nazywamy to „rozwiązywaniem” zależności. Złą praktyką jest robienie tego bezpośrednio przy użyciu kontenera, ale to inna historia. W celach demonstracyjnych możemy teraz to zrobić:
var customerService = container.Resolve<CustomerService>();
var data = customerService.GetCustomerData(customerNumber);
container.Release(customerService);
Kontener wie, że CustomerService
zależy od IAuthorizationRepository
i ICustomerDataProvider
. Wie, jakie klasy musi stworzyć, aby spełnić te wymagania. Te klasy z kolei mają więcej zależności, a kontener wie, jak je spełnić. Stworzy każdą potrzebną klasę, dopóki nie będzie w stanie zwrócić instancji CustomerService
.
Jeśli dojdzie do punktu, w którym klasa wymaga zależności, której nie zarejestrowaliśmy, np. IDoesSomethingElse
, to kiedy spróbujemy rozwiązać CustomerService
, wyrzuci wyraźny wyjątek, informujący nas, że nie zarejestrowaliśmy niczego, aby spełnić to wymaganie.
Każda struktura DI zachowuje się nieco inaczej, ale zazwyczaj daje nam kontrolę nad sposobem tworzenia instancji niektórych klas. Na przykład, czy chcemy, aby tworzyło jedno wystąpienie LogWriter
i zapewniało je każdej klasie zależnej od ILogWriter
, czy też chcemy, aby tworzyło nowe za każdym razem? Większość kontenerów ma sposób to określić.
Co z klasami, które implementują IDisposable
? Dlatego nazywamy container.Release(customerService);
na końcu. Większość kontenerów (w tym Windsor) przejdzie przez wszystkie utworzone zależności i Dispose
te, które wymagają usunięcia. Jeśli CustomerService
jest IDisposable
to usunie.
Rejestrowanie zależności, jak pokazano powyżej, może wyglądać na więcej kodu do napisania. Ale kiedy mamy wiele klas z wieloma zależnościami, to naprawdę się opłaca. A gdybyśmy musieli napisać te same klasy bez użycia wstrzykiwania zależności, wówczas ta sama aplikacja z dużą ilością klas byłaby trudna do utrzymania i przetestowania.
To rysuje, dlaczego używamy pojemników do wstrzykiwania zależności. Sposób, w jaki konfigurujemy naszą aplikację do korzystania z jednego (i korzystania z niego poprawnie), to nie tylko jeden temat - to szereg tematów, ponieważ instrukcje i przykłady różnią się w zależności od kontenera.