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

Програми в мрежата

Проблем

Касата и складът

Касата в магазина трябва да пита склада колко кафе е останало, а складовата програма работи на друг компютър. Файл не помага: двете програми не виждат дисковете си. Трябва едната да попита, а другата да отговори, веднага, по мрежата.

Така работи почти всичко около теб: браузърът пита сървъра за страница, приложението на банката пита банката за баланса, играта праща ходовете ти на другите играчи.

Идеята

Адрес, порт и два начина за пращане

За да стигнат данните до програма на друг компютър, трябват две неща:

  • IP адрес: номерът на компютъра в мрежата, например 192.168.1.20 или 142.250.185.78. Собственият ти компютър винаги е 127.0.0.1, с име localhost.
  • Порт: номер от 0 до 65 535 за програмата на този компютър. Адресът е сградата, портът е апартаментът. HTTPS ползва 443, а ние ще ползваме 5101 и нагоре.

Програмата, която чака да я потърсят, е сървър. Тази, която търси, е клиент. Как пътуват данните, решава протоколът:

TCP UDP
Връзка първо се свързват, после си говорят без връзка, всеки пакет е отделно писмо
Доставка всичко стига, в същия ред, или разбираш, че не е пакетът може да се загуби или да дойде в друг ред
Скорост по-бавен по-бърз
За какво уеб, имейл, плащания, файлове глас и видео на живо, игри, сензори
Крайните точки на връзката се казват сокети (sockets). В Python ги прави модулът socket.
По-точно, за любопитните
  • Мрежата се описва на слоеве. Моделът OSI има седем: физически (битове по кабела или въздуха), канален (връзка до съседното устройство), мрежов (IP адреси и маршрути), транспортен (TCP, UDP), сесиен, представителен и приложен (HTTP, имейл). Интернет на практика ползва по-простия модел TCP/IP с четири слоя. Програмата ти работи на най-горния: Python и операционната система поемат всичко отдолу.
  • IP доставя отделни пакети до адрес, но не обещава, че ще стигнат. TCP стъпва върху IP и номерира пакетите, за да подреди и поиска наново загубените.
  • Протоколи, които ще срещнеш: HTTP и HTTPS (уеб; HTTPS е HTTP, криптиран с TLS, по-старото име е SSL), WebSocket (двупосочна връзка за чат и известия в браузъра), FTP (файлове), SSH (сигурен достъп до отдалечен компютър).

Виж го

Сървърът

Складовият сървър получава име на продукт и отговаря с цената:

PRICES = {"кафе": 7.50, "чай": 3.20}
def serve(server):
client, address = server.accept()
with client, client.makefile(encoding="utf-8") as reader:
product = reader.readline().rstrip("\n")
print("Сървърът получи:", product)
price = PRICES.get(product)
answer = "няма такъв продукт" if price is None else f"{product}: {price} €"
client.sendall(f"{answer}\n".encode())
  • server е сокет, който слуша на порт (ще го отворим в следващата карта). server.accept() чака, докато се свърже клиент, и връща двойка: сокет за връзката с него и адреса му.
  • client.makefile(encoding="utf-8") дава файл за четене от сокета, както при файловете: readline() чете ред, заедно с "\n" накрая. rstrip("\n") го маха.
  • client.sendall(байтове) праща. По мрежата минават байтове, затова текстът се превръща с .encode() (в UTF-8). "\n" в края казва на другата страна, че редът е свършил. Ще видим защо е важно.
  • with затваря връзката и файла накрая.

Виж го

Клиентът

Клиентът се свързва със socket.create_connection((адрес, порт)) и ползва същите makefile и sendall. Тук сървърът и клиентът са в една програма: сървърът работи в своя нишка, а главната програма е клиентът. Така можеш да ги пробваш без втори компютър:
server = socket.create_server(("localhost", 5101))
threading.Thread(target=serve, args=(server,)).start()
with (
socket.create_connection(("localhost", 5101)) as sock,
sock.makefile(encoding="utf-8") as reader,
):
sock.sendall("кафе\n".encode())
print("Клиентът получи:", reader.readline().rstrip("\n"))
server.close()
Изход
Сървърът получи: кафе
Клиентът получи: кафе: 7.5 €
Портът се отваря със socket.create_server(("localhost", 5101)) в главната програма, преди нишката да тръгне. Така, когато клиентът се свърже, сървърът вече слуша. Скобите след with позволяват два ресурса на отделни редове.

Двете страни се редуват: клиентът пише ред и чака отговор с readline(), а сървърът чете реда и отговаря. Ако сменят местата си и двете чакат да четат, ще чакат завинаги.

