Чистий код і мистецтво обробки виключень



Винятки існують стільки ж, скільки сама програмування. На самому початку, коли програмування було виключно апаратним або з використанням низькорівневих мов, виключення застосовувалися для зміни потоку програми та уникнення апаратних збоїв. Згідно Википедиивиключення — це:

… помилки часу виконання та інші можливі проблеми (виключення), які можуть виникнути при виконанні програми...
Виключення вимагають до себе особливого ставлення, а необроблене виняток може призвести до непередбачуваного поведінки програми. І наслідки можуть бути дуже серйозними. Наприклад, у 1996 році необроблене виняток переповнення призвело до катастрофі при запуску ракети Ariane 5. А в цій добірці описаний ряд інших гучних подій, пов'язаних з необробленими або помилково обробленими винятками.

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

Обробка виключень — справа хороша
З розквітом объектно-орієнтованого програмування підтримка винятків перетворилася в критично важливий елемент сучасних мов програмування. Сьогодні більшість мов вбудовані надійні системи обробки винятків. Приміром, у Ruby використовується наступний стандартний шаблон:

begin
делайте_что-небудь,_что_может_не_работать!
rescue SpecificError => e
выполните_исправление_конкретной_ошибки
попытайтесь_снова_при_выполнении_какого-то_условия
ensure
это_всегда_будет_выполнено
end

З цим кодом все в порядку. Але надмірне використання таких шаблонів зробить код неохайним, і не факт, що принесе користь. А при неправильному використанні ви можете нашкодити своїй кодової базі, зробивши її нестабільною, або ускладнивши пошук джерел помилок.

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

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

  • Зручність в обслуговуванні: ми можемо легко знаходити і виправляти нові баги, не турбуючись про те, що можемо зламати поточну функціональність, внести нові баги, або з часом взагалі закинути код з-за зростання її складності.
  • Розширюваність: ми можемо легко додавати в кодову базу нові фрагменти, впроваджуючи нові або змінені вимоги без порушення наявної функціональності. Розширюваність забезпечує гнучкість і високу ступінь повторної прийнятності у використанні кодової бази.
  • Читаність: ми можемо легко зчитувати код і визначати його призначення, не витрачаючи на це занадто багато часу. Це критично важливо для ефективного дослідження багів і не зразку, що тестується коду.
Ці три властивості — головні чинники того, що ми називаємо чистотою або якістю коду. Це поняття саме по собі не є конкретним показником, що це сукупний ефект описаних трьох властивостей. Як в цьому коміксі:



Давайте тепер перейдемо до практичних рекомендацій і подивимося, як вони впливають на наші Три Властивості.

Примітка: Приклади коду будуть представлені на Ruby, але аналоги цих конструкцій є у всіх найбільш поширених ООП-мовах.

Завжди створюйте власну ієрархію ApplicationError

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

class ApplicationError < StandardError; end

# Validation Errors
class ValidationError < ApplicationError; end
class RequiredFieldError < ValidationError; end
class UniqueFieldError < ValidationError; end

# HTTP 4XX Response Errors
class ResponseError < ApplicationError; end
class BadRequestError < ResponseError; end
class UnauthorizedError < ResponseError; end
# ...



Створивши для свого додатку розширюваний, комплексний пакет винятків, ви зможете набагато легше обробляти якісь ситуації, характерні саме для вашої програми. Наприклад, можна визначати найбільш природні способи обробки тих чи інших винятків. Це покращить як читаність коду, так і зручність обслуговування додатків і бібліотек (gems).

Скажімо, куди простіше прочитати такий код:

rescue ValidationError => e


чим такий:

rescue RequiredFieldError, UniqueFieldError, ... => e


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

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

# app/controller/pseudo_controller.rb
def автентифікувати користувача!
fail AuthenticationError якщо токен неправильний? || сертифіката закінчився термін дії?
User.find_by(authentication_token: token)
rescue AuthenticationError => e
повідомити про підозрілу активність при неправильному токені?
raise e
end

def show
автентифікувати користувача!
отобразить_личные_данные!(params[:id])
rescue ClientError => e
render_error(e)
end

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

Зверніть увагу на два моменти:

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


Ніколи не використовуйте
rescue Exception

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

# main.rb
def поганий приклад
я_могу_вызвать_исключение!
rescue Exception
не,_я_всегда_буду_здесь_с_тобой
end

# elsewhere.rb
def я_могу_вызвать_исключение!
retrun сделай_много_работы!
end

Як ви могли помітити, в слові
return 
зроблена помилка. Хоча в сучасних редакторах є певна захист від подібних синтаксичних помилок, у цьому прикладі проілюстровано, як
rescue Exception
шкодить вашому коду. Ця конструкція ніяк не адресована конкретному типу виключення (в даному випадку
NoMethodError
), і при цьому ніяк не демонструється розробнику, так що можна втратити купу часу, бігаючи по колу.

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

Якщо ми спробуємо обробляти різні підтипи винятків тим же самим обробником, то у нас вийдуть масивні блоки коду з численними обов'язками. Наприклад, якщо ми створюємо бібліотеку, яка отримує дані від віддаленого API, то обробка
MethodNotAllowedError 
(HTTP 405) зазвичай відрізняється від обробки
UnauthorizedError 
(HTTP 401), хоча вони обидві
ResponseError
.

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

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

def get_info
begin
response = HTTP.get(STOCKS_URL + "#{@symbol}/info")

fail AuthenticationError if response.code == 401
fail StockNotFoundError, @symbol if response.code == 404
return JSON.parse response.body
rescue JSON::ParserError
retry
end
end

