夜雨聆风学习资料网

ARTICLE · 1182465

ASP.NET Core MVC源码:一个请求的完整旅程

ASP.NET Core MVC源码:一个请求的完整旅程

去年做智慧供热平台,上线第二天热力站集中上报,整个API全线卡死。查了两天,根因是Attribute构造函数里注入了一个Scoped服务。就这一行代码。后来我把ASP.NET Core源码从AddMvc()一路读到请求结束才明白:我们写的Controller、Attribute、Filter,运行时被框架翻译成了什么?一个请求进来,框架到底走了几步?这篇文章把那份源码笔记完整整理给你:一张MVC全链路地图、一套框架设计思维、几个马上能用的扩展点技巧。

01先看地图:三层结构,三种时间

很多人啃MVC源码啃不下去,不是因为难,而是因为一开始就把三个不同时间点发生的事混在一起看。

ASP.NET Core MVC从启动到运行,其实是三个清晰分离的阶段。

阶段
触发时机
核心动作
产出物
阶段一
Startup里调AddMvc()
IOC注册+反射加载dll
ApplicationPart集合
阶段二
UseRouting/MapControllerRoute
注册中间件+首次请求构建EndPoint
ActionDescriptorCollection→EndPoint
阶段三
第一个HTTP请求进来
路由匹配+执行
RequestDelegate委托被调用

为什么非要拆成三层?一个很现实的答案:为了兼容旧版本。EndPoint这个抽象是.NET Core 2.2才引入的,在那之前,路由匹配完就直接执行了,中间没有插入口。三层结构是在不破坏旧代码的前提下,硬生生把「匹配」和「执行」拆开的。

理解这一点,你就理解了微软那帮人做架构时的取舍逻辑——所有的抽象都不是为了好看,是为了解决「想扩展但插不进去」的问题。

02第一阶段:AddMvc() 到底干了什么

AddMvc()这个名字起得特别有欺骗性。听起来像是「添加MVC功能」,实际上它干的是两件完全不同的事。

▪︎ 本质一:往IOC容器里塞一大堆MVC相关的服务注册(AddMvcCore、AddApiExplorer、AddAuthorization…十几层链式注册)

▪︎ 本质二:把进程里所有相关的dll反射加载为ApplicationPart

关键在第二件事。ApplicationPartManager会去扫描程序集,找出所有「可能是Controller」的类型,打上标记,塞进一个集合里。注意,这时候只是收集,还没有做任何规则判断。

这个设计翻译成人话就是:「我先不管你要干嘛,先把所有可能的候选人名单拉出来。」

这是第一个可以偷师的套路,我管它叫候选集先行(Candidate First)。

在你自己的业务代码里,这个模式用得非常多。做规则引擎、做审批流、做权限校验,最优的做法永远不是「一边遍历一边判断一边执行」,而是:先遍历一遍把符合粗筛条件的全部收集成候选集,再对候选集做精细筛选,最后执行。

为什么?因为粗筛和精筛的规则变化频率完全不同。候选集的收集方式可能半年不变,但精细规则可能一个月改三次。分开之后,你改精筛规则的时候,完全不用碰扫描逻辑。

我在做供热平台的告警引擎时就是这么重构的:先扫出所有「可能是告警规则」的配置项,再按设备类型、点位类型、阈值条件层层过滤。重构之前,加一条新规则要动三段代码;重构之后,只加一个规则类就行。

03第二阶段:从 ApplicationPart 到 ActionDescriptorCollection

这一阶段是整个MVC源码里信息密度最高的部分。入口在UseRouting()和MapControllerRoute()这里。注意一个反直觉的点:很多工作是在Use和Build时同步完成的,而不是在请求进来时。

具体链条:MapControllerRoute → 创建ControllerActionEndpointDataSourceFactory → 注册UpdateCollection的变更订阅(ChangeToken)→ ControllerActionEndpointDataSource构造函数里调用Subscribe() → Subscribe()里主动调了一次GetChangeToken() → 触发前面注册的UpdateCollection() → ActionDescriptorCollection构建完成。

看到那个「主动调一次」了吗?这是源码里非常经典的一个小技巧,我管它叫订阅即触发(Subscribe and Fire Once)。设计意图是:让「初始化」和「变更」走同一条代码路径。

好处是什么?你只需要维护一套逻辑。初始化的时候它是「第一次变更」,运行时配置热更新的时候它是「第N次变更」,代码完全复用。

注意这里有个坑:不要在Reload里做耗时操作。因为它会在构造函数里同步执行,如果第一次加载就卡住,整个应用启动就卡住了。源码里微软用Lazy和ChangeToken把这件事推到了首个请求,就是为了避免启动被拖慢。你要学的不是照抄,而是理解他们为什么把这件事推到请求时做。

精筛环节:Controller和Action的准入规则

收集完候选集之后进入精细筛选。这部分源码在ControllerFeatureProvider和DefaultApplicationModelProvider里,规则非常具体,而且这些规则本身就是最好的防御性编程教材。

Controller的准入规则一共六条:必须是类;不能是抽象类;必须是public;不能包含泛型参数;不能有[NonController]特性;名字以Controller结尾或者带[Controller]特性。

Action的准入规则更长,一共九条:不能是特殊方法(运算符重载、属性的get/set);不能有[NonAction];不能是从object继承下来的方法;不能是Dispose方法;不能是静态方法;不能是抽象方法;不能是构造函数;不能是泛型方法;必须是public方法。

请你仔细看这九条规则,它们有一个共同特征——全部是「排除法」。

框架没有说「什么样的方法才是Action」,它说的是「什么样的方法不是Action」。这是一个非常重要的设计哲学,我称之为黑名单防御(Blacklist Defense)。

为什么?因为在开放式框架里,用白名单会误杀,用黑名单只会漏网。漏网了顶多是个不优雅的设计,误杀了就是别人的代码跑不起来。

这个思路直接可以搬到你的业务代码里。比如你写一个「自动注册后台任务」的功能,别去定义「什么样的类才是任务类」,而是定义「什么样的类不是任务类」,剩下全收。

规则能被扩展,才是好框架。[Controller]、[NonController]、[NonAction]这些特性都是微软留的「后门」。这套后门设计给我们的启发是:任何写死规则的地方,都应该留一个显式的覆盖开关。

04第三阶段:从 ActionDescriptorCollection 到 EndPoint

这是整篇文章最值得反复看的部分。

前面我们已经把Controller、Action、Filter、Parameter全部找出来,封装成了ActionDescriptor。到这一步,路由匹配的需求其实已经完全满足了。

那为什么还要再转一层,变成EndPoint?

源码里给了两个理由。第一个是官方说法:为了匹配和运行的分离。第二个才是真相,也是我想重点讲的:为了方便中间的扩展。

假设你要做接口鉴权,有两种设计。

设计A(不拆):路由匹配完立刻开始执行Controller方法。那你在哪里做鉴权?只能在Controller方法里加代码,或者在执行管道里再判断一次,白白浪费已经解析出来的信息。

设计B(拆开):路由匹配完先产出一个「待执行」的描述对象(EndPoint),然后由另一个组件来决定要不要执行。鉴权就能在「匹配完成、执行之前」这个位置插入,拿到的是完整的Action元数据——它知道你要调哪个Controller的哪个方法,知道这个方法上挂什么特性。这才是最高效的鉴权位置。

我管这个套路叫阶段产物落地(Intermediate Artifact)。

举个我们真实场景:供热平台的设备数据上报接口需要做限流。最朴素的做法是在中间件里对IP计数。但问题是——它不知道这个IP请求的是哪个热力站的哪台设备,所以只能做粗粒度限流。

后来我们按EndPoint的思路重构了:路由匹配完之后,产出一个「上报任务描述对象」,里面包含热力站ID、设备ID、数据点类型。限流组件拿到这个对象,就能做按热力站维度的精细限流。

重构前后的对比非常夸张:粗粒度限流下,一个边缘热力站网络抖动重试,会直接打满整个平台的配额;精细化之后,抖动被隔离在单个热力站内部,整体可用性上了一个台阶。

这就是「多产出一个中间对象」的威力。它看起来增加了代码量,实际上给了你插入逻辑的位置。

再看一层细节。UpdateEndpoints()里面做的事,是把每个Action转成EndPoint:循环每个Action → 交给ActionEndpointFactory → 各种检测 → 遍历路由匹配Action组成RoutePattern → 创建RequestDelegate(注意:是个委托)。

关键点在这里:RequestDelegate是一个委托,这里只是「声明」,并没有发生调用。这意味着:路由匹配阶段,框架根本没有实例化任何Controller。Controller的实例化,发生在后面请求真正执行、委托被调用的时候。

这个细节解释了困扰我很久的一个问题:为什么Controller的构造函数不能做耗时操作?因为Controller的生命周期是请求级的——它是在EndPoint执行时才被创建和注入依赖的。如果你的构造函数里有数据库查询、有Redis连接、有配置文件读取,那么每一个请求都会重新做一遍。

延迟绑定是框架设计里的顶级武器。它的本质是:把「什么时候做」这件事,从代码结构里解耦出来,交给运行时状态决定。

05五个可以直接抄的编程套路

把上面这些源码细节抽象一下,我总结了五个可以直接用到业务代码里的套路。这部分是全文的核心,建议收藏。

套路一:三层分离——结构层、规则层、执行层。

MVC的三层结构(ApplicationPart → ActionDescriptor → EndPoint),本质是把一个流程拆成:结构层(有哪些东西?收集,不做判断)、规则层(哪些是合法的?筛选,定义准入)、执行层(怎么把它们跑起来?绑定,延迟执行)。

对照检查一下你手上的代码。如果你的项目里有一个超大的Service类,里面既有「扫描有哪些插件」,又有「判断插件是否合法」,还有「调用插件」,那它一定会越来越难维护。

拆开它们的收益不是「代码变好看」,而是「变化被隔离」。加一个插件,你只改收集逻辑;改准入规则,你只改规则类;改执行方式,你只改执行器。

套路二:候选集先行——先粗后精。

不要在一趟遍历里做完所有事。反例是 foreach 里同时做 IsValid、IsMatchRule、IsAllowed 再 Process;正例是拆成三步:var candidates = CollectAllItems();(粗筛)→ var qualified = candidates.Where(IsValid);(精筛)→ qualified.ToList().ForEach(Process);(执行)。

看着多了一行,但可测试性和可扩展性是数量级的差距。每个环节都能单独写单元测试。

套路三:黑名单防御——定义「不是什么」。

在开放式的框架、插件系统、规则引擎里,优先用排除法定义规则。

别写「什么样的才是任务」:if (type.Name.EndsWith("Job") && type.IsPublic) { }

要写「什么样的不是任务」:一个 IsValidJob(Type type) 方法里连续排除抽象类、接口、泛型类型定义,以及带 SkipJobAttribute 的类型,最后 return true。

并且一定要留一个显式覆盖的特性(对应[NonController])。框架的可演进性,就藏在这些「逃生舱」里。

套路四:订阅即触发——初始化和变更共用一条路径。

构造函数里先 Reload(provider.GetAll()) 做初始化,再 provider.OnChanged += v => Reload(v) 订阅变更,两处都调用同一个 private void Reload(...)。

这个套路能帮你消灭一大类Bug:初始化路径和热更新路径行为不一致。我见过太多项目,程序启动时读配置是对的,运行中热更新配置就出问题——因为这两个功能是两个人分别写的两段代码。

套路五:阶段产物落地——在流程中间插一个对象。

改造前,中间件里只能拿到HTTP上下文,InvokeAsync(HttpContext context) 里信息太少;改造后,先解析出业务描述对象 var task = BuildTaskDescriptor(context); 再做 if (!_limiter.Allow(task)) 精细限流。

任何你觉得「这个位置信息不够,做不了精细控制」的地方,都值得考虑在它前面插一个中间产物。

06行业场景:这些设计在真实项目里怎么落地

讲完套路,说点具体的。我自己在供热行业做了十几年系统,上面这些设计思想,落地场景非常明确。

场景一:PLC设备接入网关的协议解析。

供热现场有西门子、施耐德、汇中等不同厂商的PLC和仪表,协议各不相同。最原始的写法是switch判断设备类型,一路if-else到底。

按MVC三层结构的思路重构之后:结构层扫描所有IDeviceParser实现(收集候选);规则层按设备型号、协议类型筛选出匹配的解析器(精筛);执行层产出「解析任务」对象,交给统一执行器(延迟执行)。

带来的直接收益是:新增一种仪表协议,只需要加一个解析器类,不需要改任何现有代码。这在投标和交付场景里特别重要——甲方临时加设备型号是家常便饭。

场景二:实时大屏的数据订阅。

我们的大屏用SignalR推送实时数据。早期是「谁来订阅就给谁推全部数据」,点位一多就扛不住。

后来借鉴EndPoint的思路,先为每个订阅请求产出一个「订阅描述对象」(包含用户所属热力站、关注的点位类型、订阅频率),再交给推送组件。这样推送组件就能做按热力站分流,单台服务器承载的点位数量提升了差不多三倍。

场景三:多租户的接口权限。

SaaS化的供热监管平台,不同热力公司看到的数据范围不同。鉴权如果写在Controller里,每个方法都要写一遍。用ModelConvention的思路,可以在匹配完成之后、执行之前统一注入租户过滤条件,业务代码里完全无感。

场景四:告警规则的动态扩展。

这是上面「黑名单防御」的直接应用。告警规则的种类会不断增长(超温、超压、失流、通信中断……),如果每加一种规则就要改核心引擎,这个引擎迟早失控。正确做法是:扫描所有IAlarmRule实现 → 按规则类型和适用范围精筛 → 产出「告警判定任务」 → 统一执行。

场景五:配置热更新的统一入口。

ChangeToken那套机制,在我们的现场部署环境里价值极高。热力站现场经常需要远程改阈值、改上报频率,如果每次都要重启服务,运维成本受不了。用「订阅即触发」的套路,配置变更走和初始化完全相同的代码路径,行为一致性有天然保障。

07进阶扩展点:ModelConvention

源码里还有一个几乎没人用、但极其强大的扩展点:ModelConvention。

在ApplicationPart经过严格的Controller/Action规则筛选之后,框架还留了一个ApplyConventions环节,用的是Options.Conventions。它支持对各个模型元素进行「约定式」加工。

▪︎ IApplicationModelConvention —— 加工整个应用模型

▪︎ IControllerModelConvention —— 加工控制器模型

▪︎ IActionModelConvention —— 加工Action模型

▪︎ IParameterModelConvention —— 加工参数模型

▪︎ IPageRouteModelConvention —— 加工Page路由模型

为什么说它极其强大?因为它让你能在框架的「模型层」做统一加工,而不是在「业务层」重复劳动。

用法一,全局注册:services.Configure<MvcOptions>(options => { options.Conventions.Add(new MyGlobalConvention()); });

用法二,特性标记:实现IXXXModelConvention并标注在具体类型上。

举一个真实用例——统一给所有API加租户过滤:写一个 TenantFilterConvention 实现 IControllerModelConvention,在 Apply(ControllerModel controller) 里遍历 controller.Actions,给每个 action.Filters 统一 Add 一个 TenantFilterAttribute。

注意这里的思维跃迁:你不是在写业务代码,你是在「写给框架的规则」。这类代码的量很小,但杠杆极大。

我把这个套路称为元编程式扩展(Extension via Convention)——当你在多个地方重复同一段横切逻辑时,正确的做法不是提取方法,而是提升到框架层做统一注入。

08回到开头那个事故

现在可以收尾了。

我们那个事故的根因是:在一个Attribute的构造函数里注入Scoped服务。

Attribute是由框架在模型构建阶段(也就是上面讲的第二阶段)实例化的,而那个阶段根本没有请求上下文,也没有DI作用域。所以框架只能给它一个Root级别的实例,或者干脆失败。

正确做法是通过IHttpContextAccessor访问当前请求上下文,或者在Filter里做依赖注入。

如果你读过MVC的三层结构,这个坑根本不会踩——因为你会清晰地知道:Attribute实例化的时间点,和请求执行的时间点,压根不在同一个阶段。

这就是我写这篇文章想说的核心观点:

读框架源码,最大的收益不是「知道它是怎么实现的」,而是「知道在哪个时间点,哪些东西是可用的」。

这个认知一旦建立,你在写代码时的很多「玄学问题」会自动消失。什么构造函数不能注入、什么Filter和Middleware的执行顺序、什么Scoped服务的生命周期——全部有确定的答案。

09最后给你留三道作业

源码读到这里,我建议你不要只是看,动手做几个验证。下面是我当年做过的几个小实验,效果很好。

▪︎ 验证Controller规则的边界:写一个类库,里面放一个不继承Controller、名字叫xxxController的普通类,看看它能不能被识别为控制器。再试试加个[NonController]特性会怎样。

▪︎ 自己实现一个ModelConvention:写一个IControllerModelConvention,给所有Action统一加一个自定义Filter,打印出当前Action的完整元数据。跑起来你就能直观感受到「模型层」能拿到多少信息。

▪︎ 验证ChangeToken的触发时机:在UpdateCollection的订阅回调里打个断点,观察它到底在什么时候被调用——是MapControllerRoute的时候?还是第一个请求来的时候?这个实验做完,你对「延迟绑定」的理解会上一个台阶。

下一期我们讲路由系统——Route的生命周期、多规则路由演练、自定义路由约束、动态路由,以及最核心的DFA匹配算法。那个是真正硬核的东西,订阅了别错过。

最后,如果你觉得这篇文章对你有帮助,欢迎点赞、在看、转发三连。

我是一名做了15年.NET的技术工作者,现在专注智慧供热领域的平台研发。这个号会持续输出ASP.NET Core源码解读、架构设计、以及工业互联网场景下的实战经验。

不写水文,只写我真正踩过的坑。我们下期见。

THE END

相关学习资料