server = socket.create_server(("localhost", 5101))
threading.Thread(target=serve, args=(server,)).start()
with (
socket.create_connection(("localhost", 5101)) as sock,
sock.makefile(encoding="utf-8") as reader,
):
sock.sendall("кафе\n".encode())
print("Клиентът получи:", reader.readline().rstrip("\n"))
server.close()
Стъпка по стъпка, с думи
  1. Сървърът отваря порт 5101 и започва да слуша.
  2. В своя нишка сървърът вика accept() и чака клиент.
  3. Клиентът се свързва. accept() връща сокет и двете страни могат да пишат и да четат.
  4. Клиентът пише ред: „кафе“. Той минава по връзката до сървъра.
  5. Клиентът чака на readline(). Сървърът прочита реда и го печата.
  6. Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
  7. readline() на клиента връща отговора и той го печата.
  8. with затваря сокета, а server.close() затваря порта.
По-точно, за любопитните
  • Цялата програма, с import-ите:
prices.py
import socket
import threading
PRICES = {"кафе": 7.50, "чай": 3.20}
def serve(server):
client, address = server.accept()
with client, client.makefile(encoding="utf-8") as reader:
product = reader.readline().rstrip("\n")
print("Сървърът получи:", product)
price = PRICES.get(product)
answer = "няма такъв продукт" if price is None else f"{product}: {price} €"
client.sendall(f"{answer}\n".encode())
server = socket.create_server(("localhost", 5101))
threading.Thread(target=serve, args=(server,)).start()
with (
socket.create_connection(("localhost", 5101)) as sock,
sock.makefile(encoding="utf-8") as reader,
):
sock.sendall("кафе\n".encode())
print("Клиентът получи:", reader.readline().rstrip("\n"))
server.close()
  • В два отделни файла и два терминала е същото: пускаш първо сървъра, после клиента. Ако на порта няма сървър, create_connection хвърля ConnectionRefusedError. Ако портът вече е зает от друга програма, create_server хвърля OSError: [Errno 98] Address already in use.
  • Текстът минава по мрежата като байтове. .encode() и makefile(encoding="utf-8") ползват UTF-8, затова кирилицата пристига цяла.

Пробвай

Чакай клиент

Кой метод чака, докато се свърже клиент?

server = socket.create_server(("localhost", 5101))
client, address = server.()

Идеята

Много клиенти наведнъж

Докато сървърът говори с един клиент, accept() не се вика и другите чакат. Затова истинският сървър прави безкраен цикъл: приема клиент, дава го на нова нишка и веднага се връща към 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"Клиент {number}: {line}")
client.sendall(f"Ехо: {line}\n".encode())
handle отговаря на всеки ред, докато клиентът не затвори връзката: тогава for line in reader свършва. Два клиента един след друг:
server = socket.create_server(("localhost", 5102))
threading.Thread(target=accept_all, args=(server,), daemon=True).start()
for order in ["маса 4: две кафета", "маса 7: сметката"]:
with (
socket.create_connection(("localhost", 5102)) as sock,
sock.makefile(encoding="utf-8") as reader,
):
sock.sendall(f"{order}\n".encode())
print(reader.readline().rstrip("\n"))
server.close()
Изход
Клиент 1: маса 4: две кафета
Ехо: маса 4: две кафета
Клиент 2: маса 7: сметката
Ехо: маса 7: сметката
По-точно, за любопитните
  • daemon=True прави нишката „фонова“: програмата свършва, когато свърши главната програма, без да чака безкрайния цикъл. Истинският сървър не я прави фонова: той трябва да работи, докато не го спреш.
  • with client, … затваря вече създадения сокет накрая.
  • При хиляди клиенти нишка за всеки е скъпо. Тогава приемащият цикъл дава клиентите на басейн от нишки от предишния урок: pool.submit(handle, client, number).

Пробвай

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

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

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

Идеята

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

Сензорите в магазина пращат температурата всяка минута. Ако едно показание се загуби, следващото идва след минута, затова тук е удобен UDP. Няма връзка и няма makefile: пращаш байтове до адрес и порт през сокет от вида SOCK_DGRAM (datagram, отделен пакет):
receiver = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
receiver.bind(("localhost", 5103))
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sensor:
for reading in ["кухня 21.5", "склад 4.0", "хладилник 3.2"]:
sensor.sendto(reading.encode(), ("localhost", 5103))
for i in range(3):
data, address = receiver.recvfrom(256)
print(f"Температура: {data.decode()} °C")
receiver.close()
Изход
Температура: кухня 21.5 °C
Температура: склад 4.0 °C
Температура: хладилник 3.2 °C
  • Получателят слуша на порт 5103 с bind(("localhost", 5103)). Изпращачът не вика bind: системата му дава свободен порт.
  • sendto(байтове, (адрес, порт)) праща един пакет. recvfrom(256) чака пакет до 256 байта и връща байтовете и адреса на изпращача.
  • Никой не потвърждава, че пакетът е стигнал. На localhost не се губят, но по истинска мрежа може.
