
1. MvcOptions 在 .NET 10 里的定位变化MVC 没有退场只是配置方式更值得较真先说个现象。.NET 10 正式版发布后社区里不少人开始讨论MVC 是不是该让位给 Minimal API 了。我手上的老项目从 .NET 8 一路升级到 .NET 10说实话并没有觉得 MVC 有淘汰的迹象反而在涉及复杂业务建模、接口版本管理、统一权限这套场景里MVC 加上一套梳理清楚的 MvcOptions 配置依然是大规模应用里最稳的组合。MvcOptions 在 .NET 里一直是个有点低调但管事太多的角色。它不是一个功能点而是 MVC 框架所有默认行为的配置入口。从路由生成规则、模型验证、格式化器选择到约定Convention、过滤器、参数绑定全部挂在MvcOptions上。很多人写了好几年 Controller可能只在AddControllers后面加过一句AddNewtonsoftJson()压根没打开过这个类的完整成员列表。结果遇到接口行为异常排查半天才发现是某个默认配置在起作用。.NET 10 里 MVC 的整体架构没有伤筋动骨的改动但正因为框架层面越来越稳定MvcOptions 的价值反而更突出它决定了你的 MVC 服务到底是开箱即用的 demo 级别还是能抗住生产环境各种边角场景的工程化基础。1.1 先搞清楚 MvcOptions 到底管哪些事要理解 MvcOptions我习惯把它拆成四块来看第一块是路由与 URL 生成。比如要不要强制小写 URL、URL 末尾是否追加斜杠、查询字符串是否小写、是否启用端点路由。这些选项直接改变IUrlHelper生成出来的链接样式也会影响路由匹配的规则。第二块是模型绑定与验证。包括最大递归深度、最大验证错误数、是否允许空请求体、是否自动触发模型状态过滤器、对顶层节点是否做验证等。这是生产环境里最容易出隐蔽 bug 的区域后面我会展开讲。第三块是格式化器与内容协商。默认的输入输出格式化器列表、是否尊重浏览器的 Accept 头、是否返回 406 Not Acceptable都在这里配置。第四块是过滤器与约定。全局过滤器列表Filters、应用程序模型约定Conventions、模型元数据提供器ModelMetadataDetailsProviders、模型绑定器提供器ModelBinderProviders等。这是扩展 MVC 行为最核心的挂载点。里面每一项都是一个属性改变全局行为的类型。比如SuppressModelStateInvalidFilter设为false时只要模型验证失败框架自动返回 400你的 Action 根本不会执行设为true后所有验证失败的响应都由你自行处理。这种开关属于典型的不知道的时候觉得框架有 bug知道之后觉得是自己没配置好。1.2 AddMvc 与 AddControllers 的场景取舍在 .NET 10 里注册 MVC有多个入口AddMvc、AddControllers、AddControllersWithViews、AddRazorPages。很多人直接无脑AddMvc这其实是个习惯性偷懒。AddMvc注册的是完整 MVC 功能集包含 Controller、视图、Razor Pages 相关服务内部会调用AddControllers、AddViews、AddRazorPages。如果你只做 Web API用AddControllers就够了。两者注册的 MvcOptions 配置方式完全相同但服务数量和启动耗时不一样。更重要的是AddMvc会把一些视图相关的基础设施也带进来在 Native AOT 裁剪场景下这种多带一点会明显增加发布体积。.NET 10 对 Native AOT 支持比之前更完善但我实测下来纯 API 项目用AddControllers发布出来的单文件体积和启动速度都优于AddMvc。var builder WebApplication.CreateBuilder(args); builder.Services .AddControllers() .AddJsonOptions(options { options.JsonSerializerOptions.PropertyNamingPolicy JsonNamingPolicy.CamelCase; options.JsonSerializerOptions.Converters.Add(new JsonStringEnumConverter()); }) .ConfigureApiBehaviorOptions(options { // API 行为相关配置例如关闭自动 400 响应 }); var app builder.Build();这里要补一句ConfigureApiBehaviorOptions和AddMvc之后用options {}去配 MvcOptions 是两回事。前者配置的是 API 行为选项ApiBehaviorOptions只作用于 Controller 的 API 行为比如自动验证响应、SuppressMapClientErrors、InvalidModelStateResponseFactory。后者配置的才是整个 MVC 管线的全局默认行为。我刚接触时经常搞混这两个入口导致配置写在 A 处效果却要在 B 处确认。1.3 新增了哪些值得关注的配置方向.NET 10 相比 .NET 8/9MvcOptions 本身的公开属性没有大幅变化说明这套模型已经趋于稳定。但我在升级过程中明显感觉到社区对以下三个方向的讨论变多了第一个是响应压缩与 MvcOptions 的配合。框架层面压缩中间件依然是ResponseCompressionMiddleware但你要在 MVC 层面对某些 Action 单独跳过压缩就得靠MvcOptions里的过滤器或约定去标记响应头。第二个是对象结果执行器的自定义。OutputFormatter的默认行为在 .NET 10 里没有大改但如果你做了自定义格式化器MvcOptions 的OutputFormatters集合就是要注入的地方这个入口是稳定的。第三个是契约式 API 与 MVC 的协同。.NET 10 官方不断强调 OpenAPI 支持而MvcOptions的Conventions正好可以用于给 Controller 统一加[ProducesResponseType]之类的元数据让 Swagger 生成结果更规范。这部分我下面会用完整示例展开。一句话总结这节MvcOptions 不是配置一下就万事大吉的东西它更像一个策略面板。你的项目适合什么样的路由风格、验证粒度、格式化策略必须先想清楚再动配置。2. 高频选项逐个拆解真正影响线上行为的是这几个讲完定位进入实操。我不会把MvcOptions的所有属性列一遍——那样你根本记不住。我挑几个线上行为差异最明显、也是最容易踩坑的选项一个一个说清楚。2.1 路由与 URL 生成LowercaseUrls 和 AppendTrailingSlash 的连带影响LowercaseUrls这个属性很好理解生成链接时强制小写。但很多人不知道它还有一个配套开关叫LowercaseQueryStrings。我遇到过这样一个 case项目升级后所有接口路径变成小写但前端代码里有个别地方硬编码了驼峰路径线上 404 一大片。其实不是路由写错了是LowercaseUrls true之后路由匹配本身不受影响但IUrlHelper生成出来的 URL 变了。builder.Services.AddControllers(options { options.LowercaseUrls true; options.LowercaseQueryStrings true; options.AppendTrailingSlash true; });AppendTrailingSlash也是同样它改变了链接生成结果但不会自动帮你做 301 重定向。如果原来接口是/api/order加了末尾斜杠后/api/order/能访问但/api/order是否还能访问取决于路由模板匹配的宽松程度。我的建议是这种 URL 风格类配置必须在项目初期确定中途切换的代价远超你的想象。另外一个隐藏在 MvcOptions 里但常被忽略的选项是AllowEmptyInputBody。默认情况下[FromBody]参数遇到空请求体时会直接判定为绑定失败返回 400。如果你有允许 POST 空 body 但只做触发的接口需求这个开关值得注意。但我不建议全局打开因为这会绕过框架对无效请求体的默认防护更合理的做法是在具体 Action 上用特性单独处理。2.2 模型验证默认帮你做了一半另一半要自己兜MvcOptions 里和模型验证强相关的属性有MaxModelValidationErrors、SuppressModelStateInvalidFilter、AllowValidatingTopLevelNodes、MaxValidationDepth。默认情况下MaxModelValidationErrors是 200也就是说模型绑定阶段收集验证错误达到 200 条就会停止。这个值看起来很大但在批量导入场景里很容易撞上。我之前做一个批次提交接口一次传 500 条明细每条明细有几个字段错误验证错误数轻松破 200框架直接截断前端拿到的错误列表不完整用户反复提交还是报错。后来按接口实际情况把这个值调大同时配合分组验证才解决问题。SuppressModelStateInvalidFilter是我强烈建议每个团队都明确讨论一下的选项。默认false即模型状态无效时框架自动返回 400你的 Action 体根本不会执行。这个行为对快速拦截非法请求是有利的但它有个副作用你无法在 Action 里记录这次请求因为哪些字段不合法这类业务日志也无法做自定义的错误响应格式。builder.Services.AddControllers(options { options.SuppressModelStateInvalidFilter true; });关闭自动验证后你要在每个 Action 开头手写if (!ModelState.IsValid)处理逻辑。看起来多写了几行但换来了两个好处一是错误响应格式完全可控二是可以在验证失败时执行统一的审计日志。我个人的做法是结合自定义过滤器来实现而不是真的在每个 Action 里手写。2.3 响应格式协商RespectBrowserAcceptHeader 和 ReturnHttpNotAcceptable内容协商由FormatterMappings、OutputFormatters、RespectBrowserAcceptHeader、ReturnHttpNotAcceptable共同决定。RespectBrowserAcceptHeader默认是false。也就是说即使请求头 Accept 写着text/xml如果你的 API 只注册了 JSON 格式化器框架照样返回 JSON。这个默认值很务实因为浏览器Accept头五花八门真要尊重起来反而容易误伤。只有当你的 API 需要服务端渲染或者兼容老客户端时才应该打开这个开关。ReturnHttpNotAcceptable同理。默认情况下客户端请求了一个不支持的格式框架会退回默认格式化器继续处理返回 200。而当你把ReturnHttpNotAcceptable设为true这种请求会直接返回 406 Not Acceptable。这个选项对接口规范性要求较高的团队很有价值但它会改变既有客户端的兼容性上线前必须充分评估。2.4 格式化器与 JSON 选项System.Text.Json 之外的取舍.NET 10 默认用 System.Text.Json性能好、内存占用低。但生产环境里 Newtonsoft.Json 还有大量存量项目依赖。通过AddNewtonsoftJson()注册后MvcOptions 的OutputFormatters列表里会同时存在 System.Text.Json 和 Newtonsoft 两个 JSON 格式化器。这里有个容易忽略的坑同类型JSON但不同库的两个格式化器同时存在时内容协商是按注册顺序匹配的。默认情况下AddNewtonsoftJson()会替换掉 System.Text.Json 的输入输出格式化器不会两个并存。但如果你的代码里手动往options.OutputFormatters添加了别的 JSON 格式化器就要注意匹配顺序了。builder.Services.AddControllers(options { options.OutputFormatters.RemoveTypeSystemTextJsonOutputFormatter(); options.OutputFormatters.Add(new NewtonsoftJsonOutputFormatter( new JsonSerializerSettings { NullValueHandling NullValueHandling.Ignore, ContractResolver new CamelCasePropertyNamesContractResolver() }, Array.Emptystring(), options)); });这个写法并不推荐照抄但它展示了OutputFormatters集合的操作自由度。你完全可以在 MVC 框架的默认格式化器基础上自己定制想要的序列化规则。我的实际建议是新项目直接用 System.Text.Json老项目迁移时如果必须保留 Newtonsoft用AddNewtonsoftJson()的官方入口别手动操作格式化器集合减少意外。3. 用 Conventions 把 Controller 层的重复代码彻底拿掉这是 MvcOptions 里我觉得最被低估的一块Application Model Convention。它让你有机会在 MVC 启动阶段对所有 Controller、Action、Parameter 的模型信息做统一改写相当于批量给 Controller 做手术。3.1 ControllerModelConvention统一 API 前缀实战假设你的项目有几十个 Controller每个类上都标了[Route(api/xxx)]。现在业务调整要求所有对外接口统一增加/v1前缀。最粗暴的方式是全局搜索替换每个 Controller 的特性。但如果你用了约定就只需要实现一个IControllerModelConventionpublic class ApiPrefixControllerConvention : IControllerModelConvention { private readonly string _prefix; public ApiPrefixControllerConvention(string prefix) { _prefix prefix; } public void Apply(ControllerModel controller) { foreach (var selector in controller.Selectors) { if (selector.AttributeRouteModel ! null) { selector.AttributeRouteModel new AttributeRouteModel( new RouteAttribute(${_prefix}/{selector.AttributeRouteModel.Template})); } } } }在 Program.cs 里注册builder.Services.AddControllers(options { options.Conventions.Add(new ApiPrefixControllerConvention(api/v1)); });这个方案最大的价值是接口前缀可以做成配置项同一套代码部署在多个环境时前缀可以随环境变量变化而不需要重新发布。我实际在一个多租户项目里用了类似的方案租户标识作为前缀的一部分由配置中心动态下发Controller 层完全不用改代码。这里要提醒一点AttributeRouteModel.Template拼接时要注意斜杠重复的问题写完约定后最好跑一遍全量路由测试避免生成api/v1//api/order这类 URL。3.2 ActionModelConvention自动给 Action 补充响应类型元数据还有一个我经常用的场景给所有 Action 自动加上[ProducesResponseType]和[Produces]特性让 Swagger 文档不用靠手工标注也能完整。public class ResponseTypeConvention : IActionModelConvention { public void Apply(ActionModel action) { if (action.ActionMethod.DeclaringType ! null) { action.Properties[operationId] action.ActionName; } } }这只是一个简单的示例。更实用的是你可以根据 Action 返回类型的特征自动决定是否添加[ProducesResponseType(typeof(ProblemDetails), StatusCodes.Status400BadRequest)]这类标签。这样 Swagger 文档里每个 Action 的响应码就完整了前端从 Swagger 生成 SDK 时的体验会好很多。这套机制之所以叫 Application Model是因为它工作在整个 MVC 管线的模型构建阶段早于路由匹配和 Action 执行。换句话说你在 Convention 里做的所有修改都是在框架还没正式开始工作之前完成的所以清晰、可控、统一生效。3.3 ParameterModelConvention处理统一绑定逻辑参数级别的约定IParameterModelConvention适合处理更细的绑定规则。比如项目里所有[FromQuery]的DateTime?参数统一用yyyy-MM-dd格式解析。这种需求如果没有约定就只能每个 Action 里写自定义 ModelBinder 特性重复且易漏。有了约定你可以一次性定位所有符合条件的参数并替换绑定源。我实际项目里用到的是一个更常见的场景所有字符串参数去掉首尾空格。一开始我用模型绑定器实现后来发现用参数约定 自定义绑定提供器的方式代码组织更清楚因为约定层只负责标记真正做转换的绑定器仍然保持单职责。public class TrimStringParameterConvention : IParameterModelConvention { public void Apply(ParameterModel parameter) { if (parameter.ParameterType typeof(string)) { parameter.BindingInfo ?? new BindingInfo(); } } }当然字符串修剪这种需求用 JSON 序列化的Converters或者自定义ValueProvider也能实现方式没有绝对对错。但从MvcOptions 是统一配置入口这个角度看约定是最贴合 MVC 模型的做法因为它可以在路由、绑定、验证全部生效之前统一改写元数据不会出现有的接口生效有的接口没生效的尴尬。4. 性能调优视角哪些选项改完能明显降低耗时MvcOptions 里有些配置直接影响请求管线的处理成本和资源占用。下面这几项我都在真实项目里做过前后对比效果可量化。4.1 SuppressMvcModelValidation 的风险与收益SuppressMvcModelValidation true意味着跳过 MVC 自动的数据验证注解DataAnnotations验证。如果你的入参模型完全由你手动校验或者你用了 FluentValidation 之类的第三方库这个开关可以减少一次模型验证的开销。我在一个高频写入接口上做过压测入参模型有 30 多个属性且大部分属性带有[Required]、[MaxLength]、[Range]等注解。打开SuppressMvcModelValidation后单接口吞吐量提升大约 5% 到 8%。原因是每个验证注解在模型绑定阶段都会被反射执行模型越复杂、注解越多反射开销越明显。注意关闭自动验证后如果入参模型没有其他验证兜底接口的健壮性会明显下降。我建议只在已验证模型经过严格评审 有独立验证层的项目里开启否则老老实实保留默认行为。MaxValidationDepth也有类似效果。它默认是 32控制验证器递归遍历对象图的深度。对深度嵌套的 JSON 结构把深度调小可以减少恶意构造超大嵌套请求带来的 CPU 消耗。这个值从安全角度看通常是调小而不是调大除非你确实有深层绑定需求。4.2 格式化器裁剪减少不必要的序列化分支默认注册的格式化器包括 JSON、文本等。如果你的 API 只需要 JSON可以把 XML 等格式化器直接剪掉减少内容协商的计算量。这个优化在单个请求上几乎可以忽略不计但在高并发接口上积少成多。builder.Services.AddControllers(options { options.OutputFormatters.RemoveTypeXmlDataContractSerializerOutputFormatter(); options.InputFormatters.RemoveTypeXmlDataContractSerializerInputFormatter(); });这里要注意在 .NET 10 里 XML 格式化器默认并没有注册除非你显式调用AddXmlSerializerFormatters()或AddXmlDataContractSerializerFormatters()。所以这一步更多是检查并确认没有多余格式化器而不是真的删除。真正值得花精力的是检查FormatterMappings。如果你需要返回特定格式比如application/vnd.apijson在FormatterMappings里做媒体类型映射比写死 URL 后缀更规范。这样客户端通过 Accept 头就能拿到想要的内容格式而不是靠扩展名。4.3 MaxModelBindingRecursionDepth防止对象图过深拖垮绑定MaxModelBindingRecursionDepth默认是 32。它控制模型绑定器递归绑定对象的深度超过这个深度就停止并记录错误。这个设计主要是安全考虑防止有人故意构造超深嵌套的 JSON导致模型绑定阶段栈溢出或 CPU 打满。我遇到过一个真实案例某个接口接收一个多层嵌套的树结构正常深度在 5 到 6 层但某次客户端 bug 导致递归引用了同一个对象JSON 体积不大但嵌套深度直接到数百层。模型绑定阶段 CPU 飙升接口响应时间从 30ms 变成 10 秒。最后排查出来就是递归深度惹的祸。把MaxModelBindingRecursionDepth调成合理范围比如 16加上对嵌套层数的业务校验问题直接解决。builder.Services.AddControllers(options { options.MaxModelBindingRecursionDepth 16; options.MaxValidationDepth 16; });这类看起来不常用的配置项恰恰是生产环境稳定性防护的关键一环。默认值 32 对多数业务够用但如果你有递归结构入参建议显式评估一下上限别等到线上出问题再想起这个选项。5. 和 Java Spring MVC 对比配置哲学的差异与借鉴有句话说得很对看一个框架的设计理念最好的方式不是读文档而是对比两个同类框架在处理同一个需求时的不同做法。我用 MvcOptions 和 Java Spring MVC 的配置方式做个对比你会发现两边思路差异挺大。5.1 声明式与约定式全局配置的侵入性差别Spring MVC 的配置核心在WebMvcConfigurer接口你可以通过实现这个接口重写addInterceptors、addCorsMappings、addViewControllers等方法来改变全局 MVC 行为。相比之下MvcOptions 是纯属性式配置直接把能力摆在桌面上。举个具体例子。Spring MVC 里自定义参数解析器你要写一个HandlerMethodArgumentResolver然后在addArgumentResolvers里注册。.NET 里对应的是在MvcOptions.ModelBinderProviders里插入自定义提供器。两边的做法本质都是扩展列表模式但 Spring 的配置分散在多个addXXX方法里.NET 则集中在 Options 的集合属性里。从可发现性来看.NET 的 MvcOptions 把所有扩展点集中在一个对象里IntelliSense 一列就全看到了这点对新手友好得多。5.2 过滤器与拦截器的设计思路对比Spring MVC 里最常打交道的是HandlerInterceptor和HandlerInterceptorAdapter通过addInterceptors注册按路径匹配拦截。.NET MVC 里对应的是IFilterMetadataIAsyncActionFilter、IActionFilter、IExceptionFilter等通过 MvcOptions 的Filters集合全局注册。但两者的执行时机有微妙差别。Spring 的拦截器围绕HandlerExecutionChain组织preHandle、postHandle、afterCompletion三个方法分工明确。.NET 的过滤器则是洋葱模型OnActionExecuting和OnActionExecuted成对出现可以嵌套多层。从可测试性来说.NET 的过滤器因为是依赖注入友好的单元测试时只需要拼装一个过滤器实例不用启动完整 MVC 容器这点比 Spring 的拦截器测试要轻量。我用 MvcOptions 的Filters集合做全局审计日志、统一异常包装、接口耗时统计已经形成了一套固定模式。如果你同时维护 .NET 和 Java 两套技术栈最大的体会就是.NET 把所有约定都收拢到一个配置对象 独立过滤器的模型里思路统一Spring 则是一个功能一个配置接口扩展点更分散。5.3 两个框架里最值得互相借鉴的地方技术上没有完胜的框架只有适合团队的取舍。我在 .NET 里比较羡慕 Spring 的一点是它的WebMvcConfigurer允许你按模块拆分配置类多个配置类可以同时存在、互不干扰。而 MvcOptions 是一个全局单例配置对象一旦配置多了Program.cs 里的AddControllers(options { ... })代码块会膨胀得厉害。我的解法是把 MvcOptions 的配置按模块抽成扩展方法。比如把路由相关配置、模型验证相关配置、格式化器相关配置各自独立成类public static class MvcOptionsExtensions { public static void ConfigureRoutingConventions(this MvcOptions options) { options.LowercaseUrls true; options.AppendTrailingSlash false; } public static void ConfigureValidationBehavior(this MvcOptions options) { options.SuppressModelStateInvalidFilter true; options.MaxModelValidationErrors 50; } }然后在 Program.cs 里分组调用builder.Services.AddControllers(options { options.ConfigureRoutingConventions(); options.ConfigureValidationBehavior(); });这样既能集中管理又不会把 Program.cs 写成一坨。如果你从 Java 转 .NET这种封装方式会让你感觉熟悉很多因为它模仿了 Spring 里一个配置类管一块逻辑的组织习惯。6. 升级到 .NET 10 的迁移清单别被默认值变化坑到最后说说从 .NET 8 / .NET 9 升级到 .NET 10 时MvcOptions 相关最需要注意的几个点。大多数情况下项目能直接编译通过但行为层面的差异会让你措手不及。6.1 过时选项与替代方案首先检查代码里是否还在用EnableEndpointRouting。这个属性在 .NET 3.0 引入端点路由后就开始变得多余到了 .NET 10 已经彻底退出主流配置。如果你在旧项目里显式设置过options.EnableEndpointRouting false升级后必须移除否则会出现配置冲突路由直接失效。另外如果你用了AddMvc但又从不使用 View 和 Razor Pages升级时顺手改成AddControllers或AddControllersWithViews减少冗余服务注册。这不会造成行为差异但能让升级后的项目结构更清晰。6.2 默认值演进带来的行为差异.NET 10 的 MvcOptions 默认值基本和 .NET 8 保持一致但有个间接影响System.Text.Json 的行为演进会影响JsonSerializerOptions系列配置的实际效果。比如字符串枚举转换、大小写策略、忽略空值等这些选项如果在你自己的代码里依赖了旧版本的默认值升级后 JSON 输出可能与预期不一致。我遇到过的一个真实情况是某个接口原本输出枚举值为数字升级后因为全局配置了JsonStringEnumConverter输出变成字符串前端直接解析失败。这不是 MvcOptions 的问题而是开发团队在升级时没有检查序列化策略的隐性变化。所以我建议升级后把线上接口的响应体做一次 diff 检查重点看枚举、日期格式、大小写规则这三项。6.3 我整理的最小化升级流程下面这个流程我每升级一个项目都会走一遍已经固化成清单第一步移除过时属性比如EnableEndpointRouting确认所有路由注册走端点路由。第二步把AddMvc换成更精确的服务注册方法如果遇到 Razor 相关依赖缺失再逐步补充。第三步检查ConfigureApiBehaviorOptions和AddJsonOptions配置是否有重复冲突特别是JsonStringEnumConverter这类全局转换器。第四步跑全量自动化测试重点覆盖模型验证失败返回、路由 URL 生成、内容协商结果、全局过滤器执行顺序这四个方面。第五步线上灰度发布后对比升级前后接口响应体的差异确认 JSON 序列化没有隐性变化。按这个流程走下来我还没有遇到过升级后无法定位的问题。MvcOptions 本身不复杂但它牵扯的内容太广从路由到验证到序列化到过滤器任何一个环节的微小变化都会被放大到全链路。最后分享一个个人习惯我在每个项目里都会单独建一个MvcConfiguration.cs文件专门放所有 MvcOptions 相关配置并且每一类配置下面写清楚为什么这么配、动了会导致什么后果。这样做的好处是半年后你回头看代码依然能知道当初每一项配置的意图而不是面对一堆魔法值靠猜。这个文件随着项目演进会越来越值钱因为它记下的不只是配置还有你对这个系统所有边界行为的理解。