Абстрактный CRUD от репозитория до контроллера: что ещё можно сделать при помощи Spring + Generics

от автора

Совсем недавно на Хабре мелькнула статья коллеги, который описал довольно интересный подход к совмещению Generics и возможностей Spring. Мне она напомнила один подход, который я использую для написания микросервисов, и именно им я решил поделиться с читателями.

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

Сразу ресурсы.
Ветка, как я не делаю: standart_version.
Подход, о котором рассказывается в статье, в ветке abstract_version.

Я собрал проект через Spring Initializr, добавив фреймворки JPA, Web и H2. Gradle, Spring Boot 2.0.5. Этого будет вполне достаточно.

Для начала, рассмотрим классический вариант транспорта от контроллера до репозитория и обратно, лишённый всякой дополнительной логики. Если же Вы хотите перейти к сути подхода, проматывайте вниз до абстрактного варианта. Но, всё же, рекомендую прочитать статью целиком.

Классический вариант.

В ресурсах примера представлены несколько сущностей и методов для них, но в статье пусть у нас будет только одна сущность User и только один метод save(), который мы протащим от репозитория через сервис до контроллера. В ресурсах же их 7, а вообще Spring CRUD / JPA Repository позволяют использовать около дюжины методов сохранения / получения / удаления плюс Вы можете пользоваться, к примеру, какими-то своими универсальными. Также, мы не будем отвлекаться на такие нужные вещи, как валидацию, мапинг dto и прочее. Это Вы сможете дописать сами или изучить в других статьях Хабра.

Domain:

@Entity public class User implements Serializable {      private Long id;     private String name;     private String phone;      @Id     @GeneratedValue     public Long getId() {         return id;     }      public void setId(Long id) {         this.id = id;     }      @Column(nullable = false)     public String getName() {         return name;     }      public void setName(String name) {         this.name = name;     }      @Column     public String getPhone() {         return phone;     }      public void setPhone(String phone) {         this.phone = phone;     } //equals, hashcode, toString }

Repository:

@Repository public interface UserRepository extends CrudRepository<User, Long> { }

Service:

public interface UserService {      Optional<User> save(User user); }

Service (имплементация):

@Service public class UserServiceImpl implements UserService {      private final UserRepository userRepository;      @Autowired     public UserServiceImpl(UserRepository userRepository) {         this.userRepository = userRepository;     }      @Override     public Optional<User> save(User user) {         return Optional.of(userRepository.save(user));     } }

Controller:

@RestController @RequestMapping("/user") public class UserController {      private final UserService service;      @Autowired     public UserController(UserService service) {         this.service = service;     }      @PostMapping     public ResponseEntity<User> save(@RequestBody User user) {         return service.save(user).map(u -> new ResponseEntity<>(u, HttpStatus.OK))                 .orElseThrow(() -> new UserException(                         String.format(ErrorType.USER_NOT_SAVED.getDescription(), user.toString())                 ));     } }

У нас получился некий набор зависимых классов, которые помогут нам оперировать сущностью User на уровне CRUD. Это в нашем примере по одному методу, в ресурсах их больше. Этот нисколько не абстрактный вариант написания слоёв представлен в ветке standart_version.

Допустим, нам нужно добавить ещё одну сущность, скажем, Car. Мапить на уровне сущностей мы их друг к другу не будем (если есть желание, можете замапить).

Для начала, создаём сущность.

@Entity public class Car implements Serializable {      private Long id;     private String brand;     private String model;      @Id     @GeneratedValue     public Long getId() {         return id;     }      public void setId(Long id) {         this.id = id;     } //геттеры, сеттеры, equals, hashcode, toString }

Потом создаём репозиторий.

public interface CarRepository extends CrudRepository<Car, Long> { }

Потом сервис…

public interface CarService {      Optional<Car> save(Car car);      List<Car> saveAll(List<Car> cars);      Optional<Car> update(Car car);      Optional<Car> get(Long id);      List<Car> getAll();      Boolean deleteById(Long id);      Boolean deleteAll(); }

Потом имплементация сервиса…… Контроллер………

Да, можно просто скопипастить те же методы (они же у нас универсальные) из класса User, потом поменять User на Car, потом проделать то же самое с имплементацией, с контроллером, далее на очереди очередная сущность, а там уже выглядывают ещё и ещё… Обычно устаёшь уже на второй, создание же служебной архитектуры для пары десятков сущностей (копипастинг, замена имени сущности, где-то ошибся, где-то опечатался…) приводит к мукам, которые вызывает любая монотонная работа. Попробуйте как-нибудь на досуге прописать двадцать сущностей и Вы поймёте, о чём я.

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

Итак, абстракции на основе типовых параметров.

Смысл данного подхода заключается в том, чтобы вынести всю логику в абстракцию, абстракцию привязать к типовым параметрам интерфейса, а в бины инжектить другие бины. И всё. Никакой логики в бинах. Только инжект других бинов. Этот подход предполагает написать архитектуру и логику один раз и не дублировать её при добавлении новых сущностей.

Начнём с краеугольного камня нашей абстракции — абстрактной сущности. Именно с неё начнётся цепочка абстрактных зависимостей, которая послужит каркасом сервиса.

У всех сущностей есть как минимум одно общее поле (обычно больше). Это ID. Вынесем это поле в отдельную абстрактную сущность и унаследуем от неё User и Car.

AbstractEntity:

@MappedSuperclass public abstract class AbstractEntity implements Serializable {      private Long id;      @Id     @GeneratedValue     public Long getId() {         return id;     }      public void setId(Long id) {         this.id = id;     } }

Не забудьте пометить абстракцию аннотацией @MappedSuperclass — Hibernate тоже должен узнать, что это абстракция.

User:

@Entity public class User extends AbstractEntity {      private String name;     private String phone;          //... }

С Car, соответственно, то же самое.

В каждом слое у нас, помимо бинов, будет один интерфейс с типовыми параметрами и один абстрактный класс с логикой. Кроме репозитория — благодаря специфике Spring Data JPA, здесь всё будет намного проще.

Первое, что нам потребуется в репозитории — общий репозиторий.

CommonRepository:

@NoRepositoryBean public interface CommonRepository<E extends AbstractEntity> extends CrudRepository<E, Long> { }

В этом репозитории мы задаём общие правила для всей цепочки: все сущности, участвующие в ней, будут наследоваться от абстрактной. Далее, для каждой сущности мы должны написать свой репозиторий-интерфейс, в котором обозначим, с какой именно сущностью будет работать эта цепочка репозиторий-сервис-контроллер.

UserRepository:

@Repository public interface UserRepository extends CommonRepository<User> { }

На этом, благодаря особенностям Spring Data JPA, настройка репозитория заканчивается — всё будет работать и так. Далее следует сервис. Мы должны создать общий интерфейс, абстракцию и бин.

CommonService:

public interface CommonService<E extends AbstractEntity, R extends CommonRepository<E>> {      R getRepository(); //ключевой метод инициализации нужного нам репозитория      Optional<E> save(E entity); //какое-то количество нужных нам методов }

AbstractService:

public abstract class AbstractService<E extends AbstractEntity, R extends CommonRepository<E>>         implements CommonService<E, R> {      @Override     public Optional<E> save(E entity) {         return Optional.of(getRepository().save(entity));    //здесь мы используем репозиторий, который заинжектим позже     }  //другие методы, переопределённые из интерфейса }

Здесь мы переопределяем все методы, кроме getRepository(). Таким образом, мы уже используем репозиторий, который мы ещё не определили. Мы пока не знаем, какая сущность будет обработана в этой абстракции и какой репозиторий нам потребуется.

UserService:

@Service public class UserService extends AbstractService<User, UserRepository> {      private final UserRepository repository;      @Autowired     public UserService(UserRepository repository) {         this.repository = repository;     }      @Override     public UserRepository getRepository() {         return repository;     } }

В бине мы делаем две заключительные вещи: явно определяем сущность и инжектим необходимый для неё репозиторий.

При помощи интерфейса и абстракции мы создали магистраль, по которой будем гонять все сущности. В бине же мы подводим к магистрали развязку, по которой будем выводить нужную нам сущность на магистраль.

Контроллер строится по тому же принципу: интерфейс, абстракция, бин.

CommonController:

public interface CommonController<         E extends AbstractEntity,         R extends CommonRepository<E>,         S extends CommonService<E, R>> {      S getService();      @PostMapping     ResponseEntity<E> save(@RequestBody E entity);  //остальные методы }

AbstractController:

public abstract class AbstractController<         E extends AbstractEntity,         R extends CommonRepository<E>,         S extends CommonService<E, R>> implements CommonController<E, R, S> {      @Override     public ResponseEntity<E> save(@RequestBody E entity) {         return getService().save(entity).map(ResponseEntity::ok)                 .orElseThrow(() -> new SampleException(                         String.format(ErrorType.ENTITY_NOT_SAVED.getDescription(), entity.toString())                 ));     }  //другие методы }

UserController:

@RestController @RequestMapping("/user") public class UserController extends AbstractController<User, UserRepository, UserService> {      private final UserService service;      @Autowired     public UserController(UserService service) {         this.service = service;     }      @Override     public UserService getService() {         return service;     } }

Это вся структура. Она пишется один раз.

Что дальше?

И вот теперь давайте представим, что у нас появилась новая сущность, которую мы уже унаследовали от AbstractEntity, и нам нужно прописать для неё такую же цепочку. На это у нас уйдёт минута. И никаких копипаст и исправлений.

Возьмём уже унаследованный от AbstractEntity Car.

CarRepository:

@Repository public interface CarRepository extends CommonRepository<Car> { }

CarService:

@Service public class CarService extends AbstractService<Car, CarRepository> {      private final CarRepository repository;      @Autowired     public CarService(CarRepository repository) {         this.repository = repository;     }      @Override     public CarRepository getRepository() {         return repository;     } }

CarController:

@Controller @RequestMapping("/car") public class CarController extends AbstractController<Car, CarRepository, CarService> {      private final CarService service;      @Autowired     public CarController(CarService service) {         this.service = service;     }      @Override     public CarService getService() {         return service;     } }

Как мы видим, копирование одинаковой логики состоит в простом добавлении бина. Не надо заново писать логику в каждом бине с изменением параметров и сигнатур. Они написаны один раз и работают в каждом последующем случае.

Заключение.

Конечно, в примере описана этакая сферическая ситуация, в которой CRUD для каждой сущности имеет одинаковую логику. Так не бывает — какие-то методы Вам всё равно придётся переопределять в бине или добавлять новые. Но это будет происходить от конкретных потребностей обработки сущности. Хорошо, если процентов 60 от общего количества методов CRUD будет оставаться в абстракции. И это будет хорошим результатом, потому что чем больше мы генерим лишнего кода вручную, тем больше времени мы тратим на монотонную работу и тем выше риск ошибки или опечатки.

Надеюсь, статья была полезна, спасибо за внимание.


ссылка на оригинал статьи https://habr.com/post/423741/


Комментарии

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

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