ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

四层平台五层MVC:基于C#和ASP.Net MVC的企业级分层架构实践

四层平台五层MVC:基于C#和ASP.Net MVC的企业级分层架构实践

1. 从“人智数智”到分层架构:这条架构思路到底在解决什么问题

1.1 标题里的“思维语言世界”和“人物对象世界”是什么

第一次看到这个标题,我想大多数人会愣一下:什么叫做“人智数智的思维语言世界”?什么又是“人机统一地隐喻‘真实的人物对象世界’”?我断续做了几年C#和ASP.Net的开发,一开始也以为这是某种玄学。但当你真正在一个复杂项目里写下去,就会明白,这句话翻译过来其实非常简单:我们要在代码里建立一个“虚拟世界”,让程序的业务对象、规则、交互方式,能够尽量贴近现实中的人物、事物和关系。

“人智数智的思维语言世界”可以理解为程序内部的一套表达系统,也就是代码里的类、接口、状态机、业务规则。而“真实的人物对象世界”就是业务本身,比如一个客户、一张订单、一个审批流程。好的架构就是在做“人机统一”:让代码中对象的命名、职责、交互路径,和现实中业务参与者的行为模式一一对应。这样无论是新同事接手,还是后来改需求,都能从代码结构直接看出业务的大致轮廓,而不是在一堆混乱的控制器里翻来找去。

1.2 为什么偏偏选ASP.Net MVC和C#

有很多框架和技术栈可以搭分层架构,但标题里点名了VS中的C#和ASP.Net MVC,这是有理由的。C#是强类型语言,编译期就能发现大量类型错误,对一个需要“精确隐喻现实对象”的系统来说非常友好。强类型配合IDE的智能提示,能让你在建模型、写服务、接数据时始终保持清晰的上下文。ASP.Net MVC则天生自带“请求-处理-响应”的管道,Controller天然适合做“接收指令”的角色,View天然适合做“呈现结果”的角色,Model天然适合做“承载数据”的角色。这三者的划分恰好对应了真实世界里的“人机交互界面、决策者、被操作对象”。

另外,VS作为开发工具,对ASP.Net MVC项目的脚手架支持已经非常成熟,自带身份认证、路由配置、依赖注入(从.NET Core时代开始)、模板视图等能力。对于团队协作来说,这套技术栈的学习曲线也相对平缓,招人容易,维护成本可控。我见过很多从Java转过来的朋友,也能很快上手C#的MVC,因为思想是相通的,只是语法风格不同。

1.3 “四层平台的五层MVC”是一次职责拆分的升级

“四层平台”和“五层MVC”并不是行业里的标准术语,更像是一种描述架构形状的说法。我理解它的意思是:整个系统平台从物理上分成四层(表现层、应用层、领域层、基础设施层),而在表现层内部,Controller、View不能再像传统MVC那样什么都干,还要拆出ViewModel、Service、Repository,形成五个核心角色。这其实是对经典MVC的一次企业级升级,目的就是让每个文件和类只干一件事。

传统的小项目里,Controller里既能查数据库,又能写业务规则,还能拼页面数据,顺手还能发个邮件。当项目规模变大后,这种写法第一个崩溃的就是Controller,它会被业务逻辑和SQL挤爆。四层平台加五层MVC的拆法,本质上是用“职责分离”换“可维护性”,把“用户请求”和“领域逻辑”解耦,把“领域对象”和“数据库表结构”解耦。付出的代价是代码量增加,目录层级变多,但收获的是:每一个改动都能被限定在很小的范围内,不容易蔓延成全局事故。

2. 四层平台怎么划分:每一层在干什么,边界在哪里

2.1 表现层:和用户打交道的壳

第一层是表现层。在ASP.Net MVC里,它通常就是那个Web项目,里面有Controllers、Views、ViewModels,以及前端用到的脚本、样式、图片。表现层最核心的职责是“翻译”:把用户的HTTP请求翻译成业务动作,把业务结果翻译成HTML或JSON返回给浏览器。

