Cách tạo một HTTP API cơ bản với ASP.NET Core

Cách tạo một HTTP API cơ bản với ASP.NET Core

Mục đích của bài viết này là hướng dẫn những điều cơ bản về cách bắt đầu một HTTP API với ASP.NET Core. Hiểu biết cơ bản về tất cả các thành phần giúp ASP.NET Core HTTP API hoạt động và tại sao mỗi thành phần lại quan trọng.

Chúng ta cũng sẽ khám phá cách viết một phương thức mở rộng (extension method) sẽ biến bất kỳ lớp nào thành một nhóm các HTTP Endpoint.

Giao thức HTTP

Đặc tả HTTP là một phần cực kỳ quan trọng của cơ sở hạ tầng hiện đại và nếu không có nó, nhiều ứng dụng yêu thích của chúng ta sẽ không hoạt động. Giao thức HTTP được sử dụng rộng rãi nhất trên giao tiếp giữa các ứng dụng (cross-application), ngay cả khi nó không phải lúc nào cũng tốt nhất.

Khả năng của HTTP là để cung cấp các nội dung khác nhau cho các máy khách khác nhau giúp các nhà phát triển rất thuận tiện ở mọi nơi. Các loại nội dung phản hồi có thể là HTML, JavaScript, CSS và các định dạng tệp nhị phân khác.

Mặc dù đặc tả HTTP có nhiều khía cạnh, nhưng bản thân HTTP là một định dạng văn bản thuần túy và con người có thể đọc được. Những người tạo ra HTTP đã xây dựng nó dựa trên các đối tượng đơn giản, có thể mở rộng và không có trạng thái.

Là một giao thức, HTTP có các định dạng cho cả yêu cầu (request) và phản hồi (response), với các phần tử chồng chéo lên nhau. Khi xây dựng các HTTP API, chúng ta thường cần nghĩ về HTTP theo các thành phần sau.

  Request Response
Headers
Version Protocol
Method  
Body
Path & Querystring  

Điều cần thiết đối với những người xây dựng HTTP API là phải hiểu các hạn chế của các phương thức HTTP và cách sử dụng phù hợp từng phương thức HTTP.

Khái niệm cơ bản về phương thức HTTP

Khi làm việc với các giao thức HTTP, có chín phương thức request: CONNECT, DELETE, GET, HEAD, OPTIONS, PATCH, POST, PUTTRACE. Trong khi chúng ta có thể sử dụng tất cả các phương thức này khi xây dựng HTTP API, hầu hết các nhà phát triển sẽ chủ yếu gắn bó với phương thức GET, POST, PUT, PATCHDELETE.

Hiểu các trường hợp sử dụng cho từng phương thức request có thể giúp chúng ta cấu trúc một API để có trải nghiệm khách hàng tốt hơn.

Sử dụng ngữ nghĩa thích hợp cũng giúp giảm sự bùng nổ của các đường dẫn trong API của chúng ta, vì các yêu cầu có đường dẫn tương tự nhưng các phương thức khác nhau có thể có kết quả khác nhau.

Endpoint HTTP GET

Các phương thức sử dụng phương thức GET này thường là các endpoint (điểm cuối) chỉ đọc. Việc gọi các endpoint GET trong API của chúng ta sẽ không gây ra bất kỳ tác dụng phụ nào.

Các tác dụng phụ bao gồm cập nhật tài nguyên cơ sở dữ liệu, gọi dịch vụ của bên thứ ba hoặc nói chung là thay đổi trạng thái của tài nguyên. Các tác dụng phụ không bao gồm ghi nhật ký và phân tích.

Ưu điểm của việc sử dụng endpoint GET là chúng thường có thể được lưu vào bộ đệm bởi ứng dụng khách đang gọi, cùng với bất kỳ proxy trung gian nào.

Các cuộc gọi đến endpoint GETkhông được bao gồm bất kỳ thông tin trọng tải nào trong phần thân yêu cầu HTTP (request body). Chúng ta phải bao gồm bất kỳ thông tin bổ sung nào vào API của chúng ta trong tiêu đề (header), đường dẫn (path) và chuỗi truy vấn (query string).

Endpoint HTTP POST, PUT và PATCH

Chúng ta xem các phương thức POST, PUTPATCHlà nơi mà hành động xảy ra trong một HTTP API. Các phương thức này cho phép máy khách chỉ định nội dung của yêu cầu và định dạng mà chúng đang gửi đến máy chủ.

