domingo, 26 de mayo de 2024

Redes con Python: Programación de Sockets para la Comunicación. 3.- Streaming

El protocolo TCP transmite datos a través de una conexión. Las funciones de socket send y recv sugieren que los mensajes individuales se envían y reciben. Sin embargo, esto no es cierto.

Ahora llegamos al principal problema de los socket, send y recv operan en los buffers de red. Ellos no manejan necesariamente todos los bytes que se les entrega porque su enfoque principal es manejar los buffers de red. En general, ellos devuelven los datos cuando los buffers de red se han llenado (send) o vaciado (recv). Luego nos dicen cuantos bytes han manejado. Es nuestra responsabilidad llamarlos nuevamente hasta que el mensaje haya sido tratado por completo.

Cuando recv retorna 0 bytes significa que el otro lado ha cerrado (o está en el proceso de cerrar) la conexión. No recibirás más datos de esta conexión. Nunca.

Un protocolo como HTTP usa un socket para una sola transferencia. El cliente manda una petición, luego lee la respuesta. Eso es todo. El socket es descartado. Esto significa que un cliente puede detectar el final de la respuesta al recibir 0 bytes.

Pero si planeas reusar el socket para más transferencias, tienes que darte cuenta que no hay una señal de fin de conexión (EOT) en un socket. Repito: si la llamada a send o recv de un socket retorna después de manejar 0 bytes, la conexión se ha interrumpido. 

Enviar y recibir flujos de bytes

Cada vez que un socket envía un mensaje, se envía una serie de bytes como un flujo.


flujo de datos


TCP garantiza que todos los bytes llegarán al socket receptor en el orden correcto.

Sin embargo, no hay garantía de que el número de bytes devueltos por una llamada a recv sea el mismo número de bytes enviados usando send

Podría ser que múltiples mensajes se hayan enviado usando send, los cuales se recuperan mediante una sola llamada a recv.

También podría ser que se necesiten múltiples llamadas a recv para recuperar todos los bytes que se enviaron en un solo mensaje.

Me explico. Cuando el cliente usa s.recv(), es posible que reciba solamente un byte, b"h" del mensaje b"Hola, Gracias por conectarte" enviado por el servidor. El argumento buffersize de 1024 que hemos usado para recibir datos, es la cantidad máxima de datos que se pueden recibir de una sola vez, lo que no significa que se devuelvan exactamente esos 1024 bytes.

Esto crea dos problemas prácticos para los programadores:

1. Cómo determinar el final de un mensaje y el comienzo de otro

2. Cómo reconstruir un solo mensaje a partir de múltiples conjuntos de bytes

Cuando se trata de mensajes de red que contienen solo texto, un enfoque común es incluir un carácter no imprimible al final de cada mensaje, como un retorno de carro \n o un carácter de Fin de Transmisión (EOT) \4.

En las siguientes secciones crearemos dos funciones que hacen uso de un carácter de terminación.

Más adelante veremos otras opciones para tratar estos problemas al enviar datos binarios (no texto).

Terminar un mensaje

Cada mensaje que envíes debe terminar con el carácter de terminación \n.
Revisa la función enviar_texto a continuación y observa que:

  • Espera un socket y el texto a enviar como parámetros.
  • Añade el carácter \n al mensaje antes de enviarlo.

def enviar_texto(client_socket, texto):
    texto = texto + "\n"
    data = texto.encode()
    client_socket.send(data)

Agrega la función enviar_texto a tu programa socket_server.py. Reemplaza la llamada a .send con el código para llamar a la función enviar_texto.

Código antiguo:

mensaje = "Hola, ¡Gracias por conectarte!"
data = mensaje.encode()
client_socket.send(data)

Código nuevo:

mensaje = "Hola, ¡Gracias por conectarte!"
enviar_texto(conexion_socket, mensaje)


Obteniendo mensajes completos

Ahora puedes crear una función complementaria `obtener_texto` que usará el carácter \n para determinar si se ha recibido un mensaje completo.

El proceso de asegurar que se reciban mensajes completos es más complicado, ya que tiene que verificar si:

1. Se han devuelto múltiples mensajes de una sola llamada a `recv`.

2. Se requieren múltiples llamadas a `recv` para obtener un solo mensaje.


La función `obtener_texto` a continuación hace esto mediante:

