7.11 - Captive dependency làm lộ dữ liệu giữa tenant
Tóm tắt: Một sự cố có thật kiểu này rất hay gặp trong hệ multi-tenant (nhiều công ty khách hàng dùng chung một ứng dụng, mỗi công ty là một tenant): người dùng của tenant A nhìn thấy dữ liệu của tenant B. Không ai viết sai truy vấn. Nguyên nhân là captive dependency: một service
Singletonnhận vào một serviceScopedchứa thông tin tenant, và giữ mãi bản của request đầu tiên. Ở Development, .NET chặn lỗi này ngay lúc khởi động; sự cố lọt ra production vì đội đã tắt kiểm tra. Bài phân tích sự cố, chỉ ra cách sửa đúng (và một cách sửa sai rất hay gặp:IServiceScopeFactorykhông cho bạn scope của request hiện tại), rồi bật lại kiểm tra để lỗi không tái diễn.
Mục tiêu bài học
Sau bài này bạn có thể:
- Nhận ra captive dependency trong cấu hình DI và giải thích vì sao nó gây rò rỉ dữ liệu giữa tenant.
- Chọn đúng cách sửa: truyền tham số, đổi lifetime, hoặc đọc
IHttpContextAccessortrong phương thức. - Giải thích vì sao
IServiceScopeFactory.CreateScope()không lấy được dữ liệu của request hiện tại. - Phân biệt việc
ValidateScopesvàValidateOnBuildbắt được, và bật cả hai ở mọi môi trường.
Cần biết trước: bài 7.4 (lifetime, captive dependency, IServiceScopeFactory), bài 7.9 (ITenantContext, IHttpContextAccessor).
Nội dung bài học
7.11.1 — Sự cố
09:14 Khách hàng tenant "acme" báo: thấy 3 lead của công ty khác
trong danh sách của mình
09:20 Kiểm tra database: dữ liệu ĐÚNG, mọi lead đều có TenantId đúng
09:35 Chạy lại truy vấn bằng tay: trả về ĐÚNG
09:50 Thử trên staging với một tài khoản: KHÔNG tái hiện được
10:30 Tái hiện được khi có HAI người dùng khác tenant cùng truy cậpBốn đặc điểm khiến nó khó chẩn đoán:
- Dữ liệu trong database đúng.
- Truy vấn chạy tay đúng.
- Thử với một người dùng không tái hiện.
- Không có lỗi nào trong log.
7.11.2 — Nguyên nhân
// Scoped — một instance MỚI cho mỗi request HTTP
public class TenantContext : ITenantContext
{
public TenantContext(IHttpContextAccessor accessor)
{
TenantId = accessor.HttpContext?.User.FindFirst("tenant_id")?.Value;
}
public string? TenantId { get; }
}
// SINGLETON — một instance duy nhất cho CẢ ỨNG DỤNG
public class CacheService : ICacheService
{
private readonly ITenantContext _tenantContext; // <- SCOPED trong SINGLETON
public CacheService(ITenantContext tenantContext) // BUG ở đây
{
_tenantContext = tenantContext;
}
public string BuildKey(string key) => $"{_tenantContext.TenantId}:{key}";
}builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ITenantContext, TenantContext>();
builder.Services.AddSingleton<ICacheService, CacheService>(); // <- SAIRequest #1 (tenant "acme"):
-> DI tạo CacheService (singleton, tạo MỘT LẦN)
-> Để tạo nó, DI tạo TenantContext với TenantId = "acme"
-> CacheService GIỮ tham chiếu tới TenantContext đó MÃI MÃI
Request #2 (tenant "globex"):
-> CacheService đã tồn tại, KHÔNG tạo lại
-> Vẫn dùng TenantContext cũ -> TenantId = "acme"
-> BuildKey trả về "acme:leads" cho người dùng của globex
-> Đọc trúng cache của tenant khác -> RÒ RỈ DỮ LIỆUService scoped bị giam trong singleton — đó là tên gọi captive dependency.
Vì sao thử với một tài khoản không thấy: chỉ có một tenant, nên TenantId bị giam tình cờ đúng với mọi request. Sự cố chỉ xuất hiện khi có tenant thứ hai.
7.11.3 — Vì sao container không báo lỗi
Mặc định, khi môi trường là Development, host bật cả ValidateScopes lẫn ValidateOnBuild (bài 7.4). Đăng ký ở mục trên sẽ làm ứng dụng không khởi động được ngay trên máy lập trình viên. Vậy vì sao lỗi lọt ra production?
Lần lại lịch sử Git của Program.cs, đội tìm thấy dòng này, thêm từ ba tháng trước:
// "Tạm tắt cho chạy được, sửa sau" — commit của một bạn trong đội
builder.Host.UseDefaultServiceProvider(o =>
{
o.ValidateScopes = false;
o.ValidateOnBuild = false;
});Lúc đó ứng dụng không khởi động được vì một captive dependency khác. Thay vì sửa, ai đó tắt kiểm tra. Từ hôm đó, mọi lỗi lifetime mới đều lọt qua im lặng, kể cả lỗi của CacheService. Staging và production vốn đã không bật kiểm tra (môi trường không phải Development), nên không có lớp bảo vệ nào còn lại.
Khi kiểm tra bị tắt, container không kiểm tra gì về lifetime:
Singleton <- Singleton : OK
Singleton <- Scoped : KHÔNG báo lỗi — đây là BUG
Singleton <- Transient : KHÔNG báo lỗi, nhưng transient bị giam
Scoped <- Singleton : OK
Scoped <- Transient : OK, transient sống bằng scope
Transient <- Scoped : OK trong scopeHàng thứ hai là ca này. Hàng thứ ba cũng đáng lưu ý: Transient trong Singleton không được tạo mới mỗi lần như tên gọi gợi ý — nó cũng bị giam, và container không báo lỗi cho trường hợp này kể cả khi kiểm tra bật.
7.11.4 — Sửa
Thông tin tenant thay đổi theo request. Ba cách sửa đúng, theo thứ tự nên cân nhắc:
Cách 1 — truyền tham số thay vì tiêm:
public class CacheService : ICacheService
{
// Không tiêm gì cả — người gọi truyền vào
public string BuildKey(string tenantId, string key) => $"{tenantId}:{key}";
}Đơn giản nhất và thường đúng nhất: nếu một giá trị thay đổi theo request, nó là tham số, không phải dependency. CacheService vẫn là Singleton, giữ cache dùng chung, còn người gọi (đang ở trong request, có ITenantContext đúng) truyền tenant vào.
Cách 2 — đổi lifetime của service:
builder.Services.AddScoped<ICacheService, CacheService>(); // thay vì SingletonChỉ đúng nếu service thật sự không cần là singleton. Nếu nó giữ trạng thái dùng chung (một ConcurrentDictionary làm cache trong bộ nhớ chẳng hạn), đổi sang scoped sẽ tạo cache mới mỗi request và mất hết tác dụng. Khi đó tách phần lưu trữ dùng chung ra một Singleton riêng, còn phần đọc tenant là Scoped.
Cách 3 — đọc IHttpContextAccessor trong phương thức:
public class CacheService(IHttpContextAccessor accessor) : ICacheService
{
public string BuildKey(string key)
{
var tenantId = accessor.HttpContext?.User.FindFirst("tenant_id")?.Value
?? throw new InvalidOperationException("Không có tenant trong request hiện tại");
return $"{tenantId}:{key}";
}
}IHttpContextAccessor là Singleton nhưng bên trong dùng AsyncLocal (bài 7.9), nên đọc HttpContext trong phương thức cho đúng request đang chạy. Đọc nó trong constructor của singleton thì lại chụp request đầu tiên, y như lỗi ban đầu. Cách này chỉ dùng được bên trong request HTTP: gọi từ tác vụ nền thì HttpContext là null.
Cách sửa SAI hay gặp — tạo scope mới bằng IServiceScopeFactory:
// SAI — trông như sửa được, thực ra không
public class CacheService(IServiceScopeFactory scopeFactory) : ICacheService
{
public string BuildKey(string key)
{
using var scope = scopeFactory.CreateScope(); // scope MỚI, không phải scope của request
var tenant = scope.ServiceProvider.GetRequiredService<ITenantContext>();
return $"{tenant.TenantId}:{key}";
}
}CreateScope() tạo một scope mới tinh, tách biệt hoàn toàn với scope của request đang chạy. Mọi service Scoped lấy từ đó là bản mới, không phải bản mà middleware vừa gán dữ liệu cho request. Với TenantContext ở mục 7.11.2, đoạn code trên tình cờ chạy đúng, chỉ vì constructor của TenantContext tự đọc lại IHttpContextAccessor. Nếu TenantContext được middleware gán giá trị (như RequestContext trong Bài 1), bản mới luôn rỗng: Bài 1 đo được TenantId = 0 ở mọi request.
IServiceScopeFactory đúng chỗ khi bạn cần một đơn vị công việc riêng, không gắn với request nào: BackgroundService cần một DbContext mới cho mỗi lượt xử lý (bài 7.4, bài 6.8). Nó không phải cách để "với tới" scope của request.
7.11.5 — Bật kiểm tra tự động
Việc đầu tiên sau sự cố: xoá dòng tắt kiểm tra, và bật tường minh cho mọi môi trường:
// Program.cs
builder.Host.UseDefaultServiceProvider((context, options) =>
{
options.ValidateScopes = true; // phát hiện captive dependency
options.ValidateOnBuild = true; // kiểm tra NGAY khi khởi động
});Với đăng ký sai ở mục 7.11.2, ứng dụng dừng ngay ở builder.Build():
System.AggregateException: Some services are not able to be constructed (Error while validating the service descriptor 'ServiceType: ICacheService Lifetime: Singleton ImplementationType: CacheService': Cannot consume scoped service 'ITenantContext' from singleton 'ICacheService'.)Ứng dụng không khởi động được thay vì chạy sai. Đây là kết quả bạn muốn.
Hai tuỳ chọn làm hai việc khác nhau, và Bài 2 đo được điều này trên .NET 10:
| Bật | Bắt được captive dependency | Bắt được thiếu đăng ký |
|---|---|---|
Chỉ ValidateScopes | Có, nhưng lúc resolve lần đầu | Lúc resolve |
Chỉ ValidateOnBuild | Không | Có, lúc khởi động |
| Cả hai | Có, lúc khởi động | Có, lúc khởi động |
Bật cả hai. Chi phí chỉ có ở lúc khởi động, và nó ngăn được đúng lớp sự cố như ca này. Nếu một ngày ứng dụng không khởi động được vì kiểm tra này, sửa đăng ký, đừng tắt kiểm tra: đó chính là cách sự cố hôm nay bắt đầu.
Thêm một test chạy trong CI (tích hợp liên tục: máy chủ tự chạy build và test mỗi lần có commit). Tách phần đăng ký ra một extension như bài 7.6 để test gọi lại được:
[Fact]
public void AllRegistrations_PassScopeAndBuildValidation()
{
var config = new ConfigurationBuilder().Build();
var services = new ServiceCollection();
services.AddHttpContextAccessor();
services.AddCrmServices(config); // extension chứa mọi đăng ký của ứng dụng
// Ném AggregateException nếu có captive dependency hoặc thiếu đăng ký
using var sp = services.BuildServiceProvider(new ServiceProviderOptions
{
ValidateScopes = true,
ValidateOnBuild = true,
});
}7.11.6 — Rà dự án
# Tìm mọi đăng ký singleton
grep -rn "AddSingleton" --include=*.cs src/Với mỗi kết quả, kiểm tra constructor của lớp đó: mọi dependency của nó phải là singleton, hoặc là IServiceScopeFactory dùng đúng mục đích.
Ba dependency thường xuyên bị tiêm nhầm vào singleton:
| Dependency | Lifetime | Hậu quả khi bị giam |
|---|---|---|
DbContext | Scoped | Change tracker phình vô hạn, dữ liệu cũ |
IHttpContextAccessor mà đọc HttpContext trong constructor | Singleton nhưng nội dung theo request | Lấy sai người dùng |
Bất kỳ ITenantContext, ICurrentUser nào | Scoped | Rò rỉ dữ liệu giữa tenant |
// SAI — chụp context trong constructor của singleton
public class MyService(IHttpContextAccessor accessor)
{
private readonly string? _userId = accessor.HttpContext?.User.Identity?.Name; // chụp MỘT LẦN
}
// ĐÚNG — đọc trong phương thức
public class MyService(IHttpContextAccessor accessor)
{
public string? GetUserId() => accessor.HttpContext?.User.Identity?.Name; // đọc MỖI LẦN
}7.11.7 — Lỗi thường gặp
| Lỗi | Vì sao sai | Cách đúng |
|---|---|---|
Tắt ValidateScopes để "chạy được đã" | Mọi lỗi lifetime sau đó lọt qua im lặng | Sửa đăng ký, giữ kiểm tra |
Dùng IServiceScopeFactory để lấy dữ liệu của request | Scope mới không phải scope của request | Truyền tham số, đổi lifetime, hoặc IHttpContextAccessor trong phương thức |
Chỉ bật ValidateOnBuild | Không bắt captive dependency | Bật cả ValidateScopes |
Đọc HttpContext trong constructor của singleton | Chụp request đầu tiên | Đọc trong phương thức |
Danh sách rà soát captive dependency
- ValidateScopes và ValidateOnBuild được bật ở mọi môi trường, không có dòng nào tắt chúng.
- Mọi singleton chỉ nhận dependency singleton.
- IServiceScopeFactory chỉ dùng cho đơn vị công việc riêng (tác vụ nền), không để lấy dữ liệu của request.
- Không tiêm DbContext vào singleton.
- Không đọc HttpContext trong constructor của singleton.
- Giá trị thay đổi theo request được truyền làm tham số hoặc đọc qua service Scoped.
- Có test trong CI dựng container với cả hai kiểm tra.
Bài tập áp dụng
Bài 1 — Tái hiện sự cố, và thử cách sửa sai
Viết chương trình console mô phỏng ba request bằng ba scope. Mỗi "request" gán TenantId cho một service Scoped (giống middleware gán tenant). Đọc tenant qua một Singleton giữ service Scoped, và qua một Singleton dùng IServiceScopeFactory.
Tiêu chí hoàn thành: bạn thấy cả hai Singleton đều đọc sai, và giải thích được vì sao cách IServiceScopeFactory không sửa được lỗi.
Gợi ý và lời giải — Bài 1
Gợi ý. Không bật kiểm tra (giống cấu hình đã bị tắt trong sự cố). Với kiểm tra bật, container không cho dựng — và bạn sẽ không thấy được hậu quả.
Lời giải (project console, cài Microsoft.Extensions.DependencyInjection):
using Microsoft.Extensions.DependencyInjection;
var services = new ServiceCollection();
services.AddScoped<RequestContext>();
services.AddSingleton<TenantReader>(); // SAI: Singleton nhận Scoped
services.AddSingleton<ScopeFactoryTenantReader>(); // "sửa" bằng IServiceScopeFactory
// Không bật ValidateScopes, giống cấu hình đã tắt kiểm tra
var root = services.BuildServiceProvider();
for (int i = 1; i <= 3; i++)
{
using var scope = root.CreateScope(); // giả lập một request
var sp = scope.ServiceProvider;
sp.GetRequiredService<RequestContext>().TenantId = i; // middleware gán tenant cho request
var captive = sp.GetRequiredService<TenantReader>();
var viaFactory = sp.GetRequiredService<ScopeFactoryTenantReader>();
Console.WriteLine($"request {i}: scope có TenantId={i} | singleton giữ Scoped đọc {captive.ReadTenant()} | singleton tạo scope mới đọc {viaFactory.ReadTenant()}");
}
public class RequestContext { public int TenantId { get; set; } }
public class TenantReader(RequestContext context)
{
public int ReadTenant() => context.TenantId;
}
public class ScopeFactoryTenantReader(IServiceScopeFactory scopeFactory)
{
public int ReadTenant()
{
using var scope = scopeFactory.CreateScope(); // scope MỚI, không phải scope của request
return scope.ServiceProvider.GetRequiredService<RequestContext>().TenantId;
}
}Kết quả chạy trên .NET 10:
request 1: scope có TenantId=1 | singleton giữ Scoped đọc 0 | singleton tạo scope mới đọc 0
request 2: scope có TenantId=2 | singleton giữ Scoped đọc 0 | singleton tạo scope mới đọc 0
request 3: scope có TenantId=3 | singleton giữ Scoped đọc 0 | singleton tạo scope mới đọc 0Singleton giữ Scoped đọc 0 ngay từ request 1. Lý do: Singleton luôn được dựng từ root, nên RequestContext mà nó nhận là bản của root, không phải của scope nào. Không scope nào gán giá trị cho bản đó, nên nó mãi là 0. Trong ứng dụng thật, nếu bản bị giam là bản đã có dữ liệu (như TenantContext đọc từ request đầu tiên ở mục 7.11.2), nó sẽ mãi giữ tenant của request đó.
Singleton tạo scope mới cũng đọc 0. Mỗi lần gọi, nó tạo một scope mới, nhận một RequestContext mới chưa ai gán gì. Scope của request (nơi TenantId = i được gán) không liên quan gì tới scope mới này.
Vì sao không có ngoại lệ nào:
Về mặt kỹ thuật, không có gì sai:
- RequestContext được tạo thành công
- Singleton giữ một tham chiếu hợp lệ
- mọi lời gọi đều chạy trơn tru
Container không có cách nào biết rằng BẠN muốn nó lấy instance của scope hiện tại.Ba dấu hiệu nhận ra trên hệ thống đang chạy:
1. "Thỉnh thoảng người dùng thấy dữ liệu của người khác"
2. Lỗi gắn với lần khởi động: sau mỗi lần restart, tenant "đúng" thay đổi
3. Chỉ xảy ra khi có nhiều tenant cùng dùng — thử một mình không tái hiện đượcSửa cho bài này: TenantReader nhận tenant làm tham số (ReadTenant(int tenantId)), hoặc đăng ký AddScoped<TenantReader>(). Chạy lại: cột thứ hai in đúng 1, 2, 3.
Bài 2 — Bật kiểm tra, và đo xem mỗi tuỳ chọn bắt được gì
Với đăng ký sai ở Bài 1, dựng container ba lần: chỉ ValidateScopes, chỉ ValidateOnBuild, và cả hai. Ghi lại lỗi xuất hiện lúc build hay lúc resolve. Sau đó thử một lỗi khác: LeadService cần ILeadRepository nhưng quên đăng ký.
Tiêu chí hoàn thành: bạn có thông điệp lỗi chính xác cho từng trường hợp, và điền được bảng ở mục 7.11.5 từ kết quả của mình.
Gợi ý và lời giải — Bài 2
Gợi ý. BuildServiceProvider(new ServiceProviderOptions { ... }) nhận đúng hai tuỳ chọn mà UseDefaultServiceProvider đặt cho ứng dụng web.
Lời giải:
using Microsoft.Extensions.DependencyInjection;
Check("ValidateScopes = true, ValidateOnBuild = false", new() { ValidateScopes = true });
Check("ValidateScopes = false, ValidateOnBuild = true", new() { ValidateOnBuild = true });
Check("ValidateScopes = true, ValidateOnBuild = true", new() { ValidateScopes = true, ValidateOnBuild = true });
var s2 = new ServiceCollection();
s2.AddScoped<LeadService>(); // LeadService cần ILeadRepository
// quên: s2.AddScoped<ILeadRepository, InMemoryLeadRepository>();
try { s2.BuildServiceProvider(new ServiceProviderOptions { ValidateOnBuild = true }); Console.WriteLine("build OK"); }
catch (AggregateException ex) { Console.WriteLine(ex.InnerExceptions[0].Message); }
static void Check(string title, ServiceProviderOptions options)
{
Console.WriteLine($"--- {title}");
var services = new ServiceCollection();
services.AddScoped<RequestContext>();
services.AddSingleton<TenantReader>();
try
{
var root = services.BuildServiceProvider(options);
Console.WriteLine(" Build : không báo lỗi");
using var scope = root.CreateScope();
scope.ServiceProvider.GetRequiredService<TenantReader>();
Console.WriteLine(" Resolve: không báo lỗi");
}
catch (Exception ex)
{
var message = ex is AggregateException a ? a.InnerExceptions[0].Message : ex.Message;
Console.WriteLine($" {ex.GetType().Name}: {message}");
}
}
public class RequestContext { public int TenantId { get; set; } }
public class TenantReader(RequestContext context) { public int ReadTenant() => context.TenantId; }
public interface ILeadRepository { }
public class LeadService(ILeadRepository repo) { public ILeadRepository Repo => repo; }Kết quả chạy trên .NET 10:
--- ValidateScopes = true, ValidateOnBuild = false
Build : không báo lỗi
InvalidOperationException: Cannot consume scoped service 'RequestContext' from singleton 'TenantReader'.
--- ValidateScopes = false, ValidateOnBuild = true
Build : không báo lỗi
Resolve: không báo lỗi
--- ValidateScopes = true, ValidateOnBuild = true
AggregateException: Error while validating the service descriptor 'ServiceType: TenantReader Lifetime: Singleton ImplementationType: TenantReader': Cannot consume scoped service 'RequestContext' from singleton 'TenantReader'.
Error while validating the service descriptor 'ServiceType: LeadService Lifetime: Scoped ImplementationType: LeadService': Unable to resolve service for type 'ILeadRepository' while attempting to activate 'LeadService'.Đọc kết quả:
- Chỉ
ValidateScopes: bắt được captive dependency, nhưng lúc resolve. NếuTenantReaderchỉ được dùng ở một endpoint ít ai gọi, lỗi có thể nằm im nhiều ngày. - Chỉ
ValidateOnBuild: không bắt được captive dependency. Nó kiểm tra "mọi service có dựng được không", còn luật lifetime thuộc vềValidateScopes. Đây là điểm nhiều người hiểu nhầm. - Cả hai: lỗi lifetime nổ lúc build, với đủ ba thông tin: lớp nào, lifetime nào, phụ thuộc nào.
- Thiếu đăng ký: chỉ cần
ValidateOnBuildlà bắt được ngay lúc build, kể cả khiLeadServicechưa bao giờ được resolve.
Vì vậy cấu hình đúng luôn là bật cả hai, ở mọi môi trường.
Bài 3 — Rà dự án
Liệt kê mọi AddSingleton trong một dự án (của bạn hoặc của nhóm) và kiểm tra constructor của từng lớp. Viết một test tự động bắt Singleton nhận service Scoped.
Tiêu chí hoàn thành: bạn có danh sách kèm đánh giá, phân biệt được singleton đúng với singleton do quán tính, và test của bạn đỏ khi thêm một đăng ký sai.
Gợi ý và lời giải — Bài 3
Gợi ý. Với mỗi singleton, đọc constructor của nó. Nếu có một tham số là DbContext, repository hay "context" của request, dừng lại xem kỹ.
Lời giải — liệt kê:
grep -rn "AddSingleton" --include="*.cs" src/ | grep -v "/Tests/"Ví dụ kết quả trên một dự án CRM:
src/Crm.Api/Program.cs:34: builder.Services.AddSingleton<IRateCounter, RateCounter>();
src/Crm.Api/Program.cs:35: builder.Services.AddSingleton<ISettingsCache, SettingsCache>();
src/Crm.Api/Program.cs:36: builder.Services.AddSingleton<IEncryptor, AesEncryptor>();
src/Crm.Api/Program.cs:37: builder.Services.AddSingleton<ISyncService, SyncService>();
src/Crm.Api/Program.cs:38: builder.Services.AddSingleton<IStatsService, StatsService>();
src/Crm.Api/Program.cs:39: builder.Services.AddSingleton(TimeProvider.System);Bảng đánh giá:
| Lớp | Phụ thuộc | Đánh giá |
|---|---|---|
RateCounter | không có | Đúng — trạng thái cố ý dùng chung |
SettingsCache | IOptionsMonitor<T> | Đúng — Monitor là Singleton |
AesEncryptor | IOptions<T> | Đúng |
SyncService | CrmDbContext | SAI — captive |
StatsService | ILeadRepository (Scoped) | SAI — captive |
TimeProvider.System | — | Đúng |
Sửa SyncService: nó là việc chạy định kỳ, không gắn với request nào, nên đây đúng là chỗ của IServiceScopeFactory — mỗi lượt đồng bộ một scope, một DbContext mới:
public class SyncService(IServiceScopeFactory scopeFactory) : ISyncService
{
public async Task SyncAsync(CancellationToken ct)
{
using var scope = scopeFactory.CreateScope();
var db = scope.ServiceProvider.GetRequiredService<CrmDbContext>();
await ProcessAsync(db, ct);
}
}Sửa StatsService: nó được gọi từ endpoint, không giữ trạng thái dùng chung nào, nên đổi sang AddScoped<IStatsService, StatsService>().
Vì sao hai lớp này được đăng ký Singleton? Thường không phải vì có lý do:
1. "Singleton nghe có vẻ nhanh hơn"
-> nhưng Scoped chỉ tạo một instance mỗi request, chi phí không đáng kể
2. Sao chép từ dòng AddSingleton ngay phía trên
-> và dòng đó đúng, vì lớp kia không có phụ thuộc scoped
3. Cần giữ trạng thái giữa các request
-> lý do THẬT, nhưng cách giải là Singleton giữ DỮ LIỆU,
còn việc đọc database đi qua scope riêngBa câu hỏi phân biệt singleton đúng với singleton do quán tính:
1. Lớp có TRẠNG THÁI cần chia sẻ giữa các request không? Không -> Scoped an toàn hơn
2. Khởi tạo có đắt không (nạp file, dựng bảng tra, mở kết nối lâu dài)? Không -> Singleton không lợi gì
3. Constructor có phụ thuộc nào là Scoped không? Có -> đổi sang Scoped, hoặc tách lớpTest tự động — đọc chính bảng đăng ký:
[Fact]
public void Singletons_DoNotDependOnScopedServices()
{
var services = new ServiceCollection();
services.AddCrmServices(new ConfigurationBuilder().Build()); // extension chứa mọi đăng ký
var scopedTypes = services
.Where(d => d.Lifetime == ServiceLifetime.Scoped)
.Select(d => d.ServiceType)
.ToHashSet();
var violations = services
.Where(d => d.Lifetime == ServiceLifetime.Singleton && d.ImplementationType is not null)
.SelectMany(d => d.ImplementationType!.GetConstructors()
.SelectMany(c => c.GetParameters())
.Where(p => scopedTypes.Contains(p.ParameterType))
.Select(p => $"{d.ImplementationType!.Name} nhận {p.ParameterType.Name}"))
.ToList();
Assert.Empty(violations);
}Test này làm đúng việc bạn vừa làm bằng tay, nhưng chạy tự động với mỗi pull request. Khi đỏ, xUnit in nội dung danh sách violations, ví dụ SyncService nhận CrmDbContext. Nó không thay được test ở mục 7.11.5 (test đó còn bắt cả thiếu đăng ký), nhưng thông báo dễ đọc hơn khi có nhiều vi phạm cùng lúc.
Tự kiểm tra
Captive dependency là gì?
Là khi một service singleton nhận vào một service scoped hoặc transient. Service đó bị giam và sống mãi cùng singleton, giữ nguyên trạng thái của lần nó được tạo.
Vì sao nó gây rò rỉ dữ liệu giữa tenant?
Vì service scoped chứa thông tin tenant bị giữ mãi trong singleton. Mọi request sau, kể cả của tenant khác, đều dùng lại giá trị đó, nên khoá cache và bộ lọc dữ liệu đều trỏ nhầm tenant.
Development bật kiểm tra mặc định, vậy sao lỗi vẫn lọt ra production?
Vì kiểm tra chỉ bật mặc định khi môi trường là Development. Nếu ai đó tắt nó trong Program.cs, hoặc chạy máy local với môi trường khác Development, thì không còn lớp bảo vệ nào; staging và production vốn không bật. Cách chặn là bật tường minh ValidateScopes và ValidateOnBuild ở mọi môi trường và có test trong CI.
Dùng IServiceScopeFactory.CreateScope trong singleton có lấy được ITenantContext của request hiện tại không?
Không. CreateScope tạo một scope mới, độc lập với scope của request, nên mọi service Scoped lấy ra từ đó là bản mới chưa có dữ liệu của request. Nó chỉ tình cờ chạy đúng khi service tự đọc lại IHttpContextAccessor. IServiceScopeFactory dành cho đơn vị công việc riêng như tác vụ nền cần DbContext mới.
Chỉ bật ValidateOnBuild có đủ không?
Không. ValidateOnBuild bắt thiếu đăng ký lúc khởi động, nhưng không kiểm tra luật lifetime; captive dependency chỉ bị bắt khi ValidateScopes cũng bật. Bật cả hai thì captive dependency nổ ngay lúc build.
Cách sửa nào ít gây bất ngờ nhất?
Truyền giá trị làm tham số thay vì tiêm. Nếu một giá trị thay đổi theo request thì nó là tham số chứ không phải dependency. Cách này không cần tạo scope và không có cạm bẫy nào.
Kết luận
Ba điều đáng nhớ nhất:
- Giá trị thay đổi theo request là tham số hoặc service Scoped, không bao giờ nằm trong Singleton.
IServiceScopeFactorykhông đưa bạn về scope của request. - Bật cả
ValidateScopeslẫnValidateOnBuildở mọi môi trường — chỉ một trong hai là không đủ. - Đừng tắt kiểm tra để "chạy được đã". Ứng dụng không chạy còn hơn chạy sai.
Tham khảo
- Dependency injection in .NET — Service lifetimes
- Dependency injection guidelines — Captive dependency
- ServiceProviderOptions class
- Access HttpContext in ASP.NET Core
Điều hướng
- Bài trước: 7.10 — Decorator, module đăng ký và Scrutor
- Bài tiếp theo: 7.12 — Đổi cấu hình mà ứng dụng không nhận
- Về module: Trang mục lục