En la lección anterior diseñaste BTCP/1, el protocolo de consulta del catálogo de BiblioTech: un protocolo de texto, sobre TCP, en el puerto 9090, con mensajes delimitados por salto de línea y respuestas con códigos de tres dígitos. Ahora toca implementarlo. Y la buena noticia es que la mayor parte del trabajo ya la sabes hacer.

Un socket es un extremo de una conversación entre dos programas. Una vez establecido, un Socket de Java te entrega un InputStream y un OutputStream. Exactamente los mismos que usaste en el módulo 7 para leer y escribir ficheros. Los mismos decoradores, el mismo InputStreamReader con charset explícito, el mismo BufferedReader, el mismo try-with-resources. Una vez conectado, la red es E/S que ya sabes hacer.

Lo nuevo, y lo que ocupa esta lección, es todo lo que rodea a esa E/S: cómo se establece la conexión y con qué tiempo límite, qué opciones tiene un socket y cuándo importan, cómo se cierra bien —y qué significa cerrar solo la mitad—, qué te dice cada excepción de red sobre lo que ha pasado realmente, y un error concreto que cometen absolutamente todos los que empiezan y que deja al otro extremo esperando para siempre.

Al terminar tendrás el cliente de BiblioTech funcionando contra un servidor que harás tú a mano con nc. El servidor de verdad llega en 09-03.

Contenido

  1. El socket y los flujos del módulo 7
  2. La clase Socket: conectar
  3. connect con tiempo límite e InetSocketAddress
  4. Escribir y leer texto sobre el socket
  5. El bug número uno: olvidar el vaciado
  6. El protocolo por líneas y el problema del delimitador
  7. Cierre correcto y try-with-resources
  8. Media conexión: shutdownOutput y shutdownInput
  9. Opciones del socket
  10. setSoTimeout y SocketTimeoutException
  11. setTcpNoDelay y el algoritmo de Nagle
  12. Las excepciones de red y qué significa cada una
  13. Datos binarios con DataOutputStream y DataInputStream
  14. Por qué no enviar objetos serializados a un cliente no confiable
  15. BiblioTech: el cliente de catálogo completo
  16. Errores Comunes y Consejos
  17. Ejercicios

  1. El socket y los flujos del módulo 7

Antes de nada, la idea que estructura toda la lección:

graph LR
    subgraph CLIENTE
    A["Tu codigo"] --> B["PrintWriter"]
    B --> C["BufferedWriter"]
    C --> D["OutputStreamWriter<br/>UTF-8"]
    D --> E["socket.getOutputStream()"]
    end
    E -->|bytes por la red| F
    subgraph SERVIDOR
    F["socket.getInputStream()"] --> G["InputStreamReader<br/>UTF-8"]
    G --> H["BufferedReader"]
    H --> I["Tu codigo"]
    end

Ese diagrama es exactamente el del módulo 7, con una sola diferencia: donde antes había un FileOutputStream y un FileInputStream, ahora hay un socket. Todo lo demás —el patrón Decorador, el puente OutputStreamWriter entre bytes y caracteres, el buffer, el charset explícito— es idéntico.

Concepto del módulo 7 Su equivalente en red
new FileInputStream(fichero) socket.getInputStream()
new FileOutputStream(fichero) socket.getOutputStream()
El fichero existe o no El servidor acepta o rechaza
read() devuelve -1 al llegar al final read() devuelve -1 cuando el otro cierra
El fichero está completo al abrirlo Los datos llegan poco a poco
Cerrar libera un descriptor Cerrar termina la conversación

Las dos filas del final son las que marcan la diferencia real. Un fichero está entero cuando lo abres; un socket te va entregando bytes conforme llegan, y una lectura puede devolverte menos de lo que pediste simplemente porque el resto todavía viaja por el cable. Esa es la raíz del problema del delimitador que verás en el apartado 6.

  1. La clase Socket: conectar

java.net.Socket representa un socket TCP del lado del cliente. La forma más directa de usarlo es su constructor, que conecta durante la construcción:

// El constructor CONECTA. Si vuelve, ya estas conectado.
// Si no puede, lanza excepcion y el objeto nunca llega a existir.
Socket socket = new Socket("localhost", 9090);

Ese constructor hace tres cosas de golpe:

  1. Resuelve el nombre localhost a una dirección (DNS o /etc/hosts).
  2. Crea el socket y le asigna un puerto efímero local.
  3. Ejecuta el saludo de tres vías contra el puerto 9090 del destino.

Y puede fallar en cualquiera de las tres, siempre con una IOException o subclase:

import java.io.IOException;
import java.net.ConnectException;
import java.net.Socket;
import java.net.UnknownHostException;

try (Socket socket = new Socket("localhost", 9090)) {

    System.out.println("Conectado a       : " + socket.getInetAddress().getHostAddress());
    System.out.println("Puerto remoto     : " + socket.getPort());
    System.out.println("Direccion local   : " + socket.getLocalAddress().getHostAddress());
    System.out.println("Puerto local      : " + socket.getLocalPort());

} catch (UnknownHostException e) {
    // Fallo en el paso 1: el nombre no se pudo resolver.
    System.err.println("No existe el equipo: " + e.getMessage());
} catch (ConnectException e) {
    // Fallo en el paso 3: la maquina respondio RST. No hay nadie escuchando.
    System.err.println("Conexion rechazada: no hay servidor en ese puerto");
} catch (IOException e) {
    // Cualquier otro fallo de red.
    System.err.println("Fallo de red: " + e.getMessage());
}

Salida si hay un servidor escuchando:

Conectado a       : 127.0.0.1
Puerto remoto     : 9090
Direccion local   : 127.0.0.1
Puerto local      : 51422

Ahí tienes la cuádrupla de la lección anterior hecha código: (127.0.0.1:51422) hablando con (127.0.0.1:9090). El puerto local no lo has elegido tú: lo asignó el sistema operativo de su rango efímero.

Métodos de consulta útiles

Método Devuelve
getInetAddress() La dirección del otro extremo
getPort() El puerto del otro extremo
getLocalAddress() / getLocalPort() Los tuyos
getRemoteSocketAddress() Ambos del otro extremo, como SocketAddress
isConnected() Si alguna vez se conectó — no si sigue vivo
isClosed() Si tú lo cerraste — no si el otro cerró
isInputShutdown() / isOutputShutdown() Si has cerrado media conexión

Aviso importante sobre isConnected(). Es la trampa más citada de la API. isConnected() devuelve true desde el momento en que la conexión se estableció, y sigue devolviendo true aunque el otro extremo se haya caído, porque TCP no avisa de nada hasta que intentas usar el socket. No existe ningún método que responda "¿sigue vivo el otro?". La única forma de saberlo es intentar leer o escribir y ver qué pasa: un read() que devuelve -1 significa que el otro cerró ordenadamente; una SocketException significa que se rompió. Cualquier código que dependa de isConnected() para decidir si sigue habiendo conversación está mal.

  1. connect con tiempo límite e InetSocketAddress

El constructor new Socket(host, puerto) tiene un problema serio: no admite tiempo límite de conexión. Si la máquina destino no responde —está apagada, o hay un cortafuegos que descarta los paquetes en silencio— el constructor se queda bloqueado hasta que el sistema operativo se rinde, y ese plazo por defecto puede ser de más de un minuto en Linux, incluso de varios minutos.

Un minuto bloqueando el hilo del menú de BiblioTech no es aceptable. La solución es separar la creación de la conexión:

import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;

// 1. Se crea el socket SIN conectar (constructor sin argumentos).
Socket socket = new Socket();

// 2. Se describe el destino con un InetSocketAddress: par (host, puerto).
InetSocketAddress destino = new InetSocketAddress("192.168.1.50", 9090);

// 3. Se conecta con tiempo limite explicito en milisegundos.
try {
    socket.connect(destino, 3000);      // 3 segundos como maximo
} catch (SocketTimeoutException e) {
    // El plazo se agoto: la maquina no ha respondido al SYN.
    // Fallo TRANSITORIO: puede tener sentido reintentar.
    System.err.println("El servidor no responde en 3 s");
}

InetSocketAddress es simplemente el par (dirección, puerto) del que hablábamos en 09-01. Tiene dos formas de construirse, y la diferencia importa:

// Resuelve el nombre AHORA. Si no resuelve, isUnresolved() sera true
// (ojo: NO lanza excepcion aqui; la lanzara connect()).
InetSocketAddress a = new InetSocketAddress("servidor.nexussoftware.local", 9090);

// No resuelve nada: deja el nombre tal cual para que lo resuelva
// quien conecte (util con proxies SOCKS, que resuelven ellos).
InetSocketAddress b = InetSocketAddress.createUnresolved("servidor.local", 9090);

// Con una InetAddress ya resuelta, sin consultar DNS.
InetSocketAddress c = new InetSocketAddress(InetAddress.getLoopbackAddress(), 9090);

Regla firme para el resto del módulo: en código de producción, nunca uses el constructor que conecta. Usa siempre new Socket() + connect(destino, tiempoLimite). Y no confundas los dos tiempos límite que existen, porque son distintos y necesitas los dos:

Tiempo límite Se fija con Cubre
De conexión connect(addr, ms) Cuánto se espera a que se establezca la conexión
De lectura setSoTimeout(ms) Cuánto se espera a que lleguen datos en cada read()

Un socket puede conectar en 5 ms y luego quedarse una hora sin recibir nada. El primero no te protege del segundo.

  1. Escribir y leer texto sobre el socket

Con la conexión hecha, entramos en terreno conocido. Para BTCP/1, que es un protocolo de texto por líneas en UTF-8, la pila correcta es esta:

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

try (Socket socket = new Socket()) {
    socket.connect(new InetSocketAddress("localhost", 9090), 3000);
    socket.setSoTimeout(10_000);

    // SALIDA: tu texto -> caracteres -> bytes UTF-8 -> red
    //   PrintWriter        da println() y formato
    //   BufferedWriter     acumula para no hacer una escritura de red por caracter
    //   OutputStreamWriter es el PUENTE caracteres -> bytes, con charset explicito
    PrintWriter escritor = new PrintWriter(
            new BufferedWriter(
                    new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)),
            true);                      // <-- autoFlush: vacia en cada println()

    // ENTRADA: red -> bytes UTF-8 -> caracteres -> lineas
    BufferedReader lector = new BufferedReader(
            new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));

    String saludo = lector.readLine();          // "200 BIBLIOTECH BTCP/1"
    System.out.println("S: " + saludo);

    escritor.println("CONSULTA 978-0000000001");
    System.out.println("C: CONSULTA 978-0000000001");

    String respuesta = lector.readLine();       // "200 OK 978-...|Java Efectivo|LIBRO|true"
    System.out.println("S: " + respuesta);

    escritor.println("SALIR");
    System.out.println("S: " + lector.readLine());   // "221 ADIOS"
}

Cada capa está ahí por una razón concreta:

Clase Por qué está
OutputStreamWriter(..., UTF_8) El puente de caracteres a bytes. El charset explícito es obligatorio: sin él se usa el de la plataforma, y cliente y servidor pueden no coincidir
BufferedWriter Sin él, cada carácter sería potencialmente un paquete de red. Con él, se acumula y se manda de una vez
PrintWriter Aporta println(), que añade el delimitador de línea del protocolo, y printf()
InputStreamReader(..., UTF_8) El puente inverso, con el mismo charset
BufferedReader Aporta readLine(), que acumula bytes hasta encontrar el salto de línea. Es la pieza que resuelve el problema de las fronteras de mensaje