По-точно, за любопитните

Ако получателят трябва да отговори, праща нов пакет до адреса, който е върнал recvfrom: receiver.sendto(отговор, address). Пакетът е ограничен по размер (под 64 KB, а по интернет е разумно до около 1 400 байта), затова за файлове UDP не става.

Идеята

HTTP: езикът на уеба

Браузърът, когато отваряш страница, прави TCP връзка до сървъра и праща текстова HTTP заявка: „дай ми /“. Сървърът връща отговор: статус, заглавки и тяло (HTML, JSON, картинка). В Python това прави urllib.request:

import urllib.request
with urllib.request.urlopen("https://example.com") as response:
print("Статус:", response.status)
print("Тип:", response.headers["content-type"])
body = response.read().decode()
print("Заглавие:", body[body.index("<title>") + 7 : body.index("</title>")])
Изход (с интернет)
Статус: 200
Тип: text/html; charset=utf-8
Заглавие: Example Domain
  • urlopen(адрес) праща заявка GET и чака отговора. with затваря връзката накрая.
  • response.read() дава тялото като байтове, а .decode() го превръща в текст.
  • Статусът казва как е минало: 200 е успех, 404 няма такава страница, 500 е грешка на сървъра. При статус 4xx и 5xx urlopen хвърля urllib.error.HTTPError.

Проверката на кода на сайта е без интернет, затова тази програма е показана с изхода от пускане с мрежа. Без мрежа urlopen хвърля urllib.error.URLError.

По-точно, за любопитните
Сървър за HTTP има и в самия Python (http.server). Така можеш да пробваш urlopen и без интернет, на localhost:
class Stock(BaseHTTPRequestHandler):
def do_GET(self):
if self.path != "/stock":
self.send_error(404)
return
answer = "кафе: 12 бр.".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 # без ред в конзолата за всяка заявка
server = HTTPServer(("localhost", 5104), Stock)
threading.Thread(target=server.serve_forever, daemon=True).start()
for path in ["/stock", "/prices"]:
try:
with urllib.request.urlopen(f"http://localhost:5104{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 кафе: 12 бр.
/prices -> 404 Not Found
За всяка заявка GET сървърът вика do_GET. За /prices той връща 404 със send_error, а urlopen го хвърля като HTTPError. log_message е празен, за да не печата сървърът ред за всяка заявка. В истински проекти за уеб сървър се ползват библиотеки като Flask, Django или FastAPI. Те са построени върху същите сокети.

Упражнение

Сървъри на localhost

Реши задачите в редактора си. Сървърът и клиентът могат да са в една програма (сървърът в нишка) или в две отделни, пуснати в два терминала.

1. Ехо сървър. Сървърът връща всеки ред с „Ехо: “ отпред, докато не получи „край“. Тогава отговаря „Довиждане“ и затваря връзката. Клиентът праща „здравей“, „как си“ и „край“ и печата отговорите:

Изход
Ехо: здравей
Ехо: как си
Довиждане

2. Сървър с команди. ГЛАВНИ текст връща текста с главни букви, ЧАС връща текущия час като 14:05 (datetime.now().strftime("%H:%M")), а всичко друго връща „Непозната команда: “ и командата. Часът се мени, затова клиентът проверява само вида му с регулярен израз:

Изход
ПОРЪЧКА 42 Е ГОТОВА
Часът има вид ЧЧ:ММ: True
Непозната команда: ВРЕМЕ
3. Чат за двама. Ана отваря сървъра на порт 5107, Борис се свързва. Всеки е в своя нишка и праща готовите си реплики, като след всяка чака отговор:
Изход
Борис получи: Здрасти, Борис! Свободен ли си в 18:00?
Ана получи: Да, ще дойда.
Борис получи: Супер, до скоро.
Ана получи: Чао!
После го направи истински: две програми, всяка с две нишки. Едната чете от клавиатурата и праща, а другата чете от сокета и печата. Така можеш да пишеш, докато чакаш отговор.

Тест

Провери се

1. Какво посочва портът?
2. Кога UDP е по-добрият избор от TCP?
3. Какво прави server.accept()?
4. Клиентът праща sock.sendall("кафе".encode()), а сървърът чете с readline(). Двамата чакат. Защо?
5. urlopen хвърля HTTPError, а e.code е 404. Какво означава?

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