ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

ASP.NET Core实战:四层平台五层MVC架构设计指南

ASP.NET Core实战:四层平台五层MVC架构设计指南

1. 为什么用MVC隐喻真实世界

1.1 “思维语言世界”与“对象世界”的对应关系

我做了几年C#后端,越来越觉得MVC不仅是一套代码组织方式,它其实暗合了人类理解世界的方式。我们平时说“张三在办公室里写报表”,这句话里其实藏着三个要素:张三是一个对象,办公室是对象所处的环境,写报表是这个对象正在执行的行为。如果你把这句话翻译成MVC的语言,张三就是Model,写报表的动作就是Controller,办公室里呈现出来的文档界面就是View。所以标题里那句“人智数智的思维语言世界”听起来玄乎,落到架构上不过就是在回答三个问题:人怎么想、机器怎么算、世界怎么建模。

这篇文章把我踩过坑之后的完整实践记录下来:如何在VS里用C# ASP.Net MVC架构模板,搭建一套“四层平台的五层MVC”结构。适合正在做中大型业务系统、被传统三层架构折磨过、或者想深入理解架构分层本质的C#开发者参考。我会把设计思路、项目结构、代码实现、排查记录都摊开来说,不绕弯子。

1.2 MVC不是三层架构,它更接近人的认知模式

很多开发者在简历里写“熟练使用MVC三层架构”,这其实是一个常见的认知误区。三层架构是物理上的横向切分,解决的是代码该放在哪个项目里的问题:UI层管界面,业务层管逻辑,数据层管存取。而MVC是一种认知上的纵向分工:Model负责描述对象世界,View负责呈现结果,Controller负责接收输入和驱动状态变化。前者回答“代码放哪里”,后者回答“谁负责什么”。

举个生活化的例子。你去餐厅吃饭,三层架构像厨房的物理分区:前台点单、后厨做菜、仓库备货。而MVC更像一张菜单的结构:菜品本身是Model,厨师的烹饪指令是Controller,端上桌的样子是View。同一个系统,两种维度并不冲突,四层平台的五层MVC就是想把这两种维度组合在一起。但组合的前提是先分清楚:层是层,MVC是MVC,别混为一谈。

2. 四层平台与五层MVC的设计拆解

2.1 四层平台的边界划分

我用的四层平台是这样划分的:表现层(Presentation)、应用层(Application)、领域层(Domain)、基础设施层(Infrastructure)。每一层只允许依赖它的下一层,不允许反向依赖,更不允许跨层跳跃。

  • 表现层:管View、管Controller的输入输出适配,不写任何业务规则。
  • 应用层:管用例编排、事务边界、权限校验,把领域层的能力组装成一个个可执行的业务场景。
  • 领域层:管核心业务模型、业务规则、领域服务,这是整个系统最有价值的部分。
  • 基础设施层:管数据库访问、文件存储、外部API、消息队列等所有技术细节。

这四层借鉴了领域驱动设计(DDD)的分层思想,但比完整版轻量很多,没有引入命令总线、事件总线那套重型机制。我把它们落到一个解决方案的四个项目里,每个项目的名字直接用后缀区分,比如Demo.Presentation、Demo.Application、Demo.Domain、Demo.Infrastructure。一眼就能看出依赖方向,团队里新人拿到代码也不会放错位置。

2.2 五层MVC:在Controller和Model之间多出来的两层

传统MVC只有三层,但实际做业务系统时你会发现一个问题:Controller直接操作数据库记录时,代码很快就腐化,一个控制器里塞满了校验、事务、查询、组装视图模型的逻辑。我给的解法是把传统MVC扩展成五层:View、Controller、Service、Repository、Model。

这里的Model层不是EF Core的实体类,而是完整的领域模型,包含业务行为;Repository负责跟数据库打交道,把数据存取细节隔离在领域模型之外;Service负责业务规则和用例编排,Controller只做参数接收和结果返回;View只负责渲染。每一层有明确的单一职责,Controller变薄了,Model变厚了,这样的设计才能让系统在业务复杂时依然可控。

2.3 设计原则:单一职责与依赖方向

五层MVC里最关键的是依赖方向,也就是谁引用谁。我的原则是:Controller依赖Service,Service依赖Repository和Model,Repository依赖Model。Controller不允许直接依赖Repository,Service不允许直接依赖View。这条依赖规则靠项目引用和代码审查双重保障,哪怕团队里有人图省事,编译期就会报错,根本藏不住。

这么做的好处可以用一个比喻解释:这就像一个公司的汇报线,Controller是前台接待,Service是部门经理,Repository是仓库管理员,Model是仓库里的货物。前台接待不会直接去仓库搬货,部门经理也不会亲自跑到前台展示货物。每一层只跟相邻层沟通,整个系统才能保持良好的演进性——数据库换了,只改基础设施层;业务规则变了,只改领域层;页面重做了,只改表现层。

3. 在VS中实操:从模板到五层MVC

3.1 创建项目:选择正确的模板

打开Visual Studio,新建项目时我通常不会直接选“ASP.NET Core Web App (Model-View-Controller)”默认模板,而是选空模板,然后手动加包。这样做的原因是:默认模板自带了一堆示例文件、样式和脚手架,删起来麻烦,而且容易让新人顺着模板的路径走。我要的五层MVC结构跟默认模板的目录结构有一定差异,从空白开始反而更干净。

我用的技术栈是.NET 8 + ASP.NET Core MVC,数据库用SQL Server,ORM用EF Core。选.NET 8的原因很务实:LTS版本,支持时间长,团队升级压力小。如果你用的是.NET Framework 4.x,本文的大多数做法也适用,只是依赖注入需要换成Unity或Autofac,整体架构思路完全一致。

创建完空解决方案后,我手动添加四个类库项目和一个Web项目。类库项目的目标框架统一,打开API项目(Web层)的Program.cs,通过依赖注入把它们串联起来。整个解决方案在VS里看起来是这样的:

Demo.sln ├─ src │ ├─ Demo.Presentation (ASP.NET Core MVC Web) │ ├─ Demo.Application (类库) │ ├─ Demo.Domain (类库) │ └─ Demo.Infrastructure (类库) └─ tests └─ Demo.Domain.Tests (xUnit测试项目)

3.2 分层实施:文件夹结构、命名空间、项目引用关系

项目引用关系是这套架构的生命线。我的引用设置是:Demo.Presentation引用Demo.Application和Demo.Domain;Demo.Application引用Demo.Domain;Demo.Infrastructure引用Demo.Domain。四个项目都不允许引用Demo.Infrastructure,包括表现层在内。那么EF Core的DbContext放在哪?放在Demo.Infrastructure里,并通过接口解耦。

这里有个新人容易犯的错:在Demo.Presentation里直接using Demo.Infrastructure,因为用起来确实方便。但这样一写,四层平台就变成两层了,表现层直接穿透到数据库层。正确的做法是在Demo.Application或Demo.Domain里定义仓储接口,比如IUserRepository,然后在Demo.Infrastructure里实现它,最后在Program.cs里注册依赖关系。Controller拿到的永远是一个接口,而不是EF Core的DbSet。

3.3 依赖注入:用什么容器,怎么配

ASP.NET Core自带的DI容器完全够用,不需要额外引第三方容器。我在Program.cs里做了三件事:

第一,注册DbContext,注意生命周期必须是Scoped,因为一个HTTP请求内要保证大家用的是同一个上下文实例。第二,注册仓储接口与实现的映射,也是Scoped。第三,注册Service层,这个要看具体状态:如果Service是纯静态的、只依赖传入参数,可以用Transient;但只要Service内部引用了仓储或DbContext,就必须用Scoped。

builder.Services.AddScoped<AppDbContext>(); builder.Services.AddScoped<IUserRepository, UserRepository>(); builder.Services.AddScoped<IUserService, UserService>();

这里有一个特别容易踩的坑:在ConfigureServices阶段直接调用new去构造某个Service,或者在一个Singleton服务里注入一个Scoped的DbContext。你的程序会在运行到一半的时候抛“Cannot consume scoped service 'AppDbContext' from singleton 'XXXService'”异常。解决的办法很简单:Singleton服务里不要注入Scoped依赖,需要用DbContext时改成IServiceScopeFactory动态创建作用域。

3.4 数据流走向:一次请求从哪里进来、到哪里出去

理清一次请求的完整路径,对理解五层MVC至关重要。假设用户要获取用户列表:

  1. 浏览器发出GET /User/List请求,路由映射到UserController的List方法。
  2. Controller把查询参数封装成一个输入模型,调用IUserService的GetUsersAsync方法,不做任何额外逻辑。
  3. Service接收到请求后,先做参数校验,再调用IUserRepository获取数据。
  4. Repository通过EF Core把数据库行映射成User实体,返回给Service。
  5. Service把User实体组装成适合页面展示的UserListViewModel,返回给Controller。
  6. Controller把ViewModel丢给Razor视图,视图渲染出HTML返回浏览器。

我把这个流程打印出来贴在工位上,每次评审代码就对照这个流程,看有没有哪一层越权了。

4. 核心环节实现:模型、仓储、服务、控制器、视图

4.1 Model层:让对象世界活起来

Model层是整个架构的灵魂。我见过太多项目把Model写成一堆只有{ get; set; }的贫血类,然后所有逻辑都放在Service里。这套传统的做法在业务简单时看不出问题,一旦系统膨胀,Service就会变成几千行的上帝类。

我在五层MVC里推荐的Model设计是充血模型:把业务规则放在Model本身。比如一个订单实体,它不该只承载数据库字段,还应该有自己的行为:计算总额、检查库存是否充足、判断是否能被取消。这样实现的核心逻辑是:让对象世界在代码里拥有真实的行为和状态,而不是一张被动的数据表。

public class Order { public Guid Id { get; private set; } public List<OrderItem> Items { get; private set; } = new(); public OrderStatus Status { get; private set; } public decimal GetTotalAmount() => Items.Sum(i => i.UnitPrice * i.Quantity); public void Cancel() { if (Status == OrderStatus.Shipped) throw new InvalidOperationException("已发货订单不能取消"); Status = OrderStatus.Cancelled; } }

这里有一个实操要点:必须给私有构造函数和Id属性重的赋值留好后路,否则EF Core做实体映射时会报“没有可用的构造函数”错误。我的做法是给EF Core留一个protected的无参构造函数,同时把属性的set方法设为private,不让外部乱改状态。

4.2 Repository层:数据世界与对象世界之间的翻译官

Repository存在的意义,是把SQL、EF Core、Dapper这些数据访问细节统统挡在领域层之外。Service层永远不需要知道数据是从SQL Server来,还是从Redis来,或者干脆是内存里的假数据,这对单元测试特别友好。

我写仓储接口时有一个原则:接口返回类型尽量用领域模型,不要暴露IQueryable。有些教程喜欢让仓储直接返回IQueryable,美其名曰可以延迟查询。这在五层架构里是个坏味道,因为IQueryable会把表达式树泄露给上层,导致Controller里写出一堆Where和Include,把查询逻辑全部打散。我用的是明确的同步/异步方法配合Expression过滤条件,比如:

Task<User> GetByIdAsync(Guid id); Task<List<User>> FindAsync(Expression<Func<User, bool>> predicate); Task<bool> AnyAsync(Expression<Func<User, bool>> predicate); Task AddAsync(User user); Task UpdateAsync(User user);

另一个细节是:FindAsync返回Task<List<T>>而不是IEnumerable<T>,避免延迟执行带来的连接未关闭问题。这个方法我调试时反复用,尤其是在ASP.NET Core的异步流程里,用IEnumerable配合EF Core的延迟加载,很容易在序列化响应时触发ObjectDisposedException,因为DbContext已经释放了。

4.3 Service层:业务规则的容器

Service层是最容易被写坏的层。很多人把Controller里的代码搬过来,换个类名就叫Service了,这没有任何意义。Service真正要管的是:参数校验、事务边界、跨实体的业务规则、对多个仓储的编排。

一个合格的Service方法,长度最好控制在20行以内。如果超过了,大概率是职责没划清楚。这里分享一个我常用的小技巧:对于复杂的业务,把Service内部流程拆成private方法,每个方法只做一件事,并用有语义的名字命名。比如下单这个用例,拆成ValidateItems、CheckInventory、CreateOrder、SendNotification四个步骤,每一步单独测试,比一个大而全的下单方法好维护一百倍。

事务的处理也应该放Service层,不要放在Controller。因为Controller只负责输入输出,它不该知道数据库事务是什么。我用ASP.NET Core经典的IDbContextTransaction来做显式事务,配合await using保证异常时自动回滚:

await using var transaction = await _dbContext.Database.BeginTransactionAsync(); try { await _orderRepository.AddAsync(order); await _inventoryRepository.DeductAsync(order.Items); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; }

4.4 Controller层:思维的入口,不是逻辑的堆积

Controller在五层MVC里的定位特别清晰:接收HTTP输入、反序列化成输入模型、调用Service、把结果转成ViewModel、返回视图或JSON。Controller里不允许出现if判断业务规则,不允许直接操作DbContext,也不允许出现超过三行以上的代码块。

我把一个UserController的典型方法放在下面,大家可以对比一下自己平时写的代码:

public class UserController : Controller { private readonly IUserService _userService; public UserController(IUserService userService) { _userService = userService; } [HttpGet] public async Task<IActionResult> List([FromQuery] UserListQuery query) { var vm = await _userService.GetUserListAsync(query); return View(vm); } }

这个方法的长度就是这么短。所有的校验、数据库查询、组装都在Service里完成了。这不是偷懒,恰恰是最难的部分:克制住把逻辑堆在Controller里的冲动。

4.5 View层:只做呈现,不做计算

Razor视图属于表现层,我要求视图里不出现复杂的C#表达式嵌套,不出现@if里套@foreach再套@if的三层地狱。视图的职责是拿ViewModel字段做简单渲染,任何需要计算的字段都在Service层提前处理成ViewModel的普通属性。

举个例子,用户列表页面要显示“VIP客户”标签,我会在ViewModel里定义一个IsVip的布尔属性,并在Service层计算好。视图里只需要判断if (item.IsVip)即可,不需要在视图里写关于消费金额大于多少的规则。这样做的原因很实际:一旦显示规则变了,你不需要去翻一个可能包含上百行代码的Razor视图找逻辑,只需要改Service里的一个方法。

ViewModel的设计也有讲究,它和领域模型是两套东西。领域模型是面向业务逻辑的,它的字段命名需要精确表达业务含义,比如OrderItemId。ViewModel是面向展示的,它的字段命名可以从简,比如Id、Name、StatusText。可以用AutoMapper做映射,也可以手写扩展方法。我倾向于手写映射,因为项目复杂之后,AutoMapper的Profile容易越堆越多,排查起来费劲,手写映射方法反而清清楚楚。

5. 常见问题与排查技巧实录

5.1 循环依赖问题

在五层MVC里,循环依赖几乎是必然会遇到的第一道坎。最常见的形式是:UserService依赖UserRepository,然后UserRepository又想依赖一个AuditService来记录操作日志,而AuditService又反过来依赖UserService做用户校验。你的DI容器在启动时就会抛“A circular dependency was detected”异常,程序直接崩掉。

排查循环依赖我有一套固定思路:先看报错信息里提示的依赖链,把整条链子列出来画成箭头;然后朝两个方向去解。要么拆分出一个更底层的服务,比如把用户校验单独抽成IUserValidator,让AuditService和UserService都只依赖它;要么把日志记录改成事件驱动,UserService只发布一个事件,由独立的AuditHandler去订阅,彻底解除互相引用。

有些开发者遇到循环依赖的第一反应是把服务改成Singleton或者在构造函数里手动new一个实例。这两种我都试过,短期能跑,后续维护就是灾难,强烈不建议碰。根本解法永远是调整职责边界,不是调整生命周期。

5.2 ViewModel与Entity的映射:字段对不上怎么办

实体类是数据库的直接映射,它的一般规则是跟数据库表结构一致。ViewModel是页面需要的数据形态,两者经常对不上。比如“用户详情页”需要同时展示用户基本信息、最近的订单列表、累计消费金额和售后记录,领域模型是分散在多个实体里的,在数据库里可能要查好几张表。如果为了图方便,让视图直接绑实体,就会把底层的数据库结构直接暴露给前台,以后改一次数据库字段,页面就要跟着改一次,牵一发而动全身。

我分配映射的做法是:在Service层内部先把多个实体组装成一个专门的UserDetailViewModel,这个视图模型专门为页面量身打造。谁来负责组装?就是Service层的方法,比如GetUserDetailAsync,它内部调用_userRepository.GetByIdAsync、_orderRepository.GetRecentByUserAsync、_spendingRepository.GetTotalAsync,然后映射成ViewModel。这种组装逻辑放在Service层,视图和Controller都保持干净。

5.3 异步与线程:别在Controller里用Task.Result

