10 điều cần tránh khi làm việc với Controller trong .NET Core

Để giữ cho controller trở nên clean và gọn gàng, chúng ta cần phải triển khai từ đầu và đã quen khi làm việc với mô hình MVC. Nhưng khi dự án phát triển và các thành viên khác trong nhóm tham gia dự án, mọi thứ có thể vượt quá tầm tay và khó kiểm soát. Và đây là một số tips giới thiệu đến các bạn với 10 điều cần tránh khi làm việc với controller trong .NET Core.
10 điều cần tránh khi làm việc với Controller trong .NET Core

 

Để giúp bạn tổ chức các controller trong .NET Core Web API của mình tốt hơn, sau đây mình giới thiệu hàng loạt kỹ thuật đơn giản với các ví dụ giúp giữ cho controller của bạn trở nên hiệu quả hơn trong thời gian dài.

Data Access Logic

Chúng ta không nên sử dụng controller để truy cập dữ liệu trực tiếp. Mặc dù đây là quy tắc chung, nhưng không phải tất cả các dự án đều cần quá nhiều layers và một số có lẽ tốt nhất nên đơn giản.
Đối với các dự án khác, đặc biệt là những dự án lớn hơn, chúng ta không nên sử dụng data access logic trong controller. Hầu hết một phương thức truy cập dữ liệu trở thành hai, và sau đó chúng ta cần thêm một phương thức nữa… Và sau một vài tháng, chúng ta đã hoàn toàn lộn xộn bên trong controller đến mức chúng ta thậm chí không biết chuyện gì đang xảy ra.
Trong trường hợp này, repository pattern là một cách tuyệt vời để che dấu data access logic và tạo một layer riêng cho nó, tuy nhiên, chúng ta không nên sử dụng trực tiếp nó trong controller:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _repository.GetProduct(id);
    return Ok(product);
}

Business Logic

Giả sử chúng ta cần trả lại giá trị product mới, cụ thể là cần tính giá sản phẩm sau khi đã áp dụng discount:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _repository.GetProduct(id);
    ApplyTheDiscount(product);
    return Ok(product);
}
Như các bạn thấy, code trên đã đáp ứng được bài toán là trả về danh sách sản phẩm sau khi đã áp dụng discount, nhưng, cách làm này không sai nhưng nó khó duy trì trong thời gian dài, các nghiệp vụ sẽ luôn thay đổi theo thời gian. Không chỉ vậy, mà controller của chúng ta đã mất đi mục đích, thay vì làm điều gì đó như thế này, chúng ta có thể tạo một lớp service và sử dụng nó để lưu trữ tất cả logic nghiệp vụ của chúng ta trong đó, cùng với data access logic.
public ProductController(IServiceManager service)
{
    _service = service;
}

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);
    return Ok(product);
}

Để rõ hơn, chúng ta quay về định nghĩa về repository pattern:

Repository Pattern là một mẫu kiến trúc cho phép chúng ta tách biệt các tầng khác nhau của ứng dụng, giúp cho mã nguồn trở nên trong sáng và dễ duy trì và mở rộng hơn. Các tầng trong repository pattern bao gồm:
  • Tầng controller: Xử lý request và response của HTTP
  • Tầng service: Xử lý các logic nghiệp vụ
  • Tầng repository: Xử lý các thao tác truy xuất CSDL

 

Mapping Model Classes

Như code ở tầng nghiệp vụ ở trên, các bạn có thể thấy chúng ta đã trả về cả một entity, nhưng thực tế chúng ta chỉ nên trả về những trường cần thiết. Ví dụ, bạn có thực thể Quiz để làm chức năng trắc nghiệp, bên trong thực thể chứa cả câu hỏi, lựa chọn và đáp án. Vậy khi trả về câu hỏi cho người dùng, có cần thiết để trả về trường đáp án trong trường hợp này.
Cụ thể, bạn cần cần một class DTO (Data Transfer Object), và trả về những trường bạn muốn:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);
    var productDto = new ProductDto
    {
        Name = Name,
        Details = Details,
        Price = Price
    };
        
    return Ok(productDto);
}