很多人犯的错误是让Controller直接去操作DbContext,甚至直接在View里写数据库查询。这样做在Demo阶段很快,但一旦需要换数据库、增加权限、统一校验,或者加一个API版本,就会痛苦到怀疑人生。表现层应该只依赖应用层暴露的接口,不应该知道EF Core里有个DbSet叫“Customers”,也不应该知道订单的状态机是怎么流转的。它只负责接收参数、调用服务、把返回值丢给视图模型。

表现层里的ViewModel是需要特别强调的。它不等同于数据库实体,而是“这个页面需要什么数据”的模型。比如订单详情页面需要显示订单号、客户姓名、合计金额、物流单号,这些数据可能来自三个不同的实体。如果你直接把实体丢给View,不仅暴露了不必要的字段,还会导致视图和数据库表结构强耦合。ViewModel就是表现层自己的数据契约,和数据库没有关系。

2.2 业务逻辑层:大脑的决策区

第二层是应用服务/业务逻辑层,对应标题里的“思维”部分。这一层可以包含服务接口和实现,承载用例:创建订单、审核报销、计算折扣、派发任务等。这里的代码不关心输入是通过网页还是API来的,只关心业务动作本身。

为什么要把业务逻辑从Controller里搬到这一层?因为Controller的职责是“协调请求”,而不是“决定业务”。举个例子,“提交订单”这件事需要校验库存、计算价格、锁定优惠券、生成订单号、发送通知。这些步骤之间是有顺序和约束的。如果写在一个Action方法里,代码会又长又乱,而且无法被其他入口复用。比如将来增加一个微信小程序入口,还是要“提交订单”,难道再把那几十行逻辑复制一遍?显然不行。

把这层做成独立的项目,就能天然形成一个复用边界。我习惯把服务接口放在应用层项目里,实现也放在应用层里;而领域实体和业务规则放在更底层的领域项目里。这样当业务规则发生变化时,只需要改服务实现,Controller可以不动,数据库可以不动,前端可以不动。

2.3 数据访问与基础设施层:记忆和外部环境

第三和第四层,通常合在一起说,也可以拆成两个项目。数据访问层负责和数据库打交道:查询、更新、事务。基础设施层负责与外部环境交互:文件存储、邮件发送、缓存、消息队列、第三方API。这两层在标题里被归结为“真实的人物对象世界”背后的支撑,也就是程序赖以生存的记忆和资源。

在C#的生态里,最常见的实现是EF Core + Repository模式。Repository是一层封装,它让上层不需要直接拼SQL或操作IQueryable,而是通过“找订单”“保存客户”这类语义化方法完成数据操作。这样一来,如果某天你要把数据库从SQL Server换成PostgreSQL,应用层不需要改一行代码,只需要换基础设施层的连接配置和少量代码。

基础设施层还包括像“连接西门子OPC”“USB摄像头采集”这类设备对接。这些不是典型的数据库任务,但它们属于“外部世界”,应该隔离在基础设施层。我之前接过一个工控项目,现场要把设备的实时数据采集后写入MES系统,一开始图省事,把OPC通信代码写在Controller里。结果设备重连、缓存刷新、线程阻塞这些问题全部和页面请求搅在一起,调试起来非常痛苦。后来我把采集逻辑封装成一个HostedService,通过接口暴露,Web层只从缓存里读数据,问题立刻变得清爽。

2.4 层与层之间的依赖规则和引用关系

四层平台最容易翻车的不是代码写不对,而是项目引用关系一团乱麻。核心规则只有一个:上层可以引用下层,下层绝对不能引用上层。表现层引用应用层,应用层引用领域层,领域层不引用任何其他层。基础设施层实现领域层定义的数据访问接口,但基础设施层本身不包含业务逻辑。

