Skip to content

Agregar campos virtuales a tu modelo para mejor el rendimiento de symfony

by david on marzo 13th, 2010

Hace unos días estaba leyendo el post de Javi (@loalf) sobre como agregar campos virtuales en el AdminGenerator de Symfony. A partir de ahí se originó un interesante conversación con Javi sobre como hacer lo que él planteaba en el post pero yendo un paso más. Ambos teníamos la necesidad de agregar campos virtuales a nuestro modelo de datos para almacenar en ellos el resultado de llamar a funciones de nuestro modelo.

Vamos a plantear el tema con un ejemplo. Imaginemos un proyecto simple con symfony bajo el orm propel, en él queremos guardar información de platos junto con los alimentos que lo componen.

El esquema sería el siguiente:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
   <table name="plato" phpName="Plato">
   <column name="id" type="integer" primaryKey="true" autoIncrement="true" required="true" />
   <column name="nombre" type="varchar" size="255" required="true" />
   <column name="slug"   type="varchar" size="250" required="true" />
   <column name="duracion" type="varchar" size="10" required="false" />
   <column name="coste" type="double" required="false" default="0" />
   <column name="comensales" type="integer" required="true" />
   <column name="receta" type="longvarchar" required="false" />
   <column name="observaciones" type="longvarchar" required="false" />
   <column name="created_at" type="timestamp" />
   <column name="updated_at" type="timestamp" />
  </table>
 
   <table name="alimento" phpName="Alimento">
   <column name="id" type="integer" primaryKey="true" autoIncrement="true" required="true" />
   <column name="slug"  type="varchar" size="250" required="true" />
   <column name="nombre" type="varchar" size="255" required="true" />
   <column name="observaciones" type="longvarchar"  required="false" />
   <column name="energia" type="double"  required="false" />
   <column name="agua" type="double" required="false" />
   <column name="etanol" type="double" required="false" />
   <column name="glucidos_totales" type="double" required="false" />
   <column name="polisacaridos" type="double" required="false" />
   <column name="azucares" type="double" required="false" />
   <column name="created_at" type="timestamp" />
   <column name="updated_at" type="timestamp" />
   <unique>
     <unique-column name="nombre" />
   </unique>
   </table>
 
   <table name="plato_alimento" phpName="PlatoAlimento">
   <column name="plato_id" type="integer" primaryKey="true" required="true" />
   <column name="alimento_id" type="integer" primaryKey="true" required="true" />
   <column name="cantidad" type="integer" required="true" default="0" />
   <foreign-key foreignTable="plato" onDelete="cascade">
    <reference local="plato_id" foreign="id" />
   </foreign-key>
   <foreign-key foreignTable="alimento" onDelete="cascade">
    <reference local="alimento_id" foreign="id" onDelete="cascade" />
   </foreign-key>
   </table>

Ahora imaginaros el backend de symfony con las acciones típicas de insertar, editar, borrar plato y listar los platos.
Nuestro cliente nos plantea la necesidad de incluir en el listado de platos un dato más como son las kilocalorías que tiene cada plato. Nosotros muy amablemente obecedemos a sus necesidades y nos disponemos a agregar un método a nuestro objeto Plato que realice dicho cálculo acorde a los alimentos que tenga el plato.

1
2
3
4
5
6
7
8
9
10
  class Plato extends BasePlato
  {
    public function getCalculoKilocalorias()
    {
      // aqui los cálculos necesarios
      return $valor;
    }
 
    // resto de funciones de nuestro objeto
  }

Luego, actualizamos la vista encargada de visualizar el listado de platos agregando un campo más llamado kilocalorias. Veamos como quedarían por un lado la acción del controlador y por otro la vista.

1
2
3
4
5
6
7
8
9
10
  class platoActions extends sfActions
  {
    public function executeList(sfWebRequest $request)
    {
      // Permitidme dejar de lado filtros y paginaciones
      $c = new Criteria();
      $c->addDescendingOrderByColumn(PlatoPeer::CREATED_AT);
      $this->platos = PlatoPeer::doSelect($c);
    }
  }

Y la vista (listSuccess.php)

1
2
3
4
5
6
7
  <table>
  <tr><th>Nombre Plato</th><th>Kilocalorias</th>
  <?php foreach ($platos as $plato): ?>
    <tr><td><?php echo $plato->nombre() ?></td>
    <td><?php echo $plato->getCalculoKilocalorias() ?></td></tr>
  </php endforeach; ?>
  </table>