Sobre PrintWriter y las excepciones. PrintWriter no lanza IOException: se traga los errores y los expone a través de checkError(). Es cómodo para escribir en consola y peligroso en red, porque un fallo de escritura puede pasar desapercibido. Si necesitas enterarte, comprueba escritor.checkError() después de escribir, o usa BufferedWriter directamente con write() y newLine(), que sí lanzan. En el cliente de BiblioTech comprobaremos checkError().

Sobre el delimitador de línea. println() escribe el separador de la plataforma: \n en Linux y macOS, \r\n en Windows. Un protocolo de red no puede depender de en qué sistema corre cada extremo. La buena noticia es que readLine() acepta \n, \r y \r\n indistintamente, así que en la práctica interopera; pero si el protocolo exige \r\n estricto (HTTP lo exige), hay que escribirlo a mano con print("...\r\n"). En BTCP/1 hemos especificado \n, y para ser rigurosos el cliente lo escribirá explícitamente.

  1. El bug número uno: olvidar el vaciado

Este es el error que comete todo el mundo la primera vez, y merece una demostración completa porque su síntoma es engañoso: no hay excepción, no hay error, no hay nada en el log. El programa simplemente se queda parado para siempre.

El código roto

// CLIENTE ROTO - no lo copies, es el ejemplo de lo que NO hay que hacer.
try (Socket socket = new Socket()) {
    socket.connect(new InetSocketAddress("localhost", 9090), 3000);

    PrintWriter escritor = new PrintWriter(
            new BufferedWriter(
                    new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)));
    //          ^^^^^ falta el "true" del autoFlush

    BufferedReader lector = new BufferedReader(
            new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));

    escritor.println("CONSULTA 978-0000000001");   // Se queda en el BUFFER.

    String respuesta = lector.readLine();          // <-- SE BLOQUEA AQUI PARA SIEMPRE
    System.out.println(respuesta);
}

Qué ha pasado exactamente

sequenceDiagram
    participant App as Codigo cliente
    participant Buf as BufferedWriter
    participant Red as Red
    participant Srv as Servidor
    App->>Buf: println("CONSULTA ...")
    Note over Buf: 24 bytes guardados en memoria.<br/>El buffer son 8192 bytes: aun cabe mas,<br/>asi que NO se envia nada.
    App->>Red: readLine()
    Note over App,Red: El cliente espera una respuesta...
    Note over Srv: ...a una peticion que nunca ha llegado.
    Note over App,Srv: INTERBLOQUEO. Los dos esperan al otro.<br/>Sin excepcion. Sin traza. Para siempre.

El BufferedWriter tiene un buffer de 8192 caracteres por defecto. Sus 24 caracteres caben de sobra, así que hace lo que se le ha pedido: esperar a tener más antes de gastar una operación de red. En un fichero eso es una optimización sin consecuencias, porque al cerrar el fichero se vacía y todo llega. En red es un interbloqueo, porque el otro extremo está esperando esos bytes para poder responder.

Y fíjate en lo cruel del detalle: el mismo código funcionaría con un mensaje de más de 8192 caracteres, porque el buffer se desbordaría y se enviaría solo. Es la clase de bug que "funciona en mi máquina" con datos grandes y falla en producción con datos pequeños.

Las tres formas de arreglarlo

// FORMA 1 (recomendada para protocolos de linea): autoFlush en PrintWriter.
// El segundo argumento true hace que println(), printf() y format()
// vacien automaticamente. OJO: print() SIN salto de linea NO vacia.
PrintWriter escritor = new PrintWriter(
        new BufferedWriter(
                new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8)),
        true);
escritor.println("CONSULTA 978-0000000001");    // se envia solo

// FORMA 2: vaciado explicito. Obligatoria si escribes con print() sin salto,
// o si el protocolo usa \r\n y escribes el terminador a mano.
escritor.print("CONSULTA 978-0000000001\n");
escritor.flush();                                // <-- imprescindible

