[Перевод] Чистый код и искусство обработки исключений
Исключения существуют столько же, сколько само программирование. В самом начале, когда программирование было исключительно аппаратным или с использованием низкоуровневых языков, исключения применялись для изменения потока программы и избегания аппаратных сбоев. Согласно Википедии, исключения — это:
…ошибки времени выполнения и другие возможные проблемы (исключения), которые могут возникнуть при выполнении программы…
Исключения требуют к себе особого отношения, а необработанное исключение может привести к непредсказуемому поведения программы. И последствия могут быть очень серьёзными. Например, в 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 или библиотеку, многие разработчики пытаются инкапсулировать функциональность, не позволяя исключениям распространяться за пределы библиотеки. Но иногда до завершения создания приложения бывает непонятно, как обрабатывать какое-то исключение.
В качестве примера идеального решения возьмём ActiveRecord
. Для полноты картины эта библиотека позволяет использовать два подхода. Метод save
обрабатывает исключения без их распространения, просто возвращая false
. А метод 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, и поняли, что не обязаны обрабатывать их сразу же по мере возникновения. Мы обсудили, когда именно лучше всего заняться обработкой, а когда этого делать не стоит, и почему в случае сомнений рекомендуется позволить исключениям распространиться.
Наконец, мы обсудили другие моменты, которые могут помочь извлечь максимальную пользу — соблюдение соглашений и полное журналирование.
Благодаря этим основным рекомендациям можно будет чувствовать себя куда увереннее и свободнее, разбираясь с ошибками в коде, и делая наши исключения по-настоящему исключительными!