Image

有一天,我夢見我死了。

地獄門口,閻羅王拿出一本厚厚的註冊清單,一邊唸:「根據你的 ServiceCollection,這輩子你註冊了以下幾項依賴…」

1
2
3
4
5
6
7
8

IConfidenceService → FakeItTillYouMakeItService
ILoveService → UnhealthyAttachmentService
ILogger<YourLife> → Mom'sConstantNagging
ICareerPlanner → JustFollowWhatEveryoneElseDidService
ISelfWorthEvaluator → DependsOnLikesAndRetweetsService
IPurposeOfLifeResolver → null

我試圖抗議:「等等!我沒有註冊這些啊!」
閻羅王翻了翻後台的 StackTrace,淡淡地說:「人走著走著、就註冊成這樣了。」

「每一次討好別人,你就呼叫了 services.AddTransient();
每一次壓抑情緒,你就將 Logger 的 level 設成了 Silent,你以為退一步海闊天空,其實 ConfigureLifetime(Singleton),永遠揮之不去。」

我低頭看著那本厚厚的註冊清單,每一筆都不是誰替我填的,
而是我用選擇、用習慣,親手寫上去的。
有些甚至沒有 interface,只有實作,就這樣默默被綁死一輩子。

也許我們都該時不時打開人生的 ServiceCollection,看看自己到底註冊了些什麼。不然有一天注入進來的,不是服務,是報應。



👼 「註冊」到底是在註冊什麼?


當你寫這樣的程式碼:

1
2
3

services.AddScoped<IMailService, GmailMailService>();

系統實際在一個「對照表(ServiceCollection)」裡,加入一筆資料,這筆資料記錄了:

  • 服務的類型(IMailService)
  • 實作的類型(GmailMailService)
  • 生命週期(Scope)
  • 要怎麼產生這個實體(Constructor Info)

會是像這樣的資料結構

1
2
3
4
5
6
7
8
9

class ServiceDescriptor
{
public Type ServiceType; // 例如 IMailService
public Type ImplementationType; // 例如 GmailMailService
public Lifetime Lifetime; // Scope / Singleton / Transient
public Func<IServiceProvider, object> Factory; // 如何產生它
}

註冊的本質:就是建立「類型對應表」和「建構方式」的設定檔。


註冊介面或直接註冊實作

1
2
3
4
5
6
7
8
9

//精簡寫法
services.AddTransient<IDataService, DataService>();

//未另宣告介面,直接使用實作型別
services.AddTransient<DataService>();
services.AddSingleton<GlobalService>();
services.AddScoped<MyService>();

在 Controller 中使用它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16

public class HomeController : Controller
{
private readonly DataService _dataService;
public HomeController(DataService dataService)
{
_dataService = dataService;
}

public IActionResult Index()
{
var data = _dataService.GetData();
return Content(data);
}
}

小型、簡單、不需要替換的服務,可以直接用實作類別,不一定要 interface。
大型、複雜、可能會變動或測試的服務,有多個實作版本、建議一定要寫 interface。



註冊時也可以選擇自行處理建立物件的細節

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25

builder.Services.AddSingleton<IClient>(sp =>
{
var logger = sp.GetRequiredService<ILogger<Client>>();
var serviceToken = builder.Configuration["SECRETS:ServiceToken:Client"];
var market = builder.Configuration["MARKET"];

//// TODO: 較好處理方式 應把環境放到Config去
var env = builder.Configuration["ENVIRONMENT"];

//// 暫解讓 TW PP 可以在 TW Prod NMQV3 Create Task
if (market == "TW")
{
env = "Prod";
}
//// 讓 QAn 全部設定為 QA
if (env?.StartsWith("QA") == true)
{
env = "QA";
}

// 從建構式注入 market, env
return new Client(logger, serviceToken, market, env);
});



註冊「同一個實體」

1
2
3
4
5

//一個介面一個 Instance
services.AddSingleton<IDataService, DataService>();
services.AddSingleton<ISomeInterface, DataService>();

雖然看起來都是 DataService,但其實你是告訴系統:

  • 建立一個 DataService 實體,當作 IDataService
  • 再建立一個新的 DataService 實體,當作 ISomeInterface

➡️ 結果:兩個不同實體、記憶體不同

問題風險在,如果你以為「是同一個實體」,卻不是,那會導致狀態不一致,修改一個的值,另一個沒跟著改,這是多份資源浪費(像是開了兩個 DB 連線)而且一些事件、註冊、記憶體、thread safety 問題難以追蹤

推薦用「工廠註冊法」:

1
2
3
4
5

