开发 ASP.NET 核心 MVC 应用

小窍门

此内容摘自电子书、使用 ASP.NET Core 和 Azure 构建新式 Web 应用程序,可在 .NET Docs 上获取或作为可脱机阅读的免费可下载 PDF。

架构现代 Web 应用程序:使用 ASP.NET Core 和 Azure 的电子书封面缩略图。

“第一次是否正确完成并不重要。 最后一次正确完成才至关重要。”- Andrew Hunt 和 David Thomas

ASP.NET Core 是用于构建新式云优化 Web 应用程序的跨平台开源框架。 ASP.NET Core 应用是轻量级和模块化应用,内置了对依赖项注入的支持,可实现更高的可测试性和可维护性。 除了基于视图的应用外,MVC 还支持构建新式 Web API,ASP.NET Core 是一个强大的框架,用于构建企业 Web 应用程序。

MVC 和 Razor Pages

ASP.NET Core MVC 提供了许多可用于生成基于 Web 的 API 和应用的功能。 MVC 术语代表“Model-View-Controller”,这是一种 UI 模式,用于将响应用户请求的责任分解为多个部分。 除了遵循此模式外,还可以将 ASP.NET Core 应用中的功能实现为 Razor Pages。

Razor Pages 内置于 ASP.NET Core MVC 中,并使用相同的功能进行路由、模型绑定、筛选器、授权等。但是,Razor Pages 不是为控制器、模型、视图等设置单独的文件夹和文件,而是使用基于属性的路由放置在单个文件夹中(“/Pages”)路由中,具体取决于此文件夹中的相对位置,并使用处理程序而不是控制器作处理请求。 因此,在使用 Razor Pages 时,通常你需要的所有文件和类都集中在一起,而不是分散在整个 web 项目中。

详细了解 如何在 eShopOnWeb 示例应用程序中应用 MVC、Razor Pages 和相关模式

创建新的 ASP.NET 核心应用时,应考虑到要构建的应用类型。 在 IDE 或使用 dotnet new CLI 命令创建新项目时,将从多个模板中进行选择。 最常见的项目模板是空、Web API、Web 应用和 Web 应用(模型View-Controller)。 虽然在首次创建项目时只能做出此决定,但这不是不可撤销的决定。 Web API 项目使用标准模型View-Controller 控制器 - 默认情况下仅缺少视图。 同样,默认 Web 应用模板使用 Razor Pages,因此也缺少 Views 文件夹。 可以稍后向这些项目添加一个 Views 文件夹,以支持基于视图的行为。 默认情况下,Web API 和模型View-Controller 项目不包含 Pages 文件夹,但你可以稍后添加一个,以支持基于 Razor Pages 的行为。 可以将这三个模板视为支持三种不同类型的默认用户交互:数据(Web API)、基于页面和基于视图。 但是,如果需要,可以在单个项目中混合和匹配所有这些模板。

为什么是 Razor Pages?

Razor Pages 是 Visual Studio 中新 Web 应用程序的默认方法。 Razor Pages 提供了一种更简单的方式来构建基于页面的应用程序功能,例如非 SPA 窗体。 使用控制器和视图时,应用程序通常具有非常大的控制器,这些控制器处理许多不同的依赖项和视图模型并返回许多不同的视图。 这导致了更复杂的问题,并且通常导致没有有效地遵循单一责任原则或开放/封闭原则的控制器。 Razor Pages 通过将给定逻辑“页面”的服务器端逻辑封装在 Web 应用程序中,并使用其 Razor 标记来解决此问题。 没有服务器端逻辑的 Razor 页只能包含 Razor 文件(例如,“Index.cshtml”)。 但是,大多数重要的 Razor Pages 都有关联的页面模型类,按照惯例,它的名称与带有“.cs”扩展名的 Razor 文件相同(例如,“Index.cshtml.cs”)。

Razor Page 的页面模型结合了 MVC 控制器和 viewmodel 的责任。 而不是通过控制器动作方法处理请求,页面模型处理程序(例如“OnGet()”)默认情况下会被执行,从而呈现其关联的页面。 Razor Pages 简化了在 ASP.NET Core 应用中生成单个页面的过程,同时仍提供 ASP.NET Core MVC 的所有体系结构功能。 对于基于页面的新功能,它们是一个很好的默认选择。

何时使用 MVC

如果要生成 Web API,则 MVC 模式比尝试使用 Razor Pages 更有意义。 如果项目仅公开 Web API 终结点,则最好从 Web API 项目模板开始。 否则,可以轻松地将控制器和关联的 API 终结点添加到任何 ASP.NET Core 应用。 如果要将现有应用程序从 ASP.NET MVC 5 或更早版本迁移到 ASP.NET Core MVC,并且希望以最少的工作量执行此作,请使用基于视图的 MVC 方法。 完成初始迁移后,可以评估是否适合采用 Razor Pages 开发新功能,甚至作为整体迁移。 有关将 .NET 4.x 应用移植到 .NET 8 的详细信息,请参阅 将现有 ASP.NET 应用移植到 ASP.NET Core eBook

无论你是选择使用 Razor Pages 还是 MVC 视图生成 Web 应用,你的应用都具有类似的性能,并且将包括对依赖项注入、筛选器、模型绑定、验证等的支持。

将请求映射到响应

ASP.NET Core 应用程序的核心是将传入请求映射到传出响应。 在低级别,此映射是使用中间件完成的,简单的 ASP.NET 核心应用和微服务可能只包含自定义中间件。 使用 ASP.NET Core MVC 时,可以更高级别地思考 路由控制器。 每个传入请求都与应用程序的路由表进行比较,如果找到匹配的路由,则会调用关联的作方法(属于控制器)来处理请求。 如果未找到匹配的路由,将调用错误处理程序(在这种情况下,返回 NotFound 结果)。

ASP.NET 核心 MVC 应用可以使用传统路由、属性路由或两者兼有。 传统路由在代码中定义,使用语法指定路由 约定 ,如以下示例所示:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});

