WeHelp
古老專案升級實戰(上):從 .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 真的沒事 所以我把這些留到下一篇: **古老專案升級實戰(下):那些只有真的搬過才會遇到的坑**