- Llamadas continuas para recibir datos (`recv`) desde un socket pasado como parámetro.

        - Agregar cualquier dato recibido a un buffer.

        - Verificar si el buffer contiene un carácter \n y, por lo tanto, un mensaje completo.

        - Devolver el mensaje completo.

        - Eliminar el mensaje devuelto del buffer.

        - Verificar si el buffer contiene más mensajes completos.

def obtener_texto(client_socket):
    buffer = ""
    socket_abierto = True
    
    while socket_abierto:
        # Lee cualquier dato desde el socket
        data = client_socket.recv(1024)
        # Si no se reciben más datos el socket debe cerrarse.
        if not data:
            socket_abierto = False
        # Añadir datos al buffer.
        buffer = buffer + data.decode()
        # Si hay un caracter de fin de mensaje en el buffer
        posicion_caracter_fin = buffer.find("\n")
        # Si el valor es más grande que -1, un /n debe existir.
        while posicion_caracter_fin > -1:
            # obtiene el mensaje del buffer
            mensaje = buffer[:posicion_caracter_fin]
            # eliminamos el mensaje del buffer
            buffer = buffer[posicion_caracter_fin + 1:]
            # yield en el mensaje (lo explicamos luego)
            yield mensaje
            # ¿Hay otro caracter de fin de mensaje el buffer
            posicion_caracter_fin = buffer.find("\n")

La función obtener_texto utiliza una función generadora yield para devolver mensajes completos uno a la vez. Hay más información sobre cómo funcionan yield y las funciones generadoras en el anexo al final de este post.

Para usar la función obtener_texto en el programa client_socket.py necesitas reemplazar este código:

client_socket.py

#...
data = client_socket.recv(1024)
mensaje = data.decode()
print(mensaje)

Podrías usar la función obtener texto dentro de un bucle for, que se repetirá hasta que se complete la función del generador obtener_texto cuando el socket esté cerrado:

for mensaje in obtener_texto(client_socket):
    print(mensaje)

O podrías usar next para llamar a la función  obtener_texto una vez y obtener un único mensaje.

mensaje = next(obtener_texto(client_socket))
print(mensaje)

Experimenta con ambas opciones y asegúrate de que entiendes la diferencia entre ambas.


EJERCICIO PRÁCTICO. APLICACIÓN DE CHAT.

En este ejercicio vamos a construir una aplicación de Chat usando lo que hemos visto hasta ahora. Vamos a crear un solo programa que se pueda utilizar a la vez como cliente y servidor y que nos permita crear una conversación entre dos máquinas. 

Puedes llamar al programa como quieras, yo lo llamará server_client_chat.py. Vamos a empezar importando la librería socket y las funciones que hemos visto antes y que nos servirán tanto para enviar como para recibir texto, las funciones obtener_texto y enviar_texto.

server_client_chat.py

import socket

TCP_IP = "0.0.0.0"  # IP address to bind to
TCP_PORT = 8081  # Port to bind to or connect to
BUFFER_SIZE = 1024  # Buffer size for receiving data
END_MESSAGE = "%end%"  # Special message to indicate end of communication

def enviar_texto(sending_socket, texto):
    """Envía texto a través de un socket, añadiendo una nueva línea al final."""
    texto += "\n"
    data = texto.encode()
    sending_socket.send(data)

def obtener_texto(receiving_socket):
    """Generador que recibe texto desde un socket y devuelve mensajes completos."""
    buffer = ""
    while True:
        data = receiving_socket.recv(BUFFER_SIZE)
        if not data:
            break
        buffer += data.decode()
        while "\n" in buffer:
            mensaje, buffer = buffer.split("\n", 1)
            yield mensaje

Creamos el menú para que el usuario pueda elegir entre la opción 1, que el programa actúe como un servidor u opción 2, que se comporte como un cliente. Si se escoge cualquier otra opción el programa volverá a mostrar el menú inicial.

Añade el siguiente código al final del código anterior.

server_client_chat.py

# ...
def main():
    """Función principal que selecciona modo de operación: servidor o cliente."""
    while True:
        ser_cli = input("Elige: iniciar un servidor (1) o conectarte a uno - cliente (2) > ")
        if ser_cli in ('1', '2'):
            break

    if ser_cli == '1':
        iniciar_servidor()
    else:
        conectar_cliente()

    print("¡Programa terminado! Sockets cerrados.")

