Мрежово програмиране
Накратко: Мрежовото програмиране е две програми, които си говорят по мрежата: сървърът чака на IP адрес и порт, клиентът се свързва, а сокетите пращат данните по TCP или UDP.
Проблем
Касата и складът
Касата в магазина трябва да пита склада колко кафе е останало, а складовата програма работи на друг компютър. Файл не помага: двете програми не виждат дисковете си. Трябва едната да попита, а другата да отговори, веднага, по мрежата.
Така работи почти всичко около теб: браузърът пита сървъра за страница, приложението на банката пита банката за баланса, играта праща ходовете ти на другите играчи.
Идеята
Адрес, порт и два начина за пращане
За да стигнат данните до програма на друг компютър, трябват две неща:
- IP адрес: номерът на компютъра в мрежата, например
192.168.1.20или142.250.185.78. Собственият ти компютър винаги е127.0.0.1, с имеlocalhost. - Порт: номер от 0 до 65 535 за програмата на този компютър. Адресът е сградата, портът е апартаментът. HTTPS ползва 443, а ние ще ползваме 5501 и нагоре.
Програмата, която чака да я потърсят, е сървър. Тази, която търси, е клиент. Как пътуват данните, решава протоколът:
| TCP | UDP | |
|---|---|---|
| Връзка | първо се свързват, после си говорят | без връзка, всеки пакет е отделно писмо |
| Доставка | всичко стига, в същия ред, или разбираш, че не е | пакетът може да се загуби или да дойде в друг ред |
| Скорост | по-бавен | по-бърз |
| За какво | уеб, имейл, плащания, файлове | глас и видео на живо, игри, сензори |
System.Net.Sockets.По-точно, за любопитните
- Мрежата се описва на слоеве. Моделът OSI има седем: физически (битове по кабела или въздуха), канален (връзка до съседното устройство), мрежов (IP адреси и маршрути), транспортен (TCP, UDP), сесиен, представителен и приложен (HTTP, имейл). Интернет на практика ползва по-простия модел TCP/IP с четири слоя. Програмата ти работи на най-горния: .NET и операционната система поемат всичко отдолу.
- IP доставя отделни пакети до адрес, но не обещава, че ще стигнат. TCP стъпва върху IP и номерира пакетите, за да подреди и поиска наново загубените.
- Протоколи, които ще срещнеш: HTTP и HTTPS (уеб; HTTPS е HTTP, криптиран с TLS, по-старото име е SSL), WebSocket (двупосочна връзка за чат и известия в браузъра), FTP (файлове), SSH (сигурен достъп до отдалечен компютър).
Виж го
Сървърът
Складовият сървър получава име на продукт и отговаря с цената:
static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["кафе"] = 7.50, ["чай"] = 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("Сървърът получи: " + product); writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "няма такъв продукт"); } } catch (IOException e) { Console.WriteLine("Сървърът: " + e.Message); }}TcpListenerслуша на порт.server.AcceptTcpClient()чака, докато се свърже клиент, и връщаTcpClientза връзката с него: същия тип, с който се свързва и клиентът.client.GetStream()дава потока на връзката: по него текат байтове и в двете посоки. Обвиваме го в два класа:StreamReaderчете текст ред по ред сReadLine(), аStreamWriterпише сWriteLine(). Същите класове четат и пишат и файлове.AutoFlush = trueзначи „изпращай след всекиWriteLine“. Ще видим защо е важно.using (…)затваря връзката и потоците в края на блока, дори ако по средата има изключение: все едноfinallyвикаDispose(). Няколкоusingедин под друг делят един блок и се затварят в обратен ред.TryGetValueтърси продукта, а цената дава презout.
Виж го
Клиентът
new TcpClient(адрес, порт) и ползва същите StreamReader и StreamWriter. Тук сървърът и клиентът са в една програма: сървърът работи в своя нишка, а Main е клиентът. Така можеш да ги пробваш без втори компютър:TcpListener server = new TcpListener(IPAddress.Loopback, 5501);server.Start();new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5501))using (StreamReader reader = new StreamReader(client.GetStream()))using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }){ writer.WriteLine("кафе"); Console.WriteLine("Клиентът получи: " + reader.ReadLine());}server.Stop();Сървърът получи: кафеКлиентът получи: кафе: 7.5 €new TcpListener(IPAddress.Loopback, 5501) още не отваря порта. Отваря го server.Start() в Main, преди нишката да тръгне. Така, когато клиентът се свърже, сървърът вече слуша. IPAddress.Loopback е 127.0.0.1: сървърът приема само програми от същия компютър.Двете страни се редуват: клиентът пише ред и чака отговор с ReadLine(), а сървърът чете реда и отговаря. Ако сменят местата си и двете чакат да четат, ще чакат завинаги.
TcpListener server = new TcpListener(IPAddress.Loopback, 5501);server.Start();new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5501))using (StreamReader reader = new StreamReader(client.GetStream()))using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }){ writer.WriteLine("кафе"); Console.WriteLine("Клиентът получи: " + reader.ReadLine());}server.Stop();Стъпка по стъпка, с думи
- Сървърът отваря порт 5501 и започва да слуша.
- В своя нишка сървърът вика AcceptTcpClient() и чака клиент.
- Клиентът се свързва. AcceptTcpClient() връща TcpClient и двете страни имат writer за писане и reader за четене.
- Клиентът пише ред: „кафе“. Той минава по връзката до сървъра.
- Клиентът чака на ReadLine(). Сървърът прочита реда и го печата.
- Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
- ReadLine() на клиента връща отговора и той го печата.
- using затваря връзката, а server.Stop() затваря порта.
По-точно, за любопитните
- Цялата програма, с
import-ите:
using System.Net;using System.Net.Sockets;
class Program{ static readonly Dictionary<string, double> Prices = new Dictionary<string, double> { ["кафе"] = 7.50, ["чай"] = 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("Сървърът получи: " + product); writer.WriteLine(Prices.TryGetValue(product, out double price) ? product + ": " + price + " €" : "няма такъв продукт"); } } catch (IOException e) { Console.WriteLine("Сървърът: " + e.Message); } }
static void Main() { TcpListener server = new TcpListener(IPAddress.Loopback, 5501); server.Start(); new Thread(() => Serve(server)).Start();
using (TcpClient client = new TcpClient("localhost", 5501)) using (StreamReader reader = new StreamReader(client.GetStream())) using (StreamWriter writer = new StreamWriter(client.GetStream()) { AutoFlush = true }) { writer.WriteLine("кафе"); Console.WriteLine("Клиентът получи: " + reader.ReadLine()); } server.Stop(); }}- В два отделни файла и два терминала е същото: пускаш първо сървъра, после клиента. Ако на порта няма сървър,
new TcpClientхвърляSocketException(Connection refused). Ако портът вече е зает от друга програма,server.Start()хвърляSocketException(Address already in use). В .NETSocketExceptionне еIOException, затоваcatch (IOException)не го хваща. - Текстът минава по мрежата като байтове.
StreamReaderиStreamWriterползват UTF-8 по подразбиране, затова кирилицата пристига цяла. usingима и кратък вид, без скоби и блок:using TcpClient client = new TcpClient("localhost", 5501);затваря връзката в края на блока, в който е редът.
Пробвай
Чакай клиент
Кой метод чака, докато се свърже клиент?
TcpListener server = new TcpListener(IPAddress.Loopback, 5501);
server.Start();
TcpClient client = server.();AcceptTcpClient() блокира, докато някой не се свърже, и връща TcpClient за връзката с него. Сървърът може да го вика много пъти, за много клиенти.Идеята
Много клиенти наведнъж
AcceptTcpClient() не се вика и другите чакат. Затова истинският сървър прави безкраен цикъл: приема клиент, дава го на нова нишка и веднага се връща към 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("Клиент " + number + ": " + line); writer.WriteLine("Ехо: " + line); } } } catch (IOException e) { Console.WriteLine("Клиент " + number + " прекъсна: " + e.Message); }}Handle отговаря на всеки ред, докато клиентът не затвори връзката: тогава ReadLine() връща null. Два клиента един след друг:TcpListener server = new TcpListener(IPAddress.Loopback, 5502);server.Start();Thread acceptor = new Thread(() => AcceptAll(server));acceptor.IsBackground = true;acceptor.Start();
foreach (string order in new string[] { "маса 4: две кафета", "маса 7: сметката" }){ using (TcpClient client = new TcpClient("localhost", 5502)) 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();Клиент 1: маса 4: две кафетаЕхо: маса 4: две кафетаКлиент 2: маса 7: сметкатаЕхо: маса 7: сметкатаПо-точно, за любопитните
IsBackground = trueправи нишката „фонова“: програмата свършва, когато свършиMain, без да чака безкрайния цикъл. Нишка отnew Threadпо подразбиране не е фонова. Истинският сървър не я прави фонова: той трябва да работи, докато не го спреш.server.Stop()затваря порта. ЧакащиятAcceptTcpClient()хвърляSocketExceptionи цикълът свършва сreturn.using (client)слага вече създаденияTcpClientвusing, за да се затвори накрая.int n = number;трябва: ламбдата не помни стойността наnumber, а самата променлива. Без копието нишката на първия клиент може да прочетеnumber, след като цикълът вече го е увеличил за втория.- При хиляди клиенти нишка за всеки е скъпо. Тогава приемащият цикъл дава клиентите на басейна от нишки от предишния урок:
Task.Run(() => Handle(client, n)).
Пробвай
Двамата чакат
Клиентът праща „кафе“ и чака отговор. Сървърът е същият, но програмата увисва и не печата нищо. На кой ред е грешката?
Избери реда с грешката.
StreamWriter без AutoFlush = true не изпраща веднага: пази текста в буфер, докато не се събере повече. WriteLine("кафе") остава в паметта на клиента, сървърът чака ред, а клиентът чака отговор. Поправката е new StreamWriter(client.GetStream()) { AutoFlush = true } или writer.Flush() след WriteLine.Идеята
UDP: писма без връзка
UdpClient:UdpClient receiver = new UdpClient(new IPEndPoint(IPAddress.Loopback, 5503));
using (UdpClient sensor = new UdpClient()){ foreach (string reading in new string[] { "кухня 21.5", "склад 4.0", "хладилник 3.2" }) { byte[] data = Encoding.UTF8.GetBytes(reading); sensor.Send(data, data.Length, "localhost", 5503); }}
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("Температура: " + text + " °C");}receiver.Close();Температура: кухня 21.5 °CТемпература: склад 4.0 °CТемпература: хладилник 3.2 °C- Получателят слуша на порт 5503:
new UdpClient(new IPEndPoint(IPAddress.Loopback, 5503)).IPEndPointе адрес плюс порт. Изпращачът еnew UdpClient()без порт: системата му дава свободен. - По мрежата минават байтове.
Encoding.UTF8.GetBytesпревръща текста в байтове, аEncoding.UTF8.GetStringобратно (Encodingе вSystem.Text). Send(байтове, брой, адрес, порт)праща един пакет.Receive(ref sender)чака пакет, връща байтовете му и пише вsenderоткъде е дошъл: затова еref.- Никой не потвърждава, че пакетът е стигнал. На
localhostне се губят, но по истинска мрежа може.
По-точно, за любопитните
Ако получателят трябва да отговори, праща нов пакет до адреса, който Receive е записал в sender: receiver.Send(отговор, отговор.Length, sender).
Пакетът е ограничен по размер (под 64 KB, а по интернет е разумно до около 1 400 байта), затова за файлове UDP не става.
Идеята
HTTP: езикът на уеба
Браузърът, когато отваряш страница, прави TCP връзка до сървъра и праща текстова HTTP заявка: „дай ми /“. Сървърът връща отговор: статус, заглавки и тяло (HTML, JSON, картинка). В C# това прави HttpClient, но той работи с async и await, които идват в модула „Уеб и десктоп“. Тук заявката е написана на ръка и пратена по TcpClient. Така се вижда, че HTTP е просто текст по 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("Статус: " + status.Split(' ')[1]); string line; while ((line = reader.ReadLine()) != "") { if (line.StartsWith("Content-Type: ")) { Console.WriteLine("Тип: " + line.Substring(14)); } } string body = reader.ReadToEnd(); int start = body.IndexOf("<title>") + 7; Console.WriteLine("Заглавие: " + body.Substring(start, body.IndexOf("</title>") - start));}Статус: 200Тип: text/html; charset=utf-8Заглавие: Example Domain- Заявката е няколко реда и всеки завършва с
\r\n:GET / HTTP/1.1(„дай ми/“),Host:(кой сайт) иConnection: close(„затвори след отговора“). Празният ред накрая казва, че заявката е свършила. - Отговорът започва с ред като
HTTP/1.1 200 OK. Второто парче в него е статусът. Следват заглавките (Content-Type: …) до празен ред, а после тялото.ReadToEnd()го чете, докато сървърът не затвори връзката. - Статусът казва как е минало:
200е успех,404няма такава страница,500е грешка на сървъра. - Порт 80 е HTTP без криптиране. Повечето сайтове днес искат HTTPS на порт 443, където текстът минава криптиран (TLS).
HttpClientправи това вместо теб.
Проверката на кода на сайта е без интернет, затова тази програма е показана с изхода от пускане с мрежа. Без мрежа new TcpClient хвърля SocketException. В модула „Уеб и десктоп“ заявката и отговорът се разглобяват ред по ред, а после HttpClient върши всичко това вместо теб.
По-точно, за любопитните
HttpListener). Така можеш да пробваш същата заявка и без интернет, на 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 ? "кафе: 12 бр." : "няма такъв адрес"); 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:5504/");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", 5504)) 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() != "") { // заглавките на отговора тук не трябват } Console.WriteLine(path + " -> " + status + " " + reader.ReadToEnd()); }}server.Stop();/stock -> 200 кафе: 12 бр./prices -> 404 няма такъв адресGetContext() чака заявка, както AcceptTcpClient() чака клиент. За /prices сървърът сам слага статус 404. ContentLength64 казва колко байта е тялото, за да знае клиентът къде свършва. server.Stop() прекъсва чакащия GetContext() с HttpListenerException. В истински проекти уеб сървърът се пише с ASP.NET Core, в модула „Уеб и десктоп“. Той е построен върху същите сокети и нишки.Упражнение
Сървъри на localhost
1. Ехо сървър. Сървърът връща всеки ред с „Ехо: “ отпред, докато не получи „край“. Тогава отговаря „Довиждане“ и затваря връзката. Клиентът праща „здравей“, „как си“ и „край“ и печата отговорите:
Ехо: здравейЕхо: как сиДовиждане2. Сървър с команди. ГЛАВНИ текст връща текста с главни букви, ЧАС връща текущия час като 14:05 (DateTime.Now.ToString("HH:mm")), а всичко друго връща „Непозната команда: “ и командата. Часът се мени, затова клиентът проверява само вида му с TimeOnly.TryParseExact(час, "HH:mm", out _):
ПОРЪЧКА 42 Е ГОТОВАЧасът има вид ЧЧ:ММ: TrueНепозната команда: ВРЕМЕБорис получи: Здрасти, Борис! Свободен ли си в 18:00?Ана получи: Да, ще дойда.Борис получи: Супер, до скоро.Ана получи: Чао!Тест
Провери се
Свързани уроци
Раздел „Свързани уроци“- Нужно отпреди: Списъци · Dictionary и HashSet · Ламбди и делегати · Нишки
- Продължава в: Недоверен вход · Сесии, бисквитки и CSRF · JWT и капаните му · Дизайн на API: договор, версии, повторени заявки
- Задачи по темата: Програми в истинския свят