Ngăn chặn các yêu cầu trùng lặp trong ASP.NET Core

Ngăn chặn các yêu cầu trùng lặp trong ASP.NET Core

Mỗi ứng dụng web thành công chắc chắn sẽ có nhiều người dùng hơn, và trong đám đông người dùng đó, có một vài cá nhân nhiệt tình mang theo ngọn đuốc của các ứng dụng dành cho máy tính để bàn trong tim họ.

Những người cầm đuốc này có nhu cầu thôi thúc phải nhấp đúp vào mọi nút. Các nhà thiết kế hệ điều hành đã xây dựng double-click vào ngôn ngữ UX của tất cả các hệ điều hành máy tính để bàn.

Tuy nhiên, nhấp đúp có thể tàn phá các nhà phát triển web không nghi ngờ, những người cho rằng tất cả người dùng sẽ nhấp một lần để gửi biểu mẫu.

Bài viết này sẽ cho thấy một kỹ thuật phía máy chủ đơn giản để giảm, nếu không muốn loại bỏ, các yêu cầu trùng lặp của người dùng trong khi vẫn giữ cho mọi người, bao gồm cả chúng ta, hài lòng trong quá trình này.

Hãy nói về sự lý tưởng

Bắt nguồn từ toán học, thuật ngữ idempotentnày là biệt ngữ để nói, “chúng ta có thể áp dụng phép toán này nhiều lần mà vẫn nhận được cùng một kết quả”.

Chúng tôi đã mô tả một tình huống trong đó người dùng có thể nhấp đúp vào nút gửi của biểu mẫu, bắt đầu nhiều yêu cầu web giống nhau. Yêu cầu chính xác có thể có những tác dụng phụ vô hại như gửi một liên lạc trùng lặp hoặc có thể gây ra tình trạng nghiêm trọng khi rút tiền nhiều lần từ tài khoản ngân hàng của một cá nhân.

Giao thức HTTP có các phương thức idempotency được xây dựng trong đặc tả kỹ thuật như GET, OPTIONSHEAD, chúng thường được sử dụng cho các hoạt động đọc. Phương thức phi idempotent bao gồm POST, PUT, PATCHDELETE. Các phương thức này có xu hướng thay đổi trạng thái, cho dù tạo tài nguyên mới, cập nhật tài nguyên hiện có hay xóa hoàn toàn.

Vì vậy, làm thế nào để chúng ta làm cho các phương thức không idempotent này giống như các phương thức idempotent?

Thực hiện yêu cầu lý tưởng (lý thuyết)

Chúng ta cần gửi một Idempotency token với mọi yêu cầu có thể gây ra thay đổi trạng thái. Vì chúng ta đang xử lý các ứng dụng web, mỗi biểu mẫu sẽ tạo một token duy nhất trên toàn cầu khi trình duyệt tải giao diện người dùng.

Token này sau đó sẽ được gửi đến máy chủ của chúng ta và được lưu vào cơ chế lưu trữ. Khi có yêu cầu, chúng ta sẽ xác minh token dựa trên cơ chế lưu trữ để đảm bảo rằng chúng ta chưa từng thấy nó trước đây.

Ok, vậy chúng ta làm điều đó như thế nào?

Triển khai ASP.NET Core Idempotency dựa trên tài nguyên

Cách tiếp cận đầu tiên là xây dựng tính hiệu quả trong các tài nguyên của chúng ta. Trong trường hợp này, chúng ta sẽ sử dụng Entity Framework Core và unique constraint có sẵn trên index. Hãy xem class Message ở ví dụ bên dưới để chúng ta chỉ xử lý một lần.

[Index(nameof(IdempotentToken), IsUnique = true)]
public class Message
{
    public int Id { get; set; }
    public string Text { get; set; }
    
    public string IdempotentToken { get; set; }
}

Chúng ta có thể tận dụng các tính năng transaction của cơ sở dữ liệu. Sử dụng cơ sở dữ liệu làm giảm nhu cầu quản lý cấu trúc lưu trữ trong bộ nhớ của chúng ta. Lưu trữ Idempotent token cùng với tài nguyên của chúng ta cũng sẽ cho phép chúng ta triển khai logic rõ ràng cho mỗi kịch bản dựa trên tài nguyên. Chúng ta sẽ thấy một giải pháp chung sau nhưng mất một số cơ hội xử lý lỗi.

Trong bài viết này, chúng ta sẽ sử dụng Razor Pages, nhưng chúng ta cũng có thể áp dụng phương pháp này cho MVC.

using System;
using System.Text.Json;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Mvc;
using Microsoft.AspNetCore.Mvc.RazorPages;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;

namespace ContactForm.Pages
{
    public class IndexModel : PageModel
    {
        private readonly ILogger<IndexModel> logger;
        private readonly Database database;

        [BindProperty] public string IdempotentToken { get; set; }
        [BindProperty] public string Text { get; set; }

        [TempData] public string AlertCookie { get; set; }

