
В прошлый раз я разобрал два примера (раз, два), как можно перейти от императивной валидации входных значений к декларативной. Второй пример действительно «слишком много знает» про аспекты хранения и имеет подводные камни (раз, два). Альтернатива – разбить валидацию на 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<string, object>(); for(var i = 0; i < failures.Length; i++) { dict[(i + 1).ToString()] = failures[i]; } Data = new ReadOnlyDictionary<string, object>(dict); } public Failure(string message) { Message = message; } public Failure(string message, IDictionary<string, object> data) { Message = message; Data = new ReadOnlyDictionary<string, object>(data); } public string Message { get; } public ReadOnlyDictionary<string, object> Data { get; protected set; } }
Объявим специализированные классы-наследники для ошибок валидации и прав доступа.
public class ValidationFailure: Failure { public ValidationResult[] ValidationResults { get; } public ValidationFailure(IEnumerable<ValidationResult> validationResults) : base(ValidationResultsToStrings(validationResults)) { ValidationResults = validationResults?.ToArray(); if (ValidationResults == null || !ValidationResults.Any()) { throw new ArgumentException(nameof(validationResults)); } Data = new ReadOnlyDictionary<string, object>( ValidationResults.ToDictionary( x => x.MemberNames.Join(","), x => (object)x.ErrorMessage)); } private static string ValidationResultsToStrings( IEnumerable<ValidationResult> 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) => result.IsFaulted; public static bool operator true(Result result) => !result.IsFaulted; 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<IActionResult>(Ok, x => BadRequest(x.Message));
Или для кейса из примера Cкотта: получить запрос, выполнить валидацию, обновить информацию в БД и в случае успеха отправить email с подтверждением так:
public IActionResult Post(ChangeUserNameCommand command) { var res = command.Validate(); if (res.IsFaulted) return res; return ChangeUserName(command) .OnSuccess(SendEmail) .Return<IActionResult>(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<TDestination> Select<TSource, TDestination>( this Result<TSource> source, Func<TSource, TDestination> selector) => source.IsFaulted ? new Result<TDestination>(source.Failure) : selector(source.Value); public static Result<TDestination> SelectMany<TSource, TDestination>( this Result<TSource> source, Func<TSource, Result<TDestination>> selector) => source.IsFaulted ? new Result<TDestination>(source.Failure) : selector(source.Value); public static Result<TDestination> SelectMany<TSource, TIntermediate, TDestination>( this Result<TSource> result, Func<TSource, Result<TIntermediate>> inermidiateSelector, Func<TSource, TIntermediate, TDestination> resultSelector) => result.SelectMany<TSource, TDestination>(s => inermidiateSelector(s) .SelectMany<TIntermediate, TDestination>(m => resultSelector(s, m))); }
Выглядит немного непривычно, зато можно строить цепочки вызовов и объединять их в один pipe. При этом все проверки
if(result.IsFaulted) выполняются «под капотом» с помощью LINQ-синтаксиса.public Result<UserNameChangedEvent> Declarative(ChangeUserNameCommand command) => from validatedCommand in command.Validate() from domainEvent in ChangeUserName(validatedCommand).OnSuccess(SendEmail) select domainEvent;
Заключение
Я не призываю отказаться от исключений. Это очень хороший иснструмент для, сюрприз, «исключительных ситуаций» — ошибок, которых ну мы совсем не ждали. Они позволяют предотвратить переход системы в неопределенное состояние и могут служить отличным индикатором штатной / аварийной работы приложения. Однако, используя исключения повсеместно, мы лишаем себя этого инструмента. Регрулярная ситуация по определению не может считаться исключительной.
В версии C# на сегодняшний день нет встроенного в язык механизма для работы с «не исключительными» ситуациями, т.е. ошибками, которые наверняка возникнут и которые мы должны обрабатывать. Возможно, в будущих версиях мы получим такие возможности. Резервировать специальные типы исключений как «не исключительные ситуации» или вводить другие специализированные типы, например, как в этой статье, решать вам.
Только зарегистрированные пользователи могут участвовать в опросе. Войдите, пожалуйста.
Как выглядит LINQ-синтаксис не для коллекций?
29.47%Странно28
18.95%Хорошо читается18
20%Плохо читается19
4.21%Похоже на do в Haskel4
52.63%Наркомания50
Проголосовали 95 пользователей. Воздержались 39 пользователей.
