martes, 18 de junio de 2024

Redes con Python: Programación de Sockets para la Comunicación. 7.- Envio de archivos binarios usando TCP y UDP

Para finalizar el tema de los sockets en Python vamos a ver como enviar archivos binarios usando ambos protocolos. Primeramente enviaremos un archivo binario, como puede ser una imagen usando TCP y luego veremos lo mismo usando UDP añadiendo la posibilidad de ver como enviar la imagen y ver como se va dibujando en tiempo real.

Enviando Imágenes Binarias de Cliente a Servidor con TCP

En este tutorial, te mostraré cómo enviar una imagen desde un cliente a un servidor utilizando TCP. Este método es útil cuando necesitas transferir archivos binarios de forma segura y fiable entre dos sistemas. A lo largo del post, te guiaré paso a paso por el código tanto del cliente como del servidor.

¿Qué es TCP y por qué usarlo?

TCP (Protocolo de Control de Transmisión) es uno de los protocolos fundamentales de Internet. Proporciona una comunicación fiable, ordenada y libre de errores entre aplicaciones que se ejecutan en hosts conectados a una red IP. A diferencia de UDP (Protocolo de Datagrama de Usuario), TCP asegura que los datos lleguen en el mismo orden en que se enviaron, lo que lo hace perfecto para la transferencia de archivos.

Código del Cliente

Vamos a empezar con el código del cliente. El objetivo del cliente es leer una imagen desde el disco y enviarla al servidor. En el mismo directorio donde crearé el siguiente archivo tengo una imagen de un gato, que es un archivo binario, en formato jpg cuyo archivo se llama gatin.jpg. Voy a crear un cliente que transmitirá este archivo a un servidor, donde se volverá a guardar con el nombre de gatin_out.jpg.

cliente.py

import socket

def send_image(file_path, host='localhost', port=12345):
    client_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    client_socket.connect((host, port))
    
    with open(file_path, 'rb') as f:
        data = f.read(1024)
        while data:
            client_socket.send(data)
            data = f.read(1024)
    
    # Enviar señal de finalización
    client_socket.send(b'END')
    print("Imagen enviada")
    client_socket.close()

