public override Task RunAsync() { var smsService = ObjectFactory.Container.GetInstance<ISmsService>(); smsService.CheckForDelivery(); return base.RunAsync(); }
UI
در نهایت نوبت به طراحی و کدنویسی UI میرسد تا بتوانیم محصولات را به کاربر نمایش دهیم. اما قبل از شروع باید موضوعی را یادآوری کنم. اگر به یاد داشته باشید، در کلاس ProductService موجود در لایهی Domain، از طریق یکی از روشهای الگوی Dependency Injection به نام Constructor Injection، فیلدی از نوع IProductRepository را مقداردهی نمودیم. حال زمانی که بخواهیم نمونه ای را از ProductService ایجاد نماییم، باید به عنوان پارامتر ورودی سازنده، شی ایجاد شده از جنس کلاس ProductRepository موجود در لایه Repository را به آن ارسال نماییم. اما از آنجایی که میخواهیم تفکیک پذیری لایهها از بین نرود و UI بسته به نیاز خود، نمونه مورد نیاز را ایجاد نموده و به این کلاس ارسال کند، از ابزارهایی برای این منظور استفاده میکنیم. یکی از این ابزارها StructureMap میباشد که یک Inversion of Control Container یا به اختصار IoC Container نامیده میشود. با Inversion of Control در مباحث بعدی بیشتر آشنا خواهید شد. StructureMap ابزاری است که در زمان اجرا، پارامترهای ورودی سازندهی کلاسهایی را که از الگوی Dependency Injection استفاده نموده اند و قطعا پارامتر ورودی آنها از جنس یک Interface میباشد را، با ایجاد شی مناسب مقداردهی مینماید.
به منظور استفاده از StructureMap در Visual Studio 2012 باید بر روی پروژه UI خود کلیک راست نموده و گزینهی Manage NuGet Packages را انتخاب نمایید. در پنجره ظاهر شده و از سمت چپ گزینهی Online و سپس در کادر جستجوی سمت راست و بالای پنجره واژهی structuremap را جستجو کنید. توجه داشته باشید که باید به اینترنت متصل باشید تا بتوانید Package مورد نظر را نصب نمایید. پس از پایان عمل جستجو، در کادر میانی structuremap ظاهر میشود که میتوانید با انتخاب آن و فشردن کلید Install آن را بر روی پروژه نصب نمایید.
جهت آشنایی بیشتر با NuGet و نصب آن در سایر نسخههای Visual Studio میتوانید به لینکهای زیر رجوع کنید:
کلاسی با نام BootStrapper را با کد زیر به پروژه UI خود اضافه کنید:
using StructureMap; using StructureMap.Configuration.DSL; using SoCPatterns.Layered.Repository; using SoCPatterns.Layered.Model; namespace SoCPatterns.Layered.WebUI { public class BootStrapper { public static void ConfigureStructureMap() { ObjectFactory.Initialize(x => x.AddRegistry<ProductRegistry>()); } } public class ProductRegistry : Registry { public ProductRegistry() { For<IProductRepository>().Use<ProductRepository>(); } } }
ممکن است یک WinUI ایجاد کرده باشید که در این صورت به جای فضای نام SoCPatterns.Layered.WebUI از SoCPatterns.Layered.WinUI استفاده نمایید.
هدف کلاس BootStrapper این است که تمامی وابستگیها را توسط StructureMap در سیستم Register نماید. زمانی که کدهای کلاینت میخواهند به یک کلاس از طریق StructureMap دسترسی داشته باشند، Structuremap وابستگیهای آن کلاس را تشخیص داده و بصورت خودکار پیاده سازی واقعی (Concrete Implementation) آن کلاس را، براساس همان چیزی که ما برایش تعیین کردیم، به کلاس تزریق مینماید. متد ConfigureStructureMap باید در همان لحظه ای که Application آغاز به کار میکند فراخوانی و اجرا شود. با توجه به نوع UI خود یکی از روالهای زیر را انجام دهید:
در WebUI:
فایل Global.asax را به پروژه اضافه کنید و کد آن را بصورت زیر تغییر دهید:
namespace SoCPatterns.Layered.WebUI { public class Global : System.Web.HttpApplication { protected void Application_Start(object sender, EventArgs e) { BootStrapper.ConfigureStructureMap(); } } }
در WinUI:
در فایل Program.cs کد زیر را اضافه کنید:
namespace SoCPatterns.Layered.WinUI { static class Program { [STAThread] static void Main() { Application.EnableVisualStyles(); Application.SetCompatibleTextRenderingDefault(false); BootStrapper.ConfigureStructureMap(); Application.Run(new Form1()); } } }
سپس برای طراحی رابط کاربری، با توجه به نوع UI خود یکی از روالهای زیر را انجام دهید:
در WebUI:
صفحه Default.aspx را باز نموده و کد زیر را به آن اضافه کنید:
<asp:DropDownList AutoPostBack="true" ID="ddlCustomerType" runat="server"> <asp:ListItem Value="0">Standard</asp:ListItem> <asp:ListItem Value="1">Trade</asp:ListItem> </asp:DropDownList> <asp:Label ID="lblErrorMessage" runat="server" ></asp:Label> <asp:Repeater ID="rptProducts" runat="server" > <HeaderTemplate> <table> <tr> <td>Name</td> <td>RRP</td> <td>Selling Price</td> <td>Discount</td> <td>Savings</td> </tr> <tr> <td colspan="5"><hr /></td> </tr> </HeaderTemplate> <ItemTemplate> <tr> <td><%# Eval("Name") %></td> <td><%# Eval("RRP")%></td> <td><%# Eval("SellingPrice") %></td> <td><%# Eval("Discount") %></td> <td><%# Eval("Savings") %></td> </tr> </ItemTemplate> <FooterTemplate> </table> </FooterTemplate> </asp:Repeater>
در WinUI:
فایل Form1.Designer.cs را باز نموده و کد آن را بصورت زیر تغییر دهید:
#region Windows Form Designer generated code /// <summary> /// Required method for Designer support - do not modify /// the contents of this method with the code editor. /// </summary> private void InitializeComponent() { this.cmbCustomerType = new System.Windows.Forms.ComboBox(); this.dgvProducts = new System.Windows.Forms.DataGridView(); this.colName = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colRrp = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colSellingPrice = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colDiscount = new System.Windows.Forms.DataGridViewTextBoxColumn(); this.colSavings = new System.Windows.Forms.DataGridViewTextBoxColumn(); ((System.ComponentModel.ISupportInitialize)(this.dgvProducts)).BeginInit(); this.SuspendLayout(); // // cmbCustomerType // this.cmbCustomerType.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList; this.cmbCustomerType.FormattingEnabled = true; this.cmbCustomerType.Items.AddRange(new object[] { "Standard", "Trade"}); this.cmbCustomerType.Location = new System.Drawing.Point(12, 90); this.cmbCustomerType.Name = "cmbCustomerType"; this.cmbCustomerType.Size = new System.Drawing.Size(121, 21); this.cmbCustomerType.TabIndex = 3; // // dgvProducts // this.dgvProducts.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize; this.dgvProducts.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] { this.colName, this.colRrp, this.colSellingPrice, this.colDiscount, this.colSavings}); this.dgvProducts.Location = new System.Drawing.Point(12, 117); this.dgvProducts.Name = "dgvProducts"; this.dgvProducts.Size = new System.Drawing.Size(561, 206); this.dgvProducts.TabIndex = 2; // // colName // this.colName.DataPropertyName = "Name"; this.colName.HeaderText = "Product Name"; this.colName.Name = "colName"; this.colName.ReadOnly = true; // // colRrp // this.colRrp.DataPropertyName = "Rrp"; this.colRrp.HeaderText = "RRP"; this.colRrp.Name = "colRrp"; this.colRrp.ReadOnly = true; // // colSellingPrice // this.colSellingPrice.DataPropertyName = "SellingPrice"; this.colSellingPrice.HeaderText = "Selling Price"; this.colSellingPrice.Name = "colSellingPrice"; this.colSellingPrice.ReadOnly = true; // // colDiscount // this.colDiscount.DataPropertyName = "Discount"; this.colDiscount.HeaderText = "Discount"; this.colDiscount.Name = "colDiscount"; // // colSavings // this.colSavings.DataPropertyName = "Savings"; this.colSavings.HeaderText = "Savings"; this.colSavings.Name = "colSavings"; this.colSavings.ReadOnly = true; // // Form1 // this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F); this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font; this.ClientSize = new System.Drawing.Size(589, 338); this.Controls.Add(this.cmbCustomerType); this.Controls.Add(this.dgvProducts); this.Name = "Form1"; this.Text = "Form1"; ((System.ComponentModel.ISupportInitialize)(this.dgvProducts)).EndInit(); this.ResumeLayout(false); } #endregion private System.Windows.Forms.ComboBox cmbCustomerType; private System.Windows.Forms.DataGridView dgvProducts; private System.Windows.Forms.DataGridViewTextBoxColumn colName; private System.Windows.Forms.DataGridViewTextBoxColumn colRrp; private System.Windows.Forms.DataGridViewTextBoxColumn colSellingPrice; private System.Windows.Forms.DataGridViewTextBoxColumn colDiscount; private System.Windows.Forms.DataGridViewTextBoxColumn colSavings;
سپس در Code Behind، با توجه به نوع UI خود یکی از روالهای زیر را انجام دهید:
در WebUI:
وارد کد نویسی صفحه Default.aspx شده و کد آن را بصورت زیر تغییر دهید:
using System; using System.Collections.Generic; using SoCPatterns.Layered.Model; using SoCPatterns.Layered.Presentation; using SoCPatterns.Layered.Service; using StructureMap; namespace SoCPatterns.Layered.WebUI { public partial class Default : System.Web.UI.Page, IProductListView { private ProductListPresenter _productListPresenter; protected void Page_Init(object sender, EventArgs e) { _productListPresenter = new ProductListPresenter(this,ObjectFactory.GetInstance<Service.ProductService>()); this.ddlCustomerType.SelectedIndexChanged += delegate { _productListPresenter.Display(); }; } protected void Page_Load(object sender, EventArgs e) { if(!Page.IsPostBack) _productListPresenter.Display(); } public void Display(IList<ProductViewModel> products) { rptProducts.DataSource = products; rptProducts.DataBind(); } public CustomerType CustomerType { get { return (CustomerType) int.Parse(ddlCustomerType.SelectedValue); } } public string ErrorMessage { set { lblErrorMessage.Text = String.Format("<p><strong>Error:</strong><br/>{0}</p>", value); } } } }
در WinUI:
وارد کدنویسی Form1 شوید و کد آن را بصورت زیر تغییر دهید:
using System; using System.Collections.Generic; using System.Windows.Forms; using SoCPatterns.Layered.Model; using SoCPatterns.Layered.Presentation; using SoCPatterns.Layered.Service; using StructureMap; namespace SoCPatterns.Layered.WinUI { public partial class Form1 : Form, IProductListView { private ProductListPresenter _productListPresenter; public Form1() { InitializeComponent(); _productListPresenter = new ProductListPresenter(this, ObjectFactory.GetInstance<Service.ProductService>()); this.cmbCustomerType.SelectedIndexChanged += delegate { _productListPresenter.Display(); }; dgvProducts.AutoGenerateColumns = false; cmbCustomerType.SelectedIndex = 0; } public void Display(IList<ProductViewModel> products) { dgvProducts.DataSource = products; } public CustomerType CustomerType { get { return (CustomerType)cmbCustomerType.SelectedIndex; } } public string ErrorMessage { set { MessageBox.Show( String.Format("Error:{0}{1}", Environment.NewLine, value)); } } } }
با توجه به کد فوق، نمونه ای را از کلاس ProductListPresenter، در لحظهی نمونه سازی اولیهی کلاس UI، ایجاد نمودیم. با استفاده از متد ObjectFactory.GetInstance مربوط به StructureMap، نمونه ای از کلاس ProductService ایجاد شده است و به سازندهی کلاس ProductListPresenter ارسال گردیده است. در مورد Structuremap در مباحث بعدی با جزئیات بیشتری صحبت خواهم کرد. پیاده سازی معماری لایه بندی در اینجا به پایان رسید.
اما اصلا نگران نباشید، شما فقط پرواز کوتاه و مختصری را بر فراز کدهای معماری لایه بندی داشته اید که این فقط یک دید کلی را به شما در مورد این معماری داده است. این معماری هنوز جای زیادی برای کار دارد، اما در حال حاضر شما یک Applicaion با پیوند ضعیف (Loosely Coupled) بین لایهها دارید که دارای قابلیت تست پذیری قوی، نگهداری و پشتیبانی آسان و تفکیک پذیری قدرتمند بین اجزای آن میباشد. شکل زیر تعامل بین لایهها و وظایف هر یک از آنها را نمایش میدهد.
EF Code First #12
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.Url.AbsoluteUri.ToString(CultureInfo.InvariantCulture))); } return ObjectFactory.GetInstance(controllerType) as Controller; } } StructureMap Exception Code: 202 No Default Instance defined for PluginFamily MyProject.Controllers.HomeController, MyProject.Controllers, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null
ASP.NET MVC #18
using System; using System.Web.Mvc; namespace SecurityModule { [AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, Inherited = true, AllowMultiple = true)] public class SiteAuthorizeAttribute : AuthorizeAttribute { protected override void HandleUnauthorizedRequest(AuthorizationContext filterContext) { if (filterContext.HttpContext.Request.IsAuthenticated) { throw new UnauthorizedAccessException(); //to avoid multiple redirects } else { base.HandleUnauthorizedRequest(filterContext); } } } }
رابطه ای از جدول Type به جدول Category.
به کمک Scaffolding یک کنترلر برای کلاس Tap (شیر آب) میسازیم ، به طور عادی در فایل Create.chtml مقدار گروه را به صورت DropDown نمایش میدهد، حال ما نیاز داریم که خودمان DropDown را برای Type ایجاد کنیم و بعد ارتباط اینها را بر قرار کنیم.
تابع اولی Create را این طوری ویرایش میکنیم :
public ActionResult Create() { ViewBag.Type = new SelectList(db.Types, "Id", "Title"); ViewBag.Category = new SelectList(db.Categories, "Id", "Title"); return View(); }
همان طور که مشخص است ، علاوه بر مقادیر Category که خودش ارسال میکند ، ما نیز مقادیر نوعها را به View مورد نظر ارسال میکنیم.
برای نمایش دادن هر دو DropDownList ویو مورد نظر را به این صورت ویرایش میکنیم :
<div> نوع </div> <div> @Html.DropDownList("Type", (SelectList)ViewBag.Type, "-- انتخاب ---", new { id = "rdbTyoe" }) @Html.ValidationMessageFor(model => model.Category) </div> <div> دسته بندی </div> <div> @Html.DropDownList("Category", (SelectList)ViewBag.Category, "-- انتخاب ---", new { id = "rdbCategory"}) @Html.ValidationMessageFor(model => model.Category) </div>
همان طور که مشاهده میکنید ، در اینجا DropDownList مربوط به Type که خودمان سمت سرور ،مقادیر آن را پر کرده بودیم نمایش میدهیم.
خب شاید تا اینجای کار ، ساده بود ولی میرسیم به اصل مطلب و ارتباط بین این دو DropDownList. (قبل از این قسمت حتما نگاهی به ساختار DropDownList یا همان تگ select بیندازید ، اطلاعات جی کوئری شما در این قسمت خیلی کمک حال شما است)
برای این کار ما از jQuery استفادی میکنیم ، کار به این صورت است که هنگامی که مقدار DropDownList اول تغییر کرد :
- ما Id آن را به سرور ارسال میکنیم.
- در آنجا Category هایی که دارای Type با Id مورد نظر هستند را جدا میکنیم
- فیلدهای مورد نیاز یعنی Id و Title را میگیریم
- و بعد به کمک Json مقادیر را بر میگردانیم
- و مقادیر ارسالی از سرور را در optionهای DropDownList دوم (گروهها ) قرار میدهم
public ActionResult SelectCategory(int id) { var categoris = db.Categories.Where(m => m.Type1.Id == id).Select(c => new { c.Id, c.Title }); return Json(categoris, JsonRequestBehavior.AllowGet); }
$('#rdbTyoe').change(function () { jQuery.getJSON('@Url.Action("SelectCategory")', { id: $(this).attr('value') }, function (data) { $('#rdbCategory').empty(); jQuery.each(data, function (i) { var option = $('<option></option>').attr("value", data[i].Id).text(data[i].Title); $("#rdbCategory").append(option); }); }); });
- ابتدا یک تگ option میسازیم
- مقادیر مربوطه شامل Id که باید در attribute مورد نظر value قرار گیرد و متن آن که باید به عنوان text باشد را مقدار دهی میکنیم
- option آماده شده را به DropDownList دومی (Category ) اضافه میکنیم.
$('#rdbCategory').empty();
بعد از انتخاب :
مراحل فعال سازی آپلود فایلها در ASP.NET Core
مرحلهی اول فعال سازی آپلود فایلها در ASP.NET Core، شامل افزودن ویژگی "enctype="multipart/form-data به یک فرم تعریف شدهاست:
<form method="post" asp-action="Index" asp-controller="TestFileUpload" enctype="multipart/form-data"> <input type="file" name="files" multiple /> <input type="submit" value="Upload" /> </form>
در سمت سرور، امضای اکشن متد دریافت کنندهی این فایلها به صورت ذیل خواهد بود:
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Index(IList<IFormFile> files)
یافتن جایگزینی برای Server.MapPath در ASP.NET Core
زمانیکه فایل ارسالی، در سمت سرور دریافت شد، مرحلهی بعد، ذخیره سازی آن بر روی سرور است و از آنجائیکه ما دقیقا نمیدانیم ریشهی سایت در کدام پوشهی سرور واقع شدهاست، میشد از متد Server.MapPath برای یافتن دقیق آن کمک گرفت. با حذف این متد در ASP.NET Core، روش یافتن ریشهی سایت یا همان پوشهی wwwroot در اینجا شامل مراحل ذیل است:
public class TestFileUploadController : Controller { private readonly IHostingEnvironment _environment; public TestFileUploadController(IHostingEnvironment environment) { _environment = environment; }
پس از آن خاصیت environment.WebRootPath_ به ریشهی پوشهی wwwroot برنامه، بر روی سرور اشاره میکند. به این ترتیب میتوان مسیر دقیقی را جهت ذخیره سازی فایلهای رسیده، مشخص کرد.
امکان ذخیره سازی async فایلها در ASP.NET Core
عملیات کار با فایلها، عملیاتی است که از مرزهای IO سیستم عبور میکند. به همین جهت یکی از بهترین مثالهای پیاده سازی async، جهت رها سازی تردهای برنامه و بالا بردن میزان پاسخدهی آن با بالا بردن تعداد تردهای آزاد بیشتر است. در ASP.NET Core، نوشتن async محتوای فایل رسیده در یک stream پشتیبانی میشود و این stream میتواند یک FileStream و یا MemoryStream باشد. در ذیل نحوهی کار async با یک FileStream را مشاهده میکنید:
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> Index(IList<IFormFile> files) { var uploadsRootFolder = Path.Combine(_environment.WebRootPath, "uploads"); if (!Directory.Exists(uploadsRootFolder)) { Directory.CreateDirectory(uploadsRootFolder); } foreach (var file in files) { if (file == null || file.Length == 0) { continue; } var filePath = Path.Combine(uploadsRootFolder, file.FileName); using (var fileStream = new FileStream(filePath, FileMode.Create)) { await file.CopyToAsync(fileStream).ConfigureAwait(false); } } return View(); }
چون برنامههای ASP.NET Core قابلیت اجرای بر روی لینوکس را نیز دارند، تا حد امکان باید از Path.Combine جهت جمع زدن اجزای مختلف یک میسر، استفاده کرد. از این جهت که در لینوکس، جداکنندهی اجزای مسیرها، / است بجای \ در ویندوز و متد Path.Combine به صورت خودکار این مسایل را لحاظ خواهد کرد.
در آخر با استفاده از متد file.CopyToAsync کار نوشتن غیرهمزمان محتوای فایل دریافتی در یک FileStream انجام میشود؛ به همین جهت در امضای متد فوق، <async Task<IActionResult را نیز ملاحظه میکنید.
پشتیبانی کامل از Model Binding آپلود فایلها در ASP.NET Core
در ASP.NET MVC 5.x اگر ویژگی Required را بر روی یک خاصیت از نوع HttpPostedFileBase قرار دهید ... کار نمیکند و در سمت کلاینت تاثیری را به همراه نخواهد داشت؛ مگر اینکه تنظیمات سمت کلاینت آنرا به صورت دستی انجام دهیم. این مشکلات در ASP.NET Core، کاملا برطرف شدهاند:
public class UserViewModel { [Required(ErrorMessage = "Please select a file.")] [DataType(DataType.Upload)] public IFormFile Photo { get; set; } }
@model UserViewModel <form method="post" asp-action="UploadPhoto" asp-controller="TestFileUpload" enctype="multipart/form-data"> <div asp-validation-summary="ModelOnly" class="text-danger"></div> <input asp-for="Photo" /> <span asp-validation-for="Photo" class="text-danger"></span> <input type="submit" value="Upload"/> </form>
اینبار جهت فعال سازی و استفادهی از قابلیتهای Model Binding میتوان از ModelState نیز بهره گرفت:
[HttpPost] [ValidateAntiForgeryToken] public async Task<IActionResult> UploadPhoto(UserViewModel userViewModel) { if (ModelState.IsValid) { var formFile = userViewModel.Photo; if (formFile == null || formFile.Length == 0) { ModelState.AddModelError("", "Uploaded file is empty or null."); return View(viewName: "Index"); } var uploadsRootFolder = Path.Combine(_environment.WebRootPath, "uploads"); if (!Directory.Exists(uploadsRootFolder)) { Directory.CreateDirectory(uploadsRootFolder); } var filePath = Path.Combine(uploadsRootFolder, formFile.FileName); using (var fileStream = new FileStream(filePath, FileMode.Create)) { await formFile.CopyToAsync(fileStream).ConfigureAwait(false); } RedirectToAction("Index"); } return View(viewName: "Index"); }
بررسی پسوند فایلهای رسیدهی به سرور
ASP.NET Core دارای ویژگی است به نام FileExtensions که ... هیچ ارتباطی به خاصیتهایی از نوع IFormFile ندارد:
[FileExtensions(Extensions = ".png,.jpg,.jpeg,.gif", ErrorMessage = "Please upload an image file.")]
در ادامه جهت بررسی پسوندهای فایلهای رسیده، میتوان یک ویژگی اعتبارسنجی سمت سرور جدید را طراحی کرد:
[AttributeUsage(AttributeTargets.Field | AttributeTargets.Property)] public class UploadFileExtensionsAttribute : ValidationAttribute { private readonly IList<string> _allowedExtensions; public UploadFileExtensionsAttribute(string fileExtensions) { _allowedExtensions = fileExtensions.Split(new[] { ',' }, StringSplitOptions.RemoveEmptyEntries).ToList(); } public override bool IsValid(object value) { var file = value as IFormFile; if (file != null) { return isValidFile(file); } var files = value as IList<IFormFile>; if (files == null) { return false; } foreach (var postedFile in files) { if (!isValidFile(postedFile)) return false; } return true; } private bool isValidFile(IFormFile file) { if (file == null || file.Length == 0) { return false; } var fileExtension = Path.GetExtension(file.FileName); return !string.IsNullOrWhiteSpace(fileExtension) && _allowedExtensions.Any(ext => fileExtension.Equals(ext, StringComparison.OrdinalIgnoreCase)); } }
public class UserViewModel { [Required(ErrorMessage = "Please select a file.")] //`FileExtensions` needs to be applied to a string property. It doesn't work on IFormFile properties, and definitely not on IEnumerable<IFormFile> properties. //[FileExtensions(Extensions = ".png,.jpg,.jpeg,.gif", ErrorMessage = "Please upload an image file.")] [UploadFileExtensions(".png,.jpg,.jpeg,.gif", ErrorMessage = "Please upload an image file.")] [DataType(DataType.Upload)] public IFormFile Photo { get; set; } }
کلمههای عبور کاربران فعلی سیستم با الگوریتمی متفاوت از الگوریتم مورد استفاده Identity هش شدهاند. برای اینکه کاربرانی که قبلا ثبت نام کرده بودند بتوانند با کلمههای عبور خود وارد سایت شوند، باید الگوریتم هش کردن Identity را با الگوریتم فعلی مورد استفاده Iris جایگزین کرد.
برای تغییر روش هش کردن کلمات عبور در Identity باید اینترفیس IPasswordHasher را پیاده سازی کنید:
public class IrisPasswordHasher : IPasswordHasher { public string HashPassword(string password) { return Utilities.Security.Encryption.EncryptingPassword(password); } public PasswordVerificationResult VerifyHashedPassword(string hashedPassword, string providedPassword) { return Utilities.Security.Encryption.VerifyPassword(providedPassword, hashedPassword) ? PasswordVerificationResult.Success : PasswordVerificationResult.Failed; } }
سپس باید وارد کلاس ApplicationUserManager شده و در سازندهی آن اینترفیس IPasswordHasher را به عنوان وابستگی تعریف کنید:
public ApplicationUserManager(IUserStore<ApplicationUser, int> store, IUnitOfWork uow, IIdentity identity, IApplicationRoleManager roleManager, IDataProtectionProvider dataProtectionProvider, IIdentityMessageService smsService, IIdentityMessageService emailService, IPasswordHasher passwordHasher) : base(store) { _store = store; _uow = uow; _identity = identity; _users = _uow.Set<ApplicationUser>(); _roleManager = roleManager; _dataProtectionProvider = dataProtectionProvider; this.SmsService = smsService; this.EmailService = emailService; PasswordHasher = passwordHasher; createApplicationUserManager(); }
برای اینکه کلاس IrisPasswordHasher را به عنوان نمونه درخواستی IPasswordHasher معرفی کنیم، باید در تنظیمات StructureMap کد زیر را نیز اضافه کنید:
x.For<IPasswordHasher>().Use<IrisPasswordHasher>();
پیاده سازی اکشن متد ثبت نام کاربر با استفاده از Identity
در کنترلر UserController، اکشن متد Register را به شکل زیر بازنویسی کنید:
[HttpPost] [ValidateAntiForgeryToken] [CaptchaVerify("تصویر امنیتی وارد شده معتبر نیست")] public virtual async Task<ActionResult> Register(RegisterModel model) { if (ModelState.IsValid) { var user = new ApplicationUser { CreatedDate = DateAndTime.GetDateTime(), Email = model.Email, IP = Request.ServerVariables["REMOTE_ADDR"], IsBaned = false, UserName = model.UserName, UserMetaData = new UserMetaData(), LastLoginDate = DateAndTime.GetDateTime() }; var result = await _userManager.CreateAsync(user, model.Password); if (result.Succeeded) { var addToRoleResult = await _userManager.AddToRoleAsync(user.Id, "user"); if (addToRoleResult.Succeeded) { var code = await _userManager.GenerateEmailConfirmationTokenAsync(user.Id); var callbackUrl = Url.Action("ConfirmEmail", "User", new { userId = user.Id, code }, protocol: Request.Url.Scheme); _emailService.SendAccountConfirmationEmail(user.Email, callbackUrl); return Json(new { result = "success" }); } addErrors(addToRoleResult); } addErrors(result); } return PartialView(MVC.User.Views._Register, model); }
برای این کار متد زیر را به کلاس EmailService اضافه کنید:
public SendingMailResult SendAccountConfirmationEmail(string email, string link) { var model = new ConfirmEmailModel() { ActivationLink = link }; var htmlText = _viewConvertor.RenderRazorViewToString(MVC.EmailTemplates.Views._ConfirmEmail, model); var result = Send(new MailDocument { Body = htmlText, Subject = "تایید حساب کاربری", ToEmail = email }); return result; }
@model Iris.Model.EmailModel.ConfirmEmailModel <div style="direction: rtl; -ms-word-wrap: break-word; word-wrap: break-word;"> <p>با سلام</p> <p>برای فعال سازی حساب کاربری خود لطفا بر روی لینک زیر کلیک کنید:</p> <p>@Model.ActivationLink</p> <div style=" color: #808080;"> <p>با تشکر</p> <p>@Model.SiteTitle</p> <p>@Model.SiteDescription</p> <p><span style="direction: ltr !important; unicode-bidi: embed;">@Html.ConvertToPersianDateTime(DateTime.Now, "s,H")</span></p> </div> </div>
اصلاح پیام موفقیت آمیز بودن ثبت نام کاربر جدید
سیستم IRIS از ارسال ایمیل تایید حساب کاربری استفاده نمیکند و به محض اینکه عملیات ثبت نام تکمیل میشد، صفحه رفرش میشود. اما در سیستم Identity یک ایمیل حاوی لینک فعال سازی حساب کاربری به او ارسال میشود.
برای اصلاح پیغام پس از ثبت نام، باید به فایل myscript.js درون پوشهی Scripts مراجعه کرده و رویداد onSuccess شیء RegisterUser را به شکل زیراصلاح کنید:
RegisterUser.Form.onSuccess = function (data) { if (data.result == "success") { var message = '<div id="alert"><button type="button" data-dismiss="alert">×</button>ایمیلی حاوی لینک فعال سازی، به ایمیل شما ارسال شد؛ لطفا به ایمیل خود مراجعه کرده و بر روی لینک فعال سازی کلیک کنید.</div>'; $('#registerResult').html(message); } else { $('#logOnModal').html(data); } };
[AllowAnonymous] public virtual async Task<ActionResult> ConfirmEmail(int? userId, string code) { if (userId == null || code == null) { return View("Error"); } var result = await _userManager.ConfirmEmailAsync(userId.Value, code); return View(result.Succeeded ? "ConfirmEmail" : "Error"); }
@{ ViewBag.Title = "حساب کاربری شما تایید شد"; } <h2>@ViewBag.Title.</h2> <div> <p> با تشکر از شما، حساب کاربری شما تایید شد. </p> <p> @Ajax.ActionLink("ورود / ثبت نام", MVC.User.ActionNames.LogOn, MVC.User.Name, new { area = "", returnUrl = Html.ReturnUrl(Context, Url) }, new AjaxOptions { HttpMethod = "GET", InsertionMode = InsertionMode.Replace, UpdateTargetId = "logOnModal", LoadingElementDuration = 300, LoadingElementId = "loadingMessage", OnSuccess = "LogOnForm.onSuccess" }, new { role = "button", data_toggle = "modal", data_i_logon_link = "true", rel = "nofollow" }) </p> </div>
اصلاح اکشن متد ورود به سایت
[HttpPost] [ValidateAntiForgeryToken] public async virtual Task<ActionResult> LogOn(LogOnModel model, string returnUrl) { if (!ModelState.IsValid) { if (Request.IsAjaxRequest()) return PartialView(MVC.User.Views._LogOn, model); return View(model); } const string emailRegPattern = @"^([a-zA-Z0-9_\-\.]+)@((\[[0-9]{1,3}\.[0-9]{1,3}\.[0-9]{1,3}\.)|(([a-zA-Z0-9\-]+\.)+))([a-zA-Z]{2,4}|[0-9]{1,3})(\]?)$"; string ip = Request.ServerVariables["REMOTE_ADDR"]; SignInStatus result = SignInStatus.Failure; if (Regex.IsMatch(model.Identity, emailRegPattern)) { var user = await _userManager.FindByEmailAsync(model.Identity); if (user != null) { result = await _signInManager.PasswordSignInAsync (user.UserName, model.Password, model.RememberMe, shouldLockout: true); } } else { result = await _signInManager.PasswordSignInAsync(model.Identity, model.Password, model.RememberMe, shouldLockout: true); } switch (result) { case SignInStatus.Success: if (Request.IsAjaxRequest()) return JavaScript(IsValidReturnUrl(returnUrl) ? string.Format("window.location ='{0}';", returnUrl) : "window.location.reload();"); return redirectToLocal(returnUrl); case SignInStatus.LockedOut: ModelState.AddModelError("", string.Format("حساب شما قفل شد، لطفا بعد از {0} دقیقه دوباره امتحان کنید.", _userManager.DefaultAccountLockoutTimeSpan.Minutes)); break; case SignInStatus.Failure: ModelState.AddModelError("", "نام کاربری یا کلمه عبور اشتباه است."); break; default: ModelState.AddModelError("", "در ورود شما خطایی رخ داده است."); break; } if (Request.IsAjaxRequest()) return PartialView(MVC.User.Views._LogOn, model); return View(model); }
اصلاح اکشن متد خروج کاربر از سایت
[HttpPost] [ValidateAntiForgeryToken] [Authorize] public virtual ActionResult LogOut() { _authenticationManager.SignOut(); if (Request.IsAjaxRequest()) return Json(new { result = "true" }); return RedirectToAction(MVC.User.ActionNames.LogOn, MVC.User.Name); }
پیاده سازی ریست کردن کلمهی عبور با استفاده از ASP.NET Identity
مکانیزم سیستم IRIS برای ریست کردن کلمهی عبور به هنگام فراموشی آن، ساخت GUID و ذخیرهی آن در دیتابیس است. سیستم Identity با استفاده از یک توکن رمز نگاری شده و بدون استفاده از دیتابیس، این کار را انجام میدهد و با استفاده از قابلیتهای تو کار سیستم Identity، تمهیدات امنیتی بهتری را نسبت به سیستم کنونی در نظر گرفته است.
برای این کار کدهای کنترلر ForgottenPasswordController را به شکل زیر ویرایش کنید:
using System.Threading.Tasks; using System.Web.Mvc; using CaptchaMvc.Attributes; using Iris.Model; using Iris.Servicelayer.Interfaces; using Iris.Web.Email; using Microsoft.AspNet.Identity; namespace Iris.Web.Controllers { public partial class ForgottenPasswordController : Controller { private readonly IEmailService _emailService; private readonly IApplicationUserManager _userManager; public ForgottenPasswordController(IEmailService emailService, IApplicationUserManager applicationUserManager) { _emailService = emailService; _userManager = applicationUserManager; } [HttpGet] public virtual ActionResult Index() { return PartialView(MVC.ForgottenPassword.Views._Index); } [HttpPost] [ValidateAntiForgeryToken] [CaptchaVerify("تصویر امنیتی وارد شده معتبر نیست")] public async virtual Task<ActionResult> Index(ForgottenPasswordModel model) { if (!ModelState.IsValid) { return PartialView(MVC.ForgottenPassword.Views._Index, model); } var user = await _userManager.FindByEmailAsync(model.Email); if (user == null || !(await _userManager.IsEmailConfirmedAsync(user.Id))) { // Don't reveal that the user does not exist or is not confirmed return Json(new { result = "false", message = "این ایمیل در سیستم ثبت نشده است" }); } var code = await _userManager.GeneratePasswordResetTokenAsync(user.Id); _emailService.SendResetPasswordConfirmationEmail(user.UserName, user.Email, code); return Json(new { result = "true", message = "ایمیلی برای تایید بازنشانی کلمه عبور برای شما ارسال شد.اعتبارایمیل ارسالی 3 ساعت است." }); } [AllowAnonymous] public virtual ActionResult ResetPassword(string code) { return code == null ? View("Error") : View(); } [AllowAnonymous] public virtual ActionResult ResetPasswordConfirmation() { return View(); } // // POST: /Account/ResetPassword [HttpPost] [AllowAnonymous] [ValidateAntiForgeryToken] public virtual async Task<ActionResult> ResetPassword(ResetPasswordViewModel model) { if (!ModelState.IsValid) { return View(model); } var user = await _userManager.FindByEmailAsync(model.Email); if (user == null) { // Don't reveal that the user does not exist return RedirectToAction("Error"); } var result = await _userManager.ResetPasswordAsync(user.Id, model.Code, model.Password); if (result.Succeeded) { return RedirectToAction("ResetPasswordConfirmation", "ForgottenPassword"); } addErrors(result); return View(); } private void addErrors(IdentityResult result) { foreach (var error in result.Errors) { ModelState.AddModelError("", error); } } } }
همچنین برای اکشن متدهای اضافه شده، Viewهای زیر را نیز باید اضافه کنید:
- View با نام ResetPasswordConfirmation.cshtml را اضافه کنید.
@{ ViewBag.Title = "کلمه عبور شما تغییر کرد"; } <hgroup> <h1>@ViewBag.Title.</h1> </hgroup> <div> <p> کلمه عبور شما با موفقیت تغییر کرد </p> <p> @Ajax.ActionLink("ورود / ثبت نام", MVC.User.ActionNames.LogOn, MVC.User.Name, new { area = "", returnUrl = Html.ReturnUrl(Context, Url) }, new AjaxOptions { HttpMethod = "GET", InsertionMode = InsertionMode.Replace, UpdateTargetId = "logOnModal", LoadingElementDuration = 300, LoadingElementId = "loadingMessage", OnSuccess = "LogOnForm.onSuccess" }, new { role = "button", data_toggle = "modal", data_i_logon_link = "true", rel = "nofollow" }) </p> </div>
- View با نام ResetPassword.cshtml
@model Iris.Model.ResetPasswordViewModel @{ ViewBag.Title = "ریست کردن کلمه عبور"; } <h2>@ViewBag.Title.</h2> @using (Html.BeginForm("ResetPassword", "ForgottenPassword", FormMethod.Post, new { @class = "form-horizontal", role = "form" })) { @Html.AntiForgeryToken() <h4>ریست کردن کلمه عبور</h4> <hr /> @Html.ValidationSummary("", new { @class = "text-danger" }) @Html.HiddenFor(model => model.Code) <div> @Html.LabelFor(m => m.Email, "ایمیل", new { @class = "control-label" }) <div> @Html.TextBoxFor(m => m.Email) </div> </div> <div> @Html.LabelFor(m => m.Password, "کلمه عبور", new { @class = "control-label" }) <div> @Html.PasswordFor(m => m.Password) </div> </div> <div> @Html.LabelFor(m => m.ConfirmPassword, "تکرار کلمه عبور", new { @class = "control-label" }) <div> @Html.PasswordFor(m => m.ConfirmPassword) </div> </div> <div> <div> <input type="submit" value="تغییر کلمه عبور" /> </div> </div> }
همچنین این View و Controller متناظر آن، احتیاج به ViewModel زیر دارند که آن را به پروژهی Iris.Models اضافه کنید.
using System.ComponentModel.DataAnnotations; namespace Iris.Model { public class ResetPasswordViewModel { [Required] [EmailAddress] [Display(Name = "ایمیل")] public string Email { get; set; } [Required] [StringLength(100, ErrorMessage = "کلمه عبور باید حداقل 6 حرف باشد", MinimumLength = 6)] [DataType(DataType.Password)] [Display(Name = "کلمه عبور")] public string Password { get; set; } [DataType(DataType.Password)] [Display(Name = "تکرار کلمه عبور")] [Compare("Password", ErrorMessage = "کلمه عبور و تکرارش یکسان نیستند")] public string ConfirmPassword { get; set; } public string Code { get; set; } } }
حذف سیستم قدیمی احراز هویت
برای حذف کامل سیستم احراز هویت IRIS، وارد فایل Global.asax.cs شده و سپس از متد Application_AuthenticateRequest کدهای زیر را حذف کنید:
var principalService = ObjectFactory.GetInstance<IPrincipalService>(); var formsAuthenticationService = ObjectFactory.GetInstance<IFormsAuthenticationService>(); context.User = principalService.GetCurrent()
فارسی کردن خطاهای ASP.NET Identity
سیستم Identity، پیامهای خطاها را از فایل Resource موجود در هستهی خود، که به طور پیش فرض، زبان آن انگلیسی است، میخواند. برای مثال وقتی ایمیلی تکراری باشد، پیامی به زبان انگلیسی دریافت خواهید کرد و متاسفانه برای تغییر آن، راه سر راست و واضحی وجود ندارد. برای تغییر این پیامها میتوان از سورس باز بودن Identity استفاده کنید و قسمتی را که پیامها را تولید میکند، خودتان با پیامهای فارسی باز نویسی کنید.
راه اول این است که از این پروژه استفاده کرد و کلاسهای زیر را به پروژه اضافه کنید:
public class CustomUserValidator<TUser, TKey> : IIdentityValidator<ApplicationUser> where TUser : class, IUser<int> where TKey : IEquatable<int> { public bool AllowOnlyAlphanumericUserNames { get; set; } public bool RequireUniqueEmail { get; set; } private ApplicationUserManager Manager { get; set; } public CustomUserValidator(ApplicationUserManager manager) { if (manager == null) throw new ArgumentNullException("manager"); AllowOnlyAlphanumericUserNames = true; Manager = manager; } public virtual async Task<IdentityResult> ValidateAsync(ApplicationUser item) { if (item == null) throw new ArgumentNullException("item"); var errors = new List<string>(); await ValidateUserName(item, errors); if (RequireUniqueEmail) await ValidateEmailAsync(item, errors); return errors.Count <= 0 ? IdentityResult.Success : IdentityResult.Failed(errors.ToArray()); } private async Task ValidateUserName(ApplicationUser user, ICollection<string> errors) { if (string.IsNullOrWhiteSpace(user.UserName)) errors.Add("نام کاربری نباید خالی باشد"); else if (AllowOnlyAlphanumericUserNames && !Regex.IsMatch(user.UserName, "^[A-Za-z0-9@_\\.]+$")) { errors.Add("برای نام کاربری فقط از کاراکترهای مجاز استفاده کنید "); } else { var owner = await Manager.FindByNameAsync(user.UserName); if (owner != null && !EqualityComparer<int>.Default.Equals(owner.Id, user.Id)) errors.Add("این نام کاربری قبلا ثبت شده است"); } } private async Task ValidateEmailAsync(ApplicationUser user, ICollection<string> errors) { var email = await Manager.GetEmailStore().GetEmailAsync(user).WithCurrentCulture(); if (string.IsNullOrWhiteSpace(email)) { errors.Add("وارد کردن ایمیل ضروریست"); } else { try { var m = new MailAddress(email); } catch (FormatException) { errors.Add("ایمیل را به شکل صحیح وارد کنید"); return; } var owner = await Manager.FindByEmailAsync(email); if (owner != null && !EqualityComparer<int>.Default.Equals(owner.Id, user.Id)) errors.Add("این ایمیل قبلا ثبت شده است"); } } }
public class CustomPasswordValidator : IIdentityValidator<string> { #region Properties public int RequiredLength { get; set; } public bool RequireNonLetterOrDigit { get; set; } public bool RequireLowercase { get; set; } public bool RequireUppercase { get; set; } public bool RequireDigit { get; set; } #endregion #region IIdentityValidator public virtual Task<IdentityResult> ValidateAsync(string item) { if (item == null) throw new ArgumentNullException("item"); var list = new List<string>(); if (string.IsNullOrWhiteSpace(item) || item.Length < RequiredLength) list.Add(string.Format("کلمه عبور نباید کمتر از 6 کاراکتر باشد")); if (RequireNonLetterOrDigit && item.All(IsLetterOrDigit)) list.Add("برای امنیت بیشتر از حداقل از یک کارکتر غیر عددی و غیر حرف برای کلمه عبور استفاده کنید"); if (RequireDigit && item.All(c => !IsDigit(c))) list.Add("برای امنیت بیشتر از اعداد هم در کلمه عبور استفاده کنید"); if (RequireLowercase && item.All(c => !IsLower(c))) list.Add("از حروف کوچک نیز برای کلمه عبور استفاده کنید"); if (RequireUppercase && item.All(c => !IsUpper(c))) list.Add("از حروف بزرک نیز برای کلمه عبور استفاده کنید"); return Task.FromResult(list.Count == 0 ? IdentityResult.Success : IdentityResult.Failed(string.Join(" ", list))); } #endregion #region PrivateMethods public virtual bool IsDigit(char c) { if (c >= 48) return c <= 57; return false; } public virtual bool IsLower(char c) { if (c >= 97) return c <= 122; return false; } public virtual bool IsUpper(char c) { if (c >= 65) return c <= 90; return false; } public virtual bool IsLetterOrDigit(char c) { if (!IsUpper(c) && !IsLower(c)) return IsDigit(c); return true; } #endregion }
سپس باید کلاسهای فوق را به Identity معرفی کنید تا از این کلاسهای سفارشی شده به جای کلاسهای پیش فرض خودش استفاده کند. برای این کار وارد کلاس ApplicationUserManager شده و درون متد createApplicationUserManager کدهای زیر را اضافه کنید:
UserValidator = new CustomUserValidator< ApplicationUser, int>(this) { AllowOnlyAlphanumericUserNames = false, RequireUniqueEmail = true }; PasswordValidator = new CustomPasswordValidator { RequiredLength = 6, RequireNonLetterOrDigit = false, RequireDigit = false, RequireLowercase = false, RequireUppercase = false };
ایجاد SecurityStamp برای کاربران فعلی سایت
سیستم Identity برای لحاظ کردن یک سری موارد امنیتی، به ازای هر کاربر، فیلدی را به نام SecurityStamp درون دیتابیس ذخیره میکند و برای این که این سیستم عملکرد صحیحی داشته باشد، باید این مقدار را برای کاربران فعلی سایت ایجاد کرد تا کاربران فعلی بتوانند از امکانات Identity نظیر فراموشی کلمه عبور، ورود به سیستم و ... استفاده کنند.
برای این کار Identity، متدی به نام UpdateSecurityStamp را در اختیار قرار میدهد تا با استفاده از آن بتوان مقدار فیلد SecurityStamp را به روز رسانی کرد.
معمولا برای انجام این کارها میتوانید یک کنترلر تعریف کنید و درون اکشن متد آن کلیهی کاربران را واکشی کرده و سپس متد UpdateSecurityStamp را بر روی آنها فراخوانی کنید.
public virtual async Task<ActionResult> UpdateAllUsersSecurityStamp() { foreach (var user in await _userManager.GetAllUsersAsync()) { await _userManager.UpdateSecurityStampAsync(user.Id); } return Content("ok"); }
انتقال نقشهای کاربران به جدول جدید و برقراری رابطه بین آنها
در سیستم Iris رابطهی بین کاربران و نقشها یک به چند بود. در سیستم Identity این رابطه چند به چند است و من به عنوان یک حرکت خوب و رو به جلو، رابطهی چند به چند را در سیستم جدید انتخاب کردم. اکنون با استفاده از دستورات زیر به راحتی میتوان نقشهای فعلی و رابطهی بین آنها را به جداول جدیدشان منتقل کرد:
public virtual async Task<ActionResult> CopyRoleToNewTable() { var dbContext = new IrisDbContext(); foreach (var role in await dbContext.Roles.ToListAsync()) { await _roleManager.CreateAsync(new CustomRole(role.Name) { Description = role.Description }); } var users = await dbContext.Users.Include(u => u.Role).ToListAsync(); foreach (var user in users) { await _userManager.AddToRoleAsync(user.Id, user.Role.Name); } return Content("ok"); }
قسمتی از یک پروژه به همراه کلاس SqlHelper آن در کامنتهای مطلب «اهمیت Code review» توسط یکی از خوانندگان بلاگ جهت Code review مطرح شده که بهتر است در یک مطلب جدید و مجزا به آن پرداخته شود. قسمت مهم آن کلاس SqlHelper است و مابقی در اینجا ندید گرفته میشوند:
//It's only for code review purpose!
using System.Data;
using System.Data.SqlClient;
using System.Web.Configuration;
public sealed class SqlHelper
{
private SqlHelper() { }
// Send Connection String
//---------------------------------------------------------------------------------------
public static string GetCntString()
{
return WebConfigurationManager.ConnectionStrings["db_ConnectionString"].ConnectionString;
}
// Connect to Data Base SqlServer
//---------------------------------------------------------------------------------------
public static SqlConnection Connect2Db(ref SqlConnection sqlCnt, string cntString)
{
try
{
if (sqlCnt == null) sqlCnt = new SqlConnection();
sqlCnt.ConnectionString = cntString;
if (sqlCnt.State != ConnectionState.Open) sqlCnt.Open();
return sqlCnt;
}
catch (SqlException)
{
return null;
}
}
// Run ExecuteScalar Command
//---------------------------------------------------------------------------------------
public static string RunExecuteScalarCmd(ref SqlConnection sqlCnt, string strCmd, bool blnClose)
{
Connect2Db(ref sqlCnt, GetCntString());
using (sqlCnt)
{
using(SqlCommand sqlCmd = sqlCnt.CreateCommand())
{
sqlCmd.CommandText = strCmd;
object objResult = sqlCmd.ExecuteScalar();
if (blnClose) CloseCnt(ref sqlCnt, true);
return (objResult == null) ? string.Empty : objResult.ToString();
}
}
}
// Close SqlServer Connection
//---------------------------------------------------------------------------------------
public static bool CloseCnt(ref SqlConnection sqlCnt, bool nullSqlCnt)
{
try
{
if (sqlCnt == null) return true;
if (sqlCnt.State == ConnectionState.Open)
{
sqlCnt.Close();
sqlCnt.Dispose();
}
if (nullSqlCnt) sqlCnt = null;
return true;
}
catch (SqlException)
{
return false;
}
}
}
مثالی از نحوه استفاده ارائه شده:
protected void BtnTest_Click(object sender, EventArgs e)
{
SqlConnection sqlCnt = new SqlConnection();
string strQuery = "SELECT COUNT(UnitPrice) AS PriceCount FROM [Order Details]";
// در این مرحله پارامتر سوم یعنی کانکشن باز نگه داشته شود
string strResult = SqlHelper.RunExecuteScalarCmd(ref sqlCnt, strQuery, false);
strQuery = "SELECT LastName + N'-' + FirstName AS FullName FROM Employees WHERE (EmployeeID = 9)";
// در این مرحله پارامتر سوم یعنی کانکشن بسته شود
strResult = SqlHelper.RunExecuteScalarCmd(ref sqlCnt, strQuery, true);
}
مروری بر این کد:
1) نحوه کامنت نوشتن
بین سی شارپ و زبان سی++ تفاوت وجود دارد. این نحوه کامنت نویسی بیشتر در سی++ متداول است. اگر از ویژوال استودیو استفاده میکنید، مکان نما را به سطر قبل از یک متد منتقل کرده و سه بار پشت سر هم forward slash را تایپ کنید. به صورت خودکار ساختار خالی زیر تشکیل خواهد شد:
/// <summary>
///
/// </summary>
/// <param name="sqlCnt"></param>
/// <param name="cntString"></param>
/// <returns></returns>
public static SqlConnection Connect2Db(ref SqlConnection sqlCnt, string cntString)
این روش مرسوم کامنت نویسی کدهای سی شارپ است. خصوصا اینکه ابزارهایی وجود دارند که به صورت خودکار از این نوع کامنتها، فایل CHM درست میکنند.
2) وجود سازنده private
احتمالا هدف این بوده که نه شخصی و نه حتی کامپایلر، وهلهای از این کلاس را ایجاد نکند. بنابراین بهتر است کلاسی را که تمام متدهای آن static است (که به این هم خواهیم رسید!) ، راسا static معرفی کنید. به این ترتیب نیازی به سازنده private نخواهد بود.
3) وجود try/catch
یک اصل کلی وجود دارد: اگر در حال طراحی یک کتابخانه پایهای هستید، try/catch را در هیچ متدی از آن لحاظ نکنید. بله؛ درست خوندید! لطفا try/catch ننویسید! کرش کردن برنامه خوب است! لایههای بالاتر برنامه که در حال استفاده از کدهای شما هستند متوجه خواهند شد که مشکلی رخ داده و این مشکل توسط کتابخانه مورد استفاده «خفه» نشده. برای مثال اگر هم اکنون SQL Server در دسترس نیست، لایههای بالاتر برنامه باید این مشکل را متوجه شوند. Exception اصلا چیز بدی نیست! کرش برنامه اصلا بد نیست!
فرض کنید که دچار بیماری شدهاید. اگر مثلا تبی رخ ندهد، از کجا باید متوجه شد که نیاز به مراقبت پزشکی وجود دارد؟ اگر هیچ علامتی بروز داده نشود که تا الان نسل بشر منقرض شده بود!
4) وجود ref و out
دوستان گرامی! این ref و out فقط جهت سازگاری با زبان C در سی شارپ وجود دارد. لطفا تا حد ممکن از آن استفاده نکنید! مثلا استفاده از توابع API ویندوز که با C نوشته شدهاند.
یکی از مهمترین کاربردهای pointers در زبان سی، دریافت بیش از یک خروجی از یک تابع است. برای مثال یک متد API ویندوز را فراخوانی میکنید؛ خروجی آن یک ساختار است که به کمک pointers به عنوان یکی از پارامترهای همان متد معرفی شده. این روش به وفور در طراحی ویندوز بکار رفته. ولی خوب در سی شارپ که از این نوع مشکلات وجود ندارد. یک کلاس ساده را طراحی کنید که چندین خاصیت دارد. هر کدام از این خاصیتها میتوانند نمایانگر یک خروجی باشند. خروجی متد را از نوع این کلاس تعریف کنید. یا برای مثال در دات نت 4، امکان دیگری به نام Tuples معرفی شده برای کسانی که سریع میخواهند چند خروجی از یک تابع دریافت کنند و نمیخواهند برای اینکار یک کلاس بنویسند.
ضمن اینکه برای مثال در متد Connect2Db، هم کانکشن یکبار به صورت ref معرفی شده و یکبار به صورت خروجی متد. اصلا نیازی به استفاده از ref در اینجا نبوده. حتی نیازی به خروجی کانکشن هم در این متد وجود نداشته. کلیه تغییرات شما در شیء کانکشنی که به عنوان پارامتر ارسال شده، در خارج از آن متد هم منعکس میشود (شبیه به همان بحث pointers در زبان سی). بنابراین وجود ref غیرضروری است؛ وجود خروجی متد هم به همین صورت.
5) استفاده از using در متد RunExecuteScalarCmd
استفاده از using خیلی خوب است؛ همیشه اینکار را انجام دهید!
اما اگر اینکار را انجام دادید، بدانید که شیء sqlCnt در پایان بدنه using ، توسط GC نابوده شده است. بنابراین اینجا bool blnClose دیگر چه کاربردی دارد؟! تصمیم شما دیگر اهمیتی نخواهد داشت؛ چون کار تخریبی پیشتر انجام شده.
6) متد CloseCnt
این متد زاید است؛ به دلیلی که در قسمت (5) عنوان شد. using های استفاده شده، کار را تمام کردهاند. بنابراین بستن اشیاء dispose شده معنا نخواهد داشت.
7) در مورد نحوه استفاده
اگر SqlHelper را در اینجا مثلا یک DAL ساده فرض کنیم (data access layer)، جای قسمت BLL (business logic layer) در اینجا خالی است. عموما هم چون توضیحات این موارد را خیلی بد ارائه دادهاند، افراد از شنیدن اسم آنها هم وحشت میکنند. BLL یعنی کمی دست به Refactoring بزنید و این پیاده سازی منطق تجاری ارائه شده در متد BtnTest_Click را به یک کلاس مجزا خارج از code behind پروژه منتقل کنید. Code behind فقط محل استفاده نهایی از آن باشد. همین! فعلا با همین مختصر شروع کنید.
مورد دیگری که در اینجا باز هم مشهود است، عدم استفاده از پارامتر در کوئریها است. چون از پارامتر استفاده نکردهاید، SQL Server مجبور است برای حالت EmployeeID = 9 یکبار execution plan را محاسبه کند، برای کوئری بعدی مثلا EmployeeID = 19، اینکار را تکرار کند و الی آخر. این یعنی مصرف حافظه بالا و همچنین سرعت پایین انجام کوئریها. بنابراین اینقدر در قید و بند باز نگه داشتن یک کانکشن نباشید؛ مشکل اصلی جای دیگری است!
8) برنامه وب و اطلاعات استاتیک!
این پروژه، یک پروژه ASP.NET است. دیدن تعاریف استاتیک در این نوع پروژهها یک علامت خطر است! در این مورد قبلا مطلب نوشتم:
متغیرهای استاتیک و برنامههای ASP.NET
یک درخواست عمومی!
لطف کنید در پروژهای «جدید» خودتون این نوع کلاسهای SqlHelper رو «دور بریزید». یاد گرفتن کار با یک ORM جدید اصلا سخت نیست. مثلا طراحی Entity framework مایکروسافت به حدی ساده است که هر شخصی با داشتن بهره هوشی در حد یک عنکبوت آبی یا حتی جلبک دریایی هم میتونه با اون کار کنه! فقط NHibernate هست که کمی مرد افکن است و گرنه مابقی به عمد ساده طراحی شدهاند.
مزایای کار کردن با ORM ها این است:
- کوئریهای حاصل از آنها «پارامتری» است؛ که این دو مزیت عمده را به همراه دارد:
امنیت: مقاومت در برابر SQL Injection
سرعت و همچنین مصرف حافظه کمتر: با کوئریهای پارامتری در SQL Server همانند رویههای ذخیره شده رفتار میشود.
- عدم نیاز به نوشتن DAL شخصی پر از باگ. چون ORM یعنی همان DAL که توسط یک سری حرفهای طراحی شده.
- یک دست شدن کدها در یک تیم. چون همه بر اساس یک اینترفیس مشخص کار خواهند کرد.
- امکان استفاده از امکانات جدید زبانهای دات نتی مانند LINQ و نوشتن کوئریهای strongly typed تحت کنترل کامپایلر.
- پایین آوردن هزینههای آموزشی افراد در یک تیم. مثلا EF را میشود به عنوان یک پیشنیاز در نظر گرفت؛ عمومی است و همه گیر. کسی هم از شنیدن نام آن تعجب نخواهد کرد. کتاب(های) آموزشی هم در مورد آن زیاد هست.
و ...
برای ایجاد «خواص الحاقی» قبلا در سایت مطلب ایجاد «خواص الحاقی» تهیه شدهاست. در این مطلب قصد داریم راه حل ارائه شدهی در مطلب مذکور را با یک TypeDescriptionProvider سفارشی ترکیب کرده تا به صورت یکدست، از طریق TypeDescriptor بتوان به آن خواص نیز دسترسی داشته باشیم.
فرض کنید در یک سیستم Modular Monolith، نیاز جدیدی به دست شما رسیده است که به شرح زیر میباشد:
نیاز داریم در گریدی از صفحهی X مربوط به «مؤلفه 1»، ستونی جدید را اضافه کنید و دیتای مربوط به این ستون، توسط «مؤلفه 2» مهیا خواهد شد.
- قبلا «مؤلفه 2» ارجاعی را به «مؤلفه 1» داده است؛ لذا امکان ارجاع معکوس را در این حالت، نداریم.
- «مؤلفه 1» باید بتواند مستقل از «مؤلفه 2» نیز توزیع شده و کار کند؛ لذا این نیاز برای زمانی است که «مؤلفه 2» برای توزیع در Component Model ما وجود داشته باشد.
- نمیخواهیم در آینده برای نیازهای مشابه در همان صفحهی X، تغییر جدیدی را در «مؤلفه 1» داشته باشیم (اضافه کردن خصوصیت مورد نظر به مدل نمایشی یا اصطلاحا ویو-مدل متناظر با گرید در در زمان طراحی، جواب مساله نمیباشد)
- میخواهیم به یک طراحی با Loose Coupling (اتصال سست و ضعیف، وابستگی ضعیف) دست پیدا کنیم.
در این حالت «مؤلفه 1» بدون آگاهی از سایر مؤلفهها، همهی پیاده سازیهای IExtraColumnConenvtion را در زمان اجرا یافته و از آنها برای ایجاد ستونهای جدید، استفاده خواهد کرد.
واسط مذکور به شکل زیر میباشد:
public interface IConvention { } public interface IExtraColumnConvention<T> : IConvention { string Name { get; } string Title { get; } void Populate(IEnumerable<T> list); }
البته این واسط میتواند جزئیات بیشتری را هم شامل شود.
گام اول: طراحی TypeDescriptionProvider
در .NET به دو طریق میتوان به متادیتای یک Type دسترسی داشت:
- استفاده از API Reflection موجود در فضای نام System.Reflection
- کلاس TypeDescriptor
به طور کلی هدف از این کلاس در دات نت، ارائه اطلاعاتی در خصوص یک وهله از جمله: Attributeها، Propertyها، Eventهای آن و غیره، میباشد. هنگام استفاده از Reflection، اطلاعات بدست آمده از Type، به دلیل اینکه بعد از کامپایل نمیتوانند تغییر کنند، لذا قابلیت توسعه پذیری را هم ندارند. در مقابل، با استفاده از کلاس TypeDescriptor این توسعه پذیری را برای وهلههای مختلف میتوانید داشته باشید.
برای مهیا کردن متادیتای سفارشی (در اینجا اطلاعات مرتبط با خصوصیات الحاقی) برای TypeDescriptor، نیاز است یک TypeDescriptionProvider سفارشی را طراحی کنیم.
/// <summary> /// Use this provider when you need access ExtraProperties with TypeDescriptor.GetProperties(instance) /// </summary> public class ExtraPropertyTypeDescriptionProvider<T> : TypeDescriptionProvider where T : class { private static readonly TypeDescriptionProvider Default = TypeDescriptor.GetProvider(typeof(T)); public ExtraPropertyTypeDescriptionProvider() : base(Default) { } public override ICustomTypeDescriptor GetTypeDescriptor(Type instanceType, object instance) { var descriptor = base.GetTypeDescriptor(instanceType, instance); return instance == null ? descriptor : new ExtraPropertyCustomTypeDescriptor(descriptor, instance); } private sealed class ExtraPropertyCustomTypeDescriptor : CustomTypeDescriptor { //... } }
در تکه کد بالا، ابتدا تامین کنندهی پیشفرض مرتبط با نوع جنریک مورد نظر را یافته و به عنوان تامین کنندهی پایه معرفی کردهایم. سپس برای معرفی CustomTypeDescritpr باید متد GetTypeDescriptor را بازنویسی کنیم. در اینجا لازم است برای معرفی متادیتا مرتبط با یک نوع، یک پیاده سازی از واسط ICustomTypeDescriptor را ارائه کنیم:
private sealed class ExtraPropertyCustomTypeDescriptor : CustomTypeDescriptor { private readonly IEnumerable<ExtraPropertyDescriptor<T>> _instanceExtraProperties; public ExtraPropertyCustomTypeDescriptor(ICustomTypeDescriptor defaultDescriptor, object instance) : base(defaultDescriptor) { _instanceExtraProperties = instance.ExtraPropertyList<T>(); } public override PropertyDescriptorCollection GetProperties(Attribute[] attributes) { var properties = new PropertyDescriptorCollection(null); foreach (PropertyDescriptor property in base.GetProperties(attributes)) { properties.Add(property); } foreach (var property in _instanceExtraProperties) { properties.Add(property); } return properties; } public override PropertyDescriptorCollection GetProperties() { return GetProperties(null); } }
public static class ExtraProperties { //... public static IEnumerable<ExtraPropertyDescriptor<T>> ExtraPropertyList<T>(this object instance) where T : class { if (!PropertyCache.TryGetValue(instance, out var properties)) throw new KeyNotFoundException($"key: {instance.GetType().Name} was not found in dictionary"); return properties.Select(p => new ExtraPropertyDescriptor<T>(p.PropertyName, p.PropertyValueFunc, p.SetPropertyValueFunc, p.PropertyType, p.Attributes)); } }
public sealed class ExtraPropertyDescriptor<T> : PropertyDescriptor where T : class { private readonly Func<object, object> _propertyValueFunc; private readonly Action<object, object> _setPropertyValueFunc; private readonly Type _propertyType; public ExtraPropertyDescriptor( string propertyName, Func<object, object> propertyValueFunc, Action<object, object> setPropertyValueFunc, Type propertyType, Attribute[] attributes) : base(propertyName, attributes) { _propertyValueFunc = propertyValueFunc; _setPropertyValueFunc = setPropertyValueFunc; _propertyType = propertyType; } public override void ResetValue(object component) { } public override bool CanResetValue(object component) => true; public override object GetValue(object component) => _propertyValueFunc(component); public override void SetValue(object component, object value) => _setPropertyValueFunc(component, value); public override bool ShouldSerializeValue(object component) => true; public override Type ComponentType => typeof(T); public override bool IsReadOnly => _setPropertyValueFunc == null; public override Type PropertyType => _propertyType; }
[TypeDescriptionProvider(typeof(ExtraPropertyTypeDescriptionProvider<Person>))] private class Person { public string Name { get; set; } public string Family { get; set; } }
[Test] public void Should_TypeDescriptor_GetProperties_Returns_ExtraProperties_And_PredefinedProperties() { //Arrange var rabbal = new Person {Name = "GholamReza", Family = "Rabbal"}; const string propertyName = "Title"; const string propertyValue = "Software Engineer"; //Act rabbal.ExtraProperty(propertyName, propertyValue); var title = TypeDescriptor.GetProperties(rabbal).Find(propertyName, true); //Assert rabbal.ExtraProperty<string>(propertyName).ShouldBe(propertyValue); title.ShouldNotBeNull(); title.GetValue(rabbal).ShouldBe(propertyValue); }
گام دوم: استفاده از IExtraColumnConvention برای نمایش ستونهای الحاقی
public class Column4Convention : IExtraColumnConvention<Product> { public string Name => "Column4"; public string Title => "Column 4" public void Populate(IEnumerable<Product> list) { //TODO: forEach on list and set ExtraProperty // item.ExtraProperty(Name,value) // item.ExtraProperty(Name,(obj)=> value) // item.ExtraProperty(Name,(obj)=> value, (obj,value)=>) } } public class Column2Convention : IExtraColumnConvention<Product> { public string Name => "Column2"; public string Title => "Column 2" public void Populate(IEnumerable<Product> list) { //TODO: forEach on list and set ExtraProperty } } public class Column3Convention : IExtraColumnConvention<Product> { public string Name => "Column3"; public string Title => "Column 3" public void Populate(IEnumerable<Product> list) { //TODO: forEach on list and set ExtraProperty } }
سپس این پیادهسازیها از طریق مکانیزمی مانند معرفی آنها به یک IoC Container، توسط میزبان (مؤلفه 1) قابل دسترسی خواهد بود. در نهایت میزبان، قبل از نمایش محصولات، به شکل زیر عمل خواهد کرد:
var products = _productService.PagedList(page:1, pageSize:10); var columns = _provider.GetServices<IExtraColumnConvention<Product>>(); foreach(var column in columns) { column.Populate(products); }
Unit of Work یا الگوی کار در واقع یک الگو، جهت جمع آوری عملیات کار با دیتابیس است که همه عملیات را تحت یک تراکنش به سمت دیتابیس ارسال میکند تا مبحث Atomic بودن عملیات، به مرحله اجرا گذاشته شود. در صورتیکه یکی از عملیات با نقص یا خطایی روبرو شود، کل عملیات Roll back یا برگشت میخورد. از آنجا که دیتابیسهای معدودی چون Ravendb این مراحل را تا حدی پیاده سازی میکنند نباید از مونگو هم چنین انتظاری نداشته باشید. مونگو برخورد تراکنشی یا اتمیک ندارد؛ پس پیاده سازی الگوی واحد کاری تاثیری بر روی روند کاری آن ندارد. هر چند تعدادی مثال بدین شکل پیاده شدهاند، ولی در عمل حقیقی نیستند و تنها یک حرکت مشابه داشتهاند.
ولی الگوی repository برای پرهیز از تکرار کد، قابلیت به روزرسانی کد و همچنین عملیاتی چون آزمونهای واحد و چهارچوب تقلید به کار میرود. وابستگی بین اشیاء را کاهش داده و باعث ایجاد یک کد با دوامتر میگردد.
ابتدا قبل از هر چیزی نیاز است تا اتصالات یا ساخت کانکشن به سرور و همچنین دریافت دیتابیس مورد نظر را در قالب یک کلاس تعریف نماییم. نام آن را MongoDbContext میگذارم:
public class MongoDbContext : IMongoDbContext { public const string DatabaseName = "MongoDbTest"; private static readonly IMongoClient _client; private static readonly IMongoDatabase Database; static MongoDbContext() { _client = new MongoClient(); Database = _client.GetDatabase(DatabaseName); } public IMongoCollection<TEntity> GetCollection<TEntity>() { return Database.GetCollection<TEntity>(typeof(TEntity).Name.ToLower() + "s"); } }
public interface IMongoDbContext { IMongoCollection<TEntity> GetCollection<TEntity>(); }
در مرحله بعد یک IMongoDbRepositry ساخته و محتوای آن را به شکل زیر پر میکنیم:
public interface IMongoDbRepository { Task<List<TEntity>> GetMany<TEntity>(FilterDefinition<TEntity> filter) where TEntity : class, new(); }
public class MongoRepository : IMongoDbRepository { private IMongoDbContext _mongoDbContext ; public MongoRepository(IMongoDbContext mongoDbContext) { _mongoDbContext = mongoDbContext; } public async Task<List<TEntity>> GetMany<TEntity>(FilterDefinition<TEntity> filter) where TEntity : class, new() { var collection = GetCollection<TEntity>(); var entities = await collection.Find(filter).ToListAsync(); return entities; } private IMongoCollection<TEntity> GetCollection<TEntity>() { return _mongoDbContext.GetCollection<TEntity>(); } }
الگوی بالا در یک کنترلر به شرح زیر استفاده شده است:
public class HomeController : Controller { private IMongoDbRepository _mongoDbRepository; public HomeController(IMongoDbRepository mongoDbRepository) { this._mongoDbRepository = mongoDbRepository; } // GET: Home public async Task<ActionResult> Index() { var filter = Builders<Resturant>.Filter.Gte("Capacity", 400); var c =await _mongoDbRepository.GetMany<Resturant>(filter); return View(c); } }
MongoRepository.zip