这段时间我集中把两个 ASP.NET Core 服务做了升级,一个从 .NET 7 升到 .NET 8,另一个直接切到了 .NET 9 预览版,顺手把 C# 语言版本也调到了 12/13。说实话,这几年 .NET 和 C# 的新功能,已经有很大一部分不再是“看看就好”的语法糖,而是真正改变你写接口、写服务、做部署的方式。这篇就把我在 ASP.NET Core 项目里实际用到的、踩过坑的 .NET / C# 新特性做一个完整梳理,重点覆盖主构造函数、集合表达式、键控依赖注入、Native AOT、内置 OpenAPI、HybridCache 这些内容。适合正在计划升级老项目、或者刚接触新版 .NET 生态的朋友参考。
我按“语言层 → 框架层 → 部署层 → 新版本尝鲜 → 迁移避坑”这个顺序来讲,尽量每条都带上能直接跑起来的示例代码和实测感受。
1. C# 12/13 语言层的核心变化:主构造函数与集合表达式
1.1 主构造函数:告别重复的样板字段
C# 12 把“主构造函数”带到了普通类和结构体上。以前写一个服务类,依赖注入的代码基本是固定的三件套:声明私有字段、写构造函数、在构造函数里赋值。现在直接把这个流程压缩成一行:
public class WeatherService(ILogger<WeatherService> logger, HttpClient httpClient) { public async Task<string> GetWeatherAsync(string city) { logger.LogInformation("查询城市 {City} 天气", city); return await httpClient.GetStringAsync($"https://xxx/weather/{city}"); } }看起来好像只是少了几行代码,但实际用起来爽感是持续的。一个真实项目中 90% 的类都是这种“构造时拿到依赖,方法里直接用”的形态,主构造函数让类的意图更集中,依赖列表直接挂在类名后面,阅读代码时一眼就能看到这个类需要什么。
但这里有个核心坑必须提醒:主构造函数的参数可以在这个类的任意实例方法中直接访问,这是编译器通过“捕获”机制实现的,但你不能在实例字段初始化器里去引用这些参数。原因是字段初始化器的执行时机在构造函数体之前,此时主构造参数还没有被赋值。比如下面这段代码会直接报错:
public class BadService(ILogger<BadService> logger) { private readonly ILogger<BadService> _logger = logger; // 编译错误 }遇到这种情况,正确做法还是把参数显式赋值给字段,或者直接在这个类的方法里使用 logger 参数,不要把它复制到字段再使用。另外,继承时主构造函数也有讲究,派生类如果不显式声明构造逻辑,编译器会要求你通过 base 把参数传上去,写起来不算麻烦,但第一次用容易懵。
1.2 集合表达式:从 new List 到 []
C# 12 的集合表达式是我日常开发中感知最明显的一个语言特性。以前创建集合并往里面塞值的写法,现在基本可以全部替换成中括号加展开符:
// 旧写法 var list = new List<string>(); list.Add("A"); list.Add("B"); // 新写法 List<string> names = ["A", "B"]; // 空集合也简洁了 List<int> empty = []; // 合并集合 List<string> all = ["A", "B", .. more, .. extra];在 ASP.NET Core 接口开发里,最常用到集合表达式的场景是校验错误汇总。以前要 concat 再 ToList,现在直接用展开语法:
public static List<string> Validate(CreateUserRequest req) { return [ .. ValidateName(req.Name), .. ValidateEmail(req.Email), .. ValidateAge(req.Age) ]; }这个特性的底层不只是语法糖。编译器会尽可能使用CollectionBuilder或者 span 形式来构建集合,能避免不必要的中间分配,性能比老式写法更好。集合表达式同样支持Span<int> span = [1, 2, 3],这种写法在不触发堆分配的情况下,直接拿栈上的 span 处理数据,在做高性能接口时很实用。
1.3 类型别名和 ref readonly:几个用得上的小改进
C# 12 允许对任意类型使用using别名,不只是原来的命名空间。比如在业务代码里,我经常用元组表示一些轻量数据:
using Location = (string City, string District); using PriceRange = (decimal Min, decimal Max);然后在服务层就能写出更接近业务语义的签名,不用到处写Tuple<string, string>。C# 13 里还扩展了部分属性和索引器,配合半自动属性一起用,对做 DTO 定义挺友好。至于ref readonly参数,在 Web API 的日常差异不大,但如果你的服务里有大结构体参数且必须避免复制,可以考虑。
2. .NET 8 带来的 ASP.NET Core 开发体验升级
2.1 Minimal API:从“能用”到“好用”
Minimal API 在 .NET 8 补上了很多关键拼图。以前写小型接口,绑定表单、上传文件、返回类型处理都不是很顺手,现在这些场景都完善了:
// 表单绑定,需要引入 Microsoft.AspNetCore.Http.HttpResults app.MapPost("/upload", async (IFormFile file, [FromForm] string tag) => { // 处理文件 }).DisableAntiforgery(); // 非浏览器场景需要关闭防伪验证TypedResults系列在 .NET 8 也逐步完善,比如TypedResults.Created、TypedResults.NoContent,用它们可以给 Swagger/OpenAPI 生成提供准确的状态码信息,不再是一水的 200。还有个容易被忽略的改进是AsParameters,你可以把多个查询参数、路由参数整理成一个对象,构造函数通过[AsParameters]标记就能自动绑定,接口签名不再堆五六个参数:
app.MapGet("/search", (SearchQuery query) => { return Results.Ok($"搜索 {query.Keyword} / {query.Page}"); }); public class SearchQuery { public string Keyword { get; set; } public int Page { get; set; } = 1; }Minimal API 的路线图已经走得非常远了。如果你还在纠结“小接口到底用 MVC 还是 Minimal API”,在 .NET 8 之后这个选择题的意义已经不大,Mini API 能力足够覆盖绝大多数中小业务接口。
2.2 键控依赖注入:解决“同名服务多个实例”的经典问题
.NET 8 的 ASP.NET Core 加了键控依赖注入,这是我在实际项目里最期待的能力之一。以前要按不同配置注册同一个接口的多个实现,只能在构造时用IServiceProvider手动解析,或者绕道工厂模式。现在可以这样直接注册:
builder.Services.AddKeyedSingleton<IUserStore, RedisUserStore>("cache-eu"); builder.Services.AddKeyedSingleton<IUserStore, RedisUserStore>("cache-us"); app.MapGet("/user", ([FromKeyedServices("cache-eu")] IUserStore store) => { return store.GetUser(); });对 MVC controller,[FromKeyedServices]可以放在 action 参数上。但要注意,键控服务不能直接通过构造函数注入,框架没给构造函数参数贴键的机制。那么构造函数注入键控服务有没有办法?有,通过IServiceProvider扩展方法手动取:
public class UserController(IServiceProvider services) : ControllerBase { private readonly IUserStore _euStore = services.GetRequiredKeyedService<IUserStore>("cache-eu"); }不过这么写会把服务的键散落在业务代码里。我个人的建议是:如果控制器要依赖键控服务,定义一个轻量工厂来封装键的解析,比在各处写字符串键更安全。我的实测经验是键控 DI 非常适合多租户场景、多环境缓存切换、不同第三方渠道的客户端注册这一类需求,它让容器本身就把对象选型解决掉,业务层不再关心具体实现。
2.3 JSON 配置与 API 返回的默认行为调整
.NET 8 里 ASP.NET Core 对 JSON 序列化的默认配置做了收敛。现在 Minimal API 可以用ConfigureHttpJsonOptions直接调整 Web API 的 JSON 行为,而不是去改全局 JsonOptions:
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.PropertyNamingPolicy = JsonNamingPolicy.SnakeCaseLower; options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()); });这样做的好处是,web 层 JSON 序列化设置和后台的非 web 序列化设置可以互相独立。实际项目里常见的问题,比如枚举到底输出数字还是字符串、字段命名是驼峰还是蛇形,都可以在这一步集中处理,不需要在 DTO 上加一堆特性。
3. 部署与性能方向:Native AOT 和静态资源指纹
3.1 Native AOT 适合什么场景,不适合什么场景
.NET 8 将 Native AOT 发布引入到 ASP.NET Core 的生产可用范围,这是很多部署运维同学的福音。我专门用一个小接口做了实验,发布命令只需要:
dotnet publish -c Release -r linux-x64 -p:PublishAot=true发布完成后得到的不是需要安装运行时才能跑的 DLL,而是一个自包含的原生可执行文件,启动时间从原来的几百毫秒降到几十毫秒,内存占用也有明显下降。对容器环境特别友好,镜像可以直接基于 scratch 或者 alpine 构建,体积小很多。
但这个特性并不是万能的,必须泼一盆冷水:如果你的项目使用了反射、动态加载程序集、或者需要运行时生成代码(比如模板引擎、某些 ORM 的动态代理),AOT 会遇到大麻烦。传统的 MVC 控制器通过反射发现,所以 AOT 支持的重点落在了 Minimal API 上;EF Core 的 AOT 支持也比完整版严格,需要提前使用编译模型工具生成模型代码。我实际测试过一个复杂项目,打开 AOT 后报了一堆裁剪警告,最后只能放弃 AOT 退回普通发布。结论是:小而专的 API 服务、无反射、无动态代码,才适合 AOT。
如果决定上 AOT,JSON 序列化这块必须用源生成器配合,比如:
[JsonSerializable(typeof(List<WeatherDto>))] [JsonSerializable(typeof(WeatherDto))] internal partial class AppJsonSerializerContext : JsonSerializerContext { }然后注册:
builder.Services.ConfigureHttpJsonOptions(options => { options.SerializerOptions.TypeInfoResolverChain.Add(AppJsonSerializerContext.Default); });这样裁剪器才能保留所需的序列化代码,不然运行时序列化会直接失败。
3.2 .NET 9 的 MapStaticAssets 与指纹缓存
如果项目里带一点前端静态资源,.NET 9 的静态资产指纹功能值得好好看看。传统的app.UseStaticFiles()不管文件内容变没变,浏览器认的是 URL 地址,更新后容易出现缓存不刷新。新方案用MapStaticAssets为每个静态文件生成内容哈希文件名,配合强缓存策略,让文件只有在内容变化时才重新下载:
if (app.Environment.IsDevelopment()) { app.MapStaticAssets(); } else { app.MapStaticAssets(); // 生产环境也是同一个入口 }框架会自动把资源的 URL 改写成带指纹的版本,比如样式表从app.css变成app.abc123.css。我试着把前端文件直接放到wwwroot,通过新 API 访问后查看响应头,缓存策略是自动带上的,不需要自己写复杂的缓存规则。
有一点要注意:因为生成的文件名变化,不能在前端代码里硬编码资源路径,页面上要用框架提供的标签生成方式去引用。这个特性对于 Blazor 项目或者 ASP.NET Core 作为前端宿主的小型应用非常有帮助。
3.3 响应压缩和中间件配置的小优化
新版 ASP.NET Core 对中间件配置也做了一些收敛。比如在自定义中间件时,现在可以通过IApplicationBuilder的扩展支持按条件插入;对于 HTTP 日志,.NET 8 以后内置了简化的日志中间件app.UseHttpLogging(),可以自动记录请求路径、状态码、耗时,不用再自己写管道统计。我经常用它替代以前自定义的日志中间件,输出格式足够排查大多数线上问题。
4. .NET 9 / C# 13 里的新玩具:OpenAPI 与 HybridCache
4.1 内置 OpenAPI:不再必须依赖第三方包
. NET 9 把 OpenAPI 文档生成正式纳入框架,意味着你可以不再额外引入 Swashbuckle 之类的第三方包。启用方式很简单:
builder.Services.AddOpenApi(); if (app.Environment.IsDevelopment()) { app.MapOpenApi(); }运行后访问/openapi/v1.json就能拿到标准 OpenAPI 文档。它的内部实现是直接扫描 Minimal API 的元数据,因此能拿到的信息比通过反射猜测要准确得多。
我自己实测过,如果之前用的是 Swashbuckle 的AddSwaggerGen,迁移到内置方案后,接口的请求、响应结构基本一致,但带 XML 注释的接口说明不会自动拾取,需要额外配置。另外,内置 OpenAPI 默认只覆盖当前程序集暴露的端点,如果你有第三方库动态注册的端点,可能要手动补充文档信息。
如果想继续用 Swagger UI 的可视化界面,也可以只依赖内置文档生成器,UI 层继续用 Scalar 或其他工具读取同一个/openapi/v1.json。说实话,只要不需要特别复杂的扩展,这个内置方案已经完全够用了。
4.2 HybridCache:终于有一个统一的缓存抽象
.NET 9 的 HybridCache 对缓存使用体验的提升非常大。以前开发接口缓存,要同时弄IMemoryCache和IDistributedCache,二级缓存逻辑完全得自己拼。现在直接注入一个HybridCache就能同时利用进程内缓存和外部分布式缓存:
builder.Services.AddHybridCache(options => { options.DefaultEntryOptions = new HybridCacheEntryOptions { Expiration = TimeSpan.FromMinutes(5), LocalCacheExpiration = TimeSpan.FromSeconds(30) }; });业务代码里调用:
public class ProductService(HybridCache cache) { public async Task<ProductDto> GetProductAsync(int id) { return await cache.GetOrCreateAsync( $"product-{id}", async cancelToken => await LoadFromDb(id), cancellationToken: cancelToken); } }内部逻辑是:先查内存缓存,没命中再查 Redis 等分布式缓存,再没命中才走你的加载方法,并且加载完成之后自动回填两级缓存。它还支持批量失效,通过RemoveByTagAsync可以一次性失效一批相关缓存。相比过去自己写“缓存服务”,这个抽象省掉了很多样板代码,也更不容易写出缓存穿透的 bug。
我唯一要注意的是,HybridCache 需要注册一个底层 IDistributedCache 才能工作。所以步聚一般是注册缓存客户端(比如 StackExchange.Redis 的 IDistributedCache 实现),再注册 HybridCache。
4.3 C# 13 的 field 关键字和 params 集合
C# 13 把field关键字转正了,用来在属性访问器里直接引用编译器生成的后备字段。举个最常见的 Trim 字符串场景:
public string Name { get => field ?? string.Empty; set => field = value.Trim(); }以前的写法必须先声明private string _name;才能做这个逻辑,而且很容易写漏。field关键字让属性保留了“看起来是属性”的简洁,又能处理自定义逻辑。另一个是params集合,现在可以支持params List<T>和params IEnumerable<T>:
void InfoLog(ILogger logger, params List<string> messages) { foreach (var m in messages) logger.LogInformation("{Message}", m); }new类型的Lock也值得提一句,System.Threading.Lock配合 C# 13 直接支持,在锁的本质上是调用 EnterScope,性能比老 lock 更好:
private readonly Lock _gate = new();在高并发场景下,如果你的服务里有需要串行化的临界区,这个替换成本很低,收益明确。
5. 实际迁移时遇到的坑与几点建议
5.1 主构造函数继承的编译问题
主构造函数在继承体系里比较隐晦。假设基类有主构造函数,派生类也有主构造函数,编译器要求派生类的参数数量至少能匹配基类,否则会报错。我第一次遇到是在重构一个基础 Controller 时,基类需要注入ILogger,派生类只想注入自己的领域服务,结果发现还要把基类参数原样透传。这不是无法解决,但会降低“少写样板代码”的初衷。所以我的建议是:主构造函数更适合叶子服务类,基类多、继承链深的地方,用传统构造函数反而可读性更好。
5.2 AOT 和反射的冲突是最大变量
我在测试 AOT 发布时,项目里用了反射去扫描程序集实现类并动态注册到容器,在普通运行时一切正常,打开 PublishAot 后这些功能全部“静默失效”或者直接被裁剪掉。框架会给出警告,但警告特别多,需要花时间逐个评估。另外,EF Core 用 AOT 前必须提前做模型编译:
dotnet ef dbcontext optimize生成的CompiledModel需要在OnModelCreating时挂上,代码逻辑上会增加复杂度。除非性能要求非常明确,否则我个人建议老项目不要轻易上 AOT,新项目从一开始就按 AOT 约束设计,成本更可控。
5.3 键控 DI 的常见误区
键控 DI 有个典型误区:以为只有注册的时候用 key 就能在任意地方自动注入。实际上你必须用[FromKeyedServices]或GetRequiredKeyedService主动表明要哪个键。在 Minimal API 的 lambda 参数上使用[FromKeyedServices]非常自然,在 MVC controller 构造函数里就稍微麻烦。我曾经在一个 controller 里注册了三个不同渠道的 HttpClient,结果构造函数没法直接区分,最后还是用了工厂方法包装了一层。所以选型前先想清楚使用场景,键控 DI 更适合 Minimal API 或即取即用的地方。
5.4 升级路线的可行建议
我的实践顺序是:先升级 C# 语言版本,用新语法重构局部代码,再升级目标框架。具体操作是把TargetFramework改成net8.0,LangVersion改成12,先让编译通过,再根据警告逐项清理。从 .NET 8 到 .NET 9 的跨度更小,主要检查包版本和第三方库的兼容性。如果项目还卡在 .NET Core 3.1 或者 .NET 6,建议优先跳到 .NET 8,因为它是 LTS 版本,支持周期长,中间过渡会平滑很多。升级过程中最容易出问题的是老第三方库对新运行时版本的兼容性,上线前务必做接口的冒烟测试,别只靠编译通过就完事。
最后分享一点个人体会
这些新功能里,我实际用到收益最大的是主构造函数、集合表达式、键控 DI 和 HybridCache。前三个是每天都要写代码时直接受益的,HybridCache 则把我以前分布式缓存那套手写封装直接替换掉了。AOT 和 MapStaticAssets 属于特定场景才需要,但一旦匹配场景,效果非常显著。技术选型时我的一个判断标准很简单:这个特性是让我每天少写重复代码,还是只让我在发布那一下更爽。前者我会优先用,后者我会评估清楚再上。希望这篇总结能帮你少踩几个坑,如果里面某些特性你也刚上手,欢迎在实际项目中多试几次,体验会更直观。