Symfony 1.1, 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

Inicializar la capa del modelo (las clases internas de Propel) 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 Propel, 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 Propel 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: off

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 2 mejoras son específicas de Symfony, pero se puede volver a las opciones por defecto de Propel cambiando estas 2 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. 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. La documentación del paginador (http://www.symfony-project.org/cookbook/1_1/pager) dispone de más información sobre esta clase.

18.2.3. 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
$this->articulos = ArticuloPeer::doSelect(new Criteria());

// 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.

Si se utiliza directamente SQL, es muy fácil reducir el número de consultas a solamente 1, 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.

Truco 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. 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
$this->articulos = ArticuloPeer::doSelect(new Criteria());

// 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[0] ?> (<?php echo $articulo[1] ?> 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.5. 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 Creole 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 Creole para optimizar los métodos del modelo, en lib/model/ArticuloPeer.php

class ArticuloPeer extends BaseArticuloPeer
{
  public static function getArticuloTitulosConNumeroComentarios()
  {
    $conexion = Propel::getConnection();
    $consulta = 'SELECT %s as titulo, COUNT(%s) AS num_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->prepareStatement($consulta);
    $resultset = $sentencia->executeQuery();
    $resultados = array();
    while ($resultset->next())
    {
      $resultados[] = array($resultset->getString('titulo'), $resultset->getInt('num_comentarios'));
    }

    return $resultados;
  }
}

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.

Truco Si Propel no es adecuado para la capa del modelo de algún proyecto, es mejor considerar el uso de otros ORM antes de escribir todas las consultas a mano. El plugin sfDoctrine proporciona una interfaz para el ORM PhpDoctrine. Además, se puede utilizar otra capa de abstracción de bases de datos en vez de Creole. Desde la versión PHP 5.1, se encuentra incluida en PHP la capa de abstracción PDO, que ofrece una alternativa mucho más rápida que Creole.

18.2.6. 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.

Truco 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

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

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).

Truco 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:
      persistent:   true
      dsn:          mysql://login:passwd@localhost/blog

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.