Symfony 1.4, la guía definitiva

18.2. Optimizando el modelo

En Symfony, la capa del modelo tiene fama de ser el componente más lento. Si las pruebas de rendimiento demuestran que se debe optimizar esta capa para una aplicación, a continuación se muestran las posibles mejoras que se pueden realizar.

18.2.1. Optimizando la integración de Propel o Doctrine

Inicializar la capa del modelo (las clases internas del ORM) requiere cierto tiempo, ya que se deben cargar algunas clases y se deben construir algunos objetos. No obstante, por la forma en la que Symfony integra los dos ORM, esta inicialización solamente se produce cuando una acción requiere realmente utilizar el modelo, por lo que si sucede, se realiza lo más tarde posible. Las clases del ORM se inicializan solamente cuando un objeto del modelo generado se carga automáticamente. Por tanto, las páginas que no utilizan el modelo no se ven penalizadas por la capa del modelo.

Si una aplicación no necesita la capa del modelo, se puede evitar la inicialización del objeto sfDatabaseManager desactivando por completo la capa del modelo mediante la siguiente opción del archivo settings.yml:

all:
  .settings:
    use_database: false

18.2.2. Mejoras para Propel

Las clases generadas para el modelo (en lib/model/om/) ya están optimizadas porque se les han eliminado los comentarios y también se cargan de forma automática cuando es necesario. Utilizar el sistema de carga automática en vez de incluir las clases a mano, garantiza que las clases se cargan solamente cuando son realmente necesarias. Por tanto, si una clase del modelo no se utiliza, el mecanismo de carga automática ahorra tiempo de ejecución, mientras que la alternativa de utilizar sentencias include de PHP no podría ahorrarlo. En lo que respecta a los comentarios, se utilizan para documentar el uso de los métodos generados, pero aumentan mucho el tamaño de los archivos, lo que disminuye el rendimiento en los sistemas con discos duros lentos. Como los métodos de las clases generadas tienen nombres muy explícitos, los comentarios se desactivan por defecto.

Estas dos mejoras son específicas de Symfony, pero se puede volver a las opciones por defecto de Propel cambiando estas dos opciones en el archivo propel.ini, como se muestra a continuación:

propel.builder.addIncludes = true   # Añadir sentencias "include" en las clases generadas
                                    #  en vez de utiliza la carga automática de clases
propel.builder.addComments = true   # Añadir comentarios a las clases generadas

18.2.2.1. Limitando el número de objetos que se procesan

Cuando se utiliza un método de una clase peer para obtener los objetos, el resultado de la consulta pasa el proceso de "hidratación" "hydrating" en inglés) en el que se crean los objetos y se cargan con los datos de las filas devueltas en el resultado de la consulta. Para obtener por ejemplo todas las filas de la tabla articulo mediante Propel, se ejecuta la siguiente instrucción:

$articulos = ArticuloPeer::doSelect(new Criteria());

La variable $articulos resultante es un array con los objetos de tipo Article. Cada objeto se crea e inicializa, lo que requiere cierta cantidad de tiempo. La consecuencia de este comportamiento es que, al contrario de lo que sucede con las consultas a la base de datos, la velocidad de ejecución de una consulta Propel es directamente proporcional al número de resultados que devuelve. De esta forma, los métodos del modelo deberían optimizarse para devolver solamente un número limitado de resultados. Si no se necesitan todos los resultados devueltos por Criteria, se deberían limitar mediante los métodos setLimit() y setOffset(). Si solamente se necesitan por ejemplo las filas de datos de la 10 a la 20 para una consulta determinada, se puede refinar el objeto Criteria como se muestra en el listado 18-1.

Listado 18-1 - Limitando el número de resultados devueltos por Criteria

$c = new Criteria();
$c->setOffset(10);  // Posición de la primera fila que se obtiene
$c->setLimit(10);   // Número de filas devueltas
$articulos = ArticuloPeer::doSelect($c);