// FORMA 3: BufferedWriter directo, que ademas SI lanza IOException.
BufferedWriter bw = new BufferedWriter(
        new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
bw.write("CONSULTA 978-0000000001");
bw.write('\n');
bw.flush();

La regla que hay que memorizar: en red, después de enviar algo que espera respuesta, hay que vaciar. Sin excepciones. Si usas autoFlush, asegúrate de escribir siempre con println y no con print, porque autoFlush solo actúa en println, printf y format — un print("SALIR\n") con autoFlush activado no vacía, y vuelves a tener el mismo interbloqueo con un código que aparentemente lo hace bien.

El síntoma reconocible. Si tu programa de red "se queda colgado" sin error, sospecha primero de esto. El segundo sospechoso es no haber puesto setSoTimeout, que convierte el bloqueo eterno en una SocketTimeoutException diagnosticable. Con las dos cosas bien hechas, este bug se vuelve imposible de sufrir en silencio.

  1. El protocolo por líneas y el problema del delimitador

En 09-01 vimos que TCP es un flujo de bytes sin fronteras de mensaje. Conviene ver ahora exactamente qué significa eso en el código.

// Lo que hace el cliente:
escritor.println("CONSULTA 111");
escritor.println("LISTA");

// Lo que puede leer el servidor con un read() sobre bytes crudos:
//   Caso A: "CONSULTA 111\nLISTA\n"    (los dos mensajes juntos)
//   Caso B: "CONSULTA 111\n"           luego "LISTA\n"    (uno y uno)
//   Caso C: "CONSU"                    luego "LTA 111\nLIS"   luego "TA\n"

Los tres casos son legales y ocurren de verdad, según el tamaño de los buffers, la MTU de la red y el algoritmo de Nagle. Un servidor que suponga el caso B funciona en localhost y falla en producción.

BufferedReader.readLine() resuelve esto por ti. Internamente mantiene su propio buffer, y cuando le pides una línea sigue leyendo del socket cuantas veces haga falta hasta encontrar un salto de línea. Devuelve la línea sin el terminador.

String linea;
while ((linea = lector.readLine()) != null) {
    // Aqui 'linea' es SIEMPRE un mensaje completo del protocolo.
    // readLine() ya ha resuelto el troceado por ti.
    procesar(linea);
}
// Salimos del bucle cuando readLine() devuelve null:
// el otro extremo ha cerrado su lado de la conexion.

Dos advertencias sobre readLine():

  1. Devuelve null al final del flujo, que en red significa "el otro cerró". No comprobarlo produce un NullPointerException con el primer cliente que se desconecte, y es un clásico absoluto. El bucle while ((linea = lector.readLine()) != null) no es un adorno estilístico.
  2. Bloquea hasta encontrar el salto de línea. Si el otro extremo envía "CONSULTA 111" sin \n y se queda callado, tu readLine() no vuelve. De ahí el setSoTimeout.

Y una limitación de diseño: readLine() no tiene límite de longitud. Un cliente malicioso puede enviar cien megabytes sin un solo salto de línea y tu servidor los acumulará en memoria hasta reventar. Es un vector de denegación de servicio real. En el servidor de 09-03 pondremos una lectura acotada.

Cuándo el delimitador no sirve

El delimitador de línea funciona si el delimitador no puede aparecer en los datos. Para BTCP/1 es cierto: ni un ISBN ni un título llevan saltos de línea. Pero si tuvieras que enviar un texto multilínea —la reseña de un libro, por ejemplo— tendrías dos salidas: escapar los saltos (\n como dos caracteres) o cambiar a longitud previa:

// Longitud previa: primero cuantos bytes vienen, luego los bytes.
byte[] datos = resena.getBytes(StandardCharsets.UTF_8);
salidaDatos.writeInt(datos.length);      // 4 bytes en big-endian
salidaDatos.write(datos);                // los datos, sin restricciones
salidaDatos.flush();

// Al leer:
int longitud = entradaDatos.readInt();
if (longitud < 0 || longitud > MAXIMO_PERMITIDO) {
    // IMPRESCINDIBLE: si no, un cliente malicioso pide un array de 2 GB
    // y provoca un OutOfMemoryError con 4 bytes de peticion.
    throw new ProtocoloException("Longitud declarada invalida: " + longitud);
}
byte[] datos = entradaDatos.readNBytes(longitud);
String resena = new String(datos, StandardCharsets.UTF_8);

Fíjate en la comprobación del límite. Siempre que leas una longitud que viene de la red, valídala antes de reservar memoria con ella. Es una de las vulnerabilidades más antiguas y más repetidas que existen.

  1. Cierre correcto y try-with-resources

Socket implementa Closeable, así que va en un try-with-resources sin discusión:

try (Socket socket = new Socket()) {
    socket.connect(destino, 3000);
    // ... conversacion ...
}   // socket.close() garantizado, tambien si salta una excepcion

Un detalle que ahorra código: cerrar el Socket cierra también sus flujos, y cerrar cualquiera de sus flujos cierra el socket. Están unidos. Por eso no hace falta —ni conviene— declarar el PrintWriter y el BufferedReader en el try-with-resources:

// INNECESARIO y ademas engañoso: da a entender que son recursos
// independientes cuando en realidad los tres son el mismo socket.
try (Socket socket = new Socket();
     PrintWriter escritor = new PrintWriter(...);
     BufferedReader lector = new BufferedReader(...)) {

Peor aún, ese código tiene un problema real de orden: try-with-resources cierra en orden inverso, así que cerraría el lector, luego el escritor —que intentará vaciar su buffer sobre un socket ya cerrado— y luego el socket. Declara solo el Socket.

Ahora bien, hay una cosa que el cierre automático no hace bien por ti: vaciar lo pendiente antes de cerrar cuando el orden importa. close() sobre un BufferedWriter sí vacía; pero si el socket se cierra primero por otra vía, lo pendiente se pierde. Con autoFlush y println no tienes ese problema, que es otra razón para usarlo.

  1. Media conexión: shutdownOutput y shutdownInput

Una conexión TCP tiene dos sentidos independientes, y se pueden cerrar por separado. Es lo que en 09-01 vimos reflejado en que el cierre son cuatro mensajes y no tres.

Operación Qué hace Qué ve el otro extremo
socket.shutdownOutput() Cierra tu sentido de escritura. Envía FIN Su read() devuelve -1; su readLine() devuelve null
socket.shutdownInput() Cierra tu sentido de lectura. Lo que llegue se descarta Nada inmediato; si sigue escribiendo, acabará con error
socket.close() Cierra los dos sentidos y libera el descriptor Su read() devuelve -1, y sus escrituras fallarán

El caso de uso clásico de shutdownOutput es un protocolo en el que el cliente envía todo, después el servidor responde todo, y la señal de "he terminado de enviar" es el fin del flujo:

// Cliente que sube un fichero al servidor y luego espera el resumen.
try (Socket socket = new Socket()) {
    socket.connect(destino, 3000);

    // 1. Enviar el contenido completo.
    try (OutputStream salida = socket.getOutputStream()) {
        // OJO: este try-with-resources cerraria el socket entero. Mal.
    }
}

Escrito así está mal, precisamente por lo que vimos: cerrar el flujo cierra el socket. La forma correcta es:

try (Socket socket = new Socket()) {
    socket.connect(destino, 3000);

    // 1. Enviar todo el contenido y VACIAR.
    OutputStream salida = socket.getOutputStream();
    Files.copy(Path.of("catalogo.csv"), salida);
    salida.flush();

    // 2. Decir "he terminado de hablar" SIN cerrar la conexion.
    //    El servidor vera fin de flujo y sabra que puede procesar.
    socket.shutdownOutput();

    // 3. Seguir escuchando: el sentido de lectura sigue abierto.
    BufferedReader lector = new BufferedReader(
            new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
    String resumen;
    while ((resumen = lector.readLine()) != null) {
        System.out.println("S: " + resumen);
    }
}   // ahora si, cierre completo

Sin shutdownOutput(), el servidor esperaría más datos indefinidamente y el cliente esperaría una respuesta: el mismo interbloqueo del apartado 5, por otra causa.

En BTCP/1 no lo necesitamos, porque el protocolo tiene un comando explícito de despedida (SALIR) y respuestas delimitadas. Pero conviene conocerlo, porque muchos protocolos reales —y varios ejercicios de este módulo— lo usan.

  1. Opciones del socket

Un socket tiene opciones que modifican el comportamiento de la pila TCP del sistema operativo. Estas son las que importan de verdad:

Opción Método Valor típico Para qué
Tiempo límite de lectura setSoTimeout(int ms) 5.000-30.000 La más importante. Evita bloqueos eternos en read()
Sin retardo (Nagle) setTcpNoDelay(boolean) true en protocolos interactivos Envía inmediatamente los mensajes pequeños
Mantener vivo setKeepAlive(boolean) true en conexiones largas Detecta pares muertos en conexiones ociosas
Reutilizar dirección setReuseAddress(boolean) true en servidores Permite volver a escuchar en un puerto en TIME_WAIT
Buffer de recepción setReceiveBufferSize(int) por defecto Ajuste fino para enlaces de alta latencia
Buffer de envío setSendBufferSize(int) por defecto Igual
Demora al cerrar setSoLinger(boolean, int) desactivado Controla qué pasa con los datos pendientes al cerrar
Prioridad de tráfico setTrafficClass(int) por defecto Sugerencia de calidad de servicio a la red

Tres avisos:

  • Casi todas hay que fijarlas antes de usar el socket, y algunas antes de conectar. setSoTimeout se puede cambiar en cualquier momento y afecta a las lecturas siguientes.
  • Los tamaños de buffer son sugerencias. El sistema operativo puede darte otra cosa; comprueba con getReceiveBufferSize() lo que realmente tienes. Salvo que estés optimizando un enlace concreto de alta latencia y alto ancho de banda, no los toques.
  • setSoLinger es una trampa. Con setSoLinger(true, 0) el cierre envía un RST en lugar de un FIN, descartando los datos pendientes. Se usa a veces para liberar puertos rápido, pero provoca SocketException: Connection reset en el otro extremo y pérdida de datos. No lo uses salvo que sepas exactamente por qué.

  1. setSoTimeout y SocketTimeoutException

Es la opción que más veces te salvará, así que merece apartado propio.

Por defecto, un read() sobre un socket bloquea indefinidamente. Si el otro extremo no envía nada y tampoco cierra —porque se le acabó la batería al portátil, o porque un cortafuegos intermedio descartó la conexión sin avisar— tu hilo se queda parado. Para siempre. Sin excepción.

socket.setSoTimeout(10_000);       // 10 segundos por lectura

try {
    String linea = lector.readLine();
    if (linea == null) {
        System.out.println("El otro extremo ha cerrado ordenadamente");
    } else {
        procesar(linea);
    }
} catch (SocketTimeoutException e) {
    // Han pasado 10 s sin llegar un solo byte.
    // MUY IMPORTANTE: el socket SIGUE VALIDO. Se puede reintentar la lectura.
    System.out.println("Sin datos en 10 s; el socket sigue utilizable");
}

Tres propiedades que hay que tener claras:

  1. El plazo es por operación de lectura, no total. Si pides una línea larga que llega poco a poco, el contador se reinicia con cada trozo que llega. Diez segundos significa "diez segundos sin recibir nada", no "diez segundos como máximo".
  2. Tras la excepción, el socket sigue siendo válido. A diferencia de casi cualquier otra excepción de red, SocketTimeoutException no invalida la conexión: puedes volver a llamar a readLine(). Esto permite el patrón de "espera con comprobación periódica":
socket.setSoTimeout(1000);          // sondeo cada segundo
while (!Thread.currentThread().isInterrupted() && ejecutando) {
    try {
        String linea = lector.readLine();
        if (linea == null) {
            break;                  // el otro cerro
        }
        procesar(linea);
    } catch (SocketTimeoutException e) {
        // Un segundo sin datos: aprovechamos para comprobar
        // la bandera de cancelacion del modulo 8 y volvemos a esperar.
        continue;
    }
}

Este patrón es lo que hace que un socket bloqueante sea cancelable, y conecta directamente con el protocolo de interrupción de 08-02: sin él, un hilo bloqueado en read() ignora las interrupciones —read() no es interrumpible— y el apagado ordenado de tu aplicación se queda esperando a un hilo que no va a volver.

  1. Un valor de 0 significa infinito, que es el valor por defecto. setSoTimeout(0) no es "sin espera": es "espera para siempre".

  1. setTcpNoDelay y el algoritmo de Nagle

En 1984, John Nagle observó que las sesiones interactivas (teclear en una consola remota) generaban un paquete de 41 bytes por cada carácter pulsado: 1 byte de datos y 40 de cabeceras. Un desperdicio del 97 %.

Su solución, el algoritmo de Nagle, está activada por defecto en todas las pilas TCP y funciona así: si hay datos pequeños ya enviados y sin confirmar, no envíes más datos pequeños; acumúlalos hasta que llegue la confirmación o hasta llenar un segmento completo.

Es una buena idea para transferencias masivas. Es un problema para protocolos de petición-respuesta con mensajes pequeños, sobre todo combinado con otra optimización llamada confirmación retardada (el receptor espera hasta 200 ms antes de confirmar, por si puede confirmar varias cosas juntas). Las dos juntas producen retardos artificiales de decenas o cientos de milisegundos:

Sin TCP_NODELAY, protocolo interactivo:

  Cliente: envia "CONSULTA 111\n"  (13 bytes)  --> sale enseguida (no hay nada pendiente)
  Servidor: recibe, procesa, responde
  Cliente: envia "LISTA\n"         (6 bytes)   --> ESPERA: hay un envio sin confirmar
           ...                                     hasta 200 ms de retardo artificial

Para BTCP/1, que es exactamente un protocolo interactivo de mensajes cortos, lo correcto es desactivarlo:

socket.setTcpNoDelay(true);     // true = SIN algoritmo de Nagle = enviar ya

El nombre confunde: setTcpNoDelay(true) significa "activa la opción NODELAY", es decir, desactiva el algoritmo de Nagle. true = enviar inmediatamente.

Tipo de tráfico setTcpNoDelay Motivo
Petición-respuesta interactiva (BTCP, Redis, juegos) true La latencia importa más que la eficiencia
Transferencia de ficheros grandes false (por defecto) Los segmentos ya van llenos; Nagle no estorba
Streaming en tiempo real true Igual que el interactivo

Nota. Nagle solo actúa sobre datos que tú no has agrupado. Si tu código ya escribe cada mensaje de una vez con un flush() por mensaje —como hace autoFlush—, y los mensajes son grandes, Nagle apenas se nota. El caso doloroso es escribir un mensaje en varios trozos pequeños con varios flush(). La conclusión práctica: agrupa tú lo que puedas y pon setTcpNoDelay(true) en protocolos interactivos.

  1. Las excepciones de red y qué significa cada una

Todas descienden de IOException, así que un solo catch (IOException e) las captura todas. Pero tratarlas por igual desperdicia información valiosísima: cada una te dice algo distinto sobre lo que ha ocurrido, y sobre si tiene sentido reintentar.

graph TD
    A["IOException"] --> B["SocketException"]
    A --> C["UnknownHostException"]
    A --> D["EOFException"]
    A --> E["InterruptedIOException"]
    B --> F["ConnectException"]
    B --> G["BindException"]
    B --> H["NoRouteToHostException"]
    E --> I["SocketTimeoutException"]
Excepción Qué ha pasado realmente ¿Reintentar? Reacción recomendada
UnknownHostException El nombre no resuelve: no existe o el DNS falla No (salvo fallo de DNS temporal) Error de configuración. Registrar y avisar al usuario
ConnectException Llegaste a la máquina y respondió RST: no hay nadie escuchando en ese puerto No de inmediato El servidor no está arrancado o es otro puerto
SocketTimeoutException (al conectar) Nadie respondió al SYN: máquina caída o cortafuegos que descarta Sí, con espera creciente Transitorio. Reintentar 2-3 veces
SocketTimeoutException (al leer) Conexión viva pero el otro no envía nada Depende El socket sigue válido: reintentar la lectura o abandonar
NoRouteToHostException No hay camino hacia esa red No Problema de enrutamiento o cortafuegos
BindException El puerto local ya está ocupado No ss -tlnp para ver quién lo tiene (típico en servidores, 09-03)
SocketException: Connection reset El otro extremo envió RST: se cayó, o cerró bruscamente, o rechazó lo enviado Sí, una vez La conexión está muerta: hay que abrir otra
SocketException: Broken pipe Escribiste en una conexión que el otro ya cerró Sí, una vez Igual
EOFException Fin de flujo inesperado leyendo con DataInputStream No en ese socket El otro cerró a mitad de un mensaje: datos incompletos

Y una distinción que hay que tener muy clara:

Situación Cómo se manifiesta
El otro cerró ordenadamente read() devuelve -1; readLine() devuelve null. No hay excepción
El otro se cayó o cerró bruscamente SocketException: Connection reset
El otro está vivo pero callado SocketTimeoutException, si has puesto setSoTimeout
El otro está vivo pero callado y no pusiste tiempo límite Nada. Silencio. Tu hilo bloqueado para siempre

Manejo completo, aplicando la estrategia por capas de 06-07:

package com.nexussoftware.bibliotech.red;

import com.nexussoftware.bibliotech.excepcion.BiblioTechException;

import java.io.IOException;
import java.net.ConnectException;
import java.net.NoRouteToHostException;
import java.net.SocketException;
import java.net.SocketTimeoutException;
import java.net.UnknownHostException;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Traduce las excepciones de red a excepciones del dominio de BiblioTech
 * y decide si el fallo es transitorio (merece reintento) o permanente.
 * Esta es la FRONTERA DE ERRORES de 06-07 aplicada a la red.
 */
public final class TraductorErroresRed {

    private static final Logger LOG = Logger.getLogger(TraductorErroresRed.class.getName());

    private TraductorErroresRed() {
    }

    /** Resultado de clasificar un fallo de red. */
    public record Clasificacion(String mensajeUsuario, boolean transitorio) {
    }

    public static Clasificacion clasificar(IOException e, String destino) {
        if (e instanceof UnknownHostException) {
            LOG.severe("Nombre no resoluble: " + destino);
            return new Clasificacion(
                    "No se encuentra el servidor de catalogo. Revisa la configuracion.", false);
        }
        if (e instanceof ConnectException) {
            // Ojo al orden: ConnectException es subclase de SocketException,
            // asi que debe comprobarse ANTES.
            LOG.severe("Conexion rechazada por " + destino + ": el servidor no esta arrancado");
            return new Clasificacion(
                    "El servidor de catalogo no esta disponible.", false);
        }
        if (e instanceof SocketTimeoutException) {
            LOG.warning("Tiempo agotado con " + destino);
            return new Clasificacion(
                    "El servidor de catalogo tarda demasiado en responder.", true);
        }
        if (e instanceof NoRouteToHostException) {
            LOG.severe("Sin ruta hacia " + destino);
            return new Clasificacion(
                    "No hay conexion con la red del servidor.", false);
        }
        if (e instanceof SocketException) {
            // "Connection reset", "Broken pipe" y similares.
            LOG.warning("Conexion rota con " + destino + ": " + e.getMessage());
            return new Clasificacion(
                    "Se ha perdido la conexion con el catalogo.", true);
        }
        LOG.log(Level.SEVERE, "Fallo de red no clasificado con " + destino, e);
        return new Clasificacion("Error de comunicacion con el catalogo.", false);
    }

    /** Convierte el fallo en la excepcion de dominio, conservando la causa. */
    public static BiblioTechException traducir(IOException e, String destino) {
        Clasificacion c = clasificar(e, destino);
        return new BiblioTechException(c.mensajeUsuario(), e);
    }
}

Fíjate en el orden de las comprobaciones: ConnectException y BindException son subclases de SocketException, así que si comprobaras SocketException primero, las capturarías todas ahí y perderías la distinción. Lo mismo pasaría con un catch escrito en mal orden — con la diferencia de que ahí el compilador te avisaría; aquí no.

  1. Datos binarios con DataOutputStream y DataInputStream

BTCP/1 es de texto, pero no todos los protocolos lo son. Cuando hay que enviar números, booleanos o bloques binarios, DataOutputStream y DataInputStream del módulo 7 funcionan sobre un socket exactamente igual que sobre un fichero, y con una ventaja añadida: escriben en big-endian, el orden de bytes de red, así que interoperan con programas escritos en otros lenguajes sin conversión.

package com.nexussoftware.bibliotech.red;

import java.io.DataInputStream;
import java.io.DataOutputStream;
import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.IOException;
import java.net.Socket;
import java.nio.charset.StandardCharsets;

/**
 * Variante binaria del envio de una ficha de material.
 * Se usa cuando el volumen importa mas que la legibilidad.
 */
public final class ProtocoloBinario {

    /** Limite defensivo: ningun campo de texto legitimo pasa de esto. */
    private static final int MAXIMO_TEXTO = 4096;

    private ProtocoloBinario() {
    }

    public static void enviarFicha(Socket socket, String isbn, String titulo,
                                   int paginas, boolean disponible) throws IOException {
        // Bufferizamos: sin BufferedOutputStream, cada writeInt seria
        // potencialmente una operacion de red independiente.
        DataOutputStream salida = new DataOutputStream(
                new BufferedOutputStream(socket.getOutputStream()));

        escribirTexto(salida, isbn);        // longitud (int) + bytes UTF-8
        escribirTexto(salida, titulo);
        salida.writeInt(paginas);           // 4 bytes, big-endian
        salida.writeBoolean(disponible);    // 1 byte
        salida.flush();                     // imprescindible, como siempre
    }

    public static String recibirTitulo(Socket socket) throws IOException {
        DataInputStream entrada = new DataInputStream(
                new BufferedInputStream(socket.getInputStream()));

        String isbn = leerTexto(entrada);
        String titulo = leerTexto(entrada);
        int paginas = entrada.readInt();
        boolean disponible = entrada.readBoolean();

        return titulo + " (" + isbn + ", " + paginas + " pag., "
                + (disponible ? "disponible" : "prestado") + ")";
    }

    /** Escribe un texto con longitud previa: 4 bytes de tamano + bytes UTF-8. */
    private static void escribirTexto(DataOutputStream salida, String texto)
            throws IOException {
        byte[] bytes = texto.getBytes(StandardCharsets.UTF_8);
        salida.writeInt(bytes.length);
        salida.write(bytes);
    }

    /** Lee un texto con longitud previa, VALIDANDO la longitud declarada. */
    private static String leerTexto(DataInputStream entrada) throws IOException {
        int longitud = entrada.readInt();

        // Sin esta comprobacion, un cliente malicioso envia el int 2_000_000_000
        // y provocamos un OutOfMemoryError con una peticion de 4 bytes.
        if (longitud < 0 || longitud > MAXIMO_TEXTO) {
            throw new IOException("Longitud de texto invalida: " + longitud);
        }

        // readNBytes lee EXACTAMENTE n bytes o lanza EOFException si el
        // flujo termina antes. Un read(byte[]) suelto podria leer menos
        // y dejarte con datos incompletos sin enterarte: error clasico.
        byte[] bytes = entrada.readNBytes(longitud);
        if (bytes.length < longitud) {
            throw new java.io.EOFException("Flujo terminado a mitad de un texto");
        }
        return new String(bytes, StandardCharsets.UTF_8);
    }
}

Los métodos de DataOutputStream escriben tamaños fijos y conocidos, lo que hace el protocolo predecible:

Método Bytes Notas
writeByte 1
writeShort 2 big-endian
writeInt 4 big-endian
writeLong 8 big-endian
writeFloat / writeDouble 4 / 8 IEEE 754
writeBoolean 1 0 o 1
writeUTF 2 + n UTF modificado, máximo 65535 bytes

Sobre writeUTF/readUTF. Son cómodos porque hacen la longitud previa por ti, pero usan una variante propia de UTF-8 ("UTF modificado") que no es UTF-8 estándar, y están limitados a 65535 bytes. Interoperan bien entre programas Java, y mal con cualquier otro lenguaje. Si el otro extremo puede no ser Java, haz la longitud previa a mano como en el ejemplo.

  1. Por qué no enviar objetos serializados a un cliente no confiable

En 07-05 aprendiste Serializable, serialVersionUID y los riesgos de deserializar datos no confiables. Es tentador aplicar la serialización a los sockets, porque parece resolverlo todo de golpe:

// TENTADOR Y PELIGROSO. No hagas esto en un servidor de red.
ObjectOutputStream salida = new ObjectOutputStream(socket.getOutputStream());
salida.writeObject(libro);
salida.flush();

// Y al otro lado:
ObjectInputStream entrada = new ObjectInputStream(socket.getInputStream());
Libro libro = (Libro) entrada.readObject();      // <-- AQUI ESTA EL AGUJERO

El problema es que readObject() construye objetos arbitrarios antes de que puedas comprobar de qué tipo son. La conversión (Libro) ocurre después de haber deserializado. Si un atacante envía un flujo cuidadosamente construido, readObject instanciará las clases que él indique y ejecutará su código de deserialización (readObject privado, readResolve, finalize) — usando clases que ya están en tu classpath. Es la familia de vulnerabilidades conocida como gadget chains, y ha causado agujeros de ejecución remota de código en productos muy conocidos.

Riesgo Descripción
Ejecución de código Cadenas de clases del classpath encadenadas para ejecutar órdenes del sistema
Denegación de servicio Un flujo pequeño que expande a estructuras gigantes u objetos con hashCode cuadrático
Acoplamiento Los dos extremos deben tener las mismas clases y versiones compatibles
No interoperable Solo Java habla ese formato

Reglas prácticas:

  1. Nunca deserialices objetos Java que vengan de una red no confiable. Ni siquiera de una red interna: la red interna de Nexus Software incluye el portátil que alguien conectó al Wi-Fi de invitados.
  2. Usa un formato de datos, no un formato de objetos: texto delimitado (como BTCP/1), CSV, JSON o binario con longitud previa. Todos ellos producen datos, que luego tú conviertes en objetos con código tuyo y validado.
  3. Si por herencia no tienes más remedio, usa ObjectInputFilter (Java 9+) para restringir qué clases se permiten deserializar:
ObjectInputStream entrada = new ObjectInputStream(socket.getInputStream());
// Lista blanca estricta: solo estas clases, maximo 1000 objetos,
// maximo 100 KB, profundidad maxima 10. Todo lo demas, rechazado.
entrada.setObjectInputFilter(ObjectInputFilter.Config.createFilter(
        "com.nexussoftware.bibliotech.dominio.Libro;"
        + "java.lang.String;"
        + "maxdepth=10;maxarray=1000;maxbytes=102400;"
        + "!*"));         // el !* final rechaza todo lo no listado

Aun así, la recomendación oficial de Oracle y del propio equipo de Java es clara: la serialización nativa no debe usarse como protocolo de red. BiblioTech no la usará. BTCP/1 envía texto, y el texto se convierte en Libro con un método tuyo que valida cada campo.

  1. BiblioTech: el cliente de catálogo completo

Es hora de juntarlo todo. Vamos a escribir ClienteCatalogo, la clase que permite a cualquier puesto de trabajo de Nexus Software hablar BTCP/1 con el servidor.

Diseño

sequenceDiagram
    participant M as MenuBiblioTech
    participant C as ClienteCatalogo
    participant S as Servidor BTCP/1
    M->>C: conectar()
    C->>S: TCP connect (3 s de limite)
    S-->>C: 200 BIBLIOTECH BTCP/1
    C->>C: comprueba el saludo y la version
    M->>C: consultar("978-0000000001")
    C->>S: CONSULTA 978-0000000001
    S-->>C: 200 OK 978-...|Java Efectivo|LIBRO|true
    C-->>M: Resultado.exito(Ficha)
    M->>C: close()
    C->>S: SALIR
    S-->>C: 221 ADIOS
    C->>C: socket.close()

ClienteCatalogo implementa AutoCloseable, así que se usa en un try-with-resources como cualquier otro recurso de BiblioTech, y su close() es cortés: se despide con SALIR antes de cerrar.

package com.nexussoftware.bibliotech.red;

import com.nexussoftware.bibliotech.excepcion.BiblioTechException;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.List;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Cliente del protocolo BTCP/1 de BiblioTech.
 *
 * Habla con el servidor de catalogo definido en 09-01 e implementado en 09-03.
 * Implementa AutoCloseable: se despide con SALIR y cierra el socket.
 *
 * NO es seguro para varios hilos: un socket es una conversacion, y dos hilos
 * escribiendo a la vez entrelazarian sus peticiones. Un hilo, un cliente.
 */
public class ClienteCatalogo implements AutoCloseable {

    private static final Logger LOG = Logger.getLogger(ClienteCatalogo.class.getName());

    private static final int LIMITE_CONEXION_MS = 3_000;
    private static final int LIMITE_LECTURA_MS = 10_000;
    private static final String VERSION_ESPERADA = "BTCP/1";
    /** Tope de lineas de una respuesta multiple: defensa contra un servidor hostil. */
    private static final int MAXIMO_LINEAS = 10_000;

    private final String host;
    private final int puerto;

    private Socket socket;
    private BufferedReader lector;
    private PrintWriter escritor;

    public ClienteCatalogo(String host, int puerto) {
        this.host = host;
        this.puerto = puerto;
    }

    /** Ficha de un material tal como la devuelve el protocolo. */
    public record FichaRed(String isbn, String titulo, String tipo, boolean disponible) {

        /** Convierte una linea "isbn|titulo|tipo|disponible" en una ficha. */
        static FichaRed desdeLinea(String linea) throws BiblioTechException {
            // -1 en split conserva los campos vacios del final;
            // sin el, "111|Titulo|LIBRO|" daria 3 campos en vez de 4.
            String[] campos = linea.split("\\|", -1);
            if (campos.length != 4) {
                throw new BiblioTechException(
                        "Respuesta del servidor con formato invalido: " + linea);
            }
            return new FichaRed(campos[0], campos[1], campos[2],
                    Boolean.parseBoolean(campos[3]));
        }
    }

    // ---------------------------------------------------------------
    // Conexion
    // ---------------------------------------------------------------

    /** Conecta y verifica el saludo del servidor. */
    public void conectar() throws BiblioTechException {
        try {
            socket = new Socket();

            // Protocolo interactivo de mensajes cortos: sin Nagle.
            socket.setTcpNoDelay(true);

            // Tiempo limite de CONEXION: nunca el constructor que conecta.
            socket.connect(new InetSocketAddress(host, puerto), LIMITE_CONEXION_MS);

            // Tiempo limite de LECTURA: distinto del anterior, e igual de obligatorio.
            socket.setSoTimeout(LIMITE_LECTURA_MS);

            // La pila de flujos del modulo 7, con charset EXPLICITO en los dos sentidos.
            escritor = new PrintWriter(
                    new BufferedWriter(
                            new OutputStreamWriter(socket.getOutputStream(),
                                    StandardCharsets.UTF_8)),
                    true);      // autoFlush: cada println() se envia
            lector = new BufferedReader(
                    new InputStreamReader(socket.getInputStream(),
                            StandardCharsets.UTF_8));

            // El servidor habla primero: comprobamos que es quien decimos
            // y que habla nuestra version antes de enviar nada.
            String saludo = lector.readLine();
            if (saludo == null) {
                throw new BiblioTechException(
                        "El servidor cerro la conexion sin saludar");
            }
            if (!saludo.startsWith("200 ") || !saludo.contains(VERSION_ESPERADA)) {
                throw new BiblioTechException(
                        "Saludo inesperado del servidor: '" + saludo + "'");
            }
            LOG.info(() -> "Conectado al catalogo " + host + ":" + puerto
                    + " (" + saludo + ")");

        } catch (IOException e) {
            cerrarSocketSilenciosamente();
            throw TraductorErroresRed.traducir(e, host + ":" + puerto);
        }
    }

    // ---------------------------------------------------------------
    // Operaciones del protocolo
    // ---------------------------------------------------------------

    /** CONSULTA <isbn>. Devuelve null si el material no existe (404). */
    public FichaRed consultar(String isbn) throws BiblioTechException {
        validarArgumento(isbn, "el ISBN");
        String respuesta = intercambiar("CONSULTA " + isbn);

        if (respuesta.startsWith("200 OK ")) {
            return FichaRed.desdeLinea(respuesta.substring("200 OK ".length()));
        }
        if (respuesta.startsWith("404 ")) {
            return null;        // "no encontrado" es un resultado, no un error
        }
        throw errorDeProtocolo(respuesta);
    }

    /** LISTA. Devuelve todas las fichas del catalogo. */
    public List<FichaRed> listar() throws BiblioTechException {
        String cabecera = intercambiar("LISTA");

        if (!cabecera.startsWith("201 LISTA ")) {
            throw errorDeProtocolo(cabecera);
        }

        int anunciadas = enteroDe(cabecera.substring("201 LISTA ".length()).strip(), cabecera);
        if (anunciadas < 0 || anunciadas > MAXIMO_LINEAS) {
            throw new BiblioTechException(
                    "El servidor anuncia un numero de lineas fuera de rango: " + anunciadas);
        }

        List<FichaRed> fichas = new ArrayList<>(anunciadas);
        try {
            String linea;
            while ((linea = lector.readLine()) != null) {
                if (linea.equals(".")) {
                    break;      // fin de la respuesta multiple
                }
                if (fichas.size() >= MAXIMO_LINEAS) {
                    throw new BiblioTechException(
                            "El servidor envia mas lineas de las permitidas");
                }
                fichas.add(FichaRed.desdeLinea(linea));
            }
            if (linea == null) {
                throw new BiblioTechException(
                        "El servidor cerro la conexion a mitad de la lista");
            }
        } catch (SocketTimeoutException e) {
            throw new BiblioTechException(
                    "El servidor dejo de responder a mitad de la lista", e);
        } catch (IOException e) {
            throw TraductorErroresRed.traducir(e, host + ":" + puerto);
        }

        // El numero anunciado es redundante a proposito: si no cuadra,
        // hay un problema y es mejor saberlo que ignorarlo.
        if (fichas.size() != anunciadas) {
            LOG.warning("El servidor anuncio " + anunciadas + " lineas y envio "
                    + fichas.size());
        }
        return fichas;
    }

    /** PRESTAR <isbn> <empleado>. Devuelve true si se registro el prestamo. */
    public boolean prestar(String isbn, String empleado) throws BiblioTechException {
        validarArgumento(isbn, "el ISBN");
        validarArgumento(empleado, "el empleado");

        String respuesta = intercambiar("PRESTAR " + isbn + " " + empleado);

        if (respuesta.startsWith("200 ")) {
            return true;
        }
        if (respuesta.startsWith("409 ") || respuesta.startsWith("404 ")) {
            LOG.info(() -> "Prestamo rechazado: " + respuesta);
            return false;
        }
        throw errorDeProtocolo(respuesta);
    }

    // ---------------------------------------------------------------
    // Nucleo: enviar una linea y leer una respuesta
    // ---------------------------------------------------------------

    private String intercambiar(String peticion) throws BiblioTechException {
        if (socket == null || socket.isClosed()) {
            throw new BiblioTechException("El cliente no esta conectado");
        }
        try {
            // El protocolo especifica \n, no el separador de la plataforma:
            // por eso lo escribimos a mano en lugar de usar println(String).
            escritor.print(peticion + "\n");
            escritor.flush();       // OBLIGATORIO: print() no dispara autoFlush

            // PrintWriter se traga las IOException; checkError() es la unica
            // forma de enterarse de que la escritura fallo.
            if (escritor.checkError()) {
                throw new BiblioTechException(
                        "Fallo al enviar la peticion al servidor de catalogo");
            }

            String respuesta = lector.readLine();
            if (respuesta == null) {
                throw new BiblioTechException(
                        "El servidor cerro la conexion sin responder a: " + peticion);
            }
            LOG.fine(() -> "C: " + peticion + "  ->  S: " + respuesta);
            return respuesta;

        } catch (SocketTimeoutException e) {
            throw new BiblioTechException(
                    "El servidor no respondio en " + LIMITE_LECTURA_MS + " ms", e);
        } catch (IOException e) {
            throw TraductorErroresRed.traducir(e, host + ":" + puerto);
        }
    }

    // ---------------------------------------------------------------
    // Validacion y utilidades
    // ---------------------------------------------------------------

    /**
     * Un argumento con salto de linea rompe el protocolo: inyectaria
     * una peticion adicional. Validar la SALIDA es tan importante como
     * validar la entrada.
     */
    private void validarArgumento(String valor, String nombre) throws BiblioTechException {
        if (valor == null || valor.isBlank()) {
            throw new BiblioTechException("Falta " + nombre);
        }
        if (valor.indexOf('\n') >= 0 || valor.indexOf('\r') >= 0) {
            throw new BiblioTechException(
                    "Valor invalido para " + nombre + ": contiene saltos de linea");
        }
    }

    private int enteroDe(String texto, String contexto) throws BiblioTechException {
        try {
            return Integer.parseInt(texto);
        } catch (NumberFormatException e) {
            throw new BiblioTechException(
                    "Numero invalido en la respuesta del servidor: '" + contexto + "'", e);
        }
    }

    private BiblioTechException errorDeProtocolo(String respuesta) {
        return new BiblioTechException(
                "Respuesta inesperada del servidor de catalogo: " + respuesta);
    }

    private void cerrarSocketSilenciosamente() {
        if (socket != null) {
            try {
                socket.close();
            } catch (IOException ignorada) {
                // Cerrando ya no hay nada que salvar.
            }
        }
    }

    // ---------------------------------------------------------------
    // Cierre
    // ---------------------------------------------------------------

    @Override
    public void close() {
        if (socket == null || socket.isClosed()) {
            return;
        }
        try {
            // Despedida cortes: el servidor puede distinguir un cierre
            // ordenado de una caida y liberar recursos con tranquilidad.
            escritor.print("SALIR\n");
            escritor.flush();

            // Le damos poco margen: si no contesta, cerramos igual.
            socket.setSoTimeout(1_000);
            String adios = lector.readLine();
            LOG.fine(() -> "Despedida del servidor: " + adios);

        } catch (IOException e) {
            // Al cerrar, un fallo no cambia nada: se registra y se sigue.
            LOG.log(Level.FINE, "Fallo durante la despedida", e);
        } finally {
            cerrarSocketSilenciosamente();
            LOG.info(() -> "Conexion con " + host + ":" + puerto + " cerrada");
        }
    }
}

Programa de prueba

package com.nexussoftware.bibliotech.presentacion;

import com.nexussoftware.bibliotech.excepcion.BiblioTechException;
import com.nexussoftware.bibliotech.red.ClienteCatalogo;
import com.nexussoftware.bibliotech.red.ClienteCatalogo.FichaRed;

import java.util.List;

/** Prueba manual del cliente BTCP/1 contra un servidor. */
public class PruebaClienteCatalogo {

    public static void main(String[] args) {
        String host = args.length > 0 ? args[0] : "localhost";
        int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 9090;

        // ClienteCatalogo es AutoCloseable: try-with-resources como siempre.
        try (ClienteCatalogo cliente = new ClienteCatalogo(host, puerto)) {
            cliente.conectar();

            System.out.println("--- CONSULTA de un ISBN existente ---");
            FichaRed ficha = cliente.consultar("978-0000000001");
            if (ficha == null) {
                System.out.println("No encontrado");
            } else {
                System.out.printf("%s - %s [%s] %s%n",
                        ficha.isbn(), ficha.titulo(), ficha.tipo(),
                        ficha.disponible() ? "disponible" : "prestado");
            }

            System.out.println();
            System.out.println("--- CONSULTA de un ISBN inexistente ---");
            System.out.println(cliente.consultar("000-0000000000") == null
                    ? "No encontrado (correcto)" : "Inesperado");

            System.out.println();
            System.out.println("--- LISTA ---");
            List<FichaRed> catalogo = cliente.listar();
            for (FichaRed f : catalogo) {
                System.out.println("  " + f.isbn() + "  " + f.titulo());
            }
            System.out.println("Total: " + catalogo.size());

            System.out.println();
            System.out.println("--- PRESTAR ---");
            boolean ok = cliente.prestar("978-0000000002", "Diego_Alonso");
            System.out.println(ok ? "Prestamo registrado" : "Prestamo rechazado");

        } catch (BiblioTechException e) {
            // Frontera de errores: aqui ya no hay sockets, solo dominio.
            System.err.println("ERROR: " + e.getMessage());
            if (e.getCause() != null) {
                System.err.println("Causa tecnica: " + e.getCause());
            }
        }
    }
}

Cómo probarlo sin servidor: nc como servidor manual

El servidor de verdad se escribe en 09-03. Mientras tanto, haz tú de servidor. Esta es la mejor forma de entender un protocolo, porque ves la conversación letra a letra.

Terminal 1 — actúa de servidor en el puerto 9090:

nc -l 9090

(En algunas versiones de netcat hace falta nc -l -p 9090. Si no tienes nc, ncat de Nmap o socat -v TCP-LISTEN:9090,reuseaddr - sirven igual.)

Terminal 2 — lanza el cliente:

javac -d clases $(find src -name "*.java")
java -cp clases com.nexussoftware.bibliotech.presentacion.PruebaClienteCatalogo

Ahora, en la terminal 1, verás aparecer lo que envía el cliente y podrás teclear las respuestas a mano. La sesión completa es esta (lo que tecleas tú va marcado):

TERMINAL 1 (tu, haciendo de servidor con nc -l 9090)
-----------------------------------------------------
200 BIBLIOTECH BTCP/1                                   <-- TECLEAS TU (y Enter)
CONSULTA 978-0000000001                                     (lo envia el cliente)
200 OK 978-0000000001|Java Efectivo|LIBRO|true          <-- TECLEAS TU
CONSULTA 000-0000000000
404 NO ENCONTRADO 000-0000000000                        <-- TECLEAS TU
LISTA
201 LISTA 3                                             <-- TECLEAS TU
978-0000000001|Java Efectivo|LIBRO|true                 <-- TECLEAS TU
978-0000000002|Patrones de Diseno|LIBRO|false           <-- TECLEAS TU
978-0000000003|Refactorizacion|LIBRO|true               <-- TECLEAS TU
.                                                       <-- TECLEAS TU
PRESTAR 978-0000000002 Diego_Alonso
409 NO DISPONIBLE 978-0000000002                        <-- TECLEAS TU
SALIR
221 ADIOS                                               <-- TECLEAS TU
TERMINAL 2 (el cliente Java)
-----------------------------------------------------
--- CONSULTA de un ISBN existente ---
978-0000000001 - Java Efectivo [LIBRO] disponible

--- CONSULTA de un ISBN inexistente ---
No encontrado (correcto)

--- LISTA ---
  978-0000000001  Java Efectivo
  978-0000000002  Patrones de Diseno
  978-0000000003  Refactorizacion
Total: 3

--- PRESTAR ---
Prestamo rechazado

Experimentos que merece la pena hacer ahora mismo, porque enseñan más que diez páginas de teoría:

  1. No teclees el saludo. El cliente esperará 10 segundos y fallará con "El servidor no respondió". Es setSoTimeout haciendo su trabajo: sin él, esperaría para siempre.
  2. Teclea un saludo mal, por ejemplo HOLA. El cliente rechaza la conexión con "Saludo inesperado". Verificar la versión antes de hablar evita conversaciones absurdas con el servidor equivocado.
  3. Cierra nc con Ctrl+C a mitad de la lista. El cliente detecta el null de readLine() y da "El servidor cerró la conexión a mitad de la lista", no un NullPointerException.
  4. No arranques nc en absoluto. Obtienes "El servidor de catálogo no está disponible", que es la traducción de ConnectException. Compárala con conectar a una IP inexistente de tu red (192.168.1.250), que da tiempo agotado tras 3 segundos: rechazado y sin respuesta son cosas distintas, y ahora las distingues.
  5. Teclea un título con acentos, como Patrones de Diseño. Llega bien porque los dos extremos usan UTF-8. Prueba a arrancar la JVM con -Dfile.encoding=ISO-8859-1 y verás que sigue llegando bien, precisamente porque el charset está explícito en el código y no depende de la plataforma. Ese es el motivo de la regla del módulo 7.

Errores Comunes y Consejos

No vaciar el buffer tras enviar. El bug número uno, ya demostrado. Síntoma: el programa se cuelga sin error. Usa autoFlush con println, o flush() explícito. Y recuerda que autoFlush no actúa con print().

Usar new Socket(host, puerto) en producción. No admite tiempo límite de conexión y puede bloquear más de un minuto. Usa siempre new Socket() + connect(addr, ms).

Confundir el tiempo límite de conexión con el de lectura. Son dos cosas distintas y necesitas las dos. connect(addr, 3000) no te protege de un read() eterno.

No poner setSoTimeout. Convierte cualquier problema del otro extremo en un hilo bloqueado para siempre. En un servidor con pool acotado, unos pocos clientes zombis agotan el pool y el servicio muere en silencio, sin una sola línea de error.

Confiar en isConnected(). Devuelve true desde que se conectó, aunque el otro extremo lleve horas apagado. La única forma de saber si sigue vivo es intentar usar el socket.

No comprobar el null de readLine(). Produce NullPointerException en el primer cliente que se desconecte. El while ((linea = lector.readLine()) != null) es obligatorio.

Usar el charset por defecto. new InputStreamReader(socket.getInputStream()) sin charset funciona en tu máquina y corrompe los acentos en cuanto el otro extremo tiene otra configuración. Siempre StandardCharsets.UTF_8.

Suponer que un read() devuelve el mensaje completo. TCP no tiene fronteras de mensaje. O usas readLine(), que ya lo resuelve, o implementas longitud previa. Nunca supongas que un envío equivale a una lectura.

Leer sin límite de tamaño. Un readLine() sobre una línea de cien megabytes agota la memoria, y un readInt() de longitud seguido de new byte[longitud] sin validar es una denegación de servicio de cuatro bytes. Valida siempre las longitudes que vienen de la red.

Declarar los flujos en el try-with-resources junto al socket. Cierra en orden inverso y el escritor intenta vaciarse sobre un socket ya cerrado. Declara solo el Socket.

Enviar objetos serializados por la red. readObject() construye objetos antes de que puedas validarlos. Envía datos, no objetos.

No validar lo que escribes en el protocolo. Si el nombre de empleado lleva un salto de línea, inyecta una petición extra. Validar la salida es tan necesario como validar la entrada — es el mismo tipo de fallo que una inyección SQL.

Consejo de oro para depurar. Cuando algo no funcione, ponte en medio. nc -l 9090 hace de servidor y te enseña exactamente qué envía tu cliente, byte a byte. Si tu petición no aparece ahí, no la has vaciado. Si aparece deformada, tienes un problema de charset o de delimitador. Dos minutos con nc ahorran dos horas de System.out.println.

Ejercicios

Ejercicio 1: Cliente de eco con medición de latencia

Escribe una clase ClienteEco que se conecte a un servidor de eco (uno que devuelve tal cual lo que recibe), le envíe N líneas y mida el tiempo de ida y vuelta de cada una.

Requisitos:

  • Tiempo límite de conexión de 2 s y de lectura de 5 s.
  • setTcpNoDelay(true), y un modo alternativo con false para comparar.
  • Debe informar de latencia mínima, media y máxima en milisegundos con dos decimales.
  • Debe verificar que lo recibido coincide con lo enviado y contar las discrepancias.
  • Manejo diferenciado de ConnectException, SocketTimeoutException y el resto.

Pruébalo contra nc -l 9090 (tendrás que hacer eco a mano) o, mejor, contra un servidor de eco de una línea: while true; do nc -l 9090 -c cat; done en Linux.

Ejercicio 2: Detector de servicios

Escribe una clase DetectorServicios con un método escanear(String host, int desde, int hasta, int limiteMs) que compruebe qué puertos de un rango tienen algo escuchando, e intente identificar el servicio.

Requisitos:

  • Para cada puerto, intenta conectar con tiempo límite corto (200-500 ms).
  • Clasifica el resultado en tres estados: ABIERTO (conectó), CERRADO (ConnectException) y FILTRADO (tiempo agotado, típico de un cortafuegos que descarta).
  • Para los abiertos, intenta leer una línea de saludo durante 500 ms; si el servicio saluda (SMTP, FTP, SSH y BTCP lo hacen), muéstrala.
  • Muestra el nombre habitual del servicio según la tabla de puertos bien conocidos de 09-01.
  • Imprime una tabla y un resumen.

Pruébalo contra localhost con un nc -l 9090 levantado, y explica en un comentario por qué escanear puertos de máquinas ajenas sin permiso es, además de descortés, ilegal en muchos países.

Ejercicio 3: Transferencia binaria con shutdownOutput

Escribe un par cliente/servidor mínimo para transferir el fichero catalogo.csv de BiblioTech:

  • EmisorFichero: conecta, envía el nombre del fichero y su tamaño con DataOutputStream (longitud previa, validada), después el contenido en bloques de 8 KB, llama a shutdownOutput() y espera una línea de resumen del receptor.
  • ReceptorFichero: con un ServerSocket mínimo (puedes anticipar lo justo de 09-03: new ServerSocket(9091) y accept()), lee el nombre y el tamaño, valida que el tamaño sea razonable (máximo 10 MB) y que el nombre no contenga / ni .. (recorrido de rutas), guarda el contenido en recibidos/ con NIO.2 y responde con un resumen.

Requisitos: charset explícito donde haya texto, validación estricta de todo lo que llega, try-with-resources y logging con java.util.logging. Explica en un comentario por qué es imprescindible el shutdownOutput().

Soluciones

Solución 1

package com.nexussoftware.bibliotech.red;

import java.io.BufferedReader;
import java.io.BufferedWriter;
import java.io.IOException;
import java.io.InputStreamReader;
import java.io.OutputStreamWriter;
import java.io.PrintWriter;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;

/**
 * Cliente de eco que mide la latencia de ida y vuelta.
 * Sirve para comparar el efecto de setTcpNoDelay y para medir la red.
 */
public class ClienteEco {

    private final String host;
    private final int puerto;
    private final boolean sinNagle;

    public ClienteEco(String host, int puerto, boolean sinNagle) {
        this.host = host;
        this.puerto = puerto;
        this.sinNagle = sinNagle;
    }

    /** Resultado de una tanda de medidas. */
    public record Medicion(int enviadas, int discrepancias,
                           double minMs, double mediaMs, double maxMs) {
    }

    public Medicion medir(int repeticiones) throws IOException {
        try (Socket socket = new Socket()) {

            // Opciones ANTES de usar el socket.
            socket.setTcpNoDelay(sinNagle);
            socket.connect(new InetSocketAddress(host, puerto), 2_000);
            socket.setSoTimeout(5_000);

            PrintWriter escritor = new PrintWriter(
                    new BufferedWriter(
                            new OutputStreamWriter(socket.getOutputStream(),
                                    StandardCharsets.UTF_8)),
                    true);
            BufferedReader lector = new BufferedReader(
                    new InputStreamReader(socket.getInputStream(),
                            StandardCharsets.UTF_8));

            double min = Double.MAX_VALUE;
            double max = 0;
            double suma = 0;
            int discrepancias = 0;
            int completadas = 0;

            for (int i = 1; i <= repeticiones; i++) {
                String mensaje = "PING-" + i + "-BiblioTech";

                long t0 = System.nanoTime();
                escritor.println(mensaje);          // autoFlush: se envia ya
                String eco = lector.readLine();     // esperamos el retorno
                long t1 = System.nanoTime();

                if (eco == null) {
                    System.out.println("El servidor cerro tras " + completadas + " ecos");
                    break;
                }

                double ms = (t1 - t0) / 1_000_000.0;
                min = Math.min(min, ms);
                max = Math.max(max, ms);
                suma += ms;
                completadas++;

                // El eco debe ser identico: si no, hay un problema de
                // charset, de delimitador o de desincronizacion del protocolo.
                if (!mensaje.equals(eco)) {
                    discrepancias++;
                    System.out.println("  DISCREPANCIA: envie '" + mensaje
                            + "' y recibi '" + eco + "'");
                }
                System.out.printf("  %2d) %.2f ms%n", i, ms);
            }

            if (completadas == 0) {
                return new Medicion(0, 0, 0, 0, 0);
            }
            return new Medicion(completadas, discrepancias, min, suma / completadas, max);
        }
    }

    public static void main(String[] args) {
        String host = args.length > 0 ? args[0] : "localhost";
        int puerto = args.length > 1 ? Integer.parseInt(args[1]) : 9090;

        for (boolean sinNagle : new boolean[]{true, false}) {
            System.out.println("=== setTcpNoDelay(" + sinNagle + ") ===");
            try {
                Medicion m = new ClienteEco(host, puerto, sinNagle).medir(10);
                System.out.printf(
                        "  Ecos: %d   Discrepancias: %d%n"
                        + "  min %.2f ms   media %.2f ms   max %.2f ms%n%n",
                        m.enviadas(), m.discrepancias(), m.minMs(), m.mediaMs(), m.maxMs());

            } catch (ConnectException e) {
                // No hay nadie escuchando: no tiene sentido reintentar.
                System.err.println("  No hay servidor de eco en " + host + ":" + puerto);
                System.err.println("  Arrancalo con:  while true; do nc -l "
                        + puerto + " -c cat; done");
                return;

            } catch (SocketTimeoutException e) {
                // Transitorio: la maquina no responde a tiempo.
                System.err.println("  Tiempo agotado hablando con " + host);

            } catch (IOException e) {
                System.err.println("  Fallo de red: " + e);
            }
        }
    }
}

Salida en localhost contra un servidor de eco real:

=== setTcpNoDelay(true) ===
   1) 1,84 ms
   2) 0,21 ms
   ...
  Ecos: 10   Discrepancias: 0
  min 0,14 ms   media 0,31 ms   max 1,84 ms

Comentarios. Tres observaciones. La primera medida es siempre la más lenta (1,84 ms frente a 0,2 ms): es el coste de que la JVM cargue las clases y de que el primer envío atraviese caminos aún no calentados; cualquier medición de red debe descartar las primeras iteraciones o al menos no tomarlas como representativas. La segunda: en localhost la diferencia entre setTcpNoDelay(true) y false es prácticamente nula, porque el bucle invertido no aplica Nagle igual que un enlace real; para ver el efecto de Nagle hacen falta dos máquinas, y ahí las diferencias pueden ser de 40 ms por mensaje. Y la tercera: la comprobación de que el eco coincide con lo enviado no es paranoia; es la forma de detectar que el protocolo se ha desincronizado, que es el fallo más difícil de diagnosticar cuando aparece.

Solución 2

package com.nexussoftware.bibliotech.red;

import java.io.BufferedReader;
import java.io.IOException;
import java.io.InputStreamReader;
import java.net.ConnectException;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.net.SocketTimeoutException;
import java.nio.charset.StandardCharsets;
import java.util.HashMap;
import java.util.Map;

/**
 * Detector de servicios: comprueba que puertos de un rango tienen algo escuchando.
 *
 * AVISO LEGAL Y ETICO: escanear puertos de maquinas ajenas sin autorizacion
 * escrita es, en muchos paises, un delito de acceso no autorizado a sistemas
 * informaticos, y en practicamente todos es motivo de bloqueo por parte del
 * proveedor. Esta herramienta esta pensada UNICAMENTE para localhost y para
 * maquinas propias de Nexus Software con autorizacion del departamento de
 * sistemas. Un escaneo genera trafico y alertas en cualquier IDS moderno.
 */
public class DetectorServicios {

    /** Estado de un puerto. Un enum como los de 04-07. */
    public enum Estado {
        ABIERTO("hay un servicio escuchando"),
        CERRADO("la maquina responde RST: no hay nadie en ese puerto"),
        FILTRADO("sin respuesta: cortafuegos que descarta, o maquina caida");

        private final String descripcion;

        Estado(String descripcion) {
            this.descripcion = descripcion;
        }

        public String descripcion() {
            return descripcion;
        }
    }

    private static final Map<Integer, String> SERVICIOS = new HashMap<>();

    static {
        SERVICIOS.put(21, "FTP");
        SERVICIOS.put(22, "SSH");
        SERVICIOS.put(25, "SMTP");
        SERVICIOS.put(53, "DNS");
        SERVICIOS.put(80, "HTTP");
        SERVICIOS.put(443, "HTTPS");
        SERVICIOS.put(3306, "MySQL");
        SERVICIOS.put(5432, "PostgreSQL");
        SERVICIOS.put(6379, "Redis");
        SERVICIOS.put(8080, "HTTP alternativo");
        SERVICIOS.put(9090, "BiblioTech BTCP/1");
        SERVICIOS.put(9091, "BiblioTech descubrimiento");
    }

    public void escanear(String host, int desde, int hasta, int limiteMs) {
        System.out.printf("Escaneando %s puertos %d-%d (limite %d ms)%n%n",
                host, desde, hasta, limiteMs);
        System.out.printf("%-8s %-10s %-24s %s%n", "PUERTO", "ESTADO", "SERVICIO", "SALUDO");
        System.out.println("-".repeat(90));

        int abiertos = 0, cerrados = 0, filtrados = 0;

        for (int puerto = desde; puerto <= hasta; puerto++) {
            Estado estado = probar(host, puerto, limiteMs);

            switch (estado) {
                case ABIERTO -> abiertos++;
                case CERRADO -> cerrados++;
                case FILTRADO -> filtrados++;
            }

            // Solo mostramos los interesantes: una tabla con 65535 CERRADO
            // no ayuda a nadie.
            if (estado == Estado.ABIERTO) {
                String saludo = leerSaludo(host, puerto, 500);
                System.out.printf("%-8d %-10s %-24s %s%n",
                        puerto, estado,
                        SERVICIOS.getOrDefault(puerto, "(desconocido)"),
                        saludo == null ? "(no saluda)" : saludo);
            } else if (estado == Estado.FILTRADO) {
                System.out.printf("%-8d %-10s %-24s %s%n",
                        puerto, estado,
                        SERVICIOS.getOrDefault(puerto, "(desconocido)"), "");
            }
        }

        System.out.println("-".repeat(90));
        System.out.printf("Abiertos: %d   Cerrados: %d   Filtrados: %d%n",
                abiertos, cerrados, filtrados);
        for (Estado e : Estado.values()) {
            System.out.printf("  %-10s %s%n", e, e.descripcion());
        }
    }

    /** Intenta conectar y clasifica el resultado por el TIPO de excepcion. */
    private Estado probar(String host, int puerto, int limiteMs) {
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress(host, puerto), limiteMs);
            return Estado.ABIERTO;

        } catch (SocketTimeoutException e) {
            // Nadie respondio al SYN: normalmente un cortafuegos con politica DROP.
            return Estado.FILTRADO;

        } catch (ConnectException e) {
            // La maquina respondio RST: esta viva, pero ese puerto no tiene servicio.
            // ES INFORMACION VALIOSA: distingue "maquina caida" de "puerto cerrado".
            return Estado.CERRADO;

        } catch (IOException e) {
            // NoRouteToHost y similares.
            return Estado.FILTRADO;
        }
    }

    /**
     * Muchos protocolos saludan al conectar (SMTP, FTP, SSH, BTCP/1).
     * Un tiempo agotado aqui NO es un fallo: significa que el servicio
     * espera que hables tu primero (como HTTP).
     */
    private String leerSaludo(String host, int puerto, int limiteMs) {
        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress(host, puerto), limiteMs);
            socket.setSoTimeout(limiteMs);

            BufferedReader lector = new BufferedReader(
                    new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            String linea = lector.readLine();

            if (linea == null) {
                return null;
            }
            // Recortamos: un saludo enorme no cabe en la tabla, y ademas
            // no queremos volcar datos arbitrarios de la red en la consola.
            return linea.length() > 45 ? linea.substring(0, 45) + "..." : linea;

        } catch (IOException e) {
            return null;
        }
    }

    public static void main(String[] args) {
        DetectorServicios detector = new DetectorServicios();
        // SOLO localhost por defecto: escanear otra maquina requiere autorizacion.
        detector.escanear("localhost", 9080, 9100, 300);
    }
}