Bien! ya tenemos la demanda del cliente satisfecha. Pero como informáticos inquietos que somos nos quedamos observando el código y vemos que la llamada al método getCalculoKilocalorias puede fastidiarnos un poco el rendimiento. Para cada plato que mostramos estamos haciendo una llamada extra a getCalculoKilocalorias y dependiendo de lo que hagamos dentro de dicho método puede que implique más consultas.
Entonces nos planteamos desnormalizar nuestro esquema de base de datos y agregar un campo más a nuestro objeto Plato llamada kilocalorias. Así pensamos reducir las consultas y acciones a ejecutar en nuestra aplicación de gestión de platos.

Nos ponemos manos a la obra, alteramos el esquema de datos agregando la nueva columna, y vamos felizmente a cambiar la vista.

1
2
3
4
5
6
7
8
  <table>
  <tr><th>Nombre Plato</th><th>Kilocalorias</th>
  <?php foreach ($platos as $plato): ?>
    <tr><td><?php echo $plato->nombre() ?></td>
    <!-- Fijaos que ahora estamos mostrando la nueva columna y no llamando al método getCalculoKilocalorias -->
    <td><?php echo $plato->getKilocalorias() ?></td></tr>
  </php endforeach; ?>
  </table>

Ahora nos surge una serie de dudas ¿cual es el lugar más adecuado para ir actualizando nuestro campo virtual?. Para ello veamos cual es el proceso de guardado de un plato. Recordaos que plato mantiene una relación N-N con alimentos, por lo que los alimentos del plato son guardados en una tabla intermedia plato_alimento.

  1. Mostramos el form de platos en el backend
  2. El usuario agrega y/o quita alimentos al plato
  3. Llamamos al método save del objeto PlatoForm
  4. El método save se encarga de guardar tanto los datos del plato como los alimentos que lo componen
  5. Finalmente devolvemos el control al usuario con los cambios guardados

Veamos con más detalle el proceso:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
  class PlatoForm extends BasePlatoForm
  {
    public save($con = null)
    {
      // aqui hay más codigo
      try
      {
        $con->beginTransaction();
        // LLAMAMOS A doSave
        $this->doSave($con);
        $con->commit();
      }
      catch (Exception $e)
      {
        $con->rollBack();
        throw $e;
      }
      return $this->getObject();
    }
 
    protected function doSave($con = null)
    {
      // ...
      $this->getObject()->save($con); // AQUÍ GUARDAMOS EL OBJETO PLATO
 
      // Y con saveEmbeddedForm nos encargamos de guardar los alimentos
      // del plato en la tabla PlatoAlimentos a través del objeto PlatoAlimento
      $this->saveEmbeddedForms($con);
    }
  }

Visto el proceso de guardado de un form con symfony (muy resumido) volvemos preguntarnos donde actualizar nuestro campo virtual ¿en el postSave del objeto Plato o en el postSave del objeto PlatoAlimento?.
Los postSave, preSave, postInsert, preInsert, etc, son hooks que facilita propel para introducir código antes y/o después de cada acción.
Pienso que lo más adecuado es hacerlo en el postSave de PlatoAlimento ya que el valor de nuestro campo virtual depende siempre del contenido existente en la tabla plato_alimento. De ahí que cualquier cambio que se produzca en dicha tabla, independientemente de dónde y quién lo este produciendo, provoca automáticamente que nuestro campo virtual sea actualizado. De este modo el campo virtual del objeto Plato, siempre tiene un valor fiable en dicha columna.
Bien, ya sabemos quién debe disparar la “alerta” para cambiar nuestro campo virtual, veamos el código

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
  class PlatoAlimento extends BasePlatoAlimento
  {
    public function postSave(PropelPDO $con = null)
    {
      if ($con === null) {
        $con = Propel::getConnection(PlatoAlimentoPeer::DATABASE_NAME, Propel::CONNECTION_WRITE);
      }
      parent::postSave($con);
 
      $plato = $this->getPlato();
      // Método encargado de resetear el campo virtual
      $plato->cambiaColumnaVirtual();
      $plato->save($con);
    }
 
    // No solo tras insertar/actualizar debemos dar la alarma, cuando borremos también
    public function postDelete(PropelPDO $con = null)
    {
      if ($con === null) {
        $con = Propel::getConnection(PlatoAlimentoPeer::DATABASE_NAME, Propel::CONNECTION_WRITE);
      }
 
      $plato = $this->getPlato();
      // Método encargado de resetear el campo virtual
      $plato->cambiaColumnaVirtual();
      $plato->save($con);
 
      parent::postDelete($con);
 
    }
 
  }
 
  class Plato extends BasePlato
  {
    public function cambiaColumnaVirtual()
    {
      $this->setKilocalorias($this->CalculoKilocalorias());
    }
 
    public function getCalculoKilocalorias()
    {
      // aqui los cálculos necesarios
      return $valor;
    }
    // ...
  }

