Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 3.

Вот наконец и появилось время сесть и написать новую часть из задуманной серии статей. В прошлых частях мы создали некий каркас приложения, в котором связали вместе работу серверной части на ASP.NET MVC и клиентской части на Angular 4. Как и было задумано, мы начали создавать Single Page Application.  И как видно по первым результатам такой тип приложения смотрится намного выгоднее простого стандартного ASP.NET MVC Приложения. А все потому, что навигация по страницам приложения практически мгновенная — представления грузятся один раз и переключение между ними не требует обращений к серверу (представления кешируются, так как являются статическим HTML кодом). На страницу нужно подгружать только данные, которые так же можно закешировать на стороне клиента, если создать достаточно «умное» клиентское приложение.

Теперь посмотрим, что же делать, если вы хорошо знаете ASP.NET MVC, но не очень хорошо разбираетесь в Angular SPA. Возможно сначала покажется что реализовать стандартное приложение на ASP.NET MVC – намного проще и быстрее. Но взглянув реально можно смело говорить – стандартный ASP.NET MVC будет проигрывать в usability клиентскому приложению на Angular. Даже при интенсивном кешировании страниц и частичных представлений сервер должен делать всю работу по рендерингу страниц и передавать намного больше данных по соединению клиенту.  По-моему, преимущество SPA приложения неоспоримо, и усилия на изучения новой архитектуры вашего веб сервиса окупятся многократно.

Однако как программисту на ASP.NET MVC с движением в сторону Angular SPA вам будет казаться, что нужно кодировать приложение дважды – на стороне сервера и на стороне клиента. Делать проверки при вводе данных на клиенте и снова проверять их на сервере, кажется, что нет возможности использовать такие удобные средства разработки как Razor HTML Tag Helpers, создавать модели данных для серверной части и клиентской части и много подобных вещей. Вам будет казаться, что код становится громоздким, плохо управляемым и тяжело понимаемым. Но все это решаемо и можно совместить всю мощь ASP.NET MVC и удобство клиентской части на Angular.

В этой статья попробуем совместить по максимум все главные возможности ASP.NET MVC Core и Angular. Для начала мы создадим простой интерфейс Web API на стороне клиента для предоставления данных, а после этого изменим представление Angular, чтобы обращаться к новому Web API и использовать данные возвращаемыми сервером на стороне клиента. Кроме того, на данном примере посмотрим как Razor HTML Tag Helpers могут помочь в решении проблемы повторяющегося кода и генерации Angular разметки HTML шаблонов.

Добавление простого Web API

Для начала добавим в наш проект простой Web API. Создадим в нашем проекте новую папку, которую назовем Api.  Думаю с этим проблем возникнуть не должно 🙂 Далее добавим в новую папку новый Web API Controler.  Назовем его для простоты SampleDataController. Не будем в нем ничего менять пока. Этот контроллер будет обслуживать простой список строк и использоваться для демонстрации работы сервиса. Ничего сверх необходимого в него добавляться пока не будет. Будем придерживаться принципа простоты KISS.

Создание нового элемента в Visual Studio 2017

Создание нового элемента в Visual Studio 2017


Создание нового класса Web API Controller в Visual Studio 2017

Создание нового класса Web API Controller в Visual Studio 2017


using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc;

// For more information on enabling Web API for empty projects, visit https://go.microsoft.com/fwlink/?LinkID=397860

namespace AngularSpa.Api
{
    [Route("api/[controller]")]
    public class SampleDataController : Controller
    {
        // GET: api/values
        [HttpGet]
        public IEnumerable<string> Get()
        {
            return new string[] { "value1", "value2" };
        }

        // GET api/values/5
        [HttpGet("{id}")]
        public string Get(int id)
        {
            return "value";
        }

        // POST api/values
        [HttpPost]
        public void Post([FromBody]string value)
        {
        }

        // PUT api/values/5
        [HttpPut("{id}")]
        public void Put(int id, [FromBody]string value)
        {
        }

        // DELETE api/values/5
        [HttpDelete("{id}")]
        public void Delete(int id)
        {
        }
    }
}

Следующий шаг – добавление сервиса в приложение Angular. Для этого создадим папку services внутри папки /wwwroot/app. Внутрь папки services добавим новый TypeScript файл с именем SampleData.services.ts. Этот файл будет отвечать а реализацию HTTP сервиса, который будет использоваться Angular для получения данных из только что созданyого Web API контролера. Скопируйте в новый пустой файл следующий код:

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs/Observable';

@Injectable()
export class SampleDataService
{
    private url: string = 'api/';

    constructor(private http: Http) { }