Salida con un nc -l 9090 levantado:

Escaneando localhost puertos 9080-9100 (limite 300 ms)

PUERTO   ESTADO     SERVICIO                 SALUDO
------------------------------------------------------------------------------------------
9090     ABIERTO    BiblioTech BTCP/1        (no saluda)
------------------------------------------------------------------------------------------
Abiertos: 1   Cerrados: 20   Filtrados: 0

Comentarios. El valor pedagógico de este ejercicio está en la clasificación en tres estados, que es exactamente lo que hace nmap. ConnectException y SocketTimeoutException significan cosas muy distintas y confundirlas es el error de diagnóstico más común: "rechazado" prueba que la máquina está viva y que el paquete llegó y volvió; "tiempo agotado" no prueba nada, porque puede ser una máquina apagada, una ruta rota o un cortafuegos con política de descarte silencioso. Fíjate también en que nc -l no saluda —espera que hables tú—, y por eso leerSaludo devuelve null sin que eso sea un error: un tiempo agotado leyendo el saludo es información, no fallo. Y observa el detalle de recortar el saludo a 45 caracteres: nunca vuelques sin límite en la consola datos que vienen de la red, porque pueden contener secuencias de escape de terminal.

Solución 3

package com.nexussoftware.bibliotech.red;

import java.io.BufferedInputStream;
import java.io.BufferedOutputStream;
import java.io.BufferedReader;
import java.io.DataOutputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.net.InetSocketAddress;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Logger;

