API REST en Java sin marcos

La traducción del artículo fue preparada especialmente para los estudiantes del curso. "Desarrollador Java" .




Hay muchos marcos y bibliotecas en el ecosistema de Java. Aunque no tanto como en JavaScript, no caducan tan rápido. Sin embargo, me hizo pensar que ya habíamos olvidado cómo escribir aplicaciones sin marcos.

Puedes decir que Spring es el estándar y ¿por qué reinventar la rueda? Y Spark es un marco REST agradable y conveniente. O Light-rest-4jis . Y diré que, por supuesto, tienes razón.

Pero junto con el marco, además de la funcionalidad finalizada, obtienes mucha magia, dificultades con el aprendizaje, funciones adicionales que probablemente no usarás, así como errores. Y cuanto mayor sea el código de terceros en su servicio, mayor será la probabilidad de que tenga errores.

La comunidad de código abierto es muy activa y existe una alta probabilidad de que los errores en el marco se solucionen rápidamente. Pero aún así, le insto a que piense si realmente necesita un marco. Si tiene un servicio pequeño o una aplicación de consola, puede prescindir de él.

¿Qué puede ganar (o perder) usando código Java puro? Piénsalo:

  • su código puede ser mucho más limpio y más comprensible (o tal vez en un completo desastre si es un mal programador)
  • tendrá más control sobre su código, no estará limitado por el marco (aunque tendrá que escribir más de su código para la funcionalidad que el marco proporciona de forma inmediata)
  • su aplicación se implementará y lanzará mucho más rápido, porque el marco no necesita inicializar docenas de clases (o no se iniciará en absoluto si mezcla algo, por ejemplo, en subprocesos múltiples)
  • si implementa la aplicación en Docker, sus imágenes serán mucho más pequeñas, porque su jar también será más pequeño

Hice un pequeño experimento e intenté desarrollar una API REST sin un marco. Quizás esto sea interesante en términos de aprendizaje y actualización de conocimientos.

Cuando comencé a escribir este código, a menudo me encontré con situaciones en las que Spring no tenía ninguna funcionalidad lista para usar. En estos momentos, en lugar de tomar Spring, tenía que repensar y desarrollar todo usted mismo.

Me di cuenta de que para resolver problemas comerciales reales, preferiría usar Spring en lugar de reinventar la rueda. Sin embargo, creo que este ejercicio fue una experiencia bastante interesante.

Empezando


Describiré cada paso, pero no siempre daré el código fuente completo. Puede ver el código completo en ramas separadas del repositorio git .

Primero cree un nuevo proyecto Maven con el siguiente 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> 

Agregue java.xml.bind como la dependencia porque se eliminó en JDK 11 ( JEP-320 ).

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

y Jackson para la serialización JSON

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

Para simplificar las clases de POJO, usaremos Lombok :

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

y vavr para herramientas de programación funcional

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

También creamos la aplicación principal de clase vacía.

El código fuente en la rama del paso 1 .

Primer punto final


Nuestra aplicación web se basará en la clase com.sun.net.httpserver.HttpServer . Y el punto final más simple /api/hello podría verse así:

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

El servidor web se ejecuta en el puerto 8000 y proporciona un punto final que simplemente devuelve Hello! .. Esto se puede verificar, por ejemplo, usando curl:

curl localhost:8000/api/hello

El código fuente en la rama del paso 2 .

Soporte para varios métodos HTTP.


Nuestro primer punto final funciona bien, pero puede notar que no importa qué método HTTP utilizar, siempre responde de la misma manera.

Por ejemplo:

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

Lo primero que debe hacer es agregar código para distinguir entre métodos, por ejemplo:

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

Intente la consulta nuevamente:

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

la respuesta sería algo como esto:

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

También hay algunos puntos a tener en cuenta. Por ejemplo, no olvide hacer flush() para OutputStream y close() para el exchange . Cuando usé Spring, ni siquiera tuve que pensarlo.

El código fuente en la rama del paso 3 .

