Работа с REST API в Angular JS
Введение
Так как я последнее время сильно увлечён фреймворком Angular, я стараюсь писать на нём настолько хорошо, насколько могу. Одна из последних интересных задач, с которыми мне пришлось столкнуться - работа с ресурсами. Для своего проекта я решил использовать так же Django REST Framework, т.к. видел о нём много положительных отзывов.
Ресурсом в данном контексте я понимаю экземпляр объекта, созданного Angular с помощью модуля $resource. Он позволяет легко обмениваться данными с сервером, полностью реализуя клиентскую часть архитектуры REST. В статье будут рассмотрены популярные решения и моё собственное.
Обзор REST
Рассматривать решение проблемы будем на примерах. Допустим, имеется новостной сайт. Каждая новость - это объект со своими свойствами. Кроме того, на сайте есть возможность оставлять комментарии. Итак, мы уже можем создать три взаимосвязанных класса объектов:
Пользователь- Логин
- Дата регистрации
- ФИО (для удобства объединим в одно поле, но на самом деле лучше сделать 3 отдельных)
- Дата рождения
- Блокировка
- Автор - ссылка на пользователя
- Дата публикации
- Заголовок
- Анонс
- Полный текст
- Опубликована (если нет, значит, находится в состоянии черновика)
- Количество просмотров (автоматически увеличивается с каждым уникальным пользователем)
- Автор - аналогично новости, ссылка на пользователя
- Текст комментария
- Новость - ссылка на описанную выше модель
- Дата написания
- Рейтинг - обновляется пользователями (автоматически вычисляется на сервере на основе анализа данных таблицы с плюсами и минусами)
Итак, у нас уже три сущности. Спроектируем API. Тут, в принципе, ничего сложного. Весь API уместится в несколько строк:
# Пользователи
/api/user/
/api/user/:id/
/api/user/:id/news/
/api/user/:id/comments/
# Новости
/api/news/
/api/news/:id/
/api/news/:id/comments/
# Комментарии
/api/comment/
/api/comment/:id/
Как видно, API получился очень простым. Сделаем допущение, что по ссылкам без указания версии используется последняя версия, а конкретную можно использовать, например, таким образом:
/api/v1/user/
Так же отметим, что здесь под :id имеется ввиду реальный id записи. Такое обозначение вдвойне удобно, учитывая, что подобным образом формируются ссылки при использовании модуля Angular Resource.
Так же важно понять, как именно работает данный API и почему так мало URL. Нижеприведённая таблица указывает, какого типа запросы отправляются на какой адрес и к чему это приводит:
URL | Метод | Результат |
---|---|---|
/api/user/ | GET | Возвращает список всех пользователей |
POST | Создаёт нового пользователя, возвращает его объект | |
/api/user/:id/ | GET | Возвращает информацию об указанном пользователе |
POST | Выполняет обновление информации об указанном пользователе | |
DELETE | Удаляет указанного пользователя | |
/api/user/:id/news/ | GET | Возвращает все новости, автором которых является указанный пользователь |
Сразу скажу, что при использовании Django REST Framework обновление записи методом POST
запрещено, вместо неё следует использовать метод PUT
. Для этого нужно будет особым образом сконфигурировать $resourceManager
, о чём будет сказано ниже.
Установка $resource и его настройка
Для установки зависимостей в проектах я предпочитаю использовать bower. На указанном сайте рассмотрена установка данного менеджера пакетов, здесь я на ней останавливаться не буду. Помимо angular-resource понадобится так же пакет angular-cookie. Без правильной конфигурации печенек Django будет блокировать любые обращения к URL нашего API.
Итак, ставим пакеты:
bower install angular-resource angular-cookie --save
Если Angular не был установлен ранее, он будет подтянут как зависимость. Подключите все нужные библиотеки к странице.
Теперь рассмотрим скрипт инициализации нашего приложения (как правило, это файл, имеющий название app.js)
(function (A) {
"use strict";
var app = A.module('MyApp', ['ngResource', 'ngCookie']);
app.config(['$resourceProvider', function($resourceProvider){
$resourceProvider.defaults.stripTrailingSlashes = false;
}]);
app.run(['$http', '$cookies', function($http, $cookies) {
$http.defaults.headers.post['X-CSRFToken'] = $cookies.csrftoken;
$http.defaults.headers.common['X-CSRFToken'] = $cookies.csrftoken;
}]);
}(this.angular));
Собственно, что происходит в этих нескольких строках.
- Указываем модуль angular-resource как зависимость для нашего приложения. Без этого ничего не заработает.
- Конфигурируем провайдер ресурсов. Говоря человеческим языком, делаем фундаментальные настройки, которые повлияют на всё приложение целиком.
- На этапе запуска приложения указываем, что любые HTTP-запросы должны содержать в себе поле X-CSRFToken. Значение берётся из Cookies, которые формируются на сервере в момент первого обращения к странице.
Работа с ресурсами
Здесь рассмотрим, как разработчики модуля $resource предлагают нам работать с ресурсами.
Получить нужную запись, изменить её данные и отправить изменения на сервер:
var News, //Ресурс
newsInstance; //Экземпляр новости
function updateNewsItem(){
newsInstance.published = false;
newsInstance.$save();
}
News = $resource('/api/news/:id/');
newsInstance = News.get({id: 3}, updateNewsItem); //Новость с id = 3, просто для примера
А как создать новую запись? Вот так
newsInstance = new News({text: 'Примерный текст новости'});
newsInstance.$save();
Что насчёт обновления? Метод $update
в свойствах объекта? Нет, не угадали.
newsInstance = News.get({id: 3});
newsInstance.author = 4; //Какой-то другой пользователь станет автором
News.update({id: 3}, newsInstance);
Немного странно, не правда ли? Скажу лишь, что скудность документации - одна из самых многочисленных жалоб на Angular.
Но это частности. Если вкратце, бОльшая часть решений, найденных мной в интернете, предполагает создание фабрики или сервиса, возвращающих созданный объект $resource, например, так мог бы выглядеть наш ресурс для работы с новостями:
(function (A){
"use strict";
var app = A.module('MyApp');
app.factory('News', ['$resource', function ($resource) {
return $resource('/api/news/:id/', {id: '@id'}, {
update:{
method: 'PUT' //Без этого не будет работать обновление объектов на стороне сервера,
//если используется Django REST Framework
}
});
}]);
}(this.angular));
Ладно, а что насчёт комментариев?
(function (A){
"use strict";
var app = A.module('MyApp');
app.factory('Comment', ['$resource', function ($resource) {
return $resource('/api/comment/:id/', {id: '@id'}, {
update:{
method: 'PUT' //Без этого не будет работать обновление объектов на стороне сервера,
//если используется Django REST Framework
}
});
}]);
}(this.angular));
Итак, налицо уже идёт дублирование кода! Значит, можно написать нечто такое:
(function (A){
"use strict";
var app = A.module('MyApp'),
resources = {
'User': '/api/user/:id/',
'UserComments': '/api/user/:id/comments/',
'UserNews': '/api/user/:id/news/',
'News': '/api/news/:id/',
'NewsCimments': '/api/news/:id/comments/',
'Comment': '/api/comment/:id/'
},
idConfig = {id: '@id'},
putSettings = {
update: {
method: 'PUT'
}
},
i;
for (i in resources){
if (resources.hasOwnProperty(i)){ //Стандартная проверка, что это свойство собственное,
//а не унаследовано от прототипа
app.factory(i, ['$resource', function ($resource){
return $resource(resources[i], idConfig, putSettings);
}]);
}
}
}(this.angular));
Ну что ж, неплохо, если не считать предупреждения статического анализатора, что опасно создавать функции в цикле. Но по-прежнему остаётся несколько проблем:
- Размножение сущностей. Почему для получения новости и комментариев к ней используются два разных ресурса?
- Фабрика. Внутри Angular работает так, что при каждом вызове фабрики создаётся новый объект. Конечно, в JavaScript работает автоматический сборщик мусора, но сам подход не очень хорош.
- В чём разница между
$save
и$update
? В том, что во втором случае передаётся значениеid
? Почему бы тогда не вызывать нужный метод в зависимости от того, есть в объекте это свойство или нет? - Так и не решилась проблема с обновлением записей. Конечно, у созданного объекта есть все нужные методы, но выглядят они уродливо (это моё личное мнение), плюс приходится работать на уровне, достаточно близком к Pure JS, а хотелось бы чего-то более абстрагированного.
- Что, если нам потребуются какие-то дополнительные методы для каждого из ресурсов? Например, автоматически выполнять локализацию дату публикации каждого комментария и каждой новости?
- Безобразие с реализацией обещаний. В некоторых случаях методы ресурсов возвращают объект, а в некоторых - promise. Опять же, путаница.
Пытаясь избавиться от множества описанных проблем, я пришёл к тому, что всё надо делать самому и написал модуль, лишённый описанных выше недостатков.
В процессе написания модуля пришлось прибегнуть к ряду различных ресурсов, самым полезным из которых оказался MDN, конкретно - вот эта статья. Для имитации классического наследования я использовал описанный в стандарте ECMAScript 5 метод Object.create
.
(function (A){
"use strict";
var app = A.module('MyApp'),
idParams = {id: '@id'}, //Эта часть без изменений
updParams = {
update: {
method: 'PUT'
}
};
app.service('Managers', [
'$q',
'$resource',
function($q, $resource){
/*
* Функция создаёт отложенный объект, т.е. тот, который
* имеет свойство promise и может находиться только в
* трех состояниях - разрешён, отклонён, в работе.
* Как правило, такие объекты используются вместе с AJAX
*/
function defer(){
return $q.defer();
}
/*
* Функция выполняет обращение к указанному ресурсу
* указанным методом. При необходимости передаются
* нужные параметры, например, экземпляр объекта.
*/
function execQuery(method, data, resource){
var d = defer();
// Ниже выполняется обращение к нужному методу ресурса
resource[method](
data,
function(response){ //Успешное выполнение запроса
d.resolve(response);
},
function (response){ //Запрос отклонён (ошибки)
d.reject(response);
}
);
return d.promise;
}
/*
* Универсальная функция для создания ресурсов. Принимает
* на вход два параметра - URL для обращения к API и
* объект конфигурации. Если он не указан, используется
* объект по-умолчанию, который будет брать id из
* свойств самого объекта, передаваемого на вход ресурса.
*/
function createResource(url, params){
return $resource(url, params || idParams, updParams);
}
function BaseManager(url, params){
this.sources = {
main: createResource(url, params);
};
}
// А теперь к прототипу добавим нужные методы
// Получить объект по id
BaseManager.prototype.getById = function(id){
return execQuery('get', {id: id}, this.sources.main);
};
// Получить все объекты
BaseManager.prototype.getAll = function(){
return execQuery('query', {}, this.sources.main);
};
//Удалить объект
BaseManager.prototype.remove = function(id){
return execQuery('remove', {id: id}, this.sources.main);
};
//Сохранить или обновить объект
BaseManager.prototype.save = function(item){
var method = item.hasOwnProperty('id') ? 'update' : 'save';
return execQuery(method, item, this.sources.main);
};
// Создадим объект новостей, используя шаблон классического
// наследования средствами ECMAScript 5
function News(){
//Вызов "предка" применительно к нашему "классу"
BaseManager.apply(this, ['/api/news/:id/']);
//Создадим дополнительный источник данных
this.sources.comments = createResource('/api/news/:id/comments/');
}
// В статье на MDN описано, что здесь происходит
News.prototype = Object.create(BaseManager.prototype);
News.prototype.constructor = News;
// А теперь - собственный метод
News.prototype.getComments = function (id){
return execQuery('query', {id: id}, this.sources.comments);
};
// Комментарии
function Comment(){
BaseManager.apply(this, ['/api/comment/:id/']);
}
Comment.prototype = Object.create(BaseManager);
Comment.prototype.constructor = Comment;
// Создаём объект, который будет хранить по одному экземпляру
// Описанных выше классов
return {
News: new News(),
Comment: new Comment()
};
}]);
}(this.angular));
Поскольку service реализует в Angular паттерн Одиночка, объект с экземплярами наших классов будет создан всего лишь один раз. Затем при каждом следующем вызове будет возвращено ранее сформированное значение. Посмотрим на практический пример применения описанного выше сервиса:
(function (A){
"use strict";
var app = A.module('MyApp');
app.controller('NewsListController', ['$scope', 'Managers', function ($scope, Managers){
// Загрузка всех новостей
function loadNews(){
$scope.loading = true;
$scope.news = [];
Managers.News.getAll().then(
function (rows){
$scope.news = rows;
},
function (response){
$scope.errors = response.data; // В DRF - именно так
}
);
}
loadNews();
// Загрузка комментариев при нажатии на ссылку "Комментарии" для
// выбранной новости
$scope.loadNewsComments = function(newsInstance){
newsInstance.commentsLoading = true;
Managers.News.getComments(newsInstance.id).then(
function(rows){
newsInstance.commentsLoading = false;
newsInstance.comments = rows;
},
function (response){
newsInstance.commentsLoading = false;
newsInstance.errors = response.data;
}
);
}
}]);
}(this.angular));
Как тот же код мог бы работать в личном кабинете администратора:
(function (A){
"use strict";
var app = A.module('MyApp');
app.controller('NewsCreateController', ['$scope', 'Managers', function ($scope, Managers){
$scope.save = function(){
Managers.News.save($scope.news_data).then(
function (response){
$scope.news_data = response; //Теперь у нашей новости
// появился id, и вообще все данные теперь - с сервера
},
function (response){
$scope.errors = response.data;
}
);
};
}]);
app.controller('NewsEditController', [
'$scope',
'$routeParams', // Взят для примера, для работы требует angular-route
'Managers',
function ($scope, $routeParams, Managers){
var newsId = $routeParams.id;
$scope.loaging = true;
Managers.News.getById(newsId).then(
function (response){
$scope.loading = false;
$scope.news_item = response;
},
function (response){
$scope.loading = false;
$scope.errors = response.data;
}
);
$scope.save = function(){
$scope.saving = true;
Managers.News.save($scope.news_item).then(
function (response){
$scope.saving = false;
$scope.news_item = response; //Строго говоря, это делать
//необязательно
},
function (response){
$scope.saving = false;
$scope.errors = response.data;
}
);
};
}]);
}(this.angular));
Как вы заметили, отличия в этих контроллерах практически минимальны. Перед нами - унифицированный способ работы с данными. Одним из важнейших преимуществ описанного подхода является так же использование наследования, что приводит к уменьшению количества кода.
Преветствуется конструктивная критика рассмотренных решений
P. S. Далее пойдёт описание того, как я создавал REST API на сервере.
Метод $update присутствует у экземпляров
ОтветитьУдалитьФабрика - тоже синглтон просто создается по другому и в ней проще делать наследуемые решения.
Про наследование и повторение кода тоже не понял, при чем оно тут, в статье про REST?
Какой прикол делать свой велосипед когда ресурсы отлично работают просто добавив в объект дополнительных функций по аналогии с update { method: 'PUT' } ?
Вы во всем правы. Статья была опубликована давно, я тогда не понимал некоторых вещей насчёт $resource. Перепишу, когда будет возможность, может, даже в следующем месяце.
ОтветитьУдалитьВ массиве ресурсов for (i in items) опечатка должно быть for (i in resources)
ОтветитьУдалить