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 5861 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 |
System.Net.Sockets.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: .NET 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:
static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["café"] = 7.50, ["té"] = 3.20 };
static void Serve(TcpListener server){ try { using (TcpClient client = server.AcceptTcpClient()) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { string product = reader.ReadLine(); Console.WriteLine("El servidor recibió: " + product); writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "no existe ese producto"); } } catch (IOException e) { Console.WriteLine("El servidor: " + e.Message); }}TcpListenerescucha en un puerto.server.AcceptTcpClient()espera hasta que se conecta un cliente y devuelve unTcpClientpara la conexión con él: el mismo tipo con el que se conecta el cliente.client.GetStream()te da el flujo de la conexión: por él pasan bytes en los dos sentidos. Lo envolvemos en dos clases:StreamReaderlee texto línea a línea conReadLine(), yStreamWriterescribe conWriteLine(). Las mismas clases también leen y escriben archivos.AutoFlush = truesignifica «envía después de cadaWriteLine». Vas a ver por qué importa.using (…)cierra la conexión y los flujos al final del bloque, aunque ocurra una excepción a la mitad: como si unfinallyllamara aDispose(). Variosusinguno debajo de otro comparten un bloque y se cierran en orden inverso.TryGetValuebusca el producto y te da el precio a través deout.
Míralo
El cliente
new TcpClient(dirección, puerto) y usa los mismos StreamReader y StreamWriter. Aquí el servidor y el cliente están en un solo programa: el servidor funciona en su propio hilo, y Main es el cliente. Así puedes probarlos sin una segunda computadora:TcpListener server = new TcpListener(IPAddress.Loopback, 5861);server.Start();new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5861))using (StreamReader reader = new StreamReader(client.GetStream()))using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }){ writer.WriteLine("café"); Console.WriteLine("El cliente recibió: " + reader.ReadLine());}server.Stop();El servidor recibió: caféEl cliente recibió: café: 7.5 €new TcpListener(IPAddress.Loopback, 5861) todavía no abre el puerto. Lo abre server.Start(), en Main, antes de que arranque el hilo. Así, cuando el cliente se conecta, el servidor ya está escuchando. IPAddress.Loopback es 127.0.0.1: el servidor solo acepta programas de la misma computadora.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.
TcpListener server = new TcpListener(IPAddress.Loopback, 5861);server.Start();new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5861))using (StreamReader reader = new StreamReader(client.GetStream()))using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }){ writer.WriteLine("café"); Console.WriteLine("El cliente recibió: " + reader.ReadLine());}server.Stop();Paso a paso, en palabras
- El servidor abre el puerto 5861 y empieza a escuchar.
- En su propio hilo, el servidor llama a AcceptTcpClient() y espera a un cliente.
- El cliente se conecta. AcceptTcpClient() devuelve un TcpClient, y cada lado tiene un writer para escribir y un reader para 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.
- using cierra la conexión, y server.Stop() cierra el puerto.
Más precisamente, para curiosos
- El programa completo, con los
import:
using System.Net;using System.Net.Sockets;
class Program{ static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["café"] = 7.50, ["té"] = 3.20 };
static void Serve(TcpListener server) { try { using (TcpClient client = server.AcceptTcpClient()) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { string product = reader.ReadLine(); Console.WriteLine("El servidor recibió: " + product); writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "no existe ese producto"); } } catch (IOException e) { Console.WriteLine("El servidor: " + e.Message); } }
static void Main() { TcpListener server = new TcpListener(IPAddress.Loopback, 5861); server.Start(); new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5861)) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { writer.WriteLine("café"); Console.WriteLine("El cliente recibió: " + reader.ReadLine()); } server.Stop(); }}- 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,
new TcpClientlanza unaSocketException(Connection refused). Si otro programa ya ocupa el puerto,server.Start()lanza unaSocketException(Address already in use). En .NET unaSocketExceptionno es unaIOException, así quecatch (IOException)no la atrapa. - El texto viaja por la red como bytes.
StreamReaderyStreamWriterusan UTF-8 por defecto, así que las tildes, la ñ y el € llegan intactos. usingtambién tiene una forma corta, sin paréntesis ni bloque:using TcpClient client = new TcpClient("localhost", 5861);cierra la conexión al final del bloque en el que está la línea.
Pruébalo
Espera a un cliente
¿Qué método espera hasta que se conecta un cliente?
TcpListener server = new TcpListener(IPAddress.Loopback, 5861);
server.Start();
TcpClient client = server.();AcceptTcpClient() se bloquea hasta que alguien se conecta, y devuelve un TcpClient para la conexión con él. El servidor puede llamarlo muchas veces, para muchos clientes.La idea
Muchos clientes a la vez
AcceptTcpClient() 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 AcceptTcpClient():static void AcceptAll(TcpListener server){ int number = 0; while (true) { try { TcpClient client = server.AcceptTcpClient(); number++; int n = number; new Thread(() => Handle(client, n)).Start(); } catch (SocketException) { return; } }}
static void Handle(TcpClient client, int number){ try { using (client) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { string line; while ((line = reader.ReadLine()) != null) { Console.WriteLine("Cliente " + number + ": " + line); writer.WriteLine("Eco: " + line); } } } catch (IOException e) { Console.WriteLine("Cliente " + number + " se desconectó: " + e.Message); }}Handle responde a cada línea hasta que el cliente cierra la conexión: entonces ReadLine() devuelve null. Dos clientes, uno detrás de otro:TcpListener server = new TcpListener(IPAddress.Loopback, 5862);server.Start();Thread acceptor = new Thread(() => AcceptAll(server));acceptor.IsBackground = true;acceptor.Start();
foreach (string order in new string[] { "mesa 4: dos cafés", "mesa 7: la cuenta" }){ using (TcpClient client = new TcpClient("localhost", 5862)) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { writer.WriteLine(order); Console.WriteLine(reader.ReadLine()); }}server.Stop();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
IsBackground = trueconvierte el hilo en un hilo «en segundo plano»: el programa termina cuando terminaMain, sin esperar al bucle infinito. Un hilo creado connew Threadno está en segundo plano por defecto. Un servidor real no lo pone en segundo plano: tiene que funcionar hasta que lo detengas.server.Stop()cierra el puerto. ElAcceptTcpClient()que está esperando lanza unaSocketException, y el bucle termina conreturn.using (client)mete elTcpClientque ya existe en unusing, para que se cierre al final.int n = number;hace falta: la lambda no recuerda el valor denumber, recuerda la variable misma. Sin la copia, el hilo del primer cliente podría leernumberdespués de que el bucle ya la haya aumentado para el segundo.- Con miles de clientes, un hilo para cada uno sale caro. Entonces el bucle que acepta les da los clientes al grupo de hilos de la lección anterior:
Task.Run(() => Handle(client, n)).
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.
StreamWriter sin AutoFlush = true no envía enseguida: guarda el texto en un búfer hasta que se junta más. WriteLine("café") se queda en la memoria del cliente, el servidor espera una línea, y el cliente espera una respuesta. La corrección es new StreamWriter(client.GetStream()) { AutoFlush = true } o writer.Flush() después de WriteLine.La idea
UDP: cartas sin conexión
UdpClient:UdpClient receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 5863));
using (UdpClient sensor = new UdpClient()){ foreach (string reading in new string[] { "cocina 21.5", "almacén 4.0", "nevera 3.2" }) { byte[] data = Encoding.UTF8.GetBytes(reading); sensor.Send(data, data.Length, "localhost", 5863); }}
IPEndPoint sender = new IPEndPoint(IPAddress.Any, 0);for (int i = 0; i < 3; i++){ byte[] data = receiver.Receive(ref sender); string text = Encoding.UTF8.GetString(data); Console.WriteLine("Temperatura: " + text + " °C");}receiver.Close();Temperatura: cocina 21.5 °CTemperatura: almacén 4.0 °CTemperatura: nevera 3.2 °C- El receptor escucha en el puerto 5863:
new UdpClient(new IPEndPoint(IPAddress.Loopback, 5863)). UnIPEndPointes una dirección más un puerto. El emisor esnew UdpClient()sin puerto: el sistema le da uno libre. - Por la red viajan bytes.
Encoding.UTF8.GetBytesconvierte el texto en bytes, yEncoding.UTF8.GetStringlos vuelve a convertir en texto (Encodingestá enSystem.Text). Send(bytes, cantidad, dirección, puerto)envía un paquete.Receive(ref sender)espera un paquete, devuelve sus bytes y escribe ensenderde dónde vino: por eso esref.- 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 Receive escribió en sender: receiver.Send(respuesta, respuesta.Length, sender).
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 C# esto lo hace HttpClient, pero funciona con async y await, que llegan en el módulo «Web y escritorio». Aquí la petición está escrita a mano y se envía por un TcpClient. Así se ve que HTTP es solo texto por una conexión TCP:
using System.Net.Sockets;
using (TcpClient client = new TcpClient("example.com", 80))using (StreamReader reader = new StreamReader(client.GetStream()))using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }){ writer.Write("GET / HTTP/1.1\r\nHost: example.com\r\nConnection: close\r\n\r\n");
string status = reader.ReadLine(); Console.WriteLine("Estado: " + status.Split(' ')[1]); string line; while ((line = reader.ReadLine()) != "") { if (line.StartsWith("Content-Type: ")) { Console.WriteLine("Tipo: " + line.Substring(14)); } } string body = reader.ReadToEnd(); int start = body.IndexOf("<title>") + 7; Console.WriteLine("Título: " + body.Substring(start, body.IndexOf("</title>") - start));}Estado: 200Tipo: text/html; charset=utf-8Título: Example Domain- La petición son unas pocas líneas, y cada una termina en
\r\n:GET / HTTP/1.1(«dame/»),Host:(qué sitio) yConnection: close(«cierra después de la respuesta»). La línea vacía del final dice que la petición terminó. - La respuesta empieza con una línea como
HTTP/1.1 200 OK. Su segunda parte es el estado. Después vienen las cabeceras (Content-Type: …) hasta una línea vacía, y luego el cuerpo.ReadToEnd()lo lee hasta que el servidor cierra la conexión. - El estado te dice cómo fue:
200es éxito,404significa que no existe esa página,500es un error del servidor. - El puerto 80 es HTTP sin cifrar. Hoy la mayoría de los sitios piden HTTPS en el puerto 443, donde el texto viaja cifrado (TLS).
HttpClienthace eso por ti.
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, new TcpClient lanza una SocketException. En el módulo «Web y escritorio» la petición y la respuesta se desarman línea a línea, y después HttpClient hace todo esto por ti.
Más precisamente, para curiosos
HttpListener). Así puedes probar la misma petición sin internet, en localhost:static void Answer(HttpListener server){ try { while (true) { HttpListenerContext context = server.GetContext(); bool found = context.Request.Url.AbsolutePath == "/stock"; byte[] answer = Encoding.UTF8.GetBytes(found ? "café: 12 uds." : "no existe esa dirección"); context.Response.StatusCode = found ? 200 : 404; context.Response.ContentLength64 = answer.Length; context.Response.OutputStream.Write(answer, 0, answer.Length); context.Response.Close(); } } catch (HttpListenerException) { return; }}
HttpListener server = new HttpListener();server.Prefixes.Add("http://localhost:5864/");server.Start();Thread answering = new Thread(() => Answer(server));answering.IsBackground = true;answering.Start();
foreach (string path in new string[] { "/stock", "/prices" }){ using (TcpClient client = new TcpClient("localhost", 5864)) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { writer.Write("GET " + path + " HTTP/1.1\r\nHost: localhost\r\nConnection: close\r\n\r\n"); string status = reader.ReadLine().Split(' ')[1]; while (reader.ReadLine() != "") { // aquí no hacen falta las cabeceras de la respuesta } Console.WriteLine(path + " -> " + status + " " + reader.ReadToEnd()); }}server.Stop();/stock -> 200 café: 12 uds./prices -> 404 no existe esa direcciónGetContext() espera una petición, igual que AcceptTcpClient() espera un cliente. Para /prices el servidor pone el estado 404 por su cuenta. ContentLength64 dice cuántos bytes tiene el cuerpo, para que el cliente sepa dónde termina. server.Stop() interrumpe el GetContext() que está esperando con una HttpListenerException. En proyectos reales, los servidores web se escriben con ASP.NET Core, en el módulo «Web y escritorio». Está construido sobre los mismos sockets e hilos.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.ToString("HH:mm")), y cualquier otra cosa devuelve «Comando desconocido: » y el comando. La hora cambia, así que el cliente solo comprueba su forma con TimeOnly.TryParseExact(hora, "HH:mm", out _):
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 · Dictionary y HashSet · Lambdas y delegados · 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