Заміна викиду винятків повідомленнями

Пропоную вашій увазі переклад статті "Replace Throw With Notification" Мартіна Фаулера. Приклади адаптовані під .NET.

Якщо ми валидируем дані, які зазвичай ми не повинні використовувати виключення, щоб повідомити про валідаційних помилки. Тут я опишу як отрефакторить такий код з використанням патерну «Повідомлення» («Notification»).



Нещодавно я дивився на код, який робив базову валідацію входять JSON повідомлень. Це виглядало приблизно так…

public void Сһеск()
{
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<BookingRequest>(json);

Клас BookingRequest містить всього два елементи, які ми валидируем тут: дату виступу і як багато місць було запитано.

class BookingRequest
{
public int? NumberOfSeats { get; set; }
public string Date { get; set; }
}

Валідація вже була показана вище.

public void Сһеск()
{
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 < string>();
if (NumberOfSeats < 5) notification.add("Кількість місць має бути не менше 5");
// ще перевірки

// потім...
if (notification.Any()) // обробка помилок

Хоча реалізація вище також дозволяє використовувати патерн, краще робити трохи більше, створюючи замість цього простий клас.

public class Notification
{
private List<String> errors = new List < string>();

public void AddError(string message)
{
errors.Add(message);
}

public bool HasErrors
{
get { return errors.Any(); }
}
}

Використовуючи спеціальний клас ми робимо наші наміри більш очевидними — читачеві не потрібно створювати уявну карту між ідеєю та її реалізацією.

Поділяємо метод Check
Нашим першим кроком буде розділити метод Check на дві частини, внутрішня частина буде мати справу тільки з повідомленнями і не викидати винятку, а зовнішня частина збереже поточний поведінка методу Check, який викидає виключення, у випадку виявлення будь-яких помилок.

Використовуючи спосіб «Виділення методу», виносимо тіло функції Check в функцію Validation.

public void Сһеск()
{
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 Сһеск()
{
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<Error> errors = new List<Error>();

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. Ми також можемо захотіти додати тести, що перевіряють правильний збір численних помилок за допомогою повідомлення.

Джерело: Хабрахабр

0 коментарів

Тільки зареєстровані та авторизовані користувачі можуть залишати коментарі.