    getSampleData(): Observable<string[]>
    {
        return this.http.get(this.url + 'sampleData')
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    private handleError(error: Response | any)
    {
        let errMsg: string;

        if (error instanceof Response)
        {
            const body = error.json() || '';
            const err  = body.error || JSON.stringify(body);
            errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
        } else
        {
            errMsg = error.message ? error.message : error.toString();
        }

        console.error(errMsg);

        return Observable.throw(errMsg);
    }
}

@Injectable () дает занть Angular, что класс может использоваться с инжектором зависимостей (Dependency Ingector). @Injectable () не является строго необходимым, если класс имеет другие Angular декораторы на нем или не имеет никаких зависимостей.

В конструкторе нашего класса мы задаем переменную типа Http. Эта переменная будет использоваться для выполнения AJAX запроса к нашему Web API сервису.

Далее мы обьявляем функцию getSampleData(). Эта функция возвращает объект Observable, который инкапсулирует в себе массив строк. Все методы класса Http возвращают этот тип данных. Класс Observable не является частью библиотеки Angular, но широко используется в ней. Этот класс предоставляется библиотекой RxJS (Reactive Extensions for JavaScript). Эта библиотека реализует очень полезный паттерн Асинхронный Наблюдатель (Asynchronous Observable).

Внутри функции очень простой код. Мы используем переменную типа Http чтобы сделать HTTP GET запрос к нашему Web API. Ответ, полученный из метода get() передается в оператор map(), который преобразует полученный ответ HTTP запроса в объект – массив строк, используя парсер JSON. В случае ошибки управление передается в метод handleError(), который форматирует ошибку и выводит её в виде строки в консоль.

Теперь мы обновим существующий файл app.module.ts, чтобы предоставить необходимые зависимости, изменив его содержимое так, как показано ниже:

import { NgModule, enableProdMode } from '@angular/core';
import { BrowserModule, Title } from '@angular/platform-browser';
import { routing, routedComponents } from './app.routing';
import { APP_BASE_HREF, Location } from '@angular/common';
import { AppComponent } from './app.component';
import { FormsModule } from '@angular/forms';
import { HttpModule } from '@angular/http';
import { SampleDataService } from './services/SampleData.services';
import './rxjs-operators';

// enableProdMode();

@NgModule({
    imports: [BrowserModule, FormsModule, HttpModule, routing],
    declarations: [AppComponent, routedComponents],
    providers: [SampleDataService, Title, { provide: APP_BASE_HREF, useValue: '/' }],
    bootstrap: [AppComponent]
})

export class AppModule { }

Разберем новые строки в этом файле.

import { HttpModule } from '@angular/http';

Эта строка включает новый HttpModule, который позволит нам использовать объект типа Http для посылки AJAX запросов к нашему Web API контролеру.

import { FormsModule } from '@angular/forms';

Этот модуль нужен для работы с формами в Angular страницах. Он нам понадобится позже.

import { SampleDataService } from './services/SampleData.services';

В этой строке мы подключаем наш новый сервис для работы с Web API.

import './rxjs-operators';

А это импорт операторов  библиотеки Reactive Extensions. Что бы это заработало нам нужно добавить новый TypeScript файл. Просто добавьте новый файл TypeScript, точно так же, как мы это сделали раньше. Задайте ему имя   rxjs-operators.ts. Затем добавте следующий код в этот файл:

 // Observable class extensions
 import 'rxjs/add/observable/of';
 import 'rxjs/add/observable/throw';
 // Observable operators
 import 'rxjs/add/operator/catch';
 import 'rxjs/add/operator/debounceTime';
 import 'rxjs/add/operator/distinctUntilChanged';
 import 'rxjs/add/operator/do';
 import 'rxjs/add/operator/filter';
 import 'rxjs/add/operator/map';
 import 'rxjs/add/operator/switchMap';

Так как библиотека RxJS Очень большая, а загрузка ненужного кода замедляет загрузку нашего приложения, то нам нужно импортировать только нужные нам операторы и расширения.

Теперь обновим уже существующий файл  about.component.ts

import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/SampleData.services';
 
@Component
({
    selector: 'my-about',
    templateUrl: '/partial/aboutComponent'
})
 
export class AboutComponent implements OnInit 
{
    testData: string[] = [];
    errorMessage: string;

    constructor(private sampleDataService: SampleDataService) { }
    
