Веб-API без фреймворка на Java

Этот пост изначально был опубликован на Середина.

Экосистема Java заполнена фреймворками и библиотеками.
Конечно, не так много, как в мире JavaScript, и они не так быстро стареют, но все же этот факт заставляет меня думать, что мы уже забыли, как создавать полностью бескаркасные приложения.

Ты можешь сказать: Весна это стандарт, зачем заново изобретать велосипед. Искра хороший небольшой фреймворк REST.
Свет-отдых-4j это еще один.

Я говорю вам, конечно, вы правы. Вы получаете много наворотов с фреймворком, но вы также получаете много волшебства, накладных расходов на обучение, дополнительных функций, которые вы, скорее всего, не будете использовать, а также ошибок.

Чем больше внешнего кода в вашем сервисе, тем больше шансов, что его разработчики допустили какие-то ошибки.

Сообщество с открытым исходным кодом активно, и есть большая вероятность, что эти ошибки в фреймворке будут исправлены в ближайшее время, но все же я хотел бы призвать вас переосмыслить, действительно ли вам нужен фреймворк.

Если вы делаете небольшой сервис или консольное приложение, возможно, вы сможете обойтись без него.

Что вы можете получить (или потерять), придерживаясь чистого кода Java? Подумайте об этом:

  • ваш код может быть намного чище и предсказуемее (или полный беспорядок, если вы плохой кодер)
  • у вас будет больше контроля над вашим кодом, вы не будете ограничены фреймворком (но вам придется часто писать собственный код для того, что фреймворк дает вам из коробки)
  • ваше приложение будет развертываться и запускаться намного быстрее, потому что коду фреймворка не нужно инициализировать дюжину классов (или вообще не запускать, если вы что-то испортили, например многопоточность)
  • если вы развернете свое приложение в Docker, ваши образы могут быть намного тоньше, потому что ваша банка тоже будет тоньше.

Я провел небольшой эксперимент и попытался разработать REST API без фреймворка.

Я подумал, что это может быть интересно с точки зрения обучения и немного освежает.

Когда я начал это делать, я часто сталкивался с ситуациями, когда я пропускал некоторые функции, которые Spring предоставляет из коробки.

В то время, вместо того, чтобы включить другую возможность Spring, мне пришлось переосмыслить ее и разработать самостоятельно.

Оказалось, что для реального бизнес-кейса я, вероятно, все же предпочел бы использовать Spring вместо того, чтобы изобретать велосипед.

Тем не менее, я считаю, что упражнение было довольно интересным опытом.

Начало.

