Tutorials
Dependency Injection
Een tutorial over wat Dependency Injection is, hoe je het gebruikt, wat Dependency Injection Containers zijn en hoe de mijne eruit ziet.
Pagina 1
Inleiding
Deze website heeft mij veel geboden. Toen ik ongeveer twaalf jaar was ontdekte ik PHPHulp.nl en sindsdien heb ik er veel geleerd over PHP, websites en programmeren in het algemeen. Nu ben ik wat verder in mijn kennis en wordt het, zeker nu Niels mij als 'OOP-guru' bestempeld heeft ;), tijd dat ik wat terugdoe. Vandaar deze tutorial.
'Dependency injection' klinkt magisch, maar zo ingewikkeld is het niet. Het is een methode/'design pattern' die je gebruikt om verantwoordelijkheden te scheiden, afhankelijkheden te structureren en de (Unit)testbaarheid van je code te vergroten.
Eerst zal ik het patroon zelf beschrijven. Dan zal ik de 'Dependency Injection Container' of 'Service Container', een object dat de afhankelijkheden beheert, introduceren. Vervolgens laat ik jullie zien hoe ik bij mijn eigen implementatie ben gekomen.
Ik hoop dat ik alles duidelijk beschreven heb. Mocht iemand vragen of opmerkingen hebben, hoor ik die graag. Een basiskennis van OO PHP is vereist.
De tutorial is gebaseerd op deze van Fabien Potencier, de maker van het Symfony framework. Mijn container is geïnspireerd op Pimple van dezelfde auteur.
Groet,
Pim
'Dependency injection' klinkt magisch, maar zo ingewikkeld is het niet. Het is een methode/'design pattern' die je gebruikt om verantwoordelijkheden te scheiden, afhankelijkheden te structureren en de (Unit)testbaarheid van je code te vergroten.
Eerst zal ik het patroon zelf beschrijven. Dan zal ik de 'Dependency Injection Container' of 'Service Container', een object dat de afhankelijkheden beheert, introduceren. Vervolgens laat ik jullie zien hoe ik bij mijn eigen implementatie ben gekomen.
Ik hoop dat ik alles duidelijk beschreven heb. Mocht iemand vragen of opmerkingen hebben, hoor ik die graag. Een basiskennis van OO PHP is vereist.
De tutorial is gebaseerd op deze van Fabien Potencier, de maker van het Symfony framework. Mijn container is geïnspireerd op Pimple van dezelfde auteur.
Groet,
Pim
Pagina 2
Dependency Injection
Ik zal het begrip 'Dependency Injection' introduceren met behulp van een voorbeeld, dat van een object geörienteerd session opslagsysteem.
Stel we de taal van de website willen opslaan in een sessievariabele, doen we dat als volgt:
<?php
$_SESSION['language'] = 'nl';
?>
Opvragen gaat dan zo:
<?php
$language = $_SESSION['language'];
?>
Wanneer we dit opslagmechanisme dan herschrijven in een object geörienteerde wijze krijg je iets als:
<?php
class SessionStorage
{
public function __construct($cookieName = 'PHP_SESS_ID')
{
session_name($cookieName);
session_start();
}
public function set($key, $value)
{
$_SESSION[$key] = $value;
}
public function get($key)
{
return $_SESSION[$key];
}
}
?>
Een User klasse kan dit dan gebruiken om de taal op te slaan:
<?php
class User
{
protected $storage;
public function __construct()
{
$this->storage = new SessionStorage();
}
public function setLanguage($language)
{
$this->storage->set('language', $language);
}
public function getLanguage()
{
return $this->storage->get('language');
}
// ...
}
?>
Het gebruik gaat dan als volgt:
<?php
$user = new User();
$user->setLanguage('nl');
$userLanguage = $user->getLanguage();
?>
Maar wat nu als we meer flexibiliteit willen en de naam van de cookie naam willen kunnen veranderen? Je kan dan bijvoorbeeld:
- De cookie naam hardcoden in de User klasse:
<?php
class User
{
public function __construct()
{
$this->storage = new SessionStorage('DIT_IS_EEN_NAAM');
}
// ...
}
?>
- Een constante gebruiken:
<?php
define('STORAGE_SESSION_NAME', 'SESSION_ID');
class User
{
public function __construct()
{
$this->storage = new SessionStorage(STORAGE_SESSION_NAME);
}
// ...
}
?>
- De cookie naam met de constructor meegeven:
<?php
class User
{
public function __construct($sessionName)
{
$this->storage = new SessionStorage($sessionName);
}
// ...
}
$user = new User('SESSION_ID');
?>
- Een array met opties aan de constructor meegeven:
<?php
class User
{
public function __construct($storageOptions)
{
$this->storage = new SessionStorage($storageOptions['session_name']);
}
// ...
}
$user = new User(array('session_name' => 'SESSION_ID'));
?>
Maar al deze methoden zijn verre van ideaal. Hardcoden in de User klasse maakt het geheel niet flexibeler en een constante voegt een afhankelijkheid toe. De naam megeven (al dan niet in een array) aan de constructor is dan nog het beste, maar als de applicatie dan complexer wordt, wordt het een chaos van meegegeven parameters. En wat als we de naam van de SessionStorage klasse willen aanpassen en een subklasse of een mock (een nep-object dat bij testen wordt gebruik) willen gebruiken? Dat is nu onmogelijk.
De oplossing is: Dependency Injection, het injecteren van de afhankelijkheid. In plaats van dat we SessionStorage (de afhankelijkheid) creëren in het User object, injecteren we het in de User klasse, bijvoorbeeld in de constructor.
<?php
class User
{
public function __construct($storage)
{
$this->storage = $storage;
}
// ...
}
?>
En dat is dus Dependency Injection. Simpel.
De User klasse instantiëren is nu iets ingewikkelder:
<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User($storage);
?>
Configuratie is nu makkelijk, evenals het vervangen van de SessionStorage klasse. De User klasse hoeft hier niet voor te worden aangepast en de verantwoordelijkheden zijn nu beter gescheiden.
De afhankelijkheden kunnen ook anders dan via de constructor worden meegegeven:
- Setter injectie:
<?php
class User
{
public function setSessionStorage($storage)
{
$this->storage = $storage;
}
// ...
}
?>
- Property injectie:
<?php
class User
{
public $sessionStorage;
}
$user->sessionStorage = $storage;
?>
Meestal wordt constructor injectie gebruikt voor noodzakelijke afhankelijkeheden en setter injectie voor optionele afhankelijkheden. Property injectie gebruikt public properties en wordt daarom vaak als slordig gezien.
Stel we de taal van de website willen opslaan in een sessievariabele, doen we dat als volgt:
<?php
$_SESSION['language'] = 'nl';
?>
Opvragen gaat dan zo:
<?php
$language = $_SESSION['language'];
?>
Wanneer we dit opslagmechanisme dan herschrijven in een object geörienteerde wijze krijg je iets als:
<?php
class SessionStorage
{
public function __construct($cookieName = 'PHP_SESS_ID')
{
session_name($cookieName);
session_start();
}
public function set($key, $value)
{
$_SESSION[$key] = $value;
}
public function get($key)
{
return $_SESSION[$key];
}
}
?>
Een User klasse kan dit dan gebruiken om de taal op te slaan:
<?php
class User
{
protected $storage;
public function __construct()
{
$this->storage = new SessionStorage();
}
public function setLanguage($language)
{
$this->storage->set('language', $language);
}
public function getLanguage()
{
return $this->storage->get('language');
}
// ...
}
?>
Het gebruik gaat dan als volgt:
<?php
$user = new User();
$user->setLanguage('nl');
$userLanguage = $user->getLanguage();
?>
Maar wat nu als we meer flexibiliteit willen en de naam van de cookie naam willen kunnen veranderen? Je kan dan bijvoorbeeld:
- De cookie naam hardcoden in de User klasse:
<?php
class User
{
public function __construct()
{
$this->storage = new SessionStorage('DIT_IS_EEN_NAAM');
}
// ...
}
?>
- Een constante gebruiken:
<?php
define('STORAGE_SESSION_NAME', 'SESSION_ID');
class User
{
public function __construct()
{
$this->storage = new SessionStorage(STORAGE_SESSION_NAME);
}
// ...
}
?>
- De cookie naam met de constructor meegeven:
<?php
class User
{
public function __construct($sessionName)
{
$this->storage = new SessionStorage($sessionName);
}
// ...
}
$user = new User('SESSION_ID');
?>
- Een array met opties aan de constructor meegeven:
<?php
class User
{
public function __construct($storageOptions)
{
$this->storage = new SessionStorage($storageOptions['session_name']);
}
// ...
}
$user = new User(array('session_name' => 'SESSION_ID'));
?>
Maar al deze methoden zijn verre van ideaal. Hardcoden in de User klasse maakt het geheel niet flexibeler en een constante voegt een afhankelijkheid toe. De naam megeven (al dan niet in een array) aan de constructor is dan nog het beste, maar als de applicatie dan complexer wordt, wordt het een chaos van meegegeven parameters. En wat als we de naam van de SessionStorage klasse willen aanpassen en een subklasse of een mock (een nep-object dat bij testen wordt gebruik) willen gebruiken? Dat is nu onmogelijk.
De oplossing is: Dependency Injection, het injecteren van de afhankelijkheid. In plaats van dat we SessionStorage (de afhankelijkheid) creëren in het User object, injecteren we het in de User klasse, bijvoorbeeld in de constructor.
<?php
class User
{
public function __construct($storage)
{
$this->storage = $storage;
}
// ...
}
?>
En dat is dus Dependency Injection. Simpel.
De User klasse instantiëren is nu iets ingewikkelder:
<?php
$storage = new SessionStorage('SESSION_ID');
$user = new User($storage);
?>
Configuratie is nu makkelijk, evenals het vervangen van de SessionStorage klasse. De User klasse hoeft hier niet voor te worden aangepast en de verantwoordelijkheden zijn nu beter gescheiden.
De afhankelijkheden kunnen ook anders dan via de constructor worden meegegeven:
- Setter injectie:
<?php
class User
{
public function setSessionStorage($storage)
{
$this->storage = $storage;
}
// ...
}
?>
- Property injectie:
<?php
class User
{
public $sessionStorage;
}
$user->sessionStorage = $storage;
?>
Meestal wordt constructor injectie gebruikt voor noodzakelijke afhankelijkeheden en setter injectie voor optionele afhankelijkheden. Property injectie gebruikt public properties en wordt daarom vaak als slordig gezien.
Pagina 3
Dependency Injection Container
Het instantieëren van een object kan uiteindelijk vrij veel code vereisen. Een instantie van Zend_Mail kan je bijvoorbeeld als volgt maken:
<?php
$transport = new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($transport);
?>
Dat is vrij veel code om elke keer als je de mailer nodig hebt te gebruiken. Een Dependency Injection Container beheert dergelijke objecten, vanaf nu ook wel services geheten. Services zijn dus kant-en-klare objecten die de container voor je kan maken. Een Dependency Injection Container wordt daarom ook wel een Service container genoemd. De taak van de container is dus om services op te bouwen en te retourneren.
Een hardcoded container voor bovenstaand voorbeeld kan er als volgt uitzien:
<?php
class Container
{
public function getMailTransport()
{
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
}
public function getMailer()
{
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
?>
Merk op hoe de verschillende afhankelijkheden ook verschillende methoden hebben.
Het gebruik is makkelijk:
<?php
$container = new Container();
$mailer = $container->getMailer();
?>
We kunnen nu aan de container vragen om voor ons een mailer te creëren, zonder iets te weten over de configuratie. De verantwoordelijkheden zijn zo mooi gescheiden.
De container is nu echter nog weinig flexibel. Dit kan opgelost worden door de container parameters mee te geven:
<?php
class Container
{
protected $parameters = array();
public function __construct(array $parameters = array())
{
$this->parameters = $parameters;
}
public function getMailTransport()
{
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => $this->parameters['mailer.username'],
'password' => $this->parameters['mailer.password'],
'ssl' => 'ssl',
'port' => 465,
));
}
public function getMailer()
{
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
}
?>
Nu kunnen gemakkelijk de gebruikersnaam en het wachtwoord worden aangepast:
<?php
$container = new Container(array(
'mailer.username' => 'foo',
'mailer.password' => 'bar',
));
$mailer = $container->getMailer();
?>
En stel dat we de klasse van de mailer willen kunnen aanpassen, kunnen we ook voor de klassenaam een parameter gebruiken:
<?php
class Container
{
// ...
public function getMailer()
{
$class = $this->parameters['mailer.class'];
$mailer = new $class();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
}
$container = new Container(array(
'mailer.username' => 'foo',
'mailer.password' => 'bar',
'mailer.class' => 'Zend_Mail',
));
$mailer = $container->getMailer();
?>
En bovendien kan je de mailer service hergebruiken:
<?php
class Container
{
protected $shared = array();
// ...
public function getMailer()
{
if (isset($this->shared['mailer']))
{
return $this->shared['mailer'];
}
$class = $this->parameters['mailer.class'];
$mailer = new $class();
$mailer->setDefaultTransport($this->getMailTransport());
return $this->shared['mailer'] = $mailer;
}
}
?>
De eerste keer dat er een mailer wordt aangemaakt, wordt het object opgeslagen in de $shared property van de container. De tweede keer wordt dan dit object teruggegeven, in plaats van dat er een nieuwe wordt aangemaakt.
De Dependency Injection Container neemt dus de instantiatie en configuratie van services voor zijn rekening. Deze objecten zelf hoeven zich niet van de container bewust te zijn. Zo kan elk PHP object een service worden.
<?php
$transport = new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($transport);
?>
Dat is vrij veel code om elke keer als je de mailer nodig hebt te gebruiken. Een Dependency Injection Container beheert dergelijke objecten, vanaf nu ook wel services geheten. Services zijn dus kant-en-klare objecten die de container voor je kan maken. Een Dependency Injection Container wordt daarom ook wel een Service container genoemd. De taak van de container is dus om services op te bouwen en te retourneren.
Een hardcoded container voor bovenstaand voorbeeld kan er als volgt uitzien:
<?php
class Container
{
public function getMailTransport()
{
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
}
public function getMailer()
{
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
?>
Merk op hoe de verschillende afhankelijkheden ook verschillende methoden hebben.
Het gebruik is makkelijk:
<?php
$container = new Container();
$mailer = $container->getMailer();
?>
We kunnen nu aan de container vragen om voor ons een mailer te creëren, zonder iets te weten over de configuratie. De verantwoordelijkheden zijn zo mooi gescheiden.
De container is nu echter nog weinig flexibel. Dit kan opgelost worden door de container parameters mee te geven:
<?php
class Container
{
protected $parameters = array();
public function __construct(array $parameters = array())
{
$this->parameters = $parameters;
}
public function getMailTransport()
{
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => $this->parameters['mailer.username'],
'password' => $this->parameters['mailer.password'],
'ssl' => 'ssl',
'port' => 465,
));
}
public function getMailer()
{
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
}
?>
Nu kunnen gemakkelijk de gebruikersnaam en het wachtwoord worden aangepast:
<?php
$container = new Container(array(
'mailer.username' => 'foo',
'mailer.password' => 'bar',
));
$mailer = $container->getMailer();
?>
En stel dat we de klasse van de mailer willen kunnen aanpassen, kunnen we ook voor de klassenaam een parameter gebruiken:
<?php
class Container
{
// ...
public function getMailer()
{
$class = $this->parameters['mailer.class'];
$mailer = new $class();
$mailer->setDefaultTransport($this->getMailTransport());
return $mailer;
}
}
$container = new Container(array(
'mailer.username' => 'foo',
'mailer.password' => 'bar',
'mailer.class' => 'Zend_Mail',
));
$mailer = $container->getMailer();
?>
En bovendien kan je de mailer service hergebruiken:
<?php
class Container
{
protected $shared = array();
// ...
public function getMailer()
{
if (isset($this->shared['mailer']))
{
return $this->shared['mailer'];
}
$class = $this->parameters['mailer.class'];
$mailer = new $class();
$mailer->setDefaultTransport($this->getMailTransport());
return $this->shared['mailer'] = $mailer;
}
}
?>
De eerste keer dat er een mailer wordt aangemaakt, wordt het object opgeslagen in de $shared property van de container. De tweede keer wordt dan dit object teruggegeven, in plaats van dat er een nieuwe wordt aangemaakt.
De Dependency Injection Container neemt dus de instantiatie en configuratie van services voor zijn rekening. Deze objecten zelf hoeven zich niet van de container bewust te zijn. Zo kan elk PHP object een service worden.
Pagina 4
Pcms container in opbouw - 1
Een nadeel van de eerder beschreven Dependency Injection Container is dat hij nog steeds weinig flexibel is. Een nieuwe service toevoegen vereist een aanpassing in de Container klasse. Mooier zou het zijn als een ander object de container zou kunnen configureren. De container moet daarvoor dynamisch worden. De services moeten via calls geconfigureerd worden.
Een mooi simpel voorbeeld van zo'n dynamische container is Pimple. Omdat ik hem iets te simpel vind, hij niet mooi om kan gaan met uitbreidingen op services en ik toch wel een beetje narcistisch ben, zal ik de container bespreken die ik geschreven heb als basis van mijn nog te voltooien (mocht dat lukken) cms, genaamd Pcms :). (Een koekje voor degene die kan bedenken hoe ik op die naam ben gekomen)
Ik zal nu stapje voor stapje bespreken hoe ik tot het eindresultaat ben gekomen.
De kern is als volgt:
<?php
class Container
{
protected $values;
public function set($key, $value)
{
$this->values[$key] = $value;
}
public function get($key)
{
// Check of de waarde bestaat
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// Als het om een service gaat
if(is_callable($value))
return $value($this);
// Als het om een parameter gaat
else
return $value;
}
}
?>
De container bevat een array met waarden. Met de set() functie kunnen die ingesteld worden. Twee typen waarden zijn toegestaan:
- Parameters
Dit zijn gewoon waarden zoals string, ints en arrays, die als configuratie voor de services dienen. Ze worden in de get() functie gewoon onveranderd teruggegeven.
- Factories
Dit zijn php callables of callbacks. Oftwel waarden die uitgevoerd kunnen worden met $var(). Dit kan een string met de naam van een functie zijn, een object dat __invoke() implementeerd of een array met ($object, 'methodenaam') en nog een paar mogelijkheden. Ik gebruik vooral de Closure oftewel anonymous function.
$var = function() { return 'test'; };
echo $var(); // geeft: test
Zie voor de details bijvoorbeeld hier.
De closure is dan hier een factory, een methode die bedoeld is om een object aan te maken en ziet er als volgt uit:
<?php
$factory = function() {
return new Zend_Mail();
};
?>
Maar een factory kan ook het object configureren. Het eerder beschreven Zend_Mail object krijg je zo:
<?php
$factory = function() {
$transport = new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($transport);
return $mailer;
};
$mailer = $factory();
?>
Het is dus mogelijk om zo'n factory bij set() mee te geven, bijvoorbeeld zo:
<?php
$container = new Container();
$container->set('mailer', function() {
return new Zend_Mailer();
};
$mailer = $container->get('mailer');
?>
In de get() functie wordt $this als argument meegegeven aan de factory. Deze beschikt dus over de container en kan zo beschikken over andere services of parameters.
Het volledige Zend_Mail object kan dan zo gemaakt worden:
<?php
$c = new Container();
// Stel de parameters in
$c->set('mailer.username', 'foo');
$c->set('mailer.password', 'bar');
$c->set('mailer.class', 'Zend_Mail');
// Stel de transport service in
$c->set('mailer.transport', function($c) {
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => $c->get('mailer.username'),
'password' => $c->get('mailer.password'),
'ssl' => 'ssl',
'port' => 465,
));
});
// Stel de mailer service in
$c->set('mailer', function($c) {
$class = $c->get('mailer.class');
$mailer = new $class();
$mailer->setDefaultTransport($c->get('mailer.transport'));
return $mailer;
});
// De mailer roep je dan zo aan:
$mailer = $c->get('mailer');
?>
Merk ook op dat de services 'lazy' gemaakt worden. De factory wordt pas aangeroepen in de get() functie en dus pas wanneer de service nodig is.
Nu kan je gedeelde serivices implementeren op dezelfde wijze als in de hardcoded container:
<?php
class Container
{
protected $values = array();
protected $shared = array();
protected $instances = array();
public function set($key, $value, $shared = false)
{
$this->values[$key] = $value;
$this->shared[$key] = $shared;
}
public function get($key)
{
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// Als het een service betreft
if(is_callable($value)) {
// Als de service gedeeld is en al eerder opgebouwd
if($this->shared[$key] && isset($this->instances[$key]))
return $this->instances[$key];
// Creëer de service door de factor aan te roepen
$instance = $value($this);
// Sla gedeelde services op
if($this->shared[$key])
$this->instances[$key] = $instance;
return $instance;
// Als het een parameter betreft
} else {
return $value;
}
}
// Maak een gedeelde service niet-gedeeld of vice versa
public function setShared($key, $shared)
{
$this->shared[$key] = $shared;
}
}
?>
Een gedeelde service maak je dan zo:
<?php
$c = new Container();
$c->set('mailer', function() {
return new Zend_Mailer();
}, true);
?>
Een mooi simpel voorbeeld van zo'n dynamische container is Pimple. Omdat ik hem iets te simpel vind, hij niet mooi om kan gaan met uitbreidingen op services en ik toch wel een beetje narcistisch ben, zal ik de container bespreken die ik geschreven heb als basis van mijn nog te voltooien (mocht dat lukken) cms, genaamd Pcms :). (Een koekje voor degene die kan bedenken hoe ik op die naam ben gekomen)
Ik zal nu stapje voor stapje bespreken hoe ik tot het eindresultaat ben gekomen.
De kern is als volgt:
<?php
class Container
{
protected $values;
public function set($key, $value)
{
$this->values[$key] = $value;
}
public function get($key)
{
// Check of de waarde bestaat
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// Als het om een service gaat
if(is_callable($value))
return $value($this);
// Als het om een parameter gaat
else
return $value;
}
}
?>
De container bevat een array met waarden. Met de set() functie kunnen die ingesteld worden. Twee typen waarden zijn toegestaan:
- Parameters
Dit zijn gewoon waarden zoals string, ints en arrays, die als configuratie voor de services dienen. Ze worden in de get() functie gewoon onveranderd teruggegeven.
- Factories
Dit zijn php callables of callbacks. Oftwel waarden die uitgevoerd kunnen worden met $var(). Dit kan een string met de naam van een functie zijn, een object dat __invoke() implementeerd of een array met ($object, 'methodenaam') en nog een paar mogelijkheden. Ik gebruik vooral de Closure oftewel anonymous function.
$var = function() { return 'test'; };
echo $var(); // geeft: test
Zie voor de details bijvoorbeeld hier.
De closure is dan hier een factory, een methode die bedoeld is om een object aan te maken en ziet er als volgt uit:
<?php
$factory = function() {
return new Zend_Mail();
};
?>
Maar een factory kan ook het object configureren. Het eerder beschreven Zend_Mail object krijg je zo:
<?php
$factory = function() {
$transport = new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => 'foo',
'password' => 'bar',
'ssl' => 'ssl',
'port' => 465,
));
$mailer = new Zend_Mail();
$mailer->setDefaultTransport($transport);
return $mailer;
};
$mailer = $factory();
?>
Het is dus mogelijk om zo'n factory bij set() mee te geven, bijvoorbeeld zo:
<?php
$container = new Container();
$container->set('mailer', function() {
return new Zend_Mailer();
};
$mailer = $container->get('mailer');
?>
In de get() functie wordt $this als argument meegegeven aan de factory. Deze beschikt dus over de container en kan zo beschikken over andere services of parameters.
Het volledige Zend_Mail object kan dan zo gemaakt worden:
<?php
$c = new Container();
// Stel de parameters in
$c->set('mailer.username', 'foo');
$c->set('mailer.password', 'bar');
$c->set('mailer.class', 'Zend_Mail');
// Stel de transport service in
$c->set('mailer.transport', function($c) {
return new Zend_Mail_Transport_Smtp('smtp.gmail.com', array(
'auth' => 'login',
'username' => $c->get('mailer.username'),
'password' => $c->get('mailer.password'),
'ssl' => 'ssl',
'port' => 465,
));
});
// Stel de mailer service in
$c->set('mailer', function($c) {
$class = $c->get('mailer.class');
$mailer = new $class();
$mailer->setDefaultTransport($c->get('mailer.transport'));
return $mailer;
});
// De mailer roep je dan zo aan:
$mailer = $c->get('mailer');
?>
Merk ook op dat de services 'lazy' gemaakt worden. De factory wordt pas aangeroepen in de get() functie en dus pas wanneer de service nodig is.
Nu kan je gedeelde serivices implementeren op dezelfde wijze als in de hardcoded container:
<?php
class Container
{
protected $values = array();
protected $shared = array();
protected $instances = array();
public function set($key, $value, $shared = false)
{
$this->values[$key] = $value;
$this->shared[$key] = $shared;
}
public function get($key)
{
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// Als het een service betreft
if(is_callable($value)) {
// Als de service gedeeld is en al eerder opgebouwd
if($this->shared[$key] && isset($this->instances[$key]))
return $this->instances[$key];
// Creëer de service door de factor aan te roepen
$instance = $value($this);
// Sla gedeelde services op
if($this->shared[$key])
$this->instances[$key] = $instance;
return $instance;
// Als het een parameter betreft
} else {
return $value;
}
}
// Maak een gedeelde service niet-gedeeld of vice versa
public function setShared($key, $shared)
{
$this->shared[$key] = $shared;
}
}
?>
Een gedeelde service maak je dan zo:
<?php
$c = new Container();
$c->set('mailer', function() {
return new Zend_Mailer();
}, true);
?>
Pagina 5
Pcms container in opbouw - 2
Nu zal ik het mogelijk maken om de container te laten uitbreiden met de definities uit andere containers. Dit is niet nodig voor eenvoudig gebruik, maar wel voor een modulaire applicatie. Deze pagina is dus niet noodzakelijk, misschien wel een leuke bonus.
<?php
class Container
{
// ...
public function extend(Container $c)
{
$c->applyValuesTo($this);
}
public function applyValuesTo(Container $c)
{
foreach($this->values as $key => $value) {
$c->set($key, $value, $this->shared[$key]);
}
}
}
$hoofdContainer = new Container();
$subContainer = new Container();
$subContainer->set('mailer', function() {
return new Zend_Mailer();
}, true);
$hoofdContainer->extend($subContainer);
$mailer = $hoofdContainer->get('mailer');
?>
Ik denk dat de code wel voor zich spreekt. Met de functie extend() kan een container zijn waarden uitbreiden met de waarden van een andere container. De functie applyValuesTo() is een hulp functie die de eigen waarden bij de andere container registreert. Deze constructie voorkomt een getAll() methode die mijns inziens de interne data te veel blootstelt.
Maar wat als een subcontainer een factory wil verfijnen om bijvoorbeeld een extra afhankelijkheid te injecteren in de service.
<?php
$c->set('service', function($c) {
$service = $c->get('service');
$service->setExtraDependency(new Dependency());
return $service;
});
?>
Dit creëert een cirkel-referentie omdat $c->get('service') de nieuwe factory zal teruggeven. De nieuwe factory word namelijk pas later aangeroepen als de oude factory al is overschreven.
Ik gebruik daarom (de door mij bedachte) 'configurators', callables die worden aangeroepen nadat de service door de oude factory is gemaakt en die de service nog kunnen aanpassen.
<?php
class Container
{
// ...
protected $configurators = array();
public function configure($key, $configurator)
{
if(!is_callable($configurator))
throw new \InvalidArgumentException('The configurator should be a callable');
$this->configurators[$key][] = $configurator;
}
public function get($key)
{
$value = $this->values[$key];
// Als het om een service gaat
if(is_callable($value)) {
$instance = $value($this);
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator) {
$instance = $configurator($instance, $this);
}
return $instance;
// Als het om een parameter gaat
else
return $value;
}
}
?>
Het bovenstaande niet werkende voorbeeld kan nu herschreven worden als:
<?php
$c->configure('service', function($service, $c) {
$service->setExtraDependency(new Dependency());
return $service;
});
?>
Zo is het mogelijk om een container uit te breiden met nieuwe parameters en factories evenals bestaande factories te verfijnen.
<?php
class Container
{
// ...
public function extend(Container $c)
{
$c->applyValuesTo($this);
}
public function applyValuesTo(Container $c)
{
foreach($this->values as $key => $value) {
$c->set($key, $value, $this->shared[$key]);
}
}
}
$hoofdContainer = new Container();
$subContainer = new Container();
$subContainer->set('mailer', function() {
return new Zend_Mailer();
}, true);
$hoofdContainer->extend($subContainer);
$mailer = $hoofdContainer->get('mailer');
?>
Ik denk dat de code wel voor zich spreekt. Met de functie extend() kan een container zijn waarden uitbreiden met de waarden van een andere container. De functie applyValuesTo() is een hulp functie die de eigen waarden bij de andere container registreert. Deze constructie voorkomt een getAll() methode die mijns inziens de interne data te veel blootstelt.
Maar wat als een subcontainer een factory wil verfijnen om bijvoorbeeld een extra afhankelijkheid te injecteren in de service.
<?php
$c->set('service', function($c) {
$service = $c->get('service');
$service->setExtraDependency(new Dependency());
return $service;
});
?>
Dit creëert een cirkel-referentie omdat $c->get('service') de nieuwe factory zal teruggeven. De nieuwe factory word namelijk pas later aangeroepen als de oude factory al is overschreven.
Ik gebruik daarom (de door mij bedachte) 'configurators', callables die worden aangeroepen nadat de service door de oude factory is gemaakt en die de service nog kunnen aanpassen.
<?php
class Container
{
// ...
protected $configurators = array();
public function configure($key, $configurator)
{
if(!is_callable($configurator))
throw new \InvalidArgumentException('The configurator should be a callable');
$this->configurators[$key][] = $configurator;
}
public function get($key)
{
$value = $this->values[$key];
// Als het om een service gaat
if(is_callable($value)) {
$instance = $value($this);
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator) {
$instance = $configurator($instance, $this);
}
return $instance;
// Als het om een parameter gaat
else
return $value;
}
}
?>
Het bovenstaande niet werkende voorbeeld kan nu herschreven worden als:
<?php
$c->configure('service', function($service, $c) {
$service->setExtraDependency(new Dependency());
return $service;
});
?>
Zo is het mogelijk om een container uit te breiden met nieuwe parameters en factories evenals bestaande factories te verfijnen.
Pagina 6
De Pcms container
Het eindresultaat is dan:
<?php
namespace Pcms;
class Container
{
/**
* The stored values
* @var array
*/
protected $values = array();
/**
* Whether the services should be shared amoung calls
* @var array
*/
protected $shared = array();
/**
* The instances of shared services
* @var array
*/
protected $instances = array();
/**
* Contains an array of configurators for each service
* @see configure()
* @var array
*/
protected $configurators = array();
public function __construct()
{
$this->setUp();
}
/**
* Allows subclasses to setup itself
*/
protected function setUp()
{
}
/**
* Set a service or parameter
* @param string $key The identifier
* @param mixed|callable $value The callable (in case of a service) or value (in case of a parameter)
* @param boolean $shared Whether the service instance should be shared
*/
public function set($key, $value, $shared = false)
{
$this->values[$key] = $value;
$this->shared[$key] = $shared;
}
/**
*
* @param string $key The identifier
* @return mixed The parameter value or service instance
*/
public function get($key)
{
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// If value is a service
if(is_callable($value)) {
// If service is shared and already instanciated, return instance
if($this->shared[$key] && isset($this->instances[$key]))
return $this->instances[$key];
// Call the closure and create the instance
$instance = $value($this);
// If any configurators are set, apply each to the instance
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator) {
$instance = $configurator($instance, $this);
}
// Store shared services
if($this->shared[$key])
$this->instances[$key] = $instance;
return $instance;
// If value is a parameter
} else {
return $value;
}
}
/**
* Set wheter the service should be shared
* @param string $key
* @param boolean $shared
*/
public function setShared($key, $shared)
{
$this->shared[$key] = $shared;
}
/**
* Set an array of parameters
* @param array $array
*/
public function setParameterArray(array $array)
{
foreach($array as $key => $value)
{
$this->values[$key] = $value;
$this->shared[$key] = false;
}
}
/**
* Extend container with all values of the given container
* @param Container $c The container whose values should be set
*/
public function extend(Container $c)
{
$c->applyValuesTo($this);
}
/**
* Set all values at given container. Helper function for extend()
* @see extend()
* @param Container $c The container at which the values should be set
*/
public function applyValuesTo(Container $c)
{
foreach($this->values as $key => $value) {
$c->set($key, $value, $this->shared[$key]);
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator)
$c->configure($key, $configurator);
}
}
/**
* Configure a service. The $configurator callable will be aplied to the instance after creation.
* @param string $key
* @param callable $configurator
*/
public function configure($key, $configurator)
{
if(!is_callable($configurator))
throw new \InvalidArgumentException('The configurator should be a callable');
$this->configurators[$key][] = $configurator;
}
}
?>
<?php
namespace Pcms;
class Container
{
/**
* The stored values
* @var array
*/
protected $values = array();
/**
* Whether the services should be shared amoung calls
* @var array
*/
protected $shared = array();
/**
* The instances of shared services
* @var array
*/
protected $instances = array();
/**
* Contains an array of configurators for each service
* @see configure()
* @var array
*/
protected $configurators = array();
public function __construct()
{
$this->setUp();
}
/**
* Allows subclasses to setup itself
*/
protected function setUp()
{
}
/**
* Set a service or parameter
* @param string $key The identifier
* @param mixed|callable $value The callable (in case of a service) or value (in case of a parameter)
* @param boolean $shared Whether the service instance should be shared
*/
public function set($key, $value, $shared = false)
{
$this->values[$key] = $value;
$this->shared[$key] = $shared;
}
/**
*
* @param string $key The identifier
* @return mixed The parameter value or service instance
*/
public function get($key)
{
if(!isset($this->values[$key]))
throw new \InvalidArgumentException(sprintf(
"Value %s has not been set",
$key
));
$value = $this->values[$key];
// If value is a service
if(is_callable($value)) {
// If service is shared and already instanciated, return instance
if($this->shared[$key] && isset($this->instances[$key]))
return $this->instances[$key];
// Call the closure and create the instance
$instance = $value($this);
// If any configurators are set, apply each to the instance
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator) {
$instance = $configurator($instance, $this);
}
// Store shared services
if($this->shared[$key])
$this->instances[$key] = $instance;
return $instance;
// If value is a parameter
} else {
return $value;
}
}
/**
* Set wheter the service should be shared
* @param string $key
* @param boolean $shared
*/
public function setShared($key, $shared)
{
$this->shared[$key] = $shared;
}
/**
* Set an array of parameters
* @param array $array
*/
public function setParameterArray(array $array)
{
foreach($array as $key => $value)
{
$this->values[$key] = $value;
$this->shared[$key] = false;
}
}
/**
* Extend container with all values of the given container
* @param Container $c The container whose values should be set
*/
public function extend(Container $c)
{
$c->applyValuesTo($this);
}
/**
* Set all values at given container. Helper function for extend()
* @see extend()
* @param Container $c The container at which the values should be set
*/
public function applyValuesTo(Container $c)
{
foreach($this->values as $key => $value) {
$c->set($key, $value, $this->shared[$key]);
if(isset($this->configurators[$key]))
foreach($this->configurators[$key] as $configurator)
$c->configure($key, $configurator);
}
}
/**
* Configure a service. The $configurator callable will be aplied to the instance after creation.
* @param string $key
* @param callable $configurator
*/
public function configure($key, $configurator)
{
if(!is_callable($configurator))
throw new \InvalidArgumentException('The configurator should be a callable');
$this->configurators[$key][] = $configurator;
}
}
?>
Pagina 7
Conclusie
Ik hoop dat ik duidelijk en volledig ben geweest en ik tenminste iemand iets heb kunnen leren. Mocht iets niet helemaal helder zijn, sta ik open voor vragen.
Als mijn vakantie nog wat dagen over heeft ben ik, mits deze tut een beetje goed ontvangen wordt, bereid nog een tutorial te schrijven over Unit testing of Symfony2. Zeg het maar :)
En trouwens: Nog een gelukkig 2012.
Pim
Als mijn vakantie nog wat dagen over heeft ben ik, mits deze tut een beetje goed ontvangen wordt, bereid nog een tutorial te schrijven over Unit testing of Symfony2. Zeg het maar :)
En trouwens: Nog een gelukkig 2012.
Pim
Reacties
0