在此示例中,已将名为“default”的路由添加到路由表中。 它定义一个路由模板,其中包含controlleractionid这三个占位符。 controlleraction占位符具有指定的默认值(Home以及Index,分别),并且id占位符是可选的(通过使用“?” 应用于它)。 此处定义的约定指出,请求的第一部分应对应于控制器的名称,第二部分对应于动作,然后如有必要,第三部分将表示一个 ID 参数。 常规路由通常在应用程序的一个特定文件中定义,例如在 Program.cs 中配置请求中间件管道时。

属性路由直接应用于控制器和动作,而不是全局指定。 在查看特定方法时,此方法的优点是使它们更易于发现,但确实意味着路由信息不会保留在应用程序中的一个位置。 使用属性路由,可以轻松为给定作指定多个路由,并合并控制器和作之间的路由。 例如:

[Route("Home")]
public class HomeController : Controller
{
    [Route("")] // Combines to define the route template "Home"
    [Route("Index")] // Combines to define route template "Home/Index"
    [Route("/")] // Does not combine, defines the route template ""
    public IActionResult Index() {}
}

可以在 [HttpGet] 和类似的属性上指定路由,避免需要添加单独的 [Route] 属性。 属性路由还可以使用令牌来减少重复控制器和操作名称的需求,如下所示:

[Route("[controller]")]
public class ProductsController : Controller
{
    [Route("")] // Matches 'Products'
    [Route("Index")] // Matches 'Products/Index'
    public IActionResult Index() {}
}

Razor Pages 不使用属性路由。 可以作为 Razor Pages 的 @page 指令的一部分为其指定其他路由模板信息:

@page "{id:int}"

在前面的示例中,所讨论的页面将匹配具有整数 id 参数的路由。 例如,位于根目录中的 /Pages 页面将响应如下请求:

/Products/123

一旦给定的请求与路由匹配,但在调用作方法之前,ASP.NET Core MVC 将对请求执行 模型绑定模型验证 。 模型绑定负责将传入的 HTTP 数据转换为指定为要调用的作方法的参数的 .NET 类型。 例如,如果作方法需要参数 int id ,模型绑定将尝试从作为请求的一部分提供的值提供此参数。 为此,模型绑定(model binding)会查找提交的表单中的值、路由本身中的值以及查询字符串中的值。 如果找到id值,它将在传递到操作方法之前被转换为整数。

绑定模型之后然后在调用操作方法之前,将进行模型验证。 模型验证对模型类型使用可选属性,有助于确保提供的模型对象符合某些数据要求。 某些值可以指定为必需值,或限制为特定长度或数值范围等。如果指定了验证属性,但模型不符合其要求,则属性 ModelState.IsValid 将为 false,并且一组失败的验证规则将可用于发送到发出请求的客户端。

如果使用模型验证,则应在执行任何状态更改命令之前,请务必始终检查模型是否有效,以确保应用未损坏无效数据。 可以使用筛选器,以避免在每个操作中添加此验证的代码。 ASP.NET 核心 MVC 筛选器提供了截获请求组的方法,以便可以基于目标应用常见策略和交叉关注点。 筛选器可以应用于单个作、整个控制器,也可以全局应用于应用程序。

对于 Web API,ASP.NET Core MVC 支持 内容协商,允许请求指定应如何设置响应的格式。 根据请求中提供的标头,返回数据的作将以 XML、JSON 或其他受支持的格式格式化响应。 此功能使多个客户端能够使用具有相同数据格式要求的相同 API。

Web API 项目应考虑使用 [ApiController] 可应用于单个控制器、基控制器类或整个程序集的属性。 此属性添加了自动模型验证检查,任何具有无效模型的操作会返回一个包含验证错误详细信息的 BadRequest。 该属性还要求所有作都具有属性路由,而不是使用传统路由,并返回更详细的 ProblemDetails 信息以响应错误。

使控制器处于控制之下

对于基于页面的应用程序,Razor Pages 在有效控制控制器大小方面表现出色。 每个独立页面都有专用于其处理程序的文件和类。 在引入 Razor Pages 之前,许多以视图为中心的应用程序将拥有负责许多不同的作和视图的大型控制器类。 这些类自然会扩展并具有许多职责和依赖项,从而使其难以维护。 如果发现基于视图的控制器变得过大,请考虑将其重构为使用 Razor Pages,或引入转存进程等模式。

中介设计模式用于减少类之间的耦合,同时允许它们之间的通信。 在 ASP.NET Core MVC 应用程序中,此模式通常用于通过使用 处理程序 来执行作方法的工作,将控制器分解成较小的部分。 常用的 MediatR NuGet 包 通常用于实现此目的。 通常,控制器包含许多不同的作方法,其中每个方法可能需要某些依赖项。 任何作所需的所有依赖项集都必须传递到控制器的构造函数中。 使用 MediatR 时,控制器通常拥有的唯一依赖项是中介的实例。 然后,每个作都使用中介实例发送一条消息,该消息由处理程序处理。 处理程序专用于单个操作,因此只需要该操作所需的依赖项。 此处显示了使用 MediatR 的控制器示例:

public class OrderController : Controller
{
    private readonly IMediator _mediator;

    public OrderController(IMediator mediator)
    {
        _mediator = mediator;
    }

    [HttpGet]
    public async Task<IActionResult> MyOrders()
    {
        var viewModel = await _mediator.Send(new GetMyOrders(User.Identity.Name));
        return View(viewModel);
    }
    // other actions implemented similarly
}

MyOrders 操作中,对 SendGetMyOrders 消息的调用由此类处理:

public class GetMyOrdersHandler : IRequestHandler<GetMyOrders, IEnumerable<OrderViewModel>>
{
    private readonly IOrderRepository _orderRepository;
    public GetMyOrdersHandler(IOrderRepository orderRepository)
    {
        _orderRepository = orderRepository;
    }

  public async Task<IEnumerable<OrderViewModel>> Handle(GetMyOrders request, CancellationToken cancellationToken)
    {
        var specification = new CustomerOrdersWithItemsSpecification(request.UserName);
        var orders = await _orderRepository.ListAsync(specification);
        return orders.Select(o => new OrderViewModel
            {
                OrderDate = o.OrderDate,
                OrderItems = o.OrderItems?.Select(oi => new OrderItemViewModel()
                  {
                    PictureUrl = oi.ItemOrdered.PictureUri,
                    ProductId = oi.ItemOrdered.CatalogItemId,
                    ProductName = oi.ItemOrdered.ProductName,
                    UnitPrice = oi.UnitPrice,
                    Units = oi.Units
                  }).ToList(),
                OrderNumber = o.Id,
                ShippingAddress = o.ShipToAddress,
                Total = o.Total()
        });
    }
}

此方法的最终结果是控制器要小得多,主要侧重于路由和模型绑定,而单个处理程序负责给定终结点所需的特定任务。 可以通过使用 ApiEndpoints NuGet 包在没有 MediatR 的情况下实现这种方法,该包尝试为 API 控制器带来与 Razor Pages 为基于视图的控制器带来的相同优势。

引用 - 将请求映射到响应

处理依赖关系

ASP.NET Core 内置支持,并在内部使用称为 依赖项注入的技术。 依赖关系注入是一种在应用程序的不同部分之间实现松散耦合的技术。 松散耦合是可取的,因为它可以更轻松地隔离应用程序的各个部分,从而允许测试或更换。 它还使得应用程序一部分的更改不太可能对应用程序中其他位置产生意外的影响。 依赖关系注入基于依赖项反转原则,并且通常是实现开放/封闭原则的关键。 评估应用程序对其依赖关系的处理方式时,请注意 static cling(静态粘附)这一代码味,并请记住这句格言:新增即粘附

当类调用静态方法或访问对基础结构有副作用或依赖项的静态属性时,会发生静态粘附。 例如,如果你有一个调用静态方法的方法,而静态方法又会写入数据库,则方法与数据库紧密耦合。 破坏该数据库调用的任何内容都会破坏该方法。 测试此类方法非常困难,因为此类测试要么需要商业模拟库来模拟静态调用,要么只能使用测试数据库进行测试。 对基础结构没有任何依赖的静态调用(尤其是那些完全无状态的调用)可以调用,对耦合或可测试性没有影响(除了将代码耦合到静态调用本身之外)。

许多开发人员了解静态粘附和全局状态的风险,但仍会通过直接实例化将代码紧密耦合到特定实现。 “新增即粘附”旨在提醒注意这种耦合,并非一律谴责使用 new 关键字。 与静态方法调用类似,没有外部依赖的类型的新实例通常不会使代码紧密耦合于实现细节,也不会增加测试的难度。 但每次将类实例化时,应花一点时间来考虑在该特定位置硬编码该特定实例是否有意义,或者说如果将该实例作为依赖项进行请求会不会更好。

声明依赖项

ASP.NET Core 是围绕使用方法和类声明其依赖项而构建的,并请求它们作为参数。 ASP.NET 应用程序通常在 Program.cs 或类中 Startup 设置。

注释

Program.cs 中完全配置应用是 .NET 6(及更高版本)和 Visual Studio 2022 应用的默认方法。 项目模板已更新,可帮助你开始使用这一新方法。 ASP.NET Core 项目如果需要,仍然可以使用 Startup 一个类。

Program.cs 中配置服务

对于非常简单的应用程序,你可以在 Program.cs 文件中直接配置依赖项,使用 WebApplicationBuilder 。 添加所有所需的服务后,生成器将用于创建应用。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
builder.Services.AddRazorPages();

var app = builder.Build();

Startup.cs 中配置服务

Startup.cs本身配置为支持多个点的依赖项注入。 如果使用类 Startup ,可以为其提供构造函数,并可以通过该构造函数请求依赖项,如下所示:

public class Startup
{
    public Startup(IHostingEnvironment env)
    {
        var builder = new ConfigurationBuilder()
            .SetBasePath(env.ContentRootPath)
            .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true)
            .AddJsonFile($"appsettings.{env.EnvironmentName}.json", optional: true);
    }
}

Startup 类很有趣,因为它没有显式类型要求。 它不继承自特殊 Startup 基类,也不实现任何特定接口。 您可以为其指定构造函数,也可以不指定,并且可以在构造函数上指定任意数量的参数。 为应用程序配置的 Web 主机启动时,它将调用 Startup 该类(如果已告知其使用一个),并使用依赖项注入来填充类所需的任何依赖项 Startup 。 当然,如果你请求未在 ASP.NET Core 使用的服务容器中配置的参数,则会出现异常,但只要坚持容器知道的依赖项,就可以请求所需的任何内容。

创建启动实例时,依赖项注入从一开始就内置于 ASP.NET Core 应用中。 它不会为 Startup 类在此停留。 还可以在Configure方法中请求依赖项。

public void Configure(IApplicationBuilder app,
    IHostingEnvironment env,
    ILoggerFactory loggerFactory)
{

}

ConfigureServices 方法是此行为的异常;它必须仅采用一个类型 IServiceCollection参数。 它实际上不需要支持依赖项注入,因为一方面它负责将对象添加到服务容器,另一方面,它有权通过 IServiceCollection 参数访问当前配置的所有服务。 因此,您可以在 Startup 类的每个部分中,通过请求所需服务作为参数,或者在 IServiceCollection 中使用 ConfigureServices 的方式,处理在 ASP.NET Core 服务集合中定义的依赖项。

注释

如果需要确保某些服务对你的Startup类可用,可以在IWebHostBuilder调用中使用ConfigureServices及其CreateDefaultBuilder方法对其进行配置。

Startup 类是一个模型,用于构建从控制器到中间件到筛选器到你自己的服务 ASP.NET 核心应用程序的其他部分。 在每个情况下,都应遵循 显式依赖项原则,请求依赖项,而不是直接创建依赖项,并在整个应用程序中利用依赖项注入。 请注意直接实例化实现的位置和方法,尤其是那些涉及基础设施或可能产生副作用的服务和对象。 相较于对针对特定实现类型的引用进行硬编码,最好是使用在应用程序核心中定义并作为参数传递的抽象元素。

构建应用程序

