相依性注入
前面的處理常式直接讀寫 Program.cs 中的清單。這一章把清單和讀寫操作放進 InMemoryTodoStore,再讓框架把這個物件傳給處理常式。
這種由外部提供所需物件的做法叫相依性注入(dependency injection,DI)。先看程式碼裡少了什麼:處理常式不再管理清單,也不用自己建立儲存物件,只要宣告一個 ITodoStore 參數。
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
builder.Services.AddSingleton<SingletonMarker>();
builder.Services.AddScoped<ScopedMarker>();
builder.Services.AddTransient<TransientMarker>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", (ITodoStore store) => store.GetAll());
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapGet("/lifetimes", (
SingletonMarker singleton1, SingletonMarker singleton2,
ScopedMarker scoped1, ScopedMarker scoped2,
TransientMarker transient1, TransientMarker transient2) => new
{
Singleton = new[] { singleton1.Id, singleton2.Id },
Scoped = new[] { scoped1.Id, scoped2.Id },
Transient = new[] { transient1.Id, transient2.Id },
}).WithTags("Demo");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
IReadOnlyList<Todo> GetAll();
Todo Add(string title);
}
class InMemoryTodoStore : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public IReadOnlyList<Todo> GetAll()
{
lock (_lock)
{
return _todos.ToList();
}
}
public Todo Add(string title)
{
lock (_lock)
{
var todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
return todo;
}
}
}
abstract class Marker
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
class SingletonMarker : Marker;
class ScopedMarker : Marker;
class TransientMarker : Marker;執行與驗證
先停止上一章的服務,從儲存庫根目錄執行:
cd samples/11-dependency-injection
dotnet run待辦事項 API 的用法和之前一樣:
curl -X POST http://localhost:5080/todos \
-H "Content-Type: application/json" \
-d '{"title":"Buy milk"}'{"id":1,"title":"Buy milk","done":false}curl http://localhost:5080/todos[{"id":1,"title":"Buy milk","done":false}]另外有一個演示端點 /lifetimes,連續請求兩次:
curl http://localhost:5080/lifetimes
curl http://localhost:5080/lifetimes{"singleton":["91e834bc","91e834bc"],"scoped":["87edf2ac","87edf2ac"],"transient":["d4f30286","a151a3a5"]}
{"singleton":["91e834bc","91e834bc"],"scoped":["16f8e1fc","16f8e1fc"],"transient":["6e9ab485","c3b925ae"]}每個編號代表一個物件執行個體,你看到的編號會不同,但哪些相同、哪些不同的規律是一樣的。這個規律是本節後半部分的重點。
把資料存取變成服務
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
builder.Services.AddSingleton<SingletonMarker>();
builder.Services.AddScoped<ScopedMarker>();
builder.Services.AddTransient<TransientMarker>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", (ITodoStore store) => store.GetAll());
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapGet("/lifetimes", (
SingletonMarker singleton1, SingletonMarker singleton2,
ScopedMarker scoped1, ScopedMarker scoped2,
TransientMarker transient1, TransientMarker transient2) => new
{
Singleton = new[] { singleton1.Id, singleton2.Id },
Scoped = new[] { scoped1.Id, scoped2.Id },
Transient = new[] { transient1.Id, transient2.Id },
}).WithTags("Demo");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
IReadOnlyList<Todo> GetAll();
Todo Add(string title);
}
class InMemoryTodoStore : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public IReadOnlyList<Todo> GetAll()
{
lock (_lock)
{
return _todos.ToList();
}
}
public Todo Add(string title)
{
lock (_lock)
{
var todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
return todo;
}
}
}
abstract class Marker
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
class SingletonMarker : Marker;
class ScopedMarker : Marker;
class TransientMarker : Marker;第 46~50 行定義了一個介面(interface)ITodoStore,它只描述"能做什麼":獲取全部、新增一項。第 52~75 行的 InMemoryTodoStore 是它的一個實作,用記憶體清單完成這些操作。按慣例,介面名稱以大寫字母 I 開頭。
這樣的類別在相依性注入中叫做服務(service):一個為應用程式其他部分提供某種能力的物件。
註冊服務
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
builder.Services.AddSingleton<SingletonMarker>();
builder.Services.AddScoped<ScopedMarker>();
builder.Services.AddTransient<TransientMarker>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", (ITodoStore store) => store.GetAll());
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapGet("/lifetimes", (
SingletonMarker singleton1, SingletonMarker singleton2,
ScopedMarker scoped1, ScopedMarker scoped2,
TransientMarker transient1, TransientMarker transient2) => new
{
Singleton = new[] { singleton1.Id, singleton2.Id },
Scoped = new[] { scoped1.Id, scoped2.Id },
Transient = new[] { transient1.Id, transient2.Id },
}).WithTags("Demo");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
IReadOnlyList<Todo> GetAll();
Todo Add(string title);
}
class InMemoryTodoStore : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public IReadOnlyList<Todo> GetAll()
{
lock (_lock)
{
return _todos.ToList();
}
}
public Todo Add(string title)
{
lock (_lock)
{
var todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
return todo;
}
}
}
abstract class Marker
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
class SingletonMarker : Marker;
class ScopedMarker : Marker;
class TransientMarker : Marker;「第一步」中說過,builder.Services 是"應用程式在執行階段需要用到的元件清單"。現在你自己往清單裡加了一項。第 7 行的意思是:
當有人需要
ITodoStore時,提供一個InMemoryTodoStore。
管理這份清單、負責建立物件的元件叫相依性注入容器(DI container)。AddOpenApi() 這類方法,內部做的也是同樣的事情:一次註冊若干個框架自己的服務。
使用服務
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
builder.Services.AddSingleton<SingletonMarker>();
builder.Services.AddScoped<ScopedMarker>();
builder.Services.AddTransient<TransientMarker>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", (ITodoStore store) => store.GetAll());
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapGet("/lifetimes", (
SingletonMarker singleton1, SingletonMarker singleton2,
ScopedMarker scoped1, ScopedMarker scoped2,
TransientMarker transient1, TransientMarker transient2) => new
{
Singleton = new[] { singleton1.Id, singleton2.Id },
Scoped = new[] { scoped1.Id, scoped2.Id },
Transient = new[] { transient1.Id, transient2.Id },
}).WithTags("Demo");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
IReadOnlyList<Todo> GetAll();
Todo Add(string title);
}
class InMemoryTodoStore : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public IReadOnlyList<Todo> GetAll()
{
lock (_lock)
{
return _todos.ToList();
}
}
public Todo Add(string title)
{
lock (_lock)
{
var todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
return todo;
}
}
}
abstract class Marker
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
class SingletonMarker : Marker;
class ScopedMarker : Marker;
class TransientMarker : Marker;處理常式宣告一個 ITodoStore 參數。它沒有顯式指定繫結來源,而這個型別已經註冊為服務,框架就從容器取出執行個體傳進來,不從請求本文讀取。
服務參數不屬於 HTTP 請求的一部分,所以不會出現在 OpenAPI 文件中;/scalar 頁面裡,GET /todos 依然顯示為沒有參數。
如果在每次請求裡 new InMemoryTodoStore(),每次得到的都是空清單。交給容器後,我們可以透過註冊方式決定哪些請求共享物件,而不用在每個處理常式裡重複管理它。
ITodoStore 演示的是另一件事:處理常式只呼叫介面中的方法,因此可以換一個實作用於測試。相依性注入並不要求每個類別都配一個介面,也可以直接註冊具體類別。第 15 章會直接注入 TodoDbContext,讓你先看清資料庫操作,不再套一層儲存介面。
FastAPI 對照
這類似 FastAPI 的 Depends(get_store):處理常式宣告所需物件,由框架提供。這裡按已註冊的型別查詢服務;是否共享同一個執行個體,取決於下面的生命週期設定。
注意
如果忘了第 7 行的註冊,編譯不會報錯,第一次請求 GET /todos 時會得到 500:
System.InvalidOperationException: Body was inferred but the method does not allow inferred body parameters.
Below is the list of parameters that we found:
Parameter | Source
---------------------------------------------------------------------------------
store | Body (Inferred)
Did you mean to register the "Body (Inferred)" parameter(s) as a Service or apply the [FromServices] or [FromBody] attribute?框架不認識 ITodoStore,就按規則把這個複雜型別當成了請求本文,而 GET 請求不允許有推斷出來的請求本文。錯誤資訊的最後一句已經給出了答案:把它註冊為服務。
三種生命週期
using Microsoft.AspNetCore.Http.HttpResults;
using Scalar.AspNetCore;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddOpenApi();
builder.Services.AddSingleton<ITodoStore, InMemoryTodoStore>();
builder.Services.AddSingleton<SingletonMarker>();
builder.Services.AddScoped<ScopedMarker>();
builder.Services.AddTransient<TransientMarker>();
var app = builder.Build();
if (app.Environment.IsDevelopment())
{
app.MapOpenApi();
app.MapScalarApiReference();
}
var todosApi = app.MapGroup("/todos").WithTags("Todos");
todosApi.MapGet("/", (ITodoStore store) => store.GetAll());
todosApi.MapPost("/", Created<Todo> (CreateTodo input, ITodoStore store) =>
{
var todo = store.Add(input.Title);
return TypedResults.Created($"/todos/{todo.Id}", todo);
});
app.MapGet("/lifetimes", (
SingletonMarker singleton1, SingletonMarker singleton2,
ScopedMarker scoped1, ScopedMarker scoped2,
TransientMarker transient1, TransientMarker transient2) => new
{
Singleton = new[] { singleton1.Id, singleton2.Id },
Scoped = new[] { scoped1.Id, scoped2.Id },
Transient = new[] { transient1.Id, transient2.Id },
}).WithTags("Demo");
app.Run();
record CreateTodo(string Title);
record Todo(int Id, string Title, bool Done);
interface ITodoStore
{
IReadOnlyList<Todo> GetAll();
Todo Add(string title);
}
class InMemoryTodoStore : ITodoStore
{
private readonly List<Todo> _todos = [];
private readonly Lock _lock = new();
private int _nextId = 1;
public IReadOnlyList<Todo> GetAll()
{
lock (_lock)
{
return _todos.ToList();
}
}
public Todo Add(string title)
{
lock (_lock)
{
var todo = new Todo(_nextId++, title, Done: false);
_todos.Add(todo);
return todo;
}
}
}
abstract class Marker
{
public string Id { get; } = Guid.NewGuid().ToString()[..8];
}
class SingletonMarker : Marker;
class ScopedMarker : Marker;
class TransientMarker : Marker;第 77~86 行定義了三個幾乎一樣的"標記"類別,每個執行個體在建立時產生一個隨機編號。第 8~10 行用三種不同的方法註冊它們,第 30~38 行的處理常式把每種型別各要了兩次,回傳它們的編號。對照開頭的兩次請求結果:
| 註冊方法 | 生命週期 | 同一請求內兩次獲取 | 不同請求之間 |
|---|---|---|---|
AddSingleton | Singleton(單例) | 同一個執行個體 | 同一個執行個體 |
AddScoped | Scoped(作用域) | 同一個執行個體 | 不同執行個體 |
AddTransient | Transient(瞬時) | 每次都是新執行個體 | 每次都是新執行個體 |
- Singleton:本例中所有請求共享一個執行個體,所以能保留記憶體清單。
- Scoped:每個作用域一個執行個體;在通常的 HTTP 請求中,同一個請求內共享,請求之間分開。
- Transient:每次向容器獲取時都新建一個執行個體。
ITodoStore 註冊為 Singleton,是因為資料必須在請求之間保留:如果註冊成 Scoped,每個請求拿到的都是一個新的空清單,剛建立的待辦事項下一次請求就消失了。
Singleton 必須是執行緒安全的
同一時刻可能有多個請求在處理,它們拿到的是同一個 Singleton 執行個體。所以 InMemoryTodoStore 用 lock 保護內部的清單(第 60、68 行):同一時間只允許一個請求進入,避免兩個請求同時修改清單導致資料損壞。「請求本文」一章中那個"不是執行緒安全"的警告,在這裡解決了。
第 62 行回傳的是 _todos.ToList(),即清單的一份副本,而不是清單本身。否則呼叫方拿到清單後在鎖外讀取它,另一個請求可能正在修改。
技術細節
Lock 是 .NET 9 引入的專用鎖型別,本例用 C# 的 lock 陳述式進入和退出鎖。它保證同一時間只有一個執行緒執行受保護的程式碼段,不需要自己呼叫解鎖方法。
為什麼 DbContext 是 Scoped
第 15 章會用到 EF Core 的 DbContext(資料庫上下文),它會記錄本次操作讀取和修改的物件。AddDbContext 預設用 Scoped 註冊:同一請求裡的程式碼可以共用它,請求結束後釋放,下一次請求重新開始。
它不是執行緒安全的,不能讓所有請求共享一個 Singleton。Scoped 也不會自動加鎖:即使在同一請求內,也不要用一個上下文同時執行多個資料庫操作。先記住這個限制,具體用法到第 15 章再看。
不要讓 Singleton 持有請求裡的 Scoped 服務
假如 InMemoryTodoStore(Singleton)的建構函式需要一個 ScopedMarker(Scoped),會怎樣?
Singleton 會一直持有建構時拿到的物件,原本應該按請求分開的 Scoped 服務就被長期留住了。如果它是 DbContext,多個請求還可能同時使用它。這種依賴被意外延長生命週期的情況叫俘獲依賴(captive dependency)。
在開發環境中,容器會在啟動時檢查這類錯誤,應用程式直接啟動失敗:
Unhandled exception. System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ITodoStore Lifetime: Singleton ImplementationType: InMemoryTodoStore': Cannot consume scoped service 'ScopedMarker' from singleton 'ITodoStore'.)這裡要記住的是“不要直接把 Scoped 服務注入 Singleton”,不是“長生命週期一律不能依賴短生命週期”。例如 Singleton 可以接收 Transient,只是會長期持有當時建立的那一個執行個體;它不會在每次請求時自動換新。
注意
預設的作用域檢查在開發環境開啟,在正式環境預設關閉。正式環境能啟動,不代表這組相依關係合理。服務作用域檢查
總結
- 相依性注入:處理常式透過參數宣告需要的服務,由容器建立並傳入,而不是自己
new。 - 用
builder.Services.AddSingleton<介面, 實作>()等方法註冊服務,也可以直接註冊具體類別,不必為每個服務建立介面。 - 已註冊的服務型別會被自動識別為"來自容器"的參數,不會出現在 OpenAPI 文件中;忘記註冊會被當成請求本文而報錯。
- 三種生命週期:Singleton 在同一容器中共享(必須執行緒安全),Scoped 每個作用域一個,Transient 每次獲取時新建。
AddDbContext預設用 Scoped 註冊資料庫上下文;不要直接把 Scoped 服務注入 Singleton,開發環境會檢查這類錯誤。
下一章:設定與 Options——把可變的設定從程式碼中分離出來。上一章:路由群組。
