Programación en red
En resumen: La programación en red son dos programas que hablan por la red: el servidor espera en una dirección IP y un puerto, el cliente se conecta, y los sockets envían los datos por TCP o UDP.
Problema
La caja y el almacén
La caja de la tienda tiene que preguntarle al almacén cuánto café queda, y el programa del almacén funciona en otra computadora. Un archivo no sirve: los dos programas no ven el disco del otro. Uno tiene que preguntar y el otro tiene que responder, en el momento, por la red.
Así funciona casi todo lo que te rodea: el navegador le pide una página al servidor, la app del banco le pide al banco tu saldo, un juego envía tus jugadas a los demás jugadores.
La idea
Dirección, puerto y dos formas de enviar
Para que los datos lleguen a un programa en otra computadora, necesitas dos cosas:
- Una dirección IP: el número de la computadora en la red, por ejemplo
192.168.1.20o142.250.185.78. Tu propia computadora es siempre127.0.0.1, con el nombrelocalhost. - Un puerto: un número del 0 al 65 535 para el programa de esa computadora. La dirección es el edificio, el puerto es el apartamento. HTTPS usa el 443, y nosotros usaremos el 5761 y los siguientes.
El programa que espera a que lo busquen es el servidor. El que busca es el cliente. Cómo viajan los datos lo decide el protocolo:
| TCP | UDP | |
|---|---|---|
| Conexión | primero se conectan, después hablan | sin conexión, cada paquete es una carta aparte |
| Entrega | todo llega, en el mismo orden, o te enteras de que no llegó | un paquete puede perderse o llegar en otro orden |
| Velocidad | más lento | más rápido |
| Para qué | la web, el correo, los pagos, los archivos | voz y video en directo, juegos, sensores |
socket.Más precisamente, para curiosos
- La red se describe en capas. El modelo OSI tiene siete: física (bits por el cable o por el aire), de enlace de datos (la conexión con el dispositivo vecino), de red (direcciones IP y rutas), de transporte (TCP, UDP), de sesión, de presentación y de aplicación (HTTP, correo). En la práctica, internet usa el modelo TCP/IP, más sencillo, con cuatro capas. Tu programa trabaja en la de arriba: Python y el sistema operativo se encargan de todo lo de abajo.
- IP entrega paquetes sueltos a una dirección, pero no promete que lleguen. TCP se apoya en IP y numera los paquetes, para poder ordenarlos y volver a pedir los que se perdieron.
- Protocolos con los que te vas a encontrar: HTTP y HTTPS (la web; HTTPS es HTTP cifrado con TLS, cuyo nombre antiguo es SSL), WebSocket (una conexión en los dos sentidos para chats y notificaciones en el navegador), FTP (archivos), SSH (acceso seguro a una computadora remota).
Míralo
El servidor
El servidor del almacén recibe el nombre de un producto y responde con su precio:
PRICES = {"café": 7.50, "té": 3.20}
def serve(server): client, address = server.accept() with client, client.makefile(encoding="utf-8") as reader: product = reader.readline().rstrip("\n") print("El servidor recibió:", product) price = PRICES.get(product) answer = "no existe ese producto" if price is None else f"{product}: {price} €" client.sendall(f"{answer}\n".encode())serveres un socket que escucha en un puerto (lo abriremos en la siguiente tarjeta).server.accept()espera hasta que se conecta un cliente y devuelve un par: un socket para la conexión con él, y su dirección.client.makefile(encoding="utf-8")te da un archivo para leer del socket, como con los archivos:readline()lee una línea, junto con el"\n"del final.rstrip("\n")lo quita.client.sendall(bytes)envía. Por la red viajan bytes, así que el texto se convierte en bytes con.encode()(en UTF-8). El"\n"del final le dice al otro lado que la línea terminó. Vas a ver por qué importa.withcierra la conexión y el archivo al final.
Míralo
El cliente
socket.create_connection((dirección, puerto)) y usa los mismos makefile y sendall. Aquí el servidor y el cliente están en un solo programa: el servidor funciona en su propio hilo, y el programa principal es el cliente. Así puedes probarlos sin una segunda computadora:server = socket.create_server(("localhost", 5761))threading.Thread(target=serve, args=(server,)).start()
with ( socket.create_connection(("localhost", 5761)) as sock, sock.makefile(encoding="utf-8") as reader,): sock.sendall("café\n".encode()) print("El cliente recibió:", reader.readline().rstrip("\n"))server.close()El servidor recibió: caféEl cliente recibió: café: 7.5 €socket.create_server(("localhost", 5761)) en el programa principal, antes de que arranque el hilo. Así, cuando el cliente se conecta, el servidor ya está escuchando. Los paréntesis después de with permiten poner dos recursos en líneas separadas.Los dos lados se turnan: el cliente escribe una línea y espera una respuesta con readline(), y el servidor lee la línea y responde. Si cambian de papel y los dos esperan para leer, esperarán para siempre.
server = socket.create_server(("localhost", 5761))threading.Thread(target=serve, args=(server,)).start()
with ( socket.create_connection(("localhost", 5761)) as sock, sock.makefile(encoding="utf-8") as reader,): sock.sendall("café\n".encode()) print("El cliente recibió:", reader.readline().rstrip("\n"))server.close()Paso a paso, en palabras
- El servidor abre el puerto 5761 y empieza a escuchar.
- En su propio hilo, el servidor llama a accept() y espera a un cliente.
- El cliente se conecta. accept() devuelve un socket, y los dos lados pueden escribir y leer.
- El cliente escribe una línea: «café». Viaja por la conexión hasta el servidor.
- El cliente espera en readline(). El servidor lee la línea y la imprime.
- El servidor busca el precio y responde: «café: 7.5 €».
- El readline() del cliente devuelve la respuesta, y el cliente la imprime.
- with cierra el socket, y server.close() cierra el puerto.
Más precisamente, para curiosos
- El programa completo, con los
import:
import socketimport threading
PRICES = {"café": 7.50, "té": 3.20}
def serve(server): client, address = server.accept() with client, client.makefile(encoding="utf-8") as reader: product = reader.readline().rstrip("\n") print("El servidor recibió:", product) price = PRICES.get(product) answer = "no existe ese producto" if price is None else f"{product}: {price} €" client.sendall(f"{answer}\n".encode())
server = socket.create_server(("localhost", 5761))threading.Thread(target=serve, args=(server,)).start()
with ( socket.create_connection(("localhost", 5761)) as sock, sock.makefile(encoding="utf-8") as reader,): sock.sendall("café\n".encode()) print("El cliente recibió:", reader.readline().rstrip("\n"))server.close()- En dos archivos separados y dos terminales es lo mismo: primero arrancas el servidor, después el cliente. Si no hay ningún servidor en el puerto,
create_connectionlanzaConnectionRefusedError. Si otro programa ya ocupa el puerto,create_serverlanzaOSError: [Errno 98] Address already in use. - El texto viaja por la red como bytes.
.encode()ymakefile(encoding="utf-8")usan UTF-8, así que las tildes, la ñ y el € llegan intactos.
Pruébalo
Espera a un cliente
¿Qué método espera hasta que se conecta un cliente?
server = socket.create_server(("localhost", 5761))
client, address = server.()accept() se bloquea hasta que alguien se conecta, y devuelve un socket para la conexión con él. El servidor puede llamarlo muchas veces, para muchos clientes.La idea
Muchos clientes a la vez
accept() y los demás esperan. Por eso un servidor real hace un bucle infinito: acepta un cliente, se lo da a un hilo nuevo y vuelve enseguida a accept():def accept_all(server): number = 0 while True: try: client, address = server.accept() except OSError: return number += 1 threading.Thread(target=handle, args=(client, number)).start()
def handle(client, number): with client, client.makefile(encoding="utf-8") as reader: for line in reader: line = line.rstrip("\n") print(f"Cliente {number}: {line}") client.sendall(f"Eco: {line}\n".encode())handle responde a cada línea hasta que el cliente cierra la conexión: entonces for line in reader termina. Dos clientes, uno detrás de otro:server = socket.create_server(("localhost", 5762))threading.Thread(target=accept_all, args=(server,), daemon=True).start()
for order in ["mesa 4: dos cafés", "mesa 7: la cuenta"]: with ( socket.create_connection(("localhost", 5762)) as sock, sock.makefile(encoding="utf-8") as reader, ): sock.sendall(f"{order}\n".encode()) print(reader.readline().rstrip("\n"))server.close()Cliente 1: mesa 4: dos cafésEco: mesa 4: dos cafésCliente 2: mesa 7: la cuentaEco: mesa 7: la cuentaMás precisamente, para curiosos
daemon=Trueconvierte el hilo en un hilo «en segundo plano»: el programa termina cuando termina el programa principal, sin esperar al bucle infinito. Un servidor real no lo pone en segundo plano: tiene que funcionar hasta que lo detengas.with client, …cierra al final el socket que ya existe.- Con miles de clientes, un hilo para cada uno sale caro. Entonces el bucle que acepta les da los clientes a un grupo de hilos (thread pool) de la lección anterior:
pool.submit(handle, client, number).
Pruébalo
Los dos esperan
El cliente envía «café» y espera una respuesta. El servidor es el mismo, pero el programa se queda colgado y no imprime nada. ¿En qué línea está el error?
Elige la línea con el error.
"\n". El servidor lee con readline(), y eso solo devuelve algo cuando llega el final de la línea. El servidor espera el final de la línea, y el cliente espera una respuesta. La corrección es sock.sendall("café\n".encode()).La idea
UDP: cartas sin conexión
makefile: envías bytes a una dirección y un puerto a través de un socket del tipo SOCK_DGRAM (datagram, un paquete suelto):receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)receiver.bind(("localhost", 5763))
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sensor: for reading in ["cocina 21.5", "almacén 4.0", "nevera 3.2"]: sensor.sendto(reading.encode(), ("localhost", 5763))
for i in range(3): data, address = receiver.recvfrom(256) print(f"Temperatura: {data.decode()} °C")receiver.close()Temperatura: cocina 21.5 °CTemperatura: almacén 4.0 °CTemperatura: nevera 3.2 °C- El receptor escucha en el puerto 5763 con
bind(("localhost", 5763)). El emisor no llama abind: el sistema le da un puerto libre. sendto(bytes, (dirección, puerto))envía un paquete.recvfrom(256)espera un paquete de hasta 256 bytes y devuelve los bytes y la dirección del emisor.- Nadie confirma que el paquete llegó. En
localhostno se pierden, pero en una red real sí puede pasar.
Más precisamente, para curiosos
Si el receptor tiene que responder, envía un paquete nuevo a la dirección que devolvió recvfrom: receiver.sendto(respuesta, address).
Un paquete tiene un tamaño máximo (menos de 64 KB, y por internet conviene quedarse en unos 1400 bytes), así que UDP no sirve para archivos.
La idea
HTTP: el idioma de la web
Cuando abres una página, el navegador hace una conexión TCP con el servidor y envía una petición HTTP de texto: «dame /». El servidor devuelve una respuesta: un estado, unas cabeceras y un cuerpo (HTML, JSON, una imagen). En Python esto lo hace urllib.request:
import urllib.request
with urllib.request.urlopen("https://example.com") as response: print("Estado:", response.status) print("Tipo:", response.headers["content-type"]) body = response.read().decode() print("Título:", body[body.index("<title>") + 7 : body.index("</title>")])Estado: 200Tipo: text/html; charset=utf-8Título: Example Domainurlopen(dirección)envía una peticiónGETy espera la respuesta.withcierra la conexión al final.response.read()te da el cuerpo como bytes, y.decode()lo convierte en texto.- El estado te dice cómo fue:
200es éxito,404significa que no existe esa página,500es un error del servidor. Con un estado 4xx o 5xx,urlopenlanzaurllib.error.HTTPError.
La revisión del código del sitio se hace sin internet, así que este programa se muestra con la salida de una ejecución con red. Sin red, urlopen lanza urllib.error.URLError.
Más precisamente, para curiosos
http.server). Así puedes probar urlopen sin internet, en localhost:class Stock(BaseHTTPRequestHandler): def do_GET(self): if self.path != "/stock": self.send_error(404) return answer = "café: 12 uds.".encode() self.send_response(200) self.send_header("Content-Length", str(len(answer))) self.end_headers() self.wfile.write(answer)
def log_message(self, format, *args): pass # sin una línea en la consola por cada petición
server = HTTPServer(("localhost", 5764), Stock)threading.Thread(target=server.serve_forever, daemon=True).start()
for path in ["/stock", "/prices"]: try: with urllib.request.urlopen(f"http://localhost:5764{path}") as response: print(path, "->", response.status, response.read().decode()) except urllib.error.HTTPError as e: print(path, "->", e.code, e.reason)server.shutdown()/stock -> 200 café: 12 uds./prices -> 404 Not FoundGET el servidor llama a do_GET. Para /prices devuelve 404 con send_error, y urlopen lo lanza como un HTTPError. log_message está vacío, así el servidor no imprime una línea por cada petición. En proyectos reales, los servidores web se hacen con bibliotecas como Flask, Django o FastAPI. Están construidas sobre los mismos sockets.Ejercicio
Servidores en localhost
1. Servidor de eco. El servidor devuelve cada línea con «Eco: » delante, hasta que recibe «fin». Entonces responde «Adiós» y cierra la conexión. El cliente envía «hola», «qué tal» y «fin», e imprime las respuestas:
Eco: holaEco: qué talAdiós2. Un servidor con comandos. MAYUS texto devuelve el texto en mayúsculas, HORA devuelve la hora actual como 14:05 (datetime.now().strftime("%H:%M")), y cualquier otra cosa devuelve «Comando desconocido: » y el comando. La hora cambia, así que el cliente solo comprueba su forma con una expresión regular:
PEDIDO 42 ESTÁ LISTOLa hora tiene la forma HH:MM: TrueComando desconocido: TIEMPOCarlos recibió: ¡Hola, Carlos! ¿Estás libre a las 18:00?Ana recibió: Sí, allí estaré.Carlos recibió: Genial, hasta pronto.Ana recibió: ¡Hasta luego!Test
Ponte a prueba
Lecciones relacionadas
Sección «Lecciones relacionadas»- Se basa en: Listas: append, remove y sort · dict y set · Lambdas · Hilos
- Continúa en: Entrada no confiable · Sesiones, cookies y CSRF · JWT y sus trampas · Diseño de API: contrato, versiones, solicitudes repetidas
- Ejercicios sobre el tema: Programas en el mundo real