Al cerrar la lección anterior quedó un problema abierto y muy concreto: SincronizadorPortadas tarda 5,4 segundos en descargar cinco portadas, y prácticamente todo ese tiempo es espera de red con la CPU parada. Mil portadas serían casi veinte minutos. Las descargas son independientes entre sí, así que hacerlas en paralelo reduciría el tiempo casi por el factor de paralelismo — pero con HttpURLConnection habría que montar el pool, las tareas y la recolección de resultados a mano.
Esta lección resuelve eso, y de paso todo lo demás que quedó señalado como defecto de la API antigua. java.net.http, incorporado en Java 11, es un cliente HTTP moderno: inmutable, con constructores fluidos, con tiempos límite de verdad, con HTTP/2 y multiplexación, con WebSocket, y con asincronía nativa.
Esa última palabra es la que hace que esta sea la lección de cierre del módulo. Porque sendAsync no devuelve una respuesta ni un Future: devuelve un CompletableFuture<HttpResponse<String>>. Todo lo que aprendiste en 08-07 —thenApply, thenCompose, thenCombine, allOf, exceptionally, orTimeout— se aplica aquí tal cual, sin adaptaciones. No es casualidad: CompletableFuture se diseñó pensando sobre todo en la red, y este es el sitio donde rinde de verdad.
Al terminar, BiblioTech consultará los metadatos de varios ISBN en paralelo y compondrá un informe sin bloquear un solo hilo. Y el módulo 9 quedará completo.
Contenido
- Las tres piezas y su diseño inmutable
- Crear el cliente:
HttpClient.newBuilder - Por qué se crea uno y se reutiliza
- Construir la petición:
HttpRequest - Los
BodyPublishers: enviar un cuerpo - Recibir:
HttpResponse<T>y losBodyHandlers - Envío síncrono:
send - Envío asíncrono:
sendAsync - Componer cadenas asíncronas
- Varias peticiones en paralelo con
allOf - Manejo de errores: excepción frente a código de estado
HttpURLConnectionfrente aHttpClient- HTTP/2 y la multiplexación
WebSocket- Enviar y recibir JSON
- Buenas prácticas para llamar a servicios externos
- BiblioTech: el enriquecedor asíncrono de catálogo
- Errores Comunes y Consejos
- Ejercicios
- Las tres piezas y su diseño inmutable
La API tiene exactamente tres clases principales, con responsabilidades limpias:
graph LR
A["HttpClient<br/>QUIEN hace las peticiones<br/>se crea UNA vez"] --> B["send / sendAsync"]
C["HttpRequest<br/>QUE se pide<br/>una por peticion"] --> B
B --> D["HttpResponse<T><br/>QUE se recibio<br/>estado, cabeceras, cuerpo"]
E["BodyPublisher<br/>como se ENVIA el cuerpo"] --> C
F["BodyHandler<T><br/>como se LEE el cuerpo"] --> B
| Clase | Papel | Ciclo de vida |
|---|---|---|
HttpClient |
Quién hace las peticiones. Guarda configuración y el pool de conexiones | Uno por aplicación, reutilizado |
HttpRequest |
Qué se pide: URI, método, cabeceras, cuerpo | Uno por petición |
HttpResponse<T> |
Qué se recibió: estado, cabeceras, cuerpo de tipo T |
Uno por respuesta |
Las tres son inmutables. Esto no es un detalle estético; tiene tres consecuencias prácticas importantes:
- Son seguras para varios hilos. Un
HttpClientpuede usarse desde cien hilos a la vez sin sincronización. Compáralo conHttpURLConnection, que era un objeto mutable con estados y que fallaba si lo configurabas fuera de orden. - Un
HttpRequestse puede reutilizar y enviar muchas veces. - No hay configuración por efectos secundarios. Se acabó el
setDoOutput(true)que cambiaba el método sin decirlo.
Se construyen con constructores fluidos (el patrón builder, que verás formalmente en 12-02):
HttpClient cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.build();
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create("http://localhost:8080/v1/libros/978-0000000001"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
HttpResponse<String> respuesta = cliente.send(peticion,
HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
System.out.println(respuesta.statusCode());
System.out.println(respuesta.body());Compara esas doce líneas con las treinta de la lección anterior, con su cast, su disconnect() en un finally y su distinción entre flujo normal y de error. Y todo lo importante está ahí: tiempos límite, cabeceras, charset explícito.
Sobre
Duration.java.time.Durationes la clase que esta API exige para los tiempos límite. Aquí solo la usamos como lo que es en este contexto —una cantidad de tiempo, construida conDuration.ofSeconds(5)oDuration.ofMillis(500)— y no entramos en más. La APIjava.timecompleta es 10-05.
Sobre
HttpResponse<String>. Ese<String>no es un genérico que tengas que definir: indica el tipo del cuerpo de la respuesta, y lo determina elBodyHandlerque pases. ConBodyHandlers.ofString()obtienesHttpResponse<String>; conofInputStream(),HttpResponse<InputStream>; conofFile(ruta),HttpResponse<Path>. Definir genéricos propios es 10-01; aquí solo hay que leerlos.
- Crear el cliente:
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 cliente = HttpClient.newBuilder()
// Version del protocolo. HTTP_2 es el valor por defecto y negocia:
// si el servidor no lo soporta, cae a HTTP/1.1 sin que hagas nada.
.version(HttpClient.Version.HTTP_2)
// Tiempo limite para ESTABLECER la conexion. Es del cliente
// porque afecta a todas sus peticiones.
.connectTimeout(Duration.ofSeconds(5))
// Politica de redirecciones.
.followRedirects(HttpClient.Redirect.NORMAL)
// Ejecutor para las operaciones asincronas. Si no se indica,
// usa uno interno. Pasar el tuyo da control y nombres de hilo.
.executor(Executors.newFixedThreadPool(8))
// Proxy, si la red lo exige.
.proxy(ProxySelector.of(new InetSocketAddress("proxy.nexussoftware.com", 3128)))
// Autenticacion basica, para servicios que la usen.
.authenticator(new Authenticator() {
@Override
protected PasswordAuthentication getPasswordAuthentication() {
return new PasswordAuthentication("bibliotech",
"clave".toCharArray());
}
})
.build();O la versión mínima, con todos los valores por defecto:
Las opciones que importan
| Opción | Valores | Comentario |
|---|---|---|
version |
HTTP_1_1, HTTP_2 |
Por defecto HTTP_2, con negociación automática |
connectTimeout |
Duration |
Ponlo siempre. Sin él, infinito |
followRedirects |
NEVER, ALWAYS, NORMAL |
Por defecto NEVER — ojo, distinto de la API antigua |
executor |
Executor |
Para sendAsync. Sin él, uno interno |
proxy |
ProxySelector |
ProxySelector.getDefault() respeta las variables del sistema |
authenticator |
Authenticator |
Solo para autenticación básica y digest |
cookieHandler |
CookieHandler |
Gestión de cookies, desactivada por defecto |
sslContext |
SSLContext |
Para certificados internos (12-07) |
priority |
1-256 | Prioridad de flujo en HTTP/2 |
Tres advertencias:
followRedirects es NEVER por defecto. HttpURLConnection seguía las redirecciones automáticamente; HttpClient no. Si tu código migrado deja de funcionar con un 301, esta es la razón. NORMAL es el valor razonable: sigue redirecciones excepto de HTTPS a HTTP, que sería una degradación de seguridad.
El authenticator solo sirve para autenticación básica y digest. La autenticación por token —lo habitual hoy— se hace con una cabecera:
Sobre el ejecutor. El interno es un pool en caché sin límite. Pasar el tuyo, con hilos nombrados como aprendiste en 08-02, hace que los volcados de hilos y los logs sirvan de algo:
.executor(Executors.newFixedThreadPool(8, r -> {
Thread h = new Thread(r, "bibliotech-http-" + contador.getAndIncrement());
h.setDaemon(true);
return h;
}))
- Por qué se crea uno y se reutiliza
Esta es la regla que más impacto tiene en el rendimiento, y la que más se incumple.
// MAL. Un cliente nuevo por peticion.
for (String isbn : isbns) {
HttpClient cliente = HttpClient.newHttpClient(); // <-- aqui esta el problema
HttpResponse<String> r = cliente.send(peticionDe(isbn), ofString());
}
// BIEN. Uno, creado al arrancar, reutilizado siempre.
private static final HttpClient CLIENTE = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
for (String isbn : isbns) {
HttpResponse<String> r = CLIENTE.send(peticionDe(isbn), ofString());
}Qué guarda un HttpClient por dentro
| Recurso | Por qué reutilizarlo importa |
|---|---|
| Pool de conexiones | Cada conexión nueva cuesta un saludo de tres vías: un viaje de ida y vuelta completo (09-01) |
| Sesiones TLS | Un saludo TLS cuesta uno o dos viajes más y criptografía asimétrica, que es cara |
| Pool de hilos | Crearlo y destruirlo por petición es puro desperdicio |
| Conexiones HTTP/2 | Una sola conexión sirve muchas peticiones a la vez (apartado 13) |
Los números lo dejan claro. Contra un servicio a 50 ms de latencia:
| Cliente nuevo por petición | Cliente reutilizado | |
|---|---|---|
| Establecer TCP | 50 ms | 50 ms solo la primera vez |
| Saludo TLS (HTTPS) | 100 ms | 100 ms solo la primera vez |
| La petición en sí | 50 ms | 50 ms |
| Total por petición | 200 ms | 50 ms (tras la primera) |
Cuatro veces más lento, y sobre HTTPS aún peor. Además, cada cliente nuevo crea su propio pool de hilos: crear cientos de clientes fuga hilos y memoria hasta tumbar la aplicación.
Sobre cerrar el cliente. Hasta Java 20,
HttpClientno implementabaAutoCloseable: no había forma de cerrarlo explícitamente y se confiaba en el recolector de basura. Desde Java 21 sí lo implementa, conclose(),shutdown()yshutdownNow(), siguiendo el mismo modelo de apagado en dos fases deExecutorServiceque conoces de 08-05. Si trabajas con Java 17, simplemente crea el cliente como campostatic finaly no te preocupes; si estás en 21 o superior, ciérralo en el apagado ordenado de la aplicación.
- Construir la petición:
HttpRequest
HttpRequestimport java.net.URI;
import java.net.http.HttpRequest;
import java.time.Duration;
HttpRequest peticion = HttpRequest.newBuilder()
// URI obligatoria. Fijate en que es URI, no URL: la API moderna
// usa la clase correcta, sin el equals() que hace DNS (09-05).
.uri(URI.create("https://api.nexussoftware.com/v1/libros/978-0000000001"))
// Cabeceras. header() anade; setHeader() reemplaza si ya existe.
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.header("Authorization", "Bearer " + token)
// Varias de golpe: pares nombre, valor.
.headers("Accept-Language", "es-ES", "X-Origen", "bibliotech")
// TIEMPO LIMITE TOTAL de la peticion. Esto NO existia en
// HttpURLConnection, que solo tenia limite por operacion.
.timeout(Duration.ofSeconds(10))
// Version especifica para esta peticion, si hace falta.
.version(HttpClient.Version.HTTP_1_1)
// El metodo. Uno de estos, al final.
.GET()
.build();Los métodos
.GET() // sin cuerpo
.DELETE() // sin cuerpo
.POST(HttpRequest.BodyPublishers.ofString(json))
.PUT(HttpRequest.BodyPublishers.ofString(json))
.method("PATCH", HttpRequest.BodyPublishers.ofString(json)) // cualquier otroPATCH no tiene método propio porque llegó al estándar después; se usa method(nombre, publisher), que sirve para cualquier método, incluidos los personalizados.
El tiempo límite total: la mejora clave
Esta es una de las diferencias más importantes con la API antigua.
HttpURLConnection |
HttpClient |
|
|---|---|---|
| Límite de conexión | setConnectTimeout |
.connectTimeout() en el cliente |
| Límite de lectura | setReadTimeout, por operación |
— |
| Límite total | No existe | .timeout() en la petición |
El problema real que resuelve: un servidor que envía un byte cada nueve segundos mantiene viva indefinidamente una petición con setReadTimeout(10_000), porque el plazo se reinicia con cada byte. Con .timeout(Duration.ofSeconds(10)), a los diez segundos la petición termina, pase lo que pase, con una HttpTimeoutException. Ese comportamiento —a veces llamado ataque de servidor lento— era imposible de acotar con la API antigua.
Reutilizar peticiones
Al ser inmutables, un HttpRequest se puede enviar muchas veces, y también partir de una plantilla:
// Plantilla con lo comun. Se construye una vez.
HttpRequest.Builder plantilla = HttpRequest.newBuilder()
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(10));
// Y por cada ISBN, solo cambia la URI.
// copy() clona el builder: sin el, modificariamos 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() es importante: sin él, plantilla.uri(...) modificaría la plantilla y la siguiente petición heredaría la URI anterior.
- Los
BodyPublishers: enviar un cuerpo
BodyPublishers: enviar un cuerpoUn BodyPublisher describe de dónde salen los bytes del cuerpo.
| Método | Envía | Uso típico |
|---|---|---|
ofString(s) |
Un texto (UTF-8 por defecto) | JSON, XML, formularios |
ofString(s, charset) |
Un texto con charset explícito | Cuando no es UTF-8 |
ofByteArray(bytes) |
Un array de bytes | Datos binarios pequeños |
ofFile(path) |
El contenido de un fichero, sin cargarlo en memoria | Subir ficheros grandes |
ofInputStream(sup) |
Lo que produzca un InputStream |
Contenido generado |
noBody() |
Nada | POST sin cuerpo |
fromPublisher(p) |
Un Flow.Publisher<ByteBuffer> |
Reactivo, avanzado |
import java.net.http.HttpRequest.BodyPublishers;
// JSON
HttpRequest p = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestamos"))
.header("Content-Type", "application/json; charset=utf-8")
.POST(BodyPublishers.ofString(json, StandardCharsets.UTF_8))
.build();
// Subir un fichero SIN cargarlo en memoria: se lee segun se envia.
// Con HttpURLConnection habia que hacer el streaming a mano.
HttpRequest subida = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/catalogo/importar"))
.header("Content-Type", "text/csv; charset=utf-8")
.POST(BodyPublishers.ofFile(Path.of("catalogo.csv")))
.build();
// Formulario: aqui URLEncoder SI es lo correcto (09-05).
String formulario = "isbn=" + URLEncoder.encode(isbn, UTF_8)
+ "&empleado=" + URLEncoder.encode(empleado, UTF_8);
HttpRequest form = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestamos"))
.header("Content-Type", "application/x-www-form-urlencoded; charset=utf-8")
.POST(BodyPublishers.ofString(formulario))
.build();Dos cosas que la API hace por ti y antes eran manuales: calcula el Content-Length (recuerda el lío de bytes contra caracteres de 09-05) y ofFile transmite sin cargar en memoria, lo que permite subir un fichero de un gigabyte sin problema.
- Recibir:
HttpResponse<T> y los BodyHandlers
HttpResponse<T> y los BodyHandlersUn BodyHandler<T> decide en qué se convierte el cuerpo de la respuesta, y con ello el tipo T del HttpResponse<T>.
| Manejador | Tipo resultante | Uso |
|---|---|---|
ofString() |
HttpResponse<String> |
JSON, HTML, texto |
ofString(charset) |
HttpResponse<String> |
Con charset explícito |
ofByteArray() |
HttpResponse<byte[]> |
Binario pequeño |
ofFile(path) |
HttpResponse<Path> |
Descarga directa a disco |
ofInputStream() |
HttpResponse<InputStream> |
Procesar sin cargar en memoria |
ofLines() |
HttpResponse<Stream<String>> |
Línea a línea (usa Streams: 10-04) |
discarding() |
HttpResponse<Void> |
Descartar el cuerpo pero consumirlo |
replacing(v) |
HttpResponse<T> |
Descartar y devolver un valor fijo |
ofByteArrayConsumer(c) |
HttpResponse<Void> |
Procesar por trozos |
import java.net.http.HttpResponse.BodyHandlers;
// Texto. El charset explicito, como siempre.
HttpResponse<String> texto = cliente.send(peticion,
BodyHandlers.ofString(StandardCharsets.UTF_8));
// Directamente a un fichero. Adios al bucle de copia de 09-05.
HttpResponse<Path> fichero = cliente.send(peticion,
BodyHandlers.ofFile(Path.of("portadas", isbn + ".jpg")));
System.out.println("Guardado en " + fichero.body());
// Como flujo, para procesar sin cargarlo entero.
HttpResponse<InputStream> flujo = cliente.send(peticion,
BodyHandlers.ofInputStream());
try (InputStream entrada = flujo.body()) {
// ... el InputStream del modulo 7, otra vez ...
}
// Solo interesa el codigo de estado (como un HEAD).
HttpResponse<Void> soloEstado = cliente.send(peticion, BodyHandlers.discarding());ofFile merece un momento de atención. En 09-05 escribiste un bucle de copia con buffer, comprobación de límite, fichero temporal y movimiento atómico. Con ofFile, la descarga a disco es una llamada. (El temporal y el movimiento atómico siguen siendo tuyos si quieres esa garantía, y siguen mereciendo la pena.)
Lo que ofrece HttpResponse<T>
HttpResponse<String> r = cliente.send(peticion, BodyHandlers.ofString(UTF_8));
int codigo = r.statusCode(); // 200
String cuerpo = r.body(); // el cuerpo, del tipo T
HttpHeaders cabeceras = r.headers(); // las cabeceras
URI uri = r.uri(); // la URI FINAL (tras redirecciones)
HttpClient.Version version = r.version(); // HTTP_2 o HTTP_1_1
HttpRequest original = r.request(); // la peticion que la produjo
// Las cabeceras, con una API decente:
Optional<String> tipo = r.headers().firstValue("Content-Type");
List<String> todas = r.headers().allValues("Set-Cookie");
OptionalLong longitud = r.headers().firstValueAsLong("Content-Length");Dos mejoras respecto a la API antigua: uri() devuelve la URI final tras las redirecciones, que es información que antes había que rastrear a mano; y headers() devuelve un HttpHeaders con métodos útiles en lugar de aquel Map<String, List<String>> con la entrada de clave null.
Nota sobre
Optional.firstValuedevuelveOptional<String>porque una cabecera puede no estar. Aquí lo usamos solo conorElse(...)oisPresent();Optionala fondo, junto con Streams, es 10-04.
Y lo más importante, que enlaza con la lección anterior: HttpResponse no distingue entre flujo normal y de error. Con un 404 o un 500 obtienes el cuerpo del error en body() como cualquier otro. Se acabó la distinción entre getInputStream y getErrorStream.
- Envío síncrono:
send
sendHttpResponse<String> respuesta = cliente.send(peticion,
BodyHandlers.ofString(StandardCharsets.UTF_8));Bloquea el hilo hasta que llega la respuesta completa. Lanza:
| Excepción | Cuándo |
|---|---|
IOException |
Fallo de red: conexión rechazada, host desconocido, conexión rota |
HttpTimeoutException |
Se agotó el .timeout() de la petición (subclase de IOException) |
HttpConnectTimeoutException |
Se agotó el .connectTimeout() del cliente |
InterruptedException |
El hilo fue interrumpido esperando |
Fíjate en InterruptedException: send es interrumpible. A diferencia de un socket.read() bloqueante, que ignora las interrupciones (09-03), aquí el protocolo de cancelación de 08-02 funciona.
try {
HttpResponse<String> r = cliente.send(peticion, BodyHandlers.ofString(UTF_8));
// OBLIGATORIO: comprobar el codigo. Un 500 NO lanza excepcion.
if (r.statusCode() != 200) {
throw new BiblioTechException("El servicio respondio " + r.statusCode()
+ ": " + recortar(r.body()));
}
procesar(r.body());
} catch (HttpTimeoutException e) {
// Transitorio: merece reintento con espera creciente.
throw new BiblioTechException("El servicio no responde a tiempo", e);
} catch (IOException e) {
throw new BiblioTechException("Error de red consultando el servicio", e);
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02: restaurar SIEMPRE la bandera
throw new BiblioTechException("Consulta interrumpida", e);
}
- Envío asíncrono:
sendAsync
sendAsyncY aquí es donde el módulo 8 y el módulo 9 se encuentran.
CompletableFuture<HttpResponse<String>> futuro = cliente.sendAsync(peticion,
BodyHandlers.ofString(StandardCharsets.UTF_8));sendAsync devuelve inmediatamente, sin bloquear nada, con un CompletableFuture<HttpResponse<String>>. Ese tipo dice exactamente lo que es: un futuro que, cuando se complete, contendrá una respuesta HTTP cuyo cuerpo es un String.
Y a partir de ahí, todo 08-07 se aplica sin cambiar una coma:
cliente.sendAsync(peticion, BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body) // extraer el cuerpo
.thenApply(this::analizarMetadatos) // convertirlo en objeto
.thenAccept(m -> System.out.println(m.titulo()))
.exceptionally(e -> {
LOG.warning("Fallo: " + e.getMessage());
return null;
});
// El hilo actual sigue trabajando. No ha esperado a nada.sequenceDiagram
participant M as Hilo principal
participant C as HttpClient
participant E as Ejecutor
participant S as Servicio externo
M->>C: sendAsync(peticion, ofString)
C-->>M: CompletableFuture (vacio, al instante)
Note over M: El hilo principal SIGUE. No espera.
C->>S: GET /v1/libros/978-0000000001
Note over S: procesa (200 ms)
S-->>C: 200 OK {...}
C->>E: completa el futuro
E->>E: thenApply(body)
E->>E: thenApply(analizar)
E->>E: thenAccept(mostrar)
Note over M,E: El resultado se procesa en un hilo del ejecutor
Sobre qué hilo se ejecuta cada etapa
Lo mismo que en 08-07: las etapas sin sufijo Async pueden ejecutarse en el hilo que completó la anterior —aquí, un hilo interno del HttpClient—, y las que llevan Async usan el ejecutor.
La regla práctica y la razón de ser: transformaciones baratas sin sufijo; trabajo caro o bloqueante, con ...Async y ejecutor propio. Si haces una operación pesada en un thenApply sin sufijo, la ejecutas en un hilo interno del cliente HTTP y estás frenando su capacidad de atender otras respuestas.
// MAL: escritura a disco en un hilo interno del HttpClient.
cliente.sendAsync(peticion, BodyHandlers.ofString(UTF_8))
.thenApply(r -> { escribirEnDisco(r.body()); return r; });
// BIEN: el trabajo caro va a nuestro ejecutor.
cliente.sendAsync(peticion, BodyHandlers.ofString(UTF_8))
.thenApplyAsync(r -> { escribirEnDisco(r.body()); return r; }, ejecutorEs);
- Componer cadenas asíncronas
Los operadores de 08-07, aplicados a HTTP.
thenApply: transformar el resultado
CompletableFuture<Metadatos> futuro =
cliente.sendAsync(peticionDe(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(r -> {
// El codigo se comprueba AQUI, dentro de la cadena.
if (r.statusCode() == 404) {
return null;
}
if (r.statusCode() != 200) {
// Lanzar dentro de la cadena la hace fallar,
// y el fallo llega al exceptionally.
throw new CompletionException(
new BiblioTechException("HTTP " + r.statusCode()));
}
return r.body();
})
.thenApply(this::analizarMetadatos);thenCompose: encadenar otra petición
Cuando el resultado de una petición determina la siguiente. La distinción de 08-07 sigue valiendo: thenApply cuando la función devuelve un valor; thenCompose cuando devuelve otro CompletableFuture.
// Primero los metadatos, y con la URL que traen, la portada.
CompletableFuture<Path> futuro =
cliente.sendAsync(peticionMetadatos(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(r -> analizarMetadatos(r.body()))
.thenCompose(m -> {
// Devuelve un CompletableFuture -> thenCompose, no thenApply.
// Con thenApply obtendriamos un
// CompletableFuture<CompletableFuture<HttpResponse<Path>>>.
HttpRequest p = HttpRequest.newBuilder()
.uri(URI.create(m.urlPortada()))
.timeout(Duration.ofSeconds(30))
.GET().build();
return cliente.sendAsync(p,
BodyHandlers.ofFile(Path.of("portadas", isbn + ".jpg")));
})
.thenApply(HttpResponse::body);Dos peticiones dependientes, encadenadas, sin bloquear un solo hilo. Con HttpURLConnection esto serían dos bloques try con dos esperas.
thenCombine: juntar dos independientes
// Dos servicios distintos, consultados EN PARALELO.
CompletableFuture<String> metadatos =
cliente.sendAsync(peticionMetadatos(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body);
CompletableFuture<String> valoraciones =
cliente.sendAsync(peticionValoraciones(isbn), BodyHandlers.ofString(UTF_8))
.thenApply(HttpResponse::body);
// El tiempo total es el del MAS LENTO, no la suma.
CompletableFuture<String> ficha = metadatos.thenCombine(valoraciones,
(m, v) -> componerFicha(m, v));orTimeout: plazo sobre la cadena completa
cliente.sendAsync(peticion, BodyHandlers.ofString(UTF_8))
.thenApply(this::procesar)
.orTimeout(15, TimeUnit.SECONDS) // plazo de TODA la cadena
.exceptionally(e -> {
if (e.getCause() instanceof TimeoutException) {
LOG.warning("La cadena completa supero los 15 s");
}
return respuestaPorDefecto();
});Con el aviso de 08-07 que sigue vigente: orTimeout no cancela el trabajo subyacente. La petición HTTP sigue en marcha; solo se completa el futuro con un error. Para cancelar de verdad hay que llamar a cancel(true) sobre el futuro que devuelve sendAsync, que sí aborta la petición.
- Varias peticiones en paralelo con
allOf
allOfEl caso que resuelve el problema abierto de 09-05.
package com.nexussoftware.bibliotech.red;
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 varios ISBN en paralelo con allOf. */
public class ConsultaParalela {
private final HttpClient cliente;
private final String base;
public ConsultaParalela(HttpClient cliente, String base) {
this.cliente = cliente;
this.base = base;
}
public List<String> consultarTodos(List<String> isbns) {
List<CompletableFuture<String>> futuros = new ArrayList<>();
// 1. Lanzar TODAS las peticiones. sendAsync devuelve al instante,
// asi que este bucle termina en microsegundos y las N peticiones
// quedan en vuelo simultaneamente.
for (String isbn : isbns) {
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/libros/" + isbn))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
CompletableFuture<String> futuro =
cliente.sendAsync(peticion, BodyHandlers.ofString(StandardCharsets.UTF_8))
.thenApply(r -> r.statusCode() == 200
? r.body()
: "ERROR " + r.statusCode() + " en " + isbn)
// BLINDAR CADA FUTURO ANTES DE AGREGARLO.
// Sin esto, un solo fallo hace fallar el allOf
// completo y perdemos las N-1 respuestas buenas.
// Es la trampa clasica de 08-07.
.exceptionally(e -> "FALLO en " + isbn + ": "
+ e.getCause().getMessage());
futuros.add(futuro);
}
// 2. allOf se completa cuando TODOS terminan.
CompletableFuture<Void> todos = CompletableFuture.allOf(
futuros.toArray(new CompletableFuture[0]));
// 3. Recoger. join() aqui es seguro porque allOf ya garantizo
// que todos estan completos: no bloquea nada.
return todos.thenApply(v -> {
List<String> resultados = new ArrayList<>(futuros.size());
for (CompletableFuture<String> f : futuros) {
resultados.add(f.join());
}
return resultados;
}).join(); // el unico bloqueo real, y es intencionado
}
}Los tres puntos del patrón, todos aprendidos en 08-07:
- Lanzar todas antes de esperar ninguna. El bucle de
sendAsynctermina en microsegundos con N peticiones en vuelo. - Blindar cada futuro con su propio
exceptionallyantes de agregarlo. Sin esto, un único fallo hace fallar elallOfentero y pierdes todas las respuestas buenas. Es el error más caro de esta API. join()tras elallOfes seguro, porque todos los futuros ya están completos.
La diferencia medida
Veinte ISBN contra un servicio que tarda 200 ms por consulta:
| Estrategia | Tiempo | Cómo |
|---|---|---|
Secuencial (send en bucle) |
4.000 ms | 20 × 200 ms |
Paralela (sendAsync + allOf) |
~250 ms | Todas a la vez, más margen |
| Mejora | 16× |
Y el mismo tráfico de red, exactamente como demostró el estimador del ejercicio 3 de 09-01: lo que cambia no es el volumen, es el solapamiento de las esperas.
Cuidado con el paralelismo desbocado. Lanzar mil
sendAsynca la vez crea mil peticiones simultáneas y probablemente te ganes un 429 o un bloqueo de IP. En producción hay que acotar: unSemaphorecomo el de 08-05, o procesar por tandas. Lo aplicaremos en el enriquecedor de BiblioTech.
- Manejo de errores: excepción frente a código de estado
La distinción que más bugs causa, y aquí conviene dejarla completamente clara.
| Situación | ¿Excepción? | Cómo se detecta |
|---|---|---|
| Host desconocido | Sí | IOException (UnresolvedAddressException como causa) |
| Conexión rechazada | Sí | IOException / ConnectException |
| Tiempo límite de conexión | Sí | HttpConnectTimeoutException |
| Tiempo límite de petición | Sí | HttpTimeoutException |
| Conexión rota a mitad | Sí | IOException |
| Fallo de certificado TLS | Sí | IOException con 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: hay excepción cuando no se pudo obtener una respuesta HTTP. Si hay respuesta, hubo éxito de red, aunque el código sea 500.
El error clásico, escrito para que lo reconozcas:
// CODIGO ROTO. Muy comun.
HttpResponse<String> r = cliente.send(peticion, BodyHandlers.ofString(UTF_8));
Metadatos m = analizar(r.body()); // <-- si fue un 500, body() es
// la pagina de error del servidorCon un 500, body() contiene el HTML de error de nginx o el JSON de error del servicio. analizar() recibirá basura y fallará de forma incomprensible, o —peor— devolverá datos absurdos que parecen válidos.
En una cadena asíncrona, la comprobación va dentro:
cliente.sendAsync(peticion, BodyHandlers.ofString(UTF_8))
.thenApply(r -> {
if (r.statusCode() == 404) {
return null; // "no existe" es un resultado, no un error
}
if (r.statusCode() / 100 == 5) {
// Lanzar dentro de una etapa hace fallar el futuro,
// y el fallo se propaga hasta el primer manejador.
throw new CompletionException(
new BiblioTechException("Fallo del servidor: " + r.statusCode()));
}
if (r.statusCode() != 200) {
throw new CompletionException(
new BiblioTechException("Respuesta inesperada: " + r.statusCode()));
}
return r.body();
})
.exceptionally(e -> {
// OJO: la causa llega ENVUELTA en CompletionException (08-07).
Throwable causa = e.getCause() != null ? e.getCause() : e;
LOG.warning("Consulta fallida: " + causa.getMessage());
return null;
});
HttpURLConnection frente a HttpClient
HttpURLConnection frente a HttpClient| Criterio | HttpURLConnection (1996) |
HttpClient (Java 11) |
|---|---|---|
| Líneas para un GET simple | ~30 con gestión de recursos | ~8 |
| Mutabilidad | Objeto mutable con estados | Inmutable |
| Seguro para varios hilos | No | Sí |
| Constructores fluidos | No | Sí |
| Tiempo límite total | No existe | Sí, .timeout() |
| Asincronía | No | sendAsync → CompletableFuture |
| HTTP/2 | No | Sí, por defecto |
| Multiplexación | No | Sí, con HTTP/2 |
| Reutilización de conexiones | Sí, pero opaca y frágil | Sí, pool gestionado |
| WebSocket | No | Sí |
| Flujo de error | getErrorStream() aparte |
Uno solo: body() |
| Redirecciones http→https | No las sigue | Sí, con NORMAL |
| Cuerpo a fichero | Bucle de copia a mano | BodyHandlers.ofFile |
| Subir fichero sin memoria | setFixedLengthStreamingMode a mano |
BodyPublishers.ofFile |
| Cabeceras de respuesta | Map con clave null rara |
HttpHeaders con métodos |
| URI final tras redirección | Hay que rastrearla | response.uri() |
| Facilidad de prueba | Muy baja | Media (interfaz sustituible) |
| Disponible desde | Java 1.0 | Java 11 |
La única razón para usar la antigua es tener que compilar para Java 8 o anterior, o mantener código que ya la usa. Para todo lo demás, HttpClient.
- HTTP/2 y la multiplexación
HttpClient habla HTTP/2 por defecto y negocia automáticamente: si el servidor no lo soporta, cae a HTTP/1.1 sin que hagas nada.
La mejora principal es la multiplexación. En HTTP/1.1, una conexión TCP sirve una petición a la vez: para hacer seis en paralelo hacen falta seis conexiones, con sus seis saludos de tres vías y sus seis saludos TLS. HTTP/2 divide la conexión en flujos independientes que viajan entrelazados, de modo que una sola conexión sirve decenas de peticiones simultáneas.
HTTP/1.1, seis peticiones en paralelo:
conexion 1: [saludo][TLS][peticion A............]
conexion 2: [saludo][TLS][peticion B............]
... seis conexiones, seis establecimientos ...
HTTP/2, seis peticiones en paralelo:
conexion 1: [saludo][TLS][A|B|C|A|D|B|E|C|F|...]
... UNA conexion, UN establecimiento, flujos entrelazados ...Consecuencias para ti:
- El
allOfcon veinte peticiones al mismo host usa una sola conexión en lugar de veinte. Menos establecimientos, menos saludos TLS, menos recursos en el servidor. - Las cabeceras se comprimen (HPACK), lo que importa cuando envías un token largo en cada petición.
- Sigue existiendo el bloqueo de cabecera de línea a nivel TCP: un paquete perdido retrasa todos los flujos de esa conexión. Es el problema que HTTP/3 resuelve moviéndose a UDP, como viste en 09-04.
// Comprobar que version se negocio realmente.
HttpResponse<String> r = cliente.send(peticion, BodyHandlers.ofString(UTF_8));
System.out.println("Version negociada: " + r.version()); // HTTP_2 o HTTP_1_1
WebSocket
WebSocketEl mismo paquete incluye un cliente de WebSocket, el protocolo de comunicación bidireccional y persistente sobre HTTP. Con HTTP el cliente pregunta y el servidor responde; con WebSocket ambos pueden enviar en cualquier momento, lo que sirve para notificaciones, chats y datos en vivo.
package com.nexussoftware.bibliotech.red;
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;
/**
* Ejemplo minimo de WebSocket: BiblioTech recibiria avisos del servidor
* en tiempo real ("el libro que esperabas esta disponible") sin sondear.
*/
public class AvisosWebSocket {
private static final Logger LOG = Logger.getLogger(AvisosWebSocket.class.getName());
public static void main(String[] args) throws Exception {
HttpClient cliente = HttpClient.newHttpClient();
// El Listener recibe los eventos. Fijate en request(1) al final
// de cada metodo: WebSocket usa CONTRAPRESION, y hay que pedir
// explicitamente el siguiente mensaje.
WebSocket.Listener oyente = new WebSocket.Listener() {
@Override
public void onOpen(WebSocket ws) {
LOG.info("Conexion WebSocket abierta");
ws.request(1); // pedimos el primer mensaje
}
@Override
public CompletionStage<?> onText(WebSocket ws, CharSequence datos,
boolean ultimo) {
System.out.println("AVISO: " + datos);
ws.request(1); // y el siguiente
return null;
}
@Override
public CompletionStage<?> onClose(WebSocket ws, int codigo, String motivo) {
LOG.info("WebSocket cerrado: " + codigo + " " + motivo);
return null;
}
@Override
public void onError(WebSocket ws, Throwable error) {
LOG.warning("Error en WebSocket: " + error.getMessage());
}
};
// buildAsync devuelve un CompletableFuture<WebSocket>: coherencia
// total con el resto de la API.
WebSocket ws = cliente.newWebSocketBuilder()
.buildAsync(URI.create("ws://localhost:8080/avisos"), oyente)
.join();
ws.sendText("SUSCRIBIR 978-0000000001", true);
Thread.sleep(30_000); // escuchamos 30 segundos
ws.sendClose(WebSocket.NORMAL_CLOSURE, "fin").join();
}
}Se menciona por completitud: es la respuesta de la biblioteca estándar cuando el sondeo periódico no basta. Su uso a fondo queda fuera del alcance de este curso.
- Enviar y recibir JSON
JSON es el formato de intercambio de prácticamente todas las APIs actuales. Y aquí toca ser honesto sobre lo que se puede y no se puede hacer con el JDK a secas.
Construir el cuerpo
// A MANO. Funciona para casos simples, y HAY QUE ESCAPAR.
String json = "{"
+ "\"isbn\":\"" + escapar(isbn) + "\","
+ "\"empleado\":\"" + escapar(empleado) + "\","
+ "\"dias\":" + dias
+ "}";
/**
* Escape minimo de JSON. Los caracteres que ROMPEN el documento
* si no se escapan son: la comilla doble, la barra invertida
* y los caracteres de control.
*/
static String escapar(String texto) {
StringBuilder sb = new StringBuilder(texto.length() + 16);
for (int i = 0; i < texto.length(); i++) {
char c = texto.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();
}Extraer un campo de la respuesta
/**
* Extrae "campo":"valor" de un JSON POR BUSQUEDA DE SUBCADENA.
*
* ESTO ES UN APAÑO DIDACTICO Y HAY QUE DECIRLO CLARO.
*
* Funciona con respuestas planas y sencillas como las de este servicio,
* y SE ROMPE con:
* - valores que contengan la subcadena buscada
* - comillas escapadas dentro de un valor ("Java \"Efectivo\"")
* - objetos anidados o arrays
* - un campo del mismo nombre en un objeto interno
* - valores null, numericos o booleanos donde se espera texto
* - espacios distintos alrededor de los dos puntos
*
* HACERLO BIEN REQUIERE UNA LIBRERIA DE JSON, Y ESO ES 11-07 (JACKSON),
* donde una linea -mapper.readValue(json, Metadatos.class)- sustituye
* a todo esto y encima convierte directamente al record. No lleves
* este codigo a produccion.
*/
static String campoTexto(String json, String campo) {
String marca = "\"" + campo + "\"";
int i = json.indexOf(marca);
if (i < 0) {
return null;
}
int dosPuntos = json.indexOf(':', i + marca.length());
if (dosPuntos < 0) {
return null;
}
int abre = json.indexOf('"', dosPuntos);
if (abre < 0) {
return null;
}
// Buscamos la comilla de cierre saltando las escapadas.
int j = abre + 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;
}Es importante entender el mensaje. No se trata de que este código sea malo por descuido: es que analizar JSON correctamente es un problema resuelto que no debes resolver tú. El JDK no trae analizador de JSON, así que en este módulo, que se limita a la biblioteca estándar, la opción honesta es un apaño acotado y bien señalado. En 11-07 verás Jackson, y mapper.readValue(json, Metadatos.class) sustituirá a todas estas líneas devolviendo el record ya construido.
- Buenas prácticas para llamar a servicios externos
Un servicio externo es la parte de tu sistema que no controlas. Estas prácticas asumen que fallará.
- Tiempos límite, siempre y los dos
HttpClient cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5)) // establecer
.build();
HttpRequest peticion = HttpRequest.newBuilder()
.timeout(Duration.ofSeconds(10)) // total de la peticion
.build();Sin ellos, un servicio lento se propaga por tu sistema hasta agotar los hilos. Es la primera causa de caídas en cascada.
- Reintentar solo lo transitorio
| Situación | ¿Reintentar? |
|---|---|
HttpTimeoutException |
Sí |
ConnectException |
Sí, pocas veces |
| 429, 502, 503, 504 | Sí, respetando Retry-After si viene |
UnresolvedAddressException |
No |
| 4xx (salvo 429) | No |
| 500 | Con cautela: puede ser determinista |
Con espera creciente y aleatorizada, exactamente como en 09-05: 200 ms, 400, 800... más un 20 % de variación aleatoria para evitar que cien clientes reintenten sincronizados y vuelvan a tumbar el servicio que se recuperaba.
- No reintentar un
POST no idempotente
POST no idempotenteSi un POST que registra un préstamo agota su plazo, no sabes si el servidor lo procesó. Reintentar puede crear dos préstamos. La solución profesional es la clave de idempotencia:
// El cliente genera un identificador UNICO por operacion logica
// -no por intento- y lo envia. El servidor almacena las claves ya
// vistas y devuelve el resultado anterior en vez de repetir la
// operacion. Con esto, reintentar SI es seguro.
String clave = UUID.randomUUID().toString();
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/prestamos"))
.header("Idempotency-Key", clave) // la MISMA en todos los reintentos
.header("Content-Type", "application/json")
.POST(BodyPublishers.ofString(json))
.build();
- Cortacircuitos, en una frase
Si un servicio lleva veinte fallos seguidos, seguir llamándolo solo consume tus hilos y retrasa a tus usuarios: un cortacircuitos deja de intentarlo durante un tiempo, devuelve el error inmediatamente y prueba de vez en cuando a ver si se ha recuperado. Se implementa a mano o con bibliotecas como Resilience4j; los patrones de resiliencia se ven en 12-07.
- Nunca registrar credenciales
// MAL: el token acaba en el fichero de log, y de ahi en la copia de
// seguridad, en el sistema de agregacion de logs y en cualquier
// captura de pantalla de soporte.
LOG.info("Peticion: " + peticion.headers());
// BIEN: solo lo que se puede registrar.
LOG.info(() -> "GET " + peticion.uri().getPath()
+ " -> " + respuesta.statusCode()
+ " (" + ms + " ms)");Retoma lo aprendido en 06-07: los datos sensibles no van al log. Y hay una regla que se olvida: una URL con un token en la cadena de consulta también es sensible. Registrar la URI completa filtra el token igual que registrar la cabecera. Por eso el ejemplo bueno registra solo getPath().
Lista de lo que nunca se registra: tokens, claves de API, contraseñas, cookies de sesión, cabeceras Authorization, números de tarjeta y datos personales.
- Un
User-Agent identificativo
User-Agent identificativo
- TLS y validación de certificados
Usa https siempre que el servicio lo ofrezca. Nunca desactives la validación de certificados: convierte HTTPS en HTTP con pasos extra y abre la puerta a un ataque de intermediario. Si tienes un certificado interno, configura un SSLContext con tu almacén de confianza:
HttpClient cliente = HttpClient.newBuilder()
.sslContext(contextoConAlmacenPropio()) // NO un TrustManager que acepte todo
.build();La seguridad de red se trata a fondo en 12-07.
- BiblioTech: el enriquecedor asíncrono de catálogo
Todo junto, y resolviendo el problema que dejó abierto 09-05.
package com.nexussoftware.bibliotech.red;
import com.nexussoftware.bibliotech.dominio.Material;
import com.nexussoftware.bibliotech.servicio.CatalogoConcurrente;
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;
/**
* Enriquece el catalogo de BiblioTech con datos del servicio externo
* de metadatos, consultando TODOS los ISBN en paralelo y descargando
* las portadas sin bloquear un solo hilo.
*
* Resuelve el problema que dejo abierto SincronizadorPortadas (09-05),
* que era secuencial y pasaba todo el tiempo esperando a la red.
*
* Aplica: HttpClient reutilizado, sendAsync + thenCompose + allOf de 08-07,
* Semaphore de 08-05 para acotar el paralelismo, y LongAdder de 08-06
* para las metricas.
*/
public class EnriquecedorCatalogo implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(EnriquecedorCatalogo.class.getName());
private static final String AGENTE = "BiblioTech/1.0 (+https://nexussoftware.com)";
private static final int MAXIMO_SIMULTANEAS = 8;
private static final long MAXIMO_PORTADA = 5L * 1024 * 1024;
private final String base;
private final Path directorioPortadas;
private final HttpClient cliente;
private final ExecutorService ejecutor;
/**
* Acota el paralelismo. Sin el, mil ISBN generarian mil peticiones
* simultaneas y el servicio nos devolveria 429 o nos bloquearia la IP.
* Es el Semaphore de 08-05, aplicado a la red.
*/
private final Semaphore permisos = new Semaphore(MAXIMO_SIMULTANEAS);
// Metricas sin contencion (08-06).
private final LongAdder enriquecidos = new LongAdder();
private final LongAdder noEncontrados = new LongAdder();
private final LongAdder portadasDescargadas = new LongAdder();
private final LongAdder fallidos = new LongAdder();
private final LongAdder bytesPortadas = new LongAdder();
public EnriquecedorCatalogo(String base, Path directorioPortadas) {
this.base = base.endsWith("/") ? base.substring(0, base.length() - 1) : base;
this.directorioPortadas = directorioPortadas;
// Ejecutor propio con hilos NOMBRADOS (08-02): en un volcado de
// hilos y en cada linea de log sabras quien hace que.
AtomicInteger n = new AtomicInteger(1);
this.ejecutor = Executors.newFixedThreadPool(MAXIMO_SIMULTANEAS, r -> {
Thread h = new Thread(r, "bibliotech-http-" + n.getAndIncrement());
h.setDaemon(true);
return h;
});
// UN cliente, creado una vez y reutilizado: pool de conexiones,
// sesiones TLS reutilizadas y multiplexacion HTTP/2.
this.cliente = HttpClient.newBuilder()
.version(HttpClient.Version.HTTP_2)
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(ejecutor)
.build();
}
/** Ficha enriquecida de un material. */
public record FichaEnriquecida(String isbn, String titulo, String autor,
int paginas, Path portada, String estado) {
}
// =================================================================
// Punto de entrada
// =================================================================
public List<FichaEnriquecida> enriquecer(CatalogoConcurrente catalogo) {
List<Material> materiales = catalogo.todos();
List<String> isbns = new ArrayList<>(materiales.size());
for (Material m : materiales) {
isbns.add(m.getIsbn());
}
return enriquecerIsbns(isbns);
}
public List<FichaEnriquecida> enriquecerIsbns(List<String> isbns) {
long inicio = System.currentTimeMillis();
System.out.println("Enriqueciendo " + isbns.size()
+ " materiales (hasta " + MAXIMO_SIMULTANEAS + " a la vez)...\n");
try {
Files.createDirectories(directorioPortadas);
} catch (Exception e) {
LOG.log(Level.WARNING, "No se pudo crear el directorio de portadas", e);
}
// --- 1. Lanzar TODAS las cadenas ---
// sendAsync devuelve al instante, asi que este bucle termina en
// microsegundos con N cadenas en marcha.
List<CompletableFuture<FichaEnriquecida>> futuros =
new ArrayList<>(isbns.size());
for (String isbn : isbns) {
futuros.add(cadenaDe(isbn));
}
// --- 2. Esperar a todas ---
CompletableFuture<Void> todas = CompletableFuture.allOf(
futuros.toArray(new CompletableFuture[0]));
// --- 3. Recoger ---
List<FichaEnriquecida> fichas = todas.thenApply(v -> {
List<FichaEnriquecida> lista = new ArrayList<>(futuros.size());
for (CompletableFuture<FichaEnriquecida> f : futuros) {
// join() seguro: allOf ya garantizo que todos estan completos.
lista.add(f.join());
}
return lista;
}).join(); // el unico bloqueo real, intencionado
informe(System.currentTimeMillis() - inicio, isbns.size());
return fichas;
}
// =================================================================
// La cadena asincrona de UN material
// =================================================================
private CompletableFuture<FichaEnriquecida> cadenaDe(String isbn) {
if (!isbnValido(isbn)) {
fallidos.increment();
return CompletableFuture.completedFuture(
new FichaEnriquecida(isbn, null, null, 0, null, "ISBN INVALIDO"));
}
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/libros/" + isbn))
.header("Accept", "application/json")
.header("User-Agent", AGENTE)
.timeout(Duration.ofSeconds(10)) // limite TOTAL, no por operacion
.GET()
.build();
// adquirirPermiso bloquea si ya hay 8 en vuelo. Se hace ANTES
// del sendAsync para que el semaforo limite peticiones reales.
adquirirPermiso();
return cliente.sendAsync(peticion, BodyHandlers.ofString(StandardCharsets.UTF_8))
// --- Etapa 1: comprobar el codigo y quedarnos con el cuerpo ---
.thenApply(respuesta -> {
liberarPermiso();
int codigo = respuesta.statusCode();
LOG.fine(() -> "GET /v1/libros/" + isbn + " -> " + codigo);
if (codigo == 404) {
return null; // "no lo tengo" es un resultado
}
if (codigo != 200) {
// Lanzar aqui hace fallar el futuro; el fallo llega
// al exceptionally del final.
throw new CompletionException(
new java.io.IOException("HTTP " + codigo
+ " consultando " + isbn));
}
return respuesta.body();
})
// --- Etapa 2: analizar el JSON ---
.thenApply(cuerpo -> {
if (cuerpo == null) {
noEncontrados.increment();
return new FichaEnriquecida(isbn, null, null, 0, null,
"NO ENCONTRADO");
}
String titulo = campoTexto(cuerpo, "titulo");
String autor = campoTexto(cuerpo, "autor");
String portada = campoTexto(cuerpo, "portada");
int paginas = campoEntero(cuerpo, "paginas");
if (titulo == null) {
throw new CompletionException(
new java.io.IOException("Respuesta sin titulo"));
}
enriquecidos.increment();
return new FichaEnriquecida(isbn, titulo,
autor == null ? "(desconocido)" : autor,
paginas,
portada == null ? null : Path.of(portada), // marcador
"OK");
})
// --- Etapa 3: descargar la portada, si la hay ---
// thenCompose porque devuelve OTRO CompletableFuture:
// con thenApply tendriamos un futuro de un futuro (08-07).
.thenCompose(ficha -> {
if (ficha.portada() == null) {
return CompletableFuture.completedFuture(ficha);
}
// El campo 'portada' lleva la URL de forma provisional.
String url = ficha.portada().toString();
return descargarPortada(url, isbn)
.thenApply(ruta -> new FichaEnriquecida(
ficha.isbn(), ficha.titulo(), ficha.autor(),
ficha.paginas(), ruta,
ruta == null ? "OK (sin portada)" : "OK"));
})
// --- Plazo de la cadena COMPLETA ---
.orTimeout(30, TimeUnit.SECONDS)
// --- Blindaje: OBLIGATORIO antes de agregar al allOf ---
// Sin esto, un solo fallo hace fallar el allOf entero y
// perdemos todas las fichas buenas. Trampa clasica de 08-07.
.exceptionally(e -> {
liberarPermiso(); // por si fallo antes de liberarlo
fallidos.increment();
// La causa llega ENVUELTA en CompletionException.
Throwable causa = e.getCause() != null ? e.getCause() : e;
LOG.warning("Fallo enriqueciendo " + isbn + ": "
+ causa.getMessage());
return new FichaEnriquecida(isbn, null, null, 0, null,
"FALLO: " + causa.getClass().getSimpleName());
});
}
/** Descarga la portada a disco. Devuelve null si no se pudo. */
private CompletableFuture<Path> descargarPortada(String url, String isbn) {
if (!url.startsWith("http://") && !url.startsWith("https://")) {
// Sin esta comprobacion, una URL "file:///etc/passwd" recibida
// del servicio nos haria leer ficheros locales.
LOG.warning("Esquema no permitido en la portada de " + isbn);
return CompletableFuture.completedFuture(null);
}
Path destino = directorioPortadas.resolve(isbn + ".jpg");
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "image/jpeg, image/png, image/*")
.header("User-Agent", AGENTE)
.timeout(Duration.ofSeconds(30))
.GET()
.build();
adquirirPermiso();
// BodyHandlers.ofFile escribe directamente a disco, sin cargar
// la imagen en memoria y sin el bucle de copia de 09-05.
return cliente.sendAsync(peticion, BodyHandlers.ofFile(destino))
.thenApply(respuesta -> {
liberarPermiso();
if (respuesta.statusCode() != 200) {
LOG.fine("Portada de " + isbn + ": HTTP "
+ respuesta.statusCode());
return null;
}
Path ruta = respuesta.body();
try {
long tamano = Files.size(ruta);
if (tamano > MAXIMO_PORTADA) {
Files.deleteIfExists(ruta);
LOG.warning("Portada de " + isbn + " demasiado grande");
return null;
}
bytesPortadas.add(tamano);
} catch (Exception e) {
LOG.fine("No se pudo comprobar el tamano: " + e.getMessage());
}
portadasDescargadas.increment();
return ruta;
})
.exceptionally(e -> {
liberarPermiso();
LOG.fine("Fallo descargando la portada de " + isbn);
return null; // sin portada no es un fallo del enriquecido
});
}
// =================================================================
// Semaforo
// =================================================================
private void adquirirPermiso() {
try {
permisos.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02
throw new CompletionException(e);
}
}
private void liberarPermiso() {
permisos.release();
}
// =================================================================
// Analisis de JSON: APAÑO DIDACTICO, ver el apartado 15
// =================================================================
/**
* Extraccion por busqueda de subcadena. Funciona con las respuestas
* planas de este servicio y se rompe con anidamiento, arrays o campos
* repetidos. HACERLO BIEN ES JACKSON, Y ESO ES 11-07.
*/
private String campoTexto(String json, String campo) {
String marca = "\"" + campo + "\"";
int i = json.indexOf(marca);
if (i < 0) {
return null;
}
int dosPuntos = json.indexOf(':', i + marca.length());
if (dosPuntos < 0) {
return null;
}
int abre = json.indexOf('"', dosPuntos);
if (abre < 0) {
return null;
}
StringBuilder valor = new StringBuilder();
int j = abre + 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 campoEntero(String json, String campo) {
String marca = "\"" + campo + "\"";
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 inicio = j;
while (j < json.length() && Character.isDigit(json.charAt(j))) {
j++;
}
return inicio == j ? 0 : Integer.parseInt(json.substring(inicio, j));
}
private boolean isbnValido(String isbn) {
if (isbn == null || isbn.isBlank() || isbn.length() > 20) {
return false;
}
// Lista blanca: impide inyectar rutas ("../admin") en 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 y cierre
// =================================================================
private void informe(long ms, int total) {
System.out.println();
System.out.println("=== ENRIQUECIMIENTO DE CATALOGO ===");
System.out.printf("%-26s %d%n", "Materiales", total);
System.out.printf("%-26s %d%n", "Enriquecidos", enriquecidos.sum());
System.out.printf("%-26s %d%n", "No encontrados", noEncontrados.sum());
System.out.printf("%-26s %d%n", "Fallidos", fallidos.sum());
System.out.printf("%-26s %d%n", "Portadas descargadas",
portadasDescargadas.sum());
System.out.printf("%-26s %.1f KB%n", "Bytes de portadas",
bytesPortadas.sum() / 1024.0);
System.out.printf("%-26s %.2f s%n", "Tiempo total", ms / 1000.0);
if (total > 0) {
System.out.printf("%-26s %.1f ms%n", "Media por material",
(double) ms / total);
}
}
@Override
public void close() {
// Apagado en dos fases (08-05).
ejecutor.shutdown();
try {
if (!ejecutor.awaitTermination(10, TimeUnit.SECONDS)) {
LOG.warning("Peticiones aun activas; se fuerza el cierre");
ejecutor.shutdownNow();
}
} catch (InterruptedException e) {
ejecutor.shutdownNow();
Thread.currentThread().interrupt(); // 08-02
}
LOG.info("EnriquecedorCatalogo cerrado");
}
}Ejecución
package com.nexussoftware.bibliotech.presentacion;
import com.nexussoftware.bibliotech.red.EnriquecedorCatalogo;
import com.nexussoftware.bibliotech.red.EnriquecedorCatalogo.FichaEnriquecida;
import java.nio.file.Path;
import java.util.List;
public class PruebaEnriquecedor {
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: apagado ordenado del ejecutor.
try (EnriquecedorCatalogo enriquecedor = new EnriquecedorCatalogo(
"http://localhost:8080", Path.of("portadas"))) {
List<FichaEnriquecida> fichas = enriquecedor.enriquecerIsbns(isbns);
System.out.println("\n--- RESULTADO ---");
for (FichaEnriquecida f : fichas) {
System.out.printf(" %-18s %-30s %s%n",
f.isbn(),
f.titulo() == null ? "-" : f.titulo(),
f.estado());
}
}
}
}Salida contra un servicio que tarda 200 ms por consulta:
Enriqueciendo 20 materiales (hasta 8 a la vez)...
=== ENRIQUECIMIENTO DE CATALOGO ===
Materiales 20
Enriquecidos 17
No encontrados 2
Fallidos 1
Portadas descargadas 15
Bytes de portadas 682.4 KB
Tiempo total 1.24 s
Media por material 62.0 ms
--- RESULTADO ---
978-0000000001 Java Efectivo OK
978-0000000002 Patrones de Diseno OK
978-0000000003 Refactorizacion OK
978-0000000004 - NO ENCONTRADO
...
978-0000000019 - FALLO: HttpTimeoutExceptionCompara con el sincronizador secuencial de 09-05. Aquel tardaba 5,4 segundos para cinco materiales, es decir, más de un segundo por material. Este tarda 1,24 segundos para veinte, con dos peticiones cada uno (metadatos y portada): 62 ms por material. Una mejora de más de diecisiete veces, con el mismo tráfico de red y sin un solo hilo bloqueado esperando.
Fíjate además en la última línea: una petición agotó su plazo y las diecinueve restantes se completaron con normalidad. Ese es el exceptionally individual haciendo su trabajo. Sin él, ese único fallo habría hecho fallar el allOf y el resultado habría sido cero fichas.
Errores Comunes y Consejos
Crear un HttpClient por petición. Pierdes el pool de conexiones, las sesiones TLS y la multiplexación HTTP/2, y creas un pool de hilos cada vez. Puede multiplicar por cuatro la latencia y acabar fugando hilos. Uno por aplicación, static final.
Suponer que un 4xx o 5xx lanza excepción. No lo hace. body() contendrá la página de error del servidor y tu analizador recibirá basura. Comprueba statusCode() siempre.
Olvidar que followRedirects es NEVER por defecto. Al contrario que en HttpURLConnection. Si tu código migrado se queda con un 301, es esto. Usa NORMAL.
No blindar cada futuro con exceptionally antes del allOf. El error más caro de esta API: un solo fallo hace fallar el allOf entero y pierdes todas las respuestas buenas.
Confundir thenApply con thenCompose. Si la función devuelve otro CompletableFuture, es thenCompose. Con thenApply acabas con un CompletableFuture<CompletableFuture<T>> y el compilador te lo dirá de una forma no especialmente clara.
Hacer trabajo pesado en un thenApply sin sufijo. Se ejecuta en un hilo interno del HttpClient y frena su capacidad de atender otras respuestas. Trabajo caro, ...Async con ejecutor propio.
Lanzar miles de sendAsync sin acotar. Te ganas un 429 o un bloqueo de IP, y saturas al servicio. Usa un Semaphore o procesa por tandas.
No poner .timeout() en la petición. El connectTimeout del cliente solo cubre el establecimiento. Sin el límite total, un servidor lento puede retenerte indefinidamente.
Olvidar Thread.currentThread().interrupt() al capturar InterruptedException. Regla de 08-02, y send es interrumpible, así que aquí aplica de verdad.
Modificar una plantilla de HttpRequest.Builder sin copy(). La siguiente petición hereda lo anterior. Usa .copy().
Registrar cabeceras o URIs completas. El Authorization acaba en el log, y una URL con un token en la consulta también. Registra el método, la ruta y el código.
Desactivar la validación de certificados TLS. Elimina toda la seguridad de HTTPS. Si el certificado es interno, configura un SSLContext con tu almacén (12-07).
Reintentar un POST no idempotente. Puedes duplicar la operación. O no reintentas, o usas clave de idempotencia.
Analizar JSON con indexOf en producción. Funciona hasta que un valor lleva comillas escapadas o el servicio anida un objeto. Jackson, en 11-07.
Consejo de migración. Si estás pasando código de HttpURLConnection a HttpClient, revisa tres cosas en este orden: followRedirects (cambia el valor por defecto), la comprobación del código de estado (ya no hay getErrorStream, pero sigue habiendo que mirar statusCode()), y el .timeout() de la petición (nuevo, y es lo que de verdad te protege). Con eso resueltas la mayoría de las sorpresas.
Ejercicios
Ejercicio 1: Cliente HTTP asíncrono resistente
Reescribe el ClienteHttpResistente de 09-05 usando HttpClient, pero asíncrono: CompletableFuture<Respuesta> get(String url).
Requisitos:
- Un
HttpClientreutilizado, con ejecutor propio de hilos nombrados. - Reintentos con espera creciente y aleatorizada, implementados dentro de la cadena asíncrona con
thenComposerecursivo. Nada deThread.sleep: usaCompletableFuture.delayedExecutor(...)para no bloquear un hilo mientras esperas. - Reintentar solo lo transitorio:
HttpTimeoutException,ConnectException, 429, 502, 503, 504. - Respetar
Retry-Aftersi viene, con un tope de 30 s. - Máximo 4 intentos, con el número de intentos en el resultado.
- Método
postque no reintente por defecto ypostIdempotenteque sí, con cabeceraIdempotency-Keyigual en todos los reintentos. - Escribe un
mainque lance 10 peticiones a la vez a un servicio que falle aleatoriamente y muestre el desglose.
Ejercicio 2: Comparador de estrategias
Escribe ComparadorEstrategias, que mida las cuatro formas de hacer N peticiones y demuestre con números por qué la asíncrona gana.
Requisitos:
- Estrategia A: secuencial con
senden un bucle. - Estrategia B: paralela con un
ExecutorServicede 8 hilos ysendbloqueante (el enfoque del módulo 8 sinCompletableFuture). - Estrategia C: asíncrona con
sendAsync+allOf, sin límite. - Estrategia D: asíncrona con
sendAsync+allOfacotada conSemaphorea 8 simultáneas. - Para cada una: tiempo total, peticiones por segundo, latencia media, y número máximo de hilos vivos durante la ejecución (con
Thread.activeCount()muestreado desde un hilo aparte). - Descartar una ronda de calentamiento antes de medir cada estrategia.
- Tabla comparativa final con el factor de mejora respecto a A.
- Comenta por qué B y D dan tiempos parecidos pero consumen recursos muy distintos.
Ejecuta con N = 50 contra un servicio que tarde 200 ms.
Ejercicio 3: Panel de estado de servicios de Nexus Software
Escribe PanelEstadoServicios, que compruebe periódicamente la salud de todos los servicios de los que depende BiblioTech y muestre un panel en consola.
Requisitos:
- Lista de servicios configurable: nombre, URL de salud, y código esperado.
- Comprobación de todos en paralelo con
sendAsync+allOf, conBodyHandlers.discarding()(no interesa el cuerpo) y.timeout()corto de 3 s. - Para cada servicio: estado (
ARRIBA,DEGRADADOsi responde pero con código inesperado,ABAJOsi hay excepción), latencia en ms, código HTTP y versión negociada (HTTP/1.1 o HTTP/2). - Historial de las últimas 20 comprobaciones por servicio, con porcentaje de disponibilidad y una barra de estado tipo
####-###-##(uno por comprobación). - Repetición cada 10 s con un
ScheduledExecutorService(08-05), con el cuerpo de la tarea envuelto entry/catchpara que una excepción no cancele la tarea programada en silencio. - Apagado ordenado en dos fases con shutdown hook.
- Alerta en el log cuando un servicio pase de
ARRIBAaABAJOo al revés, sin repetirla en cada ciclo.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.red;
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;
/**
* Cliente HTTP asincrono con reintentos, sobre java.net.http.
*
* La clave del ejercicio: los reintentos se hacen DENTRO de la cadena
* asincrona con thenCompose recursivo y delayedExecutor. Ningun hilo
* se bloquea esperando entre intentos.
*/
public class ClienteAsincronoResistente implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(ClienteAsincronoResistente.class.getName());
private static final int MAXIMO_INTENTOS = 4;
private static final long ESPERA_INICIAL_MS = 200;
private static final long MAXIMO_RETRY_AFTER_MS = 30_000;
private final HttpClient cliente;
private final ExecutorService ejecutor;
public ClienteAsincronoResistente() {
AtomicInteger n = new AtomicInteger(1);
this.ejecutor = Executors.newFixedThreadPool(8, r -> {
Thread h = new Thread(r, "http-resistente-" + n.getAndIncrement());
h.setDaemon(true);
return h;
});
// UN cliente, reutilizado: pool de conexiones y sesiones TLS.
this.cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(ejecutor)
.build();
}
public record Respuesta(int codigo, String cuerpo,
Map<String, List<String>> cabeceras, int intentos) {
public boolean exito() {
return codigo >= 200 && codigo < 300;
}
}
// =================================================================
// API publica
// =================================================================
public CompletableFuture<Respuesta> get(String url) {
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Accept", "application/json, */*")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(10))
.GET()
.build();
return conReintentos(peticion, 1, ESPERA_INICIAL_MS);
}
/**
* POST SIN reintentos. Si agota el plazo, no sabemos si el servidor
* lo proceso; reintentar podria duplicar la operacion.
*/
public CompletableFuture<Respuesta> post(String url, String cuerpo, String tipo) {
return conReintentos(construirPost(url, cuerpo, tipo, null),
MAXIMO_INTENTOS, 0); // empezar en el ultimo intento = sin reintentos
}
/**
* POST CON reintentos, usando clave de idempotencia.
*
* La clave se genera UNA VEZ, fuera de la cadena, y viaja igual en
* todos los reintentos. Eso es lo que permite al servidor reconocer
* que es la misma operacion logica y no repetirla.
*/
public CompletableFuture<Respuesta> postIdempotente(String url, String cuerpo,
String tipo) {
String clave = UUID.randomUUID().toString();
return conReintentos(construirPost(url, cuerpo, tipo, clave),
1, ESPERA_INICIAL_MS);
}
private HttpRequest construirPost(String url, String cuerpo, String tipo,
String claveIdempotencia) {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(url))
.header("Content-Type", tipo == null
? "application/json; charset=utf-8" : tipo)
.header("Accept", "application/json")
.header("User-Agent", "BiblioTech/1.0")
.timeout(Duration.ofSeconds(15))
.POST(HttpRequest.BodyPublishers.ofString(cuerpo, StandardCharsets.UTF_8));
if (claveIdempotencia != null) {
b = b.header("Idempotency-Key", claveIdempotencia);
}
return b.build();
}
// =================================================================
// Nucleo: reintentos DENTRO de la cadena asincrona
// =================================================================
private CompletableFuture<Respuesta> conReintentos(HttpRequest peticion,
int intento, long espera) {
return cliente.sendAsync(peticion, BodyHandlers.ofString(StandardCharsets.UTF_8))
// --- Caso 1: hubo respuesta. Puede ser un codigo transitorio. ---
.thenCompose(respuesta -> {
int codigo = respuesta.statusCode();
if (esTransitorio(codigo) && intento < MAXIMO_INTENTOS) {
long esperaReal = esperaTras(respuesta, espera);
LOG.warning(peticion.method() + " " + peticion.uri().getPath()
+ " -> " + codigo + "; reintento " + (intento + 1)
+ " en " + esperaReal + " ms");
// AQUI ESTA LA CLAVE DEL EJERCICIO.
// delayedExecutor devuelve un Executor que ejecuta
// la tarea TRAS el retardo, sin bloquear ningun hilo.
// Un Thread.sleep aqui dejaria parado un hilo del pool
// durante toda la espera, que es justo lo que queremos evitar.
Executor retardado = CompletableFuture.delayedExecutor(
esperaReal, TimeUnit.MILLISECONDS, ejecutor);
// supplyAsync sobre el ejecutor retardado + thenCompose:
// la recursion se convierte en otra etapa de la cadena.
return CompletableFuture
.supplyAsync(() -> null, retardado)
.thenCompose(v -> conReintentos(peticion,
intento + 1, espera * 2));
}
return CompletableFuture.completedFuture(new Respuesta(
codigo, respuesta.body(), respuesta.headers().map(), intento));
})
// --- Caso 2: hubo excepcion. Puede ser transitoria. ---
.handle((resultado, error) -> {
if (error == null) {
return CompletableFuture.completedFuture(resultado);
}
// La causa llega ENVUELTA en CompletionException (08-07).
Throwable causa = error.getCause() != null ? error.getCause() : error;
if (esTransitoria(causa) && intento < MAXIMO_INTENTOS) {
long esperaReal = conJitter(espera);
LOG.warning(causa.getClass().getSimpleName() + " en "
+ peticion.uri().getPath() + "; reintento "
+ (intento + 1) + " en " + esperaReal + " ms");
Executor retardado = CompletableFuture.delayedExecutor(
esperaReal, TimeUnit.MILLISECONDS, ejecutor);
return CompletableFuture
.supplyAsync(() -> null, retardado)
.thenCompose(v -> conReintentos(peticion,
intento + 1, espera * 2));
}
// Permanente o sin intentos: se propaga el fallo.
return CompletableFuture.<Respuesta>failedFuture(causa);
})
// handle devuelve CompletableFuture<CompletableFuture<Respuesta>>:
// thenCompose lo aplana. Es el map/flatMap de 08-07.
.thenCompose(f -> f);
}
// =================================================================
// Politica
// =================================================================
private boolean esTransitorio(int codigo) {
// El 500 se excluye a proposito: suele ser un fallo determinista
// del servidor que se repetira identicamente.
return codigo == 429 || codigo == 502 || codigo == 503 || codigo == 504;
}
private boolean esTransitoria(Throwable t) {
// HttpTimeoutException es subclase de IOException, y ConnectException
// tambien: hay que comprobar las concretas ANTES que IOException.
return t instanceof HttpTimeoutException
|| t instanceof ConnectException
|| (t instanceof IOException && !(t.getMessage() != null
&& t.getMessage().contains("UnresolvedAddress")));
}
private long esperaTras(HttpResponse<?> respuesta, long calculada) {
// firstValue devuelve Optional porque la cabecera puede no estar.
// Aqui lo usamos con isPresent()/get(); el estilo fluido de Optional
// (map, orElseGet, ifPresent) se ve en 10-04.
java.util.Optional<String> retryAfter =
respuesta.headers().firstValue("Retry-After");
if (retryAfter.isPresent()) {
try {
// Solo el formato en segundos; el de fecha exige analizar
// fechas HTTP, y eso se hace bien en 10-05.
long ms = Long.parseLong(retryAfter.get().strip()) * 1000;
return Math.min(ms, MAXIMO_RETRY_AFTER_MS);
} catch (NumberFormatException e) {
LOG.fine("Retry-After en formato de fecha; se ignora");
}
}
return conJitter(calculada);
}
/**
* Aleatoriza la espera un 20 %.
* Evita el "rebano atronador": si cien clientes fallan a la vez y todos
* reintentan exactamente a los 200 ms, la rafaga sincronizada vuelve a
* tumbar el servicio que se estaba recuperando, en un ciclo indefinido.
*/
private long conJitter(long base) {
if (base <= 0) {
return 0;
}
long variacion = Math.max(1, base / 5);
return base + ThreadLocalRandom.current().nextLong(-variacion, variacion + 1);
}
@Override
public void close() {
ejecutor.shutdown(); // apagado en dos fases (08-05)
try {
if (!ejecutor.awaitTermination(10, TimeUnit.SECONDS)) {
ejecutor.shutdownNow();
}
} catch (InterruptedException e) {
ejecutor.shutdownNow();
Thread.currentThread().interrupt();
}
}
// =================================================================
// Prueba
// =================================================================
public static void main(String[] args) {
String base = args.length > 0 ? args[0] : "http://localhost:8080";
try (ClienteAsincronoResistente c = new ClienteAsincronoResistente()) {
List<CompletableFuture<Respuesta>> futuros = new java.util.ArrayList<>();
long t0 = System.currentTimeMillis();
for (int i = 1; i <= 10; i++) {
futuros.add(c.get(base + "/v1/libros/978-000000000" + (i % 10))
// Blindaje individual ANTES del allOf: sin el, un
// fallo tumbaria las diez.
.exceptionally(e -> new Respuesta(-1,
"FALLO: " + e.getCause().getMessage(),
Map.of(), MAXIMO_INTENTOS)));
}
CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
int ok = 0, fallidas = 0, reintentadas = 0;
for (CompletableFuture<Respuesta> f : futuros) {
Respuesta r = f.join(); // seguro: allOf ya termino
if (r.exito()) {
ok++;
} else {
fallidas++;
}
if (r.intentos() > 1) {
reintentadas++;
}
System.out.printf(" codigo=%-5d intentos=%d%n", r.codigo(), r.intentos());
}
System.out.println();
System.out.printf("Correctas: %d Fallidas: %d Con reintento: %d%n",
ok, fallidas, reintentadas);
System.out.printf("Tiempo total: %d ms%n", ms);
}
}
}Comentarios. El corazón del ejercicio es hacer los reintentos sin bloquear ningún hilo, y ahí es donde CompletableFuture.delayedExecutor es la pieza clave. La solución ingenua sería un Thread.sleep(espera) dentro de una etapa, pero eso deja parado un hilo del pool durante toda la espera — y con 800 ms de espera y diez peticiones reintentando a la vez, el pool de ocho hilos se queda sin nada. delayedExecutor devuelve un Executor que programa la tarea para más tarde en un temporizador interno, sin retener ningún hilo mientras tanto.
La recursión mediante thenCompose convierte el reintento en otra etapa de la misma cadena, en lugar de en un bucle. El futuro que devuelve conReintentos no se completa hasta que la cadena entera —con todos sus reintentos— termina, y el llamante nunca se entera de cuántas vueltas hubo salvo por el campo intentos.
El handle seguido de thenCompose(f -> f) merece atención. handle es la única etapa que ve tanto el resultado como el error, que es lo que necesitamos para decidir si reintentar; pero como su función devuelve un CompletableFuture, el resultado es un futuro de un futuro, y hay que aplanarlo. Es exactamente la distinción map/flatMap de 08-07, aplicada en una situación real.
Y la clave de idempotencia generada fuera de la cadena es lo que hace correcto el postIdempotente: si se generara dentro, cada reintento llevaría una clave distinta y el servidor los trataría como operaciones diferentes, que es exactamente lo que se quería evitar.
Solución 2
package com.nexussoftware.bibliotech.red;
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 cuatro estrategias para hacer N peticiones HTTP.
* Demuestra con numeros por que la asincrona acotada es la respuesta.
*/
public class ComparadorEstrategias {
private final String base;
private final int peticiones;
private final HttpClient cliente;
public ComparadorEstrategias(String base, int peticiones) {
this.base = base;
this.peticiones = peticiones;
// UN cliente para todas las estrategias: asi la comparacion es
// justa y no medimos el coste de crear clientes.
this.cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.build();
}
public record Resultado(String estrategia, long ms, int correctas, int fallidas,
double mediaLatenciaMs, int hilosMaximos) {
}
private HttpRequest peticionDe(int i) {
return HttpRequest.newBuilder()
.uri(URI.create(base + "/v1/libros/978-" + String.format("%010d", i)))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(15))
.GET()
.build();
}
// =================================================================
// Vigilante de hilos
// =================================================================
/** Muestrea Thread.activeCount() en un hilo aparte durante la medida. */
private static class VigilanteHilos {
private final AtomicInteger maximo = new AtomicInteger();
private volatile boolean activo = true;
private Thread hilo;
void arrancar() {
hilo = new Thread(() -> {
while (activo) {
maximo.updateAndGet(m -> Math.max(m, Thread.activeCount()));
try {
Thread.sleep(10);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
}, "vigilante-hilos");
hilo.setDaemon(true);
hilo.start();
}
int parar() {
activo = false;
try {
hilo.join(500);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return maximo.get();
}
}
// =================================================================
// A: secuencial
// =================================================================
public Resultado secuencial() {
VigilanteHilos vigilante = new VigilanteHilos();
vigilante.arrancar();
int correctas = 0, fallidas = 0;
long sumaLatencias = 0;
long t0 = System.currentTimeMillis();
for (int i = 0; i < peticiones; i++) {
long p0 = System.nanoTime();
try {
HttpResponse<Void> r = cliente.send(peticionDe(i),
BodyHandlers.discarding());
if (r.statusCode() == 200) {
correctas++;
} else {
fallidas++;
}
} catch (IOException e) {
fallidas++;
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // 08-02
break;
}
sumaLatencias += (System.nanoTime() - p0) / 1_000_000;
}
long ms = System.currentTimeMillis() - t0;
return new Resultado("A. Secuencial (send en bucle)", ms, correctas, fallidas,
peticiones == 0 ? 0 : (double) sumaLatencias / peticiones,
vigilante.parar());
}
// =================================================================
// B: pool de hilos con send bloqueante
// =================================================================
public Resultado poolBloqueante() throws InterruptedException {
VigilanteHilos vigilante = new VigilanteHilos();
vigilante.arrancar();
AtomicInteger correctas = new AtomicInteger();
AtomicInteger fallidas = new AtomicInteger();
LongAdder sumaLatencias = new LongAdder();
ExecutorService pool = Executors.newFixedThreadPool(8);
CountDownLatch salida = new CountDownLatch(1);
CountDownLatch llegada = new CountDownLatch(peticiones);
for (int i = 0; i < peticiones; i++) {
final int n = i;
pool.execute(() -> {
try {
salida.await(); // todos arrancan a la vez
long p0 = System.nanoTime();
// send BLOQUEA el hilo del pool durante toda la espera
// de red. Ocho hilos = ocho peticiones simultaneas,
// y siete de cada ocho hilos estan parados sin hacer nada.
HttpResponse<Void> r = cliente.send(peticionDe(n),
BodyHandlers.discarding());
sumaLatencias.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctas.incrementAndGet();
} else {
fallidas.incrementAndGet();
}
} catch (IOException e) {
fallidas.incrementAndGet();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
} finally {
llegada.countDown();
}
});
}
long t0 = System.currentTimeMillis();
salida.countDown();
llegada.await();
long ms = System.currentTimeMillis() - t0;
pool.shutdown();
if (!pool.awaitTermination(5, TimeUnit.SECONDS)) {
pool.shutdownNow();
}
return new Resultado("B. Pool de 8 hilos (send bloqueante)", ms,
correctas.get(), fallidas.get(),
(double) sumaLatencias.sum() / peticiones, vigilante.parar());
}
// =================================================================
// C: asincrona sin limite
// =================================================================
public Resultado asincronaSinLimite() {
VigilanteHilos vigilante = new VigilanteHilos();
vigilante.arrancar();
AtomicInteger correctas = new AtomicInteger();
AtomicInteger fallidas = new AtomicInteger();
LongAdder sumaLatencias = new LongAdder();
long t0 = System.currentTimeMillis();
List<CompletableFuture<Void>> futuros = new ArrayList<>(peticiones);
for (int i = 0; i < peticiones; i++) {
long p0 = System.nanoTime();
futuros.add(cliente.sendAsync(peticionDe(i), BodyHandlers.discarding())
.thenAccept(r -> {
sumaLatencias.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctas.incrementAndGet();
} else {
fallidas.incrementAndGet();
}
})
// Blindaje individual: sin el, un fallo tumba el allOf.
.exceptionally(e -> {
fallidas.incrementAndGet();
return null;
}));
}
CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
return new Resultado("C. Asincrona sin limite (sendAsync + allOf)", ms,
correctas.get(), fallidas.get(),
(double) sumaLatencias.sum() / peticiones, vigilante.parar());
}
// =================================================================
// D: asincrona acotada con Semaphore
// =================================================================
public Resultado asincronaAcotada(int simultaneas) {
VigilanteHilos vigilante = new VigilanteHilos();
vigilante.arrancar();
AtomicInteger correctas = new AtomicInteger();
AtomicInteger fallidas = new AtomicInteger();
LongAdder sumaLatencias = new LongAdder();
Semaphore permisos = new Semaphore(simultaneas);
long t0 = System.currentTimeMillis();
List<CompletableFuture<Void>> futuros = new ArrayList<>(peticiones);
for (int i = 0; i < peticiones; i++) {
try {
// El semaforo limita cuantas peticiones hay EN VUELO,
// no cuantos hilos hay. Es la diferencia con la estrategia B.
permisos.acquire();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
break;
}
long p0 = System.nanoTime();
futuros.add(cliente.sendAsync(peticionDe(i), BodyHandlers.discarding())
.thenAccept(r -> {
permisos.release();
sumaLatencias.add((System.nanoTime() - p0) / 1_000_000);
if (r.statusCode() == 200) {
correctas.incrementAndGet();
} else {
fallidas.incrementAndGet();
}
})
.exceptionally(e -> {
permisos.release(); // TAMBIEN al fallar, o se fuga
fallidas.incrementAndGet();
return null;
}));
}
CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0])).join();
long ms = System.currentTimeMillis() - t0;
return new Resultado("D. Asincrona acotada a " + simultaneas, ms,
correctas.get(), fallidas.get(),
(double) sumaLatencias.sum() / peticiones, vigilante.parar());
}
// =================================================================
// Ejecucion
// =================================================================
public void comparar() throws InterruptedException {
// CALENTAMIENTO: la primera ronda carga clases, compila con el JIT
// y establece las primeras conexiones. Sin descartarla, la primera
// estrategia medida saldria injustamente penalizada.
System.out.println("Calentando...");
for (int i = 0; i < 5; i++) {
try {
cliente.send(peticionDe(i), BodyHandlers.discarding());
} catch (Exception ignorada) {
// El calentamiento no tiene que salir bien.
}
}
System.out.println("Midiendo " + peticiones + " peticiones por estrategia...\n");
List<Resultado> resultados = new ArrayList<>();
resultados.add(secuencial());
resultados.add(poolBloqueante());
resultados.add(asincronaSinLimite());
resultados.add(asincronaAcotada(8));
long referencia = resultados.get(0).ms();
System.out.printf("%-42s %9s %8s %10s %9s %8s%n",
"ESTRATEGIA", "TIEMPO", "PET/S", "LAT.MEDIA", "HILOS", "MEJORA");
System.out.println("-".repeat(95));
for (Resultado r : resultados) {
System.out.printf("%-42s %8d ms %8.0f %8.1f ms %9d %7.1fx%n",
r.estrategia(), r.ms(),
r.ms() == 0 ? 0 : peticiones * 1000.0 / r.ms(),
r.mediaLatenciaMs(), r.hilosMaximos(),
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 ComparadorEstrategias(base, 50).comparar();
}
}Salida contra un servicio que tarda 200 ms:
Calentando...
Midiendo 50 peticiones por estrategia...
ESTRATEGIA TIEMPO PET/S LAT.MEDIA HILOS MEJORA
-----------------------------------------------------------------------------------------------
A. Secuencial (send en bucle) 10214 ms 5 204.1 ms 9 1.0x
B. Pool de 8 hilos (send bloqueante) 1428 ms 35 221.6 ms 18 7.2x
C. Asincrona sin limite (sendAsync + allOf) 287 ms 174 263.4 ms 14 35.6x
D. Asincrona acotada a 8 1391 ms 36 215.2 ms 12 7.3xComentarios. Los números cuentan cuatro historias.
A es el suelo. 50 peticiones × 200 ms = 10 segundos exactos. Cinco peticiones por segundo, con la CPU parada el 99,9 % del tiempo. Es el sincronizador de 09-05.
B y D dan tiempos casi idénticos (1428 frente a 1391 ms) porque ambas limitan a 8 peticiones simultáneas: 50/8 = 7 tandas × 200 ms ≈ 1,4 s. Pero consumen recursos muy distintos, y esa es la respuesta a lo que pedía el enunciado. En B, ocho hilos de plataforma están bloqueados esperando la red, cada uno con su pila de hasta un megabyte, sin hacer absolutamente nada. En D, el semáforo limita las peticiones en vuelo, no los hilos: los hilos del cliente HTTP quedan libres para procesar respuestas de otras peticiones. Con ocho peticiones el ahorro es anecdótico; con quinientas, B necesitaría quinientos hilos —medio gigabyte de pilas— y D seguiría usando un puñado.
C es la más rápida (287 ms, 35 veces mejor que A) porque lanza las cincuenta a la vez. Y por eso mismo es la más peligrosa: cincuenta peticiones simultáneas contra un servicio real se ganan un 429 o un bloqueo de IP, y si el servicio es interno, puedes tumbarlo tú. Fíjate además en que su latencia media es la más alta (263 ms frente a 204 de A): las peticiones se estorban entre sí porque el servidor tiene que atender cincuenta a la vez. Va más rápido en total pero cada petición individual va peor.
La conclusión es D, aunque no sea la más rápida en el papel. Es rápida, acotada, respetuosa con el servicio y sostenible con miles de peticiones sin fugar hilos. En sistemas reales, la estrategia correcta casi nunca es la más rápida en un microbanco de pruebas: es la que sigue funcionando cuando la carga se multiplica por diez.
Solución 3
package com.nexussoftware.bibliotech.red;
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;
/**
* Panel de estado de los servicios de los que depende BiblioTech.
* Comprueba todos en paralelo cada N segundos y muestra un panel en consola.
*/
public class PanelEstadoServicios implements AutoCloseable {
private static final Logger LOG =
Logger.getLogger(PanelEstadoServicios.class.getName());
private static final int HISTORIAL = 20;
private static final int LIMITE_MS = 3_000;
/** Un servicio a vigilar. */
public record Servicio(String nombre, String url, int codigoEsperado) {
}
public enum Estado {
ARRIBA('#'), DEGRADADO('-'), ABAJO('.');
final char simbolo;
Estado(char simbolo) {
this.simbolo = simbolo;
}
}
/** Resultado de una comprobacion. */
public record Comprobacion(Estado estado, int codigo, long latenciaMs,
String version, String detalle) {
}
private final List<Servicio> servicios;
private final HttpClient cliente;
private final ScheduledExecutorService planificador;
/**
* Historial por servicio. ConcurrentHashMap porque lo escribe el hilo
* del planificador y lo leen las etapas asincronas (08-06).
*/
private final Map<String, Deque<Comprobacion>> historial = new ConcurrentHashMap<>();
/** Ultimo estado conocido, para no repetir la alerta en cada ciclo. */
private final Map<String, Estado> ultimoEstado = new ConcurrentHashMap<>();
private final AtomicInteger ciclos = new AtomicInteger();
public PanelEstadoServicios(List<Servicio> servicios) {
this.servicios = servicios;
AtomicInteger n = new AtomicInteger(1);
this.cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(2))
.followRedirects(HttpClient.Redirect.NORMAL)
.executor(Executors.newFixedThreadPool(servicios.size(), r -> {
Thread h = new Thread(r, "panel-http-" + n.getAndIncrement());
h.setDaemon(true);
return h;
}))
.build();
this.planificador = Executors.newSingleThreadScheduledExecutor(r -> {
Thread h = new Thread(r, "panel-planificador");
h.setDaemon(true);
return h;
});
for (Servicio s : servicios) {
historial.put(s.nombre(), new ArrayDeque<>(HISTORIAL));
ultimoEstado.put(s.nombre(), Estado.ARRIBA);
}
}
// =================================================================
// Arranque
// =================================================================
public void arrancar(int intervaloSegundos) {
// scheduleAtFixedRate de 08-05.
planificador.scheduleAtFixedRate(this::cicloSeguro,
0, intervaloSegundos, TimeUnit.SECONDS);
LOG.info("Panel de estado en marcha (cada " + intervaloSegundos + " s)");
}
/**
* Envoltorio de seguridad OBLIGATORIO.
*
* Si una excepcion escapa de una tarea de scheduleAtFixedRate, la tarea
* SE CANCELA EN SILENCIO y el panel deja de actualizarse sin que nadie
* se entere: ni excepcion, ni log, ni nada. Es la trampa de 08-05.
*/
private void cicloSeguro() {
try {
ciclo();
} catch (RuntimeException e) {
LOG.log(Level.SEVERE, "Fallo en el ciclo del panel", e);
}
}
// =================================================================
// Un ciclo de comprobacion
// =================================================================
private void ciclo() {
long t0 = System.currentTimeMillis();
// 1. Lanzar TODAS las comprobaciones en paralelo.
List<CompletableFuture<Void>> futuros = new ArrayList<>(servicios.size());
for (Servicio s : servicios) {
futuros.add(comprobar(s));
}
// 2. Esperar a todas. join() aqui bloquea el hilo del planificador,
// que es lo correcto: no queremos pintar el panel a medias ni
// solapar dos ciclos.
CompletableFuture.allOf(futuros.toArray(new CompletableFuture[0])).join();
ciclos.incrementAndGet();
pintar(System.currentTimeMillis() - t0);
}
private CompletableFuture<Void> comprobar(Servicio servicio) {
HttpRequest peticion = HttpRequest.newBuilder()
.uri(URI.create(servicio.url()))
.header("User-Agent", "BiblioTech-Panel/1.0")
.header("Accept", "*/*")
.timeout(Duration.ofMillis(LIMITE_MS)) // limite TOTAL
.GET()
.build();
long inicio = System.nanoTime();
// discarding(): solo interesa el codigo, no el cuerpo. Pero lo
// CONSUME, que es lo que permite reutilizar la conexion.
return cliente.sendAsync(peticion, BodyHandlers.discarding())
.thenAccept(respuesta -> {
long ms = (System.nanoTime() - inicio) / 1_000_000;
int codigo = respuesta.statusCode();
Estado estado = codigo == servicio.codigoEsperado()
? Estado.ARRIBA
: Estado.DEGRADADO;
registrar(servicio, new Comprobacion(estado, codigo, ms,
respuesta.version().toString(),
estado == Estado.DEGRADADO
? "esperabamos " + servicio.codigoEsperado() : ""));
})
// Blindaje individual: un servicio caido no puede impedir
// que se comprueben los demas ni que se pinte el panel.
.exceptionally(e -> {
long ms = (System.nanoTime() - inicio) / 1_000_000;
Throwable causa = e.getCause() != null ? e.getCause() : e;
registrar(servicio, new Comprobacion(Estado.ABAJO, -1, ms,
"-", causa.getClass().getSimpleName()));
return null;
});
}
private void registrar(Servicio servicio, Comprobacion c) {
Deque<Comprobacion> cola = historial.get(servicio.nombre());
synchronized (cola) { // ArrayDeque no es segura para varios hilos
if (cola.size() >= HISTORIAL) {
cola.removeFirst();
}
cola.addLast(c);
}
// Alerta SOLO en el cambio de estado, no en cada ciclo: un
// servicio caido durante una hora generaria 360 alertas identicas.
Estado anterior = ultimoEstado.put(servicio.nombre(), c.estado());
if (anterior != c.estado()) {
if (c.estado() == Estado.ABAJO) {
LOG.severe("ALERTA: " + servicio.nombre() + " ha CAIDO ("
+ c.detalle() + ")");
} else if (anterior == Estado.ABAJO) {
LOG.info("RECUPERADO: " + servicio.nombre() + " vuelve a responder");
} else {
LOG.warning("CAMBIO: " + servicio.nombre() + " " + anterior
+ " -> " + c.estado());
}
}
}
// =================================================================
// Pintado
// =================================================================
private void pintar(long msCiclo) {
StringBuilder sb = new StringBuilder();
sb.append("\033[H\033[2J"); // limpiar pantalla
sb.append("=== PANEL DE ESTADO - BIBLIOTECH ===\n");
sb.append(String.format("Ciclo %d comprobacion en %d ms%n%n",
ciclos.get(), msCiclo));
sb.append(String.format("%-22s %-11s %7s %6s %-9s %-22s %7s%n",
"SERVICIO", "ESTADO", "LATENCIA", "CODIGO", "VERSION",
"HISTORIAL", "DISPON."));
sb.append("-".repeat(96)).append('\n');
for (Servicio s : servicios) {
Deque<Comprobacion> cola = historial.get(s.nombre());
List<Comprobacion> copia;
synchronized (cola) {
copia = new ArrayList<>(cola);
}
if (copia.isEmpty()) {
continue;
}
Comprobacion ultima = copia.get(copia.size() - 1);
// Barra de historial y calculo de disponibilidad.
StringBuilder barra = new StringBuilder();
int arriba = 0;
for (Comprobacion c : copia) {
barra.append(c.estado().simbolo);
if (c.estado() == Estado.ARRIBA) {
arriba++;
}
}
double disponibilidad = 100.0 * arriba / copia.size();
sb.append(String.format("%-22s %-11s %6d ms %6s %-9s %-22s %6.1f%%%n",
s.nombre(),
ultima.estado(),
ultima.latenciaMs(),
ultima.codigo() < 0 ? "-" : String.valueOf(ultima.codigo()),
ultima.version().replace("HTTP_", "HTTP/"),
barra,
disponibilidad));
if (!ultima.detalle().isEmpty()) {
sb.append(String.format(" %-20s %s%n", "", ultima.detalle()));
}
}
sb.append("-".repeat(96)).append('\n');
sb.append("Leyenda: # arriba - degradado . abajo\n");
System.out.print(sb);
}
@Override
public void close() {
// Apagado en dos 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("Panel de estado detenido tras " + ciclos.get() + " ciclos");
}
// =================================================================
// Arranque
// =================================================================
public static void main(String[] args) throws InterruptedException {
List<Servicio> servicios = List.of(
new Servicio("catalogo-btcp", "http://localhost:9092/", 200),
new Servicio("metadatos", "http://localhost:8080/salud", 200),
new Servicio("portadas", "http://localhost:8081/salud", 200),
new Servicio("avisos", "http://localhost:8082/salud", 200),
new Servicio("inexistente", "http://localhost:9999/salud", 200));
PanelEstadoServicios panel = new PanelEstadoServicios(servicios);
// Shutdown hook: Ctrl+C, SIGTERM de Docker o systemd (09-03).
Runtime.getRuntime().addShutdownHook(
new Thread(panel::close, "panel-apagado"));
panel.arrancar(10);
// El planificador usa hilos daemon: hay que mantener vivo el main.
Thread.currentThread().join();
}
}Salida:
=== PANEL DE ESTADO - BIBLIOTECH ===
Ciclo 14 comprobacion en 3012 ms
SERVICIO ESTADO LATENCIA CODIGO VERSION HISTORIAL DISPON.
------------------------------------------------------------------------------------------------
catalogo-btcp ARRIBA 4 ms 200 HTTP/1_1 ############## 100.0%
metadatos ARRIBA 38 ms 200 HTTP/2 #############- 92.9%
esperabamos 200
portadas ARRIBA 21 ms 200 HTTP/2 ############## 100.0%
avisos DEGRADADO 104 ms 503 HTTP/1_1 ###########--- 78.6%
esperabamos 200
inexistente ABAJO 2001 ms - - .............. 0.0%
ConnectException
------------------------------------------------------------------------------------------------
Leyenda: # arriba - degradado . abajoComentarios. Cuatro puntos que este ejercicio deja claros.
Los tres estados no son un adorno. DEGRADADO —responde, pero con un código inesperado— es información distinta de ABAJO —no responde en absoluto—. El servicio de avisos devolviendo 503 está vivo, arrancado, alcanzable y sobrecargado; el inexistente ni siquiera tiene a nadie escuchando. Confundirlos manda al equipo de sistemas a investigar el problema equivocado, y es la misma distinción entre ConnectException y SocketTimeoutException que aprendiste en 09-02.
El cicloSeguro es obligatorio, no defensivo. Si una excepción escapa de una tarea de scheduleAtFixedRate, la tarea se cancela en silencio: el panel deja de actualizarse, no hay excepción, no hay log, y nadie se entera hasta que alguien pregunta por qué los datos son de hace tres horas. Es la trampa de 08-05, y en un panel de monitorización sería especialmente irónico.
La alerta solo en el cambio de estado es lo que distingue una herramienta útil de una que se ignora. Un servicio caído durante una hora, comprobado cada diez segundos, generaría 360 líneas SEVERE idénticas. Con el ultimoEstado se registra una al caer y otra al recuperarse. La fatiga de alertas es un problema real: cuando todo alerta, nada alerta.
Y fíjate en la duración del ciclo: 3012 ms, exactamente el .timeout() de 3 segundos. Cuatro servicios responden en decenas de milisegundos y el quinto agota su plazo; como se comprueban en paralelo, el ciclo dura lo que el más lento y no la suma. Secuencialmente serían 3,2 segundos también, pero con diez servicios caídos serían treinta segundos en lugar de tres. Ese es el allOf haciendo lo que se le pide.
Conclusión
Has cerrado el módulo 9, y con él BiblioTech ha dejado de estar sola.
Conoces la API moderna y su diseño: tres piezas inmutables —HttpClient (quién), HttpRequest (qué) y HttpResponse<T> (qué se recibió)— construidas con constructores fluidos, seguras para varios hilos y sin configuración por efectos secundarios. Se acabó el objeto mutable con estados que cambiaba el método al activar la salida.
Sabes crear el cliente con lo que importa —connectTimeout, followRedirects (que por defecto es NEVER, al contrario que en la API antigua) y un executor propio con hilos nombrados— y sobre todo sabes la regla que más rendimiento decide: se crea uno y se reutiliza, porque guarda el pool de conexiones, las sesiones TLS y las conexiones HTTP/2 multiplexadas. Crear uno por petición multiplica por cuatro la latencia contra un servicio remoto y fuga hilos hasta tumbar la aplicación.
Construyes peticiones con uri, header, los métodos y —la mejora que no existía— timeout(), el límite total de la petición, que es lo único que protege de un servidor que envía un byte cada nueve segundos y mantiene viva indefinidamente una lectura con límite por operación. Manejas los BodyPublishers para enviar —con ofFile transmitiendo sin cargar en memoria— y los BodyHandlers para recibir, que determinan el tipo T de la respuesta: ofString con charset, ofFile que descarga a disco de una llamada, ofInputStream, discarding. Y sabes leer HttpResponse<T>: statusCode, body, headers con métodos decentes, y uri() con la URI final tras las redirecciones.
Y sobre todo: sendAsync devuelve un CompletableFuture<HttpResponse<String>>, y con eso todo 08-07 se aplica sin adaptaciones. thenApply para transformar, thenCompose cuando la función devuelve otro futuro —con el CompletableFuture<CompletableFuture<T>> que aparece al equivocarse—, thenCombine para juntar dos independientes en paralelo, orTimeout para la cadena completa, handle cuando hace falta ver resultado y error a la vez, y allOf para N peticiones simultáneas, con las tres reglas del patrón: lanzar todas antes de esperar ninguna, blindar cada futuro con su propio exceptionally antes de agregarlo —sin eso, un único fallo tumba las N respuestas—, y join() después del allOf, donde ya es seguro.
Tienes clarísima la distinción que más bugs causa: hay excepción cuando no se pudo obtener una respuesta HTTP; si hay respuesta, hubo éxito de red aunque el código sea 500. Un 404 o un 503 no lanzan nada y body() contendrá la página de error del servidor. Comprobar statusCode() no es opcional.
Sabes qué gana la API moderna sobre la antigua —verbosidad, inmutabilidad, seguridad entre hilos, límite total, asincronía, HTTP/2 con multiplexación que hace que veinte peticiones al mismo host usen una sola conexión, WebSocket, y un solo flujo de cuerpo en lugar de la distinción entre normal y de error—, y qué buenas prácticas gobiernan las llamadas a servicios que no controlas: tiempos límite siempre, reintento solo de lo transitorio con espera creciente y aleatorizada, nunca reintentar un POST no idempotente salvo con clave de idempotencia, cortacircuitos cuando un servicio lleva veinte fallos seguidos, y nunca registrar tokens ni credenciales —tampoco una URI con el token en la consulta—, retomando 06-07. Con TLS y validación de certificados remitiendo a 12-07.
Y has visto, señalado sin disimulo, el apaño del JSON: extraer campos con indexOf funciona con respuestas planas y se rompe con escapes, anidamiento o arrays. Hacerlo bien es Jackson, y eso es 11-07, donde una línea sustituye a cincuenta y devuelve el record ya construido.
BiblioTech, al cerrar el módulo 9, ha salido de su máquina.
Su servidor de catálogo habla BTCP/1 en el puerto 9090 y atiende a Marta, Diego y Nuria a la vez con un pool acotado de hilos nombrados, validando por lista blanca todo lo que llega, leyendo líneas acotadas para que nadie le agote la memoria, expulsando por inactividad a los clientes que se callan, rechazando con cortesía cuando se satura y apagándose en dos fases con un shutdown hook. Su cliente conecta con tiempo límite, verifica el saludo y la versión antes de hablar, valida sus propios argumentos contra la inyección de saltos de línea y se despide educadamente. Su descubrimiento por UDP hace que los puestos de trabajo encuentren el servidor gritando a la red local, y la configuración manual de la IP en cada puesto ha desaparecido. Su telemetría envía métricas cada pocos segundos sin bloquear jamás y sin que le importe que el recolector esté apagado. Y su enriquecedor asíncrono consulta veinte ISBN y descarga sus portadas en 1,24 segundos —62 ms por material frente a más de un segundo en la versión secuencial— acotando el paralelismo con un semáforo, sin bloquear un solo hilo, y con una petición que agotó su plazo sin llevarse por delante las otras diecinueve.
De la carencia que declaraste al cerrar el módulo 8 no queda nada: BiblioTech ya no es un programa encerrado en un ordenador. Se consulta desde cualquier puesto de Nexus Software y habla con servicios externos.
Pero el código empieza a repetirse de una forma que ya no puedes ignorar. ClienteCatalogo, ClienteMetadatos y EnriquecedorCatalogo repiten la misma estructura de petición, comprobación de código, análisis y traducción de errores, cambiando solo el tipo del resultado — y no tienes forma de escribir "esto es un cliente de algo que devuelve cosas de tipo T" sin duplicar la clase entera. Cada vez que necesitas saber si una clase tiene cierto campo, o marcar un método como "no probar en producción", acabas escribiendo una convención de nombres que nadie comprueba. Tus recorridos de colecciones siguen siendo bucles verbosos con acumuladores y banderas, cuando lo que quieres decir es "de estos materiales, los prestados, ordenados por título" — y has tenido que esquivar stream() en todo el módulo. Tus null siguen significando "no encontrado" y siguen produciendo NullPointerException cuando alguien olvida comprobarlos. Las fechas de BiblioTech siguen siendo int de días, un apaño que arrastras desde el módulo 3 y que hace imposible responder a "¿cuántos días de retraso lleva este préstamo?" sin aritmética manual y propensa a errores. Y todo tu código se ejecuta sobre una JVM cuyo comportamiento —cómo reserva memoria, cuándo la libera, qué hace el compilador JIT con tus bucles, por qué la primera petición siempre es la más lenta— sigue siendo una caja negra que solo has observado de refilón en las mediciones.
En el módulo 10, Temas Avanzados, abres esa caja. Verás los genéricos para escribir código que funciona con cualquier tipo sin renunciar a la comprobación del compilador —y entenderás por fin qué significan exactamente el <T> de HttpResponse<T> y el <String> que llevas usando todo este módulo—; las anotaciones para añadir información al código que otras herramientas puedan leer, y la reflexión para inspeccionar y manipular clases en tiempo de ejecución, que es la magia sobre la que están construidos Spring, Hibernate y JUnit. Verás Java 8: la API de Streams, que convierte tus bucles anidados en una descripción declarativa de lo que quieres, y Optional, que hace imposible olvidarse de comprobar la ausencia. Verás java.time, y las fechas de BiblioTech dejarán por fin de ser enteros. Verás Java 9 y más allá: el sistema de módulos, sealed, el pattern matching que simplifica las jerarquías, y los hilos virtuales, que —como ya anticipaste en 09-03— cambian por completo el cálculo de "un hilo por conexión". Y terminarás con memoria, recolección de basura y rendimiento, donde la JVM dejará de ser una caja negra y entenderás por qué tu código va a la velocidad a la que va.
Curso de Programación en Java
Módulo 1: Introducción a Java
- Introducción a Java
- Configuración del Entorno de Desarrollo
- Sintaxis y Estructura Básica
- Variables y Tipos de Datos
- Operadores
- Entrada y Salida por Consola
- Tu Primer Programa Completo: BiblioTech
Módulo 2: Flujo de Control
- Sentencias Condicionales
- Bucles
- Sentencias Switch
- Break y Continue
- Depuración y Trazas de Ejecución
- Proyecto: Menú Interactivo de BiblioTech
Módulo 3: Programación Orientada a Objetos
- Introducción a la POO
- Clases y Objetos
- Métodos
- Constructores
- Herencia
- Polimorfismo
- Encapsulamiento
- Abstracción
- La Clase Object: equals, hashCode y toString
Módulo 4: Programación Orientada a Objetos Avanzada
- Interfaces
- Clases Abstractas
- Clases Internas
- Clases Anónimas
- Expresiones Lambda
- Interfaces Funcionales y Referencias a Métodos
- Enumeraciones y Registros
Módulo 5: Estructuras de Datos y Colecciones
- Arreglos
- El Framework de Colecciones
- ArrayList
- LinkedList
- HashMap
- HashSet
- Cola y Deque
- Pila
- Ordenación y Búsqueda en Colecciones
Módulo 6: Manejo de Excepciones
- Introducción a las Excepciones
- Bloque Try-Catch
- Throw y Throws
- Excepciones Personalizadas
- Bloque Finally
- Try-with-resources y AutoCloseable
- Estrategias de Manejo de Errores y Logging
Módulo 7: Entrada/Salida de Archivos
- Lectura de Archivos
- Escritura de Archivos
- Flujos de Archivos
- BufferedReader y BufferedWriter
- Serialización
- La API NIO.2: Path y Files
- Formatos de Intercambio: CSV y Properties
Módulo 8: Multihilo y Concurrencia
- Introducción al Multihilo
- Creación de Hilos
- Ciclo de Vida de un Hilo
- Sincronización
- Utilidades de Concurrencia
- Colecciones Concurrentes y Variables Atómicas
- Tareas Asíncronas con CompletableFuture
Módulo 9: Redes
- Introducción a las Redes
- Sockets
- ServerSocket
- DatagramSocket y DatagramPacket
- URL y HttpURLConnection
- El Cliente HTTP Moderno
Módulo 10: Temas Avanzados
- Genéricos
- Anotaciones
- Reflexión
- Características de Java 8: Streams y Optional
- Fechas y Horas con java.time
- Java 9 y Más Allá
- Memoria, Recolección de Basura y Rendimiento
Módulo 11: Frameworks y Librerías de Java
- Introducción a los Frameworks de Java
- Spring Framework
- Hibernate
- JUnit
- Maven
- Pruebas Avanzadas con Mockito
- Librerías Esenciales del Ecosistema
