Создание HTTP сервера java
Java имеет огромную экосистему HTTP-серверов и фреймворков, которые находятся на вершине.
Spring — это популярная платформа, которая позволяет программировать с использованием ее API и легко настраивать используемый базовый контейнер сервлетов (Tomcat, Jetty, Undertow).
Другие платформы, такие как Vertx и Play! построены на основе Netty — низкоуровневой асинхронной сетевой среды, управляемой событиями.
Существует также среда Java EE, включающая серверы приложений JBoss, Wildfly и Weblogic.
Когда вы создаете RESTFul-сервис с помощью одной из этих платформ, вы работаете с API и не слишком задумываетесь о том, что происходит под капотом.
В этом сообщении блога мы рассмотрим, как можно создать функциональный API поверх базового HTTP-сервера, просто используя сокеты в Java.
В частности, мы будем:
- использовать Sockets API для обработки HTTP-запросов
- реализовать очень простой пример декодирования HTTP-запроса GET
- предоставить функциональный API, позволяющий конечному пользователю определять маршруты и встраивать сервер в свое приложение.
Надеюсь, это поможет вам лучше понять, что может происходить за кулисами.
Цель здесь не в том, чтобы реализовать полную спецификацию HTTP-сервера с полноценным API поверх него — я даже не уверен, что Socket API достаточно низкого уровня для реализации полной спецификации, например. мы просто рассмотрим запрос GET в качестве учебного упражнения.
Создаем API:
Если у нас есть приложение Java Gradle с файлом build.gradle.kts, мы можем просто включить зависимость от нового серверного проекта, который мы собираемся создать, под названием «functional-server-library».
implementation(project(":functional-server-library"))
Наше основное приложение может затем использовать эту библиотеку, поскольку она предоставляет класс сервера.
Класс сервера будет:
- возьмите номер порта, на котором он должен прослушивать входящие запросы
- позволяет добавлять маршруты с помощью метода addRoute
- начать прослушивать запросы при вызове метода start
Поэтому конечное приложение будет выглядеть примерно так:
public class App {
public static void main(String[] args) throws IOException {
Server myServer = new Server(8080);
myServer.addRoute(GET, "/testOne",
(req) -> new HttpResponse.Builder()
.setStatusCode(200)
.addHeader("Content-Type", "text/html")
.setEntity("<HTML> <P> Hello There... </P> </HTML>")
.build());
myServer.start();
}
}
HTTP-протокол
Прежде чем мы углубимся, давайте на секунду вспомним формат сообщений протокола HTTP.
Формат HTTP-запроса является текстовым и может быть разбит на следующие части:
Первая строка состоит из метода HTTP, URI и версии протокола:

Далее следуют:
- несколько строк заголовков
- 1 пустая строка
- необязательное тело запроса для запросов POST/PUT.
Например.
Сообщение запроса HTTP GET:
GET /testOne HTTP/1.1
Host: 127.0.0.1:8080
User-Agent: curl/7.84.0
Accept: */*
Сообщение запроса HTTP POST:
POST /testOne HTTP/1.1
Host: localhost:8080
User-Agent: insomnia/2022.6.0
Content-Type: text/plain
Accept: */*
Content-Length: 11
hello there
HTTP-ответ очень похож:
Первая строка запроса

Общий:
STATUS LINE
HEADER
HEADER
...
BODY
Например.
HTTP/1.1 200 OK
Content-Type: text/plain; charset=UTF-8
Connection: Keep-Alive
Hello there...
Примечание о версиях HTTP:
Одной из функций, представленных в HTTP 1.1, были постоянные соединения, которые позволяют одному соединению обрабатывать несколько HTTP-запросов. Это более эффективно, поскольку устраняет необходимость в многократном подтверждении TCP.
Эту функцию можно включить, установив для соединения HTTP-заголовка значение Keep-Alive.
Ниже мы собираемся пропустить эту функциональность (а также многие другие части, относящиеся к спецификации HTTP).