        public Alert Alert =>
            AlertCookie is not null
                ? JsonSerializer.Deserialize<Alert>(AlertCookie)
                : null;

        public IndexModel(ILogger<IndexModel> logger, Database database)
        {
            this.logger = logger;
            this.database = database;
        }

        public void OnGet()
        {
            IdempotentToken = Guid.NewGuid().ToString();
        }

        public async Task<IActionResult> OnPost()
        {
            try
            {
                if (string.IsNullOrEmpty(IdempotentToken))
                {
                    AlertCookie = Alert.Error.ToJson();
                    return Page();
                }

                database.Messages.Add(new Message
                {
                    IdempotentToken = IdempotentToken,
                    Text = Text
                });

                // will throw if unique
                // constraint is violated
                await database.SaveChangesAsync();

                TempData[nameof(AlertCookie)] =
                    new Alert("Successfully received message").ToJson();

                // perform Redirect -> Get
                return RedirectToPage();
            }
            catch (DbUpdateException ex)
                when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
            {
                AlertCookie = new Alert(
                    "You somehow sent this message multiple time. " +
                        "Don't worry its safe, you can carry on.", 
                    "warning")
                .ToJson();
            }
            catch
            {
                AlertCookie = Alert.Error.ToJson();
            }

            return Page();
        }
    }

    public record Alert(string Text, string CssClass = "success")
    {
        public string ToJson()
        {
            return JsonSerializer.Serialize(this);
        }

        public static Alert Error { get; } = new(
            "We're not sure what happened.",
            "warning"
        );
    };
}

Hãy xem qua mã trên từng bước:

  1. Trong phương thức OnGet này, chúng ta tạo một token duy nhất bằng cách sử dụng Guid, nhưng đây có thể là bất kỳ giá trị duy nhất nào. Chúng ta sẽ sử dụng giá trị trong biểu mẫu HTML của chúng ta.
  2. Trong phương thức OnPost, chúng ta cố gắng lưu trữ Idempotenttoken cùng với giá trị Text của chúng ta. Nếu đó là lần đầu tiên chúng ta nhìn thấy yêu cầu, chúng ta sẽ lưu trữ nó mà không có vấn đề gì.
  3. Nếu chúng ta đã lưu trữ Idempotenttoken, chúng ta sẽ nhận được một ngoại lệ DbUpdateExceptionvới một ngoại lệ SqliteException bên trong (inner exception). Ngoại lệ sẽ phụ thuộc vào sự lựa chọn cơ sở dữ liệu của chúng ta.

Trong HTML, chúng ta cần đảm bảo rằng token là một phần của biểu mẫu của chúng ta.

<form method="post" asp-page="Index">
    <div class="form-group">
        <label asp-for="Text"></label>
        <textarea class="form-control" asp-for="Text" rows="3"></textarea>
    </div>
    <input asp-for="IdempotentToken" type="hidden" />
    <button type="submit" class="btn btn-primary mb-2">Send Message</button>
</form>

Hãy xem xét ba trường hợp có thể xảy ra trong trải nghiệm của người dùng trên trang web của chúng ta — bắt đầu với một yêu cầu thành công.

Triển khai ASP.NET Core Idempotency dựa trên tài nguyên

Có vẻ tốt; điều gì xảy ra khi chúng ta sử dụng lại token?

Triển khai ASP.NET Core Idempotency dựa trên tài nguyên

Cuối cùng, điều gì sẽ xảy ra khi chúng ta không gửi kèm theo token nào cả?

Triển khai ASP.NET Core Idempotency dựa trên tài nguyên

Tuyệt vời! Có vẻ như mọi thứ đang hoạt động, nhưng có giải pháp nào chung hơn thì sao?

Mức độ lý tưởng khi sử dụng middleware trong ASP.NET Core

Vì chúng ta đang sử dụng ASP.NET Core, giải pháp chung khác của chúng ta là sử dụng middleware (phần mềm trung gian) để kiểm tra các yêu cầu POST đối với một entity token chung trong cơ sở dữ liệu của chúng ta để ngắn mạch các yêu cầu trùng lặp. Đầu tiên, chúng ta sẽ tạo một cơ chế lưu trữ cho các token bằng cách sử dụng Entity Framework Core.

[Index(nameof(IdempotentToken), IsUnique = true)]
public class Requests
{
    public int Id { get; set; }
    public string IdempotentToken { get; set; }

    public static string New()
    {
        return Guid.NewGuid().ToString();
    }
}

Sau đó, chúng ta sẽ cần triển khai một middleware StopDuplicatesMiddleware.

using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ContactForm.Models;
using Microsoft.AspNetCore.Http;
using Microsoft.AspNetCore.Mvc.ViewFeatures;
using Microsoft.Data.Sqlite;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;

namespace ContactForm.Pages
{
    public class StopDuplicatesMiddleware : IMiddleware
    {
        private readonly string key;
        private readonly string alertTempDataKey;

