Hoy en día, la mayoria de aplicaciones web hacen uso de tareas o comandos de optimización, limpieza de datos, captura de eventos, etc. Estas tareas se lanzan a nivel de sistema operativo mediante una tarea Cron (Cron jobs) que hace llamada a una dirección URL o programa.
Planteando el escenario: en uno de los últimos proyectos en los que estoy trabajando he necesitado la ejecución de una tarea para que libere la cola de envío de emails del tipo Newsletter, es decir, el usuario genera un Newsletter asociado a un listado de suscriptores y lo lanza, una vez landazo, éste queda almacenado en una cola de envío. Para liberar esta cola es necesario lanzar la tarea de Symfony project:send-emails.
Esta tarea se puede ejecutar de forma muy sencilla mediante un Cron un par de veces al día, pero ¿qué ocurre si por impedimentos del sistema no puedo depender de un sistema de Cron? No voy a entrar a explicar ahora el porqué no puedo disponer del Cron del sistema, voy a centrarme en comentar cómo lo he solventado.
Básicamente, lo que he hecho ha sido hacer uso de la función exec() de PHP pero con la peculiaridad de lanzarla en segundo plano. Esto es muy sencillo de hacer y el usuario no depende de la finalización de la tarea para que el navegador le devuelva respuesta sino que, la respuesta es inmediata y la tarea queda en ejecución de forma transparente para el usuario.
Veamos el pequeño script PHP:
<?php
$symfonyPath = sfConfig::get('sf_root_dir');
$symfonyCommand = '/symfony project:send-emails --application=backend';
$output = ' > /dev/null &';
exec($symfonyPath . $symfonyCommand . $output);
?>
Vamos a explicarlo línea a línea:
$symfonyPath = sfConfig::get('sf_root_dir');
$symfonyCommand = '/symfony project:send-emails --application=backend';
Esto no es más que la ruta donde se encuentra el ejecutable de Symfony y la llamada al task en sí, poco más..
$output = ' > /dev/null &';
Aquí está el punto más interesante. Con esta línea desviamos la salida que produce la tarea hacia una ruta que actúa como "papelera". Con esto y el caracter &, se consigue que la tarea se ejecute en background y el script PHP no requiera estar esperando la ejecución completa del task hasta tener de nuevo el control sino que, la tarea Symfony la va a gestionar directamente el SO como hilo aparte.
Por último, solo queda lanzar la tarea:
exec($symfonyPath . $symfonyCommand . $output);
De esta forma he conseguido vaciar la cola de envío de emails sin necesidad de una ejecución por medio de Cron y además tengo el control de cuando hacerlo. De esta forma, tras el click del usuario en "Enviar Newsletter" no solo se almacena la cola de envío sino que acto seguido el sistema empieza a procesarla y la gracia de todo esto es que todo queda transparente para el usuario que ejecuta la acción.
Inspirado por un buen post de Asier Marqués: Desarrolo ágil con symfony, doctrine y mysql workbench en el que nos habla de como usar Mysql workbench y un plugin que nos permite exportar nuestra base de datos a formato YAML, he decidido contar mi corta experiencia con Doctrine migrations.
Hace poco comencé a usar esta opción que nos brinda a todos el ORM Doctrine. Tras dos o tres task lanzados, ya me enteraba de como funciona, es más, empecé a cogerle el gusto...
La idea parte teniendo un schema.yml inicial, una BBDD en el servidor y la necesidad de incorporar cambios en ambos. El uso es mucho más extenso, pero me voy a centrar en los siguiente:
Teniendo el schema.yml
# config/doctrine/schema.yml
Category:
columns:
id:
type: integer(11)
notnull: true
autoincrement: true
primary: true
unsigned: true
name:
type: string(255)
Y la BBDD en el servidor exactamente tal y como se declara en el schema.
Se necesita aplicar un cambio en la BBDD (añadir/eliminar columna, modificar una relación, etc.).
# config/doctrine/schema.yml
Category:
columns:
id:
type: integer(11)
notnull: true
autoincrement: true
primary: true
unsigned: true
name:
type: string(255)
Item:
columns:
id:
type: integer(11)
notnull: true
autoincrement: true
primary: true
unsigned: true
category_id:
type: integer(11)
notnull: true
unsigned: true
name:
type: string(255)
relations:
Category:
local: category_id
foreign: id
type: one
foreignAlias: Items
Cómo hacemos esto?
1. Modificar el schema.yml para aplicar el cambio.
2. Lanzar el task $ php symfony doctrine:generate-migrations-diff. Esto crea una estructura de ficheros en el directorio lib/ del proyecto:
// lib/migration/doctrine/1283537548_version1.php
class Version1 extends Doctrine_Migration_Base
{
public function up()
{
$this->createTable('item', array(
'id' =>
array(
'type' => 'integer',
'autoincrement' => '1',
'primary' => '1',
'unsigned' => '1',
'length' => '11',
),
'category_id' =>
array(
'type' => 'integer',
'notnull' => '1',
'unsigned' => '1',
'length' => '11',
),
'name' =>
array(
'type' => 'string',
'length' => '255',
),
), array(
'primary' =>
array(
0 => 'id',
),
));
}
public function down()
{
$this->dropTable('item');
}
}
// lib/migration/doctrine/1283537549_version2.phpclass Version2 extends Doctrine_Migration_Base
{
public function up()
{
$this->createForeignKey('item', 'item_category_id_category_id', array(
'name' => 'item_category_id_category_id',
'local' => 'category_id',
'foreign' => 'id',
'foreignTable' => 'category',
));
$this->addIndex('item', 'item_category_id', array(
'fields' =>
array(
0 => 'category_id',
),
));
}
public function down()
{
$this->dropForeignKey('item', 'item_category_id_category_id');
$this->removeIndex('item', 'item_category_id', array(
'fields' =>
array(
0 => 'category_id',
),
));
}
}
3. Lanzar el task $ php symfony doctrine:migrate. Aplica en la BBDD los cambios que se crearon en el paso anterior. Se le pueden pasar parámetros (entorno, up / down, etc.)
4. Por último se reconstruyen las clases del modelo: $ php symfony doctrine:build --all-classes
5. No nos olvidemos de borrar la cache..
Con esto, ya tenemos los cambios que requerimos, aplicados tanto en nuestro proyecto Symfony como en nuestra BBDD. Fácil y rápido.
Enlaces:
Proyecto de prueba creado para el post
Necesitaba crear un módulo que transformase imágenes al vuelo según parámetros pasados por la URL y almacenara una cache de éstas (módulo PIC para imágenes).
Cuando me disponía a empezarlo, justo vi sfImageTransformExtraPlugin para Symfony. Que hace todo lo que necesitaba y más.
Después de instalarlo y pelearme inicialmente con el routing para conseguir llamar al módulo de forma correcta el resultado es espléndido: rápido, gestión máginifica de la cache, fácil de usar una vez conocido, fexible, etc.
Plugin sfImageTransformExtraPlugin.
Tutorial parte 1.
Tutorial parte 2.
Cuando tenemos un módulo creado a través del "Admin Generator" se crean varias acciones automáticamente como son: edit, new, delete, list... A veces, se necesitan crear acciones diferentes a éstas y que son más propias del modelo de la aplicación como por ejemplo: "mostrar en portada", "Artículo fuera de Stock".
Para ello normalmente existen dos vías diferentes: desde el generator.yml o sobreescribiendo el partial correspondiente a las acciones del form o list.
Este sería un ejemplo para una acción que pondrá un artículo en la portada de la web:
Desde el generator.yml:
generator:
class: sfDoctrineGenerator
param:
model_class: Articulos
theme: admin
non_verbose_templates: true
with_show: false
singular: ~
plural: ~
route_prefix: articulos
with_doctrine_route: true
actions_base_class: sfActions
config:
actions: ~
list: ~
filter: ~
form:
actions:
portada:
action: 'inHome'
label: 'En portada'
confirm: 'Este artículo pasará a la portada de la web. Continuar?'
_delete: ~
_list: ~
_save_: ~
edit: ~
new: ~
Sobreescribiendo _form_actions.php
<ul class="sf_admin_actions">
<?php if ($form->isNew()): ?>
<?php // Si es un objeto nuevo no se muestra el botón ?>
<?php // Aquí irían las acciones propias del Form: edit, save... ?>
<?php else: ?>
<?php if (!$articulos->getInHome()): ?>
<li class="sf_admin_action_portada">
<?php echo link_to(__('En portada', array(), 'messages'), 'articulos/inHome?id='.$articulos->getId(), array( 'conditional' => 'portada', 'conditional_value' => false,)) ?>
</li>
<?php endif; ?>
<?php // Aquí irían las acciones propias del Form: edit, save... ?>
<?php endif; ?>
</ul>
Esta última opción da más control al programador y se pueden tener en cuenta condicionales como por ejemplo que el artículo tenga el campo home=true, en cuyo caso ya no se necesita que aparezca el botón.
Pero las opciones no se acaban aquí, existe otro método de crear nuevas acciones un poco más complejas sin necesidad de sobreescribir el elemento Partial. Cómo? Haciendo uso del Helper propio del módulo creado por el Admin Generator (Articulos en este ejemplo). Este archivo se sitúa en el directorio lib del módulo: apps/backend/modules/##moduleName/lib/##moduleNameGeneratorHelper.class.php y extiende de Base##moduleNameGeneratorHelper que ha su vez extiende de la clase sfModelGeneratorHelper. Si miramos esta clase vemos que contiene los helpers linkToNew, linkToEdit, linkToList, linkToSave y linkToSaveAndAdd que se corresponden con las acciones base y que devuelven el tag li con una llamada al helper link_to configurado con los parámetros de la acción.
Si se mira el Partial de las acciones, primero se comprueba que exista el método ##actionName dentro del helper, si existe se muestra su valor, si no existe se crea la acción parametrizada según el fichero generator.yml
cache/backend/#env/modules/autoArticulos/templates/_form_actions.php
<?php
// ...
if (method_exists($helper, 'linkToPortada')): ?>
<?php echo $helper->linkToPortada($form->getObject(), array( 'action' => 'inHome', 'label' => 'En portada', 'confirm' => 'Este artículo pasará a la portada de la web. Continuar?', 'params' => array( 'conditional' => 'portada', 'conditional_value' => false, ), 'class_suffix' => 'portada',)) ?>
<?php else: ?>
<?php echo link_to(__('En portada', array(), 'messages'), 'articulos/inHome?id='.$articulos->getId(), array( 'conditional' => 'portada', 'conditional_value' => false,)) ?>
<?php endif;
// ...
?>
Entonces, una forma de personalizar acciones es usando el generator.yml y el helper del módulo para pasárle parámetros, condicionales, etc. Siguiendo con el ejemplo:
generator.yml
generator:
class: sfDoctrineGenerator
param:
config:
form:
actions:
novedad:
action: 'inNew'
label: 'Novedad'
confirm: 'Este artículo será marcado como novedad. Continuar?'
params:
conditional: 'novedad'
conditional_value: false
El parámetro 'novedad' se usará para saber qué propiedad del objeto hay que chequear y el parámetro conditional_value para saber qué valor ha de tomar el objeto para que se muestre esta acción.
articulosGeneratorHelper.class.php
<?php
class articulosGeneratorHelper extends BaseArticulosGeneratorHelper
{
public function linkToPortada($object, $params)
{
$conditional = (isset($params['params']['conditional'])) ? 'get'.ucfirst($params['params']['conditional']) : null;
$conditional_value = (isset($params['params']['conditional_value'])) ? $params['params']['conditional_value'] : true;
if ( ($conditional == null) || ($conditional != null && $object->$conditional() == $conditional_value) ) {
return link_to(__($params['label'], array(), 'sf_admin'), 'articulos/' . $params['action'] . '?id=' . $object, array('confirm' => !empty($params['confirm']) ? __($params['confirm'], array(), 'sf_admin') : $params['confirm']));
} else {
return '';
}
}
}
Tengo que decir que no he visto nada en la documentación oficial de Symfony al respecto, por lo que no se hasta qué punto es una forma correcta de hacer uso de ésto, sobre todo con el paso de parámetros no estándares (conditional y conditional_value) desde el generator.yml al helper del módulo.
Navegando por la web de Symfony en busca de un plugin que implementase las operaciones de un "Carrito de la compra" y que fuese compatible con la versión 1.4 y el ORM Doctrine, no encontré nada interesante. Sin embargo, vi el plugin sfShoppingCartPlugin creado por Fabien Potencier para la versión 1.0 de Symfony y Propel.
Tras echarle un ojo, vi que implementa basicamente lo que necesito para mi carro de la compra y que solo requería de algunas modificaciones para hacerlo compatible con Doctrine, por lo que decidí hacer uso de él.
El plugin en si consta solamente con dos clases:
Básicamente, el uso es el siguiente:
<?php
// Se crea el objeto de la cesta
$carrito = new sfShoppingCart(sfConfig::get('app_tax'));
// Se añade un nuevo Articulo al carrito
$article = ArticlesTable::getInstance()->find($request->getParameter('id'));
$articleItem = new sfShoppingCartItem(get_class($article), $article->getId());
$articleItem->setPrice($article->getPrice());
$articleItem->setQuantity($request->getParameter('quantity'));
$carrito->addItem($articleItem);
// Recorrer el carrito y ver el precio total
foreach ($carrito->getItems() as $item) {
echo sprintf(
'%d: %f x %d = %f',
$item->getId(),
$item->getPrice(),
$item->getQuantity(),
($item->getPrice() * $item->getQuantity())
);
}
echo 'Total: ' . $carrito->getTotal();
Además de estas operaciones la clase sfShoppingCart incluye dos operaciones que devuelven el objeto Item del modelo:
sfShoppingCart::getObjects()
sfShoppingCart::getObject($class, $id)
Estos métodos hacen uso de Propel para obtener el objeto de la clase $class y con id $id.
Como lo que necesito es hacer uso de Doctrine, nada mejor que extender sfShoppingCart a sfDoctrineShoppingCart y sobreescribir ambos métodos:
public function getObject($class, $id)
{
// We must first make sure that the requested object does exist in the shopping cart
$ind = $this->getItemIndice($class, $id);
return (($ind !== null) ? call_user_func(array($class.'Table', 'find'), $id) : null);
}
public function getObjects()
{
$object_ids = array();
foreach ($this->getItems() as $item)
{
if (!array_key_exists($item->getClass(), $object_ids))
{
$object_ids[$item->getClass()] = array();
}
$object_ids[$item->getClass()][] = $item->getId();
}
$objects = array();
foreach ($object_ids as $class => $ids)
{
$objects = array_merge(
$objects,
Doctrine::getTable($class)->createQuery('c')->whereIn('c.id', $ids)->execute()->getData()
);
}
return $objects;
}
Con ésto ya se puede hacer uso de la clase sfDoctrineShoppingCart con todas las funcionalidades que ofrece sfShoppingCart pero adaptado a Doctrine.
Además he implementado algunos métodos extra para hacerlo más cómodo al uso. Por ejemplo sfDoctrineShoppingCart::getInstance() o sfDoctrineShoppingCart::save() para obtener y almacenar el carrito