Делюсь опытом в описанных технологиях. Блог в первую очередь выполняет роль памяток для меня самого.

Работа с REST API в Angular JS

3 комментария
Предупреждение! Статья устарела, часть описанных в ней вещей касательно $resource не верна, планируется к удалению.

Введение

Так как я последнее время сильно увлечён фреймворком 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 на сервере.

3 комментария :

  1. Метод $update присутствует у экземпляров
    Фабрика - тоже синглтон просто создается по другому и в ней проще делать наследуемые решения.
    Про наследование и повторение кода тоже не понял, при чем оно тут, в статье про REST?
    Какой прикол делать свой велосипед когда ресурсы отлично работают просто добавив в объект дополнительных функций по аналогии с update { method: 'PUT' } ?

    ОтветитьУдалить
  2. Вы во всем правы. Статья была опубликована давно, я тогда не понимал некоторых вещей насчёт $resource. Перепишу, когда будет возможность, может, даже в следующем месяце.

    ОтветитьУдалить
  3. В массиве ресурсов for (i in items) опечатка должно быть for (i in resources)

    ОтветитьУдалить