Pollito Blog
October 15, 2024

La opinión de Pollito acerca del desarrollo en Spring Boot 6: Lógica de negocio

Posted on October 15, 2024  •  8 minutes  • 1585 words  • Other languages:  English

Un poco de contexto

Esta es la sexta parte de la serie de blogs Spring Boot Development .

De momento hemos creado:

En este blog vamos a crear UsersService y UsersApiCacheService. ¡Comencemos!

1. Crear un @Mapper

Los mappers son una situación de “elige tu propia aventura” . El que yo uso es MapStruct .

Cree una interfaz @Mapper que reciba una lista de usuarios de jsonplaceholder y devuelva una lista de nuestros propios usuarios de microservicio.

import dev.pollito.post.model.User;
import java.util.List;
import org.mapstruct.Mapper;
import org.mapstruct.MappingConstants;

@Mapper(componentModel = MappingConstants.ComponentModel.SPRING)
public interface UserMapper {
  List<User> map(List<com.typicode.jsonplaceholder.model.User> users);
}

Mantenga la capa de integración de API separada de la capa del controlador

Si compruebas lo que está haciendo el mapper, está mapeando esto:

[
  {
    "address": {
      "city": "Gwenborough",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      },
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "zipcode": "92998-3874"
    },
    "company": {
      "bs": "harness real-time e-markets",
      "catchPhrase": "Multi-layered client-server neural-net",
      "name": "Romaguera-Crona"
    },
    "email": "Sincere@april.biz",
    "id": 1,
    "name": "Leanne Graham",
    "phone": "1-770-736-8031 x56442",
    "username": "Bret",
    "website": "hildegard.org"
  }
]

A esto:

[
  {
    "address": {
      "city": "Gwenborough",
      "geo": {
        "lat": "-37.3159",
        "lng": "81.1496"
      },
      "street": "Kulas Light",
      "suite": "Apt. 556",
      "zipcode": "92998-3874"
    },
    "company": {
      "bs": "harness real-time e-markets",
      "catchPhrase": "Multi-layered client-server neural-net",
      "name": "Romaguera-Crona"
    },
    "email": "Sincere@april.biz",
    "id": 1,
    "name": "Leanne Graham",
    "phone": "1-770-736-8031 x56442",
    "username": "Bret",
    "website": "hildegard.org"
  }
]

No, no fue un error, no escribí lo mismo dos veces. Screenshot2024-10-15112732

Quizás te preguntes… ¿Por qué? ¿Por qué realizar el proceso de mapeo en lugar de simplemente devolver la respuesta DTO de feignClient?

  1. Incluso si quisieras, no puedes: debido a la forma en que este proyecto depende del desarrollo impulsado por contratos con el uso de openapi-generator-maven-plugin .

    • Aunque sabemos que tanto el DTO de respuesta feignClient como el DTO de retorno @RestController tienen la misma estructura interna, desde el punto de vista del proyecto, esos dos son objetos diferentes que no tienen nada en común.
  2. De todos modos, sería una mala práctica: imaginemos que no utilizas el plugin y, en su lugar, escribes tus propios DTO a mano. Aquí tienes una lista de motivos por los que utilizar la misma clase para mapear la respuesta de feignClient y el retorno de @RestController es una mala idea:

    • Si utiliza el mismo DTO para ambos, cualquier cambio en la API externa (campos nuevos, campos obsoletos, etc.) podría afectar innecesariamente su código interno.
    • El uso de un DTO específico de @RestController le permite filtrar y adaptar la respuesta para exponer solo lo que realmente se necesita.
      • Esto evita la filtración de información confidencial o irrelevante y ayuda a reducir el tamaño de la carga útil, lo que mejora el rendimiento.
    • Los DTO de API externas a menudo necesitan mapear formatos o estructuras de datos que no son directamente útiles para su aplicación.
      • Por ejemplo, una API de terceros puede devolver fechas como cadenas, pero internamente es posible que desee trabajar con LocalDate.

Al tener DTO independientes, el código se vuelve más fácil de probar y mantener. Los cambios en los servicios externos no afectarán directamente la funcionalidad principal de su aplicación.

2. Crear un caché

Esto es opcional, pero se recomienda para nuestro caso específico. Estamos consumiendo una API cuya respuesta nunca cambia (o si lo hace, no nos importa). Entonces, ¿por qué no almacenar en caché la respuesta en lugar de preguntar lo mismo una y otra vez?

Ten en cuenta que el almacenamiento en caché puede generar respuestas obsoletas. En el mundo real, eso puede convertirse en un efecto secundario no deseado.

