В экосистеме 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)
oxidmod
16.12.2019 17:53+1Отказываясь от фреймворков — обязательно изобретешь свой. Зачем? 4fun и разобраться как оно вообще работает. Имхо, поздновато уже, если осилил Spring. Можно разобраться как оно работает и на примере Spring-a или любого другого готового фреймворка.
user_man
18.12.2019 15:43Отказываясь от фреймворков — обязательно изобретешь свой. Зачем?
Подавляющее большинство готовых библиотек весьма далеки от идеала. Поэтому многие и пытаются сочинить свой идеал. Но у них обычно опять получается что-то совсем неидеальное. И так по кругу.
Но всё-таки в итоге иногда выходит реально прекрасный каменный цветок. Один из таких цветков — сама Java (до момента, пока оракл не начал её «улучшать»).
И да, всегда и всё писать на спринге (подставить другую любимую либу) — это однозначно путь в никуда. Вы станете придатком к гайковёрту, который насочиняли далеко не идеальные люди. Ну и штамповать все решения будете «под гайки», а если надо под гвозди? Или неразъёмное соединение? Или вообще свободно перемещающиеся детали?oxidmod
18.12.2019 17:04Я и не утверждаю, что нужно учить только что-то одно. Я говорю, что сомнительно сочинять свое, если уже есть инструменты, которые решают ваши проблемы. Даже для обучения будет эффективней разобрать существующие решения.
Throwable
18.12.2019 20:42Совершенно верно, и начать нужно с Java — в ней из коробки уже идет много чего полезного. Дальше неплохо иметь представление о различных библиотеках и фреймворках в экосистеме и задач, которые с их помощью решаются. А то сейчас благодаря рекламе Spring прочно захватил всю разработку Java, и породил целое поколение, которое уже не представляет себе жизнь без него (как в прошлом было с JavaEE). Инструменты должны подбираться под конкретную задачу, и при этом не быть избыточными.
usharik
16.12.2019 18:00Насколько я могу судить, на SpringBoot все это делается едва ли ни меньшим количеством кода. В чем смысл тогда?
Если речь идет об учебных целях, то подобные упражнения должны демонстрировать какие-то базовые принципы, которые стоят за большинством фреймворков, но тут я этого не вижу.
sshikov
16.12.2019 19:54>сравните их с именем пользователя и паролем, хранящимися в базе данных.
А потом мы удивляемся, почему и куда пароли утекают?
taluks
17.12.2019 21:27Но вместе с фреймворком, помимо готовой функциональности, вы получаете много магии...
Для упрощения POJO-классов будем использовать Lombok...
В Lombok больше магии чем во всем спринге)
На счет размера не согласен, если нужно несколько приложений то у вас будет больше потребления памяти чем один контейнер + приложения.
К тому же вопрос что делать с горячей перезагрузкой?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>
mmMike
18.12.2019 13:50Если уж на то пошло, что "com.sun.net.httpserver.HttpServer" то же можно назвать framework от Sun.
Если уж извращаться, то сразу на уровне socket.
maxzh83
Очень не хватает чего-то полезного, ради чего бы это стоило делать, кроме обучения и фана. Ну например, насколько более экономичным по ресурсам получилось приложение по сравнению с монстрами.
lovermann
Одну выгоду я вижу: узнать то, что происходит «внутри», когда пользуешься либами и фреймворками.
maxzh83
Это и есть обучение