El servidor de catálogo de BiblioTech ya funciona. Marta, Diego y Nuria se conectan a la vez desde sus puestos y consultan el catálogo con BTCP/1 sobre TCP. Pero hay un detalle que estropea la fiesta: cada puesto de trabajo tiene la dirección del servidor escrita a mano en su bibliotech.properties. El día que la máquina del servidor cambie de IP, alguien tendrá que ir puesto por puesto a corregir un fichero de configuración.
Existe una solución elegante: que los puestos pregunten a toda la red local dónde está BiblioTech y el servidor responda. Pero TCP no puede hacer eso. TCP exige que sepas a quién quieres conectarte antes de conectar; no existe la operación "envía esto a todo el mundo y a ver quién contesta".
UDP sí puede. Y ese es el tema de esta lección: el otro protocolo de transporte, el que no garantiza nada —ni entrega, ni orden, ni unicidad, ni control de flujo— y que precisamente por eso puede hacer cosas que TCP no puede. Vas a entender cuándo esa falta de garantías es un problema y cuándo es exactamente lo que quieres, vas a cometer (y evitar) los dos errores clásicos que comete todo el mundo, y vas a darle a BiblioTech dos capacidades nuevas: descubrimiento automático del servidor en la red local y telemetría continua que no bloquea a nadie y a la que no le importa perder algún paquete.
Contenido
- UDP en la práctica: qué se pierde y qué se gana
- Cuándo UDP es la elección correcta
DatagramPacket: el sobreDatagramSocket: el buzón- Enviar y recibir: el cliente-servidor de eco
- Error clásico 1: reutilizar el paquete de recepción sin restaurar la longitud
- Error clásico 2: usar
buffer.lengthen lugar depacket.getLength() - Tamaño del datagrama, MTU y fragmentación
- Pérdida, duplicación y desorden: la demostración
- Fiabilidad a mano: secuencia, tiempo límite y reintento
- Difusión (broadcast)
- Multidifusión (multicast) con
MulticastSocket - BiblioTech: el servicio de descubrimiento
- BiblioTech: el emisor de telemetría
- El mismo caso resuelto con TCP y con UDP
- Errores Comunes y Consejos
- Ejercicios
- UDP en la práctica: qué se pierde y qué se gana
Con TCP tenías un tubo. Con UDP tienes un buzón de correos: metes un sobre, lo echas, y esperas que llegue.
graph LR
subgraph TCP
A["Cliente"] ---|conexion establecida| B["Servidor"]
end
subgraph UDP
C["Emisor"] -.->|sobre 1| D["Receptor"]
C -.->|sobre 2 - perdido| E["X"]
C -.->|sobre 3| D
end
Lo que pierdes respecto a TCP:
| Garantía perdida | Qué significa en tu código |
|---|---|
| Entrega | Un datagrama puede desaparecer y nadie te lo dice. No hay excepción, no hay aviso |
| Orden | El paquete 3 puede llegar antes que el 2 |
| Unicidad | Un paquete puede llegar dos veces (retransmitido por un router) |
| Control de flujo | Puedes saturar al receptor sin enterarte |
| Control de congestión | Puedes empeorar una red congestionada |
| Detección de caída del par | No hay conexión, así que no hay nada que se rompa |
Lo que ganas:
| Ventaja | Detalle |
|---|---|
| Sin establecimiento | Envías al instante. TCP gasta un viaje de ida y vuelta antes del primer byte útil |
| Cabecera mínima | 8 bytes frente a 20 o más de TCP |
| Fronteras de mensaje | Si envías 100 bytes, el otro recibe 100 bytes o nada. Nunca 60. Se acabó el problema del delimitador |
| Sin estado | El servidor no mantiene nada por cliente: puede atender a miles con un solo socket |
| Uno a muchos | Difusión y multidifusión, imposibles en TCP |
| Sin bloqueo de cabecera de línea | Un paquete perdido no retrasa a los siguientes |
De todas ellas, las dos que más cambian el diseño son las fronteras de mensaje —que hacen innecesario todo el trabajo de delimitadores de 09-02— y la capacidad de uno a muchos, que es literalmente imposible con TCP.
- Cuándo UDP es la elección correcta
La pregunta que decide es siempre la misma: ¿un dato viejo sigue valiendo, o hay que esperarlo sí o sí?
| Caso real | Por qué UDP |
|---|---|
| DNS | Una pregunta, una respuesta, cabe en un datagrama. Si se pierde, se repite. Establecer una conexión TCP para 60 bytes sería absurdo |
| Vídeo y audio en directo | Un fotograma perdido es un parpadeo de 40 ms. Retransmitirlo llegaría tarde y además pararía todo lo demás |
| Videojuegos de acción | La posición del jugador hace 200 ms ya no interesa: interesa la de ahora. Retransmitir una posición vieja es peor que perderla |
| Telemetría y métricas | Miles de medidas por segundo; perder una no cambia nada. Y el emisor nunca debe bloquearse por culpa del receptor |
| Descubrimiento en red local | Requiere difusión. TCP no puede |
| Sincronización de reloj (NTP) | La latencia debe ser mínima y predecible; el protocolo ya tolera pérdidas |
| Registro remoto (syslog) | Un servidor de logs caído no debe bloquear a las aplicaciones que le escriben |
| QUIC y HTTP/3 | Implementan su propia fiabilidad sobre UDP para evitar el bloqueo de cabecera de línea de TCP |
Y el criterio negativo, igual de importante: si acabas implementando confirmaciones, retransmisiones, números de secuencia y control de flujo sobre UDP, has escrito una versión peor de TCP. Elige UDP cuando no necesites las garantías, no cuando quieras ahorrarte doce bytes de cabecera.
Hay una excepción legítima a esa regla, y es cuando necesitas fiabilidad parcial y a tu medida: por ejemplo, retransmitir solo los fotogramas clave de un vídeo, o garantizar el orden pero no la entrega. Eso TCP no lo ofrece —es todo o nada— y es la razón por la que QUIC existe.
DatagramPacket: el sobre
DatagramPacket: el sobreUn DatagramPacket es un sobre: contiene los datos, su longitud, y —según para qué lo uses— una dirección y un puerto.
La clase se usa de dos formas completamente distintas, y confundirlas es el origen de la mayoría de los problemas.
Para enviar: datos + destino
byte[] datos = "¿DONDE ESTA BIBLIOTECH?".getBytes(StandardCharsets.UTF_8);
DatagramPacket paquete = new DatagramPacket(
datos, // el contenido
datos.length, // cuantos bytes de ese array
InetAddress.getByName("192.168.1.50"), // a donde va
9091); // a que puertoPara recibir: un buffer vacío
byte[] buffer = new byte[1024];
// Sin direccion ni puerto: los rellenara receive() con los del REMITENTE.
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
socket.receive(paquete); // bloquea hasta que llega algo
// Ahora el paquete SI tiene remitente, y sabemos cuantos bytes llegaron.
InetAddress remitente = paquete.getAddress();
int puertoRemitente = paquete.getPort();
int bytesRecibidos = paquete.getLength();Métodos de DatagramPacket
| Método | Significado |
|---|---|
getData() |
El array de bytes. Ojo: el array completo, no solo lo recibido |
getLength() |
Cuántos bytes son válidos. La pieza que todo el mundo olvida |
getOffset() |
Desde qué posición del array empiezan los datos válidos |
getAddress() / getPort() |
El destino (al enviar) o el remitente (al recibir) |
setData(byte[]) |
Cambia el array |
setLength(int) |
Cambia la longitud válida. Clave para reutilizar paquetes |
getSocketAddress() |
Dirección y puerto juntos, como SocketAddress |
La distinción entre getData().length (el tamaño del buffer) y getLength() (los bytes que realmente llegaron) es el origen del segundo error clásico, y la tratamos en el apartado 7.
DatagramSocket: el buzón
DatagramSocket: el buzónUn DatagramSocket es el buzón por el que salen y entran los sobres. A diferencia de Socket, no representa una conexión con nadie: representa un punto de la máquina por el que se envía y se recibe.
// Socket EFIMERO: el sistema asigna un puerto libre.
// Sirve para enviar y para recibir respuestas a lo enviado.
DatagramSocket cliente = new DatagramSocket();
// Socket atado a un puerto CONOCIDO: es lo que hace un servidor UDP.
DatagramSocket servidor = new DatagramSocket(9091);
// Atado a un puerto y a una interfaz concreta.
DatagramSocket local = new DatagramSocket(9091, InetAddress.getByName("127.0.0.1"));La diferencia esencial con Socket y ServerSocket
| TCP | UDP | |
|---|---|---|
| Clase del cliente | Socket |
DatagramSocket |
| Clase del servidor | ServerSocket y un Socket por cliente |
DatagramSocket (uno solo, para todos) |
| Objetos por cliente | Uno | Ninguno: no hay estado por cliente |
| Cómo se sabe quién habla | Es la conexión | Está en cada paquete: getAddress() |
| Hilos necesarios | Uno por conexión | Uno, o unos pocos |
Un servidor UDP no necesita un pool de hilos por cliente, porque no hay clientes: hay paquetes sueltos. Un solo hilo en un bucle de receive() puede atender a miles de máquinas. Es una arquitectura radicalmente más simple, a cambio de que tú te encargues de todo lo que TCP hacía por ti.
Métodos principales
| Método | Qué hace |
|---|---|
send(DatagramPacket) |
Envía. Casi nunca bloquea y casi nunca falla, aunque el destino no exista |
receive(DatagramPacket) |
Bloquea hasta que llega un datagrama |
setSoTimeout(int ms) |
Hace que receive lance SocketTimeoutException. Imprescindible |
connect(InetAddress, int) |
Filtra: solo se envía y se recibe de ese par. No establece conexión |
disconnect() |
Deshace el filtro |
setBroadcast(boolean) |
Permite enviar a la dirección de difusión |
getLocalPort() |
El puerto que se te asignó |
close() |
Libera el puerto. Implementa Closeable |
Sobre
send: el silencio absoluto. Esto sorprende a todo el mundo.send()normalmente no lanza excepción aunque el destino esté apagado, no exista o descarte el paquete. El sistema entrega el datagrama a la red y se olvida. Si envías a192.168.1.250y ahí no hay nada, tu código no se entera de nada. La única señal posible es un mensaje ICMP de "puerto inalcanzable" que puede llegar y provocar unaPortUnreachableExceptionen la siguiente operación — pero solo si el socket estáconnect-ado, y ni siquiera está garantizado. Con UDP, el éxito desend()no significa absolutamente nada.
Sobre
connecten UDP: no es lo que parece.DatagramSocket.connect(dir, puerto)no hace ningún saludo, ni establece nada, ni envía un solo paquete. Solo instala un filtro local: a partir de ahísendpuede omitir el destino yreceivedescarta los paquetes de cualquier otro remitente. Es útil por dos motivos: evita que un tercero te inyecte paquetes falsos, y permite recibirPortUnreachableException. Es una comodidad y una defensa, no una conexión.
- Enviar y recibir: el cliente-servidor de eco
Empecemos con el par más simple que funciona, para fijar la mecánica.
El servidor de eco
package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Servidor de eco UDP. Un solo hilo, un solo socket, todos los clientes.
* No hay conexiones, no hay pool, no hay estado por cliente.
*/
public class ServidorEcoUdp implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ServidorEcoUdp.class.getName());
/** Tamano del buffer de recepcion. Ver el apartado 8 sobre la MTU. */
private static final int MAXIMO_DATAGRAMA = 1400;
private final int puerto;
private DatagramSocket socket;
private volatile boolean ejecutando = false;
public ServidorEcoUdp(int puerto) {
this.puerto = puerto;
}
public void ejecutar() throws SocketException {
socket = new DatagramSocket(puerto);
ejecutando = true;
LOG.info(() -> "Servidor de eco UDP en el puerto " + puerto);
// UN buffer y UN paquete, reutilizados en todo el bucle:
// crear un array de 1400 bytes por cada paquete recibido
// generaria basura innecesaria a miles por segundo.
byte[] buffer = new byte[MAXIMO_DATAGRAMA];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
while (ejecutando) {
try {
// IMPRESCINDIBLE antes de cada receive: restaurar la longitud.
// Si el paquete anterior tenia 20 bytes, getLength() vale 20
// y solo se recibirian 20 bytes del siguiente. Apartado 6.
paquete.setLength(buffer.length);
socket.receive(paquete); // BLOQUEA hasta que llega algo
// getLength() -> lo que REALMENTE llego, no buffer.length.
String texto = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8);
LOG.info(() -> "De " + paquete.getSocketAddress() + " ("
+ paquete.getLength() + " bytes): " + texto);
// Respondemos AL REMITENTE, que viene en el propio paquete.
byte[] respuesta = ("ECO: " + texto).getBytes(StandardCharsets.UTF_8);
DatagramPacket vuelta = new DatagramPacket(
respuesta, respuesta.length,
paquete.getAddress(), paquete.getPort());
socket.send(vuelta);
} catch (SocketException e) {
if (!ejecutando) {
LOG.info("Servidor de eco UDP detenido");
return;
}
LOG.log(Level.SEVERE, "Fallo del socket UDP", e);
return;
} catch (IOException e) {
// Un fallo con UN paquete no puede tumbar el servidor.
LOG.log(Level.WARNING, "Error procesando un datagrama", e);
}
}
}
@Override
public void close() {
ejecutando = false;
if (socket != null) {
// Cerrar el socket desbloquea el receive(), igual que cerrar
// el ServerSocket desbloqueaba el accept() en 09-03.
socket.close();
}
}
public static void main(String[] args) throws SocketException {
ServidorEcoUdp servidor = new ServidorEcoUdp(9095);
Runtime.getRuntime().addShutdownHook(new Thread(servidor::close));
servidor.ejecutar();
}
}El cliente de eco
package com.nexussoftware.bibliotech.red;
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;
/** Cliente de eco UDP con tiempo limite. */
public class ClienteEcoUdp {
private static final int MAXIMO_DATAGRAMA = 1400;
public static void main(String[] args) throws IOException {
String host = args.length > 0 ? args[0] : "localhost";
int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 9095;
// Socket efimero: el sistema nos da un puerto libre.
// try-with-resources: DatagramSocket es Closeable.
try (DatagramSocket socket = new DatagramSocket()) {
// OBLIGATORIO. Sin esto, si la respuesta se pierde
// -y con UDP puede perderse- el receive() no vuelve nunca.
socket.setSoTimeout(2000);
InetAddress destino = InetAddress.getByName(host);
for (String mensaje : new String[]{"Hola", "BiblioTech", "Patrones de Diseño"}) {
byte[] datos = mensaje.getBytes(StandardCharsets.UTF_8);
socket.send(new DatagramPacket(datos, datos.length, destino, puerto));
System.out.println("-> " + mensaje + " (" + datos.length + " bytes)");
byte[] buffer = new byte[MAXIMO_DATAGRAMA];
DatagramPacket respuesta = new DatagramPacket(buffer, buffer.length);
try {
socket.receive(respuesta);
System.out.println("<- " + new String(respuesta.getData(),
respuesta.getOffset(), respuesta.getLength(),
StandardCharsets.UTF_8));
} catch (SocketTimeoutException e) {
// Con UDP esto NO es un error del programa: es el
// funcionamiento normal cuando algo se pierde.
System.out.println("<- (sin respuesta en 2 s: paquete perdido"
+ " o servidor caido)");
}
}
}
}
}Prueba en dos terminales:
# Terminal 1
java -cp clases com.nexussoftware.bibliotech.red.ServidorEcoUdp
# Terminal 2
java -cp clases com.nexussoftware.bibliotech.red.ClienteEcoUdpTerminal 2:
-> Hola (4 bytes)
<- ECO: Hola
-> BiblioTech (10 bytes)
<- ECO: BiblioTech
-> Patrones de Diseño (19 bytes)
<- ECO: Patrones de DiseñoFíjate en Patrones de Diseño: son 18 caracteres pero 19 bytes, porque la ñ ocupa dos bytes en UTF-8. Con UDP, el tamaño se mide en bytes, no en caracteres, y esa diferencia importa cuando calculas si algo cabe en un datagrama.
También puedes probarlo con nc en modo UDP:
Y algo que con TCP no ocurriría: apaga el servidor y vuelve a lanzar el cliente. No obtienes ConnectException ni ningún error; obtienes tres tiempos agotados de dos segundos. send() tuvo éxito las tres veces. Esa es la naturaleza de UDP en una línea.
- Error clásico 1: reutilizar el paquete de recepción sin restaurar la longitud
Este es el bug más citado de la API de UDP en Java, y su síntoma es desconcertante: los mensajes empiezan a llegar truncados a partir del segundo.
// CODIGO ROTO. No lo copies.
byte[] buffer = new byte[1400];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
while (true) {
socket.receive(paquete); // <-- falta setLength antes
procesar(paquete);
}Qué ocurre
receive() modifica la longitud del paquete para indicar cuántos bytes llegaron. Y a la vez, esa misma longitud es la que receive() usa como límite máximo de lo que va a aceptar en la siguiente llamada.
Estado inicial: paquete.getLength() = 1400 (el buffer entero)
1er receive: llega "Hola" (4 bytes)
-> receive escribe 4 bytes y pone getLength() = 4
2o receive: el limite ahora es 4 bytes.
Llega "BiblioTech" (10 bytes)
-> se aceptan SOLO 4: "Bibl"
-> los 6 restantes SE DESCARTAN SIN AVISO
-> getLength() = 4
3er receive: el limite sigue siendo 4. Y asi para siempre.Salida real del servidor con el bug:
De /127.0.0.1:51234 (4 bytes): Hola
De /127.0.0.1:51234 (4 bytes): Bibl
De /127.0.0.1:51234 (4 bytes): PatrEl primer mensaje llega perfecto. Todos los siguientes quedan recortados a la longitud del primero. Y no hay excepción, ni aviso, ni nada en el log: los bytes sobrantes simplemente se tiran.
La solución
// CORRECTO: restaurar la longitud ANTES de cada receive.
while (true) {
paquete.setLength(buffer.length); // <-- la linea que falta
socket.receive(paquete);
procesar(paquete);
}Una sola línea. Y si prefieres no tener que acordarte:
// Alternativa: paquete nuevo en cada vuelta. Correcto, pero genera
// basura. En un servidor que recibe miles de paquetes por segundo,
// la reutilizacion con setLength es notablemente mejor.
while (true) {
DatagramPacket paquete = new DatagramPacket(new byte[1400], 1400);
socket.receive(paquete);
procesar(paquete);
}Regla: si reutilizas el paquete —y deberías—, setLength(buffer.length) antes de cada receive(). Sin excepciones.
- Error clásico 2: usar
buffer.length en lugar de packet.getLength()
buffer.length en lugar de packet.getLength()El segundo error tiene el efecto contrario: en lugar de perder datos, arrastras basura.
// CODIGO ROTO. No lo copies.
socket.receive(paquete);
String texto = new String(paquete.getData(), StandardCharsets.UTF_8);
// ^^^^^^^^^^^^^^^^^ el array ENTERO, 1400 bytesgetData() devuelve el array completo, de 1400 bytes. Si solo llegaron 4, los otros 1396 son lo que hubiera antes en memoria: ceros la primera vez, y restos del mensaje anterior a partir de entonces.
Con el bug, recibiendo "Hola" y luego "Hi":
1er mensaje: "Hola" + 1396 bytes de ceros
-> "Hola\0\0\0\0\0\0..." (parece funcionar, engaña)
2o mensaje: "Hi" + "la" (restos del anterior) + ceros
-> "Hila\0\0\0..." <-- ¡DATOS MEZCLADOS!Ese "Hila" es un dato corrupto que ningún log delata. En un caso real —recibiendo el número de préstamos activos, o un identificador— produce valores erróneos que parecen legítimos.
La solución
// CORRECTO: siempre offset y getLength().
String texto = new String(
paquete.getData(),
paquete.getOffset(), // desde donde
paquete.getLength(), // cuantos bytes son validos
StandardCharsets.UTF_8); // charset explicito, como siempreY para copiar los bytes a un array del tamaño exacto:
byte[] utiles = Arrays.copyOfRange(
paquete.getData(),
paquete.getOffset(),
paquete.getOffset() + paquete.getLength());| Método | Devuelve | Cuándo usarlo |
|---|---|---|
paquete.getData().length |
El tamaño del buffer (1400) | Casi nunca. Es la fuente del bug |
paquete.getLength() |
Los bytes recibidos (4) | Siempre |
paquete.getOffset() |
Desde dónde empiezan | Siempre, junto al anterior |
Los dos errores de estos dos apartados son, con diferencia, los más frecuentes con DatagramPacket. Si los evitas, el 80 % de los problemas de UDP en Java desaparecen.
- Tamaño del datagrama, MTU y fragmentación
¿Cómo de grande puede ser un datagrama? La respuesta tiene tres niveles.
El límite teórico
El campo de longitud de la cabecera UDP es de 16 bits, así que el máximo absoluto son 65.535 bytes, menos 8 de cabecera UDP y 20 de cabecera IP: 65.507 bytes de datos con IPv4.
El límite práctico: la MTU
La MTU (unidad máxima de transmisión) es el tamaño máximo de trama que admite un enlace. En Ethernet son 1500 bytes, incluyendo las cabeceras IP y UDP.
1500 bytes de MTU Ethernet
- 20 bytes de cabecera IPv4 (40 si es IPv6)
- 8 bytes de cabecera UDP
= 1472 bytes de datos que caben en UNA trama EthernetSi envías más, IP fragmenta el datagrama en varias tramas y el receptor las reensambla. Y aquí está el problema:
Si se pierde un solo fragmento, se pierde el datagrama entero. No hay retransmisión de fragmentos: el receptor descarta lo que tenía y tu aplicación no recibe nada.
Con un 1 % de pérdida por paquete, un datagrama de 10 fragmentos tiene casi un 10 % de probabilidad de perderse. La fragmentación multiplica la tasa de pérdida.
Además, muchos cortafuegos descartan fragmentos IP por política de seguridad, con lo que tus datagramas grandes simplemente no llegan nunca — y el diagnóstico es infernal porque los pequeños funcionan.
La recomendación práctica
| Tamaño | Valoración |
|---|---|
| 512 bytes | Muy seguro. Es el límite del DNS clásico, elegido para funcionar en cualquier red |
| 1400 bytes | Prudente. Cabe en Ethernet incluso con túneles VPN, que restan unos bytes |
| 1472 bytes | El máximo exacto de Ethernet sin fragmentar. Sin margen |
| Más de 1500 | Fragmenta. Solo en redes controladas |
| Más de 8192 | Además, algunos sistemas lo rechazan directamente |
BiblioTech usará 1400 bytes como tamaño de buffer y mantendrá sus mensajes muy por debajo. Si un dato no cabe en un datagrama, la respuesta correcta casi nunca es "hacer el datagrama más grande": es trocear el dato tú mismo con números de secuencia, o usar TCP, que ya sabe hacerlo.
Un detalle que muerde
Si un datagrama llega y no cabe en tu buffer, Java lo trunca sin avisar. No hay excepción. Recibes los primeros N bytes y el resto se pierde.
// Truco util para detectar truncamientos: pedir un byte de mas.
// Si getLength() == buffer.length, es MUY probable que llegara
// un paquete mayor y se haya truncado.
byte[] buffer = new byte[MAXIMO_DATAGRAMA + 1];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
socket.receive(paquete);
if (paquete.getLength() > MAXIMO_DATAGRAMA) {
LOG.warning("Datagrama de " + paquete.getLength()
+ " bytes: supera el maximo previsto. Descartado.");
return;
}
- Pérdida, duplicación y desorden: la demostración
En localhost UDP parece perfecto: no se pierde nada. Eso es engañoso, porque el bucle invertido no atraviesa una red real. Vamos a comprobar que hay que tolerar los tres fenómenos.
package com.nexussoftware.bibliotech.red;
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;
import java.util.HashSet;
import java.util.Set;
/**
* Demuestra que un receptor UDP debe tolerar perdida, duplicacion y desorden.
* El emisor envia N paquetes numerados; el receptor analiza que llego.
*/
public class DemostracionFiabilidadUdp {
private static final int PUERTO = 9096;
private static final int TOTAL = 20;
/** Receptor: cuenta lo que llega y detecta huecos, duplicados y desorden. */
static void receptor() throws IOException {
try (DatagramSocket socket = new DatagramSocket(PUERTO)) {
socket.setSoTimeout(3000); // sin esto, si se pierde el ultimo,
// el bucle no terminaria nunca
byte[] buffer = new byte[512];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
Set<Integer> vistos = new HashSet<>();
int duplicados = 0;
int desordenados = 0;
int ultimoVisto = -1;
while (true) {
paquete.setLength(buffer.length); // error clasico 1
try {
socket.receive(paquete);
} catch (SocketTimeoutException e) {
break; // 3 s sin nada: damos por terminado
}
String texto = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8); // error clasico 2
int numero = Integer.parseInt(texto.substring(texto.indexOf('#') + 1));
if (!vistos.add(numero)) {
// add() devuelve false si ya estaba: es un DUPLICADO.
duplicados++;
System.out.println(" DUPLICADO: #" + numero);
continue;
}
if (numero < ultimoVisto) {
// Ha llegado despues de uno mayor: DESORDEN.
desordenados++;
System.out.println(" DESORDENADO: #" + numero
+ " tras #" + ultimoVisto);
}
ultimoVisto = Math.max(ultimoVisto, numero);
}
// Los que faltan son PERDIDOS.
StringBuilder perdidos = new StringBuilder();
for (int i = 0; i < TOTAL; i++) {
if (!vistos.contains(i)) {
perdidos.append('#').append(i).append(' ');
}
}
System.out.println();
System.out.println("=== ANALISIS DE " + TOTAL + " PAQUETES ===");
System.out.println("Recibidos unicos : " + vistos.size());
System.out.println("Perdidos : " + (TOTAL - vistos.size())
+ (perdidos.length() == 0 ? "" : " -> " + perdidos));
System.out.println("Duplicados : " + duplicados);
System.out.println("Desordenados : " + desordenados);
}
}
/** Emisor: envia TOTAL paquetes numerados lo mas rapido posible. */
static void emisor() throws IOException, InterruptedException {
try (DatagramSocket socket = new DatagramSocket()) {
InetAddress destino = InetAddress.getByName("localhost");
for (int i = 0; i < TOTAL; i++) {
byte[] datos = ("PAQUETE#" + i).getBytes(StandardCharsets.UTF_8);
socket.send(new DatagramPacket(datos, datos.length, destino, PUERTO));
// send() ha "tenido exito". Eso NO significa que haya llegado.
}
System.out.println("Enviados " + TOTAL + " paquetes");
}
}
public static void main(String[] args) throws Exception {
if (args.length > 0 && args[0].equals("emisor")) {
emisor();
} else {
receptor();
}
}
}En localhost verás normalmente cero pérdidas. Para ver el comportamiento real, hay dos caminos:
Camino 1: saturar el buffer de recepción. Sube TOTAL a 100.000 y quita cualquier pausa. El emisor generará paquetes más rápido de lo que el receptor los consume, el buffer del sistema se llenará y el sistema descartará los que no caben, sin avisar a nadie:
=== ANALISIS DE 100000 PAQUETES ===
Recibidos unicos : 73412
Perdidos : 26588
Duplicados : 0
Desordenados : 0Un 26 % de pérdida, en localhost, sin red de por medio. Eso es la ausencia de control de flujo: TCP habría frenado al emisor automáticamente; UDP no frena a nadie y los datagramas sobrantes se tiran.
Camino 2: simular una red mala. En Linux, con tc (requiere administrador):
# Anadir 10% de perdida, 5% de duplicacion y retardo variable al bucle invertido
sudo tc qdisc add dev lo root netem loss 10% duplicate 5% delay 20ms 10ms
# ... ejecutar la prueba ...
# Quitarlo SIEMPRE al terminar
sudo tc qdisc del dev lo root DUPLICADO: #3
DESORDENADO: #7 tras #8
DUPLICADO: #11
=== ANALISIS DE 20 PAQUETES ===
Recibidos unicos : 18
Perdidos : 2 -> #5 #14
Duplicados : 2
Desordenados : 1Ahí están los tres fenómenos. Cualquier receptor UDP serio tiene que tolerarlos, y el patrón es siempre el mismo: numerar los mensajes, descartar los repetidos con un conjunto o una ventana, y decidir conscientemente qué hacer con los desordenados (para telemetría, ignorar los antiguos; para un protocolo de petición-respuesta, descartar los que no correspondan a la petición actual).
- Fiabilidad a mano: secuencia, tiempo límite y reintento
Cuando necesitas algo de fiabilidad pero no toda la de TCP, se construye a mano. El esqueleto es siempre este:
sequenceDiagram
participant C as Cliente
participant S as Servidor
C->>S: PETICION id=42
Note over C: setSoTimeout(500) y espera
Note over S: (paquete perdido)
Note over C: tiempo agotado -> reintento 1
C->>S: PETICION id=42 (mismo id)
S-->>C: RESPUESTA id=42
Note over C: id coincide: aceptada
Las cuatro piezas:
- Identificador único por petición. Permite emparejar respuestas y descartar las que no corresponden — porque puede llegar la respuesta de un intento anterior que se había dado por perdido.
setSoTimeout+ reintento. Con espera creciente, para no empeorar una red congestionada: 200 ms, 400, 800...- Límite de reintentos. Reintentar para siempre es un bucle infinito disfrazado.
- Idempotencia. Si el servidor puede recibir la misma petición dos veces, la operación debe poder repetirse sin daño.
CONSULTAsí es idempotente;PRESTARno, y ese es un argumento sólido para dejar los préstamos en TCP.
package com.nexussoftware.bibliotech.red;
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;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Logger;
/**
* Peticion-respuesta fiable sobre UDP: identificador, tiempo limite,
* reintento con espera creciente y descarte de respuestas desemparejadas.
*
* ATENCION: si necesitas esto para todo tu trafico, usa TCP. Esto tiene
* sentido para intercambios cortos y sueltos (como hace el DNS), no como
* sustituto general de TCP.
*/
public class PeticionUdpFiable {
private static final Logger LOG = Logger.getLogger(PeticionUdpFiable.class.getName());
private static final int MAXIMO_DATAGRAMA = 1400;
private final InetAddress destino;
private final int puerto;
private final int maximoIntentos;
private final int esperaInicialMs;
public PeticionUdpFiable(InetAddress destino, int puerto,
int maximoIntentos, int esperaInicialMs) {
this.destino = destino;
this.puerto = puerto;
this.maximoIntentos = maximoIntentos;
this.esperaInicialMs = esperaInicialMs;
}
/**
* Envia una peticion y espera respuesta, reintentando.
* Devuelve null si no hubo respuesta tras todos los intentos.
*
* Formato: "<id> <peticion>" -> "<id> <respuesta>"
*/
public String pedir(String peticion) throws IOException {
// Identificador aleatorio: emparejar respuestas Y dificultar que
// un tercero adivine el id e inyecte una respuesta falsa.
int id = ThreadLocalRandom.current().nextInt(1, Integer.MAX_VALUE);
byte[] datos = (id + " " + peticion).getBytes(StandardCharsets.UTF_8);
try (DatagramSocket socket = new DatagramSocket()) {
// connect() en UDP no conecta: filtra. Solo aceptaremos
// paquetes de este destino, lo que descarta inyecciones.
socket.connect(destino, puerto);
int espera = esperaInicialMs;
byte[] buffer = new byte[MAXIMO_DATAGRAMA];
DatagramPacket respuesta = new DatagramPacket(buffer, buffer.length);
for (int intento = 1; intento <= maximoIntentos; intento++) {
socket.send(new DatagramPacket(datos, datos.length, destino, puerto));
socket.setSoTimeout(espera);
// Bucle interno: pueden llegar respuestas de intentos
// anteriores que dabamos por perdidos. Hay que descartarlas
// y seguir esperando la nuestra dentro del mismo plazo.
long limite = System.currentTimeMillis() + espera;
while (System.currentTimeMillis() < limite) {
try {
respuesta.setLength(buffer.length); // error clasico 1
socket.receive(respuesta);
String texto = new String(respuesta.getData(),
respuesta.getOffset(), respuesta.getLength(),
StandardCharsets.UTF_8); // error clasico 2
int sep = texto.indexOf(' ');
if (sep < 0) {
LOG.fine("Respuesta con formato invalido; descartada");
continue;
}
int idRecibido = Integer.parseInt(texto.substring(0, sep));
if (idRecibido != id) {
// Respuesta de otra peticion: se descarta.
LOG.fine("Respuesta con id " + idRecibido
+ ", esperabamos " + id + "; descartada");
continue;
}
return texto.substring(sep + 1); // ¡la nuestra!
} catch (SocketTimeoutException e) {
break; // se agoto el plazo de este intento
} catch (NumberFormatException e) {
LOG.fine("Identificador no numerico; descartada");
}
}
LOG.info("Intento " + intento + "/" + maximoIntentos
+ " sin respuesta (espera " + espera + " ms)");
// ESPERA CRECIENTE: duplicar en cada intento. Reintentar
// al mismo ritmo sobre una red congestionada la empeora.
espera *= 2;
}
return null; // sin respuesta tras todos los intentos
}
}
}Tres decisiones que merecen explicación:
El bucle interno de recepción. Cuando el primer intento agota su plazo y lanzas el segundo, la respuesta al primero puede llegar tarde. Si te limitaras a aceptar el primer paquete que llegue, te la comerías como si fuera la del segundo intento. Al usar identificador y descartar los que no coinciden, esto se resuelve; pero hay que seguir esperando dentro del mismo plazo tras descartar uno, y por eso el bucle interno con límite temporal.
La espera creciente. Es la diferencia entre un cliente educado y uno que participa en tumbar una red. Si mil clientes reintentan cada 200 ms contra un servidor saturado, garantizan que siga saturado. Duplicar el plazo reparte la carga.
El identificador aleatorio en lugar de un contador. Un contador es predecible: un tercero que sepa que vas por el 43 puede enviarte una respuesta falsa con id 44 antes de que llegue la real. Es exactamente el ataque de envenenamiento de caché DNS. Un valor aleatorio de 31 bits lo hace impracticable.
- Difusión (broadcast)
La difusión permite enviar un datagrama que reciben todas las máquinas de la red local. Es la capacidad que TCP no tiene y la que necesita BiblioTech.
Direcciones de difusión
| Dirección | Alcance |
|---|---|
255.255.255.255 |
Difusión limitada: toda la red local. Nunca atraviesa un router |
192.168.1.255 |
Difusión dirigida a la subred 192.168.1.0/24 |
10.0.255.255 |
Difusión dirigida a 10.0.0.0/16 |
La dirección dirigida se calcula poniendo a 1 todos los bits de host. En la red de Nexus Software, 192.168.1.0/24, la difusión es 192.168.1.255.
Enviar
try (DatagramSocket socket = new DatagramSocket()) {
// OBLIGATORIO. Sin esto, enviar a una direccion de difusion
// lanza SocketException: Permission denied.
socket.setBroadcast(true);
byte[] datos = "¿DONDE ESTA BIBLIOTECH?".getBytes(StandardCharsets.UTF_8);
socket.send(new DatagramPacket(datos, datos.length,
InetAddress.getByName("255.255.255.255"), 9091));
}Recibir
No hay nada especial: basta con estar atado al puerto de destino.
try (DatagramSocket socket = new DatagramSocket(9091)) {
byte[] buffer = new byte[1400];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
socket.receive(paquete); // recibe tambien las difusiones al 9091
}Límites de la difusión
| Límite | Consecuencia |
|---|---|
| No atraviesa routers | Solo funciona dentro de la misma red local. Es un límite de diseño, no un fallo |
| Molesta a todos | Cada máquina de la red procesa el paquete aunque no le interese |
| Muchas Wi-Fi la filtran | Los puntos de acceso a menudo la limitan o la bloquean |
| No existe en IPv6 | IPv6 la eliminó: su sustituto es la multidifusión |
Ese último punto es importante: la difusión es tecnología heredada. Funciona perfectamente en una red IPv4 de oficina, y por eso BiblioTech la usará; pero para algo nuevo y con vocación de durar, la multidifusión es la respuesta correcta.
- Multidifusión (multicast) con
MulticastSocket
MulticastSocketLa multidifusión es la difusión hecha bien: en lugar de molestar a toda la red, se define un grupo al que las máquinas interesadas se suscriben voluntariamente.
Direcciones de grupo
| Rango IPv4 | Uso |
|---|---|
224.0.0.0 – 224.0.0.255 |
Reservado a protocolos de red. No atraviesa routers |
224.0.1.0 – 238.255.255.255 |
Grupos globales, asignados por la IANA |
239.0.0.0 – 239.255.255.255 |
Ámbito administrativo: para uso privado. El que debes usar |
BiblioTech usará 239.10.10.10, que está en el rango privado.
El TTL: hasta dónde llega
| TTL | Alcance |
|---|---|
| 0 | Solo la propia máquina |
| 1 | Solo la red local. El valor por defecto y el prudente |
| 32 | El emplazamiento |
| 255 | Sin restricción (los routers suelen bloquearlo) |
La API moderna
MulticastSocket tenía métodos joinGroup(InetAddress) y leaveGroup(InetAddress) que están obsoletos desde Java 14, porque no permiten indicar por qué interfaz de red hay que unirse — y en una máquina con Wi-Fi, Ethernet y adaptadores virtuales de Docker, la interfaz que elige el sistema casi nunca es la que quieres.
package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MulticastSocket;
import java.net.NetworkInterface;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.logging.Logger;
/**
* Emisor y receptor de multidifusion para los avisos internos de BiblioTech
* ("el catalogo se ha actualizado", "mantenimiento en 10 minutos").
*/
public class AvisosMulticast implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(AvisosMulticast.class.getName());
/** Rango 239.x.x.x: ambito administrativo, reservado a uso privado. */
private static final String GRUPO = "239.10.10.10";
private static final int PUERTO = 9097;
private static final int MAXIMO_DATAGRAMA = 1400;
private MulticastSocket socket;
private InetSocketAddress grupo;
private NetworkInterface interfaz;
private volatile boolean ejecutando = false;
/** Se une al grupo y queda listo para recibir. */
public void unirse() throws IOException {
socket = new MulticastSocket(PUERTO);
socket.setTimeToLive(1); // no sale de la red local
grupo = new InetSocketAddress(InetAddress.getByName(GRUPO), PUERTO);
interfaz = elegirInterfaz();
// API MODERNA (Java 14+): joinGroup(SocketAddress, NetworkInterface).
// La antigua joinGroup(InetAddress) esta obsoleta porque no permite
// indicar la interfaz, y en una maquina con Wi-Fi + Ethernet + Docker
// el sistema casi nunca elige la que quieres.
socket.joinGroup(grupo, interfaz);
ejecutando = true;
LOG.info(() -> "Unido al grupo " + GRUPO + ":" + PUERTO
+ " por la interfaz " + interfaz.getName());
}
/**
* Elige una interfaz activa, no de bucle invertido y con multidifusion.
* En produccion esto deberia ser configurable en bibliotech.properties.
*/
private NetworkInterface elegirInterfaz() throws IOException {
Enumeration<NetworkInterface> interfaces = NetworkInterface.getNetworkInterfaces();
while (interfaces.hasMoreElements()) {
NetworkInterface ni = interfaces.nextElement();
if (ni.isUp() && !ni.isLoopback() && ni.supportsMulticast()) {
return ni;
}
}
// Respaldo: en una maquina sin red, el bucle invertido permite
// al menos que emisor y receptor de la misma maquina se hablen.
return NetworkInterface.getByName("lo");
}
/** Envia un aviso a todos los suscritos al grupo. */
public void avisar(String mensaje) throws IOException {
byte[] datos = mensaje.getBytes(StandardCharsets.UTF_8);
if (datos.length > MAXIMO_DATAGRAMA) {
throw new IOException("Aviso demasiado largo: " + datos.length + " bytes");
}
socket.send(new DatagramPacket(datos, datos.length,
InetAddress.getByName(GRUPO), PUERTO));
LOG.info(() -> "Aviso enviado al grupo: " + mensaje);
}
/** Bucle de escucha. Llamar desde un hilo propio. */
public void escuchar(java.util.function.Consumer<String> alRecibir) {
byte[] buffer = new byte[MAXIMO_DATAGRAMA];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
while (ejecutando) {
try {
paquete.setLength(buffer.length); // error clasico 1
socket.receive(paquete);
String mensaje = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8); // error clasico 2
// OJO: tambien recibimos NUESTROS PROPIOS avisos. Si molesta,
// se desactiva con socket.setOption(StandardSocketOptions.IP_MULTICAST_LOOP, false)
// o se filtra por remitente comparando con las direcciones locales.
alRecibir.accept(mensaje);
} catch (IOException e) {
if (!ejecutando) {
return; // cierre ordenado
}
LOG.warning("Error recibiendo aviso: " + e.getMessage());
}
}
}
@Override
public void close() {
ejecutando = false;
if (socket != null) {
try {
socket.leaveGroup(grupo, interfaz); // API moderna tambien aqui
} catch (IOException e) {
LOG.fine("Fallo abandonando el grupo: " + e.getMessage());
}
socket.close(); // desbloquea el receive()
}
}
}Difusión frente a multidifusión
| Difusión | Multidifusión | |
|---|---|---|
| Quién recibe | Todas las máquinas de la red | Solo las suscritas al grupo |
| Atraviesa routers | No | Sí, si están configurados |
| IPv6 | No existe | Sí |
| Configuración | Ninguna | Elegir grupo y TTL |
| Coste para la red | Alto: molesta a todos | Bajo |
| Cuándo usarla | Descubrimiento simple en LAN IPv4 | Todo lo demás |
- BiblioTech: el servicio de descubrimiento
Ahora resolvemos el problema con el que abría la lección: que cada puesto de trabajo tenga la IP del servidor escrita a mano.
El protocolo de descubrimiento
BTDP/1 - BiblioTech Discovery Protocol
======================================
Transporte : UDP, difusion al puerto 9091
Codificacion : UTF-8
Tamano maximo : 512 bytes (muy por debajo de cualquier MTU)
Peticion (puesto -> difusion):
BTDP/1 DONDE <idPeticion>
Respuesta (servidor -> remitente, unicast):
BTDP/1 AQUI <idPeticion> <ip> <puertoTcp> <nombreServidor>
Ejemplo:
-> BTDP/1 DONDE 748291
<- BTDP/1 AQUI 748291 192.168.1.50 9090 bibliotech-centralFíjate en dos decisiones. La petición va por difusión porque no sabemos a quién preguntar; la respuesta va por unicast al remitente, porque sí sabemos a quién contestar y no hace falta molestar a toda la red. Y el identificador permite descartar respuestas que no son a nuestra pregunta.
sequenceDiagram
participant P as Puesto de Marta
participant R as Red local (difusion)
participant S as Servidor BiblioTech
participant O as Otras maquinas
P->>R: BTDP/1 DONDE 748291 (255.255.255.255:9091)
R->>S: (llega)
R->>O: (llega, lo ignoran)
S-->>P: BTDP/1 AQUI 748291 192.168.1.50 9090 (unicast)
Note over P: Ya se donde conectar por TCP
P->>S: conexion TCP al 9090 (BTCP/1 de 09-03)
El respondedor, en el servidor
package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Responde a las peticiones de descubrimiento de BiblioTech.
* Se ejecuta junto al ServidorCatalogo de 09-03, en su propio hilo.
*
* Un solo socket, un solo hilo, todos los puestos de la oficina:
* asi es un servidor UDP, sin pool y sin estado por cliente.
*/
public class RespondedorDescubrimiento implements AutoCloseable, Runnable {
private static final Logger LOG =
Logger.getLogger(RespondedorDescubrimiento.class.getName());
private static final int PUERTO_DESCUBRIMIENTO = 9091;
/** 512 bytes: el limite prudente universal, el mismo que usa el DNS. */
private static final int MAXIMO_DATAGRAMA = 512;
private static final String VERSION = "BTDP/1";
private final int puertoTcp;
private final String nombreServidor;
private DatagramSocket socket;
private volatile boolean ejecutando = false;
private final AtomicLong respondidas = new AtomicLong();
private final AtomicLong descartadas = new AtomicLong();
public RespondedorDescubrimiento(int puertoTcp, String nombreServidor) {
this.puertoTcp = puertoTcp;
this.nombreServidor = nombreServidor;
}
public void arrancar() throws SocketException {
socket = new DatagramSocket(PUERTO_DESCUBRIMIENTO);
ejecutando = true;
LOG.info(() -> "Respondedor de descubrimiento en UDP:" + PUERTO_DESCUBRIMIENTO);
}
@Override
public void run() {
// Un buffer un byte mayor que el maximo, para detectar truncamientos.
byte[] buffer = new byte[MAXIMO_DATAGRAMA + 1];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
while (ejecutando) {
try {
paquete.setLength(buffer.length); // ERROR CLASICO 1
socket.receive(paquete);
if (paquete.getLength() > MAXIMO_DATAGRAMA) {
// Un datagrama mayor del previsto: sospechoso. Se descarta.
descartadas.incrementAndGet();
LOG.warning("Datagrama de " + paquete.getLength()
+ " bytes descartado por tamano");
continue;
}
String peticion = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8); // ERROR CLASICO 2
procesar(peticion.strip(), paquete.getAddress(), paquete.getPort());
} catch (SocketException e) {
if (!ejecutando) {
LOG.info("Respondedor de descubrimiento detenido");
return;
}
LOG.log(Level.SEVERE, "Fallo del socket de descubrimiento", e);
return;
} catch (IOException e) {
// Un paquete malo no puede tumbar el servicio.
LOG.log(Level.WARNING, "Error procesando un descubrimiento", e);
}
}
}
private void procesar(String peticion, InetAddress remitente, int puertoRemitente) {
// TODO lo que llega por la red es no confiable, y aqui llega de
// CUALQUIERA de la red local, sin conexion que lo identifique.
String[] partes = peticion.split(" ");
if (partes.length != 3 || !partes[0].equals(VERSION)
|| !partes[1].equals("DONDE")) {
descartadas.incrementAndGet();
// No respondemos a lo que no entendemos: responder a paquetes
// arbitrarios convierte este servicio en un AMPLIFICADOR para
// ataques de denegacion de servicio reflejada.
LOG.fine(() -> "Descubrimiento no reconocido de " + remitente);
return;
}
String id = partes[2];
if (!identificadorValido(id)) {
descartadas.incrementAndGet();
return;
}
try {
String miIp = InetAddress.getLocalHost().getHostAddress();
String respuesta = VERSION + " AQUI " + id + " " + miIp + " "
+ puertoTcp + " " + nombreServidor;
byte[] datos = respuesta.getBytes(StandardCharsets.UTF_8);
// UNICAST al remitente: no hace falta molestar a toda la red
// con la respuesta. La pregunta va en difusion; la respuesta, no.
socket.send(new DatagramPacket(datos, datos.length,
remitente, puertoRemitente));
respondidas.incrementAndGet();
LOG.info(() -> "Descubrimiento respondido a " + remitente.getHostAddress()
+ " (id " + id + ")");
} catch (IOException e) {
// Si el envio falla, no pasa nada grave: el cliente reintentara.
LOG.log(Level.WARNING, "No se pudo responder al descubrimiento", e);
}
}
/** Lista blanca: solo digitos, maximo 10. Nada de datos arbitrarios. */
private boolean identificadorValido(String id) {
if (id.isEmpty() || id.length() > 10) {
return false;
}
for (int i = 0; i < id.length(); i++) {
if (!Character.isDigit(id.charAt(i))) {
return false;
}
}
return true;
}
@Override
public void close() {
ejecutando = false;
if (socket != null) {
socket.close(); // desbloquea el receive()
}
LOG.info(() -> "Descubrimiento: " + respondidas.get() + " respondidas, "
+ descartadas.get() + " descartadas");
}
}El buscador, en el puesto de trabajo
package com.nexussoftware.bibliotech.red;
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;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Logger;
/**
* Busca el servidor de BiblioTech en la red local por difusion.
* Evita tener que configurar la IP del servidor en cada puesto.
*/
public class BuscadorServidor {
private static final Logger LOG = Logger.getLogger(BuscadorServidor.class.getName());
private static final int PUERTO_DESCUBRIMIENTO = 9091;
private static final int MAXIMO_DATAGRAMA = 512;
private static final String VERSION = "BTDP/1";
private static final int MAXIMO_INTENTOS = 3;
/** Lo que se descubre: donde esta el servidor. Un record de 04-07. */
public record ServidorEncontrado(String ip, int puertoTcp, String nombre) {
}
/**
* Busca el servidor. Devuelve null si nadie responde tras los reintentos.
* @param esperaInicialMs plazo del primer intento; se duplica en cada uno.
*/
public ServidorEncontrado buscar(int esperaInicialMs) throws IOException {
int id = ThreadLocalRandom.current().nextInt(1, 1_000_000);
String peticion = VERSION + " DONDE " + id;
byte[] datos = peticion.getBytes(StandardCharsets.UTF_8);
try (DatagramSocket socket = new DatagramSocket()) {
// Sin esto, enviar a 255.255.255.255 lanza
// SocketException: Permission denied.
socket.setBroadcast(true);
// OJO: aqui NO se puede usar connect(), porque la respuesta
// vendra de la IP del servidor y no de la de difusion a la
// que enviamos. connect() la filtraria y la descartariamos.
byte[] buffer = new byte[MAXIMO_DATAGRAMA + 1];
DatagramPacket respuesta = new DatagramPacket(buffer, buffer.length);
InetAddress difusion = InetAddress.getByName("255.255.255.255");
int espera = esperaInicialMs;
for (int intento = 1; intento <= MAXIMO_INTENTOS; intento++) {
socket.send(new DatagramPacket(datos, datos.length,
difusion, PUERTO_DESCUBRIMIENTO));
LOG.info("Buscando BiblioTech en la red local (intento "
+ intento + "/" + MAXIMO_INTENTOS + ")...");
socket.setSoTimeout(espera);
long limite = System.currentTimeMillis() + espera;
while (System.currentTimeMillis() < limite) {
try {
respuesta.setLength(buffer.length); // error clasico 1
socket.receive(respuesta);
String texto = new String(respuesta.getData(),
respuesta.getOffset(), respuesta.getLength(),
StandardCharsets.UTF_8); // error clasico 2
ServidorEncontrado encontrado = analizar(texto, id);
if (encontrado != null) {
LOG.info(() -> "BiblioTech encontrado: " + encontrado);
return encontrado;
}
// Respuesta de otro id o mal formada: seguimos esperando.
} catch (SocketTimeoutException e) {
break; // plazo agotado, siguiente intento
}
}
espera *= 2; // espera creciente
}
LOG.warning("Ningun servidor de BiblioTech ha respondido en la red local");
return null;
}
}
/** Analiza la respuesta VALIDANDO todo: viene de un desconocido de la red. */
private ServidorEncontrado analizar(String texto, int idEsperado) {
String[] p = texto.strip().split(" ");
if (p.length != 6 || !p[0].equals(VERSION) || !p[1].equals("AQUI")) {
return null;
}
try {
if (Integer.parseInt(p[2]) != idEsperado) {
return null; // respuesta a otra pregunta: se descarta
}
int puerto = Integer.parseInt(p[4]);
if (puerto < 1 || puerto > 65535) {
LOG.warning("Puerto invalido en la respuesta: " + puerto);
return null;
}
// La IP se valida intentando interpretarla: si no es valida,
// getByName lanzara y no nos conectaremos a nada raro.
InetAddress.getByName(p[3]);
return new ServidorEncontrado(p[3], puerto, p[5]);
} catch (NumberFormatException | IOException e) {
LOG.fine("Respuesta de descubrimiento invalida: " + texto);
return null;
}
}
public static void main(String[] args) throws IOException {
ServidorEncontrado servidor = new BuscadorServidor().buscar(500);
if (servidor == null) {
System.out.println("No se encontro ningun servidor.");
System.out.println("Configura la IP a mano en bibliotech.properties.");
} else {
System.out.println("Servidor: " + servidor.nombre());
System.out.println("Conectar a " + servidor.ip() + ":" + servidor.puertoTcp());
// Y a partir de aqui, el ClienteCatalogo de 09-02 sobre TCP.
}
}
}Cómo probarlo
# Terminal 1: el servidor (o el respondedor suelto)
java -cp clases com.nexussoftware.bibliotech.red.RespondedorDescubrimiento
# Terminal 2: el puesto de trabajo
java -cp clases com.nexussoftware.bibliotech.red.BuscadorServidorTerminal 2:
INFO: Buscando BiblioTech en la red local (intento 1/3)...
INFO: BiblioTech encontrado: ServidorEncontrado[ip=192.168.1.50, puertoTcp=9090, nombre=bibliotech-central]
Servidor: bibliotech-central
Conectar a 192.168.1.50:9090
Terminal 1:
INFO: Descubrimiento respondido a 192.168.1.23 (id 748291)Con esto, la configuración manual desaparece. El puesto de Marta arranca, pregunta a la red y encuentra el servidor. Si mañana la máquina cambia de IP, nadie tiene que tocar nada. Esta es la clase de cosa que TCP no puede hacer.
Nota de seguridad. El descubrimiento por difusión es cómodo y confía en cualquiera de la red local. Un equipo malicioso puede responder antes que el servidor real y hacer que Marta se conecte a él. Fíjate además en que el respondedor no contesta a lo que no entiende: contestar a paquetes arbitrarios lo convertiría en un amplificador para ataques de denegación de servicio reflejada, en los que el atacante falsifica la IP de origen para que las respuestas lleguen a su víctima. En un entorno real, el descubrimiento sirve para encontrar candidatos y después se verifica al servidor con TLS y un certificado (12-07). Sin verificación, el descubrimiento automático es una comodidad de red interna, no un mecanismo de seguridad.
- BiblioTech: el emisor de telemetría
El segundo caso donde UDP es la elección correcta: enviar cada pocos segundos el estado del servidor a un recolector de métricas.
Los requisitos lo dejan claro:
- No debe bloquear nunca. El servidor está atendiendo a Marta y a Diego; enviar métricas no puede interponerse.
- No importa perder alguna medida. Se envía una cada 5 segundos: si se pierde una, la siguiente llega enseguida.
- El recolector puede estar caído. Y el servidor debe seguir funcionando igual, sin errores ni reintentos.
Con TCP, un recolector caído significaría intentos de conexión que tardan, reintentos, y un hilo entretenido en algo que no importa. Con UDP, se echa el sobre al buzón y a otra cosa.
package com.nexussoftware.bibliotech.red;
import com.nexussoftware.bibliotech.servicio.EstadisticasBiblioTech;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketException;
import java.nio.charset.StandardCharsets;
import java.util.concurrent.Executors;
import java.util.concurrent.ScheduledExecutorService;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicLong;
import java.util.logging.Level;
import java.util.logging.Logger;
/**
* Emisor de telemetria de BiblioTech.
*
* Envia por UDP el estado del servicio cada N segundos. Usa el
* ScheduledExecutorService de 08-05 y NUNCA bloquea al servidor:
* si el recolector esta caido, los datagramas se pierden y no pasa nada.
*
* Formato (texto de una linea, estilo StatsD):
* bibliotech.prestamos.activos:12|g
* bibliotech.consultas.total:8471|c
*/
public class EmisorTelemetria implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(EmisorTelemetria.class.getName());
private static final int MAXIMO_DATAGRAMA = 512;
private final String hostRecolector;
private final int puertoRecolector;
private final int intervaloSegundos;
private final EstadisticasBiblioTech estadisticas;
private final ServidorCatalogo servidor;
private DatagramSocket socket;
private InetAddress destino;
private ScheduledExecutorService planificador;
private final AtomicLong enviados = new AtomicLong();
private final AtomicLong fallidos = new AtomicLong();
public EmisorTelemetria(String hostRecolector, int puertoRecolector,
int intervaloSegundos,
EstadisticasBiblioTech estadisticas,
ServidorCatalogo servidor) {
this.hostRecolector = hostRecolector;
this.puertoRecolector = puertoRecolector;
this.intervaloSegundos = intervaloSegundos;
this.estadisticas = estadisticas;
this.servidor = servidor;
}
public void arrancar() throws IOException {
socket = new DatagramSocket(); // efimero: solo enviamos
// El nombre se resuelve UNA VEZ al arrancar, no en cada envio:
// una resolucion DNS cada 5 segundos es un desperdicio y ademas
// podria bloquear el hilo del planificador.
destino = InetAddress.getByName(hostRecolector);
planificador = Executors.newSingleThreadScheduledExecutor(r -> {
Thread h = new Thread(r, "bibliotech-telemetria");
h.setDaemon(true); // la telemetria no debe impedir salir
return h;
});
// scheduleAtFixedRate de 08-05: cada intervaloSegundos, sin acumular.
planificador.scheduleAtFixedRate(this::emitir,
intervaloSegundos, intervaloSegundos, TimeUnit.SECONDS);
LOG.info(() -> "Telemetria hacia " + hostRecolector + ":" + puertoRecolector
+ " cada " + intervaloSegundos + " s");
}
/**
* Envia una tanda de medidas. Se ejecuta en el hilo del planificador.
*
* CRITICO: este metodo NO puede lanzar nada. Una excepcion que escape
* de una tarea de scheduleAtFixedRate CANCELA la tarea en silencio y
* la telemetria deja de emitir sin que nadie se entere (08-05).
*/
private void emitir() {
try {
enviar("bibliotech.prestamos.activos:"
+ estadisticas.prestamosActivos() + "|g");
enviar("bibliotech.consultas.total:"
+ estadisticas.consultasTotales() + "|c");
enviar("bibliotech.conexiones.activas:"
+ servidor.conexionesActivas() + "|g");
enviar("bibliotech.peticiones.total:"
+ servidor.peticionesAtendidas() + "|c");
Runtime rt = Runtime.getRuntime();
long memoriaMb = (rt.totalMemory() - rt.freeMemory()) / (1024 * 1024);
enviar("bibliotech.memoria.mb:" + memoriaMb + "|g");
} catch (RuntimeException e) {
// Se registra y se sigue: la proxima tanda lo intentara otra vez.
LOG.log(Level.WARNING, "Fallo emitiendo telemetria", e);
}
}
private void enviar(String medida) {
byte[] datos = medida.getBytes(StandardCharsets.UTF_8);
if (datos.length > MAXIMO_DATAGRAMA) {
LOG.warning("Medida demasiado larga; descartada: " + medida);
return;
}
try {
socket.send(new DatagramPacket(datos, datos.length,
destino, puertoRecolector));
enviados.incrementAndGet();
// Recuerda: este "exito" NO significa que haya llegado.
// Con UDP, send() tiene exito aunque el recolector este apagado.
} catch (IOException e) {
// Casi nunca ocurre. Y si ocurre, no importa: es telemetria.
fallidos.incrementAndGet();
LOG.fine("Datagrama de telemetria no enviado: " + e.getMessage());
}
}
@Override
public void close() {
if (planificador != null) {
planificador.shutdown(); // apagado en dos fases (08-05)
try {
if (!planificador.awaitTermination(2, TimeUnit.SECONDS)) {
planificador.shutdownNow();
}
} catch (InterruptedException e) {
planificador.shutdownNow();
Thread.currentThread().interrupt();
}
}
if (socket != null) {
socket.close();
}
LOG.info(() -> "Telemetria detenida: " + enviados.get() + " datagramas enviados, "
+ fallidos.get() + " fallidos");
}
}Un recolector mínimo para probarlo
package com.nexussoftware.bibliotech.red;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.charset.StandardCharsets;
import java.util.Map;
import java.util.TreeMap;
/** Recolector de telemetria minimo: muestra un panel en la consola. */
public class RecolectorTelemetria {
public static void main(String[] args) throws Exception {
int puerto = args.length > 0 ? Integer.parseInt(args[0]) : 9098;
try (DatagramSocket socket = new DatagramSocket(puerto)) {
System.out.println("Recolector escuchando en UDP:" + puerto);
byte[] buffer = new byte[512];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
Map<String, String> panel = new TreeMap<>(); // ordenado por clave
long recibidos = 0;
while (true) {
paquete.setLength(buffer.length);
socket.receive(paquete);
recibidos++;
String medida = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8);
int dosPuntos = medida.indexOf(':');
int barra = medida.indexOf('|');
if (dosPuntos < 0 || barra < 0 || barra < dosPuntos) {
continue; // medida mal formada: se ignora
}
panel.put(medida.substring(0, dosPuntos),
medida.substring(dosPuntos + 1, barra));
// Repintamos el panel entero con cada medida.
System.out.print("\033[H\033[2J"); // limpiar pantalla
System.out.println("=== TELEMETRIA BIBLIOTECH ===");
System.out.println("Datagramas recibidos: " + recibidos);
System.out.println();
for (Map.Entry<String, String> e : panel.entrySet()) {
System.out.printf(" %-38s %s%n", e.getKey(), e.getValue());
}
}
}
}
}=== TELEMETRIA BIBLIOTECH ===
Datagramas recibidos: 145
bibliotech.conexiones.activas 3
bibliotech.consultas.total 8471
bibliotech.memoria.mb 34
bibliotech.peticiones.total 12093
bibliotech.prestamos.activos 12Prueba clave: para el recolector con Ctrl+C y mira el servidor. No pasa absolutamente nada. Ni un error, ni un retraso, ni una traza. El servidor sigue atendiendo a los puestos exactamente igual. Vuelve a arrancar el recolector y las medidas reaparecen en cinco segundos.
Eso es imposible de conseguir con TCP sin escribir bastante código de reconexión, y es precisamente la razón por la que la telemetría de casi toda la industria —StatsD, la parte de métricas de Datadog, los metrics de Graphite— viaja por UDP.
- El mismo caso resuelto con TCP y con UDP
Cerremos con la comparación directa. El caso: consultar la disponibilidad de un libro.
Con TCP (lo de 09-02 y 09-03)
try (ClienteCatalogo cliente = new ClienteCatalogo("192.168.1.50", 9090)) {
cliente.conectar(); // saludo de 3 vias + saludo BTCP
FichaRed ficha = cliente.consultar("978-0000000001"); // peticion + respuesta
System.out.println(ficha.disponible());
} // SALIR + cierre de 4 mensajesCon UDP
PeticionUdpFiable peticion = new PeticionUdpFiable(
InetAddress.getByName("192.168.1.50"), 9099, 3, 300);
String respuesta = peticion.pedir("CONSULTA 978-0000000001");
if (respuesta == null) {
System.out.println("Sin respuesta tras 3 intentos");
} else {
System.out.println(respuesta);
}La comparación
| Criterio | TCP | UDP |
|---|---|---|
| Viajes de red | 3 (establecer) + 1 (consulta) + 2 (cerrar) = 6 | 1 |
| Latencia total en LAN (0,5 ms/viaje) | ~3 ms | ~0,5 ms |
| Latencia total a 100 ms de distancia | ~600 ms | ~100 ms |
| Bytes de cabecera | 20+ por segmento, muchos segmentos | 8, un solo datagrama |
| Estado en el servidor | Un socket y un hilo del pool | Ninguno |
| Clientes simultáneos que aguanta | Los del pool (16) | Miles con un hilo |
| Si se pierde un paquete | TCP retransmite solo | Tú reintentas |
| Si la respuesta no cabe en 1400 bytes | Sin problema | Hay que trocear a mano |
| Fiabilidad | Del protocolo | Tuya |
| Código necesario | Más en el cliente, mucho más en el servidor | Menos en total |
El veredicto para BiblioTech
| Operación | Transporte | Motivo |
|---|---|---|
CONSULTA de un ISBN |
TCP | Cabe en UDP, pero el catálogo ya está en TCP y la consulta va dentro de una sesión con varias operaciones |
LISTA completa |
TCP | La respuesta supera fácilmente los 1400 bytes: trocearla a mano sería reinventar TCP |
PRESTAR |
TCP | No es idempotente. Un reintento podría registrar dos préstamos |
| Descubrimiento | UDP | Necesita difusión. TCP no puede |
| Telemetría | UDP | No debe bloquear ni importar la pérdida |
| Avisos internos | UDP multicast | Uno a muchos, sin conexiones |
La conclusión general, que vale más allá de BiblioTech: UDP no sustituye a TCP, complementa a TCP. Un sistema real usa los dos, cada uno donde encaja. Y el criterio de decisión no es la velocidad, sino tres preguntas: ¿el dato viejo sigue valiendo?, ¿necesito hablar con varios a la vez? y ¿la operación es idempotente?
Errores Comunes y Consejos
No restaurar setLength() antes de cada receive(). El error número uno. El primer mensaje llega bien y todos los siguientes quedan truncados a esa misma longitud, sin ningún aviso.
Usar paquete.getData() sin getOffset() y getLength(). El error número dos. Arrastras el contenido anterior del buffer y produces datos corruptos que parecen legítimos.
Creer que send() sin excepción significa que llegó. No significa nada. send() tiene éxito aunque el destino esté apagado. Si necesitas saber si llegó, el receptor tiene que confirmarlo.
No poner setSoTimeout antes de un receive(). Con UDP la respuesta puede perderse legítimamente. Sin tiempo límite, tu hilo espera para siempre una respuesta que nunca va a llegar.
Enviar datagramas grandes. Por encima de la MTU se fragmentan, y perder un solo fragmento pierde el datagrama entero: la fragmentación multiplica la tasa de pérdida. Además, muchos cortafuegos descartan fragmentos. Mantente en 1400 bytes o, si quieres estar muy seguro, en 512.
No prever el truncamiento. Si llega un datagrama mayor que tu buffer, Java lo corta en silencio. Pide un byte de más y comprueba si getLength() alcanzó el máximo.
Olvidar setBroadcast(true). Enviar a una dirección de difusión sin él lanza SocketException: Permission denied, y el mensaje no orienta demasiado.
Usar connect() en un socket que espera respuesta de difusión. La respuesta viene de la IP real del servidor, no de la de difusión, y connect() la filtraría. En el buscador de descubrimiento no se puede usar.
Usar joinGroup(InetAddress). Está obsoleto desde Java 14. En una máquina con varias interfaces —y hoy casi todas las tienen, con Docker, VPN o Wi-Fi más Ethernet— el sistema elige una interfaz que probablemente no es la tuya. Usa joinGroup(SocketAddress, NetworkInterface).
Elegir una dirección de multidifusión cualquiera. Usa el rango 239.x.x.x, que está reservado a uso privado. Los demás rangos están asignados a protocolos concretos.
Reimplementar TCP sobre UDP. Si acabas escribiendo confirmaciones, ventanas, retransmisiones y control de congestión, usa TCP: está mejor probado que lo que vayas a escribir tú.
Reintentar sin espera creciente. Reintentar al mismo ritmo contra un servidor saturado garantiza que siga saturado. Duplica el plazo en cada intento y pon un límite.
Usar un contador predecible como identificador de petición. Permite a un tercero adivinarlo y enviar una respuesta falsa antes que la real. Usa un valor aleatorio.
Responder a paquetes que no entiendes. Convierte tu servicio en un amplificador para ataques de denegación de servicio reflejada: el atacante falsifica la IP de origen y las respuestas caen sobre su víctima. Si no reconoces el formato, calla.
Dejar que una excepción escape de una tarea de scheduleAtFixedRate. La tarea se cancela en silencio y la telemetría deja de emitir sin que nadie se entere. Envuelve el cuerpo entero en un try/catch.
Consejo de diagnóstico. UDP es difícil de depurar precisamente porque no falla ruidosamente. Tres herramientas: nc -u -l 9095 hace de receptor UDP y te enseña exactamente qué llega; ss -ulnp lista los sockets UDP en escucha (la u en lugar de la t); y tcpdump -i any -n udp port 9091 -A te muestra los datagramas en vuelo con su contenido en texto. Si con tcpdump ves el paquete salir y no llegar, el problema está en la red o en un cortafuegos, no en tu código.
Ejercicios
Ejercicio 1: Medidor de pérdida y latencia UDP
Escribe un par SondaUdp (cliente) y ReflectorUdp (servidor) que midan la calidad de un enlace UDP, al estilo de una herramienta de diagnóstico de red.
Requisitos:
- El reflector devuelve cada datagrama tal cual, sin tocarlo, y no mantiene ningún estado.
- La sonda envía N datagramas numerados con marca de tiempo en nanosegundos, a un ritmo configurable (paquetes por segundo), sin esperar la respuesta de cada uno — envía y recibe en momentos distintos.
- Un hilo aparte recibe las respuestas y calcula la latencia de cada una a partir de la marca de tiempo que vuelve.
- Informe: enviados, recibidos, perdidos y porcentaje de pérdida; latencia mínima, media, mediana, p95 y máxima; y jitter (variación media entre latencias consecutivas), que es la métrica que decide si un enlace sirve para voz o vídeo.
- Detección de duplicados y de desorden.
- Prueba con tamaños de datagrama de 64, 512 y 1400 bytes, y comenta las diferencias.
Ejercicio 2: Chat de red local por multidifusión
Escribe ChatBiblioTech, un chat interno de Nexus Software sobre multidifusión, sin servidor central.
Requisitos:
- Grupo
239.10.10.20, puerto 9099, TTL 1,joinGroupcon la API moderna y elección explícita de interfaz. - Un hilo recibe y muestra los mensajes; el principal lee de consola y envía.
- Formato:
<marca> <usuario> <mensaje>, con la marca en milisegundos desde el arranque de la aplicación (nada dejava.time, que es 10-05). - Debe filtrar los mensajes propios para no verlos duplicados. Investiga las dos formas de hacerlo (por opción del socket y comparando el remitente con las direcciones locales) e implementa una, explicando la elección.
- Anuncio de entrada y salida al grupo (
*** Marta Ruiz se ha unido ***). - Validación estricta de todo lo recibido: longitud, caracteres de control y saneado antes de mostrar en consola.
- Prueba con tres instancias en la misma máquina y, si puedes, con dos máquinas.
Ejercicio 3: Transferencia fiable de fichero sobre UDP
Implementa EnvioFiableUdp y RecepcionFiableUdp para transferir el catalogo.csv de BiblioTech sobre UDP con fiabilidad construida a mano, usando el esquema de parada y espera.
Requisitos:
- Trocear el fichero en bloques de 1024 bytes de datos.
- Cabecera binaria por datagrama con
DataOutputStreamsobre unByteArrayOutputStream: número de secuencia (int), indicador de último bloque (boolean) y longitud de datos (int). - El receptor confirma cada bloque con un datagrama de confirmación que lleva el número de secuencia recibido.
- El emisor espera la confirmación con
setSoTimeouty reintenta hasta 5 veces con espera creciente; si se agotan, aborta con error. - El receptor debe detectar bloques duplicados (una confirmación perdida hace que el emisor reenvíe un bloque que ya se recibió) y reconfirmarlos sin escribirlos dos veces.
- Comprobación de integridad al final con una suma simple calculada sobre todos los bytes.
- Simula un 20 % de pérdida artificial en el emisor (descartando datagramas antes de enviarlos) y comprueba que la transferencia se completa igualmente.
- Informe final: bloques, retransmisiones, duplicados detectados, bytes y tiempo.
- Escribe una reflexión final comparando el resultado con hacerlo en tres líneas sobre TCP.
Soluciones
Solución 1
package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
/**
* Reflector UDP: devuelve cada datagrama tal cual, sin interpretarlo.
* Sin estado, sin hilos, sin conexiones. Un servidor UDP en su forma pura.
*/
public class ReflectorUdp {
public static void main(String[] args) throws IOException {
int puerto = args.length > 0 ? Integer.parseInt(args[0]) : 9100;
try (DatagramSocket socket = new DatagramSocket(puerto)) {
// Buffer generoso: aceptamos hasta 1500 bytes sin truncar.
byte[] buffer = new byte[1500];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
long reflejados = 0;
System.out.println("Reflector UDP en el puerto " + puerto);
while (true) {
paquete.setLength(buffer.length); // error clasico 1
socket.receive(paquete);
// Devolvemos EXACTAMENTE los bytes recibidos, ni uno mas:
// el paquete ya trae la direccion y el puerto del remitente,
// asi que send() lo devuelve a su origen sin tocar nada.
socket.send(paquete);
if (++reflejados % 1000 == 0) {
System.out.println("Reflejados: " + reflejados);
}
}
}
}
}package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.util.Arrays;
import java.util.HashSet;
import java.util.Set;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
/**
* Sonda UDP: mide perdida, latencia y jitter de un enlace.
* Envia y recibe en hilos separados, sin esperar respuesta por paquete,
* que es como se mide de verdad un enlace.
*/
public class SondaUdp {
private final String host;
private final int puerto;
private final int total;
private final int tamanoDatagrama;
private final int porSegundo;
// Estado compartido entre el hilo emisor y el receptor.
private final long[] latenciasUs;
private final Set<Integer> recibidos = new HashSet<>();
private volatile int duplicados = 0;
private volatile int desordenados = 0;
private volatile int recibidosTotal = 0;
public SondaUdp(String host, int puerto, int total,
int tamanoDatagrama, int porSegundo) {
this.host = host;
this.puerto = puerto;
this.total = total;
this.tamanoDatagrama = Math.max(tamanoDatagrama, 16); // cabecera minima
this.porSegundo = porSegundo;
this.latenciasUs = new long[total];
}
public void medir() throws IOException, InterruptedException {
try (DatagramSocket socket = new DatagramSocket()) {
socket.setSoTimeout(2000);
InetAddress destino = InetAddress.getByName(host);
CountDownLatch emisionTerminada = new CountDownLatch(1);
// --- Hilo receptor ---
// Recibe en paralelo al envio: asi la medida no incluye
// el tiempo de "esperar mi turno para enviar el siguiente".
Thread receptor = new Thread(() -> recibir(socket, emisionTerminada),
"sonda-receptor");
receptor.start();
// --- Emisor, en este hilo ---
long intervaloNs = 1_000_000_000L / porSegundo;
long siguiente = System.nanoTime();
for (int i = 0; i < total; i++) {
// Cabecera binaria: numero de secuencia + marca de tiempo.
// ByteBuffer usa big-endian por defecto: el orden de red.
ByteBuffer bb = ByteBuffer.allocate(tamanoDatagrama);
bb.putInt(i);
bb.putLong(System.nanoTime());
// El resto del datagrama es relleno hasta el tamano pedido.
socket.send(new DatagramPacket(bb.array(), tamanoDatagrama,
destino, puerto));
// Ritmo controlado: sin esto saturariamos el buffer del
// receptor y mediriamos nuestra propia saturacion, no la red.
siguiente += intervaloNs;
long esperaNs = siguiente - System.nanoTime();
if (esperaNs > 0) {
TimeUnit.NANOSECONDS.sleep(esperaNs);
}
}
emisionTerminada.countDown();
// Margen para que lleguen las ultimas respuestas en vuelo.
receptor.join(3000);
socket.close(); // desbloquea el receive del receptor
informe();
}
}
private void recibir(DatagramSocket socket, CountDownLatch emisionTerminada) {
byte[] buffer = new byte[1500];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
int ultimo = -1;
while (true) {
try {
paquete.setLength(buffer.length);
socket.receive(paquete);
long ahora = System.nanoTime();
if (paquete.getLength() < 12) {
continue; // no cabe ni la cabecera: se ignora
}
ByteBuffer bb = ByteBuffer.wrap(paquete.getData(),
paquete.getOffset(), paquete.getLength());
int secuencia = bb.getInt();
long enviado = bb.getLong();
if (secuencia < 0 || secuencia >= total) {
continue; // secuencia fuera de rango: paquete ajeno
}
synchronized (recibidos) {
if (!recibidos.add(secuencia)) {
duplicados++;
continue;
}
}
if (secuencia < ultimo) {
desordenados++;
}
ultimo = Math.max(ultimo, secuencia);
latenciasUs[secuencia] = (ahora - enviado) / 1000;
recibidosTotal++;
} catch (SocketTimeoutException e) {
// Dos segundos sin nada. Si la emision ya termino, paramos.
if (emisionTerminada.getCount() == 0) {
return;
}
} catch (IOException e) {
return; // socket cerrado: fin
}
}
}
private void informe() {
// Solo las latencias de los paquetes que efectivamente llegaron.
long[] validas = new long[recibidosTotal];
int j = 0;
for (int i = 0; i < total && j < validas.length; i++) {
if (latenciasUs[i] > 0) {
validas[j++] = latenciasUs[i];
}
}
validas = Arrays.copyOf(validas, j);
if (validas.length == 0) {
System.out.println("No se recibio ninguna respuesta.");
return;
}
// Jitter: variacion media entre latencias CONSECUTIVAS. Se calcula
// ANTES de ordenar, porque depende del orden temporal.
double sumaJitter = 0;
for (int i = 1; i < validas.length; i++) {
sumaJitter += Math.abs(validas[i] - validas[i - 1]);
}
double jitterUs = validas.length > 1 ? sumaJitter / (validas.length - 1) : 0;
long suma = 0;
for (long v : validas) {
suma += v;
}
long[] ordenadas = validas.clone();
Arrays.sort(ordenadas);
double perdida = 100.0 * (total - recibidosTotal) / total;
System.out.println();
System.out.println("=== SONDA UDP: " + host + ":" + puerto + " ===");
System.out.printf("%-24s %d bytes%n", "Tamano de datagrama", tamanoDatagrama);
System.out.printf("%-24s %d/s%n", "Ritmo", porSegundo);
System.out.println();
System.out.printf("%-24s %d%n", "Enviados", total);
System.out.printf("%-24s %d%n", "Recibidos", recibidosTotal);
System.out.printf("%-24s %d (%.2f%%)%n", "Perdidos",
total - recibidosTotal, perdida);
System.out.printf("%-24s %d%n", "Duplicados", duplicados);
System.out.printf("%-24s %d%n", "Desordenados", desordenados);
System.out.println();
System.out.println("--- LATENCIA (ms) ---");
System.out.printf("%-24s %.3f%n", "Minima", ordenadas[0] / 1000.0);
System.out.printf("%-24s %.3f%n", "Media",
(suma / (double) validas.length) / 1000.0);
System.out.printf("%-24s %.3f%n", "Mediana",
ordenadas[ordenadas.length / 2] / 1000.0);
System.out.printf("%-24s %.3f%n", "p95",
ordenadas[(int) (ordenadas.length * 0.95)] / 1000.0);
System.out.printf("%-24s %.3f%n", "Maxima",
ordenadas[ordenadas.length - 1] / 1000.0);
System.out.printf("%-24s %.3f%n", "Jitter medio", jitterUs / 1000.0);
}
public static void main(String[] args) throws Exception {
String host = args.length > 0 ? args[0] : "localhost";
int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 9100;
for (int tamano : new int[]{64, 512, 1400}) {
new SondaUdp(host, puerto, 1000, tamano, 500).medir();
Thread.sleep(500);
}
}
}Salida típica en localhost:
=== SONDA UDP: localhost:9100 ===
Tamano de datagrama 64 bytes
Ritmo 500/s
Enviados 1000
Recibidos 1000
Perdidos 0 (0,00%)
Duplicados 0
Desordenados 0
--- LATENCIA (ms) ---
Minima 0,041
Media 0,078
Mediana 0,069
p95 0,142
Maxima 1,884
Jitter medio 0,031Comentarios. Cuatro cosas que enseña este ejercicio.
Emitir y recibir en hilos distintos es lo que hace que la medida sea de la red y no de tu programa. Si esperaras la respuesta de cada paquete antes de enviar el siguiente, estarías midiendo un protocolo de parada y espera, y con 500 paquetes por segundo tu propio ritmo sería el factor dominante.
El control de ritmo con siguiente += intervaloNs acumula sobre el instante previsto en lugar de dormir un intervalo fijo. La diferencia importa: dormir "20 ms" en cada vuelta acumula el tiempo de procesamiento y el ritmo real acaba siendo más lento que el pedido. Calcular el instante absoluto siguiente corrige la deriva.
El jitter se calcula antes de ordenar y es la métrica que a menudo se olvida. Un enlace con 50 ms de latencia constante sirve perfectamente para una videollamada; uno con 20 ms de media pero 40 de jitter, no, porque el receptor no puede predecir cuándo llegará el siguiente fragmento y tiene que meter un buffer grande, que a su vez añade retardo.
Y sobre los tres tamaños: en localhost las diferencias entre 64, 512 y 1400 bytes son mínimas porque no hay red real. En un enlace de verdad verías que 1400 bytes tiene más latencia (hay que serializar más bits en el cable) y que subir a 1500 dispara la pérdida por fragmentación. Merece la pena probarlo entre dos máquinas si tienes ocasión.
Solución 2
package com.nexussoftware.bibliotech.red;
import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.DatagramPacket;
import java.net.InetAddress;
import java.net.InetSocketAddress;
import java.net.MulticastSocket;
import java.net.NetworkInterface;
import java.net.StandardSocketOptions;
import java.nio.charset.StandardCharsets;
import java.util.Enumeration;
import java.util.logging.Logger;
/**
* Chat interno de Nexus Software sobre multidifusion. Sin servidor central:
* todos son iguales, todos emiten al grupo y todos reciben de el.
*/
public class ChatBiblioTech implements AutoCloseable {
private static final Logger LOG = Logger.getLogger(ChatBiblioTech.class.getName());
/** Rango 239.x.x.x: ambito administrativo, uso privado. */
private static final String GRUPO = "239.10.10.20";
private static final int PUERTO = 9099;
private static final int MAXIMO_DATAGRAMA = 1400;
private static final int MAXIMO_MENSAJE = 500;
private final String usuario;
private final long arranque = System.currentTimeMillis();
private MulticastSocket socket;
private InetSocketAddress grupo;
private NetworkInterface interfaz;
private volatile boolean ejecutando = false;
public ChatBiblioTech(String usuario) {
this.usuario = usuario;
}
public void arrancar() throws IOException {
socket = new MulticastSocket(PUERTO);
socket.setTimeToLive(1); // no sale de la red local
// FILTRADO DE MENSAJES PROPIOS - Opcion elegida.
//
// Hay dos formas:
// (a) IP_MULTICAST_LOOP = false: el sistema no nos devuelve
// nuestros propios envios. Es limpia y no cuesta nada, PERO
// tambien impide que DOS INSTANCIAS EN LA MISMA MAQUINA se
// vean entre si, porque el filtro es por maquina, no por socket.
// (b) Comparar el remitente con las direcciones locales y filtrar
// en el codigo. Mas trabajo, pero permite probar con tres
// instancias en el mismo ordenador.
//
// Elegimos (b) porque el enunciado pide poder probar con tres
// instancias locales, y ademas es el comportamiento que se quiere
// en un chat: si abro dos ventanas, quiero ver las dos.
socket.setOption(StandardSocketOptions.IP_MULTICAST_LOOP, true);
grupo = new InetSocketAddress(InetAddress.getByName(GRUPO), PUERTO);
interfaz = elegirInterfaz();
// API moderna (Java 14+): con NetworkInterface explicita.
socket.joinGroup(grupo, interfaz);
ejecutando = true;
LOG.info(() -> "Unido a " + GRUPO + ":" + PUERTO
+ " por " + interfaz.getName());
}
private NetworkInterface elegirInterfaz() throws IOException {
Enumeration<NetworkInterface> nis = NetworkInterface.getNetworkInterfaces();
NetworkInterface respaldo = null;
while (nis.hasMoreElements()) {
NetworkInterface ni = nis.nextElement();
if (!ni.isUp() || !ni.supportsMulticast()) {
continue;
}
if (!ni.isLoopback()) {
return ni; // preferimos una interfaz real
}
respaldo = ni; // el bucle invertido sirve para pruebas locales
}
if (respaldo == null) {
throw new IOException("No hay ninguna interfaz con multidifusion");
}
return respaldo;
}
/** Bucle de recepcion. Se ejecuta en su propio hilo. */
private void recibir() {
byte[] buffer = new byte[MAXIMO_DATAGRAMA];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
while (ejecutando) {
try {
paquete.setLength(buffer.length); // error clasico 1
socket.receive(paquete);
// Filtro (b): descartamos lo que enviamos nosotros mismos.
if (esNuestro(paquete.getAddress(), paquete.getPort())) {
continue;
}
String mensaje = new String(paquete.getData(), paquete.getOffset(),
paquete.getLength(), StandardCharsets.UTF_8); // error clasico 2
// TODO lo que llega por el grupo viene de un desconocido.
if (mensaje.length() > MAXIMO_MENSAJE) {
LOG.warning("Mensaje demasiado largo descartado");
continue;
}
System.out.println(sanear(mensaje));
} catch (IOException e) {
if (!ejecutando) {
return; // cierre ordenado
}
LOG.warning("Error recibiendo: " + e.getMessage());
}
}
}
/**
* Un paquete es nuestro si viene de una direccion local Y de nuestro
* puerto de origen. Comparar solo la direccion no bastaria: filtraria
* tambien los mensajes de las otras instancias de la misma maquina.
*/
private boolean esNuestro(InetAddress remitente, int puertoRemitente) {
if (puertoRemitente != socket.getLocalPort()) {
return false;
}
try {
Enumeration<InetAddress> propias = interfaz.getInetAddresses();
while (propias.hasMoreElements()) {
if (propias.nextElement().equals(remitente)) {
return true;
}
}
} catch (Exception e) {
// Si no podemos comprobarlo, preferimos mostrar de mas que de menos.
return false;
}
return false;
}
public void enviar(String texto) throws IOException {
long marca = (System.currentTimeMillis() - arranque) / 1000;
String mensaje = String.format("[%3ds] %-14s %s", marca, usuario, texto);
byte[] datos = mensaje.getBytes(StandardCharsets.UTF_8);
if (datos.length > MAXIMO_DATAGRAMA) {
System.out.println("(mensaje demasiado largo, no enviado)");
return;
}
socket.send(new DatagramPacket(datos, datos.length,
InetAddress.getByName(GRUPO), PUERTO));
}
/**
* Sanea antes de imprimir en consola: un mensaje del grupo puede
* contener secuencias de escape ANSI que manipulen el terminal
* de quien lo lea (borrar la pantalla, mover el cursor, colores).
*/
private String sanear(String texto) {
StringBuilder sb = new StringBuilder(texto.length());
for (int i = 0; i < texto.length(); i++) {
char c = texto.charAt(i);
sb.append(Character.isISOControl(c) && c != '\t' ? '?' : c);
}
return sb.toString();
}
@Override
public void close() {
if (!ejecutando) {
return;
}
try {
enviar("*** se ha marchado ***");
} catch (IOException ignorada) {
// Nos vamos igual.
}
ejecutando = false;
try {
socket.leaveGroup(grupo, interfaz);
} catch (IOException e) {
LOG.fine("Fallo abandonando el grupo: " + e.getMessage());
}
socket.close(); // desbloquea el receive()
}
public static void main(String[] args) throws IOException {
String usuario = args.length > 0 ? args[0] : "Anonimo";
ChatBiblioTech chat = new ChatBiblioTech(usuario);
chat.arrancar();
Thread receptor = new Thread(chat::recibir, "chat-receptor");
receptor.setDaemon(true);
receptor.start();
Runtime.getRuntime().addShutdownHook(new Thread(chat::close));
chat.enviar("*** se ha unido ***");
System.out.println("Chat de BiblioTech. Escribe /salir para terminar.");
try (BufferedReader teclado = new BufferedReader(
new InputStreamReader(System.in, StandardCharsets.UTF_8))) {
String linea;
while ((linea = teclado.readLine()) != null) {
if (linea.equals("/salir")) {
break;
}
if (!linea.isBlank()) {
chat.enviar(linea);
}
}
}
chat.close();
}
}Prueba con tres terminales:
java -cp clases com.nexussoftware.bibliotech.red.ChatBiblioTech "Marta Ruiz"
java -cp clases com.nexussoftware.bibliotech.red.ChatBiblioTech "Diego Alonso"
java -cp clases com.nexussoftware.bibliotech.red.ChatBiblioTech "Nuria Vidal"Terminal de Marta:
Chat de BiblioTech. Escribe /salir para terminar.
[ 2s] Diego Alonso *** se ha unido ***
[ 5s] Nuria Vidal *** se ha unido ***
¿Alguien tiene Java Efectivo?
[ 12s] Diego Alonso Lo tengo yo, lo devuelvo manana
[ 18s] Nuria Vidal *** se ha marchado ***Comentarios. El punto interesante es el filtrado de los mensajes propios, y la razón de elegir la opción (b) está explicada en el código: IP_MULTICAST_LOOP = false es más limpio, pero el filtro lo aplica el sistema por máquina, no por socket, así que tres instancias en el mismo ordenador dejarían de verse entre sí y el ejercicio no se podría probar. La comparación manual exige además comparar dirección y puerto de origen, no solo la dirección: si compararas solo la dirección, filtrarías los mensajes de tus compañeros de máquina, que es exactamente lo que queríamos evitar.
Fíjate también en que el chat no tiene servidor. Es un sistema de igual a igual real, el modelo que se presentó en 09-01: cada nodo emite al grupo y recibe del grupo. Nadie es especial, nadie mantiene estado de nadie, y si una instancia se cae las demás ni se enteran. A cambio, no hay historial, no hay entrega garantizada y no hay forma de saber quién está conectado salvo por los anuncios — que también pueden perderse. Es el precio de no tener servidor.
Y el sanear antes de imprimir no es paranoia académica: un mensaje que contenga \033[2J borra la pantalla de quien lo reciba, y secuencias más elaboradas pueden hacer cosas peores en algunos terminales.
Solución 3
package com.nexussoftware.bibliotech.red;
import java.io.ByteArrayOutputStream;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.net.InetAddress;
import java.net.SocketTimeoutException;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.concurrent.ThreadLocalRandom;
import java.util.logging.Logger;
/**
* Transferencia fiable de fichero sobre UDP con parada y espera.
*
* Formato de cada datagrama de datos:
* [int ] numero de secuencia
* [byte ] 1 si es el ultimo bloque, 0 si no
* [int ] longitud de los datos
* [bytes ] datos
*
* Formato de la confirmacion:
* [int ] numero de secuencia confirmado
*/
public class EnvioFiableUdp {
private static final Logger LOG = Logger.getLogger(EnvioFiableUdp.class.getName());
private static final int DATOS_POR_BLOQUE = 1024;
private static final int CABECERA = 4 + 1 + 4; // int + byte + int
private static final int MAXIMO_INTENTOS = 5;
private static final int ESPERA_INICIAL_MS = 200;
/** Porcentaje de datagramas que se descartan a proposito, para probar. */
private final int perdidaSimulada;
private int retransmisiones = 0;
public EnvioFiableUdp(int perdidaSimulada) {
this.perdidaSimulada = perdidaSimulada;
}
public void enviar(String host, int puerto, Path fichero) throws IOException {
long tamano = Files.size(fichero);
long inicio = System.currentTimeMillis();
try (DatagramSocket socket = new DatagramSocket();
InputStream entrada = Files.newInputStream(fichero)) {
InetAddress destino = InetAddress.getByName(host);
// connect() filtra: solo aceptamos confirmaciones de este destino.
socket.connect(destino, puerto);
byte[] bloque = new byte[DATOS_POR_BLOQUE];
byte[] bufferAck = new byte[16];
DatagramPacket ack = new DatagramPacket(bufferAck, bufferAck.length);
int secuencia = 0;
long enviadosBytes = 0;
long sumaComprobacion = 0;
while (true) {
int leidos = entrada.read(bloque);
boolean ultimo = false;
if (leidos == -1) {
// Fin del fichero: enviamos un bloque vacio marcado
// como ultimo, para que el receptor sepa que ha terminado.
leidos = 0;
ultimo = true;
} else {
// Miramos si queda algo mas sin consumirlo:
// available() es orientativo, asi que marcamos el
// ultimo con un bloque vacio final. Mas simple y seguro.
for (int i = 0; i < leidos; i++) {
sumaComprobacion += bloque[i] & 0xFF;
}
enviadosBytes += leidos;
}
byte[] datagrama = construir(secuencia, ultimo, bloque, leidos);
if (!enviarConConfirmacion(socket, destino, puerto,
datagrama, secuencia, ack, bufferAck)) {
throw new IOException("El receptor no confirma el bloque "
+ secuencia + " tras " + MAXIMO_INTENTOS + " intentos");
}
if (ultimo) {
break;
}
secuencia++;
}
long ms = System.currentTimeMillis() - inicio;
System.out.println();
System.out.println("=== TRANSFERENCIA COMPLETADA ===");
System.out.printf("%-26s %s%n", "Fichero", fichero.getFileName());
System.out.printf("%-26s %d%n", "Bytes", enviadosBytes);
System.out.printf("%-26s %d%n", "Bloques", secuencia + 1);
System.out.printf("%-26s %d%n", "Retransmisiones", retransmisiones);
System.out.printf("%-26s %d%%%n", "Perdida simulada", perdidaSimulada);
System.out.printf("%-26s %d ms%n", "Tiempo", ms);
System.out.printf("%-26s %d%n", "Suma de comprobacion", sumaComprobacion);
System.out.printf("%-26s %.1f KB/s%n", "Velocidad",
ms == 0 ? 0 : enviadosBytes / 1024.0 / (ms / 1000.0));
}
}
/** Construye el datagrama con su cabecera binaria en big-endian. */
private byte[] construir(int secuencia, boolean ultimo, byte[] datos, int longitud)
throws IOException {
ByteArrayOutputStream bytes = new ByteArrayOutputStream(CABECERA + longitud);
DataOutputStream salida = new DataOutputStream(bytes);
salida.writeInt(secuencia);
salida.writeBoolean(ultimo);
salida.writeInt(longitud);
salida.write(datos, 0, longitud);
salida.flush();
return bytes.toByteArray();
}
/** Envia y espera confirmacion, reintentando con espera creciente. */
private boolean enviarConConfirmacion(DatagramSocket socket, InetAddress destino,
int puerto, byte[] datagrama, int secuencia,
DatagramPacket ack, byte[] bufferAck)
throws IOException {
int espera = ESPERA_INICIAL_MS;
for (int intento = 1; intento <= MAXIMO_INTENTOS; intento++) {
if (intento > 1) {
retransmisiones++;
}
// --- PERDIDA SIMULADA ---
// Se descarta el datagrama ANTES de enviarlo, para probar que
// el mecanismo de reintento funciona sin necesitar una red mala.
if (ThreadLocalRandom.current().nextInt(100) >= perdidaSimulada) {
socket.send(new DatagramPacket(datagrama, datagrama.length,
destino, puerto));
} else {
LOG.fine("Perdida simulada del bloque " + secuencia);
}
socket.setSoTimeout(espera);
long limite = System.currentTimeMillis() + espera;
while (System.currentTimeMillis() < limite) {
try {
ack.setLength(bufferAck.length); // error clasico 1
socket.receive(ack);
if (ack.getLength() < 4) {
continue;
}
int confirmado = ByteBuffer.wrap(ack.getData(),
ack.getOffset(), ack.getLength()).getInt();
if (confirmado == secuencia) {
return true;
}
// Confirmacion de un bloque anterior (llego tarde):
// se descarta y se sigue esperando la nuestra.
LOG.fine("Confirmacion desfasada: " + confirmado
+ ", esperabamos " + secuencia);
} catch (SocketTimeoutException e) {
break; // plazo agotado: siguiente intento
}
}
espera *= 2; // espera creciente
}
return false;
}
public static void main(String[] args) throws IOException {
Path fichero = Path.of(args.length > 0 ? args[0] : "catalogo.csv");
int perdida = args.length > 1 ? Integer.parseInt(args[1]) : 20;
new EnvioFiableUdp(perdida).enviar("localhost", 9101, fichero);
}
}package com.nexussoftware.bibliotech.red;
import java.io.IOException;
import java.io.OutputStream;
import java.net.DatagramPacket;
import java.net.DatagramSocket;
import java.nio.ByteBuffer;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;
/** Receptor de la transferencia fiable sobre UDP. */
public class RecepcionFiableUdp {
private static final Logger LOG = Logger.getLogger(RecepcionFiableUdp.class.getName());
private static final int DATOS_POR_BLOQUE = 1024;
private static final int CABECERA = 4 + 1 + 4;
private static final int MAXIMO_DATAGRAMA = CABECERA + DATOS_POR_BLOQUE;
public void recibir(int puerto, Path destino) throws IOException {
Files.createDirectories(destino.getParent() == null
? Path.of(".") : destino.getParent());
try (DatagramSocket socket = new DatagramSocket(puerto);
OutputStream salida = Files.newOutputStream(destino)) {
System.out.println("Esperando fichero en UDP:" + puerto);
socket.setSoTimeout(30_000); // abandonamos si nadie envia
byte[] buffer = new byte[MAXIMO_DATAGRAMA + 1];
DatagramPacket paquete = new DatagramPacket(buffer, buffer.length);
int esperado = 0;
int duplicados = 0;
long bytes = 0;
long sumaComprobacion = 0;
long inicio = System.currentTimeMillis();
while (true) {
paquete.setLength(buffer.length); // error clasico 1
socket.receive(paquete);
if (paquete.getLength() < CABECERA
|| paquete.getLength() > MAXIMO_DATAGRAMA) {
LOG.warning("Datagrama de tamano invalido descartado");
continue;
}
ByteBuffer bb = ByteBuffer.wrap(paquete.getData(),
paquete.getOffset(), paquete.getLength());
int secuencia = bb.getInt();
boolean ultimo = bb.get() != 0;
int longitud = bb.getInt();
// VALIDACION: la longitud viene de la red. Sin esto,
// un emisor malicioso provoca una excepcion o algo peor.
if (longitud < 0 || longitud > DATOS_POR_BLOQUE
|| longitud > bb.remaining()) {
LOG.warning("Longitud declarada invalida: " + longitud);
continue;
}
if (secuencia < esperado) {
// DUPLICADO: nuestra confirmacion anterior se perdio y
// el emisor reenvio. Hay que RECONFIRMAR pero NO escribir
// los datos otra vez, o el fichero saldria corrupto.
duplicados++;
confirmar(socket, paquete, secuencia);
continue;
}
if (secuencia > esperado) {
// Con parada y espera esto no deberia ocurrir: significa
// que perdimos un bloque intermedio. No confirmamos, y el
// emisor reenviara el que falta.
LOG.warning("Bloque " + secuencia + " fuera de orden; "
+ "esperabamos " + esperado);
continue;
}
// Bloque correcto y en orden: se escribe.
byte[] datos = new byte[longitud];
bb.get(datos);
salida.write(datos);
for (byte b : datos) {
sumaComprobacion += b & 0xFF;
}
bytes += longitud;
confirmar(socket, paquete, secuencia);
esperado++;
if (ultimo) {
salida.flush();
long ms = System.currentTimeMillis() - inicio;
System.out.println();
System.out.println("=== RECEPCION COMPLETADA ===");
System.out.printf("%-26s %s%n", "Guardado en", destino);
System.out.printf("%-26s %d%n", "Bytes", bytes);
System.out.printf("%-26s %d%n", "Bloques", esperado);
System.out.printf("%-26s %d%n", "Duplicados detectados", duplicados);
System.out.printf("%-26s %d ms%n", "Tiempo", ms);
System.out.printf("%-26s %d%n", "Suma de comprobacion",
sumaComprobacion);
return;
}
}
}
}
private void confirmar(DatagramSocket socket, DatagramPacket original, int secuencia)
throws IOException {
byte[] ack = ByteBuffer.allocate(4).putInt(secuencia).array();
// Se responde AL REMITENTE, que viene en el paquete original.
socket.send(new DatagramPacket(ack, ack.length,
original.getAddress(), original.getPort()));
}
public static void main(String[] args) throws IOException {
new RecepcionFiableUdp().recibir(9101, Path.of("recibidos", "catalogo.csv"));
}
}Prueba con 20 % de pérdida simulada:
Terminal 1 (receptor):
Esperando fichero en UDP:9101
=== RECEPCION COMPLETADA ===
Guardado en recibidos/catalogo.csv
Bytes 2847
Bloques 4
Duplicados detectados 1
Tiempo 834 ms
Suma de comprobacion 291476
Terminal 2 (emisor):
=== TRANSFERENCIA COMPLETADA ===
Fichero catalogo.csv
Bytes 2847
Bloques 4
Retransmisiones 3
Perdida simulada 20%
Tiempo 841 ms
Suma de comprobacion 291476Comentarios. Las sumas de comprobación coinciden: la transferencia es correcta pese al 20 % de pérdida. Tres puntos merecen atención.
La detección de duplicados es imprescindible y su lógica no es obvia. Un duplicado no ocurre porque la red duplique paquetes: ocurre porque nuestra confirmación se perdió. El emisor no la recibió, agotó el plazo y reenvió un bloque que nosotros ya habíamos escrito. Si lo escribiéramos otra vez, el fichero saldría con bloques repetidos. Y fíjate en que hay que reconfirmarlo: si nos limitáramos a ignorarlo, el emisor seguiría reenviando hasta agotar los intentos y abortaría una transferencia que en realidad iba bien.
La espera creciente domina el tiempo. 841 ms para 2847 bytes es una velocidad ridícula, y no es culpa de la red: son las esperas de 200, 400 y 800 ms de los reintentos. Parada y espera es el esquema de fiabilidad más simple y el más lento, porque solo hay un bloque en vuelo a la vez. TCP usa una ventana deslizante —muchos bloques en vuelo simultáneamente, confirmados acumulativamente— y por eso alcanza velocidades órdenes de magnitud mayores.
Y la reflexión que pedía el enunciado. Este par de clases suma unas 250 líneas de código cuidadosamente pensado: numeración, confirmaciones, tiempos límite, reintentos con espera creciente, detección de duplicados, validación de longitudes y comprobación de integridad. El equivalente con TCP es:
try (Socket socket = new Socket()) {
socket.connect(new InetSocketAddress(host, puerto), 3000);
Files.copy(fichero, socket.getOutputStream());
socket.shutdownOutput();
}Cuatro líneas, más rápido, más robusto y sin un solo bug propio. Y lo que hemos escrito todavía no tiene control de congestión, ni ventana deslizante, ni reordenación, ni protección frente a un emisor que satura al receptor.
Esa es exactamente la lección de la sección 2: si acabas implementando fiabilidad sobre UDP, casi siempre deberías estar usando TCP. La excepción legítima —y la razón de existir de QUIC— es cuando necesitas fiabilidad parcial o a tu medida, algo que TCP no ofrece porque es todo o nada. Pero para transferir un fichero, TCP gana sin discusión.
Conclusión
Has aprendido el otro transporte, y con él BiblioTech ha ganado dos capacidades que TCP simplemente no puede dar.
Sabes qué pierdes con UDP —entrega, orden, unicidad, control de flujo, control de congestión y cualquier noción de que el otro extremo siga vivo— y qué ganas: envío inmediato sin establecimiento, cabecera de 8 bytes, fronteras de mensaje que hacen innecesario todo el trabajo de delimitadores de 09-02, ausencia total de estado por cliente y, sobre todo, la capacidad de hablar con muchos a la vez. Y tienes el criterio para elegir, que no es la velocidad sino tres preguntas: ¿el dato viejo sigue valiendo?, ¿necesito uno a muchos? y ¿la operación es idempotente?
Manejas las dos clases y su asimetría: DatagramPacket es el sobre —con dos usos completamente distintos, datos más destino para enviar y buffer vacío para recibir— y DatagramSocket es el buzón, uno solo para todos los clientes, sin conexiones, sin pool y sin estado. Con las dos advertencias que definen el protocolo: send() casi nunca falla aunque el destino esté apagado, así que su éxito no significa nada; y connect() no conecta, solo instala un filtro local que resulta útil como defensa contra inyecciones.
Has visto demostrados los dos errores clásicos, que juntos causan la mayoría de los problemas de UDP en Java: no llamar a setLength(buffer.length) antes de cada receive(), que trunca todos los mensajes a la longitud del primero sin un solo aviso; y usar getData() sin getOffset() y getLength(), que arrastra restos del mensaje anterior y produce datos corruptos que parecen legítimos.
Entiendes el tamaño de los datagramas: el máximo teórico de 65.507 bytes no sirve de nada frente a la MTU real de 1500, porque pasar de ahí obliga a fragmentar, y perder un solo fragmento pierde el datagrama entero — la fragmentación multiplica la pérdida y muchos cortafuegos descartan fragmentos directamente. De ahí las cifras prudentes: 512 bytes para estar seguro en cualquier red, 1400 como límite práctico. Y sabes que un datagrama que no cabe en tu buffer se trunca en silencio, con el truco de pedir un byte de más para detectarlo.
Has comprobado con tus propias medidas que hay que tolerar pérdida, duplicación y desorden — con un 26 % de pérdida en localhost, sin red de por medio, en cuanto el emisor supera al receptor: eso es la ausencia de control de flujo. Y sabes construir fiabilidad a mano cuando hace falta, con sus cuatro piezas: identificador aleatorio por petición para emparejar respuestas y evitar inyecciones, setSoTimeout con reintento y espera creciente, límite de intentos, e idempotencia de la operación — el argumento decisivo por el que PRESTAR se queda en TCP.
Conoces la difusión, que llega a toda la red local, no atraviesa routers y no existe en IPv6; y la multidifusión, que es su versión bien hecha: grupos a los que uno se suscribe voluntariamente, rango privado 239.x.x.x, TTL 1 para no salir de la red local, y la API moderna joinGroup(SocketAddress, NetworkInterface) en lugar de la obsoleta, porque en cualquier máquina con Docker, VPN o Wi-Fi más Ethernet la interfaz que elige el sistema no es la que quieres.
Y BiblioTech ha ganado piezas de verdad. RespondedorDescubrimiento y BuscadorServidor implementan BTDP/1: los puestos de trabajo preguntan por difusión «¿dónde está BiblioTech?» y el servidor responde por unicast con su dirección y su puerto TCP — la configuración manual de la IP en cada puesto ha desaparecido, y eso era literalmente imposible con TCP. EmisorTelemetria envía métricas cada pocos segundos con el ScheduledExecutorService de 08-05, sin bloquear jamás al servidor y sin que le importe que el recolector esté caído: puedes apagarlo con Ctrl+C y el servidor ni se inmuta. Más ServidorEcoUdp, PeticionUdpFiable, AvisosMulticast, RecolectorTelemetria y las clases de los ejercicios: SondaUdp con su medida de jitter, ChatBiblioTech sin servidor central, y el par de transferencia fiable que demuestra —en 250 líneas frente a 4— por qué reimplementar TCP sobre UDP casi nunca es buena idea.
Y tienes el veredicto claro para BiblioTech, que resume la lección: UDP no sustituye a TCP, lo complementa. Consulta, lista y préstamo van por TCP porque necesitan fiabilidad, respuestas grandes y no son idempotentes; descubrimiento, telemetría y avisos van por UDP porque necesitan uno a muchos, no deben bloquear y toleran la pérdida. Un sistema real usa los dos.
Hasta aquí has trabajado en el nivel del transporte: bytes, sockets, datagramas, protocolos que has diseñado tú. En la próxima lección, URL y HttpURLConnection, subes un nivel. Vas a dejar de inventar protocolos y a usar el que ya domina el mundo: HTTP. Verás la anatomía de una URL y las clases URL y URI con sus diferencias, y por qué codificar los parámetros con URLEncoder es imprescindible en cuanto aparece un acento o un espacio. Verás HTTP explicado a fondo —petición, respuesta, métodos, códigos de estado, cabeceras— con un intercambio real mostrado en texto plano, y con la conexión que lo une todo: eso es exactamente lo que viaja por el socket de 09-02, solo que ahora el protocolo lo ha diseñado otro y lo entiende medio planeta. Aprenderás URL.openStream() para el caso simple y HttpURLConnection para el control real —métodos, cabeceras, tiempos límite obligatorios, códigos de estado, y el error clásico de no leer getErrorStream() cuando llega un 404—, y descargarás un fichero binario a disco combinándolo con el módulo 7. Con una valoración honesta por delante: HttpURLConnection es una API vieja, verbosa y llena de trampas, y por eso Java 11 trajo una nueva que verás en 09-06 — pero sigue viva en montañas de código heredado y hay que saber leerla. BiblioTech empezará a hablar con el mundo exterior: consultará un servicio de metadatos por ISBN y se descargará la portada de un libro.
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