整体式应用程序通常具有单个入口点。 对于 ASP.NET Core Web 应用程序,入口点是 ASP.NET Core Web 项目。 但是,这并不意味着解决方案应只包含一个项目。 将应用程序分解为不同的层次,以便更好地遵循关注点分离原则,非常有用。 分解到不同层,有助于脱离文件夹的局限来分离项目,可帮助实现更好的封装。 使用 ASP.NET Core 应用程序实现这些目标的最佳方法是第 5 章中讨论的清理体系结构的变体。 采用此方法后,应用程序的解决方案将构成 UI、基础结构和 ApplicationCore 的独立库。

除了这些项目,还包括单独的测试项目(第 9 章中讨论了测试)。

应用程序的对象模型和接口应放置在 ApplicationCore 项目中。 此项目将尽可能少的依赖项(且不涉及特定基础结构问题),解决方案中的其他项目将引用它。 需要持久保存的业务实体在 ApplicationCore 项目中定义,与不直接依赖于基础结构的服务一样。

实施详细信息(例如如何执行持久性或通知如何发送给用户)保存在基础结构项目中。 此项目将引用特定于实现的包,例如 Entity Framework Core,但不应在项目外部公开有关这些实现的详细信息。 基础结构服务和存储库应实现 ApplicationCore 项目中定义的接口,其持久性实现负责检索和存储 ApplicationCore 中定义的实体。

ASP.NET 核心 UI 项目负责任何 UI 级别问题,但不应包括业务逻辑或基础结构详细信息。 事实上,理想情况下,它甚至不应该依赖于基础设施项目,确保不会意外引入这两个项目之间的依赖关系。 这可以使用 Autofac 等第三方 DI 容器来实现,这样就可以在每个项目中的模块类中定义 DI 规则。

将应用程序与实现详细信息分离的另一种方法是让应用程序调用微服务(可能部署在单个 Docker 容器中)。 这比在两个项目之间使用 DI 更能实现关注点分离和解耦,但也带来了额外的复杂性。

功能整理

默认情况下,ASP.NET Core 应用程序组织其文件夹结构以包括控制器和视图,并且通常会包含视图模型。 用于支持这些服务器端结构的客户端代码通常单独存储在 wwwroot 文件夹中。 但是,大型应用程序可能会遇到此组织的问题,因为处理任何给定功能通常需要在这些文件夹之间跳转。 随着每个文件夹中文件和子文件夹数量的增加,操作变得越来越困难,需要大量翻页浏览解决方案资源管理器。 此问题的一个解决方案是按 功能 而不是文件类型来组织应用程序代码。 此组织样式通常称为功能文件夹或 功能切片 (另请参阅: 垂直切片)。

ASP.NET 核心 MVC 支持用于此目的的区域。 使用区域,可以在每个区域文件夹中创建单独的控制器和视图文件夹(以及任何关联模型)。 图 7-1 显示了使用 Areas 的示例文件夹结构。

示例区域组织

图 7-1. 示例区域组织

当使用区域时,您必须使用属性来区分控制器并指定它们所属的区域名称。

[Area("Catalog")]
public class HomeController
{}

还需要向您的路由添加区域支持:

app.UseEndpoints(endpoints =>
{
    endpoints.MapControllerRoute(name: "areaRoute", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}");
    endpoints.MapControllerRoute(name: "default", pattern: "{controller=Home}/{action=Index}/{id?}");
});

除了对 Areas 的内置支持外,还可以使用自己的文件夹结构和约定代替属性和自定义路由。 这样,便可以拥有不包含视图、控制器等单独文件夹的功能文件夹,使层次结构更加平整,并更轻松地在单个位置查看每个功能的所有相关文件。 对于 API,文件夹可用于替换控制器,每个文件夹可以包含所有 API 终结点及其关联的 DTO。

ASP.NET Core 使用内置约定类型来控制其行为。 可以修改或替换这些约定。 例如,可以创建一个约定,该约定将根据给定控制器的命名空间自动获取功能名称(这通常与控制器所在的文件夹相关):

public class FeatureConvention : IControllerModelConvention
{
    public void Apply(ControllerModel controller)
    {
        controller.Properties.Add("feature",
        GetFeatureName(controller.ControllerType));
    }

    private string GetFeatureName(TypeInfo controllerType)
    {
        string[] tokens = controllerType.FullName.Split('.');
        if (!tokens.Any(t => t == "Features")) return "";
        string featureName = tokens
            .SkipWhile(t => !t.Equals("features", StringComparison.CurrentCultureIgnoreCase))
            .Skip(1)
            .Take(1)
            .FirstOrDefault();
        return featureName;
    }
}

然后在将 MVC 支持添加到应用程序 ConfigureServices 时(或在 Program.cs中):指定此约定作为选项:

// ConfigureServices
services.AddMvc(o => o.Conventions.Add(new FeatureConvention()));

// Program.cs
builder.Services.AddMvc(o => o.Conventions.Add(new FeatureConvention()));

ASP.NET Core MVC 还使用约定来查找视图。 可以使用自定义约定替代它,以便视图将位于功能文件夹中(使用上述 FeatureConvention 提供的功能名称)。 可以从 MSDN 杂志文章《 适用于 ASP.NET Core MVC 的功能切片》中了解有关此方法的详细信息并下载工作示例。

API 和 Blazor 应用程序

如果应用程序包含一组必须保护的 Web API,则最好将这些 API 配置为与视图或 Razor Pages 应用程序分开的项目。 将 API(尤其是公共 API)与服务器端 Web 应用程序分离有很多好处。 这些应用程序通常具有独特的部署和负载特征。 它们也很可能采用不同的安全机制,标准的表单应用通常使用基于 Cookie 的身份验证,而 API 最常使用基于令牌的身份验证。

此外, Blazor 应用程序(无论是使用 Blazor 服务器还是 BlazorWebAssembly)应生成为单独的项目。 应用程序具有不同的运行时特征和安全模型。 它们可能与服务器端 Web 应用程序(或 API 项目)共享常见类型,这些类型应在通用共享项目中定义。

向 eShopOnWeb 添加 BlazorWebAssembly 管理界面需要添加多个新项目。 Blazor WebAssembly 项目本身,即 BlazorAdmin。 一组新的公共 API 终结点在 BlazorAdmin 项目中定义,并由 PublicApi 使用,且配置为使用基于令牌的身份验证。 这两个项目使用的某些共享类型保存在新 BlazorShared 项目中。

