Skip to content

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ủa System.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òn Span nằ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.

csharp
// 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ả:

csharp
[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:

csharp
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:

csharp
// 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.

csharp
// 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:

csharp
// 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.0

Và cũng giải thích một lỗi rất hay gặp:

csharp
// Hàm C# — EF Core KHÔNG đọc được bên trong
_db.Leads.Where(l => IsValid(l))
// -> InvalidOperationException: could not be translated

Where 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.

csharp
// 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ột await. Từ C# 13 bạn được khai báo biến Span trong phương thức async, nhưng chỉ dùng nó giữa hai lần await; dùng nó sau await là lỗi CS4007 ("cannot be preserved across 'await' or 'yield' boundary").
  • Khi cần giữ vùng nhớ lâu hơn (qua await, trong field), dùng Memory<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.

csharp
// 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>
csharp
// 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ênChủ đềVì sao
Cao (sau Module 6)IAsyncEnumerableGiải quyết bug OOM khi xử lý dữ liệu lớn
CaoSource generator cho JSONThêm một lớp, nhanh hơn ngay
Trung bìnhExpression tree (hiểu)Giải thích lỗi "could not be translated"
Trung bìnhCovariance, contravarianceGiải thích lỗi gán generic
ThấpTự viết code dùng SpanChỉ khi profiler chỉ ra hot path
ThấpTự viết source generatorCho 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:

csharp
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):

text
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.895
ToListAsyncAsAsyncEnumerable
Heap cuối55,7 MB7,7 MB
RSS đỉnh133,9 MB82,0 MB
Thời giankhoảng 800 mskhoảng 710 ms
Kết quả tính được988.895988.895

Ba nhận xét:

1. Heap giảm khoảng 7 lần — đó là phần rõ nhất.

text
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-0

2. Nhưng RSS chỉ giảm khoảng 40%, không giảm 7 lần.

text
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%.

text
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.

csharp
// 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;
csharp
// ĐÚ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);
}
text
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.

text
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út
text
Hệ 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ốngChọn
Xuất dữ liệu, báo cáo lớn, ít người dùng đồng thờiIAsyncEnumerable
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ảngIAsyncEnumerable
Nhiều request đồng thời, mỗi request lấy ít dữ liệuToListAsync — 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.

text
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ào
text
Cá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ưa

Bà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:

csharp
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):

text
| 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:

text
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ơn

Vớ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:

csharp
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ý doMức độ
Tương thích Native AOT và trimmingQuyết định
Khởi động nhanh hơn (không dựng metadata lúc chạy)Cao
Serialize nhanh hơnTrung bình
Deserialize nhanh hơnChưa khẳng định được ở bài thử này
Lỗi phát hiện lúc biên dịchCao
text
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ại

Bật cho toàn ứng dụng — Minimal API và controller đăng ký ở hai chỗ khác nhau, như mục 5.9.2:

csharp
// 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:

text
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áo

Quyết định:

text
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ội

Vớ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:

csharp
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:

text
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):

csharp
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 })}");
text
Left  : MemberAccess -> l.Value
Right : Constant -> 1000000 (giá trị: 1000000)
biên dịch rồi chạy: True

Cây của e1 trông như sau:

text
GreaterThan
├── MemberAccess : l.Value
└── Constant     : 1000000

Và đây là câu trả lời cho câu hỏi chính — vì sao Func không dịch được sang SQL:

csharp
Func<Lead, bool> f = l => l.Value > 1_000_000;
text
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 WHERE
csharp
Expression<Func<Lead, bool>> e = l => l.Value > 1_000_000;
text
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.0

Hằ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:

csharp
// 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();
sql
-- a:
SELECT ... FROM "Leads" AS l WHERE l."Value" > 1000000.0

-- b:
SELECT ... FROM "Leads" AS l     -- TOÀN BỘ bảng
SQL Server
sql
-- a:
SELECT ... FROM [Leads] AS [l] WHERE [l].[Value] > 1000000.0

-- b:
SELECT ... FROM [Leads] AS [l]   -- TOÀN BỘ bảng
text
Chuyể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
csharp
// 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.

csharp
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);
text
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.

csharp
await _db.Leads.Where(l => CalculateScore(l) > 80).ToListAsync(ct);
text
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.
text
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:

  1. IAsyncEnumerable giữ bộ nhớ ở mức hằng số — công cụ quan trọng nhất trong bài.
  2. 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.
  3. Span bạ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 ​

Phát hành theo giấy phép MIT.