Про ошибки и исключения
В прошлый раз я разобрал два примера (раз, два), как можно перейти от императивной валидации входных значений к декларативной. Второй пример действительно «слишком много знает» про аспекты хранения и имеет подводные камни (раз, два). Альтернатива — разбить валидацию на 3 части:
- Модел байндинг: ожидали
int
, пришелstring
— возвращаем 400 - Валидация значений: поле email, должно быть в формате
your@mail.com
, а пришло123Petya
— возвращаем 422 - Валидация бизнес-правил: ожидали что корзина пользователя активна, а она в архиве. Возвращаем 422
К сожалению стандартный механизм байндинга ASP.NET MVC не различает ошибки несоответствия типа (получилиstring
вместоint
) и валидаци, поэтому если вы хотите различать 400 и 422 коды ответа, то придется это сделать самостоятельно. Но речь не об этом.
Как слой бизнес-логики может вернуть в контроллер сообщение об ошибке?
Самый распространенный по мнению Хабра способ (раз, два, три) — выбросить исключение. Таким образом между понятием «ошибка» и «исключение» ставится знак равно. Причем «ошибка» трактуется в широком смысле слова: это не только валидация, но и проверка прав доступа и бизнес-правил. Так ли это? Является ли любая ошибка «исключительной ситуацией»? Если вы когда-нибудь сталкивались с бухгалтерским или налоговым учетом, то наверняка знаете, что существует специальный термин «корректировка». Он означает, что в прошлом отчетном периоде были поданы неверные сведения и их необходимо исправить. То есть в сфере учета, без которой бизнес не может существовать в принципе, ошибки — объекты первого класса. Для них введены специальные термины. Можно ли назвать их исключительными ситуациями? Нет. Это нормальное поведение. Люди ошибаются. Программисты — просто чересчур оптимистичный народ. Мы просто никогда не снимаем розовых очков.
Исключение = ошибка?
Хорошо, возможно «исключения» — это просто неудачное название, а на самом деле они отлично подходят для работы с «ошибками». Даже MSDN определяет «исключения» как «ошибки времени выполнения». Давайте проверим. Что происходит с программой, если в ней происходит необработанное исключение? Аварийное завершение. Веб-приложения не завершаются лишь потому что на самом деле все необработанные исключения обрабатываются глобально. Все серверные платформы предоставляют возможность подписаться на «необработанные» ошибки. Должна ли программа завершаться в случае ошибки в бизнес-логике? В ряде случае да, например, если вы разрабатываете ПО для высокочастотного трейдинга и что-то пошло не так в алгоритме торговли. Не важно, как быстро ты принимаешь решения, если они неверные. А в случае ошибки в пользовательском вводе? Нет, мы должны вывести пользователю осмысленное сообщение. Таким образом, ошибки бывают фатальными или «не очень». Использовать один тип для обозначения и тех и других чревато.
Представьте, что у вас на поддержке два проекта. Оба логируют все необработанные исключения в БД. В первом исключения случаются крайне редко: 1–2 раза в месяц, а во втором сотни в день. В первом случае вы будете очень внимательно изучать логи. Если в логе что-то появилось, значит есть какая-то фундаментальная проблема и в определенных случаях система может переходить в неопределенное состояние. Во втором соотношение сигнал / шум «сломано». Как узнать система работает нормально или вошла в зону турбулентности, если в логах всегда полно ошибок?
Мы можем создать тип BusinessLogicException
и логировать их отдельно (или не логировать). Потом сделать аналогичный финт для HttpException
, DbValidationException
и других. Хм, надо бы запомнить какие исключения нужно ловить, а какие нет. Точно, в Java же есть checked exceptions, давайте завезем в .NET! Надо только еще учесть, что не все исключения можно поймать и обработать и не забыть про особенности работы с исключениями в TPL. И как его, ну этот перформанс.
Исключение = goto?
Еще один аргумент против повсеместного использования исключений — схожесть с goto. Нет никакой возможности узнать где в цепочке вызовов оно будет поймано, ведь сигнатура метода не раскрывает какие исключения могут быть выброшены внутри. Более того, сигнатуры методов в языках с исключениями не договаривают. Было бы правильнее писать не RequestDto -> IActionResult
, а RequestDto -> IActionResult | Exception
: метод может выполниться успешно или что-то может пойти не так.
Обработка исключений в трехзвенной архитектуре
Раз мы точно не знаем где именно произошла ошибка, то мы не можем хорошо ее обработать, сформировать осмысленное сообщение пользователю или применить компенсирующее действие.
Таким образом, если в слое бизнес-логике для всех типов «ошибок» используются исключения, мы должны будем либо оборачивать каждый метод контроллера в try / catch
— блок, либо переопределить метод обработки на уровне приложения. Первый вариант плох тем, что приходится повсеместно дублировать try / catch
и следить за типами отлавливаемых ошибок. Второй — тем, что мы теряем контекст выполнения.
Скотт Влашин предложил альтернативный подход к работе с ошибками в своем докладе Railway Oriented Programming (перевод на Хабре), а vkhorikov адаптировал для C#. Я взял на себя смелость слегка доработать этот вариант.
Дорабатываем Result
public class Result
{
public bool Success { get; private set; }
public string Error { get; private set; }
public bool Failure
{
get { return !Success; }
}
protected Result(bool success, string error)
{
Contracts.Require(success || !string.IsNullOrEmpty(error));
Contracts.Require(!success || string.IsNullOrEmpty(error));
Success = success;
Error = error;
}
//...
}
Тип string
не совсем удобен для работы с ошибками. Заменим строку на тип Failure
. В отличие от варианта Скотта Failure
будет не union-type, а обычный класс. Pattern matching для работы с ошибками заменим на полиморфизм. Для того, чтобы сохранить дополнительные сведения об ошибке будем использовать свойство Data
. Часто эти данные нужно просто сериализовать, поэтому конкретный тип не так важен.
public class Failure
{
public Failure(params Failure[] failures)
{
if (!failures.Any())
{
throw new ArgumentException(nameof(failures));
}
Message = failures.Select(x => x.Message).Join(Environment.NewLine);
var dict = new Dictionary();
for(var i = 0; i < failures.Length; i++)
{
dict[(i + 1).ToString()] = failures[i];
}
Data = new ReadOnlyDictionary(dict);
}
public Failure(string message)
{
Message = message;
}
public Failure(string message, IDictionary data)
{
Message = message;
Data = new ReadOnlyDictionary(data);
}
public string Message { get; }
public ReadOnlyDictionary Data { get; protected set; }
}
Объявим специализированные классы-наследники для ошибок валидации и прав доступа.
public class ValidationFailure: Failure
{
public ValidationResult[] ValidationResults { get; }
public ValidationFailure(IEnumerable validationResults)
: base(ValidationResultsToStrings(validationResults))
{
ValidationResults = validationResults?.ToArray();
if (ValidationResults == null || !ValidationResults.Any())
{
throw new ArgumentException(nameof(validationResults));
}
Data = new ReadOnlyDictionary(
ValidationResults.ToDictionary(
x => x.MemberNames.Join(","),
x => (object)x.ErrorMessage));
}
private static string ValidationResultsToStrings(
IEnumerable validationResults)
=> validationResults
.Select(x => x.ErrorMessage)
.Join(Environment.NewLine);
}
Перегружаем операторы и прячем Value
Добавим в Result
перегрузку операторов &, | и true и false
, чтобы работали &&
и ||
. Закроем value и вместо этого предоставим функцию Return
. Теперь невозможно ошибиться и не проверить свойство IsFaulted
: метод обязывает привести к типу TDestination
как параметр T
, так и Failure
. Это решает проблему с кодами возврата, которые можно забыть проверить. Результат просто нельзя получить, не обработав вариант с ошибкой.
public class Result
{
public static implicit operator Result (Failure failure)
=> new Result(failure);
// https://stackoverflow.com/questions/5203093/how-does-operator-overloading-of-true-and-false-work
public static bool operator false(Result result) => false;
public static bool operator true(Result result) => false;
public static Result operator &(Result result1, Result result2)
=> Result.Combine(result1, result2);
public static Result operator |(Result result1, Result result2)
=> result1.IsFaulted ? result2 : result1;
public Failure Failure { get; private set; }
public bool IsFaulted => Failure != null;
}
В контексте web-операции реализация метода преобразования может выглядеть так:
result.Return(Ok, x => BadRequest(x.Message));
Или для кейса из примера Cкотта: получить запрос, выполнить валидацию, обновить информацию в БД и в случае успеха отправить email с подтверждением так:
public IActionResult Post(ChangeUserNameCommand command)
{
var res = command.ValidateToResult();
if (res.IsFaulted) return res;
return ChangeUserName(command)
.OnSuccess(SendEmail)
.Return(Ok, x => BadRequest(x.Message));
}
Поддержка LINQ-синтаксиса (на любителя)
Если шагов будет больше, то строку if(res.IsFaulted) return res;
придется повторять после каждого шага. Хотелось бы этого избежать. Тут как нельзя кстати цикл статей Эрика Липперта о природе SelectMany
и слове на букву М. Вообще LINQ-синтаксис поддерживает не только IEnumerable
, но и любые другие типы. Главное реализовать SelectMany
aka Bind
. Добавим немного страшного кода с шаблонами. Здесь я не буду вдаваться в подробности как работает bind
. Если интересно, прочитайте у Липперта или Влашина.
public static class ResultExtensions
{
public static Result Select(this Result source,
Func selector)
=> source.IsFaulted
? new Result(source.Failure)
: selector(source.Value);
public static Result SelectMany(this Result source,
Func> selector)
=> source.IsFaulted
? new Result(source.Failure)
: selector(source.Value);
public static Result SelectMany(
this Result result,
Func> inermidiateSelector,
Func resultSelector)
=> result.SelectMany(s => inermidiateSelector(s)
.SelectMany(m => resultSelector(s, m)));
}
Выглядит немного непривычно, зато можно строить цепочки вызовов и объединять их в один pipe. При этом все проверки if(result.IsFaulted)
выполняются «под капотом» с помощью LINQ-синтаксиса.
public Result Declarative(ChangeUserNameCommand command) =>
from validatedCommand in command.ValidateToResult()
from domainEvent in ChangeUserName(validatedCommand).OnSuccess(SendEmail)
select domainEvent;