我见过一个真实项目,为了省事,所有模型类全部放到一个名为“Model”的项目里,结果表现层、业务层、数据层都引用它,最后这个项目变成了垃圾回收站,里面既有数据库实体、又有页面视图模型、还有接口返回的DTO。改一个字段会牵动所有层。正确的做法是:每个层拥有自己的数据契约。领域层只有领域实体;应用层定义服务接口和DTO;表现层定义ViewModel;基础设施层定义EF Core的配置和仓储实现。层之间传递数据时,应用层返回DTO,表现层再把DTO映射成ViewModel。

依赖关系清晰后,项目的测试也变得简单。可以单独给应用层写单元测试,用一个假的仓储接口实现来代替真实的数据库,直接验证业务规则。这在传统“Controller写SQL”的代码里几乎做不到,因为测试无法绕开数据库连接。

3. 五层MVC搭建实操:从VS模板到可运行的分层项目

3.1 VS中创建解决方案和四个项目

在VS里操作时,我建议先创建一个空的解决方案,然后按四层分别创建项目。以.NET 8为例,可以创建以下几个项目:

  • MyApp.Web:ASP.NET Core Web应用(MVC)
  • MyApp.Application:类库,包含服务接口、DTO、映射配置
  • MyApp.Domain:类库,包含实体、业务规则、仓储接口
  • MyApp.Infrastructure:类库,包含EF Core的DbContext、仓储实现、三方案件

创建完成后,分别设置项目引用:Web引用Application和Domain;Application引用Domain;Infrastructure引用Domain,同时需要添加EF Core相关的NuGet包。Web项目需要通过NuGet安装Microsoft.EntityFrameworkCore.Design等包,以便后续执行迁移。

这里要提醒一下:很多人习惯直接在Web项目里建Models、Services、Data文件夹,而不是拆独立项目。对小的CRUD管理系统,这无可厚非。但如果你的项目预计会超过20个页面,或者有多个前端入口(网页、API、后台作业),建议还是按四层拆。拆分的代价是前期多花半小时创建项目,但后期每次编译都能看到依赖方向是否正确。

3.2 定义实体、ViewModel和接口

先说领域实体。假设我们做一个员工审批系统,领域层里的“人员”实体大概长这样:

namespace MyApp.Domain.Entities; public class Employee { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public string Department { get; set; } = string.Empty; public string Position { get; set; } = string.Empty; public DateTime HireDate { get; set; } }

这个实体不带任何特性,不关心数据库表结构,它只是用来表达“员工”这个真实对象在程序里的形象。接下来在领域层定义仓储接口:

public interface IEmployeeRepository { Task<Employee?> GetByIdAsync(Guid id); Task<IEnumerable<Employee>> GetListAsync(); Task AddAsync(Employee employee); Task UpdateAsync(Employee employee); }

应用层需要定义服务接口和DTO。DTO是给上层用的数据契约,比如员工列表页需要显示在职年限,这个字段既不在数据库里,也不一定需要暴露所有实体属性,就可以放到DTO里:

public class EmployeeListItemDto { public Guid Id { get; set; } public string Name { get; set; } = string.Empty; public string Department { get; set; } = string.Empty; public int YearsOfWork { get; set; } }

表现层为了降低和DTO的直接耦合,可以再定义一层ViewModel。不过在小项目里,如果DTO和ViewModel字段完全一样,直接用DTO也不是不可以。但大项目里,ViewModel会逐渐膨胀出下拉选项、权限标记、页面标题等与业务无关的东西,到时候你会发现DTO和ViewModel分开是明智的。

3.3 实现仓储和服务:代码怎么写

仓储实现在基础设施层里。我通常用EF Core的DbContext来写:

namespace MyApp.Infrastructure.Persistence; public class AppDbContext : DbContext { public DbSet<Employee> Employees => Set<Employee>(); public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { } }

仓储类则实现领域层的接口:

