Az oktrojátor pattern és az IoC container
Az objektumorientált programnyelvekben értelemszerűen objektumokkal dolgozunk. Apróbb programok esetén, mikor nem használunk erre kitalált keretrendszert, az objektumpéldányaink menedzselése ránkmarad. Ahhoz, hogy a kódunk moduláris és tesztelhető legyen, az objektumaink függőségeit be kell “oktrojáljuk” a egymásba. Akinek a dependency injection nem tiszta, annak itt ez a cikk, mert szükség lesz rá a későbbiekben. Ez az egymásba pakolászás egy idő után eléggé komplex lehet, ezért egyes keretrendszerek, így a Laravel vagy Java nyelvben a Spring erre a célra rendelkezik egy ún. IoC containerrel. Ezekről lesz a cikkben szó.
Aki szerint nem állhat elő olyan helyzet, hogy a függőségek menedzselése macera, annak mutatnám az alábbi példát:
Van egy Repository osztályunk, aminek függősége egy Eloquent builder, ami buildernek van egy connection-je, amit egy connectionresolver-en keresztül kapott meg, ami resolver a .env fájl alapján lett felkonfigurálva. Ezen felül lesz még egy cache is, aminek a típusa szintén a .env fájlból jön és így tovább…
Ja igen, és ezt a repository-t be kellene injektálni már a kontrollerbe is, ami kontrollernek szintén vannak még más rendszer által biztosított függőségei. Erre mondá az orákulum, hogy “Jó étvágyat kívánok!”
Laravel IoC container
Laravel, ahogy több keretrendszer (nem csak PHP-ben) biztosít számunkra egy ún. service containert. Ez a container szolgál az egyes függőségeink tárolására. A konténerből többféle módon tudunk függőségeket előrántani, melyek közül jópárral már alapból felvértez a rendszer.
Typehint
Amikor egy objektumot a konténeren keresztül kapunk meg, akkor azt a rendszer előtte csodálatos reflectionnel feltérképezi és a hozzá szükséges függőségeket betölti. Az első ilyen objektum, amit a konténerből kapunk meg, az a controllerünk lesz. Ez lesz a leggyakoribb módszer, amit alkalmazunk. Nézzünk egy példát:
1
2
3
4
5
class PostController extends Controller {
public function index(PostRepository $postRepository) {
return view('index')->with('posts', $postRepository->getForMainPage());
}
}
A fenti példában, ha nem végeztünk semmiféle előzetes konfigurációt, akkor a container megpróbálja majd számunkra megkeresni a PostRepository osztályt (FQCN-re hivatkozva) és példányosítani azt. Nézzük most ezt a PostRepository osztályt!
1
2
3
4
5
6
7
8
9
class PostRepository {
private $model;
public function __construct(Post $model) {
$this->model = $model;
}
// metódusok
}
Hoppá, bajban lesz a container, mert ezt az osztályt nem lehet csak úgy példányosítani, ennek is megvan a maga függősége. De szerencsénkre a konténer, ahogy korábban volt szó róla, megkeresi a typehintelt függőségekhez tartozó osztályokat és beoktrojálja azt. Tehát helyettünk felgöngyölíti a függőségi fát és legjobb tudása szerint megpróbálja teljesíteni a kérésünket. How cool is that?
App, make
Persze nem csak így lehet a példányokhoz hozzájutni. Képzeljük el, hogy tesztelni szeretnénk a fenti repository-t, de nem kívánunk a tesztkódunkba hosszasan építgetni a függőségi fát. Szerencsénkre a TestCase osztályban elérjük az App instance-t, amin keresztül szintén le tudjuk kérni a fenti repository-t:
1
2
3
4
5
6
/** @test */
public function how_cool_is_that() {
$postRepository = $this->app->make('PostRepository'); // a make metódus fog kinyúlni a konténerbe érte
$postRepository = $this->app['PostRepository']; // ugyanaz a hatás, más syntax
// tesztkód
}
Bind
Természetesen a fenti példák rendkívül leegyszerűsítettek, mivel legtöbbször nem tudja a konténer kiszolgálni a kérésünket, vagy nem pont úgy, ahogy mi szeretnénk. Gondoljunk csak bele, ha példányosítás után szeretnénk pl. settereken át beállítani más függőségeket. Ahhoz, hogy be tudjunk regisztrálni egy service-t (vagy komponenst, az szebben hangzana), ahhoz a ServiceProvider osztályokban kell matatni. Legyen most ez az AppServiceProvider, ahol a $this->app változón át van referenciánk az applikációra:
1
2
3
4
5
6
7
8
9
public function register() {
// a PostRepository névre bekötjük a closure visszatérési értékét
$this->app->bind('PostRepository', function($app) { // paraméterként megkapjuk az applikációt
$repo = new PostRepository(); // példányosítjuk noarg konstruktorral
$repo->setCache($app['SomeCache']); // egy másik objektumot kikérünk a konténerből és átadjuk paraméterként
return $repo; // visszatérünk vele
});
}
A fenti megvalósítás, minden alkalommal mikor kérünk egy példányt újból lefut, tehát factory-ként működik.
Természetesen szükségünk lehet singleton-okra is, egy loggert például nem akarunk 10 alkalommal példányosítani, ugye?:)
1
2
3
4
5
$this->app->singleton('PostRepository', function($app) { // paraméterként megkapjuk az applikációt
$repo = new PostRepository(); // példányosítjuk noarg konstruktorral
$repo->setCache($app['SomeCache']); // egy másik objektumot kikérünk a konténerből és átadjuk paraméterként
return $repo; // visszatérünk vele
});
A fenti példányt egyszer fogja csak létrehozni, majd becache-eli a konténerben és később azzal tér vissza, ha hívjuk. Természetesen megtehetjük azt is, hogy nem Closure-t, hanem már kész objektumot adunk át második paraméterként:
1
$this->app->singleton('PostRepository', $repo);
Interface -> Implementation
Na de mi van akkor, ha valaki igényesen írta a kódját és a typehintekben nem konkrét megvalósítások vannak, hanem interfészek? Nézzünk egy példát:
1
2
3
4
5
class PostController extends Controller {
public function index(IPostRepository $postRepository) { // interfészt typehintelünk
return view('index')->with('posts', $postRepository->getForMainPage());
}
}
Lévén interfészt nem lehet példányosítani, de a rendszer mégis megpróbálja, ezért csodás 500-as hibával elszáll a kód. Hogy tudjuk ezt kikerülni? Tegyük fel, hogy csináltunk háromféle megvalósítást:
1
2
3
4
5
6
7
8
9
class SqlPostRepository implements IPostRepository {
// megvalósítás
}
class MongoPostRepository implements IPostRepository {
// megvalósítás
}
class SolrPostRepository implements IPostRepository {
// megvalósítás
}
Úgy, hogy az interfészt hozzákapcsoljuk a megvalósító osztályhoz:
1
2
3
<pre class=" language-php" data-language="php">$this->app->bind('IPostRepository', 'SqlPostRepository'); // az interfészt hozzákapcsoljuk sql adatbázis megvalósításhoz
$this->app->bind('IPostRepository', 'MongoPostRepository'); // mongohoz
$this->app->bind('IPostRepository', 'SolrPostRepository'); // vagy épp solrhez
Ezzel azt is megoldjuk, hogyha a megvalósító osztályok közt változtatni akarunk, akkor egyetlen egy sorban kell hozzányúlni a programhoz és bumm.
Persze itt jönnek majd azok a kommentek, hogy “én bizony nem láttam még olyan alkalmazást, ami alatt ki kellett cserélni a datasource-t” 🙂
Ellenben itt előjön az újabb probléma, mert lehet, hogy ugyanazt az interfészt több helyen is typehinteltük. Ez addig nem is jelent gondot, amíg mindenhol ugyanazt a megvalósítást akarjuk rá használni. Viszont ha ez változik, akkor bajban lehet…nénk, ugyanis erre is van megoldás a Laravelben (lévén az alaprendszerben is előfordulhattak ilyenek és így muszáj volt megoldani ):
A kontextushoz tudjuk kötni a típust, amit az adott typehintre a konténer szolgáltat. Tehát megmondhatjuk, hogyha A interfészt látjuk, de a megvalósítás B, akkor ne a szokásos módon resolveolja, hanem adja ide a C-t.
1
2
3
$this->app->when('App\Http\Controller\PostController') // ha a postcontrollerből kérjük, akkor
->needs('IPostRepository') // tekintsünk el a névterektről most
->give('SqlPostRepository'); // sql megvalósítást adunk
Eléggé pofonegyszerű a dolog, nem is taglalnám jobban ezt a részét.
A konténerünk amikor felold egy függőséget, akkor meghív egy eseményt, amire mi szépen felcsücsülhetünk és bele tudunk szólni az esemény példányosításába. Ez alapvetően nem tűnik nagy cuccnak, de vegyük a következő példát:
Van egy tanfolyamokkal foglalkozó oldalunk, ahol online lehet az egyes tanfolyamokra jelentkezni. Erről kap értesítést a felhasználó, az admin, valamint az adatbázisba is bekerül, értelemszerűen. A service, ami összefogja ezt a jelentkezés dolgot, legyen pl. az CourseService facade, amiben lekódoltuk az összes lépést. Ez, lévén függősége a mailer, az adatbázis, valamint az aktuális CourseOrder, a konténeren keresztül kérjük le:
1
2
3
4
5
6
7
8
9
10
11
12
13
// jöjjék hát a betonszimpla kontroller:
class CourseController extends Controller {
public function order(CourseService $service, CourseOrder $order) {
// a service szimplán példányosítva van, az ordert pedig felvettük a providerben
try {
$service->doMagic($order); // megkérjük a service-t, hogy végezze el a mágiát helyettünk
} catch (Exception $e) {
return view('done')->with('error', 'There was a disturbance in the Force'); // zavar támadt az erőben
}
return view('done'); // minden klappul ment
}
}
Na most, hogy is lesz ott nekünk az a CourseOrder példányunk? Korábban már volt róla szó, hogy a providerben fel tudjuk ezt venni:
1
2
3
4
$this->app->bind('CourseOrder', function($app) {
return CourseOrder::hydrateFromRequest($app['\Illuminate\Http\Request']);
// kikérjük a http requestet és abból szűrjük át a változókat az orderbe
});
A Service-ünk doMagicje alább néz ki:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
class CourseService {
private $logger;
public function setLogger(Logger $logger) {
$this->logger = $logger;
}
// a konstruktorba képzeljük oda a sok függőséget
public function doMagic(CourseOrder $order) {
if ($this->logger != null) {
$this->logger->debug($order->__toString()); // a toString-et overrideoltuk, így valami shiny logot tudunk belőle
}
$this->courseRepository->applyToCourse($order);
$this->mailer->sendAdminNotification($order);
$this->mailer->sendNotification($order);
}
}
Tök jól megy a bolt, jelentkeznek az emberek, de egyszer csak jelzi az ügyfél, hogy valami gixer van, ugyanis esetenként rossz értékek kerülnek kiküldésre/az adatbázisba. A tanfolyamok típusa keveredik, ezért mielőtt több ilyen történik, bekapcsoljuk a logolást, hogy lássuk mi a stájsz.
Lévén a konstruktorban nem szerepel a logger, így alapból nem hízlaljuk a fájlokat, mert a log üres. De most szeretnénk azt bekapcsolni és lehetőleg egyszerűen.
Ha a facade-ünket simán resolveolja a konténer, akkor mi nem szeretnénk beleavatkozni a dologba, ellenben mielőtt azt megkapjuk máshol, szeretnénk a loggert hozzáadni. Ezt úgy tudjuk megcsinálni, hogy feliratkozunk a resolve eseményére:
1
2
3
$this->app->resolving(function(CourseService $serv, $app) {
$serv->setLogger($app->make('Logger')); // az objektumhoz hozzáadjuk a loggert.
});
Ezáltal mikor a konténer kiszolgálja a fenti függőséget, előtte még hozzáadja a loggert. Így ha ezt később ki szeretnénk kapcsolni, csupán a providerből kell a fenti pár sort kitörölnünk.