پیاده سازی Remote Validation در Blazor
اندازه‌ی قلم متن
تخمین مدت زمان مطالعه‌ی مطلب: پنج دقیقه

فرم‌های Blazor به همراه پشتیبانی از ویژگی Remote که به همراه ASP.NET Core ارائه می‌شود، نیستند. هرچند می‌توان در حین ارسال فرم به سرور، نتیجه‌ی اعتبارسنجی از راه دور و سمت سرور را به کاربر نمایش داد، اما تجربه‌ی کاربری آن در حد Remote validation نیست. یعنی می‌خواهیم در حین ورود اطلاعات و یا انتقال focus به کنترل دیگری، اعتبارسنجی سمت سرور صورت گیرد و نه فقط در زمان ارسال کل اطلاعات به سرور، در پایان کار. در این مطلب روشی را جهت پیاده سازی این عملیات بررسی خواهیم کرد.


ایجاد یک پروژه‌ی ابتدایی Blazor WASM

پروژه‌ای را که در این مطلب تکمیل خواهیم کرد، از نوع Blazor WASM هاست شده‌است. بنابراین در پوشه‌ی فرضی BlazorAsyncValidation، دستور «dotnet new blazorwasm --hosted» را صادر می‌کنیم تا ساختار ابتدایی پروژه که به همراه یک کلاینت Blazor WASM و یک سرور ASP.NET Core Web API است، تشکیل شود. از قسمت Web API، برای پیاده سازی اعتبارسنجی سمت سرور استفاده خواهیم کرد.


مدل ثبت نام برنامه

مدل ثبت نام برنامه تنها از یک خاصیت نام تشکیل شده و در پروژه‌ی Shared قرار می‌گیرد تا هم در کلاینت و هم در سرور قابل استفاده باشد:
using System.ComponentModel.DataAnnotations;

namespace BlazorAsyncValidation.Shared;

public class UserDto
{
    [Required] public string Name { set; get; } = default!;
}
این نام است که می‌خواهیم عدم تکراری بودن آن‌را حین ثبت نام در سمت سرور، بررسی کنیم. به همین جهت کنترلر API زیر را برای آن تدارک خواهیم دید.


کنترلر API ثبت نام برنامه

کنترلر زیر که در پوشه‌ی BlazorAsyncValidation\Server\Controllers قرار می‌گیرد، منطق بررسی تکراری نبودن نام دریافتی از برنامه‌ی کلاینت را شبیه به منطق remote validation استاندارد MVC، پیاده سازی می‌کند که در نهایت یک true و یا false را باز می‌گرداند.
در اینجا خروجی بازگشت داده کاملا در اختیار شما است و نیازی نیست تا حتما ارتباطی با MVC داشته باشد؛ چون مدیریت سمت کلاینت بررسی آن‌را خودمان انجام خواهیم داد و نه یک کتابخانه‌ی از پیش نوشته شده و مشخص.
using BlazorAsyncValidation.Shared;
using Microsoft.AspNetCore.Mvc;

namespace BlazorAsyncValidation.Server.Controllers;

[ApiController, Route("api/[controller]/[action]")]
public class RegisterController : ControllerBase
{
    [HttpPost]
    public IActionResult IsUserNameUnique([FromBody] UserDto userModel)
    {
        if (string.Equals(userModel?.Name, "Vahid", StringComparison.OrdinalIgnoreCase))
        {
            return Ok(false);
        }

        return Ok(true);
    }
}

غنی سازی فرم استاندارد Blazor جهت انجام Remote validation



اگر بخواهیم از EditForm استاندارد Blazor در حالت متداول آن و بدون هیچ تغییری استفاده کنیم، مانند مثال زیر که InputText متصل به خاصیت Name مربوط به Dto برنامه را نمایش می‌دهد:
@page "/"

<PageTitle>Index</PageTitle>

<h2>Register</h2>