public class EmployeeRepository : IEmployeeRepository { private readonly AppDbContext _db; public EmployeeRepository(AppDbContext db) { _db = db; } public async Task<Employee?> GetByIdAsync(Guid id) { return await _db.Employees.FindAsync(id); } public async Task<IEnumerable<Employee>> GetListAsync() { return await _db.Employees.AsNoTracking().ToListAsync(); } public async Task AddAsync(Employee employee) { await _db.Employees.AddAsync(employee); } public Task UpdateAsync(Employee employee) { _db.Employees.Update(employee); return Task.CompletedTask; } }

注意这里没有直接调用SaveChangesAsync,因为工作单元通常由服务层来协调。服务层拿到仓储,完成业务动作后统一调用SaveChangesAsync,保证一个用例是一个事务。如果你让每个仓储方法都自己SaveChanges,那跨实体的业务就可能出现“只写了一半成功,另一半失败”的尴尬数据。

应用层里,员工服务的实现大概长这样:

public class EmployeeService : IEmployeeService { private readonly IEmployeeRepository _repository; public EmployeeService(IEmployeeRepository repository) { _repository = repository; } public async Task<IEnumerable<EmployeeListItemDto>> GetEmployeesAsync() { var employees = await _repository.GetListAsync(); return employees.Select(e => new EmployeeListItemDto { Id = e.Id, Name = e.Name, Department = e.Department, YearsOfWork = DateTime.Now.Year - e.HireDate.Year }); } }

这种代码看起来很朴素,但好处是:业务规则“如何计算在职年限”被限定在服务层里,将来改成按天计算,不用动Controller和View。

3.4 控制器和视图的联动:请求怎么走一遍

Controller应该保持“薄”。一个典型的Action方法通常是:从路由和表单里拿参数,调用服务,把DTO映射成ViewModel,返回View。

public class EmployeeController : Controller { private readonly IEmployeeService _service; public EmployeeController(IEmployeeService service) { _service = service; } public async Task<IActionResult> Index() { var employees = await _service.GetEmployeesAsync(); var viewModels = employees.Select(e => new EmployeeListItemViewModel { Id = e.Id, Name = e.Name, Department = e.Department, YearsOfWork = e.YearsOfWork }); return View(viewModels); } }

View里对应做展示即可:

@model IEnumerable<MyApp.Web.ViewModels.EmployeeListItemViewModel> <table> <thead> <tr> <th>姓名</th> <th>部门</th> <th>在职年限</th> </tr> </thead> <tbody> @foreach (var item in Model) { <tr> <td>@item.Name</td> <td>@item.Department</td> <td>@item.YearsOfWork</td> </tr> } </tbody> </table>

这样整个请求链路就变成了:浏览器发起Http请求,路由匹配到EmployerController的Index,Controller调用Application层的IEmployeeService,Service调用Domain层的IEmployeeRepository接口,基础设施层中的EmployeeRepository再操作数据库。数据按原路返回,最后在View里渲染成HTML。这条链路被很多人叫做“五层MVC”:View、Controller、ViewModel、Service、Repository各占一个角色。

3.5 依赖注入把五层串起来

在ASP.NET Core里,依赖注入是内置的,不需要引入额外的容器框架。在Program.cs里注册各个接口的实现即可:

builder.Services.AddScoped<IEmployeeRepository, EmployeeRepository>(); builder.Services.AddScoped<IEmployeeService, EmployeeService>(); builder.Services.AddDbContext<AppDbContext>(options => options.UseSqlServer(builder.Configuration.GetConnectionString("Default")));

注册作用域顺序很重要:Repository依赖DbContext,Service依赖Repository,Controller依赖Service。容器会自动解析整条链路。这里我建议遵循三个生命周期规则:DbContext用AddScoped,仓储用AddScoped,服务用AddScoped。不要用Singleton去注入Scoped服务,否则会出现数据库上下文被多个请求共享的问题。

有人会问,为什么不直接用MediatR或Autofac那样更高级的模式?我只能说,架构的复杂度应该匹配项目的复杂度。如果你的团队还没完全理解“接口+实现”的分离,盲目引入额外框架只会让排查问题变得困难。先用内置DI把五层MVC跑通,等真的需要更多扩展点时,再考虑引入更重的组件。

