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

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

Проблем

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

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

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

Идеята

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

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

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

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

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

Виж го

Сървърът

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

static final Map<String, Double> PRICES = Map.of("кафе", 7.50, "чай", 3.20);
static void serve(ServerSocket server) {
try (Socket client = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
String product = in.readLine();
System.out.println("Сървърът получи: " + product);
Double price = PRICES.get(product);
out.println(price == null ? "няма такъв продукт" : product + ": " + price + " €");
} catch (IOException e) {
System.out.println("Сървърът: " + e.getMessage());
}
}
  • ServerSocket слуша на порт. server.accept() чака, докато се свърже клиент, и връща Socket за връзката с него.
  • От сокета има два потока: getInputStream() за това, което идва, и getOutputStream() за това, което пращаш. Обвиваме ги, както при файловете: BufferedReader чете редове, PrintWriter пише редове.
  • true в PrintWriter значи „изпращай след всеки println“. Ще видим защо е важно.
  • try-with-resources затваря връзката и потоците накрая.

Виж го

Клиентът

Клиентът се свързва с new Socket(адрес, порт) и ползва същите два потока. Тук сървърът и клиентът са в една програма: сървърът работи в своя нишка, а main е клиентът. Така можеш да ги пробваш без втори компютър:
ServerSocket server = new ServerSocket(5101);
new Thread(() -> serve(server)).start();
try (Socket socket = new Socket("localhost", 5101);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
out.println("кафе");
System.out.println("Клиентът получи: " + in.readLine());
}
server.close();
Изход
Сървърът получи: кафе
Клиентът получи: кафе: 7.5 €
Портът се отваря с new ServerSocket(5101) в main, преди нишката да тръгне. Така, когато клиентът се свърже, сървърът вече слуша.

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

ServerSocket server = new ServerSocket(5101);
new Thread(() -> serve(server)).start();
try (Socket socket = new Socket("localhost", 5101);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
out.println("кафе");
System.out.println("Клиентът получи: " + in.readLine());
}
server.close();
Стъпка по стъпка, с думи
  1. Сървърът отваря порт 5101 и започва да слуша.
  2. В своя нишка сървърът вика accept() и чака клиент.
  3. Клиентът се свързва. accept() връща Socket и двете страни имат по два потока: за писане и за четене.
  4. Клиентът пише ред: „кафе“. Той минава по връзката до потока за четене на сървъра.
  5. Клиентът чака на readLine(). Сървърът прочита реда и го печата.
  6. Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
  7. readLine() на клиента връща отговора и той го печата.
  8. try затваря сокета, а server.close() затваря порта.
По-точно, за любопитните
  • Цялата програма, с import-ите:
Main.java
import java.io.*;
import java.net.*;
import java.util.Map;
public class Main {
static final Map<String, Double> PRICES = Map.of("кафе", 7.50, "чай", 3.20);
static void serve(ServerSocket server) {
try (Socket client = server.accept();
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
String product = in.readLine();
System.out.println("Сървърът получи: " + product);
Double price = PRICES.get(product);
out.println(price == null ? "няма такъв продукт" : product + ": " + price + " €");
} catch (IOException e) {
System.out.println("Сървърът: " + e.getMessage());
}
}
public static void main(String[] args) throws IOException {
ServerSocket server = new ServerSocket(5101);
new Thread(() -> serve(server)).start();
try (Socket socket = new Socket("localhost", 5101);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
out.println("кафе");
System.out.println("Клиентът получи: " + in.readLine());
}
server.close();
}
}
  • В два отделни файла и два терминала е същото: пускаш първо сървъра, после клиента. Ако на порта няма сървър, new Socket хвърля ConnectException: Connection refused. Ако портът вече е зает от друга програма, new ServerSocket хвърля BindException: Address already in use.
  • Текстът минава по мрежата като байтове. Java 18 и по-новите превръщат текста в UTF-8 по подразбиране, затова кирилицата пристига цяла.

Пробвай

Чакай клиент

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

ServerSocket server = new ServerSocket(5101);
Socket client = server.();

Идеята

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

