Переместите ввод-вывод на окраины вашего приложения
Не подходите слишком близко к вводу/выводу.
Именно так я бы резюмировал доклад Скотта Власчина «Перемещение ввода-вывода на периферию вашего приложения» на конференции NDC в Лондоне 2024.
Если вы не знакомы с работами Скотта Влачина, он ведет сайт F# for Fun and Profit и много говорит о функциональном программировании. Он часто выступает на конференциях NDC.
Вот видео выступления на YouTube, если вы захотите его посмотреть.
Вот основные выводы из этого разговора.
Ввод-вывод – это зло: держите его на расстоянии
В идеальном мире весь код должен быть чистым. Те же входные данные возвращают одни и те же выходные данные без каких-либо побочных эффектов.
Но мы не в идеальном мире, и наш код полон примесей: получение текущего времени, доступ к сети и вызов баз данных.
Вместо стремления к 100% чистому коду рекомендуется убрать ввод-вывод (или примеси) из бизнес-логики или правил.
┌────────────────────────────────────┐
│ ┌─────┐ ┌────────┐ ┌─────┐ │
──┼─►│ I/O ├───►│ Logic ├───►│ I/O ├──┼─►
│ └─────┘ └────────┘ └─────┘ │
└────────────────────────────────────┘
Unit Tests
├────────────────┤
Integration Tests
├──────────────────────────────────┤
Когда мы смешиваем ввод-вывод с нашей предметной логикой, мы усложняем понимание и тестирование нашей предметной логики и повышаем вероятность ошибок.
Итак, давайте обратим внимание на функции без входов и выходов. Часто они где-то выполняют ввод-вывод.
Если вы думаете, что мы не пишем функции без выходных данных, давайте еще раз взглянем на наши репозитории.
Конечно, наши методы Create
или Update
могут возвращать идентификатор. Но они не детерминистичны. Если мы вставим одну и ту же запись дважды, мы получим разные идентификаторы или даже ошибку, если в наших таблицах есть уникальные ограничения.
Здесь рекомендуется писать код, который:
- Понятно: он получает на вход то, что ему нужно, и возвращает некоторый результат.
- Детерминированный: он возвращает одни и те же выходные данные при одних и тех же входных данных.
- Без побочных эффектов: внутри он ничего не делает.
Просто верните решение
Это пример, показанный в разговоре:
Допустим, нам нужно обновить личную информацию клиента. Если клиент меняет свой адрес электронной почты, мы должны отправить письмо с подтверждением. И, конечно же, нам следует обновить новое имя и адрес электронной почты в базе данных.
Вот как мы могли бы это сделать,
async static Task UpdateCustomer(Customer newCustomer)
{
var existing = await CustomerDb.ReadCustomer(newCustomer.Id); // 👈
if (existing.Name != newCustomer.Name
|| existing.EmailAddress != newCustomer.EmailAddress)
{
await CustomerDb.UpdateCustomer(newCustomer); // 👈
}
if (existing.EmailAddress != newCustomer.EmailAddress)
{
var message = new EmailMessage(newCustomer.EmailAddress, "Some message here...");
await EmailServer.SendMessage(message); // 👈
}
}
Мы смешиваем вызовы базы данных с нашим кодом принятия решений. IO «близок» к нашей бизнес-логике.
Конечно, мы могли бы возразить, что статические методы — плохая идея, и вместо этого передать два интерфейса: ICustomerDb
и IEmailServer
. Но мы по-прежнему смешиваем ввод-вывод с бизнес-логикой.
На этот раз мы рекомендуем создать императивную оболочку и просто вернуть решение из нашей бизнес-логики.
Вот как можно сообщить нашим клиентам, «просто возвращая решение».
// This is a good place for discriminated unions.
// But we still don't have them in C#. Sorry!
public abstract record UpdateCustomerDecision
{
public record DoNothing : UpdateCustomerDecision;
public record OnlyUpdateCustomer(Customer Customer) : UpdateCustomerDecision;
public record UpdateCustomerAndSendEmail(Customer Customer, EmailMessage Message) : UpdateCustomerDecision;
}
static UpdateCustomerDecision UpdateCustomer(Customer existing, Customer newCustomer)
{
UpdateCustomerDecision result = new UpdateCustomerDecision.DoNothing();
if (existing.Name != newCustomer.Name
|| existing.EmailAddress != newCustomer.EmailAddress) // 👈
{
result = new UpdateCustomerDecision.OnlyUpdateCustomer(newCustomer);
}
if (existing.EmailAddress != newCustomer.EmailAddress) // 👈
{
var message = new EmailMessage(newCustomer.EmailAddress, "Some message here...");
result = new UpdateCustomerDecision.UpdateCustomerAndSendEmail(newCustomer, message);
}
return result;
}
async static Task ImperativeShell(Customer newCustomer)
{
var existing = await CustomerDb.ReadCustomer(newCustomer.Id);
var result = UpdateCustomer(existing, newCustomer);
// 👆👆👆
// Nothing impure here
switch (result.Decision)
{
case DoNothing:
// Well, doing nothing...😴
break;
case UpdateCustomerOnly:
await CustomerDb.UpdateCustomer(result.Customer); // 🤖
break;
case UpdateCustomerAndSendEmail:
await CustomerDb.UpdateCustomer(result.Customer); // 🤖
await EmailServer.SendMessage(result.Message);
break;
}
}
Благодаря императивной оболочке нам не приходится иметь дело с вызовами базы данных и логикой электронной почты внутри нашего UpdateCustomer()
. И мы можем провести модульное тестирование без макетов.
В качестве примечания: UpdateCustomerDecision
и UpdateCustomerResult
являются простой альтернативой дискриминируемым объединениям. Подумайте о дискриминируемых объединениях, таких как перечисления, где каждый член может быть объектом другого типа.
В более сложных базах кода ImperativeShell()
будет похож на класс варианта использования или обработчик команд.
Чистый код не общается с внешним миром
Когда мы перемещаем ввод-вывод по краям, нашему чистому коду не нужна обработка исключений или асинхронная логика. Наш чистый код не общается с внешним миром.
Вот три запаха кода, на которые, поделился спикер, следует обратить внимание в коде нашего домена:
- Это асинхронно? Если да, то вы где-то выполняете ввод-вывод
- Это перехват исключений? Опять же, вы (вероятно) где-то выполняете ввод-вывод.
- Выдает ли это исключения? Почему бы не использовать правильное возвращаемое значение?
Если что-то из этого верно, мы выполняем ввод-вывод внутри нашего домена. И нам следует провести рефакторинг нашего кода. «Все руки на ваших станциях рефакторинга».
Вуаля! Это один из подходов к созданию чистой бизнес-логики, а не единственный подход.
Независимо от того, следуем ли мы портам и адаптерам, чистой архитектуре или функциональному ядру-императивной оболочке, цель состоит в том, чтобы абстрагировать зависимости и избежать «загрязнения» нашей бизнес-области.