Comprendre · API · Vulcain
Vulcain : une API REST aussi rapide que GraphQL
Le client dit quelles relations il va suivre et quels champs il veut : en-têtes Preload et Fields, préchargement par 103 Early Hints, et une API REST découpée qui reste rapide.
Le dilemme des API REST
Une API REST bien découpée expose des ressources petites et liées entre elles. Celle, fictive, d’Acme Assurances renvoie les sinistres d’un assuré, chaque sinistre pointant vers son contrat et son expert :
GET /sinistres?assure=7
{ "member": ["/sinistres/SIN-0042", "/sinistres/SIN-0057"] }
GET /sinistres/SIN-0042
{ "reference": "SIN-0042", "statut": "En expertise", "contrat": "/contrats/CT-118", "expert": "/experts/12" }
GET /contrats/CT-118
{ "numero": "CT-118", "formule": "Habitation confort", "franchise": 150 } Afficher la liste des dossiers avec la formule du contrat demande donc une requête pour la liste, une par sinistre, puis une par contrat : trois allers-retours successifs. Sur mobile, chaque aller-retour coûte des dizaines de millisecondes.
Les réponses habituelles ont toutes un prix : imbriquer les relations dans la réponse (on renvoie trop de données et le cache HTTP devient grossier), multiplier les points d’accès ad hoc pour chaque écran, ou passer à GraphQL et renoncer au cache HTTP et à la simplicité de REST.
L’idée de Vulcain
Vulcain, protocole ouvert proposé par Kévin Dunglas (également auteur de Mercure) sous forme d’Internet-Draft, part d’un constat : avec HTTP/2 et HTTP/3, envoyer beaucoup de petites ressources ne coûte presque plus rien, puisqu’elles sont multiplexées sur une seule connexion. Ce qui coûte, ce sont les allers-retours en série.
Vulcain laisse donc l’API découpée, et ajoute deux en-têtes de requête par lesquels le client décrit ce qu’il va faire : les relations qu’il va suivre (Preload) et les champs qui l’intéressent (Fields). Le serveur s’en sert pour lui envoyer les ressources liées avant qu’il ne les demande.
L’en-tête Preload
Sa valeur est une liste de sélecteurs inspirés de JSON Pointer, où * parcourt les éléments d’un tableau :
GET /sinistres?assure=7 HTTP/2
Preload: "/member/*/contrat", "/member/*/expert"
HTTP/2 200
Link: </sinistres/SIN-0042>; rel=preload; as=fetch
Link: </sinistres/SIN-0057>; rel=preload; as=fetch
Link: </contrats/CT-118>; rel=preload; as=fetch
Link: </experts/12>; rel=preload; as=fetch Le serveur répond normalement et annonce les ressources à précharger ; le client, qui écrit du code REST ordinaire, trouve les réponses déjà en route ou en cache au moment de les demander.
// Le client écrit du REST ordinaire : les ressources préchargées arrivent sans aller-retour supplémentaire.
const headers = { Preload: '"/member/*/contrat"' };
const liste = await fetch('/sinistres?assure=7', { headers }).then((r) => r.json());
const sinistres = await Promise.all(liste.member.map((url) => fetch(url).then((r) => r.json())));
const contrats = await Promise.all(sinistres.map((s) => fetch(s.contrat).then((r) => r.json())));L’en-tête Fields
Fields réduit la réponse aux champs demandés, l’équivalent d’une sélection GraphQL :
GET /sinistres/SIN-0042 HTTP/2
Fields: "/reference", "/statut"
Preload: "/contrat"
HTTP/2 200
{ "reference": "SIN-0042", "statut": "En expertise" } Les deux en-têtes ont un équivalent en paramètres d’URL, utile pour un lien partagé ou un client qui ne maîtrise pas ses en-têtes :
GET /sinistres?assure=7&preload=/member/*/contrat&fields=/member/*/statut Le serveur doit renvoyer Vary: Preload, Fields pour que les caches HTTP distinguent les variantes d’une même ressource.
Du Server Push aux Early Hints
La première version de Vulcain s’appuyait sur le Server Push de HTTP/2 : le serveur poussait directement les réponses liées dans la connexion. Les navigateurs ont depuis abandonné le Server Push, jugé difficile à exploiter correctement.
Le préchargement passe désormais par des en-têtes Link: rel=preload, éventuellement envoyés très tôt dans une réponse intermédiaire 103 Early Hints. Le client émet alors toutes les requêtes en parallèle sur la même connexion : un aller-retour de plus qu’avec le Push, mais plus jamais une cascade. Hors navigateur, un client HTTP qui prend en charge le Push peut encore en profiter.
Mettre Vulcain en place
La voie la plus directe est la passerelle Vulcain, un module Caddy placé devant n’importe quelle API JSON, quel que soit son langage : elle lit les en-têtes, parcourt les réponses et ajoute les liens de préchargement.
# Caddyfile : la passerelle Vulcain devant une API existante (module compilé avec xcaddy).
{
order vulcain before reverse_proxy
}
api.acme-assurances.example {
vulcain
reverse_proxy http://api-interne:8080
} FrankenPHP étant bâti sur Caddy, une build personnalisée avec xcaddy peut embarquer le module. API Platform, qui expose les relations sous forme d’IRI, se prête naturellement à cette approche.
Le faire soi-même avec Symfony
Sans passerelle, le composant WebLink de Symfony suffit pour une version simple : un écouteur lit Preload, ajoute les liens à la réponse et déclare la variante pour les caches. Il couvre les relations directes ; au-delà, mieux vaut la passerelle que réimplémenter la spécification.
<?php
namespace App\EventListener;
use Symfony\Component\EventDispatcher\Attribute\AsEventListener;
use Symfony\Component\HttpKernel\Event\ResponseEvent;
use Symfony\Component\WebLink\GenericLinkProvider;
use Symfony\Component\WebLink\Link;
// Version volontairement minimale : sélecteurs d'un niveau (« /contrat ») et tableaux (« /member/* »).
// Les chemins imbriqués (« /member/*/contrat ») demandent de lire chaque ressource : c'est le travail de la passerelle.
#[AsEventListener(priority: 10)] // avant l'écouteur de WebLink, qui écrit les en-têtes Link
final class PreloadListener
{
public function __invoke(ResponseEvent $event): void
{
$request = $event->getRequest();
$header = $request->headers->get('Preload');
$body = json_decode((string) $event->getResponse()->getContent(), true);
if ($header === null || !is_array($body)) {
return;
}
$provider = $request->attributes->get('_links', new GenericLinkProvider());
foreach (str_getcsv($header) as $selector) {
[$field, $wildcard] = array_pad(explode('/', trim($selector, " \"/")), 2, null);
$value = $body[$field] ?? null;
$iris = $wildcard === '*' && is_array($value) ? $value : [$value];
foreach (array_filter($iris, 'is_string') as $iri) {
$provider = $provider->withLink((new Link('preload', $iri))->withAttribute('as', 'fetch'));
}
}
$request->attributes->set('_links', $provider);
$event->getResponse()->setVary(['Preload', 'Fields'], false);
}
} Quand on sait d’avance quelles ressources seront nécessaires, sendEarlyHints() envoie la réponse 103 avant même d’avoir interrogé la base :
#[Route('/sinistres/{reference}', methods: ['GET'])]
public function show(string $reference): Response
{
// 103 Early Hints : le client commence à charger le contrat pendant que la réponse se calcule.
$response = $this->sendEarlyHints([
(new Link('preload', '/contrats/' . $this->contrats->numeroPour($reference)))->withAttribute('as', 'fetch'),
]);
// La réponse 103 déjà partie, on complète l'objet renvoyé par sendEarlyHints().
$response->headers->set('Content-Type', 'application/json');
$response->setContent(json_encode($this->sinistres->get($reference), JSON_THROW_ON_ERROR));
return $response;
} Early Hints demande un serveur capable de transmettre une réponse intermédiaire : Symfony documente ce mécanisme avec FrankenPHP ; avec la plupart des autres configurations PHP, la réponse 103 n’atteint pas le client et seule la réponse finale part.
Vulcain ou GraphQL ?
| Critère | REST + Vulcain | GraphQL |
|---|---|---|
| Allers-retours | Un, plus le préchargement parallèle | Un |
| Cache HTTP | Par ressource, standard | À réinventer côté client |
| Sélection de champs | Fields | Native et typée |
| Adoption | Progressive, API existante inchangée | Nouveau schéma et nouvelle couche serveur |
| Outillage | Celui de HTTP | Riche : introspection, typage, génération de clients |
Vulcain convient bien à une API REST existante qu’on veut accélérer sans la réécrire, ou à une API publique où le cache HTTP compte. GraphQL garde l’avantage quand de nombreux clients aux besoins très différents doivent composer leurs requêtes. Les deux protocoles de Kévin Dunglas se combinent : Vulcain pour charger vite l’état initial, Mercure pour recevoir ensuite les changements.