    ngOnInit() 
    {
        this.sampleDataService.getSampleData()
            .subscribe((data: string[]) => this.testData = data,
            error => this.errorMessage = <any>error);
    }
}

Как видите, теперь мы импортируем сюда наш новый SampleData сервис. Он будет отвечать за доставку данных через наш Web API на страницу нашего приложения.  Обьект сервиса создается внутри конструктора компонента. Далее добавлена новая функция: ngOnInit

ngOnInit вызывается сразу после того, как свойства привязки данных директивы были проверены в первый раз и до того, как был проверен какой-либо из его дочерних элементов. Он вызывается только один раз, когда создается директива.  Немного запутанное объяснение, которое сводится к следующему – этот метод вызывается, когда компонент создан и проинициализирован.

Внутри этого метода мы вызываем наш сервис и запрашиваем данные у нашего сервера через Web API. Вызывая метод subscribe объекта Observable мы указываем, что после того как данные будут получены в асинхронном режиме, полученный массив строк нужно присвоить переменной класса this.testData. В случае ошибки проинициализировать объектом ошибки переменную  erroMessage.

Теперь нужно обновить ASP.NET MVC частичное представление (шаблон Angular) AboutComponent.cshtml так, что бы он начал использовать данные, возвращаемые нашим Web API.

@{
    ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>Example of Angular 2 requesting data from ASP.Net Core.</p>

<p>Data:</p>
<table>
    <tr *ngFor="let data of testData">
        <td>{{ data }}</td>
    </tr>
</table>

<div *ngIf="errorMessage != null">
    <p>Error:</p>
    <pre>{{ errorMessage  }}</pre>
</div>

Тут все достаточно просто. Мы изменили шаблон так, что бы показать полученный список строк внутри таблицы используя инструкцию Angular *ngFor. Так же мы сможем показать ошибку, если переменная errorMessage не равна null. Для этого используется инструкция Angular *ngIf. Подробнее о данных инструкциях Angular лучше почитать на сайте angular.io.

Теперь можно перекомпилировать проект и запустить его. И в результате, при переключении на страничку About вы увидите данные, которые возвращаются новым Web API!

Angular отображает данные Web API

Angular отображает данные Web API

Посмотрите на скриншот выше. Я специально выдели запрос, который отправляется страницей к нашему серверу для получения данных.

Конечно, это впечатляет, но все же не так сильно как хотелось бы. Столько непонятного кода, что бы получить список из двух строк. В реальной жизни нам потребуются более сложные типы данных и полный набор операций в стиле CRUD (Create, Read, Update, Delete). Все это поддерживается сервисами данных Angular, и мы будем добавлять их в следующих статьях. Но не все сразу, давайте развивать наш учебный проект и учить новую технологию постепенно.

Поддержка нескольких типов данных

Теперь двинемся дальше и добавим в наш сервис Web API поддержку новых типов данных.  Начнем с добавления новой папки в проект и назовем ее ViewModels.  В этой папке мы будем держать наши классы, которые описывают модели данных для представлений. И первой такой моделью станет класс TestData. Добавьте внутрь папки ViewModels новый C# файл TestData.cs. Затем добавьте внутрь этого класса следующий код:

using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.ViewModels
{
    public class TestData
    {
        public string Username { get; set; }

        [DataType(DataType.Currency)]
        public decimal Currency { get; set; }


        [Required, RegularExpression(@"([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})", ErrorMessage = "Please enter a valid email address.")]
        [EmailAddress]
        [Display(Name = "EmailAddress", ShortName = "Email", Prompt = "Email Address")]
        [DataType(DataType.EmailAddress)]
        public string EmailAddress { get; set; }

        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Name = "Password")]
        public string Password { get; set; }
    }
}


В этом классе мы определим  4 свойства: Username, Currency, EmailAddress и Password. Используя аннотации мы добавим к каждому полю проверки, которые будут проверят валидность данных на стороне сервера.

Далее нам нужно добавить такую же модель данных  в приложение Angular. Для этого создадим папку models внутри папки /wwwroot/app/, а затем добавим внутрь этой папки файл TestData.ts. откроем новый файл и добавим в него описание нового класса, который будет служить моделью данных в приложении Angular:

import { Component } from '@angular/core';

export class TestData 
{
    username        : string;
    currency        : number;
    emailAddress    : string;
    password        : string;
}

Небольшое отступление. Теперь кажется, что мы начинаем копировать код, что нарушает принцип повторяемости кода. И это правда, на данном этапе нам приходится создавать модель данных вручную. Но в следующих статьях я опишу способ, который позволит генерировать модели Angular прямо из C# классов и Web API сервиса.

ОК, продолжим. Теперь нам нужно обновить наш Angular сервис. Мы сделаем так, что он будет работать не с простым массивом строк, а с нашей новой моделью данных. Открываем файл SampleData.service.ts для редактирования и пишем следующий код:

import { Injectable } from '@angular/core';
import { Http, Response } from '@angular/http';
import { Observable } from 'rxjs/Observable';
import { TestData } from '../models/TestData';

@Injectable()
export class SampleDataService
{
    private url: string = 'api/';

