1. 先别急着动手,搞懂这次升级到底在升什么
我接过不少老项目,几乎每次听到“把 ASP.NET MVC 升级到 .NET”这句话,第一反应都是先按住对方的手,别急着开 IDE。因为这句话本身就有歧义:你手里那个跑在 .NET Framework 4.x 上的 ASP.NET MVC 5 项目,和我说的“.NET 6/7/8”平台,根本是两个时代的东西。很多团队死磕了几个星期,最后发现双方连目标都没对齐。
先说清楚这次的升级逻辑。ASP.NET MVC 是一个 Web 框架,它属于旧版 .NET Framework 时代,自带 System.Web 那一套,跑在 Windows 的 IIS 上。而现在的 .NET(.NET Core 之后统称 .NET 5/6/7/8)是一个跨平台运行时,搭配的 ASP.NET Core MVC 是彻底重写过的框架。所以“升级”本质上包含了两层:底层运行时从 .NET Framework 换到 .NET,上层框架从 ASP.NET MVC 换成 ASP.NET Core MVC。这两层必须一起换,没有只换一半的选项。
我见过有人试图先升级到 .NET Framework 4.8,再慢慢改代码,这种做法不能说完全没用,它确实能清理一部分 API 层面的兼容性问题,但整体思路如果还是 System.Web,那离真正的现代化还有十万八千里。我的建议是:直接以 .NET 8 为目标(现在 .NET 8 是 LTS 版本,支持到 2026 年 11 月,比 .NET 7 那种非 LTS 稳妥得多),一口吃成胖子虽然后期痛苦,但至少方向不会跑偏。
这篇文章不是理论科普,而是我实打实从一堆老项目中趟出来的升级路径。从评估、准备、改造、排坑到上线验证,每一步都有据可查。如果你手里也有一个跑了五六年的 MVC 老项目,被老板要求“提升性能”“上容器”“要跨平台”,又或者只是因为 .NET Framework 4.x 的官方支持即将结束被迫迁移,那这篇内容就是给你准备的。
我不打算给你画一个轻飘飘的“升级很简单”的大饼,恰恰相反,真实的过程涉及大量琐碎工作:项目文件格式重构、配置系统替换、依赖注入改造、身份认证机制重写、第三方库替换、性能调优……每一块都会出问题,只是多少的问题。把预期放低,才能把过程做扎实。
2. 升级之前的那道选择题:原地迁移还是渐进式重构
很多人拿到老项目,第一个动作就是创建一个新的 .NET 8 项目,然后把旧代码往里面拷贝。这个思路没有错,但先别急着敲键盘,因为迁移策略直接决定了你这几个月的痛苦程度。我总结下来,主流方案有三个,适合不同情况的项目。
第一个方案:整体迁移(Big Bang)。把整个解决方案一次性迁移到新平台,所有项目全部转换,然后在分支上改到能编译、能运行、能过测试,最后再合并进主干。这个方案适合项目规模不大(比如核心就是两三个项目、代码量在几十万行以内)、团队人数不多、没有并行迭代压力的场景。优点是干净,缺点是周期长(通常 2 到 4 周起步),期间新的需求完全冻结,如果产品经理在这时候塞需求进来,你会崩溃。
第二个方案:渐进式并行(Strangler Pattern)。保留旧系统继续跑,新系统在老系统旁边慢慢长出来,通过反向代理或者负载均衡把一部分请求导流到新系统,验证通过后逐步扩大流量比例,直到老系统退居幕后。这个方案适合项目非常大、不可能一次性迁移完成、又没法接受长时间冻结业务的场景。很多大公司实际都是这么干的。缺点是技术门槛高,你需要同时维护两套系统的部署、数据同步、会话共享,复杂度直接翻倍。
第三个方案:先做模块解耦,再逐模块迁移。把老项目里的核心业务逻辑和表现层分开,比如把数据处理的部分抽成独立的类库,在新平台重新实现 Web 层,通过接口调用方式把老代码绑进来。这个方案适合团队想反复试水、不想一次梭哈的情况,但注意:它不适合项目结构本身就像一团乱麻的情况——如果连模块边界都划不清,解耦只是把你的痛苦分摊到更多代码里。
就我自己的经验,大部分人遇到的场景其实是第一种和第二种的组合:项目不算小,但也没大到必须长期并行;团队有几个人,但都不熟悉新平台。这种情况下,我会建议你走一条“模拟整体迁移”的路线——在一个独立的分支里,花两周时间把整个解决方案强制迁移到能编译能跑,不要管细节完美不完美,先把主要问题全部暴露出来。这一步的意义是让你拿到一份“问题清单”:哪些类库没有对应版本,哪些 API 不好替换,哪些第三方组件是坑。有了这份清单,后面的决策才有依据。
我的习惯是,做这次评估的时候会拉一个表格,把解决方案里每个项目单独一行,标注它的类型(Web 应用、类库、测试项目)、依赖的关键 NuGet 包、以及预估迁移难度(低/中/高)。别小看这张表,它会成为你整个迁移期间的作战地图。下表是我帮一个朋友做迁移评估时实际用过的模板,你可以参考:
| 项目 | 类型 | 关键依赖 | 迁移难度 | 主要风险 |
|---|---|---|---|---|
| AdminSite | ASP.NET MVC 5 | Entity Framework 6, Autofac | 中 | DI 替换、EF 升级 |
| Core.Domain | 类库 | 无 | 低 | 目标框架修改 |
| Core.Services | 类库 | System.Web(注意) | 高 | HttpContext 引用 |
| Legacy.Utils | 类库 | Newtonsoft.Json | 低 | 基本无 |
| Tests | MSTest | 无 | 中 | 测试框架迁移至 xUnit/NUnit |
等你把这张表填完,迁移的整体工作量就浮出水面了。接下来,就进入实操阶段。
3. 环境准备:别用老 IDE 干新活的三个理由
很多人在第一步就栽了跟头:用 Visual Studio 2015 打开 .NET 8 项目,报错看不懂,改无可改。迁移到新平台,开发环境必须跟着升级,这不是矫情,是硬性要求。
以我的推荐配置来说,Visual Studio 2022(17.8 或更高版本)是 Windows 上的首选。它内置了 .NET 8 SDK 的工具链,支持新版本的项目文件格式(SDK-style)、热重载、以及基于 HTTP 的调试体验。如果你用的是 Mac 或者 Linux,那么 Visual Studio Code + C# Dev Kit 也能用得很顺手,命令行构建配合dotnetCLI 完全够用。很多老代码只能跑在 .NET Framework 的地方(比如某些遗留 WebForms 项目),在 VS2022 里可以用“条件编译”的方式兼容,不过那是另一个话题了,这里不展开。
至于 .NET SDK 版本,我的建议是直接装.NET 8.0 SDK,别贪新用预览版做生产迁移。LTS 版本的稳定性意味着你不会在迁移过程中被运行时或编译器的 bug 打断。在命令行里验证一下环境是否就绪:
dotnet --version # 你应当看到类似 8.0.100 或更高版本的数字除了工作机环境,还有一个我看大家容易忽略的:源码管理分支。在动手之前,建议你从主干拉一条feature/migration-to-net8分支出来,并且在迁移期间所有提交都只进这条分支,不允许直接合入主干。这不是流程控,而是万一中途要做紧急 bug 修复,主干还能正常出版本,不会因为迁移搞到一半导致整个团队没法干活。我见过因为迁移分支污染了主干,最后只能回滚的惨案,别重蹈覆辙。
最后一个是代码分析工具。老项目里往往积累了大量的编译警告和过时代码,迁移之前我会先跑一遍静态分析(比如 Visual Studio 自带的代码分析或者 NuGet 包分析器),把这些警告导出一个基线清单。迁移完成后对比这个清单,能帮你验证迁移是否在“行为不变”的前提下完成。很多人不做这一步,导致迁移后代码虽然能跑,但隐藏问题一堆,上线后靠 bug 来测试。
4. 核心迁移步骤:从项目文件、配置系统到入口程序的完整替换
4.1 第一步:把 .csproj 从“老古董”格式换成 SDK 风格
这一步是最机械、但也最容易出岔子的。旧版 .NET Framework 的 .csproj 文件是那种好几百行的 XML,里面到处是<Reference>标签、条件编译符号、以及一堆自动生成的 GUID 和导入语句。SDK 风格的项目文件只有几行到几十行,靠通配符自动引入所有 .cs 文件,靠PackageReference管理 NuGet 依赖。
我见过有朋友尝试手动把旧 .csproj 改写成新格式,结果改了半天下半身,GUID 搞丢了,需要重新添加项目引用。我的建议是:不手动改,而是直接新建 SDK 风格的项目文件,然后把旧代码文件搬进来。具体操作如下:
- 在解决方案里新建一个类库项目,目标框架选
net8.0。 - 将原来项目里的所有
.cs文件复制进新项目目录(SDK 风格会自动包含,不需要手动添加)。 - 将
App.config/Web.config中的连接字符串等配置迁移到新项目的appsettings.json。 - 在 NuGet 管理里重新添加原本的依赖包,能用新版本的就用新版本(见下方第 4.2 节)。
- 删除旧项目文件,把新项目文件重命名为旧项目的名字。
对于 Web 项目本身,因为 ASP.NET Core MVC 的项目结构和 ASP.NET MVC 差异太大(没有 Global.asax、没有 Controller 列表配置、没有 App_Start 文件夹),所以 Web 项目通常不是“改造”,而是“新建 + 把 Controller / View / Model 搬过来”。下面会专门讲。
4.2 第二步:NuGet 包的替换优先级与兼容性排查
NuGet 包是迁移过程中最磨人的环节。老项目里躺着各种老版本包,有些在新平台上有对应版本,有些没有,有些换了新名字。我的经验是这样的:
- 改动最小的:Newtonsoft.Json、AutoMapper、NLog 这类与运行时关系不大的包,通常有 netstandard2.0 或 net6.0 版本,直接升级版本即可。
- 需要换替代品的:有些不兼容 System.Web 的包,比如跨域配置包、某些基于 OWIN 的中间件,可能需要换成 ASP.NET Core 原生中间件。
- 直接淘汰的:一些老式工具类库,比如替换 web.config 里
<httpModules>的包、依赖 AppDomain 的插件机制,这些在新平台根本没有对应 API,只能重写相关逻辑,不能用“降级”手段硬扛。
一个很实用的排查技巧:在 NuGet 包里找该库的TargetFramework支持情况。如果它包含netstandard2.0或netcoreapp3.1以上的目标,大概率可以直接用。如果只有net461或<netstandard1.x这种老目标,那就需要谨慎评估,很可能会有运行时兼容问题。
这里有一个我踩过的大坑:某个日志组件,在旧项目里用的是 1.x 版本,写了大量自定义 Appender,迁移后发现新版本框架完全变了,Appender 那一套已经被移除,改用ILoggerProvider。这意味着我不仅要升级包,还要重写那段自定义日志代码。这种隐藏工作量,往往被团队在评估时完全忽略,建议你在做工作量评估时按 1.5 到 2 倍的系数去打余量。
4.3 第三步:Startup 类与新入口——替换 Global.asax 和 App_Start
ASP.NET MVC 老项目的核心入口是Global.asax,里面挂着Application_Start(),然后到哪里调用什么“Config”方法,比如AreaRegistration.RegisterAllAreas()、FilterConfig.RegisterGlobalFilters()、RouteConfig.RegisterRoutes()。这些代码在 ASP.NET Core 里不存在了,取而代之的是Program.cs(.NET 6+ 简化模板)里的WebApplicationBuilder和WebApplication。
转换对应关系大概是这样:
| 老概念 | 新概念 |
|---|---|
| Global.asax / Application_Start | Program.cs 里的 builder 配置与 app 构建 |
| RouteConfig | app.MapControllerRoute()或属性路由 |
| FilterConfig(全局过滤器) | builder.Services.AddControllers(...options => ...)里的 Filter 配置 |
| BundleConfig(打包压缩) | 前端构建工具或静态中间件(建议直接换前端方案) |
| Unity / Autofac 容器 | builder.Services自带的依赖注入容器,或保留 Autofac 作为第三方 Provider |
| Web.config transform | appsettings.json+ 环境变量,搭配 launchSettings.json |
举个例子,老项目里你大概写过类似这样的路由注册代码:
public static void RegisterRoutes(RouteCollection routes) { routes.IgnoreRoute("{resource}.axd/{*pathInfo}"); routes.MapRoute( name: "Default", url: "{controller}/{action}/{id}", defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional } ); }在 ASP.NET Core 里,等价物是 Program.cs 里的这段:
var builder = WebApplication.CreateBuilder(args); builder.Services.AddControllersWithViews(); var app = builder.Build(); app.UseStaticFiles(); app.UseRouting(); app.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}"); app.Run();你可能会说“这不是差不多吗”,对,概念上确实相似,但底层差别巨大:新路由系统基于 endpoints,支持属性路由混合、支持中间件管道管道顺序控制,而且不再依赖 System.Web 的 Request/Response。接下来进入真正的改造核心。
4.4 第四步:Controller、View 和 Model 的搬迁工作流
Controller 的迁移算是整个过程中最“友好”的一段,因为 ASP.NET Core MVC 在 API 层面与老 MVC 高度相似。但有几个细节必须处理:
- Controller 基类:老项目里
Controller类来自System.Web.Mvc,新项目里改成Microsoft.AspNetCore.Mvc。这个 namespace 变化牵涉到所有 using,如果 Controller 文件多,建议批量替换而不是手动改。 - 返回类型:
ActionResult依然存在,但像FileResult、JsonResult这些的实现细节已经变了。特别是Json()方法:老 MVC 返回的是JavaScriptSerializer或 Newtonsoft.Json 序列化结果,新框架默认用 System.Text.Json(虽然在 .NET 8 里你可以通过配置切回 Newtonsoft.Json)。这个变化会导致 JSON 序列化大小写风格不同,前端如果对字段名有依赖,必须注意。 - 异步:老 MVC 的
asyncaction 已经支持,但 ASP.NET Core 里对异步的支持更加彻底。如果你有一些同步的 action,迁移时建议顺便改为 async(牵扯到 EF 操作的话几乎是必须的,下面详谈)。 - 依赖注入:老 MVC 里 Controller 可以直接
new或通过容器解析,新框架里 Controller 的构造函数里直接用构造函数注入即可,框架自动管理生命周期。
View 这边,.cshtml文件本身基本不需要大改,但注意Web.config里那些针对 Razor 的配置(比如<system.web.webPages.razor>)不再生效,默认配置就能跑大部分场景。布局页_Layout.cshtml里如果有用Scripts.Render或Styles.Render这类 Bundle 辅助方法,这些已经不存在了,需要自己写<script>/<link>标签,或者换用前端自动化构建方案。另外,老项目里常用的Session、ViewBag、TempData这些依然支持,但TempData默认实现从使用Session变成了基于Cookie(可以配置,但行为有差异),如果你依赖 TempData 跨请求传大体积数据,性能会有变化,需要注意。
Model 层(即你定义业务实体的那些类)通常迁移难度最低,主要是命名空间替换以及对DataAnnotations和Json.NET属性的调整。如果项目用了 Entity Framework 6,需要评估是继续用 EF6(它兼容 .NET Core 3.1+,可运行在现代 .NET 上,但效果并非完美)还是升级到 EF Core 8。我建议直接升 EF Core,因为 EF6 在新平台上的运行支持文档自己都说“优先使用 EF Core”,而且 EF Core 的 LINQ 翻译能力比 EF6 强太多(比如GroupBy翻译、Take的 SQL 生成)。但注意 EF Core 的 Fluent API 配置变化不小,从modelBuilder.Configurations.Add到modelBuilder.ApplyConfigurationsFromAssembly,年代感扑面而来。
下面我会专门把 EF6 到 EF Core 的迁移单拎出来,因为它太能暴露问题了。
5. 数据库访问层的迁移:EF6 到 EF Core 的真实差异和应对手段
如果说把 Controller 和 View 从一个项目复制到另一个项目是 3 小时工作量,那么数据访问层的迁移很可能就是 3 天。因为老项目里的数据访问代码不会只用基础 CRUD,往往还夹着一堆“高级技巧”:延迟加载、多对多映射、略复杂的 LINQ 查询、还有因为 EF6 翻译不完整被迫用AsEnumerable()的骚操作。
先列一个对比表,方便你心里有底:
| 维度 | EF6 | EF Core 8 |
|---|---|---|
| DbContext 注册 | 手动实例化或旧 DI 容器 | builder.Services.AddDbContext+UseSqlServer链式配置 |
| 延迟加载 | 默认开启(virtual 导航属性) | 默认关闭,需安装Microsoft.EntityFrameworkCore.Proxies包并在注册时UseLazyLoadingProxies() |
| LINQ 翻译能力 | 较弱,复杂查询经常拉全表到内存 | 翻译能力强很多,但极少数情况仍有客户端评估提醒 |
| 迁移命令 | Enable-Migrations/Update-Database等 | dotnet ef migrations add/dotnet ef database update |
| 配置映射 | [Table]特性和 Fluent API 混用 | 推荐全部用 Fluent API 的IEntityTypeConfiguration<T>类 |
| 批量更新删除 | 不支持,需先查后删 | 支持ExecuteUpdate/ExecuteDelete(.NET 7+),性能大幅提升 |
| 事务 | Database.BeginTransaction | 同样支持,但IDbContextTransaction命名空间不同 |
迁移过程中的第一个坑就是延迟加载。老项目里如果你习惯在 Controller 里访问某个实体的导航属性(比如customer.Orders),靠的其实是 EF6 默认的延迟加载。到了 EF Core 这里默认关闭了,如果你不重新开启或者不写Include显式加载,你只会在运行时得到 null 引用。这个问题在开发时不一定立刻暴露,因为单个对象还好,但当你遍历一个列表时,瞬间 N+1 查询让你慢到怀疑人生。
我处理这个问题的套路是:强制开启延迟加载(如果你不想大规模修改查询代码),但在业务代码层面明确它只是临时方案,长期还是要改成Include或用专门的数据加载策略。因为在 EF Core 下开延迟加载意味着额外开代理,是整个框架里性能最差、最容易踩坑的选项之一。但迁移阶段为了快速恢复“行为等价”,这个妥协是可以容忍的。
第二个坑是表达式树的翻译差异。EF6 里有些查询能跑,到 EF Core 里刚跑就报“could not be translated”,或者反过来。比如在谓词里调用自定义方法,EF6 会对本地方法静默处理(实际上是客户端评估),EF Core 会直接拒绝,要求你先AsEnumerable()再过滤,或者换成可翻译的写法。说实话,我更认可 EF Core 的严格态度,因为客户端评估会让数据量一上来就彻底瘫痪,老项目就是这么被拖慢的。遇到这种查询,建议你直接停下来思考业务意图,重写查询,而不是绕过。
第三个问题是迁移命令的变化。老项目里环境可能就是 VS 里的 Package Manager Console,敲Add-MigrationUpdate-Database,新项目里你要用命令行工具:
dotnet tool install --global dotnet-ef dotnet ef migrations add InitialMigration dotnet ef database update这个dotnet-ef工具的版本必须和Microsoft.EntityFrameworkCore.Design包版本匹配,否则容易报版本冲突。另外,迁移脚本里如果包含自定义 SQL(比如存储过程、视图),这些不会自动迁移,需要你手工导入到数据库,并在代码里做好对应的映射或调用。
6. 中间件管道和配置系统的替换:最容易翻车的地方
到一个新平台,最容易被忽略的就是请求处理管道的差异。老 ASP.NET 有一套基于 HTTP Module 和 HTTP Handler 的扩展机制(Global.asax 里也能写Application_BeginRequest之类的事件),ASP.NET Core 全部统一为中间件(Middleware),顺序很重要,执行顺序由app.UseXxx()的调用顺序决定。
这不是一个简单的“替换”操作,而是一次架构层面的重建。举个例子,老项目里可能有一段代码在每次请求进来时用 Session 查数据库、写审计日志。在新平台里,你要把它写成自定义中间件类:
public class AuditMiddleware { private readonly RequestDelegate _next; public AuditMiddleware(RequestDelegate next) { _next = next; } public async Task InvokeAsync(HttpContext context, AppDbContext db) { // 请求前逻辑:记录开始时间、读取请求头、写日志 await _next(context); // 请求后逻辑:记录状态码、耗时 } }记得把这中间件注册到Program.cs的管道中间。顺序讲究:比如UseRouting()必须在MapControllerRoute()之前,UseStaticFiles()必须在UseRouting()之前(虽然静态文件不会进 endpoints,但最佳实践是先静态后路由),UseAuthentication()和UseAuthorization()必须在 mapping 之前。如果你把顺序搞反了,你会看到一些妖魔鬼怪般的 bug:登录状态读不出来、静态资源 404、路由错乱,实际上都是管道顺序问题。
配置系统这块,老项目里的Web.config的<appSettings>和<connectionStrings>被新的appsettings.json+ 环境变量取代。我建议所有敏感配置(密码、连接串)一律走环境变量或 Secret Manager,不要直接写进 appsettings.json 并提交到 Git。连接字串的格式从老的:
<add name="MyDb" connectionString="Server=.;Database=MyDb;Integrated Security=SSPI;" />变成:
{ "ConnectionStrings": { "MyDb": "Server=.;Database=MyDb;Integrated Security=true;TrustServerCertificate=true;" } }注意Integrated Security=SSPI和true的区别,以及新 SQL Server 连接串里对加密和证书的默认策略调整(否则会连不上),这些细节在迁移时很容易踩。
还有一处容易翻车:老代码里到处都是ConfigurationManager.AppSettings["key"]的读法,换到新平台后ConfigurationManager不存在了,需要改为在构造函数里注入IConfiguration然后_config["key"]。这个替换看着简单,但如果是几千处调用的老项目,会让你查得手软。我在大项目迁移时会先全局搜索ConfigurationManager数量,然后按模块分批改,不追求一天内完成,但每次提交只涉及清晰的一个模块。
7. 那些避不开的痛点:HttpContext、Session、认证与授权
7.1 HttpContext 全面改造
在旧版 ASP.NET 里,HttpContext.Current是全局静态的,你在任何地方都能直接取到当前请求的信息(QueryString、Session、User)。ASP.NET Core 彻底干掉了这个静态访问,改成构造函数注入IHttpContextAccessor或者从本身就是依赖注入的 Controller 基类里直接访问HttpContext属性。
这个变化对“什么都不能改、快速迁移”的项目来说是非常难受的。因为老项目里往往会有一个自定义的工具类,里面写着:
public static string GetCurrentUserId() { return HttpContext.Current.User.Identity.Name; }新平台里这行代码直接编译失败。正确的姿势是:
public class CurrentUserService { private readonly IHttpContextAccessor _httpContextAccessor; public CurrentUserService(IHttpContextAccessor httpContextAccessor) { _httpContextAccessor = httpContextAccessor; } public string GetCurrentUserId() { return _httpContextAccessor.HttpContext?.User.Identity?.Name; } }然后在Program.cs里记得注册builder.Services.AddHttpContextAccessor(),否则注入永远是 null。这个坑我见得太多了,几乎每个老项目迁移都会撞一次。
7.2 Session 和 Cookie 的行为差异
Session 在 ASP.NET Core 里不再由 System.Web 管理,而是通过中间件开启。你需要:
builder.Services.AddDistributedMemoryCache(); builder.Services.AddSession(options => { options.IdleTimeout = TimeSpan.FromMinutes(20); }); app.UseSession();顺序上,UseSession()必须放在UseRouting()之后、MapControllers之前。另外,Session 的 key 默认不再使用原先的ASP.NET_SessionId,而是Cookie携带AspNetCore.Session标识,如果客户端有脚本依赖这个 cookie 名称,要对齐或者直接告诉前端改一下。
Cookie 的行为变化也很大。老项目里如果做了“记住我”之类的登录持久化,生成的 cookie 是ASPXAUTH或者旧版 FormsAuthentication 生成的形式;新框架里使用ApplicationCookie前缀,默认命名AuthCookie或你自定义的Cookie.Name。为了平滑迁移,你需要在配置里把 cookie 名称显式指定为兼容模式,并在用户首次访问时让新旧 cookie 并存过渡。很多人遇到“登录后跳转正常,但刷新又要求登录”的诡异问题,多半就是 cookie 名称或验证方式不一致导致的。
7.3 认证与授权:从 FormsAuthentication 到 Cookie 认证
如果老项目用的是FormsAuthentication.SetAuthCookie(就是那个经典的AccountController代码),那么新平台已经彻底移除了这个 API,你要替换成 ASP.NET Core 的 Cookie 认证中间件。配置大概是这样:
builder.Services.AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme) .AddCookie(options => { options.LoginPath = "/Account/Login"; options.LogoutPath = "/Account/Logout"; options.Cookie.Name = "MyApp.Auth"; options.ExpireTimeSpan = TimeSpan.FromDays(7); });然后Controller里FormsAuthentication.SetAuthCookie对应要改成HttpContext.SignInAsync(principal),注销对应HttpContext.SignOutAsync()。这块改动并不大,但需要注意 Claims 的方式。老代码里通常直接用用户名区分角色(比如Roles.AddUserToRole),新框架习惯用 Claims 的ClaimTypes.Role来做角色标记,而且角色判断时推荐使用[Authorize(Roles = "Admin")]特性,而不是手动if (User.IsInRole) else。
另外一个非常重要但常被忽视的点:老项目里的 URL 授权规则(web.config中<location>加<authorization>)在 ASP.NET Core 里直接失效。你必须在代码里通过[Authorize]属性、全局授权策略或者中间件来重建授权映射。否则你以为某些页面是受保护的,实际上面向所有人开放了。这是高风险的“静默安全问题”,上线前必须逐个 URL 验证权限。
7.4 关于async的那个历史遗留问题
老 MVC 项目里,最常见的问题之一是为了省事把所有 Action 写成同步的,但底层 EF6 查数据库都是同步阻塞。到了 .NET 8,你几乎有义务把所有涉及 I/O 的操作改成 async,原因很简单:现代应用对高并发的要求更高,线程池资源就是你的核心竞争力。如果你继续用.Result、.Wait()或者同步 EF 调用,在 ASP.NET Core 下会导致线程池饥饿,高流量时整体吞吐急剧下降,甚至出现请求排队超时。这个问题在 .NET Framework 时代不明显是因为每个请求绑一个线程的执行模型有天然的“并发隔离”(虽然浪费),但现在不是这样了。
我在迁移中遇到过一个经典案例:一个报表页面,里面连续做了五六个数据库查询,全部是同步调用,在老平台上跑 8 秒没人抱怨,迁到 .NET 8 后一上线流量稍微大一点就卡到 20 秒以上。最后找出原因就是同步 IO 导致线程池耗尽。改成await之后,同样数据量下响应时间回到 1 秒左右。所以不必把 async 改造当成“顺手优化”,而是新平台固有的行为模式。
8. 第三方库和工具类代码的迁移:取舍与替代
现在聊聊一些你可能没想过的边角料,但往往会耗掉你两三天时间。
AutoMapper 的配置迁移。老项目里通常有AutoMapperConfiguration.RegisterMaps()这样的静态配置方法,新版本里你更推荐写在 Profile 类或模块化配置里。不过核心映射关系基本不用改,主要是配置初始化的位置变了。一个更容易踩的问题是:AutoMapper 新版本基于 .NET Standard 2.0,但与 System.Text.Json 的交互可能存在大小写不一致的问题,注意配置MapperConfiguration时设置 PropertyNamingPolicy。
Newtonsoft.Json 与 System.Text.Json 的分歧。上边也提过,ActionResult 默认序列化从 Newtonsoft 换成了 System.Text.Json,这导致 JSON 输出里的属性命名策略不同。举个例子,老代码里如果因为全局配置而输出 camelCase(首字母小写),新框架默认是 PascalCase(首字母大写),前端 JavaScript 端口如果写死res.data.UserName而不是res.data.username,就会瞬间爆炸。如果你希望尽量保持行为一致,在 Program.cs 里配一下:
builder.Services.AddControllersWithViews() .AddJsonOptions(options => { options.JsonSerializerOptions.PropertyNamingPolicy = System.Text.Json.JsonNamingPolicy.CamelCase; });或者干脆切回 Newtonsoft(如果你有一堆依赖 Newtonsoft 特性的自定义格式化器):
builder.Services.AddControllers().AddNewtonsoftJson();日志框架迁移。如果老项目用 NLog 或 Serilog,新框架自带的 ILogger 足够覆盖大部分需求,但如果需要结构化日志、写到特定文件等,仍建议花 10 分钟配置 Serilog。自己写自定义 Appender 的年代结束了,换一个设计更现代的日志系统能少受很多苦。
HtmlHelper、AjaxHelper 这类辅助方法。老 MVC 里的Ajax.BeginForm、Ajax.ActionLink都依赖 unobtrusive Ajax 或者 Microsoft jQuery 的.ajax库,这些在 ASP.NET Core MVC 里不存在,也不再被推荐。你需要把相关前端交互用标准的 fetch 或 jQuery.ajax 自己重建。这个工作量往往被严重低估,因为写起来容易,但设计上的交互模式变了:以前是服务端返回 HTML 片段,前端带X-Requested-With: XMLHttpRequest头去拿部分视图,现在常见的做法是服务端返回 JSON,前端渲染。如果你要保留原来的 HTML 片段方式,可以返回PartialView()再配合 fetch 注入,但把“视图渲染逻辑”和“数据加载逻辑”混在一起,长远看对维护不友好。
9. 迁移后的系统验证:坑已填平,怎么确认能上线?
迁移不是“编译通过 + 本地能跑”就完事的,上线前的验证我有一条铁律:找一份生产环境导出的匿名化数据副本,在暂存环境跑完整业务回归。
第一步,功能验证。拿一张业务流程图,把核心用户旅程走一遍:注册、登录、浏览列表、搜索、下单(或你对应的业务动作)、查看详情、退出登录。每一步记录状态码、响应时间、是否有 JS 报错。这里我能给你的建议是:不要只在浏览器里手动点,写一套基础的冒烟测试或至少用 Postman 把主要 HTTP 接口全量跑一遍,对比迁移前后的响应结构(字段名、顺序、类型)。
第二步,性能基线对比。在老系统和迁移后的新系统部署同样的环境(或尽量接近),用相同并发、相同数据量压测几个核心 API。主要看 P95 和 P99 延迟、吞吐量每秒请求数、以及内存占用变化。我通常会用dotnet-counters和dotnet-trace来观察 GC 频率和线程池状态,如果发现明显飙升,优先排查是不是有同步 IO、未释放的 DbContext 或过度使用Task.Run。
第三步,安全回归。老项目一般跑了很多年,各种漏洞修修补补。迁移后要确认[Authorize]生效、CSRF 令牌正常工作、输入验证没有被新框架默认配置破坏。特别注意:ASP.NET Core 默认对[ApiController]有自动模型验证,但这只作用于控制器标注了该特性的场景。老 MVC 项目如果没标[ValidateAntiForgeryToken]的安全习惯,迁移时往往不会自动补齐,要逐页审查。
第四步,路由回归。老项目里 SEO 友好、带可选参数、带 catch-all 的路由规则很多,在迁移后需要一条条验证。尤其是带*通配符的路由,在老版本里能匹配多段路径,新框架的 route pattern 通配语法不同(用{**path}),写错了会 404 一片。建议做一个路由清单表格,把旧路由模板、实际示例 URL、期望 Controller/Action、迁移后请求结果全部列出来。
最后是部署验证。不管你是直接部署到 Windows Server + IIS、还是已经上 Docker,都要验证静态文件缓存头、WebSocket(如果有)、HTTPS 重定向这些行为是否一致。IIS 下,老项目的web.config里可能配置了各种 HTTP 模块,迁移后要删掉,否则新平台会报“无法加载模块”的错误。Docker 镜像里要注意 .NET 运行时版本、时区、环境变量,这些都是经典问题。
10. 上线后我回头看的十条经验
我前后给不同的项目做过迁移辅导,也翻过不少老一辈开发的代码。在这篇文章的最后,我想直接列出十条我最想让你记住的经验,每一条都是我(和我带的团队)实际踩出来的,不是从文档里抄出来的理论:
老项目的“行为”并不都是对的。迁移过程中如果看到一个 SQL 慢查询、一个反模式写法,别为了“不改功能”而无脑照搬。新平台给机会重写的地方,顺手修掉比后续再折腾划算。
大量使用
HttpContext.Current的项目,不要试图一次性改完。先全局替换成注入模式,把静态访问隔离在一个专门的服务里,再慢慢裁剪。一步到位会很痛苦。Controller 里的业务逻辑,永远不要在迁移时继续堆积。新平台默认支持的最小 API、MediatR 之类的中介模式是更好的归属,但你不需要在迁移阶段引入,别贪心。
EF Core 的迁移工具链非常依赖
dotnet ef,提前在 CI 管道里安装好。否则迁移脚本不一致,数据库版本管理会乱套。Session 和 TempData 的默认实现会让你悄悄踩坑。如果老项目在分布式部署,Session 用了 StateServer 或者 Redis,记得在新平台选对 IDistributedCache 实现并且配置同一套 Redis,否则多实例部署后用户登录状态一会儿在、一会儿丢。
不要把 secrets 放进 appsettings.json。环境变量或 Secret Manager 才是正道。这是很多从 Web.config 时代过来的人改不掉的坏习惯。
配置 Logging 的数据库连接字符串是小事,但注意日志输出路径和格式。迁移后如果你硬编码了
/logs/app.log,在 Docker 里会因为目录无权限直接启动失败。Static Files 的处理方式不同了。旧项目在 web.config 里设置的自定义静态文件 MIME 类型,迁移后要重新配,否则某些特殊文件类型(比如
.json、.woff2)出现 404 或错误 Content-Type。API 层面的 JSON 变化要提前和前端沟通。前端很可能一直依赖旧的 JSON 序列化细节,比如时间为
"2025-01-01T00:00:00"而不是老 ASP.NET 站点常用的/Date(233...)/格式。这个要是通宵上线才发现,火葬场。迁移完成不是终点,把项目的 runtime 版本持续升级机制建立起来。.NET 8 之后有 .NET 9、10,LTS 之间升级的成本远小于从 Framework 跨到 Core 的成本。长期把项目停留在老版本,下一次被迫升级时又会是今天这个痛苦的局面。
我在迁移一个内部管理系统时,把这篇文章里的步骤完完整整走了一遍。开头崩溃了三天,等到把项目文件格式、Startup、认证、EF Core 四大硬骨头啃下来,后面的进度就顺多了。如果你正准备动工,建议今晚先把“迁移难度评估表”拉出来,然后在新分支里创建一个空的 .NET 8 项目,把一个最简单的 Controller 跑通。这段小小的成功经验,会给你接下来几周注入足够的信心。祝早日上线。