services.AddSingleton<DataService>();
services.AddSingleton<IDataService>(provider => provider.GetRequiredService<DataService>());
services.AddSingleton<ISomeInterface>(provider => provider.GetRequiredService<DataService>());

  1. 我先註冊 DataService,由系統 new 它(只會 new 一次)
  2. 然後把 IDataService 和 ISomeInterface 都指向同一個實體(透過 GetRequiredService

➡️ 這樣最安全、最乾淨,也不用手動 new。



「共用同一個實體」

1
2
3
4
5

var dataService = new DataService();
services.AddSingleton<IDataService>(dataService);
services.AddSingleton<ISomeInterface>(dataService);

你先 new 一個物件,然後把這個同一個實體分別註冊成不同介面 ➡️ 結果:兩個介面都指向 同一個物件實體



👼 注入

.NET 背後的 DI Container(容器) 會做以下幾件事:

📦 Step 1:尋找註冊的服務描述

透過 ServiceType(這裡是 IMailService)去你註冊時的清單找有沒有對應的項目。

🏗️ Step 2:建立物件

如果有找到對應的 GmailMailService,就會根據註冊時的「建構方式」:

  1. 檢查 GmailMailService 的建構子。
  2. 如果建構子有需要其他參數(例如 ILogger),就遞迴再去找這些參數的實作。
  3. 組裝整個依賴鏈(Dependency Graph)。
  4. 呼叫 Activator.CreateInstance(…) 或自己產生 instance(透過 Reflection 或 IL emit)。

這其實稱為:「依賴樹的遞迴解析(Recursive Resolution)」。本質上就像在砌積木,每個積木可能還需要別的積木,組合好才能建出你要的服務。

主要分為三種方式

面向 建構式注入 Invoke 參數注入 RequestServices
依賴清晰度 🌟🌟🌟🌟🌟 最清楚,依賴寫在建構式 🌟🌟🌟 有點清楚(寫在方法參數) 🌟 混亂,不容易知道用了哪些服務
可測試性 🌟🌟🌟🌟🌟 最好測試(直接模擬建構式) 🌟🌟🌟 好測試(方法參數可控制) 🌟 難測試(要模擬整個 DI 容器)
耦合程度 低(高內聚) 中(方法與 DI 框架綁在一起) 高(強烈依賴系統物件,如 HttpContext)
彈性程度 中(要在建構時就決定依賴) 高(延後注入時間) 很高(可隨時取用)
維護性

建構式注入

一開始創建角色(物件)時,就把所有會需要的服務裝備好。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24

public interface IAttackCalculator
{
int CalculateDamage(int baseDamage);
}

public class Warrior
{
private readonly IAttackCalculator _calculator;
private readonly ILogger<Warrior> _logger;

public Warrior(IAttackCalculator calculator, ILogger<Warrior> logger)
{
_calculator = calculator;
_logger = logger;
}

public void Attack()
{
int damage = _calculator.CalculateDamage(10);
_logger.LogInformation($"戰士攻擊造成了 {damage} 點傷害!");
}
}

角色一出生就配好劍、護甲和能力,隨時可以戰鬥。


Invoke 參數注入

把需要的工具「在執行某個方法時才交進來」,不是在一開始建立物件時就給。
攻擊行為是在遊戲事件中發生(例如 Controller 的 Action),用參數注入獲得幫手。

1
2
3
4
5
6
7
8
9
10
11
12
13

public class BattleController : ControllerBase
{
[HttpPost("attack")]
public IActionResult Attack([FromServices] IAttackCalculator calculator,
[FromServices] ILogger<BattleController> logger)
{
int damage = calculator.CalculateDamage(20);
logger.LogInformation($"玩家進行攻擊,造成了 {damage} 點傷害!");
return Ok(new { damage });
}
}

或是在 Middleware

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

public class MyMiddleware
{
private readonly RequestDelegate _next;

public MyMiddleware(RequestDelegate next)
{
_next = next;
}

public async Task InvokeAsync(HttpContext context, ILogger<MyMiddleware> logger)
{
logger.LogInformation("Middleware 被呼叫");
await _next(context);
}
}


RequestServices

這是「臨時需要某個服務時自己去問系統要」,不像前面兩種那樣自動注入。有點像你做早餐做到一半突然發現缺牛奶,就去冰箱(系統)裡找牛奶。

✅ 用 HttpContext.RequestServices

1
2
3
4
5
6
7
8
9
10
11
12

public class MagicController : ControllerBase
{
[HttpGet("cast")]
public IActionResult CastMagic()
{
var magicService = HttpContext.RequestServices.GetRequiredService<IMagicService>();
var result = magicService.Cast();
return Ok(result);
}
}

✅ 用全域的 IServiceProvider

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17

public class GameEngine
{
private readonly IServiceProvider _globalProvider;

public GameEngine(IServiceProvider provider)
{
_globalProvider = provider;
}

public void Execute()
{
var magicService = _globalProvider.GetRequiredService<IMagicService>();
magicService.Cast();
}
}

比較項目 IServiceProvider HttpContext.RequestServices
範圍 可以是全域、單例、任何你創的容器 限定在這一個 HTTP 請求內
建立時間 可在應用程式啟動時建立 ASP.NET Core 為每個 Request 自動建立
目的 用來解析任何註冊過的服務 用來取得「這次請求範圍內」的服務實體
常見用法 在 Console App、背景工作中 在 Controller、Middleware、Filter、Razor Page
你要解決的問題 應該用誰?
Controller 或 Middleware 中臨時需要服務 HttpContext.RequestServices
非 Request 範圍(如背景工作、Console App)需要服務 IServiceProvider
註冊服務 IServiceCollection
從 provider 抓服務 GetRequiredService<T>()


☘️ 結語

有些人會問:「這些服務註冊這麼細,真的有差嗎?反正能跑就好啊。」

就像你從不記得自己哪天開始變得愛解釋、怕尷尬、總是說「沒關係」;哪次告白沒說出口,最後竟默默註冊了一個 INoOneWillEverKnowService。

其實差別就在這裡:註冊,決定了注入;選擇,鋪好了路徑。

你現在寫下的每一行 services.AddXXX(…),未來都有可能在 Controller、Middleware,甚至某個深夜 2 點的 Exception Stack 裡出現。就像我們的性格、行為、價值觀,終將在某個人生的切點被呼叫、被注入、被執行。

所以下次看到註冊服務這件事,別再想著只是個小設定。因為當你不主動定義,預設值就會替你做決定 —— 而預設值,通常不是你想要的版本。

1
2
3

services.AddScoped<IFuture, WhatYouReallyWantService>();

你註冊了嗎?