5.7 - Expression Trees và Reflection
Tóm tắt: Hai cơ chế này trả lời cùng một câu hỏi — "làm sao code biết về chính nó?" — nhưng ở hai thời điểm khác nhau. Expression tree biến một lambda thành cây dữ liệu mô tả nó, để thứ khác đọc và dịch sang ngôn ngữ khác — đây chính là cách
_db.Orders.Where(o => o.Total > 100)trở thành một câuWHEREtrong SQL. Reflection đọc thông tin kiểu và gọi thành viên lúc chạy — nền của mọi DI container và serializer. Cả hai rất mạnh, và cả hai đều có cái giá thật: reflection không cache chậm hơn gọi trực tiếp hàng chục lần, và cả hai đều xung đột với Native AOT cùng tính năng cắt tỉa mã.
Mục tiêu bài học
Sau bài này bạn có thể:
- Phân biệt
Func<T, bool>vớiExpression<Func<T, bool>>và nói được cái nào dịch sang SQL được. - Giải thích vì sao ORM cần expression tree.
- Dùng reflection để đọc thông tin kiểu, và biết khi nào nên tránh.
- Nêu cái giá hiệu năng và cách giảm bằng caching hoặc source generator.
- Biết vì sao cả hai gây khó cho Native AOT.
Cần biết trước: bài 5.3 (IEnumerable và IQueryable), bài 5.4 (Func, delegate), bài 5.2 (generic). Đây là bài đọc thêm: phần expression tree giải thích vì sao EF Core dịch được LINQ sang SQL, phần reflection giải thích cách DI container và serializer làm việc. Bạn dùng được EF Core mà chưa cần tự viết hai thứ này.
Nội dung bài học
5.7.1 — Hai thứ rất giống nhau, khác nhau một chữ
using System.Linq.Expressions; // Expression<T>, không nằm trong implicit usings
// Delegate — code ĐÃ BIÊN DỊCH, chỉ gọi được
Func<Order, bool> f = o => o.Total > 100_000m;
bool result = f(order);
// Expression tree — CÂY DỮ LIỆU mô tả đoạn code đó
Expression<Func<Order, bool>> e = o => o.Total > 100_000m;
Console.WriteLine(e); // o => (o.Total > 100000)Chỉ khác chữ Expression<...> bọc bên ngoài, nhưng bản chất hoàn toàn khác. Cái thứ hai đọc ra được:
var body = (BinaryExpression)e.Body;
Console.WriteLine(body.NodeType); // GreaterThan
Console.WriteLine(body.Left); // o.Total
Console.WriteLine(body.Right); // 100000Cây đó có thể được duyệt và dịch sang ngôn ngữ khác — ví dụ SQL.
5.7.2 — Vì sao ORM cần expression tree
// IQueryable nhận Expression -> dịch được sang SQL
IQueryable<Order> q = _db.Orders;
q = q.Where(o => o.Total > 100_000m); // Where trả về truy vấn MỚI, phải gán lại
// -> SELECT ... FROM "Orders" AS o WHERE o."Total" > 100000.0
// IEnumerable nhận Func -> KHÔNG dịch được, phải chạy trong bộ nhớ
IEnumerable<Order> e = _db.Orders.AsEnumerable();
e = e.Where(o => o.Total > 100_000m);
// -> SELECT ... FROM "Orders" AS o rồi lọc bằng C#Where không sửa truy vấn cũ mà trả về một truy vấn mới. Viết q.Where(...); mà không gán lại thì điều kiện bị bỏ đi.
Nhìn vào chữ ký là thấy toàn bộ câu chuyện:
// System.Linq.Enumerable
Where<T>(this IEnumerable<T> source, Func<T, bool> predicate)
// System.Linq.Queryable
Where<T>(this IQueryable<T> source, Expression<Func<T, bool>> predicate)Đây là lời giải thích kỹ thuật cho cái bẫy .ToList() đặt sai chỗ ở bài 5.3: sau AsEnumerable() hay ToList(), các Where tiếp theo là bản của Enumerable, nhận Func thay vì Expression, và từ đó không còn gì để dịch sang SQL nữa.
Hệ quả kèm theo: chỉ những biểu thức mà provider biết cách dịch mới dùng được. Gọi một hàm C# tự viết trong Where sẽ ném ngoại lệ, vì cây biểu thức chứa một nút mà EF Core không hiểu.
5.7.3 — Tự dựng expression tree
// Tương đương: o => o.Total > 100000
var param = Expression.Parameter(typeof(Order), "o");
var body = Expression.GreaterThan(
Expression.Property(param, nameof(Order.Total)),
Expression.Constant(100_000m));
var lambda = Expression.Lambda<Func<Order, bool>>(body, param);
// Dùng trực tiếp với IQueryable
var result = _db.Orders.Where(lambda).ToList();
// Hoặc biên dịch thành delegate để chạy trong bộ nhớ
Func<Order, bool> compiled = lambda.Compile();Chỗ dùng thật của kỹ thuật này: dựng bộ lọc động khi người dùng chọn cột và toán tử ở giao diện, mà vẫn giữ được việc lọc chạy trong database.
Lưu ý về Compile(): nó rất tốn kém vì phải sinh IL lúc chạy. Gọi nó trong một vòng lặp hoặc trong mỗi request là một lỗi hiệu năng nghiêm trọng — hãy biên dịch một lần và cache lại.
5.7.4 — Reflection: đọc và gọi lúc chạy
using System.Reflection; // Assembly, PropertyInfo
var type = typeof(Customer);
foreach (var prop in type.GetProperties())
Console.WriteLine($"{prop.Name}: {prop.PropertyType.Name}");
// Tạo instance và gán giá trị mà không biết kiểu lúc biên dịch
var instance = Activator.CreateInstance(type)!;
type.GetProperty("Name")!.SetValue(instance, "Nguyen Van A");
// Tìm mọi lớp cài đặt một interface — nền của việc tự đăng ký DI
var handlers = Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.IsAssignableTo(typeof(IEventHandler)) && !t.IsAbstract);Bạn dùng reflection mỗi ngày mà không biết: DI container phân giải constructor bằng nó, System.Text.Json đọc thuộc tính bằng nó, ASP.NET Core tìm controller và action bằng nó, EF Core ánh xạ entity sang bảng bằng nó.
5.7.5 — Cái giá
// Gọi trực tiếp — nhanh nhất
var name = customer.Name;
// Reflection không cache — chậm hơn hàng chục lần
var name = typeof(Customer).GetProperty("Name")!.GetValue(customer);
// Reflection có cache — nhanh hơn nhiều
private static readonly PropertyInfo NameProp = typeof(Customer).GetProperty("Name")!;
var name = NameProp.GetValue(customer);
// Biên dịch thành delegate một lần — gần bằng gọi trực tiếp
private static readonly Func<Customer, string> GetName = BuildGetter();
var name = GetName(customer);Phần chậm nhất của reflection là tra cứu metadata (GetProperty, GetMethod), không phải lời gọi. Nên quy tắc là: tra cứu một lần, cache lại, dùng nhiều lần.
Hướng hiện đại hơn là source generator: sinh mã lúc biên dịch thay vì suy diễn lúc chạy. System.Text.Json đã có chế độ này, cho hiệu năng gần bằng mã viết tay và hoạt động được với AOT.
5.7.6 — Xung đột với Native AOT và trimming
Đây là điểm ngày càng quan trọng.
Trimming loại bỏ mã không được tham chiếu để giảm kích thước. Native AOT biên dịch thẳng sang mã máy lúc build, như đã nói ở bài 2.9.
Cả hai đều cần biết lúc biên dịch những gì sẽ được dùng. Reflection phá vỡ giả định đó:
// Trình phân tích không thấy được kiểu này được dùng -> có thể bị cắt mất
var type = Type.GetType(configuration["HandlerType"]!)!;
var handler = Activator.CreateInstance(type);Kết quả là ngoại lệ lúc chạy, và chỉ xuất hiện ở bản build đã trim — không tái hiện được khi chạy debug. Đây là loại lỗi rất khó chẩn đoán.
Cách xử lý, theo thứ tự nên dùng:
- Dùng source generator thay cho reflection ở những chỗ có sẵn (
System.Text.Json, logging,RegexGenerator). - Đăng ký tường minh thay vì quét assembly.
- Nếu buộc phải dùng reflection, gắn
[DynamicallyAccessedMembers]để trình phân tích biết giữ lại gì.
Nếu dự án của bạn không dùng AOT thì chưa phải lo. Nhưng đây là hướng mà .NET đang đi, nên mỗi chỗ dùng reflection là một khoản nợ tương lai.
5.7.7 — Rà lại code của bạn
Danh sách rà soát expression tree và reflection
- Hiểu rõ chỗ nào trong code chuyển từ IQueryable sang IEnumerable.
- Không gọi Expression.Compile() trong vòng lặp hay trong mỗi request.
- Mọi kết quả GetProperty, GetMethod dùng lặp lại đều được cache trong static readonly.
- Không quét assembly trong đường chạy nóng; chỉ quét một lần lúc khởi động.
- Ưu tiên source generator ở những chỗ có sẵn thay cho reflection.
- Nếu nhắm tới AOT hoặc trimming, đã rà soát mọi chỗ dùng reflection.
- Bộ lọc động dựng bằng expression tree để lọc vẫn chạy trong database.
Bài tập áp dụng
Bài 1 — Nhìn vào cây biểu thức
Tạo Expression<Func<Order, bool>> cho một điều kiện có hai vế nối bằng &&. In ra NodeType, Left, Right của từng nút và vẽ lại cây bằng tay.
Tiêu chí hoàn thành: bạn giải thích được vì sao EF Core dịch được biểu thức này thành SQL còn một Func thì không.
Gợi ý và lời giải — Bài 1
Gợi ý. Khác biệt giữa Func<Order, bool> và Expression<Func<Order, bool>> chỉ là một từ, nhưng thứ trình biên dịch sinh ra hoàn toàn khác nhau: một bên là mã máy chạy được, một bên là cấu trúc dữ liệu mô tả mã đó.
Lời giải.
using System.Linq.Expressions;
Expression<Func<Order, bool>> filter =
o => o.Total > 1_000_000m && o.Status == OrderStatus.Paid;
var root = (BinaryExpression)filter.Body;
Console.WriteLine($"gốc : {root.NodeType}"); // AndAlso
Console.WriteLine($" trái : {root.Left.NodeType} {root.Left}");
Console.WriteLine($" phải : {root.Right.NodeType} {root.Right}");
var left = (BinaryExpression)root.Left;
Console.WriteLine($" trái.trái : {left.Left.NodeType} {left.Left}");
Console.WriteLine($" trái.phải : {left.Right.NodeType} {left.Right}");
var right = (BinaryExpression)root.Right;
Console.WriteLine($" phải.trái : {right.Left.NodeType} {right.Left}");
Console.WriteLine($" phải.phải : {right.Right.NodeType} {right.Right}");
public class Order
{
public int Id { get; set; }
public decimal Total { get; set; }
public OrderStatus Status { get; set; }
}
public enum OrderStatus { Pending, Paid, Cancelled }Kết quả chạy trên .NET 10:
gốc : AndAlso
trái : GreaterThan (o.Total > 1000000)
phải : Equal (Convert(o.Status, Int32) == 1)
trái.trái : MemberAccess o.Total
trái.phải : Constant 1000000
phải.trái : Convert Convert(o.Status, Int32)
phải.phải : Constant 1Vế phải có thêm một nút Convert: trình biên dịch so sánh hai enum bằng cách đổi cả hai sang kiểu nền int, nên OrderStatus.Paid trở thành hằng số 1.
Cây vẽ ra:
Vì sao EF Core dịch được. Nhà cung cấp truy vấn đi qua cây này và sinh ra SQL tương ứng cho từng loại nút:
| Nút trong cây | SQL sinh ra |
|---|---|
AndAlso | AND |
GreaterThan | > |
Equal | = |
MemberAccess: o.Total | o."Total" |
Constant: 1000000 | ghi thẳng 1000000.0 vào câu SQL |
Câu SQL EF Core 10 sinh ra trên PostgreSQL khi chạy db.Orders.Where(filter):
SELECT o."Id", o."Status", o."Total"
FROM "Orders" AS o
WHERE o."Total" > 1000000.0 AND o."Status" = 1Hằng số viết thẳng trong lambda được ghi thẳng vào SQL. Giá trị lấy từ biến thì khác: lambda giữ tham chiếu tới biến đó (gọi là closure), trong cây nó là một nút truy cập biến chứ không phải Constant, và EF Core biến nó thành tham số:
var minTotal = 1_000_000m;
var status = OrderStatus.Paid;
db.Orders.Where(o => o.Total > minTotal && o.Status == status).ToList();SELECT o."Id", o."Status", o."Total"
FROM "Orders" AS o
WHERE o."Total" > @minTotal AND o."Status" = @statusVì vậy giá trị do người dùng nhập nên đi qua biến: câu SQL giữ nguyên hình dạng giữa các lần gọi, database dùng lại được kế hoạch thực thi đã tính, và giá trị không bao giờ bị ghép vào chuỗi SQL.
SQL Server
SELECT [o].[Id], [o].[Status], [o].[Total]
FROM [Orders] AS [o]
WHERE [o].[Total] > 1000000.0 AND [o].[Status] = 1
-- bản dùng biến
WHERE [o].[Total] > @minTotal AND [o].[Status] = @statusVì sao Func thì không. Func<Order, bool> đã được biên dịch thành mã máy. EF Core nhận được một con trỏ hàm và không có cách nào biết bên trong nó làm gì — nó chỉ gọi được hàm đó với một Order cụ thể. Vì vậy EF Core buộc phải tải toàn bộ bảng về rồi mới lọc trong bộ nhớ.
Đây chính là khác biệt giữa IQueryable<T> và IEnumerable<T> mà bài 5.3 đo bằng câu SQL sinh ra:
IQueryable<Order> q = db.Orders;
var fromDb = q.Where(o => o.Total > 1000).ToList(); // nhận Expression -> dịch sang SQL
IEnumerable<Order> e = db.Orders;
var inMemory = e.Where(o => o.Total > 1000).ToList(); // nhận Func -> kéo cả bảng rồi lọc trong bộ nhớHai dòng trông giống hệt nhau. Khác biệt duy nhất là kiểu của biến, và nó quyết định toàn bộ hành vi.
Một hệ quả thực tế. Đây là lý do một phương thức helper có thể âm thầm phá hỏng truy vấn:
// Phá hỏng — tham số là Func
public IEnumerable<Order> Filter(IQueryable<Order> q, Func<Order, bool> predicate)
=> q.Where(predicate); // chuyển sang lọc trong bộ nhớ
// Giữ nguyên — tham số là Expression
public IQueryable<Order> Filter(IQueryable<Order> q, Expression<Func<Order, bool>> predicate)
=> q.Where(predicate); // vẫn dịch được sang SQLKhi viết hàm nhận điều kiện lọc và truyền xuống IQueryable, luôn dùng Expression<Func<...>>.
Bài 2 — Đo giá của reflection
Đọc một thuộc tính một triệu lần theo bốn cách: trực tiếp, reflection không cache, reflection có cache PropertyInfo, và delegate biên dịch sẵn. Lập bảng tỉ lệ.
Tiêu chí hoàn thành: bạn biết cách nào đáng dùng khi thật sự cần reflection trong đường dẫn nóng.
Gợi ý và lời giải — Bài 2
Gợi ý. Làm nóng trước và lấy giá trị nhỏ nhất của nhiều lần chạy, như bài 1.7 đã nêu. Ngoài ra phải dùng kết quả, nếu không JIT sẽ loại bỏ luôn vòng lặp.
Lời giải. Chạy bằng dotnet run -c Release:
using System.Diagnostics; // Stopwatch
using System.Reflection; // PropertyInfo
const int N = 1_000_000;
var c = new Customer { Name = "Nguyễn Văn An" };
var pi = typeof(Customer).GetProperty("Name")!;
var getter = (Func<Customer, string>)Delegate.CreateDelegate(
typeof(Func<Customer, string>), pi.GetGetMethod()!);
long sink = 0;
double Measure(Action body)
{
var best = double.MaxValue;
for (int run = 0; run < 7; run++) // chạy 7 lần, lấy lần nhanh nhất
{
var sw = Stopwatch.StartNew();
body();
best = Math.Min(best, sw.Elapsed.TotalMilliseconds);
}
return best;
}
var direct = Measure(() => { for (int i = 0; i < N; i++) sink += c.Name.Length; });
var noCache = Measure(() => { for (int i = 0; i < N; i++)
sink += ((string)typeof(Customer).GetProperty("Name")!.GetValue(c)!).Length; });
var cached = Measure(() => { for (int i = 0; i < N; i++) sink += ((string)pi.GetValue(c)!).Length; });
var compiled = Measure(() => { for (int i = 0; i < N; i++) sink += getter(c).Length; });
Console.WriteLine($"trực tiếp : {direct,6:F1} ms {direct / direct,5:F1}x");
Console.WriteLine($"reflection không cache : {noCache,6:F1} ms {noCache / direct,5:F1}x");
Console.WriteLine($"reflection có cache : {cached,6:F1} ms {cached / direct,5:F1}x");
Console.WriteLine($"delegate biên dịch sẵn : {compiled,6:F1} ms {compiled / direct,5:F1}x");
Console.WriteLine(sink); // dùng kết quả để JIT không bỏ vòng lặp
public class Customer { public string Name { get; set; } = ""; }Kết quả một lần chạy trên .NET 10, chế độ Release, một triệu lần:
trực tiếp : 1,4 ms 1,0x
reflection không cache : 17,6 ms 12,1x
reflection có cache : 4,4 ms 3,0x
delegate biên dịch sẵn : 1,1 ms 0,8xChạy lại vài lần, tỉ lệ dao động (lần khác ra 23x và 7x) vì vòng lặp trực tiếp chỉ mất khoảng 1 ms, sai số đo vài phần mười mili-giây đã đủ làm tỉ lệ đổi nhiều. Thứ tự giữa bốn cách thì luôn giữ nguyên.
Đọc bảng này:
- Bản thân
GetPropertylà phần đắt nhất. Chỉ cần lưu lạiPropertyInfolà nhanh lên khoảng 4 lần — chỉ bằng một biếnstatic readonly. GetValuevẫn tốn hơn gọi trực tiếp. Mỗi lần gọi phải kiểm tra kiểu, xử lý tham số, và có thể boxing giá trị trả về. Từ .NET 8, runtime tối ưuGetValuenhiều, nên khoảng cách nhỏ hơn hẳn các phiên bản cũ.- Delegate biên dịch sẵn ngang gọi trực tiếp. Chênh lệch nằm trong sai số đo.
Đây là kỹ thuật mà các thư viện hiệu năng cao dùng. Thay vì gọi GetValue mỗi lần, dựng một delegate một lần cho mỗi thuộc tính rồi lưu lại:
public static class FastAccessor<T>
{
private static readonly Dictionary<string, Func<T, object?>> _cache = BuildCache();
private static Dictionary<string, Func<T, object?>> BuildCache()
{
var d = new Dictionary<string, Func<T, object?>>();
foreach (var p in typeof(T).GetProperties())
{
var param = Expression.Parameter(typeof(T), "x");
var body = Expression.Convert(Expression.Property(param, p), typeof(object));
d[p.Name] = Expression.Lambda<Func<T, object?>>(body, param).Compile();
}
return d;
}
public static object? Read(T obj, string propertyName) => _cache[propertyName](obj);
}Kết hợp với mẫu static theo kiểu generic ở bài 5.2: cache được dựng đúng một lần cho mỗi kiểu, không cần khoá.
Khi nào reflection là lựa chọn đúng. Khi nó chạy một lần lúc khởi động hoặc vài lần mỗi request, không phải trong vòng lặp triệu lần. Ví dụ hợp lý: quét assembly để đăng ký DI, đọc thuộc tính cấu hình lúc khởi động, dựng metadata cho tuần tự hoá.
Và một xu hướng đáng biết. .NET đang dịch chuyển khỏi reflection lúc chạy sang sinh mã lúc biên dịch (source generator). System.Text.Json có bộ sinh mã riêng cho phép tuần tự hoá không cần reflection, vừa nhanh hơn vừa tương thích với Native AOT và cắt tỉa assembly:
using System.Text.Json;
using System.Text.Json.Serialization;
[JsonSerializable(typeof(Customer))]
internal partial class CrmJsonContext : JsonSerializerContext { }
JsonSerializer.Serialize(customer, CrmJsonContext.Default.Customer);Module 19 bàn về đánh đổi giữa hai hướng này.
Bài 3 — Bộ lọc động bằng expression tree
Viết một hàm nhận tên cột và giá trị, dựng Expression tương ứng, rồi áp vào IQueryable. Bật log SQL và xác nhận điều kiện lọc thật sự xuất hiện trong câu lệnh. Project LinqLab ở Bài 1 của bài 5.3 dùng được cho bài này: thêm thuộc tính Region vào Customer, đổi tên database trong chuỗi kết nối để EnsureCreated tạo bảng mới.
Tiêu chí hoàn thành: câu SQL sinh ra có mệnh đề WHERE, và bạn nêu được rủi ro bảo mật của cách làm này.
Gợi ý và lời giải — Bài 3
Gợi ý. Cần dựng ba phần: tham số đầu vào, truy cập thuộc tính, và phép so sánh với hằng số. Rồi gói chúng thành một lambda.
Lời giải.
using System.Linq.Expressions;
using System.Reflection;
public static class QueryableExtensions
{
public static IQueryable<T> FilterBy<T>(this IQueryable<T> source, string column, object value)
{
var parameter = Expression.Parameter(typeof(T), "x");
var property = typeof(T).GetProperty(column,
BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase)
?? throw new ArgumentException($"Không có cột {column}", nameof(column));
var access = Expression.Property(parameter, property);
var constant = Expression.Constant(Convert.ChangeType(value, property.PropertyType));
var equal = Expression.Equal(access, constant);
var lambda = Expression.Lambda<Func<T, bool>>(equal, parameter);
return source.Where(lambda);
}
}Dùng:
var result = db.Customers.FilterBy("Region", "HN").ToList();SQL sinh ra (PostgreSQL 16, EF Core 10):
SELECT c."Id", c."Name", c."Region"
FROM "Customers" AS c
WHERE c."Region" = 'HN'SQL Server
SELECT [c].[Id], [c].[Name], [c].[Region]
FROM [Customers] AS [c]
WHERE [c].[Region] = N'HN'Điều kiện đã xuống tới database — đúng như mong muốn. Vì dùng Expression.Constant, giá trị 'HN' được ghi thẳng vào SQL (EF Core tự đặt nháy và thoát ký tự đặc biệt nên không bị SQL injection). Nhưng mỗi giá trị khác nhau sinh ra một câu SQL khác nhau, nên database không dùng lại được kế hoạch thực thi. Với giá trị do người dùng nhập, các thư viện lọc động thường bọc giá trị vào một đối tượng rồi truy cập thuộc tính của nó, để EF Core coi đó như biến và sinh tham số @... như ở Bài 1.
Rủi ro bảo mật. Thoạt nhìn có vẻ an toàn vì không nối chuỗi SQL, nhưng có ba rủi ro thật:
Lộ cột không nên lộ.
columnđến từ người dùng, nên họ lọc được theo bất kỳ thuộc tính nào, kể cảPasswordHash,InternalNote,CreditLimit. Bằng cách thử nhiều giá trị và quan sát số kết quả, kẻ tấn công suy ra được giá trị của cột đó — một dạng tấn công kênh bên.Tấn công từ chối dịch vụ. Lọc theo cột không có chỉ mục trên bảng lớn buộc database quét toàn bảng. Gửi vài chục request như vậy là đủ làm quá tải.
Lộ cấu trúc dữ liệu. Thông báo lỗi
"Không có cột X"cho biết cột nào tồn tại, giúp kẻ tấn công vẽ bản đồ lược đồ.
Sửa bằng danh sách cho phép:
private static readonly HashSet<string> AllowedColumns =
new(StringComparer.OrdinalIgnoreCase) { "Region", "Status", "CreatedAt" };
public static IQueryable<T> FilterBy<T>(this IQueryable<T> source, string column, object value)
{
if (!AllowedColumns.Contains(column))
throw new ArgumentException("Không hỗ trợ lọc theo trường này", nameof(column));
// ...
}Danh sách cho phép chứ không phải danh sách cấm — thêm một cột nhạy cảm vào entity sau này sẽ không tự động bị lộ. Đây là cùng một nguyên tắc với việc tách DTO khỏi entity ở bài 9.1.
Khi nào nên tự dựng expression tree. Ít khi. Ba lựa chọn thường tốt hơn:
| Nhu cầu | Giải pháp |
|---|---|
| Lọc động theo vài cột cố định | Chuỗi if với Where thường — đơn giản và an toàn |
| Truy vấn báo cáo phức tạp | Viết SQL thẳng, hoặc dùng view |
| Bộ lọc thật sự tuỳ biến | Thư viện có sẵn như System.Linq.Dynamic.Core, đã xử lý các vấn đề trên |
Tự dựng expression tree chỉ nên làm khi bạn cần điều mà các lựa chọn trên không đáp ứng được — và khi đó hãy nhớ rằng bạn đang nhận thêm trách nhiệm về bảo mật.
Tự kiểm tra
Func và Expression của Func khác nhau ra sao?
Func là code đã biên dịch, chỉ gọi được. Expression của Func là một cây dữ liệu mô tả chính đoạn code đó, nên đọc ra được và dịch sang ngôn ngữ khác được. Đó là lý do IQueryable nhận Expression để dịch sang SQL, còn IEnumerable nhận Func và chạy trong bộ nhớ.
Vì sao gọi ToList sớm lại làm mất khả năng dịch sang SQL?
Vì nó đổi kiểu tham số từ Expression sang Func. Sau khi đã là IEnumerable, mọi Where nhận một delegate đã biên dịch chứ không phải cây biểu thức, nên không còn gì để provider đọc và dịch sang SQL. Toàn bộ dữ liệu phải được kéo về trước rồi mới lọc bằng C#.
Vì sao gọi một hàm C# tự viết trong Where của EF Core lại lỗi?
Vì cây biểu thức khi đó chứa một nút gọi phương thức mà provider không biết cách dịch sang SQL. EF Core 3.0 trở lên ném ngoại lệ thay vì âm thầm kéo cả bảng về như phiên bản cũ, và đó là hành vi tốt hơn vì nó buộc bạn quyết định.
Phần nào của reflection là chậm nhất?
Việc tra cứu metadata như GetProperty hay GetMethod, chứ không phải lời gọi. Nên quy tắc là tra cứu một lần rồi cache PropertyInfo vào một static readonly, và dùng lại nhiều lần. Nhanh hơn nữa là biên dịch thành delegate một lần, cho hiệu năng gần bằng gọi trực tiếp.
Vì sao reflection xung đột với Native AOT và trimming?
Vì cả hai cần biết lúc biên dịch những gì sẽ được dùng, để cắt bỏ phần còn lại hoặc sinh sẵn mã máy. Reflection tra cứu kiểu lúc chạy, thường từ chuỗi trong cấu hình, nên trình phân tích không thấy được và có thể cắt mất kiểu đó. Lỗi chỉ xuất hiện ở bản build đã trim, không tái hiện khi debug.
Source generator giải quyết vấn đề gì?
Nó sinh mã lúc biên dịch thay vì suy diễn lúc chạy, nên vừa nhanh gần bằng mã viết tay, vừa hoạt động được với AOT và trimming vì mọi thứ đều hiện diện lúc build. System.Text.Json đã có chế độ này, và đó là hướng .NET đang đi để thay dần reflection.
Kết luận
Ba điều đáng nhớ nhất:
Expressionlà code dưới dạng dữ liệu. Đó là toàn bộ lý do ORM dịch được LINQ sang SQL.- Reflection chậm ở khâu tra cứu, không ở khâu gọi. Cache
PropertyInfolà hầu hết những gì bạn cần làm. - Mỗi chỗ dùng reflection là một khoản nợ với AOT. Ưu tiên source generator ở nơi có sẵn.
Tham khảo
- Expression trees
- Reflection in .NET
- Native AOT deployment — hạn chế với reflection
- LINQ overview — nơi expression tree được dùng nhiều nhất
Điều hướng
- Bài trước: 5.6 — Nullable Reference Types (C# 8+)
- Bài tiếp theo: 5.8 — Tuples và Deconstruction
- Về module: Trang mục lục