Ми визначили тут для методу контракт, полягає виключно в отриманні інформації про вартості акцій. Він обробляє помилки, характерні для кінцевої точки, начебто неповних або помилково сформованих JSON-відгуків. І не будуть оброблятися, скажімо, проблеми з аутентифікацією, або ситуації з відсутністю акцій. Все це відноситься до обов'язків інших обробників, і явним чином передається вгору по стеку викликів туди, де найзручніше обробляти подібні помилки у відповідності з принципом DRY.

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

В Rails-додатках (особливо тих, які надають тільки JSON API) часто використовується наступний метод управління:

# app/controllers/client_controller.rb

def create
@client = Client.new(params[:client])
if @client.save
render json: @client
else
render json: @client.errors
end
end

Зверніть увагу: хоча технічно це і не обробник винятку, але він виконує саме цю функцію, оскільки
@client.save
лише повертає
false 
при виявленні винятку.

Проте в даному випадку не варто дублювати один і той же обробник в кожній дії controller'а, оскільки це суперечитиме принципу DRY і нашкодить розширюваності і зручності обслуговування. Замість цього можна використовувати специфіку поширення дії винятку, обробивши його лише одного разу, в батьківському controller-класі
ApplicationController
:

# app/controllers/client_controller.rb

def create
@client = Client.create!(params[:client])
render json: @client
end
# app/controller/application_controller.rb

rescue_from ActiveRecord::RecordInvalid, with: :render_unprocessable_entity

def render_unprocessable_entity(e)
render \
json: { errors: e.record.errors },
status: 422
end

Таким чином ми можемо бути впевнені, що всі помилки
ActiveRecord::RecordInvalid
будуть правильно оброблені в одному місці, на базовому рівні
ApplicationController
, у відповідності з принципом DRY. Це дозволяє нам вільно возитися з винятками, якщо нам знадобиться обробляти якісь ситуації на більш низькому рівні, або просто дозволивши їм поширюватися.

Не всі винятку потребують обробки
Створюючи gem або бібліотеку, багато розробники намагаються инкапсулировать функціональність, не дозволяючи винятків поширюватися за межі бібліотеки. Але іноді до завершення створення програми буває незрозуміло, як обробляти якийсь виняток.

В якості прикладу ідеального рішення візьмемо
<a href="https://github.com/rails/rails/tree/master/activerecord"></a>ActiveRecord
. Для повноти картини ця бібліотека дозволяє використовувати два підходу. Метод
<a href="https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/persistence.rb#L119-L123"></a>save 
обробляє виключення без їх розповсюдження, просто повертаючи
false
. А метод
<a href="https://github.com/rails/rails/blob/4-2-stable/activerecord/lib/active_record/persistence.rb#L141-L143"></a>save!
викликає виняток у разі збою. Таким чином можна по-різному обробляти ті чи інші помилки, або кожен збій обробляти в загальному порядку.

А якщо у вас немає часу або ресурсів для такої повноцінної реалізації? Якщо у вас немає повної впевненості, то краще всього розкрити (expose) виключення і відпустити його на волю.



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

Згадаймо попередній приклад, коли API отримував дані про вартість акцій. Ми вирішили обробляти ситуації, пов'язані з неповним або помилково сформованим відгуком, повторюючи запит до тих пір, поки не буде отриманий коректний відгук. Але по ходу розробки вимоги можуть змінитися. Наприклад, нам може знадобитися не посилати новий запит, а піднімати збережену історію цін.

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

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

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

Дотримуйтеся угода
Реалізація Ruby, і ще більшою мірою Rails, дотримуються деякі угоди про найменуваннях. Наприклад,
method_names
та
method_names!
відрізняються знаком оклику. В Ruby цей знак означає, що метод змінить викликав його об'єкт. А в Rails знак оклику говорить про те, що метод викличе виключення, якщо його поведінка не відповідатиме очікуванням. Намагайтеся слідувати цій угоді, особливо якщо ви збираєтеся розкрити код своєї бібліотеки.

Якщо будете писати в Rails-додатку новий
method!
, то ви повинні мати на увазі ці угоди. Ніщо не змушує нас викликати виключення при збої цього методу. Але у разі відступу від угоди інші програмісти можуть бути введені в оману, оскільки будуть вважати, що у них є шанс самостійно обробити виняток, хоча по факту це не так.

Згідно з іншим Ruby-угоди (автор Jim Weirich потрібно використовувати fail для позначення збою методу, а
raise 
можна використовувати тільки тоді, коли ви знову викликаєте виняток.

«Попутно зауважу, що оскільки я використовую виключення для позначення збоїв, то в Ruby майже завжди застосовую слово
fail
, а не
raise
. Fail і raise — синоніми, так що різниці ніякої, за винятком того, що «fail» однозначно повідомляє про збої методу. «Raise» я використовую лише в одному випадку: коли перехоплюю виняток і викликаю його знову. Збою не відбувається, але при цьому виключення викликається явно і цілеспрямовано. Я дотримуюся цей стиль, хоча сумніваюся, що багато роблять так само.»
Подібні угоди прийняті в спільнотах і багатьох інших мов програмування, тому їх ігнорування лише нашкодить читабельності і зручності обслуговування вашого коду.

Logger.log(все підряд)
Звичайно, ця методика застосовна не тільки до обробки винятків, але якщо щось і має журналироваться завжди, так це винятки.

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

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

Впевненість у чистому коді

Чистий обробка винятку відправить якість нашого коду в космос!

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

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

Ми переконалися в тому, що не варто «ловити їх всіх», і що краще дозволяти винятків «плавати навколо і булькати».

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

Нарешті, ми обговорили інші моменти, які можуть допомогти витягти максимальну користь — дотримання угод і повне журналювання.

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

0 коментарів

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