W pewnym momencie każdy skrypt dochodzi do etapu gdzie jakiś element Foo potrzebuje do działania elementu Bar. Staje się związany, zależny.
W programowaniu istnieją pojęcia loose coupling i tight coupling określające poziom zależności. Mówi się, że loose coupling jest lepsze, a dependency injection jest magicznym lekarstwem...
Tylko co to znaczy?
Dependency
Przyjmijmy, że mamy klasę Storage, który zapewnia jednolity sposób dostępu do źródła danych, bez znaczenia czy to baza, XML czy jakieś API.
Z obiektu Storage korzysta przykładowy Article, do odczytu, zapisu itd. Article nie ma racji bytu bez instancji Storage.
class Storage {
public function __construct($user, $pass, $database);
/* costam costam */
}
class Article {
protected $Storage;
public function __construct() {
$this->Storage = new Storage('admin', 'admin1', 'articles');
}
public function getById($id) {
/* costam */
}
}
$Article = new Article();
$article = $Article->getById(123);
Mamy więc obiekt Article, ściśle powiązany ze Storage.
Zależność między Article a Storage sama w sobie nie jest zła. W końcu Article musi skądś pobierać dane by móc artykuły wyświetlić na stronie.
Problemem jest to, że zależność jest zapisana wewnątrz Article.
Ogranicza to późniejsze wykorzystanie Article jedynie do konkretnej implementacji Storage. W momencie, gdy będzie trzeba zmienić źródło danych na inną bazę, XML, czy API, będzie trzeba ingerować w klasę Article. Tak być nie powinno - Article nie musi posiadać wiedzy gdzie składowane są dane, a jedynie jak je pozyskać. Gdzie pozyskać nie oznacza odczytać - to dwie różne rzeczy.
By zrobić to porządnie, trzeba dokonać znacznych poprawek, refaktoryzować Storage i Artilce
class MySQLStorage implements StorageInterface {
public function __construct($user, $pass);
/* costam costam */
}
class XMLStorage implements StorageInterface {
public function __construct($path);
/* costam costam */
}
class Article {
protected $Storage;
public function __construct(StorageInterface $Storage) {
$this->Storage = $Storage
}
public function getById($id) {
/* costam */
}
}
$MySQL = new MySQLStorage('admin', 'admin1', 'articles');
$XML = new XMLStorage('articles.xml');
$MySQLArticle = new Article($MySQL);
$article1 = $MySQLArticle->getById(123);
$XMLArticle = new Article($XML);
$article2 = $XMLArticle->getById(456);
Co się stało?
Nie ma już klasy Storage, zamiast niej powstał interfejs StorageInterface, który przez implementację w MySQLStorage i XMLStorage standaryzuje sposób dostępu do źródeł danych. Odpowiednie instancje tychże implementacji są przekazywane - wstrzykiwane do Article. Tym samym Article nie jest już powiązany z konkretnym źródłem danych a jedynie ze standardem dostępu do nich jaki ustanawia StorageInterface.
Stopień zależności zmalał, co prawda Article jest nadal powiązane z jakimś Storage, ale nie ma powiedziane że z tym konkretnym. Wymaga jedynie źródła danych z interfejsem StorageInterface. MySQL, XML czy API - z perspektywy Article nie ma znaczenia.
Dependency Injection
Na tym polega właśnie dependency injection. Obiekt Article wymaga do działania jakiegoś Storage i ten zostaje mu wstrzyknięty, podany.
Article nie wie skąd i w jaki sposób powstaje Storage, zainteresowany jest tylko tym by mu go przekazać.
W powyższym przykładzie Article zależności zostają wstrzyknięte przez konstruktor. Ale są inne sposoby wstrzyknięcia:
- wspomniane już przez konstruktor
- Jak w powyższym przykładzie, potrzebne instancje są przekazywane jako parametry konstruktora. Najwygodniejszy sposób, od razu widać co jest potrzebne
- przez metodę / setter
- Jeżeli zależności jest zbyt wiele, to warto je wydzielić do odpowiednich metod / setterów. Trzeba tylko pamiętać o ich wywołaniu
- przez parametr / wartość
- Wspominam o tym dla porządku, bo szczerze odradzam taki sposób. Publiczne parametry są łatwe w modyfikacji, aż prosi się o stratę zależnej instancji lub podmianę na jakieś bzdury
DI Container
Każdorazowe tworzenie instancji obiektów potrzebnych dla przykładowego Article będzie masakrą. Za każdym razem trzeba sprawdzić czy nie utworzono instancji MySQLStorage, utworzyć gdy jest potrzebna, przekazać do Article i tak co jakiś czas.
By zaoszczędzić sobie roboty, wymyślono Dependency Injection Container, który pomaga zapanować nad sytuacją. Tworzy obiekty wraz z zależnościami, kontroluje czy obiekt może mieć jedną instancję czy wiele. Można też wykorzystać go do przechowywania różnego rodzaju wartości - zamiast globali.
W sieci można znaleźć wiele różnych kontenerów, z mniejszą lub większą funkcjonalnością. A ja mam swój, cały kod dostępny jest na GitHubie.
W zależności od implementacji, kontener będzie tworzył instancje obiektów w momencie utworzenia definicji (co wg mnie jest głupotą) lub dopiero w momencie pierwszego wywołania (czyli tak jak u mnie)
Całość składa się z trzech klas, na upartego można skrócić do dwóch.
- ComponentDefinition
- Obiekty tejże klasy przechowują definicje za pomocą której będzie można tworzyć inne obiekty w chwili ich użycia.
- ParameterDefinition
- Przechowuje konkretną wartość, string, integer, czy co tam się mu przekaże.
- Container
- Klasa z definicjami komponentów i parametrów, to do niej należy się odwoływać by uzyskać pożądaną instancję.
Sam Container nie jest wielce skomplikowany, zapewnia zestaw metod do rejestracji definicji w kontenerze, sprawdzenia czy definicja istnieje i pobrania instancji obiektu z definicji.
Cały mechanizm opiera się na identyfikatorach. Rejestrując definicję, podaje się identyfikator tekstowy pod jakim będzie dostępna.
$Condainer = new Container();
$Container->register('DatabaseStorage', new ComponentDefinition('MySQLStorage', true));
$MySQLArticle = new Article($Container->getComponent('DatabaseStorage'));
$article1 = $MySQLArticle->getById(123);
Utworzyłem instancję kontenera, zarejestrowałem definicję komponentu MySQLStorage pod identyfikatorem DatabaseStorage i tyle. Od tego momentu mogę wywoływać $Container->getComponent('DatabaseStorage') i za każdym razem dostanę tę samą instancję.
W przypadku ParameterDefinition można powiedzieć, że jest to przerost formy nad treścią. W końcu obiekt przechowuje tylko jakąś wartość. Chciałem jednak zachować schemat i tak zostało.
Zaś ComponentDefinition to bardziej złożona rzecz.
Tak jak pisałem wcześniej, Container sam w sobie niewiele potrafi, przechowuje tylko definicje i zapewnia interfejs. Całe tworzenie instancji, przekazywanie parametrów do konstruktorów, sprawdzanie czy już wcześniej została utworzona instancja odbywa się w definicji komponentu.
- ComponentDefinition::__construct($className, $shared = false, $instance = null)
-
- $className - Jako pierwszy parametr należy podać klasę obiektów jakiej instancje będzie tworzyć definicja, oczywiście jako string, wraz z przestrzenią nazw
- $shared - Określa czy instancja ma być współdzielona czy nie, jeśli ma być - to definicja pozwoli na utworzenie tylko jednej instancji. Każde kolejne wywołanie, będzie powodować zwrócenie tej samej instancji
- $instance - Ten parametr może wywołać lekkie oburzenie. Mianowicie, możemy przekazać istniejącą instancję obiektu i zarejestrować ją w kontenerze. W momencie kiedy przekazujemy instancję, informacja o klasie obiektu jest zbędna, tym samym pierwszy parametr może mieć wartość null.
Wartym podkreślenia jest to, że tylko współdzielone definicje mogą być tworzone z instancji. Nie jest to ograniczenie skryptu, ale narzucone przeze mnie. Nie widzę sensu, tworzenia definicji wielu na podstawie jednej konkretnej instancji. Do kolonowania, są odpowiednie komendy.
Ktoś może pokusić się do rozszerzenia funkcjonalności i usunąć ową niedogodność - do czego gorąco zachęcam. Ja takiej potrzeby nie miałem i nie mam, komuś jednak może się to przydać.
Po utworzeniu instancji definicji, można podawać parametry/argumenty, które będą przekazywane do konstruktora w momencie tworzenia instancji. Kolejność parametrów ma znaczenie, w takiej samej jak zostaną dodane, zostaną przekazane do konstruktora.
- ComponentDefinition::argument($type, $value)
-
- $type - Typ parametru, definicja obsługuje cztery typy, są to:
- container - do konstruktora zostanie przekazana instancja kontenera, dla tego typu wartość jest zbędna,
- component - określa, że do konstruktora zostanie przekazany inny komponent zdefiniowany w kontenerze, gdzie wartością jest identyfikator komponentu
- parameter - do konstruktora zostanie przekazana wartość parametru zdefiniowanego w kontenerze, jako wartość należy podać identyfikator parametru
- variable - parametrem będzie jakaś wartość
- $value - wartość dla danego typu (patrz $type)
- $type - Typ parametru, definicja obsługuje cztery typy, są to:
Definicja dla MySQLStorage będzie wyglądać tak:
$Definition = new ComponentDefinition('MySQLStorage', true);
$Definition
->argument('variable', 'admin')
->argument('variable', 'admin1')
->argument('variable', 'articles')
;
$Condainer = new Container();
$Container->register('DatabaseStorage', $Definition);
$MySQLArticle = new Article($Container->getComponent('DatabaseStorage'));
$article1 = $MySQLArticle->getById(123);
Kolejną metodą definicji jest ComponentDefinition::isShared(), która zwraca true jeśli instancja ma być wspóldzielona.
Ostatnią metodą jest tworzenie instancji.
public function &retrieve(\lib\Container $Container = null) {
if($this->shared && $this->instance) { /* 1 */
return $this->instance;
}
if(empty($this->arguments)) { /* 2 */
$instance = new $this->className();
}
else { /* 3 */
foreach($this->arguments as &$arg) {
if(!is_array($arg)) {
continue;
}
if($arg['type'] != 'variable' && !$Container) { /* 4 */
throw new \InvalidArgumentException(sprintf('Unable to resolve dependency for %s - missing container', $this->className));
}
switch($arg['type']) {
case 'container':
$arg = $Container;
break;
case 'component':
$arg = $Container->getComponent($arg['value']);
break;
case 'parameter':
$arg = $Container->getParameter($arg['value']);
break;
case 'variable':
$arg = $arg['value'];
break;
default:
throw new \InvalidArgumentException(sprintf('Invalid argument type %s', $arg['type']));
}
unset($arg);
}
/* 5 */
$ref = new \ReflectionClass($this->className);
$instance = $ref->newInstanceArgs($this->arguments);
}
/* 6 */
if($this->shared) {
$this->instance = &$instance;
}
/* 7 */
return $instance;
}
Krótki opis co się dzieje, krok po kroku.
- Definicja sprawdza, czy instancja jest współdzielona i czy już została stworzona - jeśli tak, zwraca ją
- Jeżeli definicja nie posiada argumentów, tworzy instancję obiektu
- Ma argumenty, więc trzeba je zebrać, na podstawie zdefiniowanych typów tworzona jest tablica argumentów przesyłanch później do konstruktora. Za każdym razem, gdy tworzona jest nowa instancja, argumenty są zbierane od nowa - na wypadek, gdyby stan któregoś z istniejących już komponentów się zmienił.
- Definicja potrafi stworzyć instancję bez użycia kontenera, pod warunkiem że żaden z argumentów nie wymaga jego istnienia - jest typu variable
- Dopiero w tym momencie tworzona jest instancja.
- Jeżeli instancja ma być współdzielona, instancja jest zapisywana do późniejszego wykorzystania
- Nowo utworzona instancja jest zwracana, koniec pracy
Skąd się biorą definicje? Skąd się komu zechce, kontener nie narzuca źródła. Może to być tablica, XML, JSON, YAML, czy cokolwiek innego
Jak pisałem wyżej, cały kod jest na GitHubie
Fajnie poczytać wreszcie o czymś konkretnym, wszędzie same oklepane tematy.
OdpowiedzDwie drobnostki przykuły moją uwagę:
- ComponentDefinition::__construct($className, $shared = false, $instance = null), może lepiej sprawdzać $className pod kątem obiektu? Wtedy trzeci parametr byłby zbędny
- $Definition
->argument('variable', 'admin')
->argument('variable', 'admin1')
->argument('variable', 'articles'); Ja jako użytkownik Twojej biblioteki, byłbym bardziej skłonny do używania metod typu setVariable($param) / setComponent($param).
To co lubię - konstruktywne uwagi.
Odpowiedz- konstruktor zmieniony, trzeci parametr zbędny
- metody także zmienione, ale na zasadzie addContainer(), addComponent($identifier), addParameter($identifier), addVariable($var)
Poprawki są już na githubie.
Ten cały DI container trochę za bardzo mi przypomina implementację wzorca Registry, który (z racji swego globalnego dostępu) jest na liście moich 'ulubionych' wzorców na miejscu drugim, zaraz po Singletonie:P
OdpowiedzTak poza tym, wydaje mi się, że zamiast tego wszystkiego mógłbyś zmienić obiekty, które mogą mieć jedną instancję na singletony :P, a do tworzenia tych, które mają być instancjonowane za każdym razem, użyłbym fabryki.
Tego typu obiekty (kontenery, rejestry itp.) zawsze mnie przerażają. Chodzi o to, że to tak naprawdę używanie zmiennych globalnych tylko, że opakowanych trochę ładniej. Wydaje mi się, że w trakcie projektowania, jeżeli wszystko odpowiednio zaplanujesz, to nie będziesz miał potrzeby korzystania z takich kontenerów. Prawda jest taka, że w dobrym projekcie nie będziesz miał obiektów, od których zależy x innych, więc takie klasy nie będą Ci potrzebne.
Taka mała uwaga do punktu 3-go metody retreive():
Jeżeli wartości parametrów mogą się zmieniać, to na co Ci taki kontener? Jeżeli tworz
Ucięło ci trochę wypowiedzi.
Odpowiedz1. Jedna instancja != singleton. Chodzi o to, ograniczenie możliwości tworzenia instancji ale nie ograniczanie możliwości tworzenia obiektów danej klasy.
Przykładowo - tworzysz połączenie z DB1 - obiekt klasy MySQLStorage, który może mieć tylko jedną instancję, a potem tworzysz obiekt - połączenie z DB2, tej samej klasy - i też z tylko jedną instancją.
Na singletonie tego nie zrobisz.
2. Owszem, jest podobny do Registry ale...
- nie jest globalnie dostępny i nie powinien być, w końcu służy do wstrzykiwania zależności a nie do uzależniania od niego - dlatego też dostępny (IMHO) powinien być jedynie w kontrolerach i modelach,
- uproszczając - DI Container jest połączeniem Registry i Factory
- prawda jest taka, że przy dobrym projekcie nie będziesz miał zależności między obiektami a interfejsami :)
- dla "single-instance" użyjesz singletonów, dla "many-instance" fabryki (lub wielu), a ja zmienię tylko definicję :)
3. Wartości parametrów nie mogą się zmieniać, tak samo nie mogą się zmieniać wcześniej zarejestrowane definicje. Jednak - mogą dochodzić nowe parametry, nowe definicje - a to ma już wpływ.
Ot choćby w takiej sytuacji gdzie kontener, tworzy połączenie z DB a później z tejże DB odczytywane są kolejne definicje.
Dalej ucięło (właściwie uciąłem nieświadomie) :) Sorry.
1) Prawda:)
Odpowiedz2) Piszesz, że "[...] powinien być [dostępny] jedynie w kontrolerach i modelach", czyli z tego co rozumiem masz jakąś klasę bazową, po której dziedziczą wszystkie/niektóre kontrolery/modele po to, aby mieć dostęp do kontenera? To jak na moje oko jest tutaj coś nie tak, bo kontener nie jest potrzebny wszędzie, a poza tym większość instancji klas będzie wymagało tylko niewielkiej jego 'części' (tzn. obiektów, które przechowuje), ale mimo wszystko będzie miała dostęp do całej rzeszy innych rzeczy.
3) "[...] tworzy połączenie z DB a później z tejże DB odczytywane są kolejne definicje". Rozumiem, że za ten odczyt są odpowiedzialne metody, a jeżeli tak, to nadal nie wiedzę powodu, dla którego dajesz możliwość manipulowania parametrami?
Taka możliwość sprawia, że obiekt może zmienić swój stan w każdym momencie i musisz wtedy albo:
- pamiętać kolejność użycia, aby przypadkiem nie użyć obiektu ze zmienionymi parametrami tam, gdzie chcesz mieć oryginalne, a że pamięć jest zawodna, to bym na tym nie polegał:)
- sprawdzać, czy obiekt posiada pożądane parametry z tym, że wtedy posiadanie DI nie przynosi już tyle pożytku
- albo usunąć możliwość manipulowania parametrami
2. Mam abstrakcyjną klasę i interfejs - w zależności od sytuacji dobieram to co mi potrzebne. Do modeli mam zestaw interfejsów. Kontroler z racji konstrukcji FW musi mieć dostęp do DI, a do modeli przekazuję już tylko konkretne potrzebne instancje.
Odpowiedz3. Czegoś nie rozumiem.
W przypadku współdzielonych instancji, po utworzeniu, nie ma możliwości utworzyć jeszcze jednej instancji pod tym samym identyfikatorem. Więc jakiekolwiek zmiany w utworzonej instancji są zależne tylko i wyłącznie od klasy tejże instancji.
W przypadku pozostałych obiektów, instancja po utworzeniu nie ma już żadnych powiązań z DI.
Jeśli zarejestrujesz ponownie definicję (obiektu/parametru) o tym samym identyfikatorze - nadpiszesz. Dokonałem świadomego wyboru. Nie musisz się z nim zgadzać.
Są sytuacje gdzie będę z tego korzystał.
Sprawdzanie czy definicja istnieje nie jest bez sensu.
Ot przykład z życia wzięty: jeśli istnieje definicja cache, niech system korzysta z cacheowania.
Inny przykład: w developerkach lubię mieć szczegółowe logi, w produkcyjnych część z nich wyłączam przez usunięcie definicji, a część zastępuję mailerami.
Grzebię tylko i wyłącznie w plikach z definicjami, nie w kodzie.
Witam serdecznie, na wstępie gratuluje dobrego, konkretnego artykułu o DI.
OdpowiedzPozwolę sobie dorzuć moje dwa spostrzeżenia.
Rozszerzenie DI poprzez ustawianie atrybutów obiektu, np.
$reflectionClass = new \ReflectionClass($this -> _componentClassName);
$componentInstance = $reflectionClass -> newInstanceWithoutConstructor();
$componentConstructor = $reflectionClass -> getConstructor();
// begin foreach(property)
$reflectionProperty = $reflectionClass->getProperty('propertyName');
$reflectionProperty -> setAccessible(TRUE);
$reflectionProperty -> setValue($componentInstance, 'propertyValue');
//end foreach
$componentInstance = $componentConstructor->invokeArgs($componentInstance ,$this -> _componentArguments);
Dla mnie jest to często wygodniejsze/estetyczniejsze rozwiązanie niż przekazywanie wszystkiego przez konstruktor.
Dla czystej wygody przekazywanie wielu argumentów dla konstruktora za pomocą jednej metody, np.
foreach ( func_get_args() as $arg )
{
$this -> addComponentProperty('DI_COMPONENT_ARGUMENT', $arg);
}
i wywołanie wyglądałoby już tak:
*->arguments(1, 2, 3, 4);