[Перевод] REST API на Java без фреймворков
Перевод статьи подготовлен специально для студентов курса «Разработчик Java».
В экосистеме Java есть много фреймворков и библиотек. Хотя и не так много, как в JavaScript, но они и не устаревают так быстро. Тем не менее, это заставило меня задуматься о том, что мы уже забыли, как писать приложения без фреймворков.
Вы можете сказать, что Spring — это стандарт и зачем изобретать велосипед? А Spark — это хороший удобный REST-фреймворк. Или Light-rest-4jis. И я скажу, что вы, конечно, правы.
Но вместе с фреймворком, помимо готовой функциональности, вы получаете много магии, сложности с изучением, дополнительные функции, которые вы, скорее всего, не будете использовать, а также баги. И чем больше стороннего кода в вашем сервисе, тем больше вероятность того, что у вас будут ошибки.
Сообщество open source очень активное, и есть большая вероятность, что ошибки в фреймворке будут быстро исправлены. Но все же, я хотел бы призвать вас подумать, действительно ли вам нужен фреймворк. Если у вас небольшой сервис или консольное приложение, возможно, вы сможете обойтись без него.
Что вы можете получить (или потерять), используя чистый Java-код? Подумайте об этом:
- ваш код может быть намного чище и понятнее (а может и в полном беспорядке, если вы плохой программист)
- у вас будет больше контроля над вашим кодом, вы не будете ограничены рамками фреймворка (хотя вам придется писать больше своего кода для функциональности, которую фреймворк предоставляет из коробки)
- ваше приложение будет развертываться и запускаться гораздо быстрее, потому что фреймворку не нужно инициализировать десятки классов (или не будет запускаться вообще, если вы перепутаете что-то, например, в многопоточности)
- если вы развертываете приложение в Docker, то ваши образы будут намного меньше, потому что ваши jar также будут меньше
Я провел небольшой эксперимент и попытался разработать REST API без фреймворка. Возможно, это будет интересно с точки зрения обучения и освежения знаний.
Когда я начал писать этот код, то часто сталкивался с ситуациями, когда отсутствовал функционал, который есть в Spring из коробки. В эти моменты, вместо того, чтобы взять Spring, надо было переосмыслить и разработать все самостоятельно.
Я понял, что для решения реальных бизнес-задач я, все же, предпочел бы использовать Spring, а не изобретать велосипед. Тем не менее, я считаю, что это упражнение было довольно интересным опытом.
Начинаем
Я буду описывать каждый шаг, но не всегда буду приводить полный исходный код. Полный код вы можете посмотреть в отдельных ветках git-репозитория.
Сначала создайте новый Maven-проект со следующим pom.xml
.
4.0.0
com.consulner.httpserver
pure-java-rest-api
1.0-SNAPSHOT
11
${java.version}
${java.version}
Добавьте в зависимости java.xml.bind
, потому что он был удален в JDK 11 (JEP-320).
org.glassfish.jaxb
jaxb-runtime
2.4.0-b180608.0325
и Jackson для JSON-сериализации
com.fasterxml.jackson.core
jackson-databind
2.9.7
Для упрощения POJO-классов будем использовать Lombok:
org.projectlombok
lombok
1.18.0
provided
и vavr для средств функционального программирования
io.vavr
vavr
0.9.2
Также создадим основной пустой класс 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> 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> 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
, поэтому все концепции, описанные в этой статье, вы можете изучить на реальном фреймворке, который, к тому же, достаточно мал для изучения его кода.