    constructor(private http: Http) { }

    getSampleData(): Observable<TestData>
    {
        return this.http.get(this.url + 'sampleData')
            .map((resp: Response) => resp.json())
            .catch(this.handleError);
    }

    private handleError(error: Response | any)
    {
        let errMsg: string;

        if (error instanceof Response)
        {
            const body = error.json() || '';
            const err  = body.error || JSON.stringify(body);
            errMsg = `${error.status} - ${error.statusText || ''} ${err}`;
        } else
        {
            errMsg = error.message ? error.message : error.toString();
        }

        console.error(errMsg);

        return Observable.throw(errMsg);
    }
}

На самом деле мы изменили только 2 строчки. В начале добавили строку импорта нашей новой модели, а затем изменили тип возвращаемого параметра в функции getSampleData().

Теперь нам нужно обновить Angular компонент about.component.ts что бы он смог работать с новым типом данных.

import { Component, OnInit } from '@angular/core';
import { SampleDataService } from './services/SampleData.services';
import { TestData } from './models/TestData';

@Component
({
    selector: 'my-about',
    templateUrl: '/partial/aboutComponent'
})

export class AboutComponent implements OnInit
{
    testData: TestData = null;
    errorMessage: string;

    constructor(private sampleDataService: SampleDataService) { }

    ngOnInit()
    {
        this.sampleDataService.getSampleData()
            .subscribe((data: TestData) => this.testData = data,
            error => this.errorMessage = <any>error);
    }
}

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

Теперь обновим наш Web API что бы работать с новой моделью данных. В начале файла SampleDataController.cs добавим директиву using что бы включить область видимости для нашего класса модели TestData:

using AngularSpa.ViewModels;

Далее изменим код функции Get() как показано ниже:

        // GET: api/values
        [HttpGet]
        public TestData Get()
        {
            TestData testData = new TestData
            {
                Username = "admin",
                EmailAddress = "admin@siv-blog.com",
                Password = "SomePassword",
                Currency = 123.45M
            };

            return testData;
        }

И последний штрих – внесем изменения в HTML код в файле AboutComponent.cshtml который и будет отображать данные из нашей новой модели:


Ну что, теперь можно собрать проект и запустить его на выполнение. После того как запуститься проект переключитесь на страницу About и вы увидите новые данные как на скриншоте ниже!

Angular отображает данные Web API

Angular отображает данные Web API

Представления Angular – ввод и отображение данных

У нас уже есть небольшой прогресс, но пока все еще он похож на недоделанную программу Hello World! То что мы можем показать данные на страничке это хорошо. Но теперь нам нужно научиться вводить данные (принимать ввод данных от пользователя) и сохранять их на сервере. В начале мы сделаем это самым простым способом, а затем немного все улучшим и начнем использовать Tag Helpers, которые изменят ваше представление о написании ASP.NET MVC Core + Angular 4 приложений!

Начнем мы с изменения представления наших данных. Сейчас мы просто показываем 4 поля с данными. Теперь у нас будет 2 панели. На первой панели мы будем показывать форму ввода, которая будет принимать значения от пользователя. А на второй панели мы будем показывать ту же самую модель в виде неизменяемых полей. Используя магию Angular и двунаправленного связывания мы будем показывать на правой панели данные, которые пользователь вводит на левой панели.

Для начала создадим наше представление ручками, кодирую в простом HTML. Открываем файл AboutComponent.cshtml и копируем туда следующий код:

@{
    ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>Examples of Angular 4 data served by ASP.Net Core Web API:</p>

<form>
    <div *ngIf="testData != null">
        <div class="row">
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Entry</div>
                    <div class="panel-body">
                        <div class="form-group">
                            <label for="testDataUsername">Username</label>
                            <input type="text" id="testDataUsername" name="testDataUsername"
                                   class="form-control" placeholder="Username"
                                   [(ngModel)]="testData.username">
                        </div>
                        <div class="form-group">
                            <label for="testDataCurrency">Amount (in dollars)</label>
                            <div class="input-group">
                                <div class="input-group-addon">$</div>
                                <input type="number" id="testDataCurrency" name="testDataCurrency"
                                       class="form-control" placeholder="Amount"
                                       [(ngModel)]="testData.currency">
                            </div>
                        </div>
                        <div class="form-group">
                            <label for="testDataemailaddress">Email address</label>
                            <input type="email" id="testDataemailaddress" name="testDataemailaddress"
                                   class="form-control" placeholder="Email Address"
                                   [(ngModel)]="testData.emailAddress">
                        </div>
                        <div class="form-group">
                            <label for="testDatapassword">Password</label>
                            <input type="password" id="testDatapassword" name="testDatapassword"
                                   class="form-control" placeholder="Password"
                                   [(ngModel)]="testData.password">
                        </div>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">
                        <div class="form-group">
                            <label class="control-label">Username</label>
                            <p class="form-control-static">{{ testData.username }}</p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Amount (in dollars)</label>
                            <p class="form-control-static">
                                {{ testData.currency | currency:'USD':true:'1.2-2' }}
                            </p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Email address</label>
                            <p class="form-control-static">{{ testData.emailAddress }}</p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Password</label>
                            <p class="form-control-static">{{ testData.password }}</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>

Что бы поддерживать кастомные стили валидации данных, нужно отредактировать файл стилей /wwwroot/app/styles.css и добавит в конец файла следующие CSS стили:

/* validation */
.ng-valid[required], .ng-valid.required {
    border-left: 5px solid #42A948; /* green */
}

.ng-invalid:not(form) {
    border-left: 5px solid #a94442; /* red */
}

Теперь можно собрать проект и запустить его в браузере. Результат будет как на скриншоте  ниже:

Angular отображает данные Web API

Angular отображает данные Web API

Правда, это выглядит намного лучше! И если вы будете вводить данные, то заметите, что информация на правой панели синхронно меняется. Эта магия называется двунаправленное связывание данных.

Теперь добавим немного проверки веденных данных, и сделаем это пока вручную, подредактировав все тот же файл AboutComponent.cshtml

@{
    ViewData["Title"] = "About";
}
<h2>@ViewData["Title"].</h2>
<h3>@ViewData["Message"]</h3>

<p>Examples of Angular 2 data served by ASP.Net Core Web API:</p>
<form #testForm="ngForm">
    <div *ngIf="testData != null">
        <div class="row">
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Entry</div>
                    <div class="panel-body">
                        <div class="form-group">
                            <label for="username">Username</label>
                            <input type="text" id="username" name="username"
                                   required minlength="4" maxlength="24"
                                   class="form-control" placeholder="Username"
                                   [(ngModel)]="testData.username" #name="ngModel">
                            <div *ngIf="name.errors && (name.dirty || name.touched)"
                                 class="alert alert-danger">
                                <div [hidden]="!name.errors.required">
                                    Name is required
                                </div>
                                <div [hidden]="!name.errors.minlength">
                                    Name must be at least 4 characters long.
                                </div>
                                <div [hidden]="!name.errors.maxlength">
                                    Name cannot be more than 24 characters long.
                                </div>
                            </div>
                        </div>

                        <div class="form-group">
                            <label for="currency">Payment Amount (in dollars)</label>
                            <div class="input-group">
                                <div class="input-group-addon">$</div>
                                <input type="number" id="currency" name="currency"
                                       required
                                       class="form-control" placeholder="Amount"
                                       [(ngModel)]="testData.currency" #currency="ngModel">
                            </div>
                            <div *ngIf="currency.errors"
                                 class="alert alert-danger">
                                <div [hidden]="!currency.errors.required">
                                    Payment Amount is required
                                </div>
                            </div>
                        </div>
                        <div class="form-group">
                            <label for="emailAddress">Email address</label>
                            <input type="email" id="emailAddress" name="emailAddress"
                                   required minlength="6" maxlength="80"
                                   pattern="([a-zA-Z0-9_\-\.]+)@@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})"
                                   class="form-control" placeholder="Email Address"
                                   [(ngModel)]="testData.emailAddress" #email="ngModel">
                            <div *ngIf="email.errors && (email.dirty || email.touched)"
                                 class="alert alert-danger">
                                <div [hidden]="!email.errors.required">
                                    Email Address is required
                                </div>
                                <div [hidden]="!email.errors.pattern">
                                    Email Address is invalid
                                </div>
                                <div [hidden]="!email.errors.minlength">
                                    Email Address must be at least 6 characters long.
                                </div>
                                <div [hidden]="!email.errors.maxlength">
                                    Email Address cannot be more than 80 characters long.
                                </div>
                            </div>
                        </div>
                        <div class="form-group">
                            <label for="password">Password</label>
                            <input type="password" id="password" name="password"
                                   required minlength="8" maxlength="16"
                                   class="form-control" placeholder="Password"
                                   [(ngModel)]="testData.password">
                        </div>
                    </div>
                </div>
            </div>
            <div class="col-md-6">
                <div class="panel panel-primary">
                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">
                        <div class="form-group">
                            <label class="control-label">Username</label>
                            <p class="form-control-static">{{ testData.username }}</p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Payment Amount (in dollars)</label>
                            <p class="form-control-static">
                                {{ testData.currency | currency:'USD':true:'1.2-2' }}
                            </p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Email address</label>
                            <p class="form-control-static">{{ testData.emailAddress }}</p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Password</label>
                            <p class="form-control-static">{{ testData.password }}</p>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</form>

<div *ngIf="errorMessage != null">
    <p>Error:</p>
    <pre>{{ errorMessage  }}</pre>
</div>

Код можно скопировать и вставить в файл даже не останавливая запущенный проект. После вставки сохраните изменения и обновите страницу в браузере. Теперь для эксперимента удалите значения из полей и вы увидите ошибки валидации данных как на скриншоте ниже.

Angular отображает данные Web API

Angular отображает данные Web API

Представления Angular с использование ASP.NET Core Tag Helpers

Теперь перейдем к более интересным вещам – создание представления Angular с использование ASP.NET Core Tag Helpers. Для начала небольшое объяснение что такое ASP.NET Core Tag Helpers. Это новая возможность генерировать HTML код в файлах представлений ASP.NET Core Razer. Они выглядят как обычные пользовательские HTML теги (или навернео правильнее назвать их HTML аттрибуты), но они обрабатываются движком Razer на стороне сервера и преобразуются в HTML код.  Проще всего понять как они работают на конкретном примере.  Ниже пример из официальной документации.

Итак, в своем представлении вы пишете такой код:

<form asp-controller="Demo" asp-action="Register" method="post">
    <!-- Input and Submit elements -->
</form>


После обработки этого кода движком Razer на сервере получается следующий HTML код, который будет отдан клиенту:

<form method="post" action="/Demo/Register">
     <!-- Input and Submit elements -->
</form> 

Более сложный пример, который показывает больше возможностей Tag Helpers:

@model RegisterViewModel

<form asp-controller="Demo" asp-action="RegisterInput" method="post">
    Email:  <input asp-for="Email" /> <br />
    Password: <input asp-for="Password" /><br />
    <button type="submit">Register</button>
</form>
Вот этот код сверху будет преобразован в такой HTML:
<form method="post" action="/Demo/RegisterInput">
       Email:
       <input type="email" data-val="true"
              data-val-email="The Email Address field is not a valid e-mail address."
              data-val-required="The Email Address field is required."
              id="Email" name="Email" value="" /> <br>
       Password:
       <input type="password" data-val="true"
              data-val-required="The Password field is required."
              id="Password" name="Password" /><br>
       <button type="submit">Register</button>
     <input name="__RequestVerificationToken" type="hidden" value="<removed for brevity>" />
   </form>

Как видите, использование стандартных (встроенных) Tag Helpers существенно упрощает жизнь программиста. Теперь нужно писать меньше кода, а встроенная поддержка в Visual Studio вместе с IntelliSense делает их использование понятным и простым не только для программистов, но и для людей которые могут работать с макетами страниц, например дизайнеры UI. Кроме того, Tag Helpers поддерживаю классы стилей CSS и помогают избежать дублирование кода, что наверное является важнейшим достоинством этого нововведения.

И теперь самое интересное. Мы можем создавать свои собственные Tag Helpers (custom) и мы будем это делать, что бы с их помощью генерировать как можно больше кода для наших Angular представлений.

Начнем создавать наши Tag Helpers с двух экземпляров – один для отображения наших данных, а второй для формы редактирования данных. Эти Custom tag Helpers будут генерировать для нас все метки (labels), стили и элементы форм для данных полей. Попробуем выжать с них по максимуму.

Отображение данных с использование Custom Tag Helper

Начнем с более простого – tag helper для отображения данных. Лучше всего начинать писать tag helper имея уже готовую HTML разметку. Тогда вам придется меньше исправлять его на более поздних этапах. Всегда лучше иметь готовый дизайн HTML разметки на более ранних этапах.

Для нашего tag helper мы возьмем уже существующую разметку:

                        <div class="form-group">
                            <label class="control-label">Username</label>
                            <p class="form-control-static">{{ testData.username }}</p>
                        </div>
                        <div class="form-group">
                            <label class="control-label">Payment Amount (in dollars)</label>
                            <p class="form-control-static">
                                {{ testData.currency | currency:'USD':true:'1.2-2' }}
                            </p>
                        </div>

Следующий этап – выбрать подходящие имя для нового тега. Для нашего примера возьмем <tag-dd>, где dd означает display data. Но это имя может быть любым, главное не брать зарезервированные имена стандартных тегов и директив Angular.

Далее создадим новую папку в проекте, где будем создавать наши Tag Helpers. Назовем ее TagHelpers и добавим в нее новый C# файл с именем TagDDTagHelper.cs

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;


namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-dd")]
    public class TagDDTagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set;  }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(For.Metadata.Description);
            labelTag.AddCssClass("control-label");

            string propertyName = char.ToLower(For.Name[0]).ToString() + For.Name.Substring(1);

            var pTag = new TagBuilder("p");
            pTag.AddCssClass("form-control-static");
            pTag.InnerHtml.Append("{{ testData." + propertyName + "}}");
            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);
            output.Content.AppendHtml(pTag);
        }
    }
}

