Los 10 errores más comunes de Spring Framework

Hola Habr! Les presento la traducción del artículo "Los 10 errores más comunes del marco de primavera" de Toni Kukurin.

Spring es probablemente uno de los frameworks Java más populares, así como una poderosa bestia para domesticar. Aunque sus conceptos básicos son bastante fáciles de entender, lleva tiempo y esfuerzo convertirse en un desarrollador sólido de Spring.

En este artículo, veremos algunos de los errores más comunes en Spring, especialmente aquellos relacionados con aplicaciones web y Spring Boot. Como se indicó en el sitio web de Spring Boot , impone una idea de cómo deben construirse las aplicaciones industriales, por lo que en este artículo intentaremos demostrar esta idea y dar una visión general de algunos consejos que encajarán bien en el proceso estándar de desarrollo de aplicaciones web de Spring Boot.
Si no está muy familiarizado con Spring Boot, pero aún así desea probar algunas de las cosas mencionadas, creé el repositorio de GitHub que acompaña a este artículo . Si siente que se perdió en algún lugar del artículo, le recomiendo clonar el repositorio en su computadora local y jugar con el código.

Error común n. ° 1: bajar demasiado


Nos encontramos con este error común porque el síndrome "no inventado aquí" es bastante común en el mundo del desarrollo de software. Los síntomas incluyen la reescritura regular de fragmentos de código de uso frecuente, y muchos desarrolladores parecen sufrir esto.

Si bien comprender el interior de una biblioteca en particular y su implementación es principalmente bueno y necesario (y puede ser un excelente proceso de aprendizaje), resolver constantemente los mismos detalles de implementación de bajo nivel es malo para su desarrollo como ingeniero de software. Existe una razón por la cual existen abstracciones y marcos como Spring que lo separan estrictamente del trabajo manual repetitivo y le permiten concentrarse en los detalles de nivel superior: sus objetos de dominio y la lógica empresarial.

Por lo tanto, use abstracciones: la próxima vez que encuentre un problema específico, primero haga una búsqueda rápida y determine si la biblioteca que resuelve este problema está integrada en Spring. Actualmente, es más probable que encuentre una solución adecuada existente. Como ejemplo de una biblioteca útil, en los ejemplos del resto de este artículo, usaré las anotaciones del proyecto Lombok . Lombok se usa como generador de código de plantilla y el desarrollador perezoso dentro de ti, con suerte no debería tener problemas con la idea de esta biblioteca. Como ejemplo, mire cómo se ve un "bean Java estándar" con Lombok:

@Getter @Setter @NoArgsConstructor public class Bean implements Serializable { int firstBeanProperty; String secondBeanProperty; } 

Como puede imaginar, el código anterior se compila en:

 public class Bean implements Serializable { private int firstBeanProperty; private String secondBeanProperty; public int getFirstBeanProperty() { return this.firstBeanProperty; } public String getSecondBeanProperty() { return this.secondBeanProperty; } public void setFirstBeanProperty(int firstBeanProperty) { this.firstBeanProperty = firstBeanProperty; } public void setSecondBeanProperty(String secondBeanProperty) { this.secondBeanProperty = secondBeanProperty; } public Bean() { } } 

Sin embargo, tenga en cuenta que lo más probable es que tenga que instalar el complemento si tiene la intención de usar Lombok con su IDE. La versión del complemento para IntelliJ IDEA se puede encontrar aquí .

Error Común # 2: Fugas de Contenido Interno


