En tancar la lliçó anterior va quedar un problema obert i molt concret: SincronitzadorPortades triga 5,4 segons a descarregar cinc portades, i pràcticament tot aquest temps és espera de xarxa amb la CPU aturada. Mil portades serien gairebé vint minuts. Les descàrregues són independents entre elles, així que fer-les en paral·lel reduiria el temps gairebé pel factor de paral·lelisme — però amb HttpURLConnection caldria muntar el pool, les tasques i la recollida de resultats a mà.
Aquesta lliçó resol això, i de passada tota la resta que va quedar assenyalat com a defecte de l'API antiga. java.net.http, incorporat a Java 11, és un client HTTP modern: immutable, amb constructors fluids, amb temps límit de veritat, amb HTTP/2 i multiplexació, amb WebSocket, i amb asincronia nativa.
Aquesta última paraula és la que fa que aquesta sigui la lliçó de tancament del mòdul. Perquè sendAsync no retorna una resposta ni un Future: retorna un CompletableFuture<HttpResponse<String>>. Tot el que vas aprendre a 08-07 —thenApply, thenCompose, thenCombine, allOf, exceptionally, orTimeout— s'aplica aquí tal qual, sense adaptacions. No és casualitat: CompletableFuture es va dissenyar pensant sobretot en la xarxa, i aquest és el lloc on rendeix de veritat.
En acabar, BiblioTech consultarà les metadades de diversos ISBN en paral·lel i compondrà un informe sense bloquejar ni un sol fil. I el mòdul 9 quedarà complet.
Contingut
- Les tres peces i el seu disseny immutable
- Crear el client:
HttpClient.newBuilder - Per què se'n crea un i es reutilitza
- Construir la petició:
HttpRequest - Els
BodyPublishers: enviar un cos - Rebre:
HttpResponse<T>i elsBodyHandlers - Enviament síncron:
send - Enviament asíncron:
sendAsync - Compondre cadenes asíncrones
- Diverses peticions en paral·lel amb
allOf - Gestió d'errors: excepció davant de codi d'estat
HttpURLConnectiondavant d'HttpClient- HTTP/2 i la multiplexació
WebSocket- Enviar i rebre JSON
- Bones pràctiques per cridar serveis externs
- BiblioTech: l'enriquidor asíncron de catàleg
- Errors Comuns i Consells
- Exercicis
- Les tres peces i el seu disseny immutable
L'API té exactament tres classes principals, amb responsabilitats netes:
graph LR
A["HttpClient<br/>QUI fa les peticions<br/>es crea UNA vegada"] --> B["send / sendAsync"]
C["HttpRequest<br/>QUE es demana<br/>una per peticio"] --> B
B --> D["HttpResponse<T><br/>QUE s'ha rebut<br/>estat, capcaleres, cos"]
E["BodyPublisher<br/>com s'ENVIA el cos"] --> C
F["BodyHandler<T><br/>com es LLEGEIX el cos"] --> B
| Classe | Paper | Cicle de vida |
|---|---|---|
HttpClient |
Qui fa les peticions. Guarda configuració i el pool de connexions | Un per aplicació, reutilitzat |
HttpRequest |
Què es demana: URI, mètode, capçaleres, cos | Un per petició |
HttpResponse<T> |
Què s'ha rebut: estat, capçaleres, cos de tipus T |
Un per resposta |
Totes tres són immutables. Això no és un detall estètic; té tres conseqüències pràctiques importants:
- Són segures per a diversos fils. Un
HttpClientes pot fer servir des de cent fils alhora sense sincronització. Compara-ho ambHttpURLConnection, que era un objecte mutable amb estats i que fallava si el configuraves fora d'ordre. - Un
HttpRequestes pot reutilitzar i enviar moltes vegades. - No hi ha configuració per efectes secundaris. S'ha acabat el
setDoOutput(true)que canviava el mètode sense dir-ho.
Es construeixen amb constructors fluids (el patró builder, que veuràs formalment a 12-02):
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/v1/llibres/978-0000000001"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> resposta = client.send(peticio,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(resposta.statusCode());
System.out.println(resposta.body());Compara aquestes dotze línies amb les trenta de la lliçó anterior, amb el seu cast, el seu disconnect() en un finally i la seva distinció entre flux normal i d'error. I tot l'important hi és: temps límit, capçaleres, charset explícit.
Sobre
Duration.java.time.Durationés la classe que aquesta API exigeix per als temps límit. Aquí només la fem servir com el que és en aquest context —una quantitat de temps, construïda ambDuration.ofSeconds(5)oDuration.ofMillis(500)— i no hi entrem més. L'APIjava.timecompleta és 10-05.
Sobre
HttpResponse<String>. Aquest<String>no és un genèric que hagis de definir: indica el tipus del cos de la resposta, i el determina elBodyHandlerque passis. AmbBodyHandlers.ofString()obtensHttpResponse<String>; ambofInputStream(),HttpResponse<InputStream>; ambofFile(cami),HttpResponse<Path>. Definir genèrics propis és 10-01; aquí només cal llegir-los.
- Crear el client:
HttpClient.newBuilder
HttpClient.newBuilderimport java.net.Authenticator;
import java.net.InetSocketAddress;
import java.net.PasswordAuthentication;
import java.net.ProxySelector;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.concurrent.Executors;
HttpClient client = HttpClient.newBuilder()
// Versio del protocol. HTTP_2 es el valor per defecte i negocia:
// si el servidor no el suporta, cau a HTTP/1.1 sense que facis res.
.version(HttpClient.Version.HTTP_2)
// Temps limit per ESTABLIR la connexio. Es del client
// perque afecta totes les seves peticions.
.connectTimeout(Duration.ofSeconds(5))
// Politica de redireccions.
.followRedirects(HttpClient.Redirect.NORMAL)
// Executor per a les operacions asincrones. Si no s'indica,
// en fa servir un d'intern. Passar el teu dona control i noms de fil.
.executor(Executors.newFixedThreadPool(8))
// Proxy, si la xarxa ho exigeix.
.proxy(ProxySelector.of(new InetSocketAddress("proxy.nexussoftware.com", 3128)))
// Autenticacio basica, per a serveis que la facin servir.
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("bibliotech",
"clau".toCharArray());
}
})
.build();O la versió mínima, amb tots els valors per defecte:
Les opcions que importen
| Opció | Valors | Comentari |
|---|---|---|
version |
HTTP_1_1, HTTP_2 |
Per defecte HTTP_2, amb negociació automàtica |
connectTimeout |
Duration |
Posa'l sempre. Sense ell, infinit |
followRedirects |
NEVER, ALWAYS, NORMAL |
Per defecte NEVER — compte, diferent de l'API antiga |
executor |
Executor |
Per a sendAsync. Sense ell, un d'intern |
proxy |
ProxySelector |
ProxySelector.getDefault() respecta les variables del sistema |
authenticator |
Authenticator |
Només per a autenticació bàsica i digest |
cookieHandler |
CookieHandler |
Gestió de galetes, desactivada per defecte |
sslContext |
SSLContext |
Per a certificats interns (12-07) |
priority |
1-256 | Prioritat de flux a HTTP/2 |
Tres advertiments:
followRedirects és NEVER per defecte. HttpURLConnection seguia les redireccions automàticament; HttpClient no. Si el teu codi migrat deixa de funcionar amb un 301, aquesta és la raó. NORMAL és el valor raonable: segueix redireccions excepte d'HTTPS a HTTP, que seria una degradació de seguretat.
L'authenticator només serveix per a autenticació bàsica i digest. L'autenticació per token —l'habitual avui— es fa amb una capçalera:
Sobre l'executor. L'intern és un pool en memòria cau sense límit. Passar el teu, amb fils anomenats com vas aprendre a 08-02, fa que els bolcats de fils i els logs serveixin d'alguna cosa:
.executor(Executors.newFixedThreadPool(8, r -> {
Thread f = new Thread(r, "bibliotech-http-" + comptador.getAndIncrement());
f.setDaemon(true);
return f;
}))
- Per què se'n crea un i es reutilitza
Aquesta és la regla que més impacte té en el rendiment, i la que més s'incompleix.
// MALAMENT. Un client nou per peticio.
for (String isbn : isbns) {
HttpClient client = HttpClient.newHttpClient(); // <-- aqui hi ha el problema
HttpResponse<String> r = client.send(peticioDe(isbn), ofString());
}
// BE. Un, creat en arrencar, reutilitzat sempre.
private static final HttpClient CLIENT = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
for (String isbn : isbns) {
HttpResponse<String> r = CLIENT.send(peticioDe(isbn), ofString());
}Què guarda un HttpClient per dins
| Recurs | Per què reutilitzar-lo importa |
|---|---|
| Pool de connexions | Cada connexió nova costa una salutació de tres vies: un viatge d'anada i tornada complet (09-01) |
| Sessions TLS | Una salutació TLS costa un o dos viatges més i criptografia asimètrica, que és cara |
| Pool de fils | Crear-lo i destruir-lo per petició és pur malbaratament |
| Connexions HTTP/2 | Una sola connexió serveix moltes peticions alhora (apartat 13) |
Els números ho deixen clar. Contra un servei a 50 ms de latència:
| Client nou per petició | Client reutilitzat | |
|---|---|---|
| Establir TCP | 50 ms | 50 ms només la primera vegada |
| Salutació TLS (HTTPS) | 100 ms | 100 ms només la primera vegada |
| La petició en si | 50 ms | 50 ms |
| Total per petició | 200 ms | 50 ms (després de la primera) |
Quatre vegades més lent, i sobre HTTPS encara pitjor. A més, cada client nou crea el seu propi pool de fils: crear centenars de clients fuga fils i memòria fins a tombar l'aplicació.
Sobre tancar el client. Fins a Java 20,
HttpClientno implementavaAutoCloseable: no hi havia forma de tancar-lo explícitament i es confiava en el recol·lector de brossa. Des de Java 21 sí que l'implementa, ambclose(),shutdown()ishutdownNow(), seguint el mateix model d'aturada en dues fases d'ExecutorServiceque coneixes de 08-05. Si treballes amb Java 17, simplement crea el client com a campstatic finali no t'hi amoïnis; si ets a 21 o superior, tanca'l a l'aturada ordenada de l'aplicació.
- Construir la petició:
HttpRequest
HttpRequestimport java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
HttpRequest peticio = HttpRequest.newBuilder()
// URI obligatoria. Fixa't que es URI, no URL: l'API moderna
// fa servir la classe correcta, sense l'equals() que fa DNS (09-05).
.uri(URI.create("https://api.nexussoftware.com/v1/llibres/978-0000000001"))
// Capcaleres. header() afegeix; setHeader() reemplaca si ja existeix.
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.header("Authorization", "Bearer " + token)
// Diverses de cop: parells nom, valor.
.headers("Accept-Language", "ca-ES", "X-Origen", "bibliotech")
// TEMPS LIMIT TOTAL de la peticio. Aixo NO existia a
// HttpURLConnection, que nomes tenia limit per operacio.
.timeout(Duration.ofSeconds(10))
// Versio especifica per a aquesta peticio, si cal.
.version(HttpClient.Version.HTTP_1_1)
// El metode. Un d'aquests, al final.
.GET()
.build();Els mètodes
.GET() // sense cos
.DELETE() // sense cos
.POST(HttpRequest.BodyPublishers.ofString(json))
.PUT(HttpRequest.BodyPublishers.ofString(json))
.method("PATCH", HttpRequest.BodyPublishers.ofString(json)) // qualsevol altrePATCH no té mètode propi perquè va arribar a l'estàndard després; es fa servir method(nom, publisher), que serveix per a qualsevol mètode, inclosos els personalitzats.
El temps límit total: la millora clau
Aquesta és una de les diferències més importants amb l'API antiga.
HttpURLConnection |
HttpClient |
|
|---|---|---|
| Límit de connexió | setConnectTimeout |
.connectTimeout() al client |
| Límit de lectura | setReadTimeout, per operació |
— |
| Límit total | No existeix | .timeout() a la petició |
El problema real que resol: un servidor que envia un byte cada nou segons manté viva indefinidament una petició amb setReadTimeout(10_000), perquè el termini es reinicia amb cada byte. Amb .timeout(Duration.ofSeconds(10)), als deu segons la petició acaba, passi el que passi, amb una HttpTimeoutException. Aquest comportament —de vegades anomenat atac de servidor lent— era impossible d'acotar amb l'API antiga.
Reutilitzar peticions
En ser immutables, un HttpRequest es pot enviar moltes vegades, i també partir d'una plantilla:
// Plantilla amb el que es comu. Es construeix una vegada.
HttpRequest.Builder plantilla = HttpRequest.newBuilder()
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(10));
// I per cada ISBN, nomes canvia la URI.
// copy() clona el builder: sense ell, modificariem la plantilla.
HttpRequest p1 = plantilla.copy()
.uri(URI.create(base + "/978-0000000001")).GET().build();
HttpRequest p2 = plantilla.copy()
.uri(URI.create(base + "/978-0000000002")).GET().build();El copy() és important: sense ell, plantilla.uri(...) modificaria la plantilla i la petició següent heretaria la URI anterior.
- Els
BodyPublishers: enviar un cos
BodyPublishers: enviar un cosUn BodyPublisher descriu d'on surten els bytes del cos.
| Mètode | Envia | Ús típic |
|---|---|---|
ofString(s) |
Un text (UTF-8 per defecte) | JSON, XML, formularis |
ofString(s, charset) |
Un text amb charset explícit | Quan no és UTF-8 |
ofByteArray(bytes) |
Un array de bytes | Dades binàries petites |
ofFile(path) |
El contingut d'un fitxer, sense carregar-lo en memòria | Pujar fitxers grans |
ofInputStream(sup) |
El que produeixi un InputStream |
Contingut generat |
noBody() |
Res | POST sense cos |
fromPublisher(p) |
Un Flow.Publisher<ByteBuffer> |
Reactiu, avançat |
import java.net.http.HttpRequest.BodyPublishers;
// JSON
HttpRequest p = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestecs"))
.header("Content-Type", "application/json; charset=utf-8")
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
// Pujar un fitxer SENSE carregar-lo en memoria: es llegeix a mesura que s'envia.
// Amb HttpURLConnection calia fer el streaming a ma.
HttpRequest pujada = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/cataleg/importar"))
.header("Content-Type", "text/csv; charset=utf-8")
.POST(BodyPublishers.ofFile(Path.of("cataleg.csv")))
.build();
// Formulari: aqui URLEncoder SI que es el correcte (09-05).
String formulari = "isbn=" + URLEncoder.encode(isbn, UTF_8)
+ "&empleat=" + URLEncoder.encode(empleat, UTF_8);
HttpRequest form = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestecs"))
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.POST(BodyPublishers.ofString(formulari))
.build();Dues coses que l'API fa per tu i abans eren manuals: calcula el Content-Length (recorda l'embolic de bytes contra caràcters de 09-05) i ofFile transmet sense carregar en memòria, cosa que permet pujar un fitxer d'un gigabyte sense problema.
- Rebre:
HttpResponse<T> i els BodyHandlers
HttpResponse<T> i els BodyHandlersUn BodyHandler<T> decideix en què es converteix el cos de la resposta, i amb això el tipus T de l'HttpResponse<T>.
| Gestor | Tipus resultant | Ús |
|---|---|---|
ofString() |
HttpResponse<String> |
JSON, HTML, text |
ofString(charset) |
HttpResponse<String> |
Amb charset explícit |
ofByteArray() |
HttpResponse<byte[]> |
Binari petit |
ofFile(path) |
HttpResponse<Path> |
Descàrrega directa a disc |
ofInputStream() |
HttpResponse<InputStream> |
Processar sense carregar en memòria |
ofLines() |
HttpResponse<Stream<String>> |
Línia a línia (fa servir Streams: 10-04) |
discarding() |
HttpResponse<Void> |
Descartar el cos però consumir-lo |
replacing(v) |
HttpResponse<T> |
Descartar i retornar un valor fix |
ofByteArrayConsumer(c) |
HttpResponse<Void> |
Processar per trossos |
import java.net.http.HttpResponse.BodyHandlers;
// Text. El charset explicit, com sempre.
HttpResponse<String> text = client.send(peticio,
BodyHandlers.ofString(StandardCharsets.UTF_8));
// Directament a un fitxer. Adeu al bucle de copia de 09-05.
HttpResponse<Path> fitxer = client.send(peticio,
BodyHandlers.ofFile(Path.of("portades", isbn + ".jpg")));
System.out.println("Desat a " + fitxer.body());
// Com a flux, per processar sense carregar-lo sencer.
HttpResponse<InputStream> flux = client.send(peticio,
BodyHandlers.ofInputStream());
try (InputStream entrada = flux.body()) {
// ... l'InputStream del modul 7, un altre cop ...
}
// Nomes interessa el codi d'estat (com un HEAD).
HttpResponse<Void> nomesEstat = client.send(peticio, BodyHandlers.discarding());ofFile mereix un moment d'atenció. A 09-05 vas escriure un bucle de còpia amb memòria intermèdia, comprovació de límit, fitxer temporal i moviment atòmic. Amb ofFile, la descàrrega a disc és una crida. (El temporal i el moviment atòmic continuen sent teus si vols aquesta garantia, i continuen valent la pena.)
El que ofereix HttpResponse<T>
HttpResponse<String> r = client.send(peticio, BodyHandlers.ofString(UTF_8));
int codi = r.statusCode(); // 200
String cos = r.body(); // el cos, del tipus T
HttpHeaders capcaleres = r.headers(); // les capcaleres
URI uri = r.uri(); // la URI FINAL (despres de redireccions)
HttpClient.Version versio = r.version(); // HTTP_2 o HTTP_1_1
HttpRequest original = r.request(); // la peticio que la va produir
// Les capcaleres, amb una API decent:
Optional<String> tipus = r.headers().firstValue("Content-Type");
List<String> totes = r.headers().allValues("Set-Cookie");
OptionalLong longitud = r.headers().firstValueAsLong("Content-Length");Dues millores respecte de l'API antiga: uri() retorna la URI final després de les redireccions, que és informació que abans calia rastrejar a mà; i headers() retorna un HttpHeaders amb mètodes útils en lloc d'aquell Map<String, List<String>> amb l'entrada de clau null.
Nota sobre
Optional.firstValueretornaOptional<String>perquè una capçalera pot no ser-hi. Aquí només el fem servir amborElse(...)oisPresent();Optionala fons, juntament amb Streams, és 10-04.
I el més important, que enllaça amb la lliçó anterior: HttpResponse no distingeix entre flux normal i d'error. Amb un 404 o un 500 obtens el cos de l'error a body() com qualsevol altre. S'ha acabat la distinció entre getInputStream i getErrorStream.
- Enviament síncron:
send
sendHttpResponse<String> resposta = client.send(peticio,
BodyHandlers.ofString(StandardCharsets.UTF_8));Bloqueja el fil fins que arriba la resposta completa. Llança:
| Excepció | Quan |
|---|---|
IOException |
Fallada de xarxa: connexió rebutjada, host desconegut, connexió trencada |
HttpTimeoutException |
S'ha esgotat el .timeout() de la petició (subclasse d'IOException) |
HttpConnectTimeoutException |
S'ha esgotat el .connectTimeout() del client |
InterruptedException |
El fil ha estat interromput esperant |
Fixa't en InterruptedException: send és interrompible. A diferència d'un socket.read() bloquejant, que ignora les interrupcions (09-03), aquí el protocol de cancel·lació de 08-02 funciona.
try {
HttpResponse<String> r = client.send(peticio, BodyHandlers.ofString(UTF_8));
// OBLIGATORI: comprovar el codi. Un 500 NO llanca excepcio.
if (r.statusCode() != 200) {
throw new BiblioTechException("El servei ha respost " + r.statusCode()
+ ": " + retallar(r.body()));
}
processar(r.body());
} catch (HttpTimeoutException e) {
// Transitori: mereix reintent amb espera creixent.
throw new BiblioTechException("El servei no respon a temps", e);
} catch (IOException e) {
throw new BiblioTechException("Error de xarxa consultant el servei", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02: restaurar SEMPRE el marcador
throw new BiblioTechException("Consulta interrompuda", e);
}
- Enviament asíncron:
sendAsync
sendAsyncI aquí és on el mòdul 8 i el mòdul 9 es troben.
CompletableFuture<HttpResponse<String>> futur = client.sendAsync(peticio,
BodyHandlers.ofString(StandardCharsets.UTF_8));sendAsync retorna immediatament, sense bloquejar res, amb un CompletableFuture<HttpResponse<String>>. Aquest tipus diu exactament el que és: un futur que, quan es completi, contindrà una resposta HTTP el cos de la qual és un String.
I a partir d'aquí, tot 08-07 s'aplica sense canviar ni una coma:
client.sendAsync(peticio, BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body) // extreure el cos
.thenApply(this::analitzarMetadades) // convertir-lo en objecte
.thenAccept(m -> System.out.println(m.titol()))
.exceptionally(e -> {
LOG.warning("Fallada: " + e.getMessage());
return null;
});
// El fil actual continua treballant. No ha esperat res.sequenceDiagram
participant M as Fil principal
participant C as HttpClient
participant E as Executor
participant S as Servei extern
M->>C: sendAsync(peticio, ofString)
C-->>M: CompletableFuture (buit, a l'instant)
Note over M: El fil principal CONTINUA. No espera.
C->>S: GET /v1/llibres/978-0000000001
Note over S: processa (200 ms)
S-->>C: 200 OK {...}
C->>E: completa el futur
E->>E: thenApply(body)
E->>E: thenApply(analitzar)
E->>E: thenAccept(mostrar)
Note over M,E: El resultat es processa en un fil de l'executor
Sobre quin fil s'executa cada etapa
El mateix que a 08-07: les etapes sense sufix Async es poden executar al fil que va completar l'anterior —aquí, un fil intern de l'HttpClient—, i les que porten Async fan servir l'executor.
La regla pràctica i la raó de ser: transformacions barates sense sufix; feina cara o bloquejant, amb ...Async i executor propi. Si fas una operació pesada en un thenApply sense sufix, l'executes en un fil intern del client HTTP i n'estàs frenant la capacitat d'atendre altres respostes.
// MALAMENT: escriptura a disc en un fil intern de l'HttpClient.
client.sendAsync(peticio, BodyHandlers.ofString(UTF_8))
.thenApply(r -> { escriureADisc(r.body()); return r; });
// BE: la feina cara va al nostre executor.
client.sendAsync(peticio, BodyHandlers.ofString(UTF_8))
.thenApplyAsync(r -> { escriureADisc(r.body()); return r; }, executorEs);
- Compondre cadenes asíncrones
Els operadors de 08-07, aplicats a HTTP.
thenApply: transformar el resultat
CompletableFuture<Metadades> futur =
client.sendAsync(peticioDe(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(r -> {
// El codi es comprova AQUI, dins de la cadena.
if (r.statusCode() == 404) {
return null;
}
if (r.statusCode() != 200) {
// Llancar dins de la cadena la fa fallar,
// i la fallada arriba a l'exceptionally.
throw new CompletionException(
new BiblioTechException("HTTP " + r.statusCode()));
}
return r.body();
})
.thenApply(this::analitzarMetadades);thenCompose: encadenar una altra petició
Quan el resultat d'una petició determina la següent. La distinció de 08-07 continua valent: thenApply quan la funció retorna un valor; thenCompose quan retorna un altre CompletableFuture.
// Primer les metadades, i amb la URL que porten, la portada.
CompletableFuture<Path> futur =
client.sendAsync(peticioMetadades(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(r -> analitzarMetadades(r.body()))
.thenCompose(m -> {
// Retorna un CompletableFuture -> thenCompose, no thenApply.
// Amb thenApply obtindriem un
// CompletableFuture<CompletableFuture<HttpResponse<Path>>>.
HttpRequest p = HttpRequest.newBuilder()
.uri(URI.create(m.urlPortada()))
.timeout(Duration.ofSeconds(30))
.GET().build();
return client.sendAsync(p,
BodyHandlers.ofFile(Path.of("portades", isbn + ".jpg")));
})
.thenApply(HttpResponse::body);Dues peticions dependents, encadenades, sense bloquejar ni un sol fil. Amb HttpURLConnection això serien dos blocs try amb dues esperes.
thenCombine: ajuntar dues d'independents
// Dos serveis diferents, consultats EN PARALLEL.
CompletableFuture<String> metadades =
client.sendAsync(peticioMetadades(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body);
CompletableFuture<String> valoracions =
client.sendAsync(peticioValoracions(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body);
// El temps total es el del MES LENT, no la suma.
CompletableFuture<String> fitxa = metadades.thenCombine(valoracions,
(m, v) -> compondreFitxa(m, v));orTimeout: termini sobre la cadena completa
client.sendAsync(peticio, BodyHandlers.ofString(UTF_8))
.thenApply(this::processar)
.orTimeout(15, TimeUnit.SECONDS) // termini de TOTA la cadena
.exceptionally(e -> {
if (e.getCause() instanceof TimeoutException) {
LOG.warning("La cadena completa ha superat els 15 s");
}
return respostaPerDefecte();
});Amb l'avís de 08-07 que continua vigent: orTimeout no cancel·la la feina subjacent. La petició HTTP continua en marxa; només es completa el futur amb un error. Per cancel·lar de veritat cal cridar cancel(true) sobre el futur que retorna sendAsync, que sí que avorta la petició.
- Diverses peticions en paral·lel amb
allOf
allOfEl cas que resol el problema obert de 09-05.
package com.nexussoftware.bibliotech.xarxa;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/** Consulta de diversos ISBN en parallel amb allOf. */
public class ConsultaParallela {
private final HttpClient client;
private final String base;
public ConsultaParallela(HttpClient client, String base) {
this.client = client;
this.base = base;
}
public List<String> consultarTots(List<String> isbns) {
List<CompletableFuture<String>> futurs = new ArrayList<>();
// 1. Llancar TOTES les peticions. sendAsync retorna a l'instant,
// aixi que aquest bucle acaba en microsegons i les N peticions
// queden en vol simultaniament.
for (String isbn : isbns) {
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/llibres/" + isbn))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
CompletableFuture<String> futur =
client.sendAsync(peticio, BodyHandlers.ofString(StandardCharsets.UTF_8))
.thenApply(r -> r.statusCode() == 200
? r.body()
: "ERROR " + r.statusCode() + " a " + isbn)
// BLINDAR CADA FUTUR ABANS D'AGREGAR-LO.
// Sense aixo, una sola fallada fa fallar l'allOf
// complet i perdem les N-1 respostes bones.
// Es el parany classic de 08-07.
.exceptionally(e -> "FALLADA a " + isbn + ": "
+ e.getCause().getMessage());
futurs.add(futur);
}
// 2. allOf es completa quan TOTS acaben.
CompletableFuture<Void> tots = CompletableFuture.allOf(
futurs.toArray(new CompletableFuture[0]));
// 3. Recollir. join() aqui es segur perque allOf ja ha garantit
// que tots estan complets: no bloqueja res.
return tots.thenApply(v -> {
List<String> resultats = new ArrayList<>(futurs.size());
for (CompletableFuture<String> f : futurs) {
resultats.add(f.join());
}
return resultats;
}).join(); // l'unic bloqueig real, i es intencionat
}
}Els tres punts del patró, tots apresos a 08-07:
- Llançar-les totes abans d'esperar-ne cap. El bucle de
sendAsyncacaba en microsegons amb N peticions en vol. - Blindar cada futur amb el seu propi
exceptionallyabans d'agregar-lo. Sense això, una única fallada fa fallar l'allOfsencer i perds totes les respostes bones. És l'error més car d'aquesta API. join()després de l'allOfés segur, perquè tots els futurs ja estan complets.
La diferència mesurada
Vint ISBN contra un servei que triga 200 ms per consulta:
| Estratègia | Temps | Com |
|---|---|---|
Seqüencial (send en bucle) |
4.000 ms | 20 × 200 ms |
Paral·lela (sendAsync + allOf) |
~250 ms | Totes alhora, més marge |
| Millora | 16× |
I el mateix trànsit de xarxa, exactament com va demostrar l'estimador de l'exercici 3 de 09-01: el que canvia no és el volum, és el solapament de les esperes.
Compte amb el paral·lelisme desbocat. Llançar mil
sendAsyncalhora crea mil peticions simultànies i probablement et guanyis un 429 o un bloqueig d'IP. En producció cal acotar: unSemaphorecom el de 08-05, o processar per tandes. Ho aplicarem a l'enriquidor de BiblioTech.
- Gestió d'errors: excepció davant de codi d'estat
La distinció que més bugs causa, i aquí convé deixar-la completament clara.
| Situació | Excepció? | Com es detecta |
|---|---|---|
| Host desconegut | Sí | IOException (UnresolvedAddressException com a causa) |
| Connexió rebutjada | Sí | IOException / ConnectException |
| Temps límit de connexió | Sí | HttpConnectTimeoutException |
| Temps límit de petició | Sí | HttpTimeoutException |
| Connexió trencada a mitges | Sí | IOException |
| Fallada de certificat TLS | Sí | IOException amb causa SSLHandshakeException |
| 404 Not Found | NO | statusCode() == 404 |
| 429 Too Many Requests | NO | statusCode() == 429 |
| 500 Internal Server Error | NO | statusCode() == 500 |
| 503 Service Unavailable | NO | statusCode() == 503 |
La regla, en una frase: hi ha excepció quan no s'ha pogut obtenir una resposta HTTP. Si hi ha resposta, hi va haver èxit de xarxa, encara que el codi sigui 500.
L'error clàssic, escrit perquè el reconeguis:
// CODI TRENCAT. Molt comu.
HttpResponse<String> r = client.send(peticio, BodyHandlers.ofString(UTF_8));
Metadades m = analitzar(r.body()); // <-- si ha estat un 500, body() es
// la pagina d'error del servidorAmb un 500, body() conté l'HTML d'error d'nginx o el JSON d'error del servei. analitzar() rebrà brossa i fallarà de forma incomprensible, o —pitjor— retornarà dades absurdes que semblen vàlides.
En una cadena asíncrona, la comprovació va a dins:
client.sendAsync(peticio, BodyHandlers.ofString(UTF_8))
.thenApply(r -> {
if (r.statusCode() == 404) {
return null; // "no existeix" es un resultat, no un error
}
if (r.statusCode() / 100 == 5) {
// Llancar dins d'una etapa fa fallar el futur,
// i la fallada es propaga fins al primer gestor.
throw new CompletionException(
new BiblioTechException("Fallada del servidor: " + r.statusCode()));
}
if (r.statusCode() != 200) {
throw new CompletionException(
new BiblioTechException("Resposta inesperada: " + r.statusCode()));
}
return r.body();
})
.exceptionally(e -> {
// COMPTE: la causa arriba EMBOLCALLADA en CompletionException (08-07).
Throwable causa = e.getCause() != null ? e.getCause() : e;
LOG.warning("Consulta fallida: " + causa.getMessage());
return null;
});
HttpURLConnection davant d'HttpClient
HttpURLConnection davant d'HttpClient| Criteri | HttpURLConnection (1996) |
HttpClient (Java 11) |
|---|---|---|
| Línies per a un GET simple | ~30 amb gestió de recursos | ~8 |
| Mutabilitat | Objecte mutable amb estats | Immutable |
| Segur per a diversos fils | No | Sí |
| Constructors fluids | No | Sí |
| Temps límit total | No existeix | Sí, .timeout() |
| Asincronia | No | sendAsync → CompletableFuture |
| HTTP/2 | No | Sí, per defecte |
| Multiplexació | No | Sí, amb HTTP/2 |
| Reutilització de connexions | Sí, però opaca i fràgil | Sí, pool gestionat |
| WebSocket | No | Sí |
| Flux d'error | getErrorStream() a part |
Un de sol: body() |
| Redireccions http→https | No les segueix | Sí, amb NORMAL |
| Cos a fitxer | Bucle de còpia a mà | BodyHandlers.ofFile |
| Pujar fitxer sense memòria | setFixedLengthStreamingMode a mà |
BodyPublishers.ofFile |
| Capçaleres de resposta | Map amb clau null estranya |
HttpHeaders amb mètodes |
| URI final després de redirecció | Cal rastrejar-la | response.uri() |
| Facilitat de prova | Molt baixa | Mitjana (interfície substituïble) |
| Disponible des de | Java 1.0 | Java 11 |
L'única raó per fer servir l'antiga és haver de compilar per a Java 8 o anterior, o mantenir codi que ja la fa servir. Per a tota la resta, HttpClient.
- HTTP/2 i la multiplexació
HttpClient parla HTTP/2 per defecte i negocia automàticament: si el servidor no el suporta, cau a HTTP/1.1 sense que facis res.
La millora principal és la multiplexació. A HTTP/1.1, una connexió TCP serveix una petició alhora: per fer-ne sis en paral·lel calen sis connexions, amb les seves sis salutacions de tres vies i les seves sis salutacions TLS. HTTP/2 divideix la connexió en fluxos independents que viatgen entrellaçats, de manera que una sola connexió serveix desenes de peticions simultànies.
HTTP/1.1, sis peticions en parallel:
connexio 1: [salutacio][TLS][peticio A............]
connexio 2: [salutacio][TLS][peticio B............]
... sis connexions, sis establiments ...
HTTP/2, sis peticions en parallel:
connexio 1: [salutacio][TLS][A|B|C|A|D|B|E|C|F|...]
... UNA connexio, UN establiment, fluxos entrellacats ...Conseqüències per a tu:
- L'
allOfamb vint peticions al mateix host fa servir una sola connexió en lloc de vint. Menys establiments, menys salutacions TLS, menys recursos al servidor. - Les capçaleres es comprimeixen (HPACK), cosa que importa quan envies un token llarg a cada petició.
- Continua existint el bloqueig de capçalera de línia a nivell TCP: un paquet perdut endarrereix tots els fluxos d'aquella connexió. És el problema que HTTP/3 resol movent-se a UDP, com vas veure a 09-04.
// Comprovar quina versio s'ha negociat realment.
HttpResponse<String> r = client.send(peticio, BodyHandlers.ofString(UTF_8));
System.out.println("Versio negociada: " + r.version()); // HTTP_2 o HTTP_1_1
WebSocket
WebSocketEl mateix paquet inclou un client de WebSocket, el protocol de comunicació bidireccional i persistent sobre HTTP. Amb HTTP el client pregunta i el servidor respon; amb WebSocket tots dos poden enviar en qualsevol moment, cosa que serveix per a notificacions, xats i dades en viu.
package com.nexussoftware.bibliotech.xarxa;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.WebSocket;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionStage;
import java.util.logging.Logger;
/**
* Exemple minim de WebSocket: BiblioTech rebria avisos del servidor
* en temps real ("el llibre que esperaves esta disponible") sense sondejar.
*/
public class AvisosWebSocket {
private static final Logger LOG = Logger.getLogger(AvisosWebSocket.class.getName());
public static void main(String[] args) throws Exception {
HttpClient client = HttpClient.newHttpClient();
// El Listener rep els esdeveniments. Fixa't en request(1) al final
// de cada metode: WebSocket fa servir CONTRAPRESSIO, i cal demanar
// explicitament el missatge seguent.
WebSocket.Listener oient = new WebSocket.Listener() {
@Override
public void onOpen(WebSocket ws) {
LOG.info("Connexio WebSocket oberta");
ws.request(1); // demanem el primer missatge
}
@Override
public CompletionStage<?> onText(WebSocket ws, CharSequence dades,
boolean ultim) {
System.out.println("AVIS: " + dades);
ws.request(1); // i el seguent
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket ws, int codi, String motiu) {
LOG.info("WebSocket tancat: " + codi + " " + motiu);
return null;
}
@Override
public void onError(WebSocket ws, Throwable error) {
LOG.warning("Error a WebSocket: " + error.getMessage());
}
};
// buildAsync retorna un CompletableFuture<WebSocket>: coherencia
// total amb la resta de l'API.
WebSocket ws = client.newWebSocketBuilder()
.buildAsync(URI.create("ws://localhost:8080/avisos"), oient)
.join();
ws.sendText("SUBSCRIURE 978-0000000001", true);
Thread.sleep(30_000); // escoltem 30 segons
ws.sendClose(WebSocket.NORMAL_CLOSURE, "fi").join();
}
}Es menciona per completesa: és la resposta de la biblioteca estàndard quan el sondeig periòdic no basta. El seu ús a fons queda fora de l'abast d'aquest curs.
- Enviar i rebre JSON
JSON és el format d'intercanvi de pràcticament totes les API actuals. I aquí toca ser honest sobre el que es pot i no es pot fer amb el JDK a seques.
Construir el cos
// A MA. Funciona per a casos simples, i CAL ESCAPAR.
String json = "{"
+ "\"isbn\":\"" + escapar(isbn) + "\","
+ "\"empleat\":\"" + escapar(empleat) + "\","
+ "\"dies\":" + dies
+ "}";
/**
* Escapada minima de JSON. Els caracters que TRENQUEN el document
* si no s'escapen son: la cometa doble, la barra invertida
* i els caracters de control.
*/
static String escapar(String text) {
StringBuilder sb = new StringBuilder(text.length() + 16);
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
switch (c) {
case '"' -> sb.append("\\\"");
case '\\' -> sb.append("\\\\");
case '\n' -> sb.append("\\n");
case '\r' -> sb.append("\\r");
case '\t' -> sb.append("\\t");
default -> {
if (c < 0x20) {
sb.append(String.format("\\u%04x", (int) c));
} else {
sb.append(c);
}
}
}
}
return sb.toString();
}Extreure un camp de la resposta
/**
* Extreu "camp":"valor" d'un JSON PER CERCA DE SUBCADENA.
*
* AIXO ES UN APEDACAMENT DIDACTIC I CAL DIR-HO CLAR.
*
* Funciona amb respostes planes i senzilles com les d'aquest servei,
* i ES TRENCA amb:
* - valors que continguin la subcadena cercada
* - cometes escapades dins d'un valor ("Java \"Eficac\"")
* - objectes imbricats o arrays
* - un camp del mateix nom en un objecte intern
* - valors null, numerics o booleans on s'espera text
* - espais diferents al voltant dels dos punts
*
* FER-HO BE REQUEREIX UNA LLIBRERIA DE JSON, I AIXO ES 11-07 (JACKSON),
* on una linia -mapper.readValue(json, Metadades.class)- substitueix
* tot aixo i a sobre converteix directament al record. No portis
* aquest codi a produccio.
*/
static String campText(String json, String camp) {
String marca = "\"" + camp + "\"";
int i = json.indexOf(marca);
if (i < 0) {
return null;
}
int dosPunts = json.indexOf(':', i + marca.length());
if (dosPunts < 0) {
return null;
}
int obre = json.indexOf('"', dosPunts);
if (obre < 0) {
return null;
}
// Cerquem la cometa de tancament saltant les escapades.
int j = obre + 1;
StringBuilder valor = new StringBuilder();
while (j < json.length()) {
char c = json.charAt(j);
if (c == '\\' && j + 1 < json.length()) {
valor.append(json.charAt(j + 1));
j += 2;
continue;
}
if (c == '"') {
return valor.toString();
}
valor.append(c);
j++;
}
return null;
}És important entendre el missatge. No es tracta que aquest codi sigui dolent per descuit: és que analitzar JSON correctament és un problema resolt que no has de resoldre tu. El JDK no porta analitzador de JSON, així que en aquest mòdul, que es limita a la biblioteca estàndard, l'opció honesta és un apedaçament acotat i ben assenyalat. A 11-07 veuràs Jackson, i mapper.readValue(json, Metadades.class) substituirà totes aquestes línies retornant el record ja construït.
- Bones pràctiques per cridar serveis externs
Un servei extern és la part del teu sistema que no controles. Aquestes pràctiques assumeixen que fallarà.
- Temps límit, sempre i tots dos
HttpClient client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // establir
.build();
HttpRequest peticio = HttpRequest.newBuilder()
.timeout(Duration.ofSeconds(10)) // total de la peticio
.build();Sense ells, un servei lent es propaga pel teu sistema fins a esgotar els fils. És la primera causa de caigudes en cascada.
- Reintentar només el transitori
| Situació | Reintentar? |
|---|---|
HttpTimeoutException |
Sí |
ConnectException |
Sí, poques vegades |
| 429, 502, 503, 504 | Sí, respectant Retry-After si ve |
UnresolvedAddressException |
No |
| 4xx (llevat de 429) | No |
| 500 | Amb cautela: pot ser determinista |
Amb espera creixent i aleatoritzada, exactament com a 09-05: 200 ms, 400, 800... més un 20 % de variació aleatòria per evitar que cent clients reintentin sincronitzats i tornin a tombar el servei que es recuperava.
- No reintentar un
POST no idempotent
POST no idempotentSi un POST que registra un préstec esgota el seu termini, no saps si el servidor l'ha processat. Reintentar pot crear dos préstecs. La solució professional és la clau d'idempotència:
// El client genera un identificador UNIC per operacio logica
// -no per intent- i l'envia. El servidor emmagatzema les claus ja
// vistes i retorna el resultat anterior en lloc de repetir
// l'operacio. Amb aixo, reintentar SI que es segur.
String clau = UUID.randomUUID().toString();
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestecs"))
.header("Idempotency-Key", clau) // la MATEIXA a tots els reintents
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(json))
.build();
- Tallacircuits, en una frase
Si un servei porta vint fallades seguides, continuar cridant-lo només consumeix els teus fils i endarrereix els teus usuaris: un tallacircuits deixa d'intentar-ho durant un temps, retorna l'error immediatament i prova de tant en tant a veure si s'ha recuperat. S'implementa a mà o amb biblioteques com Resilience4j; els patrons de resiliència es veuen a 12-07.
- No registrar mai credencials
// MALAMENT: el token acaba al fitxer de log, i d'alli a la copia de
// seguretat, al sistema d'agregacio de logs i a qualsevol
// captura de pantalla de suport.
LOG.info("Peticio: " + peticio.headers());
// BE: nomes el que es pot registrar.
LOG.info(() -> "GET " + peticio.uri().getPath()
+ " -> " + resposta.statusCode()
+ " (" + ms + " ms)");Reprèn el que vas aprendre a 06-07: les dades sensibles no van al log. I hi ha una regla que s'oblida: una URL amb un token a la cadena de consulta també és sensible. Registrar la URI completa filtra el token igual que registrar la capçalera. Per això l'exemple bo registra només getPath().
Llista del que no es registra mai: tokens, claus d'API, contrasenyes, galetes de sessió, capçaleres Authorization, números de targeta i dades personals.
- Un
User-Agent identificatiu
User-Agent identificatiu
- TLS i validació de certificats
Fes servir https sempre que el servei ho ofereixi. No desactivis mai la validació de certificats: converteix HTTPS en HTTP amb passos extra i obre la porta a un atac d'intermediari. Si tens un certificat intern, configura un SSLContext amb el teu magatzem de confiança:
HttpClient client = HttpClient.newBuilder()
.sslContext(contextAmbMagatzemPropi()) // NO un TrustManager que accepti tot
.build();La seguretat de xarxa es tracta a fons a 12-07.
- BiblioTech: l'enriquidor asíncron de catàleg
Tot junt, i resolent el problema que va deixar obert 09-05.
package com.nexussoftware.bibliotech.xarxa;
import com.nexussoftware.bibliotech.domini.Material;
import com.nexussoftware.bibliotech.servei.CatalegConcurrent;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Enriqueix el cataleg de BiblioTech amb dades del servei extern
* de metadades, consultant TOTS els ISBN en parallel i descarregant
* les portades sense bloquejar ni un sol fil.
*
* Resol el problema que va deixar obert SincronitzadorPortades (09-05),
* que era sequencial i passava tot el temps esperant la xarxa.
*
* Aplica: HttpClient reutilitzat, sendAsync + thenCompose + allOf de 08-07,
* Semaphore de 08-05 per acotar el parallelisme, i LongAdder de 08-06
* per a les metriques.
*/
public class EnriquidorCataleg implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(EnriquidorCataleg.class.getName());
private static final String AGENT = "BiblioTech/1.0 (+https://nexussoftware.com)";
private static final int MAXIM_SIMULTANIES = 8;
private static final long MAXIMA_PORTADA = 5L * 1024 * 1024;
private final String base;
private final Path directoriPortades;
private final HttpClient client;
private final ExecutorService executor;
/**
* Acota el parallelisme. Sense ell, mil ISBN generarien mil peticions
* simultanies i el servei ens tornaria 429 o ens bloquejaria la IP.
* Es el Semaphore de 08-05, aplicat a la xarxa.
*/
private final Semaphore permisos = new Semaphore(MAXIM_SIMULTANIES);
// Metriques sense contencio (08-06).
private final LongAdder enriquits = new LongAdder();
private final LongAdder noTrobats = new LongAdder();
private final LongAdder portadesDescarregades = new LongAdder();
private final LongAdder fallits = new LongAdder();
private final LongAdder bytesPortades = new LongAdder();
public EnriquidorCataleg(String base, Path directoriPortades) {
this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
this.directoriPortades = directoriPortades;
// Executor propi amb fils ANOMENATS (08-02): en un bolcat de
// fils i en cada linia de log sabras qui fa que.
AtomicInteger n = new AtomicInteger(1);
this.executor = Executors.newFixedThreadPool(MAXIM_SIMULTANIES, r -> {
Thread f = new Thread(r, "bibliotech-http-" + n.getAndIncrement());
f.setDaemon(true);
return f;
});
// UN client, creat una vegada i reutilitzat: pool de connexions,
// sessions TLS reutilitzades i multiplexacio HTTP/2.
this.client = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(executor)
.build();
}
/** Fitxa enriquida d'un material. */
public record FitxaEnriquida(String isbn, String titol, String autor,
int pagines, Path portada, String estat) {
}
// =================================================================
// Punt d'entrada
// =================================================================
public List<FitxaEnriquida> enriquir(CatalegConcurrent cataleg) {
List<Material> materials = cataleg.tots();
List<String> isbns = new ArrayList<>(materials.size());
for (Material m : materials) {
isbns.add(m.getIsbn());
}
return enriquirIsbns(isbns);
}
public List<FitxaEnriquida> enriquirIsbns(List<String> isbns) {
long inici = System.currentTimeMillis();
System.out.println("Enriquint " + isbns.size()
+ " materials (fins a " + MAXIM_SIMULTANIES + " alhora)...\n");
try {
Files.createDirectories(directoriPortades);
} catch (Exception e) {
LOG.log(Level.WARNING, "No s'ha pogut crear el directori de portades", e);
}
// --- 1. Llancar TOTES les cadenes ---
// sendAsync retorna a l'instant, aixi que aquest bucle acaba en
// microsegons amb N cadenes en marxa.
List<CompletableFuture<FitxaEnriquida>> futurs =
new ArrayList<>(isbns.size());
for (String isbn : isbns) {
futurs.add(cadenaDe(isbn));
}
// --- 2. Esperar-les totes ---
CompletableFuture<Void> totes = CompletableFuture.allOf(
futurs.toArray(new CompletableFuture[0]));
// --- 3. Recollir ---
List<FitxaEnriquida> fitxes = totes.thenApply(v -> {
List<FitxaEnriquida> llista = new ArrayList<>(futurs.size());
for (CompletableFuture<FitxaEnriquida> f : futurs) {
// join() segur: allOf ja ha garantit que tots estan complets.
llista.add(f.join());
}
return llista;
}).join(); // l'unic bloqueig real, intencionat
informe(System.currentTimeMillis() - inici, isbns.size());
return fitxes;
}
// =================================================================
// La cadena asincrona d'UN material
// =================================================================
private CompletableFuture<FitxaEnriquida> cadenaDe(String isbn) {
if (!isbnValid(isbn)) {
fallits.increment();
return CompletableFuture.completedFuture(
new FitxaEnriquida(isbn, null, null, 0, null, "ISBN INVALID"));
}
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/llibres/" + isbn))
.header("Accept", "application/json")
.header("User-Agent", AGENT)
.timeout(Duration.ofSeconds(10)) // limit TOTAL, no per operacio
.GET()
.build();
// adquirirPermis bloqueja si ja n'hi ha 8 en vol. Es fa ABANS
// del sendAsync perque el semafor limiti peticions reals.
adquirirPermis();
return client.sendAsync(peticio, BodyHandlers.ofString(StandardCharsets.UTF_8))
// --- Etapa 1: comprovar el codi i quedar-nos amb el cos ---
.thenApply(resposta -> {
alliberarPermis();
int codi = resposta.statusCode();
LOG.fine(() -> "GET /v1/llibres/" + isbn + " -> " + codi);
if (codi == 404) {
return null; // "no el tinc" es un resultat
}
if (codi != 200) {
// Llancar aqui fa fallar el futur; la fallada arriba
// a l'exceptionally del final.
throw new CompletionException(
new java.io.IOException("HTTP " + codi
+ " consultant " + isbn));
}
return resposta.body();
})
// --- Etapa 2: analitzar el JSON ---
.thenApply(cos -> {
if (cos == null) {
noTrobats.increment();
return new FitxaEnriquida(isbn, null, null, 0, null,
"NO TROBAT");
}
String titol = campText(cos, "titol");
String autor = campText(cos, "autor");
String portada = campText(cos, "portada");
int pagines = campEnter(cos, "pagines");
if (titol == null) {
throw new CompletionException(
new java.io.IOException("Resposta sense titol"));
}
enriquits.increment();
return new FitxaEnriquida(isbn, titol,
autor == null ? "(desconegut)" : autor,
pagines,
portada == null ? null : Path.of(portada), // marcador
"OK");
})
// --- Etapa 3: descarregar la portada, si n'hi ha ---
// thenCompose perque retorna UN ALTRE CompletableFuture:
// amb thenApply tindriem un futur d'un futur (08-07).
.thenCompose(fitxa -> {
if (fitxa.portada() == null) {
return CompletableFuture.completedFuture(fitxa);
}
// El camp 'portada' porta la URL de forma provisional.
String url = fitxa.portada().toString();
return descarregarPortada(url, isbn)
.thenApply(cami -> new FitxaEnriquida(
fitxa.isbn(), fitxa.titol(), fitxa.autor(),
fitxa.pagines(), cami,
cami == null ? "OK (sense portada)" : "OK"));
})
// --- Termini de la cadena COMPLETA ---
.orTimeout(30, TimeUnit.SECONDS)
// --- Blindatge: OBLIGATORI abans d'agregar a l'allOf ---
// Sense aixo, una sola fallada fa fallar l'allOf sencer i
// perdem totes les fitxes bones. Parany classic de 08-07.
.exceptionally(e -> {
alliberarPermis(); // per si ha fallat abans d'alliberar-lo
fallits.increment();
// La causa arriba EMBOLCALLADA en CompletionException.
Throwable causa = e.getCause() != null ? e.getCause() : e;
LOG.warning("Fallada enriquint " + isbn + ": "
+ causa.getMessage());
return new FitxaEnriquida(isbn, null, null, 0, null,
"FALLADA: " + causa.getClass().getSimpleName());
});
}
/** Descarrega la portada a disc. Retorna null si no s'ha pogut. */
private CompletableFuture<Path> descarregarPortada(String url, String isbn) {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
// Sense aquesta comprovacio, una URL "file:///etc/passwd" rebuda
// del servei ens faria llegir fitxers locals.
LOG.warning("Esquema no permes a la portada de " + isbn);
return CompletableFuture.completedFuture(null);
}
Path desti = directoriPortades.resolve(isbn + ".jpg");
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "image/jpeg, image/png, image/*")
.header("User-Agent", AGENT)
.timeout(Duration.ofSeconds(30))
.GET()
.build();
adquirirPermis();
// BodyHandlers.ofFile escriu directament a disc, sense carregar
// la imatge en memoria i sense el bucle de copia de 09-05.
return client.sendAsync(peticio, BodyHandlers.ofFile(desti))
.thenApply(resposta -> {
alliberarPermis();
if (resposta.statusCode() != 200) {
LOG.fine("Portada de " + isbn + ": HTTP "
+ resposta.statusCode());
return null;
}
Path cami = resposta.body();
try {
long mida = Files.size(cami);
if (mida > MAXIMA_PORTADA) {
Files.deleteIfExists(cami);
LOG.warning("Portada de " + isbn + " massa gran");
return null;
}
bytesPortades.add(mida);
} catch (Exception e) {
LOG.fine("No s'ha pogut comprovar la mida: " + e.getMessage());
}
portadesDescarregades.increment();
return cami;
})
.exceptionally(e -> {
alliberarPermis();
LOG.fine("Fallada descarregant la portada de " + isbn);
return null; // sense portada no es una fallada de l'enriquiment
});
}
// =================================================================
// Semafor
// =================================================================
private void adquirirPermis() {
try {
permisos.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02
throw new CompletionException(e);
}
}
private void alliberarPermis() {
permisos.release();
}
// =================================================================
// Analisi de JSON: APEDACAMENT DIDACTIC, veure l'apartat 15
// =================================================================
/**
* Extraccio per cerca de subcadena. Funciona amb les respostes
* planes d'aquest servei i es trenca amb imbricacio, arrays o camps
* repetits. FER-HO BE ES JACKSON, I AIXO ES 11-07.
*/
private String campText(String json, String camp) {
String marca = "\"" + camp + "\"";
int i = json.indexOf(marca);
if (i < 0) {
return null;
}
int dosPunts = json.indexOf(':', i + marca.length());
if (dosPunts < 0) {
return null;
}
int obre = json.indexOf('"', dosPunts);
if (obre < 0) {
return null;
}
StringBuilder valor = new StringBuilder();
int j = obre + 1;
while (j < json.length()) {
char c = json.charAt(j);
if (c == '\\' && j + 1 < json.length()) {
valor.append(json.charAt(j + 1));
j += 2;
continue;
}
if (c == '"') {
return valor.toString();
}
valor.append(c);
j++;
}
return null;
}
private int campEnter(String json, String camp) {
String marca = "\"" + camp + "\"";
int i = json.indexOf(marca);
if (i < 0) {
return 0;
}
int j = json.indexOf(':', i + marca.length()) + 1;
while (j < json.length() && !Character.isDigit(json.charAt(j))) {
if (json.charAt(j) == ',' || json.charAt(j) == '}') {
return 0;
}
j++;
}
int inici = j;
while (j < json.length() && Character.isDigit(json.charAt(j))) {
j++;
}
return inici == j ? 0 : Integer.parseInt(json.substring(inici, j));
}
private boolean isbnValid(String isbn) {
if (isbn == null || isbn.isBlank() || isbn.length() > 20) {
return false;
}
// Llista blanca: impedeix injectar camins ("../admin") a la URL.
for (int i = 0; i < isbn.length(); i++) {
char c = isbn.charAt(i);
if (!Character.isDigit(c) && c != '-') {
return false;
}
}
return true;
}
// =================================================================
// Informe i tancament
// =================================================================
private void informe(long ms, int total) {
System.out.println();
System.out.println("=== ENRIQUIMENT DE CATALEG ===");
System.out.printf("%-26s %d%n", "Materials", total);
System.out.printf("%-26s %d%n", "Enriquits", enriquits.sum());
System.out.printf("%-26s %d%n", "No trobats", noTrobats.sum());
System.out.printf("%-26s %d%n", "Fallits", fallits.sum());
System.out.printf("%-26s %d%n", "Portades descarregades",
portadesDescarregades.sum());
System.out.printf("%-26s %.1f KB%n", "Bytes de portades",
bytesPortades.sum() / 1024.0);
System.out.printf("%-26s %.2f s%n", "Temps total", ms / 1000.0);
if (total > 0) {
System.out.printf("%-26s %.1f ms%n", "Mitjana per material",
(double) ms / total);
}
}
@Override
public void close() {
// Aturada en dues fases (08-05).
executor.shutdown();
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Peticions encara actives; es forca el tancament");
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt(); // 08-02
}
LOG.info("EnriquidorCataleg tancat");
}
}Execució
package com.nexussoftware.bibliotech.presentacio;
import com.nexussoftware.bibliotech.xarxa.EnriquidorCataleg;
import com.nexussoftware.bibliotech.xarxa.EnriquidorCataleg.FitxaEnriquida;
import java.nio.file.Path;
import java.util.List;
public class ProvaEnriquidor {
public static void main(String[] args) {
List<String> isbns = List.of(
"978-0000000001", "978-0000000002", "978-0000000003",
"978-0000000004", "978-0000000005", "978-0000000006",
"978-0000000007", "978-0000000008", "978-0000000009",
"978-0000000010", "978-0000000011", "978-0000000012",
"978-0000000013", "978-0000000014", "978-0000000015",
"978-0000000016", "978-0000000017", "978-0000000018",
"978-0000000019", "978-0000000020");
// AutoCloseable: aturada ordenada de l'executor.
try (EnriquidorCataleg enriquidor = new EnriquidorCataleg(
"http://localhost:8080", Path.of("portades"))) {
List<FitxaEnriquida> fitxes = enriquidor.enriquirIsbns(isbns);
System.out.println("\n--- RESULTAT ---");
for (FitxaEnriquida f : fitxes) {
System.out.printf(" %-18s %-30s %s%n",
f.isbn(),
f.titol() == null ? "-" : f.titol(),
f.estat());
}
}
}
}Sortida contra un servei que triga 200 ms per consulta:
Enriquint 20 materials (fins a 8 alhora)...
=== ENRIQUIMENT DE CATALEG ===
Materials 20
Enriquits 17
No trobats 2
Fallits 1
Portades descarregades 15
Bytes de portades 682.4 KB
Temps total 1.24 s
Mitjana per material 62.0 ms
--- RESULTAT ---
978-0000000001 Java Eficac OK
978-0000000002 Patrons de Disseny OK
978-0000000003 Refactoritzacio OK
978-0000000004 - NO TROBAT
...
978-0000000019 - FALLADA: HttpTimeoutExceptionCompara amb el sincronitzador seqüencial de 09-05. Aquell trigava 5,4 segons per a cinc materials, és a dir, més d'un segon per material. Aquest triga 1,24 segons per a vint, amb dues peticions cadascun (metadades i portada): 62 ms per material. Una millora de més de disset vegades, amb el mateix trànsit de xarxa i sense ni un sol fil bloquejat esperant.
Fixa't a més en l'última línia: una petició ha esgotat el seu termini i les dinou restants s'han completat amb normalitat. Aquest és l'exceptionally individual fent la seva feina. Sense ell, aquella única fallada hauria fet fallar l'allOf i el resultat hauria estat zero fitxes.
Errors Comuns i Consells
Crear un HttpClient per petició. Perds el pool de connexions, les sessions TLS i la multiplexació HTTP/2, i crees un pool de fils cada vegada. Pot multiplicar per quatre la latència i acabar fugant fils. Un per aplicació, static final.
Suposar que un 4xx o 5xx llança excepció. No ho fa. body() contindrà la pàgina d'error del servidor i el teu analitzador rebrà brossa. Comprova statusCode() sempre.
Oblidar que followRedirects és NEVER per defecte. Al contrari que a HttpURLConnection. Si el teu codi migrat es queda amb un 301, és això. Fes servir NORMAL.
No blindar cada futur amb exceptionally abans de l'allOf. L'error més car d'aquesta API: una sola fallada fa fallar l'allOf sencer i perds totes les respostes bones.
Confondre thenApply amb thenCompose. Si la funció retorna un altre CompletableFuture, és thenCompose. Amb thenApply acabes amb un CompletableFuture<CompletableFuture<T>> i el compilador t'ho dirà d'una forma no especialment clara.
Fer feina pesada en un thenApply sense sufix. S'executa en un fil intern de l'HttpClient i li frena la capacitat d'atendre altres respostes. Feina cara, ...Async amb executor propi.
Llançar milers de sendAsync sense acotar. Et guanyes un 429 o un bloqueig d'IP, i satures el servei. Fes servir un Semaphore o processa per tandes.
No posar .timeout() a la petició. El connectTimeout del client només cobreix l'establiment. Sense el límit total, un servidor lent et pot retenir indefinidament.
Oblidar Thread.currentThread().interrupt() en capturar InterruptedException. Regla de 08-02, i send és interrompible, així que aquí s'aplica de veritat.
Modificar una plantilla d'HttpRequest.Builder sense copy(). La petició següent hereta l'anterior. Fes servir .copy().
Registrar capçaleres o URI completes. L'Authorization acaba al log, i una URL amb un token a la consulta també. Registra el mètode, el camí i el codi.
Desactivar la validació de certificats TLS. Elimina tota la seguretat d'HTTPS. Si el certificat és intern, configura un SSLContext amb el teu magatzem (12-07).
Reintentar un POST no idempotent. Pots duplicar l'operació. O no reintentes, o fas servir clau d'idempotència.
Analitzar JSON amb indexOf en producció. Funciona fins que un valor porta cometes escapades o el servei imbrica un objecte. Jackson, a 11-07.
Consell de migració. Si estàs passant codi d'HttpURLConnection a HttpClient, revisa tres coses en aquest ordre: followRedirects (canvia el valor per defecte), la comprovació del codi d'estat (ja no hi ha getErrorStream, però continua calent mirar statusCode()), i el .timeout() de la petició (nou, i és el que de veritat et protegeix). Amb això resols la majoria de les sorpreses.
Exercicis
Exercici 1: Client HTTP asíncron resistent
Reescriu el ClientHttpResistent de 09-05 fent servir HttpClient, però asíncron: CompletableFuture<Resposta> get(String url).
Requisits:
- Un
HttpClientreutilitzat, amb executor propi de fils anomenats. - Reintents amb espera creixent i aleatoritzada, implementats dins de la cadena asíncrona amb
thenComposerecursiu. Res deThread.sleep: fes servirCompletableFuture.delayedExecutor(...)per no bloquejar un fil mentre esperes. - Reintentar només el transitori:
HttpTimeoutException,ConnectException, 429, 502, 503, 504. - Respectar
Retry-Aftersi ve, amb un sostre de 30 s. - Màxim 4 intents, amb el nombre d'intents al resultat.
- Mètode
postque no reintenti per defecte ipostIdempotentque sí, amb capçaleraIdempotency-Keyigual a tots els reintents. - Escriu un
mainque llanci 10 peticions alhora a un servei que falli aleatòriament i mostri el desglossament.
Exercici 2: Comparador d'estratègies
Escriu ComparadorEstrategies, que mesuri les quatre formes de fer N peticions i demostri amb números per què l'asíncrona guanya.
Requisits:
- Estratègia A: seqüencial amb
senden un bucle. - Estratègia B: paral·lela amb un
ExecutorServicede 8 fils isendbloquejant (l'enfocament del mòdul 8 senseCompletableFuture). - Estratègia C: asíncrona amb
sendAsync+allOf, sense límit. - Estratègia D: asíncrona amb
sendAsync+allOfacotada ambSemaphorea 8 simultànies. - Per a cadascuna: temps total, peticions per segon, latència mitjana, i nombre màxim de fils vius durant l'execució (amb
Thread.activeCount()mostrejat des d'un fil a part). - Descartar una ronda d'escalfament abans de mesurar cada estratègia.
- Taula comparativa final amb el factor de millora respecte d'A.
- Comenta per què B i D donen temps semblants però consumeixen recursos molt diferents.
Executa amb N = 50 contra un servei que trigui 200 ms.
Exercici 3: Panell d'estat de serveis de Nexus Software
Escriu PanellEstatServeis, que comprovi periòdicament la salut de tots els serveis dels quals depèn BiblioTech i mostri un panell en consola.
Requisits:
- Llista de serveis configurable: nom, URL de salut, i codi esperat.
- Comprovació de tots en paral·lel amb
sendAsync+allOf, ambBodyHandlers.discarding()(no interessa el cos) i.timeout()curt de 3 s. - Per a cada servei: estat (
AMUNT,DEGRADATsi respon però amb codi inesperat,AVALLsi hi ha excepció), latència en ms, codi HTTP i versió negociada (HTTP/1.1 o HTTP/2). - Historial de les últimes 20 comprovacions per servei, amb percentatge de disponibilitat i una barra d'estat tipus
####-###-##(una per comprovació). - Repetició cada 10 s amb un
ScheduledExecutorService(08-05), amb el cos de la tasca embolcallat entry/catchperquè una excepció no cancel·li la tasca programada en silenci. - Aturada ordenada en dues fases amb shutdown hook.
- Alerta al log quan un servei passi d'
AMUNTaAVALLo a l'inrevés, sense repetir-la a cada cicle.
Solucions
Solució 1
package com.nexussoftware.bibliotech.xarxa;
import java.io.IOException;
import java.net.ConnectException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.net.http.HttpTimeoutException;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.List;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CompletionException;
import java.util.concurrent.Executor;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Logger;
/**
* Client HTTP asincron amb reintents, sobre java.net.http.
*
* La clau de l'exercici: els reintents es fan DINS de la cadena
* asincrona amb thenCompose recursiu i delayedExecutor. Cap fil
* es bloqueja esperant entre intents.
*/
public class ClientAsincronResistent implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(ClientAsincronResistent.class.getName());
private static final int MAXIM_INTENTS = 4;
private static final long ESPERA_INICIAL_MS = 200;
private static final long MAXIM_RETRY_AFTER_MS = 30_000;
private final HttpClient client;
private final ExecutorService executor;
public ClientAsincronResistent() {
AtomicInteger n = new AtomicInteger(1);
this.executor = Executors.newFixedThreadPool(8, r -> {
Thread f = new Thread(r, "http-resistent-" + n.getAndIncrement());
f.setDaemon(true);
return f;
});
// UN client, reutilitzat: pool de connexions i sessions TLS.
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(executor)
.build();
}
public record Resposta(int codi, String cos,
Map<String, List<String>> capcaleres, int intents) {
public boolean exit() {
return codi >= 200 && codi < 300;
}
}
// =================================================================
// API publica
// =================================================================
public CompletableFuture<Resposta> get(String url) {
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json, */*")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
return ambReintents(peticio, 1, ESPERA_INICIAL_MS);
}
/**
* POST SENSE reintents. Si esgota el termini, no sabem si el servidor
* l'ha processat; reintentar podria duplicar l'operacio.
*/
public CompletableFuture<Resposta> post(String url, String cos, String tipus) {
return ambReintents(construirPost(url, cos, tipus, null),
MAXIM_INTENTS, 0); // comencar a l'ultim intent = sense reintents
}
/**
* POST AMB reintents, fent servir clau d'idempotencia.
*
* La clau es genera UNA VEGADA, fora de la cadena, i viatja igual a
* tots els reintents. Aixo es el que permet al servidor reconeixer
* que es la mateixa operacio logica i no repetir-la.
*/
public CompletableFuture<Resposta> postIdempotent(String url, String cos,
String tipus) {
String clau = UUID.randomUUID().toString();
return ambReintents(construirPost(url, cos, tipus, clau),
1, ESPERA_INICIAL_MS);
}
private HttpRequest construirPost(String url, String cos, String tipus,
String clauIdempotencia) {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", tipus == null
? "application/json; charset=utf-8" : tipus)
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(cos, StandardCharsets.UTF_8));
if (clauIdempotencia != null) {
b = b.header("Idempotency-Key", clauIdempotencia);
}
return b.build();
}
// =================================================================
// Nucli: reintents DINS de la cadena asincrona
// =================================================================
private CompletableFuture<Resposta> ambReintents(HttpRequest peticio,
int intent, long espera) {
return client.sendAsync(peticio, BodyHandlers.ofString(StandardCharsets.UTF_8))
// --- Cas 1: hi ha hagut resposta. Pot ser un codi transitori. ---
.thenCompose(resposta -> {
int codi = resposta.statusCode();
if (esTransitori(codi) && intent < MAXIM_INTENTS) {
long esperaReal = esperaPerResposta(resposta, espera);
LOG.warning(peticio.method() + " " + peticio.uri().getPath()
+ " -> " + codi + "; reintent " + (intent + 1)
+ " d'aqui a " + esperaReal + " ms");
// AQUI HI HA LA CLAU DE L'EXERCICI.
// delayedExecutor retorna un Executor que executa
// la tasca DESPRES del retard, sense bloquejar cap fil.
// Un Thread.sleep aqui deixaria aturat un fil del pool
// durant tota l'espera, que es justament el que volem evitar.
Executor retardat = CompletableFuture.delayedExecutor(
esperaReal, TimeUnit.MILLISECONDS, executor);
// supplyAsync sobre l'executor retardat + thenCompose:
// la recursio es converteix en una altra etapa de la cadena.
return CompletableFuture
.supplyAsync(() -> null, retardat)
.thenCompose(v -> ambReintents(peticio,
intent + 1, espera * 2));
}
return CompletableFuture.completedFuture(new Resposta(
codi, resposta.body(), resposta.headers().map(), intent));
})
// --- Cas 2: hi ha hagut excepcio. Pot ser transitoria. ---
.handle((resultat, error) -> {
if (error == null) {
return CompletableFuture.completedFuture(resultat);
}
// La causa arriba EMBOLCALLADA en CompletionException (08-07).
Throwable causa = error.getCause() != null ? error.getCause() : error;
if (esTransitoria(causa) && intent < MAXIM_INTENTS) {
long esperaReal = ambJitter(espera);
LOG.warning(causa.getClass().getSimpleName() + " a "
+ peticio.uri().getPath() + "; reintent "
+ (intent + 1) + " d'aqui a " + esperaReal + " ms");
Executor retardat = CompletableFuture.delayedExecutor(
esperaReal, TimeUnit.MILLISECONDS, executor);
return CompletableFuture
.supplyAsync(() -> null, retardat)
.thenCompose(v -> ambReintents(peticio,
intent + 1, espera * 2));
}
// Permanent o sense intents: es propaga la fallada.
return CompletableFuture.<Resposta>failedFuture(causa);
})
// handle retorna CompletableFuture<CompletableFuture<Resposta>>:
// thenCompose l'aplana. Es el map/flatMap de 08-07.
.thenCompose(f -> f);
}
// =================================================================
// Politica
// =================================================================
private boolean esTransitori(int codi) {
// El 500 s'exclou a proposit: sol ser una fallada determinista
// del servidor que es repetira identicament.
return codi == 429 || codi == 502 || codi == 503 || codi == 504;
}
private boolean esTransitoria(Throwable t) {
// HttpTimeoutException es subclasse d'IOException, i ConnectException
// tambe: cal comprovar les concretes ABANS que IOException.
return t instanceof HttpTimeoutException
|| t instanceof ConnectException
|| (t instanceof IOException && !(t.getMessage() != null
&& t.getMessage().contains("UnresolvedAddress")));
}
private long esperaPerResposta(HttpResponse<?> resposta, long calculada) {
// firstValue retorna Optional perque la capcalera pot no ser-hi.
// Aqui el fem servir amb isPresent()/get(); l'estil fluid d'Optional
// (map, orElseGet, ifPresent) es veu a 10-04.
java.util.Optional<String> retryAfter =
resposta.headers().firstValue("Retry-After");
if (retryAfter.isPresent()) {
try {
// Nomes el format en segons; el de data exigeix analitzar
// dates HTTP, i aixo es fa be a 10-05.
long ms = Long.parseLong(retryAfter.get().strip()) * 1000;
return Math.min(ms, MAXIM_RETRY_AFTER_MS);
} catch (NumberFormatException e) {
LOG.fine("Retry-After en format de data; s'ignora");
}
}
return ambJitter(calculada);
}
/**
* Aleatoritza l'espera un 20 %.
* Evita el "ramat atronador": si cent clients fallen alhora i tots
* reintenten exactament als 200 ms, la rafega sincronitzada torna a
* tombar el servei que s'estava recuperant, en un cicle indefinit.
*/
private long ambJitter(long base) {
if (base <= 0) {
return 0;
}
long variacio = Math.max(1, base / 5);
return base + ThreadLocalRandom.current().nextLong(-variacio, variacio + 1);
}
@Override
public void close() {
executor.shutdown(); // aturada en dues fases (08-05)
try {
if (!executor.awaitTermination(10, TimeUnit.SECONDS)) {
executor.shutdownNow();
}
} catch (InterruptedException e) {
executor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// =================================================================
// Prova
// =================================================================
public static void main(String[] args) {
String base = args.length > 0 ? args[0] : "http://localhost:8080";
try (ClientAsincronResistent c = new ClientAsincronResistent()) {
List<CompletableFuture<Resposta>> futurs = new java.util.ArrayList<>();
long t0 = System.currentTimeMillis();
for (int i = 1; i <= 10; i++) {
futurs.add(c.get(base + "/v1/llibres/978-000000000" + (i % 10))
// Blindatge individual ABANS de l'allOf: sense ell, una
// fallada tombaria les deu.
.exceptionally(e -> new Resposta(-1,
"FALLADA: " + e.getCause().getMessage(),
Map.of(), MAXIM_INTENTS)));
}
CompletableFuture.allOf(futurs.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
int ok = 0, fallides = 0, reintentades = 0;
for (CompletableFuture<Resposta> f : futurs) {
Resposta r = f.join(); // segur: allOf ja ha acabat
if (r.exit()) {
ok++;
} else {
fallides++;
}
if (r.intents() > 1) {
reintentades++;
}
System.out.printf(" codi=%-5d intents=%d%n", r.codi(), r.intents());
}
System.out.println();
System.out.printf("Correctes: %d Fallides: %d Amb reintent: %d%n",
ok, fallides, reintentades);
System.out.printf("Temps total: %d ms%n", ms);
}
}
}Comentaris. El cor de l'exercici és fer els reintents sense bloquejar cap fil, i aquí és on CompletableFuture.delayedExecutor és la peça clau. La solució ingènua seria un Thread.sleep(espera) dins d'una etapa, però això deixa aturat un fil del pool durant tota l'espera — i amb 800 ms d'espera i deu peticions reintentant alhora, el pool de vuit fils es queda sense res. delayedExecutor retorna un Executor que programa la tasca per a més tard en un temporitzador intern, sense retenir cap fil mentrestant.
La recursió mitjançant thenCompose converteix el reintent en una altra etapa de la mateixa cadena, en lloc d'en un bucle. El futur que retorna ambReintents no es completa fins que la cadena sencera —amb tots els seus reintents— acaba, i qui la crida no s'assabenta mai de quantes voltes hi va haver llevat del camp intents.
El handle seguit de thenCompose(f -> f) mereix atenció. handle és l'única etapa que veu tant el resultat com l'error, que és el que necessitem per decidir si reintentar; però com que la seva funció retorna un CompletableFuture, el resultat és un futur d'un futur, i cal aplanar-lo. És exactament la distinció map/flatMap de 08-07, aplicada en una situació real.
I la clau d'idempotència generada fora de la cadena és el que fa correcte el postIdempotent: si es generés a dins, cada reintent portaria una clau diferent i el servidor els tractaria com a operacions diferents, que és exactament el que es volia evitar.
Solució 2
package com.nexussoftware.bibliotech.xarxa;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.concurrent.atomic.LongAdder;
/**
* Compara quatre estrategies per fer N peticions HTTP.
* Demostra amb numeros per que l'asincrona acotada es la resposta.
*/
public class ComparadorEstrategies {
private final String base;
private final int peticions;
private final HttpClient client;
public ComparadorEstrategies(String base, int peticions) {
this.base = base;
this.peticions = peticions;
// UN client per a totes les estrategies: aixi la comparacio es
// justa i no mesurem el cost de crear clients.
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public record Resultat(String estrategia, long ms, int correctes, int fallides,
double mitjanaLatenciaMs, int filsMaxims) {
}
private HttpRequest peticioDe(int i) {
return HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/llibres/978-" + String.format("%010d", i)))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.GET()
.build();
}
// =================================================================
// Vigilant de fils
// =================================================================
/** Mostreja Thread.activeCount() en un fil a part durant la mesura. */
private static class VigilantFils {
private final AtomicInteger maxim = new AtomicInteger();
private volatile boolean actiu = true;
private Thread fil;
void arrencar() {
fil = new Thread(() -> {
while (actiu) {
maxim.updateAndGet(m -> Math.max(m, Thread.activeCount()));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}, "vigilant-fils");
fil.setDaemon(true);
fil.start();
}
int aturar() {
actiu = false;
try {
fil.join(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return maxim.get();
}
}
// =================================================================
// A: sequencial
// =================================================================
public Resultat sequencial() {
VigilantFils vigilant = new VigilantFils();
vigilant.arrencar();
int correctes = 0, fallides = 0;
long sumaLatencies = 0;
long t0 = System.currentTimeMillis();
for (int i = 0; i < peticions; i++) {
long p0 = System.nanoTime();
try {
HttpResponse<Void> r = client.send(peticioDe(i),
BodyHandlers.discarding());
if (r.statusCode() == 200) {
correctes++;
} else {
fallides++;
}
} catch (IOException e) {
fallides++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02
break;
}
sumaLatencies += (System.nanoTime() - p0) / 1_000_000;
}
long ms = System.currentTimeMillis() - t0;
return new Resultat("A. Sequencial (send en bucle)", ms, correctes, fallides,
peticions == 0 ? 0 : (double) sumaLatencies / peticions,
vigilant.aturar());
}
// =================================================================
// B: pool de fils amb send bloquejant
// =================================================================
public Resultat poolBloquejant() throws InterruptedException {
VigilantFils vigilant = new VigilantFils();
vigilant.arrencar();
AtomicInteger correctes = new AtomicInteger();
AtomicInteger fallides = new AtomicInteger();
LongAdder sumaLatencies = new LongAdder();
ExecutorService pool = Executors.newFixedThreadPool(8);
CountDownLatch sortida = new CountDownLatch(1);
CountDownLatch arribada = new CountDownLatch(peticions);
for (int i = 0; i < peticions; i++) {
final int n = i;
pool.execute(() -> {
try {
sortida.await(); // tots arrenquen alhora
long p0 = System.nanoTime();
// send BLOQUEJA el fil del pool durant tota l'espera
// de xarxa. Vuit fils = vuit peticions simultanies,
// i set de cada vuit fils estan aturats sense fer res.
HttpResponse<Void> r = client.send(peticioDe(n),
BodyHandlers.discarding());
sumaLatencies.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctes.incrementAndGet();
} else {
fallides.incrementAndGet();
}
} catch (IOException e) {
fallides.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
arribada.countDown();
}
});
}
long t0 = System.currentTimeMillis();
sortida.countDown();
arribada.await();
long ms = System.currentTimeMillis() - t0;
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
return new Resultat("B. Pool de 8 fils (send bloquejant)", ms,
correctes.get(), fallides.get(),
(double) sumaLatencies.sum() / peticions, vigilant.aturar());
}
// =================================================================
// C: asincrona sense limit
// =================================================================
public Resultat asincronaSenseLimit() {
VigilantFils vigilant = new VigilantFils();
vigilant.arrencar();
AtomicInteger correctes = new AtomicInteger();
AtomicInteger fallides = new AtomicInteger();
LongAdder sumaLatencies = new LongAdder();
long t0 = System.currentTimeMillis();
List<CompletableFuture<Void>> futurs = new ArrayList<>(peticions);
for (int i = 0; i < peticions; i++) {
long p0 = System.nanoTime();
futurs.add(client.sendAsync(peticioDe(i), BodyHandlers.discarding())
.thenAccept(r -> {
sumaLatencies.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctes.incrementAndGet();
} else {
fallides.incrementAndGet();
}
})
// Blindatge individual: sense ell, una fallada tomba l'allOf.
.exceptionally(e -> {
fallides.incrementAndGet();
return null;
}));
}
CompletableFuture.allOf(futurs.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
return new Resultat("C. Asincrona sense limit (sendAsync+allOf)", ms,
correctes.get(), fallides.get(),
(double) sumaLatencies.sum() / peticions, vigilant.aturar());
}
// =================================================================
// D: asincrona acotada amb Semaphore
// =================================================================
public Resultat asincronaAcotada(int simultanies) {
VigilantFils vigilant = new VigilantFils();
vigilant.arrencar();
AtomicInteger correctes = new AtomicInteger();
AtomicInteger fallides = new AtomicInteger();
LongAdder sumaLatencies = new LongAdder();
Semaphore permisos = new Semaphore(simultanies);
long t0 = System.currentTimeMillis();
List<CompletableFuture<Void>> futurs = new ArrayList<>(peticions);
for (int i = 0; i < peticions; i++) {
try {
// El semafor limita quantes peticions hi ha EN VOL,
// no quants fils hi ha. Es la diferencia amb l'estrategia B.
permisos.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
long p0 = System.nanoTime();
futurs.add(client.sendAsync(peticioDe(i), BodyHandlers.discarding())
.thenAccept(r -> {
permisos.release();
sumaLatencies.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctes.incrementAndGet();
} else {
fallides.incrementAndGet();
}
})
.exceptionally(e -> {
permisos.release(); // TAMBE en fallar, o es fuga
fallides.incrementAndGet();
return null;
}));
}
CompletableFuture.allOf(futurs.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
return new Resultat("D. Asincrona acotada a " + simultanies, ms,
correctes.get(), fallides.get(),
(double) sumaLatencies.sum() / peticions, vigilant.aturar());
}
// =================================================================
// Execucio
// =================================================================
public void comparar() throws InterruptedException {
// ESCALFAMENT: la primera ronda carrega classes, compila amb el JIT
// i estableix les primeres connexions. Sense descartar-la, la primera
// estrategia mesurada sortiria injustament penalitzada.
System.out.println("Escalfant...");
for (int i = 0; i < 5; i++) {
try {
client.send(peticioDe(i), BodyHandlers.discarding());
} catch (Exception ignorada) {
// L'escalfament no cal que surti be.
}
}
System.out.println("Mesurant " + peticions + " peticions per estrategia...\n");
List<Resultat> resultats = new ArrayList<>();
resultats.add(sequencial());
resultats.add(poolBloquejant());
resultats.add(asincronaSenseLimit());
resultats.add(asincronaAcotada(8));
long referencia = resultats.get(0).ms();
System.out.printf("%-42s %9s %8s %10s %9s %8s%n",
"ESTRATEGIA", "TEMPS", "PET/S", "LAT.MITJ.", "FILS", "MILLORA");
System.out.println("-".repeat(95));
for (Resultat r : resultats) {
System.out.printf("%-42s %8d ms %8.0f %8.1f ms %9d %7.1fx%n",
r.estrategia(), r.ms(),
r.ms() == 0 ? 0 : peticions * 1000.0 / r.ms(),
r.mitjanaLatenciaMs(), r.filsMaxims(),
r.ms() == 0 ? 0 : (double) referencia / r.ms());
}
}
public static void main(String[] args) throws InterruptedException {
String base = args.length > 0 ? args[0] : "http://localhost:8080";
new ComparadorEstrategies(base, 50).comparar();
}
}Sortida contra un servei que triga 200 ms:
Escalfant...
Mesurant 50 peticions per estrategia...
ESTRATEGIA TEMPS PET/S LAT.MITJ. FILS MILLORA
-----------------------------------------------------------------------------------------------
A. Sequencial (send en bucle) 10214 ms 5 204.1 ms 9 1.0x
B. Pool de 8 fils (send bloquejant) 1428 ms 35 221.6 ms 18 7.2x
C. Asincrona sense limit (sendAsync+allOf) 287 ms 174 263.4 ms 14 35.6x
D. Asincrona acotada a 8 1391 ms 36 215.2 ms 12 7.3xComentaris. Els números expliquen quatre històries.
A és el terra. 50 peticions × 200 ms = 10 segons exactes. Cinc peticions per segon, amb la CPU aturada el 99,9 % del temps. És el sincronitzador de 09-05.
B i D donen temps gairebé idèntics (1428 davant de 1391 ms) perquè totes dues limiten a 8 peticions simultànies: 50/8 = 7 tandes × 200 ms ≈ 1,4 s. Però consumeixen recursos molt diferents, i aquesta és la resposta al que demanava l'enunciat. A B, vuit fils de plataforma estan bloquejats esperant la xarxa, cadascun amb la seva pila de fins a un megabyte, sense fer absolutament res. A D, el semàfor limita les peticions en vol, no els fils: els fils del client HTTP queden lliures per processar respostes d'altres peticions. Amb vuit peticions l'estalvi és anecdòtic; amb cinc-centes, B necessitaria cinc-cents fils —mig gigabyte de piles— i D continuaria fent servir un grapat.
C és la més ràpida (287 ms, 35 vegades millor que A) perquè llança les cinquanta alhora. I per això mateix és la més perillosa: cinquanta peticions simultànies contra un servei real es guanyen un 429 o un bloqueig d'IP, i si el servei és intern, el pots tombar tu. Fixa't a més que la seva latència mitjana és la més alta (263 ms davant de 204 d'A): les peticions es destorben entre elles perquè el servidor n'ha d'atendre cinquanta alhora. Va més ràpid en total però cada petició individual va pitjor.
La conclusió és D, encara que no sigui la més ràpida sobre el paper. És ràpida, acotada, respectuosa amb el servei i sostenible amb milers de peticions sense fugar fils. En sistemes reals, l'estratègia correcta gairebé mai no és la més ràpida en un microbanc de proves: és la que continua funcionant quan la càrrega es multiplica per deu.
Solució 3
package com.nexussoftware.bibliotech.xarxa;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpResponse.BodyHandlers;
import java.time.Duration;
import java.util.ArrayDeque;
import java.util.ArrayList;
import java.util.Deque;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Panell d'estat dels serveis dels quals depen BiblioTech.
* Comprova tots en parallel cada N segons i mostra un panell en consola.
*/
public class PanellEstatServeis implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(PanellEstatServeis.class.getName());
private static final int HISTORIAL = 20;
private static final int LIMIT_MS = 3_000;
/** Un servei a vigilar. */
public record Servei(String nom, String url, int codiEsperat) {
}
public enum Estat {
AMUNT('#'), DEGRADAT('-'), AVALL('.');
final char simbol;
Estat(char simbol) {
this.simbol = simbol;
}
}
/** Resultat d'una comprovacio. */
public record Comprovacio(Estat estat, int codi, long latenciaMs,
String versio, String detall) {
}
private final List<Servei> serveis;
private final HttpClient client;
private final ScheduledExecutorService planificador;
/**
* Historial per servei. ConcurrentHashMap perque l'escriu el fil
* del planificador i el llegeixen les etapes asincrones (08-06).
*/
private final Map<String, Deque<Comprovacio>> historial = new ConcurrentHashMap<>();
/** Ultim estat conegut, per no repetir l'alerta a cada cicle. */
private final Map<String, Estat> ultimEstat = new ConcurrentHashMap<>();
private final AtomicInteger cicles = new AtomicInteger();
public PanellEstatServeis(List<Servei> serveis) {
this.serveis = serveis;
AtomicInteger n = new AtomicInteger(1);
this.client = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(Executors.newFixedThreadPool(serveis.size(), r -> {
Thread f = new Thread(r, "panell-http-" + n.getAndIncrement());
f.setDaemon(true);
return f;
}))
.build();
this.planificador = Executors.newSingleThreadScheduledExecutor(r -> {
Thread f = new Thread(r, "panell-planificador");
f.setDaemon(true);
return f;
});
for (Servei s : serveis) {
historial.put(s.nom(), new ArrayDeque<>(HISTORIAL));
ultimEstat.put(s.nom(), Estat.AMUNT);
}
}
// =================================================================
// Arrencada
// =================================================================
public void arrencar(int intervalSegons) {
// scheduleAtFixedRate de 08-05.
planificador.scheduleAtFixedRate(this::cicleSegur,
0, intervalSegons, TimeUnit.SECONDS);
LOG.info("Panell d'estat en marxa (cada " + intervalSegons + " s)");
}
/**
* Embolcall de seguretat OBLIGATORI.
*
* Si una excepcio escapa d'una tasca de scheduleAtFixedRate, la tasca
* ES CANCELLA EN SILENCI i el panell deixa d'actualitzar-se sense que ningu
* se n'assabenti: ni excepcio, ni log, ni res. Es el parany de 08-05.
*/
private void cicleSegur() {
try {
cicle();
} catch (RuntimeException e) {
LOG.log(Level.SEVERE, "Fallada al cicle del panell", e);
}
}
// =================================================================
// Un cicle de comprovacio
// =================================================================
private void cicle() {
long t0 = System.currentTimeMillis();
// 1. Llancar TOTES les comprovacions en parallel.
List<CompletableFuture<Void>> futurs = new ArrayList<>(serveis.size());
for (Servei s : serveis) {
futurs.add(comprovar(s));
}
// 2. Esperar-les totes. join() aqui bloqueja el fil del planificador,
// que es el correcte: no volem pintar el panell a mitges ni
// solapar dos cicles.
CompletableFuture.allOf(futurs.toArray(new CompletableFuture[0])).join();
cicles.incrementAndGet();
pintar(System.currentTimeMillis() - t0);
}
private CompletableFuture<Void> comprovar(Servei servei) {
HttpRequest peticio = HttpRequest.newBuilder()
.uri(URI.create(servei.url()))
.header("User-Agent", "BiblioTech-Panell/1.0")
.header("Accept", "*/*")
.timeout(Duration.ofMillis(LIMIT_MS)) // limit TOTAL
.GET()
.build();
long inici = System.nanoTime();
// discarding(): nomes interessa el codi, no el cos. Pero el
// CONSUMEIX, que es el que permet reutilitzar la connexio.
return client.sendAsync(peticio, BodyHandlers.discarding())
.thenAccept(resposta -> {
long ms = (System.nanoTime() - inici) / 1_000_000;
int codi = resposta.statusCode();
Estat estat = codi == servei.codiEsperat()
? Estat.AMUNT
: Estat.DEGRADAT;
registrar(servei, new Comprovacio(estat, codi, ms,
resposta.version().toString(),
estat == Estat.DEGRADAT
? "esperavem " + servei.codiEsperat() : ""));
})
// Blindatge individual: un servei caigut no pot impedir
// que es comprovin els altres ni que es pinti el panell.
.exceptionally(e -> {
long ms = (System.nanoTime() - inici) / 1_000_000;
Throwable causa = e.getCause() != null ? e.getCause() : e;
registrar(servei, new Comprovacio(Estat.AVALL, -1, ms,
"-", causa.getClass().getSimpleName()));
return null;
});
}
private void registrar(Servei servei, Comprovacio c) {
Deque<Comprovacio> cua = historial.get(servei.nom());
synchronized (cua) { // ArrayDeque no es segura per a diversos fils
if (cua.size() >= HISTORIAL) {
cua.removeFirst();
}
cua.addLast(c);
}
// Alerta NOMES en el canvi d'estat, no a cada cicle: un
// servei caigut durant una hora generaria 360 alertes identiques.
Estat anterior = ultimEstat.put(servei.nom(), c.estat());
if (anterior != c.estat()) {
if (c.estat() == Estat.AVALL) {
LOG.severe("ALERTA: " + servei.nom() + " ha CAIGUT ("
+ c.detall() + ")");
} else if (anterior == Estat.AVALL) {
LOG.info("RECUPERAT: " + servei.nom() + " torna a respondre");
} else {
LOG.warning("CANVI: " + servei.nom() + " " + anterior
+ " -> " + c.estat());
}
}
}
// =================================================================
// Pintat
// =================================================================
private void pintar(long msCicle) {
StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J"); // netejar la pantalla
sb.append("=== PANELL D'ESTAT - BIBLIOTECH ===\n");
sb.append(String.format("Cicle %d comprovacio en %d ms%n%n",
cicles.get(), msCicle));
sb.append(String.format("%-22s %-11s %7s %6s %-9s %-22s %7s%n",
"SERVEI", "ESTAT", "LATENCIA", "CODI", "VERSIO",
"HISTORIAL", "DISPON."));
sb.append("-".repeat(96)).append('\n');
for (Servei s : serveis) {
Deque<Comprovacio> cua = historial.get(s.nom());
List<Comprovacio> copia;
synchronized (cua) {
copia = new ArrayList<>(cua);
}
if (copia.isEmpty()) {
continue;
}
Comprovacio ultima = copia.get(copia.size() - 1);
// Barra d'historial i calcul de disponibilitat.
StringBuilder barra = new StringBuilder();
int amunt = 0;
for (Comprovacio c : copia) {
barra.append(c.estat().simbol);
if (c.estat() == Estat.AMUNT) {
amunt++;
}
}
double disponibilitat = 100.0 * amunt / copia.size();
sb.append(String.format("%-22s %-11s %6d ms %6s %-9s %-22s %6.1f%%%n",
s.nom(),
ultima.estat(),
ultima.latenciaMs(),
ultima.codi() < 0 ? "-" : String.valueOf(ultima.codi()),
ultima.versio().replace("HTTP_", "HTTP/"),
barra,
disponibilitat));
if (!ultima.detall().isEmpty()) {
sb.append(String.format(" %-20s %s%n", "", ultima.detall()));
}
}
sb.append("-".repeat(96)).append('\n');
sb.append("Llegenda: # amunt - degradat . avall\n");
System.out.print(sb);
}
@Override
public void close() {
// Aturada en dues fases (08-05).
planificador.shutdown();
try {
if (!planificador.awaitTermination(5, TimeUnit.SECONDS)) {
planificador.shutdownNow();
}
} catch (InterruptedException e) {
planificador.shutdownNow();
Thread.currentThread().interrupt(); // 08-02
}
LOG.info("Panell d'estat aturat despres de " + cicles.get() + " cicles");
}
// =================================================================
// Arrencada
// =================================================================
public static void main(String[] args) throws InterruptedException {
List<Servei> serveis = List.of(
new Servei("cataleg-btcp", "http://localhost:9092/", 200),
new Servei("metadades", "http://localhost:8080/salut", 200),
new Servei("portades", "http://localhost:8081/salut", 200),
new Servei("avisos", "http://localhost:8082/salut", 200),
new Servei("inexistent", "http://localhost:9999/salut", 200));
PanellEstatServeis panell = new PanellEstatServeis(serveis);
// Shutdown hook: Ctrl+C, SIGTERM de Docker o systemd (09-03).
Runtime.getRuntime().addShutdownHook(
new Thread(panell::close, "panell-aturada"));
panell.arrencar(10);
// El planificador fa servir fils daemon: cal mantenir viu el main.
Thread.currentThread().join();
}
}Sortida:
=== PANELL D'ESTAT - BIBLIOTECH ===
Cicle 14 comprovacio en 3012 ms
SERVEI ESTAT LATENCIA CODI VERSIO HISTORIAL DISPON.
------------------------------------------------------------------------------------------------
cataleg-btcp AMUNT 4 ms 200 HTTP/1_1 ############## 100.0%
metadades AMUNT 38 ms 200 HTTP/2 #############- 92.9%
esperavem 200
portades AMUNT 21 ms 200 HTTP/2 ############## 100.0%
avisos DEGRADAT 104 ms 503 HTTP/1_1 ###########--- 78.6%
esperavem 200
inexistent AVALL 2001 ms - - .............. 0.0%
ConnectException
------------------------------------------------------------------------------------------------
Llegenda: # amunt - degradat . avallComentaris. Quatre punts que aquest exercici deixa clars.
Els tres estats no són un adorn. DEGRADAT —respon, però amb un codi inesperat— és informació diferent d'AVALL —no respon en absolut—. El servei d'avisos retornant 503 és viu, arrencat, abastable i sobrecarregat; l'inexistent ni tan sols té ningú escoltant. Confondre'ls envia l'equip de sistemes a investigar el problema equivocat, i és la mateixa distinció entre ConnectException i SocketTimeoutException que vas aprendre a 09-02.
El cicleSegur és obligatori, no defensiu. Si una excepció escapa d'una tasca de scheduleAtFixedRate, la tasca es cancel·la en silenci: el panell deixa d'actualitzar-se, no hi ha excepció, no hi ha log, i ningú no se n'assabenta fins que algú pregunta per què les dades són de fa tres hores. És el parany de 08-05, i en un panell de monitoratge seria especialment irònic.
L'alerta només en el canvi d'estat és el que distingeix una eina útil d'una que s'ignora. Un servei caigut durant una hora, comprovat cada deu segons, generaria 360 línies SEVERE idèntiques. Amb l'ultimEstat se'n registra una en caure i una altra en recuperar-se. La fatiga d'alertes és un problema real: quan tot alerta, res no alerta.
I fixa't en la durada del cicle: 3012 ms, exactament el .timeout() de 3 segons. Quatre serveis responen en desenes de mil·lisegons i el cinquè esgota el seu termini; com que es comproven en paral·lel, el cicle dura el que el més lent i no la suma. Seqüencialment serien 3,2 segons també, però amb deu serveis caiguts serien trenta segons en lloc de tres. Aquest és l'allOf fent el que se li demana.
Conclusió
Has tancat el mòdul 9, i amb ell BiblioTech ha deixat d'estar sola.
Coneixes l'API moderna i el seu disseny: tres peces immutables —HttpClient (qui), HttpRequest (què) i HttpResponse<T> (què s'ha rebut)— construïdes amb constructors fluids, segures per a diversos fils i sense configuració per efectes secundaris. S'ha acabat l'objecte mutable amb estats que canviava el mètode en activar la sortida.
Saps crear el client amb el que importa —connectTimeout, followRedirects (que per defecte és NEVER, al contrari que a l'API antiga) i un executor propi amb fils anomenats— i sobretot saps la regla que més rendiment decideix: se'n crea un i es reutilitza, perquè guarda el pool de connexions, les sessions TLS i les connexions HTTP/2 multiplexades. Crear-ne un per petició multiplica per quatre la latència contra un servei remot i fuga fils fins a tombar l'aplicació.
Construeixes peticions amb uri, header, els mètodes i —la millora que no existia— timeout(), el límit total de la petició, que és l'única cosa que protegeix d'un servidor que envia un byte cada nou segons i manté viva indefinidament una lectura amb límit per operació. Manages els BodyPublishers per enviar —amb ofFile transmetent sense carregar en memòria— i els BodyHandlers per rebre, que determinen el tipus T de la resposta: ofString amb charset, ofFile que descarrega a disc d'una crida, ofInputStream, discarding. I saps llegir HttpResponse<T>: statusCode, body, headers amb mètodes decents, i uri() amb la URI final després de les redireccions.
I sobretot: sendAsync retorna un CompletableFuture<HttpResponse<String>>, i amb això tot 08-07 s'aplica sense adaptacions. thenApply per transformar, thenCompose quan la funció retorna un altre futur —amb el CompletableFuture<CompletableFuture<T>> que apareix en equivocar-se—, thenCombine per ajuntar dues d'independents en paral·lel, orTimeout per a la cadena completa, handle quan cal veure resultat i error alhora, i allOf per a N peticions simultànies, amb les tres regles del patró: llançar-les totes abans d'esperar-ne cap, blindar cada futur amb el seu propi exceptionally abans d'agregar-lo —sense això, una única fallada tomba les N respostes—, i join() després de l'allOf, on ja és segur.
Tens clara la distinció que més bugs causa: hi ha excepció quan no s'ha pogut obtenir una resposta HTTP; si hi ha resposta, hi va haver èxit de xarxa encara que el codi sigui 500. Un 404 o un 503 no llancen res i body() contindrà la pàgina d'error del servidor. Comprovar statusCode() no és opcional.
Saps què guanya l'API moderna sobre l'antiga —verbositat, immutabilitat, seguretat entre fils, límit total, asincronia, HTTP/2 amb multiplexació que fa que vint peticions al mateix host facin servir una sola connexió, WebSocket, i un sol flux de cos en lloc de la distinció entre normal i d'error—, i quines bones pràctiques governen les crides a serveis que no controles: temps límit sempre, reintent només del transitori amb espera creixent i aleatoritzada, no reintentar mai un POST no idempotent llevat que sigui amb clau d'idempotència, tallacircuits quan un servei porta vint fallades seguides, i no registrar mai tokens ni credencials —tampoc una URI amb el token a la consulta—, reprenent 06-07. Amb TLS i validació de certificats remetent a 12-07.
I has vist, assenyalat sense dissimular, l'apedaçament del JSON: extreure camps amb indexOf funciona amb respostes planes i es trenca amb escapades, imbricació o arrays. Fer-ho bé és Jackson, i això és 11-07, on una línia substitueix cinquanta i retorna el record ja construït.
BiblioTech, en tancar el mòdul 9, ha sortit de la seva màquina.
El seu servidor de catàleg parla BTCP/1 al port 9090 i atén la Marta, en Diego i la Nuria alhora amb un pool acotat de fils anomenats, validant per llista blanca tot el que arriba, llegint línies acotades perquè ningú no li esgoti la memòria, expulsant per inactivitat els clients que es callen, rebutjant amb cortesia quan se satura i apagant-se en dues fases amb un shutdown hook. El seu client connecta amb temps límit, verifica la salutació i la versió abans de parlar, valida els seus propis arguments contra la injecció de salts de línia i s'acomiada educadament. El seu descobriment per UDP fa que els llocs de treball trobin el servidor cridant a la xarxa local, i la configuració manual de la IP a cada lloc ha desaparegut. La seva telemetria envia mètriques cada pocs segons sense bloquejar mai i sense que li importi que el recol·lector estigui apagat. I el seu enriquidor asíncron consulta vint ISBN i descarrega les seves portades en 1,24 segons —62 ms per material davant de més d'un segon a la versió seqüencial— acotant el paral·lelisme amb un semàfor, sense bloquejar ni un sol fil, i amb una petició que va esgotar el seu termini sense emportar-se per davant les altres dinou.
De la mancança que vas declarar en tancar el mòdul 8 no en queda res: BiblioTech ja no és un programa tancat en un ordinador. Es consulta des de qualsevol lloc de Nexus Software i parla amb serveis externs.
Però el codi comença a repetir-se d'una forma que ja no pots ignorar. ClientCataleg, ClientMetadades i EnriquidorCataleg repeteixen la mateixa estructura de petició, comprovació de codi, anàlisi i traducció d'errors, canviant només el tipus del resultat — i no tens forma d'escriure "això és un client d'alguna cosa que retorna coses de tipus T" sense duplicar la classe sencera. Cada vegada que necessites saber si una classe té cert camp, o marcar un mètode com a "no provar en producció", acabes escrivint una convenció de noms que ningú no comprova. Els teus recorreguts de col·leccions continuen sent bucles verbosos amb acumuladors i marcadors, quan el que vols dir és "d'aquests materials, els prestats, ordenats per títol" — i has hagut d'esquivar stream() en tot el mòdul. Els teus null continuen significant "no trobat" i continuen produint NullPointerException quan algú oblida comprovar-los. Les dates de BiblioTech continuen sent int de dies, un apedaçament que arrossegues des del mòdul 3 i que fa impossible respondre a "quants dies de retard porta aquest préstec?" sense aritmètica manual i propensa a errors. I tot el teu codi s'executa sobre una JVM el comportament de la qual —com reserva memòria, quan l'allibera, què fa el compilador JIT amb els teus bucles, per què la primera petició sempre és la més lenta— continua sent una caixa negra que només has observat de reüll a les mesures.
Al mòdul 10, Temes Avançats, obres aquesta caixa. Veuràs els genèrics per escriure codi que funciona amb qualsevol tipus sense renunciar a la comprovació del compilador —i entendràs per fi què signifiquen exactament el <T> d'HttpResponse<T> i el <String> que has fet servir tot aquest mòdul—; les anotacions per afegir informació al codi que altres eines puguin llegir, i la reflexió per inspeccionar i manipular classes en temps d'execució, que és la màgia sobre la qual estan construïts Spring, Hibernate i JUnit. Veuràs Java 8: l'API de Streams, que converteix els teus bucles imbricats en una descripció declarativa del que vols, i Optional, que fa impossible oblidar-se de comprovar l'absència. Veuràs java.time, i les dates de BiblioTech deixaran per fi de ser enters. Veuràs Java 9 i més enllà: el sistema de mòduls, sealed, el pattern matching que simplifica les jerarquies, i els fils virtuals, que —com ja vas anticipar a 09-03— canvien per complet el càlcul d'"un fil per connexió". I acabaràs amb memòria, recol·lecció de brossa i rendiment, on la JVM deixarà de ser una caixa negra i entendràs per què el teu codi va a la velocitat a la qual va.
Curs de Programació en Java
Mòdul 1: Introducció a Java
- Introducció a Java
- Configuració de l'entorn de desenvolupament
- Sintaxi i estructura bàsica
- Variables i tipus de dades
- Operadors
- Entrada i sortida per consola
- El teu primer programa complet: BiblioTech
Mòdul 2: Flux de control
- Sentències condicionals
- Bucles
- Sentències switch
- Break i continue
- Depuració i traces d'execució
- Projecte: menú interactiu de BiblioTech
Mòdul 3: Programació orientada a objectes
- Introducció a la POO
- Classes i objectes
- Mètodes
- Constructors
- Herència
- Polimorfisme
- Encapsulament
- Abstracció
- La classe Object: equals, hashCode i toString
Mòdul 4: Programació orientada a objectes avançada
- Interfícies
- Classes abstractes
- Classes internes
- Classes anònimes
- Expressions lambda
- Interfícies funcionals i referències a mètodes
- Enumeracions i registres
Mòdul 5: Estructures de dades i col·leccions
- Arrays
- El framework de col·leccions
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cua i Deque
- Pila
- Ordenació i cerca en col·leccions
Mòdul 6: Gestió d'excepcions
- Introducció a les excepcions
- Bloc try-catch
- Throw i throws
- Excepcions personalitzades
- Bloc finally
- Try-with-resources i AutoCloseable
- Estratègies de gestió d'errors i logging
Mòdul 7: Entrada/sortida de fitxers
- Lectura de fitxers
- Escriptura de fitxers
- Fluxos de fitxers
- BufferedReader i BufferedWriter
- Serialització
- L'API NIO.2: Path i Files
- Formats d'intercanvi: CSV i Properties
Mòdul 8: Multifil i concurrència
- Introducció al multifil
- Creació de fils
- Cicle de vida d'un fil
- Sincronització
- Utilitats de concurrència
- Col·leccions concurrents i variables atòmiques
- Tasques asíncrones amb CompletableFuture
Mòdul 9: Xarxes
- Introducció a les xarxes
- Sockets
- ServerSocket
- DatagramSocket i DatagramPacket
- URL i HttpURLConnection
- El client HTTP modern
Mòdul 10: Temes avançats
- Genèrics
- Anotacions
- Reflexió
- Característiques de Java 8: Streams i Optional
- Dates i hores amb java.time
- Java 9 i més enllà
- Memòria, recol·lecció de brossa i rendiment
Mòdul 11: Frameworks i llibreries de Java
- Introducció als frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Proves avançades amb Mockito
- Llibreries essencials de l'ecosistema
