Programación de Aplicaciones Cliente/Servidor con Sockets
En este apartado entraremos en la práctica desarrollando aplicaciones que se comunican a través de la red. Veremos cómo implementar tanto clientes como servidores utilizando los protocolos TCP y UDP en Kotlin.
Sockets TCP (Orientados a Conexión)
El protocolo TCP es ideal cuando necesitamos una transmisión fiable de datos. La comunicación sigue un patrón estricto: el servidor espera conexiones, el cliente se conecta, y una vez establecida la conexión, ambos pueden intercambiar datos a través de flujos (streams).
El Servidor TCP
Para crear un servidor TCP en Kotlin utilizamos la clase ServerSocket.
Pasos básicos:
- Crear el ServerSocket: Especificando el puerto donde escuchará.
- Esperar conexiones: El método
accept()bloquea la ejecución hasta que un cliente se conecta. Retorna un objetoSocketpara comunicarse con ese cliente específico. - Obtener flujos: Usar
getInputStream()ygetOutputStream()del socket cliente para recibir y enviar datos. - Cerrar: Cerrar los flujos y el socket cuando termine la comunicación.
import java.net.ServerSocket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
fun main() {
val puerto = 6000
try {
// 1. Crear el ServerSocket
val serverSocket = ServerSocket(puerto)
println("Servidor escuchando en el puerto $puerto...")
// 2. Esperar conexión (bloqueante)
val clientSocket = serverSocket.accept()
println("Cliente conectado desde: ${clientSocket.inetAddress.hostAddress}")
// 3. Obtener flujos de entrada y salida
// Para leer texto cómodamente usamos BufferedReader
val input = BufferedReader(InputStreamReader(clientSocket.getInputStream()))
// Para escribir texto cómodamente usamos PrintWriter (autoFlush = true)
val output = PrintWriter(clientSocket.getOutputStream(), true)
// Leer mensaje del cliente
val mensajeCliente = input.readLine()
println("Mensaje recibido: $mensajeCliente")
// Enviar respuesta
output.println("Hola Cliente, he recibido tu mensaje: $mensajeCliente")
// 4. Cerrar recursos
clientSocket.close()
serverSocket.close()
println("Servidor cerrado.")
} catch (e: Exception) {
e.printStackTrace()
}
}
El Cliente TCP
El cliente utiliza la clase Socket para iniciar la conexión.
Pasos básicos:
- Crear el Socket: Especificando la IP del servidor y el puerto.
- Obtener flujos: Igual que en el servidor, para enviar y recibir.
- Comunicación: Enviar solicitud y esperar respuesta.
- Cerrar: Cerrar el socket.
import java.net.Socket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
fun main() {
val host = "localhost" // O la IP del servidor
val puerto = 6000
try {
println("Conectando al servidor $host:$puerto...")
// 1. Crear el Socket y conectar
val socket = Socket(host, puerto)
// 2. Obtener flujos
val output = PrintWriter(socket.getOutputStream(), true)
val input = BufferedReader(InputStreamReader(socket.getInputStream()))
// 3. Enviar mensaje
val mensaje = "¡Hola desde Kotlin!"
output.println(mensaje)
println("Mensaje enviado: $mensaje")
// Leer respuesta del servidor
val respuesta = input.readLine()
println("Respuesta del servidor: $respuesta")
// 4. Cerrar
socket.close()
} catch (e: Exception) {
println("Error: ${e.message}")
}
}
Sockets UDP (Sin Conexión)
UDP funciona enviando paquetes independientes (datagramas). No hay un canal permanente ni garantía de entrega. Usamos DatagramSocket y DatagramPacket.
El Receptor UDP (Servidor)
- Crear DatagramSocket: En un puerto específico.
- Preparar DatagramPacket: Un array de bytes vacío para recibir los datos.
- Recibir: El método
receive()bloquea hasta que llega un paquete.
import java.net.DatagramPacket
import java.net.DatagramSocket
fun main() {
val puerto = 6000
val buffer = ByteArray(1024) // Buffer para recibir datos
try {
// 1. Crear socket
val socket = DatagramSocket(puerto)
println("Servidor UDP esperando datagramas en el puerto $puerto...")
// 2. Preparar paquete para recibir
val packet = DatagramPacket(buffer, buffer.size)
// 3. Recibir (bloqueante)
socket.receive(packet)
// Procesar datos
val mensaje = String(packet.data, 0, packet.length)
println("Recibido de ${packet.address}:${packet.port}: $mensaje")
socket.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
El Emisor UDP (Cliente)
- Crear DatagramSocket: No hace falta especificar puerto (se asigna uno aleatorio).
- Preparar DatagramPacket: Con los datos, la IP de destino y el puerto de destino.
- Enviar: Usando
send().
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
fun main() {
val host = "localhost"
val puerto = 6000
val mensaje = "Mensaje vía UDP"
try {
val socket = DatagramSocket()
val direccion = InetAddress.getByName(host)
val buffer = mensaje.toByteArray()
// Crear paquete con datos y destino
val packet = DatagramPacket(buffer, buffer.size, direccion, puerto)
// Enviar
socket.send(packet)
println("Paquete enviado a $host:$puerto")
socket.close()
} catch (e: Exception) {
e.printStackTrace()
}
}
Servidores Concurrentes (Multihilo)
El servidor TCP básico que vimos antes tiene una gran limitación: es iterativo. Solo puede atender a un cliente a la vez. Si un segundo cliente intenta conectarse mientras el servidor procesa al primero, tendrá que esperar.
Para solucionar esto, utilizamos hilos (threads). El servidor principal solo se encarga de aceptar conexiones y, por cada cliente que llega, crea un nuevo hilo dedicado a atenderlo.
Estructura de un Servidor Multihilo
- El hilo principal tiene un bucle infinito con
serverSocket.accept(). - Cuando
accept()retorna unclientSocket, se instancia un nuevo hilo (o una corrutina) pasándole este socket. - El nuevo hilo se encarga de la comunicación con ese cliente específico.
- El hilo principal vuelve inmediatamente a
accept()para esperar más clientes.
import java.net.ServerSocket
import java.net.Socket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import kotlin.concurrent.thread
// Clase que maneja la conexión con un cliente específico
class GestorCliente(private val socket: Socket) {
fun run() {
try {
val input = BufferedReader(InputStreamReader(socket.getInputStream()))
val output = PrintWriter(socket.getOutputStream(), true)
output.println("Bienvenido al servidor multihilo. Escribe 'salir' para terminar.")
var linea: String?
while (input.readLine().also { linea = it } != null) {
println("Cliente [${socket.port}] dice: $linea")
if ("salir" == linea) {
break
}
output.println("Eco: $linea")
}
} catch (e: Exception) {
println("Error con cliente: ${e.message}")
} finally {
try {
socket.close()
println("Cliente [${socket.port}] desconectado.")
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
fun main() {
val puerto = 6000
val serverSocket = ServerSocket(puerto)
println("Servidor Multihilo iniciado en puerto $puerto")
while (true) {
// 1. Esperar conexión
val clientSocket = serverSocket.accept()
println("Nuevo cliente conectado: ${clientSocket.inetAddress}")
// 2. Crear un hilo para atender al cliente
thread {
val gestor = GestorCliente(clientSocket)
gestor.run()
}
}
}
Servidores Concurrentes con Corrutinas
Aunque el uso de thread funciona, crear un hilo del sistema operativo para cada cliente es costoso en términos de memoria y tiempo de CPU. Si tuviéramos 10,000 clientes simultáneos, el servidor probablemente colapsaría.
Kotlin ofrece una solución mucho más eficiente: las Corrutinas. Una corrutina es como un "hilo ligero". Podemos tener cientos de miles de corrutinas ejecutándose sobre un número muy pequeño de hilos reales.
Para usar corrutinas en un servidor, simplemente envolvemos la lógica de atención al cliente en un bloque launch dentro de un runBlocking (o un CoroutineScope adecuado). Además, debemos usar Dispatchers.IO, que está optimizado para operaciones de entrada/salida como la red.
import java.net.ServerSocket
import java.net.Socket
import java.io.BufferedReader
import java.io.InputStreamReader
import java.io.PrintWriter
import kotlinx.coroutines.* // Requiere añadir la librería kotlinx-coroutines-core
fun main() = runBlocking {
val puerto = 6000
val serverSocket = ServerSocket(puerto)
println("Servidor con Corrutinas iniciado en puerto $puerto")
// Usamos Dispatchers.IO para operaciones de red
withContext(Dispatchers.IO) {
while (true) {
// accept() sigue siendo bloqueante, pero está en un hilo IO
val clientSocket = serverSocket.accept()
println("Nuevo cliente conectado: ${clientSocket.inetAddress}")
// Lanzamos una corrutina para cada cliente
// Esto es CASI GRATIS en términos de recursos
launch {
manejarClienteCorrutina(clientSocket)
}
}
}
}
// Esta función se ejecuta dentro de una corrutina
suspend fun manejarClienteCorrutina(socket: Socket) = withContext(Dispatchers.IO) {
try {
val input = BufferedReader(InputStreamReader(socket.getInputStream()))
val output = PrintWriter(socket.getOutputStream(), true)
output.println("Hola desde una Corrutina!")
var linea: String?
// Nota: readLine() es bloqueante. En un entorno 100% corrutinas (como Ktor)
// usaríamos funciones 'suspend' para leer, pero aquí usamos la API clásica de Java
// dentro de Dispatchers.IO, lo cual es aceptable.
while (input.readLine().also { linea = it } != null) {
println("Cliente dice: $linea")
output.println("Eco: $linea")
}
} catch (e: Exception) {
println("Error: ${e.message}")
} finally {
socket.close()
}
}
En el ejemplo anterior, aunque usamos corrutinas, las llamadas a input.readLine() siguen siendo bloqueantes porque java.net.Socket es una API bloqueante. Sin embargo, al ejecutarlo en Dispatchers.IO, el hilo que se bloquea es uno del pool de IO, no el hilo principal. Para una solución totalmente no bloqueante, se deberían usar librerías como Ktor o Java NIO, pero para aprender los conceptos básicos, envolver sockets clásicos en corrutinas es un excelente primer paso.