Bây giờ chúng ta đã giải quyết được vấn đề trước đó và kết quả là chúng ta đang gửi một class DTO thay vì cả thực thể và điều đó tốt hơn nhiều. Theo cách này, chúng ta sẽ tránh được rất nhiều vấn đề tiềm ẩn.

Nhưng như bạn có thể thấy, chúng ta đang thực hiện ánh xạ từ thực thể sang DTO theo cách thủ công. Điều này không chỉ gây mệt mỏi và lặp đi lặp lại mà còn khiến mã thực sự không thể đọc được. Đối với các thực thể có quá nhiều trường, việc ánh xạ từng trường mất rất nhiều thời gian.
Ở đây chúng ta có một cách tiếp cận khác, đó là sử dụng thư viện ánh xạ như AutoMapper để làm cho quá trình này dễ dàng hơn.
Bằng cách sử dụng AutoMapper, chúng ta có thể dễ dàng thực hiện tương tự:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var product = await _service.GetProductAndApplyDiscount(id);
    var productDto = _mapper.Map<ProductDto>(product);
    return Ok(productDto);
}

Code trên, đã minh hoạ được cách bạn sử dụng Auto Mapper, nhưng để code bạn trở nên gọn hơn và đảm bảo mục đích ban đầu đặt ra, bây giờ bạn cần chuyển việc sử dụng Auto Mapper bên trong service, và khi đó controller của chúng ta sẽ còn như sau:

[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    var productDto = await _service.GetProductAndApplyDiscount(id);
    return Ok(productDto);
}

Bây giờ lớp service trả về cho chúng ta lớp DTO trực tiếp thay vì thực thể, mà chúng ta cần ánh xạ trong controller và do đó làm cho controller trở nên sạch hơn, ngắn gọn hơn.

Exception Handling

Xử lý ngoại lệ là điều cần thiết trong bất kỳ ứng dụng nào. Nhưng liệu controller có phải là nơi để làm điều đó? Câu trả lời ngắn gọn là không.
.NET Core cung cấp một cách tuyệt vời để xử lý các ngoại lệ trên global, đó là thông qua middleware. Kết hợp global exception middleware với status codes chính xác cho các tình huống là một cách tốt nhất để giữ controller luôn clean và tránh các tình huống khó chịu và sự cố ứng dụng.
Thay vì thực hiện xử lý ngoại lệ theo cách thủ công như sau:
[HttpGet("{id}")]
public async Task<IActionResult> GetProduct(Guid id)
{
    try
    {
        var productDto = await _service.GetProductAndApplyDiscount(id);
        return Ok(productDto);
    }
    catch (Exception ex)
    {
        _logger.LogError($"Something went wrong while getting the product: {ex}");
                
        return StatusCode(500, "Internal server error");
    }
}

Chúng ta có thể cấu hình logic xử lý ngoại lệ trong phương thức Configure của class Startup (ở .NET 6 sẽ xử lý ở class Program):

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    app.ConfigureExceptionHandler(logger);
    ...
    app.UseEndpoints(endpoints =>
    {
        endpoints.MapControllers();
    });
}

Cụ thể, cách xử lý ngoại lệ trên global, mình sẽ giới thiệu chi tiết ở bài sau.

 

Repetitive Logic

Trong một dự án, chúng ta không thể tránh khỏi một số phần, hoạt động lặp đi lặp lại. và bây giờ ta xử lý chương trình và khắc phục hạn chế tình trạng này.
Cụ thể, mình minh hoạ các bạn khi có method thêm mới sản phẩm với code sau đây:
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    if (productDto == null)
    {
        _logger.LogError("Object sent from the frontend is null.");
        return BadRequest("Object sent from the frontend is null.");
    }
    if (!ModelState.IsValid)
    {
        _logger.LogError("Invalid model state for the ProductDto object");
        return UnprocessableEntity(ModelState);
    }
    var product = await _service.CreateProduct(productDto);
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