<EditForm EditContext="@EditContext" OnValidSubmit="DoSubmitAsync">
    <DataAnnotationsValidator/>
    <div class="row mb-2">
        <label class="col-form-label col-lg-2">Name:</label>
        <div class="col-lg-10">
            <InputText @bind-Value="Model.Name" class="form-control"/>
            <ValidationMessage For="@(() => Model.Name)"/>
        </div>
    </div>

    <button class="btn btn-secondary" type="submit">Submit</button>
</EditForm>
 تنها رخ‌دادی که در اختیار ما قرار می‌گیرد، رخ‌داد submit (در حالت موفقیت اعتبارسنجی سمت کلاینت و یا تنها submit معمولی) است. بنابراین برای دسترسی به رخ‌دادهای بیشتر EditForm، نیاز است با EditContext آن کار کنیم:
public partial class Index
{
    private const string UserValidationUrl = "/api/Register/IsUserNameUnique";

    private ValidationMessageStore? _messageStore;
    [Inject] private HttpClient HttpClient { set; get; } = default!;
    private EditContext? EditContext { set; get; }

    private UserDto Model { get; } = new();
به همین جهت EditContext را در سطح کامپوننت جاری تعریف کرده و همچنین سرویس HttpClient را جهت ارسال اطلاعات Name و دریافت پاسخ true/false از سرور و یک ValidationMessageStore را برای نگهداری تعاریف خطاهای سفارشی که قرار است در فرم نمایش داده شوند، معرفی می‌کنیم.
ValidationMessageStore به همراه متد Add است و اگر به آن نام فیلد مدنظر را به همراه پیامی، اضافه کنیم، این اطلاعات را به صورت خطای اعتبارسنجی توسط کامپوننت ValidationMessage نمایش می‌دهد.

محل مقدار دهی اولیه‌ی این اشیاء نیز در روال رویدادگردان OnInitialized به صورت زیر است:
    protected override void OnInitialized()
    {
        EditContext = new EditContext(Model);
        _messageStore = new ValidationMessageStore(EditContext);
        EditContext.OnFieldChanged += (sender, eventArgs) =>
        {
            var fieldIdentifier = eventArgs.FieldIdentifier;
            _messageStore?.Clear(fieldIdentifier);
            _ = InvokeAsync(async () =>
            {
                var errors = await OnValidateFieldAsync(fieldIdentifier.FieldName);
                if (errors?.Any() != true)
                {
                    return;
                }

                foreach (var error in errors)
                {
                    _messageStore?.Add(fieldIdentifier, error);
                }

                EditContext.NotifyValidationStateChanged();
            });
            StateHasChanged();
        };
        EditContext.OnValidationStateChanged += (sender, eventArgs) => StateHasChanged();
        EditContext.OnValidationRequested += (sender, eventArgs) => _messageStore?.Clear();
    }
در اینجا ابتدا یک نمونه‌ی جدید از EditContext، بر اساس Model فرم، تشکیل می‌شود. سپس ValidationMessageStore سفارشی که قرار است خطاهای اعتبارسنجی remote ما را نمایش دهد نیز نمونه سازی می‌شود. در ادامه امکان دسترسی به رخ‌دادهای OnFieldChanged ، OnValidationStateChanged و OnValidationRequested را خواهیم داشت که در حالت معمولی کار با EditForm میسر نیستند.
برای مثال اگر فیلدی تغییر کند، رویداد OnFieldChanged صادر می‌شود. در همینجا است که کار فراخوانی متد OnValidateFieldAsync که در ادامه معرفی می‌شود را انجام می‌دهیم تا کار اعتبارسنجی Async سمت سرور را انجام دهد. اگر نتیجه‌ای به همراه آن بود، توسط messageStore به صورت یک خطای اعتبارسنجی نمایش داده خواهد شد و همچنین EditContext نیز با فراخوانی متد NotifyValidationStateChanged، وادار به به‌روز رسانی وضعیت اعتبارسنجی خود می‌گردد.

متد سفارشی OnValidateFieldAsync که کار اعتبارسنجی سمت سرور را انجام می‌دهد، به صورت زیر تعریف شده‌است:
    private async Task<IList<string>?> OnValidateFieldAsync(string fieldName)
    {
        switch (fieldName)
        {
            case nameof(UserDto.Name):
                var response = await HttpClient.PostAsJsonAsync(
                    UserValidationUrl,
                    new UserDto { Name = Model.Name });
                var responseContent = await response.Content.ReadAsStringAsync();
                if (string.Equals(responseContent, "false", StringComparison.OrdinalIgnoreCase))
                {
                    return new List<string>
                    {
                        $"`{Model.Name}` is in use. Please choose another name."
                    };
                }

                // TIP: It's better to use the `DntDebounceInputText` component for this case to reduce the network round-trips.

                break;
        }

        return null;
    }
در اینجا درخواستی به سمت آدرس api/Register/IsUserNameUnique ارسال شده و سپس بر اساس مقدار true و یا false آن، پیامی را بازگشت می‌دهد. اگر نال را بازگشت دهد یعنی مشکلی نبوده.

یک نکته: InputText استاندارد در حالت معمول آن، پس از تغییر focus به یک کنترل دیگر، سبب بروز رویداد OnFieldChanged می‌شود و نه در حالت فشرده شدن کلیدها. به همین جهت اگر برنامه پیوستی را می‌خواهید آزمایش کنید، نیاز است فقط focus را تغییر دهید و یا یک کنترل سفارشی را برای اینکار توسعه دهید.


کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: BlazorAsyncValidation.zip
  • #
    ‫۱ سال و ۷ ماه قبل، چهارشنبه ۲۸ دی ۱۴۰۱، ساعت ۱۷:۳۶
    یک نکته‌ی تکمیلی: امکان اجرای ساده‌تر اعمال async پس از رخ‌داد onchange در Blazor 7x

