[Из песочницы] Замена выброса исключений уведомлениями
Предлагаю вашему вниманию перевод статьи «Replace Throw With Notification» Мартина Фаулера. Примеры адаптированы под .NET.
Если мы валидируем данные, обычно мы не должны использовать исключения, чтобы известить о валидационных ошибках. Здесь я опишу как отрефакторить такой код с использованием паттерна «Уведомление» («Notification»).
Недавно я смотрел на код, который делал базовую валидацию входящих JSON сообщений. Это выглядело примерно так…
public void Сheck()
{
if (Date == null) throw new ArgumentNullException("Дата не указана");
DateTime parsedDate;
try {
parsedDate = DateTime.Parse(Date);
}
catch (FormatException e) {
throw new ArgumentException("Дата указана в неизвестном формате", e);
}
if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
}
Это общий подход к реализации валидации. Запускается серия проверок для некоторых данных (в примере выше валидируются два параметра). Если какая-либо проверка не проходит, то кидается исключение с сообщением об ошибке.
У этого подхода есть несколько недостатков. Во первых, мы не должны использовать исключения таким образом. Исключения сигнализируют о том, что что-то вышло за границы ожидаемого кодом поведения. Но если мы делаем проверки на входные параметры, то это потому, что мы ожидаем сообщение об ошибке, и если ошибка — это ожидаемое поведение, то мы не должны использовать исключения.
Если ошибка — это ожидаемое поведение, то мы не должны использовать исключения
Вторая проблема этого кода в том, что он падает с первой обнаруженной ошибкой, но обычно лучше уведомить обо всех ошибках с входящими данными, не только с первой. В таком случае клиент может выбрать показывать все ошибки пользователю, чтобы он мог исправить их за один подход. Это лучше, чем создавать пользователю впечатление, что он играет в морской бой с компьютером.
Предпочитительно в случаях, как приведённый выше, использовать паттерн «Уведомление». Уведомление — это объект, который собирает ошибки. Каждая валидационная ошибка добавляет ошибку к уведомлению. Валидационный объект возвращает уведомление, которое мы можем интегрировать, чтобы получить информацию. Простой пример использования выглядит следующим образом.
private void ValidateNumberOfSeats(Notification note)
{
if (numberOfSeats < 1) note.addError("Количество мест должно быть положительным числом");
// другие проверки, как проверка выше
}
Затем мы можем просто вызвать метод Notification.hasErrors (), чтобы отреагировать на ошибки. Другие методы Notification могут предоставить больше деталей об ошибках.
if (numberOfSeats < 1) throw new ArgumentException(«Количество мест должно быть положительным числом»);
if (numberOfSeats < 1) note.addError("Количество мест должно быть положительным числом");
return note;
Когда использовать этот рефакторинг
Должен отметить, что я не пропагандирую избежание использования исключений в вашем коде. Исключения — это очень полезная техника для обработки непредвиденной ситуации и отделения её от основного потока логики. Этот рефакторинг имеет смысл тогда, когда выходной сигнал в виде исключения не является исключительной ситуацией, и поэтому должен быть обработан основной логикой программы. Код выше является распространённым примером такой ситуации.
Хорошее правило использования исключений можно встретить в книге «Pragmatic Programmers»:
Мы верим, что исключения редко должны использоваться, как часть нормального потока программы: исключения должны быть зарезервированы для неожиданный ситуаций. Представьте, что необработанное исключение завершит вашу программу и спросите себя: «Будет ли этот код всё ещё работать, если я уберу все обработчики исключений?» Если ответ «нет», то, возможно, исключения использовались в составе нормального потока программы.
— Дэйв Томас и Энди Хант
Важный вывод, который нужно сделать из этого — решение применять ли исключения для конкретной задачи зависит от контекста. Чтение файла, который не существует может являться исключительной ситуацией, а может и не являться. Если мы пытаемся прочитать файл по хорошо известному пути, например, /etc/hosts в Unix, то мы можем предположить, что файл должен быть здесь, поэтому выбрасывание исключения имеет смысл. С другой стороны, если мы читаем файл по пути, переданному пользователем, через командную строку, то мы должны ожидать, что, вероятно, файла здесь нет и использовать другой механизм взаимодействия с неисключительной по своей природе ошибкой.
Существуют случаи, когда использование исключений для валидационных ошибок может быть целесообразно. Это ситуации, когда есть данные, которые уже прошли валидацию в процессе обработки, но мы хотим запустить валидацию ещё раз, чтобы обезопасить
себя от программной ошибки, позволяющей невалидным данным проскочить.
Эта статья о замене исключений уведомлениями в контексте валидации сырого ввода. Также можно найти эту технику полезной в других ситуациях где уведомления — это более грамотный выбор, чем выбрасывание исключения, но сосредоточимся на валидационном случае, так как он наиболее часто встречается.
Стартовая точка
До сих пор мы не упоминали бизнес логику, так как было важно сконцентрироваться на общей форме кода. Но для дальнейшего обсуждения мы должны получить немного информации о бизнес логике. В данном случае некоторый код получает JSON сообщения с забронированными местами в театре. Код находится в классе BookingRequest, получаемом из JSON с помощью библиотеки JSON.NET.
JsonConvert.DeserializeObject(json);
Класс BookingRequest содержит всего два элемента, которые мы валидируем здесь: дату выступления и как много мест было запрошено.
class BookingRequest
{
public int? NumberOfSeats { get; set; }
public string Date { get; set; }
}
Валидация уже была показана выше.
public void Сheck()
{
if (Date == null) throw new ArgumentNullException("Дата не указана");
DateTime parsedDate;
try {
parsedDate = DateTime.Parse(Date);
}
catch (FormatException e) {
throw new ArgumentException("Дата указана в неизвестном формате", e);
}
if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
}
Создание нотификации
Чтобы использовать нотификации мы должны создать объект Notification. Нотификация может быть достаточно простой, временами просто List.
var notification = new List();
if (NumberOfSeats < 5) notification.add("Количество мест должно быть не менее 5");
// ещё проверки
// затем…
if (notification.Any()) // обработка ошибок
Хотя реализация выше также позволяет использовать паттерн, предпочтительно делать немного больше, создавая вместо этого простой класс.
public class Notification
{
private List errors = new List();
public void AddError(string message)
{
errors.Add(message);
}
public bool HasErrors
{
get { return errors.Any(); }
}
}
Используя специальный класс мы делаем наши намерения более очевидными — читателю не нужно создавать мысленную карту между идей и её реализацией.
Разделяем метод Check
Нашим первым шагом будет разделить метод Check на две части, внутренняя часть будет иметь дело только с уведомлениями и не выбрасывать исключения, а внешняя часть сохранит текущее поведение метода Check, который выкидывает исключения, в случае обнаружения какой-либо ошибки.
Используя способ «Выделение метода», выносим тело функции Check в функцию Validation.
public void Сheck()
{
Validation();
}
public void Validation()
{
if (Date == null) throw new ArgumentNullException("Дата не указана");
DateTime parsedDate;
try
{
parsedDate = DateTime.Parse(Date);
}
catch (FormatException e)
{
throw new ArgumentException("Дата указана в неизвестном формате", e);
}
if (parsedDate < DateTime.Now) throw new ArgumentException("Дата не может быть раньше сегодняшней");
if (NumberOfSeats == null) throw new ArgumentException("Количество мест не указано");
if (NumberOfSeats < 1) throw new ArgumentException("Количество мест должно быть положительным числом");
}
Затем расширяем метод Validation с созданием Notification и его возвращением из функции.
public Notification Validation()
{
var notification = new Notification();
//...
return notification;
}
Теперь я могу проверить Notification и выкинуть исключение, если он содержит ошибки.
public void Сheck()
{
var notification = Validation();
if (notification.HasErrors)
throw new ArgumentException(notification.ErrorMessage);
}
Мы сделали метод Validation открытым, так как ожидается, что большинство пользователей в будущем будут предпочитать использовать этот метод нежели Check.
К текущему моменту, мы не изменили поведение кода вообще, все упавшие валидационные проверки будут продолжать выбрасывать исключения, но мы создали базу, чтобы начать замену выброса исключений с уведомлениями.
Разделение изначального метода позволило нам отделить валидацию от реакции на её результаты
Перед тем как мы продолжим, следует сказать несколько слов о сообщениях об ошибке. Когда мы делаем рефакторинг, важно избежать изменений в наблюдаемом поведении. Данное правило ведёт нас к вопросу о том какое поведение является наблюдаемым. Очевидно, что выброс исключения — это то, что внешняя программа будет наблюдать, но в какой степени они заботятся о сообщении об ошибке? Notification будет собирать множество сообщений об ошибках и объединять их в одно, например таким образом.
public string ErrorMessage
{
get { return string.Join(", ", errors); }
}
Но здесь может быть проблема с более высокими слоями программы, которые полагаются на получение только первой обнаруженной ошибки. В таком случае нам следует её реализовать следующим образом.
public string ErrorMessage
{
get { return errors[0]; }
}
Нам следует смотреть не только на вызываемую функцию, но и на существующие обработчики, чтобы определить правильное поведение в конкретной ситуации.
Валидация числа
Очевидная вещь, которую нужно сделать это заменить первую проверку.
public Notification Validation()
{
var notification = new Notification();
if (Date == null) notification.AddError("Дата не указана");
//...
}
Очевидная замена, но плохая, так как ломает код. Если мы передадим null в качестве аргумента для Date, то мы добавим ошибку в объект Notification, код продолжит выполняться и при разборе получим NullReferenceException в методе DateTime.Parse. Это не то, что мы хотим получить.
Неочевидное, но более эффективное, что нужно сделать в этом случае, это идти с конца метода.
public Notification Validation()
{
//...
if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}
Следующая проверка — это проверка на null, поэтому мы должны добавить условие, чтобы избежать NullReferenceException
public Notification Validation()
{
//...
if (NumberOfSeats == null) notification.AddError("Количество мест не указано");
else if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}
Как мы видим, следующая проверка включает в себя другое поле. И эти проверки также должны учитываться в проверках другого поля. Метод проверки становится слишком сложным. Поэтому выносим проверки NumberOfSeats в отдельный метод.
public Notification Validation()
{
//...
ValidateNumberOfSeats(notification);
}
private void ValidateNumberOfSeats(Notification notification)
{
if (NumberOfSeats == null) notification.AddError("Количество мест не указано");
else if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}
Когда мы смотрим на выделенную валидацию для числа, она выглядит не очень естественно. Использование if-then-else блоков для валидации может легко привести к чрезмерно вложенному коду. Более предпочтительно использовать линейный код, который обрывается, если не может идти далее, что мы можем реализовать с использованием защитного условия.
private void ValidateNumberOfSeats(Notification notification)
{
if (NumberOfSeats == null)
{
notification.AddError("Количество мест не указано");
return;
}
if (NumberOfSeats < 1) notification.AddError("Количество мест должно быть положительным числом");
}
Наше решение идти с конца метода, чтобы оставлять код рабочим демонстрирует основной принцип рефакторинга. Рефакторинг — это специальная техника реструктуризации кода путем серии поведение-сохраняющих трансформаций. Поэтому, когда мы рефакторим, мы должны всегда стараться делать наиболее маленькие шаги, которые сохраняют поведение. Делая это, мы уменьшаем вероятность ошибки, которая заставит нас дебажить.
Валидация даты
Начнем с выноса проверок для даты в отдельный метод.
public Notification Validation()
{
ValidateDate(notification);
ValidateNumberOfSeats(notification);
}
Затем, как и в случае с числом, начнём заменять исключения с конца метода.
private void ValidateNumberOfSeats(Notification notification)
{
//...
if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}
В следующем шаге есть небольшая сложность с перехватом исключения, так как выпущенное исключение включает исходное исключение. Чтобы это обработать, мы должны изменить класс Notification, чтобы он принимал исключение.
Добавим в метод AddError параметр Exception и укажем ему значение по умолчанию null.
public void AddError(string message, Exception exc = null)
{
errors.Add(message);
}
Это значит мы принимаем исключение, но игнорируем его. Чтобы поместить его куда-либо мы должны изменить тип ошибки внутри класса Notification с string на более сложный объект. Создадим класс Error внутри Notification.
private class Error
{
public string Message { get; set; }
public Exception Exception { get; set; }
public Error(string message, Exception exception)
{
Message = message;
Exception = exception;
}
}
Теперь у нас есть класс и нам осталось изменить Notification, чтобы он использовал его.
//...
private List errors = new List();
public void AddError(string message)
{
errors.Add(new Error(message, null));
}
public void AddError(string message, Exception exception = null)
{
errors.Add(new Error(message, exception));
}
//...
public string ErrorMessage
{
get { return string.Join(", ", errors.Select(e => e.Message)); }
}
С новым уведомлением на месте, теперь мы можем внести изменения в запрос бронирования.
private void ValidateDate(Notification notification)
{
if (Date == null) throw new ArgumentNullException("Дата не указана");
DateTime parsedDate;
try
{
parsedDate = DateTime.Parse(Date);
}
catch (FormatException e)
{
notification.AddError("Дата указана в неизвестном формате", e);
return;
}
if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}
И последнее изменение достаточно простое.
private void ValidateDate(Notification notification)
{
if (Date == null) notification.AddError("Дата не указана");
DateTime parsedDate;
try
{
parsedDate = DateTime.Parse(Date);
}
catch (FormatException e)
{
notification.AddError("Дата указана в неизвестном формате", e);
return;
}
if (parsedDate < DateTime.Now) notification.AddError("Дата не может быть раньше сегодняшней");
}
Заключение
Как только мы преобразовали метод с использованием механизма уведомлений, следующей задачей будет просмотреть места, в которых метод Check вызывается и подумать над возможностью использовать Validate вместо него. Для этого необходимо проанализировать как валидация ложится в текущую реализацию приложения, это выходит за рамки рассмотренного здесь рефакторинга. Но в среднесрочной перспективе должна быть цель: исключить использование исключений при любых обстоятельствах, где ожидаются валидационные ошибки.
Во многих случаях это должно привести к избавлению от метода Check полностью. В этом случае любые тесты для этого метода должны быть обновлены с использованием метода Validation. Мы также можем захотеть добавить тесты, проверяющие правильный сбор множественных ошибок с помощью уведомления.