五层MVC大量使用异步方法,Controller也基本都是async Task<IActionResult>。很多从WinForms转过来的开发者不习惯异步,容易在某个方法里直接写var data = _userService.GetUsersAsync().Result或者.GetAwaiter().GetResult()。这在并发请求多的时候会造成线程池饥饿,表现是系统接口响应越来越慢,最后CPU飙高但吞吐量上不去。

正确做法是把异步贯穿到底:Controller里await,Service里await,仓储里await,一条链子从头到尾全异步。有些团队习惯“异步方法里截断一下”,这个习惯要改掉,因为异步截断位置如果恰好发生在锁或信号量上下文里,还可能触发死锁。

另外要提一嘴Task的用法。虽然EF Core的异步方法已经封装好了,但写Service时你会遇到多个查询要并行执行的情况。我常用Task.WhenAll,它比串行await快得多:

var userTask = _userRepository.GetByIdAsync(id); var ordersTask = _orderRepository.GetByUserAsync(id); var spendingTask = _spendingRepository.GetTotalAsync(id); await Task.WhenAll(userTask, ordersTask, spendingTask); var user = await userTask; var orders = await ordersTask; var spending = await spendingTask;

注意:WhenAll之后要重新await每个任务才能拿到结果。有些人只await Task.WhenAll,然后用userTask.Result拿结果,这又回到异步截断的老路上了。直接await任务本身才是最干净的写法。

5.4 性能优化:一次请求有多少次数据库往返

在五层架构里,最容易出现性能瓶颈的是N+1查询。比如在Service层循环里查用户订单,一个用户查询方法内部循环调用了100次仓储查询,每次查一次数据库,100个用户就是101次往返。数据库的连接池、网络延迟都会被拖垮,这就是典型的N+1问题。

我的排查办法是先打开EF Core的SQL日志,把打印出来的SQL语句数一遍,如果发现模式很明显“一个重复查询被调用了用户数量那么多次”,就去把Service层的循环查询改成批量查询,用IN子句或Contains过滤一次拿回所有数据,然后在内存里组装。另一个常用优化是给高频查询加AsNoTracking,当查询结果不需要被跟踪修改时能省下不少内存和上下文开销。不过要注意,加了AsNoTracking之后,你再对返回实体做修改保存是无效的,需要明确知道自己在“只读”场景下才这么用。为了给读者一个直观参考,我把测试结果贴在这里:

优化方式用户数=100,每人查3条订单数据库往返次数
循环逐条查询(N+1)100次用户查询 + 300次订单查询401
批量查订单一次1次用户查询 + 1次批量订单查询2
上述查询加AsNoTracking同上2(省内存)

从这个表可以直观看出:批量化优化后,402次查询降到2次,性能差距是数量级的。如果你接手的老项目中发现了大量N+1,先不要急着升级机器,优先去优化这种循环查询,效果立竿见影。

5.5 调试时的两个小技巧

第一个技巧是快速定位DAO层问题。当Service报出跟数据库相关的异常,比如字段映射错误、主键冲突、并发冲突,我通常会直接看EF Core生成的SQL语句,确认它查的是哪张表、用了哪些条件,而不是在断点上反复看一个变量的值。第二个技巧是合理使用依赖注入容器调试。在ASP.NET Core的Debug模式下,我偶尔会在Program.cs里注册一个用于调试的Service,比如打印每个请求用的时间,用起来很方便,线上会自动绕过调试注册,不影响性能。

这套五层MVC的架构不是一蹴而就的。我在最新的业务项目里这样搭完之后,团队里交接变得顺畅很多,新人看着项目引用关系就能判断代码该放哪里。而且最意外的是,因为Model层被养肥了,单元测试的覆盖率实打实地涨了上来——以前Service里全是贫血逻辑没法测,现在很多规则在Model内部就能直接验证。

最后分享一个从架构角度给出的个人体会。很多人问,四层平台加五层MVC是不是过度设计了?我的判断标准是:如果你的项目生命周期超过一年、业务领域有一定复杂度、团队大于三个人,这套结构的收益远大于初期多花的一点设计成本。反过来,如果只是临时原型或者简单的CRUD管理页面,那直接用默认模板就好,不要套这么重的壳。架构服务于业务,不是业务服务于架构。这个分寸,得靠踩坑踩出来。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进