El código anterior se puede automatizar utilizando un paginador. El objeto sfPropelPager gestiona de forma automática los valores offset y limit para una consulta Propel, de forma que solamente se crean los objetos mostrados en cada página.

18.2.2.2. Minimizando el número de consultas mediante Joins

Mientras se desarrolla una aplicación, se debe controlar el número de consultas a la base de datos que realiza cada petición. La barra de depuración web muestra el número de consultas realizadas para cada página y al pulsar sobre el pequeño icono de una base de datos, se muestra el código SQL de las consultas realizadas. Si el número de consultas crece de forma desproporcionada, seguramente es necesario utilizar una Join.

Antes de explicar los métodos para Joins, se muestra lo que sucede cuando se recorre un array de objetos y se utiliza un método getter de Propel para obtener los detalles de la clase relacionada, como se ve en el listado 18-2. Este ejemplo supone que el esquema describe una tabla llamada articulo con una clave externa relacionada con la tabla autor.

Listado 18-2 - Obteniendo los detalles de una clase relacionada dentro de un bucle

// En la acción, Propel
$this->articulos = ArticuloPeer::doSelect(new Criteria());

// En la acción, Doctrine
$this->articulos = Doctrine::getTable('Articulo')->findAll();

// Consulta realizada en la base de datos por doSelect()
SELECT articulo.id, articulo.titulo, articulo.autor_id, ...
FROM   articulo

// En la plantilla
<ul>
<?php foreach ($articulos as $articulo): ?>
  <li><?php echo $articulo->getTitulo() ?>,
    escrito por <?php echo $articulo->getAutor()->getNombre() ?></li>
<?php endforeach; ?>
</ul>

Si el array $articulos contiene 10 objetos, el método getAutor() se llama 10 veces, lo que implica una consulta con la base de datos cada vez que se tiene que crear un objeto de tipo Autor, como se muestra en el listado 18-3.

Listado 18-3 - Los métodos getter de las claves externas, implican una consulta a la base de datos

// En la plantilla
$articulo->getAutor()

// Consulta a la base de datos producida por getAutor()
SELECT autor.id, autor.nombre, ...
FROM   autor
WHERE  autor.id = ?                // ? es articulo.autor_id

Por tanto, la página que genera el listado 18-2 implica 11 consultas a la base de datos: una consulta para construir el array de objetos Articulo y otras 10 consultas para obtener el objeto Autor asociado a cada objeto anterior. Evidentemente, se trata de un número de consultas muy grande para mostrar simplemente un listado de los artículos disponibles y sus autores.

18.2.3. Optimizar las consultas Propel

Si se utiliza directamente SQL, es muy fácil reducir al mínimo el número de consultas, obteniendo las columnas de la tabla articulo y las de la tabla autor mediante una única consulta. Esto es exactamente lo que hace el método doSelectJoinAutor() de la clase ArticuloPeer. Este método realiza una consulta más compleja que un simple doSelect(), y las columnas adicionales que están presentes en el resultado obtenido permiten a Propel "hidratar" tanto los objetos de tipo Articulo como los objetos de tipo Autor. El código del listado 18-4 produce el mismo resultado que el del listado 18-2, pero solamente requiere 1 consulta con la base de datos en vez de 11 consultas, por lo que es mucho más rápido.

Listado 18-4 - Obteniendo los detalles de los artículos y sus autores en la misma consulta

// En la acción
$this->articulos = ArticuloPeer::doSelectJoinAutor(new Criteria());

// Consulta a la base de datos realizada por doSelectJoinAutor()
SELECT articulo.id, articulo.titulo, articulo.autor_id, ...
       autor.id, autor.name, ...
FROM   articulo, autor
WHERE  articulo.autor_id = autor.id

// En la plantilla no hay cambios
<ul>
<?php foreach ($articulos as $articulo): ?>
  <li><?php echo $articulo->getTitulo() ?>,
    escrito por <?php echo $articulo->getAutor()->getNombre() ?></li>
<?php endforeach; ?>
</ul>

