Apuntes DAM
Volver al inicio

Servidor de órdenes con sockets e hilos

Ejercicio de JavaMuy difícilUnos 100 minutos

Un servidor TCP que atiende a cada cliente en su propio hilo con un protocolo de texto (SUMA, MAYUS, INVIERTE, GUARDA, LEE, CUENTA y ADIOS) y un almacén compartido entre conexiones, y el cliente que lo prueba en el mismo programa. ServerSocket, Socket, flujos de texto, hilos y ConcurrentHashMap.

  • ServerSocket y Socket
  • Puerto 0 (efímero)
  • BufferedReader y PrintWriter
  • Un hilo por cliente
  • ConcurrentHashMap
  • Diseño de un protocolo de texto

Enunciado

Muchos servicios de Internet (SMTP, FTP, Redis, POP3) usan protocolos de texto: el cliente envía una orden por línea y el servidor responde con otra línea que empieza por un código (OK, ERR). Son fáciles de depurar, porque se pueden probar a mano con telnet o netcat.

Vas a escribir un servidor de este tipo en Java. Escucha en un puerto, acepta conexiones y atiende a cada cliente en un hilo distinto, para que un cliente lento no bloquee a los demás. Tiene un pequeño almacén clave-valor compartido por todas las conexiones, al estilo de Redis, así que tiene que ser seguro para varios hilos.

Para poder probarlo sin dos programas, el mismo main arranca el servidor en un hilo y hace de cliente: lee las órdenes de la entrada, las envía por el socket y escribe lo que contesta el servidor. Una línea --- cierra la conexión y abre otra, como si llegara un cliente nuevo.

Qué tiene que hacer el programa

  1. El servidor escucha en localhost en un puerto libre (puerto 0) y atiende cada conexión en un hilo nuevo. Lee líneas en UTF-8 y responde a cada una con una línea. Cuando recibe ADIOS responde y cierra la conexión.
  2. Protocolo (las órdenes se reconocen en mayúsculas o minúsculas): SUMA a b → OK suma con enteros de hasta 9 cifras y signo opcional, o ERR números no válidos; MAYUS texto → OK TEXTO; INVIERTE texto → OK otxet (sin texto, ERR falta el texto); GUARDA clave valor (el valor puede tener espacios) → OK guardado u OK sustituido (antes: valor), y ERR uso: GUARDA clave valor si falta algo; LEE clave → OK valor o ERR no existe clave; CUENTA → OK número de claves; ADIOS → OK adiós. Una línea vacía es ERR línea vacía y cualquier otra orden ERR orden desconocida.
  3. El cliente conecta al empezar y escribe == Cliente N conectado. Por cada línea de la entrada (incluidas las vacías) la envía, espera la respuesta y escribe > línea (solo > si la línea está vacía) y < respuesta. Una línea --- cierra la conexión actual y abre una nueva (con su mensaje de conexión); después de ADIOS, la siguiente línea también abre una conexión nueva.
  4. Al terminar la entrada, el cliente cierra su conexión, se cierra el servidor y se escribe Servidor cerrado · claves guardadas: N.
  5. El almacén es común a todas las conexiones: lo que guarda un cliente lo puede leer el siguiente.

Entrada

Una orden del protocolo por línea; --- abre una conexión nueva.

Ejemplos de ejecución

Tu programa debe escribir exactamente esta salida para estas entradas. Las pruebas del editor incluyen estos ejemplos y otros casos ocultos.

Dos clientes que comparten el almacén

Entrada

SUMA 20 22
MAYUS hola, mundo
INVIERTE ñandú
GUARDA curso 2º DAM
GUARDA curso 1º DAW
CUENTA
ADIOS
LEE curso
GUARDA tutor Ana Gil
---
LEE tutor
LEE profe
CUENTA

Salida por consola

== Cliente 1 conectado
> SUMA 20 22
< OK 42
> MAYUS hola, mundo
< OK HOLA, MUNDO
> INVIERTE ñandú
< OK údnañ
> GUARDA curso 2º DAM
< OK guardado
> GUARDA curso 1º DAW
< OK sustituido (antes: 2º DAM)
> CUENTA
< OK 1
> ADIOS
< OK adiós
== Cliente 2 conectado
> LEE curso
< OK 1º DAW
> GUARDA tutor Ana Gil
< OK guardado
== Cliente 3 conectado
> LEE tutor
< OK Ana Gil
> LEE profe
< ERR no existe profe
> CUENTA
< OK 2
Servidor cerrado · claves guardadas: 2

Errores del protocolo

Entrada

SUMA 1
SUMA uno dos
MAYUS

GUARDA solo
BORRA todo
suma -5 3

Salida por consola

