Usando Mercure en una aplicación Symfony

Jorge Hernández Ríos
9 min readNov 29, 2020

--

Definición

Históricamente, Internet ha estado basado en el paradigma cliente-servidor donde el cliente realiza las peticiones hacia el servidor, y este le brinda una respuesta. Sin embargo, hoy en día es bastante común que las aplicaciones modernas tengan requisitos en el que se necesite enviar en tiempo real datos desde el servidor a un cliente determinado.

Un ejemplo podría ser un chat. Para llevarlo a cabo, existen las siguientes posibilidades:

  • ‘AJAX polling’ , donde programamos nuestro cliente para que vaya realizando peticiones AJAX al servidor y así ir actualizando los datos (en este caso los mensajes del chat). En este caso la comunicación seguiría siendo unidireccional, ya que siempre debe realizar la petición el cliente en primer lugar al servidor y ya luego este le respondería.
  • Websocket, una tecnología en la que se crearía un canal bidireccional, pudiendo así fluir instantáneamente los mensajes entre cliente y servidor, sin necesidad de que exista una solicitud inicial (más sobre WebSockets: https://developer.mozilla.org/en-US/docs/Web/API/WebSockets_API)
  • Server-Sent Events (SSE), en este caso una tecnología unidireccional por la que se permitiría enviar datos desde el servidor hacia un cliente (más sobre SSE: https://developer.mozilla.org/es/docs/Server-sent_events).

Dada las múltiples situaciones en las que se puede dar la necesidad de enviar datos desde el servidor, bajo el paraguas de Symfony, se crea Mercure (https://mercure.rocks/). Según la propia definición que dan los creadores, Mercure es un protocolo abierto para comunicaciones en tiempo real diseñado para ser rápido, fiable y eficiente en cuanto al uso de batería. Es un sustituto moderno para la API de Websocket y aquellas librerías y servicios basadas en ella. Mercure es especialmente útil para añadir streaming y asincronía a APIs de tipo REST y GraphQL. Al ser una pequeña capa por encima de HTTP y SSE, Mercure nativamente está soportado por navegadores web modernos, aplicaciones web y dispositivos IoT (Internet of Things). Mercure, además, viene con un mecanismo de autorización, reconexión automática y es compatible con HTTP/2, y HTTP/3, lo cual precisamente una de las mayores ventajas frente al uso de Websocket.

En el siguiente vídeo, realizado por su creador Kevin Dunglas (https://twitter.com/dunglas), también creador de API Platform, se puede ver en cuán interesante puede ser su uso:

Ejemplo

Para ver mejor cada una de las partes más importantes de Mercure y cómo implementarlo en un proyecto real, vamos a desarrollar una pequeña aplicación Symfony llamada ‘LiveSports’. Se trata de una aplicación en la que se puede realizar un seguimiento en tiempo real de un determinado evento deportivo, acompañado de un pequeño chat donde los usuarios pueden conversar sobre dicho evento.

Ejemplo de uso de la aplicación resultante

De manera paralela a esta explicación, recomiendo tener la documentación de Mercure a mano: https://mercure.rocks/docs/hub/install. Además, el repositorio de este proyecto ejemplo es el siguiente (en el README están las instrucciones para instalarlo y probarlo en tu ordenador):

En primer lugar, tenemos que preparar la infraestructura necesaria para hacer trabajar el Hub de Mercure y así poder comunicarnos con él desde nuestra aplicación. Para ello, Mercure nos brinda la posibilidad de crear un servidor fácilmente a partir de su binario (https://mercure.rocks/docs/hub/install#prebuilt-binary). Siguiendo los pasos de su documentación, con lanzar un simple comando podríamos tenerlo funcionando. Por otro lado, también se nos ofrece una imagen de Docker para utilizarla a nuestro gusto, la cual vamos a aplicar en este caso, de la siguiente manera (https://mercure.rocks/docs/hub/install#docker-compose):

[...]
mercure:
image: dunglas/mercure
container_name: livesports-mercure
ports:
- 3000:3000
environment:
- ALLOW_ANONYMOUS=1
- CORS_ALLOWED_ORIGINS=*
- JWT_KEY=JorgeHRJ-JWTKey
- PUBLISH_ALLOWED_ORIGINS=http://livesports.loc
- ADDR=:3000
network_mode: bridge

Puedes encontrar el archivo aquí:

Esta configuración se puede utilizar para un entorno de desarrollo como va a ser en nuestro entorno local. Vamos a utilizar el puerto 3000 para nuestro Hub de Mercure. Otro punto clave es el indicar el parámetro ‘JWT_KEY’. Debemos elegir una clave a partir de la cual generaremos un token que nos servirá para autenticarnos contra el Hub.

Para instalar Mercure en nuestro proyecto lanzamos (con Symfony Flex):

composer require mercure

La mejor forma de configurar Mercure será a partir de variables de entorno. Si hemos utilizado el comando anterior, gracias a Symfony Flex, se nos crearán dos variables de entorno,MERCURE_PUBLISH_URL y MERCURE_JWT_TOKEN, además de un archivo de configuración en config/packages/mercure.yaml. Este archivo tiene la siguiente forma:

mercure:
enable_profiler: '%kernel.debug%'
hubs:
default:
url: '%env(MERCURE_PUBLISH_URL)%'
jwt: '%env(MERCURE_JWT_TOKEN)%'

Bastante sencillo, donde vemos que hace uso de las variables de entorno creadas para la configuración de Mercure.

La primera variable de entorno, MERCURE_PUBLISH_URL, será la URL que nos llevará directamente al Hub, y para ello hemos de tener en cuenta que nuestro Hub se 'monta' en la ruta /.well_known/mercure. Para la segunda, MERCURE_JWT_TOKEN, debemos generar un JWT (JSON Web Token) correspondiente a la key que establecimos previamente en la configuración de nuestro Hub. Podemos utilizar la siguiente página para realizar este paso, https://jwt.io/, y en este caso tendrá esta forma:

La estructura del payload es importante y tiene su por qué debe ser así en cuanto a estructura y contenido. Puedes leer más sobre ello aquí: https://symfony.com/doc/current/mercure.html#authorization.

Por tanto, ya podemos establecer los valores de nuestras variables de entorno. En nuestro caso, las vamos a modificar un poco.

###> symfony/mercure-bundle ###
MERCURE_PUBLISH_URL=http://mercure:3000/.well-known/mercure
MERCURE_PUBLISH_HUB_URL=http://livesports.loc:3000/.well-known/mercure
MERCURE_JWT_TOKEN=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJtZXJjdXJlIjp7InN1YnNjcmliZSI6W10sInB1Ymxpc2giOlsiKiJdfX0.ZT2HBc4lnoM_w90siEl1x84OyRdh3iBj8-CLDZLVduY
###< symfony/mercure-bundle ###

Como vemos, utilizamos el token generado en MERCURE_JWT_TOKEN. Sin embargo, vemos que hay una nueva URL definida por nosotros, MERCURE_PUBLISH_HUB_URL. Nuestro proyecto utilizará la creada por defecto, MERCURE_PUBLISH_URL, para comunicarse con el Hub de Mercure. Como nuestra aplicación está montada con diferentes contenedores de Docker, para que salgan las peticiones desde nuestro backend hasta el Hub, le pasamos en la URL el host del contenedor Docker del Hub con la ruta que vimos antes. La otra variable, MERCURE_PUBLISH_HUB_URL, la utilizaremos desde nuestro front para "subscribirnos" con el Hub.

Arquitectura básica de la aplicación

Backend

Para desarrollar el backend de nuestra aplicación, hemos de tener en cuenta en primer lugar cómo va a ser esta. Vamos a tener una página principal, la cual va a contener el ‘feed’ con cada actualización del evento y el chat. Son dos componentes independientes, por lo que debemos definir un ‘topic’ para cada uno de ellos para nuestro Hub. Cada uno de estos componentes se ‘subscribirá’ a los eventos del ‘topic’ que le corresponde.

Además de recibir eventos, el front de nuestra aplicación va a generar también eventos que tendrán que llegar a los otros usuarios de la aplicación. Por ejemplo, cuando un usuario manda un mensaje en el chat, este debe llegar al resto de usuarios con los que está chateando. Esa distribución al resto de usuarios es tarea del Hub, y para hacerle llegar el mensaje, lo hacemos a través de un POST a nuestro backend, que hará lo suyo contra el Hub. Por tanto, necesitamos crear un endpoint en nuestro backend para hacer dicho envío de mensajes desde el frontend. Por último, crearemos también una página específica para los administradores de la aplicación, los cuales se encargan de ir actualizando el ‘feed’ del evento en cuestión.

Con todo esto, podemos determinar que necesitaremos dos controladores: FeedController y PublishController.

App/Controller/FeedController.php

<?phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\Routing\Annotation\Route;
/**
* @Route("/", name="feed_")
*/
class FeedController extends AbstractController
{
/**
* @Route("/", name="index")
*/
public function index()
{
return $this->render('feed/index.html.twig');
}
/**
* @Route("/feed/update", name="update")
*/
public function update()
{
return $this->render('feed/update.html.twig');
}
}

Este controlador básicamente brindará las vistas de la aplicación que hemos comentado previamente.

App/Controller/PublishController.php

<?phpnamespace App\Controller;use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Mercure\Publisher;
use Symfony\Component\Mercure\Update;
use Symfony\Component\Routing\Annotation\Route;
class PublishController extends AbstractController
{
const FEED_TOPIC = 'feed';
const CHAT_TOPIC = 'chat';
const ALLOWED_TOPICS = [self::FEED_TOPIC, self::CHAT_TOPIC];
/**
* @Route("/publish/{topic}", name="publish", methods={"POST"})
*
* @param Request $request
* @param Publisher $publisher
* @param string $topic
*
* @return JsonResponse
*/
public function publish(Request $request, Publisher $publisher, string $topic)
{
if (!in_array($topic, self::ALLOWED_TOPICS)) {
return new JsonResponse(null, JsonResponse::HTTP_BAD_REQUEST);
}
$update = new Update($topic, $request->getContent());
$publisher($update);
return new JsonResponse(null, JsonResponse::HTTP_OK);
}
}

Este controlador nos permitirá tener el endpoint en el que haremos el POST de cada una de las actualizaciones que crearemos desde nuestro frontal. Lo más interesante reside en las clases Publisher y Update utilizadas. El Publisher será ese servicio que nos proporciona Mercure para manejar las actualizaciones, y Update dará forma a dichas actualizaciones con el 'topic' y el contenido correspondiente.

Frontend

En cuanto al frontend, estará basado en los dos archivos Twig que devuelven el FeedController que vimos anteriormente. Se pueden encontrar en el repositorio si se desea (https://github.com/JorgeHRJ/livesports/tree/master/templates), pero no tiene mucho interés para lo que nos atiene. Más interesante sí es la parte de JS, ya que será aquella donde hagamos nuestros POST al endpoint que creamos antes para publicar los eventos, pero sobre todo para realizar la suscripción. Estos JS los encontramos en este punto del repositorio, https://github.com/JorgeHRJ/livesports/tree/master/assets/js. Está dividido en dos archivos, uno dedicado al chat (chat.js) y otro para el feed (feed.js). Son bastante similares, así que utilizaré el del chat para explicar lo más útil e interesante:

assets/js/components/chat.js

function handleFormSubmit(event) {
event.preventDefault();
const form = event.currentTarget; fetch(form.action, { method: 'POST', body: JSON.stringify({name: form.elements.name.value, message: form.elements.message.value}) });
form.elements.message.value = '';
}
function handleMessage(stream) {
const { name, message } = JSON.parse(stream.data);
const container = document.querySelector('[data-component="chat-container"]');
const wrapperDiv = document.createElement('div');
const firstDiv = document.createElement('div');
const secondDiv = document.createElement('div');
wrapperDiv.classList.add('block', 'm-3');
firstDiv.classList.add('message', 'is-info');
secondDiv.classList.add('message-body');
secondDiv.innerHTML = `<strong>${name}:</strong> ${message}`;
firstDiv.appendChild(secondDiv);
wrapperDiv.appendChild(firstDiv);
container.appendChild(wrapperDiv);
}
function subscribeChat(chat) {
const form = chat.querySelector('form');
if (form) {
const { url } = chat.dataset;
const subscribeUrl = new URL(url);
subscribeUrl.searchParams.append('topic', 'chat');
const eventSource = new EventSource(subscribeUrl);
eventSource.onmessage = handleMessage;
form.addEventListener('submit', handleFormSubmit);
}
}
function init() {
const chat = document.querySelector('[data-component="chat"]');
if (chat) {
subscribeChat(chat);
}
}
export default init;

Este componente tiene dos partes como mencionamos antes: la suscripción a los eventos del topic ‘chat’ y el manejo del formulario del chat para hacer POST a nuestro servidor. En la función subscribeChat vemos cómo se obtiene la URL base a la que subscribirnos, añadiéndole como query param nuestro topic correspondiente, 'chat'. La URL resultante sería, por tanto: http//livesports.loc:3000/.well_known/mercure?topic=chat. Usando la API de EventSource, le pasamos esta URL y un callback para manejar la recepción de los mensajes de este "túnel" que estamos creando entre el Hub y nuestro frontend. Por último, añadimos un evento a nuestro formulario en el momento de hacer 'submit', donde nos encargamos de hacer una petición AJAX a nuestro con la información correspondiente (el mensaje y el nombre del usuario).

Y esto sería suficiente. Con esto ya tendríamos los conocimientos básicos para poder crear nuestra aplicación con Mercure, pudiendo así enviar información desde nuestro servidor. Se trata de una tecnología muy fácil de implementar y con un potencial bastante grande para utilizarlo en nuestras aplicaciones. Además, recomiendo leer la documentación oficial ya que tiene muchas más características, ya que este ejemplo es bastante básico, pero bastante interesante para comenzar a utilizar este componente.

--

--