古老專案升級實戰(上):從 .NET Framework 4.6.1 漸進式遷移到 .NET 10
2026-08-04 16:46:36
其實真的開始進行,最大的原因還是客戶希望的。
某種程度上也算順水推舟啦,本來就一直想找機會試試看,而且對公司來說,如果能慢慢脫離 Windows-only 的環境,成本和部署彈性都很誘人。
畢竟工程師覺得技術很酷是一回事,公司願不願意投入資源,又是另外一回事。
但這次真的開始動手之後,我最大的感想反而是:
> **Legacy Migration 最難的,通常不是讓它 Compile,而是怎麼證明升級之後,它還是原本那個系統。**
---
## 為什麼值得做?
先講前提。
我們都知道一個很現實的原則:
> 能動就不要動。
就算是古蹟專案,只要線上跑得好好的,其實不升級也沒什麼問題。
所以要動它,總得有個理由。
### 對 RD 來說
我覺得這是一個滿難得的經驗。
平常比較常遇到的是新增功能、修 Bug、效能優化、系統維護,但「把一個已經在線上跑很多年的 .NET Framework 系統搬到新的 Runtime」,其實完全是另一種問題。
你要考慮的不只是:
* 程式能不能 Compile
* API 有沒有替代方案
* NuGet 能不能 Restore
而是更多像:
* 升級後的行為有沒有偷偷改變
* 舊系統會不會一起受到影響
* Runtime 行為是不是跟以前一樣
* 設定檔與環境變數要怎麼搬
* 部署方式改變後會不會出現新的問題
現在有 AI 輔助,確實讓這類工作比以前容易很多。(而且有時候還真的是 Token 沒地方花,有時候又一下子就沒了——要有技巧地用,比如早上 8 點上班前就先讓它跑,讓它先開始算 5hr Limit。)
它可以幫忙分析 Compile Error、比對 API 差異、搜尋替代方案、找跨專案相依,但最後還是工程師自己負責判斷:
> **這個修改到底安不安全。**
如果是三、四年前,要純靠人工一個一個查,我不一定真的會想碰這件事情。
現在至少變成「可以試試看」。
而且說真的,升級古董專案本身就滿有趣的。
### 對公司來說
另一個很直接的誘因就是基礎設施。
舊的 .NET Framework 系統基本上綁在 Windows Server。
我有問過 IT,如果 Linux 的成本抓 1,Windows 大概是 1.2 左右。
實際數字我看不到帳單,也會受到授權模式、VM 規格、Cloud Provider 等因素影響,所以無法提供更詳細的資訊了。
不過對我來說,比省下那一點費用更重要的是:
```text
.NET Framework
↓
Windows Only
```
如果搬到新版 .NET,就有機會變成:
```text
.NET 10
↓
Linux
↓
Container / Docker
```
從「只能跑 Windows」變成「Linux 也能跑」,基礎設施和部署方式的選擇會自由非常多。
這個長期價值,我覺得比單純省多少錢還重要。
---
## 這次到底想解什麼問題?
先把公司的架構簡化一下。
大概可以想成:
```text
Solution / Repositories
├── Lib
│ ├── Lib.Enum
│ ├── Lib.Utility
│ ├── Lib.Data
│ ├── Lib.Core.Common
│ ├── Lib.Core.Shared
│ └── ...
│
└── Site
├── AppA
├── AppB
├── AppC
└── ...
```
很多 Site 並不是完全獨立的。
底下其實共用了一大堆歷史悠久的 Lib。
例如:
```text
Core.Shared
│
┌────────────┼────────────┐
↓ ↓ ↓
AppA AppB AppC
net461 net461 net461
```
這次我們假設要升級的是 AppB:
```text
AppB
net461
↓
.NET 10
```
但 AppA、AppC 還在線上跑,而且暫時完全不想動。
這才是真正麻煩的地方。
如果直接把 `Core.Shared` 升成 .NET 10:
```text
Core.Shared
net461
↓
net10
```
那其他還依賴它的舊系統可能全部都不能用
但如果 Copy 一份:
```text
Core.Shared
Core.Shared.Net10
```
短期看起來最快,長期卻會開始變成:
* 同一個 Bug 修兩次
* 同一份商業邏輯維護兩套
* 久了之後兩邊行為慢慢不一樣
這反而是我最不想看到的結果。
所以這次一開始就先定義三個原則:
1. **不能影響其他還在跑的舊專案**
2. **不能因為升級而多養一套商業邏輯**
3. **不要一次重寫全部,把風險拆小**
也就是:
```text
先挑一個 App
↓
打通它真正需要的 Lib
↓
驗證成功
↓
再換下一個
```
某種程度上,就是替舊系統一條一條鋪新的鐵軌。
---
## 關鍵解法:Multi-targeting
( 關鍵吐槽一下,AI 每次都跟我說雙腿化,...腿這個字對 AI 來說居然是 targeting )
這裡就是這次 Migration 很重要的一個做法。
.NET SDK-style Project 支援 **Multi-targeting**。
也就是:
> **同一個 Project、同一份 Source Code,可以同時 Build 給不同版本的 .NET 使用。**
例如:
```xml
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>net461;net10.0</TargetFrameworks>
</PropertyGroup>
</Project>
```
`TargetFrameworks` 是複數,代表這個 Project 同時支援多個 Target Framework。
所以原本:
```text
Core.Shared
│
└── net461
```
可以變成:
```text
Core.Shared
│
同一份 Source Code
│
┌──────────┴──────────┐
↓ ↓
net461 net10.0
↓ ↓
舊系統 新系統
```
`net461`、`net10.0` 這類名稱叫做 TFM(Target Framework Moniker)。
但比起記這個名詞,我覺得比較重要的是理解:
> **同一份程式碼,可以長出兩個版本的 DLL。**
後面我會用「net461 Target」「net10.0 Target」來稱呼這兩個版本。
---
## 連相依套件都可以分開
Multi-targeting 不只是 Build 兩個 DLL。
不同 Target 甚至可以引用不同版本的 NuGet Package。
例如 Entity Framework。
舊系統原本一直使用:
```text
EntityFramework 6.1.3
```
我們不想為了這次 Migration 順手把它升掉。
但是新的 .NET Target,需要使用可以支援 Modern .NET 的 EF6 版本。
所以可以這樣寫:
```xml
<ItemGroup Condition="'$(TargetFramework)' == 'net461'">
<PackageReference
Include="EntityFramework"
Version="6.1.3" />
</ItemGroup>
<ItemGroup Condition="'$(TargetFramework)' == 'net10.0'">
<PackageReference
Include="EntityFramework"
Version="6.5.1" />
</ItemGroup>
```
最後打成 NuGet 後,大概會變成:
```text
Lib.Data.nupkg
└── lib
├── net461
│ └── Lib.Data.dll
│
└── net10.0
└── Lib.Data.dll
```
舊系統拿 `net461` 的 DLL。
新系統拿 `net10.0` 的 DLL。
但最重要的是:
> **Entity、Enum、商業邏輯仍然只有一份 Source Code。**
這正好符合我們前面的兩個核心目標:
**舊系統不動,商業邏輯也不要複製。**
---
## 那為什麼 .NET 10 還在用 EF6?
這時候一定會有人問:
> 都已經 .NET 10 了,為什麼不直接換 EF Core?
因為我後來越來越覺得一件事情:
> **Migration 跟 Modernization 是兩件不同的事。**
這次最重要的目標,是先讓系統安全地跨過 Runtime 這條線。
如果同一個階段同時做:
```text
.NET Framework → .NET 10
EF6 → EF Core
Windows → Linux
舊 csproj → SDK-style
設定系統重寫
Lib 重構
```
那最後如果某個行為出問題,你很難判斷到底是哪一層造成的。
所以我刻意把它拆開:
```text
第一階段
Runtime Migration
.NET Framework 4.6.1
↓
.NET 10
EF6
↓
先保留
```
等主要 App 都逐步完成 Runtime Migration 之後,再進到:
```text
第二階段
Framework / ORM Modernization
EF6
↓
EF Core
```
我不要求第一次 Migration 就把所有 Legacy 技術全部消滅。
先讓它安全地活在新的 Runtime 上,這件事情本身就已經很有價值了。
---
## 先證明這條路走得通
真正開始做的時候,我們也沒有第一天就去搬 `Core.Shared` 這種大魔王。
而是先挑最簡單的 Library 做 PoC。
例如:
```text
Enum
Utility
```
先驗證整條路:
```text
同一份 Source
↓
net461 + Modern .NET
↓
Pack 成 NuGet
↓
舊專案可以吃
↓
新專案也可以吃
```
確認:
* Build 成功
* Pack 成功
* 舊系統可以正常 Reference
* 新系統也能正常 Consume
這些都成立之後,才開始往比較難的:
```text
Data
Core.Shared
GameSupplier
Robot.Core
...
```
一路推進。
這次做完後,我對 Legacy Migration 有一個感受特別深:
> **不要一開始就急著證明「全部都搬得動」,先證明「這條路真的走得通」。**
因為第一條路打通之後,後面的 Project 就不再是在研究:
> 這件事情到底可不可行?
而是:
> 按照已經驗證過的 Migration Pattern,一個一個搬。
這兩種工作的風險完全不一樣。
---
## 上篇先到這裡
到目前為止,看起來好像很順:
```text
舊專案
↓
SDK-style
↓
Multi-targeting
↓
新舊 DLL 共存
↓
.NET 10
```
但真的開始搬大型 Legacy Lib 之後,才發現 Compile 成功只是第一關。
後面開始遇到的才是真正有趣的東西:
* SDK-style 自動把以前根本沒編譯過的 `.cs` 吃進來
* NuGet Dependency Graph 開始打架
* net461 能跑的 API,在 .NET 10 根本不存在
* 專案內看起來是 0 References 的 Class,其實被別的 Lib 用了 80 次
* WCF、RealProxy、System.Data.Linq 這些歷史遺跡一個一個跑出來
* Build 全綠,不代表 Runtime 真的沒事
所以我把這些留到下一篇:
**古老專案升級實戰(下):那些只有真的搬過才會遇到的坑**
點擊複製文章連結