Parte 5: Optimización de queries con Doctrine, ajustes y página de post en Symfony 6
En este tutorial profundizaremos en el ORM Doctrine para optimizar nuestras queries y por consiguiente la velocidad de respuesta de nuestra web y añadiremos algunos ajustes en nuestro proyecto en Symfony 6. También crearemos la página para consultar un post en concreto y veremos como generar las urls automáticamente a cada post en Twig.
👋¡Hola! Una semana más seguimos con esta serie de Symfony 6 y esta vez nos vamos a centrar más en unos pequeños ajustes en nuestro proyecto y algo muy importante que es la optimización de queries. Yo creo que todos hemos oído alguna vez eso de que Doctrine es lento o el cuello de botella en un proyecto en Symfony 6 y muchas veces tiene que ver con el mal uso (o uso por defecto) de este. Hoy vamos a poner solución a eso 💪.
Antes de empezar 🛑. Si te has perdido alguno de los tutoriales de esta serie, aquí te dejo todos los tutoriales escritos hasta ahora:
- Parte 1: Cómo crear una webapp con Symfony 6
- Parte 2: Cómo crear entidades, migraciones y conectarnos a una base de datos con Symfony 6
- Parte 3: Cómo crear datos falsos en una base de datos con Symfony 6
- Parte 4: Controladores, templates y estilos con Tailwind CSS en Symfony 6
- Parte 5: Optimización de queries con Doctrine, ajustes y página de post en Symfony 6
- Parte 6: Autenticación y registro con Symfony 6
Ajustes en PostFactory para que el contenido de un post sea mayor
Actualmente, los posts que generamos con faker tienen un contenido muy pequeño de apenas una línea. Para mejorar la visualización de un post vamos a modificar esto. Para ello, abrimos el archivo src\Factory\PostFactory.php y realizamos el siguiente cambio en la clave "content" dentro del método getDefaults():
return [
'content' => self::faker()->text(500),
...
];
Como ves, self::faker()->text(500) recibe ahora como parámetro 500, el cual indica el número de caracteres que queremos que tenga el texto. De esta forma, el contenido del post será mayor.
Como siempre que actualizamos un Factory, debemos relanzar el siguiente comando que borrará la información de la base de datos y la creará de nuevo:
php bin/console doctrine:fixtures:load
Página de post en concreto
Ahora que ya tenemos más información, es hora de poder visualizar nuestros posts. Para ello abrimos el archivo src\Controller\BlogController.php y añadimos el siguiente método:
#[Route('/posts/{slug}', name: 'get_post')]
public function getPost(Post $post): Response
{
return $this->render('blog/post.html.twig', [
'post' => $post,
]);
}
En este código vemos un poco la magia de Symfony 🤔. En la ruta, definimos que los posts se encuentran en el segmento /posts/{slug}. Lo que está entre llaves, significa que puede ser cualquier cadena de caracteres y la palabra que escribamos, será el nombre del parámetro que guardará esa cadena de caracteres, por ejemplo:
/posts/mi-post-1 (slug: mi-post-1)
/posts/esto-es-otro-post (slug: esto-es-otro-post)
/posts/hoy-estoy-creando-muchos-posts (slug: hoy-estoy-creando-muchos-posts)
Después definimos el nombre del recurso, en este caso get_post y ahora viene lo interesante. Por parámetro, el método getPost recibe un objeto de la entidad Post. Esto es así gracias al paquete (sensio/framework-extra-bundle) el cual ya tenemos por la instalación que hicimos en el primer tutorial y realiza las siguientes acciones:
- En la ruta recibe el parámetro slug el cual almacena la ruta enviada.
- En el método getPost recibimos por parámetro un objeto de tipo Post por lo que gracias al paquete que mencioné antes, el cual tiene soporte para conversión de parámetros que es lo que hace la "magia".
- Con el parámetro enviado llamado slug, hace una consulta a la tabla posts en el que realiza la búsqueda de un post por la columna "slug" y con el valor enviado.
- Aquí importante es que se llama slug porque le dimos ese nombre a la columna. Si por ejemplo queremos buscar por id, podemos enviar el id por URL. En la ruta lo llamamos id (/posts/{id} y realizaría la búsqueda por esa columna y valor.
- Si no encuentra el post ya sea porque hayamos escrito mal la ruta o porque lo hemos borrado, devolverá un error 404.
Si queréis más información acerca de este punto, os dejo un enlace a la documentación:
https://symfony.com/doc/current/routing.html#parameter-conversion
En el siguiente paso, retornamos el renderizado de una plantilla que crearemos más adelante llamada "blog/post.html.twig" y enviamos el post por parámetro.
Ahora creamos el archivo templates\blog\post.html.twig y añadimos el siguiente código:
{% extends 'base.html.twig' %}
{% block title %}{{post.title}}{% endblock %}
{% block body %}
<div class="mt-10 md:flex justify-center">
<div class="max-w-full flex-row justify-center mb-8 md:mr-2">
<article class="max-w-xl text-center bg-white shadow-md mb-4 p-2">
<a class="block mb-4" href="#">
<h2 class="text-2xl font-bold">{{post.title}}</h2>
<div>{{post.user.username}}</div>
<time class="text-gray-500" datetime="{{post.publicationDate|date("Y-m-d")}}">
{{post.publicationDate|date("Y-m-d H:i:s")}}
</time>
</a>
<p class="text-justify">{{post.content}}</p>
<div class="my-2">
{% for tag in post.tags %}
<div class="tags">#{{tag.name}}</div>
{% endfor %}
</div>
<div class="m-1 text-left">
<h3>Comentarios</h3>
<hr>
<div class="my-3 text-left">
{% for comment in post.comments %}
<div class="p-3">
<div>Escrito por: <strong>{{comment.user.username}}</strong></div>
<div class="text-sm text-stone-500">{{comment.content}}</div>
</div>
<hr>
{% endfor %}
</div>
</div>
</article>
</div>
<section>
<div class="popular-posts text-center bg-white border-gray-300 mb-4 pb-4 md:w-64 shadow-md">
<div>
<h3 class="font-bold text-2xl text-gray-900 border-b-2 border-gray-100 p-2 mb-2">Recomendados</h3>
</div>
<div>
<ul>
<li>
<a href="http://">Título del post 1</a>
</li>
<li>
<a href="http://">Título del post 2</a>
</li>
<li>
<a href="http://">Título del post 3</a>
</li>
</ul>
</div>
</div>
</section>
</div>
{% endblock %}
Este caso es similar al que usamos para la template de posts, pero en este caso al ser solo uno no necesitamos recorrerlo. Como innovación frente a la template anterior, aquí mostramos el username del usuario al que pertenece el post con {{post.user.username}} y también el listado de comentarios relacionados con el post.
Para que desde la página de inicio podamos acceder a un post pulsando en su título, debemos realizar el siguiente cambio en la template templates\blog\index.html.twig:
...
{% for post in posts %}
<article class="max-w-xl text-center bg-white shadow-md mb-4 p-2">
<a class="block mb-4" href="{{ path('get_post', {slug: post.slug })}}">
<h2 class="text-2xl font-bold">{{post.title}}</h2>
...
Como ves, solo he modificado el href de la etiqueta a. En ella utilizo la función path la cual recibe como primer parámetro el nombre del recurso al que queremos acceder. Este nombre es el que damos al definir la ruta en el controlador. Cómo parámetro recibe el atributo slug el cual su valor será el slug del post que se está pintando.
Si accedemos a la página principal de nuestro proyecto y pulsamos en cualquiera de nuestros posts, ahora podremos acceder a ellos.
Ya que estamos, vamos a añadir el enlace para acceder a la página de inicio desde la cabecera. Para ello abrimos el archivo templates\base.html.twig y modificamos el siguiente segmento de la plantilla:
...
<li>
<a href="{{ path('get_posts') }}">Inicio</a>
</li>
...
Al igual que en el caso anterior, enviamos por parámetro el nombre del recurso a acceder y en este caso no necesitamos enviar ningún parámetro adicional, así que lo dejamos así.
Optimización de Queries con Doctrine
Cuando visualizamos una web desde el navegador, si te fijas, abajo del todo tenemos una barra con mucha información llamada Symfony Profiler.
Esta es una herramienta de debug que nos devuelve información acerca de una petición como las consultas a la base de datos realizadas, velocidad de respuesta, el controlador que accede al recurso, estado de la respuesta y mucha más información que puedes visualizar pulsando en esta barra o desde la documentación que te dejo aquí abajo:
https://symfony.com/doc/current/profiler.html
Pues bien, hay una parte que casi me hizo llorar y es el número de queries realizadas para obtener el listado de post:
¿41 queries para mostrar el listado de posts?
Esto es una barbaridad y cuantos más posts tengamos más va a repercutir en la velocidad de respuesta de nuestra web. Cuando hacemos una búsqueda a lo bruto con el método findAll() de la clase PostRepository lo que hacemos es traer el listado de post y además realiza las siguientes queries adicionales:
Una consulta para obtener el usuario al que pertenece cada post el cual ni siquiera estamos pintando ninguna información en la página principal.
Otra consulta para obtener los tags relacionados con ese post y una tercera consulta para obtener los comentarios de cada post del cual solo nos interesa el número total de comentarios, así que vamos a solucionarlo para que en dos únicas consultas obtengamos toda la información que necesitemos.
Recuperar el listado de tags por post
Para recuperar el listado de tags por post abrimos el archivo src\Repository\TagRepository.php el cual deberíamos usar cada vez que queramos crear un método que realice alguna acción con la tabla tags y añadimos el siguiente método:
public function getTagsByPostIds(array $post_ids): array
{
$tags = $this->createQueryBuilder('t')
->select('p.id, t.name')
->innerJoin('t.posts', 'p')
->andWhere('p.id IN (:post_ids)')
->setParameter('post_ids', $post_ids)
->getQuery()
->getResult();
$tags_by_post = [];
foreach($tags as $tag) {
if(!isset($tags_by_post[$tag['id']])) {
$tags_by_post[$tag['id']] = [];
}
$tags_by_post[$tag['id']][] = $tag['name'];
}
return $tags_by_post;
}
Aquí utilizo el Query Builder de Doctrine el cual tiene un lenguaje similar a SQL en el que vamos llamando a métodos para construir la query. Te voy a explicar qué hago en este caso y también te dejo el link a la documentación para que aprendas a realizar consultas según las necesidades de tus futuros proyectos:
- Aquí tenemos un método que recibe un array llamado $post_ids que contendrá un listado de ids de posts.
- El primer paso es crear el query Builder con "$this->createQueryBuilder('t')". Este recibe por parámetro un alias para la tabla tags llamado t. Al estar dentro de TagRepository no hace falta que indiquemos la tabla a la que queremos hacer referencia, ya que la tiene asignada por defecto.
- En el método select indico los parámetros que necesito, en este caso el id del post y el nombre del tag.
- Después uso el método innerJoin para crear la unión con la tabla posts.
- Con el método andWhere indicio la condición para obtener el listado de tags que será todos los nombres de tags que estén relacionados con los post ids enviados.
- Con el método setParameter seteo el parámetro definido en el método andWhere.
- Luego con el método getQuery creamos una instancia de la clase Query de Doctrine con todo la información que hemos añadido en el paso anterior.
- Por último, con el método getResults, ejecutamos la query y obtenemos un array con los resultados.
Una vez tenemos la información, he creado un array asociativo en el que guardo los nombres de los tags asociados a un post y retorno el resultado.
Ahora nos dirigimos al archivo src\Repository\PostRepository.php y añadimos el siguiente código:
...
use App\Repository\TagRepository;
...
public function __construct(ManagerRegistry $registry, private TagRepository $tagRepository)
{
parent::__construct($registry, Post::class);
}
...
public function getPaginatedPosts(int $page = 1, int $limit = 10)
{
$posts = $this->createQueryBuilder('post')
->select('
post.id,
post.title,
post.slug,
post.publication_date,
post.content,
COUNT(comment.id) as total_comments
')
->leftJoin('post.comments', 'comment')
->groupBy('post.id')
->orderBy('post.publication_date', 'DESC')
->setFirstResult($limit * ($page - 1))
->setMaxResults($limit)
->getQuery()
->getResult()
;
$post_ids = array_map(fn($post) => $post['id'], $posts);
$tags_by_post = $this->tagRepository->getTagsByPostIds($post_ids);
foreach($posts as &$post) {
$post['tags'] = $tags_by_post[$post['id']] ?? [];
}
unset($post);
return $posts;
}
...
En este archivo he realizado tres cambios importantes:
Primero importo TagRepository, ya que la necesitaremos para recuperar los tags por post.
Después en el constructor creo una instancia de TagRespository automáticamente gracias a la inyección de dependencias de Symfony, además al definir la visibilidad (en este caso privado) y gracias a PHP 8, ya no necesito definir la variable de clase fuera del constructor y después setearla dentro de ella.
Por último, he creado un método llamado getpaginatedPosts el cual puede recibir dos parámetros para paginar nuestra respuesta y así no tener que mostrar todos los posts en la página principal.
Construyo el createQueryBuilder para obtener el listado de posts y aquí muy importante, solo selecciono los campos que me interesan para evitar obtener información que no necesitamos. Limito la respuesta con setMaxResults y el offset con setFirstResult.
Después de obtener el resultado del queryBuilder, obtengo los ids de los posts y con ello lanzo el método que creamos anteriormente en TagRepository para obtener los tags relacionados con un post.
Por último, añado los tags a mi listado de posts y retorno el resultado.
Ahora nos dirigimos a src\Controller\BlogController.php y realizamos el siguiente cambio para ahora obtener los posts con nuestro nuevo método:
public function index(PostRepository $postRepository): Response
{
return $this->render('blog/index.html.twig', [
'posts' => $postRepository->getPaginatedPosts()
]);
}
Antes de recargar la página, debemos ir a la template templates\blog\index.html.twig y realizar unos cambios:
...
{% for post in posts %}
<article class="max-w-xl text-center bg-white shadow-md mb-4 p-2">
<a class="block mb-4" href="{{ path('get_post', {slug: post.slug })}}">
<h2 class="text-2xl font-bold">{{post.title}}</h2>
<time class="text-gray-500" datetime="{{post.publication_date|date("Y-m-d")}}">
{{post.publication_date|date("Y-m-d H:i:s")}}
</time>
</a>
<p class="text-justify">{{post.content}}</p>
<div class="my-2">
{% for tag in post.tags %}
<div class="tags">#{{tag}}</div>
{% endfor %}
</div>
<div class="inline-block w-full align-middle my-2">
<i class="fas fa-comments"></i> <span>{{post.total_comments}} Comentarios</span>
</div>
</article>
{% endfor %}
...
Básicamente, solo he cambiado dos cosas. Primero ahora es post.publication_date en vez de post.publicationDate porque antes nos devolvía un objeto de la entidad post y ahora nos devuelve un array con el resultado.
El segundo cambio es en los comentarios. Ahora accedemos a post.total_coments para obtener el número de comentarios.
Y listo, si ahora recargamos vemos que solo necesitamos dos queries para cargar toda la información. Te animo a que completes por tu lado todo el sistema de paginación y me cuentes si lo has conseguido en los comentarios.
Consejos para la paginación:
- Crear un nuevo método en PostRepository para obtener el número total de posts.
- En la respuesta del controlador ya definir página siguiente y anterior con los query params de limit y page. Investiga como recuperarlos en el controlador y envíalos al método.
- Crea en la template botones para ir navegando por las páginas.
En el próximo tutorial veremos como autenticarnos en nuestra página y trabajaremos con los formularios para crear comentarios.
Como siempre os dejo el enlace al repo por si tenéis cualquier problema https://github.com/albertorc87/blog-symfony-tutorial/tree/optimizacion-queries-doctrine-ajustes-pagina-post-symfony-6
Espero que este post te ayude y como siempre, te recomiendo seguirme en Twitter para estar al tanto de los nuevo contenido. Ahora también puedes seguirme en Instagram donde estoy subiendo tips, tutoriales en vídeo e información sobre herramientas para developers.
Por último os dejo mi guía para aprender a trabajar con APIs donde explico todo el funcionamiento de una API, el protocolo HTTP y veremos como construir una API con arquitectura REST.
Nos leemos 👋.