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

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

Проблем

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

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

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

Идеята

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

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

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

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

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

Виж го

Сървърът

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

// await readLine() чака следващия ред от сокета (undefined, когато връзката е затворена).
function lineReader(socket) {
const lines = readline.createInterface({ input: socket })[Symbol.asyncIterator]();
return async () => (await lines.next()).value;
}
const PRICES = new Map([["кафе", 7.50], ["чай", 3.20]]);
async function serve(client) {
const readLine = lineReader(client);
const product = await readLine();
console.log(`Сървърът получи: ${product}`);
const price = PRICES.get(product);
client.end(price === undefined ? "няма такъв продукт\n" : `${product}: ${price} €\n`);
}
  • Нишка и accept() няма. Node вика serve за всеки клиент, който се свърже, и му дава сокет за връзката (client).
  • Сокетът е поток: байтовете идват на парчета, когато пристигнат. lineReader (горе) ги събира в редове с node:readline, а await readLine() чака следващия ред.
  • client.end(текст) праща последния текст и затваря връзката. Текстът минава по мрежата в UTF-8. "\n" в края казва на другата страна, че редът е свършил. Ще видим защо е важно.

Виж го

Клиентът

Клиентът се свързва с net.connect(порт, адрес) и ползва същите lineReader и write. Тук сървърът и клиентът са в една програма: Node обслужва двете страни в една нишка, като ги редува, докато всяка чака. Така можеш да ги пробваш без втори компютър:
const server = net.createServer(serve);
server.listen(5101, "localhost");
await once(server, "listening");
const socket = net.connect(5101, "localhost");
const readLine = lineReader(socket);
socket.write("кафе\n");
console.log(`Клиентът получи: ${await readLine()}`);
socket.end();
server.close();
Изход
Сървърът получи: кафе
Клиентът получи: кафе: 7.5 €
server.listen(5101, "localhost") отваря порта, а await once(server, "listening") чака, докато е отворен. Така, когато клиентът се свърже, сървърът вече слуша.

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

const server = net.createServer(serve);
server.listen(5101, "localhost");
await once(server, "listening");
const socket = net.connect(5101, "localhost");
const readLine = lineReader(socket);
socket.write("кафе\n");
console.log(`Клиентът получи: ${await readLine()}`);
socket.end();
server.close();
Стъпка по стъпка, с думи
  1. Сървърът отваря порт 5101 и започва да слуша.
  2. Нишка няма: Node ще извика serve, когато дойде клиент. Дотогава програмата продължава.
  3. Клиентът се свързва. Node вика serve със сокет за клиента и двете страни могат да пишат и да четат.
  4. Клиентът пише ред: „кафе“. Той минава по връзката до сървъра.
  5. Клиентът чака с await readLine(). Сървърът прочита реда и го печата.
  6. Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
  7. await readLine() на клиента връща отговора и той го печата.
  8. socket.end() затваря връзката, а server.close() затваря порта.
По-точно, за любопитните
  • Цялата програма, с import-ите:
prices.js
import net from "node:net";
import readline from "node:readline";
import { once } from "node:events";
// await readLine() чака следващия ред от сокета (undefined, когато връзката е затворена).
function lineReader(socket) {
const lines = readline.createInterface({ input: socket })[Symbol.asyncIterator]();
return async () => (await lines.next()).value;
}
const PRICES = new Map([["кафе", 7.50], ["чай", 3.20]]);
async function serve(client) {
const readLine = lineReader(client);
const product = await readLine();
console.log(`Сървърът получи: ${product}`);
const price = PRICES.get(product);
client.end(price === undefined ? "няма такъв продукт\n" : `${product}: ${price} €\n`);
}
const server = net.createServer(serve);
server.listen(5101, "localhost");
await once(server, "listening");
const socket = net.connect(5101, "localhost");
const readLine = lineReader(socket);
socket.write("кафе\n");
console.log(`Клиентът получи: ${await readLine()}`);
socket.end();
server.close();
  • В два отделни файла и два терминала е същото: пускаш първо сървъра, после клиента. Ако на порта няма сървър, сокетът дава грешка ECONNREFUSED. Ако портът вече е зает от друга програма, сървърът дава грешка EADDRINUSE. Грешките идват като събитие "error": без socket.on("error", …) програмата спира с тях.
  • Текстът минава по мрежата като байтове. write и readline ползват UTF-8 по подразбиране, затова кирилицата пристига цяла.

Пробвай

Отвори порта

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

const server = net.createServer(serve);
server.(5101, "localhost");

Идеята

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

Тук JavaScript е по-прост. Цикъл за приемане няма: Node вика handle за всеки нов клиент. Докато един клиент чака, Node обслужва другите, всичко в една нишка:
let number = 0;
async function handle(client) {
number++;
const n = number;
for await (const line of readline.createInterface({ input: client })) {
console.log(`Клиент ${n}: ${line}`);
client.write(`Ехо: ${line}\n`);
}
}
handle отговаря на всеки ред, докато клиентът не затвори връзката: тогава for await свършва. Два клиента един след друг:
const server = net.createServer(handle);
server.listen(5102, "localhost");
await once(server, "listening");
for (const order of ["маса 4: две кафета", "маса 7: сметката"]) {
const socket = net.connect(5102, "localhost");
const readLine = lineReader(socket);
socket.write(`${order}\n`);
console.log(await readLine());
socket.end();
}
server.close();
Изход
Клиент 1: маса 4: две кафета
Ехо: маса 4: две кафета
Клиент 2: маса 7: сметката
Ехо: маса 7: сметката
По-точно, за любопитните
  • number е общ за всички клиенти, но състезание няма: в даден момент работи само една функция, а между number++ и const n = number няма await.
  • server.close() спира новите връзки. Node свършва програмата, когато се затворят и старите.
  • Тази една нишка стига за хиляди клиенти, защото през повечето време те чакат мрежата. Ако сървърът трябва да смята много, тежката работа отива в Worker от предишния урок.