Теперь перейдем в файл AboutComponent.cshtml и добавим в самом верху строчки, Которые позволят нам использовать наш новый Tag Helper в нашем представлении:

@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@addTagHelper "*,AngularSpa"
@model AngularSpa.ViewModels.TestData

Теперь прокрутим файл немного ниже, к секции HTML которая отображает данные нашей модели. Давайте попробуем заменить существующий HTML код для отображения имени пользователя на наш новый Tag Helper.

Для этого добавим между Username и Payment Amount наш новый тег:

<tag-dd for="Username"></tag-dd>

Тут для аттрибута For мы пишем имя свойства наше модели, поэтому Username должен быть написан так же как и соответствующее свойство в нашей модели. Эта запись чувствительна к регистру. После редактирования файл будет выглядеть так:

                        <div class="form-group">
                            <label class="control-label">Username</label>
                            <p class="form-control-static">{{ testData.username }}</p>
                        </div>

                        <tag-dd for="Username"></tag-dd> 

                        <div class="form-group">
                            <label class="control-label">Payment Amount (in dollars)</label>
                            <p class="form-control-static">
                                {{ testData.currency | currency:'USD':true:'1.2-2' }}
                            </p>
                        </div>

Запускаем проект и видим, что имя пользователя у нас появилось 2 раза.

