Абстрактний CRUD від сховища до контролера: що ще можна зробити за допомогою Spring + Узагальнення

Зовсім недавно майнула стаття колеги, який описав досить цікавий підхід до поєднання Узагальнення і можливостей 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.ОК))
 .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.

Читайте також  Курс молодого бійця PostgreSQL

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 буде залишатися в абстракції. І це буде хорошим результатом, тому що чим більше ми генерим зайвого коду вручну, тим більше часу ми витрачаємо на монотонну роботу і тим вище ризик помилки або друкарської помилки.

Сподіваюся, стаття була корисна, дякую за увагу.

Степан Лютий

Обожнюю технології в сучасному світі. Хоча частенько і замислююся над тим, як далеко вони нас заведуть. Не те, щоб я прям і знаюся на ядрах, пікселях, коллайдерах і інших парсеках. Просто приходжу в захват від того, що може в творчому пориві вигадати людський розум.

You may also like...

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *