英雄之旅:Angular,带有 ASP.NET Core 后端
一系列文章比较了程序员在使用 Angular、Aurelia、React、Vue、Xamarin 和 MAUI 时的体验
背景
“英雄之旅”是 Angular 2+ 的官方教程应用程序。该应用程序包含一些功能特性和技术特性,这些特性在构建实际业务应用程序时很常见
- 几个屏幕展示了表格和嵌套数据
- 数据绑定
- 导航
- 对后端进行 CRUD 操作,并且可以选择通过生成的客户端 API 进行操作
- 单元测试和集成测试
在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个与后端通信的胖客户端。
Angular、Aurelia、React、Vue、Xamarin 和 MAUI 的前端应用通过生成的客户端 API 与相同的 ASP.NET (Core) 后端进行通信。要查找同一系列的其他文章,请在我的文章中搜索“Heroes”。系列文章的最后,将讨论一些程序员经验的技术因素。
- 计算机科学
- 软件工程
- 学习曲线
- 构建大小
- 运行时性能
- 调试
选择开发平台涉及许多非技术因素,本系列文章将不讨论这些因素。
参考文献
引言
本文专注于 Angular。
开发平台
- ASP.NET Core 8 上的 Web API
- 前端使用 Angular 17
演示存储库
在 GitHub 上检出 DemoCoreWeb,并关注以下区域
Core3WebApi
ASP.NET Core Web API
Angular Heroes
这是Angular 官方教程“英雄之旅”演示的改进版本。它没有使用内存中的 mock 服务,而是通过生成的客户端 API 与真实的 ASP.NET Core Web API 后端进行通信。
备注
DemoCoreWeb 旨在测试WebApiClientGen 的 NuGet 包,并演示如何在实际项目中 YaM 库。
Using the Code
必备组件
- Core3WebApi.csproj 导入了 NuGet 包Fonlow.WebApiClientGenCore和Fonlow.WebApiClientGenCore.NG2。
- 将CodeGenController.cs添加到Core3WebApi.csproj。
- Core3WebApi.csproj 包含CodeGen.json。这是可选的,仅为方便运行某些 PowerShell 脚本来生成客户端 API。
- CreateWebApiClientApi3.ps1。这是可选的。此脚本将启动 DotNet Kestrel Web 服务器上的 Web API,并发布CodeGen.json中的数据。
备注
根据您的 CI/CD 流程,您可以调整上述第 3 项和第 4 项。有关更多详细信息,请查看
生成客户端 API
运行CreateWebApiClientApi3.ps1,生成的代码将写入WebApiCoreNg2ClientAuto.ts。
数据模型和 API 函数
export namespace DemoWebApi_Controllers_Client {
/**
* Complex hero type
*/
export interface Hero {
id?: number | null;
name?: string | null;
}
}
@Injectable()
export class Heroes {
constructor(@Inject('baseUri') private baseUri:
string = window.location.protocol + '//' +
window.location.hostname + (window.location.port ? ':' +
window.location.port : '') + '/', private http: HttpClient) {
}
/**
* DELETE api/Heroes/{id}
*/
delete(id: number | null, headersHandler?: () => HttpHeaders):
Observable<HttpResponse<string>> {
return this.http.delete(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined,
observe: 'response', responseType: 'text' });
}
/**
* Get a hero.
* GET api/Heroes/{id}
*/
getHero(id: number | null, headersHandler?: () =>
HttpHeaders): Observable<DemoWebApi_Controllers_Client.Hero> {
return this.http.get<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes/' + id,
{ headers: headersHandler ? headersHandler() : undefined });
}
/**
* Get all heroes.
* GET api/Heroes
*/
getHeros(headersHandler?: () => HttpHeaders):
Observable<Array<DemoWebApi_Controllers_Client.Hero>> {
return this.http.get<Array<DemoWebApi_Controllers_Client.Hero>>
(this.baseUri + 'api/Heroes', { headers: headersHandler ?
headersHandler() : undefined });
}
/**
* POST api/Heroes
*/
post(name: string | null, headersHandler?: () => HttpHeaders):
Observable<DemoWebApi_Controllers_Client.Hero> {
return this.http.post<DemoWebApi_Controllers_Client.Hero>
(this.baseUri + 'api/Heroes', JSON.stringify(name),
{ headers: headersHandler ? headersHandler().append('Content-Type',
'application/json;charset=UTF-8') : new HttpHeaders
({ 'Content-Type': 'application/json;charset=UTF-8' }) });
}
虽然有多种方法可以利用生成的 API 函数,但最传统的方法是通过各自开发平台推荐的依赖注入机制进行注入。例如:
export function clientFactory(http: HttpClient) {
if (SiteConfigConstants.apiBaseuri) {
console.debug('apiBaseuri:' + SiteConfigConstants.apiBaseuri)
return new namespaces.DemoWebApi_Controllers_Client.Heroes
(SiteConfigConstants.apiBaseuri, http);
}
const _baseUri = location.protocol + '//' + location.hostname +
(location.port ? ':' + location.port : '') + '/';
const webApiUrl = _baseUri + 'webapi/';
console.debug('webApiUrl: ' + webApiUrl);
return new namespaces.DemoWebApi_Controllers_Client.Heroes(webApiUrl, http);
}
@NgModule({
imports: [
...
AppRoutingModule,
HttpClientModule,
...
],
declarations: [
AppComponent,
DashboardComponent,
HeroesComponent,
HeroDetailComponent,
MessagesComponent,
HeroSearchComponent
],
providers: [
{
provide: namespaces.DemoWebApi_Controllers_Client.Heroes,
useFactory: clientFactory,
deps: [HttpClient],
},
...
]
})
视图
编辑
hero-detail.component.html
<div *ngIf="hero">
<h2>{{hero.name | uppercase}} Details</h2>
<div><span>id: </span>{{hero.id}}</div>
<div>
<label for="hero-name">Hero name: </label>
<input id="hero-name" [(ngModel)]="hero.name" placeholder="Hero name"/>
</div>
<button type="button" (click)="goBack()">go back</button>
<button type="button" (click)="save()">save</button>
</div>
hero-detail.component.ts (代码背后)
@Component({
selector: 'app-hero-detail',
templateUrl: './hero-detail.component.html',
styleUrls: ['./hero-detail.component.css']
})
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
constructor(
private heroService: DemoWebApi_Controllers_Client.Heroes,
private route: ActivatedRoute,
private location: Location
) {
}
ngOnInit(): void {
this.route.params.forEach((params: Params) => {
const id = +params['id'];
this.heroService.getHero(id).subscribe({
next: hero => {
if (hero) {
this.hero = hero;
}
},
error: error => alert(error)
});
});
}
save(): void {
if (this.hero) {
this.heroService.put(this.hero!).subscribe(
{
next: d => {
console.debug('response: ' + JSON.stringify(d));
},
error: error => alert(error)
}
);
}
}
goBack(): void {
this.location.back();
}
}
英雄列表
heroes.component.html
<h2>My Heroes</h2>
<div>
<label for="new-hero">Hero name: </label>
<input id="new-hero" #heroName />
<button type="button" class="add-button" (click)="add(heroName.value); heroName.value=''">
Add hero
</button>
</div>
<ul class="heroes">
<li *ngFor="let hero of heroes">
<a routerLink="/detail/{{hero.id}}">
<span class="badge">{{hero.id}}</span> {{hero.name}}
</a>
<button type="button" class="delete" title="delete hero"
(click)="delete(hero)">x</button>
</li>
</ul>
heroes.component.ts
@Component({
selector: 'app-heroes',
templateUrl: './heroes.component.html',
styleUrls: ['./heroes.component.css']
})
export class HeroesComponent implements OnInit {
heroes?: namespaces.DemoWebApi_Controllers_Client.Hero[];
selectedHero?: namespaces.DemoWebApi_Controllers_Client.Hero;
constructor(
private heroService: namespaces.DemoWebApi_Controllers_Client.Heroes,
private router: Router) { }
getHeroes(): void {
this.heroService.getHeros().subscribe(
heroes => {
this.heroes = heroes;
}
);
}
add(name: string): void {
name = name.trim();
if (!name) { return; }
this.heroService.post(name).subscribe(
hero => {
this.heroes?.push(hero);
this.selectedHero = undefined;
});
}
delete(hero: namespaces.DemoWebApi_Controllers_Client.Hero): void {
this.heroService.delete(hero.id!).subscribe(
() => {
this.heroes = this.heroes?.filter(h => h !== hero);
if (this.selectedHero === hero) { this.selectedHero = undefined; }
});
}
ngOnInit(): void {
this.getHeroes();
}
onSelect(hero: namespaces.DemoWebApi_Controllers_Client.Hero): void {
this.selectedHero = hero;
}
gotoDetail(): void {
this.router.navigate(['/detail', this.selectedHero?.id]);
}
}
视图模型
在 Angular 组件中,公共数据字段或函数是视图模型,Angular 运行时会对其进行监视以进行更改检测。例如:
export class HeroDetailComponent implements OnInit {
hero?: DemoWebApi_Controllers_Client.Hero;
export class HeroesComponent implements OnInit {
heroes?: namespaces.DemoWebApi_Controllers_Client.Hero[];
selectedHero?: namespaces.DemoWebApi_Controllers_Client.Hero;
提示
除了将数据模型变成视图模型外,Angular 响应式表单还提供了高级视图模型。
路由
Angular 提供了高级路由。
全局或模块内的符号路由
const routes: Routes = [
{ path: '', redirectTo: '/dashboard', pathMatch: 'full' },
{ path: 'dashboard', component: DashboardComponent },
{ path: 'detail/:id', component: HeroDetailComponent },
{ path: 'heroes', component: HeroesComponent }
];
gotoDetail(): void {
this.router.navigate(['/detail', this.selectedHero?.id]);
}
集成测试
由于“英雄之旅”的前端是一个胖客户端,因此大部分集成测试都是针对后端进行的。
describe('Heroes API', () => {
let service: DemoWebApi_Controllers_Client.Heroes;
beforeEach(async(() => {
TestBed.configureTestingModule({
imports: [HttpClientModule],
providers: [
{
provide: DemoWebApi_Controllers_Client.Heroes,
useFactory: heroesClientFactory,
deps: [HttpClient],
},
]
});
service = TestBed.get(DemoWebApi_Controllers_Client.Heroes);
}));
it('getAll', (done) => {
service.getHeros().subscribe(
data => {
expect(data!.length).toBeGreaterThan(0);
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
it('getHero', (done) => {
service.getHero(9999).subscribe(
data => {
expect(data).toBeNull();
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
it('Add', (done) => {
service.post('somebody').subscribe(
data => {
expect(data!.name).toBe('somebody');
done();
},
error => {
fail(errorResponseToString(error));
done();
}
);
}
);
关注点
GitHub 上的 DemoCoreWeb 最初是为了测试已发布的 WebApiClient
包而创建的,它还能很好地实现以下目的:
- 一个不太简单也不太复杂的演示,适用于各种开发平台。掌握一个之后,你应该很容易学习其他平台,它们基于相同的业务功能。
- 演示使用各种开发平台的程序员体验,以便在选择下一个项目的开发平台时,根据你的业务内容和上下文给你一些想法。
后端提供了多组 Web API,“英雄之旅”仅使用了HeroesController中公开的 API。在实际应用中,一个后端可能服务于多个前端应用,而一个前端应用可能与多个后端通信。