Розробка

[DotNetBook] Винятки: архітектура системи типів

З цієї статті я продовжую публікувати цілу серію статей, результатом якої буде книга по роботі .NET CLR, і .NET в цілому. За посиланнями — ласкаво просимо за кат.

Архітектура виняткової ситуації

Напевно, одне з найважливіших питань, який стосується теми винятків — це питання побудови архітектури винятків у вашому додатку. Це питання ціКаверин з багатьох причин. Як по мені так основна — це видима простота, з якою не завжди очевидно, що робити. Це властивість притаманна всім базовим конструкціям, які використовуються повсюдно: це і IEnumerable, і IDisposable і IObservable та інші-інші. З одного боку, вони приваблюють своєю простотою, втягують в себе використання в самих різних ситуаціях. А з іншого боку, вони сповнені вирів і бродів, з яких, не знаючи, як інший раз і не вибратися. І, можливо, дивлячись на майбутній обсяг у вас дозріло питання: так що ж такого у виняткових ситуаціях?

Ця стаття — перша з чотирьох в циклі статей про виключення. Також очікуються:
— Події про виняткових ситуації
— Види виняткових ситуацій
— Серійність і блоки обробки

Але для того щоб прийти до якихось висновків щодо побудови архітектури класів виняткових ситуацій ми повинні з вами накопичити певний досвід щодо їх класифікації. Адже тільки зрозумівши, з чим ми будемо мати справу, як і в яких ситуаціях програміст повинен вибирати тип помилки, а в яких — робити вибір щодо перехоплення або пропуску винятків, можна зрозуміти як можна побудувати систему типів таким чином, щоб це стало очевидним для користувача вашого коду. А тому, спробуємо класифікувати виняткові ситуації (не самі типи винятків, а саме ситуації) за різними ознаками.

З теоретичної можливості перехоплення проектованого виключення

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

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

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

Друга група як би це не звучало дивно — відповідає за винятки, які перехоплювати не потрібно. Вони можуть бути використані тільки для запису в журнал помилок, але не для того, щоб можна було якось поправити ситуацію. Найпростіший приклад — це виключення групи ArgumentException і NullReferenceException. Адже в нормальній ситуації, ви не повинні, наприклад, перехоплювати виняток ArgumentNullException тому що джерелом проблеми тут будете саме ви, а не хтось ще. Якщо ви перехоплюєте цей виняток, то тим самим ви допускаєте що ви помилилися і віддали методом те, що віддавати йому було не можна:

void SomeMethod(object argument)
{
 try {
AnotherMethod(argument);
 } catch (ArgumentNullException exception)
{
 // Log it
}
}

У цьому методі ми пробуємо перехопити ArgumentNullException. Але на мій погляд його перехоплення виглядає дуже дивним: прокинути коректні аргументи методу — повністю наша турбота. Було б не коректно зреагувати постфактум: в такій ситуації найправильніше що тільки можна зробити — це перевірити передані дані заздалегідь, до виклику методу або що ще краще — побудувати код таким чином, щоб отримання неправильних параметрів було б просто не можливим.

Ще одна група — це виключення фатальних помилок. Якщо зламаний якийсь кеш і робота підсистеми в будь-якому випадку буде не коректною? Тоді це — фатальна помилка і найближчий по стеку код її перехоплювати гарантовано не стане:

T GetFromCacheOrCalculate()
{
 try {
 if(_cache.TryGetValue(Key, out var result))
{
 return result;
 } else {
 T res = Strategy(Key);
 _cache[Key] = res;
 return res;
}
 } cache (CacheCorreptedException exception)
{
RecreateCache();
 return GetFromCacheOrCalculate();
}
}

І нехай CacheCorreptedException — це виняток, що означає “кеш на жорсткому диску не консистентний”. Тоді виходить, що якщо причина такої помилки є фатальною для підсистеми кешування (наприклад, відсутні права доступу до файла кешу), то подальший код якщо не зможе відновити кеш командою RecreateCache, а тому факт перехоплення виключення цього є помилкою сам по собі.

За фактичним перехоплення виняткової ситуації

Ще одне питання, яке зупиняє наш політ думки в програмуванні алгоритмів — це розуміння: чи варто перехоплювати ті чи інші виключення або ж варто пропустити їх крізь себе комусь більше розумнішому. Переводячи на мову термінів питання, яке нам треба вирішити, розмежувати зони відповідальності. Давайте розглянемо наступний код:


namespace JetFinance.Strategies
{
 public class WildStrategy : StrategyBase
{
 private Random random = new Random();

 public void PlayRussianRoulette()
{
 if(DateTime.Now.Second == (random.Next() % 60))
{
 throw new StrategyException();
}
}
}

 public class StrategyException : Exception { /* .. */ }
}

namespace JetFinance.Investments
{
 public class WildInvestment
{
 WildStrategy _strategy;

 public WildInvestment(WildStrategy strategy)
{
 _strategy = strategy;
}

 public void DoSomethingWild()
{
?try?
{
_strategy.PlayRussianRoulette();
}
 catch(StrategyException exception)
{
}
}
}
}

using JetFinance.Strategies;
using JetFinance.Investments;

void Main()
{
 var foo = new WildStrategy();
 var boo = new WildInvestment(foo);

?try?
{
boo.DoSomethingWild();
}
 catch(StrategyException exception)
{
}
}

Яка з двох запропонованих стратегій є більш коректною? Зона відповідальності — це дуже важливо. Спочатку може здатися, що оскільки робота WildInvestment і його консистент цілком і повністю залежить від WildStrategy, то якщо WildInvestment просто проігнорує цей виняток, воно піде на рівень вище і робити нічого не треба. Однак, прошу зауважити що існує чисто архітектурна проблема: метод Main ловить виключення з архітектурно одного шару, викликаючи метод архітектурно — іншого. Як це виглядає з точки зору використання? Так в загальному так і виглядає:

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

Однак, з цього висновку випливає інший: catch ми повинні ставити в методі DoSomethingWild. І це для нас дещо дивно: WildInvestment ніби як жорстко залежимо від когось. Тобто якщо PlayRussianRoulette відпрацювати не зміг, то і DoSomethingWild теж: кодів повернення той не має, а зіграти в рулетку він зобов’язаний. Що ж робити в такій здавалося б безвихідної ситуації? Відповідь насправді проста: перебуваючи в іншому шарі, DoSomethingWild повинен викинути власне виключення, яке відноситься до цього шару і обернути початковий як оригінальне джерело проблеми — в InnerException:


namespace JetFinance.Strategies
{
 pubilc class WildStrategy
{
 private Random random = new Random();

 public void PlayRussianRoulette()
{
 if(DateTime.Now.Second == (random.Next() % 60))
{
 throw new StrategyException();
}
}
}

 public class StrategyException : Exception { /* .. */ }
}

namespace JetFinance.Investments
{
 public class WildInvestment
{
 WildStrategy _strategy;

 public WildInvestment(WildStrategy strategy)
{
 _strategy = strategy;
}

 public void DoSomethingWild()
{
try
{
_strategy.PlayRussianRoulette();
}
 catch(StrategyException exception)
{
 throw new FailedInvestmentException("Oops", exception);
}
}
}

 public class InvestmentException : Exception { /* .. */ }

 public class FailedInvestmentException : Exception { /* .. */ }
}

using JetFinance.Investments;

void Main()
{
 var foo = new WildStrategy();
 var boo = new WildInvestment(foo);

try
{
boo.DoSomethingWild();
}
 catch(FailedInvestmentException exception)
{
}
}

Обернувши виняток іншим ми по суті переводимо проблематику з одного шару додатка в інший, зробивши його роботу більш передбачуваною з точки зору користувача цього класу: методу Main.

З питань перевикористання

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

