WeHelp
古老專案升級實戰(下):那些只有真的搬過才會遇到的坑
2026-08-04 18:01:09
上篇談的是「為什麼要升級」,以及我們最後選擇的遷移路線。 簡單來說,就是透過 Multi-targeting,讓共用 Lib 用同一份 Source Code,同時建置出 `net461` 與 `net10.0` 兩個版本: ```text Lib │ 同一份 Source Code │ ┌────────┴────────┐ ↓ ↓ net461 net10.0 ↓ ↓ Legacy System 新專案(.NET 10) ``` 舊系統繼續使用 `net461`,新專案則改吃 `net10.0`。 整個過程中,我們有一條最高原則: > **net461 Target 代表既有系統的契約。相容性修改只能發生在 net10.0 那一側,不能為了讓新版通過,就順手改掉舊版的行為。** 架構圖看起來很漂亮,路線本身也確實走得通。 但真正開始搬十幾年的 Legacy Lib 之後,才發現 Compile 成功只是第一關。後面遇到的問題,有些藏在 csproj,有些藏在套件相依裡,最麻煩的甚至要等到 Runtime 才會出現。 以下就是這次實際踩到的坑。 --- ## 第一類坑:你以為只換了 csproj,建置條件卻已經變了 把舊式 csproj 轉成 SDK-style,看起來像是專案格式的整理,但它其實可能悄悄改變「哪些程式碼會被編譯」以及「編譯器怎麼理解這些程式碼」。 ### 坑一:LangVersion 悄悄升級 轉成 SDK-style 之後,`net461` Target 編譯全過,看起來一切正常。 但仔細檢查後,我們發現有些原本不該通過的新語法,居然可以編譯了。 原因是舊式 csproj 原本有明確鎖定語言版本: ```xml <LangVersion>7</LangVersion> ``` 轉換時如果漏掉這一行,`net461` Target 會改用另一個預設語言版本。程式碼雖然還是同一份,編譯條件卻已經和以前不同。 這類問題短期不一定會造成錯誤,卻違反了我們「舊系統行為不變」的前提。 所以我們把原本的設定補回來,而且只套用在 `net461`: ```xml <LangVersion Condition="'$(TargetFramework)' == 'net461'">7</LangVersion> ``` `net10.0` Target 則先使用自己的設定,兩邊互不影響。 這裡得到的第一個教訓是: > **語言版本也是系統行為的一部分。** 轉換 csproj 時,不能只確認 Reference 和 NuGet Package;`LangVersion`、編譯常數、Nullable、警告設定等編譯條件,也要逐項比對。 後面那個最精彩的 C# 14 案例,會再證明一次這件事。 ### 坑二:SDK-style 把孤兒檔案全部吸了進來 另一個問題,是 SDK-style 對原始碼檔案的處理方式不同。 舊式 csproj 採用白名單,每個要編譯的檔案都會明確列出: ```xml <Compile Include="Utilities\SomeHelper.cs" /> ``` SDK-style 則預設使用 glob,把資料夾底下的 `.cs` 檔案自動納入編譯。 對新專案來說很方便,對活了十幾年的 Legacy 專案卻可能是一顆雷。 因為磁碟裡往往留著一些早就退出專案、卻沒有真的被刪掉的檔案,例如: - 做到一半的實驗品 - 被移除引用的舊邏輯 - 留在資料夾裡備份的歷史版本 我們實際比對後,找到 20 個從未被舊專案編譯過的 `.cs` 檔。轉成 SDK-style 後,它們全部被自動吃了進來,接著冒出一整排 Compile Error。 處理方式不是看到錯誤就開始修,而是先確認「轉換前後到底各自編譯了哪些檔案」。 我們先從舊 csproj 取出 `<Compile Include>` 清單,再用 PowerShell 與磁碟上的檔案比對: ```powershell Compare-Object $基準清單 $磁碟上的cs檔案清單 ``` 多出來的檔案逐一確認,確定原本就不屬於專案的,再明確排除: ```xml <Compile Remove="Legacy\SomeOrphan.cs" /> ``` 目標不是「讓新版全部編過」,而是先讓轉換前後的編譯輸入一致。 > **磁碟上存在的檔案,不代表它原本就屬於這個系統。** SDK-style 的自動化很方便,但在 Legacy Migration 裡,任何「自動幫你做的事」都值得多看一眼。 --- ## 第二類坑:真正的相依,經常藏在專案外面 有些錯誤不是 API 消失,而是歷史命名、套件版本與跨專案相依疊在一起,讓同一段程式碼在兩個 Target 產生不同結果。 ### 坑三:自家 namespace 遮蔽了 System.Enum 我們有一個歷史套件叫做 `Lib.Enum`。 引用它之後,原本很普通的一行程式碼: ```csharp var status = Enum.Parse(typeof(OrderStatus), input); ``` 突然編譯失敗,錯誤訊息說 `Enum` 沒有 `Parse` 方法。 原因是 `Enum` 被解析成了自家的 namespace,而不是 `System.Enum`。這類問題叫做 namespace shadowing。 最後只能在可能發生遮蔽的地方寫完整名稱: ```csharp var status = System.Enum.Parse(typeof(OrderStatus), input); ``` 這算是很單純的坑,卻也提醒了一件事:套件與 namespace 最好不要和 BCL 型別撞名。 當然,十幾年前的名字現在通常也改不了了,只能把全名寫清楚。 ### 坑四:兩個套件都有 ForEach,而且不能直接選一個 另一個案例發生在 `.ForEach()`。 `net461` Target 可以正常編譯,`net10.0` Target 卻出現 CS0121:模稜兩可的呼叫。 原因是專案同時引用了兩個都提供 `ForEach` 擴充方法的套件。在我們的案例裡,是 ClosedXML 與 MoreLINQ。 舊 Target 的套件版本組合,剛好會解析到其中一個方法;新版的套件組合不同,兩個候選同時成立,編譯器無法替我們決定。 最直覺的修法,是直接把呼叫改成明確指定某一套實作。 但這樣會有一個風險:如果連 `net461` 的呼叫路徑也一起被改掉,就等於修改了線上系統原本的行為。 所以我們用條件編譯把兩個 Target 分開: ```csharp #if NET461 items.ForEach(x => Process(x)); #else MoreLinq.MoreEnumerable.ForEach(items, x => Process(x)); #endif ``` 舊 Target 保留原本的解析方式;只有新版明確指定要呼叫哪個方法。 PR Review 時,我們還會直接看 `git diff`,確認 `NET461` 那一側沒有被順手整理或重寫。 > **相容性修改的目的,是讓新 Target 接上來,不是順便改善舊 Target。** 這兩件事最好分開處理,否則出了問題,很難知道到底是 Migration 還是 Refactor 造成的。 ### 坑五:舊 Framework 的例外型別,在 .NET 10 找不到 某段資料存取邏輯原本會捕捉: ```csharp catch (System.Data.Linq.DuplicateKeyException ex) { HandleDuplicateKey(ex); } ``` 但 `net10.0` Target 無法參考這個舊 Framework 型別,導致專案編譯失敗。 問題是,這段 catch 代表既有的錯誤處理語意,也不能看到型別不存在就直接刪掉。 我們最後在新版使用 Exception Filter,避免編譯期直接參考該型別: ```csharp #if NET461 catch (System.Data.Linq.DuplicateKeyException ex) { HandleDuplicateKey(ex); } #else catch (Exception ex) when ( ex.GetType().FullName == "System.Data.Linq.DuplicateKeyException") { HandleDuplicateKey(ex); } #endif ``` 兩邊保留相同的處理內容,但 `net10.0` 不需要直接解析舊型別。 這類做法適合用在「編譯時無法參考型別,但執行期仍可能從相容邊界收到該例外」的情境。前提是必須先確認實際 Runtime 真的可能出現這個型別,不能只為了消除 Compile Error 就照抄。 ### 坑六:專案內零引用,不代表真的沒人用 這個坑差點造成比較嚴重的結果。 我們在整理某個 Lib 時,看到一組 Proxy 類別。它在自己的專案內完全找不到呼叫點,看起來很像死碼,所以一度打算把整個檔案從 `net10.0` 排除。 後來把搜尋範圍擴大到整個 Lib Repo,才發現另一個核心 Lib 裡有 80 個檔案正在使用它。 如果真的排掉,一大片功能都會一起斷掉。 問題出在:Library 的使用者通常不在 Library 自己裡面。 在單一專案內搜尋,只能證明「這個專案沒有呼叫」,不能證明「整個系統沒有人呼叫」。越是被大量共用的底層套件,這個盲區越危險。 所以後來我們訂了一條規則: > **排除任何 Class、Method 或檔案之前,一律做全 Repo 的下游反查。** 「我搜不到」和「沒有人在用」,是兩件完全不同的事。 死碼判定本身只要幾分鐘,判錯的代價卻可能非常高。這種地方,多花十分鐘做全域掃描很值得。 --- ## 最精彩的一坑:C# 14、Span 與 EF6 一起撞車 前面的問題,大多會在 Build 階段就被看到。 但這個案例不一樣:程式可以正常編譯,只有實際跑到那條 LINQ 查詢時才會爆炸。 它同時牽涉三個東西: - 一個存在已久的 BCL API - 一條新的 C# 語言規則 - 一個歷史悠久的 ORM 三個單獨看都沒有問題,湊在一起就出事了。 ### 症狀:程式碼沒寫 Span,錯誤卻出現 ReadOnlySpan 原本只是一段再普通不過的 EF6 查詢: ```csharp string[] systemCodes = { "A01", "B02" }; var records = db.Records .Where(r => systemCodes.Contains(r.SystemCode)) .ToList(); ``` 同樣的寫法在舊系統跑了很多年,`net10.0` Target 執行時卻直接丟出: ```text System.NotSupportedException: LINQ to Entities does not recognize the method 'Boolean Contains[String](System.ReadOnlySpan`1[System.String], System.String)' ``` 最奇怪的是,我們的程式碼裡根本沒有寫任何 Span。 ### 根因:同一行程式碼,被新版編譯器綁到另一個方法 `MemoryExtensions.Contains(ReadOnlySpan<T>, T)` 並不是 .NET 10 才新增的 API。真正改變的是 C# 14 的多載解析規則。 在 C# 13 以前,接收 `Span<T>` 或 `ReadOnlySpan<T>` 的擴充方法,不能直接套用在 `T[]` receiver 上。因此: ```csharp systemCodes.Contains(r.SystemCode) ``` 會綁定到 `Enumerable.Contains`。 C# 14 加入 first-class span 支援後,陣列可以透過新的 Span 轉換參與擴充方法與多載解析。於是同一行 `array.Contains(x)`,可能改成優先綁定 `MemoryExtensions.Contains`。 在一般程式碼裡,這通常不會造成問題,甚至可能得到更好的效能。 但 EF6 並不是直接執行 Lambda,而是先把它當成 Expression Tree,再翻譯成 SQL。EF6 認得原本的 `Enumerable.Contains`,卻不知道該怎麼翻譯 `MemoryExtensions.Contains`,最後只能在 Runtime 丟出 `NotSupportedException`。 整件事最麻煩的地方在於: > **BCL API 沒變,Source Code 也沒變;只因為編譯器換了,同一行程式碼就代表不同的方法呼叫。** ### 官方解法:逐一把呼叫綁回 Enumerable.Contains Microsoft 已經把這項行為列為 .NET 10 的 Breaking Change。官方建議在需要維持非 Span 多載時,使用明確型別或靜態呼叫: ```csharp ((IEnumerable<string>)systemCodes).Contains(r.SystemCode) systemCodes.AsEnumerable().Contains(r.SystemCode) Enumerable.Contains(systemCodes, r.SystemCode) ``` 這些寫法都能讓呼叫重新綁回 `Enumerable.Contains`。 對新專案來說,逐一修正沒有問題。但對累積了十幾年 LINQ 查詢的 Legacy Lib 來說,風險在於:你得先找出所有會進入 Expression Tree 的相關呼叫點。 漏掉任何一行,Build 依然會全綠;只有等那條查詢真的被執行時才會爆。 這等於把正確性押在搜尋結果與測試覆蓋率上。 ### 我們的選擇:暫時把 net10.0 鎖在 C# 13 因為這項多載解析變更只會在 `LangVersion >= 14` 生效,我們最後選擇把含 `net10.0` Target 的專案暫時鎖在 C# 13: ```xml <LangVersion Condition="'$(TargetFramework)' == 'net10.0'">13</LangVersion> ``` 這行設定可以一次避開整類問題,不只處理 `Contains`,也包含其他可能因新 Span 規則而改變綁定結果的呼叫。 更重要的是,它從編譯條件上直接封住風險,不需要賭每一條 LINQ 都會被測試跑到。 當然,代價也很明確:`net10.0` Target 暫時不能使用 C# 14 的語言功能。 我們願意接受,是因為這次的第一階段目標是 Runtime Migration,不是急著用上所有新語法。真正的相容性瓶頸是 EF6 的 Expression Tree 翻譯能力;未來完成 ORM Modernization、換到 EF Core 並重新驗證後,再評估解除這個限制。 這個案例也讓我重新理解「升級 Runtime」這件事: > **你以為自己只換了執行環境,其實也一起換了編譯器。而「一行程式碼最後綁到哪個方法」,同樣屬於系統行為。** 參考資料: - [C# 14 overload resolution with span parameters](https://learn.microsoft.com/en-us/dotnet/core/compatibility/core-libraries/10.0/csharp-overload-resolution) - [First-class Span Types](https://learn.microsoft.com/en-us/dotnet/csharp/language-reference/proposals/csharp-14.0/first-class-span-types) --- ## 面對相容性問題,我們用了四把刀 前面幾個案例裡,`Condition` 和 `#if` 出現了很多次。 Multi-targeting 建置時,SDK 會替不同 Target 定義對應的符號。例如 `net461` 可以使用 `NET461`,`net10.0` 則有 `NET10_0` 與 `NET10_0_OR_GREATER`。 工具不難,難的是怎麼控制刀口,不讓條件編譯在專案裡失控。 我們最後整理出一個使用順序: ```text csproj Condition ↓ 無法處理才往下 成員級 #if ↓ 無法處理才往下 整檔排除 ↓ 仍然卡住才考慮 Type Forwarding ``` 越往下,影響範圍越大。 ### 第一把刀:csproj Condition 能在專案設定解決的,就先不要碰 Source Code。 像套件版本分流、Framework Reference,以及 Modern .NET 需要的相容套件,都可以集中寫在 csproj: ```xml <ItemGroup Condition="'$(TargetFramework)' == 'net461'"> <Reference Include="System.Configuration" /> </ItemGroup> <ItemGroup Condition="'$(TargetFramework)' == 'net10.0'"> <PackageReference Include="System.Configuration.ConfigurationManager" Version="4.7.0" /> </ItemGroup> ``` 這一層最好審查,也不會讓商業邏輯裡到處出現條件編譯。 ### 第二把刀:成員級 #if 如果一個 Class 有 95% 可以共用,只有一兩個 Method 使用了 .NET 10 不支援的舊技術,就只圍住那幾個成員: ```csharp public static class MemberLogic { public static void CalculateSomething() { // 兩個 Target 共用 } #if NET461 public static void SyncByLegacyChannel() { // 只有舊系統需要 } #endif } ``` 不要因為一個 Method 就排除整個檔案。刀口越小,能共用的程式碼越多,這才符合 Multi-targeting 的目的。 如果 `#if` 兩側是同一功能的兩種實作,就必須驗證兩邊的行為一致;只有確認它是舊系統專屬功能時,才允許單側存在。 ### 第三把刀:整檔排除 有些檔案整包都屬於 Legacy,例如 WCF Service 或短期內完全用不到的舊第三方整合。 這種情況硬搬沒有意義,可以先從 `net10.0` 排除: ```xml <Compile Remove="Legacy\SomeWcfService.cs" Condition="'$(TargetFramework)' == 'net10.0'" /> ``` 我們最大的一包共用 Lib,目前大約排除了 400 個檔案。 數量聽起來很多,但排除的原則很簡單: > **以所有 .NET 10 消費者實際需求的聯集為準。** 現在的新專案需要什麼,就先打通什麼。未來有其他消費者需要某個被排除的功能,再完成相容性處理並移回來。 排除不是放棄,只是還沒輪到。 不過每次排除前,都要記得前面的教訓:先做全 Repo 的下游反查,不要把活人當成死碼。 ### 第四把刀:Type Forwarding 這是影響最大、也最少使用的一招。 假設某個型別被放在一個很難 Multi-target 的專案裡,但很多下游又依賴它,可以考慮把型別搬到比較乾淨的套件,再在原 Assembly 留下 Type Forwarding 宣告: ```csharp [assembly: TypeForwardedTo(typeof(SupplierBetType))] ``` 在 Assembly Identity、版本與部署都正確的前提下,下游仍可透過原本的型別識別找到搬家後的實作,減少大範圍修改。 我們用這個方式,把資料層需要的幾個 Enum 搬到 `Lib.Enum`,讓 Data Layer 的 Multi-targeting 不再被原本的專案卡住。 不過這招會動到套件結構與部署關係,所以應該放在最後,而且一定要驗證既有 Binary Consumer 與實際部署結果。 ### 條件編譯會不會影響效能? 不會。 `#if` 是編譯期決策。每個 Target 產生的 DLL 只包含自己那一側的程式碼,另一側在 Build 時就不存在: ```text 同一份 Source Code │ ┌───┴───┐ ↓ ↓ net461 net10.0 DLL DLL │ │ 只包含 只包含 自己的 自己的 程式碼 程式碼 ``` 所以沒有 Runtime 判斷,也沒有額外的分支成本。 真正的代價是可讀性與測試成本:`#if` 越多,程式越難讀,需要驗證的組合也越多。 這也是為什麼順序很重要:先用 csproj 隔離,真的不行才進 Source Code;能切 Method,就不要切整個 File。 --- ## 結語:時間不是花在修改,而是花在證明沒有改壞 上篇我寫過一句話: > **Legacy Migration 最難的,不是讓它 Compile,而是怎麼證明升級之後,它還是原本那個系統。** 實際走完這一輪後,我對這句話的感受更深了。 現在有 AI 協助,分析 Compile Error、修改 csproj、整理條件編譯、搜尋替代 API,速度確實快很多。上面大部分的坑,從發現到處理,可能只需要幾個小時到幾天。 但「驗證新舊行為是否一致」,仍然是整個 Migration 最花時間的部分。 編譯器可以告訴你哪裡不合法,卻不會告訴你: - 某個 LINQ 呼叫是不是換綁到另一個方法 - 某個被排除的 Class 是否有跨專案消費者 - 某個 Exception Handler 是否仍保留原本的語意 - 某項功能在新 Runtime 上是否真的和以前一樣 這些事情,目前還是得靠測試、Code Review,以及一個功能一個功能地驗收。 所以如果你也打算升級大型 Legacy System,我會建議把時間預算留給驗證,而不是只估「改到能編譯」需要多久。 程式碼的修改可以交給工具與 Checklist 加速,但最後那句「它跟以前一樣」,還是需要有人負責證明。 也許再過幾年,這一段真的能由 AI 自動完成。 但現在,還不行。