基于VS的C# ASP.NET MVC四层平台五层架构实战
1. "人智""数智"不是玄学:MVC 要解决的是两个世界的翻译问题
我第一次看到"人智数智的思维语言世界""人机统一地隐喻'真实的人物对象世界'"这种标题时,第一反应是:这是哪个项目组在写架构说明,还是某个产品经理做需求汇报时的措辞?但沉下心想了几分钟,我反而觉得这个说法比大多数技术文档都更接近 MVC 的本质。做 ASP.NET MVC 这些年,我见过太多人把架构理解成"UI 调 BLL、BLL 调 DAL"的三层复制粘贴,却很少去想一个更根本的问题:用户脑子里的"人物对象世界"(张三、李四、合同、订单、审批流),和系统里的"思维语言世界"(Controller、Model、View、数据库表、对象状态)是怎么统一起来的。
人智,是人对业务对象的认知语言,它可以是一句话:"给张三建档,他今年28岁,身份证号是 xxx"。数智,是数据和机器的运算语言,它是一组类、方法、字段、表的集合。MVC 架构模板到这里就派上用场了:View 负责把机器世界翻译回人能看懂的界面,Controller 负责把人的动作翻译成系统命令,Model 负责在两边之间承载一套"统一隐喻"的对象。所谓人机统一地映射真实人物对象世界,本质就是让一次页面提交变成一次对象状态变更,再变成一条数据库记录;反过来,数据库里的一行记录又能经过查询、组装、渲染,变成用户眼前的一张列表。这个双向翻译过程如果做不干净,项目就会越改越乱。
你可能会说,这不就是 MVC 的基础课吗?但关键就在这里:默认的 ASP.NET MVC 项目模板给了一个"三层结构"的起点,可大部分项目真正需要的,是标题里说的"四层平台的五层 MVC"。我没法考证这个说法是否有标准定义,但按我拆解这套架构的落地经验,它指的是一套很具体的工程约束:表现平台、应用平台、领域平台、基础设施平台四层协作,加上 View、Controller、AppService、Domain、Repository 五级请求链路。这篇文章我就按这条思路,用 VS 里的 C# + ASP.NET MVC 模板,从创建项目开始,一直写到能跑的完整代码,中间穿插我踩过的一些坑。
1.1 从"真实人物对象世界"到"代码世界的思维语言"
先把这个标题翻译成人话。假设我们要做一个人员档案管理系统,"真实的人物对象世界"里有员工、身份证号、年龄、部门、入职日期。这些对象本身不依赖软件而存在,是业务事实。但当需求方说"我要录入一个人的档案"时,这句话在他脑子里是连续的、有语义的;到了代码里,我们必须把它拆成离散的动作和结构:一个 Person 类,一个 Create 方法,一张 Person 表,一组校验规则。
"思维语言世界"指的就是这层离散化的代码世界。真实世界里你说"张三入职了",这句话隐含了合同、权限、岗位、考勤一系列变化;代码世界里没有"入职"这个动词,只有对象状态的变化:向数据库插入一条记录、给角色表加一个关联、把状态字段从"待入职"改成"在职"。MVC 的价值就是在这个翻译过程中划定职责边界:人怎么操作、系统怎么响应、模型怎么承载、数据怎么落地,各管一段,谁也别越界。
这里要注意一个常见的误导:很多人以为 Model 就是数据库表,把 DbContext 里的实体类直接当 MVC 的 Model 用。这个做法在教程级别的小 Demo 里没毛病,但一进入真实业务就会出事。因为数据库表关心的是存储结构,而"人物对象世界"关心的是业务规则。例子:真实世界里"年龄不能是负数"是常识,但数据库里只要字段类型是 int,你就能插入 -5;真实世界里"身份证号不能重复建档"是需要跨多条记录才能判断的规则,单看一行表结构根本发现不了。所以我在后文会把 Model 这个模糊概念拆成 ViewModel、DTO、领域实体三层,避免它们混为一谈。
1.2 为什么传统三层撑不住这个隐喻
传统三层架构长这样:UI 层调用 BLL(业务逻辑层),BLL 调用 DAL(数据访问层)。如果项目只有几十张表、几百个方法,这套结构完全够用。但业务规则稍微复杂一点,三层就会开始"漏"。最典型的变化是:Controller 里渐渐出现if (person.Age < 0)这种散落的校验,BLL 里出现对数据库上下文直查的代码,DAL 里又混着业务判断。
我接手过的一个老项目就是这种状态。表面上分了 Web 层、Service 层、Repository 层,但实际调用关系是乱的:有些逻辑直接写在 Controller,有些写在 Service 里还同时操作多个 Repository,有些校验规则被复制到了三个地方。结果就是,一个"身份证号不能重复"的规则改了两次,只改对了一处,上线后产生了大量重复档案。后来问题查了半天,根子不是某一段代码写错,是根本没有一个地方敢说"业务规则只存在这里"。
传统三层之所以撑不住"统一隐喻",是因为它把 Model 当成了一个被动装数据的容器:UI 改数据、BLL 改数据、DAL 也改数据,大家都能改,规则就很难一致。真正要让"人物对象世界"在代码里保持一致性,必须把领域规则从各层捞出来,放进一个谁也不能随意突破的核心层——这就是四层平台的价值。
2. 为什么最终落在 VS 的 C# ASP.NET MVC 架构模板上
选型这件事,很多人以为"用哪个框架不重要,重要的是人"。但在这个需求上,框架选择确实有讲究。标题点名了 VS、C#、ASP.Net、MVC 架构模板,说明这不是一个随意挑选的组合,而是有着具体匹配度的选择:C# 的强类型和面向对象特性适合承载"对象世界"的建模;ASP.NET MVC 的路由与请求管线适合做"人智语言"到"数智命令"的翻译;VS 开发环境和项目模板能减少大量初始化成本。下面把选型逻辑拆开说。
2.1 和 WebForms、Web Pages 相比,MVC 的模型最接近"人智-数智统一"
在 ASP.NET 家族里,MVC 之前的主力是 Web Forms。Web Forms 的设计思路是用服务器控件把网页伪装成桌面程序:拖一个 Button 控件,双击写点击事件,页面 PostBack 后自动帮你维护 ViewState。这种模型对于小型后台系统很高效,但有一个致命问题:它把 HTTP 请求-响应的真实过程藏起来了。程序员看到的是"按钮点击",不是"用户提交了一个 Person 对象";页面状态被序列化在 ViewState 里,而不是显式地存在于模型对象中。当你想在代码里建立"真实人物对象世界"和"系统状态"的一一映射时,Web Forms 的黑盒机制反而碍事。
MVC 不一样。它把每一次请求都显式地映射到一个 Controller Action,URL 就是命令,参数就是命令参数,返回值就是视图或数据。这非常像一个翻译器:用户提交表单,绑定的 PersonCreateViewModel 接住输入,Controller 调服务处理,模型状态发生改变,视图重新渲染。这条链路里没有 ViewState,没有 PostBack 魔法,一切状态的来源和去向都明明白白。对于想"人机统一隐喻真实对象世界"的场景,MVC 的思维模型在最底层就是吻合的。
Web Pages(Razor 单页)更轻,但过度依赖页面内嵌代码,页面和逻辑耦合太紧,一旦业务对象复杂起来,代码会像毛线团一样缠住。所以综合下来,ASP.NET MVC 是这个场景里最合适的架构模板。
2.2 用 VS 2022 创建 MVC 5 项目的实际操作与版本说明
这里说清楚一个容易混淆的点:VS 里新建项目时有两个"MVC"选项,一个是.NET Framework下的 ASP.NET Web 应用程序(传统 MVC 5),一个是.NET (Core)下的 ASP.NET Core Web 应用(MVC Core)。如果这是全新绿地项目,我个人会选 ASP.NET Core,性能更好、跨平台、依赖注入内置。但标题说的是"VS 中 C#.ASP.Net MVC 架构模板",而且现实中有大量存量系统跑在 .NET Framework 的 MVC 5 上,下面我以 VS 2022 + .NET Framework 4.8 + MVC 5 为主线讲,其中所有架构思想在 Core 上同样适用,只是部分包名和配置写法有差异。
具体创建步骤:
- 打开 VS 2022,选择"创建新项目",搜索"ASP.NET Web 应用程序(.NET Framework)"。注意千万别选成 ASP.NET Core Web 应用。
- 项目名称我建议直接用
PersonSystem.Web,框架选 .NET Framework 4.8。 - 下一步在弹出的模板窗口里选"MVC",把"Web API"也勾上(后面写接口方便),HTTPS 配置按默认即可。
- 创建完成后,App_Start 文件夹下会有一堆配置文件:RouteConfig(路由)、BundleConfig(脚本打包)、FilterConfig(过滤器),还有一个自带的 AccountController 和个人账户系统,Nancy 一样复杂的 Identity 代码。
这里要提一句模板自带的"坑":默认 MVC 模板塞了完整的个人账户注册登录功能。如果项目真的需要登录,这套东西能省不少时间;如果不需要,我通常第一时间把 AccountController 以及Views/Account目录删掉,否则它引用的 Identity 相关程序集和数据库初始化逻辑会混入业务层,干扰四层结构的判断。
2.3 配置文件的初始化:RouteConfig、BundleConfig、FilterConfig 的分工
刚创建完项目,我一般先把配置文件扫一遍,搞清楚 VS 模板自动生成了什么,避免后面排查问题时抓瞎。
- RouteConfig:定义默认路由
{controller}/{action}/{id}。MVC 的"人智统一"核心就在这:一个用户可理解的 URL(比如/Person/Index)被翻译成PersonController的Index方法。默认路由一般够用,但如果要支持 REST 风格接口或自定义 URL 别名,提前了解它的匹配顺序很重要。 - BundleConfig:负责把多个 CSS、JS 文件打包压缩。主要影响页面加载性能,和架构关系不大,但留着它别乱删,网上很多教程说它多余,实际项目上线时能省不少流量。
- FilterConfig:注册全局过滤器。默认注册了
HandleErrorAttribute全局异常过滤器。后面做统一异常处理、操作日志时,这里是标准抓手。
这三个文件虽然看起来是"模板噪音",但理解了它们是干什么的,后面改起来心里才有底。这是我反复强调的:不要以"反正模板生成了"的心态面对初始化代码,每个默认设置背后都有原因。
3.1 四层平台的职责划分与实际命名
四层平台不是把三层架构再加一层就完事,而是每一层承担的问题域变了。我把它们按"关注点"划分:
- 表现平台(PersonSystem.Web):负责与人打交道。包括 Controller、View、ViewModel、Filter、路由、打包。这一层可以理解成"翻译机的显示屏和按钮",只管用户看得见摸得着的部分。
- 应用平台(PersonSystem.Application):负责编排用例。一个用户动作(建档、修改、删除、查询)就是一条用例,应用服务把这条用例翻译成一串对领域层的调用,同时管理事务边界。这一层不关心"身份证号能不能重复",只关心"先检查重复、再创建对象、然后保存"这个流程顺序。
- 领域平台(PersonSystem.Domain):负责业务规则。实体、值对象、业务不变量、领域服务都在这里。前面说的"年龄不能为负""身份证不能重复"必须出现在这里,其他地方出现就算违例。
- 基础设施平台(PersonSystem.Infrastructure):负责技术实现。EF 的 DbContext、仓储的实现、与第三方 API 的对接、邮件发送、文件存储等。这一层存在的意义是让领域层完全不知道"数据存的是 SQL Server 还是 Oracle,或者压根没存库只在内存里"。
依赖方向是硬约束:Domain 不引用任何一层;Application 引用 Domain;Infrastructure 引用 Domain(也可以实现 Application 层定义的接口,但最少要从 Domain 往上层打通);Web 引用 Application 和 Infrastructure(仅限入口注册依赖,业务代码只能面向 Application 的接口)。谁破坏了这个方向,架构就会开始腐烂。
3.2 在 VS 里搭建四个项目:从空解决方案到可编译的完整步骤
我实际操作时按下面的顺序来,保证每一步都是可编译状态:
- 先建一个空解决方案,命名
PersonSystem.sln。解决方案根目录下再建四个项目文件夹,方便后期扩展模块。 - 添加第一个项目"类库(.NET Framework)",命名
PersonSystem.Domain。这个项目引用最少,什么都不用装,因为它只放 C# 类和接口。 - 添加第二个类库
PersonSystem.Application,添加项目引用指向 Domain。这里也不需要额外 NuGet 包,先让它保持干净。 - 添加第三个类库
PersonSystem.Infrastructure,引用 Domain。然后通过 NuGet 安装EntityFramework 6.4.4(MVC5 配 EF6 是很成熟的组合)。如果项目要连接 MySQL,再装对应的数据驱动,这里用 SQL Server LocalDB 演示。 - 回到之前创建的
PersonSystem.Web项目,添加引用指向 Application 和 Infrastructure,然后安装两个包:Autofac.Mvc5(依赖注入容器)、AutoMapper(对象映射,可选,我习惯先不加,能少一个概念就少一个)。
这样四个项目之间的依赖关系是:Web -> Application -> Domain,Web -> Infrastructure -> Domain。注意 Web 引用 Infrastructure 这一点很多人会质疑,下文单独解释。
3.3 Web 层引用 Infrastructure 为什么不算破坏分层
标准的分层教育讲"上层依赖下层",Web 不应该直接碰 Infrastructure。但实际工程里有一个地方必须破例:入口处的组合根(Composition Root)。依赖注入容器需要在程序启动时知道"IPersonRepository 到底由哪个类实现",而这个信息只有 Web 项目和 Infrastructure 项目共同在场时才能组装起来。
正确做法是:Web 项目在App_Start/AutofacConfig.cs里引用 Infrastructure 的类做注册,但 Controller 里的代码绝对不允许出现PersonRepository、PersonDbContext等 Infrastructure 类型。如果哪个 Controller 需要直接 new 仓储,说明分层已经失效了。换句话说,Infrastructure 的"可见性"只存在于启动配置和配置文件里,业务代码对这个层应该是透明的。这个"引用但不使用"的平衡,很多人踩过坑——要么为了严格分层禁止引用,导致容器注册要写一堆反射;要么放开了引用,结果 Controller 直接操作 DbContext,一夜回到三层架构。
4. "五层MVC"的请求链路:从 View 到 Repository 的完整代码推演
四层平台解决的是"代码放哪",五层 MVC 解决的是"一次请求经过哪些关卡"。我用一个最简单也最常见的业务来说明:人员建档。真实世界的动作是"张三来公司报到,身份证号是 110101199001010011,今年 33 岁",系统要把它变成一条 Person 记录并落库。下面从领域层开始,逐层推演完整代码。
4.1 领域层:先把"真实人物对象"翻译成不掺水的业务模型
我在 Domain 项目下建一个Person实体。这个实体不是数据库表的直接映射,它首先是业务规则的载体。看这个构造函数:
using System; namespace PersonSystem.Domain.Models { public class Person { public int Id { get; private set; } public string Name { get; private set; } public int Age { get; private set; } public string IdCard { get; private set; } public DateTime CreatedAt { get; private set; } // EF 需要无参构造,但业务代码不应该调用它 protected Person() { } public Person(string name, int age, string idCard) { if (string.IsNullOrWhiteSpace(name)) throw new ArgumentException("姓名不能为空", nameof(name)); if (age <= 0 || age > 150) throw new ArgumentException("年龄必须大于0且不超过150", nameof(age)); if (string.IsNullOrWhiteSpace(idCard)) throw new ArgumentException("身份证号不能为空", nameof(idCard)); Name = name; Age = age; IdCard = idCard; CreatedAt = DateTime.Now; } } }这里的关键设计是Id、Name、Age、IdCard全部用private set,外部不能随意改。你想改年龄?必须重开一个方法,比如ChangeAge,在方法里重新校验。这样"人物对象世界"里关于年龄的常识就只存在于这一个类中,不可能出现 UI 层偷偷塞一个负数进去的情况。protected Person()是为 EF 的实例化准备的,因为 EF 底层要通过无参构造创建代理对象。
再定义一个仓储接口,注意它属于 Domain,不属于 Infrastructure。这代表一种纪律:领域层需要的持久化能力,由领域层自己定义,基础设施负责实现。
using System.Collections.Generic; namespace PersonSystem.Domain.Repositories { public interface IPersonRepository { Person GetById(int id); IList<Person> ListAll(); void Add(Person person); bool IdCardExists(string idCard); } }接着是一个领域服务。为什么需要它?因为"身份证不能重复"这个规则需要查询整个持久化上下文,没法写进 Person 实体的构造方法里,所以抽一个静态方法承载:
using PersonSystem.Domain.Repositories; namespace PersonSystem.Domain.Services { public static class PersonDomainService { public static void CheckDuplicateIdCard(IPersonRepository repository, string idCard) { if (repository.IdCardExists(idCard)) throw new InvalidOperationException("该身份证号已存在,不能重复建档"); } } }到这里,"真实人物对象世界"的第一层翻译完成:现实中的张三变成了有规则约束的 Person 对象。任何层想绕过这个约束直接写数据,都会被构造函数和领域服务拦下来。
4.2 应用层:把用户动作"建档"编排成一条用例
接着我看应用层。它不定义业务规则,只定义"一次用户需求在系统里怎么走完"。这里用CreatePersonCommand作为入参对象,再用PersonDto作为输出对象,都不直接使用领域实体。
namespace PersonSystem.Application.Commands { public class CreatePersonCommand { public string Name { get; set; } public int Age { get; set; } public string IdCard { get; set; } } }namespace PersonSystem.Application.Dtos { public class PersonDto { public int Id { get; set; } public string Name { get; set; } public int Age { get; set; } public string IdCard { get; set; } public string CreatedAtText { get; set; } } }然后定义应用服务接口和实现:
using System.Collections.Generic; using PersonSystem.Application.Commands; using PersonSystem.Application.Dtos; namespace PersonSystem.Application.Services { public interface IPersonAppService { void Create(CreatePersonCommand command); IList<PersonDto> GetAll(); } }using System.Collections.Generic; using System.Linq; using PersonSystem.Application.Commands; using PersonSystem.Application.Dtos; using PersonSystem.Domain.Models; using PersonSystem.Domain.Repositories; using PersonSystem.Domain.Services; namespace PersonSystem.Application.Services { public class PersonAppService : IPersonAppService { private readonly IPersonRepository _personRepository; public PersonAppService(IPersonRepository personRepository) { _personRepository = personRepository; } public void Create(CreatePersonCommand command) { // 先检查业务规则,再创建对象,最后保存 PersonDomainService.CheckDuplicateIdCard(_personRepository, command.IdCard); var person = new Person(command.Name, command.Age, command.IdCard); _personRepository.Add(person); } public IList<PersonDto> GetAll() { return _personRepository.ListAll() .Select(p => new PersonDto { Id = p.Id, Name = p.Name, Age = p.Age, IdCard = p.IdCard, CreatedAtText = p.CreatedAt.ToString("yyyy-MM-dd") }) .ToList(); } } }注意Create方法里的调用顺序:先查重,再 new Person(触发实体校验),最后 Add。这是用例的编排逻辑,属于应用层;但"查重"这个规则本身在领域服务里。将来如果加一条"同一个手机号不能重复建档",应用层只需要多调用一个领域服务方法,业务规则依然只存在于 Domain。另外这里没有直接返回实体,而是转成 DTO,目的是把"页面要展示的字段"和"领域模型的完整状态"分开。
4.3 基础设施层:为了让"对象世界"能持久化
Infrastructure 项目只做技术实现。DbContext 继承 EF 的 DbContext,并指定连接字符串:
using System.Data.Entity; using PersonSystem.Domain.Models; namespace PersonSystem.Infrastructure { public class PersonDbContext : DbContext { public PersonDbContext() : base("name=PersonSystemDb") { } public DbSet<Person> Persons { get; set; } protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<Person>() .Property(p => p.IdCard) .IsRequired() .HasMaxLength(18); modelBuilder.Entity<Person>() .Property(p => p.Name) .IsRequired() .HasMaxLength(50); base.OnModelCreating(modelBuilder); } } }连接字符串写在 Web 项目的 Web.config 里,指向 SQL Server LocalDB:
<connectionStrings> <add name="PersonSystemDb" connectionString="Server=(localdb)\MSSQLLocalDB;Database=PersonSystemDb;Trusted_Connection=True;" providerName="System.Data.SqlClient" /> </connectionStrings>仓储实现:
using System.Collections.Generic; using System.Data.Entity; using System.Linq; using PersonSystem.Domain.Models; using PersonSystem.Domain.Repositories; namespace PersonSystem.Infrastructure.Repositories { public class PersonRepository : IPersonRepository { private readonly PersonDbContext _context; public PersonRepository(PersonDbContext context) { _context = context; } public Person GetById(int id) { return _context.Persons.Find(id); } public IList<Person> ListAll() { return _context.Persons.AsNoTracking().ToList(); } public void Add(Person person) { _context.Persons.Add(person); _context.SaveChanges(); } public bool IdCardExists(string idCard) { return _context.Persons.Any(p => p.IdCard == idCard); } } }这里有个实操细节:ListAll()我用了AsNoTracking()。因为列表页只需要展示,不需要跟踪状态,这样能避免 EF 在上下文中维护冗余的快照数据。但GetById就没有加,因为后续可能还要修改实体,需要 EF 跟踪状态。这种细节对性能影响不大,但写多了就会形成职业习惯。
4.4 表现层:Controller、ViewModel、View 只负责"翻译"
表现层是离用户最近的层,也是最容易腐化的层。我的原则是:这里不写业务规则、不写 SQL、不写仓储调用,只有"收参数、调服务、处理结果"三步。
先建一个显示用的 ViewModel:
using System.Collections.Generic; using PersonSystem.Application.Dtos; namespace PersonSystem.Web.Models { public class PersonListViewModel { public IList<PersonDto> Persons { get; set; } } }再建一个表单提交用的 ViewModel,直接利用 System.ComponentModel.DataAnnotations 做客户端校验:
using System.ComponentModel.DataAnnotations; namespace PersonSystem.Web.Models { public class PersonCreateViewModel { [Required(ErrorMessage = "请输入姓名")] [StringLength(50)] public string Name { get; set; } [Range(1, 150, ErrorMessage = "年龄必须大于0且不超过150")] public int Age { get; set; } [Required(ErrorMessage = "请输入身份证号")] [StringLength(18)] public string IdCard { get; set; } } }Controller 写起来就非常薄:
using System; using System.Web.Mvc; using PersonSystem.Application.Commands; using PersonSystem.Application.Services; using PersonSystem.Web.Models; namespace PersonSystem.Web.Controllers { public class PersonController : Controller { private readonly IPersonAppService _personAppService; public PersonController(IPersonAppService personAppService) { _personAppService = personAppService; } public ActionResult Index() { var model = new PersonListViewModel { Persons = _personAppService.GetAll() }; return View(model); } [HttpGet] public ActionResult Create() { return View(new PersonCreateViewModel()); } [HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(PersonCreateViewModel vm) { if (!ModelState.IsValid) { return View(vm); } try { _personAppService.Create(new CreatePersonCommand { Name = vm.Name, Age = vm.Age, IdCard = vm.IdCard }); return RedirectToAction("Index"); } catch (InvalidOperationException ex) { ModelState.AddModelError("", ex.Message); return View(vm); } } } }Controller 里没有任何业务判断,唯一的异常处理是把领域层抛出的"身份证已存在"错误翻译成页面上的提示文本。这个模式很重要:业务规则错误从 Domain 一路抛到 UI,Controller 负责接住并展示,但不负责判断规则。View 只负责渲染:
@model PersonSystem.Web.Models.PersonListViewModel <h2>人员档案列表</h2> <table class="table"> <thead> <tr> <th>ID</th> <th>姓名</th> <th>年龄</th> <th>身份证号</th> <th>建档日期</th> </tr> </thead> <tbody> @foreach (var p in Model.Persons) { <tr> <td>@p.Id</td> <td>@p.Name</td> <td>@p.Age</td> <td>@p.IdCard</td> <td>@p.CreatedAtText</td> </tr> } </tbody> </table> <p>@Html.ActionLink("新建档案", "Create")</p>到这里,一次"真实人物建立档案"的动作,从人智语言(填写表单)经 ViewModel、Controller、AppService、Domain、Repository 落到数据库,完整走完了"五层 MVC"链路。每个人看到"五层",第一反应是"层越多越慢",但实际上每一层只是做自己该做的一件事:Controller 不碰数据库,Service 不写业务规则,Repository 不管页面展示。链路是长了,职责却清晰了。
4.5 用 Autofac 把五层串起来:依赖注入的注册与启动
代码写到这里,类之间还只是"手动 new"的关系。如果不做依赖注入,你会在 Controller 里看到new PersonAppService(new PersonRepository(new PersonDbContext())),那样分层再漂亮也会被这种硬编码摧毁。我习惯用 Autofac,它在 MVC 5 上的集成很成熟。
在 App_Start 下新建AutofacConfig.cs:
using System.Reflection; using System.Web.Mvc; using Autofac; using Autofac.Integration.Mvc; using PersonSystem.Application.Services; using PersonSystem.Domain.Repositories; using PersonSystem.Infrastructure; using PersonSystem.Infrastructure.Repositories; namespace PersonSystem.Web { public static class AutofacConfig { public static void Configure() { var builder = new ContainerBuilder(); builder.RegisterControllers(typeof(MvcApplication).Assembly); builder.RegisterType<PersonAppService>().As<IPersonAppService>().InstancePerRequest(); builder.RegisterType<PersonRepository>().As<IPersonRepository>().InstancePerRequest(); builder.RegisterType<PersonDbContext>().InstancePerRequest(); builder.RegisterModule<AutofacWebTypesModule>(); var container = builder.Build(); DependencyResolver.SetResolver(new AutofacDependencyResolver(container)); } } }然后在 Global.asax 的 Application_Start 里调用:
protected void Application_Start() { AreaRegistration.RegisterAllAreas(); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); AutofacConfig.Configure(); }Autofac 的作用是让"每一层面向接口编程"真正成立:Controller 只认识IPersonAppService,AppService 只认识IPersonRepository,上层的类根本不关心实现类长什么样。将来把 SQL Server 换成 PostgreSQL,或者把仓储实现换成 Redis 缓存版本,只需要改注册代码,业务代码一个字符都不用动。这正是"人智语言"和"数智实现"解耦的工程载体。
5. 我在这套架构上踩过的坑:EF 序列化循环、边界失效与 Migrations 实战
纸上架构说得再漂亮,落地时总会遇到各种"明明按规范写但就是跑不对"的瞬间。下面几个坑是我在 MVC 五层架构里反复踩过的,写出来供后来人少走弯路。
5.1 JSON 序列化循环:EF 代理类的经典杀手
我做过一个接口,Controller 里返回 JSON,直接把从仓储拿出来的实体丢给Json()方法。结果运行时抛异常,提示检测到循环引用。原因是 EF 默认开启延迟加载(Lazy Loading),Person实体如果加了导航属性(比如Department),那Person.Department是代理对象,访问它时 EF 自动去查数据库;而Department下又有List<Person>,当 JSON 序列化器遍历对象图时,就在两个实体之间来回绕圈,形成循环引用。
解法很简单但要养成习惯:永远不要把领域实体直接丢给序列化器。接口返回 DTO,页面渲染用 ViewModel,实体只在 Domain 和 Application 之间传递。另外如果必须保留导航属性序列化,可以在 Application_Start 里配置ReferenceLoopHandling.Ignore,但这只是治标,真正治本的是禁止实体出层。我后来给团队定了一条死规矩:实体不允许出现在 Web 项目的任何位置,除了依赖注入注册时顺便提到的程序集名。
5.2 业务规则悄悄放错层:导致"同一世界、两套规则"
四层架构刚落地时,团队里最容易出现的问题是"查重到底在哪儿写"。有人为了省事,在 Controller 里直接查库判断身份证是否重复;有人把IdCardExists的调用放在 AppService 里;还有人直接在数据库里建唯一索引,靠数据库报错来拦截。三种做法短期都能实现功能,但后患无穷。
正确的位置只有一个:领域层。要么放进 Person 实体的构造校验(单对象规则),要么放进领域服务(跨对象规则),就像前面的CheckDuplicateIdCard那样。因为这条规则属于"业务事实",无论哪条用例、哪个页面、哪个接口,都必须遵守它。把它写在 Controller,第二个入口就漏了;把它写进 AppService,第三个用例来了还是要复制一遍。所以我在代码审查时看到新增的if (IdCardExists(...)),第一反应永远是"这行代码不该出现在这一层"。
5.3 Migrations 启动失败:模型改了数据库没变
EF6 的默认数据库初始化策略是CreateDatabaseIfNotExists。开发阶段你会觉得很爽:项目跑起来,数据库自动就建好了。但等你给 Person 实体加了字段,比如新增一个Phone属性,再跑项目时会报类似"无效的列名 Phone"的错误。原因很直接:EF 只会在数据库不存在时创建,不会自动更新已存在的表结构。
正确做法是用 Migrations。在 VS 的包管理器控制台里,默认项目选择 PersonSystem.Infrastructure,然后依次执行:
Enable-Migrations Add-Migration AddPhoneToPerson Update-Database这三个命令会在 Infrastructure 项目下生成 Migrations 文件夹。Add-Migration 会对比当前代码模型和上次迁移快照,自动生成变更脚本;Update-Database 把变更应用到本地库。上线部署时,把初始化器改成MigrateDatabaseToLatestVersion,让程序启动时自动升级数据库结构。这个坑一旦处理不好,就会在团队协作里频繁出现"明明代码没动,为什么我的机器跑不起来"的尴尬。
5.4 ViewModel 直接塞实体:UI 层穿透的后遗症
还有一种常见的偷懒:ViewModel 里放List<Person>,视图里直接用p.Age、p.IdCard,看起来代码量更少。但问题在于,领域实体是"业务语言",它的字段命名和页面展示经常不是一个概念。比如页面要显示"建档日期:2024-01-15 14:30",但实体里是DateTime CreatedAt;页面要显示"年龄是否成年",实体里根本没有这个字段,视图里只能写@(p.Age >= 18 ? "成年" : "未成年")。这种三元表达式散落在视图里,将来想改文案,只能一个个页面找。
我后来强制规定:表现层一律使用 ViewModel,ViewModel 里尽量引用 DTO,不允许直接引用领域实体。页面需要展示的每个字段,都在 DTO 或 ViewModel 里明确算好。这样视图里没有任何逻辑,只有纯粹的展示,整个"翻译"过程在应用层完成,而不是让 UI 层替应用层干活。
6. 三层、四层、五层到底差在哪:一张对比表与落地检查单
架构概念最容易越讲越玄。到最后我习惯用一张表把三种结构放在一起对比,帮团队统一认识。
6.1 一张表说清职责变化
| 能力维度 | 传统三层架构 | 四层平台 | 五层MVC请求链路 |
|---|---|---|---|
| 视图展示 | View(.aspx/.cshtml) | 表现平台 | View 负责翻译模型为界面 |
| 请求接收 | Controller 直接调 BLL | 表现平台 Controller | Controller 只做参数接收与调用编排 |
| 用例编排 | BLL 承担 | 应用平台 AppService | AppService 承担用例流程与事务边界 |
| 业务规则 | 散落在 BLL/Controller/DAL | 领域平台 Domain | DomainService/实体不变量负责规则 |
| 数据访问 | DAL 直连数据库 | 基础设施平台 Infrastructure | Repository 抽象持久化细节 |
| 依赖方向 | UI -> BLL -> DAL,容易反向 | 单向依赖,Domain 是核心 | 请求从外到内,响应从内到外 |
| 典型症状 | Controller 膨胀、业务规则重复 | 层职责清晰,但需要足够的类 | 链路变长,但每个类都很薄 |
这张表是我实际培训团队时用的。三层架构不是不能用,而是它把"用例编排"和"业务规则"都塞进了一个 BLL,项目小的时候没问题,业务量一上来,BLL 里的方法会越来越长,直到没人敢改。四层平台把业务规则专门抽出来保护起来,五层 MVC 则在每次请求中强制规定链路顺序,让架构约束变成代码之间的无形边界。
6.2 落地检查单:每次新功能开发前的 5 个自问
我建议你在 Review 代码时拿着下面这 5 个问题过一遍,基本能避免大部分分层腐烂:
- Controller 里有没有出现
new PersonRepository()或DbContext?如果有,说明表现层越界了。 - Controller 方法体里有没有超过"调接口、收结果、展示"三步?如果里面出现了 if/else 的业务判断,需要把逻辑下沉到 Application 或 Domain。
- ViewModel 里有没有直接出现领域实体类型?页面展示的字段是否都已经通过 DTO 预先算好?
- 新增的业务规则(如"已离职人员不能重新建档")是在 Domain 里实现,还是在 AppService/Controller 里用 if 解决?
- 一次请求涉及多个仓储操作时,事务边界是否明确放在 AppService 层,而不是让每个 Repository 各自提交?
最后一个问题值得多说一句:示例里PersonRepository.Add直接调用了SaveChanges,这只适合单仓储提交。如果一次建档需要同时写入 Person 和 OperationLog 两个仓储,正确的做法是在 AppService 里用TransactionScope包住整个用例:
using (var scope = new TransactionScope(TransactionScopeOption.Required, new TransactionOptions { IsolationLevel = IsolationLevel.ReadCommitted })) { _personRepository.Add(person); _operationLogRepository.Add(operationLog); scope.Complete(); }这样数据库事务的粒度就是"一次完整用例",而不是"一次仓储操作"。
6.3 项目规模与这套架构的适配建议
最后说点实话:四层平台加五层 MVC 不是银弹,它有一个明显代价是类数量多、代码文件多。一个简单的下拉框查询,在三层架构里可能只要一个 Controller 加一个 BLL;进了四层以后,你至少要写 DTO、Command、AppService 接口、AppService 实现、Repository 接口、Repository 实现,前后六七个类。所以当你负责的是几十个页面、只有几个人维护的小工具型系统时,上这套架构确实有点杀鸡用牛刀。
但如果系统要活五年以上、有多个接口客户端、业务规则会持续演化,我强烈建议一开始就上这套结构。因为架构腐烂的修复成本是越晚越高的。一个已经三年没人敢动的 BLL,你不会想去重构它;而一个从第一天就按四层平台拆好的模块,你可以毫无压力地替换数据库、增加接口、重写页面,甚至把某个模块拆成微服务,其余模块都不用动。
我在把传统三层改造成四层平台时,最直观的一个感受是:不是代码变多了,而是每个人都知道了该去哪里找代码。有人反馈"身份证不能重复",我去 Domain 找;有人反馈"保存成功后要发一条消息",我去 Application 找;有人反馈"页面要多展示一列",我去 ViewModel 和 View 找。标题里说的"人智数智的思维语言世界",落到工程上无非就是这么一件事:让每一层只说自己该说的语言,然后通过 MVC 这条标准链路把它们统一起来。理解了这层含义,用 VS 的模板也好、用其他 MVC 框架也好,你都能搭出同样清爽的架构。