Zaawansowane techniki obiektowe
Rok akademicki: 2026/27
Instrukcja laboratoryjna – Część 1: Wprowadzenie,
Fundamenty Architektoniczne, Kontenery DI (IoC) i Wzorce Kreacyjne
Prowadzący: Tomasz Goluch
Wersja: 1.3
1. Wprowadzenie i Środowisko Laboratoryjne
1.1. Cel i Wymagania Środowiskowe
Celem niniejszej części laboratorium jest opanowanie fundamentów architektonicznych nowoczesnego programowania obiektowego, zrozumienie taksonomii wzorców projektowych Bandy Czworga (GoF – Gang of Four), przyswojenie zasad SOLID, dogłębne zrozumienie mechanizmu odwrócenia sterowania (IoC) i wstrzykiwania zależności (DI) z wykorzystaniem kontenerów Autofac oraz Microsoft.Extensions.DependencyInjection, a także praktyczna implementacja wzorców kreacyjnych (konstrukcyjnych) w języku C# (standard C# 12 / .NET 8+).
Laboratorium odbywa się na maszynach fizycznych (komputery laboratoryjne albo własne laptopy), z wykorzystaniem preferowanego edytora lokalnego (np. Visual Studio 2022, Visual Studio Code z rozszerzeniem C# Dev Kit, JetBrains Rider). Nie ma żadnych przeciwwskazań, aby laboratorium uruchomić w oparciu o kontenery Docker, szczególnie z wykorzystaniem Docker Compose do orkiestracji usług pomocniczych.
1.2. Geneza, Istota i Ekosystem Bibliotek Wzorców w .NET
Wzorce projektowe (ang. Design Patterns) to sprawdzone w praktyce inżynierskiej, sformalizowane szablony rozwiązań powtarzalnych problemów programistycznych i architektonicznych. Koncepcja ta została zaadaptowana do inżynierii oprogramowania przez Ericha Gammę, Richarda Helma, Ralpha Johnsona oraz Johna Vlissidesa (autorów fundamentalnej monografii z 1994 roku, zwanych odtąd Gang of Four – GoF).
Wzorzec projektowy nie jest gotowym algorytmem ani biblioteką klas, którą można bezrefleksyjnie wkleić do projektu. Jest to opis relacji, odpowiedzialności i sposobu komunikacji pomiędzy klasami oraz współpracującymi obiektami, zapewniający:
- Zwiększenie reużywalności kodu poprzez separację logiki stabilnej od zmiennej.
- Ujednolicenie słownictwa technicznego w zespole inżynierskim (np. pojęcie „Fabryka” natychmiast definiuje odpowiedzialność modułu).
- Elastyczność na zmiany wymagań biznesowych dzięki programowaniu do abstrakcji (interfejsów), a nie do konkretnych implementacji.
Mimo że wzorzec sam w sobie jest szablonem myślowym, w nowoczesnym ekosystemie platformy .NET istnieje szereg dojrzałych bibliotek i wbudowanych mechanizmów, które implementują konkretne wzorce projektowe lub bezpośrednio wspierają ich realizację produkcyjną:
Wzorce Kreacyjne w bibliotekach .NET:
Microsoft.Extensions.DependencyInjection/Autofac:- Singleton: Rejestracja z cyklem życia
.AddSingleton()lub.SingleInstance(). - Metoda Wytwórcza / Fabryka: Rejestracja delegatów
fabrykujących
Func<TService>oraz interfejsyIServiceScopeFactoryiIHttpClientFactory(zarządzanie pulą połączeń i cyklem życiaHttpMessageHandler). - Prototyp: Rejestracja usług w trybie
Transient(InstancePerDependency), gdzie każde żądanie zwraca świeżą instancję obiektu.
- Singleton: Rejestracja z cyklem życia
WebApplicationBuilder/HostApplicationBuilder:- Budowniczy (Builder) oraz Fluent Builder: Płynna, sekwencyjna konfiguracja potoku aplikacji, wstrzykiwania zależności, konfiguracji środowiskowej i serwera Kestrel.
Wzorce Strukturalne w bibliotekach .NET:
Microsoft.EntityFrameworkCore(DbContext):- Fasada (Facade): Udostępnia prosty interfejs
(
DbSet<T>,SaveChanges()), ukrywając pod spodem skomplikowaną mechanikę połączeń ADO.NET, transakcji, śledzenia zmian (Change Tracker) i translacji drzew wyrażeń LINQ na SQL.
- Fasada (Facade): Udostępnia prosty interfejs
(
Castle Core(DynamicProxy) /System.Reflection.DispatchProxy:- Pełnomocnik (Proxy): Dynamiczne generowanie obiektów pośredniczących w czasie wykonywania (runtime). Podstawa programowania zorientowanego aspektowo (AOP), bibliotek do mockowania (Moq, NSubstitute) oraz przechwytywaczy wywołań (Interceptors).
System.Net.Http.DelegatingHandler:- Dekorator (Decorator) / Łańcuch Zobowiązań:
Kaskadowe opakowywanie klienta
HttpClientw kolejne warstwy obsługi żądań HTTP (np. nagłówki autoryzacyjne, telemetria, kompresja).
- Dekorator (Decorator) / Łańcuch Zobowiązań:
Kaskadowe opakowywanie klienta
AutoMapper/Mapster:- Adapter: Automatyczna adaptacja i mapowanie interfejsów oraz schematów obiektów (np. konwersja encji bazodanowych na modele widoku DTO).
Wzorce Czynnościowe (Behawioralne) w bibliotekach .NET:
MediatR:- Mediator: Całkowite rozluźnienie powiązań pomiędzy nadawcami a odbiorcami żądań w architekturze CQRS.
- Polecenie (Command): Hermetyzacja intencji
biznesowej w postaci obiektów
IRequestiIRequest<T>. - Łańcuch Zobowiązań (Chain of Responsibility):
Obsługa potoków
IPipelineBehavior<TRequest, TResponse>realizujących przekrojową logikę (walidacja, metryki, logowanie, transakcje). - Obserwator (Observer): Rozgłaszanie powiadomień
INotificationdo wielu niezależnych odbiorcówINotificationHandler.
System.Reactive(Rx.NET):- Obserwator (Observer) i Iterator: W pełni
asynchroniczna implementacja reaktywnych strumieni danych oparta na
kontraktach
IObservable<T>(producent zdarzeń) orazIObserver<T>(subskrybent) z zestawem operatorów LINQ.
- Obserwator (Observer) i Iterator: W pełni
asynchroniczna implementacja reaktywnych strumieni danych oparta na
kontraktach
Polly:- Strategia (Strategy) / Łańcuch Zobowiązań: Definiowanie wymiennych polityk odpornościowych (Retry, Circuit Breaker, Fallback, Timeout) łączonych w elastyczne potoki wykonawcze (Resilience Pipelines).
MassTransit/Wolverine:- Wydawca-Abonent (Publish/Subscribe): Rozproszona komunikacja asynchroniczna z użyciem brokerów wiadomości (RabbitMQ, Azure Service Bus).
- Saga: Koordynacja długotrwałych transakcji rozproszonych za pomocą zdefiniowanych maszyn stanów i akcji kompensacyjnych.
CommunityToolkit.Mvvm/Prism(ICommand,RelayCommand):- Polecenie (Command): Oddzielenie akcji UI (WPF,
.NET MAUI, Avalonia) od logiki w ViewModelu wraz z deklaratywną kontrolą
stanu (
CanExecute).
- Polecenie (Command): Oddzielenie akcji UI (WPF,
.NET MAUI, Avalonia) od logiki w ViewModelu wraz z deklaratywną kontrolą
stanu (
Stateless:- Stan (State): Tworzenie deterministycznych maszyn stanów i automatów skończonych bezpośrednio w kodzie C#.
System.Collections.Generic(IEnumerable<T>,IEnumerator<T>):- Iterator: Wbudowany w język C# wzorzec iteratora
wspomagany składniowo przez słowa kluczowe
yield returnorazforeach.
- Iterator: Wbudowany w język C# wzorzec iteratora
wspomagany składniowo przez słowa kluczowe
1.3. Podwójna Taksonomia Wzorców Projektowych (GoF)
Wzorce projektowe dzielimy przynajmniej pod względem dwóch zasadniczych kryteriów: przeznaczenia oraz zakresu:
┌────────────────────────────────────────┐
│ WZORCE PROJEKTOWE (GoF) │
└──────────────────┬─────────────────────┘
│
┌────────────────────────────────────┼────────────────────────────────────┐
▼ ▼ ▼
┌──────────────────┐ ┌──────────────────┐ ┌──────────────────┐
│ KREACYJNE │ │ STRUKTURALNE │ │ BEHAWIORALNE │
│ (KONSTRUKCYJNE) │ │ │ │ (CZYNNOŚCIOWE) │
├──────────────────┤ ├──────────────────┤ ├──────────────────┤
│ • Singleton │ │ • Adapter │ │ • Łańcuch Odpow. │
│ • Metoda Wytwórcza│ │ • Dekorator │ │ • Obserwator │
│ • Fabryka Abstr. │ │ • Fasada │ │ • Polecenie (Cmd)│
│ • Budowniczy │ │ • Kompozyt │ │ • Metoda Szablon.│
│ • Prototyp │ │ • Most (Bridge) │ │ • Mediator │
│ │ │ • Pełnomocnik │ │ • Stan / Pamiątka│
│ │ │ • Pyłek │ │ • Strategia │
└────────┬─────────┘ └────────┬─────────┘ └────────┬─────────┘
│ │ │
└────────────────────────────────────┼────────────────────────────────────┘
▼
┌─────────────────────────────────┐
│ ZAKRES REALIZACJI │
├─────────────────────────────────┤
│ • Klasowe (dziedziczenie / ISA) │
│ • Obiektowe (kompozycja / HAS-A)│
└─────────────────────────────────┘
Pierwsze kryterium: Przeznaczenie wzorców
- Kreacyjne (konstrukcyjne): powiązane z tworzeniem obiektów. Hermetyzują proces powoływania instancji, uniezależniając system od sposobu, w jaki jego obiekty są alokowane, składane i reprezentowane.
- Strukturalne: dotyczą struktury klas lub obiektów. Koncentrują się na kompozycji, ułatwiając tworzenie większych struktur z zachowaniem elastyczności (omówione w Części 2 instrukcji).
- Czynnościowe (behawioralne, operacyjne): opisują sposób, w jaki klasy lub obiekty oddziałują między sobą i dzielą odpowiedzialności oraz precyzują przepływ sterowania (omówione w Części 3 instrukcji).
Drugie kryterium: Zakres wzorców
- Klasowe: wykorzystują mechanizm dziedziczenia do zmieniania klasy, której egzemplarze są tworzone lub modyfikowane. Relacje te są wiązane statycznie na etapie kompilacji (compile-time).
- Obiektowe: delegują odpowiedzialności i tworzenie egzemplarzy do innego obiektu za pomocą kompozycji i agregacji. Wiązania realizowane są dynamicznie w trakcie działania programu (runtime).
2. Fundamenty Architektoniczne: Zasady SOLID
Wzorce projektowe stanowią praktyczną implementację fundamentalnych reguł inżynierii oprogramowania sformułowanych przez Roberta C. Martina (SOLID):
Single Responsibility Principle (SRP): Klasy są małe i odpowiedzialne za jedną funkcjonalność. Klasa powinna mieć tylko jeden powód do modyfikacji. Wzorzec Singleton często łamie tę zasadę, łącząc zarządzanie własnym cyklem życia z logiką domenową.
Open/Closed Principle (OCP): Klasy bazowe nie wymagają zmian w przypadku tworzenia ich klas potomnych. W środowisku .NET pomocne w realizacji tej zasady mogą okazać się metody rozszerzające (extension methods), które pozwalają dodawać nowe funkcjonalności do istniejących klas bez potrzeby zmiany kodu i ponownej kompilacji.
Liskov Substitution Principle (LSP): Wstawienie typu pochodnego w miejsce bazowego nie powinno wymagać obsługi za pomocą dodatkowego kodu ani naruszać poprawności działania programu.
🦆 Dla dociekliwych: Zasada Podstawienia Liskov a pułapka potocznego „IS-A” – Kaczka i Kaczka Elektryczna (Kliknij, aby rozwinąć)
Częstym błędem architektonicznym jest utożsamianie dziedziczenia z potocznym rozumieniem relacji „jest czymś” (is-a) w języku naturalnym. W świecie rzeczywistym powiemy: „kaczka elektryczna to też kaczka (tylko na baterie)”. Jednak w programowaniu obiektowym podtypowanie musi być behawioralne – klasa pochodna musi bezwzględnie dotrzymywać kontraktu semantycznego klasy bazowej.
1. Naiwna hierarchia klas (naruszenie kontraktu)
Rozważmy klasę bazową modelującą ptaka oraz klasę pochodną modelującą zabawkę:
public class Kaczka
{
public virtual void Kwacz() => Console.WriteLine("Kwa, kwa!");
public virtual void Plywaj() => Console.WriteLine("Kaczka pływa po stawie.");
}
public class KaczkaElektryczna : Kaczka
{
public bool Wlaczona { get; private set; }
public void Wlacz() => Wlaczona = true;
public void Wylacz() => Wlaczona = false;
public override void Kwacz()
{
// Naruszenie LSP: wzmocnienie warunków wstępnych (preconditions)
if (!Wlaczona)
throw new InvalidOperationException("Włącz zasilanie przed kwakaniem!");
Console.WriteLine("Elektroniczne: Pii-kwa!");
}
public override void Plywaj()
{
if (!Wlaczona)
Console.WriteLine("Kaczka stoi w miejscu – brak napędu.");
else
Console.WriteLine("Silniczek napędza śrubę.");
}
}
2. Konsekwencje dla konsumenta (Code Smell)
Kod operujący na abstrakcji bazy nie ma pojęcia o stanie zasilania:
public void PoranekNaStawie(IEnumerable<Kaczka> kaczki)
{
foreach (var kaczka in kaczki)
{
// Zgłosi InvalidOperationException dla wyłączonej KaczkiElektrycznej!
kaczka.Kwacz();
}
}
Aby uchronić się przed awarią, programista jest zmuszony do weryfikacji typu w czasie wykonania (RTTI):
if (kaczka is KaczkaElektryczna ke)
ke.Wlacz();
kaczka.Kwacz();
Obecność instrukcji typu is / as lub
rzutowań w kodzie klienckim konsumującym abstrakcję to
podręcznikowy objaw złamania LSP.
3. Czy ukrycie wywołania Wlacz() wewnątrz Kwacz() rozwiązuje problem?
Kuszącym odruchem inżynierskim bywa automatyczne spełnienie warunku pod maską:
public override void Kwacz()
{
if (!Wlaczona)
Wlacz(); // Próba zamiecenia problemu pod dywan
Console.WriteLine("Elektroniczne: Pii-kwa!");
}
Rozwiązanie to nadal łamie LSP, zamieniając jawny wyjątek w trudny do wykrycia błąd logiczny:
- Niejawne efekty uboczne (Side Effects): Klient wywołuje z pozoru bezpieczną metodę emisji dźwięku, a nieświadomie zmienia globalny stan maszyny (włącza silnik, rozpoczyna pobór prądu).
- Problem wyczerpania zasobów: Co w sytuacji, gdy
bateria jest rozładowana? Rzucenie
BatteryDepletedExceptionz metodyKwacz()ponownie łamie kontrakt bazy, która nie dopuszcza awarii zasilania.
4. Poprawne rozwiązanie architektoniczne
Prawdziwa kaczka to byt biologiczny, a kaczka elektryczna to urządzenie elektromechaniczne imitujące ptaka. Rozwiązaniem jest eliminacja dziedziczenia na rzecz segregacji interfejsów (ISP) oraz kompozycji:
public interface IKwaczacy
{
void Kwacz();
}
public interface IPlywajacy
{
void Plywaj();
}
// Kaczka biologiczna implementuje zachowania bezpośrednio
public class Kaczka : IKwaczacy, IPlywajacy
{
public void Kwacz() => Console.WriteLine("Kwa, kwa!");
public void Plywaj() => Console.WriteLine("Kaczka unosi się na wodzie.");
}
// Kaczka elektryczna realizuje zachowania przez kompozycję
public class KaczkaElektryczna : IKwaczacy, IPlywajacy
{
private readonly ISoundEmitter _soundEmitter;
public bool Wlaczona { get; set; }
public KaczkaElektryczna(ISoundEmitter soundEmitter)
{
_soundEmitter = soundEmitter;
}
public void Kwacz()
{
if (Wlaczona)
_soundEmitter.EmitSound("Pii-kwa!");
}
public void Plywaj()
{
if (Wlaczona)
Console.WriteLine("Silniczek napędza śrubę.");
}
}
- Interface Segregation Principle (ISP): Unikamy rozbudowanych interfejsów. Klienci nie powinni być zmuszani do polegania na interfejsach i metodach, których nie używają.
- Dependency Inversion Principle (DIP): Moduły
wyższego poziomu nie powinny zależeć od modułów niskiego poziomu – oba
powinny zależeć od abstrakcji. W przypadku wielu wystąpień operatora
newz tą samą klasą tworzymy jej fabrykę albo stosujemy kontener DI.
🧪 Dla dociekliwych: Typologia atrap testowych – Dummy, Stub, Spy, Mock i Fake (Kliknij, aby rozwinąć)
W żargonie programistycznym pojęcia „mock” i „stub” bywają mylnie stosowane zamiennie. Zgodnie z klasyfikacją Gerarda Meszarosa (xUnit Test Patterns) wszystkie te mechanizmy tworzą rodzinę dublerów testowych (Test Doubles), jednak realizują zupełnie inne cele weryfikacyjne:
┌─────────────────────────────┐
│ DUBLER TESTOWY │
│ (Test Double) │
└──────────────┬──────────────┘
│
┌──────────────────┬─────────────────┼──────────────────┬──────────────────┐
▼ ▼ ▼ ▼ ▼
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ DUMMY │ │ STUB │ │ SPY │ │ MOCK │ │ FAKE │
├─────────────┤ ├─────────────┤ ├─────────────┤ ├─────────────┤ ├─────────────┤
│ Wypełniacz │ │ Sztywne │ │ Cichy │ │ Weryfikator │ │ Uproszczona │
│ parametrów │ │ odpowiedzi │ │ rejestrator │ │ interakcji │ │ implement. │
│ (np. null) │ │ (State-based│ │ wywołań │ │ (Behavior- │ │ biznesowa │
│ │ │ testing) │ │ │ │ testing) │ │ (np. In-Mem)│
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
Dummy (Wypełniacz):
- Obiekt powoływany wyłącznie po to, aby zaspokoić sygnaturę metody lub konstruktora (np. gdy wymagana jest instancja loggera, ale test w ogóle go nie dotyczy).
- Nigdy nie bierze czynnego udziału w teście — często przekazuje się
go jako
nulllub pustą instancję.
Stub (Zaślepka stanu):
- Zwraca z góry przygotowane, stałe odpowiedzi na wywołania testowanego modułu (State Verification).
- Nie bada liczby wywołań ani parametrów — dostarcza jedynie dane potrzebne do przejścia danej ścieżki w kodzie.
- Przykład (Moq):
mockRepo.Setup(r => r.GetById(1)).Returns(new User { IsActive = true });
Spy (Szpieg):
- Odmiana Stuba, która oprócz zwracania wartości dyskretnie zlicza wywołania i rejestruje przekazane argumenty do późniejszej asercji w teście.
Mock (Atrapa zachowania):
- Służy do weryfikacji interakcji (Behavior Verification), a nie samego stanu obiektu.
- Definiuje sztywne oczekiwania: określona metoda musi zostać wywołana dokładnie określoną liczbę razy z konkretnymi argumentami. Brak interakcji oznacza błąd asercji.
- Przykład (Moq):
emailSenderMock.Verify(s => s.Send(It.IsAny<string>(), "Awaria"), Times.Once());
Fake (Falsyfikat):
- Posiada w pełni funkcjonalną implementację biznesową, która z przyczyn wydajnościowych lub infrastrukturalnych nie nadaje się na produkcję.
- Klasyczny przykład: Baza w pamięci RAM
(
InMemoryDatabase), słownikConcurrentDictionaryudający bazę danych lub symulator bramki płatnościFakePaymentGateway.
3. Idea Wstrzykiwania Zależności (DI) i Kontenery IoC
3.1. Zasada Odwrócenia Sterowania (IoC) a Wstrzykiwanie Zależności (DI)
W tradycyjnym programowaniu to klasa nadrzędna decyduje, kiedy
utworzyć obiekt zależności za pomocą słowa kluczowego new.
Wzorzec Inversion of Control (IoC) odwraca ten
przepływ: obiekt nie tworzy swoich zależności, lecz są mu one
dostarczane z zewnątrz przez framework lub kontener.
Dependency Injection (DI) jest konkretnym wzorcem projektowym realizującym koncepcję IoC. Wstrzykiwanie zależności polega na przekazaniu instancji implementującej żądany interfejs bezpośrednio do odbiorcy.
Wyróżniamy trzy główne techniki wstrzykiwania:
- Wstrzykiwanie przez konstruktor (Constructor
Injection):
- Podstawowy i rekomendowany standard architektoniczny.
- Zależności są niemutowalne (zapisywane w polach
readonly), co gwarantuje, że obiekt od momentu powstania znajduje się w stanie w pełni zainicjalizowanym i poprawnym. - Działa jako wskaźnik naruszenia zasady SRP: konstruktor przyjmujący więcej niż 4–5 parametrów sygnalizuje, że klasa skupia zbyt wiele odpowiedzialności i wymaga podziału.
- Wstrzykiwanie przez właściwość (Property
Injection):
- Stosowane głównie dla zależności opcjonalnych oraz komponentów brzegowych, w których instancję powołuje zewnętrzny framework.
- Umożliwia dynamiczną podmianę zależności w czasie życia nosiciela (np. zmiana strategii algorytmu lub przełączenie na implementację awaryjną w locie).
- Wady: ryzyko wywołania obiektu w stanie niekompletnym (gdy właściwość nie została jeszcze zainicjalizowana) oraz brak domyślnego bezpieczeństwa wątkowego przy modyfikacji właściwości na instancjach współdzielonych.
- Wstrzykiwanie przez metodę (Method
Injection):
- Stosowane, gdy dana zależność nie opisuje stałego stanu obiektu, lecz jest potrzebna wyłącznie na czas wykonania jednej, konkretnej operacji (np. przekazanie strategii kalkulacji, kontekstu operacji lub tokena autoryzacji).
- Pozwala uniknąć przechowywania rzadko używanej zależności w polach klasy przez cały cykl jej życia.
3.2. Zaawansowany Kontener IoC: Autofac
W platformie .NET w przypadku wielu wystąpień operatora
new z tą samą klasą tworzymy jej fabrykę albo stosujemy
kontener DI, np. natywny Microsoftu
Microsoft.Extensions.DependencyInjection bądź VS MEF albo
zewnętrznego dostawcy taki jak: AutoFac,
Unity, ewentualnie własny.
W celu porównania bibliotek można wykorzystać stronę: https://dotnet.libhunt.com/categories/1810-ioc. Jak widać, AutoFac prowadzi pod względem liczby gwiazdek, obserwatorów, forków, częstości wydań oraz jakości kodu.
A. Podstawowa konfiguracja imperatywna
Instalacja pakietu NuGet:
dotnet add package Autofac
Model domenowy:
public interface ILogger
{
void Log(string message);
}
public class ConsoleLogger : ILogger
{
public void Log(string message) => Console.WriteLine($"[LOG]: {message}");
}
public class Worker
{
private readonly ILogger _logger;
// Wstrzykiwanie przez konstruktor
public Worker(ILogger logger)
{
_logger = logger;
}
public void Work()
{
_logger.Log("Rozpoczęto zadanie.");
_logger.Log("Zakończono zadanie.");
}
}
Budowa i rejestracja w kontenerze:
using Autofac;
// 1. Tworzymy budowniczego kontenera
var builder = new ContainerBuilder();
// 2. Rejestrujemy komponenty
builder.RegisterType<ConsoleLogger>().As<ILogger>();
builder.RegisterType<Worker>();
// 3. Budujemy kontener (IContainer)
using IContainer container = builder.Build();
// 4. Rozwiązujemy zależność
var worker = container.Resolve<Worker>();
worker.Work();
- Rejestracja pod własnym typem (as self):
Wywołanie
builder.RegisterType<Worker>()bez dopisku.As<...>()rejestruje komponent domyślnie pod jego własną klasą, co pozwala pobrać go bezpośrednio przezcontainer.Resolve<Worker>(). - Automatyczne wstrzykiwanie zależności (Constructor
Injection): Podczas wywołania
container.Resolve<Worker>()Autofac analizuje konstruktor klasyWorker, dopasowuje zarejestrowanyConsoleLoggerpod wymagany parametrILoggeri tworzy obie instancje. Zwrócony obiektworkerma gotową, wstrzykniętą zależność i można od razu wywołaćworker.Work().
B. Techniki wstrzykiwania w Autofac
1. Wstrzykiwanie przez konstruktor (Standard domyślny)
Podstawowy wariant zaprezentowany w punkcie 3.2.A – Autofac automatycznie analizuje parametry konstruktora i przekazuje zarejestrowane implementacje.
2.
Wstrzykiwanie przez właściwość (PropertiesAutowired)
Pozwala kontenerowi przypisać zarejestrowane usługi do publicznych
właściwości z akcesorem set po utworzeniu instancji:
// Rejestracja pod typem własnym (as self) z włączonym skanowaniem właściwości
builder.RegisterType<PropertyWorker>().PropertiesAutowired();
- Rejestracja pod własnym typem (as self):
Brak klauzuli
.As<...>()oznacza rejestrację pod klasą konkretnąPropertyWorker– wzorzec typowy dla punktów wejścia aplikacji, które same nie są wstrzykiwane jako abstrakcje do innych modułów. - Dynamiczna podmiana zależności w czasie życia
nosiciela: Właściwość z publicznym
setumożliwia zmianę implementacji w trakcie działania aplikacji bez niszczenia obiektu nadrzędnego:- Ręczne nadpisanie referencji:
worker.Logger = new FileLogger("errors.log"); - Ponowna injekcja z kontenera
(
InjectProperties):scope.InjectProperties(worker);– pozwala zaktualizować właściwości obiektu nowymi instancjami (np. z nowo otwartegoILifetimeScope). - ⚠️ Zagrożenie wielowątkowe: Modyfikacja właściwości na współdzielonej instancji (np. Singleton) może doprowadzić do wyścigu danych (race condition).
- Ręczne nadpisanie referencji:
3.
Wstrzykiwanie przez metodę (OnActivated / w punkcie
wywołania)
Autofac nie wywołuje metod automatycznie na podstawie konwencji, dlatego wstrzykiwanie przez metodę realizuje się na dwa sposoby:
- Inicjalizacja po utworzeniu instancji
(
OnActivated): Kontener wywołuje wskazaną metodę konfiguracyjną natychmiast po powołaniu obiektu:builder.RegisterType<MethodWorker>() .OnActivated(e => e.Instance.SetLogger(e.Context.Resolve<ILogger>())); - Wstrzykiwanie w punkcie wywołania (Call-site
Injection): Zależność nie jest przechowywana w polach
klasy, lecz przekazywana wyłącznie jako parametr metody biznesowej (np.
worker.Process(data, container.Resolve<ILogger>())).
4. Komponenty nazwane, klucze i indeksy (Named, Keyed & IIndex)
Stosowane w sytuacji, gdy pod jeden interfejs rejestrujemy wiele implementacji i zachodzi potrzeba ich jednoznacznego rozróżnienia:
- Komponenty nazwane (
Named): Identyfikatorem jest ciąg znaków (string)[cite: 2]. Przydatne przy bezpośrednim mapowaniu wartości z plików konfiguracyjnych (appsettings.json)[cite: 2]:builder.RegisterType<ConsoleLogger>().Named<ILogger>("console"); builder.RegisterType<FileLogger>().Named<ILogger>("file"); var fileLogger = container.ResolveNamed<ILogger>("file"); - Komponenty kluczowane (
Keyed): Identyfikatorem może być dowolny obiekt, w praktyce najczęściej silnie typowanyenum, co eliminuje ryzyko literówek na etapie kompilacji:public enum StorageType { Local, Cloud } builder.RegisterType<LocalStorage>().Keyed<IStorage>(StorageType.Local); builder.RegisterType<CloudStorage>().Keyed<IStorage>(StorageType.Cloud);
⚠️ Architektoniczna pułapka: Antywzorzec Service Locator
Ręczne wywoływanie metodcontainer.ResolveNamed<T>()lubcontainer.ResolveKeyed<T>()wewnątrz klas biznesowych uzależnia kod domenowy od API kontenera IoC i stanowi antywzorzec Service Locator. Klasy domenowe nie powinny wiedzieć o istnieniu kontenera.
Rozwiązanie w duchu czystego DI: Wstrzykiwanie indeksu (
IIndex<TKey, TValue>)
Autofac rozwiązuje ten problem deklaratywnie. Zamiast przekazywać kontener, wstrzykujemy do konstruktora interfejsIIndex<TKey, TService>, który działa jak fabryka oparta na słowniku implementacji:public class BackupManager { private readonly IIndex<StorageType, IStorage> _storages; // Autofac automatycznie wstrzykuje indeks zarejestrowanych implementacji public BackupManager(IIndex<StorageType, IStorage> storages) { _storages = storages; } public void Execute(StorageType target) { // Czyste pobranie zależności bez odwoływania się do kontenera: IStorage storage = _storages[target]; storage.Save(); } }
C. Zarządzanie Czasem Życia (Lifetime Scopes) w Autofac
| Strategia w Autofac | Odpowiednik w Microsoft DI | Opis działania |
|---|---|---|
InstancePerDependency() |
Transient |
Domyślna strategia. Nowa instancja jest powoływana przy każdym zapytaniu. |
SingleInstance() |
Singleton |
Dokładnie jedna instancja w całym procesie kontenera. |
InstancePerLifetimeScope() |
Scoped |
Jedna instancja w ramach danego zasięgu
(ILifetimeScope). Kluczowe w aplikacjach webowych (jedno
żądanie HTTP = jeden zasięg). |
InstancePerMatchingLifetimeScope(tag) |
Brak wbudowanego | Jedna współdzielona instancja w ramach zasięgu oznaczonego konkretnym identyfikatorem (tagiem). |
3.3.
Standardowy Kontener:
Microsoft.Extensions.DependencyInjection
Platforma .NET udostępnia natywny, silnie zoptymalizowany pod kątem wydajności kontener IoC, stanowiący domyślny fundament potoku aplikacji w ASP.NET Core oraz szablonach Worker Service (.NET Generic Host).
A. Podstawowa konfiguracja imperatywna i cykle życia
W przeciwieństwie do dwuetapowego budowania w Autofac
(ContainerBuilder -> Build()), natywny
kontener konfiguruje kolekcję deskryptorów usług
IServiceCollection, a następnie kompiluje zoptymalizowanego
dostawcę ServiceProvider:
using Microsoft.Extensions.DependencyInjection;
// 1. Inicjalizacja kolekcji usług
var services = new ServiceCollection();
// 2. Rejestracja z określeniem cyklu życia
services.AddTransient<ILogger, ConsoleLogger>();
services.AddTransient<Worker>(); // Rejestracja pod typem własnym (as self)
services.AddScoped<IOperationScoped, Operation>();
services.AddSingleton<IOperationSingleton, Operation>();
// 3. Kompilacja dostawcy usług (IServiceProvider)
using ServiceProvider provider = services.BuildServiceProvider();
// 4. Rozwiązanie grafu zależności
var worker = provider.GetRequiredService<Worker>();
worker.Work();
- Rejestracja pod własnym typem (as self):
Wywołanie
services.AddTransient<Worker>()(z jednym parametrem generycznym) rejestruje klasę bezpośrednio pod jej własnym typem konkretnym, co umożliwia jej pobranie przezGetRequiredService<Worker>(). - Metody pobierania usług:
GetRequiredService<T>()rzuca wyjątekInvalidOperationExceptionw przypadku braku rejestracji, co ułatwia wczesne wykrywanie błędów konfiguracji (zalecane). Z koleiGetService<T>()zwracanull.
B. Usługi kluczowane (.NET 8+) i techniki wstrzykiwania
Wbudowany kontener Microsoftu celowo nie wspiera automatycznego
wstrzykiwania do właściwości (PropertiesAutowired) ani
metod inicjalizujących na zdarzeniach. Zamiast tego opiera się na
wstrzykiwaniu przez konstruktor oraz – od wersji .NET 8
– natywnym mechanizmie usług kluczowanych (Keyed
Services), będących bezpośrednim odpowiednikiem
komponentów nazwanych i kluczowanych z Autofac.
1. Rejestracja usług kluczowanych (Keyed Services)
Kluczem identyfikującym może być ciąg znaków (string)
lub dowolny silnie typowany obiekt, w tym enum:
public enum StorageType { Local, Cloud }
// A. Rejestracja z kluczem tekstowym (odpowiednik Named z Autofac):
services.AddKeyedSingleton<ILogger, ConsoleLogger>("console");
services.AddKeyedSingleton<ILogger, FileLogger>("file");
// B. Rejestracja ze silnie typowanym kluczem enum:
services.AddKeyedScoped<IStorage, LocalStorage>(StorageType.Local);
services.AddKeyedScoped<IStorage, CloudStorage>(StorageType.Cloud);
2.
Wstrzykiwanie w konstruktorze konsumenta
([FromKeyedServices])
Do wskazania kontenerowi, którą z zarejestrowanych implementacji
należy wstrzyknąć, służy dedykowany atrybut
[FromKeyedServices]:
// Wstrzykiwanie za pomocą konstruktora pierwotnego (Primary Constructor - C# 12):
public class BackupManager(
[FromKeyedServices(StorageType.Cloud)] IStorage cloudStorage,
[FromKeyedServices("file")] ILogger fileLogger)
{
public void Execute()
{
fileLogger.Log("Rozpoczęto backup...");
cloudStorage.Save();
}
}
3.
Bezpośrednie pobranie z dostawcy
(GetRequiredKeyedService)
Pobieranie instancji z poziomu kodu konfiguracyjnego lub fabryki:
var fileLogger = provider.GetRequiredKeyedService<ILogger>("file");
var cloudStorage = provider.GetRequiredKeyedService<IStorage>(StorageType.Cloud);
🌐 Dla dociekliwych: Scenariusze wstrzykiwania i czasy życia w kontrolerach ASP.NET Core MVC (Kliknij, aby rozwinąć)
W technologii ASP.NET Core architektura MVC opiera się w całości na
kontenerze Microsoft.Extensions.DependencyInjection.
1. Scenariusze wstrzykiwania zależności do kontrolera
A. Wstrzykiwanie przez konstruktor (Constructor Injection – wariant domyślny)
Rekomendowane podejście w przypadku serwisów wykorzystywanych przez większość akcji kontrolera. Kontener automatycznie tworzy graf zależności przy każdym żądaniu HTTP:
public class OrderController : Controller
{
private readonly IOrderService _orderService;
private readonly ILogger<OrderController> _logger;
public OrderController(IOrderService orderService, ILogger<OrderController> logger)
{
_orderService = orderService;
_logger = logger;
}
}
B. Wstrzykiwanie
do akcji metodą ([FromServices])
Stosowane w sytuacji, gdy usługa jest kosztowna w inicjalizacji lub wykorzystywana wyłącznie w jednej, rzadko wywoływanej akcji. Eliminuje narzut powoływania instancji przy innych żądaniach trafiających do kontrolera:
[HttpPost]
public async Task<IActionResult> ExportToPdf(
int orderId,
[FromServices] IPdfReportGenerator pdfGenerator)
{
var pdf = await pdfGenerator.GenerateAsync(orderId);
return File(pdf, "application/pdf");
}
C.
Wstrzykiwanie usług kluczowanych ([FromKeyedServices] – C#
12 / .NET 8+)
Umożliwia precyzyjne wskazanie implementacji zarejestrowanej pod wskazanym identyfikatorem w akcji lub konstruktorze:
public IActionResult ProcessPayment(
decimal amount,
[FromKeyedServices("PayPal")] IPaymentGateway paymentGateway)
{
paymentGateway.Charge(amount);
return Ok();
}
D.
Wstrzykiwanie bezpośrednio do widoków Razor (@inject)
Pozwala na dostarczanie serwisów pomocniczych (lokalizatorów tekstów,
konfiguracji UI) bezpośrednio w szablonach .cshtml z
pominięciem kontrolera:
@inject IConfiguration Configuration
<p>Wersja systemu: @Configuration["AppVersion"]</p>
2. Cykle życia obiektów w cyklu żądania HTTP
Klient HTTP ──► [ Żądanie 1 ] ──────────────────────────────────────────► Odpowiedź HTTP
│
├─► Kontroler MVC (Transient) ────► niszczony po zakończeniu akcji
├─► Usługa A (Transient) ─────────► niszczona po wywołaniu
└─► DbContext / UoW (Scoped) ─────► współdzielony w Żądaniu 1,
niszczony przy końcu Żądania 1
Klient HTTP ──► [ Żądanie 2 ] ──────────────────────────────────────────► Odpowiedź HTTP
│
└─► DbContext / UoW (Scoped) ─────► NOWA, odizolowana instancja!
[ Stan Aplikacji w Pamięci ] ───────────────────────────────────────────► Współdzielony
└─► Cache / ThreadSafeSingleton (Singleton) ──────► jedna instancja dla Żądania 1, 2 itd.
Transient (
AddTransient):- Nowy obiekt jest alokowany za każdym razem, gdy dowolny komponent zażąda zależności w konstruktorze lub akcji.
- Jeżeli kontroler i dwa wstrzyknięte serwisy odwołują się do tego
samego interfejsu
ITransientService, w ramach jednego żądania HTTP powstaną 3 niezależne instancje.
Scoped (
AddScoped) – podstawa aplikacji webowych:- Dokładnie jedna instancja na cały czas trwania jednego żądania HTTP
(tworzona w zasięgu
IServiceScopeFactory.CreateScope()). - Zapewnia spójność stanu transakcyjnego — instancja
DbContextw Entity Framework Core jest zawsze rejestrowana jako Scoped, co pozwala współdzielić kontekst bazy i śledzenie encji w obrębie całego requestu. - Po wysłaniu odpowiedzi do klienta kontener automatycznie zwalnia
wszystkie obiekty implementujące
IDisposable.
- Dokładnie jedna instancja na cały czas trwania jednego żądania HTTP
(tworzona w zasięgu
Singleton (
AddSingleton):- Jedna instancja powoływana raz i współdzielona pomiędzy wszystkimi wątkami z puli oraz wszystkimi żądaniami HTTP.
- Wymaga bezwzględnego zapewnienia bezpieczeństwa wątkowego.
- ⚠️ Zagrożenie uwięzionej zależności (Captive
Dependency): Wstrzyknięcie obiektu
Scoped(np.DbContext) do obiektu typuSingletonprowadzi do uwięzienia zasobu w pamięci na cały czas życia aplikacji, uniemożliwiając jego zwolnienie i wywołując błędy współbieżnego dostępu do bazy.
Cykl życia samego kontrolera MVC:
- W ASP.NET Core kontrolery są domyślnie rejestrowane jako
Transient — powoływane w momencie odebrania żądania i
natychmiast utylizowane po odesłaniu wyniku
IActionResult. Nie należy zapisywać w nich stanu pomiędzy wywołaniami.
- W ASP.NET Core kontrolery są domyślnie rejestrowane jako
Transient — powoływane w momencie odebrania żądania i
natychmiast utylizowane po odesłaniu wyniku
4. Wzorce Kreacyjne (Konstrukcyjne)
Wzorce kreacyjne hermetyzują proces tworzenia obiektów, uniezależniając aplikację od konkretnych klas implementacyjnych.
4.1. Singleton (Zakres Obiektowy)
Jest to najprostszy wzorzec kreacyjny, dlatego od niego rozpoczniemy. Ze względu na wykorzystywanie w nim składników statycznych klasy, takich jak metody, pola, właściwości, problemy z testami jednostkowymi oraz z potencjalnymi problemami, jakie mogą wystąpić podczas przetwarzania współbieżnego, często uważany jest za antywzorzec.
Przykładowa implementacja:
public sealed class Singleton
{
private Singleton() { }
private static Singleton _instance;
public static Singleton GetInstance()
{
if (_instance == null)
{
_instance = new Singleton();
}
return _instance;
}
public void someBusinessLogic()
{
// ...
}
}
W celu zapobieżenia utworzenia nowej instancji singletonu z kodu jego
konstruktor musi być prywatny. Poniższy kod zwróci błąd
CS0122 Element niedostępny z powodu swojego dostępu:
Singleton s0 = new Singleton();
Do pobrania instancji singletonu służy metoda statyczna
GetInstance. Przy pierwszym jej wołaniu tworzony jest nowy
obiekt, który jest przypisywany do pola statycznego
_instance. Każde kolejne wołanie spowoduje zwrócenie tej
samej, wcześniej utworzonej instancji. Jest to najprostsze rozwiązanie,
ale jest nieodporne na niewłaściwe wykorzystanie mechanizmu refleksji,
serializacji oraz na dostęp współbieżny i może np. spowodować powstanie
dwóch lub większej liczby instancji singletonu.
Poniższy kod testuje działanie singletonu:
Singleton s1 = Singleton.GetInstance();
Singleton s2 = Singleton.GetInstance();
CheckReferencesAreEquals(s1, s2);
static void CheckReferencesAreEquals(Singleton s1, Singleton s2)
{
if (s2 != null)
{
if (s1 == s2)
{
Console.WriteLine("Success");
}
else
{
Console.WriteLine("Fail");
}
}
else
{
Console.WriteLine("Success, s2 is null");
}
}
Podatność 1: Złamanie zabezpieczeń za pomocą Refleksji
Poniższy kod pokazuje, jak można błędnie utworzyć dwie instancje singletonu, wykorzystując mechanizm refleksji:
Singleton s2 = Activator.CreateInstance(typeof(Singleton), true) as Singleton;
Metoda statyczna CreateInstance statycznej klasy
Activator jako pierwszy parametr, który jest typu
Type, pobiera informacje o typie, którego obiekt ma
utworzyć. Drugi parametr to wartość logiczna nonPublic i w
przypadku przekazania true zmienia zachowanie domyślne
funkcji, pozwalając na wykorzystanie niepublicznego konstruktora.
Podatność 2: Złamanie zabezpieczeń za pomocą Serializacji
Poniższy kod również błędnie tworzy dwie instancje singletonu, tylko z wykorzystaniem mechanizmu serializacji:
DataContractJsonSerializer jsonSerializer = new DataContractJsonSerializer(typeof(Singleton)); // by serializing
MemoryStream memoryStream = new();
jsonSerializer.WriteObject(memoryStream, s1);
memoryStream.Position = 0;
Singleton s2 = jsonSerializer.ReadObject(memoryStream) as Singleton;
Rozwiązanie obronne w konstruktorze:
W obydwu przypadkach rozwiązaniem jest sprawdzenie w konstruktorze,
czy pole z instancją obiektu nie zostało jeszcze zainicjalizowane i
wskazuje na null. Jeśli tak nie jest, to znaczy, że
konstruktor został nielegalnie wywołany i oświadczamy to, rzucając
odpowiedni wyjątek TargetInvocationException (opakowujący
wyjątek zgłoszony w konstruktorze):
private Singleton()
{
if (_instance != null)
{
throw new InvalidOperationException("Cannot create new instance, " +
"please use getInstance method instead.");
}
}
Podatność
3: Współbieżny dostęp do metody GetInstance
Kolejnym problemem może być współbieżny dostęp do metody
GetInstance. Poniższy kod uruchamia 10 000 przypadków
polegających na współbieżnym uruchomieniu 2 wołań tej metody.
Metody Task.Factory.StartNew albo bardziej zwięzła jej
forma Task.Run pozwalają na współbieżne uruchomienie kodu
przekazanego w postaci delegata Action uruchamiającego
metodę GetInstance. Metoda Task.WaitAll czeka,
aż wszystkie przekazane jako parametry zadania Task zostaną
zakończone. Jest ona wykonywana w bloku try - catch,
ponieważ może zwrócić agregowany wyjątek
AggregateException, a liczba zagregowanych wyjątków
e.InnerException.Data.Count będzie równa liczbie
nieuprawnionych prób dostępu do metody GetInstance:
int testSuccess = 0;
int testFail = 0;
int testException = 0;
for (int i = 0; i < 10000; i++)
{
ClearStaticSingletonField();
Singleton firstInstanceForTestBreakWithThreads = null;
Singleton secondInstanceForTestBreakWithThreads = null;
var producer1 = Task.Run(() =>
{
firstInstanceForTestBreakWithThreads = Singleton.GetInstance();
});
var producer2 = Task.Factory.StartNew(() =>
{
secondInstanceForTestBreakWithThreads = Singleton.GetInstance();
});
try
{
Task.WaitAll(producer1, producer2);
}
catch (AggregateException e)
{
testException += e.InnerException.Data.Count;
}
if (firstInstanceForTestBreakWithThreads == secondInstanceForTestBreakWithThreads)
{
testSuccess++;
}
else
{
testFail++;
}
}
Console.WriteLine($"testSuccess: {testSuccess}, testFail: {testFail}, testException: {testException}");
static void ClearStaticSingletonField()
{
var field = typeof(Singleton).GetField("_instance",
BindingFlags.Static |
BindingFlags.NonPublic);
field.SetValue(null, null);
}
Przykładowe wyjście:
testSuccess: 9988, testFail: 12, testException: 0
Widać, że statystycznie taki scenariusz generuje błędy. Rozwiązaniem
jest wykorzystanie słowa kluczowego lock w celu uniknięcia
równoczesnego użycia wspólnego zasobu, którym w naszym przypadku jest
konstruktor:
private static readonly object _lock = new object();
public static Singleton GetInstance()
{
lock (_lock)
{
if (_instance == null)
{
_instance = new Singleton();
}
}
return _instance;
}
Singleton a Zasada SRP
Każdy singleton udostępnia pewną logikę biznesową, która jest
zaimplementowana w postaci metod publicznych, takich jak
someBusinessLogic. Jak widać, taki singleton ma dwie
odpowiedzialności:
- Zarządza tworzeniem instancji klasy.
- Wykonuje pierwotną odpowiedzialność klasy – metoda
someBusinessLogic.
Zatem łamie pierwszą zasadę SOLID – zasadę pojedynczej odpowiedzialności (SRP). Rozwiązaniem będzie wydzielenie logiki biznesowej singletonu do innych modułów/klas.
Nowoczesna
implementacja z użyciem Lazy<T>
W platformie .NET najbardziej eleganckim rozwiązaniem jest klasa
Lazy<T>, gwarantująca leniwą inicjalizację bezpieczną
wątkowo bez manualnego lock:
public sealed class ModernSingleton
{
private static readonly Lazy<ModernSingleton> _lazyInstance =
new Lazy<ModernSingleton>(() => new ModernSingleton());
private ModernSingleton() { }
public static ModernSingleton Instance => _lazyInstance.Value;
}
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
Zamiast implementować mechanizm kontroli instancji wewnątrz samej klasy domenowej, rejestrujemy czystą klasę POCO w kontenerze z czasem życia Singleton:
// 1. Rejestracja w Microsoft.Extensions.DependencyInjection:
services.AddSingleton<IEngineTelemetryService, EngineTelemetryService>();
// 2. Rejestracja w Autofac:
builder.RegisterType<EngineTelemetryService>()
.As<IEngineTelemetryService>()
.SingleInstance();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Pełna zgodność z zasadą SRP (SOLID): Klasa realizuje wyłącznie swoją odpowiedzialność domenową. Nie zarządza swoim cyklem życia, nie zawiera pól statycznych ani metod fabrykujących.
- Bezpieczeństwo współbieżne bez manualnych blokad: Kontenery DI automatycznie gwarantują alokację typu thread-safe przy pierwszym żądaniu, bez ryzyka wyścigów pamięci.
- Izolacja testów jednostkowych: Klasa ma publiczny
konstruktor – w testach jednostkowych można powołać jej nową instancję
operatorem
newlub bez problemu przekazać atrapę (Mock<IEngineTelemetryService>). - Brak podatności na refleksję i serializację: Ponieważ instancja nie jest „wymuszana” przez ukryty konstruktor, refleksja nie może zepsuć globalnego stanu aplikacji.
4.2. Metoda Wytwórcza (Factory Method – Zakres Klasowy)
Klasy, aby mogły korzystać z udostępnianych przez inne klasy
funkcjonalności, muszą tworzyć obiekty tych klas. Domyślnie w tym celu
wykorzystywany jest konstruktor klasy poprzedzony słowem kluczowym
new. Takie rozwiązanie powoduje powstanie silnych
zależności pomiędzy klasami, a zgodnie z piątą zasadą SOLID –
Dependency Inversion Principle (DIP) – moduły wyższego
poziomu powinny zależeć od abstrakcji, a nie od modułów niskiego
poziomu.
W celu rozluźnienia tych zależności możemy wykorzystać wzorzec metody
wytwórczej. Proces tworzenia obiektów (nazywanych teraz produktami) za
pomocą new jest przenoszony do ciała tej metody, co ukrywa
ten proces przed klientem oraz pozwala na łatwą jego zmianę (zmiany
dokonujemy tylko raz w ciele metody, a nie we wszystkich wołaniach
new w klasach klienckich).
Zgodnie z zasadą DIP należy użyć abstrakcji, a w C# mamy do dyspozycji interfejsy i klasy abstrakcyjne. Zgodnie ze wzorcem wprowadzamy dwie abstrakcje: produktu i kreatora.
Interfejs
IProduct definiujący metodę Operation wraz z
implementującą go konkretną klasą produktu:
public interface IProduct
{
string Operation();
}
public class ConcreteProduct1 : IProduct
{
public string Operation()
{
return "{Result of ConcreteProduct1}";
}
}
Interfejs
ICreator definiujący metodę wytwórczą
FactoryMethod zwracającą produkt implementujący
IProduct wraz z implementującą go konkretną klasą
wytwórcy:
Jeśli wykorzystamy klasę abstrakcyjną albo interfejs w wersji C# 8.0
albo wyższej, to możemy w kreatorze umieścić domyślną implementację
metody wytwórczej FactoryMethod (tutaj niezrealizowane,
zaimplementowana jest za to wspólna dla wszystkich kreatorów logika w
metodzie SomeOperation):
public interface ICreator
{
public IProduct FactoryMethod();
public string SomeOperation()
{
// Call the factory method to create a Product object.
var product = FactoryMethod();
// Now, use the product.
var result = "Creator: The same creator's code has just worked with "
+ product.Operation();
return result;
}
}
Podstawową odpowiedzialnością kreatora nie jest tworzenie
produktów. Zazwyczaj zawiera on pewną podstawową logikę
biznesową, która opiera się na produktach zwracanych przez metodę
FactoryMethod. Podklasy mogą pośrednio zmieniać tę logikę
biznesową, nadpisując metodę FactoryMethod i zwracając z
niej inny typ produktu:
public class ConcreteCreator1 : ICreator
{
public IProduct FactoryMethod()
{
return new ConcreteProduct1();
}
}
Użycie w kodzie:
ICreator c;
c = new ConcreteCreator1();
//c = new ConcreteCreator2();
Console.WriteLine(c.SomeOperation());
W celu zmiany logiki biznesowej zmieniamy jedynie obiekt kreatora – jego metody same powołają odpowiednie produkty, na których jest oparta.
💡 Ponowne użycie obiektów a oszczędność zasobów:
Z pomocą metody wytwórczej można w miejsce tworzenia nowych obiektów dokonać ponownego użycia już istniejących. Jest to szczególnie przydatne w sytuacji, kiedy pracujemy z dużymi obiektami wykorzystującymi takie zasoby systemowe jak połączenia z bazą danych, systemy plików oraz zasoby sieciowe – pozwoli to na ich zaoszczędzenie.
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W nowoczesnym C# nie tworzy się odrębnych podklas kreatorów dla
każdego nowego typu produktu. Odpowiedzialność wytwórczą przejmują
wbudowane delegaty fabrykujące Func<T> lub serwisy
kluczowane (.NET 8):
// Wariant A: Delegat Func<IProduct> w Microsoft DI:
services.AddTransient<ConcreteProduct1>();
services.AddTransient<Func<IProduct>>(sp => () => sp.GetRequiredService<ConcreteProduct1>());
// Wariant B: Usługi kluczowane w .NET 8 (Keyed Services):
services.AddKeyedTransient<IProduct, ConcreteProduct1>("v1");
services.AddKeyedTransient<IProduct, ConcreteProduct2>("v2");
// Wariant C: Automatyczna fabryka niejawna w Autofac (wystarczy zarejestrować typ):
builder.RegisterType<ConcreteProduct1>().As<IProduct>();
// Autofac automatycznie obsłuży zależność 'Func<IProduct>' wstrzykiwaną do konsumenta!
Konsument (dawny Kreator) przyjmuje delegat fabrykujący w konstruktorze:
public class OrderProcessor
{
private readonly Func<IProduct> _factory;
public OrderProcessor(Func<IProduct> factory) => _factory = factory;
public void Process()
{
var product = _factory(); // Odpowiednik FactoryMethod()
Console.WriteLine(product.Operation());
}
}
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Eliminacja eksplozji klas (Class
Explosion): Dodanie produktu
ConcreteProduct3nie wymaga pisania nowej klasyConcreteCreator3. Wystarczy zarejestrować typ w kontenerze. - Ścisłe programowanie do abstrakcji (DIP): Konsument
zależy wyłącznie od interfejsu delegata
Func<IProduct>, a nie od hierarchii dziedziczenia kreatorów. - Maksymalna testowalność: W teście jednostkowym nie
trzeba tworzyć sztucznych klas testowych dziedziczących po kreatorze –
wystarczy przekazać lambdę:
new OrderProcessor(() => mockProduct.Object).
4.3. Fabryka Abstrakcyjna (Abstract Factory – Zakres Obiektowy)
Jest to rozszerzenie koncepcji metody wytwórczej o możliwość
tworzenia całego zbioru/rodziny współpracujących ze sobą
produktów. Interfejs IAbstractFactory deklaruje
zestaw metod, które zwracają różne produkty abstrakcyjne. Tworzą one
rodzinę produktów powiązanych tematycznie bądź koncepcyjnie.
Produkty jednej rodziny zazwyczaj mogą ze sobą współpracować. Rodzina produktów może mieć kilka wariantów, ale produkty należące do różnych wariantów są ze sobą niezgodne.
Zbiór
interfejsów IAbstractProductA i
IAbstractProductB wraz z implementującym go zbiorem
konkretnych klas produktów: ConcreteProductA1 i
ConcreteProductB1:
public interface IAbstractProductA
{
string UsefulFunctionA();
}
public class ConcreteProductA1 : IAbstractProductA
{
public string UsefulFunctionA()
{
return "The result of the product A1.";
}
}
public interface IAbstractProductB
{
string UsefulFunctionB();
string AnotherUsefulFunctionB(IAbstractProductA collaborator);
}
public class ConcreteProductB1 : IAbstractProductB
{
public string UsefulFunctionB()
{
return "The result of the product B1.";
}
public string AnotherUsefulFunctionB(IAbstractProductA collaborator)
{
var result = collaborator.UsefulFunctionA();
return $"The result of the B1 collaborating with the ({result})";
}
}
Zauważmy, że produkt implementujący IAbstractProductB
definiuje metodę AnotherUsefulFunctionB, która współpracuje
z obiektem implementującym IAbstractProductA. To właśnie
fabryka abstrakcyjna dba o to, aby wszystkie tworzone przez nią produkty
były tej samej wersji, a zatem były kompatybilne. Konkretne fabryki
produkują rodzinę produktów należących do jednego wariantu i gwarantują,
że powstałe produkty są kompatybilne.
Interfejs
IAbstractFactory wraz z implementującą go konkretną klasą
fabryki:
public interface IAbstractFactory
{
IAbstractProductA CreateProductA();
IAbstractProductB CreateProductB();
}
public class ConcreteFactory1 : IAbstractFactory
{
public IAbstractProductA CreateProductA() => new ConcreteProductA1();
public IAbstractProductB CreateProductB() => new ConcreteProductB1();
}
Użycie w kodzie:
//var factory = new ConcreteFactory1();
var factory = new ConcreteFactory2();
var productA = factory.CreateProductA();
var productB = factory.CreateProductB();
Console.WriteLine(productB.UsefulFunctionB());
Console.WriteLine(productB.AnotherUsefulFunctionB(productA));
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
Zamiast tworzyć rozbudowane interfejsy fabryk z metodami
CreateProductA() i CreateProductB(), spójność
całej rodziny produktów definiuje się na poziomie rejestracji
kontenera:
// Wariant A: Wstrzykiwanie kluczowane w Microsoft DI (.NET 8):
services.AddKeyedTransient<IAbstractProductA, ConcreteProductA1>("Variant1");
services.AddKeyedTransient<IAbstractProductB, ConcreteProductB1>("Variant1");
services.AddKeyedTransient<IAbstractProductA, ConcreteProductA2>("Variant2");
services.AddKeyedTransient<IAbstractProductB, ConcreteProductB2>("Variant2");
// Wariant B: Moduły Autofac jako Fabryki Abstrakcyjne:
public class Variant1Module : Module
{
protected override void Load(ContainerBuilder builder)
{
builder.RegisterType<ConcreteProductA1>().As<IAbstractProductA>();
builder.RegisterType<ConcreteProductB1>().As<IAbstractProductB>();
}
}
// Zmiana rodziny produktów to zmiana jednej linijki w Composition Root:
builder.RegisterModule<Variant1Module>();
Konsument wstrzykuje bezpośrednio potrzebne mu produkty, nie martwiąc się o ich powoływanie:
public class ClientWorkflow
{
public ClientWorkflow(IAbstractProductA prodA, IAbstractProductB prodB) { /* ... */ }
}
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Zredukowana odpowiedzialność klienta: Klient biznesowy nie musi wiedzieć o istnieniu fabryki ani manualnie wywoływać metod tworzących – otrzymuje od razu wstrzyknięty, w pełni kompatybilny zestaw narzędzi.
- Brak ryzyka pomieszania wariantów: Rejestracja w
module gwarantuje, że pod interfejsy
IAbstractProductAiIAbstractProductBzawsze podstawione zostaną komponenty z tej samej rodziny. - Czystość kodu: Drastyczny spadek liczby interfejsów i klas w solucji.
4.4. Budowniczy (Builder – Zakres Obiektowy)
Pozwala rozdzielić proces tworzenia złożonego obiektu od jego
reprezentacji. Jest stosowany, kiedy mamy do czynienia ze złożonymi
obiektami, które wymagałyby utworzenia dużej liczby podklas. Proces
tworzenia obiektu podzielony jest na kilka mniejszych etapów, a każdy z
nich może być implementowany na wiele sposobów. Ten sam proces
konstrukcji może prowadzić do powstania różnych reprezentacji. Finalnie
metoda Build inicjalizuje proces budowy.
Takie podejście pozwala również wyeliminować problem tzw. teleskopowych konstruktorów. Powstaje on, gdy udostępniamy zbyt wiele konstruktorów, które mają 4 lub więcej parametrów. Trudno wtedy zapamiętać, jaka jest wymagana kolejność parametrów, a także jaki konkretny konstruktor należy użyć w danej sytuacji. Warto zadbać o to, aby implementacja Buildera nie wymagała konstruowania obiektu w określonej kolejności. Zapobiega to możliwości pomyłki czy wystąpienia efektów ubocznych, które wynikają z różnej kolejności etapów konstrukcji obiektów.
Wyróżniamy następujące elementy tego wzorca:
- budowniczy: abstrakcja do tworzenia części składowych złożonego obiektu;
- konkretny budowniczy: definiuje i kontroluje tworzoną przez siebie reprezentację w oparciu o budowniczego;
- dyrektor: konstruuje obiekty, używając abstrakcji budowniczego do wywoływania metod konkretnego budowniczego oraz nadzoruje proces budowy, aby proces konstrukcyjny przebiegał w odpowiedniej sekwencji;
- produkt: złożony obiekt, który tworzony jest z wielu części składowych. Jest on konstruowany przez dyrektora przy użyciu konkretnego budowniczego.
Można połączyć go ze wzorcem Most, gdzie dyrektor to abstrakcja, zaś konkretni budowniczowie to implementacje (powiemy o tym więcej w opisie Mostu w sekcji wzorce strukturalne).
Kod klasy Product
(produktu):
public class Product
{
private List<object> _parts = new List<object>();
public void Add(string part)
{
this._parts.Add(part);
}
public string ListParts()
{
string str = string.Empty;
for (int i = 0; i < this._parts.Count; i++)
{
str += this._parts[i] + ", ";
}
str = str.Remove(str.Length - 2); // removing last ", "
return "Product parts: " + str + "\n";
}
}
Interfejs
IBuilder (budowniczy) specyfikujący metody tworzenia
różnych części obiektów produktu:
public interface IBuilder
{
void BuildPartA();
void BuildPartB();
void BuildPartC();
}
Klasa
konkretnego budowniczego implementująca interfejs
IBuilder:
public class ConcreteBuilder : IBuilder
{
private Product _product = new Product();
public void Reset()
{
this._product = new Product();
}
public void BuildPartA()
{
this._product.Add("PartA1");
}
public void BuildPartB()
{
this._product.Add("PartB1");
}
public void BuildPartC()
{
this._product.Add("PartC1");
}
public Product Build()
{
Product result = this._product;
this.Reset();
return result;
}
}
Należy podkreślić, że wszystkie etapy produkcji działają na
tej samej instancji produktu. Konkretni budowniczowie powinni
udostępniać metodę Build pozwalającą na zbudowanie i
pobieranie wynikowego obiektu. Dzieje się tak, ponieważ, w
przeciwieństwie do innych wzorców kreacyjnych, różni konkretni
budowniczowie mogą wytwarzać niepowiązane produkty, które nie korzystają
z tego samego interfejsu. Dlatego też takich metod nie można
zadeklarować w podstawowej abstrakcji Buildera (przynajmniej w
statycznie typowanym języku programowania).
Zazwyczaj po zwróceniu klientowi wyniku końcowego oczekuje się, że
instancja buildera będzie gotowa do rozpoczęcia produkcji innego
produktu. Dlatego powszechną praktyką jest na końcu metody
Build wywoływać metodę Reset. Jednak takie
zachowanie nie jest obowiązkowe i alternatywą jest, aby buildery czekały
na jawne wywołanie resetu z kodu klienta.
Kod klasy dyrektora:
public class Director
{
private IBuilder _builder;
public IBuilder Builder
{
set { _builder = value; }
}
// The Director can construct several product variations using the same
// building steps.
public void BuildMinimalViableProduct()
{
this._builder.BuildPartA();
}
public void BuildFullFeaturedProduct()
{
this._builder.BuildPartA();
this._builder.BuildPartB();
this._builder.BuildPartC();
}
}
Dyrektor jest odpowiedzialny, aby zapewnić poprawną kolejność kroków budowy. Jest to pomocne podczas produkcji produktów zgodnie z określoną konfiguracją. Klasa Dyrektora jest opcjonalna, ponieważ klient może bezpośrednio kontrolować budowniczych.
Użycie w kodzie:
var director = new Director();
var builder = new ConcreteBuilder();
director.Builder = builder;
Console.WriteLine("Standard basic product:");
director.BuildMinimalViableProduct();
Console.WriteLine(builder.Build().ListParts());
Console.WriteLine("Standard full featured product:");
director.BuildFullFeaturedProduct();
Console.WriteLine(builder.Build().ListParts());
// Użycie buildera bez dyrektora:
Console.WriteLine("Custom product:");
builder.BuildPartA();
builder.BuildPartC();
Console.Write(builder.Build().ListParts());
Praktyczne
zastosowanie w ekosystemie .NET: WebApplicationBuilder
Praktycznym zastosowaniem buildera jest klasa
WebApplicationBuilder dostępna na platformie .NET od wersji
6.0, służąca do budowania i konfigurowania aplikacji internetowych
(należy zauważyć, że większość konfiguracji została przeniesiona do
odpowiadających im warstw zgodnie z duchem czystej architektury):
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenTelemetry(builder.Configuration);
builder.Services.AddApplication();
builder.Services.AddInfrastructure(builder.Configuration);
builder.Services.AddWeb(builder.Configuration);
builder.Configuration.AddJsonFile("users.json", optional: false, reloadOnChange: false)
.AddEnvironmentVariables();
builder.WebHost.ConfigureKestrel(options =>
{
options.Limits.MaxResponseBufferSize = 2_147_483_648; // 2 GiB
});
var app = builder.Build();
Rozszerzenie: Fluent Step Builder
Rozszerzeniem tego wzorca jest Fluent Builder, który zmusza programistę do tworzenia obiektów w kolejności, poprzez wywoływanie każdego settera po kolei, aż do ustawienia wszystkich wymaganych atrybutów. W tym celu należy utworzyć łańcuch interfejsów, w którym każda metoda interfejsu będzie zwracać kolejny typ interfejsu. Jak widać, pozwala to na rezygnację z dyrektora bez utraty jego zalet.
Oto przykład budujący obiekt Email, który będzie
zawierał wszystkie informacje potrzebne do wysłania wiadomości
e-mail:
interface IEmailTo
{
IEmailSubject SetTo(string to);
}
interface IEmailSubject
{
IEmailContent SetSubject(string subject);
}
interface IEmailContent
{
IEmailCreator SetContent(string content);
}
interface IEmailCreator
{
IEmailCreator SetBCC(string bcc);
IEmailCreator SetCC(string cc);
Email Build();
}
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
W ekosystemie .NET klasycznego Dyrektora zastępuje się konfiguracją funkcyjną (Action/Options Pattern), a instancje budowniczych rejestruje się w kontenerze z czasem życia uniemożliwiającym kolizje stanu:
// Wariant A: Konfiguracja za pomocą lambdy (wzorzec powszechny w .NET Core / ASP.NET):
services.AddTransient<Product>(sp =>
{
var configurator = new FluentProductConfigurator();
configurator.AddPartA().AddPartB(); // Kroki montażu zdefiniowane w Composition Root
return configurator.Build();
});
// Wariant B: Autofac z cyklem InstancePerDependency:
// Klasyczny builder jest mutowalny. Aby uniknąć wyścigów wątków, kontener gwarantuje
// świeżą, odizolowaną instancję buildera przy każdym wstrzyknięciu:
builder.RegisterType<ConcreteBuilder>().As<IBuilder>().InstancePerDependency();
// Automatyczne wstrzyknięcie buildera do Dyrektora przez właściwość:
builder.RegisterType<Director>().PropertiesAutowired();
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Eliminacja stanu mutowalnego dzielonego między
wątkami: Klasyczny builder trzyma pole
_productw pamięci. Zarejestrowanie go jakoInstancePerDependency/Transientcałkowicie eliminuje błędy współbieżnego nadpisywania stanu montowanego obiektu. - Rezygnacja z klasy Dyrektora: W nowoczesnym C# rolę
Dyrektora przejmuje czytelne wyrażenie lambda lub metoda rozszerzająca
IServiceCollection, co redukuje narzut ceremonialnego kodu.
4.5. Prototyp (Prototype – Zakres Obiektowy)
Pozwala na klonowanie istniejących obiektów bez potrzeby dodawania
zależności do klasy kopiowanego obiektu – wystarcza sama abstrakcja
(zazwyczaj interfejs). Zazwyczaj realizacja tego wzorca polega na
definicji interfejsu implementującego pojedynczą metodę
Clone wspólną dla wszystkich prototypów.
Rozróżniamy dwa rodzaje klonowania, których zachowanie różni się podczas kopiowania typu referencyjnego oryginalnego wystąpienia:
- płytkie: kopiowana jest jedynie referencja do istniejącego obiektu.
- głębokie: kopiowane jest rekurencyjnie całe drzewo
obiektów referencyjnych z wykorzystaniem metody
Clonerealizującej kopiowanie głębokie. - pośrednie: wykorzystywane są obydwa rodzaje: płytkie i głębokie.
W .NET mamy do dyspozycji:
- Metodę
MemberwiseClonerealizującą płytką kopię przez utworzenie nowego obiektu, a następnie skopiowanie niestatycznych pól bieżącego obiektu do nowego obiektu; - Interfejs
ICloneable. W jego przypadku nie jest określone, jaką kopię (płytką, głęboką czy coś pomiędzy) wykonuje operacja klonowania. Nie wymaga się również skopiowania wszystkich wartości właściwości oryginalnego wystąpienia do nowego wystąpienia.
Rozważmy
kod klasy Person, pozwalającej zarówno na płytkie, jak i
głębokie kopiowanie, oraz wykorzystywanej przez nią klasy
IdInfo:
public class Person
{
public int Age;
public DateTime BirthDate;
public string Name;
public IdInfo IdInfo;
public Person ShallowCopy()
{
return (Person)this.MemberwiseClone();
}
public Person DeepCopy()
{
Person clone = (Person)this.MemberwiseClone();
clone.IdInfo = new IdInfo(IdInfo.IdNumber);
clone.Name = new string(Name); // lub clone.Name = Name (string jest w C# niezmienny)
return clone;
}
}
public class IdInfo
{
public int IdNumber;
public IdInfo(int idNumber)
{
this.IdNumber = idNumber;
}
}
Użycie w kodzie:
Person p1 = new Person();
p1.Age = 42;
p1.BirthDate = Convert.ToDateTime("1977-01-01");
p1.Name = "Jack Daniels";
p1.IdInfo = new IdInfo(666);
// Perform a shallow copy of p1 and assign it to p2.
Person p2 = p1.ShallowCopy();
// Make a deep copy of p1 and assign it to p3.
Person p3 = p1.DeepCopy();
// Display values of p1, p2 and p3.
Console.WriteLine("Original values of p1, p2, p3:");
Console.WriteLine(" p1 instance values: ");
DisplayValues(p1);
Console.WriteLine(" p2 instance values:");
DisplayValues(p2);
Console.WriteLine(" p3 instance values:");
DisplayValues(p3);
// Change the value of p1 properties and display the values of p1, p2 and p3.
p1.Age = 32;
p1.BirthDate = Convert.ToDateTime("1900-01-01");
p1.Name = "Frank";
p1.IdInfo.IdNumber = 7878;
Console.WriteLine("\nValues of p1, p2 and p3 after changes to p1:");
Console.WriteLine(" p1 instance values: ");
DisplayValues(p1);
Console.WriteLine(" p2 instance values (reference values have changed):");
DisplayValues(p2); // Referencja IdInfo współdzielona – wartość uległa zmianie!
Console.WriteLine(" p3 instance values (everything was kept the same):");
DisplayValues(p3); // Całkowita izolacja pamięci – wartość IdNumber nienaruszona!
static void DisplayValues(Person p)
{
Console.WriteLine(" Name: {0:s}, Age: {1:d}, BirthDate: {2:MM/dd/yy}",
p.Name, p.Age, p.BirthDate);
Console.WriteLine(" ID#: {0:d}", p.IdInfo.IdNumber);
}
Alternatywa w nowoczesnym .NET: Microsoft DI i Autofac
Zamiast manualnego implementowania MemberwiseClone() i
narażania się na błędy płytkiej kopii (Shallow Copy), ideę
prototypu w kontenerach realizuje się poprzez cykl
Transient lub dedykowane serwisy szablonowe:
// Wariant A: Cykl Transient w Microsoft DI jako czysty prototyp:
// Każde zapytanie do kontenera zwraca całkowicie nowy, odizolowany obiekt w pamięci:
services.AddTransient<Person>(sp => new Person
{
Age = 18,
Name = "Default Template",
IdInfo = new IdInfo(0)
});
// Wariant B: Rejestr szablonów (Master Template Cache):
// Kosztowny w alokacji obiekt wzorcowy rejestrujemy jako Singleton,
// a kontener udostępnia fabrykę wydającą bezpieczne kopie głębokie (DeepCopy):
services.AddSingleton<IPrototypeRegistry<Person>>(new PersonPrototypeRegistry(masterTemplate));
// Wariant C: Autofac z automatyczną fabryką dynamiczną:
// Pozwala w locie sparametryzować klonowany obiekt:
builder.Register<Func<int, Person string,>>(c => (id, name) => new Person
{
Name = name,
IdInfo = new IdInfo(id)
});
Dlaczego podejście oparte na kontenerze DI jest lepsze?
- Eliminacja ryzyka wycieku referencji (Reference
Mutation): W klasycznym
MemberwiseClone()zmiana pola referencyjnego w klonie uszkadza dane w prototypie pierwotnym. W kontenerze DI instancje są z definicji odizolowane na stercie. - Czysta domena (brak metody
Clone()w klasach biznesowych): Klasy nie muszą implementować interfejsów klonujących ani znać szczegółów kopiowania swoich pól, co sprzyja hermetyzacji i zasadzie pojedynczej odpowiedzialności. - Współbieżność: Równoległe pobieranie egzemplarzy z
kontenera w setkach wątków (
Parallel.ForEachAsync) jest w 100% bezpieczne wątkowo.
4.6. Podsumowanie Wzorców Kreacyjnych i Ewolucja w Architekturze
Podsumowując wzorce kreacyjne: pierwszym wzorcem wdrażanym w projektach zazwyczaj jest Metoda wytwórcza jako najprostsza. W kolejnym kroku, w zależności od potrzeb, projekty mogą ewoluować w bardziej elastyczne, ale też bardziej skomplikowane wzorce: Fabrykę abstrakcyjną, Prototyp lub Budowniczego (które mogą być implementowane jako Singletony).
Budowniczy koncepcyjnie jest powiązany ze strukturalnym wzorcem Most, który zaleca dzielenie dużych klas poprzez stosowanie agregacji względem dziedziczenia. Można też wykorzystać go do rekurencyjnego tworzenia drzew obiektów opisywanych przez wzorzec strukturalny Kompozyt.
Zestawienie Wzorców GoF z Nowoczesnymi Kontenerami IoC/DI:
| Klasyczny Wzorzec GoF | Odpowiednik w Kontenerze IoC/DI | Korzyść architektoniczna z migracji na kontener |
|---|---|---|
| Singleton | Rejestracja .SingleInstance()
(Autofac) lub AddSingleton() (MS DI). |
Usunięcie globalnego stanu statycznego, pełna testowalność z użyciem mocków, zgodność z SRP. |
| Metoda Wytwórcza | Rejestracja
Func<TService> lub automatyczne fabrykowanie
zależności w konstruktorze. |
Brak konieczności tworzenia dedykowanych klas podrzędnych kreatorów dla każdego typu. |
| Fabryka Abstrakcyjna | Usługi nazwane
(Named<T>), kluczowane (AddKeyedScoped)
lub fabryki z parametrami. |
Uniknięcie rozbudowanych hierarchii fabryk; dodanie nowego wariantu to rejestracja w kontenerze. |
| Budowniczy | Fluentyczna rejestracja komponentów
(builder.Register(...)),
WebApplicationBuilder. |
Konfiguracja procesu budowy grafu obiektów w jednym punkcie wejścia aplikacji (Composition Root). |
| Prototyp | Rejestracja z cyklem
InstancePerDependency() / Transient lub
dedykowane serwisy klonujące. |
Bezpieczne zarządzanie świeżymi obiektami w pamięci przez kontener. |
5. Zadania Laboratoryjne
Zadania laboratoryjne do Części 1 znajdują się w osobnym
dokumencie:
📄 Zadanie Laboratoryjne do Części 1: Architektura Asynchronicznego Systemu Dowodzenia Flotą Orbitalną (Wzorce Kreacyjne, Wielozadaniowość i IoC/DI)
6. Literatura i Źródła
- Refactoring Guru: Wzorce projektowe w C# – https://refactoring.guru/pl/design-patterns/csharp
- Infotraining: Wzorce projektowe w C-Sharp – https://infotraining.bitbucket.io/cs-dp/
- Krzysztof Owsiany / StormIT: Singleton – 7 błędów, które popełniasz wykorzystując singleton – https://stormit.pl/singleton/
- Sławomir Kowalski: Wzorce projektowe w C# – https://medium.com/@sawomirkowalski
- Maciej Szczepaniak: Wzorce projektowe – czym są i dlaczego warto je znać? – https://devszczepaniak.pl/wzorce-projektowe/
- Dokumentacja Autofac: Configuration & Lifetime Scopes – https://autofac.readthedocs.io
- Awesome .NET / LibHunt: Kategoria IoC i Dependency Injection – https://dotnet.libhunt.com/categories/1810-ioc