使用 ASP.NET Core Web API 生成 Angular 响应式表单的类型化 FormGroup
通过生成的代码构建客户端数据模型的 Angular 响应式表单。
引言
您正在同时构建 ASP.NET Core Web API 和 Angular 应用程序。
在使用 Angular 2+ 开发复杂业务应用程序的胖 Web 客户端(SPA、PWA)时,您更喜欢使用类型化的 响应式表单 而不是 模板驱动表单 来进行数据录入。本文介绍了如何在实际项目中自动化生成响应式表单的代码。
背景
您可能不会自己使用 HttpClient
编写 AJAX 调用,而是使用了由 NSwag 或 WebApiClientGen 或类似的工具生成的客户端库。因此,在消耗 API 响应或构造 API 有效负载时,您会使用客户端库中的客户端数据模型。到目前为止,一切都很好。
在使用响应式表单时,您必须手动创建 FormGroups
、FormControls
和 FormArray
等,并按照逻辑结构重新组装客户端数据模型。每次 Web API 因数据模型更改而升级时,您都必须调整响应式表单的相应代码。
能否自动化创建 FormGroups
会更好?
简而言之
- 对于从 ASP.NET Core Web API 的服务侧数据模型生成的客户端数据模型,现在您可以使用 WebApiClientGen 的 Angular FormGroup 插件 来实现了。
- 对于仅客户端数据模型,Angular 团队需要做 一些工作。
Using the Code
如果您从未在 ASP.NET (Core) Web API junto con su aplicación Angular 中使用过 WebApiClientGen
,请先阅读以下内容:
- ASP.NET Web API、Angular2、TypeScript 和 WebApiClientGen (2017 年 8 月 17 日)
- 为 ASP.NET Core Web API 生成 TypeScript 客户端 API (2023 年 10 月 18 日)
必备组件
除了在 为 ASP.NET Core Web API 生成 TypeScript 客户端 API 中描述的内容之外,您还需要调整导入和代码生成有效负载。
- 导入 Fonlow.WebApiClientGenCore.NG2FormGroup v1.2 或更高版本,而不是
Fonlow.WebapiclientGenCore.NG2
。 - 在 CodeGen.json 中定义以下内容:
{
"AssemblyName": "Fonlow.WebApiClientGenCore.NG2FormGroup",
"TargetDir": "..\\..\\..\\..\\..\\HeroesDemo\\src\\ClientApi",
"TSFile": "WebApiCoreNG2FormGroupClientAuto.ts",
"AsModule": true,
"ContentType": "application/json;charset=UTF-8",
"ClientNamespaceSuffix": ".Client",
"ContainerNameSuffix": "",
"DataAnnotationsToComments": true,
"HelpStrictMode": true
},
后端数据类型
[DataContract(Namespace = DemoWebApi.DemoData.Constants.DataNamespace)]
public class Hero
{
public Hero(long id, string name)
{
Id = id;
Name = name;
PhoneNumbers = new List<DemoWebApi.DemoData.PhoneNumber>();
}
[DataMember]
public long Id { get; set; }
[DataMember]
[Required]
[StringLength(120, MinimumLength = 2)]
public string Name { get; set; }
[DataMember]
public DateOnly DOB { get; set; }
[DataMember]
public DateOnly? Death { get; set; }
[DataMember]
[EmailAddress]
public string EmailAddress { get; set; }
[DataMember]
public DemoWebApi.DemoData.Address Address { get; set; }
[DataMember]
[MinLength(6)] //just for testing multiple validations
[RegularExpression(@"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]
{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)")]
public string WebAddress { get; set; }
[DataMember]
public virtual IList<DemoWebApi.DemoData.PhoneNumber> PhoneNumbers { get; set; }
}
TypeScript 中生成的客户端数据类型
修改后的《英雄之旅》的完整源代码位于 HeroesDemo,增加了额外的数据属性和 Angular Material 组件。
export interface Hero {
address?: DemoWebApi_DemoData_Client.Address;
death?: Date | null;
dob?: Date | null;
id?: number | null;
/**
* Required
* String length: inclusive between 2 and 120
*/
name?: string | null;
phoneNumbers?: Array<DemoWebApi_DemoData_Client.PhoneNumber>;
}
export namespace DemoWebApi_DemoData_Client {
export interface Address {
/** String length: inclusive between 2 and 50 */
city?: string | null;
/** String length: inclusive between 2 and 30 */
country?: string | null;
id?: string | null;
/** String length: inclusive between 2 and 10 */
postalCode?: string | null;
/** String length: inclusive between 2 and 30 */
state?: string | null;
/** String length: inclusive between 2 and 100 */
street1?: string | null;
/** String length: inclusive between 2 and 100 */
street2?: string | null;
type?: DemoWebApi_DemoData_Client.AddressType | null;
/**
* It is a field
*/
location?: DemoWebApi_DemoData_Another_Client.MyPoint;
}
export interface PhoneNumber {
/** Max length: 120 */
fullNumber?: string | null;
phoneType?: DemoWebApi_DemoData_Client.PhoneType | null;
}
为应用程序编程生成的 FormGroup 代码
export interface HeroFormProperties {
death: FormControl<Date | null | undefined>,
dob: FormControl<Date | null | undefined>,
emailAddress: FormControl<string | null | undefined>,
id: FormControl<number | null | undefined>,
/**
* Required
* String length: inclusive between 2 and 120
*/
name: FormControl<string | null | undefined>,
/** Min length: 6 */
webAddress: FormControl<string | null | undefined>,
}
export function CreateHeroFormGroup() {
return new FormGroup<HeroFormProperties>({
death: new FormControl<Date | null | undefined>(undefined),
dob: new FormControl<Date | null | undefined>(undefined),
emailAddress: new FormControl<string | null | undefined>
(undefined, [Validators.email]),
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>(undefined,
[Validators.required, Validators.maxLength(120), Validators.minLength(2)]),
webAddress: new FormControl<string | null | undefined>(undefined,
[Validators.minLength(6), Validators.pattern('https?:\\/\\/(www\\.)?
[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b
([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)')]),
});
}
请注意,复杂类型(如“address
”和“phoneNumbers
”)的属性在生成的代码中被跳过了,我将在后面解释。
尽管如此,您可以看到在服务数据类型中声明的数据约束已映射到验证器。
export interface AddressFormProperties {
/** String length: inclusive between 2 and 50 */
city: FormControl<string | null | undefined>,
/** String length: inclusive between 2 and 30 */
country: FormControl<string | null | undefined>,
id: FormControl<string | null | undefined>,
/** String length: inclusive between 2 and 10 */
postalCode: FormControl<string | null | undefined>,
/** String length: inclusive between 2 and 30 */
state: FormControl<string | null | undefined>,
/** String length: inclusive between 2 and 100 */
street1: FormControl<string | null | undefined>,
/** String length: inclusive between 2 and 100 */
street2: FormControl<string | null | undefined>,
type: FormControl<DemoWebApi_DemoData_Client.AddressType | null | undefined>,
}
export function CreateAddressFormGroup() {
return new FormGroup<AddressFormProperties>({
city: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(50), Validators.minLength(2)]),
country: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(30), Validators.minLength(2)]),
id: new FormControl<string | null | undefined>(undefined),
postalCode: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(10), Validators.minLength(2)]),
state: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(30), Validators.minLength(2)]),
street1: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(100), Validators.minLength(2)]),
street2: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(100), Validators.minLength(2)]),
type: new FormControl<DemoWebApi_DemoData_Client.AddressType |
null | undefined>(undefined),
});
}
export interface PhoneNumberFormProperties {
/** Max length: 120 */
fullNumber: FormControl<string | null | undefined>,
phoneType: FormControl<DemoWebApi_DemoData_Client.PhoneType | null | undefined>,
}
export function CreatePhoneNumberFormGroup() {
return new FormGroup<PhoneNumberFormProperties>({
fullNumber: new FormControl<string | null | undefined>
(undefined, [Validators.maxLength(16)]),
phoneType: new FormControl<DemoWebApi_DemoData_Client.PhoneType |
null | undefined>(undefined),
});
}
因此,通过生成的 FormGroups
代码,您可以引入客户端验证。这减少了后端验证的往返次数,并提高了整体用户体验,尤其是在使用 Angular Material UI 组件库 时。
应用程序编程
在业务应用程序中,通常会使用关系数据库。规范化的数据库架构通常将对象数据模型的嵌套属性拆分到多个表中,并通过外键连接形成一对多或多对多的关系。如果您使用 Entity Framework 等 ORM,EF 可以在多个表上生成多个插入语句来持久化带有嵌套属性的新对象。但是,在更新时,通常需要相应地更新每个嵌套结构。
根据整体用户体验设计、业务约束和技术约束,您在构建 Angular 响应式表单的应用程序编程过程中做出相应的设计决策。这就是 NG2FormGroup
跳过复杂类型和数组属性的原因。
但是,如果您的设计决策是始终一次性添加和更新包含嵌套结构的复杂对象,那么利用生成的代码仍然很容易,如下所示:
通过继承和组合,在应用程序代码中,您可以创建一个包含 Hero
类型所有属性的 FormGroup
。
import { Location } from '@angular/common';
import { Component, OnInit } from '@angular/core';
import { FormArray, FormGroup } from '@angular/forms';
import { ActivatedRoute, Params } from '@angular/router';
import { DemoWebApi_Controllers_Client, DemoWebApi_DemoData_Client }
from '../../clientapi/WebApiCoreNG2FormGroupClientAuto';
export interface HeroWithNestedFormProperties
extends DemoWebApi_Controllers_Client.HeroFormProperties {
address?: FormGroup<DemoWebApi_DemoData_Client.AddressFormProperties>,
phoneNumbers?: FormArray<FormGroup
<DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>,
}
export function CreateHeroWithNestedFormGroup() {
const fg: FormGroup<HeroWithNestedFormProperties> =
DemoWebApi_Controllers_Client.CreateHeroFormGroup();
fg.controls.address = DemoWebApi_DemoData_Client.CreateAddressFormGroup();
fg.controls.phoneNumbers = new FormArray<FormGroup
<DemoWebApi_DemoData_Client.PhoneNumberFormProperties>>([]);
return fg;
}
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html'
})
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
heroForm: FormGroup<HeroWithNestedFormProperties>;
constructor(
private heroService: DemoWebApi_Controllers_Client.Heroes,
private route: ActivatedRoute,
private location: Location
) {
this.heroForm = CreateHeroWithNestedFormGroup();
}
ngOnInit(): void {
this.route.params.forEach((params: Params) => {
const id = +params['id'];
this.heroService.getHero(id).subscribe({
next: hero => {
if (hero) {
this.hero = hero;
this.heroForm.patchValue(hero); // populate properties
// including composit ones except nested array.
if (this.hero.phoneNumbers) {
this.hero.phoneNumbers.forEach(d => {
const g = DemoWebApi_DemoData_Client.
CreatePhoneNumberFormGroup();
g.patchValue(d);
this.heroForm.controls.phoneNumbers?.push(g);
});
}
}
},
error: error => alert(error)
});
});
}
...
}
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div [formGroup]="heroForm">
<label for="hero-name">Hero name: </label>
<mat-form-field>
<mat-label>Name</mat-label>
<input matInput id="hero-name" formControlName="name" />
<mat-error *ngIf="heroForm.controls.name.hasError">
{{getErrorsText(heroForm.controls.name.errors)}}</mat-error>
</mat-form-field>
<input matInput id="hero-dob" type="date"
formControlName="dob" placeholder="DOB" />
<input matInput id="hero-death" type="date" formControlName="death"
placeholder="Death" />
<div>
<mat-form-field>
<mat-label>Email</mat-label>
<input matInput formControlName="emailAddress"
placeholder="name@domain" />
<mat-error *ngIf="heroForm.controls.emailAddress.hasError">
{{getErrorsText(heroForm.controls.emailAddress.errors)}}</mat-error>
</mat-form-field>
</div>
<div>
<mat-form-field>
<mat-label>Web</mat-label>
<input matInput formControlName="webAddress" />
<mat-error *ngIf="heroForm.controls.webAddress.hasError">
{{getErrorsText(heroForm.controls.webAddress.errors)}}</mat-error>
</mat-form-field>
</div>
<div formGroupName="address">
<mat-form-field>
<mat-label>Street</mat-label>
<input matInput formControlName="street1" />
<mat-error *ngIf="heroForm.controls.address?.controls?.street1?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.street1?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>City</mat-label>
<input matInput formControlName="city" />
<mat-error *ngIf="heroForm.controls.address?.controls?.city?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.city?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>State</mat-label>
<input matInput formControlName="state" />
<mat-error *ngIf="heroForm.controls.address?.controls?.state?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.state?.errors)}}
</mat-error>
</mat-form-field>
<mat-form-field>
<mat-label>Country</mat-label>
<input matInput formControlName="country" />
<mat-error *ngIf="heroForm.controls.address?.controls?.country?.hasError">
{{getErrorsText(heroForm.controls.address?.controls?.country?.errors)}}
</mat-error>
</mat-form-field>
</div>
<div *ngFor="let pg of heroForm.controls.phoneNumbers!.controls" [formGroup]="pg">
<mat-form-field>
<mat-label>Number</mat-label>
<input matInput formControlName="fullNumber" />
<button mat-mini-fab color="any"
matSuffix (click)="removePhoneNumber(pg)">X</button>
<mat-error *ngIf="pg.hasError">
{{getErrorsText(pg.controls.fullNumber.errors)}}</mat-error>
</mat-form-field>
</div>
<div>
<button mat-raised-button (click)="addPhoneNumber()">Add Phone Number</button>
</div>
</div>
<button mat-raised-button type="button" (click)="goBack()">go back</button>
<button mat-raised-button type="button" (click)="save()"
[disabled]="!allNestedValid(heroForm)">save</button>
</div>
关注点
Angular 17 及更高版本的 FormGroup.patchValue 和 .getRawValue
FormGroup.patchValue
将填充所有 Form Controls 和嵌套的 Form Groups,但不包括嵌套的 Form Arrays。不算太糟糕,因为这样的代码可以进行补偿。
if (this.hero.phoneNumbers) {
this.hero.phoneNumbers.forEach(d => {
const g = DemoWebApi_DemoData_Client.CreatePhoneNumberFormGroup();
g.patchValue(d);
this.heroForm.controls.phoneNumbers?.push(g);
});
}
FormGroup.getRawValue
将读取所有 Form Controls、嵌套的 Form Groups以及嵌套的 Form Arrays。
这看起来有点不一致。但是,我不确定一致性是否对响应式表单的应用程序编程真正重要。或者,在应用程序编程时让程序员决定是否填充 Form Arrays 是否更好?请留下您的评论。
FormControl 的 Null 或 undefined 作为默认值
您可能已经注意到,生成的表单控件总是带有
aField: new FormControl<PrimitiveType | null | undefined>(undefined)
但是,即使字段未被触及,formControl.defaultValue 也为 null,即使生成的代码提供了 undefined
,并且 FormGroup.getRawValue() 的行为总是输出
{...... "aField": null ......}
您可能不希望这样,因为前端和后端之间存在应用程序协议,您可能更希望一个 undefined 且未被触及的字段不会出现在请求负载中,尤其是在 ASP.NET (Core) Web API 默认不会在响应负载中返回那些值为 null 的属性时。
在设计前端和后端时,我应用了一个辅助函数来清理每个 JSON 数据请求负载的字段的 null 值。
/**
* Remove null or empty fields including those in nested objects.
* This is useful for reducing payload of AJAX serialization.
* @param obj
*/
static removeNullOrEmptyFields(obj: any) {
for (const f in obj) {
let p = obj[f];
if (p === null || p === '') {
delete obj[f];
} else if (typeof p === 'object' && p !== null) {
this.removeNullOrEmptyFields(p);
}
}
}
尽管如此,这不能成为一个普遍的解决方案/变通方法。请牢记 FormGroup.getRawValue() 的这种缺陷,并根据您的应用程序协议找出自己的解决方案。
提示
- 您可能想问为什么不使用
FormControl<PrimitiveType | null>
?这是因为在应用程序代码中,FormControl.setValue(v)
经常会收到一个可能为undefined
的值。这种方法会触发更少的 tlint 或编译器警告/错误。
备注
仅客户端数据模型
截至 Angular 17,对于仅客户端数据模型,您需要手动创建类型化的表单组。我已经向 Angular 团队提出了一个 提案:“通过验证器的声明性信息,从接口/模型生成包含验证的类型化表单组”。如果您喜欢这个可能对您的响应式表单应用程序编程有益的想法,请投票支持该问题,使其更快实现。
OpenAPI 如何?
我已将此功能复制到 OpenApiClientGen 中,该功能与 WebApiClientGen
存储库共享 TypeCodeCodeDom
和 Poco2Ts。如果您有一个 Swagger/OpenAPI 定义文件并希望生成 Angular 响应式表单的代码,可以尝试 Fonlow.OpenApiClientGen.exe v2.7 或更高版本及其插件 OpenApiClientGenCore.NG2FormGroup
v1.6。如果您的开发机器是 Mac,您可以使用 BuildForMac.bat 为 MacOS 构建 OpenApiClientGen
的发布版本。
例如,在定义 pet.yaml 后,OpenApiClientGen
会生成 Angular 响应式表单代码。
export namespace MyNS {
export interface Pet {
/** Pet ID */
id?: number | null;
/** Categories this pet belongs to */
category?: Category;
/**
* The name given to a pet
* Required
*/
name: string;
/**
* The list of URL to a cute photos featuring pet
* Required
* Maximum items: 20
*/
photoUrls: Array<string>;
friend?: Pet;
/**
* Tags attached to the pet
* Minimum items: 1
*/
tags?: Array<Tag>;
/** Pet status in the store */
status?: PetStatus | null;
/** Type of a pet */
petType?: string | null;
}
export interface PetFormProperties {
/** Pet ID */
id: FormControl<number | null | undefined>,
/**
* The name given to a pet
* Required
*/
name: FormControl<string | null | undefined>,
/** Pet status in the store */
status: FormControl<PetStatus | null | undefined>,
/** Type of a pet */
petType: FormControl<string | null | undefined>,
}
export function CreatePetFormGroup() {
return new FormGroup<PetFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
});
}
/** A representation of a cat */
export interface Cat extends Pet {
/**
* The measured skill for hunting
* Required
*/
huntingSkill: CatHuntingSkill;
}
/** A representation of a cat */
export interface CatFormProperties extends PetFormProperties {
/**
* The measured skill for hunting
* Required
*/
huntingSkill: FormControl<CatHuntingSkill | null | undefined>,
}
export function CreateCatFormGroup() {
return new FormGroup<CatFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
huntingSkill: new FormControl<CatHuntingSkill | null | undefined>
(undefined, [Validators.required]),
});
}
export enum CatHuntingSkill
{ clueless = 0, lazy = 1, adventurous = 2, aggressive = 3 }
export interface Category {
/** Category ID */
id?: number | null;
/**
* Category name
* Min length: 1
*/
name?: string | null;
/** Test Sub Category */
sub?: CategorySub;
}
export interface CategoryFormProperties {
/** Category ID */
id: FormControl<number | null | undefined>,
/**
* Category name
* Min length: 1
*/
name: FormControl<string | null | undefined>,
}
export function CreateCategoryFormGroup() {
return new FormGroup<CategoryFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.minLength(1)]),
});
}
export interface CategorySub {
/** Dumb Property */
prop1?: string | null;
}
export interface CategorySubFormProperties {
/** Dumb Property */
prop1: FormControl<string | null | undefined>,
}
export function CreateCategorySubFormGroup() {
return new FormGroup<CategorySubFormProperties>({
prop1: new FormControl<string | null | undefined>(undefined),
});
}
/** A representation of a dog */
export interface Dog extends Pet {
/**
* The size of the pack the dog is from
* Required
* Minimum: 1
*/
packSize: number;
}
/** A representation of a dog */
export interface DogFormProperties extends PetFormProperties {
/**
* The size of the pack the dog is from
* Required
* Minimum: 1
*/
packSize: FormControl<number | null | undefined>,
}
export function CreateDogFormGroup() {
return new FormGroup<DogFormProperties>({
id: new FormControl<number | null | undefined>(undefined),
name: new FormControl<string | null | undefined>
(undefined, [Validators.required]),
status: new FormControl<PetStatus | null | undefined>(undefined),
petType: new FormControl<string | null | undefined>(undefined),
packSize: new FormControl<number | null | undefined>
(undefined, [Validators.required, Validators.min(1)]),
});
}
还请参考 Swagger/OpenAPI 数据约束和 Angular Validators 之间的 映射,以及文章“从 Swagger / OpenAPI 定义生成 Angular 响应式表单的类型化表单”。
历史
- 2024 年 1 月 9 日:初始版本