نگاهی به ساختار طراحی اعتبارسنجی از راه دور در ASP.NET MVC و jQuery Validator
در نگارشهای مختلف ASP.NET MVC و ASP.NET Core، ویژگی Remote سمت سرور، سبب درج یک چنین ویژگیهایی در سمت کلاینت میشود:
data-val-remote="کلمه عبور وارد شده را راحت می‌توان حدس زد!" data-val-remote-additionalfields="*.Password1" data-val-remote-type="POST" data-val-remote-url="/register/checkpassword"
- متن نمایشی خطای اعتبارسنجی.
- تعدادی فیلد اضافی که در صورت نیز از فرم استخراج میشوند و به سمت سرور ارسال خواهند شد.
- نوع روش ارسال اطلاعات به سمت سرور.
- یک URL که مشخص میکند، این اطلاعات باید به کدام اکشن متد در سمت سرور ارسال شوند.
سمت سرور هم میتواند یک true یا false را بازگشت دهد و مشخص کند که آیا اطلاعات مدنظر معتبر نیستند یا هستند.
شبیه به یک چنین ساختاری را در ادامه با ایجاد یک دایرکتیو سفارشی اعتبارسنجی برنامههای Angular تدارک خواهیم دید.
ساختار اعتبارسنجهای سفارشی async در Angular
در مطلب «نوشتن اعتبارسنجهای سفارشی برای فرمهای مبتنی بر قالبها در Angular» جزئیات نوشتن اعتبارسنجهای متداول فرمهای Angular را بررسی کردیم. این نوع اعتبارسنجها چون اطلاعاتی را به صورت Ajax ایی به سمت سرور ارسال نمیکنند، با پیاده سازی اینترفیس Validator تهیه خواهند شد:
export class EmailValidatorDirective implements Validator {
export class RemoteValidatorDirective implements AsyncValidator {
validate(c: AbstractControl): Promise<ValidationErrors | null> | Observable<ValidationErrors | null>;
پیاده سازی یک اعتبارسنج از راه دور مبتنی بر Observableها در Angular
ابتدا یک دایرکتیو جدید را به نام RemoteValidator به ماژول custom-validators اضافه کردهایم:
>ng g d CustomValidators/RemoteValidator -m custom-validators.module
import { Directive, Input } from "@angular/core"; import { AsyncValidator, AbstractControl, NG_ASYNC_VALIDATORS } from "@angular/forms"; import { Http, RequestOptions, Response, Headers } from "@angular/http"; import "rxjs/add/operator/map"; import "rxjs/add/operator/distinctUntilChanged"; import "rxjs/add/operator/takeUntil"; import "rxjs/add/operator/take"; import { Observable } from "rxjs/Observable"; import { Subject } from "rxjs/Subject"; @Directive({ selector: "[appRemoteValidator][formControlName],[appRemoteValidator][formControl],[appRemoteValidator][ngModel]", providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective, multi: true } ] }) export class RemoteValidatorDirective implements AsyncValidator { @Input("remote-url") remoteUrl: string; @Input("remote-field") remoteField: string; @Input("remote-additional-fields") remoteAdditionalFields: string; constructor(private http: Http) {} validate(control: AbstractControl): Observable<{ [key: string]: any }> { if (!this.remoteUrl || this.remoteUrl === undefined) { return Observable.throw("`remoteUrl` is undefined."); } if (!this.remoteField || this.remoteField === undefined) { return Observable.throw("`remoteField` is undefined."); } const dataObject = {}; if ( this.remoteAdditionalFields && this.remoteAdditionalFields !== undefined ) { const otherFields = this.remoteAdditionalFields.split(","); otherFields.forEach(field => { const name = field.trim(); const otherControl = control.root.get(name); if (otherControl) { dataObject[name] = otherControl.value; } }); } // This is used to signal the streams to terminate. const changed$ = new Subject<any>(); changed$.next(); // This will signal the previous stream (if any) to terminate. const debounceTime = 400; return new Observable((obs: any) => { control.valueChanges .takeUntil(changed$) .take(1) .debounceTime(debounceTime) .distinctUntilChanged() .flatMap(term => { dataObject[this.remoteField] = term; return this.doRemoteValidation(dataObject); }) .subscribe( (result: IRemoteValidationResult) => { if (result.result) { obs.next(null); } else { obs.next({ remoteValidation: { remoteValidationMessage: result.message } }); } obs.complete(); }, error => { obs.next(null); obs.complete(); } ); }); } private doRemoteValidation(data: any): Observable<IRemoteValidationResult> { const headers = new Headers({ "Content-Type": "application/json" }); // for ASP.NET MVC const options = new RequestOptions({ headers: headers }); return this.http .post(this.remoteUrl, JSON.stringify(data), options) .map(this.extractData) .do(result => console.log("remoteValidation result: ", result)) .catch(this.handleError); } private extractData(res: Response): IRemoteValidationResult { const body = <IRemoteValidationResult>res.json(); return body || (<IRemoteValidationResult>{}); } private handleError(error: Response): Observable<any> { console.error("observable error: ", error); return Observable.throw(error.statusText); } } export interface IRemoteValidationResult { result: boolean; message: string; }
ساختار Directive تهیه شده مانند همان مطلب «نوشتن اعتبارسنجهای سفارشی برای فرمهای مبتنی بر قالبها در Angular» است، تنها با یک تفاوت:
@Directive({ selector: "[appRemoteValidator][formControlName],[appRemoteValidator][formControl],[appRemoteValidator][ngModel]", providers: [ { provide: NG_ASYNC_VALIDATORS, useExisting: RemoteValidatorDirective, multi: true } ] })
سپس ورودیهای این دایرکتیو را مشاهده میکنید:
export class RemoteValidatorDirective implements AsyncValidator { @Input("remote-url") remoteUrl: string; @Input("remote-field") remoteField: string; @Input("remote-additional-fields") remoteAdditionalFields: string;
<input #username="ngModel" required maxlength="8" minlength="4" type="text" appRemoteValidator [remote-url]="remoteUsernameValidationUrl" remote-field="FirstName" remote-additional-fields="email,password" class="form-control" name="username" [(ngModel)]="model.username">
- ویژگی remote-field مشخص میکند که اطلاعات المان جاری با چه کلیدی به سمت سرور ارسال شود.
- ویژگی remote-additional-fields مشخص میکند که علاوه بر اطلاعات کنترل جاری، اطلاعات کدامیک از کنترلهای دیگر را نیز میتوان به سمت سرور ارسال کرد.
یک نکته:
ذکر "remote-field="FirstName به معنای انتساب مقدار رشتهای FirstName به خاصیت متناظر با ویژگی remote-field است.
انتساب ویژهی "remoteUsernameValidationUrl" به [remote-url]، به معنای انتساب مقدار متغیر remoteUsernameValidationUrl که در کامپوننت متناظر این قالب مقدار دهی میشود، به خاصیت متصل به ویژگی remote-url است.
export class UserRegisterComponent implements OnInit { remoteUsernameValidationUrl = "api/Employee/CheckUser";
[remote-field]="'FirstName'"
ساختار مورد انتظار بازگشتی از سمت سرور
در کدهای فوق، یک چنین ساختاری باید از سمت سرور بازگشت داده شود:
export interface IRemoteValidationResult { result: boolean; message: string; }
namespace AngularTemplateDrivenFormsLab.Controllers { [Route("api/[controller]")] public class EmployeeController : Controller { [HttpPost("[action]")] [ResponseCache(Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult CheckUser([FromBody] Employee model) { var remoteValidationResult = new { result = true, message = $"{model.FirstName} is fine!" }; if (model.FirstName?.Equals("Vahid", StringComparison.OrdinalIgnoreCase) ?? false) { remoteValidationResult = new { result = false, message = "username:`Vahid` is already taken." }; } return Json(remoteValidationResult); } } }
همچنین اعتبارسنج سفارشی از راه دور فوق، پیامها را تنها از طریق HttpPost ارسال میکند. علت اینجا است که در حالت POST، برخلاف حالت GET میتوان اطلاعات بیشتری را بدون نگرانی از طول URL، ارسال کرد و همچنین کل درخواست، به علت وجود کاراکترهای غیرمجاز در URL (حالت GET، به درخواست یک URL از سرور تفسیر میشود)، برگشت نمیخورد.
تکمیل کامپوننت فرم ثبت نام کاربران
در ادامه تکمیل قالب user-register.component.html را مشاهده میکنید:
<div class="form-group" [class.has-error]="username.invalid && username.touched"> <label class="control-label">User Name</label> <input #username="ngModel" required maxlength="8" minlength="4" type="text" appRemoteValidator [remote-url]="remoteUsernameValidationUrl" remote-field="FirstName" remote-additional-fields="email,password" class="form-control" name="username" [(ngModel)]="model.username"> <div *ngIf="username.pending" class="alert alert-warning"> Checking server, Please wait ... </div> <div *ngIf="username.invalid && username.touched"> <div class="alert alert-danger" *ngIf="username.errors.remoteValidation"> {{username.errors.remoteValidation.remoteValidationMessage}} </div> </div> </div>
زمانیکه یک async validator مشغول به کار است و هنوز پاسخی را دریافت نکردهاست، خاصیت pending را به true تنظیم میکند. به این ترتیب میتوان پیام اتصال به سرور را نمایش داد:
همچنین چون در اینجا نحوهی طراحی شکست اعتبارسنجی به صورت ذیل است:
obs.next({ remoteValidation: { remoteValidationMessage: result.message } });
مزایای استفاده از Observableها در حین طراحی async validators
در کدهای فوق چنین مواردی را هم مشاهده میکنید:
// This is used to signal the streams to terminate. const changed$ = new Subject<any>(); changed$.next(); // This will signal the previous stream (if any) to terminate. const debounceTime = 400; return new Observable((obs: any) => { control.valueChanges .takeUntil(changed$) .take(1) .debounceTime(debounceTime) .distinctUntilChanged()
همچنین وجود و تعریف new Subject، دراینجا ضروری است و از نشتی حافظه و همچنین رفت و برگشتهای اضافهی دیگری به سمت سرور، جلوگیری میکند. این subject سبب میشود تا کلیه اعمال ناتمام پیشین، لغو شده (takeUntil) و تنها آخرین درخواست جدید رسیدهی پس از 400 میلیثانیه، به سمت سرور ارسال شود.
بنابراین همانطور که مشاهده میکنید، Observableها فراتر هستند از صرفا ارسال اطلاعات به سرور و بازگشت آنها به سمت کلاینت (استفادهی متداولی که از آنها در برنامههای Angular وجود دارد).
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید.
در مباحث اعتبارسنجی و احراز هویت Blazor در قسمتهای بعدی، به قطعه کد context@ داری در داخل یک RenderFragment خواهیم رسید:
<AuthorizeView> <Authorized> Hello, @context.User.Identity.Name! </Authorized> </AuthorizeView>
برای پاسخ به این سؤال نیاز است با مفهوم «Templated components» در برنامههای Blazor آشنا شد. تا اینجا از RenderFragmentها صرفا جهت فراهم آوردن قسمت ثابتی از قالب کامپوننت جاری، توسط استفاده کنندهی از آن، کمک گرفتیم. اما در همان سمت استفاده کننده، امکان دسترسی به اطلاعات مهیای داخل آن فرگمنت نیز وجود دارد. برای نمونه به کدهای کامپوننت TableTemplate.razor دقت کنید:
@typeparam Titem <table class="table"> <thead> <tr>@TableHeader</tr> </thead> <tbody> @foreach (var item in Items) { <tr>@RowTemplate(item)</tr> } </tbody> </table> @code { [Parameter] public RenderFragment TableHeader { get; set; } [Parameter] public RenderFragment<TItem> RowTemplate { get; set; } [Parameter] public IReadOnlyList<TItem> Items { get; set; } }
- همچنین یک RenderFragment جنریک را نیز مشاهده میکنید که قالب ردیفهای جدول را تامین میکند. نوع جنریک قابل دسترسی در این کامپوننت، توسط دایرکتیو typeparam Titem@ تعریف شدهی در ابتدای آن، مشخص شدهاست.
- بنابراین هربار که ردیفی از بدنهی جدول در حال رندر است، یک شیء item از نوع TItem را به قالب سفارشی تامین شدهی توسط بکارگیرندهی خود ارسال میکند.
اکنون این سؤال مطرح میشود که چگونه میتوان به شیء item، در سمت والد یا بکارگیرندهی کامپوننت TableTemplate فوق دسترسی یافت؟
برای اینکار میتوان از پارامتر ویژهای به نام context که یک implicit parameter است و به صورت پیشفرض تعریف شده و مهیا است، در سمت کدهای بکارگیرندهی کامپوننت، استفاده کرد:
@page "/pets" <h1>Pets</h1> <TableTemplate Items="pets"> <TableHeader> <th>ID</th> <th>Name</th> </TableHeader> <RowTemplate> <td>@context.PetId</td> <td>@context.Name</td> </RowTemplate> </TableTemplate> @code { private List<Pet> pets = new() { new Pet { PetId = 2, Name = "Mr. Bigglesworth" }, new Pet { PetId = 4, Name = "Salem Saberhagen" }, new Pet { PetId = 7, Name = "K-9" } }; private class Pet { public int PetId { get; set; } public string Name { get; set; } } }
- همچنین در اینجا پارامتر ویژهای به نام context@ را نیز مشاهده میکنید. این پارامتر همان شیء item ای است که در حین رندر هر ردیف جدول، به فرگمنت RowTemplate ارسال میشود. به این ترتیب کامپوننت والد میتواند از اطلاعات در حال رندر توسط کامپوننت فرگمنت دار، مطلع شود و از آن استفاده کند. در این مثال، نوع context@، از نوع کلاس Pet است که سعی شده بر اساس نوع پارامتر Items ارسالی به آن، توسط کامپایلر تشخیص داده شود. حتی میتوان این نوع را به صورت صریحی نیز مشخص کرد:
<TableTemplate Items="pets" TItem="Pet">
<SomeGenericComponent TParam1="Person" TParam2="Supplier" TItem=etc/>
<TableTemplate Items="pets" Context="pet"> <TableHeader> <th>ID</th> <th>Name</th> </TableHeader> <RowTemplate> <td>@pet.PetId</td> <td>@pet.Name</td> </RowTemplate> </TableTemplate>
<TableTemplate Items="pets"> <TableHeader> <th>ID</th> <th>Name</th> </TableHeader> <RowTemplate Context="pet"> <td>@pet.PetId</td> <td>@pet.Name</td> </RowTemplate> </TableTemplate>
- موقعی که کلاینت درخواست خود را مبنی بر یکی از منابع سرور ارسال میکند، Http.sys این درخواست را میگیرد.
- http.sys با WAS تماس گرفته و درخواست میکند تا اطلاعات پیکربندی یا تنظیمات IIS را برای نحوهی برخورد با درخواست، برایش بفرستد.
- WAS هم اطلاعات پیکربندی شده را از محل ذخیره دادهها که applicationHost.config هست، میخواند.
- WWW Service که یک آداپتور برای Http.sys هست، اطلاعات را از WAS دریافت میکند. این اطلاعات شامل پیکربندی application pool و سایت میباشد.
- WWW Service اطلاعات را برای Http.sys میفرستد.
- WAS یک پروسه کارگر را در application pool ایجاد میکند تا درخواست رسیده مورد پردازش قرار بگیرد.
- پروسههای کارگر درخواست را پردازش کرده و خروجی یا response مورد نظر را تولید میکنند.
- Http.sys نتیجه را دریافت و برای کلاینت میفرستد.
حال بیایید ببینیم موقعی که درخواست وارد پروسهی کارگر میشود چه اتفاقی میافتد؟
در پروسههای کارگر، یک درخواست از مراحل لیست شده ای به ترتیب عبور میکند. در هسته وب سرور، رویدادهایی را فراخوانی میکند که در هر رویداد چندین ماژول native برای کارهایی چون authentication یا events logs دارد و در صورتیکه درخواستی نیاز به یک ماژول مدیریت شده CLR داشته باشد، از ماژول native managedEngine کمک گرفته و یک app domain را ایجاد میکند تا ماژولهای مدیریت شده، عملیات لازم خودشان را انجام دهند. مثل authentication form و ...
موقعی هم که درخواست، از تمامی این رویدادها عبور کند، response برای http.sys ارسال میشود تا به کلاینت بازگشت داده شود. شکل زیر نحوه ورود یک درخواست به پروسه کارگر را نشان میدهد.
از نسخه 7 به بعد، IIS از یک معماری ماژولار استفاده میکند و این ویژگی، سه فایده دارد:
- Componentization یا کامپوننت سازی
- Extensibility یا توسعه پذیری یا قابل گشترش
- ASP.NET Integration
Componentization
- باعث کاهش attack surface میشود که در نتیجه امنیت سیستم را بالا میبرد. با ویژگی حذف کامپوننتها شما میتوانید ویژگیهای غیرقابل استفاده IIS را حذف کنید تا وروردیهای سیستم کاهش یابد. پس با کاهش ویژگیهایی که از آن هرگز استفاده نخواهید کرد، مدخل ورود هکر را از بین برده تا امنیت سرور بالاتر برود.
- افزایش کارآیی و کاهش مصرف حافظه. با حذف ویژگیهایی که هرگز استفاده نمیکنید، در مصرف حافظه و بهینه استفاده شدن منابع سرور صرفه جویی کنید.
- با وجود ویژگی افزودن و جایگزینی کامپوننتها، ناخودآگاه ذهن ما به سمت کاستوم سازی یا خصوصی سازی کشیده میشود. با این کار شما به راحتی یک custom server ایجاد میکنید که این سرور بر اساس علایق شما کارش را انجام میدهد و به راحتی امکاناتی چون افزودن third partyها را به توسعه دهنده میدهد.
Extensibility
- قدرت بخشی به برنامههای وب. امکانات و قدرتی که میتواند در این حالت به برنامههای در حال اجرا داد به مراتب بیشتر از استفاده از لایههای داخلی خود برنامه هست. برای اینکار شما میتوانید کدهای خود را با ASP.Net نوشته یا از کدهای native چون ++C استفاده کنید.
- تجربهای از توسعه پذیری سادهتر و راحت تر
- استفاده از قدرت و تمامی امکانات را به شما میدهد و میتوانید تمام دستورات را برای همه منابع حتی فایلهای ایستا، CGI ، ASP و دیگر منابع اجرا کنید.
ASP.NET Integration
در یک پروژه wasm برای ساخت یک داشبورد که شامل تعدادی کامپوننت بوده و هر کامپوننت خود در هر ثانیه درخواستی را به سرور ارسال میکندو رفرش یا رندر مجدد این کامپوننتها در یک تایمر فراخوانی میشود چه روش پیاده سازی پیشنهاد داده میشودکه تعداد زیاد درخواستها باعث کندی یا بلاک شدن ui نشود.و اینکه اگر به جای ارسال درخواستها توسط httpclient ، درخواستها به هاب signalr ارسال شود در بلاک شدن ui تاثیر دارد یا خیر؟
Command ما که نقش ایجاد یک مشتری را داشت ( CreateCustomerCommand )، هیچ Validation ای برای اعتبار سنجی مقادیر ورودی از سمت کاربر را ندارد و کاربر با هر مقادیری میتواند این Command را فراخوانی کند. در این قسمت با استفاده از کتابخانه Fluent Validation امکان اعتبار سنجی را به Commandهای خود اضافه میکنیم.
در ابتدا با استفاده از دستور زیر ، این کتابخانه را داخل پروژه خود نصب میکنیم:
Install-Package FluentValidation.AspNetCore
بعد از افزودن این کتابخانه، باید آن را داخل DI Container خود Register کنیم:
services.AddMvc() .AddFluentValidation(cfg => cfg.RegisterValidatorsFromAssemblyContaining<Startup>());
کلاس جدیدی با نام CreateCustomerCommandValidator ایجاد میکنیم و از کلاس AbstractValidator مربوط به Fluent Validation ارث بری میکنیم تا منطق اعتبارسنجی برای CreateCustomerCommand را داخل آن تعریف نماییم :
public class CreateCustomerCommandValidator : AbstractValidator<CreateCustomerCommand> { public CreateCustomerCommandValidator() { RuleFor(customer => customer.FirstName).NotEmpty(); RuleFor(customer => customer.LastName).NotEmpty(); } }
اگر برنامه را اجرا و CreateCustomerCommand را با مقادیر خالی فراخوانی کنیم، خواهید دید که بلافاصله با چنین خطایی مواجه خواهید شد که نشان میدهد Fluent Validation بدرستی وظیفه اعتبارسنجی ورودیها را انجام داده است:
Error: Bad Request { "LastName": [ "'Last Name' must not be empty." ], "FirstName": [ "'First Name' must not be empty." ] }
* نکته : تمامی اعتبارسنجیهای سطحی ( Superficial Validation ) مانند خالی نبودن مقادیر، اعتبارسنجی تاریخها، اعتبارسنجی ایمیل و ... باید قبل از Handle شدن Commandها صورت گیرد و در صورت ناموفق بودن اعتبارسنجی، نباید وارد متد Handle در Commandها شویم. ( Fail Fast Principle )
Events
برای حل این مشکل میتوانیم از Eventها استفاده کنیم. Eventها خبری را به Subscriber های خود میدهند. در فریمورک MediatR، ارسال و Handle کردن Eventها، با دو interface صورت میگیرد: INotification و INotificationHandler
بر خلاف Commandها که فقط یک Handler میتوانند داشته باشند، Event ها میتوانند چندین Handler داشته باشند. مزیت داشتن چند Subscriber برای Eventها این است که شما علاوه بر اینکه میتوانید Subscriber ای داشته باشید که وظیفه ارسال Email برای مشتری را بر عهده داشته باشد، Subscriber دیگری داشته باشید که اطلاعات مشتری جدید را Log کند.
ابتدا کلاس CustomerCreatedEvent را ایجاد و از INotification ارث بری میکنیم. این کلاس منتشر کننده یک اتفاق است که آن را Producer مینامند :
public class CustomerCreatedEvent : INotification { public CustomerCreatedEvent(string firstName, string lastName, DateTime registrationDate) { FirstName = firstName; LastName = lastName; RegistrationDate = registrationDate; } public string FirstName { get; } public string LastName { get; } public DateTime RegistrationDate { get; } }
سپس دو Handler برای این Event مینویسیم. Handler اول وظیفه ارسال ایمیل را بر عهده خواهد داشت :
public class CustomerCreatedEmailSenderHandler : INotificationHandler<CustomerCreatedEvent> { public Task Handle(CustomerCreatedEvent notification, CancellationToken cancellationToken) { // IMessageSender.Send($"Welcome {notification.FirstName} {notification.LastName} !"); return Task.CompletedTask; } }
و Handler دوم، وظیفه Log کردن اطلاعات مشتری ثبت شده را بر عهده خواهد داشت:
public class CustomerCreatedLoggerHandler : INotificationHandler<CustomerCreatedEvent> { readonly ILogger<CustomerCreatedLoggerHandler> _logger; public CustomerCreatedLoggerHandler(ILogger<CustomerCreatedLoggerHandler> logger) { _logger = logger; } public Task Handle(CustomerCreatedEvent notification, CancellationToken cancellationToken) { _logger.LogInformation($"New customer has been created at {notification.RegistrationDate}: {notification.FirstName} {notification.LastName}"); return Task.CompletedTask; } }
در نهایت کافیست داخل CreateCustomerCommandHandler که در قسمت قبل آن را ایجاد کردیم، متد Handle را ویرایش و با استفاده از متد Publish موجود در اینترفیس IMediator، این Event را Raise کنیم :
public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CustomerDto> { readonly ApplicationDbContext _context; readonly IMapper _mapper; readonly IMediator _mediator; public CreateCustomerCommandHandler(ApplicationDbContext context, IMapper mapper, IMediator mediator) { _context = context; _mapper = mapper; _mediator = mediator; } public async Task<CustomerDto> Handle(CreateCustomerCommand createCustomerCommand, CancellationToken cancellationToken) { Customer customer = _mapper.Map<Customer>(createCustomerCommand); await _context.Customers.AddAsync(customer, cancellationToken); await _context.SaveChangesAsync(cancellationToken); // Raising Event ... await _mediator.Publish(new CustomerCreatedEvent(customer.FirstName, customer.LastName, customer.RegistrationDate), cancellationToken); return _mapper.Map<CustomerDto>(customer); } }
برنامه را اجرا و روی دو NotificationHandler خود Breakpoint قرار دهید. اگر api/Customers را برای ایجاد یک مشتری جدید فراخوانی کنید، بعد از ثبت نام موفق مشتری، خواهید دید که هر دو Handler شما Raise میشوند و اطلاعات مشتری را که با LogHandler خود داخل Console لاگ کردیم، خواهیم دید:
info: MediatrTutorial.Features.Customer.Events.CustomerCreated.CustomerCreatedLoggerHandler[0] New customer has been created at 2/1/2019 11:40:48 PM: Moien Tajik
* نکته : در این قسمت از آموزش برای Log کردن اطلاعات از یک Notification استفاده کردیم. اگر تعداد Commandهای ما در برنامه بیشتر شوند، به ازای هر Command مجبور به ایجاد یک Notification و NotificationHandler خواهیم بود که منطق کار آنها بسیار شبیه به یک دیگر است.
در مقاله بعدی با استفاده از Behaviors موجود در MediatR که AOP را پیاده سازی میکند، این موارد تکراری را از بین خواهیم برد.
ASP.NET MVC #1
برای قسمت backend، از همان برنامهی تکمیل شدهی قسمت قبل استفاده میکنیم که به آن تولید مقدماتی JWTها نیز اضافه شدهاست. البته این سری، مستقل از قسمت سمت سرور آن تهیه خواهد شد و صرفا در حد دریافت توکن از سرور و یا ارسال مشخصات کاربر جهت لاگین، نیاز بیشتری به قسمت سمت سرور آن ندارد و تاکید آن بر روی مباحث سمت کلاینت React است. بنابراین اینکه چگونه این توکن را تولید میکنید، در اینجا اهمیتی ندارد و کلیات آن با تمام روشهای پیاده سازی سمت سرور سازگار است (و مختص به فناوری خاصی نیست). پیشنیاز درک کدهای سمت سرور قسمت JWT آن، مطالب زیر هستند:
- «معرفی JSON Web Token»
- «اعتبارسنجی مبتنی بر JWT در ASP.NET Core 2.0 بدون استفاده از سیستم Identity»
- «پیاده سازی JSON Web Token با ASP.NET Web API 2.x»
- « آزمایش Web APIs توسط Postman - قسمت ششم - اعتبارسنجی مبتنی بر JWT»
ثبت یک کاربر جدید
فرم ثبت نام کاربران را در قسمت 21 این سری، در فایل src\components\registerForm.jsx، ایجاد و تکمیل کردیم. البته این فرم هنوز به backend server متصل نیست. برای کار با آن هم نیاز است شیءای را با ساختار زیر که ذکر سه خاصیت اول آن اجباری است، به endpoint ای با آدرس https://localhost:5001/api/Users به صورت یک HTTP Post ارسال کنیم:
{ "name": "string", "email": "string", "password": "string", "isAdmin": true, "id": 0 }
اکنون نوبت به اتصال کامپوننت registerForm.jsx، به سرویس backend است. تا اینجا دو سرویس src\services\genreService.js و src\services\movieService.js را در قسمت قبل، به برنامه جهت کار کردن با endpointهای backend server، اضافه کردیم. شبیه به همین روش را برای کار با سرویس سمت سرور api/Users نیز در پیش میگیریم. بنابراین فایل جدید src\services\userService.js را با محتوای زیر، به برنامهی frontend اضافه میکنیم:
import http from "./httpService"; import { apiUrl } from "../config.json"; const apiEndpoint = apiUrl + "/users"; export function register(user) { return http.post(apiEndpoint, { email: user.username, password: user.password, name: user.name }); }
اطلاعات شیء user ای که در اینجا دریافت میشود، از خاصیت data کامپوننت RegisterForm تامین میگردد:
class RegisterForm extends Form { state = { data: { username: "", password: "", name: "" }, errors: {} };
پس از تعریف userService.js، به registerForm.jsx بازگشته و ابتدا امکانات آنرا import میکنیم:
import * as userService from "../services/userService";
import { register } userService from "../services/userService";
doSubmit = async () => { try { await userService.register(this.state.data); } catch (ex) { if (ex.response && ex.response.status === 400) { const errors = { ...this.state.errors }; // clone an object errors.username = ex.response.data; this.setState({ errors }); } } };
در این حالت برای ارسال اطلاعات یک کاربر، در بار اول، یک چنین خروجی را از سمت سرور میتوان شاهد بود که id جدیدی را به این رکورد نسبت دادهاست:
اگر مجددا همین رکورد را به سمت سرور ارسال کنیم، اینبار خطای زیر را دریافت خواهیم کرد:
که از نوع 400 یا همان BadRequest است:
بنابراین نیاز است بدنهی response را در یک چنین مواردی که خطایی از سمت سرور صادر میشود، دریافت کرده و با به روز رسانی خاصیت errors در state فرم (همان قسمت بدنهی catch کدهای فوق)، سبب درج و نمایش خودکار این خطا شویم:
پیشتر در قسمت بررسی «کار با فرمها» آموختیم که برای مدیریت خطاهای پیش بینی شدهی دریافتی از سمت سرور، نیاز است قطعه کدهای مرتبط با سرویس http را در بدنهی try/catchها محصور کنیم. برای مثال در اینجا اگر ایمیل شخصی تکراری وارد شود، سرویس یک return BadRequest("Can't create the requested record.") را بازگشت میدهد که در اینجا status code معادل BadRequest، همان 400 است. بنابراین انتظار داریم که خطای 400 را از سمت سرور، تحت شرایط خاصی دریافت کنیم. به همین دلیل است که در اینجا تنها مدیریت status code=400 را در بدنهی catch نوشته شده ملاحظه میکنید.
سپس برای نمایش آن، نیاز است خاصیت متناظری را که این خطا به آن مرتبط میشود، با پیام دریافت شدهی از سمت سرور، مقدار دهی کنیم که در اینجا میدانیم مرتبط با username است. به همین جهت سطر errors.username = ex.response.data، کار انتساب بدنهی response را به خاصیت جدید errors.username انجام میدهد. در این حالت اگر به کمک متد setState، کار به روز رسانی خاصیت errors موجود در state را انجام دهیم، رندر مجدد فرم، در صف انجام قرار گرفته و در رندر بعدی آن، پیام موجود در errors.username، نمایش داده میشود.
پیاده سازی ورود به سیستم
فرم ورود به سیستم را در قسمت 18 این سری، در فایل src\components\loginForm.jsx، ایجاد و تکمیل کردیم که این فرم نیز هنوز به backend server متصل نیست. برای کار با آن نیاز است شیءای را با ساختار زیر که ذکر هر دو خاصیت آن اجباری است، به endpoint ای با آدرس https://localhost:5001/api/Auth/Login به صورت یک HTTP Post ارسال کنیم:
{ "email": "string", "password": "string" }
var jwt = _tokenFactoryService.CreateAccessToken(user); return Ok(new { access_token = jwt });
در ادامه برای تعامل با منبع api/Auth/Login سمت سرور، ابتدا یک سرویس مختص آنرا در فایل جدید src\services\authService.js، با محتوای زیر ایجاد میکنیم:
import { apiUrl } from "../config.json"; import http from "./httpService"; const apiEndpoint = apiUrl + "/auth"; export function login(email, password) { return http.post(apiEndpoint + "/login", { email, password }); }
import * as auth from "../services/authService"; class LoginForm extends Form { state = { data: { username: "", password: "" }, errors: {} }; // ... doSubmit = async () => { try { const { data } = this.state; const { data: { access_token } } = await auth.login(data.username, data.password); console.log("JWT", access_token); localStorage.setItem("token", access_token); this.props.history.push("/"); } catch (ex) { if (ex.response && ex.response.status === 400) { const errors = { ...this.state.errors }; errors.username = ex.response.data; this.setState({ errors }); } } };
- ابتدا تمام خروجیهای ماژول authService را با نام شیء auth دریافت کردهایم.
- سپس در متد doSubmit، اطلاعات خاصیت data موجود در state را که معادل فیلدهای فرم لاگین هستند، به متد auth.login برای انجام لاگین سمت سرور، ارسال کردهایم. این متد چون یک Promise را باز میگرداند، باید await شود و پس از آن متد جاری نیز باید به صورت async معرفی گردد.
- همانطور که عنوان شد، خروجی نهایی متد auth.login، یک شیء JSON دارای خاصیت access_token است که در اینجا از خاصیت data خروجی نهایی دریافت شدهاست.
- سپس نیاز است برای استفادههای آتی، این token دریافتی از سرور را در جایی ذخیره کرد. یکی از مکانهای متداول اینکار، local storage مرورگرها است (اطلاعات بیشتر).
- در آخر کاربر را توسط شیء history سیستم مسیریابی برنامه، به صفحهی اصلی آن هدایت میکنیم.
- در اینجا قسمت catch نیز ذکر شدهاست تا خطاهای حاصل از return BadRequestهای دریافتی از سمت سرور را بتوان ذیل فیلد نام کاربری نمایش داد. روش کار آن، دقیقا همانند روشی است که برای فرم ثبت یک کاربر جدید استفاده کردیم.
اکنون اگر برنامه را ذخیره کرده و اجرا کنیم، توکن دریافتی، در کنسول توسعه دهندگان مرورگر لاگ شده و سپس کاربر به صفحهی اصلی برنامه هدایت میشود. همچنین این token ذخیره شده را میتوان در ذیل قسمت application->storage آن نیز مشاهده کرد:
لاگین خودکار کاربر، پس از ثبت نام در سایت
پس از ثبت نام یک کاربر در سایت، بدنهی response بازگشت داده شدهی از سمت سرور، همان شیء user است که اکنون Id او مشخص شدهاست. بنابراین اینبار جهت ارائهی token از سمت سرور، بجای response body، از یک هدر سفارشی در فایل Controllers\UsersController.cs استفاده میکنیم (کدهای کامل آن در انتهای بحث پیوست شدهاست):
var jwt = _tokenFactoryService.CreateAccessToken(user); this.Response.Headers.Add("x-auth-token", jwt);
در ادامه در کدهای سمت کلاینت src\components\registerForm.jsx، برای استخراج این هدر سفارشی، اگر شیء response دریافتی از سرور را لاگ کنیم:
const response = await userService.register(this.state.data); console.log(response);
برای اینکه در کدهای سمت کلاینت بتوان این هدر سفارشی را خواند، نیاز است هدر مخصوص access-control-expose-headers را نیز به response اضافه کرد:
var jwt = _tokenFactoryService.CreateAccessToken(data); this.Response.Headers.Add("x-auth-token", jwt); this.Response.Headers.Add("access-control-expose-headers", "x-auth-token");
اکنون میتوان این هدر سفارشی را در متد doSubmit کامپوننت RegisterForm، از طریق شیء response.headers خواند و در localStorage ذخیره کرد. سپس کاربر را توسط شیء history سیستم مسیریابی، به ریشهی سایت هدایت نمود:
class RegisterForm extends Form { // ... doSubmit = async () => { try { const response = await userService.register(this.state.data); console.log(response); localStorage.setItem("token", response.headers["x-auth-token"]); this.props.history.push("/"); } catch (ex) { if (ex.response && ex.response.status === 400) { const errors = { ...this.state.errors }; // clone an object errors.username = ex.response.data; this.setState({ errors }); } } };
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-26-backend.zip و sample-26-frontend.zip
@model ProjectName.ViewModels.Identity.RegisterViewModel <label asp-for="PhoneNumber"></label>
@using System.Reflection @using System.Linq.Expressions @using System.ComponentModel.DataAnnotations @typeparam T @if (ChildContent is null) { <label>@Label</label> } else { <label> @Label @ChildContent </label> } @code { [Parameter, EditorRequired] public Expression<Func<T>> DisplayNameFor { get; set; } = default!; [Parameter] public RenderFragment? ChildContent { get; set; } private string Label => GetDisplayName(); private string GetDisplayName() { var expression = (MemberExpression)DisplayNameFor.Body; var value = expression.Member.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute; return value?.Name ?? expression.Member.Name; } }
private LoginDto Login { get; } = new();
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)" />
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)"> <span class="text-danger">(*)</span> </CustomDisplayName>
@using System.Reflection @using System.Linq.Expressions; @using System.ComponentModel.DataAnnotations; @typeparam T @if (ChildContent == null) { <label>@Label</label> } else { <label> @Label @ChildContent </label> } @code { [Parameter] public Expression<Func<T>> DisplayNameFor { get; set; } = default!; [Parameter] public RenderFragment? ChildContent { get; set; } [Inject] public IStringLocalizerFactory LocalizerFactory { get; set; } = default!; private string Label => GetDisplayName(); private string GetDisplayName() { var expression = (MemberExpression)DisplayNameFor.Body; var displayAttribute = expression.Member.GetCustomAttribute(typeof(DisplayAttribute)) as DisplayAttribute; if (displayAttribute is {ResourceType: not null }) { // Try to dynamically create an instance of the specified resource type var resourceType = displayAttribute.ResourceType; var localizer = LocalizerFactory.Create(resourceType); return localizer[displayAttribute.Name ?? expression.Member.Name]; } return displayAttribute?.Name ?? expression.Member.Name; } }
public class LoginDto { [Display(Name = nameof(LoginDtoResource.UserName), ResourceType = typeof(LoginDtoResource))] [Required] [MaxLength(100)] public string UserName { get; set; } = default!; //... }
[Parameter(CaptureUnmatchedValues = true)] public Dictionary<string, object> InputAttributes { get; set; } = new();
@if (ChildContent is null) { <label @attributes="InputAttributes"> @Label </label> } else { <label @attributes="InputAttributes"> @Label @ChildContent </label> }
<CustomDisplayName DisplayNameFor="@(() => Login.UserName)" class="form-label" id="test" for="UserName" />