В прошлый раз я разобрал два примера (раз, два), как можно перейти от императивной валидации входных значений к декларативной. Второй пример действительно «слишком много знает» про аспекты хранения и имеет подводные камни (раз, два). Альтернатива – разбить валидацию на 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) => 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<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# на сегодняшний день нет встроенного в язык механизма для работы с «не исключительными» ситуациями, т.е. ошибками, которые наверняка возникнут и которые мы должны обрабатывать. Возможно, в будущих версиях мы получим такие возможности. Резервировать специальные типы исключений как «не исключительные ситуации» или вводить другие специализированные типы, например, как в этой статье, решать вам.
Комментарии (20)
Szer
22.01.2018 21:13Зачем пытаться из C# сделать F#?
Тем более этот SQL синтаксис
from x in xs select x
выглядит чужеродно для не коллекций.
Bonart
22.01.2018 22:32Чего только люди ни придумают лишь бы класс Exception не использовать. А в нем уже есть все для обработки ошибок.
И про исключения очередные мифы дядюшки Римуса.marshinov Автор
22.01.2018 23:09Что конкретно вы считаете «мифом»?
Bonart
22.01.2018 23:35Что конкретно вы считаете «мифом»?
Все типовые доводы против исключений в вашей статье.
Возьмем один самых распространенных и вредных:
ошибки – объекты первого класса
Нет. Объекты первого класса — данные, ошибки — просчеты при работе с данными, дефекты — разница между неверными и правильными данными, сбой — результат обработки неверных данных без коррекции.
Это частный случай более общего мифа о равноценности отработки основного алгоритма и обработке сбоев.
На деле же все наоборот:
- Если не реализован основной алгоритм — у вас вообще ничего нет, никакого продукта и никакой ценности не создано. Про обработку ошибок речь даже не заходит.
- Если не реализована обработка ошибок — вы несете убытки при сбоях. Это может быть очень неприятно, но программа без обработки ошибок все равно имеет определенную ценность. Более того, в львиной доле вариантов использования этого вполне достаточно.
- Сложность основного алгоритма в общем случае — существенная. Сложность обработки ошибок даже в самых запутанных случаях — акцидентная по Бруксу.
marshinov Автор
23.01.2018 10:11ошибки – объекты первого класса
Вы вырвали из контекста. Полная фраза такая:
Является ли любая ошибка «исключительной ситуацией»? Если вы когда-нибудь сталкивались с бухгалтерским или налоговым учетом, то наверняка знаете, что существует специальный термин «корректировка». Он означает, что в прошлом отчетном периоде были поданы неверные сведения и их необходимо исправить. То есть в сфере учета, без которой бизнес не может существовать в принципе, ошибки – объекты первого класса. Для них введены специальные термины. Можно ли назвать их исключительными ситуациями? Нет. Это нормальное поведение. Люди ошибаются.
В налоговом и бух.учетах «корректировки» — такие же участники предметной области как и «отчетный период» и «подаваемые сведения». Более того, если налоговая находит ошибки в отчетности, то вы обязаны подать корректировку и оплатить штраф. Т.е. для обработки ошибок есть отдельный бизнес-процесс. Это не миф, это реальность в странах, где существует налоговая система, коих большинство.
Если не реализована обработка ошибок — вы несете убытки при сбоях. Это может быть очень неприятно, но программа без обработки ошибок все равно имеет определенную ценность. Более того, в львиной доле вариантов использования этого вполне достаточно
Knight capital потеряла $440.000.000 за 30 минут из-за ошибки. Да она была не в программе, а из-за человеческого фактора при выкладке. Кто знает, если бы в их ПО была система самодиагностики оно бы аварийно завершилось и компания понесла бы убытки, но не обанкротилась. Один из наших клиентов занимается разработкой медицинских тренажеров. Стоимость ошибок в их отрасли — человеческие жизни. «Львиная доля» — понятие весьма субъективное. Если в сфере, где вы работает нужно, чтобы программа «делала вид, что как-то работает», это не значит, что критерии успешности других людей совпадают с вашими.
Сложность основного алгоритма в общем случае — существенная. Сложность обработки ошибок даже в самых запутанных случаях — акцидентная по Бруксу.
Если бы эта сложнасть была «акцидентная», Брукс не предлагал бы тратить половину (Карл!) времени на системное тестирование и отладку. Да не все это время уйдет на исправлние именно ошибок. Очень много «съест» работа с изменениями требований. Ноги этой проблемы все-равно растут из нежелания разбираться с «побочными» путями выполнения программы: тут не учли, здесь забыли, тут доработочка. Сдвинем ка релиз на пол года.
fillpackart
22.01.2018 22:50ФП стиль обработки исключений хорош, но когда твой ЯП набит сайд-эффектами под завязку, он ничем не поможет, особенно когда весь остальной C# мир (который будет использовать твой код и чей код ты используешь) использует try/catch/finally trow
ijsgaus
22.01.2018 23:28Railway стиль хорош, но для C# не родной. Имеет право на жизнь внутри конвейерной обработки на internal уровне в библиотеках, так как способен уменьшить количество объявленных Exception type. Однако, во первых — Result должен быть структурой, чтоб на нуль не проверять. Result должен быть Ether c Left type = Exception, должен испльзовать моноидную природу AggregateException, и нужна Try обертка над обычными функциями. Тогда возможна такая запись:
Try(() => File.Create(«abc»)).Correct(() => File.Create(«dfg»)).Bind(p => p.Write(a)).AndAlso(p => p.Close);Bonart
22.01.2018 23:57Почему так сразу и резко не родной?
Task содержит в себе все что нужно.
Конечно, размеченных объединений и нормальной do-нотации не хватает, но сделать можно немало.
vintage
23.01.2018 09:06Сначала вводим тип-объединение, чтобы заставить вызывающий код проверять ошибки. А потом используем слово на букву М, чтобы их тупо игнорировать. Что-то тут не так..
marshinov Автор
23.01.2018 09:59Вы их не игнорируете. Если не использовать
Return
вы не вынете значение из Value, значит придется обработать и успешное завершение и ошибку.Szer
23.01.2018 11:03А вы не пробовали F#? Там уже из коробки есть Discriminated Union и монструозные конструкции превращаются в:
type MyResult<'T> = | Success of 'T | Error of exn list //Это ваш Select let map f = function | Success v -> Success (f v) | x -> x //Это ваш SelectMany let bind f = function | Success v -> f v | x -> x //Это ваш SelectMany от 2 функций let bind2 f g = bind (fun x -> f x |> bind g)
А вообще в F# уже есть готовый тип Result с map, bind и пр, поэтому это всё не пригодилось бы.
Если у вас такая сложная доменная логика, вы можете отдельный проект под неё на F# запилить и 90% кодовой базы уйдёт за ненадобностью.
Меньше кода — меньше ошибок. А у вас за дженерик параметрами "леса не видно":
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)));
marshinov Автор
23.01.2018 12:18Вы все правильно поняли. Этот
Result
— калька с F# с поправкой на реалии C#. Union заменил наT,Failure
. МетодReturn
, чтобы обязать проверить оба варианта, потому что компилятор C# не предупреждает, если не все случаи обработаны в pattern matching. Приходится выкручиваться. LINQ-синтаксис — замена computation expressions.
Не всегда можно добавить в стек ещё один ЯП, зачастую по организационным причинам. Зато можно какие-то инструменты портировать, хотя они и могут смотреться чужеродно. СигнатурыSelectMany
дляIEnunerable
такие же страшные. Думаете их кто-то видит и вообще задумывается о них? Многие просто используют готовые инструменты и не вникают. Мне вообще кажется, что скоро мы войдём в эпоху, когда прикладные программисты не будут понимать как Машина выполняет код. Будем писать только DSL.
vintage
23.01.2018 11:14Найтите 10 различий:
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)); }
public IResult Post(ChangeUserNameCommand command) { command.Validate(); try { return SendEmail(ChangeUserName(command)) } catch( Exception x ) { throw new BadRequest(x.Message); } }
Давайте я начну:
- В первом коде вы узнаете об ошибке где-то далеко от места её возникновения. Во втором отладчик услужливо остановит исполнение там, где она произошла.
mayorovp
23.01.2018 11:28Во втором отладчик услужливо остановит исполнение там, где она произошла.
Только если включен отлов First-chance exceptions, но эта настройка зачастую мешает нормальной отладке из-за исключений в библиотеках. В противном случае отладчик остановит исполнение только для тех исключений которые "утекают" из пользовательского кода.
Применительно к вашему коду это означает, что исключение в
SendEmail
илиChangeUserName
поймано отладчиком не будет ни в каком из двух вариантов (а ведь именно эти исключения как правило наиболее интересны — валидация-то зачастую слишком очевидна чтобы ее отлаживать, да и сообщения об ошибках там самые понятные).
По мне, так лучше всего вот так делать:
[HandleErrors] public IActionResult Post(ChangeUserNameCommand command) { var res = command.Validate(); if (res.IsFaulted) return Fault(res); return SendEmail(ChangeUserName(command)); }
То есть ошибки валидации обрабатываем как возвращаемое значение (это необходимо хотя бы потому что их может быть много, а не одно) — а ошибки обработки запроса надо делать уже исключениями, причем с обязательным утеканием исключений в системный код перед перехватом (тут может помочь
[DebuggerNonUserCode]
если используемый фреймворк не позволяет обрабатывать исключения какими-нибудь фильтрами).
mayorovp
23.01.2018 10:35Более того, сигнатуры методов в языках с исключениями не договаривают.
На самом деле, сигнатуры "не договаривают" в любых языках программирования кроме тех что заточены на доказательство корректности программы. По одной простой причине — в сигнатурах не учитывается тот факт, что функция может вовсе не вернуть управления.
Исключение обычно рассматривается лишь как одна из разновидностей ситуации "функция не вернула управления" (две другие разновидности — завершение процесса и вечный цикл).
marshinov Автор
23.01.2018 10:51«Проблему остановки» кажется же еще не решили:) Я как-то даже не стал уточнять.
mayorovp
23.01.2018 11:16Это для Тьюринг-полных языков она неразрешима.
0xd34df00d
24.01.2018 01:21Более того, даже для Тьюринг-полных языков зачастую можно продуктивно и эффективно разделить тотальную логику и частичную узенькую обёртку вокруг неё. Ну, подобно тому, как чистые языки разделяют, собственно, чистые функции и функции с сайд-эффектами.
Скрытый текстРазве что нет никаких функций с сайд-эффектами, есть функции, описывающие последовательность действий над эффектами, которые потом выполняет рантайм.
unsafePtr
Не знаю почему многие так любят Linq синтаксис. Всегда ему предпочитал последовательный вызов методов.
Free_ze
Более высокоуровневые конструкии — ближе к человеческому языку, поэтому читаются легче, чем циклы с хитрыми счетчиками и итераторами.