Parámetros de solicitud de análisis


Analizar los parámetros de consulta es otra "función" que necesitamos implementar por nuestra cuenta.

Supongamos que queremos que nuestra api de saludo obtenga un nombre en el parámetro de name , por ejemplo:

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

Podríamos analizar los parámetros de la siguiente manera:

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

y usar de la siguiente manera:

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

Ejemplo completo en la rama del paso 4 .

Del mismo modo, si queremos usar parámetros en la ruta. Por ejemplo:

 curl localhost:8000/api/items/1 

Para obtener el elemento por id = 1, necesitamos analizar la url nosotros mismos. Se vuelve voluminoso.

Seguridad


A menudo necesitamos proteger el acceso a algunos puntos finales. Por ejemplo, esto se puede hacer usando la autenticación básica.

Para cada HttpContext, podemos establecer un autenticador, como se muestra a continuación:

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

El valor "myrealm" en el constructor BasicAuthenticator es el nombre realm. Realm es un nombre virtual que se puede usar para separar dominios de autenticación.

Puede leer más sobre esto en RFC 1945 .

Ahora puede llamar a este punto final seguro agregando el encabezado Authorization :

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

El texto después de "Básico" es admin:admin codificado en Base64 admin:admin texto de admin:admin , que son las credenciales codificadas en nuestro ejemplo.

Para la autenticación en una aplicación real, probablemente obtendrá las credenciales del encabezado y las comparará con el nombre de usuario y la contraseña almacenados en la base de datos.

Si no especifica un título, la API responderá con un estado

 HTTP/1.1 401 Unauthorized 

Ejemplo completo en la rama del paso 5 .

JSON, manejo de excepciones y más


Ahora es el momento de un ejemplo más complejo.

Desde mi experiencia en el desarrollo de software, la API más común que desarrollé fue el intercambio JSON.

Vamos a desarrollar una API para registrar nuevos usuarios. Para almacenarlos, utilizaremos la base de datos en la memoria.

Tendremos un simple objeto de dominio de User :

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

Utilizo Lombok para deshacerme de la repetitiva (constructores, captadores).

En la API REST, quiero pasar solo el inicio de sesión y la contraseña, así que creé un objeto separado:

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

User objetos de User se crean en el servicio que usaremos en el controlador de API. El método de servicio simplemente salva al usuario.

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

Puedes hacer más en una aplicación real. Por ejemplo, envíe eventos después de un registro de usuario exitoso.

La implementación del repositorio es la siguiente:

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

Finalmente, pegue todo junto en 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(); } 

Aquí, la solicitud JSON se convierte en un objeto RegistrationRequest :

 @Value class RegistrationRequest { String login; String password; } 

que luego NewUser objeto NewUser para guardarlo en la base de datos y enviar la respuesta como JSON.

También necesito convertir el objeto RegistrationResponse a una cadena JSON. Para esto usamos Jackson
( com.fasterxml.jackson.databind.ObjectMapper ).

Así es como creo un nuevo controlador en 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.. } 

Se puede encontrar un ejemplo de trabajo en la rama del paso 6 . Allí también agregué un controlador de excepción global para enviar mensajes de error JSON estándar. Por ejemplo, si el método HTTP no es compatible o la solicitud a la API no se forma correctamente.

Puede ejecutar la aplicación y probar una de las siguientes consultas:

  • ejemplo de solicitud correcta

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

respuesta:

 {"id":"395eab24-1fdd-41ae-b47e-302591e6127e"} 

  • ejemplo de error

 curl -v -X POST localhost:8000/api/users/register -d '{"wrong": "request"}' 

respuesta:

 < 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\"])"} 

Además, me encontré accidentalmente con el proyecto java-express , que es una contraparte de Java del marco Express para Node.js. También utiliza jdk.httpserver , por lo que puede aprender todos los conceptos descritos en este artículo en un marco real, que, además, es lo suficientemente pequeño como para estudiar su código.

Source: https://habr.com/ru/post/480564/


All Articles