XMLHttpRequest (XHR) и внедрение зависимостей в AngularJS

До этого урока мы хранили данные в коде. Давайте рассмотрим как получать данные с сервера, используя один из встроенных в Angular сервисов $http. Мы будем использовать dependency injection (DI) чтобы предоставить сервис PhoneListCtrl контроллеру. К концу урока должен получиться такой результат. Для достижения такого результата нужно внести такие изменения.


Для начала переведем проект в состояние соответствующее этому уроку:


git checkout -f step-5

Данные

Создадим файл с данными app/phones/phones.json:
[
 {
  "age": 13,
  "id": "motorola-defy-with-motoblur",
  "name": "Motorola DEFY\u2122 with MOTOBLUR\u2122",
  "snippet": "Are you ready for everything life throws your way?"
  ...
 },
...
]

Контроллер

Мы будем использовать сервис $http чтобы сделать HTTP-запрос к веб-серверу и получить данные из файла app/phones/phones.json$http это один из встроенных в Angular сервисов, предназначенных для управления основными операциями в веб-приложениях. Angular встраивает эти сервисы там, где они вам нужны.

Сервисы находятся в ведении DI подсистемы. Внедрение зависимостей помогает сделать ваши веб-приложения хорошо структурированными (отдельные компоненты для представления, данных, и контроллера) и слабосвязанными (зависимости между компонентами не разрешаются самостоятельно компонентами, а их разрешает DI подсистема).

var phonecatApp = angular.module('phonecatApp', []);

phonecatApp.controller('PhoneListCtrl', function ($scope, $http) {
  $http.get('phones/phones.json').success(function(data) {
    $scope.phones = data;
  });

  $scope.orderProp = 'age';
});

$http делает GET-запрос к веб-серверу, запрашивая phones/phones.json (url задан относительно файла index.html). Сервер отвечает на запрос предоставляя данные в JSON-файле. Ответ также может быть динамически сгенерирован backend-сервером.

$http возвращает promise-объект с методом success. Мы вызываем этот метод для обработки асинхронного ответа и назначения данных свойству phones  в области видимости контроллера. Тут можно обработать ответ, например ограничить количество записей:
  • $scope.phones = data.splice(0, 5);

Angular распознает JSON и распарсит его самостоятельно. Вы может добавить в index.html следующий код:
<pre>{{phones | filter:query | orderBy:orderProp | json}}</pre>
для того чтобы убедиться что список телефонов хранится в JSON-формате.

Для того чтобы задействовать сервис, надо просто объявить его в аргументах конструирующей функции контроллера:
phonecatApp.controller('PhoneListCtrl', function ($scope, $http) {...}
Обратите внимание на то, что имена аргументов приобретают некоторое дополнительное важное значение, потому что инжектор зависимостей использует их для нахождения зависимостей.

Инжектор зависимостей предоставляет сервисы вашему контроллеру при его конструировании. Инжектор зависимостей также заботится о транзитивных зависимостях, которые есть у сервиса (т.к. одни сервисы могут зависеть от других).

Правила именования с префиксом $

Пользователь может создавать собственные сервисы. По соглашению об именовании в Angular встроенные сервисы, Scope-методы и другие API имеют префикс $. Данные префикс принадлежит пространству имен сервисов предоставляемых Angular. Поэтому чтобы избежать коллизий, избегайте именовать собственные сервисы и модели начиная с префикса $. Некоторые свойства в Scope имеют префикс $$, который обозначает в данном случае приватность, т.е. эти свойства не должны читаться и записываться из вне.


Минификация

Т.к. Angular определяет зависимости контроллера смотря на имена его аргументов в конструкторе, то после минификации JavaScript-кода для PhoneListCtrl контроллера, все его аргументы в функциях тоже будут минифицированы и инжектор зависимостей не сможет корректно определить сервисы. Решить эту проблему можно если аннотировать функцию именами зависимостей представленными в строковом формате, который не подвергается минификации. Есть два способа:

1) Создать свойство $inject которое будет содержать массив строк. Каждая строка в массиев соответсвует имени сервиса. Например:
  • function PhoneListCtrl($scope, $http) {...}
    PhoneListCtrl.$inject = ['$scope', '$http'];
    phonecatApp.controller('PhoneListCtrl', PhoneListCtrl);

