65.9K
CodeProject 正在变化。 阅读更多。
Home

ASP.NET Web API、Angular2、TypeScript 和 WebApiClientGen

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.98/5 (20投票s)

2017年1月15日

CPOL

8分钟阅读

viewsIcon

72856

使用 ASP.NET Web API 和 Web API 客户端生成器,高效开发 Angular 2+ 应用程序

引言

本文扩展了“为 ASP.NET Web API 生成 TypeScript 客户端 API”,重点关注 Angular 2+ 的代码示例和相应的 SDLC。如果您正在开发 .NET Core Web API 后端,您可能需要阅读为 ASP.NET Core Web API 生成 C# 客户端 API

背景

自 2016 年 6 月 WebApiClientGen v1.9.0-beta 版本起,Angular2 的支持就已可用,当时 Angular 2 仍处于 RC2 版本。自 WebApiClientGen v2.0 起,已支持 Angular 2 生产版本。

2016 年 9 月底 Angular 2 第一个生产版本发布后几周,我正好开始了一个利用 Angular2 的大型 Web 应用程序项目,因此我一直在为 NG2 应用程序开发使用 WebApiClientGen

假设

  1. 您正在开发 ASP.NET Web API 2.x 应用程序或 ASP.NET Core 应用程序,并且将为基于 Angular 2+ 的 SPA 开发 TypeScript 库。
  2. 您和您的同事高度偏好在服务器端和客户端通过强类型数据和函数进行抽象。
  3. POCO 类由 Web API 数据序列化和 Entity Framework Code First 使用,您可能不希望将所有数据类和成员发布到客户端程序。

另外,如果您的团队推崇 Trunk-Based Development,那会更好。因为 WebApiClientGen 的设计和使用流程都假定 Trunk-Based Development,对于熟练掌握 TDD 的团队来说,它比 Feature Branching 和 GitFlow 等分支策略在持续集成方面更有效。

要跟进这种新的客户端程序开发方式,最好有一个 ASP.NET Web API 项目。您可以使用一个现有项目,或者创建一个演示项目。

Using the Code

本文重点介绍 Angular 2+ 的代码示例。假定您有一个 ASP.NET Web API 项目和一个 Angular2 项目,它们作为兄弟项目位于同一个 VS 解决方案中。如果它们分离得很远,您应该不难编写脚本来使开发步骤无缝衔接。

我推测您已经阅读了“为 ASP.NET Web API 生成 TypeScript 客户端 API”。为 jQuery 生成客户端 API 的步骤与为 Angular 2 生成的步骤几乎相同。演示的 TypeScript 代码基于TUTORIAL: TOUR OF HEROES,许多人就是通过它学习 Angular2 的。因此,您将能够看到 WebApiClientGen 如何融入并改进典型的 Angular2 应用开发周期。

以下是 Web API 代码

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web.Http;
using System.Runtime.Serialization;
using System.Collections.Concurrent;

namespace DemoWebApi.Controllers
{
    [RoutePrefix("api/Heroes")]
    public class HeroesController : ApiController
    {
        public Hero[] Get()
        {
            return HeroesData.Instance.Dic.Values.ToArray();
        }

        public Hero Get(long id)
        {
            Hero r;
            HeroesData.Instance.Dic.TryGetValue(id, out r);
            return r;
        }

        public void Delete(long id)
        {
            Hero r;
            HeroesData.Instance.Dic.TryRemove(id, out r);
        }

        public Hero Post(string name)
        {
            var max = HeroesData.Instance.Dic.Keys.Max();
            var hero = new Hero { Id = max + 1, Name = name };
            HeroesData.Instance.Dic.TryAdd(max + 1, hero);
            return hero;
        }

        public Hero Put(Hero hero)
        {
            HeroesData.Instance.Dic[hero.Id] = hero;
            return hero;
        }

