Proxy Pattern trong C#: Lazy Load, Phân Quyền, Stub Remote
Proxy pattern trong C# / .NET 10: lazy-load qua EF Core proxy, thêm phân quyền quanh service, và nhận diện gRPC client là remote proxy.
Mục lục
- Proxy pattern giải quyết bài toán gì trong C#?
- Virtual proxy trong .NET 10 trông thế nào?
- Protection proxy ép phân quyền thế nào?
- gRPC hoặc HttpClient stub là remote proxy thế nào?
- Khi nào Proxy pattern bắn trật?
- So sánh Proxy với Decorator và Adapter thế nào?
- Một ví dụ thật trong .NET 10 trông thế nào?
- Đọc tiếp gì trong series?
Bạn đang thiết kế dashboard quản lý order. Method
IOrderRepository.GetByIdAsync chạy ổn cho support staff. Rồi
security review tới: chỉ chủ order, hoặc người role support,
mới được đọc order. Cắm check đó vào mọi call site là vô vọng. Cắm
vào bản thân OrderRepository trộn data access với phân quyền.
Proxy pattern là câu trả lời. Đặt một class giữa caller và repository thật cùng interface và check identity caller trước khi forward. Pattern xuất hiện ở ba dạng trong .NET hiện đại — virtual proxy (lazy load), protection proxy (phân quyền), và remote proxy (gRPC, HttpClient) — và nhận diện mỗi dạng cho biết bạn nên viết wrapper hay dựa vào framework.
Bài này tiếp shop e-commerce và dùng mỗi dạng trên một mảnh thật của hệ thống.
Proxy pattern giải quyết bài toán gì trong C#?
Pattern xứng chỗ khi bạn cần một thứ đứng thay object để kiểm soát cách object thật được tới, mà không đổi code caller hay interface object thật. Ba hình dạng cụ thể:
- Virtual proxy. Hoãn tạo object đắt cho đến khi thật sự dùng.
EF Core lazy-loading proxy,
Lazy<T>, và placeholder image object đều theo hình này. - Protection proxy. Ép check phân quyền trước mỗi call. Hữu ích khi cùng data store có rule đọc khác cho role khác.
- Remote proxy. Forward call sang process hay máy khác. gRPC client, REST client, và message-bus stub trong NServiceBus đều là remote proxy — object local expose interface trong khi implementation sống ở nơi khác.
Cái không phải bài toán Proxy: "Tôi muốn thêm hành vi quanh object thật" — đó là Decorator. "Tôi muốn type lạ vừa interface tôi" — đó là Adapter. Proxy đặc biệt nói về kiểm soát có hay không, khi nào, hoặc ở đâu call thực diễn ra.
Virtual proxy trong .NET 10 trông thế nào?
Virtual proxy hoãn việc đắt đến khi đọc giá trị. Bản tay là field
Lazy<T>; bản EF Core được sinh cho bạn.
Một virtual proxy nhỏ tay cho ảnh CDN mà bạn không phải lúc nào cũng cần metadata:
public interface IProductImage
{
int Width { get; }
int Height { get; }
byte[] Bytes { get; }
}
public sealed class ProductImageProxy : IProductImage
{
private readonly Lazy<IProductImage> _real;
public ProductImageProxy(string cdnUrl, IHttpClientFactory http)
{
_real = new Lazy<IProductImage>(() => LoadFromCdn(cdnUrl, http));
}
public int Width => _real.Value.Width;
public int Height => _real.Value.Height;
public byte[] Bytes => _real.Value.Bytes;
private static IProductImage LoadFromCdn(string url, IHttpClientFactory http)
{
// download + decode chỉ xảy ra một lần, ở lần truy cập property đầu
// ...
return /* RealProductImage */ default!;
}
}
Caller chỉ cần URL placeholder hoặc không bao giờ động property
trả zero chi phí. Caller có đọc Bytes trigger download đúng một
lần.
Cùng pattern, tự động bởi EF Core khi opt-in:
// Program.cs
builder.Services.AddDbContext<AppDbContext>(o =>
o.UseSqlServer(connStr).UseLazyLoadingProxies());
// Trong model có virtual navigation property
public sealed class Order
{
public int Id { get; set; }
public virtual Customer Customer { get; set; } = default!;
public virtual ICollection<OrderItem> Items { get; set; } = new List<OrderItem>();
}
EF Core sinh subclass runtime — Proxy — override Customer và
Items để load từ DB lần đầu được truy cập. Cùng hình dạng bản
tay, chỉ là framework tổng hợp ra.
Protection proxy ép phân quyền thế nào?
Protection proxy thêm check phân quyền trước mỗi call object thật. Nó implement cùng interface và phụ thuộc context (user hiện tại) + implementation thật.
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
}
public sealed class OrderRepository : IOrderRepository
{
/* nói chuyện với AppDbContext */
public Task<Order?> GetByIdAsync(int id, CancellationToken ct) => /* ... */;
}
public sealed class AuthorisedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _inner;
private readonly ICurrentUser _user;
public AuthorisedOrderRepository(IOrderRepository inner, ICurrentUser user)
=> (_inner, _user) = (inner, user);
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var order = await _inner.GetByIdAsync(id, ct);
if (order is null) return null;
if (order.CustomerId != _user.Id && !_user.IsInRole("support"))
throw new ForbiddenException($"User {_user.Id} cannot read order {id}");
return order;
}
}
// Composition root với Scrutor
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrderRepository>());
builder.Services.Decorate<IOrderRepository, AuthorisedOrderRepository>();
Mọi call site phụ thuộc IOrderRepository tự động đi qua check
phân quyền. Class repository không bao giờ import ICurrentUser.
Wiring cùng hình dạng Decorator — và
cố ý vậy; bức tranh cấu trúc giống hệt, ý đồ khác.
gRPC hoặc HttpClient stub là remote proxy thế nào?
Remote proxy forward call qua biên process hoặc máy. Client gRPC sinh, typed HttpClient, và stub RPC message-bus trong NServiceBus đều là remote proxy. Hình dạng:
flowchart LR
Caller[Caller local] --> Stub[Client sinh<br>OrderServiceClient]
Stub -->|gRPC qua HTTP/2| Server[Service remote]
Server --> Real[OrderService thật]
Caller thấy interface C# bình thường. Stub serialize argument, gửi request, deserialize response, trả về. Object thật sống ở nơi khác. Nhận diện stub là Proxy nhắc bạn rằng:
- Mọi call có chi phí mạng. Vòng lặp gọi
client.GetOrderAsyncN lần tốn N round trip. - Failure phong phú hơn. Object local không bao giờ throw "channel unavailable"; remote proxy thì có.
- Observability đổi. Bọc stub trong Decorator để trace — remote proxy không có distributed tracing là blind spot.
Tài liệu của team gRPC gọi chúng là client, không phải proxy — nhưng trong từ vựng design pattern chúng là remote proxy, và đặt tên vậy làm profile chi phí dễ lý luận hơn.
Khi nào Proxy pattern bắn trật?
Ba bẫy:
- Chi phí giấu bất ngờ caller. EF Core lazy loading là ví dụ
kinh điển.
foreach (var item in order.Items)trông như truy cập property; nó là N+1 query. Tắt lazy loading mặc định và dùngIncludetường minh khi cần data liên quan. - Protection proxy nhân bản khắp nơi. Mỗi repository tự bọc
protection proxy. Logic phân quyền giờ ở mười class. Đẩy về một
chỗ — một
IAuthorisationServiceđược mỗi Proxy tham vấn — và giữ class Proxy gọn. - Remote proxy dùng như local. Code gọi gRPC client trong vòng lặp chật vì trông như method local. Profile phát hiện sau; nhận diện Proxy lúc thiết kế ngừa bug.
So sánh Proxy với Decorator và Adapter thế nào?
| Pattern | Cùng interface? | Thêm hành vi? | Kiểm soát truy cập / vị trí? |
|---|---|---|---|
| Proxy | Có | Không (hoặc trong suốt) | Có (lazy, auth, remote) |
| Decorator | Có | Có (log, retry) | Không |
| Adapter | Không (đổi interface) | Không | Không |
Câu rõ ràng nhất: Proxy kiểm soát có hay ở đâu call xảy ra; Decorator kiểm soát việc thêm gì xung quanh; Adapter kiểm soát hình dạng call mang. Cả ba trông giống hệt ở mức class definition — khác biệt ở ý đồ và body wrapper làm gì.
Một ví dụ thật trong .NET 10 trông thế nào?
Hình dạng đầy đủ kết hợp protection proxy với phần còn lại codebase checkout. Repository đăng ký, proxy đăng ký dạng decorator, consumer chỉ phụ thuộc interface.
public sealed record Order(int Id, int CustomerId, decimal Total);
public interface IOrderRepository
{
Task<Order?> GetByIdAsync(int id, CancellationToken ct);
}
public sealed class OrderRepository : IOrderRepository
{
private readonly AppDbContext _db;
public OrderRepository(AppDbContext db) => _db = db;
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var row = await _db.Orders.FindAsync(new object[] { id }, ct);
return row is null ? null : new Order(row.Id, row.CustomerId, row.Total);
}
}
public interface ICurrentUser
{
int Id { get; }
bool IsInRole(string role);
}
public sealed class HttpCurrentUser : ICurrentUser
{
public HttpCurrentUser(IHttpContextAccessor http)
{
var p = http.HttpContext?.User
?? throw new InvalidOperationException("No user");
Id = int.Parse(p.FindFirst("sub")?.Value ?? "0");
_user = p;
}
private readonly ClaimsPrincipal _user;
public int Id { get; }
public bool IsInRole(string role) => _user.IsInRole(role);
}
public sealed class AuthorisedOrderRepository : IOrderRepository
{
private readonly IOrderRepository _inner;
private readonly ICurrentUser _user;
private readonly ILogger<AuthorisedOrderRepository> _log;
public AuthorisedOrderRepository(IOrderRepository inner, ICurrentUser user,
ILogger<AuthorisedOrderRepository> log)
=> (_inner, _user, _log) = (inner, user, log);
public async Task<Order?> GetByIdAsync(int id, CancellationToken ct)
{
var order = await _inner.GetByIdAsync(id, ct);
if (order is null) return null;
if (order.CustomerId != _user.Id && !_user.IsInRole("support"))
{
_log.LogWarning("User {U} blocked from order {O}", _user.Id, id);
throw new ForbiddenException($"User {_user.Id} cannot read order {id}");
}
return order;
}
}
// Composition root
builder.Services.AddHttpContextAccessor();
builder.Services.AddScoped<ICurrentUser, HttpCurrentUser>();
builder.Services.AddScoped<OrderRepository>();
builder.Services.AddScoped<IOrderRepository>(sp => sp.GetRequiredService<OrderRepository>());
builder.Services.Decorate<IOrderRepository, AuthorisedOrderRepository>();
// Consumer
[ApiController, Route("orders")]
public sealed class OrdersController : ControllerBase
{
private readonly IOrderRepository _orders;
public OrdersController(IOrderRepository orders) => _orders = orders;
[HttpGet("{id:int}")]
public async Task<ActionResult<Order>> Get(int id, CancellationToken ct)
{
var order = await _orders.GetByIdAsync(id, ct);
return order is null ? NotFound() : Ok(order);
}
}
// Test logic protection riêng biệt
[Fact]
public async Task Proxy_blocks_non_owner_who_is_not_support()
{
var inner = new Mock<IOrderRepository>();
inner.Setup(x => x.GetByIdAsync(1, default))
.ReturnsAsync(new Order(1, CustomerId: 42, Total: 9.99m));
var user = new Mock<ICurrentUser>();
user.SetupGet(u => u.Id).Returns(99);
user.Setup(u => u.IsInRole("support")).Returns(false);
var sut = new AuthorisedOrderRepository(inner.Object, user.Object, NullLogger<AuthorisedOrderRepository>.Instance);
await Assert.ThrowsAsync<ForbiddenException>(() => sut.GetByIdAsync(1, default));
}
Cái hình dạng này cho: controller và repository không biết gì về
phân quyền. Proxy là class duy nhất import ICurrentUser. Ngày
rule đổi, bạn sửa một file.
Đọc tiếp gì trong series?
- Bài trước: Flyweight — share instance immutable để cắt chi phí cấp phát.
- Bài kế: Chain of Responsibility — pattern behavioral đầu tiên, về chuỗi handler quyết định xử lý hay đẩy tiếp.
- Tham chiếu chéo: Decorator — cùng hình, ý đồ khác (thêm hành vi vs kiểm soát truy cập).
- Tham chiếu chéo: Adapter — cùng hình, ý đồ lại khác (đổi interface vs forward thẳng).
- Cây quyết định: Cách chọn design pattern phù hợp.
Một ghi chú về cả nhóm structural: mỗi pattern trong nhóm là một class bọc class khác cùng hoặc khác interface. Yếu tố phân biệt luôn là ý đồ. Khi thấy class wrapping trong code, đừng dừng ở "đó là wrapper" — đẩy đến "tại sao nó là wrapper?". Câu trả lời là structural pattern code đang dùng, và đổi nó đòi đọc body, không phải signature.
Câu hỏi thường gặp
Proxy khác Decorator ở điểm gì?
EF Core lazy-loading dùng Proxy pattern thế nào?
services.AddDbContext<MyContext>(o => o.UseLazyLoadingProxies()), EF Core sinh subclass runtime của mọi entity override mỗi navigation property. Subclass là Proxy: truy cập order.Customer lần đầu trigger query DB, trong suốt. Tham khảo: tài liệu lazy loading của Microsoft.Client API gRPC hay HttpClient có phải Proxy không?
OrderServiceClient được sinh expose cùng interface với IOrderService local nhưng mọi call serialize và đi qua mạng. Nhận diện stub gRPC là Proxy nhắc bạn giữ interface gọn: mỗi method trên nó tốn round trip.Khi nào Proxy giấu quá nhiều?
order.Items.Count() trong foreach vô tình chạy N+1 query. Proxy đổi profile chi phí thao tác phải kèm hint phát hiện được — XML doc, đặt tên, code analysis rule — nếu không sẽ bất ngờ với người đọc kế.