Módulo 09: Programación en red
Objetivos del módulo
- Comprender el modelo TCP/IP, las direcciones IP, los puertos, el DNS y la diferencia entre TCP y UDP
- Trabajar con direcciones (
InetAddress) y URI (URI, codificación de parámetros) - Programar un servidor (
ServerSocket) y un cliente (Socket) TCP con un protocolo de texto propio - Convertir el servidor en multihilo con hilos virtuales y medir la diferencia
- Construir un chat sencillo con difusión de mensajes a todos los clientes
- Enviar y recibir datagramas UDP con
DatagramSocket - Conocer el protocolo HTTP: peticiones, respuestas, métodos, códigos de estado y cabeceras
- Usar
java.net.http.HttpClient: GET, POST con JSON, cabeceras, timeouts y peticiones asíncronas - Consumir una API REST pública real y mapear sus respuestas con Jackson
- Crear una mini-API REST con el servidor HTTP del JDK y servir ficheros con
jwebserver - Conocer los fundamentos de seguridad: HTTPS, TLS y certificados
1. Conceptos de redes
El modelo TCP/IP
Para que dos programas se comuniquen a través de una red intervienen varias capas, cada una con una responsabilidad. Es como enviar un paquete por mensajería: tú escribes la carta (aplicación), la metes en un sobre con nombre y piso (transporte), la empresa le pone la dirección postal (red) y el camión la lleva por la carretera (acceso a la red). Cada capa solo habla con la de arriba y la de abajo.
Figura 1. El modelo TCP/IP: capas, encapsulación y clases de Java que trabajan en cada una.
| Capa | Función | Ejemplos | En Java |
|---|---|---|---|
| 4. Aplicación | El “idioma” de los programas | HTTP, HTTPS, DNS, SMTP, SSH, tu propio protocolo | HttpClient, HttpServer, URI |
| 3. Transporte | Comunicación entre procesos, identificados por puertos | TCP, UDP | Socket, ServerSocket, DatagramSocket |
| 2. Internet | Llevar paquetes de un equipo a otro a través de redes | IP (IPv4, IPv6), ICMP | InetAddress |
| 1. Acceso a la red | Transmisión física en una red local | Ethernet, Wi-Fi | (la gestiona el sistema operativo) |
Direcciones IP, puertos y DNS
- Dirección IP: identifica un equipo en la red. IPv4 usa 4 bytes (
188.114.96.5); IPv6, 16 bytes (2001:db8::1). Hay rangos especiales:127.0.0.1(o::1) es el propio equipo (loopback,localhost);10.x.x.x,172.16-31.x.xy192.168.x.xson privadas (redes locales, no accesibles desde Internet). - Puerto: número de 0 a 65 535 que identifica un proceso dentro del equipo. Los menores de 1024 son “bien conocidos” (80 HTTP, 443 HTTPS, 22 SSH, 25 SMTP, 53 DNS) y en Linux requieren privilegios; otros habituales: 3306 MySQL, 5432 PostgreSQL, 8080 servidores de desarrollo.
- DNS (Domain Name System): traduce nombres (
jsonplaceholder.typicode.com) en direcciones IP, como una agenda telefónica de Internet. - Una conexión TCP queda identificada por la combinación (IP origen, puerto origen, IP destino, puerto destino). Por eso un servidor en el puerto 80 puede atender a miles de clientes a la vez: cada uno llega desde una IP o un puerto origen distinto.
TCP frente a UDP
Figura 2. TCP es una conversación fiable; UDP, un envío de postales sin garantías.
| TCP | UDP | |
|---|---|---|
| Conexión | Sí: saludo inicial y cierre ordenado | No |
| Fiabilidad | Garantiza entrega, orden y ausencia de duplicados (reenvía lo perdido) | Ninguna garantía |
| Unidad | Flujo continuo de bytes | Datagramas independientes (máx. ~64 KB) |
| Latencia y sobrecarga | Mayor | Mínima |
| Usos | Web, correo, bases de datos, transferencia de ficheros, SSH | DNS, streaming, videojuegos, VoIP, HTTP/3 (sobre QUIC) |
| Clases Java | Socket, ServerSocket | DatagramSocket, DatagramPacket |
2. Direcciones: InetAddress y URI
import java.net.InetAddress;
import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.net.URLEncoder;
import java.net.UnknownHostException;
import java.nio.charset.StandardCharsets;
import java.util.Arrays;
public class Direcciones {
public static void main(String[] args) throws Exception {
// --- InetAddress: direcciones IP y DNS ---
InetAddress local = InetAddress.getLocalHost();
System.out.println("Este equipo: " + local.getHostName() + " -> " + local.getHostAddress());
InetAddress loopback = InetAddress.getLoopbackAddress();
System.out.println("Loopback: " + loopback + ", ¿es loopback? " + loopback.isLoopbackAddress());
InetAddress[] todas = InetAddress.getAllByName("jsonplaceholder.typicode.com"); // consulta DNS
System.out.println("DNS de jsonplaceholder.typicode.com: "
+ Arrays.stream(todas).map(InetAddress::getHostAddress).sorted().toList());
InetAddress ip = InetAddress.getByName("8.8.8.8"); // no consulta DNS
System.out.println("8.8.8.8 es IPv4: " + (ip.getAddress().length == 4)
+ ", ¿privada? " + ip.isSiteLocalAddress()
+ "; 192.168.1.10 ¿privada? " + InetAddress.getByName("192.168.1.10").isSiteLocalAddress());
try {
InetAddress.getByName("no-existe.ejemplo.invalid");
} catch (UnknownHostException e) {
System.out.println("UnknownHostException: " + e.getMessage());
}
System.out.println("¿Responde jsonplaceholder (isReachable)? " + todas[0].isReachable(2000)
+ " (depende de la red: muchos servidores y cortafuegos bloquean el ping)");
// --- URI: componentes de una dirección ---
URI uri = new URI("https://ana:secreto@tienda.ejemplo.es:8443/api/productos/7?campos=nombre,precio&moneda=EUR#detalle");
System.out.println("esquema=" + uri.getScheme() + " | usuario=" + uri.getUserInfo() + " | host=" + uri.getHost()
+ " | puerto=" + uri.getPort());
System.out.println("ruta=" + uri.getPath() + " | consulta=" + uri.getQuery() + " | fragmento=" + uri.getFragment());
URI base = URI.create("https://api.ejemplo.es/v1/clientes/");
System.out.println("resolve(\"42/pedidos\") = " + base.resolve("42/pedidos"));
System.out.println("resolve(\"../productos\") = " + base.resolve("../productos"));
System.out.println("Puerto por defecto si no se indica: " + URI.create("http://ejemplo.es/").getPort());
try {
new URI("http://ejemplo.es/buscar?q=café con leche");
} catch (URISyntaxException e) {
System.out.println("URISyntaxException: " + e.getMessage());
}
// --- Codificar parámetros de consulta ---
String q = URLEncoder.encode("café con leche & tostada", StandardCharsets.UTF_8);
System.out.println("Codificado: " + q + " -> decodificado: " + URLDecoder.decode(q, StandardCharsets.UTF_8));
URI buena = URI.create("https://ejemplo.es/buscar?q=" + q);
System.out.println("URI válida: " + buena + " | toURL(): " + buena.toURL().getProtocol());
}
}
docker run --rm -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java Direcciones.java
Este equipo: 25d6131f7c90 -> 172.31.0.4
Loopback: localhost/127.0.0.1, ¿es loopback? true
DNS de jsonplaceholder.typicode.com: [188.114.96.5, 188.114.97.5]
8.8.8.8 es IPv4: true, ¿privada? false; 192.168.1.10 ¿privada? true
UnknownHostException: no-existe.ejemplo.invalid: Name or service not known
¿Responde jsonplaceholder (isReachable)? true (depende de la red: muchos servidores y cortafuegos bloquean el ping)
esquema=https | usuario=ana:secreto | host=tienda.ejemplo.es | puerto=8443
ruta=/api/productos/7 | consulta=campos=nombre,precio&moneda=EUR | fragmento=detalle
resolve("42/pedidos") = https://api.ejemplo.es/v1/clientes/42/pedidos
resolve("../productos") = https://api.ejemplo.es/v1/productos
Puerto por defecto si no se indica: -1
URISyntaxException: Illegal character in query at index 31: http://ejemplo.es/buscar?q=café con leche
Codificado: caf%C3%A9+con+leche+%26+tostada -> decodificado: café con leche & tostada
URI válida: https://ejemplo.es/buscar?q=caf%C3%A9+con+leche+%26+tostada | toURL(): https
El “equipo” es el contenedor Docker, que tiene su propio nombre y una IP privada de la red curso-java. Las IP de un dominio pueden ser varias y cambiar con el tiempo (aquí son servidores de Cloudflare).
| Parte de la URI | Ejemplo | Método |
|---|---|---|
| Esquema | https | getScheme() |
| Información de usuario | ana:secreto (¡no pongas contraseñas en URL!) | getUserInfo() |
| Host | tienda.ejemplo.es | getHost() |
| Puerto | 8443 (-1 si no se indica: se usa el del esquema) | getPort() |
| Ruta | /api/productos/7 | getPath() |
| Consulta | campos=nombre,precio&moneda=EUR | getQuery() |
| Fragmento | detalle (no se envía al servidor) | getFragment() |
💡 Consejo: Usa
URI(no la antigua claseURL, cuyos constructores están obsoletos desde Java 20): valida la sintaxis sin conectarse a nada y suequalsno hace consultas DNS. Codifica siempre conURLEncoder.encode(valor, UTF_8)los valores que insertes en una consulta: los espacios, las tildes y los caracteres&,=,?romperían la URL.
3. Sockets TCP: cliente y servidor
📘 Concepto: Un socket es el extremo de una conexión de red, identificado por una IP y un puerto. En TCP hay dos clases: el
ServerSocketdel servidor, que escucha en un puerto y acepta conexiones, y elSocket, que representa una conexión ya establecida y ofrece un flujo de entrada y otro de salida, exactamente como los flujos del módulo 08.
Diseñar un protocolo
Antes de programar hay que decidir el protocolo: qué mensajes se intercambian, en qué orden y con qué formato. El nuestro, al que llamaremos PCT/1, es de texto y orientado a líneas (como SMTP o el propio HTTP/1.1):
| Paso | Quién | Mensaje |
|---|---|---|
| 1 | Servidor | Saludo BIENVENIDO PCT/1 |
| 2 | Cliente | Una orden por línea: HORA, ECO texto, SUMA a b c..., SALIR |
| 3 | Servidor | Una línea de respuesta: OK ... o ERROR motivo |
| 4 | Ambos | Tras SALIR (o si el cliente cierra), se cierra la conexión |
Figura 3. Secuencia de una sesión cliente-servidor con el protocolo PCT/1.
El servidor
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.time.LocalTime;
import java.time.format.DateTimeFormatter;
/**
* Servidor TCP de un protocolo de texto propio ("PCT/1"), una línea por orden:
* HORA -> OK 10:15:30
* ECO texto -> OK texto
* SUMA a b ... -> OK resultado
* SALIR -> OK adiós (y cierra la conexión)
* cualquier otra -> ERROR orden desconocida
* Versión SECUENCIAL: atiende a los clientes de uno en uno.
*/
public class ServidorEco {
static final int PUERTO = 5000;
static String procesar(String linea) {
String[] partes = linea.strip().split("\\s+", 2);
String orden = partes[0].toUpperCase();
String resto = partes.length > 1 ? partes[1] : "";
return switch (orden) {
case "HORA" -> "OK " + LocalTime.now().format(DateTimeFormatter.ofPattern("HH:mm:ss"));
case "ECO" -> "OK " + resto;
case "SUMA" -> {
try {
long suma = 0;
for (String n : resto.split("\\s+")) suma += Long.parseLong(n);
yield "OK " + suma;
} catch (NumberFormatException e) {
yield "ERROR números no válidos";
}
}
case "SALIR" -> "OK adiós";
default -> "ERROR orden desconocida: " + orden;
};
}
static void atender(Socket cliente) {
String quien = cliente.getRemoteSocketAddress().toString();
System.out.println("[servidor] conectado " + quien);
try (cliente;
BufferedReader in = new BufferedReader(new InputStreamReader(cliente.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(cliente.getOutputStream(), true, StandardCharsets.UTF_8)) {
out.println("BIENVENIDO PCT/1"); // saludo inicial del protocolo
String linea;
while ((linea = in.readLine()) != null) { // null = el cliente cerró la conexión
String respuesta = procesar(linea);
System.out.println("[servidor] " + quien + " <- '" + linea + "' -> '" + respuesta + "'");
out.println(respuesta);
if (linea.strip().equalsIgnoreCase("SALIR")) break;
}
} catch (IOException e) {
System.out.println("[servidor] error con " + quien + ": " + e.getMessage());
}
System.out.println("[servidor] desconectado " + quien);
}
public static void main(String[] args) throws IOException {
try (ServerSocket servidor = new ServerSocket(PUERTO)) {
System.out.println("[servidor] escuchando en el puerto " + PUERTO);
while (true) {
Socket cliente = servidor.accept(); // bloquea hasta que llega un cliente
atender(cliente); // de uno en uno
}
}
}
}
Detalles importantes:
new PrintWriter(salida, true, UTF_8): eltrueactiva el vaciado automático (autoflush) en cadaprintln. Sin él, la respuesta se quedaría en el búfer y el cliente esperaría para siempre.readLine()devuelvenullcuando el otro extremo cierra la conexión: es la forma de detectar que el cliente se ha ido.try (cliente; ...)(Java 9) cierra el socket recibido como parámetro al terminar.
El cliente
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.List;
public class ClienteEco {
public static void main(String[] args) {
String host = args.length > 0 ? args[0] : "localhost";
int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 5000;
List<String> ordenes = List.of("HORA", "ECO Hola, servidor", "SUMA 10 20 12", "SUMA 1 dos", "BAILA", "SALIR");
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(host, puerto), 3000); // tiempo máximo para conectar
s.setSoTimeout(5000); // tiempo máximo esperando cada respuesta
System.out.println("[cliente] conectado a " + s.getRemoteSocketAddress() + " desde el puerto local " + s.getLocalPort());
try (BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
System.out.println("[cliente] saludo: " + in.readLine());
for (String orden : ordenes) {
out.println(orden); // envía una línea
System.out.println("[cliente] " + orden + " -> " + in.readLine()); // espera la respuesta
}
}
} catch (ConnectException e) {
System.out.println("[cliente] no hay ningún servidor en " + host + ":" + puerto + " (" + e.getMessage() + ")");
} catch (SocketTimeoutException e) {
System.out.println("[cliente] el servidor no responde: " + e.getMessage());
} catch (IOException e) {
System.out.println("[cliente] error de E/S: " + e.getMessage());
}
}
}
Ejecución en dos contenedores
Para que la prueba sea realista, ejecutamos el servidor y el cliente en dos contenedores distintos conectados a la misma red de Docker (curso-java). Dentro de esa red, cada contenedor es accesible por su nombre, como si fuera un nombre DNS:
# Red compartida (si no existe)
docker network create curso-java
# Terminal 1: el servidor, con el nombre "servidor-eco"
docker run --rm --name servidor-eco --network curso-java -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java ServidorEco.java
# Terminal 2: el cliente, que se conecta al host "servidor-eco"
docker run --rm --network curso-java -v "$PWD:/w" -w /w maven:3.9-eclipse-temurin-25 java ClienteEco.java servidor-eco 5000
Si lanzas el cliente cuando aún no hay servidor:
[cliente] no hay ningún servidor en localhost:5000 (Connection refused (connect failed))
Con el servidor en marcha, el cliente muestra:
[cliente] conectado a java_a_05_09_srv/172.31.0.5:5000 desde el puerto local 37916
[cliente] saludo: BIENVENIDO PCT/1
[cliente] HORA -> OK 15:53:15
[cliente] ECO Hola, servidor -> OK Hola, servidor
[cliente] SUMA 10 20 12 -> OK 42
[cliente] SUMA 1 dos -> ERROR números no válidos
[cliente] BAILA -> ERROR orden desconocida: BAILA
[cliente] SALIR -> OK adiós
Y el registro del servidor:
[servidor] escuchando en el puerto 5000
[servidor] conectado /172.31.0.4:37916
[servidor] /172.31.0.4:37916 <- 'HORA' -> 'OK 15:53:15'
[servidor] /172.31.0.4:37916 <- 'ECO Hola, servidor' -> 'OK Hola, servidor'
[servidor] /172.31.0.4:37916 <- 'SUMA 10 20 12' -> 'OK 42'
[servidor] /172.31.0.4:37916 <- 'SUMA 1 dos' -> 'ERROR números no válidos'
[servidor] /172.31.0.4:37916 <- 'BAILA' -> 'ERROR orden desconocida: BAILA'
[servidor] /172.31.0.4:37916 <- 'SALIR' -> 'OK adiós'
[servidor] desconectado /172.31.0.4:37916
(En la ejecución real, el contenedor del servidor se llamaba java_a_05_09_srv.) Observa que el cliente usa un puerto local efímero (37916) asignado por el sistema operativo; el servidor ve la conexión llegar desde la IP del contenedor cliente y ese puerto.
💡 Consejo: También puedes probar el servidor sin escribir un cliente:
telnet localhost 5000onc localhost 5000(netcat) te permiten teclear las órdenes a mano. Es la mejor forma de comprobar que un protocolo de texto está bien implementado.
| Problema | Causa habitual | Solución |
|---|---|---|
ConnectException: Connection refused | No hay nadie escuchando en ese puerto o host | Arranca el servidor; revisa host y puerto |
BindException: Address already in use | Otro proceso (o una ejecución anterior) usa el puerto | Detén el otro proceso o usa otro puerto |
UnknownHostException | El nombre no se puede resolver | Revisa el nombre o la red de Docker |
| El cliente se queda esperando para siempre | Falta flush() (o autoflush), o ambos esperan a leer a la vez | Autoflush y protocolo claro de turnos; setSoTimeout |
SocketTimeoutException | No llegó respuesta en el tiempo fijado | Aumenta el tiempo o informa del error |
SocketException: Broken pipe / Connection reset | El otro extremo cerró la conexión mientras escribías | Trata la excepción como desconexión |
4. Servidor multihilo con hilos virtuales
El servidor anterior tiene un problema grave: mientras atiende a un cliente, accept() no se vuelve a llamar, y los demás esperan. La solución clásica es atender a cada cliente en su propio hilo; desde Java 21, con un hilo virtual por cliente (lo que viste en el módulo 07), un servidor puede atender a miles de clientes simultáneos con el código bloqueante de siempre.
Figura 4. Un hilo acepta conexiones y cada cliente se atiende en su propio hilo virtual.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
/** Servidor del protocolo PCT/1 que atiende a cada cliente en su propio hilo virtual. */
public class ServidorMultihilo {
static final AtomicInteger conectados = new AtomicInteger();
static String procesar(String linea) throws InterruptedException {
String[] p = linea.strip().split("\\s+", 2);
return switch (p[0].toUpperCase()) {
case "ECO" -> "OK " + (p.length > 1 ? p[1] : "");
case "ESPERA" -> { // simula una operación lenta (consulta a BD...)
Thread.sleep(Long.parseLong(p[1]));
yield "OK he esperado " + p[1] + " ms";
}
case "CLIENTES" -> "OK conectados ahora: " + conectados.get();
case "SALIR" -> "OK adiós";
default -> "ERROR orden desconocida";
};
}
static void atender(Socket cliente) {
conectados.incrementAndGet();
try (cliente;
BufferedReader in = new BufferedReader(new InputStreamReader(cliente.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(cliente.getOutputStream(), true, StandardCharsets.UTF_8)) {
out.println("BIENVENIDO PCT/1");
String linea;
while ((linea = in.readLine()) != null) {
out.println(procesar(linea));
if (linea.strip().equalsIgnoreCase("SALIR")) break;
}
} catch (IOException | InterruptedException | RuntimeException e) {
System.out.println("[servidor] error: " + e);
} finally {
conectados.decrementAndGet();
}
}
public static void main(String[] args) throws IOException {
int puerto = args.length > 0 ? Integer.parseInt(args[0]) : 5001;
boolean secuencial = args.length > 1 && args[1].equals("secuencial");
try (ServerSocket servidor = new ServerSocket(puerto, 1000); // cola de 1000 conexiones pendientes
ExecutorService hilos = Executors.newVirtualThreadPerTaskExecutor()) {
System.out.println("[servidor] escuchando en " + puerto + (secuencial ? " (modo secuencial)" : " (un hilo virtual por cliente)"));
while (true) {
Socket cliente = servidor.accept();
if (secuencial) {
atender(cliente);
} else {
hilos.submit(() -> atender(cliente)); // el bucle vuelve enseguida a accept()
}
}
}
}
}
Para medirlo, este programa lanza N clientes a la vez; cada uno pide una operación que tarda 1 segundo (ESPERA 1000):
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
/** Lanza N clientes a la vez; cada uno pide al servidor una operación de 1 segundo. */
public class PruebaCarga {
public static void main(String[] args) throws Exception {
String host = args[0];
int puerto = Integer.parseInt(args[1]);
int clientes = Integer.parseInt(args[2]);
long t0 = System.nanoTime();
List<Future<String>> resultados = new ArrayList<>();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= clientes; i++) {
int n = i;
resultados.add(ex.submit(() -> {
try (Socket s = new Socket(host, puerto);
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
in.readLine(); // saludo
out.println("ESPERA 1000");
String r = in.readLine();
out.println("SALIR");
in.readLine();
return "cliente " + n + ": " + r + " (a los " + (System.nanoTime() - t0) / 1_000_000 + " ms)";
}
}));
}
}
if (clientes <= 5) for (Future<String> f : resultados) System.out.println(" " + f.resultNow());
System.out.printf("%d clientes atendidos en %,d ms%n", clientes, (System.nanoTime() - t0) / 1_000_000);
}
}
Con dos servidores en marcha en el mismo contenedor (el puerto 5001 en modo secuencial y el 5002 multihilo), la salida real es:
[servidor] escuchando en 5001 (modo secuencial)
[servidor] escuchando en 5002 (un hilo virtual por cliente)
--- secuencial (3 clientes)
cliente 1: OK he esperado 1000 ms (a los 2625 ms)
cliente 2: OK he esperado 1000 ms (a los 1604 ms)
cliente 3: OK he esperado 1000 ms (a los 3635 ms)
3 clientes atendidos en 3.639 ms
--- multihilo (3 clientes)
cliente 1: OK he esperado 1000 ms (a los 1327 ms)
cliente 2: OK he esperado 1000 ms (a los 1327 ms)
cliente 3: OK he esperado 1000 ms (a los 1326 ms)
3 clientes atendidos en 1.359 ms
--- multihilo (1000 clientes)
1000 clientes atendidos en 1.758 ms
Con el servidor secuencial, los clientes se atienden uno detrás de otro (el cliente 2 llegó primero en esta ejecución, y el 3 tuvo que esperar a los otros dos: 3,6 s). Con el multihilo, los tres terminan a la vez, y mil clientes simultáneos se atienden en 1,8 s.
⚠️ Importante: El segundo parámetro de
new ServerSocket(puerto, 1000)es la longitud de la cola de conexiones pendientes (backlog), que por defecto es 50. Al probar con mil clientes simultáneos y la cola por defecto, muchas conexiones no cabían en la cola, el sistema operativo las descartaba y los clientes se quedaban reintentando: la prueba no terminaba. Los servidores reales ajustan este valor (y el sistema operativo impone un máximo).
💡 Consejo: Todo lo que compartan los hilos del servidor debe ser seguro entre hilos: el contador de conectados es un
AtomicInteger; en el chat de la sección siguiente, la lista de clientes es unConcurrentHashMap.
5. Un chat sencillo
Un chat añade un reto nuevo: los mensajes de un cliente deben llegar a todos los demás. El servidor guarda el canal de salida de cada cliente y, cuando recibe un mensaje, lo difunde (broadcast). El cliente necesita dos hilos: uno que lee lo que escribe el usuario y otro que muestra lo que llega del servidor en cualquier momento.
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
/**
* Chat sencillo. Protocolo:
* el servidor pide NICK; el cliente envía su apodo;
* cada línea que envía un cliente se reenvía a todos; /quien lista los conectados; /salir desconecta.
*/
public class ServidorChat {
// apodo -> canal de salida hacia ese cliente (concurrente: lo usan todos los hilos)
static final Map<String, PrintWriter> clientes = new ConcurrentHashMap<>();
static void difundir(String mensaje) {
System.out.println("[difusión] " + mensaje);
clientes.values().forEach(out -> out.println(mensaje)); // PrintWriter es thread-safe en cada println
}
static void atender(Socket s) {
String nick = null;
try (s;
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
// 1. Registro del apodo (único)
while (true) {
out.println("NICK?");
String propuesto = in.readLine();
if (propuesto == null) return;
propuesto = propuesto.strip();
if (!propuesto.isEmpty() && clientes.putIfAbsent(propuesto, out) == null) { // atómico
nick = propuesto;
break;
}
out.println("ERROR apodo no válido o en uso");
}
out.println("OK bienvenido, " + nick);
difundir("* " + nick + " ha entrado (" + clientes.size() + " conectados)");
// 2. Mensajes
String linea;
while ((linea = in.readLine()) != null) {
if (linea.equals("/salir")) break;
if (linea.equals("/quien")) {
out.println("* conectados: " + clientes.keySet().stream().sorted().toList());
} else if (!linea.isBlank()) {
difundir("[" + nick + "] " + linea);
}
}
} catch (IOException e) {
System.out.println("[servidor] conexión perdida con " + nick + ": " + e.getMessage());
} finally {
if (nick != null) {
clientes.remove(nick);
difundir("* " + nick + " ha salido");
}
}
}
public static void main(String[] args) throws IOException {
int puerto = args.length > 0 ? Integer.parseInt(args[0]) : 6000;
try (ServerSocket servidor = new ServerSocket(puerto);
ExecutorService hilos = Executors.newVirtualThreadPerTaskExecutor()) {
System.out.println("[servidor] chat escuchando en " + puerto);
while (true) {
Socket s = servidor.accept();
hilos.submit(() -> atender(s));
}
}
}
}
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
/**
* Cliente de chat: un hilo lee del servidor y lo muestra; el hilo principal lee del teclado y lo envía.
* Uso: java ClienteChat.java host puerto
*/
public class ClienteChat {
public static void main(String[] args) throws IOException, InterruptedException {
String host = args.length > 0 ? args[0] : "localhost";
int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 6000;
try (Socket s = new Socket(host, puerto);
BufferedReader servidor = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8);
BufferedReader teclado = new BufferedReader(new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
// Hilo receptor: lo que llega del servidor puede llegar en cualquier momento
Thread receptor = Thread.ofVirtual().start(() -> {
try {
String linea;
while ((linea = servidor.readLine()) != null) {
System.out.println(linea);
}
} catch (IOException e) {
// el socket se cerró: terminamos
}
});
// Hilo principal: lo que escribe el usuario
String linea;
while ((linea = teclado.readLine()) != null) {
out.println(linea);
if (linea.equals("/salir")) break;
Thread.sleep(150); // solo para que la salida de la demo quede ordenada
}
Thread.sleep(300); // deja llegar los últimos mensajes
s.shutdownOutput();
receptor.join(1000);
}
}
}
Para la prueba, dos usuarios (Ana y Luis) se conectan con unos segundos de diferencia; su “teclado” es una entrada preparada con pausas ((sleep 3; printf "ana\nHola a todos\n/quien\n"; ...) | java ClienteChat localhost 6000). Luis intenta primero usar el apodo ana, que ya está ocupado. Esto es lo que vio cada uno:
== pantalla de ana
NICK?
OK bienvenido, ana
* ana ha entrado (1 conectados)
[ana] Hola a todos
* conectados: [ana]
* luis ha entrado (2 conectados)
[luis] ¡Hola, Ana!
[ana] Me voy, hasta luego
== pantalla de luis
NICK?
ERROR apodo no válido o en uso
NICK?
OK bienvenido, luis
* luis ha entrado (2 conectados)
[luis] ¡Hola, Ana!
[ana] Me voy, hasta luego
* ana ha salido
== log del servidor
[servidor] chat escuchando en 6000
[difusión] * ana ha entrado (1 conectados)
[difusión] [ana] Hola a todos
[difusión] * luis ha entrado (2 conectados)
[difusión] [luis] ¡Hola, Ana!
[difusión] [ana] Me voy, hasta luego
[difusión] * ana ha salido
[difusión] * luis ha salido
Claves del diseño:
clientes.putIfAbsent(apodo, out)comprueba y registra el apodo de forma atómica: si dos personas eligen el mismo apodo a la vez, solo una lo consigue (concontainsKey+puthabría una condición de carrera, como viste en el módulo 07).- El bloque
finallygarantiza que el cliente se elimina del mapa y se avisa a los demás aunque la conexión se corte de golpe. PrintWriter.printlnestá sincronizado internamente, así que varios hilos pueden escribir al mismo cliente sin mezclar líneas.
⚠️ Importante: Si un cliente deja de leer (por ejemplo, porque su red es muy lenta), su búfer de envío se llena y el
printlndel hilo que difunde se bloquea, retrasando el mensaje para todos. Los servidores de chat reales usan una cola de salida por cliente y un hilo escritor dedicado, o E/S no bloqueante (NIOSelector, Netty).
6. UDP: DatagramSocket
Con UDP no hay conexión: cada mensaje viaja en un datagrama independiente con la dirección del destinatario, y puede perderse sin que nadie avise.
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
/** Servidor y cliente UDP en el mismo programa: el servidor devuelve cada mensaje en mayúsculas. */
public class Udp {
static final int PUERTO = 7000;
public static void main(String[] args) throws Exception {
// --- Servidor (en un hilo aparte) ---
Thread servidor = Thread.ofPlatform().daemon().start(() -> {
try (DatagramSocket socket = new DatagramSocket(PUERTO)) {
byte[] bufer = new byte[1024];
while (true) {
DatagramPacket recibido = new DatagramPacket(bufer, bufer.length);
socket.receive(recibido); // espera un datagrama
String texto = new String(recibido.getData(), 0, recibido.getLength(), StandardCharsets.UTF_8);
System.out.println("[servidor] " + recibido.getSocketAddress() + " dice: " + texto);
if (texto.equals("ignórame")) continue; // simulamos un paquete perdido
byte[] respuesta = texto.toUpperCase().getBytes(StandardCharsets.UTF_8);
socket.send(new DatagramPacket(respuesta, respuesta.length, recibido.getSocketAddress()));
}
} catch (IOException e) {
System.out.println("[servidor] " + e);
}
});
Thread.sleep(300);
// --- Cliente ---
try (DatagramSocket socket = new DatagramSocket()) { // puerto local aleatorio
socket.setSoTimeout(1000); // UDP no avisa si se pierde algo
InetAddress destino = InetAddress.getLoopbackAddress();
for (String msg : new String[]{"hola, udp", "ignórame", "adiós"}) {
byte[] datos = msg.getBytes(StandardCharsets.UTF_8);
socket.send(new DatagramPacket(datos, datos.length, destino, PUERTO));
DatagramPacket resp = new DatagramPacket(new byte[1024], 1024);
try {
socket.receive(resp);
System.out.println("[cliente] respuesta: " + new String(resp.getData(), 0, resp.getLength(), StandardCharsets.UTF_8));
} catch (SocketTimeoutException e) {
System.out.println("[cliente] sin respuesta en 1 s para '" + msg + "' (¿datagrama perdido?)");
}
}
}
}
}
[servidor] /127.0.0.1:49038 dice: hola, udp
[cliente] respuesta: HOLA, UDP
[servidor] /127.0.0.1:49038 dice: ignórame
[cliente] sin respuesta en 1 s para 'ignórame' (¿datagrama perdido?)
[servidor] /127.0.0.1:49038 dice: adiós
[cliente] respuesta: ADIÓS
- El servidor responde a la dirección y puerto de origen de cada datagrama (
recibido.getSocketAddress()): así sabe a quién contestar sin conexión. - Usa siempre
getLength()al convertir los bytes recibidos: el búfer es de 1024 bytes, pero el mensaje ocupa menos. - Como UDP no confirma nada, el cliente pone un tiempo máximo (
setSoTimeout) y decide qué hacer si no llega respuesta (reintentar, avisar…). Los protocolos sobre UDP, como DNS, implementan sus propios reintentos.
💡 Consejo: Con UDP también se pueden enviar mensajes a varios destinatarios a la vez (broadcast en la red local, o multicast con
MulticastSocket), algo imposible con TCP. Se usa, por ejemplo, para descubrir dispositivos en una red local.
7. El protocolo HTTP
HTTP (HyperText Transfer Protocol) es el protocolo de la web y de las APIs REST. Funciona sobre TCP (HTTP/1.1 y HTTP/2) o sobre QUIC/UDP (HTTP/3) con un modelo petición-respuesta: el cliente envía una petición y el servidor devuelve una respuesta. Es sin estado: cada petición es independiente, y las sesiones se simulan con cookies o tokens.
Figura 5. Anatomía de una petición y una respuesta HTTP, métodos y códigos de estado.
HTTP/1.1 es un protocolo de texto, igual que nuestro PCT/1. Para comprobarlo, enviemos una petición escrita a mano sobre un socket. Como el servidor solo acepta HTTPS, usamos un SSLSocket, que es un socket con cifrado TLS:
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.nio.charset.StandardCharsets;
import javax.net.ssl.SSLSocket;
import javax.net.ssl.SSLSocketFactory;
/** Una petición HTTP escrita "a mano" sobre un socket TLS: así de simple es el protocolo. */
public class HttpAMano {
public static void main(String[] args) throws Exception {
String host = "jsonplaceholder.typicode.com";
SSLSocketFactory fabrica = (SSLSocketFactory) SSLSocketFactory.getDefault();
try (SSLSocket s = (SSLSocket) fabrica.createSocket(host, 443)) { // HTTPS = HTTP sobre TLS, puerto 443
s.startHandshake(); // negociación TLS y verificación del certificado
System.out.println("TLS: " + s.getSession().getProtocol() + ", cifrado " + s.getSession().getCipherSuite());
System.out.println("Certificado emitido para: " + s.getSession().getPeerPrincipal().getName());
PrintWriter out = new PrintWriter(s.getOutputStream(), false, StandardCharsets.UTF_8);
// Petición: línea de petición + cabeceras + línea en blanco (\r\n obligatorios)
out.print("GET /todos/1 HTTP/1.1\r\n");
out.print("Host: " + host + "\r\n");
out.print("Accept: application/json\r\n");
out.print("User-Agent: CursoJava/1.0\r\n");
out.print("Connection: close\r\n");
out.print("\r\n");
out.flush();
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
System.out.println("---- respuesta ----");
String linea;
boolean cuerpo = false;
while ((linea = in.readLine()) != null) {
if (linea.isEmpty()) {
cuerpo = true;
System.out.println("(línea en blanco: fin de cabeceras)");
continue;
}
// mostramos solo algunas cabeceras para no alargar la salida
if (cuerpo || linea.startsWith("HTTP/") || linea.matches("(?i)(content-type|content-length|cache-control|date|server):.*")) {
System.out.println(linea);
}
}
}
}
}
TLS: TLSv1.3, cifrado TLS_AES_256_GCM_SHA384
Certificado emitido para: CN=typicode.com
---- respuesta ----
HTTP/1.1 200 OK
Date: Sun, 04 Oct 2026 14:03:10 GMT
Content-Type: application/json; charset=utf-8
Content-Length: 83
cache-control: max-age=43200
Server: cloudflare
(línea en blanco: fin de cabeceras)
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
}
Ahí está todo: la línea de estado (HTTP/1.1 200 OK), las cabeceras (Content-Type, Content-Length…), una línea en blanco y el cuerpo JSON. Los nombres de las cabeceras no distinguen mayúsculas (cache-control aparece en minúsculas). Escribir HTTP a mano es útil para entenderlo; para trabajar, usarás HttpClient.
Cabeceras habituales
| Cabecera | Dirección | Significado |
|---|---|---|
Host | Petición | Servidor al que va dirigida (obligatoria en HTTP/1.1) |
Content-Type | Ambas | Formato del cuerpo: application/json, text/html; charset=utf-8… |
Content-Length | Ambas | Tamaño del cuerpo en bytes |
Accept | Petición | Formatos que el cliente acepta |
Authorization | Petición | Credenciales: Bearer <token>, Basic <base64> |
User-Agent | Petición | Programa cliente |
Location | Respuesta | URL del recurso creado (201) o destino de una redirección (3xx) |
Cache-Control | Respuesta | Durante cuánto tiempo se puede guardar en caché |
Set-Cookie / Cookie | Respuesta / petición | Estado de sesión |
8. El cliente HTTP de Java: HttpClient
Desde Java 11, el paquete java.net.http incluye un cliente HTTP moderno, con soporte de HTTP/1.1 y HTTP/2, peticiones síncronas y asíncronas, timeouts y redirecciones. Tiene tres piezas, todas inmutables:
| Clase | Papel |
|---|---|
HttpClient | El cliente: configuración común (timeouts de conexión, redirecciones, versión, proxy). Se crea una vez y se reutiliza |
HttpRequest | Una petición: URI, método, cabeceras, cuerpo (BodyPublishers) y timeout |
HttpResponse<T> | La respuesta: código, cabeceras, versión y cuerpo convertido por un BodyHandler |
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.net.http.HttpTimeoutException;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.IntStream;
public class ClienteHttp {
static final String API = "https://jsonplaceholder.typicode.com";
public static void main(String[] args) throws Exception {
// El cliente es inmutable, seguro entre hilos y reutilizable: créalo una vez
HttpClient cliente = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(5))
.followRedirects(HttpClient.Redirect.NORMAL)
.version(HttpClient.Version.HTTP_2) // intenta HTTP/2; si no, HTTP/1.1
.build();
// 1. GET sencillo
HttpRequest get = HttpRequest.newBuilder(URI.create(API + "/posts/1"))
.header("Accept", "application/json")
.timeout(Duration.ofSeconds(10))
.GET() // es el método por defecto
.build();
HttpResponse<String> r = cliente.send(get, HttpResponse.BodyHandlers.ofString());
System.out.println("GET /posts/1 -> " + r.statusCode() + " (" + r.version() + ")");
System.out.println("Content-Type: " + r.headers().firstValue("content-type").orElse("?"));
System.out.println("Cuerpo: " + r.body().replaceAll("\\s+", " ").substring(0, 90) + "...");
// 2. GET con parámetros de consulta
HttpResponse<String> lista = cliente.send(
HttpRequest.newBuilder(URI.create(API + "/comments?postId=1")).build(),
HttpResponse.BodyHandlers.ofString());
long comentarios = lista.body().lines().filter(l -> l.contains("\"email\"")).count();
System.out.println("GET /comments?postId=1 -> " + lista.statusCode() + ", " + comentarios + " comentarios");
// 3. POST con cuerpo JSON
String json = """
{"userId": 1, "title": "Aprendiendo HttpClient", "body": "Java 11+ trae un cliente HTTP moderno"}
""";
HttpRequest post = HttpRequest.newBuilder(URI.create(API + "/posts"))
.header("Content-Type", "application/json; charset=UTF-8")
.POST(HttpRequest.BodyPublishers.ofString(json))
.build();
HttpResponse<String> creado = cliente.send(post, HttpResponse.BodyHandlers.ofString());
System.out.println("POST /posts -> " + creado.statusCode() + " " + creado.body().replaceAll("\\s+", " "));
// 4. PUT, PATCH y DELETE
HttpRequest put = HttpRequest.newBuilder(URI.create(API + "/posts/1"))
.header("Content-Type", "application/json")
.PUT(HttpRequest.BodyPublishers.ofString("{\"id\":1,\"userId\":1,\"title\":\"nuevo\",\"body\":\"cambiado\"}"))
.build();
System.out.println("PUT /posts/1 -> " + cliente.send(put, HttpResponse.BodyHandlers.ofString()).statusCode());
HttpRequest patch = HttpRequest.newBuilder(URI.create(API + "/posts/1"))
.header("Content-Type", "application/json")
.method("PATCH", HttpRequest.BodyPublishers.ofString("{\"title\":\"solo el título\"}"))
.build();
System.out.println("PATCH /posts/1 -> " + cliente.send(patch, HttpResponse.BodyHandlers.ofString()).statusCode());
HttpRequest delete = HttpRequest.newBuilder(URI.create(API + "/posts/1")).DELETE().build();
System.out.println("DELETE /posts/1 -> " + cliente.send(delete, HttpResponse.BodyHandlers.discarding()).statusCode());
// 5. Un recurso que no existe: HttpClient NO lanza excepción por un 404
HttpResponse<String> noExiste = cliente.send(
HttpRequest.newBuilder(URI.create(API + "/posts/9999")).build(), HttpResponse.BodyHandlers.ofString());
System.out.println("GET /posts/9999 -> " + noExiste.statusCode() + ", cuerpo: '" + noExiste.body() + "'");
// 6. Tiempo máximo muy corto: HttpTimeoutException
try {
cliente.send(HttpRequest.newBuilder(URI.create(API + "/photos")).timeout(Duration.ofMillis(1)).build(),
HttpResponse.BodyHandlers.ofString());
} catch (HttpTimeoutException e) {
System.out.println("Timeout: " + e.getMessage());
}
// 7. Peticiones asíncronas en paralelo con sendAsync
long t0 = System.nanoTime();
List<CompletableFuture<String>> futuros = IntStream.rangeClosed(1, 10)
.mapToObj(i -> HttpRequest.newBuilder(URI.create(API + "/users/" + i)).build())
.map(req -> cliente.sendAsync(req, HttpResponse.BodyHandlers.ofString())
.thenApply(HttpResponse::body)
.thenApply(b -> b.lines().filter(l -> l.contains("\"name\"")).findFirst().orElse("").strip()))
.toList();
CompletableFuture.allOf(futuros.toArray(CompletableFuture[]::new)).join();
System.out.println("10 usuarios en paralelo en " + (System.nanoTime() - t0) / 1_000_000 + " ms:");
futuros.stream().limit(3).forEach(f -> System.out.println(" " + f.join()));
System.out.println(" ...");
}
}
Salida real contra la API pública JSONPlaceholder (una API REST de pruebas: acepta POST, PUT y DELETE, pero no guarda los cambios):
GET /posts/1 -> 200 (HTTP_2)
Content-Type: application/json; charset=utf-8
Cuerpo: { "userId": 1, "id": 1, "title": "sunt aut facere repellat provident occaecati excepturi o...
GET /comments?postId=1 -> 200, 5 comentarios
POST /posts -> 201 { "userId": 1, "title": "Aprendiendo HttpClient", "body": "Java 11+ trae un cliente HTTP moderno", "id": 101 }
PUT /posts/1 -> 200
PATCH /posts/1 -> 200
DELETE /posts/1 -> 200
GET /posts/9999 -> 404, cuerpo: '{}'
Timeout: HTTP connect timed out
10 usuarios en paralelo en 200 ms:
"name": "Leanne Graham",
"name": "Ervin Howell",
"name": "Clementine Bauch",
...
Observa:
- La conexión usó HTTP/2 automáticamente (con TLS, cliente y servidor lo negocian).
- El POST devolvió 201 Created y el recurso creado con un
idnuevo (101). - Un 404 no es una excepción:
sendsolo lanza excepciones por problemas de red o timeouts. Comprueba siemprestatusCode(). - Las 10 peticiones asíncronas se hicieron en paralelo en 200 ms:
sendAsyncdevuelve unCompletableFuture, que se compone como en el módulo 07. Con HTTP/2 viajan además por una única conexión (multiplexación).
| Elemento | Opciones |
|---|---|
BodyPublishers (cuerpo de la petición) | ofString(s), ofByteArray(b), ofFile(path), ofInputStream(...), noBody() |
BodyHandlers (cuerpo de la respuesta) | ofString(), ofByteArray(), ofFile(path), ofLines() (stream de líneas), ofInputStream(), discarding() |
| Timeouts | connectTimeout en el cliente (conectar) y timeout en cada petición (respuesta completa) |
| Redirecciones | Redirect.NEVER (por defecto), NORMAL (no de HTTPS a HTTP), ALWAYS |
| Otros | authenticator(...), proxy(...), cookieHandler(...), executor(...), sslContext(...) |
💡 Consejo: Desde Java 21,
HttpClientimplementaAutoCloseable(close(),shutdown()), útil para liberar sus recursos en aplicaciones de vida corta. En un servidor, crea uno al arrancar y compártelo: mantiene un pool de conexiones reutilizables.
9. Consumir una API REST con Jackson
Manipular el JSON como texto, como en la sección anterior, no es práctico. Combinando HttpClient con Jackson 3 (del módulo 08) podemos escribir un cliente tipado: métodos que devuelven objetos Java. El proyecto Maven usa la misma configuración del módulo 08 (BOM tools.jackson:jackson-bom 3.2.3 y tools.jackson.core:jackson-databind).
package es.curso.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.time.Duration;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.stream.Collectors;
import tools.jackson.core.type.TypeReference;
import tools.jackson.databind.json.JsonMapper;
/** Cliente tipado de la API pública https://jsonplaceholder.typicode.com con HttpClient + Jackson 3. */
public class ClienteJsonPlaceholder {
// Records con SOLO los campos que nos interesan: Jackson 3 ignora el resto por defecto
public record Direccion(String street, String city, String zipcode) { }
public record Usuario(int id, String name, String username, String email, Direccion address) { }
public record Post(Integer id, int userId, String title, String body) { }
public record Tarea(int id, int userId, String title, boolean completed) { }
/** Excepción propia para respuestas que no son 2xx. */
public static class ApiException extends RuntimeException {
final int estado;
ApiException(int estado, String mensaje) {
super("HTTP " + estado + ": " + mensaje);
this.estado = estado;
}
}
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(5)).build();
private final JsonMapper json = JsonMapper.builder().build();
private final String base;
public ClienteJsonPlaceholder(String base) {
this.base = base;
}
private <T> T enviar(HttpRequest peticion, TypeReference<T> tipo) {
try {
HttpResponse<String> r = http.send(peticion, HttpResponse.BodyHandlers.ofString());
if (r.statusCode() / 100 != 2) {
throw new ApiException(r.statusCode(), peticion.method() + " " + peticion.uri().getPath());
}
return json.readValue(r.body(), tipo);
} catch (IOException e) {
throw new ApiException(0, "error de red: " + e.getMessage());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new ApiException(0, "interrumpido");
}
}
private HttpRequest.Builder peticion(String ruta) {
return HttpRequest.newBuilder(URI.create(base + ruta))
.timeout(Duration.ofSeconds(10))
.header("Accept", "application/json");
}
public <T> T get(String ruta, TypeReference<T> tipo) {
return enviar(peticion(ruta).GET().build(), tipo);
}
public <T> T post(String ruta, Object cuerpo, TypeReference<T> tipo) {
String jsonCuerpo = json.writeValueAsString(cuerpo); // objeto Java -> JSON
return enviar(peticion(ruta).header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonCuerpo)).build(), tipo);
}
public static void main(String[] args) {
ClienteJsonPlaceholder api = new ClienteJsonPlaceholder("https://jsonplaceholder.typicode.com");
List<Usuario> usuarios = api.get("/users", new TypeReference<List<Usuario>>() { });
System.out.println(usuarios.size() + " usuarios. Los tres primeros:");
usuarios.stream().limit(3).forEach(u ->
System.out.printf(" %d %-18s %-26s %s%n", u.id(), u.name(), u.email(), u.address().city()));
Usuario u1 = api.get("/users/1", new TypeReference<Usuario>() { });
List<Tarea> tareas = api.get("/todos?userId=" + u1.id(), new TypeReference<List<Tarea>>() { });
long hechas = tareas.stream().filter(Tarea::completed).count();
System.out.println(u1.name() + " tiene " + tareas.size() + " tareas, " + hechas + " completadas");
List<Post> posts = api.get("/posts", new TypeReference<List<Post>>() { });
Map<Integer, Long> postsPorUsuario = posts.stream()
.collect(Collectors.groupingBy(Post::userId, Collectors.counting()));
System.out.println("Posts totales: " + posts.size() + ", por usuario: " + postsPorUsuario.values().stream().distinct().toList());
Post masLargo = posts.stream().max(Comparator.comparingInt(p -> p.body().length())).orElseThrow();
System.out.println("Post más largo: #" + masLargo.id() + " \"" + masLargo.title() + "\"");
Post nuevo = new Post(null, 1, "Hola desde Java 25", "Publicado con HttpClient y Jackson 3");
Post creado = api.post("/posts", nuevo, new TypeReference<Post>() { });
System.out.println("Creado: " + creado);
try {
api.get("/users/999", new TypeReference<Usuario>() { });
} catch (ApiException e) {
System.out.println("Error controlado: " + e.getMessage());
}
}
}
docker run --rm -v "$PWD:/w" -v java-m2:/root/.m2 -w /w maven:3.9-eclipse-temurin-25 mvn -q compile exec:java -Dexec.mainClass=es.curso.red.ClienteJsonPlaceholder
10 usuarios. Los tres primeros:
1 Leanne Graham Sincere@april.biz Gwenborough
2 Ervin Howell Shanna@melissa.tv Wisokyburgh
3 Clementine Bauch Nathan@yesenia.net McKenziehaven
Leanne Graham tiene 20 tareas, 11 completadas
Posts totales: 100, por usuario: [10]
Post más largo: #13 "dolorum ut in voluptas mollitia et saepe quo animi"
Creado: Post[id=101, userId=1, title=Hola desde Java 25, body=Publicado con HttpClient y Jackson 3]
Error controlado: HTTP 404: GET /users/999
Puntos de diseño:
- Los records recogen solo los campos que necesitamos: el JSON de cada usuario trae también teléfono, web, empresa y coordenadas, que Jackson 3 ignora por defecto. Si usaras Jackson 2, tendrías que anotarlos con
@JsonIgnoreProperties(ignoreUnknown = true). - El método genérico
enviarcentraliza la comprobación del código de estado y la conversión de errores en una excepción propia: el resto del código trabaja con objetos y no con códigos HTTP. PostusaInteger id(noint) para poder enviarlo comonullal crear: el identificador lo asigna el servidor.
💡 Consejo: Este patrón (un cliente con métodos tipados sobre
HttpClient+ Jackson) es lo que automatizan las herramientas profesionales: en Spring Boot,RestClienty las interfaces declarativas con@HttpExchange(módulo 20).
10. Servidores HTTP con el JDK
com.sun.net.httpserver.HttpServer: una mini-API
El JDK incluye un servidor HTTP sencillo en el módulo jdk.httpserver (paquete com.sun.net.httpserver, una API pública y soportada pese a su nombre). No sustituye a Tomcat o Spring Boot, pero es perfecto para prototipos, pruebas, servicios internos pequeños y para entender qué hace un framework web por debajo.
package es.curso.red;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.math.BigDecimal;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
import tools.jackson.core.JacksonException;
import tools.jackson.databind.json.JsonMapper;
/**
* Mini-API REST de productos con el servidor HTTP que incluye el JDK (com.sun.net.httpserver).
* GET /api/productos lista (admite ?max=precio)
* GET /api/productos/{id} uno
* POST /api/productos crea (JSON en el cuerpo) -> 201 + Location
* DELETE /api/productos/{id} borra -> 204
*/
public class MiniApi {
public record Producto(Integer id, String nombre, BigDecimal precio, int stock) { }
public record Error(int estado, String mensaje) { }
private final Map<Integer, Producto> productos = new ConcurrentHashMap<>();
private final AtomicInteger siguienteId = new AtomicInteger(1);
private final JsonMapper json = JsonMapper.builder().build();
private HttpServer servidor;
MiniApi() {
alta("Portátil UltraBook 14", "899.00", 25);
alta("Ratón inalámbrico", "19.90", 200);
alta("Monitor 24 FHD", "139.00", 35);
}
private Producto alta(String nombre, String precio, int stock) {
int id = siguienteId.getAndIncrement();
Producto p = new Producto(id, nombre, new BigDecimal(precio), stock);
productos.put(id, p);
return p;
}
public void arrancar(int puerto) throws IOException {
servidor = HttpServer.create(new InetSocketAddress(puerto), 0);
servidor.createContext("/api/productos", this::productos);
servidor.createContext("/hola", ex -> responder(ex, 200, "text/plain; charset=utf-8", "¡Hola desde el JDK!"));
servidor.setExecutor(Executors.newVirtualThreadPerTaskExecutor()); // cada petición en un hilo virtual
servidor.start();
System.out.println("[api] escuchando en http://localhost:" + puerto);
}
public void parar() {
servidor.stop(0);
}
// ---------------------------------------------------------------- manejador
private void productos(HttpExchange ex) throws IOException {
String metodo = ex.getRequestMethod();
String ruta = ex.getRequestURI().getPath(); // /api/productos o /api/productos/7
String[] partes = ruta.split("/");
Optional<Integer> id = partes.length == 4 ? parsearId(partes[3]) : Optional.empty();
System.out.println("[api] " + metodo + " " + ex.getRequestURI());
try {
if (partes.length == 3 && metodo.equals("GET")) {
listar(ex);
} else if (partes.length == 3 && metodo.equals("POST")) {
crear(ex);
} else if (id.isPresent() && metodo.equals("GET")) {
Producto p = productos.get(id.get());
if (p == null) error(ex, 404, "no existe el producto " + id.get());
else enviarJson(ex, 200, p);
} else if (id.isPresent() && metodo.equals("DELETE")) {
if (productos.remove(id.get()) == null) error(ex, 404, "no existe el producto " + id.get());
else responder(ex, 204, null, null);
} else if (partes.length == 4 && id.isEmpty()) {
error(ex, 400, "identificador no numérico: " + partes[3]);
} else {
ex.getResponseHeaders().set("Allow", "GET, POST, DELETE");
error(ex, 405, "método " + metodo + " no permitido en " + ruta);
}
} catch (RuntimeException e) {
error(ex, 500, "error interno: " + e.getMessage());
}
}
private void listar(HttpExchange ex) throws IOException {
String consulta = Optional.ofNullable(ex.getRequestURI().getQuery()).orElse("");
BigDecimal max = consulta.startsWith("max=") ? new BigDecimal(consulta.substring(4)) : null;
List<Producto> lista = productos.values().stream()
.filter(p -> max == null || p.precio().compareTo(max) <= 0)
.sorted(Comparator.comparing(Producto::id))
.toList();
enviarJson(ex, 200, lista);
}
private void crear(HttpExchange ex) throws IOException {
String tipo = Optional.ofNullable(ex.getRequestHeaders().getFirst("Content-Type")).orElse("");
if (!tipo.startsWith("application/json")) {
error(ex, 415, "se esperaba application/json");
return;
}
Producto entrada;
try (InputStream in = ex.getRequestBody()) {
entrada = json.readValue(in, Producto.class);
} catch (JacksonException e) {
error(ex, 400, "JSON no válido: " + e.getOriginalMessage());
return;
}
if (entrada.nombre() == null || entrada.nombre().isBlank() || entrada.precio() == null || entrada.precio().signum() < 0) {
error(ex, 422, "nombre y precio (>= 0) son obligatorios");
return;
}
Producto nuevo = alta(entrada.nombre(), entrada.precio().toPlainString(), entrada.stock());
ex.getResponseHeaders().set("Location", "/api/productos/" + nuevo.id());
enviarJson(ex, 201, nuevo);
}
// ---------------------------------------------------------------- utilidades
private static Optional<Integer> parsearId(String s) {
try {
return Optional.of(Integer.parseInt(s));
} catch (NumberFormatException e) {
return Optional.empty();
}
}
private void enviarJson(HttpExchange ex, int estado, Object cuerpo) throws IOException {
responder(ex, estado, "application/json; charset=utf-8", json.writeValueAsString(cuerpo));
}
private void error(HttpExchange ex, int estado, String mensaje) throws IOException {
enviarJson(ex, estado, new Error(estado, mensaje));
}
private static void responder(HttpExchange ex, int estado, String tipo, String cuerpo) throws IOException {
byte[] bytes = cuerpo == null ? new byte[0] : cuerpo.getBytes(StandardCharsets.UTF_8);
if (tipo != null) ex.getResponseHeaders().set("Content-Type", tipo);
ex.sendResponseHeaders(estado, bytes.length == 0 ? -1 : bytes.length); // -1 = sin cuerpo
try (OutputStream out = ex.getResponseBody()) {
out.write(bytes);
}
}
public static void main(String[] args) throws IOException {
new MiniApi().arrancar(args.length > 0 ? Integer.parseInt(args[0]) : 8080);
}
}
Y un programa que la arranca y la prueba con HttpClient:
package es.curso.red;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
/** Arranca la mini-API y la prueba con HttpClient. */
public class ProbarMiniApi {
static final HttpClient HTTP = HttpClient.newHttpClient();
static String base;
static void llamar(String metodo, String ruta, String tipo, String cuerpo) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(base + ruta));
if (tipo != null) b.header("Content-Type", tipo);
b.method(metodo, cuerpo == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(cuerpo));
HttpResponse<String> r = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
String extra = r.headers().firstValue("location").map(l -> " [Location: " + l + "]").orElse("");
System.out.println(metodo + " " + ruta + " -> " + r.statusCode() + extra + (r.body().isEmpty() ? "" : " " + r.body()));
}
public static void main(String[] args) throws Exception {
MiniApi api = new MiniApi();
api.arrancar(8080);
base = "http://localhost:8080";
try {
llamar("GET", "/hola", null, null);
llamar("GET", "/api/productos", null, null);
llamar("GET", "/api/productos?max=150", null, null);
llamar("GET", "/api/productos/2", null, null);
llamar("POST", "/api/productos", "application/json", "{\"nombre\":\"Webcam HD\",\"precio\":45.00,\"stock\":30}");
llamar("GET", "/api/productos/4", null, null);
llamar("POST", "/api/productos", "application/json", "{\"nombre\":\"\",\"precio\":-1}");
llamar("POST", "/api/productos", "application/json", "{\"nombre\":\"Roto\",");
llamar("POST", "/api/productos", "text/plain", "hola");
llamar("DELETE", "/api/productos/1", null, null);
llamar("GET", "/api/productos/1", null, null);
llamar("GET", "/api/productos/abc", null, null);
llamar("PUT", "/api/productos/2", "application/json", "{}");
llamar("GET", "/api/productos", null, null);
} finally {
api.parar();
}
}
}
[api] escuchando en http://localhost:8080
GET /hola -> 200 ¡Hola desde el JDK!
[api] GET /api/productos
GET /api/productos -> 200 [{"id":1,"nombre":"Portátil UltraBook 14","precio":899.00,"stock":25},{"id":2,"nombre":"Ratón inalámbrico","precio":19.90,"stock":200},{"id":3,"nombre":"Monitor 24 FHD","precio":139.00,"stock":35}]
[api] GET /api/productos?max=150
GET /api/productos?max=150 -> 200 [{"id":2,"nombre":"Ratón inalámbrico","precio":19.90,"stock":200},{"id":3,"nombre":"Monitor 24 FHD","precio":139.00,"stock":35}]
[api] GET /api/productos/2
GET /api/productos/2 -> 200 {"id":2,"nombre":"Ratón inalámbrico","precio":19.90,"stock":200}
[api] POST /api/productos
POST /api/productos -> 201 [Location: /api/productos/4] {"id":4,"nombre":"Webcam HD","precio":45.00,"stock":30}
[api] GET /api/productos/4
GET /api/productos/4 -> 200 {"id":4,"nombre":"Webcam HD","precio":45.00,"stock":30}
[api] POST /api/productos
POST /api/productos -> 422 {"estado":422,"mensaje":"nombre y precio (>= 0) son obligatorios"}
[api] POST /api/productos
POST /api/productos -> 400 {"estado":400,"mensaje":"JSON no válido: Unexpected end-of-input within/between Object entries"}
[api] POST /api/productos
POST /api/productos -> 415 {"estado":415,"mensaje":"se esperaba application/json"}
[api] DELETE /api/productos/1
DELETE /api/productos/1 -> 204
[api] GET /api/productos/1
GET /api/productos/1 -> 404 {"estado":404,"mensaje":"no existe el producto 1"}
[api] GET /api/productos/abc
GET /api/productos/abc -> 400 {"estado":400,"mensaje":"identificador no numérico: abc"}
[api] PUT /api/productos/2
PUT /api/productos/2 -> 405 {"estado":405,"mensaje":"método PUT no permitido en /api/productos/2"}
[api] GET /api/productos
GET /api/productos -> 200 [{"id":2,"nombre":"Ratón inalámbrico","precio":19.90,"stock":200},{"id":3,"nombre":"Monitor 24 FHD","precio":139.00,"stock":35},{"id":4,"nombre":"Webcam HD","precio":45.00,"stock":30}]
(Las líneas [api] las escribe el hilo del servidor y las demás el cliente; por eso se intercalan.) Con un centenar de líneas tenemos una API REST con los códigos de estado correctos: 200, 201 con Location, 204 sin cuerpo, 400, 404, 405 con Allow, 415 y 422. Fíjate en todo lo que hemos tenido que hacer a mano: analizar la ruta, decidir por método, convertir JSON, validar, elegir el código… Es exactamente lo que Spring Boot hará por ti con anotaciones (@GetMapping, @PostMapping, @PathVariable, @Valid…) en el módulo 20.
Elemento de HttpServer | Uso |
|---|---|
HttpServer.create(direccion, backlog) | Crea el servidor (HttpsServer para HTTPS) |
createContext(ruta, manejador) | Asocia un prefijo de ruta a un HttpHandler (lambda que recibe un HttpExchange) |
setExecutor(executor) | Hilos que atienden las peticiones (por defecto, uno solo) |
ex.getRequestMethod(), getRequestURI(), getRequestHeaders(), getRequestBody() | Leer la petición |
ex.getResponseHeaders(), sendResponseHeaders(codigo, longitud), getResponseBody() | Escribir la respuesta (-1: sin cuerpo; 0: longitud desconocida) |
start() / stop(segundos) | Arrancar / parar |
⚠️ Importante: Por defecto
HttpServeratiende las peticiones en un único hilo: una petición lenta bloquea a todas las demás. Configura siempre un executor; conExecutors.newVirtualThreadPerTaskExecutor()cada petición tiene su propio hilo virtual. Y recuerda llamar asendResponseHeadersantes de escribir el cuerpo.
jwebserver: servidor de ficheros estáticos (Java 18)
Java 18 añadió (JEP 408) la herramienta de línea de comandos jwebserver, un servidor web mínimo para servir ficheros estáticos de una carpeta, ideal para probar páginas HTML, compartir ficheros en clase o simular una API con ficheros JSON:
jwebserver -p 8000 -b 0.0.0.0 -d /w/m09/web
Serving /w/m09/web and subdirectories on 0.0.0.0 (all interfaces) port 8000
URL http://172.31.0.4:8000/
0:0:0:0:0:0:0:1 - - [04/Oct/2026:16:05:39 +0200] "GET /datos.json HTTP/1.1" 200 -
0:0:0:0:0:0:0:1 - - [04/Oct/2026:16:05:40 +0200] "GET / HTTP/1.1" 200 -
0:0:0:0:0:0:0:1 - - [04/Oct/2026:16:05:40 +0200] "GET /nada.txt HTTP/1.1" 404 -
Las tres últimas líneas son el registro de tres peticiones hechas con curl; por ejemplo, curl -i http://localhost:8000/datos.json devolvió:
HTTP/1.1 200 OK
Date: Sun, 04 Oct 2026 14:05:39 GMT
Last-modified: Sun, 4 Oct 2026 14:05:27 GMT
Content-type: application/json
Content-length: 33
{"mensaje": "fichero estático"}
| Opción | Significado |
|---|---|
-p puerto | Puerto (por defecto 8000) |
-b dirección | Dirección de escucha (por defecto solo 127.0.0.1; 0.0.0.0 = todas las interfaces, necesario dentro de Docker) |
-d carpeta | Carpeta a servir (por defecto, la actual); debe ser una ruta absoluta |
-o none\|info\|verbose | Nivel de registro |
⚠️ Importante:
jwebserveres para desarrollo y pruebas: solo sirve GET y HEAD, solo HTTP (sin HTTPS) y no tiene seguridad ni rendimiento de producción. Lo mismo está disponible desde código conSimpleFileServer.createFileServer(...).
11. Seguridad básica: HTTPS y TLS
Todo lo que viaja por HTTP o por un socket normal va en claro: cualquiera en la misma red Wi-Fi, en un router intermedio o en el proveedor de Internet puede leerlo o modificarlo. Por eso hoy toda la web usa HTTPS.
📘 Concepto: HTTPS es HTTP sobre TLS (Transport Layer Security, sucesor de SSL). TLS proporciona tres garantías: confidencialidad (los datos van cifrados), integridad (no se pueden alterar sin que se note) y autenticación del servidor (mediante un certificado digital firmado por una autoridad de certificación en la que confía el cliente). La salida de
HttpAManolo mostró: protocoloTLSv1.3, cifradoTLS_AES_256_GCM_SHA384y un certificado emitido paratypicode.com.
En Java:
HttpClientusa TLS automáticamente con las URLhttps://y verifica el certificado con el almacén de autoridades del JDK (cacerts). Si el certificado no es válido (caducado, autofirmado, de otro dominio), lanzaSSLHandshakeException.SSLSocketFactory/SSLServerSocketFactorycrean sockets TLS (como enHttpAMano);HttpsServeres la versión TLS del servidor del JDK.- Los certificados y claves se guardan en almacenes (
.p12, formato PKCS#12) que se gestionan con la herramientakeytooldel JDK. Por ejemplo, para crear un certificado autofirmado de pruebas:keytool -genkeypair -alias servidor -keyalg RSA -keystore servidor.p12 -storetype PKCS12. - En Spring Boot, HTTPS se activa con unas propiedades (
server.ssl.*); en producción es habitual que TLS lo termine un proxy inverso (Nginx, un balanceador de la nube) delante de la aplicación.
⚠️ Importante: Nunca “arregles” un error de certificado desactivando la verificación (un
TrustManagerque acepta todo, código muy copiado en Internet): eliminas la autenticación del servidor y quedas expuesto a ataques de intermediario (man-in-the-middle). Si necesitas confiar en un certificado propio de pruebas, añádelo a un almacén de confianza específico. Y recuerda las otras reglas básicas: valida todo lo que llega por la red (tamaños, formatos, rutas), pon timeouts y no confíes en los datos del cliente.
12. Buenas prácticas
| Práctica | Motivo |
|---|---|
| Define el protocolo por escrito antes de programar | Cliente y servidor deben coincidir en formato, orden y cierre |
Indica la codificación (UTF-8) en todos los Reader/Writer de red | Las tildes deben llegar igual a ambos lados |
Usa autoflush o flush() tras cada mensaje | Si no, el mensaje se queda en el búfer y ambos extremos esperan |
Pon timeouts (connect, setSoTimeout, HttpRequest.timeout) | Un servidor caído no debe colgar tu programa |
Cierra los sockets con try-con-recursos | Evita fugas de conexiones y puertos |
| Un hilo (virtual) por conexión en los servidores | Los clientes no se bloquean entre sí |
| Estructuras concurrentes para el estado compartido | Varios hilos atienden a la vez |
Reutiliza un único HttpClient | Pool de conexiones y HTTP/2 |
Comprueba siempre statusCode() | Un 404 o un 500 no lanzan excepción |
| Valida las entradas de la red (tamaño máximo, rutas, formato) | La red es la puerta de entrada de los ataques |
| Usa HTTPS y no desactives la verificación de certificados | Confidencialidad, integridad y autenticación |
13. Ejercicios
Ejercicio 1: Comprobador de puertos
Escribe un programa que compruebe en paralelo (con hilos virtuales) qué puertos TCP de un equipo aceptan conexiones, intentando conectar con un tiempo máximo de 500 ms. Prueba los puertos 22, 80, 443, 3306, 8080 y del 4995 al 5005 contra el contenedor del servidor del apartado 3 y contra el contenedor de MySQL del curso (mysql-curso).
Ver solución
import java.io.IOException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.stream.IntStream;
/** Comprueba qué puertos TCP de un equipo aceptan conexiones. ¡Úsalo solo en equipos propios! */
public class Ej1Puertos {
static boolean abierto(String host, int puerto, int msTimeout) {
try (Socket s = new Socket()) {
s.connect(new InetSocketAddress(host, puerto), msTimeout);
return true;
} catch (IOException e) { // ConnectException (rechazado) o SocketTimeoutException (filtrado)
return false;
}
}
public static void main(String[] args) throws Exception {
String host = args.length > 0 ? args[0] : "localhost";
List<Integer> puertos = IntStream.concat(IntStream.of(22, 80, 443, 3306, 8080), IntStream.rangeClosed(4995, 5005))
.boxed().toList();
long t0 = System.nanoTime();
try (ExecutorService ex = Executors.newVirtualThreadPerTaskExecutor()) { // todas las pruebas a la vez
List<Future<Boolean>> res = puertos.stream().map(p -> ex.submit(() -> abierto(host, p, 500))).toList();
System.out.print("Puertos abiertos en " + host + ": ");
for (int i = 0; i < puertos.size(); i++) {
if (res.get(i).get()) System.out.print(puertos.get(i) + " ");
}
}
System.out.printf("%n%d puertos comprobados en %d ms%n", puertos.size(), (System.nanoTime() - t0) / 1_000_000);
}
}
Puertos abiertos en java_a_05_09_srv: 5000
16 puertos comprobados en 139 ms
Puertos abiertos en mysql-curso: 3306
16 puertos comprobados en 245 ms
Un puerto cerrado responde enseguida con un rechazo (ConnectException); uno filtrado por un cortafuegos no responde y agota el timeout. Gracias a los hilos virtuales, los 16 intentos se hacen a la vez y el total no supera mucho el timeout de uno solo. Comprobar puertos de equipos ajenos sin permiso puede ser ilegal: hazlo solo en tus equipos o en entornos de prácticas.
Ejercicio 2: Adivina el número por red
Implementa un servidor TCP del juego “adivina el número”: al conectarse, el servidor piensa un número del 1 al 1000 y saluda con PIENSO 1 1000; el cliente envía números y el servidor responde MAYOR, MENOR o ACIERTO n (con el número de intentos). Atiende a cada jugador en un hilo virtual y escribe un cliente automático que juegue con búsqueda binaria. Lanza tres jugadores a la vez.
Ver solución
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.PrintWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.ThreadLocalRandom;
/**
* "Adivina el número" por TCP. Protocolo: el servidor saluda con "PIENSO 1 1000";
* el cliente envía un número; el servidor responde MAYOR, MENOR o ACIERTO n_intentos.
*/
public class Ej2Adivina {
static void partida(Socket s) {
int secreto = ThreadLocalRandom.current().nextInt(1, 1001);
try (s;
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
out.println("PIENSO 1 1000");
int intentos = 0;
String linea;
while ((linea = in.readLine()) != null) {
int n;
try {
n = Integer.parseInt(linea.strip());
} catch (NumberFormatException e) {
out.println("ERROR se esperaba un número");
continue;
}
intentos++;
if (n < secreto) out.println("MAYOR");
else if (n > secreto) out.println("MENOR");
else {
out.println("ACIERTO " + intentos);
return;
}
}
} catch (IOException e) {
System.out.println("[servidor] " + e.getMessage());
}
}
/** Cliente automático: búsqueda binaria. */
static String jugar(String nombre, int puerto) throws IOException {
try (Socket s = new Socket("localhost", puerto);
BufferedReader in = new BufferedReader(new InputStreamReader(s.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(s.getOutputStream(), true, StandardCharsets.UTF_8)) {
String[] saludo = in.readLine().split(" ");
int min = Integer.parseInt(saludo[1]), max = Integer.parseInt(saludo[2]);
StringBuilder traza = new StringBuilder();
while (true) {
int prueba = (min + max) / 2;
out.println(prueba);
String r = in.readLine();
traza.append(prueba).append(r.startsWith("ACIERTO") ? "!" : r.equals("MAYOR") ? "↑ " : "↓ ");
if (r.startsWith("ACIERTO")) return nombre + ": " + traza + " (" + r + ")";
if (r.equals("MAYOR")) min = prueba + 1; else max = prueba - 1;
}
}
}
public static void main(String[] args) throws Exception {
int puerto = 5050;
ServerSocket servidor = new ServerSocket(puerto);
Thread aceptador = Thread.ofPlatform().daemon().start(() -> {
try (ExecutorService hilos = Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket s = servidor.accept();
hilos.submit(() -> partida(s));
}
} catch (IOException e) {
// servidor cerrado
}
});
try (ExecutorService jugadores = Executors.newVirtualThreadPerTaskExecutor()) {
var a = jugadores.submit(() -> jugar("Jugador A", puerto));
var b = jugadores.submit(() -> jugar("Jugador B", puerto));
var c = jugadores.submit(() -> jugar("Jugador C", puerto));
System.out.println(a.get());
System.out.println(b.get());
System.out.println(c.get());
}
servidor.close();
}
}
Jugador A: 500↑ 750↑ 875↑ 938↓ 906↓ 890↑ 898↓ 894! (ACIERTO 8)
Jugador B: 500↓ 250↓ 125↑ 187↓ 156↓ 140↓ 132↓ 128↑ 130! (ACIERTO 9)
Jugador C: 500↓ 250↑ 375↓ 312↑ 343↓ 327↓ 319↑ 323↑ 325! (ACIERTO 9)
Cada partida tiene su propio número secreto como variable local del hilo que la atiende, así que no hay estado compartido entre jugadores. La búsqueda binaria acierta siempre en 10 intentos o menos (2^10 = 1024 > 1000). Los números concretos cambian en cada ejecución. Cerrar el ServerSocket hace que accept() lance una excepción y el hilo aceptador termine.
Ejercicio 3: Comprobador de enlaces
Escribe un programa que compruebe en paralelo, con sendAsync y peticiones HEAD, una lista de URL, y muestre para cada una el código de estado con su significado (OK, redirección con su destino, error del cliente o del servidor) o el tipo de fallo, junto con el tiempo de respuesta. No sigas las redirecciones automáticamente.
Ver solución
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.time.Duration;
import java.util.List;
import java.util.concurrent.CompletableFuture;
/** Comprueba en paralelo el estado de una lista de URL (código HTTP y tiempo de respuesta). */
public class Ej3Comprobador {
record Resultado(String url, String estado, long ms) { }
static final HttpClient HTTP = HttpClient.newBuilder()
.connectTimeout(Duration.ofSeconds(3))
.followRedirects(HttpClient.Redirect.NEVER) // queremos ver las redirecciones
.build();
static CompletableFuture<Resultado> comprobar(String url) {
long t0 = System.nanoTime();
HttpRequest req = HttpRequest.newBuilder(URI.create(url))
.method("HEAD", HttpRequest.BodyPublishers.noBody()) // HEAD: solo cabeceras, sin cuerpo
.timeout(Duration.ofSeconds(5))
.build();
return HTTP.sendAsync(req, HttpResponse.BodyHandlers.discarding())
.thenApply(r -> {
String estado = r.statusCode() + switch (r.statusCode() / 100) {
case 2 -> " OK";
case 3 -> " redirige a " + r.headers().firstValue("location").orElse("?");
case 4 -> " error del cliente";
case 5 -> " error del servidor";
default -> "";
};
return new Resultado(url, estado, (System.nanoTime() - t0) / 1_000_000);
})
.exceptionally(ex -> new Resultado(url, "FALLO " + ex.getCause().getClass().getSimpleName(),
(System.nanoTime() - t0) / 1_000_000));
}
public static void main(String[] args) {
List<String> urls = List.of(
"https://jsonplaceholder.typicode.com/posts/1",
"https://jsonplaceholder.typicode.com/no-existe",
"http://jsonplaceholder.typicode.com/posts/1",
"https://dominio-que-no-existe.invalid/",
"https://openjdk.org/jeps/444");
long t0 = System.nanoTime();
List<Resultado> resultados = urls.stream().map(Ej3Comprobador::comprobar).toList()
.stream().map(CompletableFuture::join).toList();
resultados.forEach(r -> System.out.printf("%-48s %5d ms %s%n", r.url(), r.ms(), r.estado()));
System.out.println("Total: " + (System.nanoTime() - t0) / 1_000_000 + " ms (en paralelo)");
}
}
https://jsonplaceholder.typicode.com/posts/1 2419 ms 200 OK
https://jsonplaceholder.typicode.com/no-existe 2400 ms 404 error del cliente
http://jsonplaceholder.typicode.com/posts/1 684 ms 200 OK
https://dominio-que-no-existe.invalid/ 327 ms FALLO ConnectException
https://openjdk.org/jeps/444 2508 ms 200 OK
Total: 2905 ms (en paralelo)
La clave es el doble stream: el primero (map(comprobar).toList()) lanza todas las peticiones; el segundo espera los resultados con join. Si se hiciera en un solo stream, join esperaría cada petición antes de lanzar la siguiente, y todo sería secuencial. El total (2,9 s) es poco más que la petición más lenta. Las primeras peticiones HTTPS tardan más porque incluyen la resolución DNS y la negociación TLS.
Ejercicio 4: Descarga directa a disco
Descarga el recurso https://jsonplaceholder.typicode.com/photos (unos 5000 objetos JSON) directamente a un fichero con BodyHandlers.ofFile, sin cargarlo en memoria como String. Muestra el código, el tamaño descargado, el tiempo, cuántas fotos contiene (contando líneas con Files.lines) y las primeras líneas del fichero.
Ver solución
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.util.stream.Stream;
/** Descarga un recurso directamente a disco y lo analiza sin cargarlo entero en memoria. */
public class Ej4Descarga {
public static void main(String[] args) throws Exception {
HttpClient http = HttpClient.newBuilder().followRedirects(HttpClient.Redirect.NORMAL).build();
Path destino = Path.of("descargas", "photos.json");
Files.createDirectories(destino.getParent());
long t0 = System.nanoTime();
HttpResponse<Path> r = http.send(
HttpRequest.newBuilder(URI.create("https://jsonplaceholder.typicode.com/photos")).build(),
HttpResponse.BodyHandlers.ofFile(destino, StandardOpenOption.CREATE, StandardOpenOption.TRUNCATE_EXISTING, StandardOpenOption.WRITE));
long ms = (System.nanoTime() - t0) / 1_000_000;
System.out.println("Estado: " + r.statusCode() + ", guardado en " + r.body());
System.out.printf("Tamaño: %,d bytes en %d ms%n", Files.size(destino), ms);
System.out.println("Content-Encoding: " + r.headers().firstValue("content-encoding").orElse("(ninguno)"));
try (Stream<String> lineas = Files.lines(destino)) {
long fotos = lineas.filter(l -> l.contains("\"thumbnailUrl\"")).count();
System.out.println("Fotos en el fichero: " + fotos);
}
// Lectura del principio sin abrir todo el fichero
try (Stream<String> lineas = Files.lines(destino)) {
lineas.limit(7).forEach(l -> System.out.println(" " + l));
}
}
}
Estado: 200, guardado en descargas/photos.json
Tamaño: 1.039.898 bytes en 3954 ms
Content-Encoding: (ninguno)
Fotos en el fichero: 5000
[
{
"albumId": 1,
"id": 1,
"title": "accusamus beatae ad facilis cum similique qui sunt",
"url": "https://picsum.photos/seed/1/600",
"thumbnailUrl": "https://picsum.photos/seed/1/150"
Con ofFile, los bytes se escriben en el disco según llegan: aunque la descarga ocupara gigas, el programa solo usaría la memoria del búfer. El cuerpo de la respuesta (r.body()) es el Path del fichero. HttpClient no pide compresión por defecto (por eso no hay Content-Encoding); si añadieras Accept-Encoding: gzip, tendrías que descomprimir tú con GZIPInputStream.
Ejercicio 5: Servicio web de hora mundial
Con HttpServer y sin bibliotecas externas, crea un servicio con dos rutas: GET /hora?zona=Europe/Madrid devuelve en JSON la fecha y hora actuales de esa zona y su desfase (400 si la zona no existe; 405 si el método no es GET), y GET /visitas devuelve cuántas veces se ha consultado la hora. Pruébalo desde el mismo programa con HttpClient.
Ver solución
import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.DateTimeException;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicLong;
import com.sun.net.httpserver.HttpExchange;
import com.sun.net.httpserver.HttpServer;
/** Servicio web de la hora mundial, sin dependencias externas. */
public class Ej5ServidorHora {
static final AtomicLong visitas = new AtomicLong();
static void enviar(HttpExchange ex, int estado, String json) throws IOException {
byte[] b = json.getBytes(StandardCharsets.UTF_8);
ex.getResponseHeaders().set("Content-Type", "application/json; charset=utf-8");
ex.getResponseHeaders().set("Access-Control-Allow-Origin", "*"); // permitir llamadas desde webs
ex.sendResponseHeaders(estado, b.length);
try (OutputStream out = ex.getResponseBody()) {
out.write(b);
}
}
static void hora(HttpExchange ex) throws IOException {
visitas.incrementAndGet();
if (!ex.getRequestMethod().equals("GET")) {
enviar(ex, 405, "{\"error\":\"solo GET\"}");
return;
}
String q = ex.getRequestURI().getQuery(); // zona=America/Bogota
String zona = q != null && q.startsWith("zona=") ? q.substring(5) : "Europe/Madrid";
try {
ZonedDateTime ahora = ZonedDateTime.now(ZoneId.of(zona));
enviar(ex, 200, """
{"zona":"%s","fechaHora":"%s","desfase":"%s"}""".formatted(
zona, ahora.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME).substring(0, 19), ahora.getOffset()));
} catch (DateTimeException e) {
enviar(ex, 400, "{\"error\":\"zona desconocida: " + zona + "\"}");
}
}
public static void main(String[] args) throws Exception {
HttpServer servidor = HttpServer.create(new InetSocketAddress(8090), 0);
servidor.createContext("/hora", Ej5ServidorHora::hora);
servidor.createContext("/visitas", ex -> enviar(ex, 200, "{\"visitas\":" + visitas.get() + "}"));
servidor.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
servidor.start();
HttpClient http = HttpClient.newHttpClient();
for (String ruta : new String[]{"/hora", "/hora?zona=Asia/Tokyo", "/hora?zona=America/Bogota",
"/hora?zona=Marte/Olympus", "/visitas"}) {
HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create("http://localhost:8090" + ruta)).build(),
HttpResponse.BodyHandlers.ofString());
System.out.println("GET " + ruta + " -> " + r.statusCode() + " " + r.body());
}
HttpResponse<String> r = http.send(HttpRequest.newBuilder(URI.create("http://localhost:8090/hora"))
.POST(HttpRequest.BodyPublishers.noBody()).build(), HttpResponse.BodyHandlers.ofString());
System.out.println("POST /hora -> " + r.statusCode() + " " + r.body());
servidor.stop(0);
}
}
GET /hora -> 200 {"zona":"Europe/Madrid","fechaHora":"2026-10-04T16:07:43","desfase":"+02:00"}
GET /hora?zona=Asia/Tokyo -> 200 {"zona":"Asia/Tokyo","fechaHora":"2026-10-04T23:07:43","desfase":"+09:00"}
GET /hora?zona=America/Bogota -> 200 {"zona":"America/Bogota","fechaHora":"2026-10-04T09:07:43","desfase":"-05:00"}
GET /hora?zona=Marte/Olympus -> 400 {"error":"zona desconocida: Marte/Olympus"}
GET /visitas -> 200 {"visitas":4}
POST /hora -> 405 {"error":"solo GET"}
Reutilizamos lo aprendido en el módulo 05: ZoneId.of lanza una DateTimeException (en concreto ZoneRulesException) con zonas inexistentes, que traducimos a un 400. El contador es un AtomicLong porque cada petición se atiende en un hilo virtual distinto. Componer JSON con formatted es aceptable para respuestas muy simples, pero en cuanto haya textos del usuario conviene Jackson, que escapa comillas y caracteres especiales.
Ejercicio 6: Transferencia de ficheros con un protocolo binario
Diseña un protocolo binario sobre TCP para enviar ficheros: el cliente envía el nombre (writeUTF) y el tamaño (writeLong); el servidor responde LISTO o ERROR motivo (nombre peligroso o fichero de más de 10 MB); solo si recibe LISTO, el cliente envía los bytes, y el servidor contesta OK con el hash SHA-256 de lo recibido. Comprueba que la copia es idéntica y que se rechaza un nombre como ../../tmp/malicioso.bin.
Ver solución
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.file.Files;
import java.nio.file.Path;
import java.security.MessageDigest;
import java.util.HexFormat;
import java.util.Random;
/**
* Envío de ficheros por TCP con un protocolo BINARIO:
* cliente -> servidor: nombre (writeUTF) y tamaño (long)
* servidor -> cliente: "LISTO" o "ERROR <motivo>"
* cliente -> servidor: los bytes del fichero (solo si recibió LISTO)
* servidor -> cliente: "OK <sha256>"
*/
public class Ej6Transferencia {
static final long MAXIMO = 10 * 1024 * 1024; // 10 MB
static String sha256(Path p) throws Exception {
MessageDigest md = MessageDigest.getInstance("SHA-256");
try (InputStream in = Files.newInputStream(p)) {
byte[] b = new byte[8192];
int n;
while ((n = in.read(b)) != -1) md.update(b, 0, n);
}
return HexFormat.of().formatHex(md.digest()).substring(0, 16) + "…";
}
static void recibir(Socket s, Path carpeta) {
try (s;
DataInputStream in = new DataInputStream(new BufferedInputStream(s.getInputStream()));
DataOutputStream out = new DataOutputStream(s.getOutputStream())) {
String nombre = in.readUTF();
long tamano = in.readLong();
Path destino = carpeta.resolve(nombre).normalize();
if (!destino.getParent().equals(carpeta)) { // evita "../../etc/passwd"
out.writeUTF("ERROR nombre no permitido");
return;
}
if (tamano > MAXIMO) {
out.writeUTF("ERROR fichero demasiado grande");
return;
}
out.writeUTF("LISTO"); // el cliente solo envía tras el visto bueno
try (OutputStream f = new BufferedOutputStream(Files.newOutputStream(destino))) {
byte[] b = new byte[8192];
long restante = tamano;
while (restante > 0) { // leer EXACTAMENTE 'tamano' bytes
int n = in.read(b, 0, (int) Math.min(b.length, restante));
if (n == -1) throw new IOException("conexión cortada a mitad");
f.write(b, 0, n);
restante -= n;
}
}
out.writeUTF("OK " + sha256(destino));
} catch (Exception e) {
System.out.println("[servidor] " + e);
}
}
static String enviar(Path fichero, String nombreRemoto, int puerto) throws IOException {
try (Socket s = new Socket("localhost", puerto);
DataOutputStream out = new DataOutputStream(new BufferedOutputStream(s.getOutputStream()));
DataInputStream in = new DataInputStream(s.getInputStream())) {
out.writeUTF(nombreRemoto);
out.writeLong(Files.size(fichero));
out.flush(); // la cabecera sale ya, sin esperar al búfer
String visto = in.readUTF();
if (!visto.equals("LISTO")) return visto; // rechazado: no enviamos el contenido
Files.copy(fichero, out);
out.flush();
return in.readUTF();
}
}
public static void main(String[] args) throws Exception {
Path recibidos = Files.createDirectories(Path.of("recibidos")).toAbsolutePath();
ServerSocket servidor = new ServerSocket(5060);
Thread.ofPlatform().daemon().start(() -> {
while (true) {
try {
Socket s = servidor.accept();
Thread.ofVirtual().start(() -> recibir(s, recibidos));
} catch (IOException e) {
return;
}
}
});
Path original = Path.of("informe.bin");
byte[] datos = new byte[1_500_000];
new Random(7).nextBytes(datos);
Files.write(original, datos);
System.out.println("Local: " + Files.size(original) + " bytes, sha256 " + sha256(original));
System.out.println("Servidor: " + enviar(original, "informe.bin", 5060));
System.out.println("¿Copia idéntica? " + (Files.mismatch(original, recibidos.resolve("informe.bin")) == -1));
System.out.println("Ataque: " + enviar(original, "../../tmp/malicioso.bin", 5060));
servidor.close();
}
}
Local: 1500000 bytes, sha256 f5fa2140ecdffcf9…
Servidor: OK f5fa2140ecdffcf9…
¿Copia idéntica? true
Ataque: ERROR nombre no permitido
Un detalle real de diseño: en la primera versión de este ejercicio el cliente enviaba la cabecera y los bytes de golpe, sin esperar respuesta. Al rechazar el nombre malicioso, el servidor cerraba la conexión mientras el cliente aún enviaba 1,5 MB, y el cliente terminaba con SocketException: Broken pipe. El paso intermedio LISTO (un “apretón de manos”) lo resuelve. Fíjate también en que, como TCP es un flujo de bytes sin límites de mensaje, el servidor debe leer exactamente tamano bytes: el tamaño enviado delante es lo que delimita el fichero.
Ejercicio 7 (propuesto): Chat con salas y mensajes privados
Amplía el chat de la sección 5 con salas (/unirse sala, los mensajes solo llegan a los de la misma sala), mensajes privados (/privado apodo texto), y una cola de salida por cliente con su propio hilo virtual escritor, para que un cliente lento no retrase a los demás. Prueba la desconexión brusca de un cliente (matando su proceso).
Ejercicio 8 (propuesto): Protocolo de agenda compartida
Diseña e implementa un servidor TCP de agenda compartida con órdenes ALTA nombre;telefono, BUSCA texto, BORRA nombre, LISTA y SALIR, con respuestas de varias líneas terminadas en una línea FIN. Los datos deben guardarse en un fichero JSON (Jackson) al modificarse y cargarse al arrancar. Escribe un cliente interactivo con Scanner.
Ejercicio 9 (propuesto): Termómetros por UDP
Simula cinco sensores (hilos) que envían cada segundo por UDP una lectura de temperatura sensor;valor a un servidor. El servidor calcula la media, el mínimo y el máximo de cada sensor, y cada 5 segundos muestra un informe. Simula la pérdida de un 10 % de los datagramas y muestra cuántas lecturas faltan de cada sensor.
Ejercicio 10 (propuesto): Cliente del tiempo
Busca una API pública de meteorología sin clave (por ejemplo, Open-Meteo) y escribe un cliente con HttpClient y Jackson que, dadas unas coordenadas, muestre la temperatura actual y la previsión de los próximos tres días. Modela solo los campos necesarios con records, pon timeouts y trata los errores HTTP con una excepción propia.
Ejercicio 11 (propuesto): Mini-API completa
Amplía la MiniApi con PUT /api/productos/{id} (reemplazar) y PATCH (cambiar solo el stock), paginación (?pagina=1&tamano=10, con la cabecera X-Total-Count), persistencia en un fichero JSON y una ruta / que sirva una página HTML estática con SimpleFileServer. Escribe un programa de pruebas con HttpClient que compruebe los códigos de estado de todos los casos.
Ejercicio 12 (propuesto): HTTPS con un certificado propio
Genera con keytool un almacén PKCS#12 con un certificado autofirmado, arranca un HttpsServer con él (configurando un SSLContext) y consúmelo con HttpClient. Comprueba que la primera conexión falla con SSLHandshakeException y resuélvelo sin desactivar la verificación: crea un almacén de confianza con el certificado del servidor y pásalo al cliente con sslContext(...).
Resumen
| Concepto | API | Idea clave |
|---|---|---|
| Modelo TCP/IP | — | Aplicación, transporte, internet, acceso; encapsulación |
| Direcciones | InetAddress, InetSocketAddress | IP + DNS; getByName, getAllByName |
| URI | URI, URLEncoder | Componentes, resolve; codificar parámetros |
| Servidor TCP | ServerSocket | accept() devuelve un Socket por cliente; backlog |
| Cliente TCP | Socket | connect con timeout, setSoTimeout, flujos de E/S |
| Protocolo de texto | BufferedReader, PrintWriter(…, true, UTF_8) | Una línea por mensaje; readLine() == null = cierre |
| Servidor multihilo | Executors.newVirtualThreadPerTaskExecutor() | Un hilo virtual por cliente |
| Difusión (chat) | ConcurrentHashMap<String, PrintWriter> | putIfAbsent atómico; limpiar en finally |
| UDP | DatagramSocket, DatagramPacket | Sin conexión ni garantías; getLength(), timeouts |
| HTTP | — | Petición (método, ruta, cabeceras, cuerpo) y respuesta (código, cabeceras, cuerpo) |
| Cliente HTTP | HttpClient, HttpRequest, HttpResponse | Inmutables; send y sendAsync; comprobar statusCode() |
| JSON por HTTP | HttpClient + JsonMapper | Cliente tipado con records y TypeReference |
| Servidor HTTP | com.sun.net.httpserver.HttpServer | createContext, HttpExchange, setExecutor |
| Ficheros estáticos | jwebserver (Java 18), SimpleFileServer | Solo para desarrollo |
| Seguridad | SSLSocket, HttpsServer, keytool | HTTPS = HTTP + TLS; nunca desactivar la verificación |