☰
C# 12/13 与 ASP.NET Core 8/9 新特性实战:从主构造函数到 HybridCache
2026/10/24 1:37:19 网站建设 项目流程

每次 .NET 大版本发布,社区里都会出现两种声音:一波人盯着 C# 语言层面的新东西,另一波人盯着 ASP.NET Core 框架层面的变化。可标题把这两件事放到了一起,搞得好像是一套发布包,实际上这两个时间线的节奏完全不同。C# 12 和 C# 13 带来的主构造函数、集合表达式、params 集合,需要在编译器层面理解;而 ASP.NET Core 8 和 9 的 Keyed DI、Identity API endpoints、MapStaticAssets、内置 OpenAPI 生成,属于框架和运行时层面的事情。两者都叫“新功能”,却常常被人混在一起讨论,这也是很多人看完更新日志之后反而更懵的原因。

我这篇文章就把这两条线放在一起拆开讲,侧重讲它们在真实 ASP.NET Core 项目里的落地姿势。内容会比较“挑食”,不追求把每个新特性都铺一遍,而是选那些我在实际项目里真正用到、也觉得值得用的东西。想升级老项目的、准备开新项目的、或者只是被标题里“新增功能”四个字吸引进来的读者,都可以从里面找到可以直接抄走的写法和注意事项。

1. 语言与框架的版本关系:两个版本号背后的三条升级路径

1.1 同一个项目里,语言版本和框架版本是可以分开控制的

很多开发者以为升级到 .NET 8 就自动用上了 C# 12 的语法,其实不完全对。.NET 8 SDK 确实默认带 C# 12,但在项目文件里,TargetFramework决定的是框架版本,LangVersion决定的是编译器版本。两者可以分开调,虽然一般不会有人故意把新框架配老语言,但反过来是常见情况:比如公司统一要求代码风格保守,或者某个旧项目还依赖着没更新编译器插件的构建脚本,就可以通过在.csproj里显式写<LangVersion>10.0</LangVersion>来锁住语法版本。

理解这一点很重要,因为网上很多教程会混着写。比如介绍主构造函数时,有人会说“在 .NET 8 项目里使用”,其实主构造函数是 C# 12 的语法,你把目标框架设成 net7.0,只要 SDK 支持、LangVersion 设置为 12,一样能编译。当然,实际项目中我们不会故意这么干,但知道这层关系之后,遇到编译报错或者特性不可用,就能快速分清是框架的问题还是语言版本的问题。

1.2 不同版本聚类下的选型策略

我把 .NET 8 和 .NET 9 这两个大版本里和 ASP.NET Core 相关的内容大致分成了四类:纯语言语法层、框架内置服务层、开发体验层、性能与发布层。这么分类之后,选型思路会清楚很多。

层级代表特性影响范围
语言语法主构造函数、集合表达式、params 集合所有 C# 代码
框架服务Keyed DI、Identity API endpoints、输出缓存依赖注入、认证、响应缓存
开发体验内置 OpenAPI 文档生成、改进的测试框架行为接口调试、自动化测试
性能发布MapStaticAssets、静态资产指纹、混合缓存前端资源交付、高并发场景

我的选型策略很朴素:新项目直接用当前最新稳定版,老项目按 LTS 节奏走。也就是说,如果你的生产环境还在 .NET 6,我不会劝你立刻跳到 .NET 9,而是建议先规划到 .NET 8,因为 8 是 LTS 版本,支持周期更长。至于语言语法,C# 12 的主构造函数和集合表达式属于“低风险高感知”的改动,可以优先用;C# 13 的 params 集合则要看具体场景,不必为了用而用。

2. 主构造函数:API 项目中最直观的写法变化与使用边界

2.1 从三段式构造注入到一行声明

以前写一个服务类,最标准的姿势是三段式:先声明私有字段,再写构造函数,最后在构造函数里做赋值。代码本身不复杂,但每个类都要重复这一套,项目里文件一多,光看这些样板代码就很消耗耐心。

public class OrderService { private readonly ILogger<OrderService> _logger; private readonly IOrderRepository _repository; public OrderService(ILogger<OrderService> logger, IOrderRepository repository) { _logger = logger; _repository = repository; } public async Task<OrderDto> GetAsync(int id) { _logger.LogInformation("loading order {OrderId}", id); return await _repository.GetByIdAsync(id); } }

C# 12 的主构造函数把这个过程压缩到一行类声明里。参数直接写在类型名后面的括号中,类内部可以像使用普通参数一样使用它们,也可以把它们赋给字段:

public class OrderService( ILogger<OrderService> logger, IOrderRepository repository) { private readonly ILogger<OrderService> _logger = logger; public async Task<OrderDto> GetAsync(int id) { _logger.LogInformation("loading order {OrderId}", id); return await repository.GetByIdAsync(id); } }

注意我上面故意做了一点区分:_logger需要被多个方法使用,所以放入字段;repository直接在方法里使用,可以保留参数形式。这个细节是主构造函数最容易被误解的地方——这些参数不是普通构造函数参数,它们作用域是整个类体,但又不能像字段一样到处用而不承担捕获成本。在简单方法里直接用参数名,代码更短;在多个方法里都要用时,赋值给字段更符合原来代码的可读性习惯。

2.2 在 Controller、服务、TagHelper 中的实际差异

主构造函数在 ASP.NET Core 项目里表现最好的场景是普通服务类和 MVC Controller。因为框架的激活器本来就会通过构造函数去解析依赖,主构造函数只是换了个写法,编译后的行为和旧式构造没有本质区别。我也在最小 API 的端点过滤器和中间件里用过这个语法,同样没有遇到 DI 解析方面的问题。

但有几个边界需要留意。第一个是“类的继承和重载”场景:主构造函数只允许一种构造形式,如果你的服务需要多个构造函数重载,或者需要做一些复杂的初始化逻辑(比如构造后立即校验某个参数不为空),那还是老老实实用传统写法更清晰。第二个是反射和序列化场景,某些框架功能会通过无参构造实例化对象,主构造函数生成了带参构造,虽然不影响 DI,但如果是你自己写的反射代码,就要注意参数名和编译后构造函数参数名的对应关系。第三个是 AOT 发布,主构造函数本身没有额外的裁剪问题,但如果你在主构造函数里依赖了反射设置私有字段,那和普通的反射代码一样要互相定位。

2.3 我实际用下来的体会

我在一个订单处理服务里全面改了主构造函数之后,最直观的收益不是代码量减少多少,而是类的“依赖清单”一眼就能看到。过去要滚动到构造函数区域才能看到的注入项,现在类名后面就是一串参数列表,代码审查时非常舒服。但我也劝一句:不要为了“新语法”把每个类都强行改成主构造函数。如果这个类有多个公开构造、依赖注入之外还有默认构造,或者需要逻辑复杂的字段初始化,主构造函数反而会限制表达。新技术的价值在于让代码变简单,而不是让代码显得时髦。

3. 集合表达式与 params 集合:中间件与配置代码里的细节优化

3.1 集合表达式替换旧式初始化

C# 12 的集合表达式用一对[]就能初始化数组、List、Span 甚至自定义集合类型。最常用的替换场景是配置代码里的白名单数组和硬编码列表。

以前写 CORS 允许的来源,是这样:

string[] allowedOrigins = new string[] { "http://localhost:3000", "https://localhost:5001" }; builder.Services.AddCors(options => { options.AddPolicy("dev", policy => policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod()); });

用集合表达式之后:

string[] allowedOrigins = ["http://localhost:3000", "https://localhost:5001"]; builder.Services.AddCors(options => { options.AddPolicy("dev", policy => policy.WithOrigins(allowedOrigins) .AllowAnyHeader() .AllowAnyMethod()); });

看起来只是少写了一个new string[],但配合展开运算符..之后,很多需要拼接集合的场景会变得特别干净。比如你在配置里想要“默认来源 + 环境变量额外来源”:

string[] baseOrigins = ["http://localhost:3000"]; string[] extraOrigins = ["https://api.example.com", "https://admin.example.com"]; string[] allOrigins = [.. baseOrigins, .. extraOrigins];

这种写法放到中间件管线的配置里也很方便,比如自定义授权方案的名称列表、需要用到的 scheme 列表,都比以前用 Concat 或者 AddRange 强很多。要提醒的是,集合表达式生成的集合类型取决于左边声明的类型,如果左边是string[],展开多个集合时会生成数组;如果左边是List<string>,则对应生成 List。建议在配置边界统一声明为接口或明确类型,避免后面改类型时连带影响。

3.2 C# 13 的 params 集合:让扩展方法顺手很多

C# 13 把params参数的能力从数组扩展到了更多集合类型。以前只能写params string[],现在可以写params IEnumerable<string>、params ReadOnlySpan<string>等。这意味着调用方传参时的限制更小,不需要为了凑一个数组特地去new[]。

举一个真实场景:在最小 API 里自定义响应头统一处理逻辑,以前如果让方法接收多个头部,只能写成数组参数:

public static IResult ApplyHeaders(params (string Key, string Value)[] headers) { foreach (var (key, value) in headers) { // 处理逻辑 } return Results.NoContent(); }

C# 13 改成:

public static IResult ApplyHeaders(params IEnumerable<(string Key, string Value)> headers) { foreach (var (key, value) in headers) { // 处理逻辑 } return Results.NoContent(); }

调用时可以继续用集合表达式传一个元组数组,也可以传已经存在的 List、数组、甚至 Span。在 ASP.NET Core 项目里,这类“处理一组配置项”的扩展方法很常见,params 集合可以减少一部分集合类型转换的样板代码。不过,params 集合是 C# 13 的语法,要确认你的 SDK 版本和 LangVersion 支持,最好是在新项目或者显式升级语言版本的项目里使用。

3.3 内联数组和框架底层的关系

C# 12 还引入了内联数组特性,但这属于编译器层面的高级能力,日常 ASP.NET Core 开发几乎用不到。它主要服务于高性能库的作者,比如需要固定大小缓冲区、避免数组分配的场景,框架内部的某些读写路径会用到。普通业务代码不建议直接去定义[InlineArray]特性,一方面语法较冷门,另一方面编译器优化不一定按你想的方向走。了解它的存在即可,别为了“用新特性”而用。

4. ASP.NET Core 8 的实战新功能:Keyed DI、Identity API 端点和输出缓存

4.1 Keyed DI:多实现场景下的依赖注入新姿势

Keyed DI 是 .NET 8 里一个非常实在的增强。以前你在 DI 容器里注册同一个接口的多个实现时,很难在构造函数里直接表达“我要拿哪一个”。通常的做法是注册一个工厂类,或者注入IEnumerable<T>再自己做选择。Keyed DI 给了我们一个标准化的方案。

注册时的写法变化:

builder.Services.AddKeyedScoped<ICacheProvider, InMemoryCacheProvider>("memory"); builder.Services.AddKeyedScoped<ICacheProvider, RemoteCacheProvider>("remote");

使用时,构造函数参数上标注[FromKeyedServices]:

public class WeatherService( [FromKeyedServices("remote")] ICacheProvider cacheProvider, ILogger<WeatherService> logger) { // ... }

这个特性解决了我过去经常遇到的一个问题:同一个系统里存在多个缓存实现,某个服务要指定用远程缓存,另一个服务用内存缓存,以前要写一堆选择逻辑,现在容器层面就完成了。它也适用于策略模式,比如多个支付渠道、多个消息推送实现,都可以用 key 来区分。

实际使用时有一个容易踩的坑:keyed service 只能通过GetRequiredKeyedService、GetKeyedService或[FromKeyedServices]解析,普通构造函数注入拿不到。如果某个服务既要在 keyed context 中使用,又要作为普通服务被解析,那么注册时就要同时考虑两种注册方式,或者提供一个非 keyed 的适配器。另外,框架自带的一些泛型服务解析路径(比如日志服务中的泛型类型)不一定自动支持 keyed,遇到问题时先检查是不是解析方式不匹配。如果用了解析方式不匹配的错误,报错信息里一般会明确说明“没有为非 keyed service 找到服务”。

4.2 Identity API endpoints:不用再手写 Account 控制器的认证方案

.NET 8 之前的传统 ASP.NET Core 项目要做认证,通常得自己搭 AccountController:注册、登录、登出,甚至还要自己发 cookie 或签发 JWT。现在框架内置了 Identity API endpoints,可以快速生成一组标准的认证 API,对最小 API 项目和前端分离架构尤其友好。

基本用法是在服务注册阶段配置 Identity 相关服务,然后调用MapIdentityApi:

builder.Services.AddAuthentication(IdentityConstants.ApplicationScheme) .AddIdentityCookies(); builder.Services.AddIdentityCore<AppUser>(options => { options.User.RequireUniqueEmail = true; }) .AddApiEndpoints() .AddEntityFrameworkStores<AppDbContext>(); var app = builder.Build(); app.MapIdentityApi<AppUser>();

这组内置端点包含/register、/login、/logout、/manage/info等常用路径。很多人以为它默认就是 JWT 认证,其实不是——IdentityConstants.ApplicationScheme走的是 cookie 方案。如果你的前端是同一域名的单页应用,cookie 认证通常更方便,也天然规避了 token 存储问题;但如果是纯第三方 API 的场景,需要 JWT bearer token,那内置端点不直接满足,还得配合其它 token 服务。换句话说,这组端点的价值是消灭了重复的账号注册登录代码,但认证协议选型、token 策略仍然由你自己决定。

4.3 输出缓存中间件:比 ResponseCaching 更接近业务缓存的方案

ASP.NET Core 8 把输出缓存从实验状态提升到了正式能力。它和应用层中间件app.UseOutputCache()搭配,可以针对某些端点做响应结果缓存。注意它和早年的ResponseCaching中间件不是同一个东西:ResponseCaching是基于 HTTP 响应头和服务端协商的,通常更适合处理静态资源或者对 Cache-Control 头有严格控制的场景;而OutputCache是在框架内部直接保存并复用端点执行结果,控制更细腻,可以设置VaryByQuery、VaryByRouteValue等策略。

我举一个实际用法,比如一个报表接口需要按page参数变化缓存 30 秒:

app.UseOutputCache(); app.MapGet("/report", (IMyReportService report) => TypedResults.Ok(report.Load())) .CacheOutput(policy => policy.Expire(TimeSpan.FromSeconds(30)) .SetVaryByQuery("page"));

这里有几个坑必须提醒。第一,输出缓存缓存的是整个 HTTP 响应,如果接口内容依赖当前用户身份或者有 set-cookie 逻辑,不能直接套缓存,否则用户 A 的数据可能被返回给用户 B。第二,默认的缓存存储是进程内内存,如果你的服务部署了多个实例,每个实例的缓存各自独立,得接上分布式缓存才能做一致性。第三,缓存的 key 和路由参数、查询参数相关,修改缓存策略后最好做一轮回归,确认不同参数之间的隔离生效了。

5. ASP.NET Core 9 的方向性调整:静态资产、混合缓存与内置 OpenAPI

5.1 MapStaticAssets:把静态文件也放进构建流程

过去我们在 ASP.NET Core 里发布前端静态资源,基本就是靠UseStaticFiles中间件在运行时扫描wwwroot目录,逐个映射文件。运行时逻辑简单,但每个请求都要做文件系统访问、目录遍历和内容协商,性能上不够极致。ASP.NET Core 9 新增的MapStaticAssets换了一个思路:它把静态资产的处理前移到构建阶段。

在项目里调用方式很简单:

var app = WebApplication.CreateBuilder(args).Build(); app.MapStaticAssets();

这个中间件会在编译和发布时分析wwwroot下的文件,提前生成 ETag、内容哈希、压缩版本(gzip 和 Brotli)等元数据。实际请求发出时,这些信息已经内嵌在应用里,省去了运行时反复扫描 IO 的成本。同时它更聪明地处理浏览器缓存头,带指纹的文件可以设置更激进的缓存策略,不带指纹的 HTML 页面则走较短的缓存时间。

我在一个静态资源很多的内部系统里对比过UseStaticFiles和MapStaticAssets,存在静态资源请求的性能差异报表里,后者的 CPU 占用和文件 IO 明显下降。但也要知道它的限制:MapStaticAssets面向构建期已知的静态文件,它不会处理运行时才动态新增或上传到目录的文件。如果你有用户上传图片、自动生成的临时文件这类需求,仍然要保留合适的静态文件服务逻辑,不能彻底用MapStaticAssets替代。更好的做法是在 Kestrel 前再加一层静态资源服务,但这就属于部署架构了。

5.2 HybridCache:终于不用手写两三层缓存了

做 Web 开发的人都知道,缓存往往不能只有一层。进程内缓存快但容量有限,分布式缓存容量大但访问有网络开销,所以很多项目会自己叠一个两级缓存:先查内存,没命中再查远程缓存,最后再查数据库,同时还要防缓存击穿。这套逻辑写起来不复杂,但每个团队写的版本都不一样,容易出错。.NET 9 的 HybridCache 就是把这类模板代码固化成了框架能力。

我在项目里用起来的大概形式是:

builder.Services.AddHybridCache(options => { options.DefaultEntryTimeToLive = TimeSpan.FromMinutes(5); });

然后在服务里通过HybridCache实例调用:

var page = await cache.GetOrCreateAsync( $"page-view:{pageId}", async ct => await LoadPageContent(pageId), tags: ["page", "region"]);

我特别看重的是它自带的防缓存击穿设计。多个并发请求同时查同一个 key 时,默认会合并为一次底层计算,避免“缓存刚过期,大量请求同时打穿到数据库”的典型事故。它内部的 L1 是进程内缓存,L2 对接IDistributedCache接口,你用什么样的远程缓存服务都可以挂上去。如果你的场景里已经有很重的缓存逻辑,迁移 HybridCache 不一定能立刻减少代码量,但至少可以统一标准,把自研两三层缓存慢慢淘汰掉。

5.3 内置 OpenAPI 文档生成:少装一个包不是重点

以前要做到项目启动后自动暴露/swagger这类接口文档页面,有两个固定动作:加第三方 OpenAPI/Swagger 相关包,再注册相关服务。.NET 9 把 OpenAPI 文档生成做成了框架内置能力,启动配置明显简化:

builder.Services.AddOpenApi(); var app = builder.Build(); app.MapOpenApi();

基础效果是能自动生成 OpenAPI 描述文件,供接口调试工具和前端代码生成使用。这里我想提醒的是:内置能力不等于“和原来的调试页面完全一样”。很多人第一次升级后发现,生成出来的 JSON 文档格式没问题,但可视化调试页面比原来第三方库默认提供的简单很多。如果你的团队已经习惯了原来那种带接口调用表单、分组清晰、还能直接测试的页面,那么你可以继续保留第三方库,安装它并不影响内置 OpenAPI 生成。也就是说,内置能力解决的是“我没有第三方依赖也能用 OpenAPI”的问题,至于要不要把它当主力调试入口,取决于你的团队习惯。

6. 从旧版本迁到新版本的踩坑记录:行为变化和回归测试清单

6.1 启动时才开始爆发的错误,最考验迁移方案

从 .NET 7 或 .NET 6 迁到 .NET 8/9,我的经验是:编译通过的代码不等于运行正确。最典型的是依赖注入容器的验证变得更严格了。以前某个服务在启动时没有校验完整的对象图,只在第一次真正解析时才暴露问题;升级之后,开发环境下启动阶段就会做更多校验,一些被隐藏的缺失注册直接变成启动异常。这其实是好事,但迁移窗口期内的工作量会增加,尤其是一个项目里注册了几百个服务的时候。

另一个意料之外的问题是测试框架行为差异。我维护过的一个服务,集成测试用内存测试服务器跑,升级后某些测试因为请求路径的前缀、AuthenticationHeader 的处理差异而失败。这里我强烈建议把测试项目也纳入迁移范围,不要只看生产启动是否正常。那些平时很少被触碰的黑盒测试,反而是最先暴露兼容问题的地方。遇到这种问题,先不要急着改测试断言,优先去查框架版本迁移指南里关于测试服务器的说明。

6.2 中间件顺序、静态资源与认证方案的回归项

如果你改了MapStaticAssets或认证方案,回归清单不能只覆盖接口。我整理了一个简易检查表,可以直接拿去做升级排查:

检查项容易被忽略的点建议检查方式
静态资源发布后 JS/CSS 是否带上了指纹,开发环境和发布环境是否一致线上浏览器强制刷新后看网络面板
认证接口/register、/login 的返回状态码和 Cookie 行为是否符合预期用接口工具完整走一遍注册登录流程
缓存策略输出缓存和 HybridCache 的 key 是否导致了脏数据连续请求同一个接口,观察响应头变化
路由匹配最小 API 的新增端点是否与旧路由冲突写几个针对性集成测试覆盖旧路径
文档生成内置 OpenAPI 生成的 JSON 是否满足前端团队的工具需求让前端团队用 JSON 生成一次代码
依赖注入有没有服务在启动时因 keyed 特性而解析失败设置环境变量启动一次空请求

6.3 老项目迁移的谨慎顺序

我建议老项目不要贪多,一次只做一层升级。比如先升级目标框架到.NET 8,跑完全部测试;再开启 C# 12 语法特性,逐个文件改成主构造函数、集合表达式;最后才考虑是否要引入 .NET 9 的新框架特性。如果一步到位跨两个大版本,出了问题很难判断是哪一层引起的。

另一个重要的前置步骤是先删掉项目里那些依赖过时 API 的第三方库。很多迁移问题并不是 .NET 本身变难用了,而是旧版第三方库和目标框架不兼容。先升级基础依赖,再动业务代码,顺序反了会陷入“业务代码和框架代码混着报错”的泥潭。我不用什么自动迁移工具也能完成这些事,但前提是对项目里各模块的依赖关系足够清楚。模块边界模糊的老项目,建议先补自动化测试,再动手升级。

最后再说一个我一直保留的习惯:每次框架大版本发布后,我会专门抽时间把一个最小的 Demo 项目升级一遍,跑通新特性,写下升级笔记。不要等项目真正需要升级的时候才去研究,那时候往往工期紧、问题多,心态也是慌的。现在把版本迁移的常见坑提前摸一遍,真正动手时你会发现,大部分问题其实都在预期之内,是个体力活,而不是冒险活。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询