Revelar su estructura interna siempre es una mala idea, ya que crea inflexibilidad en el diseño del servicio y, por lo tanto, contribuye a la mala práctica de codificación. Una "fuga" de contenido interno se manifiesta en el hecho de que la estructura de la base de datos es accesible desde ciertos puntos finales API. Como ejemplo, suponga que el siguiente POJO ("Objeto Java simple" representa una tabla en su base de datos:

 @Entity @NoArgsConstructor @Getter public class TopTalentEntity { @Id @GeneratedValue private Integer id; @Column private String name; public TopTalentEntity(String name) { this.name = name; } } 

Supongamos que hay un punto final que necesita acceder a los datos de TopTalentEntity. Por tentador que sea devolver las instancias de TopTalentEntity, una solución más flexible sería crear una nueva clase para mostrar los datos de TopTalentEntity en el punto final de la API:

 @AllArgsConstructor @NoArgsConstructor @Getter public class TopTalentData { private String name; } 

Por lo tanto, realizar cambios en el backend de la base de datos no requerirá ningún cambio adicional en la capa de servicio. Piense en lo que sucede si agrega el campo de contraseña a TopTalentEntity para almacenar hashes de contraseñas de usuario en la base de datos; sin un conector como TopTalentData, si olvida cambiar el servicio, la interfaz mostrará accidentalmente información secreta muy indeseable.

Error común # 3: falta de separación de deberes


A medida que su aplicación crece, organizar su código se convierte en un problema cada vez más importante. Irónicamente, la mayoría de los buenos principios del desarrollo de software están comenzando a violarse en todas partes, especialmente en aquellos casos en los que se presta poca atención al diseño de la arquitectura de la aplicación. Uno de los errores más comunes que enfrentan los desarrolladores es mezclar las responsabilidades del código, ¡y es muy fácil de hacer!

Lo que generalmente viola el principio de separación de funciones es simplemente "agregar" nuevas funcionalidades a las clases existentes. Esto, por supuesto, es una excelente solución a corto plazo (para empezar, requiere menos tipeo), pero inevitablemente se convertirá en un problema en el futuro, ya sea durante las pruebas, el mantenimiento o en algún punto intermedio. Considere el siguiente controlador, que devuelve TopTalentData desde su repositorio:

 @RestController public class TopTalentController { private final TopTalentRepository topTalentRepository; @RequestMapping("/toptal/get") public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(this::entityToData) .collect(Collectors.toList()); } private TopTalentData entityToData(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } } 

Al principio, no se nota que algo está mal con este código. Proporciona una lista de TopTalentData que se recupera de las instancias de TopTalentEntity. Sin embargo, si observa detenidamente, veremos que, de hecho, TopTalentController hace algunas cosas aquí. A saber: asigna solicitudes para un punto final específico, recupera datos del repositorio y convierte las entidades obtenidas de TopTalentRepository en otro formato. Una solución "más limpia" sería dividir estas responsabilidades en sus propias clases. Puede verse más o menos así:

 @RestController @RequestMapping("/toptal") @AllArgsConstructor public class TopTalentController { private final TopTalentService topTalentService; @RequestMapping("/get") public List<TopTalentData> getTopTalent() { return topTalentService.getTopTalent(); } } @AllArgsConstructor @Service public class TopTalentService { private final TopTalentRepository topTalentRepository; private final TopTalentEntityConverter topTalentEntityConverter; public List<TopTalentData> getTopTalent() { return topTalentRepository.findAll() .stream() .map(topTalentEntityConverter::toResponse) .collect(Collectors.toList()); } } @Component public class TopTalentEntityConverter { public TopTalentData toResponse(TopTalentEntity topTalentEntity) { return new TopTalentData(topTalentEntity.getName()); } } 

Un beneficio adicional de esta jerarquía es que nos permite determinar dónde se encuentra la funcionalidad simplemente verificando el nombre de la clase. Además, durante las pruebas, podemos reemplazar fácilmente cualquiera de las clases con una implementación simulada, si es necesario.

Error común # 4: inconsistencia y manejo de errores deficientes


El tema de la coherencia no es necesariamente exclusivo de Spring (o Java, para el caso), pero sigue siendo un aspecto importante a tener en cuenta al trabajar en proyectos de Spring. Si bien el estilo de escribir código puede ser tema de discusión (y generalmente es un acuerdo sobre el equipo o en toda la empresa), la presencia de un estándar común es de gran ayuda en el rendimiento. Esto es especialmente cierto para equipos de varias personas. La consistencia permite que el código se transmita sin el gasto de recursos para el mantenimiento o la provisión de explicaciones detalladas sobre las responsabilidades de varias clases.

Considere un proyecto Spring con varios archivos de configuración, servicios y controladores. Al ser semánticamente consistentes al nombrarlos, se crea una estructura fácil de buscar en la que cualquier desarrollador nuevo puede controlar cómo trabajar con el código: por ejemplo, el sufijo Config se agrega a las clases de configuración, el sufijo de servicio a los servicios y el sufijo de controlador a los controladores.