Như code trên, bạn có thể thấy được, chúng ta đã dùng câu lệnh if quá nhiều lần. Và giải pháp bây giờ là sử dụng action filter. Sau đã sử dụng, ta có validation filter đơn giản như sau:

[HttpPost]
[ServiceFilter(typeof(ValidationFilterAttribute))]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

 

Và bên trong class ValidationFilterAttribute sẽ xử lý như sau:
public class ValidationFilterAttribute : IActionFilter
    {
        public void OnActionExecuting(ActionExecutingContext context)
        {
            var param = context.ActionArguments.SingleOrDefault(p => p.Value is IEntity);
            if(param.Value == null)
            {
                context.Result = new BadRequestObjectResult("Object is null");
                return;
            }
            
            if(!context.ModelState.IsValid)
            {
                context.Result = new UnprocessableEntityObjectResult(context.ModelState);
            }
        }
        public void OnActionExecuted(ActionExecutedContext context)
        {          
        }
    }

Và tất nhiên, trong những bài sắp tới, mình sẽ hướng dẫn các bạn rõ hơn về action filter trong .NET Core.

Việc tạo các action filter không khó lắm nhưng cũng không phải là quá dễ. Nhưng một khi bạn thành thạo chúng, chúng sẽ trở thành những công cụ rất mạnh để phát triển API trong dự án của bạn.

Manual Authorization

.NET Core cung cấp một số tools để Authorize users và protect resources của bạn phù hợp.
Bạn không cần phải cố tạo ra một số cơ chế phức tạp và không cần thiết để thực hiện authorize. Trong hầu hết các trường hợp, thuộc tính [Authorize] có thể làm nên điều kỳ diệu như code sau:
[HttpPost, Authorize(Roles = "Manager")]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product );
}

Điều này có nghĩa là chỉ có những user với role Manager mới có thể thêm mới sản phẩm.

Synchronous

Khi tạo các API linh hoạt, một trong những điều quan trọng nhất là làm cho nó bất đồng bộ từ trên xuống dưới.
API bất đồng bộ cung cấp trải nghiệm tốt hơn nhiều và đó là cách nên làm.
Entity Framework Core đã được cung cấp các phương thức bất đồng bộ để truy cập cơ sở dữ liệu, vậy tại sao không bắt đầu từ trên cùng (trong controller) và đẩy cách tiếp cận không đồng bộ xuống data access layer.
[HttpPost]
public async Task<IActionResult> CreateProduct([FromBody] ProductDto productDto)
{
    var product = await _service.CreateProduct(productDto);
    return CreatedAtAction(nameof(GetProduct), new { id = product.Id }, product);
}

“HTTP GET Đạo”

Tất cả chúng ta đều biết rằng một API tốt bao gồm tất cả các loại phương thức, GET, POST, PUT, DELETE, PATCH, và nhiều hơn nữa.
Một số người không thích dùng quá nhiều HTTP method và họ thích tạo bộ quy tắc của riêng mình về cách tổ chức API REST. Theo đó, họ xem các phương thức GET là tất cả những gì bạn sẽ cần, vì vậy họ sử dụng chúng như một phần chính của API của mình.

Lời kết

Đây là những điều quan trọng nhất bạn nên nghĩ đến khi cố gắng giữ cho các controller của bạn trở nên clean hơn trong .NET Core.
Sau khi đã đi sai lệch thì code sau này rất khó maintain, và đây là những tips để bạn tham khảo và áp dụng phù hợp. Nhưng trong những tình huống khác nhau bạn nên linh động và chọn giải pháp phù hợp.
Mong bài viết hữu ích, chúc các bạn thành công.
Hieu Ho.

Để lại một bình luận

Email của bạn sẽ không được hiển thị công khai. Các trường bắt buộc được đánh dấu *