/**
 * Emisor de un fichero del catalogo por TCP.
 *
 * Protocolo (binario, con longitud previa):
 *   [int  ] longitud del nombre en bytes UTF-8
 *   [bytes] nombre
 *   [long ] tamano del contenido en bytes
 *   [bytes] contenido
 *   -- shutdownOutput --
 *   [linea] resumen del receptor, en texto UTF-8
 */
public class EmisorFichero {

    private static final Logger LOG = Logger.getLogger(EmisorFichero.class.getName());
    private static final int BLOQUE = 8192;

    public void enviar(String host, int puerto, Path fichero) throws IOException {
        if (!Files.isRegularFile(fichero)) {
            throw new IOException("No es un fichero: " + fichero);
        }
        long tamano = Files.size(fichero);

        try (Socket socket = new Socket()) {
            socket.connect(new InetSocketAddress(host, puerto), 3_000);
            socket.setSoTimeout(30_000);

            DataOutputStream salida = new DataOutputStream(
                    new BufferedOutputStream(socket.getOutputStream()));

            // --- Cabecera: nombre con longitud previa ---
            // Enviamos SOLO el nombre del fichero, nunca la ruta completa:
            // el receptor decide donde guardar.
            byte[] nombre = fichero.getFileName().toString()
                    .getBytes(StandardCharsets.UTF_8);
            salida.writeInt(nombre.length);
            salida.write(nombre);
            salida.writeLong(tamano);

            // --- Contenido en bloques ---
            long enviados = 0;
            try (InputStream entradaFichero =
                         new BufferedInputStream(Files.newInputStream(fichero))) {
                byte[] bufer = new byte[BLOQUE];
                int leidos;
                while ((leidos = entradaFichero.read(bufer)) != -1) {
                    // read puede devolver MENOS de BLOQUE: hay que escribir
                    // exactamente 'leidos' bytes, nunca bufer.length.
                    salida.write(bufer, 0, leidos);
                    enviados += leidos;
                }
            }
            salida.flush();     // el flush de siempre, antes de cerrar el sentido

            LOG.info(() -> "Enviados " + tamano + " bytes de " + fichero.getFileName());

            // --- shutdownOutput: IMPRESCINDIBLE ---
            // El receptor lee hasta agotar el flujo. Sin este cierre de medio
            // sentido, el receptor seguiria esperando mas bytes indefinidamente
            // y nosotros esperariamos su resumen: interbloqueo perfecto.
            // No podemos usar socket.close() porque aun tenemos que LEER.
            socket.shutdownOutput();

            // --- Resumen del receptor (el sentido de lectura sigue abierto) ---
            BufferedReader lector = new BufferedReader(
                    new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
            String resumen = lector.readLine();
            System.out.println("Receptor: " + (resumen == null ? "(sin respuesta)" : resumen));

            if (enviados != tamano) {
                LOG.warning("El fichero cambio de tamano durante el envio");
            }
        }
    }

    public static void main(String[] args) throws IOException {
        Path fichero = Path.of(args.length > 0 ? args[0] : "catalogo.csv");
        new EmisorFichero().enviar("localhost", 9091, fichero);
    }
}
package com.nexussoftware.bibliotech.red;

import java.io.BufferedOutputStream;
import java.io.BufferedWriter;
import java.io.DataInputStream;
import java.io.EOFException;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.net.ServerSocket;
import java.net.Socket;
import java.nio.charset.StandardCharsets;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.logging.Level;
import java.util.logging.Logger;

/**
 * Receptor del fichero. Servidor minimo de una sola conexion:
 * la version concurrente y completa se escribe en 09-03.
 */
public class ReceptorFichero {