Angular отображает данные Web API

Angular отображает данные Web API

Правда, пока показано только имя (значение) но не показана метка (Label). Это потому что сейчас текст метки берется из метаданных модели, а не как раньше.  Что бы добавить метку на нашу страницу, нужно добавить атрибут Display с параметром Description. Для этого открываем файл модели /ViewModels/TestData.cs и добавляем аттрибут к полю Username как в коде ниже:

        [Display(Description = "Username")]
        public string Username { get; set; }

Теперь если запустить проект, вы увидите, что у нас появилась метка для нашего поля.

Улучшаем код Html tag Helper

Код нашего Html Tag Helper не очень большой, но не идеальный. Во-первых, у нас есть фиксированная строка, которая задает имя Angular объекта testData. А это не очень хорошо. Лучше если все будет оптимизировано и следовать неким соглашениям. Мы можем легко сказать, что имя объекта модели Angular совпадает с именем объекта модели C#. Единственное отличие – в C# мы используем нотацию Pascal для имен переменных (слова начинаются с заглавных букв) а в Angular у нас верблюжья нотация – первое слово с маленькой, остальные с большой.  Для преобразования Pascal нотации в Camel нотацию мы можем использовать отдельную функцию.

Если посмотреть, то информацию о поле класса модели мы берем из специального атрибута for:

<tag-dd for="Username"></tag-dd>

HTML Tag Helper класс определяет этот атрибут как свойство

        [HtmlAttributeName("for")]
        public ModelExpression For { get; set;  }

Т.е. значение свойства For заполняется метаинформацией о поле модели, которое вы указали в HTML разметке. В этот объект включены значения всех атрибутов, которые вы применили к полю модели, а так же информация о типе и имени поля. Очень крутая штука, не так ли? Поэтому используя значения из этого поля можно получить все что вам нужно знать о поле модели.

Так как нам нужно будет получать имя объекта модели и его поля для привязки к Angular очень часто, то лучше положить специальный метод расширения в объект ModelExpression. Это поможет получать нужное имя одним вызовов.

Иатк, создадим специальный файл,в котором будут методы расширения для класса ModelExpression. Создадим файл ModelExpressionExtention.cs в папке Helpers. Добавим в него код:

using Microsoft.AspNetCore.Mvc.ViewFeatures;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;

namespace AngularSpa.TagHelpers
{
    public static class ModelExpressionExtention
    {
        public static string AngularName(this ModelExpression modelExpression)
        {
            string className = modelExpression.Metadata.ContainerType.Name;
            className = char.ToLower(className[0]).ToString() + className.Substring(1);

            string propertyName = modelExpression.Name;
            propertyName = char.ToLower(propertyName[0]).ToString() + propertyName.Substring(1);

            return className + "." + propertyName;
        }
    }
}

Теперь исправим код в нашем HTML Tag Helper классе:

using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Rendering;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.AspNetCore.Razor.TagHelpers;


namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-dd")]
    public class TagDDTagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set;  }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(For.Metadata.Description);
            labelTag.AddCssClass("control-label");

            var pTag = new TagBuilder("p");
            pTag.AddCssClass("form-control-static");
            pTag.InnerHtml.Append("{{" + For.AngularName() + "}}");

            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);
            output.Content.AppendHtml(pTag);
        }
    }
}

Можете запустить проект и проверить что все работает.

Завершающие штрихи для HTML Tag Helper Отображающим данные

Теперь мы можем заменить в HTML коде все поля отображающие данные Angular модели на код нашего HTML Tag Helper. Открываем файл AboutComponent.cshtml и редактируем его:


                    <div class="panel-heading">Data Display</div>
                    <div class="panel-body">

                        <tag-dd for="Username"></tag-dd> 

                        <tag-dd for="Currency" pipe="currency:'USD':true:'1.2-2'"></tag-dd>

                        <tag-dd for="EmailAddress"></tag-dd> 

                        <tag-dd for="Password"></tag-dd> 

                    </div>

Тут все понятно, кроме нового атрибута в теге для Currency. Мы добавили новый трибут что бы можно было передавать Angular pipe выражение внутрь Tag Helper и использовать его для форматирования данных.

Что бы новый атрибут заработал, нам нужно добавить его поддержку в Tag Helper класс. Сделать это очень просто. Новый код класса будет выглядеть следующим образом:

namespace AngularSpa.TagHelpers
{
    [HtmlTargetElement("tag-dd")]
    public class TagDDTagHelper : TagHelper
    {
        [HtmlAttributeName("for")]
        public ModelExpression For { get; set;  }

        [HtmlAttributeName("pipe")]
        public string Pipe { get; set; }

        public override void Process(TagHelperContext context, TagHelperOutput output)
        {
            var labelTag = new TagBuilder("label");
            labelTag.InnerHtml.Append(For.Metadata.Description);
            labelTag.AddCssClass("control-label");

            var pTag = new TagBuilder("p");
            pTag.AddCssClass("form-control-static");

            if(Pipe == null || Pipe.Length <= 0)
                pTag.InnerHtml.Append("{{" + For.AngularName() + "}}");
            else
                pTag.InnerHtml.Append("{{" + For.AngularName() + "|" + Pipe + "}}");

            output.TagName = "div";
            output.Attributes.Add("class", "form-group");

            output.Content.AppendHtml(labelTag);
            output.Content.AppendHtml(pTag);
        }
    }
}


Теперь нужно добавить атрибуты с именем к каждому полю в объекте модели, и наш пример будет готов:

namespace AngularSpa.ViewModels
{
    public class TestData
    {
        [Display(Description = "Username")]
        public string Username { get; set; }

        [Display(Description = "Currency")]
        [DataType(DataType.Currency)]
        public decimal Currency { get; set; }


        [Required, RegularExpression(@"([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})", ErrorMessage = "Please enter a valid email address.")]
        [EmailAddress]
        [Display(Description = "Email Address", Name = "EmailAddress", ShortName = "Email", Prompt = "Email Address")]
        [DataType(DataType.EmailAddress)]
        public string EmailAddress { get; set; }

        [Required]
        [StringLength(100, ErrorMessage = "The {0} must be at least {2} characters long.", MinimumLength = 6)]
        [DataType(DataType.Password)]
        [Display(Description = "Password", Name = "Password")]
        public string Password { get; set; }
    }
}

Запускаем наш проект и проверяем, что все работает  так как и задумывалось. Обратите внимание, насколько меньше HTML кода стало в HTML шаблоне страницы. HTML Tag Helper хорошо помог нам в повторном использовании кода — он сделал код проекта намного проще и лучше читаемым.

Angular отображает данные Web API

Angular отображает данные Web API

И небольшой анонс. В следующей части мы будем работать над HTML Tag Helper для полей ввода. Это намного более объемная работа чем мы проделали в этой статье. Кроме того, я собираюсь добавить поддержку базы данных что бы хранить данные в ней и извлекать их оттуда. В качестве базы данных буду использовать PostgreSQL. Так что следите за блогом, должно быть интересно.

Исходный код

Полностью готовый Visual Studio 2017 проект для второй части можно загрузить тут: Visual Studio 2017 ASP.NET MVC Core + Angular SPA Project.

Все части серии

Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 1.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 2.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 3 [читаете сейчас].
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 4.
Single Page Application : ASP.NET MVC .NET Core + Angular 4. Часть 5.
Single Page Application : ASP.NET MVC .NET Core 2 + Angular 5. Часть 6.

4 комментария

    1. SDK и VS обновились а проект старый. Уже не работает как надо.
      В следующих статьях я описываю как обновитьв се до последнйе версии. Проект из 6 статьи работает.

Добавить комментарий

Ваш e-mail не будет опубликован.