Перевод статьи подготовлен специально для студентов курса «Разработчик Java».




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

Вы можете сказать, что Spring — это стандарт и зачем изобретать велосипед? А Spark — это хороший удобный REST-фреймворк. Или Light-rest-4jis. И я скажу, что вы, конечно, правы.

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

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

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

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

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

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

Я понял, что для решения реальных бизнес-задач я, все же, предпочел бы использовать Spring, а не изобретать велосипед. Тем не менее, я считаю, что это упражнение было довольно интересным опытом.

Начинаем


Я буду описывать каждый шаг, но не всегда буду приводить полный исходный код. Полный код вы можете посмотреть в отдельных ветках git-репозитория.

Сначала создайте новый Maven-проект со следующим pom.xml.

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
        xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
        xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
   <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 (JEP-320).

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

и Jackson для JSON-сериализации

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

Для упрощения POJO-классов будем использовать Lombok:

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

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

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

Также создадим основной пустой класс Application.

Исходный код в ветке step-1.

Первый эндпоинт


В основе нашего веб-приложения будет класс com.sun.net.httpserver.HttpServer. И простейший эндпоинт (endpoint) /api/hello может выглядеть следующим образом:

package com.consulner.api;

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); // creates a default executor
       server.start();
   }
}

Веб-сервер запускается на порту 8000 и предоставляет эндпоинт, который просто возвращает Hello!.. Это можно проверить, например, используя curl:

curl localhost:8000/api/hello

Исходный код в ветке step-2.

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


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

Например:

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

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

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);// 405 Method Not Allowed
   }
   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

Есть также несколько моментов, которые нужно помнить. Например, не забыть сделать flush() для OutputStream и close() для exchange. При использовании Spring мне об этом даже не приходилось думать.

Исходный код в ветке step-3.

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


Парсинг параметров запроса — это еще одна «функция», которую нам нужно реализовать самостоятельно.

Допустим, мы хотим, чтобы наш hello api получал имя в параметре name, например:

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);

Полный пример в ветке step-4.

Аналогично, если мы хотим использовать параметры в path. Например:

curl localhost:8000/api/items/1

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

Безопасность


Часто нам нужно защитить доступ к некоторым эндпоинтам. Например, это можно сделать, используя базовую аутентификацию (basic authentication).

Для каждого HttpContext мы можем установить аутентификатор, как показано ниже:

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");
   }
});

Значение “myrealm” в конструкторе BasicAuthenticator — это имя realm. 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

Полный пример в ветке step-5.

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


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

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

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

У нас будет простой доменный объект User:

@Value
@Builder
public class User {
   String id;
   String login;
   String password;
}

Я использую Lombok, чтобы избавится от бойлерплейта (конструкторы, геттеры).

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

@Value
@Builder
public class NewUser {
   String login;
   String password;
}

Объекты User создаются в сервисе, который будем использовать в обработчике 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;
   }
}

Наконец, склеим все вместе в handle():

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-строку. Для этого используем Jackson
(com.fasterxml.jackson.databind.ObjectMapper).

Вот как я создаю новый обработчик (handler) в main():

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);

   // here follows the rest..
}

Рабочий пример можно найти в ветке step-6. Там я также добавил глобальный обработчик исключений для отправки стандартных 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 #0 to host localhost left intact
{"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-express, который является Java-аналогом фреймворка Express для Node.js. В нем также используется jdk.httpserver, поэтому все концепции, описанные в этой статье, вы можете изучить на реальном фреймворке, который, к тому же, достаточно мал для изучения его кода.

Комментарии (12)


  1. maxzh83
    16.12.2019 17:29

    Очень не хватает чего-то полезного, ради чего бы это стоило делать, кроме обучения и фана. Ну например, насколько более экономичным по ресурсам получилось приложение по сравнению с монстрами.


    1. lovermann
      16.12.2019 17:38
      +1

      Одну выгоду я вижу: узнать то, что происходит «внутри», когда пользуешься либами и фреймворками.


      1. maxzh83
        17.12.2019 22:24

        кроме обучения

        Это и есть обучение


  1. oxidmod
    16.12.2019 17:53
    +1

    Отказываясь от фреймворков — обязательно изобретешь свой. Зачем? 4fun и разобраться как оно вообще работает. Имхо, поздновато уже, если осилил Spring. Можно разобраться как оно работает и на примере Spring-a или любого другого готового фреймворка.


    1. user_man
      18.12.2019 15:43

      Отказываясь от фреймворков — обязательно изобретешь свой. Зачем?

      Подавляющее большинство готовых библиотек весьма далеки от идеала. Поэтому многие и пытаются сочинить свой идеал. Но у них обычно опять получается что-то совсем неидеальное. И так по кругу.

      Но всё-таки в итоге иногда выходит реально прекрасный каменный цветок. Один из таких цветков — сама Java (до момента, пока оракл не начал её «улучшать»).

      И да, всегда и всё писать на спринге (подставить другую любимую либу) — это однозначно путь в никуда. Вы станете придатком к гайковёрту, который насочиняли далеко не идеальные люди. Ну и штамповать все решения будете «под гайки», а если надо под гвозди? Или неразъёмное соединение? Или вообще свободно перемещающиеся детали?


      1. oxidmod
        18.12.2019 17:04

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


        1. Throwable
          18.12.2019 20:42

          Совершенно верно, и начать нужно с Java — в ней из коробки уже идет много чего полезного. Дальше неплохо иметь представление о различных библиотеках и фреймворках в экосистеме и задач, которые с их помощью решаются. А то сейчас благодаря рекламе Spring прочно захватил всю разработку Java, и породил целое поколение, которое уже не представляет себе жизнь без него (как в прошлом было с JavaEE). Инструменты должны подбираться под конкретную задачу, и при этом не быть избыточными.


  1. usharik
    16.12.2019 18:00

    Насколько я могу судить, на SpringBoot все это делается едва ли ни меньшим количеством кода. В чем смысл тогда?

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


  1. sshikov
    16.12.2019 19:54

    >сравните их с именем пользователя и паролем, хранящимися в базе данных.
    А потом мы удивляемся, почему и куда пароли утекают?


  1. taluks
    17.12.2019 21:27

    Но вместе с фреймворком, помимо готовой функциональности, вы получаете много магии...

    Для упрощения POJO-классов будем использовать Lombok...

    В Lombok больше магии чем во всем спринге)

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

    К тому же вопрос что делать с горячей перезагрузкой?


    1. Maksclub
      16.12.2019 22:46

      ахахаха, POJO (англ. Plain Old Java Object) — «старый добрый Java-объект»:

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


  1. mmMike
    18.12.2019 13:50

    Если уж на то пошло, что "com.sun.net.httpserver.HttpServer" то же можно назвать framework от Sun.
    Если уж извращаться, то сразу на уровне socket.