Контроллеры на дженериках: пишем кода в 3 раза меньше

от автора

В рамках наших Java курсов «Из Middle в Senior» (предыдущие посты Миграция Java Spring Boot на Kotlin и «Работа с документами в Java») недавно вышел новый курс Startup: Spring Boot веб-приложение с хостингом и инфраструктурой на основе эволюции нашей платформы онлайн-обучения с 2016г.
В рамках курса есть много подходов, сокращающих количество кода/усилий разработчиков. Один из них: сквозная параметризация от сервисов до репозиториев, позволяющая сокращать количество кода ~3х. Код приведен на Java, но общий подход может быть использован в любом языке с параметризацией. Кому интересно — добро пожаловать.

Репозитории

Все, кто работает со Spring Data знает, насколько упростилось кодирование за счет готовых параметризованных интерфейсов работы с БД. Мы также можем создавать собственные наследники этих интерфейсов, расширяя базовый функционал. Например, для JPA:

@NoRepositoryBean public interface BaseRepository<T> extends JpaRepository<T, Integer> {      @Transactional     @Modifying     @Query("DELETE FROM #{#entityName} e WHERE e.id=:id")     int delete(int id);      @SuppressWarnings("all") // transaction invoked     default void deleteExisted(int id) {         if (delete(id) == 0) {             throw new NotFoundException("Entity with id=" + id + " not found");         }     }      default T getExisted(int id) {         return findById(id).orElseThrow(() -> new NotFoundException("Entity with id=" + id + " not found"));     } }

Мапперы

Обычно в большом приложении много преобразований Entity <-> Transfer Object (TO). Для автоматизации этого кода есть много библиотек-мапперов. Мне больше всего нравится инструмент автогенерации кода MapStruct. Кроме прямого маппинга, MapStruct также умеет преобразовывать списки и обновлять поля классов. Создаем базовый параметризированный интерфейс мапперов:

public interface BaseMapper<E, T> {      E toEntity(T to);      List<E> toEntityList(Collection<T> tos);      E updateFromTo(T to, @MappingTarget E entity);      T toTo(E entity);      List<T> toToList(Collection<E> entities); }

Создаем общую конфигурацию для всех мапперов. Здесь мапперы создаются как бины Spring и на незамапленные поля предупреждения не выдаются:

@MapperConfig(         componentModel = MappingConstants.ComponentModel.SPRING,         unmappedTargetPolicy = ReportingPolicy.IGNORE ) public interface MapStructConfig { }

Поля, которые маппятся 1:1 указывать не надо, для остальных есть разные опции. Пример маппера User <-> UserTo:

@Mapper(config = MapStructConfig.class) public interface UserMapper extends BaseMapper<User, UserTo> {      @Mapping(target = "email", expression = "java(to.getEmail().toLowerCase())")     @Override     User toEntity(UserTo to);      @Mapping(target = "id", ignore = true)     @Mapping(target = "email", expression = "java(to.getEmail().toLowerCase())")     @Override     User updateFromTo(UserTo to, @MappingTarget User entity); }

Мапперы генерируются на фазе compile, при сборке maven в каталоге \target\generated-sources можно посмотреть код реализации. Если маппинг происходить 1:1 без дополнительных подстроек, переопределять методы BaseMapper не требуется.

Общие классы и интерфейсы данных

Сделаем общие классы и интерфейсы для данных, чтобы не дублировать их в каждом объекте. equals/hashCode для сущности сделаем на основе последних рекомендаций от jpa buddy

public interface HasId {     Integer getId();      void setId(Integer id);      @JsonIgnore     default boolean isNew() {         return getId() == null;     }      // doesn't work for hibernate lazy proxy     default int id() {         Assert.notNull(getId(), "Entity must has id");         return getId();     } }  @NoArgsConstructor @AllArgsConstructor(access = AccessLevel.PROTECTED) @Data public abstract class BaseTo implements HasId {     @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473     protected Integer id;      @Override     public String toString() {         return getClass().getSimpleName() + ":" + id;     } }  @MappedSuperclass @Access(AccessType.FIELD) @Getter @Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PROTECTED) public abstract class BaseEntity implements HasId {      @Id     @GeneratedValue(strategy = GenerationType.IDENTITY)     @Schema(accessMode = Schema.AccessMode.READ_ONLY) // https://stackoverflow.com/a/28025008/548473     protected Integer id;      //  https://jpa-buddy.com/blog/hopefully-the-final-article-about-equals-and-hashcode-for-jpa-entities-with-db-generated-ids/     @Override     public boolean equals(Object o) {         if (this == o) return true;         if (o == null || getEffectiveClass(this) != getEffectiveClass(o)) return false;         return getId() != null && getId().equals(((BaseEntity) o).getId());     }      @Override     public final int hashCode() {         return getEffectiveClass(this).hashCode();     }      @Override     public String toString() {         return getClass().getSimpleName() + ":" + id;     } }

Добавим утильные классы для работы с данными

@UtilityClass public class Util {     public static Class getEffectiveClass(Object o) {         return o instanceof HibernateProxy ? ((HibernateProxy) o).getHibernateLazyInitializer().getPersistentClass() : o.getClass();     } }  @UtilityClass public class ValidationUtil {      public static void checkNew(HasId bean) {         if (!bean.isNew()) {             throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must be new (id=null)");         }     }      //  Conservative when you reply, but accept liberally (http://stackoverflow.com/a/32728226/548473)     public static void assureIdConsistent(HasId bean, int id) {         if (bean.isNew()) {             bean.setId(id);         } else if (bean.id() != id) {             throw new IllegalRequestDataException(bean.getClass().getSimpleName() + " must has id=" + id);         }     } }

Сервисы

Наконец, мы можем связать вместе мапперы и репозитории, получив параметризованные сервисы с наиболее частыми запросами контроллеров. Иногда при создании или при обновлении из Entity требуются дополнительные преобразования, добавим опциональные методы преобразования BaseService.prepareForSave  и BaseService.prepareForUpdate (при обновлении из TO преобразования делаются в маппере). Параметризация маппера <M> и репозитория <R> дает возможность брать их из сервиса без необходимости кастинга:

public class BaseService<E extends HasId, T extends BaseTo, R extends BaseRepository<E>, M extends BaseMapper<E, T>> {     protected final Logger log = LoggerFactory.getLogger(getClass());      public BaseService(R repository, M mapper) {         this(repository, mapper, null, null);     }      public BaseService(R repository, M mapper,                        Function<E, E> prepareForSave, BiFunction<E, E, E> prepareForUpdate) {         this.repository = repository;         this.mapper = mapper;         this.prepareForSave = prepareForSave;         this.prepareForUpdate = prepareForUpdate;     }      @Getter     protected final R repository;     @Getter     protected final M mapper;     private final Function<E, E> prepareForSave;     private final BiFunction<E, E, E> prepareForUpdate;      public T getTo(int id) {         log.info("getTo by id={}", id);         return toTo(repository.getExisted(id));     }      public E get(int id) {         log.info("get by id={}", id);         return repository.getExisted(id);     }      public List<E> getAll() {         return getAll(Sort.unsorted());     }      public List<E> getAll(Sort sort) {         log.info("getAll");         return repository.findAll(sort);     }      public List<T> getAllTos() {         return getAllTos(Sort.unsorted());     }      public List<T> getAllTos(Sort sort) {         log.info("getAllTos");         return toToList(repository.findAll(sort));     }      public E createFromTo(T to) {         log.info("createFromTo {}", to);         ValidationUtil.checkNew(to);         E entity = toEntity(to);         if (prepareForSave != null) entity = prepareForSave.apply(entity);         return repository.save(entity);     }      public E create(E entity) {         log.info("create {}", entity);         ValidationUtil.checkNew(entity);         if (prepareForSave != null) entity = prepareForSave.apply(entity);         return repository.save(entity);     }      public void delete(int id) {         log.info("delete by id={}", id);         repository.deleteExisted(id);     }      @Transactional     public E update(E entity, int id) {         log.info("update {} with id={}", entity, id);         ValidationUtil.assureIdConsistent(entity, id);         if (prepareForUpdate != null) {             E dbEntity = repository.getExisted(entity.id());             entity = prepareForUpdate.apply(entity, dbEntity);         }         return repository.save(entity);     }      @Transactional     public E updateFromTo(T to, int id) {         log.info("updateFromTo {} with id={}", to, id);         ValidationUtil.assureIdConsistent(to, id);         E dbEntity = repository.getExisted(to.id());         return repository.save(updateFromTo(to, dbEntity));     }      // delegate to mapper     public E toEntity(T to) {         return mapper.toEntity(to);     }      public List<E> toEntityList(Collection<T> tos) {         return mapper.toEntityList(tos);     }      public E updateFromTo(T to, E entity) {         return mapper.updateFromTo(to, entity);     }      public T toTo(E entity) {         return mapper.toTo(entity);     }      public List<T> toToList(List<E> entities) {         return mapper.toToList(entities);     } }

Контроллеры

Общий код создание ответов POST вынесем в WebUtil:

@UtilityClass public class WebUtil {     // create ResponseEntity     public static <T extends HasId> ResponseEntity<T> createdResponse(String url, T created) {         return createdResponse(url + "/{id}", created, created.getId());     }      public static <T extends HasId> ResponseEntity<T> createdResponse(String url, T created, Object... params) {         URI uriOfNewResource = ServletUriComponentsBuilder.fromCurrentContextPath()                 .path(url).buildAndExpand(params).toUri();         return ResponseEntity.created(uriOfNewResource).body(created);     } }

Наконец, посмотрим, сколько кода нам теперь потребуется на примере написания обычного REST контроллера:

@RestController @RequestMapping(value = AdminUserController.REST_URL, produces = MediaType.APPLICATION_JSON_VALUE) @Slf4j public class AdminUserController {     @Autowired     protected UserService service;      static final String REST_URL = SecurityConfig.API_PATH + "/admin/users";      @GetMapping("/{id}")     public User get(@PathVariable int id) {         return service.get(id);     }      @DeleteMapping("/{id}")     @ResponseStatus(HttpStatus.NO_CONTENT)     public void delete(@PathVariable int id) {         service.delete(id);     }      @GetMapping     public List<User> getAll() {         log.info("getAll");         return service.getAll(Sort.by(Sort.Direction.ASC, "email"));     }      @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)     public ResponseEntity<User> createWithLocation(@Valid @RequestBody User user) {         User created = service.create(user);         return createdResponse(REST_URL, created);     }      @PutMapping(value = "/{id}", consumes = MediaType.APPLICATION_JSON_VALUE)     @ResponseStatus(HttpStatus.NO_CONTENT)     public void update(@Valid @RequestBody User user, @PathVariable int id) {         service.update(user, id);     }      @GetMapping("/by-email")     public User getByEmail(@RequestParam String email) {         log.info("getByEmail {}", email);         return service.getRepository().getExistedByEmail(email);     } }

Если проект большой и контролеров много, энономия получается существенная. Меньше кода, меньше ошибок, проще и понятнее код.

Приятного кодирования и приглашаем на наши курсы!


ссылка на оригинал статьи https://habr.com/ru/articles/807047/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *