英雄之旅:Blazor WebAssembly 独立应用
Blazor WebAssembly 独立应用与 ASP.NET Core Web API 通信,并生成 C# 客户端 API 代码
背景
“英雄之旅”是 Angular 2+ 的官方教程应用。该应用包含一些功能性特性和技术性特性,这些是构建与 Web API 通信的真实业务应用时常见的。
- 几个屏幕展示了表格和嵌套数据
- 数据绑定
- 导航
- 对后端进行 CRUD 操作,并且可以选择通过生成的客户端 API 进行操作。
- 单元测试和集成测试
在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个与后端通信的胖客户端。
Angular、Aurelia、React、Vue、Xamarin 和 MAUI 前端应用都通过生成的客户端 API 与同一个 ASP.NET (Core) 后端通信。要查找同一系列的其他文章,请在 我的文章 中搜索“英雄之旅”。在系列结束时,将讨论一些程序员体验的技术因素。
- 计算机科学
- 软件工程
- 学习曲线
- 构建大小
- 运行时性能
- 调试
选择开发平台涉及许多非技术因素,本系列文章将不讨论这些因素。
参考文献
- 英雄之旅:Angular,带有 ASP.NET Core 后端
- 英雄之旅:React,带有 ASP.NET Core 后端
- 英雄之旅:Aurelia,带有 ASP.NET Core 后端
- 英雄之旅:Xamarin,带有 ASP.NET 后端
- 英雄之旅:MAUI,搭配 ASP.NET Core 8 后端
引言
假定您已经阅读了 ASP.NET Core Blazor 文档 的前几章,并且已经通过脚手架代码以及 todolist 演示 运行过 WeatherForecast 应用。
本文重点介绍运行在 Web 浏览器中的 Blazor WebAssembly 独立应用,具备“英雄之旅”的功能,并与 ASP.NET Core Web API 后端通信。
演示存储库
在 GitHub 上查看 DemoCoreWeb,并重点关注以下领域:
Core3WebApi
ASP.NET Core Web API csproj 只提供 Web API,包括 Heroes API。
Blazor 英雄
Blazor Web Assembly 独立应用。
备注
DemoCoreWeb 建立是为了测试 WebApiClientGen 的 NuGet 包,并演示如何在实际项目中使用该库。生成的 C# 客户端 API 代码可以通过 WebAssembly 在 Web 浏览器中使用。
使用代码
必备组件
- Core3WebApi.csproj 导入了 NuGet 包 `Fonlow.WebApiClientGenCore`。
- 将 CodeGenController.cs 添加到 Core3WebApi.csproj。
- Core3WebApi.csproj 包含 CodeGen.json。这是可选的,只是为了方便运行一些 PowerShell 脚本来生成客户端 API。
- CreateWebApiClientApi3.ps1。这是可选的。此脚本将启动 Kestrel 上的 Web API,并发布 CodeGen.json 中的数据。
生成客户端 API
运行 CreateWebApiClientApi3.ps1,生成的代码将写入 CoreWebApi.ClientApi。
备注
- 根据您的 CI/CD 流程,您可能需要调整上述第 3 和第 4 项。更多详情,请参阅:“为 ASP.NET Core Web API 生成 C# 客户端 API”。该存储库已包含生成的客户端 API 代码。
数据模型和 API 函数
namespace DemoWebApi.Controllers.Client;
public class Hero : object
{
public DemoWebApi.DemoData.Client.Address Address { get; set; }
public System.Nullable<System.DateOnly> Death { get; set; }
public System.DateOnly DOB { get; set; }
public string EmailAddress { get; set; }
public long Id { get; set; }
[System.ComponentModel.DataAnnotations.RequiredAttribute()]
[System.ComponentModel.DataAnnotations.StringLength(120, MinimumLength=2)]
public string Name { get; set; }
public System.Collections.Generic.IList<DemoWebApi.DemoData.Client.PhoneNumber> PhoneNumbers { get; set; }
[System.ComponentModel.DataAnnotations.MinLength(6)]
[System.ComponentModel.DataAnnotations.RegularExpressionAttribute(@"https?:\\/\\/(www\\.)?[-a-zA-Z0-9@:%._\\+~#=]{1,256}\\.[a-zA-Z0-9()]{1,6}\\b([-a-zA-Z0-9()@:%_\\+.~#?&//=]*)")]
public string WebAddress { get; set; }
}
备注
- 在演示应用中,只使用了 Id 和 Name,而其他属性是为了测试某些代码生成功能。
- 将某些服务器端属性复制到客户端。对于 TypeScript 代码,属性可能会转换为 JsDoc for TypeScript。
- 某些验证属性可能会转换为 Angular Reactive Forms。
英雄客户端 API
public partial class Heroes
{
private System.Net.Http.HttpClient client;
private JsonSerializerOptions jsonSerializerSettings;
public Heroes(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings=null)
{
if (client == null)
throw new ArgumentNullException(nameof(client), "Null HttpClient.");
if (client.BaseAddress == null)
throw new ArgumentNullException(nameof(client), "HttpClient has no BaseAddress");
this.client = client;
this.jsonSerializerSettings = jsonSerializerSettings;
}
/// <summary>
/// DELETE api/Heroes/{id}
/// </summary>
public async Task DeleteAsync(long id, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
var requestUri = "api/Heroes/"+id;
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Delete, requestUri);
handleHeaders?.Invoke(httpRequestMessage.Headers);
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
}
finally
{
responseMessage.Dispose();
}
}
/// <summary>
/// GET api/Heroes/{id}
/// </summary>
[return: System.Diagnostics.CodeAnalysis.MaybeNullAttribute()]
public async Task<DemoWebApi.Controllers.Client.Hero> GetHeroAsync(long id, Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
var requestUri = "api/Heroes/"+id;
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
handleHeaders?.Invoke(httpRequestMessage.Headers);
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
var contentString = await responseMessage.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero>(contentString, jsonSerializerSettings);
}
finally
{
responseMessage.Dispose();
}
}
/// <summary>
/// GET api/Heroes
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero[]> GetHeroesAsync(Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
var requestUri = "api/Heroes";
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
handleHeaders?.Invoke(httpRequestMessage.Headers);
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
var contentString = await responseMessage.Content.ReadAsStringAsync();
return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>(contentString, jsonSerializerSettings);
}
finally
{
responseMessage.Dispose();
}
}
/// <summary>
/// GET api/Heroes
/// </summary>
public DemoWebApi.Controllers.Client.Hero[] GetHeroes(Action<System.Net.Http.Headers.HttpRequestHeaders> handleHeaders = null)
{
var requestUri = "api/Heroes";
using var httpRequestMessage = new HttpRequestMessage(HttpMethod.Get, requestUri);
handleHeaders?.Invoke(httpRequestMessage.Headers);
var responseMessage = client.SendAsync(httpRequestMessage).Result;
try
{
responseMessage.EnsureSuccessStatusCodeEx();
if (responseMessage.StatusCode == System.Net.HttpStatusCode.NoContent) { return null; }
var contentString = responseMessage.Content.ReadAsStringAsync().Result;
return JsonSerializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>(contentString, jsonSerializerSettings);
}
finally
{
responseMessage.Dispose();
}
}
...
}
Razor 组件
Dashboard.razor
@page "/dashboard"
@inject DemoWebApi.Controllers.Client.Heroes heroesApi
<PageTitle>Dashboard</PageTitle>
<h2>Top Heroes</h2>
@if (heroes == null)
{
<p><em>Loading Heroes...</em></p>
}
else
{
<div class="heroes-menu">
@foreach (var hero in heroes.Take(4))
{
string href = $"/detail/{hero.Id}";
<a href="@href">@hero.Name</a>
}
</div>
}
@code {
private DemoWebApi.Controllers.Client.Hero[]? heroes;
DemoWebApi.Controllers.Client.Heroes? heroesClient;
protected override async Task OnInitializedAsync()
{
heroes = await heroesApi.GetAsyncHeroesAsync();
}
}
英雄列表
@page "/heroes"
@inject DemoWebApi.Controllers.Client.Heroes heroesApi
<PageTitle>List</PageTitle>
<h2>My Heroes</h2>
@if (heroes == null)
{
<p><em>Loading Heroes...</em></p>
}
else
{
<div>
<label for="new-hero">Hero name: </label>
<input id="new-hero" @bind="@newHeroName" />
<button type="button" class="add-button" @onclick="AddAndClear">
Add hero
</button>
</div>
<ul class="heroes">
@foreach (var hero in heroes)
{
string href = $"/detail/{hero.Id}";
<li>
<a href="@href">
<span class="badge">@hero.Id</span> @hero.Name
</a>
<button type="button" class="delete" title="delete hero" @onclick="()=>Delete(hero)">x</button>
</li>
}
</ul>
}
@code {
private List<DemoWebApi.Controllers.Client.Hero> heroes;
private DemoWebApi.Controllers.Client.Hero? selectedHero;
string? newHeroName;
protected override async Task OnInitializedAsync()
{
heroes = new List<DemoWebApi.Controllers.Client.Hero>(await heroesApi.GetHeroesAsync());
}
async Task Add(string name)
{
name = name.Trim();
if (string.IsNullOrEmpty(name))
{
return;
}
var newHero = await heroesApi.PostAsync(name);
this.selectedHero = null;
heroes.Add(newHero);
}
async Task AddAndClear()
{
await Add(newHeroName);
newHeroName = null;
}
async Task Delete(DemoWebApi.Controllers.Client.Hero hero)
{
await heroesApi.DeleteAsync(hero.Id);
heroes.Remove(hero);
if (selectedHero == hero)
{
selectedHero = null;
}
}
}
HttpClient 的依赖注入
虽然 HttpClient 已实现 IDisposable,但根据应用程序宿主的不同,Microsoft 通常建议不要为每个 HTTP 请求实例化然后处置它。而是使用 Blazor 的 依赖注入,这样宿主将管理 HttpClient 实例的生命周期。
本文的示例代码遵循“从 ASP.NET Core Blazor 调用 Web API”的建议,同时利用了命名 HttpClient 和类型化 HttpClient。
命名 HttpClient
Program.cs
builder.Services.AddHttpClient("Core3WebAPI", client => { client.BaseAddress = new Uri("https://:5000"); });
Weather.razor
@page "/weather"
@inject IHttpClientFactory ClientFactory
@inject System.Text.Json.JsonSerializerOptions jsonSerializerOptions
...
@code {
private WebApplication1.Client.WeatherForecast[]? forecasts;
protected override async Task OnInitializedAsync()
{
var client = ClientFactory.CreateClient("Core3WebAPI");
WebApplication1.Controllers.Client.WeatherForecast weatherForecastClient = new WebApplication1.Controllers.Client.WeatherForecast(client, jsonSerializerOptions);
forecasts = (await weatherForecastClient.GetAsync()).ToArray();
}
}
类型化 HttpClient
Program.cs
builder.Services.AddHttpClient<DemoWebApi.Controllers.Client.Heroes>(heroesApiClient =>
{
heroesApiClient.BaseAddress = new Uri("https://:5000");
});
Heroes.razor
@page "/heroes"
@inject DemoWebApi.Controllers.Client.Heroes heroesApi
...
@code {
private List<DemoWebApi.Controllers.Client.Hero> heroes;
private DemoWebApi.Controllers.Client.Hero? selectedHero;
string? newHeroName;
protected override async Task OnInitializedAsync()
{
heroes = new List<DemoWebApi.Controllers.Client.Hero>(await heroesApi.GetHeroesAsync());
}
async Task Add(string name)
{
name = name.Trim();
if (string.IsNullOrEmpty(name))
{
return;
}
var newHero = await heroesApi.PostAsync(name);
this.selectedHero = null;
heroes.Add(newHero);
}
async Task AddAndClear()
{
await Add(newHeroName);
newHeroName = null;
}
async Task Delete(DemoWebApi.Controllers.Client.Hero hero)
{
await heroesApi.DeleteAsync(hero.Id);
heroes.Remove(hero);
if (selectedHero == hero)
{
selectedHero = null;
}
}
}
生成的客户端 API 代码通过 DI 支持类型化 HttpClient。
public partial class Heroes
{
private System.Net.Http.HttpClient client;
private JsonSerializerOptions jsonSerializerSettings;
public Heroes(System.Net.Http.HttpClient client, JsonSerializerOptions jsonSerializerSettings=null)
{
...
.NET 运行时可以向容器类“Heroes”注入一个 HttpClient 实例和一个作用域的 JsonSerializerOptions 实例。
关注点
Web 浏览器中的 HttpClient
根据“从 ASP.NET Core Blazor 调用 Web API”
引用HttpClient 是使用浏览器的 Fetch API 实现的,并受其限制,包括同源策略的强制执行,这将在本文的“跨域资源共享 (CORS)”部分稍后讨论。
除了上述限制外,“System.Net.Http.HttpClient
”在 Web 浏览器中的实际操作还受到 JavaScript 在处理大于 53 位整数时的其他限制的影响,这些限制在以下文章中讨论:
为了支持 Int64(long),Core3WebApi 使用了 `Int64JsonConverter`。
builder.Services.AddJsonOptions(options =>
{
options.JsonSerializerOptions.Converters.Add(new Int64JsonConverter());
Web API 将响应一个 Int64 的 JSON 字符串对象,而不是 JSON 数字对象。
在基于 Blazor WebAssembly 的客户端,也需要相同的转换器类。
builder.Services.AddScoped<JsonSerializerOptions>(sp =>
{
var options = new JsonSerializerOptions
{
PropertyNameCaseInsensitive = true,
};
options.Converters.Add(new Int64JsonConverter()); //Hero.Id is long, 64-bit, exceeding 53-bit precision of JS number
return options;
});
否则,浏览器中会出现运行时错误。
在 WebAssembly 运行时将作用域的 JsonSerializerOptions 注入到命名 HttpClient 和类型化 HttpClient(Heroes 客户端 API)之后,独立应用就可以优雅地处理 Int64,因为您已经享受了使用 Int64 的强类型编码。