Inyectar lógica en los métodos de cualquier clase PHP dinámicamente

La Programación Orientada a Aspectos (POA) no es la tendencia más de moda hoy en día, pero tiene bastantes implementaciones muy interesantes en diferentes lenguajes. Lo que a continuación presento es una implementación parcial y experimental de POA, aunque siendo puristas no sería POA realmente, ya que no se declaran los aspectos como tales. Además, solo hay dos join points: preMethod y postMethod (antes y después de un método).

Se trata de inyectar funciones antes y/o después de cualquier método de cualquier clase que queramos. Estas funciones podrán manipular la forma en que se ejecutan los métodos objetivo, modificando los parámetros de llamada o incluso deteniendo su ejecución, así como manipular su salida.

Para ver más claro de qué va todo esto, imaginemos una clase sencilla:

class Coche
{
	private $posicion = 0;

	public function avanzar($incremento)
	{
		$this->posicion += $incremento;
	}

	public function getPosicion()
	{
		return $this->posicion;
	}
}

El método avanzar() aumentará la propiedad $posicion y no hará nada más. No implementa restricciones de máximo, mínimo, etc. En principio, el método está ya escrito y no se puede modificar en tiempo de ejecución.

Pongamos por caso que queremos introducir ciertas restricciones en avanzar(), por ejemplo, que el atributo $posicion nunca sea superior a 10. Sería genial poder inyectar una función que, antes de ejecutar avanzar(), que hiciese la comprobación y cancelase la ejecución del método en caso de que la $posicion resultante fuese mayor a 10.

Pues esto es exactamente lo que vamos a hacer con… ¡tachán! ClassTriggers:

//Instanciamos un ClassTriggers que envuelve nuestro objeto Coche
$cocheInterceptado = new ClassTriggers(new Coche);

//Definimos una acción que se ejecutará antes (preMethod) del método avanzar()
$cocheInterceptado->bind('avanzar', 'preMethod', function(&$arguments) {
	echo 'Has solicitado avanzar(' . $arguments[0] . ")\n";

	if ($this->posicion + $arguments[0] > 10) {
		echo 'No puedes avanzar más de 10. Deteniendo ejecución del método';
		return ClassTriggers::COND_STOP_EXECUTION;
	}
});

$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";

$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";

$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";

$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";

Como puedes ver, tenemos una función anónima que recibe el parámetro $arguments. Este array contendrá los parámetros de la llamada a avanzar(), así podemos interceptar el valor que se quiere incrementar a $posicion. Además, como el array se pasa por referencia tenemos la posibilidad de manipular los parámetros de llamada.

En este caso no lo hacemos, sino que devolvemos el valor especial ClassTriggers::COND_STOP_EXECUTION, que sirve para cancelar la ejecución del método. Es decir, si la suma de $posicion + $incremento es superior a 10, el método avanzar() no se ejecutará realmente.

La salida que producirá el script será la siguiente:

Has solicitado avanzar(3)
3
Has solicitado avanzar(3)
6
Has solicitado avanzar(3)
9
Has solicitado avanzar(3)
No puedes avanzar más de 10. Deteniendo ejecución del método
9

Es decir, tratamos de sumar 3 cuatro veces, pero después de la tercera llamada (cuando $posicion vale 9), el método que hemos inyectado detecta que quieres incrementar $posicion por encima de 10, y cancela la ejecución del método.

Llegados a este punto, el abanico de posibilidades es muy amplio, pero espera, que aún queda más ;-)

Es posible definir más de una acción para un evento (los eventos posibles son preMethod y postMethod) y método, y se irán ejecutando en el mismo orden en que se definieron:

$cocheInterceptado->bind('avanzar', 'preMethod', function(&$arguments) {
	echo "Una acción antes de avanzar()\n";
});
$cocheInterceptado->bind('avanzar', 'preMethod', function(&$arguments) {
	echo "Otra acción\n";
});

Por otra parte, el trigger postMethod se ejecuta después del método real, y las acciones podrán acceder al valor devuelto por el método original, para poder evaluarlo, manipularlo, etc. Siguiendo el ejemplo del coche que no debe avanzar más de 10 posiciones, podemos implementar la restricción después de ejecutar avanzar():

$cocheInterceptado->bind('avanzar', 'postMethod', function(&$arguments, $output) {
	if ($this->posicion > 10)
	{
		$this->posicion = 10;
	}
});

$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";
$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";
$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";
$cocheInterceptado->avanzar(3);
echo $cocheInterceptado->getPosicion() . "\n";

En este ejemplo se llama cuatro veces a avanzar(3), por lo que, después de la primera llamada, $posición valdrá 3, después de la segunda 6, después de la tercera 9, y después de la cuarta 12… en teoría:

3
6
9
10

Como puedes ver, el valor final no es 12 sino 10, debido a la acción que hemos ejecutado.

Valores especiales

Ya hemos visto por encima para qué sirve ClassTriggers::COND_STOP_EXECUTION. Devolviendo este valor en una acción, ClassTriggers hará que no se ejecute realmente el método solicitado.

Otro valor especial que se puede devolver es ClassTriggers::NO_MORE_ACTIONS. Con esto indicaremos que, después de ejecutar la acción que devuelve este valor, no se ejecutará ninguna acción más para ese trigger y ese método. Por ejemplo:

$cocheInterceptado->bind('avanzar', 'postMethod', function(&$arguments) {
	if ($this->posicion > 10)
	{
		$this->posicion = 10;
	}
	return ClassTriggers::NO_MORE_ACTIONS;
});

$cocheInterceptado->bind('avanzar', 'postMethod', function(&$arguments) {
	$this->posicion = 99;
});

$cocheInterceptado->avanzar(11);

