یک اپلیکیشن با SQL Membership بسازید
حال با استفاده از ابزار ASP.NET Configuration دو کاربر جدید بسازید: oldAdminUser و oldUser.
نقش جدیدی با نام Admin بسازید و کاربر oldAdminUser را به آن اضافه کنید.
بخش جدیدی با نام Admin در سایت خود بسازید و فرمی بنام Default.aspx به آن اضافه کنید. همچنین فایل web.config این قسمت را طوری پیکربندی کنید تا تنها کاربرانی که در نقش Admin هستند به آن دسترسی داشته باشند. برای اطلاعات بیشتر به این لینک مراجعه کنید.
پنجره Server Explorer را باز کنید و جداول ساخته شده توسط SQL Membership را بررسی کنید. اطلاعات اصلی کاربران که برای ورود به سایت استفاده میشوند، در جداول aspnet_Users و aspnet_Membership ذخیره میشوند. دادههای مربوط به نقشها نیز در جدول aspnet_Roles ذخیره خواهند شد. رابطه بین کاربران و نقشها نیز در جدول aspnet_UsersInRoles ذخیره میشود، یعنی اینکه هر کاربری به چه نقش هایی تعلق دارد.
برای مدیریت اساسی سیستم عضویت، مهاجرت جداول ذکر شده به سیستم جدید ASP.NET Identity کفایت میکند.
مهاجرت به Visual Studio 2013
- برای شروع ابتدا Visual Studio Express 2013 for Web یا Visual Studio 2013 را نصب کنید.
- حال پروژه ایجاد شده را در نسخه جدید ویژوال استودیو باز کنید. اگر نسخه ای از SQL Server Express را روی سیستم خود نصب نکرده باشید، هنگام باز کردن پروژه پیغامی به شما نشان داده میشود. دلیل آن وجود رشته اتصالی است که از SQL Server Express استفاده میکند. برای رفع این مساله میتوانید SQL Express را نصب کنید، و یا رشته اتصال را طوری تغییر دهید که از LocalDB استفاده کند.
- فایل web.config را باز کرده و رشته اتصال را مانند تصویر زیر ویرایش کنید.
- پنجره Server Explorer را باز کنید و مطمئن شوید که الگوی جداول و دادهها قابل رویت هستند.
- سیستم ASP.NET Identity با نسخه 4.5 دات نت فریم ورک و بالاتر سازگار است. پس نسخه فریم ورک پروژه را به آخرین نسخه (4.5.1) تغییر دهید.
پروژه را Build کنید تا مطمئن شوید هیچ خطایی وجود ندارد.
نصب پکیجهای NuGet
- Microsoft.AspNet.Identity.Owin
- Microsoft.Owin.Host.SystemWeb
- Microsoft.Owin.Security.Facebook
- Microsoft.Owin.Security.Google
- Microsoft.Owin.Security.MicrosoftAccount
- Microsoft.Owin.Security.Twitter
مهاجرت دیتابیس فعلی به سیستم ASP.NET Identity
در پنجره کوئری باز شده، تمام محتویات فایل Migrations.sql را کپی کنید. سپس اسکریپت را با کلیک کردن دکمه Execute اجرا کنید.
ممکن است با اخطاری مواجه شوید مبنی بر آنکه امکان حذف (drop) بعضی از جداول وجود نداشت. دلیلش آن است که چهار عبارت اولیه در این اسکریپت، تمام جداول مربوط به Identity را در صورت وجود حذف میکنند. از آنجا که با اجرای اولیه این اسکریپت چنین جداولی وجود ندارند، میتوانیم این خطاها را نادیده بگیریم. حال پنجره Server Explorer را تازه (refresh) کنید و خواهید دید که پنج جدول جدید ساخته شده اند.
لیست زیر نحوه Map کردن اطلاعات از جداول SQL Membership به سیستم Identity را نشان میدهد.
- aspnet_Roles --> AspNetRoles
- aspnet_Users, aspnet_Membership --> AspNetUsers
- aspnet_UsersInRoles --> AspNetUserRoles
ساختن مدلها و صفحات عضویت
کلاس User باید کلاس IdentityUser را که در اسمبلی Microsoft.AspNet.Identity.EntityFramework وجود دارد گسترش دهد. خاصیت هایی را تعریف کنید که نماینده الگوی جدول AspNetUser هستند. خواص ID, Username, PasswordHash و SecurityStamp در کلاس IdentityUser تعریف شده اند، بنابراین این خواص را در لیست زیر نمیبینید.
public class User : IdentityUser { public User() { CreateDate = DateTime.Now; IsApproved = false; LastLoginDate = DateTime.Now; LastActivityDate = DateTime.Now; LastPasswordChangedDate = DateTime.Now; LastLockoutDate = DateTime.Parse("1/1/1754"); FailedPasswordAnswerAttemptWindowStart = DateTime.Parse("1/1/1754"); FailedPasswordAttemptWindowStart = DateTime.Parse("1/1/1754"); } public System.Guid ApplicationId { get; set; } public string MobileAlias { get; set; } public bool IsAnonymous { get; set; } public System.DateTime LastActivityDate { get; set; } public string MobilePIN { get; set; } public string Email { get; set; } public string LoweredEmail { get; set; } public string LoweredUserName { get; set; } public string PasswordQuestion { get; set; } public string PasswordAnswer { get; set; } public bool IsApproved { get; set; } public bool IsLockedOut { get; set; } public System.DateTime CreateDate { get; set; } public System.DateTime LastLoginDate { get; set; } public System.DateTime LastPasswordChangedDate { get; set; } public System.DateTime LastLockoutDate { get; set; } public int FailedPasswordAttemptCount { get; set; } public System.DateTime FailedPasswordAttemptWindowStart { get; set; } public int FailedPasswordAnswerAttemptCount { get; set; } public System.DateTime FailedPasswordAnswerAttemptWindowStart { get; set; } public string Comment { get; set; } }
حال برای دسترسی به دیتابیس مورد نظر، نیاز به یک DbContext داریم. اسمبلی Microsoft.AspNet.Identity.EntityFramework کلاسی با نام IdentityDbContext دارد که پیاده سازی پیش فرض برای دسترسی به دیتابیس ASP.NET Identity است. نکته قابل توجه این است که IdentityDbContext آبجکتی از نوع TUser را میپذیرد. TUser میتواند هر کلاسی باشد که از IdentityUser ارث بری کرده و آن را گسترش میدهد.
در پوشه Models کلاس جدیدی با نام ApplicationDbContext بسازید که از IdentityDbContext ارث بری کرده و از کلاس User استفاده میکند.
public class ApplicationDbContext : IdentityDbContext<User> { }
مدیریت کاربران در ASP.NET Identity توسط کلاسی با نام UserManager انجام میشود که در اسمبلی Microsoft.AspNet.Identity.EntityFramework قرار دارد. چیزی که ما در این مرحله نیاز داریم، کلاسی است که از UserManager ارث بری میکند و آن را طوری توسعه میدهد که از کلاس User استفاده کند.
در پوشه Models کلاس جدیدی با نام UserManager بسازید.
public class UserManager : UserManager<User> { }
کلمه عبور کاربران بصورت رمز نگاری شده در دیتابیس ذخیره میشوند. الگوریتم رمز نگاری SQL Membership با سیستم ASP.NET Identity تفاوت دارد. هنگامی که کاربران قدیمی به سایت وارد میشوند، کلمه عبورشان را توسط الگوریتمهای قدیمی SQL Membership رمزگشایی میکنیم، اما کاربران جدید از الگوریتمهای ASP.NET Identity استفاده خواهند کرد.
کلاس UserManager خاصیتی با نام PasswordHasher دارد. این خاصیت نمونه ای از یک کلاس را ذخیره میکند، که اینترفیس IPasswordHasher را پیاده سازی کرده است. این کلاس هنگام تراکنشهای احراز هویت کاربران استفاده میشود تا کلمههای عبور را رمزنگاری/رمزگشایی شوند. در کلاس UserManager کلاس جدیدی بنام SQLPasswordHasher بسازید. کد کامل را در لیست زیر مشاهده میکنید.
public class SQLPasswordHasher : PasswordHasher { public override string HashPassword(string password) { return base.HashPassword(password); } public override PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) { string[] passwordProperties = hashedPassword.Split('|'); if (passwordProperties.Length != 3) { return base.VerifyHashedPassword(hashedPassword, providedPassword); } else { string passwordHash = passwordProperties[0]; int passwordformat = 1; string salt = passwordProperties[2]; if (String.Equals(EncryptPassword(providedPassword, passwordformat, salt), passwordHash, StringComparison.CurrentCultureIgnoreCase)) { return PasswordVerificationResult.SuccessRehashNeeded; } else { return PasswordVerificationResult.Failed; } } } //This is copied from the existing SQL providers and is provided only for back-compat. private string EncryptPassword(string pass, int passwordFormat, string salt) { if (passwordFormat == 0) // MembershipPasswordFormat.Clear return pass; byte[] bIn = Encoding.Unicode.GetBytes(pass); byte[] bSalt = Convert.FromBase64String(salt); byte[] bRet = null; if (passwordFormat == 1) { // MembershipPasswordFormat.Hashed HashAlgorithm hm = HashAlgorithm.Create("SHA1"); if (hm is KeyedHashAlgorithm) { KeyedHashAlgorithm kha = (KeyedHashAlgorithm)hm; if (kha.Key.Length == bSalt.Length) { kha.Key = bSalt; } else if (kha.Key.Length < bSalt.Length) { byte[] bKey = new byte[kha.Key.Length]; Buffer.BlockCopy(bSalt, 0, bKey, 0, bKey.Length); kha.Key = bKey; } else { byte[] bKey = new byte[kha.Key.Length]; for (int iter = 0; iter < bKey.Length; ) { int len = Math.Min(bSalt.Length, bKey.Length - iter); Buffer.BlockCopy(bSalt, 0, bKey, iter, len); iter += len; } kha.Key = bKey; } bRet = kha.ComputeHash(bIn); } else { byte[] bAll = new byte[bSalt.Length + bIn.Length]; Buffer.BlockCopy(bSalt, 0, bAll, 0, bSalt.Length); Buffer.BlockCopy(bIn, 0, bAll, bSalt.Length, bIn.Length); bRet = hm.ComputeHash(bAll); } } return Convert.ToBase64String(bRet); } }
دقت کنید تا فضاهای نام System.Text و System.Security.Cryptography را وارد کرده باشید.
متد EncodePassword کلمه عبور را بر اساس پیاده سازی پیش فرض SQL Membership رمزنگاری میکند. این الگوریتم از System.Web گرفته میشود. اگر اپلیکیشن قدیمی شما از الگوریتم خاصی استفاده میکرده است، همینجا باید آن را منعکس کنید. دو متد دیگر نیز بنامهای HashPassword و VerifyHashedPassword نیاز داریم. این متدها از EncodePassword برای رمزنگاری کلمههای عبور و تایید آنها در دیتابیس استفاده میکنند.
سیستم SQL Membership برای رمزنگاری (Hash) کلمههای عبور هنگام ثبت نام و تغییر آنها توسط کاربران، از PasswordHash, PasswordSalt و PasswordFormat استفاده میکرد. در روند مهاجرت، این سه فیلد در ستون PasswordHash جدول AspNetUsers ذخیره شده و با کاراکتر '|' جدا شده اند. هنگام ورود کاربری به سایت، اگر کله عبور شامل این فیلدها باشد از الگوریتم SQL Membership برای بررسی آن استفاده میکنیم. در غیر اینصورت از پیاده سازی پیش فرض ASP.NET Identity استفاده خواهد شد. با این روش، کاربران قدیمی لازم نیست کلمههای عبور خود را صرفا بدلیل مهاجرت اپلیکیشن ما تغییر دهند.
کلاس UserManager را مانند قطعه کد زیر بروز رسانی کنید.
public UserManager() : base(new UserStore<User>(new ApplicationDbContext())) { this.PasswordHasher = new SQLPasswordHasher(); }
ایجاد صفحات جدید مدیریت کاربران
- فایلهای Register.aspx.cs و Login.aspx.cs از کلاس UserManager استفاده میکنند. این ارجاعات را با کلاس UserManager جدیدی که در پوشه Models ساختید جایگزین کنید.
- همچنین ارجاعات استفاده از کلاس IdentityUser را به کلاس User که در پوشه Models ساختید تغییر دهید.
- لازم است توسعه دهنده مقدار ApplicationId را برای کاربران جدید طوری تنظیم کند که با شناسه اپلیکیشن جاری تطابق داشته باشد. برای این کار میتوانید پیش از ساختن حسابهای کاربری جدید در فایل Register.aspx.cs ابتدا شناسه اپلیکیشن را بدست آورید و اطلاعات کاربر را بدرستی تنظیم کنید.
private Guid GetApplicationID() { using (SqlConnection connection = new SqlConnection(ConfigurationManager.ConnectionStrings["ApplicationServices"].ConnectionString)) { string queryString = "SELECT ApplicationId from aspnet_Applications WHERE ApplicationName = '/'"; //Set application name as in database SqlCommand command = new SqlCommand(queryString, connection); command.Connection.Open(); var reader = command.ExecuteReader(); while (reader.Read()) { return reader.GetGuid(0); } return Guid.NewGuid(); } }
var currentApplicationId = GetApplicationID(); User user = new User() { UserName = Username.Text, ApplicationId=currentApplicationId, …};
مباحث eager fetching/loading (واکشی حریصانه) و lazy loading/fetching (واکشی در صورت نیاز، با تاخیر، تنبل) جزو نکات کلیدی کار با ORM های پیشرفته بوده و در صورت عدم اطلاع از آنها و یا استفادهی ناصحیح از هر کدام، باید منتظر از کار افتادن زود هنگام سیستم در زیر بار چند کاربر همزمان بود. به همین جهت تصور اینکه "با استفاده از ORMs دیگر از فراگیری SQL راحت شدیم!" یا اینکه "به من چه که پشت صحنه چه اتفاقی میافته!" بسی مهلک و نادرست است!
در ادامه به تفصیل به این موضوع پرداخته خواهد شد.
ابزار مورد نیاز
در این مطلب از برنامهی NHProf استفاده خواهد شد.
اگر مطالب NHibernate این سایت را دنبال کرده باشید، در مورد لاگ کردن SQL تولیدی به اندازهی کافی توضیح داده شده یا حتی یک ماژول جمع و جور هم برای مصارف دم دستی نوشته شده است. این موارد شاید این ایده را به همراه داشته باشند که چقدر خوب میشد یک برنامهی جامعتر برای این نوع بررسیها تهیه میشد. حداقل SQL نهایی فرمت میشد (یعنی برنامه باید مجهز به یک SQL Parser تمام عیار باشد که کار چند ماهی هست ...؛ با توجه به اینکه مثلا NHibernate از افزونههای SQL ویژه بانکهای اطلاعاتی مختلف هم پشتیبانی میکند، مثلا T-SQL مایکروسافت با یک سری ریزه کاریهای منحصر به MySQL متفاوت است)، یا پس از فرمت شدن، syntax highlighting به آن اضافه میشد، در ادامه مشخص میکرد کدام کوئریها سنگینتر هستند، کدامیک نشانهی عدم استفادهی صحیح از ORM مورد استفاده است، چه مشکلی دارد و از این موارد.
خوشبختانه این ایدهها یا آرزوها با برنامهی NHProf محقق شده است. این برنامه برای استفادهی یک ماه اول آن رایگان است (آدرس ایمیل خود را وارد کنید تا یک فایل مجوز رایگان یک ماهه برای شما ارسال گردد) و پس از یک ماه، باید حداقل 300 دلار هزینه کنید.
واکشی حریصانه و غیرحریصانه چیست؟
رفتار یک ORM جهت تعیین اینکه آیا نیاز است برای دریافت اطلاعات بین جداول Join صورت گیرد یا خیر، واکشی حریصانه و غیرحریصانه را مشخص میسازد.
در حالت واکشی حریصانه به ORM خواهیم گفت که لطفا جهت دریافت اطلاعات فیلدهای جداول مختلف، از همان ابتدای کار در پشت صحنه، Join های لازم را تدارک ببین. در حالت واکشی غیرحریصانه به ORM خواهیم گفت به هیچ عنوان حق نداری Join ایی را تشکیل دهی. هر زمانی که نیاز به اطلاعات فیلدی از جدولی دیگر بود باید به صورت مستقیم به آن مراجعه کرده و آن مقدار را دریافت کنی.
به صورت خلاصه برنامه نویس در حین کار با ORM های پیشرفته نیازی نیست Join بنویسد. تنها باید ORM را طوری تنظیم کند که آیا اینکار را حتما خودش در پشت صحنه انجام دهد (واکشی حریصانه)، یا اینکه خیر، به هیچ عنوان SQL های تولیدی در پشت صحنه نباید حاوی Join باشند (lazy loading).
چگونه واکشی حریصانه و غیرحریصانه را در NHibernate 3.0 تنظیم کنیم؟
در NHibernate اگر تنظیم خاصی را تدارک ندیده و خواص جداول خود را به صورت virtual معرفی کرده باشید، تنظیم پیش فرض دریافت اطلاعات همان lazy loading است. به مثالی در این زمینه توجه بفرمائید:
مدل برنامه:
مدل برنامه همان مثال کلاسیک مشتری و سفارشات او میباشد. هر مشتری چندین سفارش میتواند داشته باشد. هر سفارش به یک مشتری وابسته است. هر سفارش نیز از چندین قلم جنس تشکیل شده است. در این خرید، هر جنس نیز به یک سفارش وابسته است.
using System.Collections.Generic;
namespace CustomerOrdersSample.Domain
{
public class Customer
{
public virtual int Id { get; set; }
public virtual string Name { get; set; }
public virtual IList<Order> Orders { get; set; }
}
}
using System;
using System.Collections.Generic;
namespace CustomerOrdersSample.Domain
{
public class Order
{
public virtual int Id { get; set; }
public virtual DateTime OrderDate { set; get; }
public virtual Customer Customer { get; set; }
public virtual IList<OrderItem> OrderItems { set; get; }
}
}
namespace CustomerOrdersSample.Domain
{
public class OrderItem
{
public virtual int Id { get; set; }
public virtual Product Product { get; set; }
public virtual int Quntity { get; set; }
public virtual Order Order { set; get; }
}
}
namespace CustomerOrdersSample.Domain
{
public class Product
{
public virtual int Id { set; get; }
public virtual string Name { get; set; }
public virtual decimal UnitPrice { get; set; }
}
}
که جداول متناظر با آن به صورت زیر خواهند بود:
create table Customers (
CustomerId INT IDENTITY NOT NULL,
Name NVARCHAR(255) null,
primary key (CustomerId)
)
create table Orders (
OrderId INT IDENTITY NOT NULL,
OrderDate DATETIME null,
CustomerId INT null,
primary key (OrderId)
)
create table OrderItems (
OrderItemId INT IDENTITY NOT NULL,
Quntity INT null,
ProductId INT null,
OrderId INT null,
primary key (OrderItemId)
)
create table Products (
ProductId INT IDENTITY NOT NULL,
Name NVARCHAR(255) null,
UnitPrice NUMERIC(19,5) null,
primary key (ProductId)
)
alter table Orders
add constraint fk_Customer_Order
foreign key (CustomerId)
references Customers
alter table OrderItems
add constraint fk_Product_OrderItem
foreign key (ProductId)
references Products
alter table OrderItems
add constraint fk_Order_OrderItem
foreign key (OrderId)
references Orders
همچنین یک سری اطلاعات آزمایشی زیر را هم در نظر بگیرید: (بانک اطلاعاتی انتخاب شده SQL CE است)
SET IDENTITY_INSERT [Customers] ON;
GO
INSERT INTO [Customers] ([CustomerId],[Name]) VALUES (1,N'Customer1');
GO
SET IDENTITY_INSERT [Customers] OFF;
GO
SET IDENTITY_INSERT [Products] ON;
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (1,N'Product1',1000.00000);
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (2,N'Product2',2000.00000);
GO
INSERT INTO [Products] ([ProductId],[Name],[UnitPrice]) VALUES (3,N'Product3',3000.00000);
GO
SET IDENTITY_INSERT [Products] OFF;
GO
SET IDENTITY_INSERT [Orders] ON;
GO
INSERT INTO [Orders] ([OrderId],[OrderDate],[CustomerId]) VALUES (1,{ts '2011-01-07 11:25:20.000'},1);
GO
SET IDENTITY_INSERT [Orders] OFF;
GO
SET IDENTITY_INSERT [OrderItems] ON;
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (1,10,1,1);
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (2,5,2,1);
GO
INSERT INTO [OrderItems] ([OrderItemId],[Quntity],[ProductId],[OrderId]) VALUES (3,20,3,1);
GO
SET IDENTITY_INSERT [OrderItems] OFF;
GO
دریافت اطلاعات :
میخواهیم نام کلیه محصولات خریداری شده توسط مشتریها را به همراه نام مشتری و زمان خرید مربوطه، نمایش دهیم (دریافت اطلاعات از 4 جدول بدون join نویسی):
var list = session.QueryOver<Customer>().List();
foreach (var customer in list)
{
foreach (var order in customer.Orders)
{
foreach (var orderItem in order.OrderItems)
{
Console.WriteLine("{0}:{1}:{2}", customer.Name, order.OrderDate, orderItem.Product.Name);
}
}
}
خروجی به صورت زیر خواهد بود:
Customer1:2011/01/07 11:25:20 :Product1
Customer1:2011/01/07 11:25:20 :Product2
Customer1:2011/01/07 11:25:20 :Product3
همانطور که مشاهده میکنید در اینجا اطلاعات از 4 جدول مختلف دریافت میشوند اما ما Join ایی را ننوشتهایم. ORM هرجایی که به اطلاعات فیلدهای جداول دیگر نیاز داشته، به صورت مستقیم به آن جدول مراجعه کرده و یک کوئری، حاصل این عملیات خواهد بود (مطابق تصویر جمعا 6 کوئری در پشت صحنه برای نمایش سه سطر خروجی فوق اجرا شده است).
این حالت فقط و فقط با تعداد رکورد کم بهینه است (و به همین دلیل هم تدارک دیده شده است). بنابراین اگر برای مثال قصد نمایش اطلاعات حاصل از 4 جدول فوق را در یک گرید داشته باشیم، بسته به تعداد رکوردها و تعداد کاربران همزمان برنامه (خصوصا در برنامههای تحت وب)، بانک اطلاعاتی باید بتواند هزاران هزار کوئری رسیده حاصل از lazy loading را پردازش کند و این یعنی مصرف بیش از حد منابع (IO بالا، مصرف حافظه بالا) به همراه بالا رفتن CPU usage و از کار افتادن زود هنگام سیستم.
کسانی که پیش از این با SQL نویسی خو گرفتهاند احتمالا الان منابع موجود را در مورد نحوهی نوشتن Join در NHibernate زیر و رو خواهند کرد؛ زیرا پیش از این آموختهاند که برای دریافت اطلاعات از دو یا چند جدول مرتبط باید Join نوشت. اما همانطور که پیشتر نیز عنوان شد، اگر با جزئیات کار با NHibernate آشنا شویم، نیازی به Join نویسی نخواهیم داشت. اینکار را خود ORM در پشت صحنه باید و میتواند مدیریت کند. اما چگونه؟
در NHibernate 3.0 با معرفی QueryOver که جایگزینی از نوع strongly typed همان ICriteria API قدیمی است، یا با معرفی Query که همان LINQ to NHibernate میباشد، متدی به نام Fetch نیز تدارک دیده شده است که استراتژیهای lazy loading و eager loading را به سادگی توسط آن میتوان مشخص نمود.
مثال: دریافت اطلاعات با استفاده از QueryOver
var list = session
.QueryOver<Customer>()
.Fetch(c => c.Orders).Eager
.Fetch(c => c.Orders.First().OrderItems).Eager
.Fetch(c => c.Orders.First().OrderItems.First().Product).Eager
.List();
foreach (var customer in list)
{
foreach (var order in customer.Orders)
{
foreach (var orderItem in order.OrderItems)
{
Console.WriteLine("{0}:{1}:{2}", customer.Name, order.OrderDate, orderItem.Product.Name);
}
}
}
پشت صحنه:
اینبار فقط یک کوئری حاصل عملیات بوده و join ها به صورت خودکار با توجه به متدهای Fetch ذکر شده که حالت eager loading آنها صریحا مشخص شده است، تشکیل شدهاند (6 بار رفت و برگشت به بانک اطلاعاتی به یکبار تقلیل یافت).
نکته 1: نتایج تکراری
اگر حاصل join آخر را نمایش دهیم، نتایجی تکراری خواهیم داشت که مربوط است به مقدار دهی customer با سه وهله از شیء مربوطه تا بتواند واکشی حریصانهی مجموعه اشیاء فرزند آنرا نیز پوشش دهد. برای رفع این مشکل یک سطر TransformUsing باید اضافه شود:
...
.TransformUsing(NHibernate.Transform.Transformers.DistinctRootEntity)
.List();
دریافت اطلاعات با استفاده از LINQ to NHibernate3.0
برای اینکه بتوان متدهای Fetch ذکر شده را به LINQ to NHibernate 3.0 اعمال نمود، ذکر فضای نام NHibernate.Linq ضروری است. پس از آن خواهیم داشت:
var list = session
.Query()
.FetchMany(c => c.Orders)
.ThenFetchMany(o => o.OrderItems)
.ThenFetch(p => p.Product)
.ToList();
اینبار از FetchMany، سپس ThenFetchMany (برای واکشی حریصانه مجموعههای فرزند) و در آخر از ThenFetch استفاده خواهد شد.
همانطور که ملاحظه میکنید حاصل این کوئری، با کوئری قبلی ذکر شده یکسان است. هر دو، اطلاعات مورد نیاز از دو جدول مختلف را نمایش میدهند. اما یکی در پشت صحنه شامل چندین و چند کوئری برای دریافت اطلاعات است، اما دیگری تنها از یک کوئری Join دار تشکیل شده است.
نکته 2: خطاهای ممکن
ممکن است حین تعریف متدهای Fetch در زمان اجرا به خطاهای Antlr.Runtime.MismatchedTreeNodeException و یا Specified method is not supported و یا موارد مشابهی برخورد نمائید. تنها کاری که باید انجام داد جابجا کردن مکان بکارگیری extension methods است. برای مثال متد Fetch باید پس از Where در حالت استفاده از LINQ ذکر شود و نه قبل از آن.
سرویس ما متدهای زیر را در دسترس قرار میدهد.
Relative URl | HTTP method | Action |
api/products/ | GET | گرفتن لیست تمام محصولات |
api/products/id/ | GET | گرفتن یک محصول بر اساس شناسه |
api/products?category=category/ | GET | گرفتن یک محصول بر اساس طبقه بندی |
api/products/ | POST | ایجاد یک محصول جدید |
api/products/id/ | PUT | بروز رسانی یک محصول |
api/products/id/ | DELETE | حذف یک محصول |
همانطور که مشاهده میکنید برخی از آدرس ها، شامل شناسه محصول هم میشوند. بعنوان مثال برای گرفتن محصولی با شناسه 28، کلاینت یک درخواست GET را به آدرس زیر ارسال میکند:
http://hostname/api/products/28
منابع
سرویس ما آدرس هایی برای دستیابی به دو نوع منبع (resource) را تعریف میکند:
URI | Resource |
api/products/ | لیست تمام محصولات |
api/products/id/ | یک محصول مشخص |
متد ها
چهار متد اصلی HTTP یعنی همان GET, PUT, POST, DELETE میتوانند بصورت زیر به عملیات CRUD نگاشت شوند:
- متد GET یک منبع (resource) را از آدرس تعریف شده دریافت میکند. متدهای GET هیچگونه تاثیری روی سرور نباید داشته باشند. مثلا حذف رکوردها با متد اکیدا اشتباه است.
- متد PUT یک منبع را در آدرس تعریف شده بروز رسانی میکند. این متد برای ساختن منابع جدید هم میتواند استفاده شود، البته در صورتی که سرور به کلاینتها اجازه مشخص کردن آدرسهای جدید را بدهد. در مثال جاری پشتیبانی از ایجاد منابع توسط متد PUT را بررسی نخواهیم کرد.
- متد POST منبع جدیدی میسازد. سرور آدرس آبجکت جدید را تعیین میکند و آن را بعنوان بخشی از پیام Response بر میگرداند.
- متد DELETE منبعی را در آدرس تعریف شده حذف میکند.
نکته: متد PUT موجودیت محصول (product entity) را کاملا جایگزین میکند. به بیان دیگر، از کلاینت انتظار میرود که آبجکت کامل محصول را برای بروز رسانی ارسال کند. اگر میخواهید از بروز رسانیهای جزئی/پاره ای (partial) پشتیبانی کنید متد PATCH توصیه میشود. مثال جاری متد PATCH را پیاده سازی نمیکند.
یک پروژه Web API جدید بسازید
ویژوال استودیو را باز کنید و پروژه جدیدی از نوع ASP.NET MVC Web Application بسازید. نام پروژه را به "ProductStore" تغییر دهید و OK کنید.
در دیالوگ New ASP.NET Project قالب Web API را انتخاب کرده و تایید کنید.
افزودن یک مدل
یک مدل، آبجکتی است که داده اپلیکیشن شما را نمایندگی میکند. در ASP.NET Web API میتوانید از آبجکتهای Strongly-typed بعنوان مدل هایتان استفاده کنید که بصورت خودکار برای کلاینت به فرمتهای JSON, XML مرتب (Serialize) میشوند. در مثال جاری، دادههای ما محصولات هستند. پس کلاس جدیدی بنام Product میسازیم.
در پوشه Models کلاس جدیدی با نام Product بسازید.
حال خواص زیر را به این کلاس اضافه کنید.
namespace ProductStore.Models { public class Product { public int Id { get; set; } public string Name { get; set; } public string Category { get; set; } public decimal Price { get; set; } } }
افزودن یک مخزن
ما نیاز به ذخیره کردن کلکسیونی از محصولات داریم، و بهتر است این کلکسیون از پیاده سازی سرویس تفکیک شود. در این صورت بدون نیاز به بازنویسی کلاس سرویس میتوانیم منبع دادهها را تغییر دهیم. این نوع طراحی با نام الگوی مخزن یا Repository Pattern شناخته میشود. برای شروع نیاز به یک قرارداد جنریک برای مخزنها داریم.
روی پوشه Models کلیک راست کنید و گزینه Add, New Item را انتخاب نمایید.
نوع آیتم جدید را Interface انتخاب کنید و نام آن را به IProductRepository تغییر دهید.
حال کد زیر را به این اینترفیس اضافه کنید.
namespace ProductStore.Models { public interface IProductRepository { IEnumerable<Product> GetAll(); Product Get(int id); Product Add(Product item); void Remove(int id); bool Update(Product item); } }
namespace ProductStore.Models { public class ProductRepository : IProductRepository { private List<Product> products = new List<Product>(); private int _nextId = 1; public ProductRepository() { Add(new Product { Name = "Tomato soup", Category = "Groceries", Price = 1.39M }); Add(new Product { Name = "Yo-yo", Category = "Toys", Price = 3.75M }); Add(new Product { Name = "Hammer", Category = "Hardware", Price = 16.99M }); } public IEnumerable<Product> GetAll() { return products; } public Product Get(int id) { return products.Find(p => p.Id == id); } public Product Add(Product item) { if (item == null) { throw new ArgumentNullException("item"); } item.Id = _nextId++; products.Add(item); return item; } public void Remove(int id) { products.RemoveAll(p => p.Id == id); } public bool Update(Product item) { if (item == null) { throw new ArgumentNullException("item"); } int index = products.FindIndex(p => p.Id == item.Id); if (index == -1) { return false; } products.RemoveAt(index); products.Add(item); return true; } } }
مخزن ما لیست محصولات را در حافظه محلی نگهداری میکند. برای مثال جاری این طراحی کافی است، اما در یک اپلیکیشن واقعی دادههای شما در یک دیتابیس یا منبع داده ابری ذخیره خواهند شد. همچنین استفاده از الگوی مخزن، تغییر منبع دادهها در آینده را راحتتر میکند.
افزودن یک کنترلر Web API
اگر قبلا با ASP.NET MVC کار کرده باشید، با مفهوم کنترلرها آشنایی دارید. در ASP.NET Web API کنترلرها کلاس هایی هستند که درخواستهای HTTP دریافتی از کلاینت را به اکشن متدها نگاشت میکنند. ویژوال استودیو هنگام ساختن پروژه شما دو کنترلر به آن اضافه کرده است. برای مشاهد آنها پوشه Controllers را باز کنید.
- HomeController یک کنترلر مرسوم در ASP.NET MVC است. این کنترلر مسئول بکار گرفتن صفحات وب است و مستقیما ربطی به Web API ما ندارد.
- ValuesController یک کنترلر نمونه WebAPI است.
کنترلر ValuesController را حذف کنید، نیازی به این آیتم نخواهیم داشت. حال برای اضافه کردن کنترلری جدید مراحل زیر را دنبال کنید.
در پنجره Solution Explorer روی پوشه Controllers کلیک راست کرده و گزینه Add, Controller را انتخاب کنید.
در دیالوگ Add Controller نام کنترلر را به ProductsController تغییر داده و در قسمت Scaffolding Options گزینه Empty API Controller را انتخاب کنید.
حال فایل کنترلر جدید را باز کنید و عبارت زیر را به بالای آن اضافه نمایید.
using ProductStore.Models;
public class ProductsController : ApiController { static readonly IProductRepository repository = new ProductRepository(); }
فراخوانی ()new ProductRepository طراحی جالبی نیست، چرا که کنترلر را به پیاده سازی بخصوصی از این اینترفیس گره میزند. بهتر است از تزریق وابستگی (Dependency Injection) استفاده کنید. برای اطلاعات بیشتر درباره تکنیک DI در Web API به این لینک مراجعه کنید.
گرفتن منابع
ProductStore API اکشنهای متعددی در قالب متدهای HTTP GET در دسترس قرار میدهد. هر اکشن به متدی در کلاس ProductsController مرتبط است.
Relative URl | HTTP Method | Action |
api/products/ | GET | دریافت لیست تمام محصولات |
api/products/id/ | GET | دریافت محصولی مشخص بر اساس شناسه |
api/products?category=category/ | GET | دریافت محصولات بر اساس طبقه بندی |
برای دریافت لیست تمام محصولات متد زیر را به کلاس ProductsController اضافه کنید.
public class ProductsController : ApiController { public IEnumerable<Product> GetAllProducts() { return repository.GetAll(); } // .... }
برای دریافت محصولی مشخص بر اساس شناسه آن متد زیر را اضافه کنید.
public Product GetProduct(int id) { Product item = repository.Get(id); if (item == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } return item; }
نام این متد هم با "Get" شروع میشود اما پارامتری با نام id دارد. این پارامتر به قسمت id مسیر درخواست شده (request URl) نگاشت میشود. تبدیل پارامتر به نوع داده مناسب (در اینجا int) هم بصورت خودکار توسط فریم ورک ASP.NET Web API انجام میشود.
متد GetProduct در صورت نامعتبر بودن پارامتر id استثنایی از نوع HttpResponseException تولید میکند. این استثنا بصورت خودکار توسط فریم ورک Web API به خطای 404 (Not Found) ترجمه میشود.
در آخر متدی برای دریافت محصولات بر اساس طبقه بندی اضافه کنید.
public IEnumerable<Product> GetProductsByCategory(string category) { return repository.GetAll().Where( p => string.Equals(p.Category, category, StringComparison.OrdinalIgnoreCase)); }
اگر آدرس درخواستی پارامترهای query string داشته باشد، Web API سعی میکند پارامترها را با پارامترهای متد کنترلر تطبیق دهد. بنابراین درخواستی به آدرس "api/products?category=category" به این متد نگاشت میشود.
ایجاد منبع جدید
قدم بعدی افزودن متدی به ProductsController برای ایجاد یک محصول جدید است. لیست زیر پیاده سازی ساده ای از این متد را نشان میدهد.
// Not the final implementation! public Product PostProduct(Product item) { item = repository.Add(item); return item; }
- نام این متد با "Post" شروع میشود. برای ساختن محصولی جدید کلاینت یک درخواست HTTP POST ارسال میکند.
- این متد پارامتری از نوع Product میپذیرد. در Web API پارامترهای پیچیده (complex types) بصورت خودکار با deserialize کردن بدنه درخواست بدست میآیند. بنابراین در اینجا از کلاینت انتظار داریم که آبجکتی از نوع Product را با فرمت XML یا JSON ارسال کند.
پیاده سازی فعلی این متد کار میکند، اما هنوز کامل نیست. در حالت ایده آل ما میخواهیم پیام HTTP Response موارد زیر را هم در بر گیرد:
- Response code: بصورت پیش فرض فریم ورک Web API کد وضعیت را به 200 (OK) تنظیم میکند. اما طبق پروتکل HTTP/1.1 هنگامی که یک درخواست POST منجر به ساخته شدن منبعی جدید میشود، سرور باید با کد وضعیت 201 (Created) پاسخ دهد.
- Location: هنگامی که سرور منبع جدیدی میسازد، باید آدرس منبع جدید را در قسمت Location header پاسخ درج کند.
ASP.NET Web API دستکاری پیام HTTP response را آسان میکند. لیست زیر پیاده سازی بهتری از این متد را نشان میدهد.
public HttpResponseMessage PostProduct(Product item) { item = repository.Add(item); var response = Request.CreateResponse<Product>(HttpStatusCode.Created, item); string uri = Url.Link("DefaultApi", new { id = item.Id }); response.Headers.Location = new Uri(uri); return response; }
متد CreateResponse آبجکتی از نوع HttpResponseMessage میسازد و بصورت خودکار آبجکت Product را مرتب (serialize) کرده و در بدنه پاسخ مینویسد. نکته دیگر آنکه مثال جاری، مدل را اعتبارسنجی نمیکند. برای اطلاعات بیشتر درباره اعتبارسنجی مدلها در Web API به این لینک مراجعه کنید.
بروز رسانی یک منبع
بروز رسانی یک محصول با PUT ساده است.
public void PutProduct(int id, Product product) { product.Id = id; if (!repository.Update(product)) { throw new HttpResponseException(HttpStatusCode.NotFound); } }
حذف یک منبع
برای حذف یک محصول متد زیر را به کلاس ProductsController اضافه کنید.
public void DeleteProduct(int id) { Product item = repository.Get(id); if (item == null) { throw new HttpResponseException(HttpStatusCode.NotFound); } repository.Remove(id); }
در مثال جاری متد DeleteProduct نوع void را بر میگرداند، که فریم ورک Web API آن را بصورت خودکار به کد وضعیت 204 (No Content) ترجمه میکند.
خلاصهای از اعمال متداول با AutoMapper و Entity Framework
public class Order { public int Id { get; set; } public string Description { get; set; } public virtual ICollection<Detail> Details{get;set;} public virtual ICollection<Trace> Traces { get; set; } } public class Detail { public int ProductRef { get; set; } public int qty { get; set; } public string Description { get; set; } public virtual ICollection<Audit> Audits { get; set; } } public class Audit { public int detailRef { get; set; } public int qty { get; set; } public int status { get; set; } } public class Trace { public int OrderId { get; set; } public int UserRef { get; set; } public int step { get; set; } public string Description { get; set; } }
public class OrderVM { public int Id { get; set; } public string Description { get; set; } //مثلا توضیحات مرحله 1 //where step==1 public string Description1 { get; set; } //مثلا توضیحات مرحله 2 //where step==2 public string Description2 { get; set; } } public class OrderDetailVM { //مثلا تعداد درخواست مرحله 1 //where status==1 public int qty1 { get; set; } //مثلا تعداد درخواست مرحله 2 //where status==2 public int qty2 { get; set; } //تعداد درخواست نهایی public int qty { get; set; } public int productId{ get; set; } }
FluentValidation #1
public class Customer { public int Id { get; set; } public string Surname { get; set; } public string Forename { get; set; } public decimal Discount { get; set; } public string Address { get; set; } }
using FluentValidation; public class CustomerValidator : AbstractValidator<Customer> { public CustomerValidator { RuleFor(customer => customer.Surname).NotNull(); } }
اعتبارسنجی زنجیره ای برای یک خاصیت
RuleFor(customer => customer.Surname).NotNull().NotEqual("foo");
Customer customer = new Customer(); CustomerValidator validator = new CustomerValidator(); ValidationResult results = validator.Validate(customer);
خروجی متد Validate، یک ValidationResult است که شامل دو خاصیت زیر میباشد:
- IsValid: از نوع bool برای تعیین اینکه اعتبارسنجی موفقیت آمیز بوده یا خیر.
- Errors: یک مجموعه از ValidationFailure که جزئیات تمام اعتبارسنجیهای ناموفق را شامل میشود.
Customer customer = new Customer(); CustomerValidator validator = new CustomerValidator(); ValidationResult results = validator.Validate(customer); if(! results.IsValid) { foreach(var failure in results.Errors) { Console.WriteLine("Property " + failure.PropertyName + " failed validation. Error was: " + failure.ErrorMessage); } }
پرتاب استثناها (Throwing Exceptions)
Customer customer = new Customer(); CustomerValidator validator = new CustomerValidator(); validator.ValidateAndThrow(customer);
public class Customer { public string Name { get; set; } public Address Address { get; set; } } public class Address { public string Line1 { get; set; } public string Line2 { get; set; } public string Town { get; set; } public string County { get; set; } public string Postcode { get; set; } } public class AddressValidator : AbstractValidator<Address> { public AddressValidator() { RuleFor(address => address.Postcode).NotNull(); //etc } } public class CustomerValidator : AbstractValidator<Customer> { public CustomerValidator() { RuleFor(customer => customer.Name).NotNull(); RuleFor(customer => customer.Address).SetValidator(new AddressValidator()) } }
استفاده از Validatorها برای مجموعهها (Collections)
public class Customer { public IList<Order> Orders { get; set; } } public class Order { public string ProductName { get; set; } public decimal? Cost { get; set; } } var customer = new Customer(); customer.Orders = new List<Order> { new Order { ProductName = "Foo" }, new Order { Cost = 5 } };
public class OrderValidator : AbstractValidator<Order> { public OrderValidator() { RuleFor(x => x.ProductName).NotNull(); RuleFor(x => x.Cost).GreaterThan(0); } }
این Validator میتواند داخل CustomerValidator مورد استفاده قرار بگیرد (با استفاده از متد SetCollectionValidator):
public class CustomerValidator : AbstractValidator<Customer> { public CustomerValidator() { RuleFor(x => x.Orders).SetCollectionValidator(new OrderValidator()); } }
می توان با استفاده از متد Where یا Unless روی اعتبارسنجی شرط گذاشت:
RuleFor(x => x.Orders).SetCollectionValidator(new OrderValidator()).Where(x => x.Cost != null);
گروه بندی قوانین اعتبارسنجی
public class PersonValidator : AbstractValidator<Person> { public PersonValidator() { RuleSet("Names", () => { RuleFor(x => x.Surname).NotNull(); RuleFor(x => x.Forename).NotNull(); }); RuleFor(x => x.Id).NotEqual(0); } }
var validator = new PersonValidator(); var person = new Person(); var result = validator.Validate(person, ruleSet: "Names");
اصل چهارم: Starve for loosely coupled designs
"به دنبال طراحی با اتصال سست بین اجزا باش"
اتصال بین اجزای برنامه نویسی باعث سختتر شدن مدیریت تغییرات میشود؛ چرا که با تغییر یک بخش، بخشهای متصل نیز دچار مشکل خواهند شد. اتصالها از لحاظ نوع قدرت متفاوتند و اساسا سیستمی بدون اتصال وجود ندارد. لذا باید به دنبال یک طراحی با کمترین میزان قدرت اتصال یا همان سست اتصال باشیم.
تا به اینجا، اصلهای دوم و سوم ما را در کاهش وابستگی و اتصال قوی کمک کردهاند. استفاده از واسطها، باعث کاهش وابستگی به نوع پیاده سازی میشود. استفاده از ترکیب نیز به نوعی باعث از بین رفتن وابستگی قوی بین کلاسهای فرزند و کلاس والد میشود و با روشی دیگر (استفاده از شیء در برگرفته شده برای پیاده سازی وظیفهی تغییر کننده) وظایف را در کلاسها پیاده سازی میکند. در زیر نمونهی اتصال قوی و نتیجهی آن را میبینیم:
public class StrongCoupledConcreteA { public string GenerateString(string s) { return s + " from" + this.GetType().ToString(); } } public class StrongCoupledConcreteB { public void GenerateString(ref string s) { s += " from" + this.GetType().ToString(); } } public class Printer { bool condition; public Printer(bool cond) { condition = cond; } public void SetCondition(bool value) { condition = value; } public void Print() { string result; string input = " this message is"; if (condition) { var stringGenerator = new StrongCoupledConcreteA(); result = stringGenerator.GenerateString(input); } else { var stringGenerator = new StrongCoupledConcreteB(); result = input; stringGenerator.GenerateString(ref result); } Console.WriteLine(result); } } public class Context { Printer printer; public void DoWork() { printer = new Printer(true); printer.Print(); printer.SetCondition(false); printer.Print(); } }
حال کد بازنویسی شده را با آن مقایسه کنید:
public interface IStringGenerator { string GenerateString(string s); } public class LooslyCoupledConcreteA : IStringGenerator { public string GenerateString(string s) { return s + " from " + this.GetType().ToString(); } } public class LooslyCoupledConcreteB : IStringGenerator { public string GenerateString(string s) { return s + " from " + this.GetType().ToString(); } } public class Printer { bool condition; public Printer(bool cond) { condition = cond; } public void SetCondition(bool value) { condition = value; } public void Print() { string result; string input = " this message is"; IStringGenerator generator; if (condition) { generator = new LooslyCoupledConcreteA(); } else { generator = new LooslyCoupledConcreteB(); } result = generator.GenerateString(input); Console.WriteLine(result); } }
با کمی دقت مشاهده میکنیم که در کلاسهای strongly coupled با اینکه هدف هر دو کلاس تولید یک رشته است، ولی عدم وجود پروتکل باعث شده است نحوهی گرفتن ورودی و برگرداندن خروجی متفاوت شود و در نتیجه نیازمند به اضافه کردن پیچیدگی در کلاس فراخوانی کنندهی آنها میشویم. این در حالی است که در روش loosely coupled با ایجاد یک پروتکل (واسط IStringGenerator ) این پیچیدگی از بین رفته است. در اینجا نوع اتصال (وابستگی) از جنس اتصال (وابستگی) قوی به تعریف (prototype) و شاید به نوعی نحوهی پیاده سازی متد میباشد.
SOLID Principles *
پنج اصل بعدی به اصول SOLID معروف هستند.
S: Single Responsibility
O: Open/Closed
L: Liskov’s Substitution
I: Interface Segregation
D: Dependency Injection
اصل پنجم: Single responsibility
"به دنبال ماژولهای تک مسئولیتی باش"
در این قسمت مقصود از مسئولیت، «دلیلی است که کلاس باید تغییر کند» بدین معنا که اگر کلاسی با چند دلیل متفاوت مجبور به تغییر شود، آن کلاس چند مسئولیتی است. کلاسهای چند مسئولیتی عموما کد حجیمی دارند؛ نام آنها تعریف دقیقی را از مسئولیتشان ارائه نمیدهد و با عنوانی بسیار کلی نامگذاری میشوند و اشکال زدایی آنها بسیار طاقت فرساست. از طرفی، چند مسئولیتی بودن یک کلاس، باعث از بین رفتن مزایای توارث میشود. مثلا فرض کنید دو مسئولیت A,B در واسطی بیان میشوند که به یکدیگر مرتبط نبوده و مستقلند. برای مسئولیت A دو پیاده سازی و برای مسئولیت B، سه پیاده سازی در نظر گرفته شده است و جمعا برای پشتیبانی از تمامی حالات باید شش کلاس پیاده ساز، در نظر گرفته شود که توارث را سخت و بی معنی میکند زیرا قابلیت استفاده مجدد را از توارث سلب کرده است. با این وجود عملا رعایت همچین نکتهای در دنیای واقعی کار سختی است.
مثال زیر این مشکل را بیان میدارد:
// single responsibility principle - bad example interface IEmail { void SetSender(string sender); void SetReceiver(string receiver); void SetContent(string content); } class Email : IEmail { public void SetSender(string sender) { throw new NotImplementedException(); } public void SetReceiver(string receiver) { throw new NotImplementedException(); } public void SetContent(string content) { throw new NotImplementedException(); } }
در این مثال کلاس Email دارای دو مسئولیت (دلیل برای تغییر) است: الف- نحوه مقداردهی فرستنده و گیرنده براساس پروتکلهای مختلف مانند IMAP, POP3 ، بدین معنا که با تغییر پروتکل نیاز به تغییر پیاده سازی خواهیم شد. ب- تعریف محتوای پیام، بدین معنا که برای پشتیبانی از محتوای html, xml نیاز به تغییر کلاس Email داریم.
با تغییر طراحی خواهیم داشت:
// single responsibility principle - good example public interface IMessage { void SetSender(string sender); void SetReceiver(string receiver); void SetContent(IContent content); } public interface IContent { string GetAsString(); // used for serialization } public class Email : IMessage { public void SetSender(string sender) { throw new NotImplementedException(); } public void SetReceiver(string receiver) { throw new NotImplementedException(); } public void SetContent(IContent content) { throw new NotImplementedException(); } }
در اینجا واسط IContent مسئولیت پشتیبانی از xml, html را
خواهد داشت و نیازی به تغییر کلاس Email برای
پشتیبانی از این فرمتهای محتوای پیام را نخواهیم داشت.
اصل ششم: Open for
extension, close for modification : Open/Closed Principle
"پذیرای توسعه و
بازدارنده از تغییر هر آنچه که هست، باش"
ا ین اصل میگوید طراحی باید به گونهای باشد که با
اضافه شدن یک ویژگی، کدهای قبلی تغییری نکنند و فقط کدهای جدید برای پیاده سازی
ویژگی جدید نوشته شوند.
public class AreaCalculator { public double Area(object[] shapes) { double area = 0; foreach (var shape in shapes) { if (shape is Square) { Square square = (Square)shape; area += Math.Sqrt(square.Height); } if (shape is Triangle) { Triangle triangle = (Triangle)shape; double TotalHalf = (triangle.FirstSide + triangle.SecondSide + triangle.ThirdSide) / 2; area += Math.Sqrt(TotalHalf * (TotalHalf - triangle.FirstSide) * (TotalHalf - triangle.SecondSide) * (TotalHalf - triangle.ThirdSide)); } if (shape is Circle) { Circle circle = (Circle)shape; area += circle.Radius * circle.Radius * Math.PI; } } return area; } } public class Square { public double Height { get; set; } } public class Circle { public double Radius { get; set; } } public class Triangle { public double FirstSide { get; set; } public double SecondSide { get; set; } public double ThirdSide { get; set; } }
در اینجا کلاس AreaCalculator برای محاسبه مساحت تمام اشیاء ورودی، مساحت تک تک اشیاء را محاسبه میکند و نتیجه را برمیگرداند. در این مثال با اضافه شدن شکل هندسی جدید، باید کد این کلاس تغییر کند که با اصل Open/Closed مغایر است. برای بهبود این کد طراحی زیر پیشنهاد شده است:
public class AreaCalculator { public double Area(Shape[] shapes) { double area = 0; foreach (var shape in shapes) { area += shape.Area(); } return area; } } public abstract class Shape { public abstract double Area(); } public class Square : Shape { public double Height { get { return _height; } } private double _height; public Square(double Height) { _height = Height; } public override double Area() { return Math.Sqrt(_height); } } public class Circle : Shape { public double Radius { get { return _radius; } } private double _radius; public Circle(double Radius) { _radius = Radius; } public override double Area() { return _radius * _radius * Math.PI; } } public class Triangle : Shape { public double FirstSide { get { return _firstSide; } } public double SecondSide { get { return _secondSide; } } public double ThirdSide { get { return _thirdSide; } } private double _firstSide; private double _secondSide; private double _thirdSide; public Triangle(double FirstSide, double SecondSide, double ThirdSide) { _firstSide = FirstSide; _secondSide = SecondSide; _thirdSide = ThirdSide; } public override double Area() { double TotalHalf = (_firstSide + _secondSide + _thirdSide) / 2; return Math.Sqrt(TotalHalf * (TotalHalf - _firstSide) * (TotalHalf - _secondSide) * (TotalHalf - _thirdSide)); } }
در این طراحی، پیچیدگی محاسبه مساحت هر شکل به کلاس آن شکل منتقل شده است و با اضافه شدن شکل جدید نیازی به تغییر کلاس AreaCalculator نداریم.
در مقالهی بعدی به سه اصل دیگر اصول SOLID خواهم پرداخت.
- تمام تعاریف بومی سازی مورد نیاز برنامه در یک تک فایل SharedResource.fa.resx قرار میگیرند. این فایل نیز در یک اسمبلی مستقل از برنامهی اصلی اضافه میشود.
- با استفاده از تزریق سرویس IStringLocalizer میتوان به کلیدهای فایل SharedResource.fa.resx در هر قسمتی از برنامهی Web API دسترسی یافت.
- در این بین اگر کلیدی یافت نشد، خطایی با ذکر دقیق جزئیات منبع جستجو شده، لاگ میشود.
- کلیدهای بومی سازی data annotations نیز قابل دریافت از فایل SharedResource.fa.resx میباشند.
در ادامه روش پیاده سازی یک چنین امکاناتی را بررسی میکنیم.
قرار دادن فایل منبع اشتراکی در اسمبلی ExternalResources
پس از ایجاد پروژهی ابتدایی Web API به نام Core3xSharedResource.WebApi، یک اسمبلی جدید را برای مثال به نام Core3xSharedResource.ExternalResources تعریف کرده و در داخل آن پوشهی جدید Resources را تعریف میکنیم. به این پوشه، فایل منبع جدیدی را به نام SharedResource.fa.resx اضافه میکنیم. در کنار آن باید یک کلاس خالی به نام SharedResource.cs نیز وجود داشته باشد.
کار با ین فایل (و یا فایلهای دیگری مانند SharedResource.en.resx) همانند تمام فایلهای منبع استاندارد است و نکتهی خاصی را به همراه ندارد.
معرفی فایل منبع اشتراکی به سرویسهای بومی سازی برنامه
پس از ایجاد و تکمیل فایل منبع اشتراکی، برای معرفی آن به برنامه، ابتدا کلاس جدید LocalizationConfig را تعریف کرده و در آن متد جدید AddCustomLocalization را به صورت زیر معرفی میکنیم:
public static class LocalizationConfig { public static IMvcBuilder AddCustomLocalization(this IMvcBuilder mvcBuilder, IServiceCollection services) { mvcBuilder.AddDataAnnotationsLocalization(options => { const string resourcesPath = "Resources"; string baseName = $"{resourcesPath}.{nameof(SharedResource)}"; var location = new AssemblyName(typeof(SharedResource).GetTypeInfo().Assembly.FullName).Name; options.DataAnnotationLocalizerProvider = (type, factory) => { // to use `SharedResource.fa.resx` file return factory.Create(baseName, location); }; }); services.AddLocalization(); services.AddScoped<IStringLocalizer>(provider => provider.GetRequiredService<IStringLocalizer<SharedResource>>()); services.AddScoped<ISharedResourceService, SharedResourceService>(); return mvcBuilder; } }
- سپس با استفاده از متد AddLocalization، سرویسهای پایهی بومی سازی ASP.NET Core به برنامه اضافه میشوند. برای مثال پس از این تعریف اگر در هر جائی از برنامه سرویس <IStringLocalizer<SharedResource را تزریق کنید، میتوان به مداخل فایل منبع اشتراکی، دسترسی یافت.
- در ادامه امکان تزریق سرویس غیرجنریک IStringLocalizer را نیز میسر کردهایم که تعاریف خودش را از همان سرویس توکار <IStringLocalizer<SharedResource دریافت میکند. مزیت اینکار، فراهم شدن امکانات بومی سازی، برای مثال در کتابخانههایی مانند Fluent Validation است که دقیقا از سرویس غیرجنریک IStringLocalizer برای دریافت منابع استفاده میکنند.
- در آخر تعریف یک سرویس سفارشی را نیز مشاهده میکنید که در ادامهی بحث تکمیل خواهد شد.
هدف از متد AddCustomLocalization فوق، خلوت کردن فایل startup برنامه است. این متد به صورت زیر مورد استفاده قرار میگیرد:
public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddHttpContextAccessor(); services.AddControllers().AddCustomLocalization(services); }
پس از آن نیاز است میانافزار بومی سازی را نیز فعال کرد. متد UseCustomRequestLocalization زیر، اینکار را انجام میدهد:
public static class LocalizationConfig { public static IApplicationBuilder UseCustomRequestLocalization(this IApplicationBuilder app) { var requestLocalizationOptions = new RequestLocalizationOptions { DefaultRequestCulture = new RequestCulture(new CultureInfo("fa-IR")), SupportedCultures = new[] { new CultureInfo("en-US"), new CultureInfo("fa-IR") }, SupportedUICultures = new[] { new CultureInfo("en-US"), new CultureInfo("fa-IR") } }; app.UseRequestLocalization(requestLocalizationOptions); return app; } }
public class Startup { public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); app.UseCustomRequestLocalization(); app.UseRouting(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); } }
تعریف مدل برنامه به همراه ویژگیهای بومی سازی شده
در اینجا تعریف RegisterModel را مشاهده میکنید که ErrorMessageهای آن هرچند به ظاهر یک رشتهی معمولی هستند، اما در عمل از فایل منبع اشتراکی خوانده میشوند:
using System.ComponentModel.DataAnnotations; namespace Core3xSharedResource.Models.Account { public class RegisterModel { [Required(ErrorMessage = "Please enter an email address")] // -->> from the shared resources [EmailAddress(ErrorMessage = "Please enter a valid email address")] // -->> from the shared resources public string Email { get; set; } } }
فایل resx ما دارای یک چنین کلیدهایی است:
<?xml version="1.0" encoding="utf-8"?> <root> <data name="<b>Hello</b><i> {0}</i>" xml:space="preserve"> <value><b>سلام</b><i> {0}</i></value> </data> <data name="About Title" xml:space="preserve"> <value>درباره</value> </data> <data name="DNT" xml:space="preserve"> <value>.NET Tips</value> </data> <data name="SiteName" xml:space="preserve"> <value>DNT</value> </data> <data name="Please enter an email address" xml:space="preserve"> <value>لطفا ایمیلی را وارد کنید</value> </data> <data name="Please enter a valid email address" xml:space="preserve"> <value>لطفا ایمیل معتبری را وارد کنید</value> </data> </root>
آزمایش برنامه
اکنون برنامهی Web API، برای آزمایش آمادهاست. برای مثال در کنترلر زیر، سرویس عمومی IStringLocalizer به سازندهی کلاس تزریق شدهاست و سپس قصد بازگشت مقدار کلید «About Title» را دارد. همچنین خطاهای بومی شدهی مدل برنامه را نیز بررسی میکنیم:
using Core3xSharedResource.Models.Account; using Microsoft.AspNetCore.Mvc; using Microsoft.Extensions.Localization; namespace Core3xSharedResource.WebApi.Controllers { [ApiController] [Route("[controller]")] public class NormalIStringLocalizerController : ControllerBase { private readonly IStringLocalizer _localizer; public NormalIStringLocalizerController(IStringLocalizer localizer) { _localizer = localizer; } [HttpGet] public ActionResult<string> Get() { var localizedString = _localizer["About Title"]; if (localizedString.ResourceNotFound) { return NotFound($"The localization resource with ID:`{localizedString.Name}` not found. SearchedLocation: `{localizedString.SearchedLocation}`."); } return localizedString.Value; } [HttpPost] public ActionResult<RegisterModel> Post(RegisterModel model) { return model; } } }
حالت get را در تصویر فوق مشاهده میکنید. در Web API برای تنظیم زبان مورد استفاده میتوان از هدری به نام Accept-Language استفاده کرد که برای مثال در اینجا به fa تنظیم شدهاست و نتیجهی آن مراجعه به فایل SharedResource.fa.resx خواهد بود. اگر en-us وارد شود، نیاز خواهد بود تا فایل منبع اشتراکی دیگری را تعریف کنید. البته اگر این هدر تنظیم نشود، با توجه به تنظیمات متد UseCustomRequestLocalization، مقدار پیشفرض آن همان fa-IR خواهد بود.
حالت post را نیز در تصویر زیر میتوان مشاهده کرد:
در اینجا چون ایمیل وارد نشده، هر دو خطای تنظیم شدهی در مدل برنامه را دریافت کردهایم و این خطاها نیز فارسی هستند. به این معنا که بومی سازی data annotations نیز به درستی کار میکند.
تعریف یک سرویس عمومی برای محصور سازی قابلیتهای بومی سازی، در برنامههای Web API
در ادامه تعریف سرویس SharedResourceService را مشاهده میکنید که ثبت آنرا پیشتر انجام دادیم:
using System; using System.Collections.Generic; using Microsoft.Extensions.Localization; using Microsoft.Extensions.Logging; using Microsoft.AspNetCore.Http; namespace Core3xSharedResource.Services { public interface ISharedResourceService { string this[string index] { get; } IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures); string GetString(string name, params object[] arguments); string GetString(string name); } public class SharedResourceService : ISharedResourceService { private readonly IStringLocalizer _sharedLocalizer; private readonly ILogger<SharedResourceService> _logger; private readonly IHttpContextAccessor _httpContextAccessor; public SharedResourceService( IStringLocalizer sharedHtmlLocalizer, IHttpContextAccessor httpContextAccessor, ILogger<SharedResourceService> logger ) { _logger = logger ?? throw new ArgumentNullException(nameof(logger)); _sharedLocalizer = sharedHtmlLocalizer ?? throw new ArgumentNullException(nameof(sharedHtmlLocalizer)); _httpContextAccessor = httpContextAccessor ?? throw new ArgumentNullException(nameof(httpContextAccessor)); } public IEnumerable<LocalizedString> GetAllStrings(bool includeParentCultures) { return _sharedLocalizer.GetAllStrings(includeParentCultures); } public string this[string index] => GetString(index); public string GetString(string name, params object[] arguments) { var result = _sharedLocalizer.GetString(name, arguments); logError(name, result); return result; } private void logError(string name, LocalizedString result) { if (result.ResourceNotFound) { var acceptLanguage = _httpContextAccessor?.HttpContext?.Request?.Headers["Accept-Language"]; _logger.LogError($"The localization resource with Accept-Language:`{acceptLanguage}` & ID:`{name}` not found. SearchedLocation: `{result.SearchedLocation}`."); } } public string GetString(string name) { var result = _sharedLocalizer.GetString(name); logError(name, result); return result; } } }
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Core3xSharedResource.zip
الف) مثالهای کدپلکس
ب) مثال نیوگت
در ادامه قصد داریم مثال نیوگت آنرا که مثال کاملی است از نحوهی استفاده از ASP.NET Identity در ASP.NET MVC، جهت اعمال الگوی واحد کار و تزریق وابستگیها، بازنویسی کنیم.
پیشنیازها
- برای درک مطلب جاری نیاز است ابتدا دورهی مرتبطی را در سایت مطالعه کنید و همچنین با نحوهی پیاده سازی الگوی واحد کار در EF Code First آشنا باشید.
- به علاوه فرض بر این است که یک پروژهی خالی ASP.NET MVC 5 را نیز آغاز کردهاید و توسط کنسول پاور شل نیوگت، فایلهای مثال Microsoft.AspNet.Identity.Samples را به آن افزودهاید:
PM> Install-Package Microsoft.AspNet.Identity.Samples -Pre
ساختار پروژهی تکمیلی
همانند مطلب پیاده سازی الگوی واحد کار در EF Code First، این پروژهی جدید را با چهار اسمبلی class library دیگر به نامهای
AspNetIdentityDependencyInjectionSample.DataLayer
AspNetIdentityDependencyInjectionSample.DomainClasses
AspNetIdentityDependencyInjectionSample.IocConfig
AspNetIdentityDependencyInjectionSample.ServiceLayer
تکمیل میکنیم.
ساختار پروژهی AspNetIdentityDependencyInjectionSample.DomainClasses
مثال Microsoft.AspNet.Identity.Samples بر مبنای primary key از نوع string است. برای نمونه کلاس کاربران آنرا به نام ApplicationUser در فایل Models\IdentityModels.cs میتوانید مشاهده کنید. در مطلب جاری، این نوع پیش فرض، به نوع متداول int تغییر خواهد یافت. به همین جهت نیاز است کلاسهای ذیل را به پروژهی DomainClasses اضافه کرد:
using System.ComponentModel.DataAnnotations.Schema; using Microsoft.AspNet.Identity.EntityFramework; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class ApplicationUser : IdentityUser<int, CustomUserLogin, CustomUserRole, CustomUserClaim> { // سایر خواص اضافی در اینجا [ForeignKey("AddressId")] public virtual Address Address { get; set; } public int? AddressId { get; set; } } } using System.Collections.Generic; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class Address { public int Id { get; set; } public string City { get; set; } public string State { get; set; } public virtual ICollection<ApplicationUser> ApplicationUsers { set; get; } } } using Microsoft.AspNet.Identity.EntityFramework; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class CustomRole : IdentityRole<int, CustomUserRole> { public CustomRole() { } public CustomRole(string name) { Name = name; } } } using Microsoft.AspNet.Identity.EntityFramework; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class CustomUserClaim : IdentityUserClaim<int> { } } using Microsoft.AspNet.Identity.EntityFramework; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class CustomUserLogin : IdentityUserLogin<int> { } } using Microsoft.AspNet.Identity.EntityFramework; namespace AspNetIdentityDependencyInjectionSample.DomainClasses { public class CustomUserRole : IdentityUserRole<int> { } }
بدیهی است در اینجا کلاس پایه کاربران را میتوان سفارشی سازی کرد و خواص دیگری را نیز به آن افزود. برای مثال در اینجا یک کلاس جدید آدرس تعریف شدهاست که ارجاعی از آن در کلاس کاربران نیز قابل مشاهده است.
سایر کلاسهای مدلهای اصلی برنامه که جداول بانک اطلاعاتی را تشکیل خواهند داد نیز در آینده به همین اسمبلی DomainClasses اضافه میشوند.
ساختار پروژهی AspNetIdentityDependencyInjectionSample.DataLayer جهت اعمال الگوی واحد کار
اگر به همان فایل Models\IdentityModels.cs ابتدایی پروژه که اکنون کلاس ApplicationUser آنرا به پروژهی DomainClasses منتقل کردهایم، مجددا مراجعه کنید، کلاس DbContext مخصوص ASP.NET Identity نیز در آن تعریف شدهاست:
public class ApplicationDbContext : IdentityDbContext<ApplicationUser>
اینترفیس IUnitOfWork برنامه، در پروژهی DataLayer چنین شکلی را دارد که نمونهای از آنرا در مطلب آشنایی با نحوهی پیاده سازی الگوی واحد کار در EF Code First، پیشتر ملاحظه کردهاید.
using System.Collections.Generic; using System.Data.Entity; namespace AspNetIdentityDependencyInjectionSample.DataLayer.Context { public interface IUnitOfWork { IDbSet<TEntity> Set<TEntity>() where TEntity : class; int SaveAllChanges(); void MarkAsChanged<TEntity>(TEntity entity) where TEntity : class; IList<T> GetRows<T>(string sql, params object[] parameters) where T : class; IEnumerable<TEntity> AddThisRange<TEntity>(IEnumerable<TEntity> entities) where TEntity : class; void ForceDatabaseInitialize(); } }
public class ApplicationDbContext : IdentityDbContext<ApplicationUser, CustomRole, int, CustomUserLogin, CustomUserRole, CustomUserClaim>, IUnitOfWork { public DbSet<Category> Categories { set; get; } public DbSet<Product> Products { set; get; } public DbSet<Address> Addresses { set; get; }
کار کردن با این کلاس، هیچ تفاوتی با DbContextهای متداول EF Code First ندارد و تمام اصول آنها یکی است.
در ادامه اگر به فایل App_Start\IdentityConfig.cs مراجعه کنید، کلاس ذیل در آن قابل مشاهدهاست:
public class ApplicationDbInitializer : DropCreateDatabaseIfModelChanges<ApplicationDbContext>
using System.Data.Entity.Migrations; namespace AspNetIdentityDependencyInjectionSample.DataLayer.Context { public class Configuration : DbMigrationsConfiguration<ApplicationDbContext> { public Configuration() { AutomaticMigrationsEnabled = true; AutomaticMigrationDataLossAllowed = true; } } }
ساختار پروژهی AspNetIdentityDependencyInjectionSample.ServiceLayer
در ادامه مابقی کلاسهای موجود در فایل App_Start\IdentityConfig.cs را به لایه سرویس برنامه منتقل خواهیم کرد. همچنین برای آنها یک سری اینترفیس جدید نیز تعریف میکنیم، تا تزریق وابستگیها به نحو صحیحی صورت گیرد. اگر به فایلهای کنترلر این مثال پیش فرض مراجعه کنید (پیش از تغییرات بحث جاری)، هرچند به نظر در کنترلرها، کلاسهای موجود در فایل App_Start\IdentityConfig.cs تزریق شدهاند، اما به دلیل عدم استفاده از اینترفیسها، وابستگی کاملی بین جزئیات پیاده سازی این کلاسها و نمونههای تزریق شده به کنترلرها وجود دارد و عملا معکوس سازی واقعی وابستگیها رخ ندادهاست. بنابراین نیاز است این مسایل را اصلاح کنیم.
الف) انتقال کلاس ApplicationUserManager به لایه سرویس برنامه
کلاس ApplicationUserManager فایل App_Start\IdentityConfig.c را به لایه سرویس منتقل میکنیم:
using System; using System.Security.Claims; using System.Threading.Tasks; using AspNetIdentityDependencyInjectionSample.DomainClasses; using AspNetIdentityDependencyInjectionSample.ServiceLayer.Contracts; using Microsoft.AspNet.Identity; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin.Security.Cookies; using Microsoft.Owin.Security.DataProtection; namespace AspNetIdentityDependencyInjectionSample.ServiceLayer { public class ApplicationUserManager : UserManager<ApplicationUser, int>, IApplicationUserManager { private readonly IDataProtectionProvider _dataProtectionProvider; private readonly IIdentityMessageService _emailService; private readonly IApplicationRoleManager _roleManager; private readonly IIdentityMessageService _smsService; private readonly IUserStore<ApplicationUser, int> _store; public ApplicationUserManager(IUserStore<ApplicationUser, int> store, IApplicationRoleManager roleManager, IDataProtectionProvider dataProtectionProvider, IIdentityMessageService smsService, IIdentityMessageService emailService) : base(store) { _store = store; _roleManager = roleManager; _dataProtectionProvider = dataProtectionProvider; _smsService = smsService; _emailService = emailService; createApplicationUserManager(); } public void SeedDatabase() { } private void createApplicationUserManager() { // Configure validation logic for usernames this.UserValidator = new UserValidator<ApplicationUser, int>(this) { AllowOnlyAlphanumericUserNames = false, RequireUniqueEmail = true }; // Configure validation logic for passwords this.PasswordValidator = new PasswordValidator { RequiredLength = 6, RequireNonLetterOrDigit = true, RequireDigit = true, RequireLowercase = true, RequireUppercase = true, }; // Configure user lockout defaults this.UserLockoutEnabledByDefault = true; this.DefaultAccountLockoutTimeSpan = TimeSpan.FromMinutes(5); this.MaxFailedAccessAttemptsBeforeLockout = 5; // Register two factor authentication providers. This application uses Phone and Emails as a step of receiving a code for verifying the user // You can write your own provider and plug in here. this.RegisterTwoFactorProvider("PhoneCode", new PhoneNumberTokenProvider<ApplicationUser, int> { MessageFormat = "Your security code is: {0}" }); this.RegisterTwoFactorProvider("EmailCode", new EmailTokenProvider<ApplicationUser, int> { Subject = "SecurityCode", BodyFormat = "Your security code is {0}" }); this.EmailService = _emailService; this.SmsService = _smsService; if (_dataProtectionProvider != null) { var dataProtector = _dataProtectionProvider.Create("ASP.NET Identity"); this.UserTokenProvider = new DataProtectorTokenProvider<ApplicationUser, int>(dataProtector); } } } }
- متد استاتیک Create این کلاس حذف و تعاریف آن به سازندهی کلاس منتقل شدهاند. به این ترتیب با هربار وهله سازی این کلاس توسط IoC Container به صورت خودکار این تنظیمات نیز به کلاس پایه UserManager اعمال میشوند.
- اگر به کلاس پایه UserManager دقت کنید، به آرگومانهای جنریک آن یک int هم اضافه شدهاست. این مورد جهت استفاده از primary key از نوع int ضروری است.
- در کلاس پایه UserManager تعدادی متد وجود دارند. تعاریف آنها را به اینترفیس IApplicationUserManager منتقل خواهیم کرد. نیازی هم به پیاده سازی این متدها در کلاس جدید ApplicationUserManager نیست؛ زیرا کلاس پایه UserManager پیشتر آنها را پیاده سازی کردهاست. به این ترتیب میتوان به یک تزریق وابستگی واقعی و بدون وابستگی به پیاده سازی خاص UserManager رسید. کنترلری که با IApplicationUserManager بجای ApplicationUserManager کار میکند، قابلیت تعویض پیاده سازی آنرا جهت آزمونهای واحد خواهد یافت.
- در کلاس اصلی ApplicationDbInitializer پیش فرض این مثال، متد Seed هم قابل مشاهدهاست. این متد را از کلاس جدید Configuration اضافه شده به DataLayer حذف کردهایم. از این جهت که در آن از متدهای کلاس ApplicationUserManager مستقیما استفاده شدهاست. متد Seed اکنون به کلاس جدید اضافه شده به لایه سرویس منتقل شده و در آغاز برنامه فراخوانی خواهد شد. DataLayer نباید وابستگی به لایه سرویس داشته باشد. لایه سرویس است که از امکانات DataLayer استفاده میکند.
- اگر به سازندهی کلاس جدید ApplicationUserManager دقت کنید، چند اینترفیس دیگر نیز به آن تزریق شدهاند. اینترفیس IApplicationRoleManager را ادامه تعریف خواهیم کرد. سایر اینترفیسهای تزریق شده مانند IUserStore، IDataProtectionProvider و IIdentityMessageService جزو تعاریف اصلی ASP.NET Identity بوده و نیازی به تعریف مجدد آنها نیست. فقط کلاسهای EmailService و SmsService فایل App_Start\IdentityConfig.c را نیز به لایه سرویس منتقل کردهایم. این کلاسها بر اساس تنظیمات IoC Container مورد استفاده، در اینجا به صورت خودکار ترزیق خواهند شد. حالت پیش فرض آن، وهله سازی مستقیم است که مطابق کدهای فوق به حالت تزریق وابستگیها بهبود یافتهاست.
ب) انتقال کلاس ApplicationSignInManager به لایه سرویس برنامه
کلاس ApplicationSignInManager فایل App_Start\IdentityConfig.c را نیز به لایه سرویس منتقل میکنیم.
using AspNetIdentityDependencyInjectionSample.DomainClasses; using AspNetIdentityDependencyInjectionSample.ServiceLayer.Contracts; using Microsoft.AspNet.Identity.Owin; using Microsoft.Owin.Security; namespace AspNetIdentityDependencyInjectionSample.ServiceLayer { public class ApplicationSignInManager : SignInManager<ApplicationUser, int>, IApplicationSignInManager { private readonly ApplicationUserManager _userManager; private readonly IAuthenticationManager _authenticationManager; public ApplicationSignInManager(ApplicationUserManager userManager, IAuthenticationManager authenticationManager) : base(userManager, authenticationManager) { _userManager = userManager; _authenticationManager = authenticationManager; } } }
ج) انتقال کلاس ApplicationRoleManager به لایه سرویس برنامه
کلاس ApplicationRoleManager فایل App_Start\IdentityConfig.c را نیز به لایه سرویس منتقل خواهیم کرد:
using AspNetIdentityDependencyInjectionSample.DomainClasses; using AspNetIdentityDependencyInjectionSample.ServiceLayer.Contracts; using Microsoft.AspNet.Identity; namespace AspNetIdentityDependencyInjectionSample.ServiceLayer { public class ApplicationRoleManager : RoleManager<CustomRole, int>, IApplicationRoleManager { private readonly IRoleStore<CustomRole, int> _roleStore; public ApplicationRoleManager(IRoleStore<CustomRole, int> roleStore) : base(roleStore) { _roleStore = roleStore; } public CustomRole FindRoleByName(string roleName) { return this.FindByName(roleName); // RoleManagerExtensions } public IdentityResult CreateRole(CustomRole role) { return this.Create(role); // RoleManagerExtensions } } }
تا اینجا کار تنظیمات لایه سرویس برنامه به پایان میرسد.
ساختار پروژهی AspNetIdentityDependencyInjectionSample.IocConfig
پروژهی IocConfig جایی است که تنظیمات StructureMap را به آن منتقل کردهایم:
using System; using System.Data.Entity; using System.Threading; using System.Web; using AspNetIdentityDependencyInjectionSample.DataLayer.Context; using AspNetIdentityDependencyInjectionSample.DomainClasses; using AspNetIdentityDependencyInjectionSample.ServiceLayer; using AspNetIdentityDependencyInjectionSample.ServiceLayer.Contracts; using Microsoft.AspNet.Identity; using Microsoft.AspNet.Identity.EntityFramework; using Microsoft.Owin.Security; using StructureMap; using StructureMap.Web; namespace AspNetIdentityDependencyInjectionSample.IocConfig { public static class SmObjectFactory { private static readonly Lazy<Container> _containerBuilder = new Lazy<Container>(defaultContainer, LazyThreadSafetyMode.ExecutionAndPublication); public static IContainer Container { get { return _containerBuilder.Value; } } private static Container defaultContainer() { return new Container(ioc => { ioc.For<IUnitOfWork>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationDbContext>(); ioc.For<ApplicationDbContext>().HybridHttpOrThreadLocalScoped().Use<ApplicationDbContext>(); ioc.For<DbContext>().HybridHttpOrThreadLocalScoped().Use<ApplicationDbContext>(); ioc.For<IUserStore<ApplicationUser, int>>() .HybridHttpOrThreadLocalScoped() .Use<UserStore<ApplicationUser, CustomRole, int, CustomUserLogin, CustomUserRole, CustomUserClaim>>(); ioc.For<IRoleStore<CustomRole, int>>() .HybridHttpOrThreadLocalScoped() .Use<RoleStore<CustomRole, int, CustomUserRole>>(); ioc.For<IAuthenticationManager>() .Use(() => HttpContext.Current.GetOwinContext().Authentication); ioc.For<IApplicationSignInManager>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationSignInManager>(); ioc.For<IApplicationUserManager>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationUserManager>(); ioc.For<IApplicationRoleManager>() .HybridHttpOrThreadLocalScoped() .Use<ApplicationRoleManager>(); ioc.For<IIdentityMessageService>().Use<SmsService>(); ioc.For<IIdentityMessageService>().Use<EmailService>(); ioc.For<ICustomRoleStore>() .HybridHttpOrThreadLocalScoped() .Use<CustomRoleStore>(); ioc.For<ICustomUserStore>() .HybridHttpOrThreadLocalScoped() .Use<CustomUserStore>(); //config.For<IDataProtectionProvider>().Use(()=> app.GetDataProtectionProvider()); // In Startup class ioc.For<ICategoryService>().Use<EfCategoryService>(); ioc.For<IProductService>().Use<EfProductService>(); }); } } }
در تعاریف فوق یک مورد را به فایل Startup.cs موکول کردهایم. برای مشخص سازی نمونهی پیاده سازی کنندهی IDataProtectionProvider نیاز است به IAppBuilder کلاس Startup برنامه دسترسی داشت. این کلاس آغازین Owin اکنون به نحو ذیل بازنویسی شدهاست و در آن، تنظیمات IDataProtectionProvider را به همراه وهله سازی CreatePerOwinContext مشاهده میکنید:
using System; using AspNetIdentityDependencyInjectionSample.IocConfig; using AspNetIdentityDependencyInjectionSample.ServiceLayer.Contracts; using Microsoft.AspNet.Identity; using Microsoft.Owin; using Microsoft.Owin.Security.Cookies; using Microsoft.Owin.Security.DataProtection; using Owin; using StructureMap.Web; namespace AspNetIdentityDependencyInjectionSample { public class Startup { public void Configuration(IAppBuilder app) { configureAuth(app); } private static void configureAuth(IAppBuilder app) { SmObjectFactory.Container.Configure(config => { config.For<IDataProtectionProvider>() .HybridHttpOrThreadLocalScoped() .Use(()=> app.GetDataProtectionProvider()); }); SmObjectFactory.Container.GetInstance<IApplicationUserManager>().SeedDatabase(); // Configure the db context, user manager and role manager to use a single instance per request app.CreatePerOwinContext(() => SmObjectFactory.Container.GetInstance<IApplicationUserManager>()); // 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 = SmObjectFactory.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); } } }
تنظیمات برنامهی اصلی ASP.NET MVC، جهت اعمال تزریق وابستگیها
الف) ابتدا نیاز است فایل Global.asax.cs را به نحو ذیل بازنویسی کنیم:
using System; using System.Data.Entity; using System.Web; using System.Web.Mvc; using System.Web.Optimization; using System.Web.Routing; using AspNetIdentityDependencyInjectionSample.DataLayer.Context; using AspNetIdentityDependencyInjectionSample.IocConfig; using StructureMap.Web.Pipeline; namespace AspNetIdentityDependencyInjectionSample { public class MvcApplication : HttpApplication { protected void Application_Start() { AreaRegistration.RegisterAllAreas(); FilterConfig.RegisterGlobalFilters(GlobalFilters.Filters); RouteConfig.RegisterRoutes(RouteTable.Routes); BundleConfig.RegisterBundles(BundleTable.Bundles); setDbInitializer(); //Set current Controller factory as StructureMapControllerFactory ControllerBuilder.Current.SetControllerFactory(new StructureMapControllerFactory()); } protected void Application_EndRequest(object sender, EventArgs e) { HttpContextLifecycle.DisposeAndClearAll(); } public class StructureMapControllerFactory : DefaultControllerFactory { protected override IController GetControllerInstance(RequestContext requestContext, Type controllerType) { if (controllerType == null) throw new InvalidOperationException(string.Format("Page not found: {0}", requestContext.HttpContext.Request.RawUrl)); return SmObjectFactory.Container.GetInstance(controllerType) as Controller; } } private static void setDbInitializer() { Database.SetInitializer(new MigrateDatabaseToLatestVersion<ApplicationDbContext, Configuration>()); SmObjectFactory.Container.GetInstance<IUnitOfWork>().ForceDatabaseInitialize(); } } }
ب) به پوشهی Models برنامه مراجعه کنید. در اینجا در هر کلاسی که Id از نوع string وجود داشت، باید تبدیل به نوع int شوند. چون primary key برنامه را به نوع int تغییر دادهایم. برای مثال کلاسهای EditUserViewModel و RoleViewModel باید تغییر کنند.
ج) اصلاح کنترلرهای برنامه جهت اعمال تزریق وابستگیها
اکنون اصلاح کنترلرها جهت اعمال تزریق وابستگیها سادهاست. در ادامه نحوهی تغییر امضای سازندههای این کنترلرها را جهت استفاده از اینترفیسهای جدید مشاهده میکنید:
[Authorize] public class AccountController : Controller { private readonly IAuthenticationManager _authenticationManager; private readonly IApplicationSignInManager _signInManager; private readonly IApplicationUserManager _userManager; public AccountController(IApplicationUserManager userManager, IApplicationSignInManager signInManager, IAuthenticationManager authenticationManager) { _userManager = userManager; _signInManager = signInManager; _authenticationManager = authenticationManager; } [Authorize] public class ManageController : Controller { // Used for XSRF protection when adding external logins private const string XsrfKey = "XsrfId"; private readonly IAuthenticationManager _authenticationManager; private readonly IApplicationUserManager _userManager; public ManageController(IApplicationUserManager userManager, IAuthenticationManager authenticationManager) { _userManager = userManager; _authenticationManager = authenticationManager; } [Authorize(Roles = "Admin")] public class RolesAdminController : Controller { private readonly IApplicationRoleManager _roleManager; private readonly IApplicationUserManager _userManager; public RolesAdminController(IApplicationUserManager userManager, IApplicationRoleManager roleManager) { _userManager = userManager; _roleManager = roleManager; } [Authorize(Roles = "Admin")] public class UsersAdminController : Controller { private readonly IApplicationRoleManager _roleManager; private readonly IApplicationUserManager _userManager; public UsersAdminController(IApplicationUserManager userManager, IApplicationRoleManager roleManager) { _userManager = userManager; _roleManager = roleManager; }
البته تعدادی اکشن متد نیز در اینجا وجود دارند که از string id استفاده میکنند. اینها را باید به int? Id تغییر داد تا با نوع primary key جدید مورد استفاده تطابق پیدا کنند.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید:
AspNetIdentityDependencyInjectionSample
معادل این پروژه جهت ASP.NET Core Identity : «سفارشی سازی ASP.NET Core Identity - قسمت اول - موجودیتهای پایه و DbContext برنامه »
public class Customer { public string Name { get; set; } } public class Order { public Customer Customer { get; set; } }
public class Customer { public string Name { get; set; } public ICollection<Order> Orders { get; set; } } public class Order { public Customer Customer { get; set; } }