    private static final Logger LOG = Logger.getLogger(ReceptorFichero.class.getName());

    private static final int MAXIMO_NOMBRE = 255;
    private static final long MAXIMO_TAMANO = 10L * 1024 * 1024;   // 10 MB
    private static final Path DESTINO = Path.of("recibidos");

    public void escuchar(int puerto) throws IOException {
        Files.createDirectories(DESTINO);

        try (ServerSocket servidor = new ServerSocket(puerto)) {
            LOG.info(() -> "Receptor escuchando en el puerto " + puerto);

            // accept() bloquea hasta que llega una conexion y devuelve
            // un Socket YA conectado. Todo el detalle, en 09-03.
            try (Socket socket = servidor.accept()) {
                socket.setSoTimeout(30_000);
                LOG.info(() -> "Conexion de " + socket.getRemoteSocketAddress());
                atender(socket);
            }
        }
    }

    private void atender(Socket socket) throws IOException {
        DataInputStream entrada = new DataInputStream(socket.getInputStream());

        String resumen;
        try {
            // --- Nombre, con longitud previa VALIDADA ---
            int longitudNombre = entrada.readInt();
            if (longitudNombre <= 0 || longitudNombre > MAXIMO_NOMBRE) {
                throw new IOException("Longitud de nombre invalida: " + longitudNombre);
            }
            byte[] bytesNombre = entrada.readNBytes(longitudNombre);
            if (bytesNombre.length < longitudNombre) {
                throw new EOFException("Flujo cortado leyendo el nombre");
            }
            String nombre = new String(bytesNombre, StandardCharsets.UTF_8);

            // --- Saneado del nombre: RECORRIDO DE RUTAS ---
            // Sin esto, un emisor malicioso envia "../../etc/cron.d/puerta"
            // y escribimos donde el quiera. Es una vulnerabilidad clasica.
            if (nombre.contains("/") || nombre.contains("\\")
                    || nombre.contains("..") || nombre.isBlank()) {
                throw new IOException("Nombre de fichero no permitido: " + nombre);
            }

            // --- Tamano, tambien validado ---
            long tamano = entrada.readLong();
            if (tamano < 0 || tamano > MAXIMO_TAMANO) {
                throw new IOException("Tamano fuera de rango: " + tamano);
            }

            // --- Contenido ---
            Path destino = DESTINO.resolve(nombre).normalize();
            // Cinturon y tirantes: comprobamos que el resultado sigue dentro
            // del directorio previsto, por si algo se nos escapo antes.
            if (!destino.startsWith(DESTINO)) {
                throw new IOException("Ruta resultante fuera del directorio: " + destino);
            }

            long recibidos = 0;
            try (OutputStream salidaFichero =
                         new BufferedOutputStream(Files.newOutputStream(destino))) {
                byte[] bufer = new byte[8192];
                int leidos;
                // Leemos hasta fin de flujo: es el shutdownOutput() del emisor
                // el que provoca este -1. Sin el, este bucle no terminaria.
                while ((leidos = entrada.read(bufer)) != -1) {
                    if (recibidos + leidos > tamano) {
                        throw new IOException(
                                "El emisor envia mas bytes de los anunciados");
                    }
                    salidaFichero.write(bufer, 0, leidos);
                    recibidos += leidos;
                }
            }

            if (recibidos != tamano) {
                throw new EOFException("Se anunciaron " + tamano
                        + " bytes y llegaron " + recibidos);
            }

            long finalRecibidos = recibidos;
            LOG.info(() -> "Guardado " + destino + " (" + finalRecibidos + " bytes)");
            resumen = "200 OK " + nombre + " " + recibidos + " bytes";

        } catch (IOException e) {
            LOG.log(Level.WARNING, "Transferencia rechazada", e);
            resumen = "400 RECHAZADO " + e.getMessage();
        }

        // --- Respuesta de texto, con charset explicito ---
        BufferedWriter escritor = new BufferedWriter(
                new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8));
        escritor.write(resumen);
        escritor.write('\n');
        escritor.flush();       // sin esto, el emisor esperaria para siempre
    }