Chúng ta có thể đặt loại nội dung trong header Content-Type. Đối với hầu hết các API hiện đại, Content-Typethường sẽ là application/jsonnhưng cũng có thể application/x-www-form-urlencodeddành cho các API hỗ trợ các biểu mẫu HTML (HTML form). Chúng ta nên xem xét các phương thức này khi truyền dữ liệu sẽ thay đổi tài nguyên trong ứng dụng của chúng ta.

Chúng ta thường không coi các phương thức này là an toàn khi gọi lặp lại, vì mỗi lệnh gọi sẽ thay đổi trạng thái của tài nguyên. Chúng ta có thể lưu phản hồi vào bộ nhớ cache, nhưng độ mới của bộ nhớ đệm do máy chủ quyết định và máy khách tôn trọng điều này.

Phương thức POST được cho phép bởi các biểu mẫu HTML, nhưng phương thức PUTPATCH thì không. Chúng ta nên xem xét các khách hàng của mình và khả năng chỉ định phương thức của chúng khi xây dựng HTTP API.

Endpoint DELETE

Endpoint DELETE được sử dụng cho các hành động xóa được thực hiện trên máy chủ, chẳng hạn như xóa tài nguyên. Nó hoạt động tương tự như các phương thức POST, PUTPATCHnhưng cần đúng đắn về mặt ngữ nghĩa hơn.

Giới thiệu khái quát về các phương thức HTTP trong một API

Chúng ta có thể điều chỉnh các phương thức với các hành động mà chúng thực hiện. Hầu hết các API sẽ có sự kết hợp của các endpoint đọc (read), tạo (create), cập nhật (update) và xóa (delete). Tham khảo bảng dưới đây khi xem xét phương thức nào sẽ sử dụng cho endpoint mới.

  Read Create Update Delete
GET      
POST      
PUT      
PATCH      
DELETE      

Content Negotiation

Content negotiation trong HTTP API có thể phức tạp hoặc đơn giản như chúng ta muốn. Tốt nhất bạn nên chọn một loại định dạng nội dung duy nhất trên một API như một quy tắc chung.

Hiện tại, JavaScript Object Notation (JSON) được sử dụng phổ biến nhất trên nhiều API vì tính đơn giản và phổ biến của nó. Một số API vẫn hoạt động với XML và những API khác hoàn toàn chọn các định dạng khác.

Khi làm việc với content negotiation, chúng ta thiết lập giá trị cho header Content-Type trên mỗi HTTP request cho máy chủ biết nội dung mong đợi từ máy khách.

Nếu API của chúng ta mong đợi JSON, thì giá trị sẽ là application/json. Ví dụ: trong đoạn mã sau, chúng ta muốn đọc một nội dung JSON từ một yêu cầu.

await context.Request.ReadFromJsonAsync<Person>();

Trong ASP.NET Core, đoạn mã này sẽ không thành công nếu Content-Type của request của chúng ta không phải là application/json.

Chúng ta cũng có thể thiết lập header Accept từ máy khách, thông báo cho máy chủ phản hồi theo một định dạng cụ thể. Máy chủ không có nghĩa vụ phản hồi dưới bất kỳ hình thức cụ thể nào.

Máy khách cho biết mong muốn của nó đối với một định dạng dữ liệu vì khả năng xử lý phản hồi của nó. Ở đây chúng ta có mã gửi phản hồi JSON trở lại máy khách.

await context.Response.WriteAsJsonAsync<Person>(person);

Tại sao không nên dùng ASP.NET Core MVC để tạo API?

ASP.NET Core MVC là một framework có khả năng xây dựng các API và mọi người nên coi nó là một tùy chọn hợp lệ. Điều đó nói rằng, MVC đi kèm với chi phí trong quá trình mà nhiều người có thể không cần hoặc sử dụng.

Chi phí này có thể làm chậm hiệu suất của API cuối cùng của chúng ta. Chi phí này bắt nguồn từ nhiều nhà cung cấp giá trị ràng buộc mô hình, cơ sở hạ tầng xác thực và các điểm mở rộng đường ống tổng thể.