    پیشنیاز: برای اجرای نکات زیر، نیاز به حداقل NET SDK 7.0.101. است و اگر از ویژوال استودیو استفاده می‌کنید، باید شماره نگارش آن حداقل 17.4.3 باشد؛ در غیراینصورت با خطای «'cannot convert from 'method group' to 'Action» مواجه خواهید شد.


    همانطور که در مطلب فوق هم مشاهده کردید، در جهت انجام اعتبارسنجی از راه دور async پس از ورود اطلاعات، تنها رخ‌دادی که در اینجا در اختیار ما قرار می‌گیرد، رخ‌داد submit (در حالت موفقیت اعتبارسنجی سمت کلاینت و یا تنها submit معمولی) است. بنابراین برای دسترسی به رخ‌دادهای بیشتر EditForm، نیاز است با EditContext آن کار کنیم تا بتوانیم برای مثال به کمک رویداد OnFieldChanged آن، این عملیات async را انجام دهیم. در دات نت 7.0.1، این وضعیت با معرفی modifier جدیدی به نام bind:after@ تغییر کرده‌است که در ادامه توضیحات آن‌را ملاحظه خواهید کرد.


    تعاریف زیر را جهت پیاده سازی یک انقیاد دوطرفه (two-way data-binding) درنظر بگیرید:
    <input @bind="username" />
    
    <InputText @bind-Value="Model.Name" />
    که در اولی با درج bind@ بر روی یک المان استاندارد HTML و در دومی با ذکر bind-Value@ میسر شده‌است. در این حالت هر تغییری در مقدار کنترل قرار گرفته‌ی بر روی صفحه، به خاصیت متصل به آن منعکس می‌شود (با پیاده سازی خودکار یک رویدادگردان onchange توسط Blazor در پشت صحنه) و برعکس.
    مشکل! اگر در اینجا نیاز باشد تا در حین ورود اطلاعات، کدی نیز اجرا شود چه باید کرد؟
    متاسفانه در این حالت نمی‌توانیم رویدادگردان onchange را به صورت دستی، به تعاریف فوق اضافه کنیم و اگر چنین کاری را انجام دهیم، با خطای زیر مواجه خواهیم شد:
    RZ10008 The attribute 'onchange' is used two or more times for this element.
    Attributes must be unique (case-insensitive). 
    The attribute 'onchange' is used by the '@bind' directive attribute.
    عنوان می‌کند که چون ما خودمان onchange را راسا پیاده سازی کرده‌ایم، شما دیگر نمی‌توانید اینکار را مجددا انجام دهید!