Estrechamente relacionado con el tema de la coherencia, el manejo de errores del lado del servidor merece una atención especial. Si alguna vez ha tenido que manejar respuestas de excepción de una API mal escrita, probablemente sepa por qué puede ser doloroso analizar excepciones, y es aún más difícil determinar la razón por la cual estas excepciones ocurrieron originalmente.

Como desarrollador de API, lo ideal es cubrir todos los puntos finales de los usuarios y traducirlos a un formato de error común. Esto generalmente significa que tiene un código de error y una descripción comunes, y no solo una excusa en la forma de: a) devolver el mensaje "500 Error interno del servidor" ob) simplemente restablecer el seguimiento de la pila al usuario (lo que debe evitarse a toda costa, ya que muestra su interior además de la complejidad del procesamiento en el lado del cliente).
Un ejemplo de un formato de respuesta de error común podría ser:

 @Value public class ErrorResponse { private Integer errorCode; private String errorMessage; } 

Algo similar se encuentra generalmente en las API más populares y generalmente funciona bien, ya que puede documentarse de manera fácil y sistemática. Puede traducir las excepciones a este formato proporcionando el método con la anotación @ExceptionHandler (en el error común n. ° 6 se proporciona un ejemplo de la anotación).

Error común # 5: subprocesamiento múltiple incorrecto


Independientemente de si se encuentra en aplicaciones de escritorio o web, en Spring o no en Spring, el subprocesamiento múltiple puede ser una tarea desalentadora. Los problemas causados ​​por la ejecución de programas paralelos son difíciles de resolver y, a menudo, extremadamente difíciles de depurar; de hecho, debido a la naturaleza del problema, una vez que comprenda que está lidiando con el problema de ejecución paralela, probablemente debería abandonar por completo el depurador y comenzar verifique su código manualmente hasta que encuentre la causa del error. Desafortunadamente, para resolver estos problemas no hay una solución de plantilla. Dependiendo del caso específico, tendrá que evaluar la situación y luego atacar el problema desde un ángulo que considere el mejor.

Idealmente, por supuesto, le gustaría evitar por completo los errores de subprocesos múltiples. Nuevamente, no existe un enfoque único para esto, pero aquí hay algunas consideraciones prácticas para depurar y prevenir errores de subprocesos múltiples:

Evitar estado global


Primero, recuerde siempre el problema del "estado global". Si está creando una aplicación multiproceso, absolutamente todo lo que se pueda cambiar globalmente se debe monitorear cuidadosamente y, si es posible, eliminar por completo. Si hay una razón por la cual la variable global debe permanecer mutable, use cuidadosamente la sincronización y monitoree el rendimiento de su aplicación para confirmar que no se está desacelerando debido a los nuevos períodos de espera.

Evitar la mutabilidad


Esto se deduce directamente de la programación funcional y, según OOP, establece que se debe evitar la volatilidad de clase y el cambio de estado. En resumen, lo anterior significa la presencia de establecedores y campos finales privados en todas las clases del modelo. Sus valores cambian solo durante la construcción. Por lo tanto, puede estar seguro de que no habrá problemas en la carrera por los recursos y que el acceso a las propiedades del objeto siempre proporcionará los valores correctos.

Registrar datos críticos


Evalúe dónde su aplicación puede causar problemas y registre previamente todos los datos importantes. Si se produce un error, agradecerá la información sobre las solicitudes recibidas y podrá comprender mejor por qué su aplicación se está comportando mal. Una vez más, debe tenerse en cuenta que el registro aumenta la E / S del archivo, por lo que no debe abusar de él, ya que esto puede afectar seriamente el rendimiento de su aplicación.

Reutilice implementaciones existentes


Siempre que necesite crear sus propios subprocesos (por ejemplo, para realizar solicitudes asincrónicas a varios servicios), reutilice las implementaciones seguras existentes, en lugar de crear sus propias soluciones. En su mayor parte, esto significaría usar ExecutorServices y CompletableFutures en el estilo funcional ordenado de Java 8 para crear hilos. Spring también permite el procesamiento de solicitudes asíncronas a través de la clase DeferredResult .

Error común # 6: no usar validación basada en anotaciones