== Cliente 1 conectado
> SUMA 1
< ERR números no válidos
> SUMA uno dos
< ERR números no válidos
> MAYUS
< ERR falta el texto
>
< ERR línea vacía
> GUARDA solo
< ERR uso: GUARDA clave valor
> BORRA todo
< ERR orden desconocida
> suma -5 3
< OK -2
Servidor cerrado · claves guardadas: 0

Guía paso a paso

Intenta resolverlo por tu cuenta y abre un paso solo cuando te atasques: cada uno te acerca a la solución sin dártela entera.

1. El protocolo, primero sin red

Escribe responder(String linea) como un método normal que devuelva la respuesta. Es lo más largo del ejercicio y se puede probar sin sockets. Separa la orden del resto con split("\\s+", 2): así el texto de MAYUS o el valor de GUARDA conservan sus espacios.

2. Servir a un cliente

Con el socket del cliente, envuelve sus flujos en un BufferedReader (para leer líneas) y un PrintWriter con autoflush (true), los dos en UTF-8. Lee con readLine() hasta que devuelva null (el cliente ha cerrado) o llegue ADIOS.

java
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true);
3. Un hilo para aceptar y uno por cliente

servidor.accept() bloquea hasta que llega una conexión. Ponlo en un bucle dentro de un hilo y lanza otro hilo por cada cliente aceptado. Cuando el main cierre el ServerSocket, accept() lanzará una excepción: ahí termina el bucle.

4. El almacén compartido

Varios hilos pueden escribir a la vez en el almacén. ConcurrentHashMap está preparado para eso, y su put devuelve el valor anterior (o null), lo que te da la respuesta de GUARDA en una sola operación atómica.

5. El cliente

Conecta con new Socket("localhost", servidor.getLocalPort()). Envía cada línea y lee la respuesta antes de seguir: el protocolo es de pregunta y respuesta. Al final, cierra el socket del cliente, después el ServerSocket, y espera al hilo aceptador con join().

Resuélvelo aquí

El editor trae el esqueleto del programa. Pulsa «Ejecutar» para comprobarlo con los ejemplos y con 2 casos ocultos que buscan los errores típicos.

☕JavaServidor de órdenes con sockets e hilosMuy difícil

Ejemplo

Entrada (lo que se escribe por teclado)
SUMA 20 22
MAYUS hola, mundo
INVIERTE ñandú
GUARDA curso 2º DAM
GUARDA curso 1º DAW
CUENTA
ADIOS
LEE curso
GUARDA tutor Ana Gil
---
LEE tutor
LEE profe
CUENTA
Salida esperada
== Cliente 1 conectado
> SUMA 20 22
< OK 42
> MAYUS hola, mundo
< OK HOLA, MUNDO
> INVIERTE ñandú
< OK údnañ
> GUARDA curso 2º DAM
< OK guardado
> GUARDA curso 1º DAW
< OK sustituido (antes: 2º DAM)
> CUENTA
< OK 1
> ADIOS
< OK adiós
== Cliente 2 conectado
> LEE curso
< OK 1º DAW
> GUARDA tutor Ana Gil
< OK guardado
== Cliente 3 conectado
> LEE tutor
< OK Ana Gil
> LEE profe
< ERR no existe profe
> CUENTA
< OK 2
Servidor cerrado · claves guardadas: 2
⏳
Test oculto #3
⏳
Test oculto #4
0/4 tests pasados · pulsa un test para ver su entrada y su salida esperada

Solución explicada