    public static void main(String[] args) throws IOException {
        new ReceptorFichero().escuchar(9091);
    }
}

Prueba en dos terminales:

# Terminal 1
java -cp clases com.nexussoftware.bibliotech.red.ReceptorFichero

# Terminal 2
java -cp clases com.nexussoftware.bibliotech.red.EmisorFichero catalogo.csv
Terminal 1:
INFO: Receptor escuchando en el puerto 9091
INFO: Conexion de /127.0.0.1:52104
INFO: Guardado recibidos/catalogo.csv (2847 bytes)

Terminal 2:
INFO: Enviados 2847 bytes de catalogo.csv
Receptor: 200 OK catalogo.csv 2847 bytes

Comentarios. Cuatro puntos que merecen quedarse.

El shutdownOutput() es el corazón del ejercicio. El receptor lee con while ((leidos = entrada.read(bufer)) != -1), y ese -1 solo aparece cuando el emisor cierra su sentido de escritura. Si el emisor hiciera socket.close() en su lugar, el -1 también llegaría, pero el emisor habría cerrado también su sentido de lectura y nunca podría recibir el resumen. Media conexión es exactamente la herramienta para "he terminado de hablar, pero sigo escuchando".

La validación del nombre no es un adorno. Files.newOutputStream(DESTINO.resolve("../../../etc/passwd")) escribe donde el atacante quiera si el proceso tiene permisos. Se comprueba dos veces: rechazando los caracteres peligrosos y verificando después con startsWith que la ruta normalizada sigue dentro del directorio previsto. En seguridad, la redundancia es correcta.

