7.13 - Ôn tập và đánh giá Module 7
Tóm tắt: Bài ôn tập cuối Module 7 gồm ba phần: 10 câu quiz bám vào những chỗ dễ sai nhất của DI trong .NET (lifetime, captive dependency, thứ tự đăng ký,
ValidateScopesso vớiValidateOnBuild, Options, keyed services), ba bài lab chạy được bằng project console và xUnit, có lời giải và tiêu chí chấm kiểm tra được, và một rubric mô tả từng mức điểm. Làm quiz trước khi xem đáp án, làm lab trước khi mở lời giải.
Mục tiêu bài học
Sau bài này bạn có thể:
- Trả lời đúng ít nhất 8/10 câu quiz và giải thích lý do cho từng câu.
- Refactor một lớp tự
newphụ thuộc thành constructor injection và chạy nó bằngHost.CreateApplicationBuilder. - Viết unit test cho service bằng bản giả (NSubstitute), không cần container.
- Đọc cấu hình qua Options pattern, có kiểm tra lúc khởi động và có test cho cả quy tắc lẫn cấu hình sai.
- Tự chấm bài lab theo rubric và chỉ ra bài nào trong module cần đọc lại.
Cần biết trước: toàn bộ Module 7, từ bài 7.1 tới bài 7.12; viết test xUnit (bài 0.4).
Nội dung bài học
7.13.1 — Cách làm bài
- Làm Phần A (quiz) trên giấy hoặc file nháp, ghi đáp án kèm một dòng lý do. Xong cả 10 câu mới mở đáp án.
- Làm Phần B (lab) trong một solution gồm hai project:
Crm.Lab(console) vàCrm.Lab.Tests(xUnit, tham chiếuCrm.Lab), dựng như bài 0.4. Ba bài lab nối tiếp nhau trên cùng solution. - Tự chấm theo rubric ở mục 7.13.4. Câu quiz sai và tiêu chí lab bị trừ điểm cho biết bài nào cần đọc lại.
7.13.2 — Phần A: Quiz (20 điểm, mỗi câu 2 điểm)
Câu 1. AddDbContext<CrmDbContext>() đăng ký CrmDbContext với lifetime nào, và vì sao đó là lựa chọn đúng cho ứng dụng web? A. Singleton, để dùng chung kết nối B. Scoped, để mỗi request có một change tracker riêng C. Transient, để mỗi repository có context riêng D. Tuỳ provider database
Đáp án câu 1
B. Mỗi request một DbContext: các repository trong cùng request dùng chung nó nên SaveChangesAsync ghi mọi thay đổi trong một lần, và context được dispose khi request kết thúc. Singleton làm change tracker phình mãi và trả dữ liệu cũ; Transient làm các repository trong cùng request không chia sẻ được thay đổi. Xem bài 7.4.
Câu 2. Đoạn code sau in ra gì?
var s = new ServiceCollection();
s.AddSingleton<IEmail, SmtpEmail>();
s.AddSingleton<IEmail, SendGridEmail>();
var p = s.BuildServiceProvider();
Console.WriteLine(p.GetRequiredService<IEmail>().GetType().Name);
Console.WriteLine(p.GetServices<IEmail>().Count());A. SmtpEmail rồi 1 B. SendGridEmail rồi 2 C. SmtpEmail rồi 2 D. Ném lỗi vì đăng ký trùng
Đáp án câu 2
B. Khi resolve một instance, đăng ký sau cùng thắng; GetServices trả về tất cả theo thứ tự đăng ký. Container không bao giờ báo lỗi vì đăng ký trùng. Xem bài 7.2.
Câu 3. Captive dependency là gì? A. Service không được đăng ký B. Service có lifetime dài giữ tham chiếu tới service có lifetime ngắn hơn, ví dụ Singleton giữ Scoped C. Hai constructor cùng giải được D. Vòng phụ thuộc A cần B, B cần A
Đáp án câu 3
B. Service bị giam sống theo lifetime của service giữ nó, nên một DbContext hay ITenantContext vốn chỉ sống một request bị dùng mãi. Xem bài 7.4 và bài 7.11.
Câu 4. Ứng dụng có một Singleton nhận một Scoped qua constructor. Chỉ bật ValidateOnBuild = true, không bật ValidateScopes. Điều gì xảy ra lúc khởi động? A. Ném lỗi "Cannot consume scoped service ... from singleton ..." B. Không báo lỗi gì C. Ném lỗi "Unable to resolve service" D. Container tự đổi Scoped thành Singleton
Đáp án câu 4
B. ValidateOnBuild chỉ kiểm tra mọi service có dựng được không (bắt thiếu đăng ký). Luật lifetime thuộc về ValidateScopes; chỉ khi bật cả hai thì captive dependency mới nổ ngay lúc build. Bài 2 của bài 7.11 đo điều này trên .NET 10.
Câu 5. Trong Program.cs của ứng dụng web .NET 10, bạn gọi builder.Services.AddScoped<IFoo, Foo>() sau var app = builder.Build();. Kết quả? A. Biên dịch lỗi B. Chạy bình thường, IFoo dùng được C. Ứng dụng ném InvalidOperationException ngay tại dòng đó: danh sách đăng ký đã chỉ đọc D. Chạy bình thường, nhưng request đầu tiên dùng IFoo ném lỗi
Đáp án câu 5
C. Build() khoá builder.Services; thông báo là "The service collection cannot be modified because it is read-only." Xem bài 7.6.
Câu 6. Factory AddSingleton<IReportService>(sp => new ReportService(sp.GetRequiredService<ICustomerRepository>())), với ICustomerRepository là Scoped. sp ở đây là gì? A. Provider của scope đang xin B. Root provider C. Một scope mới tạo riêng cho factory D. null
Đáp án câu 6
B. Factory của Singleton luôn chạy với root provider. Bật ValidateScopes thì lần resolve đầu ném "Cannot resolve scoped service ... from root provider"; tắt kiểm tra thì repository bị giam vĩnh viễn và không bao giờ được dispose. ValidateOnBuild không nhìn vào lambda nên không bắt được lúc khởi động. Xem bài 7.5.
Câu 7. Một Singleton muốn đọc ITenantContext (Scoped, được middleware gán tenant cho request) nên gọi scopeFactory.CreateScope() rồi lấy ITenantContext từ scope đó. Nó nhận được gì? A. Đúng ITenantContext của request hiện tại B. Một ITenantContext mới, chưa có tenant của request C. ITenantContext của request đầu tiên D. Ném lỗi
Đáp án câu 7
B. CreateScope() tạo scope mới, độc lập với scope của request. Với dữ liệu theo request: truyền tham số, đổi service sang Scoped, hoặc đọc IHttpContextAccessor trong phương thức. Xem bài 7.11.
Câu 8. Service Singleton cần thấy giá trị mới khi appsettings.json đổi lúc đang chạy. Tiêm gì? A. IOptions<T> B. IOptionsSnapshot<T> C. IOptionsMonitor<T> và đọc CurrentValue mỗi lần dùng D. IConfiguration rồi tự parse
Đáp án câu 8
C. IOptions<T> không bao giờ đọc lại. IOptionsSnapshot<T> là Scoped nên tiêm vào Singleton là captive dependency (ở Development ứng dụng không khởi động được). Xem bài 7.7.
Câu 9. Đăng ký AddScoped<IValidator, EmailValidator>() và AddKeyedScoped<IValidator, PhoneValidator>("phone"). Constructor nhận IEnumerable<IValidator> thấy bao nhiêu phần tử? A. 0 B. 1 C. 2 D. Ném lỗi
Đáp án câu 9
B. Chỉ EmailValidator. Service có khoá và không khoá nằm ở hai không gian tách biệt, nên một validator lỡ đăng ký có khoá sẽ không bao giờ chạy trong vòng foreach. Xem bài 7.8.
Câu 10. Lớp nghiệp vụ nhận IServiceProvider qua constructor rồi tự GetRequiredService ba service bên trong phương thức. Vấn đề chính là gì? A. Chậm hơn constructor injection B. Constructor không còn cho biết lớp cần gì, test phải dựng container, và thiếu đăng ký chỉ lộ ra lúc chạy C. Không biên dịch được D. Không dùng được với Scoped
Đáp án câu 10
B. Đó là service locator. Chỗ hợp lệ duy nhất để chạm IServiceProvider là composition root (Program.cs), hạ tầng như BackgroundService qua IServiceScopeFactory, và một factory hẹp chọn keyed service. Xem bài 7.9.
7.13.3 — Phần B: Lab (80 điểm)
Dựng solution:
dotnet new console -n Crm.Lab
dotnet new xunit -n Crm.Lab.Tests
dotnet add Crm.Lab.Tests reference Crm.Lab
dotnet add Crm.Lab package Microsoft.Extensions.Hosting
dotnet add Crm.Lab package Microsoft.Extensions.Options.DataAnnotations
dotnet add Crm.Lab.Tests package NSubstituteMicrosoft.Extensions.Hosting cho Host.CreateApplicationBuilder: phiên bản console của WebApplication.CreateBuilder, cũng có container, cấu hình từ appsettings.json và logging. Microsoft.Extensions.Options.DataAnnotations cung cấp ValidateDataAnnotations(); project web có sẵn nó, project console thì phải cài.
Bài 1 — Refactor CustomerService sang DI (Dễ, 25 điểm)
Cho lớp dính chặt sau (cùng ý với bài 7.1):
public class CustomerService
{
private readonly SmtpEmailService _email = new("smtp.company.com", 587);
private readonly PostgresCustomerRepository _repository =
new("Host=db.company.com;Database=crm;Username=crm;Password=...");
public async Task<Customer> CreateCustomerAsync(string name, string email) { /* ... */ }
}Yêu cầu:
- Tạo
ICustomerRepository(cóAddAsync,GetByIdAsync,GetAllAsync) vàIEmailService(cóSendWelcomeEmailAsync). - Viết
InMemoryCustomerRepository(lưu trong bộ nhớ) vàConsoleEmailService(in ra màn hình thay cho gửi thật). CustomerServicenhậnICustomerRepository,IEmailService,ILogger<CustomerService>qua constructor;CreateCustomerAsync(CreateCustomerRequest request, CancellationToken ct)kiểm tra tên không rỗng và email có@, rồi lưu và gửi email.Program.csdùngHost.CreateApplicationBuilder, đăng ký với lifetime phù hợp, tạo một scope và tạo một khách hàng.
Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
CustomerService không còn new service nào, không còn chuỗi kết nối hay host trong code | 8 |
InMemoryCustomerRepository đăng ký Singleton (giữ dữ liệu) và an toàn đa luồng (ConcurrentDictionary) | 6 |
CustomerService đăng ký Scoped hoặc Transient, resolve trong một scope tạo bằng CreateScope() | 5 |
dotnet run in dòng email và dòng "Đã tạo" như output mẫu | 6 |
Lời giải — Bài 1
Crm.Lab/Customers.cs:
using System.Collections.Concurrent;
using Microsoft.Extensions.Logging;
namespace Crm.Lab;
public record Customer(Guid Id, string Name, string Email);
public record CreateCustomerRequest(string Name, string Email);
public interface ICustomerRepository
{
Task AddAsync(Customer customer, CancellationToken ct = default);
Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default);
Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct = default);
}
public interface IEmailService
{
Task SendWelcomeEmailAsync(string email, CancellationToken ct = default);
}
// Lưu trong bộ nhớ: Singleton nên phải an toàn khi nhiều luồng dùng cùng lúc
public sealed class InMemoryCustomerRepository : ICustomerRepository
{
private readonly ConcurrentDictionary<Guid, Customer> _customers = new();
public Task AddAsync(Customer customer, CancellationToken ct = default)
{
_customers[customer.Id] = customer;
return Task.CompletedTask;
}
public Task<Customer?> GetByIdAsync(Guid id, CancellationToken ct = default)
=> Task.FromResult(_customers.GetValueOrDefault(id));
public Task<IReadOnlyList<Customer>> GetAllAsync(CancellationToken ct = default)
=> Task.FromResult<IReadOnlyList<Customer>>(_customers.Values.ToList());
}
// Thay cho SMTP thật khi học: chỉ in ra màn hình
public sealed class ConsoleEmailService : IEmailService
{
public Task SendWelcomeEmailAsync(string email, CancellationToken ct = default)
{
Console.WriteLine($"[email] Gửi thư chào mừng tới {email}");
return Task.CompletedTask;
}
}
public class CustomerService(
ICustomerRepository repository,
IEmailService emailService,
ILogger<CustomerService> logger)
{
public async Task<Customer> CreateCustomerAsync(CreateCustomerRequest request, CancellationToken ct = default)
{
if (string.IsNullOrWhiteSpace(request.Name))
throw new ArgumentException("Tên không được rỗng", nameof(request));
if (!request.Email.Contains('@'))
throw new ArgumentException("Email không hợp lệ", nameof(request));
var customer = new Customer(Guid.NewGuid(), request.Name.Trim(), request.Email.Trim());
await repository.AddAsync(customer, ct);
await emailService.SendWelcomeEmailAsync(customer.Email, ct);
logger.LogInformation("Đã tạo khách hàng {CustomerId}", customer.Id);
return customer;
}
}Crm.Lab/Program.cs (phần Bài 1; Bài 3 thêm vài dòng vào đây):
using Crm.Lab;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
var builder = Host.CreateApplicationBuilder(args);
builder.Services.AddSingleton<ICustomerRepository, InMemoryCustomerRepository>(); // giữ dữ liệu: Singleton
builder.Services.AddSingleton<IEmailService, ConsoleEmailService>(); // không trạng thái
builder.Services.AddScoped<CustomerService>();
using var host = builder.Build();
using (var scope = host.Services.CreateScope())
{
var customers = scope.ServiceProvider.GetRequiredService<CustomerService>();
var created = await customers.CreateCustomerAsync(new("Nguyễn Văn An", "[email protected]"));
Console.WriteLine($"Đã tạo: {created.Name} <{created.Email}>");
}Output khi chạy trên .NET 10 (Guid khác mỗi lần; dòng info: có thể xen ở vị trí khác vì logger ghi ra màn hình ở luồng nền):
[email] Gửi thư chào mừng tới [email protected]
Đã tạo: Nguyễn Văn An <[email protected]>
info: Crm.Lab.CustomerService[0]
Đã tạo khách hàng cde7a7b0-63b5-42a1-ab2c-6cdec507935aVì sao InMemoryCustomerRepository là Singleton: nếu đăng ký Scoped, mỗi scope có một Dictionary rỗng mới, khách hàng tạo ở request này biến mất ở request sau. Singleton thì nhiều request dùng chung, nên phải là ConcurrentDictionary (bài 7.4).
Bài 2 — Unit test và kiểm chứng lifetime (Trung bình, 25 điểm)
- Trong
Crm.Lab.Tests, viết test choCustomerService.CreateCustomerAsyncbằng NSubstitute: một test cho trường hợp hợp lệ (đã lưu đúng một lần, đã gửi email đúng một lần tới đúng địa chỉ), một test cho email sai (némArgumentException, không lưu, không gửi). - Viết một test dựng
ServiceCollectionđể chứng minh: Scoped cùng instance trong một scope, khác instance giữa hai scope; Transient luôn là instance mới. DùngReferenceEquals.
Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
Test hợp lệ kiểm tra cả kết quả trả về, lời gọi AddAsync và SendWelcomeEmailAsync | 8 |
| Test email sai kiểm tra cả ngoại lệ lẫn việc không gọi repository và email | 7 |
| Test lifetime có đủ ba khẳng định (cùng scope, khác scope, transient) | 6 |
Test không dựng container cho CustomerService (dùng bản giả qua constructor) và dotnet test xanh | 4 |
Lời giải — Bài 2
Crm.Lab.Tests/CustomerServiceTests.cs:
using Crm.Lab;
using Microsoft.Extensions.Logging.Abstractions;
using NSubstitute;
namespace Crm.Lab.Tests;
public class CustomerServiceTests
{
private readonly ICustomerRepository _repository = Substitute.For<ICustomerRepository>();
private readonly IEmailService _emailService = Substitute.For<IEmailService>();
private readonly CustomerService _sut;
public CustomerServiceTests()
=> _sut = new CustomerService(_repository, _emailService, NullLogger<CustomerService>.Instance);
[Fact]
public async Task CreateCustomerAsync_ValidRequest_SavesAndSendsWelcomeEmail()
{
var request = new CreateCustomerRequest("Nguyễn Văn An", "[email protected]");
var result = await _sut.CreateCustomerAsync(request);
Assert.Equal("[email protected]", result.Email);
await _repository.Received(1).AddAsync(
Arg.Is<Customer>(c => c.Email == "[email protected]"), Arg.Any<CancellationToken>());
await _emailService.Received(1).SendWelcomeEmailAsync("[email protected]", Arg.Any<CancellationToken>());
}
[Fact]
public async Task CreateCustomerAsync_InvalidEmail_ThrowsAndDoesNothing()
{
var request = new CreateCustomerRequest("An", "khong-co-a-cong");
await Assert.ThrowsAsync<ArgumentException>(() => _sut.CreateCustomerAsync(request));
await _repository.DidNotReceiveWithAnyArgs().AddAsync(default!, default);
await _emailService.DidNotReceiveWithAnyArgs().SendWelcomeEmailAsync(default!, default);
}
}DidNotReceiveWithAnyArgs() khẳng định phương thức không được gọi với bất kỳ tham số nào; các giá trị default! chỉ để cú pháp hợp lệ.
Crm.Lab.Tests/LifetimeTests.cs:
using Microsoft.Extensions.DependencyInjection;
namespace Crm.Lab.Tests;
public class LifetimeTests
{
private sealed class ScopedThing;
private sealed class TransientThing;
[Fact]
public void Scoped_SameInsideScope_DifferentAcrossScopes_TransientAlwaysNew()
{
var services = new ServiceCollection();
services.AddScoped<ScopedThing>();
services.AddTransient<TransientThing>();
using var root = services.BuildServiceProvider(new ServiceProviderOptions { ValidateScopes = true });
using var scope1 = root.CreateScope();
using var scope2 = root.CreateScope();
var a = scope1.ServiceProvider.GetRequiredService<ScopedThing>();
var b = scope1.ServiceProvider.GetRequiredService<ScopedThing>();
var c = scope2.ServiceProvider.GetRequiredService<ScopedThing>();
Assert.True(ReferenceEquals(a, b)); // cùng scope: cùng instance
Assert.False(ReferenceEquals(a, c)); // khác scope: khác instance
Assert.False(ReferenceEquals(
scope1.ServiceProvider.GetRequiredService<TransientThing>(),
scope1.ServiceProvider.GetRequiredService<TransientThing>())); // transient: luôn mới
}
}Cả ba test xanh trên .NET 10. Để ý test của CustomerService không cần container nào: đó chính là lợi ích của constructor injection mà bài 7.1 hứa.
Bài 3 — Options pattern cho CrmSettings (Khá, 30 điểm)
Mỗi nhân viên kinh doanh (agent) chỉ được giữ tối đa MaxLeadsPerAgent lead đang mở.
- Tạo lớp
CrmSettingsvới hằngSectionName = "Crm"và thuộc tínhMaxLeadsPerAgent, giá trị hợp lệ từ 1 tới 1000. Thêm"Crm": { "MaxLeadsPerAgent": 50 }vàoappsettings.json(đặtCopyToOutputDirectoryđể file được chép khi build). - Tạo
ILeadRepositoryvớiTask<int> CountOpenLeadsAsync(int agentId, CancellationToken ct)và một bản in-memory trả dữ liệu mẫu. - Tạo
LeadServicenhậnILeadRepositoryvàIOptions<CrmSettings>, cóTask<bool> CanAssignLeadAsync(int agentId, CancellationToken ct): trảtruekhi số lead đang mở nhỏ hơn hạn mức. - Đăng ký Options với
ValidateDataAnnotations()vàValidateOnStart(), gọiawait host.StartAsync()trước khi dùng service. - Viết test:
CanAssignLeadAsyncđúng ở ba trường hợp dưới, bằng và trên hạn mức; và một test chứng minh cấu hìnhMaxLeadsPerAgent = 0làm host không khởi động được.
Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
CrmSettings có SectionName và [Range(1, 1000)]; không chỗ nào đọc IConfiguration["Crm:..."] trực tiếp | 6 |
CanAssignLeadAsync đúng ở ranh giới (bằng hạn mức thì false) và có test [Theory] cho ba trường hợp | 10 |
| Cấu hình sai làm ứng dụng dừng lúc khởi động, có test tự động chứng minh | 10 |
dotnet run với cấu hình mẫu in kết quả đúng cho hai agent | 4 |
Lời giải — Bài 3
Crm.Lab/Leads.cs:
using System.ComponentModel.DataAnnotations;
using Microsoft.Extensions.Options;
namespace Crm.Lab;
public sealed class CrmSettings
{
public const string SectionName = "Crm";
[Range(1, 1000)]
public int MaxLeadsPerAgent { get; init; }
}
public interface ILeadRepository
{
Task<int> CountOpenLeadsAsync(int agentId, CancellationToken ct = default);
}
public sealed class InMemoryLeadRepository : ILeadRepository
{
public Task<int> CountOpenLeadsAsync(int agentId, CancellationToken ct = default)
=> Task.FromResult(agentId == 1 ? 50 : 3); // dữ liệu mẫu
}
public class LeadService(ILeadRepository leads, IOptions<CrmSettings> options)
{
public async Task<bool> CanAssignLeadAsync(int agentId, CancellationToken ct = default)
{
var open = await leads.CountOpenLeadsAsync(agentId, ct);
return open < options.Value.MaxLeadsPerAgent;
}
}Crm.Lab/appsettings.json:
{
"Logging": { "LogLevel": { "Default": "Information", "Microsoft": "Warning" } },
"Crm": { "MaxLeadsPerAgent": 50 }
}Trong Crm.Lab.csproj, thêm để file được chép vào thư mục chạy:
<ItemGroup>
<None Update="appsettings.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>Thêm vào Program.cs của Bài 1, trước builder.Build():
builder.Services.AddOptions<CrmSettings>()
.Bind(builder.Configuration.GetSection(CrmSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
builder.Services.AddSingleton<ILeadRepository, InMemoryLeadRepository>();
builder.Services.AddScoped<LeadService>();Sau using var host = builder.Build(); thêm await host.StartAsync(); (đây là lúc ValidateOnStart chạy), và trong khối using (var scope ...):
var leads = scope.ServiceProvider.GetRequiredService<LeadService>();
Console.WriteLine($"Agent 1 nhận thêm lead được không? {await leads.CanAssignLeadAsync(1)}");
Console.WriteLine($"Agent 2 nhận thêm lead được không? {await leads.CanAssignLeadAsync(2)}");Cuối file thêm await host.StopAsync();. Output trên .NET 10 (agent 1 có 50 lead, đúng bằng hạn mức):
Agent 1 nhận thêm lead được không? False
Agent 2 nhận thêm lead được không? TrueChạy với cấu hình sai qua biến môi trường (Git Bash: Crm__MaxLeadsPerAgent=0 dotnet run):
fail: Microsoft.Extensions.Hosting.Internal.Host[11]
Hosting failed to start
Microsoft.Extensions.Options.OptionsValidationException: DataAnnotation validation failed for 'CrmSettings' members: 'MaxLeadsPerAgent' with the error: 'The field MaxLeadsPerAgent must be between 1 and 1000.'.Crm.Lab.Tests/LeadServiceTests.cs:
using Crm.Lab;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using Microsoft.Extensions.Options;
using NSubstitute;
namespace Crm.Lab.Tests;
public class LeadServiceTests
{
[Theory]
[InlineData(2, true)] // dưới hạn mức
[InlineData(3, false)] // đúng bằng hạn mức: không nhận thêm
[InlineData(4, false)] // vượt hạn mức
public async Task CanAssignLeadAsync_ComparesOpenLeadsWithLimit(int openLeads, bool expected)
{
var leads = Substitute.For<ILeadRepository>();
leads.CountOpenLeadsAsync(7, Arg.Any<CancellationToken>()).Returns(openLeads);
var options = Options.Create(new CrmSettings { MaxLeadsPerAgent = 3 });
var sut = new LeadService(leads, options);
Assert.Equal(expected, await sut.CanAssignLeadAsync(7));
}
[Fact]
public async Task InvalidSettings_HostFailsToStart()
{
var builder = Host.CreateApplicationBuilder();
builder.Configuration.AddInMemoryCollection(new Dictionary<string, string?>
{
["Crm:MaxLeadsPerAgent"] = "0",
});
builder.Services.AddOptions<CrmSettings>()
.Bind(builder.Configuration.GetSection(CrmSettings.SectionName))
.ValidateDataAnnotations()
.ValidateOnStart();
using var host = builder.Build();
var ex = await Assert.ThrowsAsync<OptionsValidationException>(() => host.StartAsync());
Assert.Contains("MaxLeadsPerAgent", ex.Message);
}
}Options.Create(...) bọc một đối tượng thành IOptions<T>: test không cần container hay file cấu hình. Toàn bộ 7 test của ba bài lab xanh trên .NET 10.
Mở rộng (không chấm điểm): đổi IOptions<CrmSettings> thành IOptionsMonitor<CrmSettings>, giữ app chạy (ví dụ thêm await host.WaitForShutdownAsync();), sửa MaxLeadsPerAgent trong bin/Debug/net10.0/appsettings.json và in giá trị trong callback OnChange (bài 7.7).
7.13.4 — Rubric và ngưỡng điểm
Tổng điểm = quiz (20) + lab (80, theo bảng tiêu chí từng bài).
| Mức | Tổng điểm | Mô tả |
|---|---|---|
| Chưa đạt | dưới 60 | Lab chưa chạy được ít nhất hai bài, hoặc sai từ 5 câu quiz trở lên. Đọc lại các bài ứng với câu sai trước khi sang Project 1. |
| Đạt | 60 – 74 | Bài 1 và Bài 2 chạy đúng, dotnet test xanh. Bài 3 có thể thiếu test cấu hình sai. Giải thích được đáp án quiz bằng lời của mình. |
| Khá | 75 – 89 | Cả ba bài đủ tiêu chí, kể cả ranh giới "bằng hạn mức" và test host không khởi động. Lifetime của mọi đăng ký có lý do (ghi một dòng chú thích cạnh mỗi Add...). |
| Giỏi | 90 – 100 | Như mức Khá, cộng thêm: bật ValidateScopes và ValidateOnBuild cho host bằng UseDefaultServiceProvider hoặc ServiceProviderOptions và có test dựng container với cả hai kiểm tra (bài 7.11), hoặc làm phần mở rộng của Bài 3. |
7.13.5 — Tóm tắt Module 7
| Bài | Điều cần mang theo |
|---|---|
| 7.1 | Vấn đề là lớp tự new, không phải thiếu interface; chỉ tiêm thứ có I/O, thời gian, ngẫu nhiên |
| 7.2 | ServiceCollection là công thức, ServiceProvider là container; đăng ký sau thắng; container chỉ dispose thứ nó tạo |
| 7.3 | Constructor injection là mặc định; method injection ở middleware, minimal API, [FromServices] |
| 7.4 | Lifetime là về Dispose; Singleton không nhận Scoped; DbContext bị giam trả dữ liệu cũ |
| 7.5 | Factory Singleton nhận root provider; TryAdd cho thư viện; decorator bằng factory |
| 7.6 | Mọi đăng ký trước Build(); không BuildServiceProvider() thủ công (ASP0000); thứ tự middleware là ngữ nghĩa |
| 7.7 | ValidateOnStart(); Snapshot là Scoped, Singleton dùng Monitor; tắt reload bằng hostBuilder:reloadConfigOnChange |
| 7.8 | Khoá dùng enum; IEnumerable<T> không thấy service có khoá |
| 7.9 | Service locator, static mutable, facade giấu phụ thuộc đều là giấu phụ thuộc |
| 7.10 | Decorate sau nằm ngoài; module theo tính năng; test resolve thật |
| 7.11 | CreateScope() không phải scope của request; bật cả ValidateScopes và ValidateOnBuild |
| 7.12 | IOptions không đọc lại; biến môi trường thắng file và không đọc lại |
Tham khảo
- Dependency injection in .NET
- Dependency injection guidelines
- .NET Generic Host — Host.CreateApplicationBuilder
- Options pattern in .NET
- NSubstitute documentation
Điều hướng
- Bài trước: 7.12 — Đổi cấu hình mà ứng dụng không nhận
- Bài tiếp theo: Project 1 — Inventory System (Console + API)
- Về module: Trang mục lục