有人可能会问,为什么在已存在一个可用于共享BlazorSharedApplicationCore所需的任何类型的公共PublicApi项目时,还要添加一个单独的BlazorAdmin项目? 答案是,此项目包括应用程序的所有业务逻辑,因此比必要大得多,而且更可能需要在服务器上保持安全。 请记住,任何由BlazorAdmin引用的库将在用户加载Blazor应用程序时下载到他们的浏览器中。

根据应用是否使用 Backends-For-Frontends (BFF) 模式,应用使用的 BlazorWebAssembly API 可能不会与其 Blazor共享其类型 100%。 具体而言,许多不同客户端使用的公共 API 可以定义自己的请求和结果类型,而不是在特定于客户端的共享项目中共享它们。 在 eShopOnWeb 示例中,假设 PublicApi 项目实际上是托管公共 API,因此并非所有请求和响应类型都来自 BlazorShared 项目。

横切关注点

随着应用程序的增长,将交叉关注点分离出来以消除重复和维护一致性变得越来越重要。 ASP.NET 核心应用程序中交叉关注的一些示例包括身份验证、模型验证规则、输出缓存和错误处理,但还有其他许多问题。 ASP.NET 核心 MVC 筛选器 允许在请求处理管道中的某些步骤之前或之后运行代码。 例如,筛选器可以在模型绑定前后、动作执行前后或动作结果前后运行。 还可以使用授权筛选器来控制对管道其余部分的访问。 图 7-2 显示了请求执行如何流经筛选器(如果已配置)。

请求通过授权过滤器、资源过滤器、模型绑定、动作过滤器、动作执行和动作结果转换、异常过滤器、结果过滤器和结果执行进行处理。在响应生成过程中,请求仅由结果过滤器和资源过滤器处理,然后再成为发送到客户端的响应。

图 7-2. 通过筛选器和请求管道来执行请求。

筛选器通常作为属性实现,因此你可以将它们应用于控制器或作(甚至全局)。 以这种方式添加时,在操作级别指定的过滤器会覆盖在控制器级别指定的过滤器(会覆盖全局过滤器)或在其基础之上生成。 例如,[Route] 属性可用于在控制器和动作之间建立路由。 同样,可以在控制器级别配置授权,然后被各操作覆盖,如下所示:

[Authorize]
public class AccountController : Controller
{
    [AllowAnonymous] // overrides the Authorize attribute
    public async Task<IActionResult> Login() {}
    public async Task<IActionResult> ForgotPassword() {}
}

第一个方法是 Login,它使用 [AllowAnonymous] 筛选器(属性)来替代在控制器级别设置的 Authorize 筛选器。 该 ForgotPassword 动作(以及类中没有 AllowAnonymous 属性的任何其他动作)需要认证请求。

筛选器可用于消除 API 常见错误处理策略形式的重复。 例如,典型的 API 策略是对于引用不存在键的请求返回一个 NotFound 响应,而如果模型验证失败,则返回一个 BadRequest 响应。 以下示例演示了以下两个策略:

[HttpPut("{id}")]
public async Task<IActionResult> Put(int id, [FromBody]Author author)
{
    if ((await _authorRepository.ListAsync()).All(a => a.Id != id))
    {
        return NotFound(id);
    }
    if (!ModelState.IsValid)
    {
        return BadRequest(ModelState);
    }
    author.Id = id;
    await _authorRepository.UpdateAsync(author);
    return Ok();
}

不要让你的动作方法变得杂乱无章,充满条件代码。 相反,将策略整合到可根据需要应用的筛选器中。 在此示例中,只要将命令发送到 API,模型验证检查就会发生,可以替换为以下属性:

public class ValidateModelAttribute : ActionFilterAttribute
{
    public override void OnActionExecuting(ActionExecutingContext context)
    {
        if (!context.ModelState.IsValid)
        {
            context.Result = new BadRequestObjectResult(context.ModelState);
        }
    }
}

可以通过包含 ValidateModelAttribute 包将 作为 NuGet 依赖项添加到项目中。 对于 API,可以使用 ApiController 属性来强制实施此行为,而无需单独的 ValidateModel 筛选器。

同样,筛选器可用于检查记录是否存在,并在执行作之前返回 404,而无需在作中执行这些检查。 在将常见约定提取出来、整理解决方案、将基础结构代码和业务逻辑与 UI 分离开后,MVC 操作方法会变得极其精简:

[HttpPut("{id}")]
[ValidateAuthorExists]
public async Task<IActionResult> Put(int id, [FromBody]Author author)
{
    await _authorRepository.UpdateAsync(author);
    return Ok();
}

可以从 MSDN 杂志文章 Real-World ASP.NET 核心 MVC 筛选器阅读有关实现筛选器和下载工作示例的详细信息。

如果您发现根据常见场景,API 有许多常见响应,例如验证错误(错误请求)、资源未找到以及服务器错误,那么可以考虑使用 结果 抽象。 结果抽象将由 API 终结点使用的服务返回,控制器操作或终结点将使用筛选器将这些结果转换为 IActionResults

参考 - 构建应用程序

安全

保护 Web 应用程序是一个大主题,有许多注意事项。 在最基本的级别,安全性涉及确保你知道给定请求来自谁,然后确保请求仅有权访问它应使用的资源。 身份验证是将提供的凭据与受信任数据存储中的凭据进行比较的过程,以确定请求是否应被视为来自已知实体。 授权是基于用户标识限制对某些资源的访问的过程。 第三个安全问题是防止第三方窃听请求,至少应 确保应用程序使用 SSL

身份

ASP.NET 核心标识是一种成员身份系统,可用于支持应用程序的登录功能。 它支持本地用户帐户,以及来自如 Microsoft Account、Twitter、Facebook、Google 等提供商的外部登录服务提供商支持。 除了 ASP.NET 核心标识,应用程序还可以使用 Windows 身份验证或第三方标识提供者(如 标识服务器)。

如果选择了“单个用户帐户”选项,则新项目模板中将包括 ASP.NET 核心标识。 此模板包括对注册、登录、外部登录、忘记的密码和其他功能的支持。