2.1. Agregar dependencias

Estas son:

Aquí te dejo un copy-paste listo para usar. Considera revisar la última versión.

Dento del tag <dependencies>:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-cache</artifactId>
</dependency>
<dependency>
    <groupId>com.github.ben-manes.caffeine</groupId>
    <artifactId>caffeine</artifactId>
    <version>3.1.8</version>
</dependency>

2.2 Agregar tiempo de expiración en application.yml

Bajo jsonplaceholder, cree una nueva propiedad “expiresAfter”.

application.yml

jsonplaceholder:
  baseUrl: https://jsonplaceholder.typicode.com/
  expiresAfter: 24 #hours
spring:
  application:
    name: user_manager_backend

No olvides agregarlo a la clase @ConfigurationProperties para que puedas tener acceso.

config/properties/JsonPlaceholderConfigProperties.java

import lombok.AccessLevel;
import lombok.Data;
import lombok.experimental.FieldDefaults;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.context.annotation.Configuration;

@Configuration
@ConfigurationProperties(prefix = "jsonplaceholder")
@Data
@FieldDefaults(level = AccessLevel.PRIVATE)
public class JsonPlaceholderConfigProperties {
  String baseUrl;
  Integer expiresAfter;
}

2.3. Crear una configuración de caché

config/CacheConfig.java

import com.github.benmanes.caffeine.cache.Caffeine;
import dev.pollito.user_manager_backend.config.properties.JsonPlaceholderConfigProperties;
import java.util.concurrent.TimeUnit;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.CacheManager;
import org.springframework.cache.annotation.EnableCaching;
import org.springframework.cache.caffeine.CaffeineCacheManager;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

@Configuration
@EnableCaching
@RequiredArgsConstructor
public class CacheConfig {
  private final JsonPlaceholderConfigProperties jsonPlaceholderConfigProperties;
  public static final String JSON_PLACEHOLDER_CACHE = "JSON_PLACEHOLDER_CACHE";

  @Bean
  public CacheManager cacheManager() {
    CaffeineCacheManager caffeineCacheManager = new CaffeineCacheManager(JSON_PLACEHOLDER_CACHE);
    caffeineCacheManager.setCaffeine(
        Caffeine.newBuilder()
            .expireAfterWrite(jsonPlaceholderConfigProperties.getExpiresAfter(), TimeUnit.HOURS));

    return caffeineCacheManager;
  }
}

2.4. Crear UsersApiCacheService e implementarlo

service/UsersApiCacheService.java

import com.typicode.jsonplaceholder.model.User;
import java.util.List;

public interface UsersApiCacheService {
  List<User> getUsers();
}

service/impl/UsersApiCacheServiceImpl.java


import static dev.pollito.user_manager_backend.config.CacheConfig.JSON_PLACEHOLDER_CACHE;

import com.typicode.jsonplaceholder.api.UserApi;
import com.typicode.jsonplaceholder.model.User;
import dev.pollito.user_manager_backend.service.UsersApiCacheService;
import java.util.List;
import lombok.RequiredArgsConstructor;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UsersApiCacheServiceImpl implements UsersApiCacheService {
  private final UserApi userApi;

  @Override
  @Cacheable(value = JSON_PLACEHOLDER_CACHE)
  public List<User> getUsers() {
    return userApi.getUsers();
  }
}

3. Crear UsersService e implementarlo

service/UsersService.java

import dev.pollito.user_manager_backend.model.SortDirection;
import dev.pollito.user_manager_backend.model.User;
import dev.pollito.user_manager_backend.model.UserSortProperty;
import dev.pollito.user_manager_backend.model.Users;

public interface UsersService {
  User findById(Long id);

  Users findAll(
      Integer pageNumber,
      Integer pageSize,
      UserSortProperty sortProperty,
      SortDirection sortDirection,
      String q);
}

service/impl/UsersServiceImpl.java

import dev.pollito.user_manager_backend.mapper.UserModelMapper;
import dev.pollito.user_manager_backend.model.Pageable;
import dev.pollito.user_manager_backend.model.SortDirection;
import dev.pollito.user_manager_backend.model.User;
import dev.pollito.user_manager_backend.model.UserSortProperty;
import dev.pollito.user_manager_backend.model.Users;
import dev.pollito.user_manager_backend.service.UsersApiCacheService;
import dev.pollito.user_manager_backend.service.UsersService;
import java.util.Comparator;
import java.util.List;
import java.util.NoSuchElementException;
import java.util.Objects;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.stereotype.Service;

