
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.WebASP.NET Core Web应用MVCMyApp.Application类库包含服务接口、DTO、映射配置MyApp.Domain类库包含实体、业务规则、仓储接口MyApp.Infrastructure类库包含EF Core的DbContext、仓储实现、三方案件创建完成后分别设置项目引用Web引用Application和DomainApplication引用DomainInfrastructure引用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 { TaskEmployee? GetByIdAsync(Guid id); TaskIEnumerableEmployee 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 DbSetEmployee Employees SetEmployee(); public AppDbContext(DbContextOptionsAppDbContext options) : base(options) { } }仓储类则实现领域层的接口public class EmployeeRepository : IEmployeeRepository { private readonly AppDbContext _db; public EmployeeRepository(AppDbContext db) { _db db; } public async TaskEmployee? GetByIdAsync(Guid id) { return await _db.Employees.FindAsync(id); } public async TaskIEnumerableEmployee 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 TaskIEnumerableEmployeeListItemDto 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 TaskIActionResult 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 IEnumerableMyApp.Web.ViewModels.EmployeeListItemViewModel table thead tr th姓名/th th部门/th th在职年限/th /tr /thead tbody foreach (var item in Model) { tr tditem.Name/td tditem.Department/td tditem.YearsOfWork/td /tr } /tbody /table这样整个请求链路就变成了浏览器发起Http请求路由匹配到EmployerController的IndexController调用Application层的IEmployeeServiceService调用Domain层的IEmployeeRepository接口基础设施层中的EmployeeRepository再操作数据库。数据按原路返回最后在View里渲染成HTML。这条链路被很多人叫做“五层MVC”View、Controller、ViewModel、Service、Repository各占一个角色。3.5 依赖注入把五层串起来在ASP.NET Core里依赖注入是内置的不需要引入额外的容器框架。在Program.cs里注册各个接口的实现即可builder.Services.AddScopedIEmployeeRepository, EmployeeRepository(); builder.Services.AddScopedIEmployeeService, EmployeeService(); builder.Services.AddDbContextAppDbContext(options options.UseSqlServer(builder.Configuration.GetConnectionString(Default)));注册作用域顺序很重要Repository依赖DbContextService依赖RepositoryController依赖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/awaitController的Action建议改成async TaskIActionResult服务层和仓储的接口也尽量返回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五个角色的边界。一开始你会觉得文件目录变多层代码量变多但当你真正遇到一次“需求变更只需要改动一个方法其他文件全都不动”的时刻你会觉得这些麻烦都值得。