Я буду выполнять это упражнение шаг за шагом, но не всегда буду вставлять сюда полный код.
Вы всегда можете проверить каждый шаг из отдельной ветки [git repository] (

Создайте новый проект Maven с начальным pom.xml файл:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="
  xmlns:xsi="
  xsi:schemaLocation=" 
  <modelVersion>4.0.0</modelVersion>

  <groupId>com.consulner.httpserver</groupId>
  <artifactId>pure-java-rest-api</artifactId>
  <version>1.0-SNAPSHOT</version>

  <properties>
    <java.version>11</java.version>
    <maven.compiler.source>${java.version}</maven.compiler.source>
    <maven.compiler.target>${java.version}</maven.compiler.target>
  </properties>
  
  <dependencies></dependencies>
</project>

Включите зависимость модуля java.xml.bind, так как эти модули были удалены в JDK 11 ДЖЭП-320.

<dependency>
    <groupId>org.glassfish.jaxb</groupId>
    <artifactId>jaxb-runtime</artifactId>
    <version>2.4.0-b180608.0325</version>
</dependency>

а также Джексон для сериализации JSON

<dependency>
    <groupId>com.fasterxml.jackson.core</groupId>
    <artifactId>jackson-databind</artifactId>
    <version>2.9.7</version>
</dependency>

Затем мы будем использовать Ломбок чтобы упростить классы POJO:

<dependency>
  <groupId>org.projectlombok</groupId>
  <artifactId>lombok</artifactId>
  <version>1.18.0</version>
  <scope>provided</scope>
</dependency>

а также вавр для средств функционального программирования

<dependency>
  <groupId>io.vavr</groupId>
  <artifactId>vavr</artifactId>
  <version>0.9.2</version>
</dependency>

Я начал с пустого Application основной класс.

Вы можете получить начальный код от шаг 1 ответвляться.

Первая конечная точка

Отправной точкой веб-приложения является com.sun.net.httpserver.HttpServer учебный класс.
Самый простой /api/hello конечная точка может выглядеть следующим образом:

import java.io.IOException;
import java.io.OutputStream;
import java.net.InetSocketAddress;

import com.sun.net.httpserver.HttpServer;

class Application {

    public static void main(String[] args) throws IOException {
        int serverPort = 8000;
        HttpServer server = HttpServer.create(new InetSocketAddress(serverPort), 0);
        server.createContext("/api/hello", (exchange -> {
            String respText = "Hello!";
            exchange.sendResponseHeaders(200, respText.getBytes().length);
            OutputStream output = exchange.getResponseBody();
            output.write(respText.getBytes());
            output.flush();
            exchange.close();
        }));
        server.setExecutor(null); 
        server.start();
    }
}

Когда вы запускаете основную программу, она запускает веб-сервер на порту 8000 и выставить первую конечную точку, которая просто печатает Hello!например, используя curl:

curl localhost:8000/api/hello

Попробуйте сами из шаг 2 ответвляться.

Поддержка различных методов HTTP

Наша первая конечная точка работает как шарм, но вы заметите, что независимо от того, какой метод HTTP вы будете использовать, он будет отвечать одинаково.
Например:

curl -X POST localhost:8000/api/hello
curl -X PUT localhost:8000/api/hello

Первая проблема при самостоятельном создании API без фреймворка заключается в том, что нам нужно добавить собственный код, чтобы различать методы, например:

        server.createContext("/api/hello", (exchange -> {

            if ("GET".equals(exchange.getRequestMethod())) {
                String respText = "Hello!";
                exchange.sendResponseHeaders(200, respText.getBytes().length);
                OutputStream output = exchange.getResponseBody();
                output.write(respText.getBytes());
                output.flush();
            } else {
                exchange.sendResponseHeaders(405, -1);
            }
            exchange.close();
        }));

Теперь попробуйте еще раз запросить:

curl -v -X POST localhost:8000/api/hello

и ответ будет таким:

> POST /api/hello HTTP/1.1
> Host: localhost:8000
> User-Agent: curl/7.61.0
> Accept: */*
> 
< HTTP/1.1 405 Method Not Allowed

Есть также несколько вещей, которые нужно помнить, например, сбрасывать вывод или закрывать обмен каждый раз, когда мы возвращаемся из API.
Когда я использовал Spring, мне даже не приходилось об этом думать.

Попробуйте эту часть из шаг 3 ответвляться.

Парсинг параметров запроса

Анализ параметров запроса — это еще одна «функция», которую нам нужно реализовать самостоятельно, а не использовать фреймворк.
Допустим, мы хотели бы, чтобы наш hello API отвечал именем, переданным в качестве параметра, например:

curl localhost:8000/api/hello?name=Marcin

Hello Marcin!

Мы могли бы анализировать параметры с помощью такого метода, как:

public static Map<String, List<String>> splitQuery(String query) {
        if (query == null || "".equals(query)) {
            return Collections.emptyMap();
        }

        return Pattern.compile("&").splitAsStream(query)
            .map(s -> Arrays.copyOf(s.split("="), 2))
            .collect(groupingBy(s -> decode(s[0]), mapping(s -> decode(s[1]), toList())));

    }

и используйте его, как показано ниже:

 Map<String, List<String>> params = splitQuery(exchange.getRequestURI().getRawQuery());
String noNameText = "Anonymous";
String name = params.getOrDefault("name", List.of(noNameText)).stream().findFirst().orElse(noNameText);
String respText = String.format("Hello %s!", name);
           

Вы можете найти полный пример в шаг-4 ответвляться.

Точно так же, если бы мы хотели использовать параметры пути, например:

curl localhost:8000/api/items/1

чтобы получить элемент по id=1, нам нужно было бы самостоятельно проанализировать путь, чтобы извлечь из него идентификатор. Это становится громоздким.

Безопасная конечная точка

Распространенным случаем в каждом REST API является защита некоторых конечных точек с помощью учетных данных, например, с использованием базовой аутентификации.
Для каждого контекста сервера мы можем установить аутентификатор, как показано ниже:

HttpContext context =server.createContext("/api/hello", (exchange -> {
  
}));
context.setAuthenticator(new BasicAuthenticator("myrealm") {
    @Override
    public boolean checkCredentials(String user, String pwd) {
        return user.equals("admin") && pwd.equals("admin");
    }
});

«Мое царство» в BasicAuthenticator это имя области. Realm — это виртуальное имя, которое можно использовать для разделения разных пространств аутентификации.
Подробнее об этом можно прочитать в RFC 1945

Теперь вы можете вызвать эту защищенную конечную точку, добавив Authorization заголовок такой:

curl -v localhost:8000/api/hello?name=Marcin -H 'Authorization: Basic YWRtaW46YWRtaW4='

Текст после Basic это кодировка Base64 admin:admin которые являются учетными данными, жестко запрограммированными в нашем примере кода.
В реальном приложении для аутентификации пользователя вы, вероятно, получите его из заголовка и сравните с именем пользователя и паролем в базе данных.
Если вы пропустите заголовок, API ответит статусом

HTTP/1.1 401 Unauthorized

Ознакомьтесь с полным кодом из шаг-5 ответвляться.

JSON, обработчики исключений и другие

Теперь пришло время для более сложного примера.

Исходя из моего прошлого опыта разработки программного обеспечения, наиболее распространенным API, который я разрабатывал, был обмен JSON.

Мы собираемся разработать API для регистрации новых пользователей. Мы будем использовать базу данных в оперативной памяти для их хранения.

Наш объект пользовательского домена будет простым:

@Value
@Builder
public class User {

    String id;
    String login;
    String password;
}

Я использую аннотации Lombok, чтобы избавить меня от шаблонного кода конструктора и геттера, он будет сгенерирован во время сборки.

В REST API я хочу передать только логин и пароль, поэтому создал отдельный объект домена:

@Value
@Builder
public class NewUser {

    String login;
    String password;
}

Пользователи будут созданы в службе, которую я буду использовать в своем обработчике API. Метод службы просто сохраняет пользователя.
В полном приложении он может делать больше, например отправлять события после успешной регистрации пользователя.

public String create(NewUser user) {
    return userRepository.create(user);
}

Наша реализация репозитория в памяти выглядит следующим образом:


import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

import com.consulner.domain.user.NewUser;
import com.consulner.domain.user.User;
import com.consulner.domain.user.UserRepository;

public class InMemoryUserRepository implements UserRepository {

    private static final Map USERS_STORE = new ConcurrentHashMap();

    @Override
    public String create(NewUser newUser) {
        String id = UUID.randomUUID().toString();
        User user = User.builder()
            .id(id)
            .login(newUser.getLogin())
            .password(newUser.getPassword())
            .build();
        USERS_STORE.put(newUser.getLogin(), user);

        return id;
    }
}

Наконец, давайте склеим все вместе в обработчике:

protected void handle(HttpExchange exchange) throws IOException {
        if (!exchange.getRequestMethod().equals("POST")) {
            throw new UnsupportedOperationException();
        }

        RegistrationRequest registerRequest = readRequest(exchange.getRequestBody(), RegistrationRequest.class);

        NewUser user = NewUser.builder()
            .login(registerRequest.getLogin())
            .password(PasswordEncoder.encode(registerRequest.getPassword()))
            .build();

        String userId = userService.create(user);

        exchange.getResponseHeaders().set(Constants.CONTENT_TYPE, Constants.APPLICATION_JSON);
        exchange.sendResponseHeaders(StatusCode.CREATED.getCode(), 0);

        byte[] response = writeResponse(new RegistrationResponse(userId));

        OutputStream responseBody = exchange.getResponseBody();
        responseBody.write(response);
        responseBody.close();
    }

Он переводит запрос JSON в RegistrationRequest объект:

@Value
class RegistrationRequest {

    String login;
    String password;
}

который я позже сопоставляю с объектом домена NewUser чтобы, наконец, сохранить его в базе данных и написать ответ в формате JSON.

мне нужно перевести RegistrationResponse объект обратно в строку JSON.

Маршаллинг и демаршаллинг JSON выполняется с помощью средства сопоставления объектов Jackson (com.fasterxml.jackson.databind.ObjectMapper).

И вот как я создаю новый обработчик в основном методе приложения:

 public static void main(String[] args) throws IOException {
        int serverPort = 8000;
        HttpServer server = HttpServer.create(new InetSocketAddress(serverPort), 0);

        RegistrationHandler registrationHandler = new RegistrationHandler(getUserService(), getObjectMapper(),
            getErrorHandler());
        server.createContext("/api/users/register", registrationHandler::handle);
        
        

 }

Вы можете найти рабочий пример в шаг-6 git, где я также добавил глобальный обработчик исключений, который используется
API для ответа стандартным сообщением об ошибке JSON в случае, например, когда метод HTTP не поддерживается или запрос API имеет неверный формат.

Вы можете запустить приложение и попробовать один из приведенных ниже примеров запросов:

curl -X POST localhost:8000/api/users/register -d '{"login": "test" , "password" : "test"}'

отклик:

{"id":"395eab24-1fdd-41ae-b47e-302591e6127e"}
curl -v -X POST localhost:8000/api/users/register -d '{"wrong": "request"}'

отклик:

< HTTP/1.1 400 Bad Request
< Date: Sat, 29 Dec 2018 00:11:21 GMT
< Transfer-encoding: chunked
< Content-type: application/json
< 
* Connection 
{"code":400,"message":"Unrecognized field \"wrong\" (class com.consulner.app.api.user.RegistrationRequest), not marked as ignorable (2 known properties: \"login\", \"password\"])\n at [Source: (sun.net.httpserver.FixedLengthInputStream); line: 1, column: 21] (through reference chain: com.consulner.app.api.user.RegistrationRequest[\"wrong\"])"}

Так же случайно наткнулся на проект Java-экспресс
который является Java-аналогом Node.js Выражать рамки
и также использует jdk.httpserver, поэтому все концепции, рассмотренные в этой статье, вы можете найти в реальной среде приложений. 😃
который также достаточно мал, чтобы быстро переваривать коды.

Похожие записи

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *