5.9 - Đọc thêm: IAsyncEnumerable, source generator và Span
Tóm tắt: Bài đọc thêm, không bắt buộc ở lần học đầu: bốn chủ đề C# nâng cao nối tiếp Module 5, kèm điều kiện cụ thể để biết khi nào cần. Hai mục đáng dùng ngay:
IAsyncEnumerable(stream dữ liệu mà không nạp hết vào bộ nhớ — giải quyết đúng lớp bug làm sập container khi export dữ liệu lớn) và source generator có sẵn củaSystem.Text.Json(bỏ reflection khi serialize, chỉ cần thêm một lớp). Hai mục còn lại — expression tree vàSpan— bạn sẽ dùng gián tiếp hàng ngày mà không tự viết: expression tree là thứ khiến_db.Leads.Where(l => ...)dịch được thành SQL, cònSpannằm bên dưới hầu hết API xử lý chuỗi hiện đại.
Mục tiêu bài học
Sau bài này bạn có thể:
- Dùng
IAsyncEnumerableđể stream dữ liệu lớn. - Bật source generator cho JSON serialization.
- Hiểu expression tree và vì sao EF Core cần nó.
- Biết covariance và contravariance giải quyết vấn đề gì.
Cần biết trước: bài 6.2 (async/await, cần cho mục 5.9.1), bài 8.10 (EF Core tối thiểu), bài 5.7 (expression tree), bài 5.2 (covariance). Nếu chưa học Module 6 và Module 8, đọc mục 5.9.3 tới 5.9.5 trước, quay lại mục 5.9.1, 5.9.2 và Bài tập 1, 2 sau.
Nội dung bài học
5.9.1 — IAsyncEnumerable: dùng ngay
Điều kiện kích hoạt: xử lý tập dữ liệu có thể rất lớn.
// Nạp HẾT vào bộ nhớ — bộ nhớ tăng theo số bản ghi;
// vài triệu bản ghi, hoặc bản ghi lớn, có thể làm container giới hạn 512 MiB bị OOM
public async Task<List<Lead>> GetAllAsync(CancellationToken ct)
=> await _db.Leads.ToListAsync(ct);
// Stream từng bản ghi — bộ nhớ là HẰNG SỐ
public async IAsyncEnumerable<Lead> GetAllAsync(
[EnumeratorCancellation] CancellationToken ct = default)
{
await foreach (var lead in _db.Leads.AsAsyncEnumerable().WithCancellation(ct))
yield return lead;
}
// Sử dụng
await foreach (var lead in _service.GetAllAsync(ct))
{
await ProcessAsync(lead, ct);
}Thuộc tính [EnumeratorCancellation] là chi tiết dễ quên nhưng quan trọng: không có nó, CancellationToken truyền qua WithCancellation không tới được phần thân phương thức, nên việc huỷ không có tác dụng.
Đây là công cụ giải quyết đúng lớp bug làm container bị OOM kill khi export dữ liệu (bài 19.12).
Trong ASP.NET Core, trả về IAsyncEnumerable trực tiếp từ action của controller (hoặc từ endpoint Minimal API) cũng stream được kết quả:
[HttpGet("stream")]
public IAsyncEnumerable<LeadDto> Stream(CancellationToken ct)
=> _db.Leads.AsNoTracking().Select(l => new LeadDto(l.Id, l.Name)).AsAsyncEnumerable();5.9.2 — Source generator: dùng cái có sẵn
Điều kiện kích hoạt: serialize JSON trong đường xử lý nóng, hoặc cần tương thích Native AOT.
Bạn không cần viết source generator, nhưng nên dùng cái có sẵn:
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonSerializable(typeof(LeadDto))]
[JsonSerializable(typeof(List<LeadDto>))]
internal partial class AppJsonContext : JsonSerializerContext { }
// Dùng
var json = JsonSerializer.Serialize(lead, AppJsonContext.Default.LeadDto);Trình biên dịch sinh ra code serialize lúc biên dịch thay vì dùng reflection lúc chạy. Lợi ích: nhanh hơn, cấp phát ít hơn, và chạy được dưới Native AOT (bài 19.10).
Đăng ký để ASP.NET Core dùng context này khi đọc và ghi JSON. Minimal API và controller lấy tuỳ chọn JSON từ hai chỗ khác nhau, nên cách đăng ký khác nhau:
// Minimal API (app.MapGet, Results, TypedResults)
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));
// Controller ([ApiController], [HttpGet])
builder.Services.AddControllers().AddJsonOptions(o =>
o.JsonSerializerOptions.TypeInfoResolverChain.Insert(0, AppJsonContext.Default));Ứng dụng dùng cả hai kiểu thì đăng ký cả hai.
Source generator khác biệt với reflection ở một điểm quan trọng: nó hoàn toàn tường minh. Bạn thấy được code sinh ra, gỡ lỗi được, và trình biên dịch kiểm tra được — khác với reflection vốn chỉ lộ lỗi lúc chạy.
5.9.3 — Expression tree
Điều kiện kích hoạt: bạn dùng nó hàng ngày qua EF Core; chỉ cần tự viết khi xây thư viện.
// Delegate — code ĐÃ BIÊN DỊCH, chỉ chạy được
Func<Lead, bool> predicate = l => l.Value > 1000;
// Expression tree — CẤU TRÚC DỮ LIỆU mô tả code, ĐỌC và PHÂN TÍCH được
Expression<Func<Lead, bool>> expression = l => l.Value > 1000;
Console.WriteLine(expression.Body); // (l.Value > 1000)
Console.WriteLine(expression.Body.NodeType); // GreaterThanĐây chính là lý do EF Core dịch được LINQ sang SQL:
// EF Core ĐỌC cây biểu thức và sinh SQL
_db.Leads.Where(l => l.Value > 1000)
// -> SELECT ... FROM "Leads" AS l WHERE l."Value" > 1000.0Và cũng giải thích một lỗi rất hay gặp:
// Hàm C# — EF Core KHÔNG đọc được bên trong
_db.Leads.Where(l => IsValid(l))
// -> InvalidOperationException: could not be translatedWhere của IQueryable nhận Expression<Func<...>>, không phải Func<...>. Một lời gọi phương thức thường là một nút "gọi hàm" trong cây, và EF Core không biết dịch nó thành SQL gì.
Hiểu điều này giúp bạn đọc được thông báo lỗi "could not be translated" và biết ngay phải sửa ở đâu (bài 13.5).
5.9.4 — Span và Memory
Điều kiện kích hoạt: hot path đã đo được là tốn cấp phát; hoặc bạn viết thư viện.
// Cấp phát: Substring tạo chuỗi MỚI
string code = input.Substring(0, 3);
// Không cấp phát: Span trỏ vào bộ nhớ SẴN CÓ
ReadOnlySpan<char> code = input.AsSpan(0, 3);Span<T> là một "cửa sổ" nhìn vào vùng nhớ có sẵn, không sao chép gì. Với code chạy hàng triệu lần, khác biệt về số lần cấp phát là đáng kể.
Hai giới hạn cần biết:
Span<T>làref struct, chỉ sống trên stack: không lưu được vào field của class, và không giữ được qua mộtawait. Từ C# 13 bạn được khai báo biếnSpantrong phương thứcasync, nhưng chỉ dùng nó giữa hai lầnawait; dùng nó sauawaitlà lỗiCS4007("cannot be preserved across 'await' or 'yield' boundary").- Khi cần giữ vùng nhớ lâu hơn (qua
await, trong field), dùngMemory<T>— linh hoạt hơn nhưng chậm hơn một chút.
Thực tế: bạn dùng nó gián tiếp liên tục. int.Parse(ReadOnlySpan<char>), string.Split phiên bản mới, và phần lớn API xử lý chuỗi trong .NET hiện đại đều nhận Span. Tự viết code dùng Span chỉ đáng khi profiler đã chỉ ra một hot path cụ thể.
5.9.5 — Covariance và contravariance
Điều kiện kích hoạt: thiết kế interface generic dùng lại nhiều nơi.
// Covariance (out) — dùng được kiểu CỤ THỂ HƠN ở đầu ra
IEnumerable<Lead> leads = leadList;
IEnumerable<object> objects = leads; // OK vì IEnumerable<out T>
// Contravariance (in) — dùng được kiểu TỔNG QUÁT HƠN ở đầu vào
Action<object> handleObject = o => Console.WriteLine(o);
Action<Lead> handleLead = handleObject; // OK vì Action<in T>// Trong thiết kế của bạn (Lead cài đặt interface IEntity)
public interface IReadOnlyRepository<out T> // chỉ TRẢ VỀ T
{
T? GetById(int id);
IEnumerable<T> GetAll();
}
// Giờ dùng được:
IReadOnlyRepository<Lead> leadRepo = new LeadRepository();
IReadOnlyRepository<IEntity> entityRepo = leadRepo; // hợp lệMột bẫy hay gặp: thêm phương thức Task<T?> GetByIdAsync(int id) vào interface trên sẽ báo lỗi CS1961 (variance không hợp lệ). Task<T> là một class, mà chỉ interface và delegate mới khai được out, nên T nằm trong Task<T> bị coi là vừa vào vừa ra. Repository bất đồng bộ vì vậy thường không covariant. Nếu thật sự cần, trả về IAsyncEnumerable<T>, một interface đã khai out T.
Quy tắc dễ nhớ: out cho kiểu chỉ xuất hiện ở đầu ra, in cho kiểu chỉ xuất hiện ở đầu vào. Nếu kiểu xuất hiện ở cả hai (ví dụ IList<T> vừa đọc vừa ghi), không đánh dấu được — và đó là lý do IList<Lead> không gán được cho IList<object>.
Ít khi bạn cần tự khai báo, nhưng hiểu nó giải thích được vì sao một số phép gán generic hợp lệ còn số khác thì không.
5.9.6 — Thứ tự nên học
| Ưu tiên | Chủ đề | Vì sao |
|---|---|---|
| Cao (sau Module 6) | IAsyncEnumerable | Giải quyết bug OOM khi xử lý dữ liệu lớn |
| Cao | Source generator cho JSON | Thêm một lớp, nhanh hơn ngay |
| Trung bình | Expression tree (hiểu) | Giải thích lỗi "could not be translated" |
| Trung bình | Covariance, contravariance | Giải thích lỗi gán generic |
| Thấp | Tự viết code dùng Span | Chỉ khi profiler chỉ ra hot path |
| Thấp | Tự viết source generator | Cho thư viện |
Sau Module 5, bước tiếp theo là Module 6 — Async Programming.
5.9.7 — Rà lại code của bạn
Danh sách rà soát C# nâng cao
- Endpoint trả danh sách lớn dùng IAsyncEnumerable thay vì ToListAsync.
- Phương thức IAsyncEnumerable có thuộc tính EnumeratorCancellation.
- Đã cân nhắc source generator cho JSON trong đường xử lý nóng.
- Hiểu vì sao gọi hàm C# trong Where của IQueryable gây lỗi.
- Không tự viết code dùng Span khi chưa có số liệu profiler.
- Interface chỉ đọc dùng out để tăng khả năng tái sử dụng.
Bài tập áp dụng
Bài 1 — Stream dữ liệu
Viết một endpoint trả 100.000 bản ghi bằng ToListAsync rồi bằng IAsyncEnumerable, so sánh bộ nhớ đỉnh.
Tiêu chí hoàn thành: bạn đo được cả hai, và biết hai điều kiện để streaming thật sự có tác dụng — thiếu một trong hai thì không.
Gợi ý và lời giải — Bài 1
Gợi ý. Đo cả heap quản lý lẫn RSS đỉnh (bộ nhớ tiến trình thật sự chiếm, PeakWorkingSet64). Hai con số kể hai phần của cùng một câu chuyện. Chạy mỗi cách trong một tiến trình riêng, để số đo của cách này không lẫn vào cách kia.
Lời giải. Không cần dựng endpoint: một console app đo đúng phần đọc dữ liệu. Tạo project, cài gói Npgsql.EntityFrameworkCore.PostgreSQL, bật PostgreSQL bằng docker compose up -d, rồi chạy lần lượt dotnet run -c Release -- seed, -- list, -- stream:
using System.Diagnostics;
using Microsoft.EntityFrameworkCore;
var mode = args.Length > 0 ? args[0] : "list";
var ct = CancellationToken.None;
await using var db = new CrmDbContext();
if (mode == "seed")
{
await db.Database.EnsureDeletedAsync(ct);
await db.Database.EnsureCreatedAsync(ct);
var note = new string('x', 200); // mỗi lead có ghi chú 200 ký tự
for (int batch = 0; batch < 10; batch++)
{
db.Leads.AddRange(Enumerable.Range(1, 10_000).Select(i => new Lead
{
Name = $"Lead {batch * 10_000 + i}", Note = note,
Value = i * 1000m, CreatedAt = DateTime.UtcNow,
}));
await db.SaveChangesAsync(ct);
db.ChangeTracker.Clear();
}
return;
}
var sw = Stopwatch.StartNew();
long total = 0;
if (mode == "list")
{
// A — nạp hết rồi xử lý
var leads = await db.Leads.AsNoTracking().ToListAsync(ct);
foreach (var l in leads) total += l.Name.Length;
GC.KeepAlive(leads);
}
else
{
// B — đọc từng bản ghi
await foreach (var l in db.Leads.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
total += l.Name.Length;
}
var heap = GC.GetTotalMemory(false) / 1024.0 / 1024;
var peak = Process.GetCurrentProcess().PeakWorkingSet64 / 1024.0 / 1024;
Console.WriteLine($"{mode,-7} | heap cuối {heap,6:F1} MB | RSS đỉnh {peak,6:F1} MB | {sw.ElapsedMilliseconds,5} ms | tổng {total:N0}");
public class Lead
{
public int Id { get; set; }
public string Name { get; set; } = "";
public string Note { get; set; } = "";
public decimal Value { get; set; }
public DateTime CreatedAt { get; set; }
}
public class CrmDbContext : DbContext
{
public DbSet<Lead> Leads => Set<Lead>();
protected override void OnConfiguring(DbContextOptionsBuilder options)
=> options.UseNpgsql("Host=localhost;Port=5432;Database=crm_stream_lab;Username=postgres;Password=postgres");
}Kết quả trên .NET 10, EF Core 10, PostgreSQL 16 chạy cùng máy, 100.000 lead (ba lần chạy cho số gần như nhau):
list | heap cuối 55,7 MB | RSS đỉnh 133,9 MB | 799 ms | tổng 988.895
stream | heap cuối 7,7 MB | RSS đỉnh 82,0 MB | 729 ms | tổng 988.895ToListAsync | AsAsyncEnumerable | |
|---|---|---|
| Heap cuối | 55,7 MB | 7,7 MB |
| RSS đỉnh | 133,9 MB | 82,0 MB |
| Thời gian | khoảng 800 ms | khoảng 710 ms |
| Kết quả tính được | 988.895 | 988.895 |
Ba nhận xét:
1. Heap giảm khoảng 7 lần — đó là phần rõ nhất.
ToListAsync giữ 100.000 đối tượng Lead cùng lúc
AsAsyncEnumerable giữ vài chục đối tượng tại một thời điểm
-> phần còn lại bị thu gom ngay trong gen-02. Nhưng RSS chỉ giảm khoảng 40%, không giảm 7 lần.
RSS gồm cả: runtime .NET, mã đã JIT, stack của luồng, bộ đệm mạng của driver
-> phần heap chỉ là một phần của RSS
Với dữ liệu lớn hơn, khoảng cách này giãn ra:
heap của ToListAsync tăng tuyến tính, heap của streaming thì không.3. Thời gian gần như nhau, streaming nhanh hơn chừng 10%.
Cả hai đều phải đọc đủ 100.000 dòng qua cùng một kết nối.
Streaming chỉ bớt được việc cấp phát và mở rộng một List 100.000 phần tử,
và bớt áp lực GC. Lợi ích chính của streaming là bộ nhớ, không phải tốc độ.Và đây là phần quan trọng nhất — hai điều kiện để streaming có tác dụng:
Điều kiện 1: phía tiêu thụ cũng phải là streaming.
// VÔ NGHĨA — vẫn gom hết vào bộ nhớ
var result = new List<LeadDto>();
await foreach (var l in query.AsAsyncEnumerable())
result.Add(ToDto(l)); // gom lại -> mất hết lợi ích
return result;// ĐÚNG — trả thẳng IAsyncEnumerable cho ASP.NET Core
[HttpGet("stream")]
public async IAsyncEnumerable<LeadDto> Stream([EnumeratorCancellation] CancellationToken ct)
{
await foreach (var l in _db.Leads.AsNoTracking().AsAsyncEnumerable().WithCancellation(ct))
yield return ToDto(l);
}ASP.NET Core ghi từng phần tử ra Response.Body khi nhận được
-> bộ nhớ hằng số từ database tới tận trình duyệtĐiều kiện 2: kết nối database phải giữ mở suốt quá trình.
AsAsyncEnumerable giữ một DataReader mở trong suốt vòng lặp.
-> kết nối bị chiếm lâu hơn nhiều so với ToListAsync
ToListAsync: mở, đọc hết trong 300 ms, đóng
AsAsyncEnumerable: mở, và giữ suốt thời gian xử lý — có thể vài phútHệ quả: với pool 30 kết nối, 30 request streaming đồng thời làm cạn pool
-> và request thứ 31 chờ, dù CPU còn rỗiĐây là đánh đổi thật, và nó dẫn tới quy tắc chọn:
| Tình huống | Chọn |
|---|---|
| Xuất dữ liệu, báo cáo lớn, ít người dùng đồng thời | IAsyncEnumerable |
| Endpoint danh sách có phân trang (dưới 100 bản ghi) | ToListAsync — đơn giản hơn |
| Job nền xử lý toàn bộ bảng | IAsyncEnumerable |
| Nhiều request đồng thời, mỗi request lấy ít dữ liệu | ToListAsync — giữ kết nối ngắn |
Và một cái bẫy riêng của IAsyncEnumerable trong endpoint: lỗi giữa chừng.
ToListAsync hỏng -> chưa ghi gì ra response -> trả 500 sạch sẽ
IAsyncEnumerable hỏng ở bản ghi thứ 40.000
-> 40.000 bản ghi đầu ĐÃ được ghi ra, kèm mã trạng thái 200
-> client nhận JSON cụt, không có dấu hiệu lỗi nàoCách giảm nhẹ: dùng định dạng mỗi dòng một bản ghi (NDJSON)
và kèm một dòng kết thúc có đánh dấu
-> client biết được nó đã nhận đủ hay chưaBài 2 — Bật source generator
Thêm JsonSerializerContext cho một DTO và benchmark serialize trước/sau.
Tiêu chí hoàn thành: bạn có số đo cho cả serialize lẫn deserialize, và biết lý do chính để dùng nó không phải tốc độ.
Gợi ý và lời giải — Bài 2
Gợi ý. Đo cả hai chiều. Chúng cho kết quả khác nhau rõ rệt, và sự khác nhau đó chính là câu trả lời.
Lời giải:
Benchmark viết bằng thư viện BenchmarkDotNet (giấy phép MIT, cài bằng dotnet add package BenchmarkDotNet), chạy bằng dotnet run -c Release:
using System.Text.Json;
using System.Text.Json.Serialization;
using BenchmarkDotNet.Attributes;
using BenchmarkDotNet.Running;
BenchmarkRunner.Run<JsonBenchmarks>();
public sealed record LeadDto(
int Id, string Name, string Email, string Phone,
string Status, DateTime CreatedAt, decimal Value);
[JsonSourceGenerationOptions(PropertyNamingPolicy = JsonKnownNamingPolicy.CamelCase)]
[JsonSerializable(typeof(LeadDto))]
[JsonSerializable(typeof(List<LeadDto>))]
public partial class CrmJsonContext : JsonSerializerContext { }
[MemoryDiagnoser]
[SimpleJob(iterationCount: 10)]
public class JsonBenchmarks
{
private static readonly JsonSerializerOptions Options = new() { PropertyNamingPolicy = JsonNamingPolicy.CamelCase };
private List<LeadDto> _list = [];
private string _json = "";
[Params(1, 100)] public int Count;
[GlobalSetup]
public void Setup()
{
_list = Enumerable.Range(1, Count).Select(i => new LeadDto(
i, $"Nguyễn Văn {i}", $"lead{i}@example.com", "0901234567",
"New", new DateTime(2026, 1, 1, 0, 0, 0, DateTimeKind.Utc), i * 1000m)).ToList();
_json = JsonSerializer.Serialize(_list, Options);
}
[Benchmark(Baseline = true, Description = "Serialize - reflection")]
public string SerReflection() => JsonSerializer.Serialize(_list, Options);
[Benchmark(Description = "Serialize - source generator")]
public string SerSourceGen() => JsonSerializer.Serialize(_list, CrmJsonContext.Default.ListLeadDto);
[Benchmark(Description = "Deserialize - reflection")]
public List<LeadDto>? DeReflection() => JsonSerializer.Deserialize<List<LeadDto>>(_json, Options);
[Benchmark(Description = "Deserialize - source generator")]
public List<LeadDto>? DeSourceGen() => JsonSerializer.Deserialize(_json, CrmJsonContext.Default.ListLeadDto);
}Kết quả đo trên .NET 10, BenchmarkDotNet 0.15, 10 lần lặp (cột Gen0, Gen1 lược bớt):
| Method | Count | Mean | Error | Ratio | Allocated |
|--------------------------------- |------ |------------:|-------------:|------:|----------:|
| 'Serialize - reflection' | 1 | 315.0 ns | 8.92 ns | 1.00 | 648 B |
| 'Serialize - source generator' | 1 | 215.2 ns | 5.07 ns | 0.68 | 336 B |
| 'Deserialize - reflection' | 1 | 613.0 ns | 45.71 ns | 1.95 | 960 B |
| 'Deserialize - source generator' | 1 | 603.0 ns | 14.30 ns | 1.91 | 960 B |
| | | | | | |
| 'Serialize - reflection' | 100 | 24,024.4 ns | 2,489.70 ns | 1.00 | 31673 B |
| 'Serialize - source generator' | 100 | 17,295.8 ns | 1,913.65 ns | 0.72 | 31361 B |
| 'Deserialize - reflection' | 100 | 50,111.1 ns | 732.47 ns | 2.09 | 43404 B |
| 'Deserialize - source generator' | 100 | 57,156.3 ns | 12,321.38 ns | 2.39 | 43404 B |Serialize nhanh hơn rõ (0,68× và 0,72×). Deserialize thì không kết luận được:
1 đối tượng : 613,0 so với 603,0 ns — chênh 1,6%, Error tới 45,7 ns
100 đối tượng: 50.111 so với 57.156 ns — bản source generator còn chậm hơn,
nhưng Error của nó là 12.321 ns, tức 22% của Mean
-> các khoảng tin cậy chồng lên nhau
-> không đủ bằng chứng để nói cái nào nhanh hơnVới một đối tượng, cấp phát khi serialize giảm một nửa (648 → 336 B) — đó là phần việc mà bản reflection phải làm lúc chạy, còn bản source generator đã sinh sẵn lúc biên dịch.
Trên một số máy Windows, BenchmarkDotNet không tự sinh được project phụ (lỗi UnauthorizedAccessException khi dò thư mục). Khi đó cho nó chạy ngay trong tiến trình hiện tại bằng toolchain InProcessEmitToolchain (bỏ attribute [SimpleJob] ở lớp benchmark); số đo trên được lấy theo cách này:
using BenchmarkDotNet.Configs;
using BenchmarkDotNet.Jobs;
using BenchmarkDotNet.Toolchains.InProcess.Emit;
BenchmarkRunner.Run<JsonBenchmarks>(DefaultConfig.Instance.AddJob(
Job.Default.WithIterationCount(10).WithToolchain(InProcessEmitToolchain.Instance)));Và lý do chính để dùng source generator không nằm trong bảng trên:
| Lý do | Mức độ |
|---|---|
| Tương thích Native AOT và trimming | Quyết định |
| Khởi động nhanh hơn (không dựng metadata lúc chạy) | Cao |
| Serialize nhanh hơn | Trung bình |
| Deserialize nhanh hơn | Chưa khẳng định được ở bài thử này |
| Lỗi phát hiện lúc biên dịch | Cao |
PublishTrimmed + reflection:
trimmer xoá thuộc tính nó không thấy ai dùng
-> chạy lên, JSON THIẾU TRƯỜNG, không có lỗi nào
-> loại lỗi tệ nhất: sai âm thầm
Source generator: mã tuần tự hoá được SINH RA lúc biên dịch
-> trimmer thấy chúng được dùng -> giữ lạiBật cho toàn ứng dụng — Minimal API và controller đăng ký ở hai chỗ khác nhau, như mục 5.9.2:
// Minimal API
builder.Services.ConfigureHttpJsonOptions(o =>
o.SerializerOptions.TypeInfoResolverChain.Insert(0, CrmJsonContext.Default));
// Controller
builder.Services.AddControllers().AddJsonOptions(o =>
o.JsonSerializerOptions.TypeInfoResolverChain.Insert(0, CrmJsonContext.Default));Ba giới hạn cần biết trước khi chuyển:
1. Phải khai báo TRƯỚC mọi kiểu sẽ tuần tự hoá
-> quên một kiểu: JsonTypeInfo metadata for type ... was not provided
2. Không hỗ trợ một số tính năng động
-> converter tuỳ biến phụ thuộc kiểu lúc chạy, đa hình không khai báo trước
3. Thời gian build tăng theo số kiểu khai báoQuyết định:
Native AOT hoặc PublishTrimmed -> BẮT BUỘC
Serverless, quan tâm cold start -> nên dùng
API thường, JSON không phải nghẽn -> chưa cần vộiVới một endpoint mất 200 ms, tiết kiệm khoảng 7 µs khi serialize 100 đối tượng là chưa tới 0,004% — đúng loại tối ưu mà micro-benchmark đo được còn hệ thống thì không.
Bài 3 — Đọc expression tree
In ra Body và NodeType của vài biểu thức LINQ khác nhau.
Tiêu chí hoàn thành: bạn in được cấu trúc cây của ít nhất ba dạng biểu thức, và giải thích được vì sao Func<T,bool> không dịch được sang SQL còn Expression<Func<T,bool>> thì được.
Gợi ý và lời giải — Bài 3
Gợi ý. Func là code đã biên dịch. Expression là mô tả của code, còn đọc được từng nút.
Lời giải:
using System.Linq.Expressions;
Expression<Func<Lead, bool>> e1 = l => l.Value > 1_000_000;
Expression<Func<Lead, bool>> e2 = l => l.Name.Contains("Nguyen") && l.Status == 2;
Expression<Func<Lead, string>> e3 = l => l.Name.ToUpper();
foreach (var (name, ex) in new (string, LambdaExpression)[] { ("e1", e1), ("e2", e2), ("e3", e3) })
Console.WriteLine($"{name}: NodeType={ex.Body.NodeType,-12} Type={ex.Body.Type.Name,-8} Body={ex.Body}");
public class Lead
{
public int Id { get; set; }
public string Name { get; set; } = "";
public decimal Value { get; set; }
public int Status { get; set; }
}Kết quả chạy trên .NET 10:
e1: NodeType=GreaterThan Type=Boolean Body=(l.Value > 1000000)
e2: NodeType=AndAlso Type=Boolean Body=(l.Name.Contains("Nguyen") AndAlso (l.Status == 2))
e3: NodeType=Call Type=String Body=l.Name.ToUpper()Đi sâu vào một nút (thêm các dòng này ngay sau vòng foreach):
var be = (BinaryExpression)e1.Body;
Console.WriteLine($"Left : {be.Left.NodeType} -> {be.Left}");
Console.WriteLine($"Right : {be.Right.NodeType} -> {be.Right} (giá trị: {((ConstantExpression)be.Right).Value})");
Console.WriteLine($"biên dịch rồi chạy: {e1.Compile()(new Lead { Value = 2_000_000 })}");Left : MemberAccess -> l.Value
Right : Constant -> 1000000 (giá trị: 1000000)
biên dịch rồi chạy: TrueCây của e1 trông như sau:
GreaterThan
├── MemberAccess : l.Value
└── Constant : 1000000Và đây là câu trả lời cho câu hỏi chính — vì sao Func không dịch được sang SQL:
Func<Lead, bool> f = l => l.Value > 1_000_000;Func = một con trỏ tới MÃ MÁY đã biên dịch.
Chỉ làm được một việc: gọi nó với một Lead và nhận về true/false.
Không có cách nào hỏi "anh so sánh trường nào?" hay "với giá trị bao nhiêu?"
-> EF Core không có thông tin để dựng câu WHEREExpression<Func<Lead, bool>> e = l => l.Value > 1_000_000;Expression = một CÂY ĐỐI TƯỢNG mô tả chính đoạn code đó.
EF Core duyệt cây:
GreaterThan -> sinh ra >
MemberAccess l.Value -> tra bảng ánh xạ -> cột "Value"
Constant 1000000 -> ghi thẳng giá trị vào SQL
-> WHERE l."Value" > 1000000.0Hằng số viết thẳng trong lambda được ghi thẳng vào SQL; giá trị lấy từ một biến mới thành tham số @tên_biến (bài 5.7 có kết quả chạy thật của cả hai trường hợp).
Hệ quả thực tế — hai dòng trông giống hệt nhau, chạy hoàn toàn khác:
// IQueryable.Where nhận Expression -> lọc ở DATABASE
var a = await _db.Leads.Where(l => l.Value > 1_000_000).ToListAsync(ct);
// IEnumerable.Where nhận Func -> nạp HẾT rồi lọc trong bộ nhớ
var b = await _db.Leads.ToListAsync(ct);
var c = b.Where(l => l.Value > 1_000_000).ToList();-- a:
SELECT ... FROM "Leads" AS l WHERE l."Value" > 1000000.0
-- b:
SELECT ... FROM "Leads" AS l -- TOÀN BỘ bảngSQL Server
-- a:
SELECT ... FROM [Leads] AS [l] WHERE [l].[Value] > 1000000.0
-- b:
SELECT ... FROM [Leads] AS [l] -- TOÀN BỘ bảngChuyển từ IQueryable sang IEnumerable là ranh giới quyết định điều này.
Và nó xảy ra âm thầm:
.AsEnumerable() -> tường minh
.ToList() -> tường minh
.AsQueryable() -> KHÔNG đưa ngược lại được về database// Bẫy hay gặp: kiểu trả về của một phương thức
public IEnumerable<Lead> GetByTenant(int id) => _db.Leads.Where(l => l.TenantId == id);
// ^^^^^^^^^^^^^^^ mọi Where nối thêm sau đó chạy TRONG BỘ NHỚ
public IQueryable<Lead> GetByTenant(int id) => _db.Leads.Where(l => l.TenantId == id);
// ^^^^^^^^^^^^^^^ Where nối thêm vẫn dịch thành SQLỨng dụng thực tế của expression tree: dựng bộ lọc động.
public static IQueryable<T> WhereIf<T>(
this IQueryable<T> q, bool condition, Expression<Func<T, bool>> predicate)
=> condition ? q.Where(predicate) : q;
var leads = await _db.Leads
.WhereIf(keyword is not null, l => l.Name.Contains(keyword!))
.WhereIf(status.HasValue, l => l.Status == status!.Value)
.WhereIf(fromDate.HasValue, l => l.CreatedAt >= fromDate!.Value)
.ToListAsync(ct);Chỉ những điều kiện được chọn mới vào câu SQL.
Không có Expression thì phải dựng chuỗi SQL bằng tay — và mở cửa cho SQL injection.Và một giới hạn cần biết: không phải biểu thức nào cũng dịch được.
await _db.Leads.Where(l => CalculateScore(l) > 80).ToListAsync(ct);InvalidOperationException: The LINQ expression '... CalculateScore(l) ...'
could not be translated.
EF Core thấy một MethodCall tới một phương thức của bạn,
nó không biết phương thức đó làm gì -> không dịch được.Từ EF Core 3.0 trở đi, trường hợp này NÉM LỖI thay vì âm thầm
chuyển sang lọc trong bộ nhớ như các phiên bản cũ.
-> đây là một thay đổi tốt: lỗi rõ ràng hơn là chậm âm thầm.Tự kiểm tra
IAsyncEnumerable giải quyết vấn đề gì?
Nó cho phép xử lý từng bản ghi một thay vì nạp hết vào bộ nhớ, nên bộ nhớ là hằng số bất kể dữ liệu lớn tới đâu. Đây là cách sửa đúng cho lớp bug làm container bị OOM khi export dữ liệu lớn.
Vì sao cần thuộc tính EnumeratorCancellation?
Không có nó, CancellationToken truyền qua WithCancellation không tới được phần thân phương thức, nên việc huỷ không có tác dụng gì.
Expression tree khác delegate thế nào?
Delegate là code đã biên dịch, chỉ chạy được. Expression tree là cấu trúc dữ liệu mô tả code, nên đọc và phân tích được. Đó là lý do EF Core dịch được LINQ sang SQL.
Vì sao gọi hàm C# trong Where của IQueryable gây lỗi?
Vì lời gọi phương thức chỉ là một nút gọi hàm trong cây biểu thức, và EF Core không biết dịch nó thành SQL gì. Thông báo could not be translated chính là điều đó.
Hai giới hạn của Span là gì?
Nó là ref struct, chỉ sống trên stack, nên không lưu được vào field của lớp và không giữ được qua một await (từ C# 13 được khai báo trong phương thức async nhưng chỉ dùng giữa hai lần await). Khi cần những điều đó thì dùng Memory, linh hoạt hơn nhưng chậm hơn một chút.
Quy tắc dễ nhớ cho out và in trong generic là gì?
Dùng out khi kiểu chỉ xuất hiện ở đầu ra, dùng in khi nó chỉ xuất hiện ở đầu vào. Nếu xuất hiện ở cả hai như IList thì không đánh dấu được, và đó là lý do IList của kiểu con không gán được cho IList của kiểu cha.
Kết luận
Ba điều đáng nhớ nhất:
IAsyncEnumerablegiữ bộ nhớ ở mức hằng số — công cụ quan trọng nhất trong bài.- Expression tree là thứ khiến LINQ dịch được sang SQL, và hiểu nó giải thích lỗi thường gặp.
Spanbạn dùng gián tiếp hàng ngày; tự viết chỉ khi profiler chỉ ra.
Tham khảo
Điều hướng
- Bài trước: 5.8 — Tuples và Deconstruction
- Bài tiếp theo: 5.10 — Case study: refactor hàm tính giảm giá
- Về module: Trang mục lục