usharik.dev

← Все статьи

Знакомство с 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());
}

В этих четырёх строках три проблемы.

  1. Активное ожидание. while (!input.ready()) ; крутит ядро процессора на 100%, пока не придут первые байты.
  2. **ready() не значит «запрос пришёл целиком».** Это лишь значит, что какие-то байты уже в буфере. Запрос, разбитый на два TCP-пакета, прочитается наполовину, и сервер ответит слишком рано. HTTP сам говорит, где кончается голова запроса: на пустой строке. Читать надо до неё, а не до момента, когда буфер случайно опустел.
  3. **В ответе нет 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.

Чего этот сервер всё ещё не умеет

Это учебный сервер, и его ограничения — тоже уроки.

То же на сервере из 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 — статический файловый сервер из командной строки.

Итоги

Написать такой сервер — по-прежнему лучший первый урок о вебе, который я знаю. После него GET, POST, заголовки и коды ответа перестают быть заклинаниями.

Исходные файлы