Обработка ошибок в RESTful — приложениях
За последнее время очень многие веб-фреймворки обзавелись RESTful роутингом. Более того, REST стал де-факто стандартом проектирования архитектуры веб-приложений. Практически все более-менее значимые сервисы обзавелись RESTful API с представлением данных через xml и json форматы. Такой популярности REST помогло как появление большого количества руководств, так и горячие обсуждения REST среди специалистов.
Вместе с тем, REST до сих пор воспринимается скорее как некоторый набор правил роутинга, а всё что не связано в прямую с роутингом решается произвольным путём, в частности это касается обработки ошибок в RESTful-приложениях.
Обработка ошибок на программном уровне
Рассмотрим некоторое веб-приложение в котором пользователь может динамически добавить статью article
в список статей self.articles
. Обычно при динамической реализации используется такой подход:
- Собираются данные с формы и формируется некоторый объект с данными
- Данные отправляются через post/put запрос на сервер
- Дальнейшее выполнение программы зависит от состояния флага успешности в ответе, например
response.status = 'ok'
Т.е. в коде на javscript + jQuery и Ruby это могло бы выглядеть так:
$.post('articles', {title: 'title', text: 'text'}, 'json').done(function(article_data){
if(response.status == 'ok'){
self.articles.push(article_data);
}else{
var messages = response.messages;
// Обработать ошибку валидации и вывести сообщения
}
}).fail(function(response){
// Обработать ошибку сервера или соединения
});
А это пример контроллера на Ruby on Rails. Это далеко не идеальный код, но часто встречающийся в таком виде. Результат выполнения метода create
не влияет на статус код HTTP:
class ArticlesController < ApplicationController
def create
@article = Article.new
@article.title = params[:title]
@article.text = params[:text]
# ... какие-то действия ...
if not @article.valid?
render json: {status: 'error', messages: @article.errors.messages}
end
@article.save
render json: @article
end
end
Плюсы:
- Можно делать сколь угодно сложный интерфейс для обработки ошибок.
- Нет ограничений на количество состояний.
- Не требуется знаний всех тонкостей HTTP (коих очень много).
В таком подходе есть несколько слабых мест:
- Нарушается семантика ответов на прикладном уровне, невалидный запрос может получить ответ 200 OK
- Прикладной уровень и программный дублируют друг друга. Так в случае успеха, появляются две проверки на уровне XMLHttpRequest и на уровне пользовательского кода, в виде проверки response.status == 'ok'
- Требуется спецификация интерфейса, что бы знать какие поля отвечают за состояние
Обработка ошибок на прикладном уровне
Т.к. RESTful уже подразумевает наложение некоторых ограничений, то такой же подход можно применить и по отношению к ошибкам. Т.е. использовать для обработки ошибок статус коды HTTP, подобно тому как ресурсы отображают доступные модели и контроллеры. Так, например, для уведомления о невалидных данных можно использовать ошибку 422 Unprocessable Entity
, а список невалидных полей передавать непосредственно в виде массива в теле ответа:
$.post('articles', {title: 'title', text: 'text'}, 'json').done(function(response){
var article = response.data;
self.articles.push(article);
}).fail(function(e, ){
switch(e.status){
case 422:
var messages = response.responseText;
// Обработать ошибку валидации и вывести сообщения
default:
// Обработать ошибку сервера или соединения
}
});
При реализации контроллера есть смысл разнести обработку ошибок по разным rescue блокам. Так, в случае если поля окажутся невалидными, выполнится первый rescue-блок. Если случится какая-то иная ошибка с валидными данными, то сработает последний rescue. Таким образом, на стороне клиента можно отлавливать ошибки без привлечения дополнительных статус-полей в json.
class ArticlesController < ApplicationController
def create
@article = Article.new
@article.title = params[:title]
@article.text = params[:text]
# ... какие-то действия ...
# ! возбуждает исключение если есть невалидные поля
@article.save!
render json: @article
rescue Mongoid::Errors::Validations
render json: @article.errors.messages, status: 422
rescue
render text: 'Internal server error', status: 500
end
end
Плюсы:
- Семантика кодов ошибок HTTP и приложения совпадают, подобно тому как совпадают модели с названием ресурсов, а методы контроллера с методами HTTP.
- Исключается дублирования программного и прикладного уровней.
- Не требуется разработка спецификаций ошибок.
Недостатки:
- Не всегда существует нужный код ошибки
- Необходимо хорошо знать особенности HTTP (статус код может повлиять на работу браузера)
Как вариант, для расширения существующих статусов можно добавлять поля в заголовок, например X-Status-Reason: Validation failed
В 70% случаев мне удаётся ограничится данными статус кодами HTTP:
200 OK |
Штатный ответ, у пользователя есть права на доступ к ресурсу |
404 Not Found |
Ресурса с данным id не существует |
403 Forbidden |
Попытка доступа к ресурсу на которого у пользователя нет прав. |
409 Conflict |
Попытка создания дублирующего ресурса, например регистрация пользователя с существующим в БД email'ом. |
422 Unprocessable Entity |
Форма с невалидными данными. |
500 Internal Server Error |
В случае какого-то непредвиденного исключения. |
Тем не менее вопрос, какие коды использовать в конкретном случае может зависит от многих факторов. Поэтому привожу полезные обсуждения на стековерфлоу:
Proper use of HTTP status codes in a “validation” server
Руководство по роутингу в Ruby on Rails:
Rails Routing from the Outside In
Диссертация Роя Филдинга по REST:
Результаты опроса на хабре: