Estrategias de ruteado con Symfony

Normalmente cuando se afronta uno proyecto web, uno de los objetvos es conseguir urls “limpias” o “SEO friendly”. Para ello se suele buscar reducir al máximo el uso de parámetros o segmentos que no aporten información en las urls.

Si la estructura de urls tiene patrones facilmente separables (por ejemplo: /blog, /clientes, /productos) es fácil de conseguir. Pero si esa división de tipologías no existe, puede resultar mas complicado.

El sistema de enrutado de Symfony

El componente Routing (más info) de Symfony es el encarfado de enviar la petición que recibe nuestra aplicación al controlador y acción deseada. Cuando nuestra aplicación reciba una petición, será este componente el encargado de analizar la url y determinar que a que acción de que controlador ha de enviar el objeto Request para su procesado en función de las reglas que hallamos definido.

El fichero routing.yml

En este fichero se ha de indicar que Controllers queremos que la aplicación tenga en cuenta, que estrategia para definir las rutas queremos usar, así como otro tipo de restricciones como por ejemplo que metodos acepta una ruta o prefijos para todas las rutas de un controlador dado.

En el código del ejemplo le estamos diciendo a Symfony que todas las rutas que encajen en el patro dado por “path” se envíen a la acción deliver del controlador ImageDelivery

image_deliver:
path: /cache/{filter}/{path}
defaults: { _controller: AppBundle\ContentDelivery\ImageDeliveryController::deliverAction }
methods:

También se puede definir un conjunto de rutas que apunten a un controlador y dejar que sea el controlador el que, mediante anotaciones, decida a que acción enviar la petición

app:
resource: '@AppBundle/Controller/DefaultController.php'
type: annotation

Y finalmente tamvien podemos definir conjuntos de rutas que se resuelvan de manera personalizada

custom_content:
resource: "@AppBundle/Controller/CustomContent.php"
type: ruta_dinamica

Como es de esperar, no hay una solución válida para todos los casos, por ello mismo Symfony permite utilizar simultaneamente los diferentes sistemas.

Definir rutas concretas en routes.yml

Personalmente este es el método que menos uso, ya que considero que la información se dispersa y cuando estás editando routes.yml tienes que ser consciente de que acción va en cada controlador. Para aplicaciones pequeñas puede no ser un problema, pero conforme la aplicación crece, va a obligar a estar saltando de fichero en fichero para localizar la acción concreta.

Si que lo uso para casos muy específicos con controladores con muy pocas acciones, pero en general prefiero las anotaciones.

Por contra, si tenemos todas las rutas definidas explicitamente en routes.yml puede resultar mas sencillo resolver colisiones en las rutas.

Usando anotaciones.

A día de hoy es el método que más utilizo. Prefiero tener toda la información relacionada con una accion en un solo sitio. Esto hace que a veces cuando un patron se solapa con otro pueda ser mas lioso de resolver, ya que tendremos que jugar tanto con el orden en el que se cargan los controladores en routes.yml así como con el orden de las acciones en cada uno de los controladores.

Precarga de rutas

Este método implica crear un servicio que extienda de la clase Symfony\Component\Config\Loader\Loader. Este servicio ha de implementar el método load que a su vez devuelve un objeto Symfony\Component\Routing\RouteCollection conteniendo parejas de nombres de ruta y definiciones de ruta.

Este servicio se ha de marcar con un tag router.loader

app.preloader_rutas:
class: AppBundle\Routing\RutasPersonalizadasLoader
tags:
- { name: routing.loader }

Dentro de ese servicio podemos por ejemplo recuperar todos los posts de la base de datos y generar las correspondientes rutas apuntando al controlador y acción exactos. Esto hace que en el momento de ejecución todas las rutas están creadas y son rutas concretas (si así lo definimos en el servicio) en vez de patrones que se puedan solapar.

Por contra, cada vez que creemos un nuevo contenido es necesario limpiar la cache de aplicación (comando cache:clear). En una aplicación pequeña, monolítica, y con pocas urls (unos pocos cientos) no vamos a notar mucho problema mas allá de tener que ejecutar el comando cada vez que publiquemos algo. En aplicaciones grandes con muchas rutas (decenas de miles o más) y si por ejemplo esas rutas depended de datos remotos que se obtienen via API puede ser un problema. La ejecución de ese comando puede llevar varios minutos o incluso horas. Durante ese tiempo nuestra aplicación no responderá peticiones, por lo que habrá que resolverlo a nivel sistemas (Proxy Cache, Balanceo…). Si estamos consumiendo apis de terceros, durante ese proceso estaremos haciendo cientos o miles de peticiones que pueden acabar con un corte del servicio o con una sobrecarga del servidor remoto. En entornos de desarrollo puede dispara los tiempos de desarrollo al tener que esperar 30 o 40 minutos mientras se regeneran las rutas despues de hacer un cambio.

Poniendolo todo junto

Imaginemos que tenemos esta estructura de urls

/
/{slug-post}
/{tag}
/{categoria-posts}

