[Rails] Exception handling
Продолжаю свои увлекательные (или не очень) рассказы про рабочие будни и задачи, которые надо было решать. Сразу к делу:
Задача
Сделать кастомные страницы исключений для апи и веб интерфейсов. Стандартизировать подход к отлову и отдаче исключений.
№ Контекст
Иногда в приложении происходит неведомая х**ня что-то странное, и вылезает исключение, с ответом в виде статичной html страницы и в апи тоже, отчего случается веселье на всех концах.
А что там под капотом?
Под капотом Rack-based приложение на Rails. Rack был изначально придуман, чтобы упростить всем жизнь, он представляет из себя слоеный пирог middleware. Каждый слой это Ruby объект, принимающий в конструктор объект app, отвечающий на метод call, в который прокидывается environment, а возвращается массив(Array), состоящий ровно из трех элементов: status, headers, body. Начиная с версии руби 1.9 body должно реализовывать метод each, т.е. по сути быть тем же массивом.
Middleware является реализацией то ли паттерна Chain of Responsibility, то ли Pipeline, этого момента я сам до конца не понял. С одной стороны, оно как бы отдает на выход то, что принимает следующий слой, с другой, реквест, который пришел, обрабатывают не все слои, а только те, которые активированы или требованиям которых запрос удовлетворяет. Ну как-то так...
А как это делают?
Готовые решения уже есть, но чтобы все самому прочувствовать, надо залезть во внутренности Rails и посмотреть, закатали рукава и вперед.
Чтобы получить весь список middleware из приложения набираем в консоли:
$ rake middleware
Смотрим на понятно-непонятные буковки и нас интересует:
use ActionDispatch::ShowExceptions
В нашем приложении используется последняя стабильная сборка 3.2.9, поэтому её и будем копать:
actionpack/lib/action_dispatch/middleware/show_exceptions.rb
def call(env)
begin
response = @app.call(env)
rescue Exception => exception
raise exception if env['action_dispatch.show_exceptions'] == false
end
response || render_exception(env, exception)
end
Ничего особо сложного, прокидываем дальше запрос и ждем пока вылетит исключение, если такая ситуация не возникла, то просто отдадим response, если что-то пошло не так, то поймаем Exception(т.е. любое исключение), т.к. в конфиге рельсов можно настроить все что хочешь, то и этот middleware можно отключить, если так, то ошибка необработанной уйдет дальше по цепочке. Если слой не отключен, то вызовем метод _render_exception//. А вот в нем творятся интересные вещи(код приведу не полностью):
def render_exception(env, exception)
wrapper = ExceptionWrapper.new(env, exception)
...
env["PATH_INFO"] = "/#{status}"
response = @exceptions_app.call(env)
rescue Exception => failsafe_error
$stderr.puts "..."
FAILSAFE_RESPONSE
end
Во-первых, исключение отдается в ExceptionWrapper, который сомтрит готовый маппинг известных ошибок в символьный аналог статус кодов. Во-вторых, в переменную пути складывается статус-код, что нам впоследствии пригодится, и далее, обработка всего и вся отдается некоему @exceptions_app. Это еще что такое?
А это ,всего навсего, еще одно middleware, которое по дефолту в Rails занимается обработкой исключений. Если почитаете гайды, то узнаете, что имя ему PublicExceptions. Все, что там происходит, так это прокидывание статуса(404, 422, 500) дальше по цепочке и рендеринг стандартных страниц с ошибками, которые лежат в public.
А дальше что?
А дальше, сконфигурировать можно все, поэтому переопределим exceptions_app
. Тут встает вопрос, писать своё middleware или взять что-то готовое?
за свое | против |
органично вписывается в архитектуру | то не знает, не найдет |
свобода мысли | сложность тестирования |
А какое готовое? Есть ли такое приложение или слой, который органично вписывался бы в существующую структуру и при этом решал поставленную задачу? Да.
В application.rb
укажем:
config.exceptions_app = self.routes
Как вы помните в path_info
складывается путь /404, /500, /422 их будем ловить в routes.rb
. Перенаправим на обычный контроллер, назовем его errors
.
match '/404', :to => "errors#not_found"
match '/422', :to => "errors#unprocessable_entity"
match '/500', :to => "errors#internal_error"
Теперь создадим errors_controller.rb
в папке /app/controllers/
, никакой авторизации, эти страницы должны быть доступны для всех. Добавляем
respond_to :json, :html
def not_found
respond_with({:message => 'Not Found'}, :status => :not_found)
end
Аналогично определяем еще 2 метода, заменив только статусы на соответствующие ошибкам(422 - :unprocessable_entity, 500 - :internal_server_error).
Напишем тесты — ссылка на github. Ничего сложного, код опущу для экономии места.
Далее сделаем view
отдельно для html и json и сложим их в app/views/errors/
Последнее, что надо сделать, чтобы в режиме development увидеть кастомные страницы, это заменить значение одного параметра в config/environments/development.rb true -> false
.
config.consider_all_requests_local = false
Полный код можно посмотреть и скачать с github demo_exception_handling. После этого можно сделать bundle install
, затем запустить сервер(bundle exec rails s
) и обратиться по адресам стандартных страничек ошибок:
Там можно увидеть наши простенькие кастомные страницы, а что самое классное, если обратить к этим страницам с форматом json
, то получим
{ message: 'Not Found' }
а не статичный html. Если потребуется можно выводить какие-то специфические коды ошибок в апи и отдавать их в json как будет удобно. В общем простор для фантазии. На этом я думаю все.
Откуда черпал информацию:
- https://coderwall.com/p/w3ghqq\r\n - http://blog.plataformatec.com.br/2012/01/my-five-favorite-hidden-features-in-rails-3-2/\r\n
- http://pickardayune.com/blog/2012/06/25/rails-3-exception-handling/\r\n - http://www.derekhammer.com/2012/05/03/use-exceptions-in-rails-to-create-readable-apis-.html\r\n
- http://www.codyfauser.com/2008/7/4/rails-http-status-code-to-symbol-mapping\r\n
- https://github.com/rails/rails/tree/v3.2.9