    راه حل‌های ممکن انجام اعمال async پس از بروز تغییرات تا پیش از دات نت 7

    الف) username متصل را تبدیل به یک خاصیت get و set دار کرده و اکنون در قسمت set آن می‌توان عملیات synchronous ای را انجام داد که متاسفانه در این حالت، امکان انجام اعمال async میسر نیست.
    ب) چون می‌خواهیم عملیات async ای را پس از تغییرات انجام دهیم، باید از انقیاد دوطرفه صرفنظر کنیم و مدیریت رویداد onchange را خودمان به‌دست بگیریم؛ برای نمونه در مثال زیر می‌توان با پیاده سازی async متد CheckUsername به هدف خود رسید؛ اما همانطور که مشاهده می‌کنید، این عملیات اکنون one-way binding است:
    <input value="@username" @onchange="CheckUsername" />
    ج) اگر از EditForm و کنترل‌های آن استفاده می‌کنیم، می‌توان همانند مثال مطلب جاری از رویداد OnFieldChanged استفاده کرد یا راه دیگر آن شکستن bind-Value@ به اجزای تشکیل دهنده‌ی آن است که سه جزء Value ،ValueExpression و ValueChanged را تشکیل می‌دهد و اینبار می‌توان رویداد ValueChanged آن‌را دستی پیاده سازی کرد:
    <InputText
      Value="@Model.Name"
      ValueExpression="()=>Model.Name"
      ValueChanged="(string s)=>CheckUsername(s)" />  
    <ValidationMessage For="() => Model.Name" />

    راه حل جدید انجام اعمال async پس از بروز تغییرات در دات نت 7

    Blazor در دات نت 7، به همراه یک bind:after modifier@ است که امکان اجرای متدی را (چه همزمان یا غیرهمزمان) پس از بروز تغییرات، میسر می‌کند و مزیت آن عدم نیاز به بازنویسی متد onchange و از دست دادن انقیاد دوطرفه است:
    <input @bind="username" @bind:after="CheckUsername" />
    همانطور که مشاهده می‌کنید هنوز در این حالت bind@ وجود دارد (یعنی two-way data-binding هنوز هم برقرار است) و توسط bind:after@، متدی را که قرار است پس از تغییرات اجرا شود، مشخص کرده‌ایم.

    این modifier را حتی می‌توان به کنترل‌های EditForm نیز اعمال کرد؛ بدون اینکه نیازی به استفاده از راه‌حل‌های پیشین (حالت ج عنوان شده) باشد:
    <InputText
      @bind-Value="Model.Name"
      @bind-Value:after="CheckUsername" />   
    <ValidationMessage For="() => Model.Name" />
    در اینجا نیز هنوز از مزایای two-way data-binding برخورداریم و همچنین می‌توانیم پس از تغییری، یک متد sync و یا async را فراخوانی کنیم. برای نمونه پیاده سازی اعتبارسنجی از راه دور مطلب جاری، اینبار به صورت زیر ساده می‌شود:
    async Task CheckUsername()
    {
        if (!string.IsNullOrWhiteSpace(Model.Name))
        {
            _messageStore?.Clear(EditContext.Field(nameof(UserDto.Name)));
    
    
            var response = await HttpClient.PostAsJsonAsync(
                        UserValidationUrl,
                        new UserDto { Name = Model.Name });
            var responseContent = await response.Content.ReadAsStringAsync();
            if (string.Equals(responseContent, "false", StringComparison.OrdinalIgnoreCase))
            {
                _messageStore?.Add(EditContext.Field(nameof(UserDto.Name)), 
                          $"`{Model.Name}` is in use. Please choose another name.");        
            }
    
            EditContext.NotifyValidationStateChanged();
        }
    }