Cách tiếp cận mà chúng tôi sẽ giới thiệu trong phần tiếp theo áp dụng một cách làm thoải mái hơn. Phải thừa nhận rằng mỗi tình huống sẽ khác nhau, và mỗi người nên đánh giá nhu cầu của mình trước khi áp dụng bất kỳ cách tiếp cận nào.

Hãy xây dựng một API

Hãy bắt đầu với ứng dụng Empty ASP.NET Core mới bằng cách sử dụng IDE yêu thích của bạn hoặc sử dụng dotnetCLI. Những người sử dụng CLI có thể sử dụng lệnh sau.

> dotnet new web -o basic

Sau khi lệnh hoàn tất, chúng ta sẽ có một dự án với các tệp sau.

  • appsettings.json
  • Program.cs
  • Startup.cs

Chúng ta có thể thấy cách các nhà phát triển có thể xây dựng các HTTP API đơn giản bằng ASP.NET Core trong lớp Startup.

app.UseEndpoints(endpoints =>
{
    endpoints.MapGet("/", async context =>
    {
        await context.Response.WriteAsync("Hello World!");
    });
});

Mặc dù là một cách tiếp cận có thể chấp nhận được, nhưng nó có thể trở nên tẻ nhạt và lộn xộn khi xây dựng bằng cách tiếp cận này.

Hãy xem cách chúng ta có thể tạo một Module cho HTTP API. Lớp sau sẽ cho phép chúng ta định nghĩa các endpoint giống nhau trong các lớp riêng biệt. Trong dự án, hãy tạo một class mới có tên HttpEndpointsModulesRegistrationExtensions.

#nullable enable
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.Routing;
using Microsoft.AspNetCore.Routing;

namespace HttpApi
{
    public static class HttpEndpointsModulesRegistrationExtensions
    {
        /// <summary>
        /// Will map all public instance endpoint methods that have an HttpMethodAttribute, which includes:
        ///
        /// - HttpGet
        /// - HttpPost
        /// - HttpPut
        /// - HttpDelete
        /// - HttpPatch
        ///
        /// Modules must be added to the services collection in ConfigureServices. Modules will be created
        /// using the DI features of ASP.NET Core.
        ///
        /// Endpoint methods are allowed to have additional parameters, which will be resolved via the
        /// services collection as a courtesy to developers.
        /// </summary>
        /// <param name="endpoints"></param>
        /// <typeparam name="THttpEndpointsModule"></typeparam>
        /// <returns></returns>
        /// <exception cref="Exception"></exception>
        public static IEndpointRouteBuilder Map<THttpEndpointsModule>(this IEndpointRouteBuilder endpoints)
        {
            var type = typeof(THttpEndpointsModule);

            var methods = type
                .GetMethods(BindingFlags.Public | BindingFlags.Instance)
                .Select(m => new {method = m, attribute = m.GetCustomAttribute<HttpMethodAttribute>(true)})
                .Where(m => m.attribute is not null)
                .Select(m => new {
                    methodInfo = m.method, 
                    template = m.attribute?.Template, 
                    httpMethod = m.attribute?.HttpMethods
                })
                .ToList();
            
            foreach (var method in methods)
            {
                if (method.methodInfo.ReturnType != typeof(Task))
                    throw new Exception($"Endpoints must return a {nameof(Task)}.");
                
                if (method.methodInfo.GetParameters().FirstOrDefault()?.ParameterType != typeof(HttpContext))
                    throw new Exception($"{nameof(HttpContext)} must be the first parameter of any endpoint.");
                
                endpoints.MapMethods(method.template, method.httpMethod, context => {
                    var module = context.RequestServices.GetService(type);

                    if (module is null) {
                        throw new Exception($"{type.Name} is not registered in services collection.");
                    }
                    
                    var parameters = method.methodInfo.GetParameters();
                    List<object?> arguments = new() { context };
                    
                    // skip httpContext
                    foreach (var parameter in parameters.Skip(1))
                    {
                        var arg = context.RequestServices.GetService(parameter.ParameterType);
                        if (arg is null) {
                            throw new Exception($"{parameter.ParameterType} is not registered in services collection.");
                        }

                        arguments.Add(arg);
                    }
                    
                    var task = method.methodInfo.Invoke(module, arguments.ToArray()) as Task;
                    return task!;
                });
            }

            return endpoints;
        }
    }
}

Mọi người không nên lo lắng quá nhiều về chi tiết triển khai của lớp này. Phương thức mở rộng này cũng sẽ cho phép chúng ta đăng ký các module của chúng ta bằng cú pháp sau trong class Startup.

