Знакомство с HTTP через простейший веб-сервер на Java
Впервые опубликовано на Хабре 21 февраля 2019; здесь переработанная и обновлённая версия.
Когда я начал преподавать Java, понадобился способ объяснить HTTP так, чтобы запомнилось. Слайды не помогли. Помог сервер: открыть сокет, прочитать, что присылает браузер, вручную написать ответ и увидеть, как появляется страница. Ничто не объясняет протокол лучше, чем его самая маленькая работающая реализация.
Этот сервер я опубликовал на Хабре в 2019 году. В браузере он работал, но был неправ в нескольких местах, и каждое стоит отдельного урока. Ниже исправленная версия для Java 21 и что именно изменилось.
Два файла: TinyHttpServer.java на голом сокете и JdkServer.java на встроенном сервере. Запуск — java TinyHttpServer.java, затем откройте http://localhost:8080/.
Что присылает браузер
Наберите в адресной строке http://localhost:8080/hello, и браузер откроет TCP-соединение и отправит обычный текст:
GET /hello HTTP/1.1
Host: localhost:8080
User-Agent: Mozilla/5.0 ...
Accept: text/html,application/xhtml+xml,...
Первая строка — строка запроса: метод, путь, версия протокола. Дальше заголовки, по одному Имя: значение на строку. Пустая строка заканчивает голову запроса. Если есть тело (отправленная форма, JSON), заголовок Content-Length говорит, сколько байт идёт после пустой строки.
Строки заканчиваются на \r\n, а не просто \n: HTTP унаследовал это от более старых текстовых протоколов.
Что отвечает сервер
У ответа та же форма: строка статуса, заголовки, пустая строка, тело.
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 62
Connection: close
<!doctype html><p>Привет всем! Hello, everyone!</p>
Content-Type говорит браузеру, как показать тело и в какой оно кодировке. Content-Length — где тело кончается. Connection: close — что после ответа сервер закроет соединение.
Что было не так в 2019 году
Старый сервер читал запрос так:
while (!input.ready()) ; // ждём первую строку
while (input.ready()) { // читаем «всё, что пришло»
System.out.println(input.readLine());
}
В этих четырёх строках три проблемы.
- Активное ожидание.
while (!input.ready()) ;крутит ядро процессора на 100%, пока не придут первые байты. - **
ready()не значит «запрос пришёл целиком».** Это лишь значит, что какие-то байты уже в буфере. Запрос, разбитый на два TCP-пакета, прочитается наполовину, и сервер ответит слишком рано. HTTP сам говорит, где кончается голова запроса: на пустой строке. Читать надо до неё, а не до момента, когда буфер случайно опустел. - **В ответе нет
Content-Length.** Браузер находил конец страницы только потому, что сервер закрывал соединение. А keep-alive, режим по умолчанию в HTTP/1.1, предполагает обратное.
Кроме того, сервер обслуживал одного клиента за раз и писал концы строк через println, то есть \n в Linux вместо \r\n.
Исправленный сервер
Каждое соединение обрабатывается в своём виртуальном потоке. Они дешёвые, и блокирующий код остаётся простым:
try (ServerSocket server = new ServerSocket(8080);
var pool = Executors.newVirtualThreadPerTaskExecutor()) {
while (true) {
Socket socket = server.accept();
pool.submit(() -> handle(socket));
}
}
handle идёт по протоколу шаг за шагом:
// 1. Строка запроса: МЕТОД ПУТЬ ВЕРСИЯ
String requestLine = in.readLine();
if (requestLine == null || requestLine.isBlank()) return;
String[] parts = requestLine.split(" ");
if (parts.length != 3) {
send(out, 400, "Bad Request", "text/plain", "Bad request line\n");
return;
}
String method = parts[0], path = parts[1];
// 2. Заголовки, по одному на строку, до пустой строки
String line;
int contentLength = 0;
while ((line = in.readLine()) != null && !line.isEmpty()) {
int colon = line.indexOf(':');
if (colon > 0 && line.substring(0, colon).equalsIgnoreCase("Content-Length")) {
contentLength = Integer.parseInt(line.substring(colon + 1).trim());
}
}
// 3. Тело — только если о нём сказал Content-Length (POST, PUT...)
char[] body = new char[contentLength];
int read = 0;
while (read < contentLength) {
int n = in.read(body, read, contentLength - read);
if (n < 0) break;
read += n;
}
Имена заголовков не зависят от регистра, отсюда equalsIgnoreCase. Тело читается в цикле, потому что один read может вернуть меньше символов, чем просили.
В ответе Content-Length считается по байтам тела. «Привет» — 6 символов, но 12 байт в UTF-8, а браузер считает байты:
private static void send(OutputStream out, int status, String reason, String type, String body) throws IOException {
byte[] bytes = body.getBytes(StandardCharsets.UTF_8);
String head = "HTTP/1.1 " + status + " " + reason + "\r\n"
+ "Content-Type: " + type + "\r\n"
+ "Content-Length: " + bytes.length + "\r\n"
+ "Connection: close\r\n"
+ "\r\n";
out.write(head.getBytes(StandardCharsets.US_ASCII));
out.write(bytes);
out.flush();
}
Маршрутизация — обычный if: GET / отдаёт страницу, POST /echo возвращает тело запроса, на всё остальное — 404 Not Found.
Проверяем через curl
curl -i печатает строку статуса и заголовки, а ради них всё и затевалось:
$ curl -i http://localhost:8080/
HTTP/1.1 200 OK
Content-Type: text/html; charset=utf-8
Content-Length: 62
Connection: close
<!doctype html><p>Привет всем! Hello, everyone!</p>
$ curl -X POST --data 'ping=1' http://localhost:8080/echo
ping=1
$ curl -s -o /dev/null -w '%{http_code}\n' http://localhost:8080/nope
404
В логе сервера видно, что прислал curl, включая Content-Length: 6 и Content-Type: application/x-www-form-urlencoded у POST. Пять параллельных запросов получают 200, каждый в своём виртуальном потоке. Соединение, которое вместо строки запроса присылает garbage, получает 400 Bad Request.
Чего этот сервер всё ещё не умеет
Это учебный сервер, и его ограничения — тоже уроки.
- Голову он читает символьным потоком в ASCII и считает тело в символах. Настоящий сервер читает байты:
Content-Lengthсчитает байты, а тело может быть в UTF-8 или вообще двоичным. - Он верит
Content-Length. Клиент, объявивший два гигабайта, заставит его их выделить. Настоящие серверы ограничивают размер головы и тела. - Нет keep-alive, chunked-передачи, тайм-аутов и HTTPS.
То же на сервере из JDK
В JDK есть небольшой HTTP-сервер ещё с Java 6, в пакете com.sun.net.httpserver. Он сам разбирает запросы, следит за Content-Length и keep-alive-соединениями (для HTTPS есть отдельный класс HttpsServer), а с исполнителем на виртуальных потоках масштабируется так же:
HttpServer server = HttpServer.create(new InetSocketAddress(8081), 0);
server.createContext("/", exchange -> {
byte[] body = "<!doctype html><p>Hello from com.sun.net.httpserver</p>\n".getBytes(StandardCharsets.UTF_8);
exchange.getResponseHeaders().set("Content-Type", "text/html; charset=utf-8");
exchange.sendResponseHeaders(200, body.length);
try (var out = exchange.getResponseBody()) {
out.write(body);
}
});
server.setExecutor(Executors.newVirtualThreadPerTaskExecutor());
server.start();
sendResponseHeaders принимает длину тела в байтах — тот же урок про Content-Length, только в виде API. А чтобы быстро раздать папку с файлами, начиная с Java 18 есть jwebserver — статический файловый сервер из командной строки.
Итоги
- HTTP/1.1 — это текст: строка запроса, заголовки, пустая строка и необязательное тело длиной
Content-Lengthбайт. У ответа та же форма. - Читать нужно до пустой строки, а не «пока буфер не опустеет».
Content-Lengthсчитается в байтах.- Виртуальные потоки позволяют серверу «поток на соединение» оставаться простым и не упираться в число потоков.
Написать такой сервер — по-прежнему лучший первый урок о вебе, который я знаю. После него GET, POST, заголовки и коды ответа перестают быть заклинаниями.