Cấu trúc Trung bình 8 phút đọc

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
  1. Proxy pattern giải quyết bài toán gì trong C#?
  2. Virtual proxy trong .NET 10 trông thế nào?
  3. Protection proxy ép phân quyền thế nào?
  4. gRPC hoặc HttpClient stub là remote proxy thế nào?
  5. Khi nào Proxy pattern bắn trật?
  6. So sánh Proxy với Decorator và Adapter thế nào?
  7. Một ví dụ thật trong .NET 10 trông thế nào?
  8. Đọ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ể:

  1. 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.
  2. 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.
  3. 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 CustomerItems để 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 Proxy nhắc bạn rằng:

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:

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 Không (hoặc trong suốt) Có (lazy, auth, remote)
Decorator 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 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?

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ì?
Cả hai bọc object sau cùng interface. Decorator thêm hành vi (log, retry, cache). Proxy kiểm soát truy cập (tạo trễ, phân quyền, call remote). Đường ranh mờ — caching proxy cũng là decorator thêm cache — nhưng ý đồ giúp: nếu nói 'làm thêm việc trước/sau', nghĩa là Decorator; nếu nói 'quyết định có/ở đâu xảy ra call', nghĩa là Proxy.
EF Core lazy-loading dùng Proxy pattern thế nào?
Khi gọi 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?
Có — Proxy remote. 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?
Khi caller không phân biệt được một thao tác có chi phí khác hẳn vẻ ngoài. EF Core lazy loading là ví dụ kinh điển: 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ế.