Контроллеры на дженериках: пишем кода в 3 раза меньше
В рамках наших Java курсов «Из Middle в Senior» (предыдущие посты Миграция Java Spring Boot на Kotlin и «Работа с документами в Java») недавно вышел новый курс Startup: Spring Boot веб-приложение с хостингом и инфраструктурой на основе эволюции нашей платформы онлайн-обучения с 2016 г.
В рамках курса есть много подходов, сокращающих количество кода/усилий разработчиков. Один из них: сквозная параметризация от сервисов до репозиториев, позволяющая сокращать количество кода ~3х. Код приведен на Java, но общий подход может быть использован в любом языке с параметризацией. Кому интересно — добро пожаловать.
Репозитории
Все, кто работает со Spring Data знает, насколько упростилось кодирование за счет готовых параметризованных интерфейсов работы с БД. Мы также можем создавать собственные наследники этих интерфейсов, расширяя базовый функционал. Например, для JPA:
@NoRepositoryBean
public interface BaseRepository extends JpaRepository {
@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 toEntity(T to);
List toEntityList(Collection tos);
E updateFromTo(T to, @MappingTarget E entity);
T toTo(E entity);
List toToList(Collection 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 {
@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
преобразования делаются в маппере). Параметризация маппера
и репозитория
дает возможность брать их из сервиса без необходимости кастинга:
public class BaseService, M extends BaseMapper> {
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 prepareForSave, BiFunction 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 prepareForSave;
private final BiFunction 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 getAll() {
return getAll(Sort.unsorted());
}
public List getAll(Sort sort) {
log.info("getAll");
return repository.findAll(sort);
}
public List getAllTos() {
return getAllTos(Sort.unsorted());
}
public List 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 toEntityList(Collection 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 toToList(List entities) {
return mapper.toToList(entities);
}
}
Контроллеры
Общий код создание ответов POST вынесем в WebUtil
:
@UtilityClass
public class WebUtil {
// create ResponseEntity
public static ResponseEntity createdResponse(String url, T created) {
return createdResponse(url + "/{id}", created, created.getId());
}
public static ResponseEntity 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 getAll() {
log.info("getAll");
return service.getAll(Sort.by(Sort.Direction.ASC, "email"));
}
@PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE)
public ResponseEntity 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);
}
}
Если проект большой и контролеров много, энономия получается существенная. Меньше кода, меньше ошибок, проще и понятнее код.
Приятного кодирования и приглашаем на наши курсы!