Сервер можно разбить на следующие события:
- Сервер запускается и прослушивает соединения на определенном порту.
- При получении соединения используйте HttpHandler для обработки соединения (передаются входные и выходные потоки).
- Декодирование сообщения с помощью HttpDecoder.
- Маршрут сообщения для исправления конечной точки.
- Запись. ответ с использованием HttpWriter
Разобравшись с этим, приступим к строительству!
public class Server {
private final Map<String, RequestRunner> routes;
private final ServerSocket socket;
private final Executor threadPool;
private HttpHandler handler;
public Server(int port) throws IOException {
routes = new HashMap<>();
threadPool = Executors.newFixedThreadPool(100);
socket = new ServerSocket(port);
}
Созданный threadPool жестко запрограммирован на уровне 100, поэтому это ограничит количество запросов, которые можно будет обрабатывать одновременно (я произвольно выбрал 100, в идеале это должно быть настроено конечным пользователем).
Добавление маршрутов:
Давайте добавим в приложение метод для добавления маршрутов/конечных точек. Для этого мы сохраним маршрут на карте Map
public void addRoute(HttpMethod opCode, String route, RequestRunner runner) {
routes.put(opCode.name().concat(route), runner);
}
Ключ карты будет представлять собой комбинацию кода операции запроса (в нашем случае GET) и URI.
RequestRunner — это интерфейс Java, который имеет только один метод, который принимает HttpResponse в качестве аргумента и возвращает объект HttpRequest.
Это позволяет ему быть функциональным интерфейсом, который мы можем предоставить с помощью Lambda.
Например.
public interface RequestRunner {
HttpResponse run(HttpRequest request);
}
HttpMethod — это простое перечисление:
public enum HttpMethod {
GET,
PUT,
POST,
PATCH
}
Запускаем сервер:
Теперь мы добавим метод start, который будет ждать входящие запросы и обрабатывать их:
public void start() throws IOException {
handler = new HttpHandler(routes);
while (true) {
Socket clientConnection = socket.accept();
handleConnection(clientConnection);
}
}
Socket.accept() блокируется, поэтому handleConnection будет вызываться только тогда, когда клиент подключается к определенному порту.
Мы будем использовать объект HttpHandler, созданный для обработки соединения.
private void handleConnection(Socket clientConnection) {
try {
handler.handleConnection(clientConnection.getInputStream(), clientConnection.getOutputStream());
} catch (IOException ignored) {
}
}
Однако мы не хотим, чтобы этот 1 запрос блокировал выполнение всех остальных запросов, поэтому мы обернем эту функциональность в Runnable.
Таким образом, жизненный цикл запроса/ответа каждого запроса будет обрабатываться одним потоком (синхронным сервером).
Если бы мы хотели иметь возможность обрабатывать один запрос в нескольких потоках, нам нужно было бы использовать библиотеку нижнего уровня Java NIO (что означает новый ввод-вывод).
Таким образом, вышеизложенное становится:
/*
* Записывайте каждый жизненный цикл запроса/ответа в потоке,
* выполняемом в пуле потоков.
*/
private void handleConnection(Socket clientConnection) {
Runnable httpRequestRunner = () -> {
try {
handler.handleConnection(clientConnection.getInputStream(), clientConnection.getOutputStream());
} catch (IOException ignored) {
}
};
threadPool.execute(httpRequestRunner);
}
2. HttpHandler
- Декодировать HTTP-запрос
- Направьте запрос к правильному RequestRunner
- Записать ответ в выходной поток
/**
* Обработка жизненного цикла ответа на HTTP-запрос.
*/
public class HttpHandler {
private final Map<String, RequestRunner> routes;
public HttpHandler(final Map<String, RequestRunner> routes) {
this.routes = routes;
}
Обработка запроса:
public void handleConnection(final InputStream inputStream, final OutputStream outputStream) throws IOException {
final BufferedWriter bufferedWriter = new BufferedWriter(new OutputStreamWriter(outputStream));
Optional<HttpRequest> request = HttpDecoder.decode(inputStream);
request.ifPresentOrElse((r) -> handleRequest(r, bufferedWriter), () -> handleInvalidRequest(bufferedWriter));
bufferedWriter.close();
inputStream.close();
}
Мы украшаем наш OutputStream BufferedWriter, который позволяет нам записывать текст в поток вывода символов.
Шаги:
- Декодируйте сообщение в объект HttpRequest.
- Если он присутствует, обработайте
- Else, обработайте недопустимый запрос.
- Закройте выходные и входные потоки.
Сценарий недопустимого запроса:
Создайте объект HTTP-ответа с кодом состояния 400 и сообщением «Неверный запрос».
private void handleInvalidRequest(final BufferedWriter bufferedWriter) {
HttpResponse notFoundResponse = new HttpResponse.Builder().setStatusCode(400).setEntity("Bad Request...").build();
ResponseWriter.writeResponse(bufferedWriter, notFoundResponse);
}
Действительный сценарий запроса:
Получаем ключ маршрута из объекта HttpRequest (uri path), если он есть, извлекаем RequestRunner из Карты маршрутов, выполняем и записываем ответ.
В противном случае мы создаем не найденный ответ с кодом состояния 404.
private void handleRequest(final HttpRequest request, final BufferedWriter bufferedWriter) {
final String routeKey = request.getHttpMethod().name().concat(request.getUri().getRawPath());
if (routes.containsKey(routeKey)) {
ResponseWriter.writeResponse(bufferedWriter, routes.get(routeKey).run(request));
} else {
// Not found
ResponseWriter.writeResponse(bufferedWriter, new HttpResponse.Builder().setStatusCode(404).setEntity("Route Not Found...").build());
}
}
Читаем сообщение:
Если во входном потоке нет данных, мы возвращаем пустой необязательный параметр, в противном случае данные считываются в созданный буфер char[].
Затем объект Scanner используется для чтения буфера построчно и добавления каждого из них в возвращаемый список массивов.
Если возникает какое-либо исключение, возвращается пустой необязательный параметр.
private static Optional<List<String>> readMessage(final InputStream inputStream) {
try {
if (!(inputStream.available() > 0)) {
return Optional.empty();
}
final char[] inBuffer = new char[inputStream.available()];
final InputStreamReader inReader = new InputStreamReader(inputStream);
final int read = inReader.read(inBuffer);
List<String> message = new ArrayList<>();
try (Scanner sc = new Scanner(new String(inBuffer))) {
while (sc.hasNextLine()) {
String line = sc.nextLine();
message.add(line);
}
}
return Optional.of(message);
} catch (Exception ignored) {
return Optional.empty();
}
}
Создание HTTP-запроса:
Этот метод выполняет простой анализ, чтобы определить, является ли сообщение действительным HTTP-запросом.
Если сообщение пустое или первая строка не содержит трех слов, возвращается пустой необязательный параметр.
Первая строка проверяется:
- Использование Enum HttpMethod для извлечения метода HTTP для первого слова
- Использование класса java.net.URI для проверки компонента URI.
- Сравнение третьего слова (Протокол) с «HTTP/1.1»
Если ни одно из этих условий не выполнено (Исключения обнаружены), возвращается пустой необязательный параметр.
Окончательный анализ заголовков запроса (помните, что мы имеем дело только со сценарием запроса GET без тела) выполняется в методе addRequestHeaders.
private static Optional<HttpRequest> buildRequest(List<String> message) {
if (message.isEmpty()) {
return Optional.empty();
}
String firstLine = message.get(0);
String[] httpInfo = firstLine.split(" ");
if (httpInfo.length != 3) {
return Optional.empty();
}
String protocolVersion = httpInfo[2];
if (!protocolVersion.equals("HTTP/1.1")) {
return Optional.empty();
}
try {
Builder requestBuilder = new Builder();
requestBuilder.setHttpMethod(HttpMethod.valueOf(httpInfo[0]));
requestBuilder.setUri(new URI(httpInfo[1]));
return Optional.of(addRequestHeaders(message, requestBuilder));
} catch (URISyntaxException | IllegalArgumentException e) {
return Optional.empty();
}
}
public enum HttpMethod {
GET,
PUT,
POST,
PATCH
}
Разбор заголовков запроса:
Мы пропускаем первую строку сообщения, как уже читали выше, для каждой оставшейся строки мы проверяем наличие символа двоеточия «:» и просто читаем то, что находится до, как имя заголовка, а то, что следует после, как значение заголовка.
В конце метода мы добавляем новый объект Map>, созданный как заголовки запроса с использованием переданного объекта Builder.
private static HttpRequest addRequestHeaders(final List<String> message, final Builder builder) {
final Map<String, List<String>> requestHeaders = new HashMap<>();
if (message.size() > 1) {
for (int i = 1; i < message.size(); i++) {
String header = message.get(i);
int colonIndex = header.indexOf(':');
if (! (colonIndex > 0 && header.length() > colonIndex + 1)) {
break;
}
String headerName = header.substring(0, colonIndex);
String headerValue = header.substring(colonIndex + 1);
requestHeaders.compute(headerName, (key, values) -> {
if (values != null) {
values.add(headerValue);
} else {
values = new ArrayList<>();
}
return values;
});
}
}
builder.setRequestHeaders(requestHeaders);
return builder.build();
}
теперь мы декодировали наш входной поток в объект HttpRequest.
- Писатель ответов (ResponseWriter)
Возвращаясь к нашему HttpHandler, мы видим, что и handleRequest, и handleInvalidRequest используют статический метод writeResponse внутри класса ResponseWriter.
Это используется для записи объекта HttpResponse в выходной поток.
В этом примере мы просто пишем тело как text/plain, но на самом деле этот класс может использовать разные реализации для написания тела в зависимости от типа записываемых данных (JSON/XML и т. д.).
Шаги средства записи:
- Извлечение кода состояния.
- Извлечение описания кода состояния.
- Создание строк заголовка (например, «Content-Length: 50»).
- Запись первой строки (протокол, код состояния, описание кода состояния).
- Запись заголовков запроса в следующих строках.
- Запись пустой строки.
- Запись тела (если присутствует). )
/**
* Write a HTTPResponse to an outputstream
* @param outputStream - the outputstream
* @param response - the HTTPResponse
*/
public static void writeResponse(final BufferedWriter outputStream, final HttpResponse response) {
try {
final int statusCode = response.getStatusCode();
final String statusCodeMeaning = HttpStatusCode.STATUS_CODES.get(statusCode);
final List<String> responseHeaders = buildHeaderStrings(response.getResponseHeaders());
outputStream.write("HTTP/1.1 " + statusCode + " " + statusCodeMeaning + "\r\n");
for (String header : responseHeaders) {
outputStream.write(header);
}
final Optional<String> entityString = response.getEntity().flatMap(ResponseWriter::getResponseString);
if (entityString.isPresent()) {
final String encodedString = new String(entityString.get().getBytes(StandardCharsets.UTF_8), StandardCharsets.UTF_8);
outputStream.write("Content-Length: " + encodedString.getBytes().length + "\r\n");
outputStream.write("\r\n");
outputStream.write(encodedString);
} else {
outputStream.write("\r\n");
}
} catch (Exception ignored) {
}
}
Извлечение строк заголовка:
private static List<String> buildHeaderStrings(final Map<String, List<String>> responseHeaders) {
final List<String> responseHeadersList = new ArrayList<>();
responseHeaders.forEach((name, values) -> {
final StringBuilder valuesCombined = new StringBuilder();
values.forEach(valuesCombined::append);
valuesCombined.append(";");
responseHeadersList.add(name + ": " + valuesCombined + "\r\n");
});
return responseHeadersList;
}
Преобразуйте объект тела ответа в строку:
private static Optional<String> getResponseString(final Object entity) {
// Currently only supporting Strings
if (entity instanceof String) {
try {
return Optional.of(entity.toString());
} catch (Exception ignored) {
}
}
return Optional.empty();
}
В завершение давайте быстро рассмотрим Pojo HttpRequest/HttpResponse и протестируем наше приложение:
HttpRequest:
package Sockets.pojos;
import Sockets.contract.HttpMethod;
import java.net.URI;
import java.util.List;
import java.util.Map;
public class HttpRequest {
private final HttpMethod httpMethod;
private final URI uri;
private final Map<String, List<String>> requestHeaders;
private HttpRequest(HttpMethod opCode,
URI uri,
Map<String, List<String>> requestHeaders) {
this.httpMethod = opCode;
this.uri = uri;
this.requestHeaders = requestHeaders;
}
public URI getUri() {
return uri;
}
public HttpMethod getHttpMethod() {
return httpMethod;
}
public Map<String, List<String>> getRequestHeaders() {
return requestHeaders;
}
public static class Builder {
private HttpMethod httpMethod;
private URI uri;
private Map<String, List<String>> requestHeaders;
public Builder() {
}
public void setHttpMethod(HttpMethod httpMethod) {
this.httpMethod = httpMethod;
}
public void setUri(URI uri) {
this.uri = uri;
}
public void setRequestHeaders(Map<String, List<String>> requestHeaders) {
this.requestHeaders = requestHeaders;
}
public HttpRequest build() {
return new HttpRequest(httpMethod, uri, requestHeaders);
}
}
}
HttpResponse:
package Sockets.pojos;
import java.time.ZoneOffset;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Optional;
public class HttpResponse {
private final Map<String, List<String>> responseHeaders;
private final int statusCode;
private final Optional<Object> entity;
/**
* Headers should contain the following:
* Date: < date >
* Server: < my server >
* Content-Type: text/plain, application/json etc...
* Content-Length: size of payload
*/
private HttpResponse(final Map<String, List<String>> responseHeaders, final int statusCode, final Optional<Object> entity) {
this.responseHeaders = responseHeaders;
this.statusCode = statusCode;
this.entity = entity;
}
public Map<String, List<String>> getResponseHeaders() {
return responseHeaders;
}
public int getStatusCode() {
return statusCode;
}
public Optional<Object> getEntity() {
return entity;
}
public static class Builder {
private final Map<String, List<String>> responseHeaders;
private int statusCode;
private Optional<Object> entity;
public Builder() {
// Create default headers - server etc
responseHeaders = new HashMap<>();
responseHeaders.put("Server", List.of("MyServer"));
responseHeaders.put("Date", List.of(DateTimeFormatter.RFC_1123_DATE_TIME.format(ZonedDateTime.now(ZoneOffset.UTC))));
entity = Optional.empty();
}
public Builder setStatusCode(final int statusCode) {
this.statusCode = statusCode;
return this;
}
public Builder addHeader(final String name, final String value) {
responseHeaders.put(name, List.of(value));
return this;
}
public Builder setEntity(final Object entity) {
if (entity != null) {
this.entity = Optional.of(entity);
}
return this;
}
public HttpResponse build() {
return new HttpResponse(responseHeaders, statusCode, entity);
}
}
Тестирование!
Если мы загрузим наше приложение и сгенерируем HTTP-запрос GET к URI /testOne, мы должны увидеть следующий ответ:
➜ sockets git:(master) ✗ curl http://127.0.0.1:8080/testOne
<HTML> <P> Hello There... </P> </HTML>%
К URI не определен:
➜ sockets git:(master) ✗ curl http://127.0.0.1:8080/testTwo
Route Not Found...%
и, наконец, неверный HTTP-запрос:
➜ sockets git:(master) ✗ echo hi | nc 127.0.0.1 8080
HTTP/1.1 400 BAD_REQUEST
Server: MyServer;
Date: Mon, 9 Jan 2023 22:17:20 GMT;
Content-Length: 18
Invalid Request...%
Это лишь поверхностное представление о создании HTTP-сервера, но, надеюсь, в качестве учебного упражнения оно окажется полезным для лучшего понимания некоторых возможных шагов, происходящих за кулисами.
Весь упомянутый код можно найти здесь: https://github.com/AlexanderPanshin/SimpleJavaWebServer