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