5.12 - Ôn tập và đánh giá Module 5
Tóm tắt: Bài ôn tập cuối Module 5 gồm ba phần: 10 câu quiz bám vào những cái bẫy chính của module (SRP đo bằng người yêu cầu thay đổi, covariance, deferred execution,
ToList()đặt sai chỗ,Singlelấy hai dòng, rò rỉ do event, lambda không gỡ được, extension trênnull, giới hạn của nullable reference types, tuple khi serialize), bốn bài lab 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.
- Tách một god class theo người yêu cầu thay đổi mà không đổi hành vi của chương trình.
- Viết truy vấn LINQ lọc, nhóm, nối và tổng hợp ra đúng kết quả mong đợi.
- Viết một kiểu generic và một extension method có kiểm tra đầu vào.
- Sửa một rò rỉ do event và viết test xUnit chứng minh đã sửa.
Cần biết trước: toàn bộ Module 5, từ bài 5.1 tới bài 5.11. Bài 5.9 là bài đọc thêm, quiz và lab không hỏi tới.
Nội dung bài học
5.12.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 project console (
dotnet new console), riêng Bài 4 cần thêm một project xUnit như bài 0.4. Chạy được, ra đúng output mong đợi, rồi mới mở lời giải để so. - Tự chấm theo rubric ở mục 5.12.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.
5.12.2 — Phần A: Quiz (20 điểm, mỗi câu 2 điểm)
Câu 1. Lớp InvoiceService dài 600 dòng. Toàn bộ là quy tắc tính thuế, và chỉ đổi khi luật thuế đổi. Theo SRP, kết luận nào đúng? A. Phải tách, vì quá 300 dòng B. Không cần tách, vì chỉ có một lý do để thay đổi C. Phải tách mỗi phương thức thành một lớp D. Phải thêm interface IInvoiceService
Đáp án câu 1
B. SRP đo bằng "ai yêu cầu thay đổi", không đo bằng số dòng. Một nguồn thay đổi duy nhất (luật thuế) nghĩa là lớp đang gắn kết tốt; tách ra chỉ khiến mỗi lần luật đổi phải sửa nhiều file. Xem bài 5.1.
Câu 2. Dòng nào lỗi biên dịch? (Dog kế thừa Animal)
List<Dog> dogs = new();
IEnumerable<Animal> a = dogs; // (1)
List<Animal> b = dogs; // (2)
IReadOnlyList<Animal> c = dogs; // (3)A. Chỉ (1) B. Chỉ (2) C. (2) và (3) D. Cả ba
Đáp án câu 2
B. IEnumerable<out T> và IReadOnlyList<out T> là covariant vì chỉ trả T ra, nên gán từ Dog sang Animal an toàn. List<T> vừa đọc vừa ghi nên bất biến: nếu cho gán, bạn sẽ Add được một Cat vào danh sách chó. Lỗi là CS0029. Xem bài 5.2.
Câu 3. Đoạn sau in ra gì?
int calls = 0;
var q = Enumerable.Range(1, 5).Where(x => { calls++; return x % 2 == 1; });
var count = q.Count();
var first = q.First();
Console.WriteLine(calls);A. 5 B. 6 C. 10 D. 0
Đáp án câu 3
B. Where chạy trễ: khai báo không gọi lambda lần nào. Count() duyệt hết 5 phần tử (5 lần), First() duyệt lại từ đầu và dừng ngay ở phần tử 1 vốn đã lẻ (thêm 1 lần). Tổng 6. Xem bài 5.3.
Câu 4. db.Customers.ToList().Where(c => c.Revenue > 50_000_000m).Take(20).ToList() gửi câu SQL nào xuống database?
A. SELECT ... FROM "Customers" WHERE "Revenue" > 50000000.0 LIMIT 20 B. SELECT ... FROM "Customers" LIMIT 20 C. SELECT ... FROM "Customers" không có WHERE, không có LIMIT D. Không gửi câu nào, vì chưa gọi ToListAsync
Đáp án câu 4
C. ToList() đầu tiên chạy SQL ngay và kéo cả bảng về. Where và Take sau đó là bản của IEnumerable, chạy trong bộ nhớ. Đổi chỗ ToList() xuống cuối thì Where và Take mới thành WHERE và LIMIT. Xem bài 5.3.
Câu 5. Vì sao SingleOrDefault(c => c.Email == email) sinh ra LIMIT 2 chứ không phải LIMIT 1?
A. Do lỗi của EF Core B. Để kiểm tra có dòng thứ hai hay không, có thì ném ngoại lệ C. Để lấy dòng thứ hai nếu dòng đầu bị khoá D. LIMIT 2 nhanh hơn LIMIT 1 trên PostgreSQL
Đáp án câu 5
B. Single hứa có đúng một kết quả. Muốn biết có vi phạm lời hứa hay không, nó phải thử lấy dòng thứ hai. Đổi lại, bạn được một phép kiểm tra dữ liệu trùng chạy liên tục. Xem bài 5.3.
Câu 6. Một service singleton có event; mỗi request tạo một object đăng ký += vào event đó và không bao giờ -=. Vì sao bộ nhớ tăng mãi?
A. Object đăng ký giữ tham chiếu tới singleton B. Singleton (qua delegate trong event) giữ tham chiếu tới mọi object đã đăng ký C. GC không chạy khi có event D. Event tự sao chép dữ liệu của mỗi object
Đáp án câu 6
B. Delegate tạo bởi += giữ Target là object chứa phương thức handler. Event của singleton giữ các delegate đó, nên bên phát giữ sống bên nghe. Singleton sống suốt ứng dụng, nên không object nào được thu gom. Xem bài 5.4 và bài 5.11.
Câu 7. Sau đoạn code này, event còn bao nhiêu handler?
pub.Notified += (s, e) => Log(e);
pub.Notified -= (s, e) => Log(e);A. 0 B. 1 C. 2 D. Lỗi biên dịch
Đáp án câu 7
B. Hai lambda viết giống hệt nhau vẫn là hai delegate khác nhau (khác phương thức do trình biên dịch sinh ra), nên -= không tìm thấy gì để gỡ, và không báo lỗi. Muốn gỡ được thì lưu lambda vào biến hoặc dùng phương thức có tên. Xem bài 5.4.
Câu 8. Cho public static bool IsBlank(this string? s) => string.IsNullOrWhiteSpace(s); và string? name = null;. Gọi name.IsBlank() thì sao?
A. Ném NullReferenceException tại dòng gọi B. Trả về true C. Lỗi biên dịch vì không gọi được phương thức trên null D. Trả về false
Đáp án câu 8
B. Extension method chỉ là phương thức static: name.IsBlank() được dịch thành StringExtensions.IsBlank(null), không có lời gọi nào trên instance. Lưu ý ngược lại: nếu đặt tên extension trùng phương thức có sẵn của kiểu (như Normalize của string), phương thức của class thắng và extension không bao giờ được gọi. Xem bài 5.5.
Câu 9. Lớp có public string Name { get; set; } = null!;. Chạy JsonSerializer.Deserialize<CustomerDto>("{}")!.Name.Length. Điều gì xảy ra?
A. Trình biên dịch báo lỗi vì Name có thể null B. System.Text.Json ném JsonException vì thiếu Name C. NullReferenceException lúc chạy, không có cảnh báo nào lúc biên dịch D. In ra 0
Đáp án câu 9
C. Nullable reference types chỉ là phân tích tĩnh, không sinh kiểm tra lúc chạy. = null! là lời hứa với trình biên dịch nên nó im lặng; deserializer không quan tâm chú thích nullable nên để Name là null. Dùng required thì mới có JsonException (đáp án B). Xem bài 5.6.
Câu 10. JsonSerializer.Serialize(GetStats()) với static (decimal Total, int Count) GetStats() => (100m, 3); in ra gì (tuỳ chọn mặc định)?
A. {"Total":100,"Count":3} B. {"Item1":100,"Item2":3} C. {} D. [100,3]
Đáp án câu 10
C. Tên Total, Count chỉ tồn tại lúc biên dịch; kiểu thật là ValueTuple<decimal, int> với hai field Item1, Item2. System.Text.Json mặc định bỏ qua field, nên ra object rỗng. Bật IncludeFields thì ra B. Hợp đồng API dùng record. Xem bài 5.8.
5.12.3 — Phần B: Lab (80 điểm)
Bài 1 — Tách god class theo người yêu cầu thay đổi (Trung bình, 20 điểm)
Lớp CustomerService dưới đây làm bốn việc cho bốn nhóm người: tạo và tìm khách (bán hàng), tính điểm thành viên (marketing), xuất CSV (kế toán), gửi email chào mừng (marketing, theo chiến dịch). Tách nó theo người yêu cầu thay đổi.
// Customer.cs
public class Customer
{
public int Id { get; init; }
public required string Name { get; set; }
public required string Email { get; set; }
public decimal TotalSpent { get; set; }
public int LoyaltyPoints { get; set; }
}
// CustomerService.cs
// God class: CRUD, điểm thành viên, xuất CSV, gửi email trong cùng một lớp
public class CustomerService
{
private readonly List<Customer> _customers = [];
private int _nextId = 1;
public Customer Create(string name, string email)
{
var customer = new Customer { Id = _nextId++, Name = name, Email = email };
_customers.Add(customer);
Console.WriteLine($"[email] Gửi tới {email}: Chào mừng {name} đến với cửa hàng!");
return customer;
}
public Customer? Find(int id) => _customers.FirstOrDefault(c => c.Id == id);
public void AddPurchase(int id, decimal amount)
{
var customer = Find(id) ?? throw new KeyNotFoundException($"Không có khách {id}");
customer.TotalSpent += amount;
customer.LoyaltyPoints += (int)(amount / 100_000m); // 1 điểm mỗi 100.000đ
if (customer.TotalSpent >= 3_000_000m)
customer.LoyaltyPoints += 5; // thưởng khi tổng chi tiêu từ 3 triệu
}
public int GetLoyaltyPoints(int id) => Find(id)?.LoyaltyPoints ?? 0;
public string ExportCsv()
{
var lines = _customers.Select(c => $"{c.Id},{c.Name},{c.Email},{c.TotalSpent}");
return "Id,Name,Email,TotalSpent\n" + string.Join("\n", lines) + "\n";
}
}Code chạy thử:
// Program.cs
var service = new CustomerService();
var an = service.Create("Nguyễn Văn An", "[email protected]");
service.AddPurchase(an.Id, 2_500_000m);
service.AddPurchase(an.Id, 800_000m);
service.Create("Trần Thị Bình", "[email protected]");
Console.WriteLine($"Điểm của An: {service.GetLoyaltyPoints(an.Id)}");
Console.Write(service.ExportCsv());Dán vào một file Program.cs thì khối Program.cs đặt lên đầu, khối khai báo đặt xuống cuối (C# bắt buộc, nếu không sẽ báo lỗi CS8803).
Output hiện tại (phải giữ nguyên sau khi tách):
[email] Gửi tới [email protected]: Chào mừng Nguyễn Văn An đến với cửa hàng!
[email] Gửi tới [email protected]: Chào mừng Trần Thị Bình đến với cửa hàng!
Điểm của An: 38
Id,Name,Email,TotalSpent
1,Nguyễn Văn An,[email protected],3300000
2,Trần Thị Bình,[email protected],0Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
Output giống hệt output hiện tại (so bằng mắt hoặc diff) | 6 |
| Luật tính điểm nằm trong một lớp riêng, là hàm thuần (không đọc danh sách khách, không in gì) | 5 |
| Phần xuất CSV và phần gửi email không còn nằm trong lớp quản lý khách hàng | 5 |
| Email được gửi qua một interface handler truyền vào constructor, không dùng event .NET | 4 |
Lời giải — Bài 1
// Customer.cs
public class Customer
{
public int Id { get; init; }
public required string Name { get; set; }
public required string Email { get; set; }
public decimal TotalSpent { get; set; }
public int LoyaltyPoints { get; set; }
}
// LoyaltyRules.cs
// Marketing: luật tính điểm — hàm thuần, test trực tiếp được
public static class LoyaltyRules
{
public static int PointsFor(decimal amount, decimal totalSpentAfter)
{
var points = (int)(amount / 100_000m); // 1 điểm mỗi 100.000đ
if (totalSpentAfter >= 3_000_000m) points += 5; // thưởng khi tổng chi tiêu từ 3 triệu
return points;
}
}
// CustomerCsvExporter.cs
// Kế toán: định dạng file xuất
public static class CustomerCsvExporter
{
public static string Export(IEnumerable<Customer> customers)
{
var lines = customers.Select(c => $"{c.Id},{c.Name},{c.Email},{c.TotalSpent}");
return "Id,Name,Email,TotalSpent\n" + string.Join("\n", lines) + "\n";
}
}
// WelcomeEmailSender.cs
// Marketing: email chào mừng — nhận qua interface, không dùng event .NET
public interface ICustomerCreatedHandler { void Handle(Customer customer); }
public class WelcomeEmailSender : ICustomerCreatedHandler
{
public void Handle(Customer customer)
=> Console.WriteLine($"[email] Gửi tới {customer.Email}: Chào mừng {customer.Name} đến với cửa hàng!");
}
// CustomerService.cs
// Bán hàng / chăm sóc khách hàng: tạo khách, ghi nhận mua hàng
public class CustomerService(IEnumerable<ICustomerCreatedHandler> createdHandlers)
{
private readonly List<Customer> _customers = [];
private int _nextId = 1;
public IReadOnlyList<Customer> All => _customers;
public Customer Create(string name, string email)
{
var customer = new Customer { Id = _nextId++, Name = name, Email = email };
_customers.Add(customer);
foreach (var handler in createdHandlers) handler.Handle(customer);
return customer;
}
public Customer? Find(int id) => _customers.FirstOrDefault(c => c.Id == id);
public void AddPurchase(int id, decimal amount)
{
var customer = Find(id) ?? throw new KeyNotFoundException($"Không có khách {id}");
customer.TotalSpent += amount;
customer.LoyaltyPoints += LoyaltyRules.PointsFor(amount, customer.TotalSpent);
}
}// Program.cs
var service = new CustomerService([new WelcomeEmailSender()]);
var an = service.Create("Nguyễn Văn An", "[email protected]");
service.AddPurchase(an.Id, 2_500_000m);
service.AddPurchase(an.Id, 800_000m);
service.Create("Trần Thị Bình", "[email protected]");
Console.WriteLine($"Điểm của An: {service.Find(an.Id)?.LoyaltyPoints ?? 0}");
Console.Write(CustomerCsvExporter.Export(service.All));Output của bản đã tách giống hệt bản gốc (đã so bằng diff).
Vì sao tách như vậy:
- Marketing đổi luật điểm thì chỉ sửa
LoyaltyRules, và test của nó không cần dựngCustomerService. - Kế toán đổi cột CSV thì chỉ sửa
CustomerCsvExporter. - Thêm một việc khi tạo khách (ghi nhật ký, tặng voucher) là thêm một lớp cài
ICustomerCreatedHandler, không sửaCustomerService— nguyên tắc mở/đóng ở bài 5.1. Không dùng event .NET vì trong ASP.NET Core handler thường có vòng đời ngắn hơn bên phát (bài 5.11). - Không có ngưỡng số dòng nào ở đây:
CustomerServicevẫn có thể dài lên nếu thêm nghiệp vụ của chính đội bán hàng.
Test cho luật điểm (xUnit):
public class LoyaltyRulesTests
{
[Theory]
[InlineData(2_500_000, 2_500_000, 25)] // chưa tới 3 triệu: chỉ điểm cơ bản
[InlineData(800_000, 3_300_000, 13)] // vượt 3 triệu: 8 + 5 điểm thưởng
public void PointsFor_ReturnsExpectedPoints(decimal amount, decimal totalAfter, int expected)
=> Assert.Equal(expected, LoyaltyRules.PointsFor(amount, totalAfter));
}Bài 2 — Thống kê CRM bằng LINQ (Trung bình, 25 điểm)
Cho mô hình và dữ liệu dưới đây. Viết ba truy vấn LINQ (cú pháp phương thức), mỗi truy vấn in ra đúng output mong đợi.
// Deal.cs
public record Customer(int Id, string Name);
public enum DealStatus { Open, Won, Lost }
public record Deal(int Id, int CustomerId, decimal Value, DealStatus Status, DateOnly? ClosedDate);
public enum LeadStatus { New, Converted, Lost }
public record Lead(int Id, string Source, LeadStatus Status);// Program.cs
List<Customer> customers =
[
new(1, "Công ty An Phát"), new(2, "Bình Minh Food"), new(3, "Cửa hàng Cúc"),
new(4, "Đại Dương Logistics"), new(5, "Én Vàng Travel"),
];
List<Deal> deals =
[
new(1, 1, 120_000_000m, DealStatus.Won, new DateOnly(2026, 1, 15)),
new(2, 1, 45_000_000m, DealStatus.Lost, new DateOnly(2026, 1, 20)),
new(3, 2, 80_000_000m, DealStatus.Won, new DateOnly(2026, 2, 3)),
new(4, 4, 200_000_000m, DealStatus.Won, new DateOnly(2026, 2, 28)),
new(5, 3, 15_000_000m, DealStatus.Won, new DateOnly(2026, 3, 10)),
new(6, 5, 60_000_000m, DealStatus.Open, null),
new(7, 2, 30_000_000m, DealStatus.Won, new DateOnly(2025, 12, 30)),
new(8, 4, 25_000_000m, DealStatus.Won, new DateOnly(2026, 3, 22)),
];
List<Lead> leads =
[
new(1, "Website", LeadStatus.Converted), new(2, "Website", LeadStatus.New),
new(3, "Website", LeadStatus.Lost), new(4, "Website", LeadStatus.Converted),
new(5, "Facebook", LeadStatus.New), new(6, "Facebook", LeadStatus.Converted),
new(7, "Facebook", LeadStatus.Lost), new(8, "Hội chợ", LeadStatus.Converted),
new(9, "Hội chợ", LeadStatus.Converted), new(10, "Hội chợ", LeadStatus.New),
];
// ... ba truy vấn của bạn ở đây ...- Doanh thu deal thắng theo tháng trong năm 2026, sắp theo tháng.
- Top 3 khách hàng theo tổng giá trị deal thắng, in tên khách hàng.
- Tỉ lệ chuyển đổi lead (
Converted/ tổng) theo nguồn, sắp giảm dần.
Output mong đợi (máy định dạng số tiếng Việt):
Tháng 1: 1 deal, 120.000.000
Tháng 2: 2 deal, 280.000.000
Tháng 3: 2 deal, 40.000.000
Đại Dương Logistics: 225.000.000
Công ty An Phát: 120.000.000
Bình Minh Food: 110.000.000
Hội chợ : 2/3 = 67%
Website : 2/4 = 50%
Facebook: 1/3 = 33%Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
| Truy vấn 1 ra đúng ba dòng: loại deal thua, deal đang mở (không có ngày) và deal năm 2025 | 8 |
Truy vấn 2 ra đúng tên và tổng, dùng GroupBy kết hợp Join (hoặc tra từ điển tạo bằng ToDictionary) | 8 |
| Truy vấn 3 ra đúng tỉ lệ, không chia số nguyên (1/3 không được ra 0) | 6 |
Không duyệt cùng một truy vấn chưa ToList() nhiều lần không cần thiết | 3 |
Lời giải — Bài 2
// 1. Doanh thu deal thắng theo tháng trong năm 2026
var monthly = deals
.Where(d => d.Status == DealStatus.Won && d.ClosedDate?.Year == 2026)
.GroupBy(d => d.ClosedDate!.Value.Month) // Where ở trên đã loại deal chưa có ngày
.Select(g => new { Month = g.Key, Revenue = g.Sum(d => d.Value), Deals = g.Count() })
.OrderBy(r => r.Month);
foreach (var m in monthly)
Console.WriteLine($"Tháng {m.Month}: {m.Deals} deal, {m.Revenue:N0}");
// 2. Top 3 khách hàng theo tổng giá trị deal thắng
var top3 = deals
.Where(d => d.Status == DealStatus.Won)
.GroupBy(d => d.CustomerId)
.Select(g => new { CustomerId = g.Key, Total = g.Sum(d => d.Value) })
.Join(customers, x => x.CustomerId, c => c.Id, (x, c) => new { c.Name, x.Total })
.OrderByDescending(x => x.Total)
.Take(3);
foreach (var t in top3)
Console.WriteLine($"{t.Name}: {t.Total:N0}");
// 3. Tỉ lệ chuyển đổi lead theo nguồn
var conversion = leads
.GroupBy(l => l.Source)
.Select(g => new
{
Source = g.Key,
Total = g.Count(),
Converted = g.Count(l => l.Status == LeadStatus.Converted),
})
.Select(x => new { x.Source, x.Total, x.Converted, Rate = (double)x.Converted / x.Total })
.OrderByDescending(x => x.Rate);
foreach (var c in conversion)
Console.WriteLine($"{c.Source,-8}: {c.Converted}/{c.Total} = {c.Rate:P0}");Ba điểm hay sai:
d.ClosedDate?.Year == 2026vừa lọc năm vừa loại deal không có ngày (null == 2026làfalse). Dấu!ở dòngGroupBylà trường hợp dùng!chấp nhận được ở bài 5.6: bạn vừa kiểm tra bằng cách trình biên dịch không theo dõi được qua hai lambda.- Deal 7 của Bình Minh Food đóng năm 2025 nên không có trong truy vấn 1, nhưng vẫn tính vào truy vấn 2 (đề không giới hạn năm): 80 + 30 = 110 triệu.
x.Converted / x.Totalvới hai sốintlà phép chia nguyên, 1/3 ra 0. Ép một vế sangdoubletrước khi chia.
Cùng ba truy vấn này viết trên DbContext thay cho List thì EF Core dịch được sang SQL GROUP BY, JOIN — bạn sẽ làm ở Module 13.
Bài 3 — Phân trang generic bằng extension method (Trung bình, 20 điểm)
Viết:
record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount)có thêm hai thuộc tính tính toánTotalPagesvàHasNextPage.- Extension method
ToPagedResult<T>(this IEnumerable<T> source, int page, int pageSize):pagevàpageSizenhỏ hơn 1 thì némArgumentOutOfRangeException.
Chạy với đoạn kiểm tra sau:
// Lead.cs
public record Lead(int Id, string Name);// Program.cs
var leads = Enumerable.Range(1, 23).Select(i => new Lead(i, $"Lead {i}")).ToList();
var page1 = leads.ToPagedResult(page: 1, pageSize: 10);
Console.WriteLine($"Trang {page1.Page}/{page1.TotalPages}: {page1.Items.Count} lead, đầu tiên {page1.Items[0].Name}, còn trang sau: {page1.HasNextPage}");
var page3 = leads.ToPagedResult(page: 3, pageSize: 10);
Console.WriteLine($"Trang {page3.Page}/{page3.TotalPages}: {page3.Items.Count} lead, đầu tiên {page3.Items[0].Name}, còn trang sau: {page3.HasNextPage}");
var page9 = leads.ToPagedResult(page: 9, pageSize: 10);
Console.WriteLine($"Trang {page9.Page}/{page9.TotalPages}: {page9.Items.Count} lead");
try { leads.ToPagedResult(page: 0, pageSize: 10); }
catch (ArgumentOutOfRangeException ex) { Console.WriteLine(ex.Message); }Output mong đợi:
Trang 1/3: 10 lead, đầu tiên Lead 1, còn trang sau: True
Trang 3/3: 3 lead, đầu tiên Lead 21, còn trang sau: False
Trang 9/3: 0 lead
page ('0') must be greater than or equal to '1'. (Parameter 'page')
Actual value was 0.Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
| Output đúng cả năm dòng, kể cả trang vượt quá (trả danh sách rỗng, không ném lỗi) | 8 |
PagedResult<T> là kiểu generic dùng được cho mọi kiểu phần tử, TotalPages làm tròn lên | 5 |
ToPagedResult là extension method trong lớp static, kiểm tra đầu vào ở đầu hàm | 4 |
| Nguồn dữ liệu không bị duyệt lại nhiều lần khi đếm và cắt trang | 3 |
Lời giải — Bài 3
public record PagedResult<T>(IReadOnlyList<T> Items, int Page, int PageSize, int TotalCount)
{
public int TotalPages => (TotalCount + PageSize - 1) / PageSize; // chia làm tròn lên
public bool HasNextPage => Page < TotalPages;
}
public static class PagingExtensions
{
public static PagedResult<T> ToPagedResult<T>(this IEnumerable<T> source, int page, int pageSize)
{
ArgumentOutOfRangeException.ThrowIfLessThan(page, 1);
ArgumentOutOfRangeException.ThrowIfLessThan(pageSize, 1);
var all = source as IReadOnlyCollection<T> ?? source.ToList(); // tránh duyệt nguồn hai lần
var items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
return new PagedResult<T>(items, page, pageSize, all.Count);
}
}ArgumentOutOfRangeException.ThrowIfLessThan (có từ .NET 8) tự sinh thông báo lỗi có tên tham số, nên output dòng cuối khớp mà không phải tự viết chuỗi.
Dòng source as IReadOnlyCollection<T> ?? source.ToList() xử lý bẫy duyệt nhiều lần ở bài 5.3: nếu source là một truy vấn LINQ chưa chạy, gọi Count() rồi Skip().Take() sẽ chạy nó hai lần. List<T> đã cài IReadOnlyCollection<T> nên không bị sao chép thừa.
Lưu ý: bản này dành cho dữ liệu trong bộ nhớ. Với database, phải phân trang trên IQueryable<T> để Skip/Take thành OFFSET/LIMIT, nếu không sẽ kéo cả bảng về — bài 9.4 làm phần đó.
Bài 4 — Sửa rò rỉ do event và viết test (Khá, 15 điểm)
PriceWatcher sống suốt ứng dụng. Mỗi lần người dùng mở giỏ hàng, chương trình tạo một CartPage mới; trang đóng thì không ai dùng nó nữa.
public class PriceWatcher
{
public event EventHandler<decimal>? PriceChanged;
public int HandlerCount => PriceChanged?.GetInvocationList().Length ?? 0;
public void Change(decimal price) => PriceChanged?.Invoke(this, price);
}
public class CartPage
{
public decimal LastPrice { get; private set; }
public CartPage(PriceWatcher watcher)
{
watcher.PriceChanged += (s, price) => LastPrice = price;
}
}Sửa CartPage để đóng trang (gọi Dispose) thì gỡ đăng ký. Viết hai test xUnit: mở rồi đóng 100 trang thì HandlerCount về 0; trang đang mở vẫn nhận được giá mới.
Tiêu chí chấm:
| Tiêu chí | Điểm |
|---|---|
CartPage cài IDisposable và Dispose gỡ đúng handler đã đăng ký | 6 |
| Không gỡ bằng một lambda viết lại (dùng phương thức có tên hoặc lưu delegate vào field) | 4 |
Hai test chạy dotnet test đều xanh, dùng Assert của xUnit | 5 |
Lời giải — Bài 4
public sealed class CartPage : IDisposable
{
private readonly PriceWatcher _watcher;
public decimal LastPrice { get; private set; }
public CartPage(PriceWatcher watcher)
{
_watcher = watcher;
_watcher.PriceChanged += OnPriceChanged; // nhóm phương thức, gỡ được
}
private void OnPriceChanged(object? sender, decimal price) => LastPrice = price;
public void Dispose() => _watcher.PriceChanged -= OnPriceChanged;
}public class CartPageTests
{
[Fact]
public void Dispose_UnsubscribesFromPriceWatcher()
{
var watcher = new PriceWatcher();
for (int i = 0; i < 100; i++)
{
using var page = new CartPage(watcher);
}
Assert.Equal(0, watcher.HandlerCount);
}
[Fact]
public void LivePage_ReceivesNewPrice()
{
var watcher = new PriceWatcher();
using var page = new CartPage(watcher);
watcher.Change(150_000m);
Assert.Equal(150_000m, page.LastPrice);
}
}Kết quả dotnet test trên .NET 10: cả hai test xanh. Nếu Dispose gỡ bằng (s, price) => LastPrice = price viết lại, test đầu đỏ với Expected: 0, Actual: 100, đúng bẫy ở câu 7 của quiz.
Test thứ hai cần thiết vì một bản "sửa" bỏ hẳn dòng += cũng làm test đầu xanh, nhưng trang không còn nhận giá.
5.12.4 — Rubric và ngưỡng điểm
Tổng điểm = quiz (20) + lab (80). Ngoài điểm theo tiêu chí từng bài, người chấm xem thêm chất lượng chung:
| 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 Module 6. |
| Đạt | 60 – 74 | Bài 1 và Bài 2 ra đúng output. Bài 3 hoặc Bài 4 có thể còn thiếu một tiêu chí (ví dụ chưa kiểm tra đầu vào, chỉ có một test). Giải thích được đáp án quiz bằng lời của mình. |
| Khá | 75 – 89 | Cả bốn bài ra đúng output và đạt các tiêu chí chính. Định danh bằng tiếng Anh, không dùng ! để dập cảnh báo nullable trừ chỗ có lý do ghi bằng comment. |
| Giỏi | 90 – 100 | Như mức Khá, cộng thêm: có test xUnit cho LoyaltyRules và ToPagedResult (giá trị mong đợi tính tay), và giải thích được trong một đoạn ngắn vì sao Bài 1 dùng interface handler thay cho event .NET. |
5.12.5 — Tóm tắt Module 5
| Bài | Điều cần mang theo |
|---|---|
| 5.1 | SRP đo bằng "ai yêu cầu thay đổi"; OCP chỉ đáng khi đã thấy trục mở rộng thật |
| 5.2 | Generic tránh boxing; out ra, in vào; static trong lớp generic tách theo từng kiểu |
| 5.3 | LINQ chạy trễ; ToList() là ranh giới database và bộ nhớ; Single lấy hai dòng |
| 5.4 | event là delegate bị giới hạn quyền; mỗi += cần một -=; lambda viết lại không gỡ được |
| 5.5 | Extension là static với cú pháp đẹp; class luôn thắng; gọi được trên null; C# 14 có khối extension |
| 5.6 | Nullable reference types chỉ là phân tích tĩnh; ! là lời hứa; vẫn phải kiểm tra ở ranh giới |
| 5.7 | Expression là code dạng dữ liệu; hằng số được ghi thẳng vào SQL, biến thành tham số; reflection chậm ở khâu tra cứu |
| 5.8 | Tuple cho nội bộ, record cho hợp đồng; tuple serialize ra {} |
| 5.9 | Đọc thêm: IAsyncEnumerable giữ bộ nhớ ổn định; source generator cho JSON; Span |
| 5.10 | switch, từ điển Func, lớp chiến lược: chọn theo mức độ thay đổi |
| 5.11 | Singleton giữ sống scoped qua event; đảo chiều bằng IServiceScopeFactory |
Tham khảo
- C# documentation — Microsoft Learn
- Language Integrated Query (LINQ)
- Covariance and contravariance in generics
- Nullable reference types
- Tuple types
- Unit testing C# with xUnit
Điều hướng
- Bài trước: 5.11 — Sự cố thực tế: rò rỉ bộ nhớ do event
- Bài tiếp theo: Module 6 — Async Programming
- Về module: Trang mục lục