4. 这套架构在真实项目里踩过的坑与调优经验

4.1 事务与工作单元的处理

我在前面提到过,服务层统一调用SaveChanges。但多人协作时,总有人图方便在Repository里直接SaveChanges,还会说“我这个查询需要立刻写入”。一旦这样的代码出现,就会出现跨服务调用时“内层已经提交,外层回滚无效”的惨剧。

正确的做法是把“事务边界”放在服务层。如果一次操作要修改多个实体,甚至调用多个仓储,那么在服务层器里显式开启一个事务:

public async Task CreateEmployeeWithDepartmentAsync(Employee employee, Department department) { await using var transaction = await _db.Database.BeginTransactionAsync(); try { _db.Departments.Add(department); await _db.SaveChangesAsync(); employee.DepartmentId = department.Id; _db.Employees.Add(employee); await _db.SaveChangesAsync(); await transaction.CommitAsync(); } catch { await transaction.RollbackAsync(); throw; } }

这里有个小技巧:让服务直接注入AppDbContext会破坏分层边界,所以我通常会定义一个IUnitOfWork接口,放在Application层,由基础设施层实现,暴露SaveChangesAsync和BeginTransactionAsync。这样服务层写事务既不用直接接触EF Core的细节,又不用把DbContext提升到应用层。

4.2 模型验证和视图模型该怎么做

ASP.Net MVC自带模型验证机制,通过DataAnnotation特性就能做很多基础校验。但要注意,验证最好是加在ViewModel或DTO上,而不是领域实体上。因为同一个实体在不同的场景下有不同规则。例如创建员工时,姓名必填;导入历史数据时,姓名可以有默认值。如果规则写在实体里,就会出现模型在A场景合法、在B场景非法的状况,验证代码会被掰来掰去。

我在项目里惯用的做法是:Controller的Action入参类型用对应的ViewModel,然后用ModelState.IsValid做入口校验。校验通过后再调用服务层,服务层对DTO做业务级校验。这样“语法校验”和“业务规则校验”分离开,错误消息既不会漏给用户,也不会在层之间泄露。

举个例子,员工工号格式001-1001是输入格式问题,应该在ViewModel上用正则校验;但“一个部门下不能有超过200名员工”是业务规则,必须在服务层校验。如果你在Controller里写这个规则,将来新增一个API接口时就会漏掉校验,造成脏数据。

4.3 性能优化:查询、缓存、异步

分层架构容易带来一个现象:页面加载变慢。因为每加一层就多一次方法调用,数据在层之间转换也会产生开销。但有经验的开发者都知道,这里的性能瓶颈大部分不在层数,而在数据库查询和对象映射。

优化思路第一条:仓储返回的数据尽量精简。比如列表页只需要Id、Name、DepartmentId,就不要Select *。用EF Core时,可以写成投影:

var employees = await _db.Employees .Where(e => e.IsActive) .Select(e => new EmployeeListItemDto { Id = e.Id, Name = e.Name, Department = e.Department.Name }) .ToListAsync();

这样即使Repository接口返回的是查询结果,但实际SQL只查需要的列。优化思路第二条:给整个服务方法加缓存。比如“部门列表”这种几乎不变的数据,第一次从数据库加载后放入内存缓存,后续请求直接从缓存拿。但要注意,服务层的缓存键必须考虑参数和用户权限,否则A用户能看到B用户的数据就麻烦了。

异步方面,ASP.NET Core天然支持async/await,Controller的Action建议改成async Task<IActionResult>,服务层和仓储的接口也尽量返回Task。必须提示的是,异步并不是无脑加async就能变快,它的价值在高并发时减少线程占用。对于每秒只有几个请求的内部系统,性能提升感受不明显,但代码的复杂度会上升。所以开始时可以先同步实现,等监控到瓶颈再改异步。

