В рамках наших 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); } }
Если проект большой и контролеров много, энономия получается существенная. Меньше кода, меньше ошибок, проще и понятнее код.
Приятного кодирования и приглашаем на наши курсы!