La validación del tamaño cierra el otro agujero: sin ella, un emisor que anuncia 500 GB llena el disco. Y fíjate en que se comprueba dos veces: el límite antes de empezar, y durante el bucle que no lleguen más bytes de los anunciados. Un emisor puede mentir en la cabecera.

Y el read(bufer) que puede devolver menos de lo pedido aparece en los dos lados: el emisor escribe salida.write(bufer, 0, leidos) y nunca salida.write(bufer). Escribir el buffer entero cuando solo se han leído 300 bytes envía 7892 bytes de basura. Es el mismo error que en el módulo 7, y en red duele más porque el fichero llega corrupto y no te enteras hasta que alguien lo abre.

Conclusión

Has abierto tu primera conexión desde Java, y con ella has descubierto lo que anunciaba la lección anterior: una vez conectado, la red es la E/S del módulo 7. getInputStream() y getOutputStream() te devuelven los mismos flujos, con los mismos decoradores, el mismo InputStreamReader con charset explícito y el mismo try-with-resources. Lo nuevo no es leer y escribir: es todo lo que rodea a esa lectura y esa escritura.

Sabes conectar bien: nunca con el constructor new Socket(host, puerto), que puede bloquear más de un minuto sin tiempo límite, sino con new Socket() seguido de connect(new InetSocketAddress(host, puerto), ms). Y tienes claro que hay dos tiempos límite distintos y hacen falta los dos: el de conexión, que fijas en connect, y el de lectura, que fijas con setSoTimeout y que es el que evita que un cliente zombi te deje un hilo bloqueado para siempre. Sabes además que SocketTimeoutException es la única excepción de red que no invalida el socket, y que eso permite el patrón de sondeo con comprobación de cancelación que hace interrumpible un socket bloqueante.

Has visto demostrado el bug número uno de los principiantes: escribir en un BufferedWriter y quedarte esperando una respuesta que nunca llega, porque tus veinticuatro caracteres siguen en un buffer de ocho mil y no han salido a la red. Sin excepción, sin traza, sin nada. Y conoces las tres formas de evitarlo, con el matiz que salva vidas: autoFlush solo actúa con println, printf y format, nunca con print.

Entiendes por qué existe readLine() y qué problema resuelve: TCP es un flujo de bytes sin fronteras de mensaje, y lo que tú escribes en dos envíos puede leerse en uno, en dos o en siete. BufferedReader acumula hasta el delimitador y te entrega mensajes completos. Con las dos advertencias que lo acompañan: devuelve null cuando el otro cierra —y no comprobarlo es un NullPointerException garantizado—, y no tiene límite de longitud, lo que lo convierte en un vector de denegación de servicio si no lo acotas. Y sabes cuándo el delimitador no sirve y hay que pasar a longitud previa, validando siempre la longitud declarada antes de reservar memoria con ella.

Sabes cerrar bien: solo el Socket en el try-with-resources, porque cerrar cualquiera de sus flujos lo cierra a él; y conoces la media conexión, con shutdownOutput() para decir "he terminado de hablar, pero sigo escuchando", que es la única forma de resolver los protocolos que terminan el envío con el fin de flujo.

Manejas las opciones del socket, y sobre todo las dos que importan: setSoTimeout, ya comentada, y setTcpNoDelay(true) para desactivar el algoritmo de Nagle en protocolos interactivos de mensajes cortos, donde la combinación de Nagle con la confirmación retardada produce retardos artificiales de hasta doscientos milisegundos. Y sabes cuáles no tocar: los tamaños de buffer y, sobre todo, setSoLinger.

Distingues cada excepción de red y lo que significa: UnknownHostException es configuración, ConnectException es que llegaste a la máquina pero no hay nadie en ese puerto, SocketTimeoutException es que nadie respondió, SocketException: Connection reset es que el otro se cayó, y el cierre ordenado no lanza ninguna excepción: se manifiesta como -1 o null. Con la regla que ordena el catch: ConnectException y BindException son subclases de SocketException y hay que comprobarlas antes. Y sabes traducir todo eso a BiblioTechException en la frontera, clasificando entre transitorio —merece reintento— y permanente —reintentar solo gasta CPU—, tal como aprendiste en 06-07.

Sabes enviar datos binarios con DataOutputStream/DataInputStream, que además escriben en big-endian y por tanto interoperan; y sabes por qué nunca debes enviar objetos serializados a un cliente no confiable: readObject() construye objetos arbitrarios antes de que tu conversión de tipos pueda decir nada, y eso es una vía de ejecución remota de código. La regla es enviar datos, no objetos.

Y BiblioTech ha ganado tres clases: ClienteCatalogo, que habla BTCP/1 completo —conecta con tiempo límite, verifica el saludo y la versión, consulta, lista, presta, valida sus propios argumentos contra la inyección de saltos de línea, acota las respuestas del servidor y se despide con SALIR en su close()—; TraductorErroresRed, la frontera que convierte cada excepción de red en un mensaje de dominio con su clasificación de transitoriedad; y DiagnosticoRed de la lección anterior. Lo has probado contra nc -l 9090 haciendo tú de servidor a mano, que es la mejor forma que existe de entender un protocolo, y has comprobado en directo qué pasa cuando el servidor no saluda, cuando saluda mal, cuando cierra a mitad y cuando no está.

Pero tu cliente todavía no tiene con quién hablar de verdad. Hasta ahora el servidor eras tú, tecleando respuestas en una terminal.

En la próxima lección, ServerSocket, escribirás el otro extremo. Verás ServerSocket y su bind a un puerto, la cola de conexiones pendientes, y accept() como el método bloqueante que te entrega un Socket ya conectado. Empezarás por un servidor secuencial, comprobarás en directo su límite —el segundo cliente espera a que el primero termine— y lo resolverás con el ExecutorService del módulo 8: pool acotado, ThreadFactory con nombres para los logs, apagado en dos fases y cierre del ServerSocket para desbloquear el accept. Ahí es donde todo el módulo 8 rinde de verdad, y donde entenderás por qué el CatalogoConcurrente con su ConcurrentHashMap ya estaba preparado para esto sin saberlo. Al terminar, el servidor de catálogo de BiblioTech aceptará a Marta, a Diego y a Nuria a la vez, validará todo lo que llegue por la red, responderá con códigos, expulsará a los clientes inactivos y se apagará ordenadamente — y lo probarás con telnet localhost 9090.

Curso de Programación en Java

Módulo 1: Introducción a Java

Módulo 2: Flujo de Control

Módulo 3: Programación Orientada a Objetos

Módulo 4: Programación Orientada a Objetos Avanzada

Módulo 5: Estructuras de Datos y Colecciones

Módulo 6: Manejo de Excepciones

Módulo 7: Entrada/Salida de Archivos

Módulo 8: Multihilo y Concurrencia

Módulo 9: Redes

Módulo 10: Temas Avanzados

Módulo 11: Frameworks y Librerías de Java

Módulo 12: Construcción de Aplicaciones del Mundo Real

© Copyright 2026. Todos los derechos reservados