Докато сървърът говори с един клиент, accept() не се вика и другите чакат. Затова истинският сървър прави безкраен цикъл: приема клиент, дава го на нова нишка и веднага се връща към accept():
static void acceptAll(ServerSocket server) {
int number = 0;
while (true) {
try {
Socket client = server.accept();
number++;
int n = number;
new Thread(() -> handle(client, n)).start();
} catch (IOException e) {
return;
}
}
}
static void handle(Socket client, int number) {
try (client;
BufferedReader in = new BufferedReader(new InputStreamReader(client.getInputStream()));
PrintWriter out = new PrintWriter(client.getOutputStream(), true)) {
String line;
while ((line = in.readLine()) != null) {
System.out.println("Клиент " + number + ": " + line);
out.println("Ехо: " + line);
}
} catch (IOException e) {
System.out.println("Клиент " + number + " прекъсна: " + e.getMessage());
}
}
handle отговаря на всеки ред, докато клиентът не затвори връзката: тогава readLine() връща null. Два клиента един след друг:
ServerSocket server = new ServerSocket(5102);
Thread acceptor = new Thread(() -> acceptAll(server));
acceptor.setDaemon(true);
acceptor.start();
for (String order : new String[] { "маса 4: две кафета", "маса 7: сметката" }) {
try (Socket socket = new Socket("localhost", 5102);
PrintWriter out = new PrintWriter(socket.getOutputStream(), true);
BufferedReader in = new BufferedReader(new InputStreamReader(socket.getInputStream()))) {
out.println(order);
System.out.println(in.readLine());
}
}
server.close();
Изход
Клиент 1: маса 4: две кафета
Ехо: маса 4: две кафета
Клиент 2: маса 7: сметката
Ехо: маса 7: сметката
По-точно, за любопитните
  • setDaemon(true) прави нишката „фонова“: програмата свършва, когато свърши main, без да чака безкрайния цикъл. Истинският сървър не я прави фонова: той трябва да работи, докато не го спреш.
  • try (client; ...) слага вече създадения сокет в try-with-resources, за да се затвори накрая.
  • При хиляди клиенти нишка за всеки е скъпо. Тогава приемащият цикъл дава клиентите на басейн от нишки от предишния урок: pool.execute(() -> handle(client, n)).

Пробвай

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

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

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

Идеята

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

Сензорите в магазина пращат температурата всяка минута. Ако едно показание се загуби, следващото идва след минута, затова тук е удобен UDP. Няма връзка и няма потоци: пращаш DatagramPacket (байтове плюс адрес и порт) през DatagramSocket:
DatagramSocket receiver = new DatagramSocket(5103);
try (DatagramSocket sensor = new DatagramSocket()) {
InetAddress address = InetAddress.getByName("localhost");
for (String reading : new String[] { "кухня 21.5", "склад 4.0", "хладилник 3.2" }) {
byte[] data = reading.getBytes();
sensor.send(new DatagramPacket(data, data.length, address, 5103));
}
}
byte[] buffer = new byte[256];
for (int i = 0; i < 3; i++) {
DatagramPacket packet = new DatagramPacket(buffer, buffer.length);
receiver.receive(packet);
String text = new String(packet.getData(), 0, packet.getLength());
System.out.println("Температура: " + text + " °C");
}
receiver.close();
Изход
Температура: кухня 21.5 °C
Температура: склад 4.0 °C
Температура: хладилник 3.2 °C
  • Получателят слуша на порт 5103 с new DatagramSocket(5103). Изпращачът ползва new DatagramSocket() без порт: системата му дава свободен.
  • receive чака пакет и го пише в буфера. getLength() казва колко байта са дошли.
  • Никой не потвърждава, че пакетът е стигнал. На localhost не се губят, но по истинска мрежа може.
По-точно, за любопитните

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

Идеята

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

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

import java.net.URI;
import java.net.http.*;
HttpClient client = HttpClient.newHttpClient();
HttpRequest request = HttpRequest.newBuilder(URI.create("https://example.com")).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println("Статус: " + response.statusCode());
System.out.println("Тип: " + response.headers().firstValue("content-type").orElse("?"));
String body = response.body();
System.out.println("Заглавие: " + body.substring(body.indexOf("<title>") + 7, body.indexOf("</title>")));
Изход (с интернет)
Статус: 200
Тип: text/html; charset=utf-8
Заглавие: Example Domain
  • HttpRequest.newBuilder(URI) описва заявката, по подразбиране GET.
  • send праща заявката и чака отговора. BodyHandlers.ofString() казва „дай ми тялото като текст“.
  • Статусът казва как е минало: 200 е успех, 404 няма такава страница, 500 е грешка на сървъра.

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

По-точно, за любопитните
Сървър за HTTP има и в самата Java (com.sun.net.httpserver). Така можеш да пробваш HttpClient и без интернет, на localhost:
HttpServer server = HttpServer.create(new InetSocketAddress("localhost", 5104), 0);
server.createContext("/stock", exchange -> {
byte[] answer = "кафе: 12 бр.".getBytes();
exchange.sendResponseHeaders(200, answer.length);
exchange.getResponseBody().write(answer);
exchange.close();
});
server.start();
HttpClient client = HttpClient.newHttpClient();
for (String path : new String[] { "/stock", "/prices" }) {
HttpRequest request = HttpRequest.newBuilder(URI.create("http://localhost:5104" + path)).build();
HttpResponse<String> response = client.send(request, HttpResponse.BodyHandlers.ofString());
System.out.println(path + " -> " + response.statusCode() + " " + response.body());
}
server.stop(0);
Изход
/stock -> 200 кафе: 12 бр.
/prices -> 404 <h1>404 Not Found</h1>No context found for request
Адресът /prices няма обработчик, затова сървърът сам връща 404. В истински проекти за уеб сървър се ползват библиотеки като Spring Boot. Те са построени върху същите сокети и нишки.

Упражнение

Сървъри на localhost

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

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

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

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

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

Тест

Провери се

1. Какво посочва портът?
2. Кога UDP е по-добрият избор от TCP?
3. Какво прави server.accept()?
4. Клиентът пише с println, но сървърът не получава нищо, а двамата чакат. Каква е най-честата причина?
5. HttpResponse.statusCode() връща 404. Какво означава?

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