Chào mừng các bạn quay trở lại với series “Lộ trình .NET”! Sau khi cùng nhau đi qua những nền tảng ban đầu như C# căn bản, tìm hiểu hệ sinh thái .NET, làm quen với .NET CLI, Git, và đào sâu vào HTTP/HTTPS, các cấu trúc dữ liệu, hay khám phá thế giới cơ sở dữ liệu quan hệ với SQL và các công cụ ORM như Entity Framework Core, đã đến lúc chúng ta chạm vào một chủ đề cốt lõi trong phát triển ứng dụng hiện đại với .NET Core và ASP.NET Core: Dependency Injection (DI) và quản lý vòng đời (lifetime) của các dịch vụ được inject.
Nếu bạn đang bắt đầu với ASP.NET Core (theo lộ trình ASP.NET Core roadmap), chắc chắn bạn đã thấy rất nhiều đoạn code dạng services.Add...
trong file Startup.cs
hoặc Program.cs
. Đó chính là cách chúng ta đăng ký các dịch vụ (services) vào bộ chứa DI (DI Container) của .NET. Nhưng những phương thức như AddTransient
, AddScoped
, AddSingleton
nghĩa là gì? Tại sao chúng ta cần quan tâm đến chúng? Bài viết này sẽ giúp bạn hiểu rõ bản chất của các vòng đời dịch vụ này và cách sử dụng chúng một cách hiệu quả.
Mục lục
Dependency Injection (DI) là gì? (Nhắc lại nhanh)
Trước khi đi sâu vào vòng đời dịch vụ, hãy nhắc lại một chút về Dependency Injection. DI là một kỹ thuật thiết kế phần mềm cho phép một đối tượng (class) nhận các phụ thuộc (dependencies) của nó từ bên ngoài thay vì tự tạo ra chúng. Điều này giúp:
- Giảm sự kết dính (Coupling): Các class phụ thuộc vào interface hoặc abstract class thay vì các implementation cụ thể.
- Dễ dàng kiểm thử (Testability): Có thể dễ dàng mock hoặc thay thế các phụ thuộc bằng các phiên bản giả (fake) hoặc stub để kiểm thử.
- Tăng khả năng tái sử dụng: Các dịch vụ có thể được sử dụng ở nhiều nơi khác nhau.
- Quản lý vòng đời tập trung: Bộ chứa DI (trong .NET là
IServiceProvider
) sẽ quản lý việc tạo và giải phóng các đối tượng theo cấu hình bạn cung cấp.
Trong ASP.NET Core, DI được tích hợp sẵn. Bạn đăng ký các dịch vụ vào IServiceCollection
trong phương thức ConfigureServices
(hoặc trực tiếp trong Program.cs
với .NET 6+) và framework sẽ tự động inject chúng vào các constructor của controller, service, middleware, v.v.
Tại Sao Cần Hiểu Vòng Đời Dịch Vụ?
Khi bạn đăng ký một dịch vụ, bạn không chỉ nói “đây là service A, khi ai đó cần interface IA thì đưa cho họ service A”, mà bạn còn phải nói “hãy tạo service A này khi nào và quản lý nó như thế nào”. Đó chính là lúc vòng đời dịch vụ (Service Lifetime) phát huy tác dụng. Vòng đời quyết định:
- Khi nào một thể hiện (instance) mới của dịch vụ được tạo ra?
- Thể hiện đó sẽ được chia sẻ giữa những yêu cầu (request) hoặc các đối tượng nào?
- Khi nào thể hiện đó sẽ được giải phóng khỏi bộ nhớ?
Việc lựa chọn vòng đời phù hợp là cực kỳ quan trọng, ảnh hưởng trực tiếp đến hiệu suất, việc sử dụng bộ nhớ, và đặc biệt là tính đúng đắn (correctness) của ứng dụng (ví dụ: quản lý trạng thái – state).
ASP.NET Core cung cấp ba loại vòng đời dịch vụ chính:
- Transient (Tạm thời)
- Scoped (Theo phạm vi)
- Singleton (Đơn nhất)
Hãy cùng khám phá từng loại một.
1. Transient (Tạm thời)
Bản chất
Vòng đời Transient là vòng đời đơn giản nhất. Khi bạn đăng ký một dịch vụ với vòng đời Transient, một thể hiện mới hoàn toàn sẽ được tạo ra mỗi khi dịch vụ đó được yêu cầu từ bộ chứa DI.
Hãy tưởng tượng bạn có một chiếc máy bán hàng tự động. Mỗi lần bạn bỏ tiền vào và chọn món, máy sẽ đưa ra một sản phẩm *mới*. Đó là Transient.
Cách đăng ký
services.AddTransient<IMyTransientService, MyTransientService>();
Hoặc nếu không có interface:
services.AddTransient<MyTransientService>();
Khi nào sử dụng?
Transient phù hợp cho các dịch vụ:
- Nhẹ, không giữ trạng thái (stateless).
- Thực hiện một thao tác cụ thể và không cần chia sẻ dữ liệu giữa các lần sử dụng.
- Có chi phí khởi tạo thấp.
Ví dụ: Một service giúp format chuỗi, một service tính toán đơn giản.
Ưu điểm
- Đơn giản, dễ hiểu.
- An toàn nhất về mặt quản lý trạng thái và luồng (thread safety) vì mỗi lần dùng là một thể hiện mới độc lập.
Nhược điểm
- Có thể tạo ra số lượng thể hiện rất lớn, gây tốn kém tài nguyên (CPU, bộ nhớ) nếu dịch vụ được yêu cầu thường xuyên hoặc trong các vòng lặp.
Lưu ý quan trọng: Nếu một dịch vụ Transient phụ thuộc vào một dịch vụ Scoped hoặc Singleton, nó vẫn sẽ là Transient. Tuy nhiên, hãy cẩn thận khi Transient service tự nó chứa state hoặc giữ tham chiếu đến các dịch vụ khác, vì mỗi thể hiện Transient sẽ có bản sao riêng của các phụ thuộc đó.
2. Scoped (Theo phạm vi)
Bản chất
Vòng đời Scoped nghĩa là một thể hiện của dịch vụ sẽ được tạo ra một lần cho mỗi phạm vi (scope). Thể hiện đó sau đó sẽ được tái sử dụng trong suốt phạm vi đó. Khi phạm vi kết thúc, thể hiện đó sẽ được giải phóng.
Trong ứng dụng web ASP.NET Core, phạm vi phổ biến nhất chính là một yêu cầu HTTP (HTTP Request). Tức là, với mỗi request gửi đến server, một thể hiện Scoped mới sẽ được tạo ra lần đầu tiên dịch vụ đó được yêu cầu trong request đó. Thể hiện này sẽ được tái sử dụng cho tất cả các lần yêu cầu dịch vụ đó trong cùng một request. Khi request kết thúc, thể hiện Scoped đó (và mọi dịch vụ Transient được nó sử dụng trong phạm vi đó) sẽ được giải phóng.
Hãy tưởng tượng bạn thuê một chiếc xe cho chuyến đi trong ngày. Bạn có thể dùng chiếc xe đó để đi nhiều nơi trong chuyến đi (request), nhưng khi kết thúc chuyến đi, bạn trả xe. Mỗi chuyến đi khác sẽ cần thuê một chiếc xe khác. Đó là Scoped.
Cách đăng ký
services.AddScoped<IMyScopedService, MyScopedService>();
Hoặc không có interface:
services.AddScoped<MyScopedService>();
Khi nào sử dụng?
Scoped là lựa chọn phổ biến cho các dịch vụ:
- Cần duy trì trạng thái (state) trong suốt một yêu cầu xử lý.
- Làm việc với các tài nguyên cần được chia sẻ trong cùng một request nhưng không giữa các request.
- Phổ biến nhất: Database Context (ví dụ:
DbContext
trong Entity Framework Core – liên quan đến bài viết Bắt Đầu Với Entity Framework Core). Một DbContext thường cần theo dõi các thay đổi (change tracking – tìm hiểu thêm về Change Tracking) và duy trì cùng một kết nối/transaction trong suốt một request.
Ưu điểm
- Quản lý trạng thái hiệu quả trong phạm vi yêu cầu.
- Tiết kiệm tài nguyên hơn Transient vì không tạo mới ở mọi điểm injection trong cùng một request.
Nhược điểm
- Cần hiểu rõ khái niệm “scope”.
- Có thể gây ra lỗi khó hiểu nếu cố gắng sử dụng dịch vụ Scoped bên ngoài phạm vi của nó (ví dụ: trong một Singleton service hoặc một background task không có scope riêng).
3. Singleton (Đơn nhất)
Bản chất
Vòng đời Singleton nghĩa là chỉ một thể hiện duy nhất của dịch vụ được tạo ra cho toàn bộ vòng đời của ứng dụng. Thể hiện này được chia sẻ bởi tất cả các yêu cầu và tất cả các đối tượng yêu cầu dịch vụ đó.
Hãy tưởng tượng một cái máy in chung trong văn phòng. Tất cả mọi người đều dùng chung một máy in duy nhất. Đó là Singleton.
Cách đăng ký
services.AddSingleton<IMySingletonService, MySingletonService>();
Bạn cũng có thể đăng ký một thể hiện đã tồn tại:
services.AddSingleton<IMySingletonService>(new MySingletonService());
Khi nào sử dụng?
Singleton phù hợp cho các dịch vụ:
- Không giữ trạng thái theo từng yêu cầu (stateless).
- Hoặc giữ trạng thái cần được chia sẻ toàn cục và là thread-safe.
- Có chi phí khởi tạo cao nhưng được sử dụng thường xuyên.
- Quản lý tài nguyên dùng chung (ví dụ: kết nối database, client tới Redis – xem bài viết về Redis, cấu hình ứng dụng).
- Các service cache in-memory toàn cục (so sánh Cache In-Memory và Cache Phân Tán).
Ưu điểm
- Hiệu quả nhất về mặt tài nguyên (chỉ một thể hiện).
- Dễ dàng chia sẻ trạng thái toàn cục (nếu cần).
Nhược điểm
- Yêu cầu nghiêm ngặt về tính thread-safe. Vì thể hiện Singleton được truy cập bởi nhiều luồng cùng lúc, bạn phải đảm bảo code của service là an toàn cho môi trường đa luồng.
- Có thể giữ tài nguyên hoặc bộ nhớ trong suốt vòng đời ứng dụng, cần quản lý cẩn thận.
- Không nên inject các dịch vụ Scoped hoặc Transient vào một Singleton service. Nếu làm vậy, Singleton service sẽ “bắt giữ” (capture) thể hiện đầu tiên của dịch vụ Scoped/Transient được tạo ra và tái sử dụng nó, đi ngược lại vòng đời mong muốn của Scoped/Transient, có thể dẫn đến lỗi sai hoặc rò rỉ bộ nhớ (memory leak).
So Sánh Các Vòng Đời Dịch Vụ
Để hình dung rõ hơn, đây là bảng tóm tắt sự khác biệt giữa ba loại vòng đời:
Vòng đời | Mô tả | Khi nào thể hiện được tạo? | Chia sẻ bởi | Phương thức đăng ký | Trạng thái (State) | Độ an toàn cho luồng (Thread Safety) |
---|---|---|---|---|---|---|
Transient | Mới hoàn toàn mỗi khi được yêu cầu. | Mỗi lần yêu cầu (từ DI Container). | Không chia sẻ (mỗi lần yêu cầu là một thể hiện riêng). | services.AddTransient<T>() |
Thường là stateless. | Tự nhiên an toàn (do không chia sẻ thể hiện). |
Scoped | Một lần cho mỗi phạm vi (request). | Lần đầu tiên được yêu cầu trong một phạm vi. | Tất cả các đối tượng trong cùng một phạm vi. | services.AddScoped<T>() |
Có thể giữ trạng thái theo phạm vi. | Cần cẩn thận nếu phạm vi được chia sẻ giữa các luồng (ít xảy ra trong request HTTP thông thường). |
Singleton | Chỉ một thể hiện duy nhất cho toàn bộ ứng dụng. | Lần đầu tiên được yêu cầu hoặc khi ứng dụng khởi động (tùy cấu hình). | Tất cả các đối tượng và tất cả các phạm vi. | services.AddSingleton<T>() |
Có thể giữ trạng thái toàn cục (phải là thread-safe). | Bắt buộc phải là thread-safe. |
Chọn Vòng Đời Nào Cho Phù Hợp?
Việc lựa chọn vòng đời phụ thuộc vào bản chất của dịch vụ bạn đang xây dựng:
- Bắt đầu với Transient: Nếu dịch vụ của bạn hoàn toàn stateless (không giữ bất kỳ dữ liệu nào giữa các lần gọi), Transient thường là lựa chọn an toàn và mặc định tốt nhất. Nó đảm bảo không có vấn đề về chia sẻ trạng thái không mong muốn hoặc thread safety.
- Chuyển sang Scoped khi cần trạng thái theo Request: Nếu dịch vụ cần duy trì trạng thái hoặc tài nguyên trong suốt một yêu cầu xử lý (như `DbContext`, hoặc một service tích lũy log cho request), hãy sử dụng Scoped. Đây là vòng đời rất phổ biến trong các ứng dụng web.
- Sử dụng Singleton chỉ khi thật sự cần: Chỉ sử dụng Singleton cho các dịch vụ stateless mà chi phí khởi tạo cao, hoặc các dịch vụ quản lý tài nguyên dùng chung toàn cục (config, client tới dịch vụ bên ngoài, cache…). Luôn đảm bảo dịch vụ Singleton của bạn là thread-safe. Tránh inject Scoped hoặc Transient services vào Singleton.
Ví Dụ Minh Họa
Hãy xem một ví dụ đơn giản trong môi trường ASP.NET Core để thấy sự khác biệt:
// Define interfaces
public interface IOperationService
{
Guid OperationId { get; }
string Lifetime { get; }
}
public interface IOperationTransient : IOperationService { }
public interface IOperationScoped : IOperationService { }
public interface IOperationSingleton : IOperationService { }
// Implementations
public class OperationService : IOperationTransient, IOperationScoped, IOperationSingleton
{
private readonly Guid _id;
public string Lifetime { get; }
public OperationService(string lifetime)
{
_id = Guid.NewGuid();
Lifetime = lifetime;
Console.WriteLine($"Creating {lifetime} service with ID: {_id}");
}
public Guid OperationId => _id;
}
// Service that consumes others
public class OperationServiceConsumer
{
public IOperationTransient TransientOperation { get; }
public IOperationScoped ScopedOperation { get; }
public IOperationSingleton SingletonOperation { get; }
public OperationServiceConsumer(
IOperationTransient transientOperation,
IOperationScoped scopedOperation,
IOperationSingleton singletonOperation)
{
TransientOperation = transientOperation;
ScopedOperation = scopedOperation;
SingletonOperation = singletonOperation;
}
}
Đăng ký trong Program.cs
(hoặc Startup.cs
):
// Program.cs (minimal API style)
using LifetimeDemo.Services;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddTransient<IOperationTransient, OperationService>(sp => new OperationService("Transient"));
builder.Services.AddScoped<IOperationScoped, OperationService>(sp => new OperationService("Scoped"));
builder.Services.AddSingleton<IOperationSingleton, OperationService>(sp => new OperationService("Singleton"));
builder.Services.AddTransient<OperationServiceConsumer>(); // Register the consumer as well
var app = builder.Build();
app.MapGet("/", (
OperationServiceConsumer consumer1,
OperationServiceConsumer consumer2,
IOperationTransient transient1,
IOperationTransient transient2,
IOperationScoped scoped1,
IOperationScoped scoped2,
IOperationSingleton singleton1,
IOperationSingleton singleton2) =>
{
var result = $@"
<h2>Request 1:</h2>
Consumer 1:
- Transient: {consumer1.TransientOperation.OperationId} ({consumer1.TransientOperation.Lifetime})
- Scoped: {consumer1.ScopedOperation.OperationId} ({consumer1.ScopedOperation.Lifetime})
- Singleton: {consumer1.SingletonOperation.OperationId} ({consumer1.SingletonOperation.Lifetime})
Consumer 2:
- Transient: {consumer2.TransientOperation.OperationId} ({consumer2.TransientOperation.Lifetime})
- Scoped: {consumer2.ScopedOperation.OperationId} ({consumer2.ScopedOperation.Lifetime})
- Singleton: {consumer2.SingletonOperation.OperationId} ({consumer2.SingletonOperation.Lifetime})
Direct Injection in endpoint:
- Transient 1: {transient1.OperationId} ({transient1.Lifetime})
- Transient 2: {transient2.OperationId} ({transient2.Lifetime})
- Scoped 1: {scoped1.OperationId} ({scoped1.Lifetime})
- Scoped 2: {scoped2.OperationId} ({scoped2.Lifetime})
- Singleton 1: {singleton1.OperationId} ({singleton1.Lifetime})
- Singleton 2: {singleton2.OperationId} ({singleton2.Lifetime})
";
return Results.Content(result, "text/html");
});
app.Run();
Khi bạn chạy ứng dụng này và gửi một yêu cầu HTTP, bạn sẽ thấy kết quả tương tự như sau:
<h2>Request 1:</h2>
Consumer 1:
- Transient: [GUID A] (Transient)
- Scoped: [GUID B] (Scoped)
- Singleton: [GUID C] (Singleton)
Consumer 2:
- Transient: [GUID D] (Transient) <-- DIFFERENT from A
- Scoped: [GUID B] (Scoped) <-- SAME as Consumer 1 Scoped
- Singleton: [GUID C] (Singleton) <-- SAME as Consumer 1 Singleton
Direct Injection in endpoint:
- Transient 1: [GUID E] (Transient) <-- DIFFERENT from A and D
- Transient 2: [GUID F] (Transient) <-- DIFFERENT from A, D, and E
- Scoped 1: [GUID B] (Scoped) <-- SAME as Consumer 1 and 2 Scoped
- Scoped 2: [GUID B] (Scoped) <-- SAME as Consumer 1, 2 Scoped, and Scoped 1
- Singleton 1: [GUID C] (Singleton) <-- SAME as Consumer 1 and 2 Singleton
- Singleton 2: [GUID C] (Singleton) <-- SAME as Consumer 1, 2 Singleton, and Singleton 1
Phân tích kết quả trong MỘT request:
- Transient: Mỗi lần dịch vụ
IOperationTransient
được yêu cầu (qua Consumer 1, Consumer 2, Transient 1, Transient 2), một GUID mới được tạo ra. Có tổng cộng 4 thể hiện Transient trong một request này. - Scoped: Dịch vụ
IOperationScoped
được yêu cầu nhiều lần trong cùng một request, nhưng chỉ có một GUID duy nhất ([GUID B]) được tạo ra và được tái sử dụng cho tất cả các điểm yêu cầu trong request đó (Consumer 1, Consumer 2, Scoped 1, Scoped 2). - Singleton: Dịch vụ
IOperationSingleton
cũng chỉ có một GUID duy nhất ([GUID C]) được tạo ra và tái sử dụng.
Bây giờ, nếu bạn gửi một yêu cầu HTTP khác, bạn sẽ thấy:
- Tất cả các dịch vụ Transient sẽ có GUID mới hoàn toàn so với yêu cầu trước.
- Tất cả các dịch vụ Scoped trong yêu cầu mới này sẽ chia sẻ một GUID mới, khác với GUID Scoped của yêu cầu trước.
- Tất cả các dịch vụ Singleton trong yêu cầu mới này sẽ vẫn chia sẻ cùng GUID ([GUID C]) với yêu cầu đầu tiên. GUID Singleton chỉ thay đổi khi ứng dụng được khởi động lại.
Ví dụ này minh họa rõ ràng cách mỗi vòng đời quản lý việc tạo và chia sẻ thể hiện dịch vụ.
Các Lưu Ý Quan Trọng và Cạm Bẫy Thường Gặp
- Thread Safety của Singleton: Nhắc lại lần nữa vì điều này rất quan trọng. Nếu Singleton service của bạn có state và không được thiết kế thread-safe, nhiều request truy cập cùng lúc sẽ dẫn đến race condition và lỗi không mong muốn.
- “Capturing” Dependencies: Đây là cạm bẫy phổ biến nhất cho người mới. Không inject dịch vụ Scoped hoặc Transient vào dịch vụ Singleton. Singleton service tồn tại lâu hơn Scoped và Transient, nếu nó giữ tham chiếu đến chúng, nó sẽ “bắt giữ” thể hiện đầu tiên được tạo ra và sử dụng lại, phá vỡ vòng đời của dịch vụ con. Tương tự, cẩn thận khi inject Transient vào Scoped (dù ít nguy hiểm hơn) hoặc Transient vào Transient (thường không vấn đề).
- Sử dụng
IServiceProvider
Trực tiếp (Service Locator Anti-Pattern): Tránh injectIServiceProvider
vào constructor hoặc dùngGetService
/GetRequiredService
một cách tùy tiện trong logic nghiệp vụ. Điều này làm class của bạn khó kiểm thử và che giấu các phụ thuộc thực sự. Chỉ sử dụng khi bạn cần tạo scope con (ví dụ: trong background task chạy trong Singleton) hoặc trong code cấu hình đặc biệt. - Tạo Scope Thủ Công: Trong một số trường hợp (ví dụ: background service chạy trong Singleton cần thực hiện các tác vụ theo scope request, như truy cập DbContext), bạn cần tạo scope con thủ công bằng cách inject
IServiceScopeFactory
và sử dụngfactory.CreateScope()
.
public class MyBackgroundService : IHostedService
{
private readonly IServiceScopeFactory _scopeFactory;
public MyBackgroundService(IServiceScopeFactory scopeFactory)
{
_scopeFactory = scopeFactory;
}
public async Task StartAsync(CancellationToken cancellationToken)
{
// Long-running task
using (var scope = _scopeFactory.CreateScope()) // Create a new scope
{
// Get Scoped services from this scope
var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
var scopedService = scope.ServiceProvider.GetRequiredService<IMyScopedService>();
// Use dbContext and scopedService within this scope
// They will be disposed when the 'using' block exits
}
// The scope and its services are disposed here
}
// ... other methods
}
Kết Luận
Hiểu và sử dụng đúng vòng đời dịch vụ (Scoped, Transient, Singleton) là một kỹ năng thiết yếu khi làm việc với Dependency Injection trong .NET Core/ASP.NET Core. Nó không chỉ giúp code của bạn sạch sẽ, dễ kiểm thử mà còn đảm bảo ứng dụng hoạt động đúng đắn, quản lý tài nguyên hiệu quả và tránh các vấn đề về trạng thái hay thread safety.
Hãy dành thời gian thực hành với ví dụ trên, thử thay đổi vòng đời và quan sát kết quả. Đó là cách tốt nhất để “thấm” được những khái niệm này.
Chúc mừng bạn đã hoàn thành thêm một chặng đường quan trọng trong Lộ trình .NET! Tiếp theo, chúng ta sẽ cùng khám phá sâu hơn các khía cạnh khác của phát triển ứng dụng .NET hiện đại. Hẹn gặp lại trong các bài viết tiếp theo!