选择要预配置标识的单个用户帐户

图 7-3. 选择单个用户帐户以预配置身份信息。

标识支持在Program.csStartup中配置,包括配置服务和中间件。

Program.cs 中配置标识

Program.cs中,从实例配置服务 WebHostBuilder ,然后在创建应用后,配置其中间件。 需要注意的要点是对AddDefaultIdentity所需服务的调用,以及UseAuthenticationUseAuthorization的调用,这些调用用于添加所需的中间件。

var builder = WebApplication.CreateBuilder(args);

// Add services to the container.
var connectionString = builder.Configuration.GetConnectionString("DefaultConnection");
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(connectionString));
builder.Services.AddDatabaseDeveloperPageExceptionFilter();

builder.Services.AddDefaultIdentity<IdentityUser>(options => options.SignIn.RequireConfirmedAccount = true)
    .AddEntityFrameworkStores<ApplicationDbContext>();
builder.Services.AddRazorPages();

var app = builder.Build();

// Configure the HTTP request pipeline.
if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
  app.UseExceptionHandler("/Error");
  // The default HSTS value is 30 days. You may want to change this for production scenarios, see https://aka.ms/aspnetcore-hsts.
  app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

app.Run();

在应用启动时配置标识

// Add framework services.
builder.Services.AddDbContext<ApplicationDbContext>(options =>
    options.UseSqlServer(Configuration.GetConnectionString("DefaultConnection")));
builder.Services.AddIdentity<ApplicationUser, IdentityRole>()
    .AddEntityFrameworkStores<ApplicationDbContext>()
    .AddDefaultTokenProviders();
builder.Services.AddMvc();

var app = builder.Build();

if (app.Environment.IsDevelopment())
{
    app.UseMigrationsEndPoint();
}
else
{
    app.UseExceptionHandler("/Error");
    app.UseHsts();
}

app.UseHttpsRedirection();
app.UseStaticFiles();

app.UseRouting();

app.UseAuthentication();
app.UseAuthorization();

app.MapRazorPages();

重要的是, UseAuthenticationUseAuthorization 出现在之前 MapRazorPages。 配置标识服务时,将注意到对 AddDefaultTokenProviders 的调用。 这与可用于保护 Web 通信的令牌无关,而是指创建提示的提供程序,这些提供程序可以通过短信或电子邮件发送给用户,以便他们确认其身份。

可以了解有关 配置双因素身份验证 以及从官方 ASP.NET Core 文档 启用外部登录提供程序 的详细信息。

身份验证

身份验证是确定谁访问系统的过程。 如果使用 ASP.NET Core Identity 和上一部分中所示的配置方法,它将在应用程序中自动配置某些身份验证默认值。 但是,也可以手动配置这些默认值,或替代 AddIdentity 设置的默认值。 如果您使用 Identity,它会将基于 Cookie 的身份验证配置为默认的方案

在基于 Web 的身份验证中,在对系统的客户端进行身份验证的过程中,通常最多可以执行五个作。 其中包括:

  • 身份验证。 使用客户端提供的信息创建一个标识,供他们在应用程序中使用。
  • 质询。 此操作用于要求客户端进行身份识别。
  • 禁止。 告知客户端禁止执行操作。
  • 登录。 以某种方式保留现有客户端。
  • 退出登录。将客户端从持久化存储中删除。

在 Web 应用程序中执行身份验证有许多常见技术。 这些称为方案。 给定方案将定义某些或全部这些选项的操作。 某些方案仅支持部分动作,并且可能需要单独的方案来完成它不支持的动作。 例如,OpenId-Connect(OIDC)方案不支持登录或注销,但通常配置为使用此持久性的 Cookie 身份验证。

在 ASP.NET Core 应用程序中,您可以为上述每个操作配置一个 DefaultAuthenticateScheme 以及可选的特定方案。 例如,DefaultChallengeSchemeDefaultForbidScheme。 调用 AddIdentity 将配置应用程序的多个方面,并添加许多必需的服务。 它还包括一个调用函数来配置身份验证方案:

builder.Services.AddAuthentication(options =>
{
    options.DefaultAuthenticateScheme = IdentityConstants.ApplicationScheme;
    options.DefaultChallengeScheme = IdentityConstants.ApplicationScheme;
    options.DefaultSignInScheme = IdentityConstants.ExternalScheme;
});

默认情况下,这些方案使用 Cookie 进行持久性和重定向到登录页进行身份验证。 这些方案适用于通过 Web 浏览器与用户交互的 Web 应用程序,但不建议用于 API。 相反,API 通常会使用另一种形式的身份验证,例如 JWT 持有者令牌。

Web API 基于代码使用,例如 .NET 应用程序中的 HttpClient 和其他框架中的等效类型。 这些客户端需要来自 API 调用的可用响应,或指示发生了什么(如果有)问题的状态代码。 这些客户端不会通过浏览器交互,也不会呈现或与 API 可能返回的任何 HTML 进行交互。 因此,如果 API 终结点未进行身份验证,则不适合将客户端重定向到登录页。 另一个方案更合适。

若要为 API 配置身份验证,可以像在 eShopOnWeb 参考应用程序中由 PublicApi 项目使用的那样设置身份验证:

builder.Services
    .AddAuthentication(config =>
    {
      config.DefaultScheme = JwtBearerDefaults.AuthenticationScheme;
    })
    .AddJwtBearer(config =>
    {
        config.RequireHttpsMetadata = false;
        config.SaveToken = true;
        config.TokenValidationParameters = new TokenValidationParameters
        {
            ValidateIssuerSigningKey = true,
            IssuerSigningKey = new SymmetricSecurityKey(key),
            ValidateIssuer = false,
            ValidateAudience = false
        };
    });

虽然可以在单个项目中配置多个不同的身份验证方案,但配置单个默认方案要简单得多。 出于此原因,eShopOnWeb 引用应用程序将其 API 划分到自己的项目 PublicApi 中,与包含应用程序视图和 Razor Pages 的主 Web 项目分开。

Blazor 应用中的身份验证

