
Tutoriel Symfony WebSocket : Architecture temps réel évolutive
Mercure est la solution temps réel officielle de Symfony, et elle fonctionne très bien pour les notifications push du serveur vers le client. Mais que se passe-t-il quand vous avez besoin d'une véritable communication bidirectionnelle? Les applications de clavardage avec indicateurs de frappe, l'édition collaborative de documents, les jeux multijoueurs ou les tableaux de bord de négociation en direct nécessitent tous que les clients envoient des données via des connexions persistantes, et c'est exactement là que Mercure atteint ses limites.
Prérequis
Avant de commencer, assurez-vous d'avoir :
- Une application Symfony 6.x ou 7.x fonctionnelle
- Une compréhension de base de Symfony Messenger
- Docker installé (pour la configuration locale de Redis/RabbitMQ)
- Une familiarité avec au moins un de ces langages : Node.js, Python ou Go
- Une compréhension des patrons d'authentification JWT
Pour les exemples de code dans ce guide, nous utiliserons :
- Python avec FastAPI pour la passerelle WebSocket
- Redis pour la messagerie pub/sub
- Symfony Messenger avec le transport Redis
Comprendre l'intégration pub/sub Redis avec Symfony est utile mais pas obligatoire.
Comprendre pourquoi Mercure ne suffit pas
Avant de construire une alternative, soyons clairs sur ce que Mercure fait bien et où il a des difficultés. Cette section explique pourquoi une approche alternative à Mercure pour Symfony est souvent nécessaire.
Ce que Mercure gère bien :
- Les Server-Sent Events (SSE) pour pousser des mises à jour vers les navigateurs
- Les abonnements par sujet avec autorisation JWT
- Les patrons pub/sub simples
- L'intégration native avec Symfony
Où Mercure atteint ses limites :
L'absence de messagerie native du client vers le serveur signifie que les clients doivent utiliser des requêtes HTTP séparées pour envoyer des données. La publication uniquement par HTTP signifie que tous les messages passent par des requêtes POST vers le hub. Le protocole SSE plutôt que WebSocket signifie qu'il n'y a pas de véritable communication full-duplex. L'absence de gestion de l'état des connexions signifie qu'il n'y a pas de détection de présence ni d'indicateurs de frappe intégrés.
Pour les applications nécessitant une latence inférieure à 100 ms dans les deux directions, une synchronisation d'état continue ou une messagerie bidirectionnelle à haute fréquence, vous avez besoin de véritables WebSockets. C'est particulièrement pertinent pour les implémentations de clavardage temps réel avec Symfony.
L'architecture de passerelle externe
En travaillant avec divers clients, nous avons appris que le patron le plus fiable consiste à séparer complètement la gestion des WebSockets de Symfony. Voici pourquoi ça fonctionne : l'architecture de passerelle WebSocket garde la gestion des connexions séparée de la logique métier.
┌─────────────┐ WebSocket ┌──────────────────┐
│ Browser │◄──────────────────►│ WebSocket │
│ Client │ │ Gateway │
└─────────────┘ │ (Python/Node) │
└────────┬─────────┘
│
┌────────▼─────────┐
│ Redis │
│ Pub/Sub │
└────────┬─────────┘
│
┌────────▼─────────┐
│ Symfony │
│ Application │
└──────────────────┘Chaque composant a des responsabilités claires :
Symfony gère toute la logique métier, l'authentification, l'autorisation, les opérations de base de données et le traitement du domaine. Il consomme les messages de Redis via Symfony Messenger et publie les réponses en retour. L'intégration de Symfony Messenger avec WebSocket permet une gestion propre des messages.
La passerelle WebSocket gère les connexions persistantes, traite les événements du cycle de vie des connexions (connexion, déconnexion, reconnexion), achemine les messages vers Redis et diffuse les mises à jour aux clients connectés. Elle reste légère et sans état. Cela crée effectivement un patron de serveur WebSocket PHP via l'architecture de proxy.
Redis découple les deux services, permet la mise à l'échelle horizontale et gère le routage pub/sub entre eux.
Implémentation étape par étape
Étape 1 : Configurer Redis et Symfony Messenger
D'abord, installez le transport Redis pour Symfony Messenger :
composer require symfony/redis-messenger
Configurez vos transports dans config/packages/messenger.yaml. La configuration du transport Redis pour Symfony Messenger ressemble à ceci :
framework:
messenger:
transports:
websocket_inbound:
dsn: 'redis://localhost:6379/websocket_inbound'
websocket_outbound:
dsn: 'redis://localhost:6379/websocket_outbound'
routing:
'App\Message\IncomingWebSocketMessage': websocket_inboundÉtape 2 : Créer les classes de messages
Définissez la structure de vos messages :
// src/Message/IncomingWebSocketMessage.php
namespace App\Message;
class IncomingWebSocketMessage
{
public function __construct(
public readonly string $userId,
public readonly string $type,
public readonly array $payload,
public readonly string $connectionId,
) {}
}// src/Message/OutgoingWebSocketMessage.php
namespace App\Message;
class OutgoingWebSocketMessage
{
public function __construct(
public readonly string $target,
public readonly string $type,
public readonly array $payload,
) {}
}Étape 3 : Construire le gestionnaire de messages
Créez un gestionnaire qui traite les messages WebSocket entrants :
// src/MessageHandler/WebSocketMessageHandler.php
namespace App\MessageHandler;
use App\Message\IncomingWebSocketMessage;
use App\Message\OutgoingWebSocketMessage;
use Symfony\Component\Messenger\Attribute\AsMessageHandler;
use Symfony\Component\Messenger\MessageBusInterface;
#[AsMessageHandler]
class WebSocketMessageHandler
{
public function __construct(
private MessageBusInterface $bus,
private ChatService $chatService,
) {}
public function __invoke(IncomingWebSocketMessage $message): void
{
match ($message->type) {
'chat.message' => $this->handleChatMessage($message),
'chat.typing' => $this->handleTypingIndicator($message),
'presence.update' => $this->handlePresenceUpdate($message),
default => null,
};
}
private function handleChatMessage(IncomingWebSocketMessage $message): void
{
$savedMessage = $this->chatService->saveMessage(
$message->userId,
$message->payload['roomId'],
$message->payload['content']
);
$this->bus->dispatch(new OutgoingWebSocketMessage(
target: 'room:' . $message->payload['roomId'],
type: 'chat.new_message',
payload: [
'id' => $savedMessage->getId(),
'content' => $savedMessage->getContent(),
'author' => $savedMessage->getAuthor()->getUsername(),
'timestamp' => $savedMessage->getCreatedAt()->format('c'),
]
));
}
}Étape 4 : Créer le point de terminaison pour les jetons WebSocket
Les clients ont besoin d'un jeton à courte durée de vie pour se connecter à la passerelle WebSocket. L'authentification JWT pour WebSocket est essentielle pour sécuriser ces connexions :
// src/Controller/WebSocketController.php
namespace App\Controller;
use Lexik\Bundle\JWTAuthenticationBundle\Encoder\JWTEncoderInterface;
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\Routing\Annotation\Route;
use Symfony\Component\Security\Http\Attribute\IsGranted;
class WebSocketController extends AbstractController
{
public function __construct(
private JWTEncoderInterface $jwtEncoder,
) {}
#[Route('/api/ws-token', methods: ['POST'])]
#[IsGranted('ROLE_USER')]
public function generateToken(): JsonResponse
{
$user = $this->getUser();
$token = $this->jwtEncoder->encode([
'user_id' => $user->getId(),
'username' => $user->getUsername(),
'exp' => time() 300,
'scope' => 'websocket',
]);
return new JsonResponse(['token' => $token]);
}
}Étape 5 : Construire la passerelle WebSocket en Python
Notre équipe a constaté que Python avec FastAPI offre un excellent équilibre entre performance et lisibilité pour les passerelles WebSocket. Voici une implémentation de base utilisant Python FastAPI WebSocket :
# gateway/main.py
import asyncio
import json
from fastapi import FastAPI, WebSocket, WebSocketDisconnect
import redis.asyncio as redis
import jwt
app = FastAPI()
REDIS_URL = "redis://localhost:6379"
JWT_SECRET = "your-symfony-jwt-secret"
class ConnectionManager:
def __init__(self):
self.active_connections: dict[str, WebSocket] = {}
self.user_rooms: dict[str, set[str]] = {}
async def connect(self, websocket: WebSocket, user_id: str):
await websocket.accept()
self.active_connections[user_id] = websocket
self.user_rooms[user_id] = set()
def disconnect(self, user_id: str):
if user_id in self.active_connections:
del self.active_connections[user_id]
if user_id in self.user_rooms:
del self.user_rooms[user_id]
async def send_to_user(self, user_id: str, message: dict):
if user_id in self.active_connections:
await self.active_connections[user_id].send_json(message)
async def send_to_room(self, room_id: str, message: dict):
for user_id, rooms in self.user_rooms.items():
if room_id in rooms:
await self.send_to_user(user_id, message)
manager = ConnectionManager()
def verify_token(token: str) -> dict | None:
try:
payload = jwt.decode(token, JWT_SECRET, algorithms=["HS256"])
if payload.get("scope") != "websocket":
return None
return payload
except jwt.InvalidTokenError:
return None
@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket, token: str):
payload = verify_token(token)
if not payload:
await websocket.close(code=4001)
return
user_id = payload["user_id"]
await manager.connect(websocket, user_id)
redis_client = redis.from_url(REDIS_URL)
try:
while True:
data = await websocket.receive_json()
await redis_client.publish(
"websocket_inbound",
json.dumps({
"user_id": user_id,
"type": data.get("type"),
"payload": data.get("payload", {}),
"connection_id": str(id(websocket)),
})
)
except WebSocketDisconnect:
manager.disconnect(user_id)
finally:
await redis_client.close()
async def redis_listener():
redis_client = redis.from_url(REDIS_URL)
pubsub = redis_client.pubsub()
await pubsub.subscribe("websocket_outbound")
async for message in pubsub.listen():
if message["type"] == "message":
data = json.loads(message["data"])
target = data.get("target", "")
if target.startswith("room:"):
room_id = target[5:]
await manager.send_to_room(room_id, data)
elif target.startswith("user:"):
user_id = target[5:]
await manager.send_to_user(user_id, data)
@app.on_event("startup")
async def startup():
asyncio.create_task(redis_listener())Étape 6 : Publier les messages sortants depuis Symfony
Créez un service pour publier les messages vers la passerelle WebSocket :
// src/Service/WebSocketPublisher.php
namespace App\Service;
use App\Message\OutgoingWebSocketMessage;
use Predis\Client;
class WebSocketPublisher
{
public function __construct(
private Client $redis,
) {}
public function publish(OutgoingWebSocketMessage $message): void
{
$this->redis->publish('websocket_outbound', json_encode([
'target' => $message->target,
'type' => $message->type,
'payload' => $message->payload,
]));
}
}Étape 7 : Démarrer vos workers
Exécutez le worker Symfony Messenger pour traiter les messages entrants :
php bin/console messenger:consume websocket_inbound -vv
Démarrez la passerelle Python :
uvicorn gateway.main:app --host 0.0.0.0 --port 8080
Erreurs courantes à éviter
1. Mettre la logique métier dans la passerelle
La passerelle devrait uniquement gérer les connexions et acheminer les messages. Toute la validation, les vérifications d'autorisation et la logique de domaine appartiennent à Symfony. Si vous commencez à ajouter des règles métier dans la passerelle, vous vous retrouverez avec une logique dupliquée et un comportement incohérent.
2. Ne pas gérer la reconnexion correctement
Les clients vont se déconnecter. Les réseaux tombent en panne, les navigateurs se rafraîchissent, les portables se mettent en veille. Concevez votre client pour qu'il se reconnecte automatiquement et se réabonne aux canaux pertinents. Stockez l'état des abonnements dans Redis pour que les clients puissent reprendre là où ils se sont arrêtés.
3. Ignorer la contre-pression
Si Symfony ne peut pas traiter les messages aussi vite qu'ils arrivent, vos files d'attente Redis vont croître sans limite. Implémentez une limitation du débit au niveau de la passerelle et surveillez la profondeur des files d'attente. Envisagez des files d'attente de messages morts pour les messages qui échouent répétitivement au traitement.
4. Utiliser Pub/Sub pour les messages critiques
Redis Pub/Sub a une livraison « au plus une fois ». Si aucun abonné n'écoute quand un message arrive, il est perdu. Pour les messages qui ne doivent pas être perdus, utilisez Redis Streams ou passez à RabbitMQ avec des files d'attente persistantes.
5. Valider les jetons uniquement au moment de la connexion
Les jetons JWT expirent. Si la session d'un utilisateur doit être terminée (déconnexion, changement de permissions), vous avez besoin d'un moyen de fermer sa connexion WebSocket. Stockez les connexions actives dans Redis et implémentez un mécanisme pour forcer la déconnexion d'utilisateurs spécifiques.
Tests et vérification
Tester le flux de connexion
- Démarrez tous les services (Redis, Symfony, passerelle Python)
- Demandez un jeton WebSocket depuis /api/ws-token
- Connectez-vous à ws://localhost:8080/ws?token=VOTRE_JETON
- Envoyez un message de test : {"type": "chat.message", "payload": {"roomId": "test", "content": "Bonjour"}}
- Vérifiez que le message apparaît dans les journaux de Symfony
- Confirmez que la diffusion revient via le WebSocket
Surveiller la santé des connexions
Ajoutez une simple vérification de santé à votre passerelle :
@app.get("/health")
async def health():
return {
"status": "healthy",
"active_connections": len(manager.active_connections),
}Surveillez ces métriques en production :
- Nombre de connexions actives
- Débit des messages (entrées/sorties par seconde)
- Profondeur de la file d'attente Redis
- Temps de traitement des workers Symfony
Choisir entre les courtiers de messages
Nous recommandons de commencer avec Redis pour la plupart des applications. C'est plus simple à configurer, a une latence plus faible et s'intègre directement avec Symfony Messenger. Comprendre les compromis entre RabbitMQ et Redis pour WebSocket aide à choisir votre courtier de messages.
Redis Pub/Sub a une faible complexité de configuration, une latence inférieure à la milliseconde, pas de persistance des messages, une garantie de livraison « au plus une fois » et pas de routage complexe. Redis Streams a une faible complexité de configuration, une latence inférieure à la milliseconde, une persistance des messages, une garantie de livraison « au moins une fois » et un routage complexe limité. RabbitMQ a une complexité de configuration moyenne, une latence de 1 à 5 ms, une persistance des messages, une garantie de livraison « au moins une fois » et un support complet du routage complexe.
Passez à RabbitMQ quand vous avez besoin d'une livraison garantie, de patrons de routage complexes ou si votre volume de messages dépasse ce qu'une seule instance Redis peut gérer.
Mise à l'échelle pour la production
La mise à l'échelle horizontale des WebSockets nécessite une considération attentive à la fois de la passerelle et des composants workers. Voici comment réaliser des déploiements Symfony temps réel évolutifs.
Mise à l'échelle horizontale
Les deux composants peuvent être mis à l'échelle horizontalement :
Passerelle WebSocket : Déployez plusieurs instances derrière un répartiteur de charge avec des sessions persistantes. Utilisez l'adaptateur Redis pour partager l'état entre les instances.
Workers Symfony : Exécutez plusieurs processus messenger:consume en utilisant Supervisor :
[program:messenger-websocket] command=php /var/www/bin/console messenger:consume websocket_inbound --time-limit=3600 numprocs=4 autostart=true autorestart=true
Configuration de l'infrastructure
Votre proxy inverse doit prendre en charge les mises à niveau WebSocket. Pour NGINX :
location /ws {
proxy_pass http://gateway:8080;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_read_timeout 86400;
}Conclusion
Construire des applications WebSocket dans Symfony nécessite de sortir du patron Mercure standard quand vous avez besoin d'une véritable communication bidirectionnelle. L'architecture de passerelle externe, bien que plus complexe à mettre en place, vous donne la flexibilité et l'évolutivité que les applications interactives temps réel exigent. En gardant la gestion des connexions séparée de la logique métier, vous pouvez mettre à l'échelle chaque composant selon ses besoins réels et utiliser les meilleurs outils pour chaque tâche.
L'approche décrite ici gère tout, des simples applications de clavardage aux tableaux de bord de négociation à haute fréquence. Commencez avec le patron de base, puis ajoutez de la complexité au fur et à mesure que vos besoins évoluent.
Si vous planifiez une fonctionnalité temps réel pour votre application Symfony et n'êtes pas certain que Mercure suffira ou si vous avez besoin de l'approche WebSocket complète, nous pouvons vous aider à évaluer les compromis. Notre équipe a implémenté les deux patrons à différentes échelles et peut vous guider vers l'architecture qui correspond à vos exigences spécifiques et aux capacités de votre équipe.
