Към съдържанието
wannacode

Мрежово програмиране

Накратко: Мрежовото програмиране е две програми, които си говорят по мрежата: сървърът чака на 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
Връзка първо се свързват, после си говорят без връзка, всеки пакет е отделно писмо
Доставка всичко стига, в същия ред, или разбираш, че не е пакетът може да се загуби или да дойде в друг ред
Скорост по-бавен по-бърз
За какво уеб, имейл, плащания, файлове глас и видео на живо, игри, сензори
Крайните точки на връзката се казват сокети (sockets). В C# класовете за тях са в 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();
Стъпка по стъпка, с думи
  1. Сървърът отваря порт 5501 и започва да слуша.
  2. В своя нишка сървърът вика AcceptTcpClient() и чака клиент.
  3. Клиентът се свързва. AcceptTcpClient() връща TcpClient и двете страни имат writer за писане и reader за четене.
  4. Клиентът пише ред: „кафе“. Той минава по връзката до сървъра.
  5. Клиентът чака на ReadLine(). Сървърът прочита реда и го печата.
  6. Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
  7. ReadLine() на клиента връща отговора и той го печата.
  8. using затваря връзката, а server.Stop() затваря порта.
По-точно, за любопитните
  • Цялата програма, с import-ите:
Program.cs
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). В .NET SocketException не е 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() не се вика и другите чакат. Затова истинският сървър прави безкраен цикъл: приема клиент, дава го на нова нишка и веднага се връща към 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)).

Пробвай

Двамата чакат

Клиентът праща „кафе“ и чака отговор. Сървърът е същият, но програмата увисва и не печата нищо. На кой ред е грешката?

Избери реда с грешката.

Идеята

UDP: писма без връзка

Сензорите в магазина пращат температурата всяка минута. Ако едно показание се загуби, следващото идва след минута, затова тук е удобен 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 върши всичко това вместо теб.

По-точно, за любопитните
Сървър за HTTP има и в самия .NET (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
Непозната команда: ВРЕМЕ
3. Чат за двама. Ана отваря сървъра на порт 5507, Борис се свързва. Всеки е в своя нишка и праща готовите си реплики, като след всяка чака отговор:
Изход
Борис получи: Здрасти, Борис! Свободен ли си в 18:00?
Ана получи: Да, ще дойда.
Борис получи: Супер, до скоро.
Ана получи: Чао!
После го направи истински: две програми, всяка с две нишки. Едната чете от клавиатурата и праща, а другата чете от сокета и печата. Така можеш да пишеш, докато чакаш отговор.

Тест

Провери се

1. Какво посочва портът?
2. Кога UDP е по-добрият избор от TCP?
3. Какво прави server.AcceptTcpClient()?
4. Клиентът пише с WriteLine, но сървърът не получава нищо, а двамата чакат. Каква е най-честата причина?
5. Първият ред на отговора е HTTP/1.1 404 Not Found. Какво означава?

Създаден от Иво Гергов като част от кампанията на Evo Tech за достъпно обучение по програмиране ·