socket – Establecer una conexión TCP, calcular su latencia (ping) e intercambiar información



Versión: 2.x, 3.x.
Descarga: fuentes.zip.

Introducción

Originalmente este artículo estaría titulado «Cómo calcular la latencia en una conexión TCP». Durante el desarrollo del código para alcanzar este objetivo llegué a la conclusión que se emplean diversos métodos que sería absurdo no explicarlos y resultaría confuso para aquellos que se estén iniciando en el mundo de la programación en Python. Por lo tanto, decidí ampliar el contenido de la entrada para cubrir los aspectos principales de las conexiones cliente-servidor, el módulo socket y el intercambio de información.

El módulo

socket es un módulo escrito en C (_socket.so, _socket.pyd, entre otras, dependiendo de la plataforma) con una interfaz de alto nivel desarrollada en Python (socket.py). Permite crear clientes y servidores para intercambiar información entre dos o más ordenadores.

La arquitectura y funcionamiento de los sockets en general es un tema aparte, mucho más amplio y complejo, que requeriría de varios artículos y está por fuera de los objetivos de este artículo, que pretende mostrar el funcionamiento básico del módulo en Python.

Para empezar, voy a indicar el uso que se le dará a los tres archivos de código de fuente Python que utilizaremos: client.py, server.py y packet.py. El primero será el cliente, se conectará al segundo, server.py, y ambos se comunicarán gracias a dos protocolos comunes: el TCP (que lo establece el módulo socket) y el creado por nosotros, packet.py, que se encarga de empaquetar y desempaquetar porciones de información para intercambiar. En base a esto, voy a llamar a estas porciones de información paquetes.

Protocolo

Crea un nuevo archivo llamado packet.py. Iré mostrando el código en orden, de arriba hacia abajo.

#!/usr/bin/env python
# -*- coding: utf-8 -*-

MESSAGE = 0
PING = 1

En las primeras dos líneas, como sabrás, le indicamos al sistema operativo cómo interpretar nuestro archivo (en algunas plataformas) y su codificación. A continuación, defino los dos paquetes que utilizarán tanto cliente como servidor. MESSAGE envía un mensaje de texto plano, y PING obtiene la latencia de la conexión.

def pack(packet_id, message):
    return chr(packet_id) + message

pack() se encarga de, como lo indica el nombre, juntar el ID del paquete (0 para MESSAGE y 1 para PING) y el mensaje en una única cadena, que luego pueda enviarse utilizando la función socket.send, que veremos más adelante.

De modo contrario, unpack() descompone una cadena, retornando el ID del paquete y el mensaje.

def unpack(input_data):
    packet_id = (input_data[0] if isinstance(input_data[0], int)
                               else ord(input_data[0]))
    message = input_data[1:]
    return packet_id, message

Como se puede observar, el ID del paquete ocupa siempre 1 byte en la cadena enviada a través del socket, mientras que el mensaje que lo acompaña puede ser, por el momento, de hasta 1023 bytes. Ambos suman 1024 bytes, número que utilizaremos como tamaño máximo de un paquete.

En Python 2 la función socket.send requiere una cadena de caracteres (el tipo str), mientras que en Python 3 requiere una sucesión de bytes (el tipo bytes). Por lo tanto, para mantener la compatiblidad entre ambas versiones, se realizan comprobaciones y, de ser necesario, se convierte de un tipo al otro.

Para finalizar con el archivo packet.py y con respecto a lo anterior, nuestra implementación de la función send, un simple wrapper o envoltura de socket.send() que previamente convierte de str a bytes en caso de ser necesario.

def send(socket, output_data):
    try:
        socket.send(output_data)
    except TypeError:
        socket.send(bytes(output_data, "utf-8"))

Código completo:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

MESSAGE = 0
PING = 1


def pack(packet_id, message):
    return chr(packet_id) + message


def unpack(input_data):
    packet_id = (input_data[0] if isinstance(input_data[0], int)
                               else ord(input_data[0]))
    message = input_data[1:]
    return packet_id, message


def send(socket, output_data):
    try:
        socket.send(output_data)
    except TypeError:
        socket.send(bytes(output_data, "utf-8"))

Cliente

client.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from socket import socket
from time import time

import packet


# Compatibilidad con Python 3 
try:
    raw_input
except NameError:
    raw_input = input

La función socket.socket crea un nuevo objeto socket que puede actuar como cliente o servidor. Toma algunos parámetros que puedes observar en la documentación del módulo para indicar el tipo de protocolo a usar, sin embargo utilizaremos aquellos por defecto que establecen una conexión TCP.

time.time() será utilizada para calcular la latencia (ping) de la conexión.

def main():
    s = socket()
    s.connect(("localhost", 3030))

Creamos un objeto socket e intentamos conectar a la dirección de IP 127.0.0.1 (o localhost) vía el puerto 3030.

    while True:
        message = raw_input("> ")

Un bucle para solicitar constantemente la entrada del usuario, un mensaje para enviar al servidor.

        if message.lower() == "ping":
            output_data = packet.pack(packet.PING, "")
            ticks = time()
        else:
            output_data = packet.pack(packet.MESSAGE, message[1:])

En caso que la entrada sea ping, se calcula la latencia de la conexión. La función de ticks la explicaré posteriormente. Caso contrario, se envía el mensaje sin modificación alguna.

        packet.send(s, output_data)
        incoming_data = s.recv(1024)