        public StopDuplicatesMiddleware(string key = "IdempotentToken", string alertTempDataKey = "AlertCookie")
        {
            this.key = key;
            this.alertTempDataKey = alertTempDataKey;
        }

        public async Task InvokeAsync(HttpContext context, RequestDelegate next)
        {
            if (context.Request.Method == HttpMethod.Post.Method &&
                context.Request.Form.TryGetValue(key, out var values))
            {
                var token = values.FirstOrDefault();
                var database = context.RequestServices.GetRequiredService<Database>();
                var factory = context.RequestServices.GetRequiredService<ITempDataDictionaryFactory>();
                var tempData = factory.GetTempData(context);

                try
                {
                    database.Requests.Add(new Requests
                    {
                        IdempotentToken = token
                    });
                    // we're good
                    await database.SaveChangesAsync();
                }
                catch (DbUpdateException ex)
                    when (ex.InnerException is SqliteException {SqliteErrorCode: 19})
                {
                    tempData[alertTempDataKey] = new Alert(
                            "You somehow sent this message multiple time. " +
                            "Don't worry its safe, you can carry on.",
                            "warning")
                        .ToJson();
                    tempData.Keep(alertTempDataKey);
                    
                    // a redirect and
                    // not an immediate view
                    context.Response.Redirect("/", false);
                }
            }
            
            await next(context);
        }
    }
}

Middleware mà chúng ta đã viết ở trên sẽ lưu trữ bất kỳ token nào mà nó nhận được trong cơ sở dữ liệu. Nếu token đã tồn tại, lệnh gọi sẽ đưa ra một ngoại lệ. Trong trường hợp này, chúng ta sẽ giữ một cảnh báo trong TempData và trang Razor Page sẽ sử dụng khi chuyển hướng.

Bước cuối cùng, chúng ta cần đăng ký StopDuplicatesMiddlewarevới ASP.NET Core.

using ContactForm.Models;
using ContactForm.Pages;
using Microsoft.AspNetCore.Builder;
using Microsoft.AspNetCore.Hosting;
using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Hosting;

namespace ContactForm
{
    public class Startup
    {
        public Startup(IConfiguration configuration)
        {
            Configuration = configuration;
        }

        public IConfiguration Configuration { get; }

        // This method gets called by the runtime. Use this method to add services to the container.
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddRazorPages();
            services.AddEntityFrameworkSqlite();
            services.AddDbContext<Database>();
            services.AddSingleton<StopDuplicatesMiddleware>();
        }

        // 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();
            }
            else
            {
                app.UseExceptionHandler("/Error");
                app.UseHsts();
            }

            app.UseHttpsRedirection();
            app.UseStaticFiles();
            app.UseRouting();
            app.UseAuthorization();
            
            app.UseMiddleware<StopDuplicatesMiddleware>();

            app.UseEndpoints(endpoints => { endpoints.MapRazorPages(); });
        }
    }
}

Với phần mềm trung gian đã đăng ký, chúng ta nhận được cùng một hành vi trên tất cả các biểu mẫu với một phần tử đầu vào có tên IdempotentToken.

Phần kết luận

Cách tốt nhất luôn là viết các ứng dụng của bạn hướng đến tính hiệu quả. Bài viết này cho thấy hai cách tiếp cận với Entity Framework Core và ASP.NET Core để giảm cơ hội xử lý cùng một yêu cầu hai lần. Mã ở đây là điểm khởi đầu, nhưng bất kỳ ai cũng có thể điều chỉnh nó cho phù hợp với nhu cầu cụ thể và công nghệ của họ.

Có một số nhược điểm của phương pháp này mà các nhà phát triển nên xem xét:

  • Công cụ cơ sở dữ liệu của chúng ta là một điểm nghẽn tiềm ẩn và có thể làm chậm trải nghiệm tổng thể.
  • Chúng ta đang chuyển dữ liệu bổ sung đến máy chủ, điều này có thể ảnh hưởng đến hiệu suất.
  • Việc tạo ra các token duy nhất trên quy mô lớn có thể trở nên tốn kém và cần được quản lý.

Một cách tiếp cận đơn giản hơn đối với một số người có thể là tạo một trình giữ chỗ cho tài nguyên và sau đó sử dụng các yêu cầu tiếp theo để hydrate hóa và hoàn thành thực thể. Ví dụ về việc tạo trước một nguồn tài nguyên bao gồm giỏ hàng hoặc hoàn thành một cuộc khảo sát trực tuyến. Cách tiếp cận được sửa đổi này loại bỏ nhu cầu về nhiều cơ chế lưu trữ dữ liệu và các ràng buộc duy nhất.

Vì vậy, làm thế nào để bạn hạn chế các yêu cầu trùng lặp trong hệ thống của bạn? Ý tưởng có quan trọng đối với bạn không, hay nó chỉ là một phần của việc chạy ứng dụng web của bạn?

Cảm ơn bạn đã đọc bài viết này!

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 *