目录
背景
“英雄之旅”是Angular 2+ 的官方教程应用程序。该应用程序包含一些功能特性和技术特性,这些特性在构建实际业务应用程序时很常见:
- 显示表和嵌套数据的几个屏幕
- 数据绑定
- 导航
- 通过后端进行CRUD操作,也可以通过生成的客户端API进行操作
- 单元测试和集成测试
在本系列文章中,我将演示各种前端开发平台在构建相同功能特性时的程序员体验:“英雄之旅”,一个胖客户端与后端交谈。
Angular、Aurelia、React、Vue、Xamarin和MAUI上的前端应用通过生成的客户端API与相同的ASP.NET(Core)后端通信。要查找同系列的其他文章,请在我的文章中搜索“英雄之旅”。在本系列的最后,将讨论程序员体验的一些技术因素:
- 计算机科学
- 软件工程
- 学习曲线
- 构建大小
- 运行时性能
- 调试
选择开发平台涉及许多非技术因素,本系列将不讨论这些因素。
介绍
本文重点介绍 MAUI。
开发平台:
- ASP.NET Core 8
- .NET多平台应用程序UI
演示仓库
在GitHub中查看DemoCoreWeb,并专注于以下方面:
ASP.NET Core Web API csproj仅提供Web API。
此文件夹包含一个MAUI应用程序(Fonlow.Heroes.Maui.csproj),用于重新组合“英雄之旅”的功能。
- Fonlow.Heroes.Maui
- Fonlow.Heroes.ViewModels
- Fonlow.Heroes.View
- CoreWebApi.ClientApi
言论
DemoCoreWeb的建立是为了测试WebApiClientGen for .NET 的NuGet包,并演示如何在实际项目中使用这些库。
使用代码
先决条件
- Core3WebApi.csproj 已导入NuGet包Fonlow.WebApiClientGenCore。
- 将CodeGenController.cs添加到Core3WebApi.csproj。
- Core3WebApi.csproj包含CodeGen.json。这是可选的,只是为了方便运行一些PowerShell脚本来生成客户端API。
- CreateWebApiClientApi3.ps1。这是可选的。此脚本将在IIS Express上启动Web API,并在CodeGen.json中发布数据。
摘要
根据您的CI/CD流程,您可以调整上述第3项和第4项。
生成客户端API
运行 CreateWebApiClientApi3.ps1,生成的代码将写入 CoreWebApi.ClientApi。
数据模型和API函数
namespace DemoWebApi.Controllers.Client
{
/// <summary>
/// Complex hero type
/// </summary>
public class Hero : object
{
public long Id { get; set; }
public string Name { get; set; }
}
}
MAUI客户端应用中使用的大多数数据模型都来自生成的客户端API库。
public partial class Heroes
{
private System.Net.Http.HttpClient client;
private JsonSerializerSettings jsonSerializerSettings;
public Heroes(System.Net.Http.HttpClient client,
JsonSerializerSettings jsonSerializerSettings=null)
{
if (client == null)
throw new ArgumentNullException("Null HttpClient.", "client");
if (client.BaseAddress == null)
throw new ArgumentNullException
("HttpClient has no BaseAddress", "client");
this.client = client;
this.jsonSerializerSettings = jsonSerializerSettings;
}
/// <summary>
/// DELETE api/Heroes/{id}
/// </summary>
public async Task DeleteAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Delete, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Get all heroes.
/// GET api/Heroes
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero[]> GetAsync()
{
var requestUri = "api/Heroes";
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Get a hero.
/// GET api/Heroes/{id}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> GetAsync(long id)
{
var requestUri = "api/Heroes/"+id;
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// POST api/Heroes?name={name}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> PostAsync(string name)
{
var requestUri = "api/Heroes?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Add a hero
/// POST api/Heroes/q?name={name}
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero>
PostWithQueryAsync(string name)
{
var requestUri = "api/Heroes/q?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Post, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader = new JsonTextReader
(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Update hero.
/// PUT api/Heroes
/// </summary>
public async Task<DemoWebApi.Controllers.Client.Hero> PutAsync
(DemoWebApi.Controllers.Client.Hero hero)
{
var requestUri = "api/Heroes";
using (var httpRequestMessage = new HttpRequestMessage
(HttpMethod.Put, requestUri))
{
using (var requestWriter = new System.IO.StringWriter())
{
var requestSerializer = JsonSerializer.Create(jsonSerializerSettings);
requestSerializer.Serialize(requestWriter, hero);
var content = new StringContent
(requestWriter.ToString(), System.Text.Encoding.UTF8, "application/json");
httpRequestMessage.Content = content;
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
/// <summary>
/// Search heroes
/// GET api/Heroes/search?name={name}
/// </summary>
/// <param name="name">keyword contained in hero name.</param>
/// <returns>Hero array matching the keyword.</returns>
public async Task<DemoWebApi.Controllers.Client.Hero[]> SearchAsync(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = await client.SendAsync(httpRequestMessage);
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = await responseMessage.Content.ReadAsStreamAsync();
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
/// <summary>
/// Search heroes
/// GET api/Heroes/search?name={name}
/// </summary>
/// <param name="name">keyword contained in hero name.</param>
/// <returns>Hero array matching the keyword.</returns>
public DemoWebApi.Controllers.Client.Hero[] Search(string name)
{
var requestUri = "api/Heroes/search?name="+
(name == null ? "" : Uri.EscapeDataString(name));
using (var httpRequestMessage =
new HttpRequestMessage(HttpMethod.Get, requestUri))
{
var responseMessage = client.SendAsync(httpRequestMessage).Result;
try
{
responseMessage.EnsureSuccessStatusCodeEx();
var stream = responseMessage.Content.ReadAsStreamAsync().Result;
using (JsonReader jsonReader =
new JsonTextReader(new System.IO.StreamReader(stream)))
{
var serializer = new JsonSerializer();
return serializer.Deserialize<DemoWebApi.Controllers.Client.Hero[]>
(jsonReader);
}
}
finally
{
responseMessage.Dispose();
}
}
}
}
视图模型
视图模型包含在Fonlow.Heroes.ViewModels.csproj中。
Fonlow.HeroesVM.HeroesVM将被多个视图使用。
namespace Fonlow.Heroes.VM
{
public class HeroesVM : INotifyPropertyChanged
{
public HeroesVM()
{
DeleteCommand = new Command<long>(DeleteHero);
SearchCommand = new Command<string>(Search);
}
public void Load(IEnumerable<Hero> items)
{
Items = new ObservableCollection<Hero>(items);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
public event PropertyChangedEventHandler PropertyChanged;
public ObservableCollection<Hero> Items { get; private set; }
public IEnumerable<Hero> Top4
{
get
{
if (Items == null)
{
return null;
}
return Items.Take(4);
}
}
Hero selected;
public Hero Selected
{
get { return selected; }
set
{
selected = value;
NotifyPropertyChanged("Selected");
NotifyPropertyChanged("AllowEdit");
}
}
public int Count
{
get
{
if (Items == null)
{
return 0;
}
return Items.Count;
}
}
public void NotifyPropertyChanged
([System.Runtime.CompilerServices.CallerMemberName] string propertyName = "")
{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
public ICommand DeleteCommand { get; private set; }
public ICommand SearchCommand { get; private set; }
async void DeleteHero(long id)
{
var first = Items.FirstOrDefault(d => d.Id == id);
if (first != null)
{
if (first.Id == Selected?.Id)
{
Selected = null;
}
await HeroesFunctions.DeleteAsync(id);
Items.Remove(first);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
public bool AllowEdit
{
get
{
return Selected != null;
}
}
async void Search(string keyword)
{
var r = await HeroesFunctions.SearchAsync(keyword);
Items = new ObservableCollection<Hero>(r);
NotifyPropertyChanged("Items");
NotifyPropertyChanged("Count");
}
}
}
视图
编辑
HeroDetailPage.xaml
<?xml version="1.0" encoding="utf-8" ?>
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroDetailPage">
<ContentPage.Content>
<StackLayout>
<Label Text="{Binding Name, StringFormat='{0} Details'}"
VerticalOptions="CenterAndExpand"
HorizontalOptions="CenterAndExpand" />
<Label Text="ID:"></Label>
<Entry Text="{Binding Id}" Placeholder="ID"></Entry>
<Label Text="Name:"></Label>
<Entry Text="{Binding Name}" Placeholder="Name"></Entry>
<Button Text="Save" Clicked="Save_Clicked"/>
</StackLayout>
</ContentPage.Content>
</ContentPage>
HeroDetailPage.xaml.cs(代码后面)
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroDetailPage : ContentPage
{
public HeroDetailPage(long heroId)
{
InitializeComponent();
BindingContext = VM.HeroesFunctions.LoadHero(heroId);
}
Hero Model
{
get
{
return BindingContext as Hero;
}
}
private async void Save_Clicked(object sender, EventArgs e)
{
await VM.HeroesFunctions.SaveAsync(Model);
}
}
}
英雄列表
HeroesView.xaml
<?xml version="1.0" encoding="UTF-8"?>
<ContentView xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
x:Class="Fonlow.Heroes.Views.HeroesView"
xmlns:vmNS="clr-namespace:Fonlow.Heroes.VM;
assembly=Fonlow.Heroes.ViewModels"
x:Name="heroesView"
>
<ContentView.BindingContext>
<vmNS:HeroesVM/>
</ContentView.BindingContext>
<ContentView.Content>
<StackLayout>
<Label Text="My Heroes"/>
<Entry Placeholder="New Hero Name" Completed="Entry_Completed"/>
<ListView x:Name="HeroesListView" ItemsSource="{Binding Items}"
Header="Selected Heroes" Footer="{Binding Count, StringFormat='Total: {0}'}"
SelectedItem="{Binding Selected}"
ItemSelected="HeroesListView_ItemSelected"
>
<ListView.ItemTemplate>
<DataTemplate>
<ViewCell>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="50" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="50" />
</Grid.ColumnDefinitions>
<Label Text="{Binding Id}" Grid.Column="0"
TextColor="Yellow" BackgroundColor="SkyBlue"/>
<Label Text="{Binding Name}" Grid.Column="1"/>
<Button x:Name="DeleteButton" Text="X" Grid.Column="2"
Command="{Binding Source={x:Reference heroesView},
Path=BindingContext.DeleteCommand}"
CommandParameter="{Binding Id}"
/>
</Grid>
</ViewCell>
</DataTemplate>
</ListView.ItemTemplate>
</ListView>
<Label Text="{Binding Selected.Name, StringFormat='{0} is my hero'}"/>
<Button Text="View Details" Clicked="Edit_Clicked"
IsEnabled="{Binding AllowEdit}"></Button>
</StackLayout>
</ContentView.Content>
</ContentView>
视图绑定到视图模型Fonlow.Heroes.VM.HeroesVM,视觉组件与视图模型的数据和函数具有绑定。
HeroesView.xaml.cs
namespace Fonlow.Heroes.Views
{
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
HeroesVM Model
{
get
{
return BindingContext as HeroesVM;
}
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
private void HeroesListView_ItemSelected
(object sender, SelectedItemChangedEventArgs e)
{
System.Diagnostics.Debug.WriteLine(e.SelectedItem == null);
}
private async void Entry_Completed(object sender, EventArgs e)
{
var text = ((Entry)sender).Text;
var hero= await HeroesFunctions.AddAsync(text);
Model.Items.Add(hero);
}
}
}
后面的代码也可以访问视图模型。
导航
导航在JavaScript SPA库和框架中通常称为路由。
[XamlCompilation(XamlCompilationOptions.Compile)]
public partial class HeroesView : ContentView
{
public HeroesView()
{
InitializeComponent();
}
async void Edit_Clicked(object sender, EventArgs e)
{
await Navigation.PushAsync(new HeroDetailPage(Model.Selected.Id));
}
导航通常在后面的代码中实现。
集成测试
由于“Tour of Heroes”的前端是一个胖客户端,因此集成测试的很大一部分是针对后端的。
using Fonlow.Testing;
using Xunit;
namespace IntegrationTests
{
public class HeroesFixture : DefaultHttpClient
{
public HeroesFixture()
{
Api = new DemoWebApi.Controllers.Client.Heroes(base.HttpClient);
}
public DemoWebApi.Controllers.Client.Heroes Api { get; private set; }
}
[Collection(TestConstants.IisExpressAndInit)]
public partial class HeroesApiIntegration : IClassFixture<HeroesFixture>
{
public HeroesApiIntegration(HeroesFixture fixture)
{
api = fixture.Api;
}
readonly DemoWebApi.Controllers.Client.Heroes api;
[Fact]
public async void TestGetAsyncHeroes()
{
var array = await api.GetAsync();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroes()
{
var array = api.Get();
Assert.NotEmpty(array);
}
[Fact]
public void TestGetHeroNotExists()
{
DemoWebApi.Controllers.Client.Hero h = api.Get(99999);
Assert.Null(h);
}
[Fact]
public void TestPost()
{
var hero = api.Post("Abc");
Assert.Equal("Abc", hero.Name);
}
[Fact]
public void TestPostWithQuery()
{
var hero = api.PostWithQuery("Xyz");
Assert.Equal("Xyz", hero.Name);
}
}
}
建议生成的客户端API代码保留在其自己的csproj项目中,因为这样做的好处:
- 便于为前端代码的不同层制作集成测试。
- 便于对服务和客户端API代码进行版本控制。
- 从特定于域的静态代码分析中排除生成的代码。
- 将生成的代码与手工制作的代码隔离开来,以便您更准确地了解应用程序代码的大小和复杂性。
兴趣点
通过WebApiClientGen,客户端数据模型几乎是100%与服务数据模型的一对一映射,因此作为应用程序程序员的您将享受.NET提供的丰富的数据类型约束。例如,数值类型 sbyte、byte、short、ushort、int、uint、long、ulong、nint和nuint 也映射到客户端数据类型。这是构建企业应用程序的福音,因为.NET设计时和运行时可以保护你。
在.NET编程中,WPF、Xamarin和MAUI通过内置的MVVM体系结构提供不错的程序员体验。在Web前端开发中,尤其是使用SPA时,最接近程序员的体验是通过Angular及其Reactive Forms。
Xamarin与MAUI
Xamarin支持将于 2024年5月1日结束,适用于所有Xamarin SDK,包括Xamarin.Forms。Android API 34和Xcode 15 SDK(iOS和iPadOS 17、macOS 14)将是Xamarin从现有Xamarin SDK面向的最终版本(即,没有计划新的API)。
该示例是通过将 Xamarin应用迁移到MAUI来创建的。
差异
- 在Xamarin上,需要为每个平台创建特定于平台的应用程序项目:Android、iOS或Windows。在MAUI上,通常只需要一个应用程序项目。
- 在Xamarin.Forms上,XAML的默认命名空间为“http://xamarin.com/schemas/2014/forms”。在MAUI上,“http://schemas.microsoft.com/dotnet/2021/maui”。不过,升级向导应该能够为您进行替换。
- 在Xamarin上,应基于.NET Standard构建与平台无关的库。在MAUI上,它是.NET(Core)。但是,如果某些第三方组件仍保留在.NET Standard中,则MAUI可以很好地链接这些库。
https://www.codeproject.com/Articles/5373931/Tour-of-Heroes-MAUI-with-ASP-NET-Core-8-Backend