@Service
@RequiredArgsConstructor
public class UsersServiceImpl implements UsersService {
  private final UsersApiCacheService userApi;
  private final UserModelMapper userModelMapper;

  @Override
  public Users findAll(
      Integer pageNumber,
      Integer pageSize,
      UserSortProperty sortProperty,
      SortDirection sortDirection,
      String q) {
    List<User> users = getUsersFromApi();
    users = filterUsers(q, users);
    users = sortUsers(users, sortProperty, sortDirection);

    return new Users()
        .content(usersSubList(users, pageNumber, pageSize))
        .pageable(new Pageable().pageNumber(pageNumber).pageSize(pageSize))
        .totalElements(users.size());
  }

  @Override
  public User findById(Long id) {
    return getUsersFromApi().stream()
        .filter(user -> user.getId().equals(id))
        .findFirst()
        .orElseThrow(NoSuchElementException::new);
  }

  private List<User> getUsersFromApi() {
    return userModelMapper.map(userApi.getUsers());
  }

  private static List<User> filterUsers(String q, List<User> users) {
    if (Objects.nonNull(q) && !q.isEmpty()) {
      users = users.stream().filter(user -> filterUsersPredicate(q, user)).toList();
    }
    return users;
  }

  private static boolean filterUsersPredicate(@NotNull String q, @NotNull User user) {
    String query = q.toLowerCase();
    return (Objects.nonNull(user.getEmail()) && user.getEmail().toLowerCase().contains(query))
        || (Objects.nonNull(user.getName()) && user.getName().toLowerCase().contains(query))
        || (Objects.nonNull(user.getUsername())
            && user.getUsername().toLowerCase().contains(query));
  }

  private static List<User> sortUsers(
      List<User> users, @NotNull UserSortProperty sortProperty, SortDirection sortDirection) {
    Comparator<User> comparator =
        switch (sortProperty) {
          case EMAIL -> Comparator.comparing(User::getEmail);
          case ID -> Comparator.comparing(User::getId);
          case NAME -> Comparator.comparing(User::getName);
          case USERNAME -> Comparator.comparing(User::getUsername);
        };

    if (SortDirection.DESC.equals(sortDirection)) {
      comparator = comparator.reversed();
    }

    return users.stream().sorted(comparator).toList();
  }

  private static @NotNull List<User> usersSubList(
      @NotNull List<User> users, Integer pageNumber, Integer pageSize) {
    int total = users.size();
    int fromIndex = Math.min(pageNumber * pageSize, total);
    int toIndex = Math.min(fromIndex + pageSize, total);

    return users.subList(fromIndex, toIndex);
  }
}

¿Por qué paginación?

4. Llamar los métodos en UsersController

Llame al método @Service en la clase @RestController.

controller/UsersController.java

import dev.pollito.user_manager_backend.api.UsersApi;
import dev.pollito.user_manager_backend.model.SortDirection;
import dev.pollito.user_manager_backend.model.User;
import dev.pollito.user_manager_backend.model.UserSortProperty;
import dev.pollito.user_manager_backend.model.Users;
import dev.pollito.user_manager_backend.service.UsersService;
import lombok.RequiredArgsConstructor;
import org.jetbrains.annotations.NotNull;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.RestController;

@RestController
@RequiredArgsConstructor
public class UsersController implements UsersApi {
  private final UsersService usersService;

  @Override
  public ResponseEntity<Users> findAll(
      Integer pageNumber,
      Integer pageSize,
      @NotNull UserSortProperty sortProperty,
      @NotNull SortDirection sortDirection,
      String q) {
    return ResponseEntity.ok(
        usersService.findAll(pageNumber, pageSize, sortProperty, sortDirection, q));
  }

  @Override
  public ResponseEntity<User> findById(Long id) {
    return ResponseEntity.ok(usersService.findById(id));
  }
}

Quiero que admires cómo la llamada a UsersService es solo una línea cada una.

Solo la línea de retorno… Es hermoso de ver, es casi arte. Para cosas como estas me gusta programar.

FCpH_GYVkAE9NaT

4. Ejecutar la aplicación y ver los resultados

Realice un Maven clean and compile, y ejecute la clase de aplicación principal. Luego haga una solicitud GET a localhost:8080/users .

Screenshot2024-10-15180830

Repita la solicitud. La memoria caché entrará en acción y debería encontrar un tiempo de respuesta mucho más rápido: pasó de 1014 ms a 13 ms, un aumento de velocidad del 98,7%. Screenshot2024-10-15181728

Siguiente lectura

La opinión de Pollito acerca del desarrollo en Spring Boot 7: Unit tests

Hey, check me out!

You can find me here