Ver la solución completa
java
1import java.io.BufferedReader;
2import java.io.IOException;
3import java.io.InputStreamReader;
4import java.io.OutputStreamWriter;
5import java.io.PrintWriter;
6import java.net.ServerSocket;
7import java.net.Socket;
8import java.nio.charset.StandardCharsets;
9import java.util.Map;
10import java.util.Scanner;
11import java.util.concurrent.ConcurrentHashMap;
12
13public class Main {
14    // Compartido por todas las conexiones: cada cliente se atiende en su propio hilo
15    static final Map<String, String> almacen = new ConcurrentHashMap<>();
16
17    /** La respuesta del protocolo a una línea. */
18    static String responder(String linea) {
19        String[] p = linea.trim().split("\\s+", 2);
20        String orden = p[0].toUpperCase();
21        String resto = p.length > 1 ? p[1] : "";
22        switch (orden) {
23            case "" -> { return "ERR línea vacía"; }
24            case "SUMA" -> {
25                String[] n = resto.split("\\s+");
26                if (n.length != 2 || !n[0].matches("-?\\d{1,9}") || !n[1].matches("-?\\d{1,9}")) return "ERR números no válidos";
27                return "OK " + (Long.parseLong(n[0]) + Long.parseLong(n[1]));
28            }
29            case "MAYUS" -> { return resto.isEmpty() ? "ERR falta el texto" : "OK " + resto.toUpperCase(); }
30            case "INVIERTE" -> { return resto.isEmpty() ? "ERR falta el texto" : "OK " + new StringBuilder(resto).reverse(); }
31            case "GUARDA" -> {
32                String[] kv = resto.split("\\s+", 2);
33                if (kv.length != 2) return "ERR uso: GUARDA clave valor";
34                String anterior = almacen.put(kv[0], kv[1]);
35                return anterior == null ? "OK guardado" : "OK sustituido (antes: " + anterior + ")";
36            }
37            case "LEE" -> {
38                String valor = almacen.get(resto);
39                return valor == null ? "ERR no existe " + resto : "OK " + valor;
40            }
41            case "CUENTA" -> { return "OK " + almacen.size(); }
42            case "ADIOS" -> { return "OK adiós"; }
43            default -> { return "ERR orden desconocida"; }
44        }
45    }
46
47    /** Atiende a un cliente: una respuesta por línea hasta ADIOS o hasta que cierre la conexión. */
48    static void atender(Socket socket) {
49        try (socket;
50             BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream(), StandardCharsets.UTF_8));
51             PrintWriter out = new PrintWriter(new OutputStreamWriter(socket.getOutputStream(), StandardCharsets.UTF_8), true)) {
52            String linea;
53            while ((linea = in.readLine()) != null) {
54                out.println(responder(linea));
55                if (linea.trim().equalsIgnoreCase("ADIOS")) break;
56            }
57        } catch (IOException e) {
58            System.out.println("Error con un cliente: " + e.getMessage());
59        }
60    }
61
62    public static void main(String[] args) throws Exception {
63        // Puerto 0: el sistema elige uno libre
64        ServerSocket servidor = new ServerSocket(0);
65        Thread aceptador = new Thread(() -> {
66            try {
67                while (true) {
68                    Socket cliente = servidor.accept();
69                    new Thread(() -> atender(cliente)).start();
70                }
71            } catch (IOException e) {
72                // El servidor se ha cerrado: se termina el bucle
73            }
74        });
75        aceptador.start();
76
77        // El cliente: cada «---» de la entrada abre una conexión nueva
78        Scanner sc = new Scanner(System.in);
79        int numero = 0;
80        Socket conexion = null;
81        BufferedReader in = null;
82        PrintWriter out = null;
83        while (sc.hasNextLine()) {
84            String linea = sc.nextLine();
85            if (conexion == null || linea.equals("---")) {
86                if (conexion != null) conexion.close();
87                conexion = new Socket("localhost", servidor.getLocalPort());
88                in = new BufferedReader(new InputStreamReader(conexion.getInputStream(), StandardCharsets.UTF_8));
89                out = new PrintWriter(new OutputStreamWriter(conexion.getOutputStream(), StandardCharsets.UTF_8), true);
90                System.out.println("== Cliente " + (++numero) + " conectado");
91                if (linea.equals("---")) continue;
92            }
93            out.println(linea);
94            String respuesta = in.readLine();
95            System.out.println(linea.isEmpty() ? ">" : "> " + linea);
96            System.out.println("< " + (respuesta == null ? "(conexión cerrada)" : respuesta));
97            if (respuesta != null && linea.trim().equalsIgnoreCase("ADIOS")) {
98                conexion.close();
99                conexion = null;
100            }
101        }
102        if (conexion != null) conexion.close();
103        servidor.close();
104        aceptador.join();
105        System.out.println("Servidor cerrado · claves guardadas: " + almacen.size());
106    }
107}

Separar el protocolo (responder) de la red (atender) es lo que hace manejable un servidor: la lógica se prueba como cualquier método y la parte de sockets queda reducida a leer una línea, responder y repetir.

Un hilo por cliente es el modelo más sencillo de servidor concurrente: mientras un hilo espera a que su cliente escriba, los demás siguen atendiendo. Para miles de conexiones se usa un pool de hilos o E/S no bloqueante, pero el protocolo sería el mismo.

El almacén es el único estado compartido, y por eso es un ConcurrentHashMap. Su put es atómico: con un HashMap y un get seguido de un put, dos clientes podrían creer a la vez que la clave era nueva.

Usar el puerto 0 hace que el sistema elija uno libre: el programa nunca falla porque el puerto esté ocupado. El cliente lo averigua con getLocalPort(), igual que en un test de integración real.

Para ir más allá

  • Sustituye los hilos sueltos por un ExecutorService de tamaño fijo y comprueba qué pasa cuando hay más clientes que hilos.
  • Añade CADUCA clave segundos para que las claves desaparezcan pasado un tiempo, como en Redis.
  • Pruébalo desde otra máquina con nc ip puerto escuchando en todas las interfaces, y piensa qué haría falta para cifrar la conexión (SSLServerSocket).

Dónde se explica