No existen diferencias entre el resultado devuelto por doSelect() y el resultado devuelto por doSelectJoinXXX(); los dos métodos devuelven el mismo array de objetos (de tipo Articulo en este ejemplo). La diferencia se hace evidente cuando se utiliza un método getter asociado con una clave externa. En el caso del método doSelect(), se realiza una consulta a la base de datos y se crea un nuevo objeto con el resultado; en el caso del método doSelectJoinXXX(), el objeto asociado ya existe y no se realiza la consulta con la base de datos, por lo que el proceso es mucho más rápido. Por tanto, si se sabe de antemano que se van a utilizar los objetos relacionados, se debe utilizar el método doSelectJoinXXX() para reducir el número de consultas a la base de datos y por tanto, para mejorar el rendimiento de la página.

El método doSelectJoinAutor() se genera automáticamente cuando se ejecuta la tarea propel-build-model, debido a la relación entre las tablas articulo y autor. Si existen otras claves externas en la tabla del artículo, por ejemplo una tabla de categorías, la clase BaseArticuloPeer generada contendría otros métodos Join, como se muestra en el listado 18-5.

Listado 18-5 - Ejemplo de métodos doSelect disponibles para una clase ArticuloPeer

// Obtiene objetos "Articulo"
doSelect()

// Obtiene objetos "Articulo" y crea los objetos "Autor" relacionados
doSelectJoinAutor()

// Obtiene objetos "Articulo" y crea los objetos "Categoria" relacionados
doSelectJoinCategoria()

// Obtiene objetos "Articulo" y crea todos los objetos relacionados salvo "Autor"
doSelectJoinAllExceptAutor()

// Obtiene objetos "Articulo" y crea todos los objetos relacionados
doSelectJoinAll()

Las clases peer también disponen de métodos Join para doCount(). Las clases que soportan la internacionalización (ver Capítulo 13) disponen de un método doSelectWithI18n(), que se comporta igual que los métodos Join, pero con los objetos de tipo i18n. Para descubrir todos los métodos de tipo Join generados para las clases del modelo, es conveniente inspeccionar las clases peer generadas en el directorio lib/model/om/. Si no se encuentra el método Join necesario para una consulta (por ejemplo no se crean automáticamente los métodos Join para las relaciones muchos-a-muchos), se puede crear un método propio que extienda el modelo.

Nota Evidentemente, la llamada al método doSelectJoinXXX() es un poco más lenta que la llamada a un método simple doSelect(), por lo que solamente mejora el rendimiento global de la página si se utilizan los objetos relacionados.

18.2.4. Optimizar las consultas Doctrine

Doctrine define su propio lenguaje para consultas llamado DQL o Doctrine Query Language. Su sintaxis es muy similar a la de SQL, pero se emplea para obtener objetos en vez de filas de tablas. Con SQL podrías obtener en una sola consulta las columnas de la tabla articulo y autor. Con DQL también resulta muy sencillo hacerlo, ya que sólo debes añadir una sentencia de tipo join en la consulta original. Doctrine se encarga de hidratar correctamente todos los objetos relacionados. El siguiente ejemplo muestra cómo realizar una unión entre dos tablas:

// en la acción
Doctrine::getTable('Articulo')
  ->createQuery('a')
  ->innerJoin('a.Autor')  // "a.Autor" hace referencia a la relación llamada 'Autor'
  ->execute();

// La plantilla no requiere cambios
<ul>
<?php foreach ($articulos as $articulo): ?>
  <li><?php echo $articulo->getTitulo() ?>,
    escrito por <?php echo $articulo->getAutor()->getNombre() ?></li>
<?php endforeach; ?>
</ul>

18.2.5. Evitar el uso de arrays temporales

Cuando se utiliza Propel, los objetos creados ya contienen todos los datos, por lo que no es necesario crear un array temporal de datos para la plantilla. Los programadores que no están acostumbrados a trabajar con ORM suelen caer en este error. Estos programadores suelen preparar un array de cadenas de texto o de números para las plantillas, mientras que, en realidad, las plantillas pueden trabajar directamente con los arrays de objetos. Si la plantilla por ejemplo muestra la lista de títulos de todos los artículos de la base de datos, un programador que no está acostumbrado a trabajar de esta forma puede crear un código similar al del listado 18-6.