Imaginemos que nuestro servicio TopTalent, mencionado anteriormente, necesita un punto final para agregar nuevos Super Talentos. Además, suponga que por alguna buena razón, cada nuevo nombre debe tener exactamente 10 caracteres de longitud. Una forma de hacerlo podría ser la siguiente:

 @RequestMapping("/put") public void addTopTalent(@RequestBody TopTalentData topTalentData) { boolean nameNonExistentOrHasInvalidLength = Optional.ofNullable(topTalentData) .map(TopTalentData::getName) .map(name -> name.length() == 10) .orElse(true); if (nameNonExistentOrInvalidLength) { // throw some exception } topTalentService.addTopTalent(topTalentData); } 

Sin embargo, lo anterior (además de estar mal diseñado) no es realmente una solución "limpia". Verificamos más de un tipo de validez (es decir, que TopTalentData no es nulo, y que TopTalentData.name no es nulo, y que TopTalentData.name tiene 10 caracteres de longitud), y también arroja una excepción si los datos no son válidos.

Esto se puede hacer de manera mucho más limpia usando el validador de Hibernate con Spring. Primero, reescribimos el método addTopTalent para admitir la validación:

 @RequestMapping("/put") public void addTopTalent(@Valid @NotNull @RequestBody TopTalentData topTalentData) { topTalentService.addTopTalent(topTalentData); } @ExceptionHandler @ResponseStatus(HttpStatus.BAD_REQUEST) public ErrorResponse handleInvalidTopTalentDataException(MethodArgumentNotValidException methodArgumentNotValidException) { // handle validation exception } 

Además, debemos indicar qué propiedad queremos verificar en la clase TopTalentData:

 public class TopTalentData { @Length(min = 10, max = 10) @NotNull private String name; } 

Spring ahora interceptará la solicitud y la verificará antes de llamar al método; no es necesario utilizar pruebas manuales adicionales.

Otra forma en que podríamos lograr lo mismo es crear nuestras propias anotaciones. Aunque las anotaciones personalizadas generalmente se usan solo cuando sus necesidades exceden el conjunto de constantes incorporado de Hibernate , para este ejemplo, imaginemos que las anotaciones de longitud no existen. Debe crear un validador que verifique la longitud de una cadena creando dos clases adicionales, una para verificar y otra para anotar propiedades:

 @Target({ElementType.METHOD, ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Documented @Constraint(validatedBy = { MyAnnotationValidator.class }) public @interface MyAnnotation { String message() default "String length does not match expected"; Class<?>[] groups() default {}; Class<? extends Payload>[] payload() default {}; int value(); } @Component public class MyAnnotationValidator implements ConstraintValidator<MyAnnotation, String> { private int expectedLength; @Override public void initialize(MyAnnotation myAnnotation) { this.expectedLength = myAnnotation.value(); } @Override public boolean isValid(String s, ConstraintValidatorContext constraintValidatorContext) { return s == null || s.length() == this.expectedLength; } } 

Tenga en cuenta que en estos casos, las mejores prácticas para la separación de tareas requieren que marque una propiedad como válida si es nula (s == null en el método isValid), y luego use la anotación NotNull si este es un requisito adicional para la propiedad:

 public class TopTalentData { @MyAnnotation(value = 10) @NotNull private String name; } 

Error común n. ° 7: uso de la configuración XML (aún)


Aunque XML era necesario para versiones anteriores de Spring, actualmente la mayor parte de la configuración se puede hacer exclusivamente con código / anotaciones Java. Las configuraciones XML simplemente representan una plantilla adicional e innecesaria.
Este artículo (y el repositorio de GitHub que lo acompaña) usa anotaciones para configurar Spring y Spring sabe a qué beans se debe conectar porque el paquete raíz se anotó utilizando la anotación compuesta @SpringBootApplication, por ejemplo:

 @SpringBootApplication public class Application { public static void main(String[] args) { SpringApplication.run(Application.class, args); } } 

Esta anotación compuesta (puede obtener más información al respecto en la documentación de Spring ) solo le da a Spring una pista sobre qué paquetes deben escanearse para extraer los beans. En nuestro caso particular, esto significa que las siguientes clases se utilizarán para conectar los beans, comenzando con el paquete de nivel superior (co.kukurin):

  • @Component (TopTalentConverter, MyAnnotationValidator) @RestController (TopTalentController) @Repository (TopTalentRepository) @Service (TopTalentService)
  • @Component (TopTalentConverter, MyAnnotationValidator) @RestController (TopTalentController) @Repository (TopTalentRepository) @Service (TopTalentService)
  • @Component (TopTalentConverter, MyAnnotationValidator) @RestController (TopTalentController) @Repository (TopTalentRepository) @Service (TopTalentService)
  • @Component (TopTalentConverter, MyAnnotationValidator) @RestController (TopTalentController) @Repository (TopTalentRepository) @Service (TopTalentService)

Si tuviéramos clases adicionales anotadas con @Configuration, también se verificaría la configuración de Java.

Error común número 8: olvidarse de los perfiles


El problema que a menudo se encuentra al desarrollar servidores es la diferencia entre los diferentes tipos de configuraciones, generalmente las configuraciones industriales y de desarrollo. En lugar de cambiar manualmente los diversos parámetros de configuración cada vez que cambie de prueba a implementación de aplicación, una forma más eficiente sería utilizar perfiles.

Considere el caso cuando utiliza la base de datos en memoria para el desarrollo local y la base de datos MySQL en PROM. En esencia, esto significará que usará diferentes URL y (con suerte) diferentes credenciales para acceder a cada una de ellas. Veamos cómo se puede hacer esto con dos archivos de configuración diferentes:

APLICACIÓN DE ARCHIVO. YAML


 # set default profile to 'dev' spring.profiles.active: dev # production database details spring.datasource.url: 'jdbc:mysql://localhost:3306/toptal' spring.datasource.username: root spring.datasource.password: 

APLICACIÓN DE ARCHIVO-DEV.YAML


 spring.datasource.url: 'jdbc:h2:mem:' spring.datasource.platform: h2 

Aparentemente, no desea realizar ninguna acción accidental en su base de datos industrial mientras se mete con el código, por lo que tiene sentido establecer el perfil predeterminado en dev. Luego, en el servidor, puede anular manualmente el perfil de configuración especificando el parámetro -Dspring.profiles.active = prod para la JVM. Además, también puede establecer la variable de entorno del sistema operativo en el perfil predeterminado deseado.

Error común # 9: incapacidad para aceptar la inyección de dependencia


El uso adecuado de la inyección de dependencia en Spring significa que le permite unir todos sus objetos al escanear todas las clases de configuración requeridas; Esto es útil para desacoplar las relaciones y también facilita mucho las pruebas. En lugar de clases vinculantes, haciendo algo como esto:

 public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController() { this.topTalentService = new TopTalentService(); } } 


Dejamos que Spring haga el enlace por nosotros:

 public class TopTalentController { private final TopTalentService topTalentService; public TopTalentController(TopTalentService topTalentService) { this.topTalentService = topTalentService; } } 

Misko Hevery de Google Talk explica en detalle los "motivos" para la inyección de dependencia, así que veamos cómo se usa esto en la práctica. En la división de responsabilidades (Errores comunes # 3), creamos clases de servicio y controlador. Supongamos que queremos probar un controlador bajo el supuesto de que TopTalentService se está comportando correctamente. Podemos insertar un objeto simulado en lugar de la implementación real del servicio, proporcionando una clase de configuración separada:

 @Configuration public class SampleUnitTestConfig { @Bean public TopTalentService topTalentService() { TopTalentService topTalentService = Mockito.mock(TopTalentService.class); Mockito.when(topTalentService.getTopTalent()).thenReturn( Stream.of("Mary", "Joel") .map(TopTalentData::new).collect(Collectors.toList())); return topTalentService; } } 

Luego podemos incrustar el objeto simulado diciéndole a Spring que use SampleUnitTestConfig como proveedor de configuración:

 @ContextConfiguration(classes = { SampleUnitTestConfig.class }) 

Entonces esto nos permitirá usar la configuración de contexto para incrustar el bean personalizado en la prueba unitaria.

Error común # 10: falta de pruebas o pruebas incorrectas


A pesar del hecho de que la idea de las pruebas unitarias ha estado con nosotros durante mucho tiempo, muchos desarrolladores parecen "olvidar" hacer esto (especialmente si esto no es necesario), o simplemente dejarlo para más adelante. Obviamente, esto no es deseable, ya que las pruebas no solo deben verificar la exactitud de su código, sino que también sirven como documentación sobre cómo debe comportarse la aplicación en diferentes situaciones.

Cuando se prueban los servicios web, rara vez se realizan pruebas unitarias excepcionalmente "limpias", ya que la interacción a través de HTTP generalmente requiere llamar a DispatcherServlet Spring y ver qué sucede cuando se recibe la HttpServletRequest real (lo que lo convierte en una prueba de integración, con usando validación, serialización, etc.). REST Assured : Java DSL para probar fácilmente los servicios REST además de MockMVC ha demostrado ser una solución muy elegante. Considere el siguiente fragmento de código con inyección de dependencia:

 @RunWith(SpringJUnit4ClassRunner.class) @ContextConfiguration(classes = { Application.class, SampleUnitTestConfig.class }) public class RestAssuredTestDemonstration { @Autowired private TopTalentController topTalentController; @Test public void shouldGetMaryAndJoel() throws Exception { // given MockMvcRequestSpecification givenRestAssuredSpecification = RestAssuredMockMvc.given() .standaloneSetup(topTalentController); // when MockMvcResponse response = givenRestAssuredSpecification.when().get("/toptal/get"); // then response.then().statusCode(200); response.then().body("name", hasItems("Mary", "Joel")); } } 

SampleUnitTestConfig habilita la implementación simulada de TopTalentService en TopTalentController, mientras que todas las demás clases están conectadas usando la configuración estándar obtenida escaneando paquetes que tienen raíces en el paquete de la clase Aplicación. RestAssuredMockMvc simplemente se usa para crear un entorno ligero y enviar una solicitud GET al punto final / toptal / get.

Conviértete en un maestro de primavera


Spring es un marco poderoso con el que es fácil comenzar, pero que requiere dedicación y tiempo para lograr el dominio total. Si pasa tiempo conociendo el marco, sin duda aumentará su productividad a largo plazo y, en última instancia, lo ayudará a escribir un código más limpio y a convertirse en un mejor desarrollador.

Si está buscando recursos adicionales, Spring In Action es un libro de buenas prácticas que cubre muchos temas centrales de Spring.

Etiquetas
Java SpringFramework

Comentarios


Timothy Schimandle
En el n. ° 2, creo que en la mayoría de los casos se prefiere devolver un objeto de dominio. Su objeto personalizado de ejemplo es una de varias clases que tienen campos que queremos ocultar. Pero la gran mayoría de los objetos con los que trabajé no tienen esa restricción, y agregar la clase dto es solo un código innecesario.
En definitiva un buen artículo. Buen trabajo

ESPÍRITU a Timothy Schimandle,
estoy totalmente de acuerdo. Parece que se ha agregado una capa de código adicional innecesaria, creo que @JsonIgnore ayudará a ignorar los campos (aunque con fallas en las estrategias de detección de repositorio predeterminadas), pero en general esta es una gran publicación de blog. Orgulloso de tropezar ...

Arokiadoss Asirvatham
Dude, otro error común para principiantes: 1) Dependencia cíclica y 2) incumplimiento de las doctrinas básicas de la declaración de la Clase Singleton, como el uso de la variable de instancia en beans con alcance singleton.

Hlodowig
Con respecto al número 8, creo que los enfoques de los perfiles son muy insatisfactorios. A ver:

  • Seguridad: algunas personas dicen: si su repositorio fuera público, ¿habría claves / contraseñas secretas? Lo más probable es que sea así, siguiendo este enfoque. A menos que, por supuesto, agregue archivos de configuración a .gitignore, pero esta no es una opción seria.
  • Duplicación: cada vez que tengo configuraciones diferentes, necesito crear un nuevo archivo de propiedades, lo cual es bastante molesto.
  • Portabilidad: Sé que este es solo un argumento JVM, pero cero es mejor que uno. Infinitamente menos propenso a errores.

Traté de encontrar una manera de usar variables de entorno en mis archivos de configuración en lugar de "codificar" los valores, pero hasta ahora no he tenido éxito, creo que necesito investigar más.

Gran artículo Tony, ¡sigue con el buen trabajo!

Traducción completada: tele.gg/middle_java

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


All Articles