Програми в мрежата
Проблем
Касата и складът
Касата в магазина трябва да пита склада колко кафе е останало, а складовата програма работи на друг компютър. Файл не помага: двете програми не виждат дисковете си. Трябва едната да попита, а другата да отговори, веднага, по мрежата.
Така работи почти всичко около теб: браузърът пита сървъра за страница, приложението на банката пита банката за баланса, играта праща ходовете ти на другите играчи.
Идеята
Адрес, порт и два начина за пращане
За да стигнат данните до програма на друг компютър, трябват две неща:
- IP адрес: номерът на компютъра в мрежата, например
192.168.1.20или142.250.185.78. Собственият ти компютър винаги е127.0.0.1, с имеlocalhost. - Порт: номер от 0 до 65 535 за програмата на този компютър. Адресът е сградата, портът е апартаментът. HTTPS ползва 443, а ние ще ползваме 5101 и нагоре.
Програмата, която чака да я потърсят, е сървър. Тази, която търси, е клиент. Как пътуват данните, решава протоколът:
| TCP | UDP | |
|---|---|---|
| Връзка | първо се свързват, после си говорят | без връзка, всеки пакет е отделно писмо |
| Доставка | всичко стига, в същия ред, или разбираш, че не е | пакетът може да се загуби или да дойде в друг ред |
| Скорост | по-бавен | по-бърз |
| За какво | уеб, имейл, плащания, файлове | глас и видео на живо, игри, сензори |
По-точно, за любопитните
- Мрежата се описва на слоеве. Моделът 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();Стъпка по стъпка, с думи
- Сървърът отваря порт 5101 и започва да слуша.
- В своя нишка сървърът вика accept() и чака клиент.
- Клиентът се свързва. accept() връща Socket и двете страни имат по два потока: за писане и за четене.
- Клиентът пише ред: „кафе“. Той минава по връзката до потока за четене на сървъра.
- Клиентът чака на readLine(). Сървърът прочита реда и го печата.
- Сървърът намира цената и пише обратно: „кафе: 7.5 €“.
- readLine() на клиента връща отговора и той го печата.
- try затваря сокета, а server.close() затваря порта.
По-точно, за любопитните
- Цялата програма, с
import-ите:
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() не се вика и другите чакат. Затова истинският сървър прави безкраен цикъл: приема клиент, дава го на нова нишка и веднага се връща към 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)).
Пробвай
Двамата чакат
Клиентът праща „кафе“ и чака отговор. Сървърът е същият, но програмата увисва и не печата нищо. На кой ред е грешката?
Избери реда с грешката.
PrintWriter без true не изпраща веднага: пази текста в буфер, докато не се събере повече. println("кафе") остава в паметта на клиента, сървърът чака ред, а клиентът чака отговор. Поправката е new PrintWriter(socket.getOutputStream(), true) или out.flush() след println.Идеята
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 DomainHttpRequest.newBuilder(URI)описва заявката, по подразбиранеGET.sendпраща заявката и чака отговора.BodyHandlers.ofString()казва „дай ми тялото като текст“.- Статусът казва как е минало:
200е успех,404няма такава страница,500е грешка на сървъра.
Проверката на кода на сайта е без интернет, затова тази програма е показана с изхода от пускане с мрежа. Без мрежа send хвърля IOException.
По-точно, за любопитните
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Непозната команда: ВРЕМЕБорис получи: Здрасти, Борис! Свободен ли си в 18:00?Ана получи: Да, ще дойда.Борис получи: Супер, до скоро.Ана получи: Чао!Тест