2) Использовать inline-аннотацию, где вместо функции вы предоставляете массив содержащий имена сервисов и саму функцию:
  • function PhoneListCtrl($scope, $http) {...}
    phonecatApp.controller('PhoneListCtrl', ['$scope', '$http', PhoneListCtrl]);
При использовании второго метода функцию конструктора можно сделать анонимной:
phonecatApp.controller('PhoneListCtrl', ['$scope', '$http', function($scope, $http) {...}]);

Оба этих метода работают с любой функцией которую может инжектировать Angular.

В нашем уроке мы использовали второй способ для контроллера PhoneListCtrl:
var phonecatApp = angular.module('phonecatApp', []);

phonecatApp.controller('PhoneListCtrl', ['$scope', '$http',
  function ($scope, $http) {
    $http.get('phones/phones.json').success(function(data) {
      $scope.phones = data;
    });

    $scope.orderProp = 'age';
  }]);

Тестирование

Т.к. мы начали использовать внедрение зависимостей и у нашего контроллера появились зависимости, конструирование контроллера в тестах немного усложнится. Мы можем использовать оператор new и предоставить конструктору какую-нибудь реализацию $http. Однако Angular предоставляет mock-объект $http сервиса, который можно использовать в модульных тестах. Мы настраиваем фальшивые ответы от сервера путем вызова методов на сервисе с именем $httpBackend:
describe('PhoneCat controllers', function() {

describe('PhoneListCtrl', function(){
  var scope, ctrl, $httpBackend;

  // Load our app module definition before each test.
  beforeEach(module('phonecatApp'));

  // The injector ignores leading and trailing underscores here (i.e. _$httpBackend_).
  // This allows us to inject a service but then attach it to a variable
  // with the same name as the service in order to avoid a name conflict.
  beforeEach(inject(function(_$httpBackend_, $rootScope, $controller) {
    $httpBackend = _$httpBackend_;
    $httpBackend.expectGET('phones/phones.json').
        respond([{name: 'Nexus S'}, {name: 'Motorola DROID'}]);

    scope = $rootScope.$new();
    ctrl = $controller('PhoneListCtrl', {$scope: scope});
  }));

Т.к. мы загружаем Jasmine и angular-mocks.js в нашу тестовую среду, мы получаем две вспомогательные функции module и inject, которые можно использовать для доступа к инжектору и его настройке

Мы создали контроллер в тестовой среде следующим способом:
  • Мы использовали вспомогательный метод inject для внедрения экземпляров сервисов $rootScope$controller и $httpBackend в функцию Jasmine beforeEach. Эти экземпляры исходят от инжектора, который создается заново для каждого одиночного теста. Это гарантирует, что тесты изолированы друг от друга.
  • Мы создали новую область видимости для нашего контроллера вызвав $rootScope.$new()
  • Мы вызвали внедренную функцию $controller передав ей в качестве аргументов контроллер PhoneListCtrl и созданную область видимости.

Т.к. наш код теперь использует $http сервис для извлечения списка телефонов в нашем контроллере, то перед тем как мы создадим дочернюю область видимости PhoneListCtrl, нам нужно сказать тестовому механизму ожидать входящего запрос от контроллера:

  • $httpBackend - это mock-версия сервиса, которые в production-среде обеспечивает поддержку всех XHR и JSONP-запросов. Mock-версия этого сервиса позволяет писать тесты без надобности иметь дело с родным API.
  • Метод $httpBackend.expectGET используется для того чтобы научить сервис $httpBackend, что он должен возвращать на входящие HTTP-запросы. Обратите внимание, что ответы не возвращаются пока не будет вызван метод $httpBackend.flush.


Теперь надо сделать утверждения чтобы удостоверится в том что модель phones не существует в области видимости scope пока не получен ответ:
it('should create "phones" model with 2 phones fetched from xhr', function() {
  expect(scope.phones).toBeUndefined();
  $httpBackend.flush();

  expect(scope.phones).toEqual([{name: 'Nexus S'},
                               {name: 'Motorola DROID'}]);
});



Наконец мы проверяем, что правильно установлено свойство orderProp:
it('should set the default value of orderProp model', function() {
  expect(scope.orderProp).toBe('age');
});

Результаты теста должны быть такими:
Chrome 22.0: Executed 2 of 2 SUCCESS (0.028 secs / 0.007 secs)