Blazor 服务器应用程序可以利用与任何其他 ASP.NET Core 应用程序相同的身份验证功能。 Blazor WebAssembly 但是,应用程序无法使用内置标识和身份验证提供程序,因为它们在浏览器中运行。 Blazor WebAssembly 应用程序可以在本地存储用户身份验证状态,并且可以访问声明信息以确定用户应能够执行的操作。 但是,无论应用内 BlazorWebAssembly 实现的任何逻辑,都应在服务器上执行所有身份验证和授权检查,因为用户可以轻松绕过应用并直接与 API 交互。

引用 - 身份验证

授权

最简单的授权形式涉及限制对匿名用户的访问。 通过将[Authorize]属性应用于某些控制器或操作,可以实现此功能。 如果使用角色,则可以进一步扩展该属性以限制对属于特定角色的用户的访问,如下所示:

[Authorize(Roles = "HRManager,Finance")]
public class SalaryController : Controller
{

}

在这种情况下,属于 HRManagerFinance 角色(或两者)的用户将有权访问 SalaryController。 若要要求用户属于多个角色(而不仅仅是多个角色之一),可以多次应用该属性,每次指定一个所需角色。

在许多不同的控制器和作中将某些角色集指定为字符串可能会导致不必要的重复。 至少为这些字符串文本定义常量,并在任何需要指定这些字符串的地方使用这些常量。 还可以配置授权策略,该策略封装授权规则,然后在应用 [Authorize] 属性时指定策略而不是单个角色:

[Authorize(Policy = "CanViewPrivateReport")]
public IActionResult ExecutiveSalaryReport()
{
    return View();
}

以这种方式使用策略,可以将受限制的作类型与应用于策略的特定角色或规则分开。 稍后,如果创建需要访问某些资源的新角色,则只需更新策略,而不是更新每个属性上每个 [Authorize] 角色的列表。

申请

声明是名称值对,代表已通过身份验证的用户的属性。 例如,可以将用户的员工编号存储为声明。 然后,可以将声明用作授权策略的一部分。 可以创建一个名为“EmployeeOnly”的策略,该策略要求存在一个声明,即 "EmployeeNumber",如下例所示:

public void ConfigureServices(IServiceCollection services)
{
    services.AddMvc();
    services.AddAuthorization(options =>
    {
        options.AddPolicy("EmployeeOnly", policy => policy.RequireClaim("EmployeeNumber"));
    });
}

然后,此策略可以与 [Authorize] 属性一起使用,以保护任何控制器和/或操作,如上所述。

保护 Web API

大多数 Web API 应实现基于令牌的身份验证系统。 令牌认证是无状态的,旨在实现扩展性。 在基于令牌的身份验证系统中,客户端必须先使用身份验证提供程序进行身份验证。 如果成功,客户端将颁发一个令牌,该令牌只是一个加密有意义的字符字符串。 令牌的最常见格式是 JSON Web 令牌或 JWT(通常发音为“jot”)。 客户端随后需要向 API 发出请求时,它会将此令牌添加为请求上的标头。 然后,服务器在完成请求之前验证请求标头中找到的令牌。 图 7-4 演示了此过程。

TokenAuth

图 7-4. Web API 的基于令牌的身份验证。

可以创建自己的身份验证服务、与 Azure AD 和 OAuth 集成,或使用 IdentityServer 等开源工具实现服务。

JWT 令牌可以嵌入有关用户(可在客户端或服务器上读取)的声明。 可以使用 jwt.io 等工具查看 JWT 令牌的内容。 不要将敏感数据(如密码或密钥)存储在 JTW 令牌中,因为它们的内容很容易读取。

将 JWT 令牌用于 SPA 或 BlazorWebAssembly 应用程序时,必须在客户端上某个位置存储令牌,然后将其添加到每个 API 调用。 此活动通常作为标头完成,如以下代码所示:

// AuthService.cs in BlazorAdmin project of eShopOnWeb
private async Task SetAuthorizationHeader()
{
      var token = await GetToken();
      _httpClient.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
}

调用上述方法后,使用 _httpClient 请求发出的请求将包含嵌入在请求标头中的令牌,从而允许服务器端 API 对请求进行身份验证和授权。

自定义安全性

谨慎

一般情况下,请避免实现自己的自定义安全实现。

要特别注意加密、用户成员身份或令牌生成系统的“自己回滚”实现。 有许多商业和开源替代项可用,这几乎肯定会比自定义实现更好的安全性。

引用 - 安全性

客户端通信

除了通过 Web API 提供页面和响应数据请求之外,ASP.NET Core 应用还可以直接与连接的客户端通信。 这种出站通信可以使用各种传输技术,最常见的是 WebSocket。 ASP.NET Core SignalR 是一个库,可用于向应用程序添加实时服务器到客户端通信功能。 SignalR 支持各种传输技术,包括 WebSocket,并从开发人员中抽象掉许多实现细节。

实时客户端通信(无论是直接使用 WebSocket 还是其他技术)在各种应用程序方案中都很有用。 一些示例包括:

  • 实时聊天室应用程序

  • 监控应用程序

  • 作业进度更新

  • 通知

  • 交互式表单应用程序

在应用程序中生成客户端通信时,通常有两个组件:

  • 服务器端连接管理器 (SignalR Hub, WebSocketManager WebSocketHandler)

  • 客户端库

客户端不限于浏览器 - 移动应用、控制台应用和其他本机应用也可以使用 SignalR/WebSocket 进行通信。 下面的简单程序是 WebSocketManager 示例应用程序的一部分,它向控制台回显发送给聊天应用程序的所有内容:

public class Program
{
    private static Connection _connection;
    public static void Main(string[] args)
    {
        StartConnectionAsync();
        _connection.On("receiveMessage", (arguments) =>
        {
            Console.WriteLine($"{arguments[0]} said: {arguments[1]}");
        });
        Console.ReadLine();
        StopConnectionAsync();
    }

    public static async Task StartConnectionAsync()
    {
        _connection = new Connection();
        await _connection.StartConnectionAsync("ws://localhost:65110/chat");
    }

    public static async Task StopConnectionAsync()
    {
        await _connection.StopConnectionAsync();
    }
}

