ASP.NET Core DI 容器陷阱:避免 BuildServiceProvider 與生命週期錯置
ASP.NET Core 的內建相依注入看似只要註冊服務再由建構子取得即可,但啟動階段若手動呼叫 BuildServiceProvider(),就可能在應用程式正式容器之外建立第二套物件生命週期。這類錯誤通常能編譯、能啟動,甚至在單次測試中看似正常,卻會讓 singleton 狀態分裂、資源釋放時點不同,並掩蓋 scoped 服務被長期物件持有的問題。本文聚焦 ASP.NET Core DI 容器的可驗證設計方法,以 .NET 9 本機測試說明如何辨認、重構與防止問題。
ASP.NET Core DI 容器的核心結論
ASP.NET Core DI 容器應由主機統一建立,服務註冊期間不要手動呼叫 BuildServiceProvider();需要其他服務時改用建構子注入、註冊工廠或明確 scope,並在測試環境開啟 scope 與建置驗證。
這個結論的重點不是「某個 API 永遠不能用」,而是要分清楚容器的所有權。IServiceCollection 只是服務描述集合;每次呼叫 BuildServiceProvider() 都會依當下描述建立一個新的 provider。之後 ASP.NET Core 主機還會建立正式 provider,因此提早建立的 provider 與正式 provider 不共用 singleton 快取、scope、釋放佇列或服務解析狀態。兩者即使使用同一組註冊,也不是同一個容器。
最直接的判斷準則是:若程式仍在組合服務註冊,不應為了「先拿一個已註冊服務」而建出臨時 provider。這通常表示依賴關係放錯位置。將環境資訊交給啟動類別的建構子、將執行期初始化移至主機建立之後、或使用接受 IServiceProvider 的註冊工廠,能保留單一容器邊界。對 scoped 服務則必須先建立 scope,再從 scope.ServiceProvider 取得實例,不能從根容器直接抓取後長期保存。
官方文件也把這些原則連在一起:服務生命週期決定實例可存活多久,容器負責建立與釋放它所管理的物件,而 scope validation 用來找出 scoped 服務被根 provider 解析或被 singleton 捕捉的錯誤。這些不是風格偏好,而是可由測試觀察的執行期行為。
為何 BuildServiceProvider 會製造第二套 singleton
BuildServiceProvider() 每執行一次就建立獨立 provider;singleton 的「單一」範圍只限於該 provider,並不代表整個處理程序永遠只有一個實例,因此多個 provider 會產生多份狀態與不同釋放時點。
假設服務集合註冊 AddSingleton()。臨時 provider 第一次解析時建立實例 A,正式 provider 第一次解析時建立實例 B。若 A 收到暖機資料,B 不會自動看到;若兩者各自維護記憶體快取、背景工作佇列或事件訂閱,行為就會分裂。問題不在 singleton 註冊失效,而是 singleton 的邊界被誤解。
第二個風險是註冊時間差。開發者可能在服務尚未全部加入集合時建置臨時 provider,之後再追加服務。已建成的 provider 不會隨 IServiceCollection 後續變動而更新,因此從舊 provider 解析不到稍後註冊的項目。為了讓程式繼續執行而回傳 null 或使用 fallback,會把原本應在啟動時暴露的組態錯誤推遲到請求期間。
第三個風險是釋放責任。provider 會追蹤由它建立且需要釋放的物件。兩個 provider 各自持有自己的 singleton,就需要各自被正確釋放;臨時 provider 若未進入明確的 using 邊界,資源可能比預期活得更久。反過來,若太早釋放臨時 provider,從中取得並傳到其他位置的服務又可能提早失效。最穩定的修法不是補上更多釋放程式碼,而是取消多餘 provider。
檢查現有程式時,先搜尋 BuildServiceProvider(,再逐一問三個問題:此處是否仍在註冊服務、為何不能由工廠取得 provider、解析出的物件將由誰管理生命週期。只要答案涉及全域欄位、靜態快取或跨請求保存,就應停止合併並重構。
Transient、Scoped 與 Singleton 的邊界
生命週期必須按照實際擁有者選擇:transient 適合短小且無共享狀態的工作,scoped 適合單一操作範圍內共用的狀態,singleton 則只能依賴可安全存活同樣久的服務與資料。
Transient 每次解析通常取得新實例。它適合無狀態轉換器或輕量協調物件,但「每次建立」不等於可以忽略釋放成本;若 transient 需要釋放,仍由建立它的容器追蹤。大量從根 provider 解析可釋放 transient,可能使其一直被保留到根 provider 結束,因此建立位置仍然重要。
Scoped 在 Web 應用中通常對應一次請求,但更一般的定義是「每個 IServiceScope 一份」。背景工作沒有自動請求 scope;需要 scoped 服務時,應透過 IServiceScopeFactory.CreateScope() 為每次工作建立邊界,在 scope 內解析並完成工作後釋放。這也讓測試能明確斷言兩個 scope 取得不同實例,而同一 scope 內取得相同實例。
Singleton 從首次建立到 provider 被釋放都可能持續存在,必須能安全處理並行呼叫。singleton 不應直接依賴 scoped 服務,因為這會把短生命週期物件提升成長生命週期,也就是 captive dependency。若確實需要每次操作使用 scoped 服務,singleton 應保存 IServiceScopeFactory,在方法執行期間建立短 scope,而不是把 scoped 實例存成欄位。
ValidateScopes = true 可以在解析時檢查不合法的生命週期關係,ValidateOnBuild = true 則盡可能在 provider 建立時檢查服務描述。兩者適合放進整合測試或獨立容器測試。驗證不是所有設計問題的完整證明,例如它不會理解自訂全域狀態是否執行緒安全,但能把常見 captive dependency 從晚期故障提前成明確失敗。
不建臨時容器的重構方式
重構目標是讓相依解析發生在主機擁有的 provider 與明確 scope 內;依需求分別採建構子注入、註冊工廠、Hosted Service 或 IServiceScopeFactory,不要用 service locator 取代清楚的物件關係。
第一類需求是「註冊 B 時需要 A」。直接使用工廠多載:services.AddSingleton(sp => new B(sp.GetRequiredService()))。這個 sp 由正式 provider 提供,因此 A 與 B 位於正確容器。工廠應保持簡短且只做物件組合;若包含網路呼叫、資料初始化或長時間工作,應移到可等待、可取消的啟動流程。
第二類需求是「主機啟動後執行初始化」。使用 IHostedService 或 BackgroundService,讓主機管理開始、停止與取消。初始化若依賴 scoped 服務,就在執行方法內建立 scope。不要在註冊階段啟動未等待的非同步方法;呼叫 ConfigureAwait(false) 只設定 await 後的接續行為,本身不會等待工作完成。
第三類需求是「中介軟體需要服務」。優先使用中介軟體建構子注入 singleton 相依,或在 InvokeAsync 參數注入 scoped 相依。若以 app.ApplicationServices 直接解析並保存 scoped 物件,仍會破壞生命週期。解析位置必須與使用範圍一致。
以下模式讓正式 provider 負責組合,並由背景服務為每次工作建立 scope:
services.AddScoped();
services.AddSingleton(sp =>
new Coordinator(sp.GetRequiredService()));
sealed class Coordinator(IServiceScopeFactory scopeFactory)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await using var scope = scopeFactory.CreateAsyncScope();
var work = scope.ServiceProvider.GetRequiredService();
await work.ExecuteAsync(cancellationToken);
}
}
這段程式的關鍵不是把 IServiceProvider 傳遍所有類別,而是把建立 scope 的責任限制在真正跨 scope 的協調者。一般領域服務仍使用建構子注入,維持可測試性與相依關係可見性。
驗證與重現:.NET 9 安全測試
本次在隔離的 .NET 9 主控台專案實際驗證三件事:固定 SDK 可被選取、兩次建置 provider 會產生不同 singleton,以及 scope validation 能拒絕 singleton 捕捉 scoped 服務。
前置條件為本機已安裝 .NET SDK 9.0.100;測試只建立主控台專案與記憶體內物件,不連接外部系統、不修改既有資料。global.json 使用 rollForward: disable,因此排程環境若沒有精確版本會立即失敗,而不是默默改用其他 SDK。
- 在空白測試目錄建立指定 9.0.100 的
global.json,執行dotnet --version;通過條件是輸出精確為9.0.100。 - 建置 Release 組態,再執行測試程式;程式由同一服務集合建立兩個 provider,通過條件是輸出
DISTINCT_SINGLETONS=True。 - 讓 singleton 建構子依賴 scoped 服務,並以
ValidateScopes與ValidateOnBuild建置;通過條件是捕捉預期例外並輸出SCOPE_VALIDATION_CAUGHT=True。
dotnet --version
dotnet build DiContainerCheck.csproj -c Release --nologo
dotnet run --project DiContainerCheck.csproj -c Release --no-build
排程環境的真實結果為 SDK 9.0.100,Release 建置完成且零警告、零錯誤,兩個觀察標記皆為 True。這些結果只證明本文列出的 DI 行為與測試前置條件,不代表任何真實服務的效能、穩定性或流量結果。讀者若使用不同 .NET 版本,應修改目標框架與固定版本後重新執行,不應直接套用此處的執行結果。
若第一步顯示其他版本,判定版本前置條件失敗;若建置結束碼非零,先處理編譯錯誤,不解讀後續標記;若任一標記為 False 或缺少,判定該主張未通過。這種 fail-closed 判定能避免只看「程式跑完」就誤認驗證成功。
程式碼審查與合併前檢查清單
審查 DI 設計時應同時檢查容器數量、生命週期方向、scope 建立位置、非同步初始化與釋放所有權;只搜尋一個 API 名稱不足以發現所有長短生命週期錯置。
- 服務註冊期間沒有呼叫
BuildServiceProvider(),也沒有把臨時 provider 存入靜態欄位。 - singleton 的建構子相依鏈不包含 scoped 服務;需要短期服務時,由方法內建立並釋放 scope。
- 背景工作每次操作建立自己的 scope,不重用 Web 請求結束後的物件。
- 工廠只負責同步物件組合,耗時初始化由主機生命週期元件執行,且可等待與取消。
- 整合測試以
ValidateScopes與ValidateOnBuild建置 provider,任何生命週期例外都使測試失敗。 - 由容器建立的可釋放服務不被應用程式任意手動釋放;由程式自行建立的物件則明確定義所有者。
- 中介軟體的 scoped 相依在每次
InvokeAsync呼叫中取得,不在長期建構子或全域欄位保存。 - 合併說明附上固定 SDK、完整指令與可觀察標記,不用未量測的效能敘述代替驗證。
如果既有程式已經出現臨時 provider,修正順序應先盤點所有解析出的服務及其生命週期,再把解析搬到正式 provider,最後加入容器驗證測試。不要直接刪除呼叫後假設沒有副作用;若原本利用它偷偷執行初始化,必須把初始化遷移到明確且可等待的主機階段。
結語:讓容器邊界成為可測試契約
單一正式 provider、正確的生命週期方向與明確 scope 是 ASP.NET Core DI 的基本契約;把這些規則寫成固定版本測試,才能在程式碼變更時立即阻擋第二容器與 captive dependency 回歸。
BuildServiceProvider() 陷阱之所以難找,是因為它常在功能測試中保持沉默。只要把「singleton 是整個程序唯一」改成「singleton 是每個 provider 唯一」,就能理解狀態分裂的根因。接著以工廠取得正式 provider、以 Hosted Service 管理初始化、以 scope factory 管理短生命週期,設計就會回到清楚的所有權模型。
最後,把驗證條件保持具體:固定 SDK、零錯誤建置、不同 singleton 的布林標記、scope validation 的預期失敗。這些訊號比「啟動看起來正常」更可靠,也不需要接觸真實帳號、外部資料或不可逆操作。