Hasta ahora hemos visto porqué agregar un campo virtual en symfony, cómo agregarlo y dónde. Pero hay una cosa más, el código de anterior tiene un problema.
Si lo ejecutásemos, nuestro objeto Plato no tendría actualizado el campo virtual cuando agregásemos un alimento nuevo al plato. ¿Porqué? Veámoslo.

Nuestro proyecto symfony está utilizando propel en su versión 1.4, y propel a partir de su versión 1.3 incorpora object instace pooling, es decir estamos “cacheando” objetos.
Dicho comportamiento está indicado en el fichero database.yml.

all:
  propel:
    class: sfPropelDatabase
    param:
      dsn:        mysql:dbname=miBBDD;host=localhost
      username:   mi_user
      password:   mi_clave
      encoding:   utf8
      persistent: true
      pooling:    true // Aqui está el parámetro
      classname:  PropelPDO

¿En qué o dónde nos afecta esto a la solución que habíamos planteado?.
Si os fijáis en el código del método postSave del objeto PlatoAlimento, hemos recuperado el objeto Plato ($this->getPlato()), y posteriormente hemos hemos actualizado nuestro campo virtual y ahí es donde está el problema, ya que el objeto Plato recuperado mantiene los datos “viejos” de los alimentos y todavía no se ha dado cuenta de nuestros cambios. Según esto todo el proceso de cálculo de las kilocalorías devolverá los mismos valores que teníamos.
¿Cómo lo solucionamos?
Todos los objetos de Symfony disponen de unos métodos para limpiar y obtener objetos del “pool”. Y uno de esos métodos es reload que recibe dos parámetros.
El primero es para indicar si hacemos el “reload” en profundidad, es decir recargando todos los objetos relacionados que puede tener. En nuestro caso nos interesa que relea todo lo relacionado con alimentos (PlatoAlimento). El segundo parámetro es el link de conexión a la base de datos ($con).

Mostremos el código final

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
  class PlatoAlimento extends BasePlatoAlimento
  {
    public function postSave(PropelPDO $con = null)
    {
      if ($con === null) {
        $con = Propel::getConnection(PlatoAlimentoPeer::DATABASE_NAME, Propel::CONNECTION_WRITE);
      }
      parent::postSave($con);
 
      $plato = $this->getPlato();
      // releemos los valores de plato así como de sus objetos relacionados
      $plato->reload(true,$con);
      // Método encargado de resetear el campo virtual
      $plato->cambiaColumnaVirtual();
      $plato->save($con);
    }
 
    // No solo tras insertar/actualizar debemos dar la alarma, cuando borremos también
    public function postDelete(PropelPDO $con = null)
    {
      if ($con === null) {
        $con = Propel::getConnection(PlatoAlimentoPeer::DATABASE_NAME, Propel::CONNECTION_WRITE);
      }
 
      $plato = $this->getPlato();
      // releemos los valores de plato así como de sus objetos relacionados
      $plato->reload(true,$con);
      // Método encargado de resetear el campo virtual
      $plato->cambiaColumnaVirtual();
      $plato->save($con);
 
      parent::postDelete($con);
    }
 
  }
 
  class Plato extends BasePlato
  {
    public function cambiaColumnaVirtual()
    {
      $this->setKilocalorias($this->CalculoKilocalorias());
    }
 
    public function getCalculoKilocalorias()
    {
      // aqui los cálculos necesarios
      return $valor;
    }
    // ...
  }

Eso es todo! Ya hemos visto como mejorar un poco nuestras aplicaciones symfony. Todo lo expuesto aquí esta abierto a debate y mejoras, así que no dudéis en comentarlas.
Un cosa más, tened en cuenta que nuestra aplicación web para la gestión de platos las consultas de insert/update van a ser mucho menores que las de selección. Esto es un factor a tener en cuenta a la hora de buscar mejorar el rendimiento. Si las acciones de inserción/borrado/actualización fuesen mayores habría que plantearse otra estrategia de optimización.

3 Comments
  1. Muy bien explicado todo. Gracias por compartir la solución al problema!

  2. Bastante bien explicado, además, has conseguido mezclar dos cosas que me gustan bastante: la comida y la programación, ;) . Intentaré reproducir el ejemplo con Doctrine, a ver si tengo el mismo problema que tú. Muchas gracias.

  3. Esperamos el ejemplo de javi en doctrine

Leave a Reply

Note: XHTML is allowed. Your email address will never be published.

Subscribe to this comment feed via RSS