В этой статье я расскажу, как использую команды и обработчики, чтобы код был удобным и аккуратным. Я стремился не избавиться от
if-elseif-else
, а найти более подходящее решение.Помните, что один-единственный подход не позволит полностью избавиться от традиционного программирования с ветвлением. Нужно создать себе целый инструментарий, а потом выбирать из него нужные техники.
Рассматриваемый в статье способ — просто один из многих.
Сама по себе конструкция
if-else
не так уж плоха. Мы просто попали в ситуацию «когда в руках молоток, всё вокруг кажется гвоздями». В основах программирования мы изучаем условные операторы и многим разработчикам не удаётся перерасти их использование.Однако
if-else
и switch
зачастую неидеальны. Программисты обычно пренебрегают более качественными решениями, например, полиморфическим исполнением и словарями.Мы стремимся избегать традиционного условного ветвления
Я написал статью, в которой предложил способ замены условного ветвления полиморфическим исполнением. Прежде чем переходить к командам и обработчикам, я вкратце повторю некоторые примеры из предыдущей статьи.
Вот пример того, чего бы мы хотели избежать: некрасивое, плохо расширяемое ветвление, зависящее от дискретного значения.
Сложное ветвление, вызывающее головную боль
Кроме уродливого использования
if-elseif-else
основная проблема заключается в том, что нужно добавлять ветвление для каждой новой причины обновления. Это явное нарушение принципов открытости/закрытости и единственной ответственности.По сути, каждую ветвь можно преобразовать в собственную команду и соответствующие обработчики.
Давайте посмотрим, как это возможно.
Использование команд и обработчиков для упрощения приложения
Репозиторий GitHub
Я не буду устраивать вам лекцию о теории команд, запросов и обработчиков. По этой теме существует множество ресурсов. Вместо этого я составил краткий список их возможных преимуществ.
- Тестирование становится намного проще. Не нужно дополнять существующие тесты для учёта новых возможностей. Если команда требует дополнительной обработки, мы создаём ещё один обработчик, который тестируется независимо.
- Несколько обработчиков может обрабатывать одну команду. Как вы уже наверно заметили, передача одной команды может вызвать один или несколько обработчиков. Таким образом, можно добавлять новую функциональность, не касаясь старого кода.
- Простые классы. Команда — это набор свойств без сеттеров. Ошибиться здесь будет сложно. Аналогично, обработчик — это класс только с одним публичным методом.
- Действия контроллера подчиняются шаблону Request-Delegate-Response. Они не содержат никакой бизнес-логики и слоёв хранения данных.
Если вы практикуете event storming, то наверняка уже вполне понимаете, почему замечательны команды и обработчики.
Вышеупомянутую статью я завершил намёком на то, как можно использовать динамическую диспетчеризацию команд для устранения необязательного ветвления. А сейчас вы увидите один из способов реализации команд и обработчиков.
Наконец-то код!
Чтобы можно было следить за кодом, позвольте вкратце рассказать, чего мы хотим достичь.
Мы хотим сказать: «Так, должно произойти нечто. Вот значения. Мне не важно, кто этим займётся, просто дайте знать, когда всё будет готово».
Существует три критерия, которые нам нужно удовлетворить:
- Команду можно выполнить так, чтобы вызывающий не знал конкретных обработчиков.
- Необходимо выполнить каждый обработчик, соответствующий команде.
- Новые команды или этапы обработки не должны требовать изменения существующего кода.
Начнём с самого внешнего слоя и дойдём до самого дна
Если смотреть не с точки зрения контроллера, то нам не важно знать конкретные обработчики и даже интерфейсы. Действие должно быть сосредоточено только на данных.
Для этого нужно, чтобы действие контроллера было как можно более простым, например, как показано ниже.
Обновление конечной точки электронной почты
Примерный смысл кода должен быть вам понятен, хоть это и C# aspnetcore. Если вкратце, это действие контроллера — конечная точка и её реализация.
Я знаю, о чём вы думаете: «а где обработка ошибок?» Не волнуйтесь, вы правы, она должна здесь быть, но ради краткости я вырезал её, чтобы можно было сосредоточиться на концепции выполнения команд.
Контроллер имеет зависимость от
CommandDispatcher
. Мы доберёмся до этого класса позже. Класс диспетчера имеет единственный метод DispatchAsync(command)
. Это пока всё, что вам нужно знать.Это позволяет нашему контроллеру заниматься только проверкой правильности получаемых им данных и отправкой команд. Как обрабатываются данные после запуска команды, контроллеру совершенно не важно.
Каждая «причина обновления» (update reason) требует собственной конечной точки со своей формой данных, то есть отправляемой командой.
На этом этапе для реализации новых функций, например «update username», достаточно создать новую конечную точку и отправить команду.
Конечная точка Update username
При использовании такого подхода создание конечных точек становится чрезвычайно тривиальным, и это замечательно.
По сути, наша конечная точка уже готова.
Итак, давайте двигаться дальше.
В командах и обработчиках находится вся бизнес-логика
При работе с командами нам нужно заботиться о двух аспектах: неизменяемости и корректности данных.
Это просто старые добрые классы, в них нет ничего сложного. Взгляните на этот
ChangeEmailCommand
.Старый добрый класс команды
Очевидно, что этот класс команды выполняет не очень много действий. В этом-то и весь смысл. Его задача — передаваться обработчику.
Итак, мы добрались до обработчика. Изучите представленный ниже код. Далее я объясню, что в нём происходит.
Простой обработчик команды, который легко тестировать.
Во-первых, у нас есть интерфейс, который должны реализовать все обработчики команды. Интерфейс важен, когда нам нужно обнаружение динамических типов. Скоро мы к этому вернёмся.
Во-вторых, я создал простой обработчик, знающий, как работать с
ChangeEmailCommand
. Обобщённый параметр ICommandHandlerAsync сообщает нам «этот обработчик нужно вызывать при передаче команды ‘change email command’».Вы ощущаете, насколько удобен для тестирования этот класс? В этом весь смысл. Его ужасно легко будет тестировать. Этот класс очень сфокусирован — один метод, одна зависимость.
Если вы привыкли к классам «Service», то знаете, какими безумными иногда становятся конструкторы. Такой подход полностью устраняет возможность разбухания конструктора.
Сам диспетчер, невероятно простой и надёжный
Вы уже видели интерфейс диспетчера, он понятен и прост. Но давайте освежим воспоминания.
Публичный интерфейс диспетчера команды
Прежде чем мы приступим к разбору реализации, давайте повторим, чего нам нужно достичь при помощи
CommandDispatcher
.Мы хотим сказать: «вот команда, найди все соответствующие ей обработчики и передай команду каждому из них».
Это означает, что для каждого класса команды нам нужен список соответствующих обработчиков команды. В коде мы можем выразить эту необходимость при помощи словаря, в котором ключом будет тип команды, а значением — список обработчиков.
Не торопитесь, разберитесь во всём тщательно. Освоившим ООП это может показаться очень лёгким, но кому-то понадобится время. Ниже я опишу то, что мы делаем.
Диспетчер команды с поиском по словарю
Здесь нет ничего особо сложного, но не знающих ООП людей код может запутать. Не волнуйтесь, вероятно, вы запросто сможете найти способ реализации на функциональном языке.
Сам код в описании не нуждается. Это обычный C#. Важно только его назначение.
Самая важная часть — наличие механизма сопоставления команд с обработчиками. Для этого я использую словарь.
Каждый ключ — это тип команды. Соответствующее ему значение — это список обработчиков, реализующих
ICommandHandlerAsync<commandType>
.При вызове, например,
DispatchAsync(ChangeEmailCommand)
, диспетчер пытается найти внутри словаря ключ «Type: CommandHandler» и вернуть список зарегистрированных обработчиков.Затем вызывается каждый обработчик.
Вот и всё. Это довольно просто.
Соединяем это всё с обнаружением динамических типов
На самом деле, на данном этапе ещё ничего не работает.
Нам нужно передать где-нибудь Dictionary диспетчеру команды.
В идеале нужно создать диспетчер команды и его словарь где-нибудь при запуске приложения и зарегистрировать его при помощи фреймворка внедрения зависимостей.
Помните наш третий критерий? Новые функции/требования не должны требовать изменения существующего кода.
Если регистрировать новые команды и обработчики при помощи словаря вручную, то мы, по сути, модифицируем имеющийся код. Это вполне может нас устраивать, но чаще всего не устраивает.
Если вы стремитесь к совершенствованию кода, то можете попробовать обнаружение динамических типов.
У нас снова есть код на C#, который совершенно не относится к делу, но, возможно, покажется кому-то интересным.
Достаточно просто знать, что этот код будет находить все обработчики команды, регистрировать их с помощью контейнера зависимостей, создавать словарь и передавать этот словарь новому экземпляру диспетчера команды, каждый раз, когда
CommandDispatcher
потребуется, например, контроллеру.Обнаружение динамических типов при запуске программы
При такой схеме нет необходимости изменять существующий код, даже при создании новой команды или обработчика. Всё привязывается при запуске приложения.
Да, это выглядит запутанным, и на первый взгляд покажется, что поддержка такого кода будет настоящим кошмаром.
Я писал код таким образом намеренно. Естественно, чтобы он был более приятным, нужно было бы провести рефакторинг с извлечением методов. Однако для демонстрации это вполне приемлемо.
Смысл в том, что написав этот код один раз, вы больше не должны будете его касаться. Достаточно написать юнит-тесты, и всё будет в порядке.
На правах рекламы
Эпичные серверы — это VPS на Windows или Linux с мощными процессорами семейства AMD EPYC и очень быстрыми NVMe дисками Intel. Спешите заказать!
welovelain
Расширять, а не изменять — кажется буква O из SOLID.