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

8d2169ac92c439415d8139fa1c93f659.jpg

В рамках наших 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);
    }
}

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

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

© Habrahabr.ru