考虑应用程序直接与客户端应用程序通信的方式,并考虑实时通信是否会改善应用的用户体验。

参考 - 客户端通信

域驱动设计 - 应该应用它吗?

Domain-Driven 设计(DDD)是一种敏捷方法,用于构建侧重于 业务领域的软件。 它非常强调与能够向开发人员解释实际系统如何运行的业务领域专家进行沟通和互动。 例如,如果你正在构建一个处理股票交易的系统,你的域专家可能是一个经验丰富的股票经纪人。 DDD 旨在解决大型、复杂的业务问题,通常不适合更小、更简单的应用程序,因为对理解和建模域的投资并不值得。

在采用 DDD 方法构建软件时,你的团队(包括非技术利益干系人和参与者)应该为问题空间开发 一种无处不在的语言 。 也就是说,应将相同的术语用于正在建模的实际概念、软件等效项以及可能存在以保留概念的任何结构(例如数据库表)。 因此,无处不在语言中描述的概念应构成 域模型的基础。

域模型包含相互交互的对象,以表示系统的行为。 这些对象可能属于以下类别:

  • 实体,表示具有标识线程的对象。 实体通常使用一个密钥存储在持久存储中,以便之后可以检索这些实体。

  • 聚合,表示应保留为单元的对象组。

  • 值对象,表示可以根据属性值之和进行比较的概念。 例如,包含开始日期和结束日期的 DateRange。

  • 域事件,表示系统中对系统的其他部分感兴趣的事件。

DDD 域模型应在模型中封装复杂行为。 特别是实体不应只是属性的集合。 当域模型缺少行为并且只是表示系统的状态时,据说它是一个贫血模型,在 DDD 中这是不受欢迎的。

除这些模型类型外,DDD 通常采用各种模式:

  • 存储库,用于抽象持久性详细信息。

  • 工厂,用于封装复杂对象创建。

  • 用于封装复杂行为和/或基础结构实现详细信息的服务。

  • 命令,用于分离发出命令和执行命令本身。

  • 规约模式,用于封装查询细节。

DDD 还建议使用前面讨论的清理体系结构,从而允许使用单元测试轻松验证松散耦合、封装和代码。

何时应应用 DDD

DDD 非常适合具有重大业务(而不仅仅是技术)复杂性的大型应用程序。 应用程序应要求具备域专家的知识。 域模型本身应该有重大行为,表示业务规则和交互,而不仅仅是存储和检索数据存储中各种记录的当前状态。

何时不应应用 DDD

DDD 涉及建模、体系结构和通信方面的投资,这些投资对于本质上只是 CRUD(创建/读取/更新/删除)的较小应用程序或应用程序来说可能不需要这些投资。 如果选择采用 DDD 处理应用程序,但发现域中有一个没有任何行为的贫乏性模型,则可能需要重新考虑处理方法。 应用程序可能不需要 DDD,或者可能需要帮助重构应用程序,以便将业务逻辑封装到域模型中,而不是在数据库或用户界面中。

一种混合方法是仅使用 DDD 于应用程序的事务性或较复杂区域,而不用于应用程序中较简单的 CRUD 或只读部分。 例如,如果要查询数据以显示报表或可视化仪表板的数据,则不需要聚合的约束。 对于此类要求,拥有单独的更简单的读取模型是完全可以接受的。

参考 - 域驱动设计

部署

无论您将在哪里托管 ASP.NET Core 应用程序,在其部署过程中都需要执行几个步骤。 第一步是发布应用程序,这可以通过使用 dotnet publish CLI 命令来完成。 此步骤将编译应用程序,并将运行应用程序所需的所有文件放入指定的文件夹中。 从 Visual Studio 部署时,会自动执行此步骤。 发布文件夹包含应用程序的 .exe 和 .dll 文件及其依赖项。 独立应用程序还将包含 .NET 运行时的版本。 ASP.NET 核心应用程序还包括配置文件、静态客户端资产和 MVC 视图。

ASP.NET 核心应用程序是当服务器启动时必须启动的控制台应用程序,如果应用程序(或服务器)崩溃,则必须重新启动这些应用程序。 进程管理器可用于自动执行此过程。 ASP.NET Core 的最常见进程管理器是 Nginx 和 Apache on Linux 和 IIS 或 Windows 上的 Windows 服务。

除了进程管理器,ASP.NET 核心应用程序还可以使用反向代理服务器。 反向代理服务器从 Internet 接收 HTTP 请求,并在进行初步处理后将其转发到 Kestrel。 反向代理服务器为应用程序提供一层安全性。 Kestrel 也不支持在同一端口上托管多个应用程序,因此主机标头等技术不能与它一起使用,以便在同一端口和 IP 地址上托管多个应用程序。

Kestrel 到 Internet

图 7-5. 反向代理服务器背后托管在 Kestrel 中的 ASP.NET

另一种情况下,反向代理可以帮助使用 SSL/HTTPS 保护多个应用程序。 在这种情况下,只有反向代理需要配置 SSL。 反向代理服务器和 Kestrel 之间的通信可以通过 HTTP 进行,如图 7-6 所示。

ASP.NET 托管在 HTTPS 保护的反向代理服务器后面

图 7-6. ASP.NET 托管在 HTTPS 保护的反向代理服务器后面

越来越受欢迎的方法是在 Docker 容器中托管 ASP.NET Core 应用程序,然后可以在本地托管或部署到 Azure 进行基于云的托管。 Docker 容器可以包含应用程序代码,在 Kestrel 上运行,并将部署在反向代理服务器后面,如下所示。

如果要在 Azure 上托管应用程序,可以使用 Microsoft Azure 应用程序网关作为专用虚拟设备来提供服务。 除了充当单个应用程序的反向代理外,应用程序网关还可以提供以下功能:

  • HTTP 负载均衡

  • SSL 卸载(仅到 Internet 的 SSL)

  • 端到端 SSL

  • 多站点路由(在单个应用程序网关上合并最多 20 个站点)

  • Web 应用程序防火墙

  • Websocket 支持

  • 高级诊断

第 10 章中详细了解 Azure 部署选项。

参考 - 部署