При виборі типу винятків можна спробувати взяти вже існуюче рішення: знайти виключення зі схожим змістом у назві та використовувати його. Наприклад, якщо нам віддали через параметр якусь сутність, яка нас не влаштовує, ми можемо викинути InvalidArgumentException, вказавши причину помилки — у Message. Цей сценарій виглядає добре, особливо з урахуванням того, що InvalidArgumentException знаходиться в групі винятків, що не підлягають обов’язковому перехопленню. Але поганим буде вибір InvalidDataException якщо ви працюєте з якими-небудь даними. Просто тому що цей тип знаходиться в зоні System.IO, а це навряд чи те, чим ви займаєтеся. Тобто виходить що знайти існуючий тип бо ліньки робити свій — практично завжди буде не правильним підходом. Винятків, які створені для загального кола завдань майже не існує. Практично всі з них створені під конкретні ситуації та їх переиспользование буде грубим порушенням архітектури виняткових ситуацій. Мало того, отримавши виключення певного типу (наприклад, той же System.IO.InvalidDataException), користувач буде заплутаний: з одного боку він побачить джерело проблеми в System.IO як простір імен винятку, а з іншого — зовсім інший простір імен точки викиду. Плюс до всього, задумавшись про правила викиду цього виключення зайде на referencesource.microsoft.com і знайде всі місця його викиду:

  • internal class System.IO.Compression.Inflater

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

Також в цілях спрощення перевикористання можна просто взяти і створити якесь одне виключення, оголосивши у нього поле ErrorCode з кодом помилки і жити собі на втіху. Здавалося б: хороше рішення. Кидаєте скрізь одне і те ж виключення, виставивши код, ловіть всього-навсього одним catch підвищуючи тим самим стабільність додатку: і робити нічого не треба. Однак, прошу не погодитися з такою позицією. Діючи таким чином по всьому додатком ви з одного боку, звичайно, спрощуєте собі життя. Але з іншого — ви відкидаєте можливість ловити підгрупу винятків, об’єднаних певною спільною особливістю. Як це зроблено, наприклад, з ArgumentException, який під собою об’єднує цілу групу винятків шляхом успадкування. Другий серйозний мінус — надмірно великі і нечитані простирадла коду, який буде організовувати фільтрацію за кодом помилки. А от якщо взяти іншу ситуацію: коли кінцевому користувачеві конкретизація помилки не повинна бути важлива, введення узагальнюючого типу плюс код помилки виглядає вже куди більш правильним застосуванням:

public class ParserException
{
 public ParserError ErrorCode { get; }

 public ParserException(ParserError errorCode)
{
 ErrorCode = errorCode;
}

 public override string Message
{
 get {
 return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}");
}
}
}

public enum ParserError
{
MissingModifier,
MissingBracket,
 // ...
}

// Usage
throw new ParserException(ParserError.MissingModifier);

Коду, який захищає виклик парсера майже завжди байдуже, з якої причини був завалений парсинг: йому важливий сам факт помилки. Однак, якщо це все-таки стане важливо, користувач завжди зможе виокремити код помилки властивості ErrorCode. Для цього зовсім не обов’язково шукати потрібні слова підрядку у Message.

Якщо відштовхуватися від ігнорування питань перевикористання, то можна створити за типом виключення під кожну ситуацію. З одного боку це виглядає логічно: один тип помилки — один тип винятку. Проте, тут, як і у всьому, головне не перестаратися: маючи за типом виняткових операцій на кожну точку викиду ви породжуєте тим самим проблеми для перехоплення: код виклику методу буде перевантажений блоками catch. Адже йому треба обробити всі типи винятків, які ви хочете йому віддати. Інший мінус — чисто архітектурний. Якщо ви не використовуєте спадкування, то тим самим дезориентируете користувача цих винятків: між ними може бути багато спільного, а перехоплювати їх доводиться окремо.

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

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

public abstract class ParserException
{
 public abstract ParserError ErrorCode { get; }

 public override string Message
{
 get {
 return Resources.GetResource($"{nameof(ParserException)}{Enum.GetName(typeof(ParserError), ErrorCode)}");
}
}
}

public enum ParserError
{
MissingModifier,
MissingBracket
}

public class MissingModifierParserException : ParserException
{
 public override ParserError ErrorCode { get; } => ParserError.MissingModifier;
}

public class MissingBracketParserException : ParserException
{
 public override ParserError ErrorCode { get; } => ParserError.MissingBracket;
}

// Usage
throw new MissingModifierParserException(ParserError.MissingModifier);

Які чудові властивості ми отримаємо при такому підході?

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

Як по мені так дуже зручний варіант.

По відношенню до єдиної групи поведінкових ситуацій

Які ж висновки можна зробити, ґрунтуючись на раніше описаних міркуваннях? Давайте спробуємо їх сформулювати:

Для початку давайте визначимося, що мається на увазі під ситуаціями. Коли ми говоримо про класи і об’єкти, то ми звикли в першу чергу оперувати сутностями з деяким внутрішнім станом над якими можна здійснювати дії. Виходить, що тим самим ми знайшли перший тип поведінкової ситуації: дії над деякою сутністю. Далі, якщо подивитися на граф об’єктів як би з боку, можна помітити що він логічно об’єднаний в функціональні групи: перша займається кешуванням, друга — робота з базами даних, третя здійснює математичні розрахунки. Через всі ці функціональні групи можуть йти шари: шар логування різних внутрішніх станів, запису процесів, трасування викликів методів. Шари можуть бути більш охоплюючими: поєднують в собі декілька функціональних груп. Наприклад, шар моделі, шар контролерів, шар подання. Ці групи можуть знаходитися як в одній збірці, так і в абсолютно різних, але кожна з них може створювати свої виняткові ситуації.

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

Давайте розглянемо код:


namespace JetFinance
{
 namespace FinancialPipe
{
 namespace Services
{
 namespace XmlParserService
{
}

 namespace JsonCompilerService
{
}

 namespace TransactionalPostman
{
}
}
}

 namespace Accounting
{
 /* ... */
}
}

На що це схоже? Як на мене, простору імен — прекрасна можливість природного угруповання типів виключень щодо їх поведінкових ситуацій: все, що належить певним групам там і повинно знаходитися, включаючи виключення. Мало того, коли ви отримаєте певний виняток, то крім назви його типу ви побачите і його простір імен, що чітко визначить його приналежність. Пам’ятаєте приклад поганого перевикористання типу InvalidDataException, який насправді визначений у просторі імен System.IO? Його приналежність даного простору імен означає що по суті виключення цього типу може бути викинуто з класів, що знаходяться в просторі імен System.IO або в більш вкладеному. Але саме виключення при цьому було викинуто зовсім з іншого місця, заплутуючи дослідника виниклої проблеми. Зосереджуючи типи винятків з тим же просторів імен, що і типи, ці винятки викидають, ви, з одного боку зберігаєте архітектуру типів консистентним, а з іншого — полегшуєте розуміння причин події кінцевим розробником.

Який другий шлях угруповання на рівні коду? Спадкування:


public abstract class LoggerExceptionBase : Exception
{
 protected LoggerExceptionBase(..);
}

public class IOLoggerException : LoggerExceptionBase
{
 internal IOLoggerException(..);
}

public class ConfigLoggerException : LoggerExceptionBase
{
 internal ConfigLoggerException(..);
}

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

Об’єднуючи обидва методу групування, можна зробити деякі висновки:

  • всередині збірки (Assembly) має бути базовий тип винятків, які дана збірка викидає. Цей тип винятків повинен знаходитися в кореневому для складання просторі імен. Це буде перший шар угруповання;
  • далі всередині самої збірки може бути одне або кілька різних просторів імен. Кожне з них ділить збірку на деякі функціональні зони, тим самим визначаючи групи ситуацій, які в даній збірці виникають. Це можуть бути зони контролерів, сутностей баз даних, алгоритмів обробки даних і інших. Для нас ці простори імен — групування типів за функціональною належністю, а з точки зору винятків — групувати за проблемним зонам цієї ж збірки;
  • спадкування винятків може йти тільки від типів у цьому ж просторі імен або в більш кореневому. Це гарантує однозначне розуміння ситуації кінцевим користувачем і відсутність перехоплення лівих винятків при перехопленні за базовим типом. Погодьтеся, було б дивно отримати global::Finiki.Logistics.OhMyException, маючи catch(global::Legacy.LoggerExeption exception), зате абсолютно гармонійно виглядає наступний код:

namespace JetFinance.FinancialPipe
{
 namespace Services.XmlParserService
{
 public class XmlParserServiceException : FinancialPipeExceptionBase
{
 // ..
}

 public class Parser
{
 public void Parse(string input)
{
 // ..
}
}
}

 public abstract class FinancialPipeExceptionBase : Exception
{

}
}

using JetFinance.FinancialPipe;
using JetFinance.FinancialPipe.Services.XmlParserService;