4.4 和外部系统集成时怎么做适配

在标题相关的项目里,“真实的人物对象世界”可能不只是数据库,还包括外部设备或第三方系统。比如热词里提到的西门子OPC、USB摄像头免费组件,这些都是典型的“外部世界”。四层架构在这里的优势就体现出来了:所有外部交互都被挡在基础设施层后面。

举一个我实际处理过的例子:一个质检系统需要读USB摄像头扫码枪。如果把摄像头的SDK调用写在Controller里,那么页面请求每次都要初始化摄像头,这既慢又不稳定。后来我在基础设施层写了一个CameraService,后台独立线程采集画面并通过事件更新缓存;Web层只从缓存拿帧数据,输出到前端。Controller没有一行与摄像头有关的代码。这样即使摄像头掉线、重新插拔,页面也几乎不受影响。

类似地,连接OPC设备读取数据时,保持服务接口稳定,底层协议随便换。我之前用过OPC DA,后来设备升级成OPC UA,我只需要改基础设施层的实现,应用层和表现层一行代码不用动。这正是“人机统一隐喻真实对象”的另一种表达:真实世界中的设备会变,但“读取当前温度”这个业务意图在代码里的接口形态可以一直稳定。

4.5 常见问题速查表

现象大概率原因排查思路
Controller里无法解析服务实例依赖注入没有注册接口实现检查Program.cs中的AddScoped/AddTransient注册
仓储方法执行多次SaveChanges导致数据不一致Repository内部自行提交事务移除仓储内的SaveChanges,统一由服务层提交
数据库表结构变化后,ViewModel报错视图和实体直接绑定了引入ViewModel,不要将实体直接作为Model
查询数据特别慢数据层没有做投影,且缺乏索引检查SQL日志,精简查询字段
一个Controller里写了大几百行服务层边界失守把Action里的业务逻辑抽到Application服务中
更换数据库提供商时改动太多代码直接依赖EF Core的SQL细节将数据访问封装到仓储接口,避免上层出现DbSet

每个团队刚切到四层五层架构时,都会经历一段“找文件比以前久”的适应期。因为以前一个Controller里面就能看完所有逻辑,现在要跨三四个项目跳来跳去。这是正常的,就像整理房间:把东西分类放好的头几天,你总是找不到袜子放哪;但只要坚持一周,你就会发现找东西更省力了。

5. 最后再说点实操体会

这套“四层平台的五层MVC”架构,说穿了不是新东西,它就是把经典的领域分层思想和ASP.Net MVC模板结合起来。真正难的不是技术,而是克制自己想“图省事”的冲动。还有一点很重要:架构不能乱抄,要根据团队规模和项目生命周期来剪裁。如果你一个人写个几百行的工具页面,硬上四层项目反而碍手碍脚;但如果你在维护一个要活五六年、不断有需求变化的系统,那么前期多花半天搭好骨架,远比后期重构来得划算。

我个人在搭建这类架构时,还有几个小习惯想分享。第一,所有项目名都用有业务含义的命名,不要用“DAL”“BLL”这种缩写,时间久了没人看得懂。第二,每个服务方法的注释里只写业务意图和特殊规则,不写“调用仓储”这类显而易见的话。第三,定期用VS的架构分析工具或手工检查项目引用,防止有人偷偷让Web项目直接引用了Infrastructure项目。第四,如果你们团队还没人用过依赖注入,第一次会议一定要花两个小时讲清楚生命周期,不然后面会出现各种诡异的上下文冲突。

如果你正在准备用VS里的C#和ASP.Net MVC模板来启动一个系统,我建议你就按照上面提到的四层平台去拆分解决方案,再在表现层严格维护View、ViewModel、Controller、Service、Repository五个角色的边界。一开始你会觉得文件目录变多层,代码量变多,但当你真正遇到一次“需求变更只需要改动一个方法,其他文件全都不动”的时刻,你会觉得这些麻烦都值得。

返回列表