if __name__ == '__main__':
    send_image('gatin.jpg')
  • Creación del Socket: Creamos un socket TCP/IP con socket.socket(socket.AF_INET, socket.SOCK_STREAM).

  • Conexión al Servidor: Nos conectamos al servidor especificado en host y port.

  • Lectura y Envío de la Imagen: Abrimos el archivo de imagen en modo binario (rb) y leemos bloques de 1024 bytes. Estos bloques se envían al servidor hasta que se hayan leído todos los datos.

  • Señal de Finalización: Después de enviar todos los datos, enviamos una señal de finalización (b'END').

  • Cierre del Socket: Cerramos el socket del cliente.


  • Código del Servidor

    Ahora, vamos a ver el código del servidor. El servidor recibirá la imagen enviada por el cliente y la guardará en el disco.

    servidor.py

    import socket
    
    def start_server(host='0.0.0.0', port=12345):
        server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
        server_socket.bind((host, port))
        server_socket.listen(1)
        print(f"Servidor escuchando en {host}:{port}")
        
        client_socket, addr = server_socket.accept()
        print(f"Conexión establecida con {addr}")
        
        with open('gatin_out.png', 'wb') as f:
            while True:
                data = client_socket.recv(1024)
                if data == b'END':
                    break
                f.write(data)
        
        print("Imagen recibida y guardada como 'gatin_out.png'")
        client_socket.close()
        server_socket.close()
        print("Servidor cerrado")
    
    if __name__ == '__main__':
        start_server()


    Creación del Socket: Creamos un socket TCP/IP con socket.socket(socket.AF_INET, socket.SOCK_STREAM).

    Configuración del Servidor: Enlazamos el socket a la dirección y puerto especificados con bind((host, port)).

    Escucha de Conexiones: Ponemos el servidor en modo de escucha con listen(1), esperando conexiones entrantes.

    Aceptación de Conexión: Aceptamos una conexión entrante con accept(), que devuelve un nuevo socket y la dirección del cliente.

    Recepción de la Imagen: Abrimos un archivo en modo binario (wb) para guardar los datos recibidos. En un bucle, recibimos bloques de datos de 1024 bytes y los escribimos en el archivo. El bucle termina cuando recibimos la señal de finalización (b'END').

    Cierre del Socket: Cerramos los sockets del cliente y del servidor.

    Ejecuta primero el archivo del servidor (servidor.py) y a continuación el del cliente (cliente.py). Si todo ha ido bien tendrás en el lado del servidor un archivo llamado gatin_out.png.


    Enviando Imágenes Binarias de Cliente a Servidor con UDP.


    Advertencia. Como ya hemos comentado varias veces en estos post sobre sockets el usar el protocolo UDP implica que no hay garantía de que todos los datos enviados lleguen, ni que lleguen en el mismo orden en el que se enviaron. 

    Por tanto si lo que buscas es que el archivo llegue tal cual es mejor usar TCP. No obstante a efectos de ver como usar este protocolo vamos a enviar el archivo igual que hicimos anteriormente.


    Código del cliente.


    cliente_UDP.py

    import socket
    
    def udp_client(server_host, server_port, file_path, buffer_size):
        client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        
        with open(file_path, 'rb') as f:
            while (chunk := f.read(buffer_size)):
                # := operador walrus introducido a partir de python 3.8
                # asigna y devuelve el contendido de la variable.
                client_socket.sendto(chunk, (server_host, server_port))
        
        client_socket.sendto(b"END", (server_host, server_port))
        print(f"Imagen enviada a {server_host}:{server_port}")
    
        client_socket.close()
    
    # Parámetros del cliente
    server_host = '127.0.0.1'
    server_port = 12345
    file_path = 'gatin.jpg'
    buffer_size = 1024
    
    udp_client(server_host, server_port, file_path, buffer_size)


    Un cliente UDP para enviar imágenes

    Este código escrito en Python define una función udp_client que sirve para enviar una imagen a un servidor utilizando el protocolo UDP (User Datagram Protocol). Veamos paso a paso cómo funciona:

    Importando librerías:

    • import socket: Esta línea importa la librería socket que provee herramientas para la comunicación por red.

    La función udp_client:

    • def udp_client(server_host, server_port, file_path, buffer_size):: Esta línea define una función llamada udp_client que toma cuatro argumentos:
      • server_host: La dirección del servidor al que se conectará (en este caso, '127.0.0.1' que representa la máquina local).
      • server_port: El puerto del servidor donde se escucharán los mensajes (en este caso, 12345).
      • file_path: La ruta del archivo que se enviará (en este caso, 'gatin.jpg' que es la imagen del gato).
      • buffer_size: El tamaño de los bloques en los que se dividirá la imagen para su envío (en este caso, 1024 bytes).

    Creando el socket:

    • client_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM): Esta línea crea un socket para la comunicación UDP.
      • socket.AF_INET: Indica que se utilizará el protocolo de internet (IPv4).
      • socket.SOCK_DGRAM: Indica que se trata de un socket no orientado a la conexión, propio del protocolo UDP.

    Abriendo el archivo y enviando por bloques:

    • with open(file_path, 'rb') as f:: Abre el archivo especificado en modo lectura binario ('rb').
    • while (chunk := f.read(buffer_size))::
      • f.read(buffer_size): Lee un bloque de buffer_size bytes del archivo y lo asigna a la variable chunk.
      • := (Python 3.8+): Este operador (walrus) asigna el valor leído a chunk y también lo devuelve para la condición del while.
    • client_socket.sendto(chunk, (server_host, server_port)): Envía el bloque leído (chunk) al servidor en la dirección (server_host, server_port).

    Enviando el mensaje de fin y cerrando el socket:

    • client_socket.sendto(b"END", (server_host, server_port)): Envía un mensaje final al servidor indicando el término de la transmisión (en este caso, "END" como bytes b"END").
    • client_socket.close(): Cierra el socket del cliente.

    Ejecutando el cliente:

    • # Parámetros del cliente: Define los valores por defecto para los argumentos de la función udp_client.
    • udp_client(server_host, server_port, file_path, buffer_size): Llama a la función udp_client con los parámetros definidos anteriormente, enviando la imagen gatin.jpg al servidor local en el puerto 12345 usando bloques de 1024 bytes.
    • print(f"Imagen enviada a {server_host}:{server_port}"): Imprime un mensaje de confirmación indicando que la imagen se ha enviado al servidor.

    Este código permite enviar una imagen a un servidor que esté esperando por paquetes UDP. Ten en cuenta que se necesita un servidor compatible con UDP para recibir la imagen transmitida por este cliente así que vamos con ello.

    Código del servidor.


    servidor_UDP.py

    import socket
    
    def udp_server(host, port, buffer_size, output_file):
        server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
        server_socket.bind((host, port))
        
        print(f"Servidor UDP escuchando en {host}:{port}")
        
        with open(output_file, 'wb') as f:
            while True:
                data, addr = server_socket.recvfrom(buffer_size)
                if data == b"END":
                    print("Transferencia completa.")
                    break
                f.write(data)
                print(f"Recibiendo datos de {addr}")
    
        server_socket.close()
    
    # Parámetros del servidor
    host = '127.0.0.1'
    port = 12345
    buffer_size = 1024
    output_file = 'gatin_out.jpg'
    
    udp_server(host, port, buffer_size, output_file)

    En los ejemplos gatin.jpg es una imagen en formato jpg que está en el mismo directorio que ambos archivos.

    Este código implementa un servidor UDP (User Datagram Protocol). Vamos a desglosarlo paso a paso:

    - Importación de módulos:

        - El código comienza importando el módulo socket, que proporciona funciones para crear y administrar sockets de red.

        - Los sockets son canales de comunicación bidireccionales que permiten la transferencia de datos entre dispositivos a través de una red.

    - Definición de la función udp_server:

        - La función udp_server acepta cuatro parámetros:

            - host: la dirección IP en la que el servidor escuchará (en este caso, 127.0.0.1 se refiere a la dirección local del equipo).

            - port: el número de puerto en el que el servidor estará disponible (aquí, 12345).

            - buffer_size: el tamaño del búfer utilizado para recibir datos (en bytes, aquí 1024).

            - output_file: el nombre del archivo en el que se guardarán los datos recibidos (por ejemplo, 'gatin_out.jpg').

        - La función crea un socket UDP utilizando socket.AF_INET para indicar que se utilizará IPv4 y socket.SOCK_DGRAM para especificar que es un socket UDP.

        - Luego, enlaza el socket al host y port especificados.

    - Bucle principal:

        - El servidor entra en un bucle infinito (while True) para recibir datos.

        - Cuando recibe datos del cliente (usando server_socket.recvfrom(buffer_size)), verifica si los datos son igual a "END". Si es así, imprime "Transferencia completa" y sale del bucle.

        - De lo contrario, escribe los datos recibidos en el archivo especificado (output_file) y muestra la dirección del cliente desde la que se recibieron los datos.

    - Cierre del socket:

        - Después de salir del bucle, el servidor cierra el socket con server_socket.close().


    Transmitiendo imágenes a través de UDP en Python: Un enfoque práctico

    Enviar imágenes de una máquina a otra a través de UDP en Python puede ser un desafío interesante debido a las características y limitaciones inherentes del protocolo UDP. En este artículo, exploraremos cómo implementar una aplicación básica para enviar y recibir imágenes utilizando sockets UDP y la biblioteca Pygame para la visualización.

    Entendiendo UDP y sus desafíos

    UDP (User Datagram Protocol) es un protocolo de red que ofrece una comunicación rápida y sin conexión entre aplicaciones pero no garantiza la entrega de los mensajes ni el orden de los mismos. Esto lo hace ideal para aplicaciones donde la velocidad es crucial pero no tanto la integridad absoluta de los datos, como el streaming de video o audio en tiempo real.

    Implementación del servidor (receptor)

    Comencemos con el código del servidor que recibirá la imagen enviada desde el cliente. Para que funcinoe tienes que instalar los módulos pygame y PILLOW (PIL):

    pip install pygame

    pip install pilllow

    Código del receptor.

    import socket import pickle from time import sleep import pygame # Parámetros del servidor host = '127.0.0.1' port = 12345 buffer_size = 2048 # Configuración de Pygame para la visualización pygame.init() screen = None # Configuración del socket UDP server_socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) server_socket.bind((host, port)) print(f"Servidor UDP escuchando en {host}:{port}") while True: data, addr = server_socket.recvfrom(buffer_size) mensaje = pickle.loads(data) # Finaliza la transmisión si se recibe "END" if mensaje == "END": print("Transferencia completa.") break if not screen: # Se reciben las dimensiones de la imagen width, height = mensaje print(f"Las dimensiones de la imagen son {width},{height}") screen = pygame.display.set_mode((width, height)) pygame.display.set_caption("Imagen recibida") else: # Se reciben los píxeles y se actualiza la pantalla index = mensaje[0] pixel = mensaje[1] x = index[0] y = index[1] screen.set_at((x, y), pixel) pygame.display.flip() # Cierre de conexiones y finalización de Pygame server_socket.close() pygame.quit() print("Conexión finalizada.")

    Explicación del código del servidor:

    1. Configuración inicial: Importamos los módulos necesarios y configuramos los parámetros del servidor UDP.

    2. Inicialización de Pygame: Se inicializa Pygame para poder visualizar la imagen recibida.

    3. Bucle principal: El servidor entra en un bucle infinito donde espera recibir datos del cliente.

    4. Recepción de datos: Cuando llega un datagrama UDP, se deserializa usando pickle para obtener la información sobre las dimensiones de la imagen o los píxeles.

    5. Visualización de la imagen: Si es la primera vez que se recibe un mensaje (dimensiones de la imagen), se configura la pantalla de Pygame con estas dimensiones. Para mensajes posteriores, se actualiza la pantalla con los píxeles recibidos.

    6. Finalización: Cuando se recibe el mensaje "END", se sale del bucle y se cierran las conexiones.

    Implementación del cliente (emisor)

    A continuación, veamos el código del cliente que enviará la imagen al servidor.

    import socket import pickle from time import sleep from PIL import Image # Carga de la imagen image = Image.open("gatin.jpg") width, height = image.size # Configuración del socket UDP udp_client = socket.socket(socket.AF_INET, socket.SOCK_DGRAM) # Envío de las dimensiones de la imagen dimension = (width, height) data = pickle.dumps(dimension) udp_client.sendto(data, ("127.0.0.1", 12345)) sleep(1) # Espera para asegurar que el servidor esté listo # Envío de cada píxel de la imagen for y in range(height): for x in range(width): pos = (x, y) rgba = image.getpixel(pos) message = (pos, rgba) data = pickle.dumps(message) udp_client.sendto(data, ("127.0.0.1", 12345)) sleep(0.005) # Pequeña pausa para simular el envío gradual # Envío del mensaje de finalización mensaje = "END" data = pickle.dumps(mensaje) udp_client.sendto(data, ("127.0.0.1", 12345)) # Cierre del socket del cliente udp_client.close()

    Explicación del código del cliente:

    1. Carga de la imagen: Utilizamos Pillow (PIL) para cargar la imagen "gatin.jpg" y obtener sus dimensiones.

    2. Configuración del socket UDP: Creamos un socket UDP para la comunicación con el servidor.

    3. Envío de las dimensiones: Convertimos las dimensiones a una cadena de bytes usando pickle y las enviamos al servidor.

    4. Envío de los píxeles: Iteramos sobre cada píxel de la imagen, obtenemos su valor RGBA, lo empaquetamos en un mensaje y lo enviamos al servidor.

    5. Finalización: Enviamos un mensaje "END" para indicar al servidor que la transmisión ha terminado.

    6. Cierre del socket: Finalmente, cerramos el socket del cliente.

    Problemas comunes con UDP en la transmisión de imágenes

    1. Pérdida de píxeles: Debido a la naturaleza no garantizada de UDP, algunos datagramas pueden perderse durante la transmisión, lo que resulta en la pérdida de píxeles en la imagen recibida.

    2. Retardo en la recepción: Para evitar saturar la red, es común introducir pequeñas pausas (como sleep(0.005)) entre el envío de cada píxel. Esto introduce un pequeño retardo en la transmisión, pero ayuda a mejorar la fiabilidad al permitir que los datagramas se entreguen de manera más efectiva.

    En conclusión, aunque UDP ofrece una manera eficiente de transmitir datos como imágenes en tiempo real, es importante tener en cuenta sus limitaciones y diseñar la aplicación para manejar posibles pérdidas de datos y asegurar una transmisión fluida y confiable.

    Puedes encontrar el código en este enlace de Github.


    viernes, 7 de junio de 2024

    Redes con Python: Programación de Sockets para la Comunicación. 6.- Usando UDP en Python y diferencias con TCP

    El usar sockets para comunicarse en las redes de ordenadores reduce la complejidad de usar diferentes protocolos, ya que permite al programador abstraerse de como realmente se envían los datos. El usar el protocolo UDP es muy similar a usar TCP aunque difiere un poco.

    Recordemos que TCP es un protocolo orientado a la conexión, lo que significa que se establece una conexión entre dos dispositivos antes de que se inicie la comunicación entre ambos. UDP, por otro lado, es un protocolo sin conexión lo que significa que no se establece una conexión antes de que comience la comunicación. 

    TCP garantiza que los datos se entreguen de manera ordenada y sin errores. Esto se logra mediante un mecanismo de confirmación y retransmisión de paquetes. TCP también utiliza un mecanismo de control de congestión para garantizar que la red no se sature con demasiados paquetes.

    UDP, por otro lado, no proporciona garantías de entrega de paquetes. Los paquetes pueden perderse, duplicarse o entregarse en un orden diferente al que se enviaron. UDP es más rápido que TCP ya que no hay necesidad de establecer una conexión y no se utiliza ningún mecanismo de control de congestión.

    En palabras más claras:

     Un socket de flujo (TCP) es como una llamada telefónica: un lado hace la llamada, el otro responde, se saludan (SYN/ACK en TCP) y luego intercambian información. Una vez que terminan, se despiden (FIN/ACK en TCP). Si un lado no escucha una despedida, normalmente volverá a llamar, ya que esto es un evento inesperado; generalmente el cliente se reconectará al servidor. Hay una garantía de que los datos no llegarán en un orden diferente al que se enviaron, y hay una garantía razonable de que los datos no estarán dañados.

    Un socket UDP es como pasar una nota en clase. Considera el caso en el que no estás directamente al lado de la persona a la que le estás pasando la nota; la nota viajará de persona a persona. Puede que no llegue a su destino, y puede que esté modificada para cuando llegue. Si pasas dos notas a la misma persona, pueden llegar en un orden que no pretendías, ya que la ruta que toman las notas a través del aula puede no ser la misma; una persona podría no pasar una nota tan rápido como otra, etc.

    Así que usas un socket de flujo cuando tener la información en orden y sin daños es importante. Los protocolos de transferencia de archivos son un buen ejemplo aquí. ¡No querrás descargar un archivo con su contenido aleatoriamente desordenado y dañado!

    Usarías un socket de datagrama (UDP) cuando el orden es menos importante que la entrega oportuna (piensa en VoIP o protocolos de juegos), cuando no quieres la sobrecarga adicional de un flujo (por esto el DNS es principalmente un protocolo de datagrama, para que los servidores puedan responder a muchas solicitudes a la vez muy rápidamente), o cuando no te importa demasiado si los datos nunca llegan a su destino.

    Para ampliar el caso de VoIP/juegos, tales protocolos incluyen su propio mecanismo de ordenación de datos. Pero si un paquete se daña o se pierde, no quieres esperar a que el protocolo de flujo (generalmente TCP) emita una solicitud de reenvío; necesitas recuperarte rápidamente. El TCP puede tardar hasta varios minutos en recuperarse, y para protocolos en tiempo real como juegos o VoIP, incluso tres segundos pueden ser inaceptables. Usar un protocolo de datagrama como UDP permite que el software se recupere de tal evento extremadamente rápido, simplemente ignorando los datos perdidos o re-solicitándolos antes de lo que lo haría TCP.

    Si comparas el código para crear un servidor TCP y UDP que reciban datos de un cliente y vuelvan a enviar esos mismos datos de vuelta, puedes ver que en el código para crear un servidor UDP no se utiliza ni el método listen para estar a la escucha de clientes, ni el método accept para aceptar la conexión.

    Servidor TCP

    import socket
    
    tcp_server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    tcp_server.bind(("0.0.0.0", 8081))
    
    tcp_server.listen()
    connection_socket, address = tcp_server.accept()
    
    data = connection_socket.recv(1024)
    connection_socket.send(data)

    Servidor UDP

    import socket

    udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
    udp_server.bind(("0.0.0.0", 20001))

    data, client_address = udp_server.recvfrom(1024)
    udp_server.sendto(data, client_address)

    UDP también envía datagramas (o paquetes) completos de información, en lugar de un flujo de bytes como TCP. Sin embargo no se garantiza que estos paquetes lleguen en el mismo orden en el que fueron enviados. Ahora bien, si llegan lo harán completos. Esto significa que como programador no tendrás que preocuparte por terminar o dividir los mensajes. Un único mensaje enviado será un único mensaje recibido. Las otras diferencias están relacionadas con la configuración del socket y el envío-recepción de datos sin conexión.

    Vamos a verlo paso a paso:

    1.- Cuando se crea el socket, su tipo se establece como socket.SOCK_DGRAM.

    udp_server = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)

    2.- De manera similar a TCP, bind se usa para configurar el puerto para aceptar datos de todas las direcciones de red, 0.0.0.0 usando el puerto 20001

    udp_server.bind(("0.0.0.0", 20001))

    Hemos usado el puerto 20001, ya que se usa a menudo para realizar pruebas usando UDP, pero puedes usar cualquier puerto. De manera similar a TCP, los puertos del 0 al 1023 normalmente se evitan, ya que son puertos que se usan para otras finalidades.

    3.- Como no hay ningún socket de conexión para recibir datos, se utiliza la función recvfrom, que no sólo devuelve los datos recibidos, si no también la dirección de donde provienen los datos:

    data, client_address = udp_server.recvfrom(1024)

    La variable client_address contendrá una tupla con la dirección IP y el puerto. El valor 1024 hace referencia a cuantos bytes se referirán. Como UDP envía paquetes de información, este valor debe ser lo suficientemente grande para poder recibir un paquete completo. Se generará un error si no se recibe un paquete completo de datos. 

    4.- Cuando se envían los datos, se utiliza la función sendto y se proporciona un dirección específica. 

    udp_server.sendto(data, client_address)

    En el ejemplo anterior los datos se vuelven a enviar de regreso a la dirección desde donde se recibieron. Sin embargo, tu puedes especificar cualquier dirección y el socket la enviaría, por ejemplo:

    udp_server.sendto(data, ("169.192.1.2", 9999))

    En el caso de que un socket envíe datos y otro socket no espere recibirlos, simplemente se pierden.


    Con lo que hemos visto te planteo un reto. ¿Puedes crear un cliente que se conecte a este servidor UDP que hemos creado?

    Tu programa deberá:

    • crear un socket UDP
    • Usar sendto para enviar un mensaje a la dirección y puerto del servidor UDP.
    • Usar recvfrom para obtener el mensaje desde el servidor.
    • Imprimir el mensaje devuelto por pantalla.

     Si al final te das por vencido, puedes ver una forma de hacerlo a través de este enlace en Pastebin.

    Puedes encontrar el código de este post en este enlace de github.

    En el próximo post "Envío de archivos binarios usando TCP y UDP"

    lunes, 3 de junio de 2024

    Redes con Python: Programación de Sockets para la Comunicación. 5.- Parsing_serialización de datos.

    Vamos a introducir en este post dos conceptos:

    Parsing (análisis) y Serialización.

    El parsing o análisis es el proceso de convertir los datos nuevamente en sus componentes. Toma un flujo de datos (estructurados) y luego hace algo basado en el contenido de ese flujo, generalmente creando una estructura en memoria basada en ella o ejecutando una secuencia de operaciones basada en ella.

    La serialización es el proceso de convertir una estructura de datos en un conjunto de bytes, es decir, un formato que pueda almacenarse o transmitirse de forma segura y recrearse a partir del formato almacenado o transmitido. Generalmente se usa para referirse a la aplicación de este proceso a estructuras de datos complejas como árboles, objetos o gráficos, ya que no se prestan inherentemente a ser transmitidos como una simple cadena de datos binarios. Convertir un objeto en memoria a JSON o XML es un ejemplo de serialización.

    Vamos a ver esta idea mejor con un ejemplo práctico.

    Ponte en la piel de un programador que tiene que encontrar una solución al siguiente problema. Necesita comprobar todos los ordenadores de una red para asegurarse de que tienen instalada la última versión de Python.

    Para ello va a crear un servidor y clientes en cada unos de los equipos. Cada cliente debería enviar la siguiente información al servidor:

    - La fecha actual.

    - El nombre de red del cliente.

    - La versión de Python que el cliente está usando.

    Con lo que ya conocemos sobre como crear un cliente usando el protocolo TCP/IP vamos a crear este programa de una forma muy básica. Crea un archivo, yo lo llamará cliente.py y añade el siguiente código:

    cliente.py

    import socket
    from time import time, ctime
    from platform import node
    import pickle
    from sys import version
    
    fecha_actual = time()
    nombre_red = node()
    version_python = version
    
    print("fecha_actual:", ctime(fecha_actual))
    print ('Descripción:', node()) 
    print(version_python)

    Salida:

    fecha_actual: Mon Jun  3 17:48:08 2024
    Descripción: machine
    3.10.12 (main, Nov 20 2023, 15:14:05) [GCC 11.4.0]


    import socket.

    Módulo para crear la conexión.

    from time import time, ctime. 

    La función time nos facilita la fecha actual en formato de tiempo Unix. Como queremos verlo más humanizado utilizamos el modulo ctime que nos trasforma el tiempo Unix en la fecha actual más legible.

    from platform import node.

    Con este módulo, podremos acceder a los datos de nuestro sistema tales como: el hardware, el sistema operativo, etc. utilizamos node para obtener simplemente el nombre de red de la máquina.

    import pickle.

    Librería para serializar datos es decir convertir datos en bytes o viceversa que pueden ser guardados o enviados.

    from sys import version.

    Con este módulo obtendremos la versión actual de Python en el sistema.

    Ya tenemos todo lo necesario que el cliente tiene que enviar al servidor. Tenemos todos los datos en formato Str (texto); si hubiera algo en formato numérico, por ejemplo, habría que pasarlo a texto.

    Vamos a prepararlo para enviar estos datos al servidor. Tenemos que crear el mensaje y como siempre codificarlo para poderlo enviar. Finalmente implantamos el código para conectarnos al servidor y enviar el mensaje.

    cliente.py

    from time import time, ctime
    from platform import node
    import pickle
    from sys import version
    
    fecha_actual = time()
    nombre_red = node()
    version_python = version
    
    print("fecha_actual:", ctime(fecha_actual))
    print ('Descripción:', node()) 
    print(version_python)
    
    from time import time, ctime
    from platform import node
    import pickle
    from sys import version
    
    fecha_actual_unix = time()
    
    fecha_actual = ctime(fecha_actual_unix)
    nombre_red = node()
    version_python = version
    
    print("fecha_actual:", fecha_actual)
    print ('Descripción:', node()) 
    print(version_python)
    
    mensaje = fecha_actual + "\n" + nombre_red + "\n" + version_python
    data = mensaje.encode()
    
    HOST = "127.0.0.1"  # El nombre del servidor o IP del mismo
    PORT = 8081  # El puerto usado por el servidor
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
        client_socket.connect((HOST, PORT))
        client_socket.send(data)
    


    Por otra parte tenemos que crear el código para que el servidor reciba cada una de las lecturas que le irán enviando los clientes. Un código muy sencillo.


    server.py

    import socket
    
    HOST = '127.0.0.1' # Standard loopback interface address (localhost)
    PORT = 8081 # Port to listen on (non-privileged ports are > 1023)
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    
        # Vincula el socket a una dirección y puerto específicos
        s.bind((HOST, PORT))
    
        # Escucha conexiones entrantes (el argumento especifica el tamaño máximo de la cola de conexiones)
        s.listen()
    
        print("Servidor escuchando en el puerto 8081...")
    
        # Aceptar una conexión
        client_socket, client_address = s.accept()
        with client_socket:
            print(f"Conexión aceptada de {client_address}")
            
            mensaje = "Hola,¡Gracias por conectarte!"
            data = client_socket.recv(1024)
            mensaje = data.decode()
            print(mensaje)

    El código anterior debería decodificar los bytes recibidos del cliente y realizar un parsing del mensaje para transformarlo en sus datos originales. Incluso para un mensaje bastante simple como el nuestro el proceso de traspasar los bytes en sus datos originales es bastante farragoso. Es relativamente lento de ejecutar, difícil de cambiar y propenso a que se comentan errores.

    Por eso podemos usar la serialización. Como dijimos al principio del post la serialización es el proceso de convertir datos en una serie de bytes para que puedan ser guardados o transmitidos. El ejemplo anterior es una forma simple de serialización. Para hacer la serialización más fácil se utiliza la librería pickle en Python.

    Por ejemplo para enviar el mensaje podemos modificar el cliente. Utilizamos una tupla de Python para guardar la información y enviarla con Pickle, nos da igual cual sea el tipo de datos a enviar. Puede ser cualquier cosa una tupla, una lista, un diccionario etc.

    cliente.py

    import socket
    from time import time, ctime
    from platform import node
    import pickle
    from sys import version
    
    fecha_actual_unix = time()
    
    fecha_actual = ctime(fecha_actual_unix)
    nombre_red = node()
    version_python = version
    
    print("fecha_actual:", fecha_actual)
    print ('Descripción:', node()) 
    print(version_python)
    
    # mensaje = fecha_actual + "\n" + nombre_red + "\n" + version_python
    # data = mensaje.encode()
    mensaje = (fecha_actual, nombre_red, version_python)
    # pickle.dumps((mensaje) convierte el mesaje en bytes sea cual sea el tipo de datos.
    data = pickle.dumps(mensaje)
    
    HOST = "127.0.0.1"  # El nombre del servidor o IP del mismo
    PORT = 8081  # El puerto usado por el servidor
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as client_socket:
        client_socket.connect((HOST, PORT))
        client_socket.sendall(data)
    
    y lo mismo para esta versión muy sencilla del servidor creado.

    server.py

    import socket
    import pickle
    
    HOST = '127.0.0.1' # Standard loopback interface address (localhost)
    PORT = 8081 # Port to listen on (non-privileged ports are > 1023)
    
    with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
    
        # Vincula el socket a una dirección y puerto específicos
        s.bind((HOST, PORT))
    
        # Escucha conexiones entrantes (el argumento especifica el tamaño máximo de la cola de conexiones)
        s.listen()
    
        print("Servidor escuchando en el puerto 8081...")
    
        # Aceptar una conexión
        client_socket, client_address = s.accept()
        with client_socket:
            print(f"Conexión aceptada de {client_address}")
            
            mensaje = "Hola,¡Gracias por conectarte!"
            data = client_socket.recv(1024)
    #         mensaje = data.decode()
            mensaje = pickle.loads(data)
            # hace lo contrario, convierte bytes a sus originales.
            print(mensaje)
    


    Puedes encontrar el código de este post en este enlace de github.

    En el siguiente post veremos. Protocolo UDP y diferencias con TCP.

    domingo, 2 de junio de 2024

    Redes con Python: Programación de Sockets para la Comunicación. 4.- Manejando múltiples conexiones

    El modelo que hemos usado hasta ahora tiene sus limitaciones. El mayor es que el servidor atiende solamente a un cliente y luego finaliza la conexión. Para solventarlo usaremos una biblioteca de Python bastante similar, socketserver. Es muy útil para manejar sockets y nos ayudará a manejar múltiples conexiones. 

    Vamos a verlo a través de un ejemplo. Crearemos un programa de preguntas y respuestas que se pueda jugar solo o bien entre dos jugadores. El servidor será el que envíe las preguntas y luego compruebe las respuestas de los clientes. Los clientes serán los jugadores.

    Empecemos.

    Vamos a empezar a crear el servidor importando las librerías que necesitaremos.

    quiz_server.py

    import socketserver
    from collections import namedtuple
    from random import choice
    from threading import Event
    import pickle

    1.- socketserver. Es la librería que nos va a proporcionar la infraestructura básica para usar los servidores de red.  En el programa lo utilizaremos para crear un TCP multihilo que maneje las conexiones de los clientes (los jugadores)

    2.- from collections import namedtuple. Es una extensión para poder utilizar las tuplas y acceder a sus elementos por su nombre, en vez de por su posición en la tupla.

    3.- from random import choice. Proporciona funciones para generar números pseudo-aleatorios y seleccionar elementos aleatorios de una secuencia.

    4.- from threading import Event. Con el crearemos diferentes hilos para cada cliente que se conecte. Utilizaremos instancias de Event para sincronizar el estado entre los diferentes hilos. Lo haremos explicando a medida que desarrollemos el programa.

    5.- pickle. Se utiliza para serializar y deserializar datos en Python a una cadena de bytes que pueda ser enviada a través de sockets.

    A continuación vamos a especificar los comandos que se podrán enviar entre servidor-cliente (luego veremos para que se usan) e iniciaremos las variables que vamos a necesitar posteriormente.

    quiz_server.py

    #...
    

    ''' Comandos: COLOCA TUS COMANDOS AQUÍ QUES - comando de pregunta UNIR - solicitud de unirse al principio 1 - Se utiliza para enviar una pregunta al cliente después de una solicitud 2 - Se utiliza para enviar "Espera el evento listo para empezar" al cliente. 4 - Se utiliza para enviar una respuesta al servidor después de una pregunta 7 - Se utiliza para enviar una respuesta "Correcta" o "Incorrecta" después de una respuesta

    '''

    NUMERO_DE_JUGADORES = 2 jugadores = [] listo_para_empezar = Event() contestadas = 0 esperando_respuestas = Event() pregunta_Actual = None Pregunta = namedtuple('Pregunta', ['p', 'respuesta'])
    Para tener un buen concurso de preguntas y respuestas tendremos que crear unas cuantas. Puedes poner las preguntas y las respuestas que te parezcan. Yo crearé unas pocas para que el programa sea funcional.

    quiz_server.py

    #...
    juego_Preguntas = [
        Pregunta("¿Como se llama un polígono de tres lados?", "Triángulo"),
        Pregunta("¿Cuál es el quinto planeta del sistema solar?", "Jupiter"),
        Pregunta("¿Cuál es la capital de Francia?", "Paris"),
        Pregunta("¿Quién dijo la frase: 'Solo se que no se nada'?", "Sócrates"),
        Pregunta("¿Cuál es el antónimo de rico?", "Pobre")
    ]
    Vamos a detenernos un momento para ver el funcionamiento de namedtuple.

    Podríamos haber guardado las preguntas y respuestas como una tupla directamente dentro de una lista, es decir del siguiente modo:

    juego_Preguntas = [(pregunta, respuesta), (pregunta, respuesta),....(pregunta, respuesta)]

    Si son pocas preguntas podríamos manejarlo pero si fueran 1000 preguntas la cosa se nos complicaría, ya que nos tendríamos que acordar de que la posición 0 de la tupla es la pregunta y la posición 1 es la respuesta.  Sin embargo al usar nametupled podemos acceder a los elementos por su nombre:

    Pregunta.p = La pregunta en cuestión
    Pregunta.respuesta = la respuesta a la pregunta.

    Por ejemplo la primera de la lista es el elemento cero. Si añades un print con el siguiente código verás:

    quiz_server.py

    #...
    juego_Preguntas = [
        Pregunta("¿Como se llama un polígono de tres lados?", "Triángulo"),
        Pregunta("¿Cuál es el quinto planeta del sistema solar?", "Jupiter"),
        Pregunta("¿Cuál es la capital de Francia?", "Paris"),
        Pregunta("¿Quién dijo la frase: 'Solo se que no se nada'?", "Socrates"),
        Pregunta("¿Cual es el antónimo de rico?", "Pobre")
    ]
    
    print(f"La pregunta es {juego_Preguntas[0].p} y su respuesta: {juego_Preguntas[0].respuesta}")
    print("\n")
    print(juego_Preguntas)

    La salida será algo como esto:

    >>> %Run 
    La pregunta es ¿Como se llama un polígono de tres lados? y su respuesta: Triángulo
    
    
    [Pregunta(p='¿Como se llama un polígono de tres lados?', 
    respuesta='Triángulo'), Pregunta(p='¿Cuál es el quinto planeta del sistema solar?', respuesta='Jupiter'), 
    Pregunta(p='¿Cuál es la capital de Francia?', respuesta='Paris'), 
    Pregunta(p="¿Quién dijo la frase: 'Solo se que no se nada'?", respuesta='Socrates'), 
    Pregunta(p='¿Cuál es el antónimo de rico?', respuesta='Pobre')]

    Para conocer más sobre como funciona las tuplas con nombre puedes acceder a su documentación en Python.

    Ahora, comenta o elimina los print que hemos utilizado anteriormente (los que están en negrita arriba) para ver el funcionamiento de las namedtuple y sigamos con el desarrollo del ejemplo.


    Crear un servidor multijugador en Python. Entendiento ThreadedTCPServer.

    ¿Te has preguntado cómo funcionan los servidores que permiten a múltiples personas conectarse al mismo tiempo, como en los juegos en línea o las aplicaciones de chat? Vamos a explorar cómo crear un servidor básico en Python que pueda manejar varias conexiones al mismo tiempo, usando algo llamado ThreadedTCPServer. Nos servirá para que los jugadores de nuestro juego se puedan conectar de forma simultánea.

    ¿Qué es un Servidor?

    Un servidor es como un anfitrión de una fiesta. Imagina que organizas una fiesta en tu casa. Las personas (los clientes) llegan a tu puerta y tú las recibes, hablas con ellas, y las dejas entrar. En el mundo de la informática, un servidor hace algo similar: recibe conexiones de otros programas (los clientes) y les envía y recibe información. En nuestro programa les enviará las preguntas y los clientes, los jugadores, le enviarán sus respuestas.

    ¿Qué es ThreadedTCPServer?

    ThreadedTCPServer es una forma especial de crear un servidor en Python que puede hablar con muchos clientes al mismo tiempo. Sin ThreadedTCPServer, nuestro servidor solo podría hablar con una persona a la vez, lo cual no sería muy eficiente para una fiesta concurrida.

    Vamos a desglosar ThreadedTCPServer y ver cómo funciona:

    class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
        pass
    

    Aquí estamos creando una nueva clase llamada ThreadedTCPServer. Esta clase combina dos cosas:

    1. socketserver.ThreadingMixIn: Esto le da al servidor la capacidad de manejar cada conexión en un hilo separado. Piensa en un hilo como un asistente personal. Si tienes muchos asistentes, puedes hablar con muchos invitados a la vez sin tener que esperar a que termines de hablar con cada uno.

    2. socketserver.TCPServer: Esto le da al servidor la habilidad básica de recibir conexiones de clientes y hablar con ellos usando un protocolo llamado TCP (es como el lenguaje que usan para comunicarse).

    Cuando combinamos estas dos cosas, tenemos un servidor que puede hablar con muchos clientes al mismo tiempo, de manera eficiente.

    ¿Cómo Usamos ThreadedTCPServer?

    Vamos a ver cómo ponemos todo esto en acción. Olvidaté de momento del código anterior. Aquí vamos a ver  un pequeño programa que crea un servidor usando ThreadedTCPServer. 

    import socketserver
    from threading import Event
    
    class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
        pass
    
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            self.request.sendall(b"Hello, world")
    
    if __name__ == "__main__":
        HOST, PORT = "localhost", 2065
        with ThreadedTCPServer((HOST, PORT), QuizGame) as server:
            print(f"Server running on {HOST}:{PORT}")
            # Hace que el servidor funcione de forma indefinida
            server.serve_forever() 

    ¿Qué Hace Este Programa?

    1. Creamos ThreadedTCPServer: Esto define nuestro servidor que puede manejar múltiples conexiones al mismo tiempo.
    2. Definimos QuizGame: Esta es la parte del servidor que se encarga de lo que sucede cuando alguien se conecta. En este caso, solo envía un mensaje "Hello, world".
    3. Configuramos y Ejecutamos el Servidor: Le decimos a nuestro servidor que escuche en "localhost" (tu propia computadora) en el puerto 2065. Luego, lo ponemos a funcionar indefinidamente para que esté siempre listo para recibir conexiones.

    Cuando ejecutas este programa, puedes conectar múltiples clientes (otros programas) al servidor, y todos recibirán el mensaje "Hello, world" sin tener que esperar uno por uno. Puedes abrir en tu navegador varias pesatañas y conectarte a "localhost:2065" para ver como funciona el servidor.



    servidor multihilo en funcionamiento


    Como te habrás dado cuenta en post anteriores para enviar los datos utilizábamos el método send. Sin embargo en esta ocasión hemos utilizado sendall. ¿En que se diferencian?

    La diferencia principal entre sendall y send en Python radica en cómo envían los datos a través de un socket y cómo manejan el tamaño de los datos enviados. Aquí te explico cada uno:

    send

    • Descripción: send es un método del objeto socket que envía datos a través del socket. Sin embargo, no garantiza que todos los datos sean enviados en una sola llamada. Puede enviar solo una parte de los datos, especialmente si la cantidad de datos es grande o si la red está congestionada.
    • Uso: Útil cuando se está dispuesto a manejar la lógica para asegurarse de que todos los datos se envíen completamente.
    • Retorno: Devuelve el número de bytes efectivamente enviados. Es posible que necesites llamar a send repetidamente para enviar todos los datos.

    sendall

    • Descripción: sendall es una versión más conveniente de send que intenta enviar todos los datos en una sola llamada. Internamente, sendall usa un bucle para llamar a send repetidamente hasta que todos los datos hayan sido enviados o se produzca un error.
    • Uso: Ideal cuando quieres asegurarte de que todos los datos se envíen completamente sin tener que manejar la lógica de reintentos manualmente.
    • Retorno: No devuelve nada si tiene éxito. Si ocurre un error, se lanza una excepción.

  • send es más flexible y te da más control, pero necesitas manejar la lógica para asegurarte de que todos los datos se envíen.
  • sendall es más conveniente cuando quieres asegurarte de que todo el mensaje se envíe sin tener que manejar manualmente los reintentos.

  • Después de las explicaciones vamos a ver como implementar esto en nuestro programa de preguntas y respuestas. Volvemos al código que teníamos. Para centrarnos un poco voy a volver a ponerlo entero:

    quiz_server.py

    import socketserver
    from collections import namedtuple
    from random import choice
    from threading import Event
    import pickle
    
    '''
    Comandos:
    COLOCA TUS COMANDOS AQUÍ
    QUES - comando de pregunta
    UNIR - solicitud de unirse al principio
    1 - Se utiliza para enviar una pregunta al cliente después de una solicitud
    2 - Se utiliza para enviar "Espera el evento listo para empezar" al cliente.
    4 - Se utiliza para enviar una respuesta al servidor después de una pregunta
    7 - Se utiliza para enviar una respuesta "Correcta" o "Incorrecta" después de una respuesta
    '''
    
    NUMERO_DE_JUGADORES = 2
    jugadores = []
    listo_para_empezar = Event()
    contestadas = 0
    esperando_respuestas = Event()
    pregunta_Actual = None
    Pregunta = namedtuple('Pregunta', ['p', 'respuesta'])
    # Pregunta = namedtuple('Pregunta', 'p respuesta')
    juego_Preguntas = [
        Pregunta("¿Como se llama un polígono de tres lados?", "Triángulo"),
        Pregunta("¿Cuál es el quinto planeta del sistema solar?", "Jupiter"),
        Pregunta("¿Cuál es la capital de Francia?", "Paris"),
        Pregunta("¿Quién dijo la frase: 'Solo se que no se nada'?", "Socrates"),
        Pregunta("¿Cuál es el antónimo de rico?", "Pobre")
    ]
    
    Ahora aplicaremos lo hemos visto anteriormente que es la base para crear nuestro servidor. Crearemos el servidor, crearemos una clase que indicará al programa que hacer cuando el cliente se conecte y finalmente haremos un punto para ejecutar el programa. Vamos a ello. Añade el siguiente código al final del anterior.

    quiz_server.py

    #...
    # Creamos el servidor multihilo
    class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
        pass
    
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        pass
    
    # Punto de entrada al programa.
    if __name__ == "__main__":
        HOST, PORT = "localhost", 2065
        with ThreadedTCPServer((HOST, PORT), QuizGame) as server:
            print(f"Servidor ejecutándose en {HOST}:{PORT}")
            server.serve_forever()
    
    
    Dentro de la clase QuizGame vamos a crear un módulo llamado "handle". El modulo "socketserver"  usará estos "handler" para interactuar con las conexiones que se realicen. Cuando un cliente se conecta, se crea una versión de esta clase para manejarlo. 

    Por otra parte también crearemos un módulo llamado "enviar_mensaje" que enviará un determinado mensaje desde el servidor al cliente.

    Actualicemos el código:

    quiz_server.py

    #...
    # Creamos el servidor multihilo
    class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
        pass
    
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            pass
    
        def enviar_mensaje(self, mensaje):
            pass
    
    # Punto de entrada al programa.
    if __name__ == "__main__":
        HOST, PORT = "localhost", 2065
        with ThreadedTCPServer((HOST, PORT), QuizGame) as server:
            print(f"Servidor ejecutándose en {HOST}:{PORT}")
            server.serve_forever()
    

    Módulo enviar_mensaje.

    Para enviar los mensajes a los clientes vamos a utilizar el modulo pickle de Python. Pickle nos permite convertir objetos Python a una cadena de bytes y viceversa. Definiremos el método enviar_mensaje dentro de la clase QuizGame para encapsular la lógica de serialización y envio de datos.

    Si utilizamos self.request.sendall(pickle.dumps(mensaje)) convertiremos el mensaje en bytes y lo enviaremos al cliente. Vamos a implementarlo.

    quiz_server.py

    #...
    # Creamos el servidor multihilo
    class ThreadedTCPServer(socketserver.ThreadingMixIn, socketserver.TCPServer):
        pass
    
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            pass
    
        def enviar_mensaje(self, mensaje):
            self.request.sendall(pickle.dumps(mensaje))
    
    # Punto de entrada al programa.
    if __name__ == "__main__":
        HOST, PORT = "localhost", 2065
        with ThreadedTCPServer((HOST, PORT), QuizGame) as server:
            print(f"Servidor ejecutándose en {HOST}:{PORT}")
            server.serve_forever()

    Ya nos queda solo el grueso del programa, que codificaremos a través de "handle".  Es importante que mantengas el nombre de este módulo tal como está "handle". Eso es así porque es el módulo predefinido que es invocado automáticamente cuando recibe una conexión del cliente.

    Manejo de Solicitudes del Cliente en el Servidor

    En esta parte del código, estamos viendo cómo el servidor maneja las solicitudes entrantes de los clientes. Vamos a ir añadiendo el código y desglosándolo paso a paso:

    1. Variables globales y Bucle Infinito

    quiz_server.py

    #...
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            global jugadores
            global contestadas
            global pregunta_Actual
    
            while True:
                pass
    
        def enviar_mensaje(self, mensaje):
           # ...
    Como estás variables van a ser usadas por múltiples clientes nos aseguraremos de que sean las mismas para todos y por ello las declararemos como globales.

    while True:

    Este bucle se ejecuta continuamente, lo que significa que el servidor estará siempre listo para recibir y manejar nuevas solicitudes de los clientes.

    2. Recepción de Datos del Cliente

    quiz_server.py

    #...
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            global jugadores
            global contestadas
            global pregunta_Actual
    
            while True:
                try:
                    data = self.request.recv(1024)
                    if not data:
                        break
                    request = pickle.loads(data)
                except EOFError:
                    break          
        def enviar_mensaje(self, mensaje):        # ...
    Aquí, el servidor espera recibir datos del cliente a través del socket de la conexión. `self.request.recv(1024)` recibe hasta 1024 bytes de datos del cliente. Si no se reciben datos o la conexión se cierra, el servidor sale del bucle con `break`. La función `pickle.loads()` se utiliza para deserializar los datos recibidos en un formato que el servidor pueda entender.

    3. Procesamiento de la Solicitud del Cliente

    Una vez que se recibe y deserializa la solicitud del cliente, el servidor procede a procesarla y tomar las acciones correspondientes según el contenido de la solicitud:

    quiz_server.py

    #...
    # Creamos la clase que especificará lo que se haga cuando se conecte
    # el cliente.
    class QuizGame(socketserver.BaseRequestHandler):
        def handle(self):
            global jugadores
            global contestadas
            global pregunta_Actual
    
            while True:
                try:
                    data = self.request.recv(1024)
                    if not data:
                        break
                    request = pickle.loads(data)
                except EOFError:
                    break
    if request[0] == "JOIN": nombre_equipo = request[1] jugadores.append(nombre_equipo) print(f'El Equipo {nombre_equipo} está conectado.')                 if len(jugadores) == NUMERO_DE_JUGADORES:                     # Si el número de jugadores es correcto listo_para_empezar.set()                     # activa el evento #Envia la respuesta de confirmación                 if listo_para_empezar.is_set() == False: mensaje = "[Server]...esperando que los otros jugadores se unan" else: mensaje = "[Server]...empezando" self.enviar_mensaje([2, mensaje])                 # Espera hasta que el evento este listo para comenzar listo_para_empezar.wait() if request[0] == "QUES": if pregunta_Actual == None: pregunta_Actual = choice(juego_Preguntas) esperando_respuestas.clear() self.enviar_mensaje((1, pregunta_Actual.p)) if request[0] == "4":                 # Envía si la respuesta es correcta o no if request[1] == pregunta_Actual.respuesta: self.enviar_mensaje((7, "Correcto")) else: self.enviar_mensaje((7, "Incorrecto")) contestadas += 1 if contestadas == len(jugadores): contestadas = 0 pregunta_Actual = None esperando_respuestas.set()                 esperando_respuestas.wait()          
        def enviar_mensaje(self, mensaje):        # ...


    - Si la solicitud es un comando `"JOIN"`, el servidor registra al equipo que se está uniendo, imprime un mensaje indicando que el equipo se ha conectado correctamente, y si el número de jugadores alcanza el número deseado (2 por defecto), activa un evento para indicar que el juego está listo para comenzar.
    - Si la solicitud es `"QUES"`, el servidor envía una pregunta al cliente.
    - Si la solicitud es `"4"`, el servidor verifica si la respuesta del cliente es correcta o incorrecta, actualiza el contador de respuestas y activa un evento si todas las respuestas han sido recibidas.

    Finalmente, después de enviar una pregunta al cliente o de recibir una respuesta, el servidor espera hasta que todas las respuestas de los jugadores hayan sido recibidas antes de continuar con el siguiente paso del juego.

    Este bloque de código es esencial para la comunicación bidireccional entre el servidor y los clientes en el juego, permitiendo que el servidor reciba y procese las acciones de los clientes de manera eficiente y oportuna.


    Los eventos (`Event`) en Python son una forma sencilla de coordinar la ejecución entre múltiples hilos. En el contexto de este programa, se utilizan dos eventos para sincronizar el flujo del juego entre el servidor y los clientes. Veamos cómo funcionan estos eventos:

    Eventos en Python

    - set(): Señala el evento, permitiendo que todos los hilos que están esperando en `wait()` continúen su ejecución.
    - clear(): Restablece el evento, haciendo que las llamadas a 'wait()' bloqueen nuevamente.
    - wait(): Bloquea el hilo actual hasta que el evento sea señalado.

    Uso de Eventos en el Programa

    1. 'listo_para_empezar'

    Este evento se utiliza para coordinar el inicio del juego. El juego no comenzará hasta que se hayan unido todos los jugadores necesarios.

    - listo_para_empezar.set(): Este método se llama cuando el número de jugadores alcanza el número requerido (`NUMERO_DE_JUGADORES`). Señala el evento, indicando que el juego está listo para empezar.
    - listo_para_empezar.wait(): Los hilos de los clientes llaman a este método para esperar hasta que el evento sea señalado, asegurándose de que todos los jugadores estén listos antes de comenzar.

    2. 'esperando_respuestas'

    Este evento se utiliza para sincronizar las respuestas de los jugadores a una pregunta.

    - esperando_respuestas.clear(): Este método se llama antes de enviar una nueva pregunta, restableciendo el evento y haciendo que las futuras llamadas a `wait()` bloqueen.
    - esperando_respuestas.set(): Este método se llama una vez que todas las respuestas han sido recibidas y procesadas. Señala el evento, permitiendo que los hilos de los clientes continúen.
    - esperando_respuestas.wait(): Los hilos de los clientes llaman a este método para esperar hasta que el evento sea señalado, asegurándose de que todas las respuestas hayan sido recibidas y procesadas antes de proceder.

    Ejemplo con Dos Clientes

    Supongamos que tenemos dos clientes (A y B) conectados al servidor. Aquí hay un flujo simplificado de cómo los eventos funcionan para coordinar el juego:

    1. **Inicio del Juego**:
        - Cliente A y Cliente B envían el comando `"JOIN"`.
        - El servidor agrega a ambos clientes a la lista `jugadores`.
        - Cuando el segundo cliente se une, el servidor llama a `listo_para_empezar.set()`.
        - Ambos clientes están esperando en `listo_para_empezar.wait()`, y ahora pueden continuar porque el evento ha sido señalado.

    2. **Enviar Pregunta**:
        - Un cliente envía el comando `"QUES"` para solicitar una pregunta.
        - El servidor elige una pregunta y la envía al cliente.
        - El servidor llama a `esperando_respuestas.clear()` antes de enviar la pregunta, asegurándose de que las futuras respuestas bloqueen.

    3. **Recibir Respuestas**:
        - Ambos clientes envían sus respuestas con el comando `"4"`.
        - El servidor recibe las respuestas y las procesa.
        - Cuando se reciben todas las respuestas, el servidor llama a `esperando_respuestas.set()`.
        - Los clientes que estaban esperando en `esperando_respuestas.wait()` ahora pueden continuar porque el evento ha sido señalado.

    4. **Repetir el Proceso**:
        - Este proceso se repite para cada pregunta, coordinando el flujo del juego entre el servidor y los clientes.

    Esta estructura asegura que el servidor y los clientes estén sincronizados, manteniendo el flujo del juego ordenado y manejando correctamente la concurrencia en un entorno multijugador.

    Una vez completado el código del servidor vamos con el del cliente.

    CLIENTE.

    quiz_client.py

    import socket
    import pickle
    
    jugando = True
    
    # Crear un socket TCP/IP
    servidor_preguntas = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
    
    # Pedir al usuario que ingrese el nombre del equipo
    nombre_equipo = input("¿Cual es el nombre de tu equipo? >>> ")
    
    # Pedir al usuario que ingrese la dirección IP del servidor
    serverIP = input("Introduce la dirección IP (e.j. 127.0.0.1) a la que quieres conectarte o presiona ENTER para usar LOCALHOST: ")
    if serverIP == "":
        serverIP = "127.0.0.1"
    
    # Conectar con el servidor
    print(f"\n¡Bienvenido Equipo {nombre_equipo} al Gran Juego de las Preguntas!")
    servidor_preguntas.connect((serverIP, 2065))
    
    # Enviar un comando al servidor para unirse al juego
    servidor_preguntas.sendall(pickle.dumps(["JOIN", nombre_equipo]))
    
    while jugando:
        # Recibir datos del servidor
        response_data = servidor_preguntas.recv(1024)
        if not response_data:
            break
        response = pickle.loads(response_data)
        
        # Manejar las respuestas del servidor
        if response[0] == 1: # Respuesta de pregunta
            print(response[1])
            respuesta = input('Respuesta : ')
            servidor_preguntas.sendall(pickle.dumps(["4", respuesta]))
        elif response[0] == 2: # Esperando que todos los jugadores estén listos
            print(response[1])
            servidor_preguntas.sendall(pickle.dumps(["QUES", ""]))
        elif response[0] == 7: # Respuesta de correcto o incorrecto
            print(response[1])
            servidor_preguntas.sendall(pickle.dumps(["QUES", ""]))
        elif response[0] == 3: # Esperando que los otros jugadores respondan
            print("...esperandos las respuestas de los otros jugadores")
            servidor_preguntas.sendall(pickle.dumps(["QUES", ""]))
    
    servidor_preguntas.close()
    El código del cliente ya lo hemos visto previamente. Se crea un socket y se conecta al servidor. El funcionamiento posterior dependerá de las respuestas del servidor.

    Vamos a ver si todo funciona. Ejecuta el servidor y dos clientes. Sigue las instrucciones. Deberías ver algo similar a esto:


    imagen final del programa


    Con esto ya tendríamos visto el funcionamiento básico del servidor multihilo y los cliente. Falta bastante para completar el juego porque de momento se repetirían de forma infinita las preguntas, no se mostraría quien es el equipo ganador y bastantes cosas más, pero no es el objeto de este post. Si quieres puedes acabar de desarrollar la lógica del programa ya que lo básico que es la conexión y envío de datos ya está en funcionamiento.

    Puedes encontrar el código de este capítulo en este enlace de este enlace de Github.

    Próximo Post.