Недавно мой коллега показал мне, как работает 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 ResponseEntity insert(@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(
ObjectProvider commandHandlers,
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. - Реализовав упомянутый выше интерфейс и сделав экземпляр управляемым (
@Componentannotation), этот обработчик будет зарегистрирован и доступен для использования экземпляром конвейера (подробнее об этом в следующем разделе).
Трубопровод
Теперь пришло время привести обработчик и команды в действие. Как было замечено ранее, наш 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 ResponseEntity insert(@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”