Listado 18-6 - Crear un array temporal en la acción es inútil si ya se dispone de un array de objetos

// En la acción
$articulos = ArticuloPeer::doSelect(new Criteria());
$titulos = array();
foreach ($articulos as $articulo)
{
  $titulos[] = $articulo->getTitulo();
}
$this->titulos = $titulos;

// En la plantilla
<ul>
<?php foreach ($titulos as $titulo): ?>
  <li><?php echo $titulo ?></li>
<?php endforeach; ?>
</ul>

El problema del código anterior es que el proceso de creación de objetos del método doSelect() hace que crear el array $titulos sea inútil, ya que el mismo código se puede reescribir como muestra el listado 18-7. De esta forma, el tiempo que se pierde creando el array $titulos se puede aprovechar para mejorar el rendimiento de la aplicación.

Listado 18-7 - Utilizando el array de objetos, no es necesario crear un array temporal

// En la acción, con Propel
$this->articulos = ArticuloPeer::doSelect(new Criteria());

// En la acción, con Doctrine
$this->articulos = Doctrine::getTable('Articulo')->findAll();

// En la plantilla
<ul>
<?php foreach ($articulos as $articulo): ?>
  <li><?php echo $articulo->getTitulo() ?></li>
<?php endforeach; ?>
</ul>

Si realmente es necesario crear un array temporal porque se realiza cierto procesamiento con los objetos, la mejor solución es la de crear un nuevo método en la clase del modelo que devuelva directamente ese array. Si por ejemplo se necesita un array con los títulos de los artículos y el número de comentarios de cada artículo, la acción y la plantilla deberían ser similares a las del listado 18-8.

Listado 18-8 - Creando un método propio para preparar un array temporal

// En la acción
$this->articulos = ArticuloPeer::getArticuloTitulosConNumeroComentarios();

// En la plantilla
<ul>
<?php foreach ($articulos as $articulo): ?>
  <li><?php echo $articulo['titulo'] ?> (<?php echo $articulo['numero_comentarios'] ?> comentarios)</li>
<?php endforeach; ?>
</ul>

Solamente falta crear un método getArticuloTitulosConNumeroComentarios() muy rápido en el modelo, que se puede crear saltándose por completo el ORM y todas las capas de abstracción de bases de datos.

18.2.6. Saltándose el ORM

Cuando no se quieren utilizar los objetos completos, sino que solamente son necesarias algunas columnas de cada tabla (como en el ejemplo anterior) se pueden crear métodos específicos en el modelo que se salten por completo la capa del ORM. Se puede utilizar por ejemplo PDO para acceder directamente a la base de datos y devolver un array con un formato propio, como se muestra en el listado 18-9.

Listado 18-9 - Accediendo directamente con PDO para optimizar los métodos del modelo, en lib/model/ArticuloPeer.php

// Con Propel
class ArticuloPeer extends BaseArticuloPeer
{
  public static function getArticuloTitulosConNumeroComentarios()
  {
    $conexion = Propel::getConnection();
    $consulta = 'SELECT %s as titulo, COUNT(%s) AS numero_comentarios FROM %s LEFT JOIN %s ON %s = %s GROUP BY %s';
    $consulta = sprintf($consulta,
      ArticuloPeer::TITULO, ComentarioPeer::ID,
      ArticuloPeer::TABLE_NAME, ComentarioPeer::TABLE_NAME,
      ArticuloPeer::ID, ComentarioPeer::ARTICULO_ID,
      ArticuloPeer::ID
    );

    $sentencia = $conexion->prepare($consulta);
    $sentencia->execute();

    $resultados = array();
    while ($resultset = $sentencia->fetch(PDO::FETCH_OBJ))
    {
      $resultados[] = array('titulo' => $resultset->titulo, 'numero_comentarios' => $resultset->numero_comentarios);
    }

    return $resultados;
  }
}