public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
{
    if (env.IsDevelopment())
    {
        app.UseDeveloperExceptionPage();
    }

    app.UseRouting();

    app.UseEndpoints(endpoints =>
    {
        endpoints.Map<HelloWorldModule>();
    });
}

Hãy định nghĩa module của chúng ta ngay bây giờ. Tạo tệp C# mới và thêm class HelloWorldModule sau. Điều quan trọng cần lưu ý là chúng ta đang sử dụng các kiểu HttpMethodAttribute trong ASP.NET Core MVC.

Chúng giúp chúng ta xác định phương thức và đường dẫn đến điểm cuối HTTP của chúng ta. Chúng ta cũng có thể tạo các thuộc tính tùy chỉnh để lưu trữ các siêu dữ liệu này, nhưng tại sao lại phải phát minh lại bánh xe?

public class HelloWorldModule
{
    [HttpGet("/")]
    public Task Get(HttpContext context)
    {
        return context.Response.WriteAsync("Hello World!");
    }
}

Chúng ta có thể cập nhật class Startup để đăng ký module. Bạn có thể cần thêm khai báo using HttpApi vào đầu class Startup của mình.

Chúng ta cũng cần đăng ký module của mình với service collection trong ASP.NET Core. Khi hoàn thành, class Startupsẽ trông giống như sau.

using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;
using HttpApi;

namespace basic
{
    public class Startup
    {
        // This method gets called by the runtime. Use this method to add services to the container.
        // For more information on how to configure your application, visit https://go.microsoft.com/fwlink/?LinkID=398940
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddTransient<HelloWorldModule>();
        }

        // This method gets called by the runtime. Use this method to configure the HTTP request pipeline.
        public void Configure(IApplicationBuilder app, IWebHostEnvironment env)
        {
            if (env.IsDevelopment())
            {
                app.UseDeveloperExceptionPage();
            }

            app.UseRouting();

            app.UseEndpoints(endpoints =>
            {
                endpoints.Map<HelloWorldModule>();
            });
        }
    }
}

Chạy ứng dụng lên, chúng tôi thấy phản hồi từ module HelloWorldModule.

Hello World!

Bây giờ, trong module HelloWorldModule, chúng ta có thể xử lý tất cả các loại yêu cầu từ máy khách. Một trong những lợi thế của cách tiếp cận module là chúng ta có thể tận dụng tối đa khả năng dependency injection của ASP.NET Core, từ việc tiếp nhận các phụ thuộc thông qua constructor injection hoặc nhận phụ thuộc thông qua các tham số.

public class HelloWorldModule
{
    private readonly Person _person;

    public HelloWorldModule(Person person)
    {
        _person = person;
    }
    
    [HttpGet("/")]
    public async Task Index(HttpContext context, Person person)
    {
        await context.Response.WriteAsync($"Hello {person.Name}!");
    }

    [HttpGet("/bye")]
    public Task Get(HttpContext context)
    {
        return context.Response.WriteAsync($"Goodbye, {_person.Name}!");
    }

    [HttpGet("/person")]
    public async Task GetPerson(HttpContext context)
    {
        var reader = File.OpenText("./Views/Form.html");
        context.Response.ContentType = "text/html";
        var html = await reader.ReadToEndAsync();
        await context.Response.WriteAsync(html);
    }

    [HttpPost("/person")]
    public async Task Post(HttpContext context)
    {
        var person = await context.Request.BindFromFormAsync<PersonRequest>();
        context.Response.ContentType = "text/html";
        await context.Response.WriteAsync($"<h1>🤩 Happy Birthday, {person.Name} ({person.Birthday:d})!</h1>");
        await context.Response.WriteAsync($"<h2>Counting {string.Join(",",person.Count)}...</h2>");
    }
    
}

Form Binding

Khi xem ví dụ trên, bạn có thể nhận thấy một phương thức mở rộng của BindFromFormAsync. Việc triển khai là một mô hình ràng buộc dữ liệu đáng tin cậy được viết để ánh xạ các giá trị của biểu mẫu tới một đối tượng C#.

Đáng tin cậy theo nghĩa là nó không hỗ trợ tất cả các trường hợp ngoài rìa hoặc các yêu cầu lồng nhau.

