Эта статья является частью 3 учебного курса по SitePoint Angular 2+ о том, как создать приложение CRUD с помощью Angular CLI . В этой статье мы обновим наше приложение для связи с серверной частью REST API.
Предпочитаете изучать Angular с помощью пошагового видеокурса? Проверьте Learn Angular 5 на SitePoint Premium.
В первой части мы узнали, как запустить наше приложение Todo и развернуть его на страницах GitHub. Это работало просто отлично, но, к сожалению, все приложение было собрано в один компонент.
Во второй части мы рассмотрели более модульную архитектуру компонентов и узнали, как разбить этот отдельный компонент на структурированное дерево более мелких компонентов, которые легче понять, использовать повторно и поддерживать.
- Часть 0 — Ultimate Angular CLI Справочное руководство
- Часть 1. Подготовка и запуск нашей первой версии приложения Todo
- Часть 2. Создание отдельных компонентов для отображения списка задач и одной задачи
- Часть 3. Обновление сервиса Todo для связи с серверной частью REST API.
- Часть 4. Использование углового маршрутизатора для разрешения данных
- Часть 5. Добавление аутентификации для защиты частного контента.
- Часть 6 — Как обновить Angular Projects до последней версии.
Вам не нужно следовать частям 1 и 2 этого урока, чтобы иметь смысл. Вы можете просто взять копию нашего репо , получить код из второй части и использовать его в качестве отправной точки. Это объясняется более подробно ниже.
Краткий обзор
Вот как выглядела наша архитектура приложения в конце части 2:
В настоящее время TodoDataService
хранит все данные в памяти. В этой третьей статье мы обновим наше приложение для взаимодействия с серверной частью REST API.
Мы будем:
- создать макет REST API
- сохранить URL API в качестве переменной среды
- создать
ApiService
для связи с серверной частью REST API - обновите
TodoDataService
чтобы использовать новыйApiService
- обновить
AppComponent
для обработки асинхронных вызовов API - создайте
ApiMockService
чтобы избежать реальных HTTP-вызовов при выполнении модульных тестов.
К концу этой статьи вы поймете:
- как вы можете использовать переменные среды для хранения настроек приложения
- как вы можете использовать Angular HTTP-клиент для выполнения HTTP-запросов
- как вы можете обращаться с Observables, которые возвращаются клиентом Angular HTTP
- как вы можете издеваться над HTTP-вызовами, чтобы избежать выполнения реального HTTP-запроса при запуске модульных тестов
Итак, начнем!
Вверх и работает
Убедитесь, что у вас установлена последняя версия Angular CLI. Если вы этого не сделаете, вы можете установить это с помощью следующей команды:
npm install -g @angular/cli@latest
Если вам нужно удалить предыдущую версию Angular CLI, вы можете:
npm uninstall -g @angular/cli angular-cli npm cache clean npm install -g @angular/cli@latest
После этого вам понадобится копия кода из второй части. Это доступно на GitHub . Каждая статья в этой серии имеет соответствующий тег в репозитории, чтобы вы могли переключаться между различными состояниями приложения.
Код, который мы закончили во второй части и с которого мы начнем в этой статье, помечен как часть-2 . Код, которым мы заканчиваем эту статью, помечен как часть-3 .
Вы можете думать о тегах как псевдоним определенного идентификатора коммита. Вы можете переключаться между ними, используя git checkout
. Вы можете прочитать больше об этом здесь .
Итак, чтобы начать работу (установлена последняя версия Angular CLI), мы должны сделать следующее:
git clone [email protected]:sitepoint-editors/angular-todo-app.git cd angular-todo-app git checkout part-2 npm install ng serve
Затем посетите http: // localhost: 4200 / . Если все хорошо, вы должны увидеть работающее приложение Todo.
Настройка серверной части REST API
Давайте использовать json-сервер, чтобы быстро настроить макет бэкэнда.
Из корня приложения запустите:
npm install json-server --save
Затем в корневом каталоге нашего приложения создайте файл с именем db.json
со следующим содержимым:
{ "todos": [ { "id": 1, "title": "Read SitePoint article", "complete": false }, { "id": 2, "title": "Clean inbox", "complete": false }, { "id": 3, "title": "Make restaurant reservation", "complete": false } ] }
Наконец, добавьте скрипт в package.json
чтобы запустить наш бэкэнд:
"scripts": { ... "json-server": "json-server --watch db.json" }
Теперь мы можем запустить наш REST API, используя:
npm run json-server
Это должно отобразить следующее:
\{^_^}/ hi! Loading db.json Done Resources http://localhost:3000/todos Home http://localhost:3000
Это оно! Теперь у нас есть серверная часть REST API, прослушивающая порт 3000.
Чтобы убедиться, что ваш сервер работает должным образом, вы можете перейти в браузере по http://localhost:3000
.
Поддерживаются следующие конечные точки:
-
GET /todos
: получить все существующие задачи -
GET /todos/:id
: получить существующую задачу -
POST /todos
: создать новую задачу -
PUT /todos/:id
: обновить существующую задачу -
DELETE /todos/:id
: удалить существующую задачу
Поэтому, если вы перейдете в браузер по http://localhost:3000/todos
, вы должны увидеть ответ JSON со всеми db.json
из db.json
.
Чтобы узнать больше о json-сервере, обязательно ознакомьтесь с фиктивными REST API, используя json-server .
Хранение URL API
Теперь, когда у нас есть наш сервер, мы должны сохранить его URL в нашем приложении Angular.
В идеале мы должны быть в состоянии это:
- хранить URL-адрес в одном месте, так что мы должны изменить его только один раз, когда нам нужно изменить его значение
- заставьте наше приложение подключаться к API разработки во время разработки и подключаться к производственному API в производстве
К счастью, Angular CLI поддерживает среды. По умолчанию существует две среды: разработка и производство, обе с соответствующим файлом среды: src/environments/environment.ts
и ‘ src/environments/environment.prod.ts
.
Давайте добавим наш URL API в оба файла:
// src/environments/environment.ts // used when we run `ng serve` or `ng build` export const environment = { production: false, // URL of development API apiUrl: 'http://localhost:3000' };
// src/environments/environment.prod.ts // used when we run `ng serve --environment prod` or `ng build --environment prod` export const environment = { production: true, // URL of production API apiUrl: 'http://localhost:3000' };
Позже это позволит нам получить URL API из нашей среды в нашем приложении Angular, выполнив:
import { environment } from 'environments/environment'; // we can now access environment.apiUrl const API_URL = environment.apiUrl;
Когда мы запускаем ng serve
или ng build
, Angular CLI использует значение, указанное в среде разработки ( src/environments/environment.ts
).
Но когда мы запускаем ng serve --environment prod
или ng build --environment prod
, Angular CLI использует значение, указанное в src/environments/environment.prod.ts
.
Это именно то, что нам нужно, чтобы использовать другой URL-адрес API для разработки и производства, без необходимости изменять наш код.
Приложение из этой серии статей не размещается в рабочей среде, поэтому мы указываем один и тот же URL API в нашей среде разработки и производства. Это позволяет нам запускать ng serve --environment prod
или ng build --environment prod
локально, чтобы увидеть, все ли работает как положено.
Вы можете найти соответствие между dev
и prod
и соответствующими им файлами окружения в .angular-cli.json
:
"environments": { "dev": "environments/environment.ts", "prod": "environments/environment.prod.ts" }
Вы также можете создать дополнительные среды, такие как staging
, добавив ключ:
"environments": { "dev": "environments/environment.ts", "staging": "environments/environment.staging.ts", "prod": "environments/environment.prod.ts" }
и создание соответствующего файла среды.
Чтобы узнать больше о средах Angular CLI, ознакомьтесь со Справочным руководством Ultimate Angular CLI .
Теперь, когда наш URL API хранится в нашей среде, мы можем создать службу Angular для связи с серверной частью REST API.
Создание службы для связи с бэкэндом API REST
Давайте используем Angular CLI для создания ApiService
для взаимодействия с нашей REST API серверной частью:
ng generate service Api --module app.module.ts
Это дает следующий вывод:
installing service create src/app/api.service.spec.ts create src/app/api.service.ts update src/app/app.module.ts
Опция --module app.module.ts
указывает Angular CLI не только создавать службу, но и регистрировать ее в качестве поставщика в модуле Angular, определенном в app.module.ts
.
Давайте откроем src/app/api.service.ts
:
import { Injectable } from '@angular/core'; @Injectable() export class ApiService { constructor() { } }
Далее мы внедряем нашу среду и встроенный HTTP-сервис Angular:
import { Injectable } from '@angular/core'; import { environment } from 'environments/environment'; import { Http } from '@angular/http'; const API_URL = environment.apiUrl; @Injectable() export class ApiService { constructor( private http: Http ) { } }
Прежде чем мы реализуем необходимые нам методы, давайте взглянем на HTTP-сервис Angular.
Если вы не знакомы с синтаксисом, почему бы не купить наш Премиум курс, вводящий TypeScript .
Угловой HTTP-сервис
Служба Angular HTTP доступна в виде инъекционного класса из @angular/http
.
Он построен на основе XHR / JSONP и предоставляет нам HTTP-клиент, который мы можем использовать для выполнения HTTP-запросов из нашего приложения Angular.
Для выполнения HTTP-запросов доступны следующие методы:
-
delete(url, options)
: выполнить запрос DELETE -
get(url, options)
: выполнить запрос GET -
head(url, options)
: выполнить запрос HEAD -
options(url, options)
: выполнить запрос OPTIONS -
patch(url, body, options)
: выполнить запрос PATCH -
post(url, body, options)
: выполнить запрос POST -
put(url, body, options)
: выполнить запрос PUT.
Каждый из этих методов возвращает наблюдаемую RxJS.
В отличие от методов HTTP-сервиса AngularJS 1.x, которые возвращали обещания, методы HTTP-сервиса Angular возвращают Observables.
Не беспокойтесь, если вы еще не знакомы с RxJS Observables. Нам нужны только основы, чтобы запустить наше приложение. Вы можете постепенно узнавать больше о доступных операторах, когда ваше приложение требует их, а веб-сайт ReactiveX предлагает фантастическую документацию.
Если вы хотите узнать больше о Observables, возможно, стоит ознакомиться с введением SitePoint в функциональное реактивное программирование с использованием RxJS .
Реализация методов ApiService
Если мы вспомним о конечных точках, то наша серверная часть REST API предоставит:
-
GET /todos
: получить все существующие задачи -
GET /todos/:id
: получить существующую задачу -
POST /todos
: создать новую задачу -
PUT /todos/:id
: обновить существующую задачу -
DELETE /todos/:id
: удалить существующую задачу
мы уже можем создать приблизительную схему нужных нам методов и соответствующих им методов Angular HTTP:
import { Injectable } from '@angular/core'; import { environment } from 'environments/environment'; import { Http, Response } from '@angular/http'; import { Todo } from './todo'; import { Observable } from 'rxjs/Observable'; const API_URL = environment.apiUrl; @Injectable() export class ApiService { constructor( private http: Http ) { } // API: GET /todos public getAllTodos() { // will use this.http.get() } // API: POST /todos public createTodo(todo: Todo) { // will use this.http.post() } // API: GET /todos/:id public getTodoById(todoId: number) { // will use this.http.get() } // API: PUT /todos/:id public updateTodo(todo: Todo) { // will use this.http.put() } // DELETE /todos/:id public deleteTodoById(todoId: number) { // will use this.http.delete() } }
Давайте подробнее рассмотрим каждый из методов.
getAllTodos ()
Метод getAllTodos()
позволяет нам получить все задачи из API:
public getAllTodos(): Observable<Todo[]> { return this.http .get(API_URL + '/todos') .map(response => { const todos = response.json(); return todos.map((todo) => new Todo(todo)); }) .catch(this.handleError); }
Сначала мы делаем запрос GET, чтобы получить все задачи из нашего API:
this.http .get(API_URL + '/todos')
Это возвращает Observable.
Затем мы вызываем метод map()
в Observable для преобразования ответа от API в массив объектов Todo
:
.map(response => { const todos = response.json(); return todos.map((todo) => new Todo(todo)); })
Входящий HTTP-ответ является строкой, поэтому сначала мы вызываем response.json()
чтобы проанализировать строку JSON и соответствующее ей значение JavaScript.
Затем мы перебираем todos ответа API и возвращаем массив экземпляров Todo. Обратите внимание, что при втором использовании map()
используется Array.prototype.map()
, а не оператор RxJS.
Наконец, мы подключаем обработчик ошибок для регистрации потенциальных ошибок на консоли:
.catch(this.handleError);
Мы определяем обработчик ошибок в отдельном методе, чтобы мы могли использовать его в других методах:
private handleError (error: Response | any) { console.error('ApiService::handleError', error); return Observable.throw(error); }
Прежде чем мы сможем запустить этот код, мы должны импортировать необходимые зависимости из библиотеки RxJS:
import { Observable } from 'rxjs/Observable'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import 'rxjs/add/observable/throw';
Обратите внимание, что библиотека RxJS огромна. Вместо импорта всей библиотеки RxJS с использованием import * as Rx from 'rxjs/Rx'
, рекомендуется импортировать только те фрагменты, которые вам необходимы. Это существенно уменьшит размер вашего результирующего пакета кода до минимума.
В нашем приложении мы импортируем класс Observable
:
import { Observable } from 'rxjs/Observable';
Мы импортируем три оператора, которые необходимы нашему коду:
import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import 'rxjs/add/observable/throw';
Импорт операторов гарантирует, что к нашим экземплярам Observable прикреплены соответствующие методы.
Если в нашем коде нет import 'rxjs/add/operator/map'
, то следующее не будет работать:
this.http .get(API_URL + '/todos') .map(response => { const todos = response.json(); return todos.map((todo) => new Todo(todo)); })
Это связано с тем, что в Observable, возвращаемом this.http.get
, не будет метода map()
.
Нам нужно только один раз импортировать операторы, чтобы включить соответствующие методы Observable в вашем приложении. Тем не менее, импортировать их более одного раза не проблема и не увеличит размер результирующего пакета.
getTodoById ()
Метод getTodoById()
позволяет нам получить одну задачу:
public getTodoById(todoId: number): Observable<Todo> { return this.http .get(API_URL + '/todos/' + todoId) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); }
Нам не нужен этот метод в нашем приложении, но он включен, чтобы дать вам представление о том, как он будет выглядеть.
createTodo ()
Метод createTodo()
позволяет нам создать новую задачу:
public createTodo(todo: Todo): Observable<Todo> { return this.http .post(API_URL + '/todos', todo) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); }
Сначала мы выполняем запрос POST к нашему API и передаем данные в качестве второго аргумента:
this.http.post(API_URL + '/todos', todo)
Затем мы преобразовываем ответ в объект Todo
:
map(response => { return new Todo(response.json()); })
updateTodo ()
Метод updateTodo()
позволяет нам обновить одну задачу:
public updateTodo(todo: Todo): Observable<Todo> { return this.http .put(API_URL + '/todos/' + todo.id, todo) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); }
Сначала мы выполняем запрос PUT к нашему API и передаем данные в качестве второго аргумента:
put(API_URL + '/todos/' + todo.id, todo)
Затем мы преобразовываем ответ в объект Todo
:
map(response => { return new Todo(response.json()); })
deleteTodoById ()
Метод deleteTodoById()
позволяет нам удалить одну задачу:
public deleteTodoById(todoId: number): Observable<null> { return this.http .delete(API_URL + '/todos/' + todoId) .map(response => null) .catch(this.handleError); }
Сначала мы выполняем запрос DELETE к нашему API:
delete(API_URL + '/todos/' + todoId)
Затем мы преобразовываем ответ в null
:
map(response => null)
Нам не нужно трансформировать ответ здесь, и мы могли бы пропустить эту строку. Это просто включено, чтобы дать вам представление о том, как вы можете обработать ответ, если ваш API вернет данные при выполнении запроса DELETE.
Вот полный код нашего ApiService
:
import { Injectable } from '@angular/core'; import { environment } from 'environments/environment'; import { Http, Response } from '@angular/http'; import { Todo } from './todo'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/operator/map'; import 'rxjs/add/operator/catch'; import 'rxjs/add/observable/throw'; const API_URL = environment.apiUrl; @Injectable() export class ApiService { constructor( private http: Http ) { } public getAllTodos(): Observable<Todo[]> { return this.http .get(API_URL + '/todos') .map(response => { const todos = response.json(); return todos.map((todo) => new Todo(todo)); }) .catch(this.handleError); } public createTodo(todo: Todo): Observable<Todo> { return this.http .post(API_URL + '/todos', todo) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); } public getTodoById(todoId: number): Observable<Todo> { return this.http .get(API_URL + '/todos/' + todoId) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); } public updateTodo(todo: Todo): Observable<Todo> { return this.http .put(API_URL + '/todos/' + todo.id, todo) .map(response => { return new Todo(response.json()); }) .catch(this.handleError); } public deleteTodoById(todoId: number): Observable<null> { return this.http .delete(API_URL + '/todos/' + todoId) .map(response => null) .catch(this.handleError); } private handleError (error: Response | any) { console.error('ApiService::handleError', error); return Observable.throw(error); } }
Теперь, когда у нас есть наш ApiService
, мы можем использовать его, чтобы наш TodoDataService
с нашей REST API серверной частью.
Обновление TodoDataService
В настоящее время наш TodoDataService
хранит все данные в памяти:
import {Injectable} from '@angular/core'; import {Todo} from './todo'; @Injectable() export class TodoDataService { // Placeholder for last id so we can simulate // automatic incrementing of ids lastId: number = 0; // Placeholder for todos todos: Todo[] = []; constructor() { } // Simulate POST /todos addTodo(todo: Todo): TodoDataService { if (!todo.id) { todo.id = ++this.lastId; } this.todos.push(todo); return this; } // Simulate DELETE /todos/:id deleteTodoById(id: number): TodoDataService { this.todos = this.todos .filter(todo => todo.id !== id); return this; } // Simulate PUT /todos/:id updateTodoById(id: number, values: Object = {}): Todo { let todo = this.getTodoById(id); if (!todo) { return null; } Object.assign(todo, values); return todo; } // Simulate GET /todos getAllTodos(): Todo[] { return this.todos; } // Simulate GET /todos/:id getTodoById(id: number): Todo { return this.todos .filter(todo => todo.id === id) .pop(); } // Toggle todo complete toggleTodoComplete(todo: Todo) { let updatedTodo = this.updateTodoById(todo.id, { complete: !todo.complete }); return updatedTodo; } }
Чтобы наш TodoDataService
взаимодействовать с нашим REST API, мы должны внедрить наш новый ApiService
:
import { Injectable } from '@angular/core'; import { Todo } from './todo'; import { ApiService } from './api.service'; import { Observable } from 'rxjs/Observable'; @Injectable() export class TodoDataService { constructor( private api: ApiService ) { } }
Мы также обновляем его методы, чтобы делегировать всю работу соответствующим методам в ApiService
:
import { Injectable } from '@angular/core'; import { Todo } from './todo'; import { ApiService } from './api.service'; import { Observable } from 'rxjs/Observable'; @Injectable() export class TodoDataService { constructor( private api: ApiService ) { } // Simulate POST /todos addTodo(todo: Todo): Observable<Todo> { return this.api.createTodo(todo); } // Simulate DELETE /todos/:id deleteTodoById(todoId: number): Observable<Todo> { return this.api.deleteTodoById(todoId); } // Simulate PUT /todos/:id updateTodo(todo: Todo): Observable<Todo> { return this.api.updateTodo(todo); } // Simulate GET /todos getAllTodos(): Observable<Todo[]> { return this.api.getAllTodos(); } // Simulate GET /todos/:id getTodoById(todoId: number): Observable<Todo> { return this.api.getTodoById(todoId); } // Toggle complete toggleTodoComplete(todo: Todo) { todo.complete = !todo.complete; return this.api.updateTodo(todo); } }
Наши новые реализации методов выглядят намного проще, потому что логика данных теперь обрабатывается серверной частью REST API.
Тем не менее, есть важное отличие. Старые методы содержали синхронный код и сразу возвращали значение. Обновленные методы содержат асинхронный код и возвращают Observable.
Это означает, что мы также должны обновить код, который вызывает методы TodoDataService
для правильной обработки Observables.
Обновление AppComponent
В настоящее время AppComponent
ожидает, что TodoDataService
будет напрямую возвращать объекты и массивы JavaScript:
import {Component} from '@angular/core'; import {TodoDataService} from './todo-data.service'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [TodoDataService] }) export class AppComponent { constructor( private todoDataService: TodoDataService ) { } onAddTodo(todo) { this.todoDataService.addTodo(todo); } onToggleTodoComplete(todo) { this.todoDataService.toggleTodoComplete(todo); } onRemoveTodo(todo) { this.todoDataService.deleteTodoById(todo.id); } get todos() { return this.todoDataService.getAllTodos(); } }
Но наши новые методы ApiService
возвращают Observables.
Подобно Обещаниям, Observables по своей природе асинхронны, поэтому мы должны обновить код для соответствующей обработки ответов Observable:
Если мы в настоящее время вызываем метод TodoDataService.getAllTodos()
в get todos()
:
// AppComponent get todos() { return this.todoDataService.getAllTodos(); }
метод TodoDataService.getAllTodos()
вызывает соответствующий ApiService.getAllTodos()
:
// TodoDataService getAllTodos(): Observable<Todo[]> { return this.api.getAllTodos(); }
Это, в свою очередь, дает указание службе Angular HTTP выполнить запрос HTTP GET:
// ApiService public getAllTodos(): Observable<Todo[]> { return this.http .get(API_URL + '/todos') .map(response => { const todos = response.json(); return todos.map((todo) => new Todo(todo)); }) .catch(this.handleError); }
Однако есть одна важная вещь, которую мы должны помнить!
Пока мы не подписываемся на Observable, возвращаемый:
this.todoDataService.getAllTodos()
фактический HTTP-запрос не выполняется.
Чтобы подписаться на Observable, мы можем использовать метод subscribe()
, который принимает три аргумента:
-
onNext
: функция, котораяonNext
, когда Observable испускает новое значение -
onError
: функция, которая вызывается, когда Observable выдает ошибку -
onCompleted
: функция, которая вызывается, когда Observable завершается корректно.
Давайте перепишем наш текущий код:
// AppComponent get todos() { return this.todoDataService.getAllTodos(); }
Это будет загружать AppComponent
асинхронно при инициализации AppComponent
:
import { Component, OnInit } from '@angular/core'; import { TodoDataService } from './todo-data.service'; import { Todo } from './todo'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [TodoDataService] }) export class AppComponent implements OnInit { todos: Todo[] = []; constructor( private todoDataService: TodoDataService ) { } public ngOnInit() { this.todoDataService .getAllTodos() .subscribe( (todos) => { this.todos = todos; } ); } }
Сначала мы определяем публичное свойство todos
и устанавливаем его начальное значение в пустой массив.
Затем мы используем метод ngOnInit()
чтобы подписаться на this.todoDataService.getAllTodos()
, и когда значение приходит, мы присваиваем его this.todos
, перезаписывая его начальное значение пустого массива.
Теперь давайте обновим метод onAddTodo(todo)
чтобы он также обрабатывал наблюдаемый ответ:
// previously: // onAddTodo(todo) { // this.todoDataService.addTodo(todo); // } onAddTodo(todo) { this.todoDataService .addTodo(todo) .subscribe( (newTodo) => { this.todos = this.todos.concat(newTodo); } ); }
Опять же, мы используем метод subscribe()
для подписки на Observable, возвращаемое this.todoDataService.addTodo(todo)
, и когда приходит ответ, мы добавляем вновь созданный todo в текущий список задач.
Мы повторяем то же самое упражнение для других методов, пока наш AppComponent
выглядеть следующим образом:
import { Component, OnInit } from '@angular/core'; import { TodoDataService } from './todo-data.service'; import { Todo } from './todo'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'], providers: [TodoDataService] }) export class AppComponent implements OnInit { todos: Todo[] = []; constructor( private todoDataService: TodoDataService ) { } public ngOnInit() { this.todoDataService .getAllTodos() .subscribe( (todos) => { this.todos = todos; } ); } onAddTodo(todo) { this.todoDataService .addTodo(todo) .subscribe( (newTodo) => { this.todos = this.todos.concat(newTodo); } ); } onToggleTodoComplete(todo) { this.todoDataService .toggleTodoComplete(todo) .subscribe( (updatedTodo) => { todo = updatedTodo; } ); } onRemoveTodo(todo) { this.todoDataService .deleteTodoById(todo.id) .subscribe( (_) => { this.todos = this.todos.filter((t) => t.id !== todo.id); } ); } }
Это оно; все методы теперь способны обрабатывать Observables, возвращаемые методами TodoDataService
.
Обратите внимание, что нет необходимости отписываться вручную, когда вы подписываетесь на Observable, который возвращается службой Angular HTTP. Angular очистит все для вас, чтобы предотвратить утечки памяти.
Посмотрим, все ли работает так, как ожидалось.
Пробовать
Откройте окно терминала.
Из корня каталога нашего приложения запустите серверную часть REST API:
npm run json-server
Откройте второе окно терминала.
Опять же, из корня нашего каталога приложений, обслуживаем приложение Angular:
ng serve
Теперь перейдите в браузере по http://localhost:4200
.
Если все идет хорошо, вы должны увидеть это:
Если вы видите ошибку, вы можете сравнить ваш код с рабочей версией на GitHub .
Потрясающие! Наше приложение теперь взаимодействует с серверной частью REST API!
Совет: если вы хотите запустить npm run json-server
и ng serve
в одном терминале, вы можете одновременно использовать обе команды для одновременного запуска без открытия нескольких окон или вкладок терминала.
Давайте запустим наши модульные тесты, чтобы убедиться, что все работает как положено.
Запуск наших тестов
Откройте третье окно терминала.
Опять же, из корня каталога вашего приложения запустите модульные тесты:
ng test
Кажется, что 11 модульных тестов не проходят:
Давайте посмотрим, почему наши тесты терпят неудачу и как мы можем их исправить.
Исправление наших юнит-тестов
Сначала давайте откроем src/todo-data.service.spec.ts
:
/* tslint:disable:no-unused-variable */ import {TestBed, async, inject} from '@angular/core/testing'; import {Todo} from './todo'; import {TodoDataService} from './todo-data.service'; describe('TodoDataService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [TodoDataService] }); }); it('should ...', inject([TodoDataService], (service: TodoDataService) => { expect(service).toBeTruthy(); })); describe('#getAllTodos()', () => { it('should return an empty array by default', inject([TodoDataService], (service: TodoDataService) => { expect(service.getAllTodos()).toEqual([]); })); it('should return all todos', inject([TodoDataService], (service: TodoDataService) => { let todo1 = new Todo({title: 'Hello 1', complete: false}); let todo2 = new Todo({title: 'Hello 2', complete: true}); service.addTodo(todo1); service.addTodo(todo2); expect(service.getAllTodos()).toEqual([todo1, todo2]); })); }); describe('#save(todo)', () => { it('should automatically assign an incrementing id', inject([TodoDataService], (service: TodoDataService) => { let todo1 = new Todo({title: 'Hello 1', complete: false}); let todo2 = new Todo({title: 'Hello 2', complete: true}); service.addTodo(todo1); service.addTodo(todo2); expect(service.getTodoById(1)).toEqual(todo1); expect(service.getTodoById(2)).toEqual(todo2); })); }); describe('#deleteTodoById(id)', () => { it('should remove todo with the corresponding id', inject([TodoDataService], (service: TodoDataService) => { let todo1 = new Todo({title: 'Hello 1', complete: false}); let todo2 = new Todo({title: 'Hello 2', complete: true}); service.addTodo(todo1); service.addTodo(todo2); expect(service.getAllTodos()).toEqual([todo1, todo2]); service.deleteTodoById(1); expect(service.getAllTodos()).toEqual([todo2]); service.deleteTodoById(2); expect(service.getAllTodos()).toEqual([]); })); it('should not removing anything if todo with corresponding id is not found', inject([TodoDataService], (service: TodoDataService) => { let todo1 = new Todo({title: 'Hello 1', complete: false}); let todo2 = new Todo({title: 'Hello 2', complete: true}); service.addTodo(todo1); service.addTodo(todo2); expect(service.getAllTodos()).toEqual([todo1, todo2]); service.deleteTodoById(3); expect(service.getAllTodos()).toEqual([todo1, todo2]); })); }); describe('#updateTodoById(id, values)', () => { it('should return todo with the corresponding id and updated data', inject([TodoDataService], (service: TodoDataService) => { let todo = new Todo({title: 'Hello 1', complete: false}); service.addTodo(todo); let updatedTodo = service.updateTodoById(1, { title: 'new title' }); expect(updatedTodo.title).toEqual('new title'); })); it('should return null if todo is not found', inject([TodoDataService], (service: TodoDataService) => { let todo = new Todo({title: 'Hello 1', complete: false}); service.addTodo(todo); let updatedTodo = service.updateTodoById(2, { title: 'new title' }); expect(updatedTodo).toEqual(null); })); }); describe('#toggleTodoComplete(todo)', () => { it('should return the updated todo with inverse complete status', inject([TodoDataService], (service: TodoDataService) => { let todo = new Todo({title: 'Hello 1', complete: false}); service.addTodo(todo); let updatedTodo = service.toggleTodoComplete(todo); expect(updatedTodo.complete).toEqual(true); service.toggleTodoComplete(todo); expect(updatedTodo.complete).toEqual(false); })); }); });
Большинство неудачных модульных тестов связаны с проверкой обработки данных. Эти тесты больше не требуются, потому что обработка данных теперь выполняется нашей серверной частью REST API вместо TodoDataService
, поэтому давайте удалим устаревшие тесты:
/* tslint:disable:no-unused-variable */ import {TestBed, inject} from '@angular/core/testing'; import {TodoDataService} from './todo-data.service'; describe('TodoDataService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ TodoDataService, ] }); }); it('should ...', inject([TodoDataService], (service: TodoDataService) => { expect(service).toBeTruthy(); })); });
Если мы теперь запустим модульные тесты, мы получим ошибку:
TodoDataService should ... Error: No provider for ApiService!
Ошибка TestBed.configureTestingModule()
потому что TestBed.configureTestingModule()
создает временный модуль для тестирования, а инжектор временного модуля не знает ни о каком ApiService
.
Чтобы инжектор знал о ApiService
, мы должны зарегистрировать его во временном модуле, указав ApiService
в качестве поставщика в объекте конфигурации, который передается в TestBed.configureTestingModule()
:
/* tslint:disable:no-unused-variable */ import {TestBed, inject} from '@angular/core/testing'; import {TodoDataService} from './todo-data.service'; import { ApiService } from './api.service'; describe('TodoDataService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ TodoDataService, ApiService ] }); }); it('should ...', inject([TodoDataService], (service: TodoDataService) => { expect(service).toBeTruthy(); })); });
Однако, если мы сделаем это, наш модульный тест будет использовать наш реальный ApiService
, который подключается к нашему REST API.
Мы не хотим, чтобы наш тестовый исполнитель подключался к реальному API при выполнении наших модульных тестов, поэтому давайте создадим ApiMockService
для ApiService
реального ApiService
в модульных тестах.
Создание ApiMockService
Давайте используем Angular CLI для генерации нового ApiMockService
:
ng g service ApiMock --spec false
Это показывает следующее:
installing service create src/app/api-mock.service.ts WARNING Service is generated but not provided, it must be provided to be used
Далее мы реализуем те же методы, что и ApiService
, но мы позволяем методам возвращать фиктивные данные вместо выполнения HTTP-запросов:
import { Injectable } from '@angular/core'; import { Todo } from './todo'; import { Observable } from 'rxjs/Observable'; import 'rxjs/add/observable/of'; @Injectable() export class ApiMockService { constructor( ) { } public getAllTodos(): Observable<Todo[]> { return Observable.of([ new Todo({id: 1, title: 'Read article', complete: false}) ]); } public createTodo(todo: Todo): Observable<Todo> { return Observable.of( new Todo({id: 1, title: 'Read article', complete: false}) ); } public getTodoById(todoId: number): Observable<Todo> { return Observable.of( new Todo({id: 1, title: 'Read article', complete: false}) ); } public updateTodo(todo: Todo): Observable<Todo> { return Observable.of( new Todo({id: 1, title: 'Read article', complete: false}) ); } public deleteTodoById(todoId: number): Observable<null> { return null; } }
Обратите внимание, как каждый метод возвращает новые новые данные макета. Это может показаться немного повторяющимся, но это хорошая практика. Если один модульный тест изменит измененные данные, это изменение никогда не повлияет на данные другого модульного теста.
Теперь, когда у нас есть служба ApiMockService
, мы можем заменить ApiService
в наших модульных тестах на ApiMockService
.
Давайте снова откроем src/todo-data.service.spec.ts
.
В массиве providers
мы говорим инжектору предоставлять ApiMockService
всякий раз, когда запрашивается ApiService
:
/* tslint:disable:no-unused-variable */ import {TestBed, inject} from '@angular/core/testing'; import {TodoDataService} from './todo-data.service'; import { ApiService } from './api.service'; import { ApiMockService } from './api-mock.service'; describe('TodoDataService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ TodoDataService, { provide: ApiService, useClass: ApiMockService } ] }); }); it('should ...', inject([TodoDataService], (service: TodoDataService) => { expect(service).toBeTruthy(); })); });
Если мы теперь повторно запустим модульные тесты, ошибка исчезнет. Большой!
У нас еще есть еще два провальных теста:
ApiService should ... Error: No provider for Http! AppComponent should create the app Failed: No provider for ApiService!
Ошибки похожи на ту, которую мы только что исправили.
Чтобы исправить первую ошибку, давайте откроем src/api.service.spec.ts
:
import { TestBed, inject } from '@angular/core/testing'; import { ApiService } from './api.service'; describe('ApiService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ApiService] }); }); it('should ...', inject([ApiService], (service: ApiService) => { expect(service).toBeTruthy(); })); });
Сбой теста с сообщением No provider for Http!
, указывая, что нам нужно добавить провайдера для Http
.
Опять же, мы не хотим, чтобы сервис Http
отправлял реальные HTTP-запросы, поэтому мы создаем экземпляр фиктивного сервиса Http
который использует MockBackend
от MockBackend
:
import { TestBed, inject } from '@angular/core/testing'; import { ApiService } from './api.service'; import { BaseRequestOptions, Http, XHRBackend } from '@angular/http'; import { MockBackend } from '@angular/http/testing'; describe('ApiService', () => { beforeEach(() => { TestBed.configureTestingModule({ providers: [ { provide: Http, useFactory: (backend, options) => { return new Http(backend, options); }, deps: [MockBackend, BaseRequestOptions] }, MockBackend, BaseRequestOptions, ApiService ] }); }); it('should ...', inject([ApiService], (service: ApiService) => { expect(service).toBeTruthy(); })); });
Не беспокойтесь, если настройка тестового модуля выглядит несколько сложнее.
Вы можете узнать больше о настройке модульного теста в официальной документации для тестирования приложений Angular .
Чтобы исправить последнюю ошибку:
AppComponent should create the app Failed: No provider for ApiService!
давайте откроем src/app.component.spec.ts
:
import { TestBed, async } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { AppComponent } from './app.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TodoDataService } from './todo-data.service'; describe('AppComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ FormsModule ], declarations: [ AppComponent ], providers: [ TodoDataService ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); })); it('should create the app', async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); })); });
Затем предоставьте инжектору наш макет ApiService
:
import { TestBed, async } from '@angular/core/testing'; import { FormsModule } from '@angular/forms'; import { AppComponent } from './app.component'; import { NO_ERRORS_SCHEMA } from '@angular/core'; import { TodoDataService } from './todo-data.service'; import { ApiService } from './api.service'; import { ApiMockService } from './api-mock.service'; describe('AppComponent', () => { beforeEach(async(() => { TestBed.configureTestingModule({ imports: [ FormsModule ], declarations: [ AppComponent ], providers: [ TodoDataService, { provide: ApiService, useClass: ApiMockService } ], schemas: [ NO_ERRORS_SCHEMA ] }).compileComponents(); })); it('should create the app', async(() => { const fixture = TestBed.createComponent(AppComponent); const app = fixture.debugElement.componentInstance; expect(app).toBeTruthy(); })); });
Ура! Все наши тесты проходят:
Мы успешно подключили наше приложение Angular к нашему интерфейсу REST API.
Чтобы развернуть наше приложение в производственной среде, теперь мы можем запустить:
ng build --aot --environment prod
Мы также загружаем сгенерированный каталог dist
на наш хостинг-сервер. Насколько это сладко?
Давайте вспомним то, что мы узнали.
Резюме
В первой статье мы узнали, как:
- инициализировать наше приложение Todo с помощью Angular CLI
- создать класс
Todo
для представления отдельных задач - создать сервис
TodoDataService
для создания, обновления и удаления задач - используйте компонент
AppComponent
для отображения пользовательского интерфейса - разверните наше приложение на страницах GitHub.
Во второй статье мы реорганизовали AppComponent
чтобы делегировать большую часть его работы:
-
TodoListComponent
для отображения списка задач -
TodoListItemComponent
для отображения одного todo -
TodoListHeaderComponent
для создания новой задачи -
TodoListFooterComponent
чтобы показать, сколькоTodoListFooterComponent
.
В этой третьей статье мы:
- создал фиктивный бэкэнд API REST
- хранит URL API как переменную среды
- создал
ApiService
для связи с серверной частью REST API - обновил
TodoDataService
для использования новогоApiService
- обновил
AppComponent
для обработки асинхронных вызовов API - создал
ApiMockService
чтобы избежать реальных HTTP-вызовов при запуске модульных тестов.
В процессе мы узнали:
- как использовать переменные среды для хранения настроек приложения
- как использовать Angular HTTP-клиент для выполнения HTTP-запросов
- как обращаться с Observables, которые возвращаются клиентом Angular HTTP
- как имитировать HTTP-вызовы, чтобы избежать реальных HTTP-запросов при запуске модульных тестов
Весь код из этой статьи доступен на GitHub .
В четвертой части мы познакомим AppComponent
с маршрутизатором и рефакторингом, чтобы использовать маршрутизатор для извлечения задач из серверной части.
В пятой части мы внедрим аутентификацию, чтобы предотвратить несанкционированный доступ к нашему приложению.
Эта статья была рецензирована Vildan Softic . Спасибо всем рецензентам SitePoint за то, что сделали контент SitePoint как можно лучше!