// Con Doctrine
class ArticuloTable extends Doctrine_Table
{
  public function getArticuloTitulosConNumeroComentarios()
  {
    return $this->createQuery('a')
        ->select('a.titulo, count(*) as numero_comentarios')
        ->leftJoin('a.Comentarios')
        ->groupBy('a.id')
        ->fetchArray();
  }
}

Si se crean muchos métodos de este tipo, se puede acabar creando un método específico para cada acción, perdiendo la ventaja de la separación en capas y la abstracción de la base de datos.

18.2.7. Optimizando la base de datos

Existen numerosas técnicas para optimizar la base de datos y que pueden ser aplicadas independientemente de Symfony. En esta sección, se repasan brevemente algunas de las estrategias más utilizadas, aunque es necesario un buen conocimiento de motores de bases de datos para optimizar la capa del modelo.

Nota Recuerda que la barra de depuración web muestra el tiempo de ejecución de cada consulta realizada por la página, por lo que cada cambio que se realice debería comprobarse para ver si realmente reduce el tiempo de ejecución.

A menudo, las consultas a las bases de datos se realizan sobre columnas que no son claves primarias. Para aumentar la velocidad de ejecución de esas consultas, se deben crear índices en el esquema de la base de datos. Para añadir un índice a una columna, se añade la propiedad index: true a la definición de la columna, tal y como muestra el listado 18-10.

Listado 18-10 - Añadiendo un índice a una sola columna, en config/schema.yml

# Esquema de Propel
propel:
  articulo:
    id:
    autor_id:
    titulo:   { type: varchar(100), index: true }

# Esquema de Doctrine
Articulo:
  columns:
    autor_id: integer
    titulo:   string(100)
  indexes:
    titulo:
      fields: [titulo]

Se puede utilizar de forma alternativa el valor index: unique para definir un índice único en vez de un índice normal. El archivo schema.yml también permite definir índices sobre varias columnas (el Capítulo 8 contiene más información sobre la sintaxis de los índices). El uso de índices es muy recomendable, ya que es una buena forma de acelerar las consultas más complejas.

Después de añadir el índice al esquema, se debe añadir a la propia base de datos: directamente mediante una sentencia de tipo ADD INDEX o mediante el comando propel-build-all (que no solamente reconstruye la estructura de la tabla, sino que borra todos los datos existentes).

Nota Las consultas de tipo SELECT son más rápidas cuando se utilizan índices, pero las sentencias de tipo INSERT, UPDATE y DELETE son más lentas. Además, los motores de bases de datos solamente utilizan 1 índice en cada consulta y determinan el índice a utilizar en cada consulta mediante métodos heurísticos internos. Por tanto, se deben medir las mejoras producidas por la creación de los índices, ya que en ocasiones las mejoras producidas en el rendimiento son muy escasas.

A menos que se especifique lo contrario, en Symfony cada petición utiliza una conexión con la base de datos y esa conexión se cierra al finalizar la petición. Se pueden habilitar conexiones persistentes con la base de datos, de forma que se cree un pool de conexiones abiertas con la base de datos y se reutilicen en las diferentes peticiones. La opción que se debe utilizar es persistent: true en el archivo databases.yml, como muestra el listado 18-11.

Listado 18-11 - Activar las conexiones persistentes con la base de datos, en config/databases.yml

prod:
  propel:
    class:         sfPropelDatabase
    param:
      dsn:         mysql:dbname=example;host=localhost
      username:    username
      password:    password
      persistent:  true      # Use persistent connections

Esta opción puede mejorar el rendimiento de la base de datos o puede no hacerlo, dependiendo de numerosos factores. En Internet existe mucha documentación sobre las posibles mejoras que produce. Por tanto, es conveniente hacer pruebas de rendimiento sobre la aplicación antes y después de modificar el valor de esta opción.