Cette page décrit le flux complet d’un appel client REST Cyrano, du moment où l’interface est annotée à celui où la valeur typée est retournée. Tout est build-time-friendly : pas de réflexion dynamique, pas de classpath scan à chaud, pas de synchronized dans le chemin chaud.
1. Découverte : BCE Vauban
L’amorçage commence à process-classes quand Vauban exécute les Build Compatible Extensions enregistrées via META-INF/services/jakarta.enterprise.inject.build.compatible.spi.BuildCompatibleExtension.
CyranoRestClientExtension (dans cyrano-cdi-vauban) déclare deux hooks :
@Enhancement(types = Object.class, withAnnotations = RegisterRestClient.class)
public void enhanceClient(ClassConfig cfg, MessagerLogger log) {
log.info("Discovered REST client interface: " + cfg.info().name());
// mémorise pour @Synthesis
}
@Synthesis
public void synthesize(SyntheticComponents syn) {
for (Class<?> iface : discoveredClients) {
syn.addBean(iface)
.type(iface)
.qualifier(RestClient.Literal.INSTANCE)
.scope(ApplicationScoped.class)
.createWith(CyranoRestClientSyntheticCreator.class);
}
}
@Validation (optionnel) vérifie qu’une URL de base est résolvable au build-time si la spec l’exige.
2. Création : CyranoRestClientSyntheticCreator
À la première résolution CDI de @Inject @RestClient UsersClient, le créateur synthétique est invoqué :
-
Le scope d’instanciation et les paramètres optionnels sont récupérés via
SyntheticBeanCreator.create(Instance<Object> lookup, Parameters params). -
CyranoBaseUriResolver.resolve(iface)agrège les sources d’URL (annotation@RegisterRestClient(baseUri), MP Config<configKey>/mp-rest/url, MP Config<fqn>/mp-rest/url) et applique la priorité spec. -
RestClientBuilder.newBuilder().baseUri(uri).build(UsersClient.class)est appelé. La résolution du builder passe parServiceLoadersurRestClientBuilderResolver; Cyrano publie le sien dans le moduleio.vidocq.cyrano.coreviaprovides … with ….
MP Config est consulté par réflexion sur org.eclipse.microprofile.config.ConfigProvider. cyrano-cdi-vauban ne déclare aucune dépendance compile sur microprofile-config-api — si l’API n’est pas chargeable au runtime, defaultMpConfigLookup() renvoie key → Optional.empty() (dégradation gracieuse documentée dans AGENTS.md).
|
3. Scan d’interface
CyranoInterfaceScanner lit les annotations JAX-RS de l’interface et de ses méthodes via l’API java.lang.reflect (uniquement pour les annotations — pas de réflexion dans le chemin chaud).
Pour chaque méthode, un RequestSpec immuable est construit :
public record RequestSpec(
String httpMethod, // "GET", "POST", ...
String pathTemplate, // "/users/{id}"
List<ParamBinding> params, // ordre = arguments de la méthode
MediaType[] consumes, // @Consumes
MediaType[] produces, // @Produces
Map<String, String> staticHeaders, // @ClientHeaderParam(value="...")
Map<String, String> dynamicHeaders, // @ClientHeaderParam(value="{method}")
Class<?> declaredReturn, // type de retour brut
Type genericReturn // type générique (pour CompletionStage<List<X>>)
) {}
ParamBinding est une sealed interface : PathParamBinding, QueryParamBinding, HeaderParamBinding, CookieParamBinding, MatrixParamBinding, FormParamBinding, BeanParamBinding, BodyBinding. Le switch dans CyranoInvocationHandler est exhaustif (sealed types — vérifié par le compilateur).
4. Génération de proxy via Class-File API (JEP 484)
CyranoProxyGenerator émet le bytecode d’une classe nommée Cyrano$<SimpleName> dans le package de l’interface (via MethodHandles.privateLookupIn(iface, MethodHandles.lookup())). Le squelette généré est :
package com.example.client;
public final class Cyrano$UsersClient implements UsersClient {
private final io.vidocq.cyrano.runtime.CyranoInvocationHandler $handler;
public Cyrano$UsersClient(io.vidocq.cyrano.runtime.CyranoInvocationHandler handler) {
this.$handler = handler;
}
@Override
public UserDto findById(long id) {
return (UserDto) $handler.invoke(0, new Object[] { id });
}
}
L’émission utilise l’API java.lang.classfile.ClassFile.build(…). Aucune dépendance ASM, aucun setAccessible(true). La classe est chargée avec lookup.defineClass(bytes) ; le MethodHandles.Lookup est obtenu via privateLookupIn sur l’interface.
CyranoProxyCache mémoïse l'`Entry` (couple Class<?> proxyClass, MethodHandle constructor) dans un ConcurrentHashMap ; computeIfAbsent garantit qu’une interface est traitée une seule fois, même sous charge concurrente.
5. Invocation : CyranoInvocationHandler
Le MethodHandle du constructeur reçoit le CyranoInvocationHandler au moment de l’instanciation du proxy. À chaque appel, invoke(int methodIndex, Object[] args) :
-
Récupère le
RequestSpecindexé. -
Itère sur les
ParamBindingpour construire l’URI (path + query + matrix), les headers, le corps. -
Évalue les
@ClientHeaderParamdynamiques en invoquant la méthodedefaultcorrespondante viaMethodHandle. -
Sérialise le corps via Jakarta JSON-B (
Jsonb.toJson(body)). -
Construit la
java.net.http.HttpRequest(HttpRequest.newBuilder().uri(uri).method(verb, BodyPublishers.ofString(json)).headers(…)).
6. Transport : CyranoHttpTransport
Un seul HttpClient par client REST, construit ainsi :
HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2) // tente H/2, fallback HTTP/1.1
.connectTimeout(connectTimeout)
.executor(Executors.newVirtualThreadPerTaskExecutor())
.followRedirects(redirect)
.proxy(proxySelector)
.sslContext(sslContext) // si trust/keystore configurés
.build();
Mode synchrone : client.send(req, BodyHandlers.ofByteArray()) — appel bloquant sur un virtual thread (sans monopoliser un thread plateforme).
Mode async (CompletionStage<T>) : client.sendAsync(req, BodyHandlers.ofByteArray()) — le CompletableFuture interne est complété par le pool virtual-thread de HttpClient.
7. Désérialisation et exception mapping
Après réception, CyranoInvocationHandler :
-
Applique les
ClientResponseFilterenregistrés. -
Vérifie si un
ResponseExceptionMapperquihandles(status, headers)existe (priorité croissante). Si oui :throw mapper.toThrowable(response). -
Si statut ≥ 400 et aucun mapper personnalisé : par défaut,
throw new WebApplicationException(response). -
Sinon, désérialise le corps via JSON-B selon le type de retour générique (
Jsonb.fromJson(body, genericReturn)). -
Pour
CompletionStage<T>, encapsule dans unCompletableFuturedéjà résolu / async selon le mode.
Coût mémoire et performance
-
Une classe générée par interface client (pas une par appel). Le
CyranoProxyCachegarantit la mémoïsation. -
Une instance d'`HttpClient` par client REST, partagée entre tous les appels.
-
Aucun
ThreadLocal, aucunsynchronized— pas de pin sur virtual thread. -
Pas de copie de buffer dans le chemin chaud :
BodyHandlers.ofByteArray()puis désérialisation streaming JSON-B.
Décisions de conception
-
record+sealed interfacepourRequestSpecetParamBinding— pattern matching exhaustif, immutabilité, lisibilité. -
Pas de
java.lang.reflect.Proxy— incompatible AOT, stack traces obscures ($Proxy0.invoke). -
Pas de pool plateforme —
Executors.newVirtualThreadPerTaskExecutor()est suffisant et plus efficace pour de l’I/O. -
Module repackage
cyrano-mp-rest-client-api— la spec upstream est livrée sansmodule-infoniAutomatic-Module-Name, ce qui interdit jlink. Le repackage la rend explicitementio.vidocq.cyrano.mp.rest.client.api.
La stratégie de génération de proxy est documentée dans les ADR (ADR-001-classfile-proxy-strategy.md).