Nó hỗ trợ các kiểu C# nguyên thủy và generic collection của các kiểu nguyên thủy. Mặc dù nó không hoàn hảo nhưng nó hoạt động khá tốt và chúng ta có thể mở rộng mã để đáp ứng hầu hết các nhu cầu phát triển.

using System;
using System.Collections;
using System.Globalization;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;

namespace HttpApi
{
    public static class FormCollectionExtensions
    {
        /// <summary>
        /// A naive model binding that takes the values found in the IFormCollection
        /// </summary>
        /// <param name="formCollection"></param>
        /// <param name="model"></param>
        /// <typeparam name="TModel"></typeparam>
        /// <returns></returns>
        public static TModel Bind<TModel>(this IFormCollection formCollection, TModel model = default)
            where TModel : new()
        {
            model ??= new TModel();
            
            var properties = typeof(TModel)
                .GetProperties(BindingFlags.Instance | BindingFlags.Public | BindingFlags.SetProperty);

            foreach (var property in properties)
            {
                if (formCollection.TryGetValue(property.Name, out var values) ||
                    formCollection.TryGetValue($"{property.Name}[]", out values)) 
                {
                    // support generic collections
                    if (property.PropertyType.IsAssignableTo(typeof(IEnumerable)) &&
                        property.PropertyType != typeof(string) &&
                        property.PropertyType.IsGenericType)
                    {
                        var collectionType = property.PropertyType.GenericTypeArguments[0];
                        
                        var mi = typeof(Enumerable).GetMethod(nameof(Enumerable.Cast));
                        var cast = mi?.MakeGenericMethod(collectionType);

                        var items = values.Select<string, object?>(v => ConvertToType(v, collectionType));
                        var collection = cast?.Invoke(null, new object?[]{ items });
                        property.SetValue(model, collection);
                    }
                    else
                    {
                        // last in wins
                        var result = ConvertToType(values[^1], property.PropertyType);
                        property.SetValue(model, result);    
                    }
                }
            }

            return model;
        }

        public static async Task<TModel> BindFromFormAsync<TModel>(this HttpRequest request, TModel model = default)
            where TModel : new()
        {
            var form = await request.ReadFormAsync();
            return form.Bind(model);
        }

        private static object? ConvertToType(string value, Type type)
        {
            var underlyingType = Nullable.GetUnderlyingType(type);

            if (value.Length > 0)
            {
                if (type == typeof(DateTimeOffset) || underlyingType == typeof(DateTimeOffset))
                {
                    return DateTimeOffset.Parse(value, CultureInfo.InvariantCulture);
                }
                
                if (type == typeof(DateTime) || underlyingType == typeof(DateTime))
                {
                    return DateTime.Parse(value, CultureInfo.InvariantCulture);
                }

                if (type == typeof(Guid) || underlyingType == typeof(Guid))
                {
                    return new Guid(value);
                }

                if (type == typeof(Uri) || underlyingType == typeof(Uri))
                {
                    if (Uri.TryCreate(value, UriKind.RelativeOrAbsolute, out var uri))
                    {
                        return uri;
                    }

                    return null;
                }
            }
            else
            {
                if (type == typeof(Guid))
                {
                    return default(Guid);
                }

                if (underlyingType != null)
                {
                    return null;
                }
            }

            if (underlyingType is not null)
            {
                return Convert.ChangeType(value, underlyingType);
            }

            return Convert.ChangeType(value, type);
        }
    }
}

Phần kết luận

Thông qua bài viết này, tôi hy vọng bạn đã biết về cấu trúc của HTTP API, mỗi phương thức khi nào thích hợp để sử dụng và cách content negotiation có thể đóng một phần thiết yếu trong trải nghiệm API.

Với một số phương thức mở rộng, chúng ta cũng có thể tạo một phương pháp tiếp cận module mạnh mẽ cho phép chúng ta xây dựng và quản lý một HTTP API.

Các nhóm phát triển muốn các giải pháp được cộng đồng hỗ trợ nên xem xét Carter, theo phương pháp được nêu trong bài viết này.

Một tùy chọn khác là FeatherHttp, vẫn đang ở dạng alpha nhưng cho thấy mức độ tối thiểu của HTTP API mà chúng ta có thể xây dựng bằng ASP.NET Core.

Như mọi khi, cảm ơn vì đã đọc.

Trả lời

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 *