La mayoría de los proyectos ya existen. ¿Que quiero decir con esto? Bueno, creo que la mayoría de desarrolladores en la era de las grandes documentaciones simplemente abusan de la superficialidad que estas nos permiten. Y creo que esto puede estar bien en cierto modo, pero también creo que son las grandes implementaciones las que, en última instancia, nos hacen crecer como profesionales.
Es por esto que en la documentación del proyecto que quiero pseudodocumentar hoy empieza con una de las frases más conocidas por el homo-developer. "Oh, no way... another Dependency Injection container written in PHP?". Pues me temo que si, y siento defraudar a la gente que espere que este DIC tenga más features, y más chulas o modernas que las ya existentes. This is not gonna happen.
Lo que si prometo es conocimiento mediante implementación, uno de los grandes recursos que tenemos como profesionales y una de las técnicas más desaprovechadas de nuestro entorno. Quiero explicaros la forma en la que he planteado este ejercicio, y como el TDD me ha ayudado tanto en la definición como en la implementación.
Empezemos pues. Que es lo primero que debemos hacer para plantear un proyecto? Algunos de vosotros tal vez respondáis sin pensar demasiado... "programar!". Bueno, porque no... ¿pero programar el que? Parece imposible programar nada sin tener una pequeña idea de lo que uno quiere programar, almenos tener una pequeña aproximación, un pequeño análisis o simplemente un segundo de inspiración fugaz.
Pues bien, en esta ocasión, vamos a dedicar un tiempo consistente a esta fase de análisis, y vamos a pensar exactamente la forma en que un usuario de nuestra aplicación debería poder interactuar con nuestra aplicación. Y dado que nuestro proyecto es un Dependency Injection Container, vamos a definir toda posible interacción exterior.
En este post, voy a dar por supuesto que tienes conciencia de estos conceptos.
- The dependency Inversion principle
- Dependency Injection
- Test Driven Development - TDD
- Mutability Object
Así que propongo empezar a definir lo que nuestro container podrá handle, definiendo como se comportará dado una entrada y un environment, así como los estados que este experimentará durante todo el proceso. Me explico.
- Hombre... un DIC? Que puedo hacer con él?
- Pues no mucho, algunas cosas básicas.
- ¿Cuales?
- Enumero:
- Vamos a trabajar con el concepto de Parameter y de Service.
- En nuestro caso, un parámetro no es otra cosa que un valor asignado a un identificador único. Este valor puede ser cualquier tipo de elemento asignable en PHP (entero, string, object, callable...)
- En nuestro caso, un servicio es una instancia de un objeto, definido por un identificador único, su namespace y un array de argumentos
- Cada uno de estos argumentos puede ser una referencia a otro servicio, siguiendo el formato "@service_id", un parámetro siguiendo el formato "~parameter_id" o en su defecto, un valor cualquiera.
- Dado que es un contenedor, un servicio será instanciado una (y solo una vez) devolviendo siempre la misma instancia. Dicho servicio será instanciado en el momento que se pida por primera vez.
- Para adquirir este servicio, se utilizará el método público
$container->get("service_id")
- Para adquirir un parámetro, se utilizará el método público
$container->getParameter("parameter_name");
- Bien.
¿Que os parece como definición? Bueno, pues creo que es un buen comienzo para empezar nuestra implementación.
- ¿Ya implementamos?
Bueno, si, pero no vamos a implementar el container en sí, sino vamos a crear un pequeño juego de pruebas con toda esta definición que hemos generado. Tenemos suficiente información para poder definir en que casos nuestra aplicación debería funcionar y los elementos de retorno que debería devolver, y en que ocasiones debería fallar.
Vamos a verlo por partes. Empezemos por un ejemplo de una correcta configuración de nuestros servicios.
$configuration = [
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_other_service' => [
'class' => 'My\Class\Namespace',
],
'my_parameter' => 'parameter_value',
];
Como podéis observar tenemos:
- Un servicio con identificador
my_service
, cuya instancia se resuelve con el namespaceMy\Class\Namespace
y pasando por argumentos otro servicio llamadomy_other_service
, un parámetro llamadomy_parameter
y un string simple con valor"simple_value"
- Un servicio con identificador
my_other_service
(referenciado en el constructor del servicio anterior), cuya instancia se crea haciendo un simple new de la claseMy\Other\Class\Namespace
, sin dependencias. - Un parámetro con identificador
my_parameter
, cuyo valor es el string"parameter_value"
Partiendo de la base que existen ambas clases con sus constructores adecuados, en principio esta configuración es coherente, por lo que nuestro container debería poder compilarlo perfectamente.
Compilar el container en nuestro caso no es otra cosa que, dada una
configuración, hacer check que tal configuración es coherente, y crear una
estructura interna para poder instanciar los objetos más rapidamente.
Luego vamos a ver como funciona.
Veamos una configuración que no debería funciona por varias razones.
$configuration = [
'' => [
'class' => 'My\Non\Existing\Class\Namespace',
'arguments' => [
'@my_non_existing_service',
'~my_non_existing_parameter',
'simple_value',
]
],
];
En este caso
- El identificador del servicio es incorrecto, no puede ser vacío
- no existe el namespace
My\Non\Existing\Class\Namespace
- No existe el servicio
my_non_existing_service
- No existe el parámetro
my_non_existing_parameter
Vamos viendo que poco a poco estamos definiendo bastante bien como se debe comportar nuestra librería, aún no habiendo implementado absolutamente nada. Pues a pesar que parezca algo raro, en realidad es algo muy útil, ya que puede ahorrarte mucho tiempo de refáctoring a posteriori, justamente por el mero hecho de no haber prestado atención a la definición.
La segunda parte de esta fase, cuando tenemos un juego de pruebas bien definido, tanto los que harán que nuestra aplicación funcione bien, como los que harán que no funcione, es cuando empezamos nuestros tests unitarios. En nuestro caso vamos a trabajar con la librería PHPUnit por mi conocimiento de la propia librería y por el hecho que es una libería bastante extendida y de sobras conocida y testeada.
Veamos una pequeña muestra de nuestro primer test.
/**
* This method just will test that, given a configuration, the container will
* be built properly
*/
public function testBuildOk()
{
$configuration = [
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_other_service' => [
'class' => 'My\Class\Namespace',
],
'my_parameter' => 'parameter_value',
];
$container = new Doppo($configuration);
$container->compile();
}
En si, este test no hace ningun assert, simplemente testea que la librería no lanzará ningún tipo de exception, por lo que debería ser suficiente. Evidentemente, debemos testear todas las possibles configuraciones distintas, por lo que podríamos convertir el test anterior por algo como este.
/**
* This method just will test that, given a configuration, the container will
* be built properly
*
* @dataProvider dataBuildOk
*/
public function testBuildOk(array $configuration)
{
$container = new Doppo($configuration);
$container->compile();
}
/**
* data for testBuildOk
*/
public function dataBuildOk()
{
return [
[[
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_other_service' => [
'class' => 'My\Class\Namespace',
],
'my_parameter' => 'parameter_value',
]],
[[
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => []
],
]],
[[
'my_service' => [
'class' => 'My\Class\Namespace',
],
]],
];
}
Bonita batería de tests, no? En este caso, para cada una de las posiciones del
array que devuelve el método dataBuildOk
, testearemos la compilación de
nuestro. Aqui
podéis encontrar información sobre la annotation @dataProvider de PHPUnit.
Vamos a intentar definir casos en los que nuestro contenedor debería fallar.
Para esto implementaremos una capa de fallo muy básica, formada solo por el
lanzado de Exception
en todos los puntos de fallo.
/**
* This method just will test that, given a configuration, the container will
* be built properly
*
* @dataProvider dataBuildFail
* @exceptionExpected \Exception
*/
public function testBuildFail(array $configuration)
{
$container = new Doppo($configuration);
$container->compile();
}
/**
* data for testBuildFail
*/
public function dataBuildFail()
{
return [
[[
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_parameter' => 'parameter_value',
]],
[[
'my_service' => [
'class' => 'My\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_other_service' => [
'class' => 'My\Class\Namespace',
],
]],
[[
'my_service' => [
'class' => 'My\Non\Existing\Class\Namespace',
'arguments' => [
'@my_other_service',
'~my_parameter',
'simple_value',
]
],
'my_other_service' => [
'class' => 'My\Class\Namespace',
],
'my_parameter' => 'parameter_value',
]],
[[
'' => [
'class' => 'My\Class\Namespace',
],
]]
];
}
En este caso, si os fijáis, estamos cubriendo todos los posibles casos que habíamos definido anteriormente, por lo que en el momento en que los tests pasen, el comportamiento del container será el esperado.
Estamos haciendo tests, recordáis? Siempre que se hace tests, debemos lanzarlos para saber el resultado de su ejecución. Para que nos hagamos una pequeña idea, cuanto más verde sea la pantalla de resultados, mejor.
Dado que ahora mismo no tenemos ninguna implementación, evidentemente los tests fallarán, todos, pero debemos contar con ello ya que forma parte de la metodología TDD en sí.
El trabajo en este punto es conseguir que, de forma iterativa, vayamos implementando aquello que vamos especificando y testeando, por lo que dada toda la especificación que hemos logrado hasta este punto con los tests, vamos a empezar nuestra implementación.
/*
* This file is part of the Doppo package
*/
class Doppo
{
/**
* @var array
*
* Configuration
*/
private $configuration;
/**
* Constructor
*
* @param array $configuration Container Configuration
* @param boolean $debug Debug mode
*/
public function __construct(array $configuration)
{
$this->configuration = $configuration;
}
/**
* Compile action
*/
public function compile() {
}
/**
* Get service instance given its name
*
* @param string $serviceName Service name
*
* @return Object service instance
*/
public function get($serviceName)
{
}
/**
* Get parameter value given its name
*
* @param string $parameterName Parameter name
*
* @return mixed parameter value
*/
public function getParameter($parameterName)
{
}
}
Esta es nuestra clase completamente vacía. Si ahora hacemos correr nuestros tests probablemente los errores no sean que no los métodos no existen, sino que los resultados no serán los esperados. Para la implementación vamos a seguir unos pasos para que no nos perdemos. Iremos buscando pequeños hitos para tener, al fin, toda la implementación completa y nuestros tests en verde.
Vamos a separar en distintos bloques la implementación de nuestro compilador.
- Detección de tipo (parámetro o servicio)
- Compilación de parámetros
- Estructura interna - ParameterDefinition
- Compilación de servicios
- Valors por defecto
- Estructura interna - ServiceDefinition
- Argumentos de servicio, ArgumentChain
- ParameterArgument
- ServiceArgument
- Compilation check
Para crear una nueva instancia de compilador, necesitamos proveer un array de configuración como el que hemos visto anteriormente.
Este array puede contener tanto la definición de un servicio como la definición de un parámetro, por lo que de alguna forma debemos comprobar de cual se trata.
Para esto, y dado que la unica propiedad indispensable que tiene un servicio que no tiene un parámetro es la clase que lo define, es comprobar si este valor está en la definición. En caso afirmativo, compilaremos el bloque como servicio. Otherwise lo haremos como parámetro.
/**
* Compile the configuration
*
* @param array $configuration Container Configuration
*
* @throws Exception Element type is not correct
*/
protected function compileConfiguration(array $configuration)
{
foreach ($configuration as $configurationName => $configurationElement) {
if (is_array($configurationElement) && array_key_exists('class', $configurationElement)) {
$this->compileService(
$configurationName,
$configurationElement
);
} else {
$this->compileParameter(
$configurationName,
$configurationElement
);
}
}
}
En este caso, vemos que iteramos todas las posiciones del array, y para cada una
de ellas hacemos dicha comprobación. En el caso que encontremos class
compilamos un servicio, y sino, un parámetro.
Para compilar nuestros parámetros vamos a trabajar con una estructura propia. Esta estructura consta de un Chain, algo parecido a una collection, y de un ValueObject con la información necesaria para definir un parámetro.
Podéis ver las implementaciones aqui.
Dado que vamos a popular una instancia del tipo ParameterDefinitionChain, debemos inicializarlo en el constructor de nuestro compilador.
/**
* Constructor
*
* @param array $configuration Container Configuration
* @param boolean $debug Debug mode
*/
public function __construct(array $configuration)
{
$this->configuration = $configuration;
$this->parameters = new ParameterDefinitionChain();
}
Para compilar un parámetro, solo necesitamos popular dicha colección con objetos
del tipo ParameterDefinition
. A continuación una pequeña implementación.
/**
* Compile a parameter
*
* @param string $parameterName Parameter name
* @param string $parameterValue Parameter value
*/
protected function compileParameter($parameterName, $parameterValue)
{
$this
->parameters
->addParameterDefinition(
new ParameterDefinition(
$parameterName,
$parameterValue
)
);
}
La complejidad de compilar un servicio es un poco más alta. Como hemos visto anteriormente hay algunos escenarios en que nuestra definición no es buena, por lo que tenemos que buscar estos casos y lanzar una excepción (En nuestro caso, siempre una \Exception).
Como en los parámetros, vamos a trabajar con una estructura interna para definir lo que es un servicio.
Podéis ver las implementaciones aqui.
Como anteriormente hemos hecho con el objeto ParameterDefinitionChain
, vamos a inicializar
la instancia del compilador en su constructor.
/**
* Constructor
*
* @param array $configuration Container Configuration
* @param boolean $debug Debug mode
*/
public function __construct(array $configuration)
{
$this->configuration = $configuration;
$this->parameters = new ParameterDefinitionChain();
$this->services = new ServiceDefinitionChain();
}
Una vez hemos inicializado la clase Chain, debemos implementar como se construye
una instancia del tipo ServiceDefinition
dado un array de definición. Teniendo en cuenta
que:
- Un servicio sin class definida no puede existir
- Cuando un servicio no tiene argumentos definidos, equivale a constructor vacío
Podemos implementar algo parecido a esto (La parte de los argumentos aún no la hemos trabajado, es un paso posterior).
/**
* Compile a service
*
* @param string $serviceName Service name
* @param array $serviceConfiguration Service configuration
*
* @throws DoppoServiceClassNotFoundException Service class not found
*/
protected function compileService($serviceName, array $serviceConfiguration)
{
if (!class_exists($serviceConfiguration['class'])) {
throw new DoppoServiceClassNotFoundException(
sprintf(
'Class %s not found',
$serviceConfiguration['class']
)
);
}
$arguments = isset($serviceConfiguration['arguments'])
? $serviceConfiguration['arguments']
: array();
$this
->services
->addServiceDefinition(
new ServiceDefinition(
$serviceName,
'\\' . ltrim($serviceConfiguration['class'], '\\'),
$this->compileArguments($arguments),
$public
)
);
}
En realidad este bloque no es demasiado distinto al de los parámetros, pero claro, el tema es que un servicio puede tener argumentos, y es una parte que también debemos tener en cuenta a la hora de compilar.
Para los argumentos hemos creado una pequeña estructura parecida a la que estamos utilizando para compilar tanto servicios como parámetros.
ArgumentChain ServiceArgument ParameterArgument ValueArgument
Esta estructura se resume en que un objeto del tipo ArgumentChain
contiene
n elementos del tipo Argument
, una Interface de la cual extienden tanto
ServiceArgument
, ParameterArgument
y ValueArgument
.
A la hora de compilar los argumentos, tenemos este método, encargado de popular el object ArgumentChain.
/**
* Compile arguments
*
* @param array $arguments Argument configuration
*
* @return ArgumentChain Argument chain
*/
protected function compileArguments(array $arguments)
{
$argumentChain = new ArgumentChain();
foreach ($arguments as $argument) {
$argumentChain->addArgument(
$this->compileArgument($argument)
);
}
return $argumentChain;
}
pero ahora estamos en la misma posición que antes. Debemos saber que tipo de argumento se trata en cada caso.
Como hemos definido anteriormente:
- Cuando el argumento empieza por el carácter @, se trata de una referencia a un servicio
- Cuando el argumento empieza por el carácter ~, se trata de una referencia a un parámetro
- Sino, se trata de un valor plano
Nuestra implementación, entonces, buscará estos carácteres, y en función del formato, compilará un argumento del tipo servicio, parámetro o valor.
/**
* Given an argument return its definition
*
* @param string $argument Argument
*
* @return Argument Argument
*/
protected function compileArgument($argument)
{
$argumentDefinition = null;
if (is_string($argument) && strpos($argument, Doppo::SERVICE_PREFIX) === 0) {
$cleanArgument = preg_replace('#^' . Doppo::SERVICE_PREFIX . '{1}#', '', $argument);
$argumentDefinition = $this->compileServiceArgument($cleanArgument);
} elseif (is_string($argument) && strpos($argument, Doppo::PARAMETER_PREFIX) === 0) {
$cleanArgument = preg_replace('#^' . Doppo::PARAMETER_PREFIX . '{1}#', '', $argument);
$argumentDefinition = $this->compileParameterArgument($cleanArgument);
} else {
$argumentDefinition = $this->compileValueArgument($argument);
}
return $argumentDefinition;
}
/**
* Given a service argument value return its definition
*
* @param string $argumentValue Argument value
*
* @return ServiceArgument Service argument
*/
protected function compileServiceArgument($argumentValue)
{
return new ServiceArgument($argumentValue);
}
/**
* Given a parameter argument value return its definition
*
* @param string $argumentValue Argument value
*
* @return ParameterArgument Parameter argument
*/
protected function compileParameterArgument($argumentValue)
{
return new ParameterArgument($argumentValue);
}
/**
* Given a value argument value return its definition
*
* @param mixed $argumentValue Argument value
*
* @return ValueArgument Value argument
*/
protected function compileValueArgument($argumentValue)
{
return new ValueArgument($argumentValue);
}
A la hora de compilar los argumentos, nos damos cuenta que, y dado que es posible que estemos creando una referencia a un servicio que aún no está compilado, tenemos que revisar posteriormente que todas las referencias son correctas.
Es por esto que, en el punto en que hemos compilado la configuración entera, debemos añadir una última capa para comprobar todos los argumentos.
/**
* Check services arguments references
*
* This call has only sense if the service stack is built before. The why
* of this methods is because now we have the correct acknowledgement about
* all the services and parameters we will work with.
*
* We will now check that all service arguments have correct references.
*
* @throws DoppoServiceArgumentNotExistsException service argument not found
*/
protected function checkServiceArgumentsReferences()
{
$this
->services
->each(function (ServiceDefinition $serviceDefinition) {
$serviceDefinition
->getArgumentChain()
->each(function (Argument $argument) use ($serviceDefinition) {
$argumentValue = $argument->getValue();
if ($argument instanceof ServiceArgument) {
if (!$this->services->has($argumentValue)) {
throw new DoppoServiceArgumentNotExistsException(
sprintf(
'Service "%s" not found in "@%s" arguments list',
$argumentValue,
$serviceDefinition->getName()
)
);
}
}
if ($argument instanceof ParameterArgument) {
if (!$this->parameters->has($argumentValue)) {
throw new DoppoServiceArgumentNotExistsException(
sprintf(
'Parameter "%s" not found in "@%s" arguments list',
$argumentValue,
$serviceDefinition->getName()
)
);
}
}
});
});
}
Simple. Si un argumento del tipo ServiceArgument
contiene una referencia a un
servicio inexistente, lanzamos una Exception. Por otro lado, si tenemos un argumento
del tipo ParameterArgument
con una referencia a un parámetro inexistente, también
lanzamos una Exception.
Hasta aqui todo el proceso de compilación. El resultado final de compilar un
container con una configuración válida es un objeto del tipo ParameterChain
con
instancias de ParameterDefinition
, y un objeto del tipo ServiceChain
con
instancias de ServiceDefinition
.
Recuperemos los tests que hemos hecho anteriormente. Si ahora los intentamos pasar veremos que efectivamente pasarán en verde, por lo que nuestra implementación, por el momento, se estará comportando como queremos.
Sigamos.
La segunda iteración la vamos a dedicar al hecho que un container se puede, o debería poderse compilar una sola vez. Una vez compilado, solo se debería poder acceder a las instancias de los servicios y a los valores de los parametros.
Para esto, vamos a crear un pequeño test que nos permita comprobar este escenario.
/**
* Test container compilation more than once
*
* @expectedException \Exception
*/
public function testCompileMoreThanOnce()
{
$container = new Doppo(array());
$container->compile();
$container->compile();
}
En este caso vemos que no nos importa el contenido de la configuración. En el momento en que compilemos un container compilado, esperamos que se lanze una Exception. Si lanzamos nuestro test ahora, volverá a fallar, por lo que necesitamos implementar tal feature hasta que no falle.
Para esto, podemos reforzar nuestra clase con estos elementos
/**
* @var boolean
*
* The container is compiled
*/
protected $compiled;
/**
* Constructor
*
* @param array $configuration Container Configuration
* @param boolean $debug Debug mode
*/
public function __construct(array $configuration)
{
// Initialization
$this->compiled = false;
}
/**
* Compile container
*
* @throws Exception Container already compiled
*/
public function compile()
{
if (true === $this->compiled) {
throw new DoppoAlreadyCompiledException(
'Container already compiled'
);
}
$this->compileConfiguration($this->configuration);
$this->checkServiceArgumentsReferences();
$this->compiled = true;
}
Pasemos ahora los tests. Todo en verde! Bien, ahora tan solo nos queda implementar como instanciamos y devolvemos los servicios y los parámetros.
Tal y como hemos definido al inicio del post, una de las especificaciones de nuestro
container es que para recuperar un servicio debemos hacerlo con el método
$container->get($serviceName);
.
Por ahora esta funcionalidad no la hemos implementado, por lo que empezamos con los tests dando por supuesto que ninguno de ellos va a pasar.
/**
* Testing get method with good values
*/
public function testGetOK()
{
$container = new Doppo(array(
'foo' => array(
'class' => '\Doppo\Tests\Data\Foo',
'arguments' => array(
'value1',
array('value2'),
'~my.parameter',
),
),
'goo' => array(
'class' => 'Doppo\Tests\Data\Goo',
'arguments' => array(
'@foo',
'@moo',
),
),
'moo' => array(
'class' => 'Doppo\Tests\Data\Moo'
),
'my.parameter' => 'my.value',
));
$container->compile();
$this->assertInstanceOf('Doppo\Tests\Data\Foo', $container->get('foo'));
$this->assertInstanceOf('Doppo\Tests\Data\Goo', $container->get('goo'));
$this->assertInstanceOf('Doppo\Tests\Data\Moo', $container->get('moo'));
}
/**
* Testing get method in a non-compiled Container
*
* @expectedException \Exception
*/
public function testGetFail()
{
$doppo = $this->getDoppoInstance(array(
'moo' => array(
'class' => 'Doppo\Tests\Data\Moo'
),
));
$doppo->get('moo');
}
/**
* Testing get method with bad values
*
* @expectedException \Exception
*/
public function testGetFail()
{
$container = $this->getDoppoInstance(array(
'moo' => array(
'class' => 'Doppo\Tests\Data\Moo'
),
));
$container->compile();
$container->get('foo');
}
/**
* Tests that a service is only built once, even is called more than once
*/
public function testServiceInstancedOnce()
{
$container = $this
->getMockBuilder('Doppo')
->setMethods[array('buildExistentService')]
->setConstructArguments(array(array(
'moo' => array(
'class' => 'Doppo\Tests\Data\Moo'
),
)))
->getMock();
$container
->expects($this->once())
->method('buildExistentService')
->with($this->equalTo('moo'))
->willReturn(new \Doppo\Tests\Data\Moo);
$container->compile();
$container->get('moo');
$container->get('moo');
}
Todos los tests que podamos hacer en este punto, tienen que partir de la base que la compilación del container funciona correctamente.
Los tests que se presentan cubren las siguientes situaciones:
- Dada una configuración válida, el método
get
devuelve las instancias esperadas. - El container lanza una Exception cuando se llama a
get
y no está compilado. - El container lanza una Exception si se busca un servicio inexistente.
- Un servicio es creado una (y sola) una vez, aunque se pida más de una vez.
Para volver estos tests en verde, vamos a implementar esta feature.
/**
* Get service instance
*
* @param string $serviceName Service Name
*
* @return mixed Service instance
*
* @throws DoppoNotCompiledException Container not compiled yet
* @throws DoppoServiceNotExistsException Service not found
*/
public function get($serviceName)
{
if (!$this->compiled) {
throw new DoppoNotCompiledException(
'Container should be compiled before being used'
);
}
/**
* The service is found as an instance, so we can be ensured that the
* value inside this position is a valid Service instance
*/
if (isset($this->serviceInstances[$serviceName])) {
return $this->serviceInstances[$serviceName];
}
/**
* Otherwise, we must check if the service defined with its name has
* been compiled
*/
if (!$this->services->has($serviceName)) {
throw new DoppoServiceNotExistsException(
sprintf(
'Service "%s" not found',
$serviceName
)
);
}
return $this->serviceInstances[$serviceName] = $this->buildExistentService($serviceName);
}
/**
* Build service. We assume that the service exists and can be build
*
* @param string $serviceName Service Name
*
* @return mixed Service instance
*/
protected function buildExistentService($serviceName)
{
$serviceDefinition = $this->services->get($serviceName);
$serviceReflectionClass = new ReflectionClass($serviceDefinition->getClass());
$serviceArguments = array();
/**
* Each argument is built recursively. If the argument is defined
* as a service we will return the value of the get() call.
*
* Otherwise, if is defined as a parameter we will return the
* parameter value
*
* Otherwise, we will treat the value as a plain value, not precessed.
*/
$serviceDefinition
->getArgumentChain()
->each(function (Argument $argument) use (&$serviceArguments) {
$argumentValue = $argument->getValue();
if ($argument instanceof ServiceArgument) {
$serviceArguments[] = $this->get($argumentValue);
} elseif ($argument instanceof ParameterArgument) {
$serviceArguments[] = $this->getParameter($argumentValue);
} else {
$serviceArguments[] = $argumentValue;
}
});
return $serviceReflectionClass->newInstanceArgs($serviceArguments);
}
Como vemos, el método buildExistentService
es protected en lugar de private.
Hay una razón sólida para que sea así, y es que una de las cosas más importantes a la hora
de trabajar con un container es el hecho de que un servicio es creado una (y solo una) vez.
Para esto es importante poder Mock el método en cuestión y verificar que solo se llama una vez, y para esto debe formar parte de la API de la clase (método público y protected).
Finalmente queremos testear que los parámetros están disponibles mediante el método
getParameter
. De la misma forma que hemos hecho con los servicios, creamos los tests
que comprueban los valores y luego implementamos.
/**
* Testing get method with good values
*/
public function testGetParameterOK()
{
$container = new Doppo(array(
'my.parameter' => 'my.value',
));
$container->compile();
$this->assertEquals('my.value', $container->getParameter('my.parameter'));
}
/**
* Testing get method with bad values
*
* @dataProvider dataGetParameterFail
* @expectedException \Exception
*/
public function testGetParameterFail($parameterName)
{
$container = new Doppo(array(
'my.parameter' => 'my.value',
));
$container->compile();
$container->getParameter($parameterName);
}
/**
* Data for testGetParameterFail
*
* @return array
*/
public function dataGetParameterFail()
{
return array(
array('my.nonexisting.parameter'),
array(true),
array(false),
array(null),
);
}
/**
* Testing getParameter method with a non-compiled container
*
* @expectedException \Exception
*/
public function testGetParameterWithoutCompile()
{
$container = new Doppo(array());
$container->getParameter('my.parameter');
}
En estos tests estamos cubriendo los siguientes casos:
* Dada una configuración válida, el método `getParameter` devuelve los valores esperadas.
* El container lanza una Exception cuando se llama a `getParameter` y no está compilado.
* El container lanza una Exception si se busca un parámetro inexistente.
Finalmente, la implementación.
```php
/**
* Get parameter value
*
* @param string $parameterName Parameter Name
*
* @return mixed Parameter value
*
* @throws DoppoNotCompiledException Container not compiled yet
* @throws DoppoParameterNotExistsException Parameter not found
*/
public function getParameter($parameterName)
{
if (!$this->compiled) {
throw new DoppoNotCompiledException(
'Container should be compiled before being used'
);
}
if (!$this->parameters->has($parameterName)) {
throw new DoppoParameterNotExistsException(
sprintf(
'Parameter "%s" not found',
$parameterName
)
);
}
return $this
->parameters
->get($parameterName)
->getValue();
}
Si ahora lanzamos toda la batería de tests, deberíamos poder comprobar que todos están en verde, por lo que hemos logrado construir un DIC utilizando TDD.
Muchas cosas a partir de ahora.
- Recordemos que tenemos el método
buildExistentService
protected, por lo que podemos sobreescribirlo. Que os parece crear una capa de caché? De esta forma una vez compilamos el container, podemos bolcar toda la configuración en un fichero plano, como lo hace el Dependency Injection de Symfony. - Podéis encontrar una implementación completa, con caché incluida, en el repositorio Doppo.