var parser = new Parser();

try {
parser.Parse();
}
catch (XmlParserServiceException exception)
{
 // Something wrong in parser
}
catch (FinancialPipeExceptionBase exception)
{
 // Something else wrong. Looks critical because we don't know real reason
}

Зауважте, що тут відбувається: ми як користувальницький код викликаємо якийсь бібліотечний метод, який, наскільки ми знаємо, може при деяких обставинах викинути виняток XmlParserServiceException. І, наскільки ми знаємо, цей виняток знаходиться в просторі імен, наслідуючи JetFinance.FinancialPipe.FinancialPipeExceptionBase, що говорить про можливий недогляд інших винятків: це зараз мікросервіс XmlParserService створює лише одне виключення, але в майбутньому можуть з’явитися і інші. І оскільки у нас є конвенція у створенні типів виключень, ми точно знаємо від кого цей новий виняток буде успадковуватися і заздалегідь ставимо узагальнюючий catch не зачіпаючи при цьому нічого зайвого: те що не потрапило в зону нашої відповідальності пролетить повз.

Як же побудувати ієрархію типів?

  • Для початку необхідно зробити базовий клас для домену. Назвемо його доменним базовим класом. Домен в даному випадку — це узагальнююче деяку кількість збірок слово, що єднає їх з деякого глобального ознакою: логгирование, бізнес-логіка, UI. Тобто максимально великі функціональні зони програми;
  • Далі необхідно ввести додатковий базовий клас для винятків, які необхідно перехоплювати: від нього будуть успадковуватись всі винятки, які будуть перехоплюватися ключовим словом catch;
  • Всі винятки, які позначають фатальні помилки – успадковуються безпосередньо від доменного базового класу. Тим самим ви відокремили їх від перехоплених архітектурно;
  • Розділити домен на функціональні зони з простору імен і оголосити базовий тип винятків, які будуть викидатися з кожної функціональної зони. Тут варто додатково орудувати здоровим глуздом: якщо додаток має велику вкладеність просторів імен, то робити за базовим типом для кожного рівня вкладеності, звичайно, не варто. Однак, якщо на якомусь рівні вкладеності відбувається розгалуження: одна група винятків пішла в один підпростір імен, а інша — в інший, то тут, звичайно, варто ввести два базових типи для кожної підгрупи;
  • Приватні виключення успадковуються від типів виключень функціональних зон
  • Якщо група приватних винятків може бути об’єднана, об’єднайте їх ще одним базовим типом: так ви спрощуєте їх перехоплення;
  • Якщо передбачається, що група буде частіше перехоплюватися свого базового класу, ввести Mixed Mode c ErrorCode.

За джерелом помилки

Ще одним приводом для об’єднання виключень в деяку групу може виступати джерело помилки. Наприклад, якщо ви розробляєте бібліотеку класів, то групами джерел можуть стати:

  • Виклик unsafe коду, який відпрацював з помилкою. Дану ситуацію слід обробити наступним чином: обернути виключення або код помилки у власний тип винятку, а отримані дані про помилку (наприклад, оригінальний код помилки) зберегти в публічній властивості виключення;
  • Виклик коду з зовнішніх залежностей, який викликав винятки, які наша бібліотека перехопити не може, оскільки вони не входять в її зону відповідальності. Сюди можуть входити виключення з методів тих сутностей, які були прийняті в якості параметрів поточного методу або ж конструктора того класу, метод якого викликав зовнішню залежність. Як приклад, метод нашого класу викликав метод іншого класу, примірник якого був отриманий через параметри методу. Якщо виняток говорить про те, що джерелом проблеми були ми самі — генеруємо наш власний виняток із збереженням оригінального — в InnerExcepton. Якщо ж ми розуміємо, що проблема саме в роботі зовнішньої залежності — пропускаємо виняток наскрізь належить до групи зовнішніх непоконтрольных залежностей;
  • Наш власний код, який був випадковим чином введений в не консистентний стан. Хорошим прикладом може стати парсинг тексту. Зовнішніх залежностей немає, догляду в unsafe немає, а помилка парсингу є.

Related Articles

Залишити відповідь

Ваша e-mail адреса не оприлюднюватиметься. Обов’язкові поля позначені *

Close