Si la primera acción no devolviese ClassTriggers::NO_MORE_ACTIONS, la segunda acción se ejecutaría siempre, y en todos los casos $cocheInterceptado->posicion valdría 99. Pero devolviendo ClassTriggers::NO_MORE_ACTIONS se detiene la ejecución de más acciones.

¡Ojo!

Es importante resaltar que las acciones inyectadas operan a nivel de objeto, y no de clase, por lo que si tenemos dos instancias diferentes de Coche, inyectar lógica en una de ellos no modificará en absoluto a la otra. La posibilidad de reutilizar las acciones de un objeto para otras instancias, agrupándolas en aspectos (según la terminología de POA), no ha sido implementada, pero no sería complicado.

Otro dato importante: ClassTriggers sólo funciona con PHP 5.4, ya que hace uso de Closure::bind() para que las acciones puedan acceder a $this.

Posibles ampliaciones

Algunas ideas factibles siguiendo el modelo de ClassTriggers:

  • Añadir un trigger (join point en la terminología de AOP) que se ejecute cuando el método objetivo lance una excepción.
  • Añadir un trigger que se ejecute cuando un atributo del objeto se modifique (se puede implementar con __set).
  • Añadir un trigger que se ejecute cuando un atributo del objeto se lea (se puede implementar con __get).
  • Join point introspection: Que una acción sepa si para ese trigger hay otras acciones, cuáles son e incluso poder manipular su ejecución. Para ello las acciones deberían poder nombrarse.

Conclusiones

Ha sido divertido pensar e implementar ClassTriggers. Si echas un vistazo al código verás que es bastante sencillo y pequeño (¡140 líneas!), aunque hace uso de closures y métodos mágicos (no es apto para principiantes).

Algunas aplicaciones prácticas pueden ser:

  • Inserción de pre-condiciones y post-condiciones en el acceso a bases de datos (auto-iniciar/terminar transacciones, manipular las sentencias SQL…).
  • Auto-guardado de objetos en base de datos: en un objeto con getters y setters de toda la vida, auto-guardar en la base de datos los valores que cambien. Es decir, que cuando llames a $cliente->setNombre('Pepe') automáticamente se guarde ese valor en la base de datos.
  • Hacer un sistema diabólico de versionado de objetos (sí, sí, como Subversion, pero con POPOs).
  • Validar los parámetros de los métodos sin tener que duplicar un montón de código.

No obstante, en la mayoría de aplicaciones del mundo real es más apropiado usar simplemente la herencia de clases y sobreescritura de métodos para introducir pre/post-condiciones a un método cualquiera. Depende del diseño general, de qué nivel de modularidad que necesites y de las ganas que tengas de usar un montón de closures… porque al fin y al cabo, esto no es JavaScript :-P

¡Espero tus comentarios!

Tests de integración con Zarangotest

¿Cuántas veces hemos tenido que pelearnos con los ficheros de configuración a la hora de desplegar nuestro proyecto?

Respuesta: un montón. Y la mayoría de ellas, evitables.

La versión de PHP, las extensiones instaladas, los datos de conexión a la base de datos correctos… son algunas de las comprobaciones que debemos hacer cada vez que desplegamos una nueva versión de la aplicación. Y como es de bien nacidos automatizar los tests, algunos lo hacen con PHPUnit, otros de soluciones caseras… y otros usamos Zarangotest.

Zarangotest (cuyo nombre proviene del zarangollo, delicioso plato tradicional murciano) es un sencillísimo framework que podría haber escrito cualquiera, cuyos objetivos son organizar los tests (primordialmente los de integración), proporcionar una serie de tests ya escritos y presentar los resultados en una bonita página HTML.

Captura de un informe de Zarangotest
 ¿Cómo se usa Zarangotest?

Zarangotest, a diferencia de otros frameworks de test, está pensado para usar desde el servidor web, no desde la consola.

  1. Cargas el fichero de zarangotest: require_once ‘zarangotest.php’;
  2. Inicializas tus test según tus necesidades: cargar ficheros de configuración, declarar constantes…
  3. Declaras un array y lo vas rellenando de tests. Cada test es una instancia de la clase Zarangotest, y puedes especificar un título y una categoría (por si quieres agrupar tus tests para organizarlos mejor).
  4. Una vez declarado tu juego de tests, llamas a la función zarangotest($juegoDeTests, “Un título”).
<?php

include 'zarangotest.php';

$juegoDeTests = array();

/** Test Mysqli */
$juegoDeTests[] = new Zarangotest("Extensión mysqli", "Extensiones", function() {
    return function_exists("mysqli_close");
});

zarangotest($juegoDeTests, "MiProyecto");

Como puedes ver, el contenido de los tests consiste en una función anónima que devuelve true o false. Cualquier otro valor devuelto se considerará como un fracaso en la ejecución del test y se mostrará en el informe. Así pues, ejecutamos el script y obtenemos el informe:

Informe de Zarangotest

¿Y los tests pre-fabricados?

Zarangotest no está del todo terminado. Cuando lo esté, los tests prefabricados estáran disponibles como clases separadas. Mientras tanto, puedes utilizar el código de prefabricados.php.

¿Y esto no se puede hacer con PHPUnit?

Sí, se puede. Aunque PHPUnit está pensado para tests unitarios, se puede utilizar para hacer tests de integración e incluso tests funcionales. Pero si no dominas PHPUnit o no quieres/puedes desplegarlo en tus servidores, Zarangotest es una buena alternativa. Además, Zarangotest genera directamente el informe en HTML, algo que no es tan ágil con PHPUnit.

Es todo por el momento. En las próximas semanas iré subiendo actualizaciones, mejoras y más documentación a Zarangotest. Mientras tanto, sería genial que le echaras un vistazo y comentaras tus impresiones aquí :-)

Descargar Zarangotest