Пробвай

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

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

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

Идеята

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

Сензорите в магазина пращат температурата всяка минута. Ако едно показание се загуби, следващото идва след минута, затова тук е удобен UDP. Пакетите праща модулът node:dgram (datagram, отделен пакет):
const receiver = dgram.createSocket("udp4");
receiver.bind(5103, "localhost");
await once(receiver, "listening");
const sensor = dgram.createSocket("udp4");
sensor.connect(5103, "localhost");
await once(sensor, "connect");
for (const reading of ["кухня 21.5", "склад 4.0", "хладилник 3.2"]) {
sensor.send(reading);
}
let count = 0;
receiver.on("message", (data) => {
console.log(`Температура: ${data} °C`);
count++;
if (count === 3) {
receiver.close();
sensor.close();
}
});
Изход
Температура: кухня 21.5 °C
Температура: склад 4.0 °C
Температура: хладилник 3.2 °C
  • createSocket("udp4") прави UDP сокет. Получателят слуша на порт 5103 с bind(5103, "localhost").
  • Изпращачът вика connect(5103, "localhost"): при UDP това не прави връзка, само запомня накъде отиват пакетите. После send(текст) праща един пакет.
  • Всеки пакет идва като събитие "message". След третия програмата затваря двата сокета, иначе Node чака още пакети завинаги.
  • Никой не потвърждава, че пакетът е стигнал. На localhost не се губят, но по истинска мрежа може.
По-точно, за любопитните

Ако получателят трябва да отговори, взима адреса и порта на изпращача от втория параметър на "message": (data, rinfo) => receiver.send(отговор, rinfo.port, rinfo.address). Пакетът е ограничен по размер (под 64 KB, а по интернет е разумно до около 1 400 байта), затова за файлове UDP не става.

Идеята

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

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

const response = await fetch("https://example.com");
console.log(`Статус: ${response.status}`);
console.log(`Тип: ${response.headers.get("content-type")}`);
const body = await response.text();
console.log(`Заглавие: ${body.slice(body.indexOf("<title>") + 7, body.indexOf("</title>"))}`);
Изход (с интернет)
Статус: 200
Тип: text/html; charset=utf-8
Заглавие: Example Domain
  • fetch(адрес) праща заявка GET и дава обещание за отговора. await го чака.
  • response.text() чете тялото като текст. И то е обещание, затова пак await.
  • Статусът казва как е минало: 200 е успех, 404 няма такава страница, 500 е грешка на сървъра. fetch не хвърля при 404: провери response.status или response.ok.

Проверката на кода на сайта е без интернет, затова тази програма е показана с изхода от пускане с мрежа. Без мрежа fetch хвърля TypeError: fetch failed.

По-точно, за любопитните
Сървър за HTTP има и в самия Node (node:http). Така можеш да пробваш fetch и без интернет, на localhost:
const server = http.createServer((request, response) => {
if (request.url !== "/stock") {
response.writeHead(404).end("няма такъв адрес");
return;
}
response.writeHead(200, { "content-type": "text/plain; charset=utf-8" }).end("кафе: 12 бр.");
});
server.listen(5104, "localhost");
await once(server, "listening");
for (const path of ["/stock", "/prices"]) {
const response = await fetch(`http://localhost:5104${path}`);
console.log(`${path} -> ${response.status} ${await response.text()}`);
}
server.close();
Изход
/stock -> 200 кафе: 12 бр.
/prices -> 404 няма такъв адрес
Node вика функцията на сървъра за всяка заявка. За /prices тя връща 404, а fetch го дава като обикновен отговор. В истински проекти за уеб сървър се ползват библиотеки като Express. Те са построени върху същия node:http.

Упражнение

Сървъри на localhost

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

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

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

2. Сървър с команди. ГЛАВНИ текст връща текста с главни букви, ЧАС връща текущия час като 14:05 (new Date().toTimeString().slice(0, 5)), а всичко друго връща „Непозната команда: “ и командата. Часът се мени, затова клиентът проверява само вида му с регулярен израз:

Изход
ПОРЪЧКА 42 Е ГОТОВА
Часът има вид ЧЧ:ММ: true
Непозната команда: ВРЕМЕ
3. Чат за двама. Ана отваря сървъра на порт 5107, Борис се свързва. Всеки е async функция, двете са пуснати заедно с Promise.all, и всеки праща готовите си реплики, като след всяка чака отговор (await once(server, "connection") чака първия клиент):
Изход
Борис получи: Здрасти, Борис! Свободен ли си в 18:00?
Ана получи: Да, ще дойда.
Борис получи: Супер, до скоро.
Ана получи: Чао!
После го направи истински: две програми. Всяка чете редове от клавиатурата (readline с input: process.stdin) и ги праща, а редовете от сокета печата, щом дойдат. Втора нишка не трябва: и двете идват като събития в цикъла на събитията. Така можеш да пишеш, докато чакаш отговор.

Тест

Провери се

1. Какво посочва портът?
2. Кога UDP е по-добрият избор от TCP?
3. Какво прави net.createServer(serve)?
4. Клиентът праща socket.write("кафе"), а сървърът чете редове. Двамата чакат. Защо?
5. response.status от fetch е 404. Какво означава?

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