Недавно мой коллега показал мне, как работает MediatR – реализация .NET шаблона проектирования mediator , который, вкратце, “определяет объект, который инкапсулирует, как взаимодействует набор объектов” ¹.
Идея показалась интересной, учитывая, что, если только фреймворк не требует какого-то определенного способа настройки файлов и их обязанностей, мой подход по умолчанию для большинства проектов заключается в использовании 3-уровневой архитектуры или многоуровневой архитектуры .
Не поймите меня неправильно: я все еще думаю, что наличие уровня презентации, бизнеса и постоянства (или какой-то его вариант) все еще работает в большинстве случаев и должно быть правильным решением 2. Однако у меня был личный проект ((написано на Java с помощью SpringBoot и Spring Data JPA) который вырос в размерах и сложности и хотел попробовать что-то “новое” во время рефакторинга.
Первым делом нужно было найти Java-эквивалент Mediator. В поисках альтернатив я наткнулся на Pipeline . Исходный код был более или менее таким, как я ожидал от предлагаемой функциональности, так что пришло время начать играть с ним.
Прежде чем мы перейдем к части рефакторинга, я хотел бы показать, как изначально выглядел проект:
Структура пакета (вдохновлена шаблоном BCE )
├── boundary │ └── ColorsResource.java ├── control │ ├── ColorRepository.java │ └── ColorService.java └── entity └── Color.java
ColorResource.java
:
@RestController @RequestMapping("/colors") public class ColorsResource { private final ColorService service; public ColorsResource(final ColorService service) { this.service = service; } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntityinsert(@Valid @RequestBody Color color) { var data = service.insert(color); var uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") .buildAndExpand(data.getId()).toUri(); return ResponseEntity.created(uri).body(data); // (other endpoints omitted) }
ColorResource.java ||:
@Service public class ColorsService { private final ColorRepository repository; public ColorsService(final ColorRepository repository) { this.repository = repository; } public Color insert(Color color) { try { return repository.save(color); } catch (DataIntegrityViolationException e) { throw new ResponseStatusException( HttpStatus.CONFLICT, MessageFormat.format("A Color with name [{0}] already exists.", color.getName())); } // (other methods omitted) }
ColorResource.java ||:
@Repository public interface ColorRepository extends JpaRepository{ }
ColorResource.java ||:
@Entity @Table(name = "colors") public class Color { private static final long serialVersionUID = 4L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Integer id; @NotBlank(message = "Color.name cannot be blank.") @Column(unique = true, nullable = false) private String name; @Column(name = "created_date", nullable = false, updatable = false) private OffsetDateTime createdDate; @Column(name = "updated_date") private OffsetDateTime updatedDate; // (constructor, getters and setters omitted) }
Конвейер, Команды и обработчики
Первым шагом для начала использования Pipeline является добавление зависимости Maven и репозитория, в котором она размещена:
an.awesome pipelinr 0.5 central bintray https://jcenter.bintray.com
Второй шаг – создать управляемый экземпляр конвейера, который будет внедрен в наши классы:
@Configuration public class PipelinrProvider { @Bean public Pipeline getPipeline( ObjectProvidercommandHandlers, ObjectProvider notificationHandlers, ObjectProvider middlewares) { return new Pipelinr() .with(commandHandlers::stream) // Registers Handlers .with(notificationHandlers::stream) // Registers Notifications (not covered here) .with(middlewares::orderedStream); // Registers Middlewares (not covered here) } }
Теперь, когда это сделано, мы можем приступить к рефакторингу.
Команды
Грубо говоря, команды – это объекты, которые инкапсулируют детали запроса и фиксируют его намерение. В нашем случае мы выставляем конечную точку POST с намерением создать новый цвет. Итак, давайте создадим две команды: одну для захвата намерения создания цвета и одну с результатом намерения (созданный цвет).
Итак, давайте создадим две команды: одну для захвата намерения создания цвета и одну с результатом намерения (созданный цвет).
public final class ColorResponseCommand { private final int id; private final String name; private final OffsetDateTime createdDate; private ColorResponseCommand(final int id, final String name, final OffsetDateTime createdDate) { this.id = id; this.name = name; this.createdDate = createdDate; } public static ColorResponseCommand from(final Color color) { return new ColorResponseCommand(color.getId(), color.getName(), color.getCreatedDate()); } // getters omitted }
Итак, давайте создадим две команды: одну для захвата намерения создания цвета и одну с результатом намерения (созданный цвет).
public class CreateColorRequestCommand implements Command{ // Only name is required when creating a Color @NotBlank(message = "Name must not be blank.") private final String name; public CreateColorRequestCommand(final String name) { this.name = name; } public String getName() { return name; } }
Обратите внимание, что приведенный выше класс реализует командный интерфейс из конвейера, определяя ответ как его параметризованный тип.
Обработчики
Обработчики в конвейере – это классы, которые знают, как и что делать с командами. Это очень похоже на то, как работает схема цепочки ответственности. Поскольку наш вариант использования заключается в создании цвета, давайте добавим обработчик для этого:
@Component public class CreateColorHandler implements Command.Handler{ private final ColorRepository repository; public CreateColorHandler(final ColorRepository repository) { this.repository = repository; } @Override public ColorResponseCommand handle(final CreateColorRequestCommand command) { var color = new Color(); color.setName(command.getName()); try { var createdColor = repository.save(color); return ColorResponseCommand.from(createdColor); } catch (DataIntegrityViolationException e) { throw new ResponseStatusException( HttpStatus.CONFLICT, MessageFormat .format("A Color with name [{0}] already exists.", command.getName())); } } }
Есть несколько вещей, на которые следует обратить внимание:
- Наш управляемый класс реализует команду. Обработчик, который определяет, что этот обработчик получит команду
Create Color Request
и ответит командойColorResponseCommand
. - Реализовав упомянутый выше интерфейс и сделав экземпляр управляемым (
@Component
annotation), этот обработчик будет зарегистрирован и доступен для использования экземпляром конвейера (подробнее об этом в следующем разделе).
Трубопровод
Теперь пришло время привести обработчик и команды в действие. Как было замечено ранее, наш ColorsResource.java
делегировал создание Цвета
введенному ColorService.java
. Вместо этого давайте используем экземпляр конвейера, который мы определили ранее. Он работает как посредник между командами и обработчиками, поскольку знает, какому обработчику отправлять каждую команду в приложении.
@RestController @RequestMapping("/colors") public class ColorsResource { private final Pipeline pipeline; public ColorsResource(final Pipeline pipeline) { this.pipeline = pipeline; } @PostMapping(consumes = MediaType.APPLICATION_JSON_VALUE) public ResponseEntityinsert(@Valid @RequestBody CreateColorRequestCommand command) { var data = pipeline.send(command); var uri = ServletUriComponentsBuilder.fromCurrentRequest().path("/{id}") .buildAndExpand(data.getId()).toUri(); return ResponseEntity.created(uri).body(data); } }
С помощью этой новой настройки мы теперь можем избавиться от нашего ColorService.java
– который в реальном мире имел бы несколько методов – и вместо этого создавал обработчики и команды для каждого варианта использования.
Давайте рассмотрим некоторые плюсы и минусы принятия такого решения.
Плюсы
- Разъединяет вызывающего и получателя команды, полагаясь на посредника для отправки ее нужному обработчику.
- Модульное тестирование упрощается за счет наличия лишь минимального количества зависимостей обработчиков и команд.
- Имена файлов отражают варианты использования приложений, что делает навигацию более понятной (спорно).
- Каждая команда предоставляет только атрибуты, необходимые для конкретного варианта использования, избегая полей отправки и получения, которые не должны быть применимы/доступны во всех вариантах использования.
- Уменьшает вероятность конфликтов слияния в больших командах, поскольку варианты использования децентрализованы из одного служебного файла.
- Упрощает добавление новых функций/вариантов использования в приложение. Поскольку, скорее всего, потребуются новые обработчики и команды, это может не повлиять на существующий код.
Аферы
- Среда IDE не будет вашим другом при попытке перейти к правильному обработчику с помощью метода
pipeline.send()
. - Каждый новый вариант использования требует создания нового обработчика и по крайней мере одной новой команды, в результате чего в проекте появляется множество классов.
- В зависимости от реализации объекта посредника/конвейера это может оказать некоторое влияние на производительность.
Вывод
Давайте будем честны: это много кода для чего-то такого простого, как CRUD-операции. Но этот подход эффективен в определенных сценариях, перечисленных в разделе “Плюсы” выше.
В то время как приведенные здесь примеры основывались на конвейере, существуют и другие альтернативы (такие как https://github.com/kmhigashioka/ShortBus ), которые охватывают одни и те же концепции.
Окончательный код можно найти по адресу https://github.com/davibandeira/pipelinr-demo .
Дайте мне знать ваши мысли об этом и других подходах 😉
1 Гамма, Эрих – Шаблоны проектирования: Элементы многоразового объектно-ориентированного программного обеспечения: Аддисон-Уэсли, 1994. ² ЯГНИК и ПОЦЕЛУЙ принципы. 3 https://refactoring.guru/design-patterns/command ⁴ https://sourcemaking.com/design_patterns/chain_of_responsibility
Оригинал: “https://dev.to/davibandeira/from-services-to-command-and-handlers-use-case-driven-code-2dmc”