        [HttpGet]
        public Hero[] Search(string name)
        {
            return HeroesData.Instance.Dic.Values.Where(d => d.Name.Contains(name)).ToArray();
        }          
    }

    [DataContract(Namespace = DemoWebApi.DemoData.Constants.DataNamespace)]
    public class Hero
    {
        [DataMember]
        public long Id { get; set; }

        [DataMember]
        public string Name { get; set; }
    }

    public sealed class HeroesData
    {
        private static readonly Lazy<HeroesData> lazy =
            new Lazy<HeroesData>(() => new HeroesData());

        public static HeroesData Instance { get { return lazy.Value; } }

        private HeroesData()
        {
            Dic = new ConcurrentDictionary<long, Hero>(new KeyValuePair<long, Hero>[] {
                new KeyValuePair<long, Hero>(11, new Hero {Id=11, Name="Mr. Nice" }),
                new KeyValuePair<long, Hero>(12, new Hero {Id=12, Name="Narco" }),
                new KeyValuePair<long, Hero>(13, new Hero {Id=13, Name="Bombasto" }),
                new KeyValuePair<long, Hero>(14, new Hero {Id=14, Name="Celeritas" }),
                new KeyValuePair<long, Hero>(15, new Hero {Id=15, Name="Magneta" }),
                new KeyValuePair<long, Hero>(16, new Hero {Id=16, Name="RubberMan" }),
                new KeyValuePair<long, Hero>(17, new Hero {Id=17, Name="Dynama" }),
                new KeyValuePair<long, Hero>(18, new Hero {Id=18, Name="Dr IQ" }),
                new KeyValuePair<long, Hero>(19, new Hero {Id=19, Name="Magma" }),
                new KeyValuePair<long, Hero>(20, new Hero {Id=29, Name="Tornado" }),
                });
        }

        public ConcurrentDictionary<long, Hero> Dic { get; private set; }
    }
}

步骤 0:在 Web API 项目中安装 NuGet 包 WebApiClientGen 和插件 WebApiClientGen.NG2

安装过程还将把依赖的 NuGet 包 Fonlow.TypeScriptCodeDOMFonlow.Poco2Ts 添加到项目引用中。

此外,为触发 CodeGen 的 CodeGenController.cs 已添加到 Web API 项目的 Controllers 文件夹中。

CodeGenController 应仅在调试版本开发期间可用,因为客户端 API 应为每个 Web API 版本生成一次。

提示

  1. 如果您使用的是 @angular/http 中定义的 Angular2 的 Http 服务,则应使用 WebApiClientGen v2.2.5。如果您使用的是 Angular 4.3 中定义的、在 Angular 5 中已弃用的 HttpClient 服务(位于 @angular/common/http),则应使用 WebApiClientGen v2.3.0。
  2. WebApiClientGen 3+ 及其 WebApiClientGen.NG2 仅支持 Angular 6+。

步骤 1:准备 JSON 配置数据

下面的 JSON 配置数据将 `POST` 到 CodeGen Web API

{
	"ApiSelections": {
		"ExcludedControllerNames": [
			"DemoWebApi.Controllers.Account",
			"DemoWebApi.Controllers.FileUpload"
		],

		"DataModelAssemblyNames": [
			"DemoWebApi.DemoData",
			"DemoWebApi"
		],

		"CherryPickingMethods": 3
	},

	"ClientApiOutputs": {
		"CamelCase": true,

		"Plugins": [
			{
				"AssemblyName": "Fonlow.WebApiClientGen.NG2",
				"TargetDir": "..\\DemoNGCli\\NGSource\\src\\ClientApi",
				"TSFile": "WebApiNG2ClientAuto.ts",
				"AsModule": true,
				"ContentType": "application/json;charset=UTF-8"
			}
		]
	}
}

备注

您应该确保“TypeScriptNG2Folder”定义的文件夹存在,因为 WebApiClientGen 不会为您创建此文件夹,这是设计如此。

建议将 JSON payload 数据保存到一个像此文件一样的文件中,该文件位于 Web API 项目文件夹中。

如果您所有的 POCO 类都定义在 Web API 项目中,您应该将 Web API 项目的程序集名称添加到“DataModelAssemblyNames”数组中。如果您为了更好地分离关注点而拥有一些专门的数据模型程序集,您应该将相应的程序集名称添加到该数组中。

"TypeScriptNG2Folder" 是一个绝对路径或相对于 Angular2 项目的路径。例如,“..\\DemoAngular2\\ClientApi”表示一个名为“DemoAngular2”的 Angular 2 项目,该项目是 Web API 项目的兄弟项目。

CodeGen 根据“CherryPickingMethods”(在下面的文档注释中有所描述)从 POCO 类生成强类型的 TypeScript 接口。

/// <summary>
/// Flagged options for cherry picking in various development processes.
/// </summary>
[Flags]
public enum CherryPickingMethods
{
    /// <summary>
    /// Include all public classes, properties and properties.
    /// </summary>
    All = 0,

    /// <summary>
    /// Include all public classes decorated by DataContractAttribute,
    /// and public properties or fields decorated by DataMemberAttribute.
    /// And use DataMemberAttribute.IsRequired
    /// </summary>
    DataContract =1,

    /// <summary>
    /// Include all public classes decorated by JsonObjectAttribute,
    /// and public properties or fields decorated by JsonPropertyAttribute.
    /// And use JsonPropertyAttribute.Required
    /// </summary>
    NewtonsoftJson = 2,

    /// <summary>
    /// Include all public classes decorated by SerializableAttribute,
    /// and all public properties or fields
    /// but excluding those decorated by NonSerializedAttribute.
    /// And use System.ComponentModel.DataAnnotations.RequiredAttribute.
    /// </summary>
    Serializable = 4,

    /// <summary>
    /// Include all public classes, properties and properties.
    /// And use System.ComponentModel.DataAnnotations.RequiredAttribute.
    /// </summary>
    AspNet = 8,
}

默认值为 DataContract,表示选择加入。您可以使用任何一种或组合方法。

步骤 2:运行 Web API 项目的 DEBUG 生成

步骤 3:POST JSON 配置数据以触发客户端 API 代码的生成

在 IDE 中使用 IIS Express 运行 Web 项目。

然后,您可以使用 CurlPoster 或任何您喜欢的客户端工具 **POST** 请求到 https://:10965/api/CodeGen,并设置 content-type=application/json

编写一些批处理脚本来启动 Web API 和 POST JSON 配置数据应该不难。事实上,我已为您草拟了一个:一个PowerShell 脚本文件 CreateClientApi.ps1,它可以在 IIS Express 上启动 Web (API) 项目,然后 POST JSON 配置文件以触发代码生成

sequence diagram

所以基本上,您创建 Web API 代码,包括 API 控制器和数据模型,然后执行 CreateClientApi.ps1。就这么简单! WebApiClientGenCreateClientApi.ps1 将为您完成其余工作。

发布客户端 API 库

现在您已生成了 TypeScript 客户端 API,类似于此示例

import { Injectable, Inject } from '@angular/core';
import { HttpClient, HttpHeaders, HttpResponse } from '@angular/common/http';
import { Observable } from 'rxjs';
export namespace DemoWebApi_DemoData_Client {
    export enum AddressType {Postal, Residential}

    export enum Days {Sat=1, Sun=2, Mon=3, Tue=4, Wed=5, Thu=6, Fri=7}

    export interface PhoneNumber {
        fullNumber?: string;
        phoneType?: DemoWebApi_DemoData_Client.PhoneType;
    }

    export enum PhoneType {Tel, Mobile, Skype, Fax}

    export interface Address {
        id?: string;
        street1?: string;
        street2?: string;
        city?: string;
        state?: string;
        postalCode?: string;
        country?: string;
        type?: DemoWebApi_DemoData_Client.AddressType;
        location?: DemoWebApi_DemoData_Another_Client.MyPoint;
    }

    export interface Entity {
        id?: string;
        name: string;
        addresses?: Array<DemoWebApi_DemoData_Client.Address>;
        phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
    }

    export interface Person extends DemoWebApi_DemoData_Client.Entity {
        surname?: string;
        givenName?: string;
        dob?: Date;
    }

    export interface Company extends DemoWebApi_DemoData_Client.Entity {
        businessNumber?: string;
        businessNumberType?: string;
        textMatrix?: Array<Array<string>>;
        int2DJagged?: Array<Array<number>>;
        int2D?: number[][];
        lines?: Array<string>;
    }

    export interface MyPeopleDic {
        dic?: {[id: string]: DemoWebApi_DemoData_Client.Person };
        anotherDic?: {[id: string]: string };
        intDic?: {[id: number]: string };
    }
}

export namespace DemoWebApi_DemoData_Another_Client {
    export interface MyPoint {
        x: number;
        y: number;
    }
}

export namespace DemoWebApi_Controllers_Client {
    export interface FileResult {
        fileNames?: Array<string>;
        submitter?: string;
    }

    export interface Hero {
        id?: number;
        name?: string;
    }
}

   @Injectable()
    export class Heroes {
        constructor(@Inject('baseUri') private baseUri: string = location.protocol + '//' + 
        location.hostname + (location.port ? ':' + location.port : '') + 
                             '/', private http: Http){
        }

        /**
         * Get all heroes.
         * GET api/Heroes
         * @return {Array<DemoWebApi_Controllers_Client.Hero>}
         */
        get(): Observable<Array<DemoWebApi_Controllers_Client.Hero>>{
            return this.http.get(this.baseUri + 'api/Heroes').map(response=> response.json());
        }

        /**
         * Get a hero.
         * GET api/Heroes/{id}
         * @param {number} id
         * @return {DemoWebApi_Controllers_Client.Hero}
         */
        getById(id: number): Observable<DemoWebApi_Controllers_Client.Hero>{
            return this.http.get(this.baseUri + 'api/Heroes/'+id).map
                                (response=> response.json());
        }

        /**
         * DELETE api/Heroes/{id}
         * @param {number} id
         * @return {void}
         */
        delete(id: number): Observable<Response>{
            return this.http.delete(this.baseUri + 'api/Heroes/'+id);
        }

        /**
         * Add a hero
         * POST api/Heroes?name={name}
         * @param {string} name
         * @return {DemoWebApi_Controllers_Client.Hero}
         */
        post(name: string): Observable<DemoWebApi_Controllers_Client.Hero>{
            return this.http.post(this.baseUri + 'api/Heroes?name='+encodeURIComponent(name), 
            JSON.stringify(null), { headers: new Headers({ 'Content-Type': 
            'text/plain;charset=UTF-8' }) }).map(response=> response.json());
        }

        /**
         * Update hero.
         * PUT api/Heroes
         * @param {DemoWebApi_Controllers_Client.Hero} hero
         * @return {DemoWebApi_Controllers_Client.Hero}
         */
        put(hero: DemoWebApi_Controllers_Client.Hero): 
                  Observable<DemoWebApi_Controllers_Client.Hero>{
            return this.http.put(this.baseUri + 'api/Heroes', JSON.stringify(hero), 
            { headers: new Headers({ 'Content-Type': 'text/plain;charset=UTF-8' 
            }) }).map(response=> response.json());
        }

        /**
         * Search heroes
         * GET api/Heroes?name={name}
         * @param {string} name keyword contained in hero name.
         * @return {Array<DemoWebApi_Controllers_Client.Hero>} Hero array matching the keyword.
         */
        search(name: string): Observable<Array<DemoWebApi_Controllers_Client.Hero>>{
            return this.http.get(this.baseUri + 'api/Heroes?name='+
            encodeURIComponent(name)).map(response=> response.json());
        }
    }

提示

如果您希望生成的 TypeScript 代码符合 JavaScript 和 JSON 的驼峰式命名约定,您可以在 Web API 的脚手架代码的 WebApiConfig 类中添加以下行:

config.Formatters.JsonFormatter.SerializerSettings.ContractResolver = 
            new Newtonsoft.Json.Serialization.CamelCasePropertyNamesContractResolver();

那么属性名和函数名将采用驼峰式命名,前提是 C# 中的相应名称为 Pascal 命名。更多详细信息,请参见camelCasing 或 PascalCasing

客户端应用程序编程

在使用 Visual Studio 等不错的文本编辑器编写客户端代码时,您可能会获得很好的智能提示。

import { Component, Inject, OnInit } from '@angular/core';
import * as namespaces from '../clientapi/WebApiNG2ClientAuto';

@Component({
    selector: 'my-dashboard',
    templateUrl: 'dashboard.component.html',
    styleUrls: ['dashboard.component.css']
})
export class DashboardComponent implements OnInit {
    heroes: namespaces.DemoWebApi_Controllers_Client.Hero[] = [];

    constructor(private heroService: namespaces.DemoWebApi_Controllers_Client.Heroes) { }

    ngOnInit(): void {
        this.heroService.getHeros().subscribe(
            heroes => this.heroes = heroes.slice(1, 5),
            error => console.error(error)
        );
    }
}

通过 IDE 的设计时类型检查和生成代码之上的编译时类型检查,以更少的精力提高客户端编程的生产力和产品质量。

不要做计算机能做的事情,让计算机为我们辛勤工作。我们的工作是为客户提供自动化解决方案,因此最好先自动化我们自己的工作。

关注点

在典型的 Angular 2 教程中,包括官方教程(该教程已被存档),作者经常敦促应用程序开发者创建服务类,例如“HeroService”,黄金法则永远是:始终将数据访问委托给支持服务类

WebApiClientGen 为您生成此服务类,它将消耗真实的 Web API 而不是内存中的 Web API,即 DemoWebApi_Controllers_Client.Heroes。在开发 WebApiClientGen 期间,我创建了一个演示项目 DemoAngular2相应的 Web API 控制器进行测试。

典型的教程也推荐使用模拟服务来进行单元测试。WebApiClientGen 使使用真实的 Web API 服务变得更便宜,因此您可能不需要创建模拟服务。您应该根据您的具体情况权衡在开发过程中使用模拟服务还是真实服务的使用成本/效益。通常,如果您的团队能够在每台开发机器上利用持续集成环境,那么使用真实服务运行测试可能会非常无缝且快速。

在典型的 SDLC 中,在初始设置之后,开发 Web API 和 NG2 应用的典型步骤如下:

  1. 升级 Web API。
  2. 运行 CreateClientApi.ps1 以更新 NG2 的 TypeScript 客户端 API。
  3. 在 Web API 更新后,利用生成的 TypeScript 客户端 API 代码或 C# 客户端 API 代码编写新的集成测试用例。
  4. 相应地修改 NG2 应用。
  5. 为了测试,运行 StartWebApi.ps1 来启动 Web API,然后运行 NG2 应用。

提示

对于步骤 5,有替代方案。例如,您可以使用 VS IDE 同时以调试模式启动 Web API 和 NG2 应用。有些开发人员可能更喜欢使用“npm start”。

本文最初是为 Angular 2 和 Http 服务编写的。WebApiClientGen 2.3.0 支持 Angular 4.3 中引入的 HttpClient。生成的 API 在接口级别保持不变。与未使用生成 API 而直接使用 Http 服务的 Angular 应用程序编程相比,这使得从已弃用的 Http 服务迁移到 HttpClient 服务变得相当轻松或无缝。

顺便说一句,如果您还没有迁移到 Angular 5,那么本文可能有所帮助:升级到 Angular 5 和 HttpClient。如果您使用的是 Angular 6,则应使用 WebApiClientGen 2.4.0+。

关于 Swagger?

如果您已经接触过 Swagger,特别是 Swashbuckle.AspNetCore 加上 NSwag,您可能会想使用哪个来生成客户端 API 代码。

TypeScript 客户端用于 JavaScript 库或框架

WebApiClientGen

  • jQuery with callbacks
  • Angular 2+
  • Axios
  • Aurelia
  • Fetch API

NSwag

  • JQuery 和回调函数
  • JQuery 和 promises
  • Angular (v2+) 使用 http 服务
  • Fetch API
  • Aurelia
  • Axios (预览)

示例:Controller Operation HeroesController.Get(id)

        /// <summary>
        /// Get a hero.
        /// </summary>
        /// <param name="id"></param>
        /// <returns></returns>
        [HttpGet("{id}")]
        public Hero Get(long id)
        {...

NSwagStudio 生成的 TypeScript Angular API 代码

    /**
     * @return Success
     */
    heroesGet(id: number): Observable<Hero> {
        let url_ = this.baseUrl + "/api/Heroes/{id}";
        if (id === undefined || id === null)
            throw new Error("The parameter 'id' must be defined.");
        url_ = url_.replace("{id}", encodeURIComponent("" + id)); 
        url_ = url_.replace(/[?&]$/, "");

        let options_ : any = {
            observe: "response",
            responseType: "blob",            
            headers: new HttpHeaders({
                "Accept": "text/plain"
            })
        };

        return this.http.request("get", url_, options_).pipe
                                (_observableMergeMap((response_ : any) => {
            return this.processHeroesGet(response_);
        })).pipe(_observableCatch((response_: any) => {
            if (response_ instanceof HttpResponseBase) {
                try {
                    return this.processHeroesGet(<any>response_);
                } catch (e) {
                    return <Observable<Hero>><any>_observableThrow(e);
                }
            } else
                return <Observable<Hero>><any>_observableThrow(response_);
        }));
    }

    protected processHeroesGet(response: HttpResponseBase): Observable<Hero> {
        const status = response.status;
        const responseBlob = 
            response instanceof HttpResponse ? response.body : 
            (<any>response).error instanceof Blob ? (<any>response).error : undefined;

        let _headers: any = {}; 
                       if (response.headers) { for (let key of response.headers.keys())
                       { _headers[key] = response.headers.get(key); }};
        if (status === 200) {
            return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => {
            let result200: any = null;
            let resultData200 = _responseText === "" ? 
                 null : JSON.parse(_responseText, this.jsonParseReviver);
            result200 = Hero.fromJS(resultData200);
            return _observableOf(result200);
            }));
        } else if (status !== 200 && status !== 204) {
            return blobToText(responseBlob).pipe(_observableMergeMap(_responseText => {
            return throwException("An unexpected server error occurred.", 
                                   status, _responseText, _headers);
            }));
        }
        return _observableOf<Hero>(<any>null);
    }

提示

更多详情请访问 Angular.ts

WebApiClientGen 生成的 TypeScript Angular API 代码

        /**
         * Get a hero. 
         * GET api/Heroes/{id}
         */
        getById(id: number): Observable<DemoWebApi_Controllers_Client.Hero> {
            return this.http.get<DemoWebApi_Controllers_Client.Hero>
                                  (this.baseUri + 'api/Heroes/' + id);
        }

提示

更多详情请访问 WebApiCoreNG2ClientAuto.ts

提示

更多详情,请参见WebApiClientGen vs Swashbuckle.AspNetCore plus NSwagStudio

参考文献

历史

  • 2020 年 2 月 24 日:初始版本
© . All rights reserved.