- Creating an IdentityServer 5 Project
- Adding JWT Bearer Authentication to an ASP.NET Core 5 API
- Adding Policy-Based Authorisation to an ASP.NET Core 5 API
- Authenticating a .NET 5 Console Application using IdentityServer 5
- Adding API Resources to IdentityServer 5
- Containerising a PostgreSQL 13 Database
- Adding Entity Framework Core 5 to IdentityServer 5
- Adding an OAuth 2.0 Security Scheme to an ASP.NET Core 5 API
- Adding ASP.NET Core 5 Identity to IdentityServer 5
- Seeding an ASP.NET Core 5 Identity Database with Users
- Authenticating an ASP.NET Core 5 Web Application using IdentityServer 5
- Authenticating an Angular 11 Single-Page Application using IdentityServer 5
- Authenticating a Flutter Android Application using IdentityServer 5
- Containerising an IdentityServer 5 Project
- Containerising an IdentityServer 5 Project with API Resources and Clients
نصب و راه اندازی
Install-Package MediatR
بعد از نصب نیاز داریم تا نیازمندیهای این فریمورک را داخل DI Container خود Register کنیم. اگر از DI Container پیشفرض ASP.NET Core استفاده کنیم ، کافیست پکیج متناسب آن با Microsoft.Extensions.DependencyInjection را نصب کرده و بهراحتی نیازمندیهای MediatR را فراهم سازیم:
Install-Package MediatR.Extensions.Microsoft.DependencyInjection
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddMediatR(); }
* اگر از DI Containerهای دیگری استفاده میکنید، میتوانید با استفاده از توضیحات این لینک MediatR را داخل Container مورد نظرتان Register کنید.
IRequest
پیاده سازی Non-Generic آن برای درخواستهایی است که Response برگشتی ندارند ( معمولا Commandها ) و منتظر جوابی از سمت آنها نیستیم و پیاده سازی Generic آن، نوع Response ای را که بعد از پردازش درخواست برگشت داده میشود، مشخص میسازد.
public class Customer { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public DateTime RegistrationDate { get; set; } }
و Dto متناسب با آن نیز به این صورت تعریف شده است :
public class CustomerDto { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string RegistrationDate { get; set; } }
افزودن مشتری، یک Command است؛ زیرا باعث افزودن رکوردی جدیدی به دیتابیس و تغییر State برنامه میشود. کلاس جدیدی به اسم CreateCustomerCommand ایجاد کرده و از IRequest ارث بری میکنیم و نوع Response برگشتی آن را CustomerDto قرار میدهیم:
public class CreateCustomerCommand : IRequest<CustomerDto> { public CreateCustomerCommand(string firstName, string lastName) { FirstName = firstName; LastName = lastName; } public string FirstName { get; } public string LastName { get; } }
کلاس CreateCustomerCommand نیازمندیهای خود را از طریق Constructor مشخص میسازد. برای ایجاد کردن یک مشتری حداقل چیزی که لازم است، Firstname و Lastname آن است و بعد از ارسال مقادیر مورد نیاز به سازنده این کلاس، مقادیر بدلیل get-only بودن قابل تغییر نیستند.
IRequestHandler
public class CreateCustomerCommandHandler : IRequestHandler<CreateCustomerCommand, CustomerDto> { readonly ApplicationDbContext _context; readonly IMapper _mapper; public CreateCustomerCommandHandler(ApplicationDbContext context, IMapper mapper) { _context = context; _mapper = mapper; } 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); return _mapper.Map<CustomerDto>(customer); } }
ورودی اول IRequestHandler، کلاسی است که درخواست، آن را پردازش خواهد کرد و پارامتر ورودی دوم، کلاسی است که در نتیجه پردازش بعنوان Response برگشت داده خواهد شد.
همانطور که میبینید در این Handler از DbContext مربوط به Entity Framework برای ثبت اطلاعات داخل دیتابیس و IMapper مربوط به AutoMapper برای نگاشت CreateCustomerCommand به Customer استفاده شده است.
تنظیمات Profile مربوط به AutoMapper ما به این صورت است تا در هنگام نگاشت CreateCustomerCommand ، مقدار RegistrationDate مربوط به Customer برابر با زمان فعلی قرار داده شود و برای نگاشت Customer به CustomerDto نیز ، تاریخ RegistrationDate با فرمتی قابل فهم به کاربران نمایش داده شود :
public class DomainProfile : Profile { public DomainProfile() { CreateMap<CreateCustomerCommand, Customer>() .ForMember(c => c.RegistrationDate, opt => opt.MapFrom(_ => DateTime.Now)); CreateMap<Customer, CustomerDto>() .ForMember(cd => cd.RegistrationDate, opt => opt.MapFrom(c => c.RegistrationDate.ToShortDateString())); } }
در نهایت با inject کردن اینترفیس IMediator به کنترلر خود و فرستادن یک درخواست POST به این اکشن، درخواست ایجاد مشتری را توسط متد Send میدهیم :
[HttpPost] public async Task<IActionResult> CreateCustomer([FromBody] CreateCustomerCommand createCustomerCommand) { CustomerDto customer = await _mediator.Send(createCustomerCommand); return CreatedAtAction(nameof(GetCustomerById), new { customerId = customer.Id }, customer); }
همانطور که میبینید ما در اینجا فقط درخواست، فرستادهایم و وظیفه پیدا کردن Handler این درخواست را فریمورک MediatR برعهده گرفتهاست و ما هیچ جایی بطور مستقیم Handler خود را صدا نزده ایم. ( Hollywood Principle: Don't Call Us, We Call You )
اینترفیس IMediator علاوه بر متد Send ، دارای متد دیگری بنام Publish نیز هست که وظیفه Raise کردن Eventها را برعهده دارد که در مقالات بعدی از آن استفاده خواهیم کرد.
چند نکته :
اگر به کدهای مثال رسمی ASP.NET Identity نگاهی بیندازید، میبینید که کلاس مربوط به جدول کاربران ApplicationUser نام دارد، ولی در سیستم IRIS نام آن User است. بهتر است که ما هم نام کلاس خود را از User به ApplicationUser تغییر دهیم چرا که مزایای زیر را به دنبال دارد:
1- به راحتی میتوان کدهای مورد نیاز را از مثال Identity کپی کرد.
2- در سیستم Iris، بین کلاس User متعلق به پروژه خودمان و User مربوط به HttpContext تداخل رخ میداد که با تغییر نام کلاس User دیگر این مشکل را نخواهیم داشت.
برای این کار وارد پروژه Iris.DomainClasses شده و نام کلاس User را به ApplicationUser تغییر دهید. دقت کنید که این تغییر نام را از طریق Solution Explorer انجام دهید و نه از طریق کدهای آن. پس از این تغییر ویژوال استودیو میپرسد که آیا نام این کلاس را هم در کل پروژه تغییر دهد که شما آن را تایید کنید.
برای آن که نام جدول Users در دیتابیس تغییری نکند، وارد پوشهی Entity Configuration شده و کلاس UserConfig را گشوده و در سازندهی آن کد زیر را اضافه کنید:
ToTable("Users");
برای نصب ASP.NET Identity دستور زیر را در کنسول Nuget وارد کنید:
Get-Project Iris.DomainClasses, Iris.Datalayer, Iris.Servicelayer, Iris.Web | Install-Package Microsoft.AspNet.Identity.EntityFramework
همچنین بهتر است که به کلاس CustomRole، یک property به نام Description اضافه کنید تا توضیحات فارسی نقش مورد نظر را هم بتوان ذخیره کرد:
public class CustomRole : IdentityRole<int, CustomUserRole> { public CustomRole() { } public CustomRole(string name) { Name = name; } public string Description { get; set; } }
نکته: پیشنهاد میکنم که اگر میخواهید مثلا نام CustomRole را به IrisRole تغییر دهید، این کار را از طریق find and replace انجام ندهید. با همین نامهای پیش فرض کار را تکمیل کنید و سپس از طریق خود ویژوال استودیو نام کلاس را تغییر دهید تا ویژوال استودیو به نحو بهتری این نامها را در سرتاسر پروژه تغییر دهد.
سپس کلاس ApplicationUser پروژه IRIS را باز کرده و تعریف آن را به شکل زیر تغییر دهید:
public class ApplicationUser : IdentityUser<int, CustomUserLogin, CustomUserRole, CustomUserClaim>
اکنون میتوانید propertyهای Id، UserName، PasswordHash و Email را حذف کنید؛ چرا که در کلاس پایه IdentityUser تعریف شده اند.
وارد Iris.DataLayer شده و کلاس IrisDbContext را به شکل زیر ویرایش کنید:
public class IrisDbContext : IdentityDbContext<ApplicationUser, CustomRole, int, CustomUserLogin, CustomUserRole, CustomUserClaim>, IUnitOfWork
public DbSet<ApplicationUser> Users { get; set; }
public IrisDbContext() : base("IrisDbContext") { }
همچنین درون متد OnModelCreating کدهای زیر را پس از فراخوانی متد (base.OnModelCreating(modelBuilder جهت تعیین نام جداول دیتابیس بنویسید:
modelBuilder.Entity<CustomRole>().ToTable("AspRoles"); modelBuilder.Entity<CustomUserClaim>().ToTable("UserClaims"); modelBuilder.Entity<CustomUserRole>().ToTable("UserRoles"); modelBuilder.Entity<CustomUserLogin>().ToTable("UserLogins");
Add-Migration UpdateDatabaseToAspIdentity
public partial class UpdateDatabaseToAspIdentity : DbMigration { public override void Up() { CreateTable( "dbo.UserClaims", c => new { Id = c.Int(nullable: false, identity: true), UserId = c.Int(nullable: false), ClaimType = c.String(), ClaimValue = c.String(), ApplicationUser_Id = c.Int(), }) .PrimaryKey(t => t.Id) .ForeignKey("dbo.Users", t => t.ApplicationUser_Id) .Index(t => t.ApplicationUser_Id); CreateTable( "dbo.UserLogins", c => new { LoginProvider = c.String(nullable: false, maxLength: 128), ProviderKey = c.String(nullable: false, maxLength: 128), UserId = c.Int(nullable: false), ApplicationUser_Id = c.Int(), }) .PrimaryKey(t => new { t.LoginProvider, t.ProviderKey, t.UserId }) .ForeignKey("dbo.Users", t => t.ApplicationUser_Id) .Index(t => t.ApplicationUser_Id); CreateTable( "dbo.UserRoles", c => new { UserId = c.Int(nullable: false), RoleId = c.Int(nullable: false), ApplicationUser_Id = c.Int(), }) .PrimaryKey(t => new { t.UserId, t.RoleId }) .ForeignKey("dbo.Users", t => t.ApplicationUser_Id) .ForeignKey("dbo.AspRoles", t => t.RoleId, cascadeDelete: true) .Index(t => t.RoleId) .Index(t => t.ApplicationUser_Id); CreateTable( "dbo.AspRoles", c => new { Id = c.Int(nullable: false, identity: true), Description = c.String(), Name = c.String(nullable: false, maxLength: 256), }) .PrimaryKey(t => t.Id) .Index(t => t.Name, unique: true, name: "RoleNameIndex"); AddColumn("dbo.Users", "EmailConfirmed", c => c.Boolean(nullable: false)); AddColumn("dbo.Users", "SecurityStamp", c => c.String()); AddColumn("dbo.Users", "PhoneNumber", c => c.String()); AddColumn("dbo.Users", "PhoneNumberConfirmed", c => c.Boolean(nullable: false)); AddColumn("dbo.Users", "TwoFactorEnabled", c => c.Boolean(nullable: false)); AddColumn("dbo.Users", "LockoutEndDateUtc", c => c.DateTime()); AddColumn("dbo.Users", "LockoutEnabled", c => c.Boolean(nullable: false)); AddColumn("dbo.Users", "AccessFailedCount", c => c.Int(nullable: false)); } public override void Down() { DropForeignKey("dbo.UserRoles", "RoleId", "dbo.AspRoles"); DropForeignKey("dbo.UserRoles", "ApplicationUser_Id", "dbo.Users"); DropForeignKey("dbo.UserLogins", "ApplicationUser_Id", "dbo.Users"); DropForeignKey("dbo.UserClaims", "ApplicationUser_Id", "dbo.Users"); DropIndex("dbo.AspRoles", "RoleNameIndex"); DropIndex("dbo.UserRoles", new[] { "ApplicationUser_Id" }); DropIndex("dbo.UserRoles", new[] { "RoleId" }); DropIndex("dbo.UserLogins", new[] { "ApplicationUser_Id" }); DropIndex("dbo.UserClaims", new[] { "ApplicationUser_Id" }); DropColumn("dbo.Users", "AccessFailedCount"); DropColumn("dbo.Users", "LockoutEnabled"); DropColumn("dbo.Users", "LockoutEndDateUtc"); DropColumn("dbo.Users", "TwoFactorEnabled"); DropColumn("dbo.Users", "PhoneNumberConfirmed"); DropColumn("dbo.Users", "PhoneNumber"); DropColumn("dbo.Users", "SecurityStamp"); DropColumn("dbo.Users", "EmailConfirmed"); DropTable("dbo.AspRoles"); DropTable("dbo.UserRoles"); DropTable("dbo.UserLogins"); DropTable("dbo.UserClaims"); } }
AddColumn("dbo.Users", "EmailConfirmed", c => c.Boolean(nullable: false, defaultValue:true));
Update-Database
Get-Project Iris.Servicelayer, Iris.Web | Install-Package Microsoft.AspNet.Identity.Owin
باز از پروژه AspNetIdentityDependencyInjectionSample.ServiceLayer کلاسهای ApplicationRoleManager، ApplicationSignInManager، ApplicationUserManager، CustomRoleStore، CustomUserStore، EmailService و SmsService را به پوشه EFServcies پروژهی Iris.ServiceLayer کپی کنید.
x.For<IIdentity>().Use(() => (HttpContext.Current != null && HttpContext.Current.User != null) ? HttpContext.Current.User.Identity : null); x.For<IUnitOfWork>() .HybridHttpOrThreadLocalScoped() .Use<IrisDbContext>(); x.For<IrisDbContext>().HybridHttpOrThreadLocalScoped() .Use(context => (IrisDbContext)context.GetInstance<IUnitOfWork>()); x.For<DbContext>().HybridHttpOrThreadLocalScoped() .Use(context => (IrisDbContext)context.GetInstance<IUnitOfWork>()); x.For<IUserStore<ApplicationUser, int>>() .HybridHttpOrThreadLocalScoped() .Use<CustomUserStore>(); x.For<IRoleStore<CustomRole, int>>() .HybridHttpOrThreadLocalScoped() .Use<RoleStore<CustomRole, int, CustomUserRole>>(); x.For<IAuthenticationManager>() .Use(() => HttpContext.Current.GetOwinContext().Authentication); x.For<IApplicationSignInManager>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationSignInManager>(); x.For<IApplicationRoleManager>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationRoleManager>(); // map same interface to different concrete classes x.For<IIdentityMessageService>().Use<SmsService>(); x.For<IIdentityMessageService>().Use<IdentityEmailService>(); x.For<IApplicationUserManager>().HybridHttpOrThreadLocalScoped() .Use<ApplicationUserManager>() .Ctor<IIdentityMessageService>("smsService").Is<SmsService>() .Ctor<IIdentityMessageService>("emailService").Is<IdentityEmailService>() .Setter<IIdentityMessageService>(userManager => userManager.SmsService).Is<SmsService>() .Setter<IIdentityMessageService>(userManager => userManager.EmailService).Is<IdentityEmailService>(); x.For<ApplicationUserManager>().HybridHttpOrThreadLocalScoped() .Use(context => (ApplicationUserManager)context.GetInstance<IApplicationUserManager>()); x.For<ICustomRoleStore>() .HybridHttpOrThreadLocalScoped() .Use<CustomRoleStore>(); x.For<ICustomUserStore>() .HybridHttpOrThreadLocalScoped() .Use<CustomUserStore>();
اگر ()HttpContext.Current.GetOwinContext شناسایی نمیشود دلیلش این است که متد GetOwinContext یک متد الحاقی است که برای استفاده از آن باید پکیج نیوگت زیر را نصب کنید:
Install-Package Microsoft.Owin.Host.SystemWeb
تغییرات Iris.Web
using System; using Iris.Servicelayer.Interfaces; using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Microsoft.Owin.Security.DataProtection; using Owin; using StructureMap; namespace Iris.Web { public class Startup { public void Configuration(IAppBuilder app) { configureAuth(app); } private static void configureAuth(IAppBuilder app) { ObjectFactory.Container.Configure(config => { config.For<IDataProtectionProvider>() .HybridHttpOrThreadLocalScoped() .Use(() => app.GetDataProtectionProvider()); }); //ObjectFactory.Container.GetInstance<IApplicationUserManager>().SeedDatabase(); // Enable the application to use a cookie to store information for the signed in user // and to use a cookie to temporarily store information about a user logging in with a third party login provider // Configure the sign in cookie app.UseCookieAuthentication(new CookieAuthenticationOptions { AuthenticationType = DefaultAuthenticationTypes.ApplicationCookie, LoginPath = new PathString("/Account/Login"), Provider = new CookieAuthenticationProvider { // Enables the application to validate the security stamp when the user logs in. // This is a security feature which is used when you change a password or add an external login to your account. OnValidateIdentity = ObjectFactory.Container.GetInstance<IApplicationUserManager>().OnValidateIdentity() } }); app.UseExternalSignInCookie(DefaultAuthenticationTypes.ExternalCookie); // Enables the application to temporarily store user information when they are verifying the second factor in the two-factor authentication process. app.UseTwoFactorSignInCookie(DefaultAuthenticationTypes.TwoFactorCookie, TimeSpan.FromMinutes(5)); // Enables the application to remember the second login verification factor such as phone or email. // Once you check this option, your second step of verification during the login process will be remembered on the device where you logged in from. // This is similar to the RememberMe option when you log in. app.UseTwoFactorRememberBrowserCookie(DefaultAuthenticationTypes.TwoFactorRememberBrowserCookie); app.CreatePerOwinContext( () => ObjectFactory.Container.GetInstance<IApplicationUserManager>()); // Uncomment the following lines to enable logging in with third party login providers //app.UseMicrosoftAccountAuthentication( // clientId: "", // clientSecret: ""); //app.UseTwitterAuthentication( // consumerKey: "", // consumerSecret: ""); //app.UseFacebookAuthentication( // appId: "", // appSecret: ""); //app.UseGoogleAuthentication( // clientId: "", // clientSecret: ""); } } }
تا به این جای کار اگر پروژه را اجرا کنید نباید هیچ مشکلی مشاهده کنید. در بخش بعدی کدهای مربوط به کنترلرهای ورود، ثبت نام، فراموشی کلمه عبور و ... را با سیستم Identity پیاده سازی میکنیم.
تنظیم مسیریابی ماژولها
در اینجا نیازی به تنظیم base path نیست و این تنظیم تنها یکبار به ازای کل برنامه انجام میشود. همانطور که در قسمت قبل نیز عنوان شد، ماژول مسیریابی Angular و یا همان RouterModule، به همراه سرویسی برای دسترسی به امکانات آن، تنظیمات مسیریابی و یک سری دایرکتیو مانند routerLink، جهت تعامل با آن است. از آنجائیکه سرویس ماژول مسیریابی در فایل src\app\app-routing.module.ts تعریف و تنظیم شدهاست، باید اطمینان حاصل کرد که این سرویس تنها یکبار در طول عمر برنامه وهله سازی میشود و از آنجائیکه هر ماژول تنظیمات مجزای مسیریابی خود را خواهد داشت، دیگر نمیتوان از متد RouterModule.forRoot سراسری استفاده کرد و در اینجا باید از متد forChild این ماژول، جهت تعریف تنظیمات مسیریابیهای ماژولهای مختلف کمک گرفت. متد forChild نیز شبیه به همان آرایهی تنظیمات مسیریابی متد forRoot را دریافت میکند.
یک مثال: در ادامهی مثالی که در قسمت قبل به کمک Angular CLI ایجاد کردیم، ماژول جدید محصولات را به همراه تنظیمات ابتدایی مسیریابی آن ایجاد میکنیم:
>ng g m product --routing
import { NgModule } from '@angular/core'; import { Routes, RouterModule } from '@angular/router'; const routes: Routes = []; @NgModule({ imports: [RouterModule.forChild(routes)], exports: [RouterModule] }) export class ProductRoutingModule { }
سپس ProductRoutingModule به قسمت imports ماژول محصولات به صورت خودکار اضافه شدهاست:
import { NgModule } from '@angular/core'; import { CommonModule } from '@angular/common'; import { ProductRoutingModule } from './product-routing.module'; @NgModule({ imports: [ CommonModule, ProductRoutingModule ], declarations: [] }) export class ProductModule { }
در ادامه کامپوننت جدید لیست محصولات را به این ماژول اضافه میکنیم:
>ng g c product/ProductList
installing component create src\app\product\product-list\product-list.component.css create src\app\product\product-list\product-list.component.html create src\app\product\product-list\product-list.component.spec.ts create src\app\product\product-list\product-list.component.ts update src\app\product\product.module.ts
import { ProductListComponent } from './product-list/product-list.component'; @NgModule({ imports: [ ], declarations: [ProductListComponent] }) export class ProductModule { }
اکنون که این ماژول جدید را به همراه یک کامپوننت نمونه در آن تعریف کردیم، برای افزودن مسیریابی به آن، به فایل src\app\product\product-routing.module.ts مراجعه کرده و آرایهی Routes آنرا تکمیل میکنیم:
import { ProductListComponent } from './product-list/product-list.component'; const routes: Routes = [ { path: 'products', component: ProductListComponent } ];
در ادامه میخواهیم لینکی را به این مسیریابی جدید اضافه کنیم. در قسمت قبل منویی را به برنامه اضافه کردیم. به همین جهت به فایل src\app\app.component.html مراجعه کرده و routerLink جدیدی را به آن اضافه میکنیم:
<nav class="navbar navbar-default"> <div class="container-fluid"> <a class="navbar-brand">{{title}}</a> <ul class="nav navbar-nav"> <li> <a [routerLink]="['/home']">Home</a> </li> <li> <a [routerLink]="['/products']">Product List</a> </li> </ul> </div> </nav> <div class="container"> <router-outlet></router-outlet> </div>
در اینجا نیز نحوهی تعریف لینکها مانند قبل است و آرایهی تنظیمات پارامترهای لینک باید به مقدار خاصیت path تعریف شده اشاره کند.
اکنون دستور ng serve -o را صادر کنید تا برنامه در حافظه ساخته شده و در مرورگر نمایش داده شود. در ادامه اگر بر روی لینک لیست محصولات کلیک کنید، صفحهی ذیل را مشاهده خواهید کرد:
به این معنا که برنامه اطلاعی از این مسیریابی جدید نداشته و صفحهی یافت نشدن مسیریابی را که در قسمت قبل تنظیم کردیم، نمایش دادهاست. برای رفع این مشکل باید به فایل src\app\app.module.ts مراجعه کرده و این ماژول جدید را به آن معرفی کنیم:
import { ProductModule } from './product/product.module'; @NgModule({ declarations: [ ], imports: [ BrowserModule, FormsModule, HttpModule, ProductModule, AppRoutingModule ],
نکته 1: علت اینکه ProductModule را پیش از AppRoutingModule تعریف کردیم این است که AppRoutingModule دارای تعریف مسیریابی ** یا catch all است که در قسمت قبل آنرا جهت مدیریت مسیرهای یافت نشده به برنامه افزودیم. اگر ابتدا AppRoutingModule تعریف میشد و سپس ProductModule، هیچگاه فرصت به پردازش مسیریابیهای ماژول محصولات نمیرسید؛ چون مسیر ** پیشتر برنده شده بود.
نکته 2: میتوان در قسمت import متد RouterModule.forRoot را نیز مستقیما قرار داد (بجای AppRoutingModule). اگر این کار صورت گیرد، ابتدا مسیریابیهای موجود در ماژولها پردازش میشوند و در آخر مسیرهای موجود در RouterModule.forRoot صرفنظر از محل قرارگیری آن در این لیست بررسی خواهد شد (حتی اگر در ابتدای لیست قرار گیرد). هرچند جهت مدیریت بهتر برنامه، این متد به AppRoutingModule منتقل شدهاست. بنابراین اکنون «نکتهی 1» برقرار است.
انتخاب استراتژی مناسب نامگذاری مسیرها
هنگام کار کردن با تعدادی ویژگی مرتبط به هم قرار گرفتهی داخل یک ماژول، بهتر است روش نامگذاری مناسبی را برای تنظیمات مسیریابی آن درنظر گرفت تا مسیرهای تعیین شده علاوه بر زیبایی، وضوح بیشتری را نیز پیدا کنند. به علاوه این نامگذاری مناسب، گروه بندی مسیریابیها و lazy loading آنها را نیز سادهتر میکند.
استراتژی ابتدایی که به ذهن میرسد، نامگذاری هر مسیر بر اساس عملکرد آنها است مانند products برای نمایش لیست محصولات، product/:id برای نمایش جزئیات محصولی خاص که در اینجا id پارامتر مسیریابی است و productEdit/:id برای ویرایش جزئیات یک محصول مشخص. همانطور که مشاهده میکنید، هرچند این مسیرها متعلق به یک ماژول هستند، اما مسیرهای تعیین شدهی برای آنها اینگونه به نظر نمیرسد. بنابراین بهتر است تمام ویژگیهای قرار گرفتهی درون یک ماژول را با مسیر ریشهی یکسانی شروع کنیم. به این ترتیب نمایش لیست محصولات همان products باقی خواهد ماند اما برای نمایش جزئیات محصولی خاص از مسیر products/:id استفاده میکنیم (همان اسم جمع ریشهی مسیر؛ بجای اسم مفرد). اینبار مسیر ویرایش جزئیات یک محصول به صورت products/:id/edit تنظیم خواهد شد:
products products/:id products/:id/edit
فعالسازی یک مسیر با کدنویسی
تا اینجا نحوهی فعالسازی یک مسیر را با استفاده از دایرکتیو routerLink بررسی کردیم. اما گاهی از اوقات نیاز است تا بتوان با کدنویسی نیز کاربران را به مسیری خاص هدایت کرد. برای مثال پس از عملیات logout میخواهیم مجددا صفحهی اول سایت نمایش داده شود. برای اینکار از سرویس Router مسیریاب Angular کمک گرفته میشود. ابتدا آنرا در سازندهی یک کامپوننت تزریق کرده و سپس میتوان به قابلیتهای آن مانند استفادهی از متد navigate آن، در کدهای برنامه دسترسی یافت.
باید درنظر داشت که دایرکتیو routerLink نیز در پشت صحنه از همین متد navigate سرویس Router استفاده میکند. بنابراین تمام پارامترهای آن در متد navigate نیز قابل استفاده هستند. برای مثال زمانیکه تعداد پارامترهای routerLink یک مورد است، میتوان آرایهی آنرا به یک رشته خلاصه کرد. یک چنین قابلیتی با متد navigate نیز میسر است.
متد navigate تنها قسمتهایی از URL جاری را تغییر میدهد. اگر نیاز باشد تا کل آدرس تعویض شود، میتوان از متد دیگر سرویس Router به نام navigateByUrl استفاده کرد. این متد تمام URL segments موجود را با مسیر جدیدی جایگزین میکند. به علاوه برخلاف متد navigate، تنها یک رشته را به عنوان پارامتر میپذیرد.
در ادامه مثال جاری میخواهیم پیاده سازی ابتدایی login و logout را به برنامه اضافه کنیم. به همین منظور ابتدا ماژول جدید user را به همراه تنظیمات ابتدایی مسیریابی آن اضافه میکنیم:
>ng g m user --routing
همانند ماژول قبلی، نیاز است UserModule را به قسمت imports فایل src\app\app.module.ts نیز معرفی کنیم:
import { UserModule } from './user/user.module'; @NgModule({ declarations: [ ], imports: [ BrowserModule, FormsModule, HttpModule, ProductModule, UserModule, AppRoutingModule ],
سپس کامپوننت جدید لاگین را به ماژول user برنامه اضافه میکنیم:
>ng g c user/login
در ادامه به فایل src\app\user\user-routing.module.ts مراجعه کرده و مسیریابی جدیدی را به کامپوننت لاگین تعریف میکنیم:
import { LoginComponent } from './login/login.component'; const routes: Routes = [ { path: 'login', component: LoginComponent} ];
مرحلهی بعد، فعالسازی این مسیریابی است، با تعریف لینکی به آن. به همین جهت به فایل src\app\app.component.html مراجعه کرده و منوی برنامه را تکمیل میکنیم:
<nav class="navbar navbar-default"> <div class="container-fluid"> <a class="navbar-brand">{{title}}</a> <ul class="nav navbar-nav"> <li> <a [routerLink]="['/home']">Home</a> </li> <li> <a [routerLink]="['/products']">Product List</a> </li> </ul> <ul class="nav navbar-nav navbar-right"> <li> <a [routerLink]="['/login']">Log In</a> </li> </ul> </div> </nav> <div class="container"> <router-outlet></router-outlet> </div>
تکمیل کامپوننت login و افزودن لینک logout
در ادامه میخواهیم یک فرم لاگین مقدماتی را پس از کلیک بر روی لینک لاگین نمایش دهیم و هدایت به صفحهی لیست محصولات را پس از لاگین و مخفی کردن لینک لاگین و نمایش لینک خروج را در این حالت پیاده سازی کنیم. برای این منظور ابتدا اینترفیس خالی کاربر را ایجاد میکنیم:
>ng g i user/user
export interface IUser { id: number; userName: string; isAdmin: boolean; }
پس از آن یک سرویس ابتدایی اعتبارسنجی کاربران را نیز اضافه خواهیم کرد:
>ng g s user/auth -m user/user.module
installing service create src\app\user\auth.service.spec.ts create src\app\user\auth.service.ts update src\app\user\user.module.ts
پس از ایجاد قالب ابتدایی فایل auth.service.ts آنرا به نحو ذیل تکمیل کنید:
import { IUser } from './user'; import { Injectable } from '@angular/core'; @Injectable() export class AuthService { currentUser: IUser; constructor() { } isLoggedIn(): boolean { return !this.currentUser; } login(userName: string, password: string): boolean { if (!userName || !password) { return false; } if (userName === 'admin') { this.currentUser = { id: 1, userName: userName, isAdmin: true }; return true; } this.currentUser = { id: 2, userName: userName, isAdmin: false }; return true; } logout(): void { this.currentUser = null; } }
سپس کامپوننت لاگین واقع در فایل src\app\user\login\login.component.ts را به نحو ذیل تکمیل کنید:
import { Router } from '@angular/router'; import { AuthService } from './../auth.service'; import { Component, OnInit } from '@angular/core'; import { NgForm } from '@angular/forms'; @Component({ selector: 'app-login', templateUrl: './login.component.html', styleUrls: ['./login.component.css'] }) export class LoginComponent implements OnInit { errorMessage: string; pageTitle = 'Log In'; constructor(private authService: AuthService, private router: Router) { } ngOnInit() { } login(loginForm: NgForm) { if (loginForm && loginForm.valid) { let userName = loginForm.form.value.userName; let password = loginForm.form.value.password; if (this.authService.login(userName, password)) { this.router.navigate(['/products']); } } else { this.errorMessage = 'Please enter a user name and password.'; }; } }
از AuthService برای اعتبارسنجی کاربر و لاگین او به سیستم استفاده میکنیم و از سرویس مسیریاب Angular جهت فراخوانی متد navigate آن به صفحهی مشاهدهی محصولات، پس از لاگین کاربر استفاده شدهاست.
اکنون میخواهیم قالب این کامپوننت را نیز تکمیل کنیم. پیش از آن به فایل src\app\user\user.module.ts مراجعه کرده و در قسمت imports آن FormsModule را نیز اضافه کنید:
import { FormsModule } from '@angular/forms'; @NgModule({ imports: [ CommonModule, FormsModule, UserRoutingModule ],
سپس فایل src\app\user\login\login.component.html را به نحو ذیل تغییر دهید:
<div class="panel panel-default"> <div class="panel-heading"> {{pageTitle}} </div> <div class="panel-body"> <form class="form-horizontal" novalidate (ngSubmit)="login(loginForm)" #loginForm="ngForm" autocomplete="off"> <fieldset> <div class="form-group" [ngClass]="{'has-error': (userNameVar.touched || userNameVar.dirty) && !userNameVar.valid }"> <label class="col-md-2 control-label" for="userNameId">User Name</label> <div class="col-md-8"> <input class="form-control" id="userNameId" type="text" placeholder="User Name (required)" required (ngModel)="userName" name="userName" #userNameVar="ngModel" /> <span class="help-block" *ngIf="(userNameVar.touched || userNameVar.dirty) && userNameVar.errors"> <span *ngIf="userNameVar.errors.required"> User name is required. </span> </span> </div> </div> <div class="form-group" [ngClass]="{'has-error': (passwordVar.touched || passwordVar.dirty) && !passwordVar.valid }"> <label class="col-md-2 control-label" for="passwordId">Password</label> <div class="col-md-8"> <input class="form-control" id="passwordId" type="password" placeholder="Password (required)" required (ngModel)="password" name="password" #passwordVar="ngModel" /> <span class="help-block" *ngIf="(passwordVar.touched || passwordVar.dirty) && passwordVar.errors"> <span *ngIf="passwordVar.errors.required"> Password is required. </span> </span> </div> </div> <div class="form-group"> <div class="col-md-4 col-md-offset-2"> <span> <button class="btn btn-primary" type="submit" style="width:80px;margin-right:10px" [disabled]="!loginForm.valid"> Log In </button> </span> <span> <a class="btn btn-default" [routerLink]="['/welcome']"> Cancel </a> </span> </div> </div> </fieldset> </form> <div class="has-error" *ngIf="errorMessage">{{errorMessage}}</div> </div> </div>
اکنون میخواهیم پس از ورود او، نام او را نمایش داده و همچنین دکمهی logout را بجای login در منوی بالای سایت نمایش دهیم. به همین جهت در قالب کامپوننت App که منوی برنامه در آن تنظیم شدهاست، نیاز است بتوانیم به سرویس Auth سفارشی دسترسی یافته و خروجی متد isLoggedIn آنرا بررسی کنیم. به همین منظور به فایل src\app\app.component.ts مراجعه کرده و آنرا به صورت ذیل تکمیل کنید:
import { Router } from '@angular/router'; import { AuthService } from './user/auth.service'; import { Component } from '@angular/core'; @Component({ selector: 'app-root', templateUrl: './app.component.html', styleUrls: ['./app.component.css'] }) export class AppComponent { pageTitle: string = 'Routing Lab'; constructor(private authService: AuthService, private router: Router) { } logOut(): void { this.authService.logout(); this.router.navigateByUrl('/welcome'); } }
پس از این تغییرات، اکنون میتوان قالب src\app\app.component.html را به نحو ذیل تکمیل کرد:
<nav class="navbar navbar-default"> <div class="container-fluid"> <a class="navbar-brand">{{title}}</a> <ul class="nav navbar-nav"> <li> <a [routerLink]="['/home']">Home</a> </li> <li> <a [routerLink]="['/products']">Product List</a> </li> </ul> <ul class="nav navbar-nav navbar-right"> <li *ngIf="authService.isLoggedIn()"> <a>Welcome {{ authService.currentUser.userName }}</a> </li> <li *ngIf="!authService.isLoggedIn()"> <a [routerLink]="['/login']">Log In</a> </li> <li *ngIf="authService.isLoggedIn()"> <a (click)="logOut()">Log Out</a> </li> </ul> </div> </nav> <div class="container"> <router-outlet></router-outlet> </div>
اکنون اگر برنامه را توسط دستور ng serve -o اجرا کنید، صفحهی لاگین و منوی بالای صفحه چنین شکلی را خواهد داشت:
پس از لاگین، لینک لاگین از منو حذف شده و سپس نام کاربری و لینک به logout نمایان میشوند.
اینبار اگر بر روی logout کلیک کنید، نام کاربری و لینک logout از صفحه حذف و مجددا لینک لاگین نمایش داده میشود.
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: angular-routing-lab-01.zip
برای اجرای آن فرض بر این است که پیشتر Angular CLI را نصب کردهاید. سپس از طریق خط فرمان به ریشهی پروژه وارد شده و دستور npm install را صادر کنید تا وابستگیهای آن دریافت و نصب شوند. در آخر با اجرای دستور ng serve -o برنامه ساخته شده و در مرورگر پیش فرض سیستم نمایش داده خواهد شد.
در کتابخانهی Microsoft AspNetCore Identity میتوان با این کد، فیلد Email را منحصر بهفرد کرد:
//Program.cs file builder.Services.AddIdentity<User, Role>(options => { options.User.RequireUniqueEmail = true; }).AddEntityFrameworkStores<DatabaseContext>();
برنامه را اجرا و درخواستها را یکی یکی به سمت سرور ارسال میکنیم و اگر ایمیل تکراری باشد به ما خطا میده و میگه: "ایمیل تکراری است".
ولی مشکل اینجاست که کد بالا فیلد Email رو داخل دیتابیس منحصر بهفرد نمیکنه و فقط از سمت نرم افزار بررسی تکراری بودن ایمیل رو انجام میده. حالا اگه ما با استفاده از نرم افزارهای "تست برنامههای وب" مثل Apache JMeter تعداد زیادی درخواست را به سمت برنامهمان ارسال کنیم و بعد رکوردهای داخل جدول کاربران را نگاه کنیم، با وجود اینکه داخل نرم افزارمان پراپرتی Email را منحصر بهفرد کردهایم، ولی چندین رکورد، با یک ایمیل مشابه در داخل جدول User وجود خواهد داشت.
برای تست این سناریو، برنامه Apache JMeter را از این لینک دانلود میکنیم (در بخش Binaries فایل zip رو دانلود می کنیم).
نکته: داشتن jdk ورژن 8 به بالا پیش نیاز است. برای اینکه بدونید ورژن جاوای سیستمتون چنده، داخل cmd دستور java -version رو صادر کنید.
اگه تمایل به نصب، یا به روز رسانی jdk را داشتید، میتونید از این لینک استفاده کنید و بسته به سیستم عاملتون، یکی از تبهای Windows, macOS یا Linux رو انتخاب کنید و فایل مورد نظر رو دانلود کنید (برای Windows فایل x64 Compressed Archive رو دانلود و نصب میکنیم).
حالا فایل دانلود شده JMeter رو استخراج میکنیم، وارد پوشهی bin میشیم و فایل jmeter.bat رو اجرا میکنیم تا برنامهی JMeter اجرا بشه.
قبل از اینکه وارد برنامه JMeter بشیم، کدهای برنامه رو بررسی میکنیم.
موجودیت کاربر:
public class User : IdentityUser<int>;
ویوو مدل ساخت کاربر:
public class UserViewModel { public string UserName { get; set; } = null!; public string Email { get; set; } = null!; public string Password { get; set; } = null!; }
کنترلر ساخت کاربر:
[ApiController] [Route("/api/[controller]")] public class UserController(UserManager<User> userManager) : Controller { [HttpPost] public async Task<IActionResult> Add(UserViewModel model) { var user = new User { UserName = model.UserName, Email = model.Email }; var result = await userManager.CreateAsync(user, model.Password); if (result.Succeeded) { return Ok(); } return BadRequest(result.Errors); } }
حالا وارد برنامه JMeter میشیم و اولین کاری که باید انجام بدیم این است که مشخص کنیم چند درخواست را در چند ثانیه قرار است ارسال کنیم. برای اینکار در برنامه JMeter روی TestPlan کلیک راست میکنیم و بعد:
Add -> Threads (Users) -> Thread Group
حالا باید بر روی Thread Group کلیک کنیم و بعد در بخش Number of threads (users) تعداد درخواستهایی را که قرار است به سمت سرور ارسال کنیم، مشخص کنیم؛ برای مثال عدد 100.
گزینه Ramp-up period (seconds) برای اینه که مشخص کنیم این 100 درخواست قرار است در چند ثانیه ارسال شوند که آن را روی 0.1 ثانیه قرار میدهیم تا درخواستها را با سرعت بسیار زیاد ارسال کند.
الان باید مشخص کنیم چه دیتایی قرار است به سمت سرور ارسال شود:
برای اینکار باید یک Http Request اضافه کنیم. برای این منظور روی Thread Group که از قبل ایجاد کردیم، کلیک راست میکنیم و بعد:
Add -> Sampler -> Http Request
حالا روی Http Request کلیک میکنیم و متد ارسال درخواست رو که روی Get هست، به Post تغییر میدیم و بعد Path رو هم به آدرسی که قراره دیتا رو بهش ارسال کنیم، تغییر میدهیم:
https://localhost:7091/api/User
حالا پایینتر Body Data رو انتخاب میکنیم و دیتایی رو که قراره به سمت سرور ارسال کنیم، در قالب Json وارد میکنیم:
{ "UserName": "payam${__Random(1000, 9999999)}", "Email": "payam@gmail.com", "Password": "123456aA@" }
چون بخش UserName در پایگاه داده منحصر بهفرد است، با این دستور:
${__Random(1000, 9999999)}
یک عدد Random رو به UserName اضافه میکنیم که دچار خطا نشیم.
حالا فقط باید یک Header رو هم به درخواستمون اضافه کنیم، برای اینکار روی Http Request که از قبل ایجاد کردیم، کلیک راست میکنیم و بعد:
Add -> Config Element -> Http Header Manager
حالا روی دکمهی Add در پایین صفحه کلیک میکنیم و این Header رو اضافه میکنیم:
Name: Content-Type Value: application/json
همچنین میتونیم یک View result رو هم اضافه کنیم تا وضعیت تمامی درخواستهای ارسال شده رو مشاهده کنیم. برای اینکار روی Http Request که از قبل ایجاد کردیم، کلیک راست میکنیم و بعد:
Add -> Listener -> View Results Tree
فایل Backup، برای اینکه مراحل بالا رو سریعتر انجام بدید:
File -> Open
حالا بر روی دکمهی سبز رنگ Play در Toolbar بالا کلیک میکنیم تا تمامی درخواست ها را به سمت سرور ارسال کنه و همچنین میتونیم از طریق View result tree ببینیم که چند درخواست موفقیت آمیز و چند درخواست ناموفق انجام شدهاست.
حالا اگر وارد پایگاه داده بشیم، میبینیم که چندین رکورد، با Email یکسان، در جدول User وجود داره:
در حالیکه ایمیل رو در تنظیمات کتابخانه Microsoft AspNetCore Identity به صورت Unique تعریف کردهایم:
//Program.cs file builder.Services.AddIdentity<User, Role>(options => { options.User.RequireUniqueEmail = true; }).AddEntityFrameworkStores<DatabaseContext>();
دلیل این مشکل این است که درخواستها در قالب یک صف، یک به یک اجرا نمیشوند؛ بلکه به صورت همزمان فریم ورک ASP.NET Core برای بالا بردن سرعت اجرای درخواستها از تمامی Thread هایی که در اختیارش هست استفاده میکند و در چندین Thread جداگانه، درخواستهایی رو به کنترلر User میفرسته و در نتیجه، در یک زمان مشابه، چندین درخواست ارسال میشه که آیا یک ایمیل برای مثال با مقدار payam@yahoo.com وجود داره یا خیر و در تمامی درخواستها چون همزمان انجام شده، جواب خیر است. یعنی ایمیل تکراری با آن مقدار، در پایگاه داده وجود ندارد و تمامی درخواستهایی که همزمان به سرور رسیدهاند، کاربر جدید را با ایمیل مشابهی ایجاد میکنند.
این مشکل را میتوان حتی در سایتهای فروش بلیط نیز پیدا کرد؛ یعنی چند نفر یک صندلی را رزرو کردهاند و همزمان وارد درگاه پرداخت شده و هزینهایی را برای آن پرداخت میکنند. اگر آن درخواستها را وارد صف نکنیم، امکان دارد که یک صندلی را به چند نفر بفروشیم. این سناریو برای زمانی است که در پایگاه داده، فیلدها را Unique تعریف نکرده باشیم. هر چند که اگر فیلدها را نیز Unique تعریف کرده باشیم تا یک صندلی را به چند نفر نفروشیم، در آن صورت هم برنامه دچار خطای 500 خواهد شد. پس بهتر است که حتی در زمانهایی هم که فیلدها را Unique تعریف میکنیم، باز هم از ورود چند درخواست همزمان به اکشن رزرو صندلی جلوگیری کنیم.
راه حل
برای حل این مشکل میتوان از Lock statement استفاده کرد که این راه حل نیز یک مشکل دارد که در ادامه به آن اشاره خواهم کرد.
Lock statement به ما این امکان رو میده تا اگر بخشی از کد ما در یک Thread در حال اجرا شدن است، Thread دیگری به آن بخش از کد، دسترسی نداشته باشد و منتظر بماند تا آن Thread کارش با کد ما تموم شود و بعد Thread جدید بتونه کد مارو اجرا کنه.
نحوه استفاده از Lock statement هم بسیار سادهاست:
public class TestClass { private static readonly object _lock1 = new(); public void Method1() { lock (_lock1) { // Body } } }
حالا باید کدهای خودمون رو در بخش Body اضافه کنیم تا دیگر چندین Thread به صورت همزمان، کدهای ما رو اجرا نکنند.
اما یک مشکل وجود داره و آن این است که ما نمیتوانیم در Lock statement، از کلمه کلیدی await استفاده کنیم؛ در حالیکه برای ساخت User جدید باید از await استفاده کنیم:
var result = await userManager.CreateAsync(user, model.Password);
برای حل این مشکل میتوان از کلاس SemaphoreSlim بجای کلمهی کلیدی lock استفاده کرد:
[ApiController] [Route("/api/[controller]")] public class UserController(UserManager<User> userManager) : Controller { private static readonly SemaphoreSlim Semaphore = new (initialCount: 1, maxCount: 1); [HttpPost] public async Task<IActionResult> Add(UserViewModel model) { var user = new User { UserName = model.UserName, Email = model.Email }; // Acquire the semaphore await Semaphore.WaitAsync(); try { // Perform user creation var result = await userManager.CreateAsync(user, model.Password); if (result.Succeeded) { return Ok(); } return BadRequest(result.Errors); } finally { // Release the semaphore Semaphore.Release(); } } }
این کلاس نیز مانند lock عمل میکند، ولی تواناییهای بیشتری را در اختیار ما قرار میدهد؛ برای مثال میتوان تعیین کرد که همزمان چند ترد میتوانند به این کد دسترسی داشته باشند؛ در حالیکه در lock statement فقط یک Thread میتوانست به کد دسترسی داشته باشد. مزیت دیگر کلاس SemaphoreSlim این است که میتوان برای اجرای کدمان Timeout در نظر گرفت تا از بلاک شدن نامحدود Thread جلوگیری کنیم.
با فراخوانی await semaphore.WaitAsync، دسترسی کد ما توسط سایر Thread ها محدود و با فراخوانی Release، کد ما توسط سایر Thread ها قابل دسترسی میشود.
مشکل قفل کردن Thread ها
هنگام قفل کردن Thread ها، مشکلی وجود دارد و آن این است که اگر برنامهی ما روی چندین سرور مختلف اجرا شود، این روش جوابگو نخواهد بود؛ چون قفل کردن Thread روی یک سرور تاثیری در سایر سرورها جهت محدود کردن دسترسی به کد ما ندارد. اما به صورت کلی میتوان از این روش برای بخشهایی خاص از برنامههایمان استفاده کنیم.
پیاده سازی با کمک الگوی AOP
برای اینکه کارمون راحت تر بشه، میتونیم کدهای بالا رو به یک Attribute انتقال بدیم و از اون Attribute در بالای اکشنهامون استفاده کنیم تا کل عملیات اکشنهامونو رو در یک Thread قفل کنیم:
[AttributeUsage(AttributeTargets.Method)] public class SemaphoreLockAttribute : Attribute, IAsyncActionFilter { private static readonly SemaphoreSlim Semaphore = new (1, 1); public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next) { // Acquire the semaphore await Semaphore.WaitAsync(); try { // Proceed with the action await next(); } finally { // Release the semaphore Semaphore.Release(); } } }
حالا میتونیم این Attribute را برای هر اکشنی استفاده کنیم:
[HttpPost] [SemaphoreLock] public async Task<IActionResult> Add(UserViewModel model) { var user = new User { UserName = model.UserName, Email = model.Email }; var result = await userManager.CreateAsync(user, model.Password); if (result.Succeeded) { return Ok(); } return BadRequest(result.Errors); }