常用 Web 应用程序体系结构
“如果你认为好的体系结构很昂贵,试试糟糕的体系结构吧。”- Brian Foote 和 Joseph Yoder
大多数传统 .NET 应用程序都部署为单一单位,对应于单一 IIS 应用域中运行的可执行文件或单个 Web 应用程序。 此方法是最简单的部署模型,能很好地为众多内部和小型公共应用程序提供服务。 然而,即使提供此单一单位部署,大多数重要的商业应用程序仍受益于逻辑分层。
什么是整体式应用程序?
就其行为而言,整体式应用程序是完全独立的应用程序。 在执行操作的过程中,该应用程序可能与其他服务或数据存储发生交互,但其核心业务在其自身进程中运转,且整个应用程序通常作为单个单位部署。 如果此类应用程序需要横向扩展,通常需要在多个服务器或虚拟机中复制整个应用程序。
一体式应用程序
应用程序体系结构项目可能的最小数量是一。 在这种体系结构中,应用程序的完整逻辑包含在单一项目中,编译为单一程序集并作为单个单元进行部署。
一个新的 ASP.NET Core 项目,不管是在 Visual Studio 中还是通过命令行创建,最初都是简单的“一体式”整体应用程序。 它包含应用程序的所有行为,包括展现、业务和数据访问逻辑。 图 5-1 展示了单项目应用的文件结构。
图 5-1. 单项目 ASP.NET Core 应用。
在单项目方案中,通过使用文件夹实现关注点分离。 默认模板包括单独的文件夹,对应于 MVC 模式中的模型、视图和控制器,以及其他数据和服务文件夹。 在这种结构安排中,应尽可能地将展现逻辑限制在“Views”文件夹,将数据访问逻辑限制在“Data”文件夹中保存的类。 业务逻辑应位于“Models”文件夹内的服务和类中。
尽管简单,但单项目整体解决方案也有一些缺点。 随着项目的大小和复杂性增加,文件和文件夹数量也会继续随之增加。 用户界面 (UI) 问题(模型、视图和控制器)驻留于多个文件夹中,这些文件夹未按字母顺序组合在一起。 将其他 UI 级别的构造(例如筛选器或 ModelBinder)添加到它们自己的文件夹时,问题只会变得更糟。 业务逻辑分散于“Models”和“Services”文件夹之间,没有明确地指示哪些文件中的哪些类应当依赖其他类。 这种项目级别缺少组织的情况通常会导致面条式代码。
为解决这些问题,应用程序通常演变为多项目解决方案,其中将每个项目视为位于应用程序的特定层 。
什么是层次?
随着应用程序的复杂性增加,管理复杂性的方式之一是根据职责或问题分解应用程序。 此方法遵循关注点分离原则,有助于使基本代码井然有序,以便开发人员可轻松找到实现特定功能的位置。 然而,分层体系结构提供的好处远远不止于组织代码结构。
通过将代码分层排列,常见的低级功能可在整个应用程序中重复使用。 这种重复使用很有用,因为它意味着需要编写的代码变少,还因为它可以让应用程序能够对某个实现进行标准化,从而遵循不要自我重复 (DRY) 原则。
借助分层体系结构,应用程序可以强制实施有关哪些层可以与其他层通信的限制。 此体系结构有助于实现封装。 某层发生更改或更换时,只有与它一起工作的那些层会受到影响。 通过限制哪些层依赖其他层,可缓解更改的影响,使一项更改不会影响整个应用程序。
分层(和封装)让替换应用程序内的功能变得更加轻松。 例如,应用程序最初可能使用自己的 SQL Server 数据库来实现持久性,但稍后可能选择使用基于云的持久性策略,或 Web API 后的策略。 如果应用程序将其持久性实现正确封装于逻辑层中,则可使用实现相同公共接口的新的 SQL Server 特定层替换它。
除可能的交换实现,以应对将来的要求更改之外,应用程序层还能让测试用途的交换实现变得更加轻松。 无需编写针对应用程序的真实数据层或 UI 层操作的测试,可在测试时使用提供请求的已知响应的假实现来替换这些层。 通常情况下,与针对应用的实际基础结构运行测试相比,此方法可以降低测试的编写难度,并提高测试的运行速度。
逻辑分层是用于改进企业软件应用程序代码的常用技术,可通过多种方式将代码分层排列。
注意
层次表示应用程序内的逻辑分隔 。 如果应用程序逻辑以物理方式分布到单独的服务器或进程中,这些单独的物理部署目标就称为“层级” 。 具有部署到单一层级的 N 层应用程序是可能的,也很常见。
传统“N 层”体系结构应用程序
图 5-2 展示了应用程序逻辑分层最常用的组织结构。
图 5-2. 典型的应用程序层次。
这些层经常简称为 UI、BLL(业务逻辑层)和 DAL(数据访问层)。 使用此体系结构,用户可通过 UI 层(仅与 BLL 交互)提出请求。 反过来,BLL 可为数据访问请求调用 DAL。 UI 层不应直接向 DAL 提出任何请求,也不得通过其他途径直接与持久性发生交互。 同样,BLL 应仅通过 DAL 与持久性发生交互。 通过这种方式,每层都有自己熟知的职责。
这种传统分层方法的缺点之一是编译时依赖关系由上而下运行。 即,UI 层依赖于 BLL,而 BLL 依赖于 DAL。 这意味着,通常保存应用程序中最重要的逻辑的 BLL层,必须依赖于数据访问的实现方式(且通常依赖于数据库的存在)。 在这样的体系结构中很难测试业务逻辑,需要一个测试数据库。 如下节中所述,依赖倒置原则可以用来解决此问题。
图 5-3 展示了一个示例解决方案,其中按职责(层次)将应用程序分解为三个项目。
图 5-3. 具有三个项目的简单整体式应用程序。
尽管出于组织架构目的,此应用程序使用多个项目,但它仍作为单一单位进行部署,且其客户端以单一 Web 应用的形式与其交互。 这使部署过程变得非常简单。 图 5-4 展示了如何使用 Azure 托管此类应用。
图 5-4. Azure Web 应用简单部署
随着应用程序需求增长,可能需要更复杂、更可靠的部署解决方案。 图 5-5 展示了支持其他功能、更复杂的部署计划示例。
图 5-5. 将 Web 应用部署到 Azure 应用服务
在内部,此项目的组织根据职责分为多个项目,提高了应用程序的可维护性。
可纵向或横向扩展此单位,以利用基于云的按需可伸缩性。 纵向扩展指的是向承载应用的服务器添加额外的 CPU、内存、磁盘空间或其他资源。 横向扩展指的是添加此类服务器的其他实例,无论它们属于物理服务器、虚拟机还是容器。 在多个实例中承载应用时,可以使用负载均衡器来将请求分配给各个应用实例。
在 Azure 中缩放 Web 应用程序最简单的方法是在应用程序的应用服务计划中手动配置缩放。 图 5-6 展示用于配置为应用提供服务的实例数量的相应 Azure 仪表板屏幕。
图 5-6. Azure 中的应用服务计划缩放。
干净体系结构
遵循依赖倒置原则以及域驱动设计原则 (DDD) 的应用程序倾向于达到类似的体系结构。 多年来,这种体系结构有多种名称。 最初的名称之一是六边形体系结构,然后是端口 - 适配器。 最近,它被称为洋葱体系结构或干净体系结构。 此电子书中将后一种名称“干净体系结构”用作此体系结构的名称。
eShopOnWeb 参考应用程序使用“干净体系结构”方法将其代码组织到项目中。 可以在 ardalis/cleanarchitecture GitHub 存储库中或通过从 NuGet 安装模板找到一个解决方案模板,用作自己的 ASP.NET Core 解决方案的起点。
干净体系结构将业务逻辑和应用程序模型置于应用程序的中心。 而不是让业务逻辑依赖于数据访问或其他基础设施,此依赖关系被倒置:基础结构和实现细节依赖于应用程序内核。 此功能是通过在应用程序核心中定义抽象或接口来实现的,然后通过基础设施层中定义的类型实现。 将此体系结构可视化的常用方法是使用一系列同心圆,类似于洋葱。 图 5-7 展示这种样式的体系结构表示形式的示例。
图 5-7. 干净体系结构,洋葱视图
在此关系图中,依赖关系流向最里面的圆。 “应用程序内核”因其位于此关系图的核心位置而得名。 从关系图上可见,该应用程序内核在其他应用程序层上没有任何依赖项。 应用程序的实体和接口位于正中心。 在外圈但仍在应用程序核心中的是域服务,它通常实现内圈中定义的接口。 在应用程序内核外面,UI 和基础结构层均依赖于应用程序内核,但不一定彼此依赖。
图 5-8 展示了可更好地反映 UI 和其他层之间的依赖关系的更传统的水平层次关系图。
图 5-8. 干净体系结构,水平层次视图
注意,实线箭头表示编译时依赖关系,而虚线箭头表示仅运行时依赖关系。 使用干净体系结构,UI 层可使用编译时应用程序内核中定义的接口,理想情况下不应知道体系结构层中定义的实现类型。 但是在运行时,这些实现类型是应用执行所必需的,因此它们需要存在并通过依赖关系注入接通应用程序内核接口。
图 5-9 展示了遵循这些建议生成 ASP.NET Core 应用程序体系结构时的更详细的视图。
图 5-9. 遵循干净体系结构的 ASP.NET Core 体系结构关系图。
由于应用程序内核不依赖于基础结构,可轻松为此层次编写自动化单元测试。 图 5-10 和 5-11 展示了测试如何适应此体系结构。
图 5-10. 隔离状态下的单元测试应用程序内核。
图 5-11. 使用外部依赖关系的集成测试基础结构实现。
由于 UI 层对基础结构项目中定义的类型没有任何直接依赖关系,同样,可轻松交换实现,无论是为便于测试还是为应对不断变化的应用程序要求。 ASP.NET Core 对内置依赖关系注入的使用及相关支持使此体系结构最适合用于构造重要的整体式应用程序。
对于单片式应用程序,应用程序内核、基础结构和 UI 项目均作为单一应用程序运行。 运行时应用程序体系结构可能类似于图 5-12。
图 5-12. 示例 ASP.NET Core 应用的运行时体系结构。
采用干净体系结构排列代码
在干净体系结构解决方案中,每个项目都有明确的职责。 在这种情况下,某些类型将属于每个项目,你会经常在相应的项目中找到与这些类型相应的文件夹。
应用程序核心
应用程序内核包含业务模型,后者包括实体、服务和接口。 这些接口包括使用基础结构执行的操作(如数据访问、文件系统访问和网络调用等)的抽象。有时,在此层定义的服务或接口需要使用与 UI 或基础结构没有任何依赖关系的非实体类型。 这些类型可定义为简单的数据传输对象 (DTO)。
应用程序内核类型
- 实体(保存的业务模型类)
- 聚合(实体组)
- 接口
- 域服务
- 规范
- 自定义异常和临界子句
- 域事件和处理程序
基础结构
基础结构项目通常包括数据访问实现。 在典型的 ASP.NET Core Web 应用程序中,这些实现包括 Entity Framework (EF) DbContext、任何已定义的 EF Core Migration
对象以及数据访问实现类。 提取数据访问实现代码最常用的方式是通过使用存储库设计模式。
除数据访问实现外,基础结构项目还应包含必须与基础结构问题交互的服务的实现。 这些服务应实现应用程序内核中定义的接口,因此基础结构应包含对应用程序内核项目的引用。
基础结构类型
- EF Core 类型(
DbContext
、Migration
) - 数据访问实现类型(存储库)
- 特定于基础结构的服务(如
FileLogger
或SmtpNotifier
)
UI 层
ASP.NET Core MVC 应用程序中的用户界面层是应用程序的入口点。 此项目应引用应用程序内核项目,且其类型应严格通过应用程序内核中定义的接口与基础结构进行交互。 UI 层中不允许基础结构层类型的直接实例化(或静态调用)。
UI 层类型
- Controllers
- 自定义筛选器
- 自定义中间件
- 视图
- ViewModels
- 启动
Startup
类或 Program.cs 文件负责配置应用程序,并将实现类型与接口接通。 执行此逻辑的位置称为应用程序的组合根,它允许依赖项注入在运行时正常工作。
注意
为了在应用程序启动过程中接通依赖关系注入,UI 层项目可能需要引用 Infrastructure 项目。 此依赖项可以通过使用自定义 DI 容器来轻而易举地消除,此容器内置了对从程序集中加载类型的支持。 就本示例而言,最简单的方法是允许 UI 项目引用 Infrastructure 项目(但开发人员应将对 Infrastructure 项目中类型的实际引用限制为应用程序的组合根)。
整体式应用程序和容器
可以构建基于单个和整体部署的 Web 应用程序或服务,并将其部署为容器。 在应用程序内,它可能不是一个整体,而是排列在若干个库、组件或层中。 但在外部,它是单个容器,具有单个进程、单个 Web 应用或单个服务。
若要管理此模型,可部署单个容器来表示应用程序。 若要进行缩放,只需添加更多副本,并将负载均衡器置于前面即可。 为了简单起见,在单个容器或 VM 中管理单个部署。
如图 5-13 中所示,可以在每个容器内添加多个组件/库或内部层。 但是,遵循容器原则(“一个容器在一个进程中做一件事”),整体模式可能成为冲突 。
这种方法的缺点是应用程序增长时,需要将它进行缩放。 如果整个应用程序都已缩放,这就不是问题了。 但在大多数情况下,应用程序中只有一小部分是瓶颈,需要进行缩放,而其他组件使用较少。
在典型的电子商务示例中,可能需要缩放产品信息组件。 众多客户浏览产品,但并不购买它们。 使用购物车的顾客比使用付款管道的多。 较少的顾客会评论或查看购买记录。 而且你可能只需要少量的员工(在一个区域内)管理货物和营销活动。 通过缩放整体式设计,可多次部署所有代码。
除了“缩放所有组件”问题外,更改单个组件还需要完全重新测试整个应用程序,以及完全重新部署所有实例。
整体式方法很常见,并且许多组织均使用此体系结构方法进行开发。 其中许多组织取得了足够好的成果,而其他组织已达到极限。 许多组织使用这种模型设计应用程序,因为工具和基础结构难以构建面向服务的体系结构 (SOA),而且在应用程序增长之前他们也没有发现这种需要。 如果发现已达到整体式方法的极限,请分解应用,使其可更好地利用可能作为下一个逻辑步骤的容器和微服务。
在 Microsoft Azure 中部署整体式应用程序可以通过使用每个实例的专用 VM 实现。 使用 Azure 虚拟机规模集可轻松缩放 VM。 Azure 应用服务 可运行整体式应用程序并轻松缩放实例,无需管理 VM。 Azure 应用服务还可运行 Docker 容器的单个实例,从而简化部署。 通过使用 Docker,可将单个 VM 部署为 Docker 主机,并运行多个实例。 如图 5-14 所示,使用 Azure 均衡器可管理缩放。
使用传统的部署技术可以管理各种主机的部署。 通过 docker run 等命令可以手动管理 Docker 主机,也可以通过持续交付 (CD) 管道等自动化管理 。
部署为容器的整体式应用程序
使用容器管理整体式应用程序部署有很多好处。 缩放容器实例比部署额外的 VM 要快得多,也容易得多。 即便使用虚拟机规模集缩放 VM,也需要时间才能创建。 部署为应用实例时,应用的配置将作为 VM 的一部分进行管理。
将更新部署为 Docker 映像会快得多,并且网络效率更高。 Docker 映像通常会在几秒内启动,加快了推出速度。 拆除 Docker 实例与发出 docker stop
命令一样简单,通常在一秒钟以内便可完成。
正如容器从设计上来说,它的本质就是不可变的,因此你无需担心 VM 损坏,而更新脚本可能忘记考虑磁盘上剩下的某些特定配置或文件。
对于简单 Web 应用程序的单片式部署,可以使用 Docker 容器。 此方法可以改进持续集成和持续部署管道,并有助于成功实现部署到生产环境。 不再出现“为什么可以在我的计算机上正常运行,却不能在生产环境中正常运行?”的问题
基于微服务的体系结构具有许多优势,但以增加复杂性为代价。 在某些情况下,付出的代价会比得到的优势更为重大,因此在单个或少量容器中运行的单片式部署应用程序是更好的选择。
单片式应用程序可能不易分解到独立性良好的微服务。 微服务应彼此独立地运行,以提供恢复能力更强的应用程序。 如果无法实现应用程序的独立功能切片,将其分离只会增加复杂性。
应用程序可能尚不需要独立地扩展功能。 许多应用程序在需要扩展到超出单个实例时,可以通过克隆该整个实例这一相对简单的过程来实现此目的。 将应用程序分离成各自分散的服务不仅需增加额外工作量,且收效甚微,相比之下,缩放应用程序的完整实例则既简单又节约成本。
在应用程序开发前期,可能并不确定自然功能边界。 开发最小可独立产品时,自然分离可能尚未出现。 其中一些条件可能是临时的。 可首先创建单片式应用程序,稍后再分离要以微服务的形式开发和部署的某些功能。 而另一些条件可能对应用程序的容错空间至关重要,这意味着应用程序可能永远无法分解为多个微服务。
将应用程序分离到多个离散进程还会带来开销。 将功能分离到不同的进程则更复杂。 通信协议会变得更加复杂。 在服务之间必须使用异步通信,而不得使用方法调用。 移动到微服务体系结构时,需要添加许多在 eShopOnContainers 应用程序的微服务版本中实现的构建基块:事件总线处理、消息恢复和重试、最终一致性等。
较为简单的 eShopOnWeb 参考应用程序支持单容器整体化容器应用。 该应用程序包括一个 Web 应用程序,其中包括传统的 MVC 视图、Web API 和 Razor Pages。 或者,可以运行应用程序的基于 Blazor 的管理组件,这也需要单独的 API 项目才能运行。
可使用 docker-compose build
和 docker-compose up
命令从解决方案根目录启动该应用程序。 此命令使用 web 项目根目录中的 Dockerfile
为 Web 实例配置容器,并在指定端口上运行容器。 可从 GitHub 下载此应用程序的源代码,并在本地运行。 即使是这一单片式应用程序,在容器环境中部署也是有益的。
其一,容器化部署意味着应用程序的每个实例都在同一环境中运行。 此方法包括用于前期测试和开发的开发人员环境。 开发团队可在与生产环境完全相同的容器化环境中运行应用程序。
此外,容器化应用程序横向扩展成本较低。 使用容器环境比使用传统 VM 环境更有利于资源共享。
最后,容器化应用程序会强制分离业务逻辑和存储服务器。 应用程序横向扩展时,多个容器将全部依赖于单个物理存储介质。 此存储介质通常是运行 SQL Server 数据库的高可用性服务器。
Docker 支持
eShopOnWeb
项目在 .NET 上运行。 因此,该项目可以在基于 Linux 或 Windows 的容器中运行。 请注意,在 Docker 部署中,请对 SQL Server 使用相同的主机类型。 基于 Linux 的容器占用较小,是首选方案。
可使用 Visual Studio 2017 或更高版本向现有应用程序添加 Docker 支持:右键单击“解决方案资源管理器”中的一个项目,然后选择“添加”>“Docker 支持” 。 此步骤可添加所需文件并修改项目以使用这些文件。 当前的 eShopOnWeb
示例中已具有这些文件。
解决方案级别 docker-compose.yml
文件包含有关要生成的映像和要启动的容器的信息。 该文件可让你使用 docker-compose
命令来同时启动多个应用程序。 在本示例中,它只启动 Web 项目。 还可使用该文件配置依赖项,例如单独的数据库容器。
version: '3'
services:
eshopwebmvc:
image: eshopwebmvc
build:
context: .
dockerfile: src/Web/Dockerfile
environment:
- ASPNETCORE_ENVIRONMENT=Development
ports:
- "5106:5106"
networks:
default:
external:
name: nat
docker-compose.yml
文件引用 Web
项目中的 Dockerfile
。 Dockerfile
用于指定将要使用的基容器以及在该容器上配置应用程序的方式。 Web
' Dockerfile
:
FROM mcr.microsoft.com/dotnet/sdk:8.0 AS build
WORKDIR /app
COPY *.sln .
COPY . .
WORKDIR /app/src/Web
RUN dotnet restore
RUN dotnet publish -c Release -o out
FROM mcr.microsoft.com/dotnet/aspnet:8.0 AS runtime
WORKDIR /app
COPY --from=build /app/src/Web/out ./
ENTRYPOINT ["dotnet", "Web.dll"]
Docker 问题疑难解答
运行容器化应用程序后,它会持续运行,直到将其停止。 可使用 docker ps
命令查看正在运行的容器。 可通过使用 docker stop
命令并指定容器 ID 来停止正在运行的容器。
请注意,正在运行的 Docker 容器可能已绑定到其他可能在开发环境中尝试使用的端口。 如果尝试使用与运行 Docker 容器相同的端口来运行或调试应用程序,将收到指示服务器无法绑定到该端口的错误。 再次强调,停止容器应可解决该问题。
如果要使用 Visual Studio 向应用程序添加 Docker 支持,请确保执行此操作时 Docker Desktop 处于运行状态。 如果启动向导时 Docker Desktop 未处于运行状态,则向导无法正常运行。 此外,向导会检查当前的容器选择,添加正确的 Docker 支持。 若要为 Windows 容器添加支持,需在运行配置了 Windows 容器的 Docker Desktop 时运行向导。 若要为 Linux 容器添加支持,请在运行配置了 Linux 容器的 Docker 时运行向导。
其他 Web 应用程序体系结构样式
- Web 队列辅助角色:该体系结构的核心组件是处理客户端请求的 Web 前端和执行资源密集型任务、长时间运行的工作流或批处理作业的辅助角色。 Web 前端通过消息队列与辅助角色进行通信。
- N 层:N 层体系结构将应用程序分成逻辑层级和物理层级。
- 微服务:微服务体系结构由一系列小型的自治服务组成。 每个服务都是自包含服务,并且应在边界上下文中实现单个业务功能。
参考 - 常见 Web 体系结构
- 干净体系结构
https://blog.cleancoder.com/uncle-bob/2012/08/13/the-clean-architecture.html - 洋葱体系结构
https://jeffreypalermo.com/blog/the-onion-architecture-part-1/ - 存储库模式
https://deviq.com/repository-pattern/ - 干净体系结构解决方案模板
https://github.com/ardalis/cleanarchitecture - 构建微服务电子书
https://aka.ms/MicroservicesEbook - DDD(域驱动设计)
https://learn.microsoft.com/dotnet/architecture/microservices/microservice-ddd-cqrs-patterns/