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

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

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

Комментарии (17)


  1. onets
    11.04.2024 13:33
    +4

    Чуть менее, чем каждый раз , когда я начинал с дженерик контроллеров / сервисов / репозиториев / и тому подобное - спустя некоторое время то тут то там появлялись специфичные вещи, которые не укладывались в общий подход и приходилось их переделывать на нормальные…


    1. gkislin Автор
      11.04.2024 13:33

      Если посмотрите - сам контроллер у меня не параметризирован + есть возможность напрямую вызывать специфичные методы репозитория и настраивать маппинг. Попробуйте попользовать и жду фидбэка- что не получилось сделать


      1. aleksandy
        11.04.2024 13:33

        За использование в контроллере репозиториев и базюшных сущностей нужно бить по рукам железной линейкой. Работа со слоем данных должна быть только в сераисах.


        1. gkislin Автор
          11.04.2024 13:33

          1. Все это очень проекто-специфично

          2. Никто не мешает отнаследоваться от BaseService и вызывать репозиторий оттуда


  1. gkislin Автор
    11.04.2024 13:33

    Простите, рука дрогнула, был вопрос про сложность использования. Ответ такой:

    1. Сложно ли пользоваться Spring Data Jpa? Упрощает ли он работу?

    2. Смотрели ли вы внутрь него, как он реализован?

    Я к тому, что для использования предложенного подхода вам нужно только делать код контроллера в конце статьи, что проще для понимания и использования, чем традиционный подход


  1. bugy
    11.04.2024 13:33

    @Override public final int hashCode() {

    return getEffectiveClass(this).hashCode();

    }

    Выглядит сурово. У всех ентити одного типа будет один и тот же хешкод?


    1. gkislin Автор
      11.04.2024 13:33
      +2

      Да. По ссылке jpa buddy посмотрите

      У себя на сайте они пишут, что с hashCode от ID будут проблемы, если сначала был NULL, а затем после сохранения появился ID, сгенерированный БД:

      "Once the id is generated (on its first save) the hashCode gets changed. So the HashSet looks for the entity in a different bucket and cannot find it. It wouldn’t be an issue if the id was set during the entity object creation (e.g. was a UUID set by the app), but DB-generated ids are more common."


      1. bugy
        11.04.2024 13:33

        Понял, критика не к вам, извините :)

        Особенно порадовало, что решение от Thorben Janssen им не подошло, т.к.

        The implementation proposed by Thorben Janssen returns the same hash code for all objects! This approach makes hashCode() completely meaningless.

        А вот возвращать один и тот же hashCode для обьектов одного и того же класса вполне норм. Это другое


        1. gkislin Автор
          11.04.2024 13:33

          Это не критика, за удобство JPA приходиться платить. Обычно не критично, но, как всегда, думаю есть особые случаи.


  1. Antharas
    11.04.2024 13:33

    Чем текущее решение лучше/практичнее Spring Data REST?
    Зачем выдумывать очередной велосипед, кроме как получение физического контроля над выполняемой логикой?


    1. gkislin Автор
      11.04.2024 13:33

      Spring Data REST - это вообще не про это
      Это Hateoas, который генерит код на основе репозиториев. Там как раз невозможно ничего кастомизировать


      1. Antharas
        11.04.2024 13:33

        А про что тогда 0_о? В данный момент вы делаете тоже самое что и указанный модуль. Поясните в чем же фундаментальные различия.


        1. gkislin Автор
          11.04.2024 13:33

          Вы не поняли статьи или сути Spring Data REST, я делаю все вручную, никаких автогенераций кода. НО без дублирования кода - композиция, наследование, стратегия и дженерики.


  1. iozhukau
    11.04.2024 13:33

    Я может не уловил сути. Если у нас простой CRUD с возможностью кастомизации, тогда чем к задаче не подходит Spring Data REST?


    1. gkislin Автор
      11.04.2024 13:33

      См ответ выше + у нас не простой CRUD- любые методы в репозиториях и контроллерах поддерживаются. Суть - не дублировать код на основе общих методов и маппинга.


      1. iozhukau
        11.04.2024 13:33

        Ну это не правда, там много чего кастомизируется. Логика спокойно наращивается, притом довольно чистым кодом т.к. завязана на события CRUD. + Если нужно как-то изменять представление сущностей, там вроде работает JPA Projections.

        Исходя из примеров кода, я вижу что для любого не CRUD действия, нужно переопределять метод или создавать промежуточный сервис в цепочку запрос-ответ. Я имею в виду, что всё, что не заложено в "дженерик" реализацию, нужно всё равно писать, но если это не нужно, то как бы есть уже готовое решение (Data REST).

        Да, это Hateoas и RESTFull, где не так удобны некоторые операции. Но если задача стоит уменьшить кодовую базу (дублирование) + время разработки, я думаю это валидный способ, воспользоваться существующим инструментом.

        А если задача построить большой монолит, то как бы я предполагаю, что хороший архитектор подумает о разработчиках\коллегах и базовые одинаковые действия выведет в слой абстракции.
        Всегда же приятнее, удобно добавлять фичи, а не страдать и рефакторить бесконечно или колупаться в копипастах с мыслями "Где же не переименовал\добавил\удалил что-то?".


        1. gkislin Автор
          11.04.2024 13:33

          Ну да, все верно- код выше как раз ближе всего к слою абстрации для большого монилита, можно вынести в модуль common. В Data REST кроме ресурсов на выходе делать маппинг на урове проекций очень муторно. И совсем некастомизируемо, например кэширование в сервисах или енкодинг пароля приходится чз приседания с Jackson делать. Используя абстракцию вы ничем не связаны- собственный биндинг, валидация, кэширование, настраиваемый маппинг и пр. фишки Spring, Mapstruct и просто Java кода.