Recibimos una peticion a la url /routeado. Symfony no tienen ni idea de si “routeado” es un post, un tag o una categoría. Somos nosotros los que se lo tenemos que decir y actuar en cada caso.

Tanto si usamos anotaciones como las rutas concretas en routes.yml tenemos la ruta definida como /{slug} apuntando a un controlador y una acción. Es esa acción la que tiene la responsabilidad de determinar a que tipo de contenido se refiere “routeado”.

Hay que tener en cuenta que una vez que Symfony ha determinado que controlador y acción encaja con la ruta dada la petición se resuelve en esa acción. Si hay otra ruta que encaja en ese patron pero se carga mas tarde (esto depende del orden de carga de los controladores en routes.yml y del orden de las acciones en el mismo controlador) Symfony no se va a dar cuenta de que esa segunda ruta es mejor o peor que la primera. Simplemente la ignorará aunque se lancen excepciones en las rutas anteriores.

Si sólo tenemos que discriminar entre 3 tipos de datos podriamos dejar que sea el mismo controlador el que lo haga, pero ahora imagina que tenemos:

/{tipo-dato-1}
/{tipo-dato-2}
/{tipo-dato-3}
/{tipo-dato-4}
/{tipo-dato-5}
/{tipo-dato-2}/{comunidad}/{provincia}
/{tipo-dato-3}/{comunidad}/{provincia}
/{tipo-dato-4}/{comunidad}/{provincia}
/{tipo-dato-5}/{comunidad}/{provincia}
/{tipo-dato-5}/{provincia}/{municipio}
/{tipo-dato-1}/{slug-comentario}
/{tipo-dato-1}/{slug-pregunta}
/{tipo-dato-1}/{slug-pregunta}/{slug-respuesta}
etc...

Nuestro controlador tendría un aspecto parecido a este:

/**
* @Route("/{slug1}")
*/
public function slug1Action($slug1){}
/**
* @Route("/{slug1}/{slug2}")
*/
public function slug2Action($slug1,$slug2){}
/**
* @Route("/{slug1}/{slug2}/{slug3}")
*/
public function slug3Action($slug1,$slug2,$slug3){}

Ahora recibimos la peticion en la ruta /contenido-concreto-a. El ruteador determinará que la acción correspondiente es slug1Action y en primer lugar esta acción deberá determinar que tipo de dato es “contenido-concreto-a” para despues decidir que hacer con el (transformar datos, calcular cosas, elegir que twig renderizar….). Esto para cada posible tipo de dato.

Si recibimos algo como /contenido-concreto-b/zaragoza/sos-del-rey-catolico el controlador primero tendra que evaluar el primer segmento para determinar el tipo de dato, despues el segundo y por último el tercero, mas luego realizar la acción necesaria, con lo que el tamaño de esas acciones crece y mucho, haciendo que terminemos con controladores enormes que son incomodos de mantener. Llega un momento que pasados los meses al tener que volver sobre ellos casi “da miedo” tocarlos.

Una forma de evitar que los controladores crezcan de manera desmesurada es utilizar redirecciones internas. Una vez que hemos determinado que tipo de dato es cada uno de los segmentos de la url podemos realizar una redireccion interna (documentación Symfony) a una funcion del mismo u otro controlador que es la que responsable de generar el contenido.

De esta manera mantendremos controladores mas manejables con acciones mas concisas.

Si por contra usamos la precarga de rutas, internamente Symfony habrá generado rutas concretas apuntando al controlador y la acción concreta:

/routeado -> App:Content:ContentByTag
/estrategias-de-ruteado-con-symfony -> App:Content:Content
/symfony -> App:Content:ContentByCategory

En este caso ya no es necesario que el controlador discrimine el contenido y podemos hacer acciones mas específicas y por lo tanto mas sencillas en cada controlador.

Conclusiones

Como suele ser habitual no hay una solución mejor que otra. Utilizar patrones en las urls mediante anotaciones resulta muy cómodo y en general acelera el proceso de desarrollo. Mientras sea un proyecto sencillo con tipologias de url facilmente separables funcionará perfectamente. Es el metodo que yo usaría si las urls son facilmente diferenciables.

En el momento en el que el número de tipologías crece o son dificilmente separables (por ejemplo cuando no hay elementos fijos en una url que nos permite definir patrones distintos para cada tipología) usar anotaciones se vuelve mas y mas complicado. Podemos empezar a tener problemas con el orden de evaluación de las rutas, haciendo que las peticiones acaben en controladores equivocados.

Usar un “front controller” que discrimine los datos asociados a cada segmento de la url y redireccione a controladores concretos es la solución que creo que es mas adaptable para proyectos de tamaño medio y grande.

Cuando usemos la precarga de rutas, los controladores suelen ser mas concisos ya que cada actión sabe exactamente lo que recibe y lo que tiene que hacer. Ahora bien, si la web genera nuevas urls con frecuencia nos va a exigir limpiar la cache frecuentemente para que se creen las nuevas rutas. Si el proyecto es grande y pesado ese proceso de regenerar la cache puede generar caidas del servicio por lo que es un método que no utilizaría en general.