if __name__ == "__main__":
    main()

Vamos a desarrollar el código para la opción del servidor (conexión, enlace, escucha, aceptación) que se ejecutará a través de la función iniciar_servidor()

- Conexión. Lo primero es crear un objeto socket usando la biblioteca que importamos previamente (Creamos un socket TCP  de flujo).

- Enlace. Cuando estamos intentando configurar un servidor, necesitamos que el socket se asocie a una dirección IP y a un número de puerto especifico para que pueda recibir las conexiones. La función "bind" se utiliza para esta tarea.

- Escucha. Llamamos al método listen del socket. El programa se parará hasta que se conecte un cliente.

- Aceptación. Si se acepta la conexión mostraremos un mensaje en pantalla con la dirección IP y el puerto donde se realiza la conexión.

Crearemos un mensaje para que primero, el cliente sepa que se ha realizado la conexión y luego sepa como finalizar la conexión. 

server_client_chat.py

def iniciar_servidor():
    """Inicializa el servidor y maneja las conexiones entrantes."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as servidor_socket:
        servidor_socket.bind((TCP_IP, TCP_PORT))
        servidor_socket.listen()
        print(f"Servidor vinculado a {TCP_IP}:{TCP_PORT}\nEsperando conexión...")

        conexion, client_address = servidor_socket.accept()
        with conexion:
            print(f"Cliente conectado desde {client_address[0]}:{client_address[1]}")
            enviar_texto(conexion, "¡Bienvenido al servidor! Teclea %end% para finalizar...")
            manejar_comunicacion(conexion)

Una vez que tenemos hecha la parte del servidor pasemos a ver como desarrollar la parte del cliente. Es igual que la hemos usado previamente, con la peculiaridad de que incorporamos la posibilidad de que el usuario introduzca la IP del servidor.

server_client_chat.py

# ...
   def conectar_cliente():
    """Conecta al cliente a un servidor especificado."""
    serverIP = input("Introduce la dirección IP (e.g. 127.0.0.1) o deja en blanco para LOCALHOST: ") or "127.0.0.1"
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as conexion:
        conexion.connect((serverIP, TCP_PORT))
        print(f"Conectado al servidor {serverIP}:{TCP_PORT}")
        manejar_comunicacion(conexion)

Crearemos otra función que maneje las comunicaciones entre el servidor y el cliente. La conexión entre ambos se finalizará cuando el mensaje enviado sea "%end%". 

Luego usaríamos otro conjunto de instrucciones try - except-finally para cerrar las conexiones.

server_client_chat.py

#...
def manejar_comunicacion(conexion):
    """Maneja la comunicación bidireccional entre cliente y servidor."""
    try:
        for mensaje in obtener_texto(conexion):
            print(">> ", mensaje)
            if mensaje == END_MESSAGE:
                break
            nuevo_mensaje = input("Escribe un mensaje > ")
            enviar_texto(conexion, nuevo_mensaje)
    except Exception as e:
        print(f"Error en la comunicación: {e}")
    finally:
        print("Conexión cerrada.")

En resumen:

Descripción del flujo principal

  1. Selección de Modo (Servidor o Cliente):

    • El usuario elige si quiere iniciar un servidor (1) o conectarse a uno como cliente (2).
  2. Servidor:

    • Se crea un socket de servidor (servidor_socket).
    • Se vincula (bind) el socket a la IP y el puerto definidos.
    • Se pone el socket en modo de escucha (listen) para aceptar conexiones entrantes.
    • Cuando un cliente se conecta, se acepta la conexión (accept), creando un nuevo socket (conexion) para la comunicación con el cliente.
    • Se envía un mensaje de bienvenida al cliente.
  3. Cliente:

    • Se crea un socket de cliente (conexion).
    • El usuario ingresa la dirección IP del servidor al que desea conectarse.
    • El socket se conecta al servidor usando la IP y el puerto definidos.
  4. Comunicación:

    • Se entra en un bucle donde el cliente y el servidor intercambian mensajes.
    • Los mensajes recibidos se muestran en pantalla y el usuario puede enviar nuevos mensajes.
    • Si se recibe el mensaje %end%, el bucle se rompe y se cierran los sockets.
  5. Cierre:

    • Se intentan cerrar los sockets tanto en el cliente como en el servidor para asegurar que los recursos se liberen correctamente.
Así quedaría el código final:

server_client_chat.py

import socket

TCP_IP = "0.0.0.0"  # IP address to bind to
TCP_PORT = 8081  # Port to bind to or connect to
BUFFER_SIZE = 1024  # Buffer size for receiving data
END_MESSAGE = "%end%"  # Special message to indicate end of communication

def enviar_texto(sending_socket, texto):
    """Envía texto a través de un socket, añadiendo una nueva línea al final."""
    texto += "\n"
    data = texto.encode()
    sending_socket.send(data)

def obtener_texto(receiving_socket):
    """Generador que recibe texto desde un socket y devuelve mensajes completos."""
    buffer = ""
    while True:
        data = receiving_socket.recv(BUFFER_SIZE)
        if not data:
            break
        buffer += data.decode()
        while "\n" in buffer:
            mensaje, buffer = buffer.split("\n", 1)
            yield mensaje

def iniciar_servidor():
    """Inicializa el servidor y maneja las conexiones entrantes."""
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as servidor_socket:
        servidor_socket.bind((TCP_IP, TCP_PORT))
        servidor_socket.listen()
        print(f"Servidor vinculado a {TCP_IP}:{TCP_PORT}\nEsperando conexión...")

        conexion, client_address = servidor_socket.accept()
        with conexion:
            print(f"Cliente conectado desde {client_address[0]}:{client_address[1]}")
            enviar_texto(conexion, "¡Bienvenido al servidor! Teclea %end% para finalizar...")
            manejar_comunicacion(conexion)

def conectar_cliente():
    """Conecta al cliente a un servidor especificado."""
    serverIP = input("Introduce la dirección IP (e.g. 127.0.0.1) o deja en blanco para LOCALHOST: ") or "127.0.0.1"
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as conexion:
        conexion.connect((serverIP, TCP_PORT))
        print(f"Conectado al servidor {serverIP}:{TCP_PORT}")
        manejar_comunicacion(conexion)

def manejar_comunicacion(conexion):
    """Maneja la comunicación bidireccional entre cliente y servidor."""
    try:
        for mensaje in obtener_texto(conexion):
            print(">> ", mensaje)
            if mensaje == END_MESSAGE:
                break
            nuevo_mensaje = input("Escribe un mensaje > ")
            enviar_texto(conexion, nuevo_mensaje)
    except Exception as e:
        print(f"Error en la comunicación: {e}")
    finally:
        print("Conexión cerrada.")

def main():
    """Función principal que selecciona modo de operación: servidor o cliente."""
    while True:
        ser_cli = input("Elige: iniciar un servidor (1) o conectarte a uno - cliente (2) > ")
        if ser_cli in ('1', '2'):
            break

    if ser_cli == '1':
        iniciar_servidor()
    else:
        conectar_cliente()

    print("¡Programa terminado! Sockets cerrados.")

if __name__ == "__main__":
    main()

Vamos a probarlo. Abre dos ventanas del terminal. En una, ejecuta el archivo server_client_chat.py con la opción 1 y en la otra el mismo archivo con la opción 2. Prueba a enviar mensajes. Primero en uno y luego en el otro. Cuando quieras terminar envia el mensaje %end%.

comunicación cliente servidor


Anexo:

YIELD MENSAJE

Se usa "yield" para devolver los datos desde una función especial denominada generador. 

Los generadores son muy útiles cuando necesitas devolver una gran cantidad de datos o simplemente desconoces cuantos datos debes devolver. Hemos usado un generador en la función obtener_texto porque desconocemos cuantos mensajes están disponibles para obtener.

Hay dos diferencias fundamentales entre las funciones normales y los generadores:

- Un generador siempre devuelve un iterador en vez de un valor individual, por ejemplo una lista de uno o más elementos.

- Cuando se utiliza yield, la función no termina sino que recuerda donde se ha detenido. La siguiente vez que se la llama, continuará en el punto donde termino sin tener que volver a empezar desde el principio. 

Los generadores deben llamarse como parte de un bucle for o usando el comando next. 

Puedes encontrar más información y ejemplos en el siguiente enlace.

El código de este post se encuentra en este enlace de GITHUB.

Próximo Post:

4.- Manejando múltiples conexiones.


No hay comentarios:

Publicar un comentario