Luego enviamos los datos al servidor y esperamos su respuesta. La función socket.recv bloquea hasta recibir información del servidor. 1024 es el número máximo de bytes que se leerán por cada llamada.

        if incoming_data:
            packet_id, message = packet.unpack(incoming_data)

Si se ha recibido algo del servidor, se desempaca la información, obtieniendo así el ID del paquete y su mensaje correspondiente.

            if packet_id == packet.MESSAGE:
                if isinstance(message, bytes):
                    message = message.decode("utf-8")
                print("El servidor ha respondido: %s." % message)

De ser necesario, se convierte el mensaje desde una secuencia de bytes a una cadena de caracteres, y se imprime el resultado en pantalla.

            elif packet_id == packet.PING:
                ticks = (time() - ticks) / 2
                print("Ping: %.2f ms." % ticks)

El procedimiento para calcular la latencia de una conexión es simple. Se denomina ticks a los milisegundos transcurridos desde el inicio del sistema. Luego de haberlos calculados, se envía un paquete al servidor. Cuando su respuesta llega al cliente, se vuelven a calcular y se restan aquellos obtenidos inicialmente. Por último, se divide por dos, ya que es la suma del tiempo transcurrido entre llegar al servidor y regresar al cliente. Al trabajar con una conexión local, la latencia será siempre 0, debido a que no hay distancia para recorrer, únicamente el tiempo que toman las funciones send() y recv().

    s.close()


if __name__ == "__main__":
    main()

Por último aseguramos que la conexión siempre sea cerrada al salir del bucle, sea por cuenta propia del usuario o por algun error ocurrido.

Código completo:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from socket import socket
from time import time

import packet


# Compatibilidad con Python 3 
try:
    raw_input
except NameError:
    raw_input = input


def main():
    s = socket()
    s.connect(("localhost", 3030))

    while True:
        message = raw_input("> ")
        
        if message.lower() == "ping":
            output_data = packet.pack(packet.PING, "")
            ticks = time()
        else:
            output_data = packet.pack(packet.MESSAGE, message[1:])
        
        packet.send(s, output_data)
        incoming_data = s.recv(1024)
        
        if incoming_data:
            packet_id, message = packet.unpack(incoming_data)
            if packet_id == packet.MESSAGE:
                if isinstance(message, bytes):
                    message = message.decode("utf-8")
                print("El servidor ha respondido: %s." % message)
            elif packet_id == packet.PING:
                ticks = (time() - ticks) / 2
                print("Ping: %.2f ms." % ticks)
    
    s.close()


if __name__ == "__main__":
    main()

Servidor

server.py

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from socket import socket

import packet


# Compatibilidad con Python 3 
try:
    raw_input
except NameError:
    raw_input = input


def main():
    s = socket()

Importar funciones necesarias y crear el socket.

    s.bind(("localhost", 3030))
    s.listen(1)

Establecerlo como servidor, escuchando en el puerto 3030 y, como máximo, 1 conexión.

conn, addr = s.accept()

socket.accept() se bloquea hasta recibir una conexión por parte de un cliente, y retorna otro objeto socket junto con la dirección IP y puerto del equipo remoto.

    while True:
        incoming_data = conn.recv(1024)

Verificar constantemente si se ha recibido información.

        if incoming_data:
            packet_id, message = packet.unpack(incoming_data)

Decodificar o desempacar la información, obteniendo el ID del paquete y el mensaje.

            if packet_id == packet.MESSAGE:
                packet.send(
                    conn, packet.pack(packet.MESSAGE, "Hola cliente")
                )

En caso de ser un mensaje, se envía la respuesta, «Hola cliente».

            elif packet_id == packet.PING:
                packet.send(conn, packet.pack(packet.PING, ""))

Si se trata de una solicitud para obtener la latencia, se envía rápidamente la respuesta hacia el cliente para calcular el tiempo transcurrido.

    s.close()


if __name__ == "__main__":
    main()

Siempre cerrar la conexión.

Código completo:

#!/usr/bin/env python
# -*- coding: utf-8 -*-

from socket import socket

import packet


# Compatibilidad con Python 3 
try:
    raw_input
except NameError:
    raw_input = input


def main():
    s = socket()
    s.bind(("localhost", 3030))
    s.listen(1)
    
    conn, addr = s.accept()
    
    while True:
        incoming_data = conn.recv(1024)
        
        if incoming_data:
            packet_id, message = packet.unpack(incoming_data)
            
            if packet_id == packet.MESSAGE:
                packet.send(
                    conn, packet.pack(packet.MESSAGE, "Hola cliente")
                )
            elif packet_id == packet.PING:
                packet.send(conn, packet.pack(packet.PING, ""))
    
    s.close()


if __name__ == "__main__":
    main()

Ejemplo de utilización

Abrir el servidor, para comenzar a escuchar conexiones, y luego el cliente.

Mensajes de entrada y salida del cliente:

> ¡Hola servidor!
El servidor ha respondido: Hola cliente.
> ping
Ping: 0.00 ms.

Conclusión

Una simple demostración de cómo montar una conexión TCP cliente-servidor, como también administrar la información intercambiada a través de un protocolo común, que brinda mayor organización y claridad en el código.

Sin embargo, socket sigue siendo un módulo de bajo nivel. Crear aplicaciones medianas o grandes con éste resulta incómodo, ya que muchas librerías y frameworks se han encargado a través del tiempo de estas tareas para facilitar la implementación de conexiones TCP, sobre todo si hay más de un cliente (por ejemplo, Twisted).



Deja un comentario