public class User { public int UserId { get; set;} public string Name { get; set; } public virtual ICollection<Comment> HomeCommentes { get; set; } public virtual ICollection<Comment> AwayCommentes { get; set; } } public class Comment { public int CommentId { get; set; } public int HomeUserId { get; set; } public int GuestUserId { get; set; } public virtual User HomeUser { get; set; } public virtual User GuestUser { get; set; } } public class Context : DbContext { ... protected override void OnModelCreating(DbModelBuilder modelBuilder) { modelBuilder.Entity<Comment>() .HasRequired(m => m.HomeUser) .WithMany(t => t.HomeCommentes) .HasForeignKey(m => m.HomeUserId) .WillCascadeOnDelete(false); modelBuilder.Entity<Comment>() .HasRequired(m => m.GuestUser) .WithMany(t => t.AwayCommentes) .HasForeignKey(m => m.GuestUserId) .WillCascadeOnDelete(false); } }
public async Task<ClaimsIdentity> GenerateUserIdentityAsync(User applicationUser) { // Note the authenticationType must match the one defined in CookieAuthenticationOptions.AuthenticationType var userIdentity = await CreateIdentityAsync(applicationUser, DefaultAuthenticationTypes.ApplicationCookie); // Add custom user claims here userIdentity.AddClaim(new Claim("emailaddress", applicationUser.Email)); return userIdentity; }
public virtual async Task<ActionResult> Login(LoginViewModel viewModel, string returnUrl) { // ... more code var user = await this._userManager.FindByNameAsync(viewModel.UserName); await this._userManager.GenerateUserIdentityAsync(user); return this.View(viewModel); }
_.For<IPrincipal>().Use(() => HttpContext.Current.User);
var useremail = this._principal.GetClaimValue("emailaddress");
public class Startup
{
public void ConfigureServices(IServiceCollections services)
{
services.AddMvc(); // موجب فعال شدن «صفحات» و کنترلرها میشود
}
public void Configure(IApplicationBuilder app)
{
app.UseMvc();
}
}
@page @{ var message = "Hello, World!"; } <html> <body> <p>@message</p> </body> </html>
URL متناظر | نام فایل و مسیر آن |
/ یا /Index | /Pages/Index.cshtml |
/Contact | /Pages/Store/Contact.cshtml |
/Store/Contact | /Pages/Store/Contact.cshtml |
using System.ComponentModel.DataAnnotations; namespace MyApp { public class Contact { [Required] public string Name { get; set; } [Required] public string Email { get; set; } } }
@page @using MyApp @using Microsoft.AspNetCore.Mvc.RazorPages @addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers" @inject ApplicationDbContext Db @functions { [BindProperty] public Contact Contact { get; set; } public async Task<IActionResult> OnPostAsync() { if (ModelState.IsValid) { Db.Contacts.Add(Contact); await Db.SaveChangesAsync(); return RedirectToPage(); } return Page(); } } <html> <body> <p>فرم زیر را پر کنید تا در اسرع وقت، کارشناسان ما با شما بگیرند</p> <div asp-validation-summary="All"></div> <form method="POST"> <div>Name: <input asp-for="Contact.Name" /></div> <div>Email: <input asp-for="Contact.Email" /></div> <input type="submit" /> </form> </body> </html>
- اگر خطایی نبود، اطلاعات ذخیره شده و به صفحۀ دیگر ریدایرکت میشود.
- درغیراینصورت، صفحه را دوباره بههمراه خطاهای اعتبار سنجی نمایش میدهد.
- اگر اطلاعات موفقتآمیز وارد شوند، آنگاه متد هندلر OnPostAsync، هلپر RedirctToPage را برای برگرداندن نمونهای از RedirectToPageResult فراخوانی میکند. این یک نوع جدید بازگشتی برای اکشن متد است که شبیه RedirectToAction یا RedirectToRoute است؛ با این تفاوت که مخصوص صفحات طراحی شده است.
@page @using MyApp @using MyApp.Pages @using Microsoft.AspNetCore.Mvc.RazorPages @addTagHelper "*, Microsoft.AspNetCore.Mvc.TagHelpers" @model ContactModel <html> <body> <p>فرم زیر را پر کنید تا در اسرع وقت، کارشناسان ما با شما بگیرند </p> <div asp-validation-summary="All"></div> <form method="POST"> <div>Name: <input asp-for="Contact.Name" /></div> <div>Email: <input asp-for="Contact.Email" /></div> <input type="submit" /> </form> </body> </html>
using Microsoft.AspNetCore.Mvc; using Microsoft.AspNetCore.Mvc.RazorPages; namespace MyApp.Pages { public class ContactModel : PageModel { public ContactModel(ApplicationDbContext db) { Db = db; } [BindProperty] public Contact Contact { get; set; } private ApplicationDbContext Db { get; } public async Task<IActionResult> OnPostAsync() { if (ModelState.IsValid) { Db.Contacts.Add(Contact); await Db.SaveChangesAsync(); return RedirectToPage(); } return Page(); } } }
public class User { public string UserName { get; set; } public bool IsRole { get; set; } public HashSet<string> ConnectionIds { get; set; } } [Authorize] [HubName("userActivityHub")] public class UserActivityHub : Hub { private static readonly ConcurrentDictionary<string, User> Users = new ConcurrentDictionary<string, User>(); public void AdminJoin() { Groups.Add(Context.ConnectionId, "admins"); } public void Join() { var userName = Context.User.Identity.Name; var connectionId = Context.ConnectionId; var isAdmin = Context.User.IsInRole("Admin"); var user = Users.GetOrAdd(userName, _ => new User { UserName = userName, IsRole = isAdmin, ConnectionIds = new HashSet<string>() }); if (user.IsRole == true) { Groups.Add(user.ConnectionIds.ToString(), "admins"); } else { lock (user.ConnectionIds) { user.ConnectionIds.Add(connectionId); } Clients.Group("admins").showUserCount(Users.Count(a => a.Value.IsRole != true)); } } public void GetUserCount() { Clients.Group("admins").showUserCount(Users.Count(a => a.Value.IsRole != true)); } public override System.Threading.Tasks.Task OnDisconnected(bool stopCalled) { if (stopCalled) { var userName = Context.User.Identity.Name; var connectionId = Context.ConnectionId; User user; Users.TryGetValue(userName, out user); if (user != null) { lock (user.ConnectionIds) { user.ConnectionIds.RemoveWhere(cid => cid.Equals(connectionId)); if (!user.ConnectionIds.Any()) { User removeUser; Users.TryRemove(userName, out removeUser); } } } return Clients.Group("admins").showUserCount(Users.Count(a => a.Value.IsRole != true)); } else { return base.OnDisconnected(false); } } }
<script type="text/javascript"> var userHub = $.connection.userActivityHub; $.connection.hub.logging = true; $.connection.hub.start().done(function() { userHub.server.join(); }); $(function() { window.onbeforeunload = function() { $.connection.hub.stop(); }; }); </script>
<script type="text/javascript"> var userHub = $.connection.userActivityHub; userHub.client.showUserCount = function (message) { $('#userOnlineCount').html(message); }; $.connection.hub.start().done(function() { userHub.server.adminJoin().done(function() { userHub.server.getUserCount(); }); }); </script>
<script> $('a.btn.btn-danger.btn-block').click(function(e) { e.preventDefault(); $('#logoutForm').submit(); $.connection.userActivityHub.connection.stop(); }); $(function() { window.onbeforeunload = function(e) { $.connection.hub.stop(); }; }); </script>
پیشنیازهای کار با EF Core Migrations
در قسمت قبل در حین بررسی «برپایی تنظیمات اولیهی EF Core 1.0 در یک برنامهی ASP.NET Core 1.0»، چهار مدخل جدید را به فایل project.json برنامه اضافه کردیم. مدخل جدید Microsoft.EntityFrameworkCore.Tools که به قسمت tools آن اضافه شد، پیشنیاز اصلی کار با EF Core Migrations است.
بررسی ابزارهای خط فرمان EF Core و تشکیل ساختار بانک اطلاعاتی بر اساس کلاسهای برنامه
پس از تکمیل پیشنیازهای کار با EF Core، از طریق خط فرمان به پوشهی جاری پروژه وارد شده و دستور dotnet ef را صادر کنید.
یک نکته: در ویندوز اگر در پوشهای، کلید shift را نگه دارید و در آن پوشه کلیک راست کنید، در منوی باز شده، گزینهی جدیدی را به نام Open command window here مشاهده خواهید کرد که میتواند به سرعت خط فرمان را از پوشهی جاری شروع کند.
خروجی صدور فرمان dotnet ef را در ذیل مشاهده میکنید:
D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef _/\__ ---==/ \\ ___ ___ |. \|\ | __|| __| | ) \\\ | _| | _| \_/ | //|\\ |___||_| / \\\/\\ Entity Framework .NET Core CLI Commands 1.0.0-preview2-21431 Usage: dotnet ef [options] [command] Options: -h|--help Show help information -v|--verbose Enable verbose output --version Show version information --assembly <ASSEMBLY> The assembly file to load. --startup-assembly <ASSEMBLY> The assembly file containing the startup class. --data-dir <DIR> The folder used as the data directory (defaults to current working directory). --project-dir <DIR> The folder used as the project directory (defaults to current working directory). --content-root-path <DIR> The folder used as the content root path for the application (defaults to application base directory). --root-namespace <NAMESPACE> The root namespace of the target project (defaults to the project assembly name). Commands: database Commands to manage your database dbcontext Commands to manage your DbContext types migrations Commands to manage your migrations Use "dotnet ef [command] --help" for more information about a command.
D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef migrations add InitialDatabase
نام دلخواه InitialDatabase را در انتهای نام فایل 13950526050417_InitialDatabase مشاهده میکنید.
اگر قصد حذف این مرحله را داشته باشیم، میتوان دستور dotnet ef migrations remove را مجددا صادر کرد.
فایل 13950526050417_InitialDatabase به همراه کلاسی است که در آن دو متد Up و Down قابل مشاهده هستند. متد Up نحوهی ایجاد جدول جدیدی را از کلاس Person بیان میکند و متد Down نحوهی Drop این جدول را پیاده سازی کردهاست.
فایل ApplicationDbContextModelSnapshot.cs دارای کلاسی است که خلاصهای از تعاریف موجودیتهای ذکر شدهی در DB Context برنامه را به همراه دارد و تفسیر آنها را از دیدگاه EF در اینجا میتوان مشاهده کرد.
پس از مرحلهی افزودن migrations، نوبت به اعمال آن به بانک اطلاعاتی است. تا اینجا EF تنها متدهای Up و Down مربوط به ساخت و حذف ساختار جداول را ایجاد کردهاست. اما هنوز آنها را به بانک اطلاعاتی برنامه اعمال نکردهاست. برای اینکار در پوشهی جاری دستور ذیل را صادر کنید:
D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef database update Applying migration '13950526050417_InitialDatabase'. Done.
اکنون اگر به لیست بانکهای اطلاعاتی مراجعه کنیم، بانک اطلاعاتی جدید TestDbCore2016 را به همراه جدول متناظر کلاس Person میتوان مشاهده کرد:
در اینجا جدول دیگری به نام __EFMigrationsHistory نیز قابل مشاهدهاست که کار آن ذخیره سازی وضعیت فعلی Migrations در بانک اطلاعاتی، جهت مقایسههای آتی است. این جدول صرفا توسط ابزارهای EF استفاده میشود و نباید به صورت مستقیم تغییری در آن ایجاد کنید.
مقدار دهی اولیهی جداول بانکهای اطلاعاتی در EF Core
در همین حالت اگر کنترلر TestDB مطرح شدهی در انتهای بحث قسمت قبل را اجرا کنیم، به این استثناء خواهیم رسید:
این تصویر بدین معنا است که کار Migrations موفقیت آمیز بودهاست و اینبار امکان اتصال و کار با بانک اطلاعاتی وجود دارد، اما این جدول حاوی اطلاعات اولیهای برای نمایش نیست.
در نگارش قبلی EF Code First، امکانات Migrations به همراه یک متد Seed نیز بود که توسط آن کار مقدار دهی اولیهی جداول را میتوان انجام داد (زمانیکه جدولی ایجاد میشود، در همان هنگام، چند رکورد خاص نیز به آن اضافه شوند. برای مثال به جدول کاربران، رکورد اولین کاربر یا همان Admin اضافه شود). این متد در EF Core 1.0 وجود ندارد.
برای این منظور کلاس جدیدی را به نام ApplicationDbContextSeedData به همان پوشهی جدید Migrations اضافه کنید؛ با این محتوا:
using System.Collections.Generic; using System.Linq; using Core1RtmEmptyTest.Entities; using Microsoft.Extensions.DependencyInjection; namespace Core1RtmEmptyTest.Migrations { public static class ApplicationDbContextSeedData { public static void SeedData(this IServiceScopeFactory scopeFactory) { using (var serviceScope = scopeFactory.CreateScope()) { var context = serviceScope.ServiceProvider.GetService<ApplicationDbContext>(); if (!context.Persons.Any()) { var persons = new List<Person> { new Person { FirstName = "Admin", LastName = "User" } }; context.AddRange(persons); context.SaveChanges(); } } } } }
public void Configure(IServiceScopeFactory scopeFactory) { scopeFactory.SeedData();
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationDbContext>(ServiceLifetime.Scoped);
- برای پیاده سازی الگوی واحد کار، اولین قدم، مشخص سازی طول عمر Db Context برنامه است. برای اینکه تنها یک Context در طول یک درخواست وهله سازی شود، نیاز است به نحو صریحی طول عمر آنرا به حالت Scoped تنظیم کرد. متد AddDbContext دارای پارامتری است که این طول عمر را دریافت میکند. بنابراین در اینجا ServiceLifetime.Scoped ذکر شدهاست. همچنین در این مثال از نمونهای که IConfigurationRoot به سازندهی کلاس ApplicationDbContext تزریق شده (نکتهی انتهای بحث قسمت قبل)، استفاده شدهاست. به همین جهت تنظیمات options آنرا ملاحظه نمیکنید.
- مرحلهی بعد نحوهی دسترسی به این سرویس ثبت شده در یک کلاس static دارای متدی الحاقی است. در اینجا دیگر دسترسی مستقیمی به تزریق وابستگیها نداریم و باید کار را با IServiceScopeFactory شروع کنیم. در اینجا میتوانیم به صورت دستی یک Scope را ایجاد کرده، سپس توسط ServiceProvider آن، به سرویس ApplicationDbContext دسترسی پیدا کنیم و در ادامه از آن به نحو متداولی استفاده نمائیم. IServiceScopeFactory جزو سرویسهای توکار ASP.NET Core است و در صورت ذکر آن به عنوان پارامتر جدیدی در متد Configure، به صورت خودکار وهله سازی شده و در اختیار ما قرار میگیرد.
- نکتهی مهمی که در اینجا بکار رفتهاست، ایجاد Scope و dispose خودکار آن توسط عبارت using است. باید دقت داشت که ایجاد Scope و تخریب آن به صورت خودکار در ابتدا و انتهای درخواستها توسط ASP.NET Core انجام میشود. اما چون شروع کار ما از متد Configure است، در اینجا خارج از Scope قرار داریم و باید مدیریت ایجاد و تخریب آنرا به صورت دستی انجام دهیم که نمونهای از آنرا در متد SeedData کلاس ApplicationDbContextSeedData ملاحظه میکنید. در اینجا Scope ایی ایجاد شدهاست. سپس دادههای اولیهی مدنظر به بانک اطلاعاتی اضافه گردیده و در آخر این Scope تخریب شدهاست.
- اگر کار ایجاد و تخریب scope، به نحوی که مشخص شدهاست انجام نگیرد، طول عمر درخواستی خارج از Scope، همواره Singleton خواهد بود. چون خارج از طول عمر درخواست جاری قرار داریم و هنوز کار به سرویس دهی درخواستها نرسیدهاست. بنابراین مدیریت Scopeها هنوز شروع نشدهاست و باید به صورت دستی انجام شود.
در این حالت اگر برنامه را اجرا کنیم، این خروجی قابل مشاهده است:
که به معنای کار کردن متد SeedData و ثبت اطلاعات اولیهای در بانک اطلاعاتی است.
اعمال تغییرات به مدلهای برنامه و به روز رسانی ساختار بانک اطلاعاتی
فرض کنید به کلاس Person قسمت قبل، خاصیت Age را هم اضافه کردهایم:
namespace Core1RtmEmptyTest.Entities { public class Person { public int PersonId { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public int Age { get; set; } } }
An unhandled exception occurred while processing the request. SqlException: Invalid column name 'Age'.
D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef migrations add v2 D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef database update
با اجرا این دستورات، فایل جدید 13950526073248_v2 به پوشهی Migrations اضافه میشود. این فایل حاوی نحوهی به روز رسانی بانک اطلاعاتی، بر اساس خاصیت جدید Age است. سپس با اجرای دستور dotnet ef database update، کار به روز رسانی بانک اطلاعاتی بر اساس مرحلهی v2 انجام میشود.
بنابراین هر بار که تغییری را در مدلهای خود ایجاد میکنید، یکبار باید کلاس مهاجرت آنرا ایجاد کنید و سپس آنرا به بانک اطلاعاتی اعمال نمائید.
تهیه اسکریپت تغییرات بجای اعمال تغییرات توسط ابزارهای EF
شاید علاقمند باشید که پیش از اعمال تغییرات به بانک اطلاعاتی، یک اسکریپت SQL از آن تهیه کنید (جهت مطالعه و یا اعمال دستی آن توسط خودتان). برای اینکار میتوانید دستور ذیل را در پوشهی جاری پروژه اجرا کنید:
D:\Prog\1395\Core1RtmEmptyTest\src\Core1RtmEmptyTest>dotnet ef migrations script -o v2.sql
تغییرات ساختار جدول __EFMigrationsHistory در EF Core 1.0
در EF 6.x، ساختار اطلاعات جدول نگهداری تاریخچهی تغییرات، بسیار پیچیده بود و شامل رشتهای gzip شدهی حاوی یک snapshot از کل ساختار دیتابیس در هر مرحلهی migration بود. در این نگارش، این snapshot حذف شدهاست و بجای آن فایل ApplicationDbContextModelSnapshot.cs را مشاهده میکنید (تنها یک snapshot به ازای کل context برنامه). همچنین در اینجا کاملا مشخص است که چه مراحلی به بانک اطلاعاتی اعمال شدهاند و دیگر خبری از رشتهی gzip شدهی قبلی نیست (تصویر فوق).
در شکل زیر ساختار قبلی این جدول را در EF 6.x مشاهده میکنید. در EF 6.x حتی فضای نام کلاسهای موجودیتهای برنامه هم مهم هستند و در صورت تغییر، مشکل ایجاد میشود:
مهاجرت خودکار از EF Core حذف شدهاست
در EF 6.x در کنار کلاس Db Context یک کلاس Configuration هم وجود داشت که برای مثال امکان چنین تعریفی در آن میسر هست:
public Configuration() { AutomaticMigrationsEnabled = true; }
با حذف مهاجرت خودکار:
- دیگر نیازی نیست تا model snapshots در بانک اطلاعاتی ذخیره شوند (همان ساده شدن ساختار جدول ذخیره سازی تاریخچهی مهاجرتهای فوق).
- در حالت افزودن یک مرحلهی مهاجرت، دیگر نیازی به کوئری گرفتن از بانک اطلاعاتی نخواهد بود (سرعت بیشتر).
- میتوان چندین مرحلهی مهاجرت را افزود بدون اینکه الزاما مجبور به اعمال آنها به بانک اطلاعاتی باشیم.
- کاهش کدهای مدیریت ساختار بانک اطلاعاتی.
- تیمها برای یکی کردن تغییرات خود مشکلی نخواهند داشت چون دیگر snapshot مدلها در جدول __EFMigrationsHistory ذخیره نمیشود.
بنابراین در EF Core میتوان مهاجرت v1 را اضافه کرد. سپس تغییراتی را در کدها اعمال کرد. در ادامه مهاجرت v2 را تولید کرد و در آخر کار اعمال یکجای اینها را به بانک اطلاعاتی انجام داد.
هرچند در اینجا اگر میخواهید مرحلهی اجرای دستور dotnet ef database update را حذف کنید، میتوانید از کدهای ذیل نیز استفاده نمائید:
using Core1RtmEmptyTest.Entities; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.DependencyInjection; namespace Core1RtmEmptyTest.Migrations { public static class DbInitialization { public static void Initialize(this IServiceScopeFactory scopeFactory) { using (var serviceScope = scopeFactory.CreateScope()) { var context = serviceScope.ServiceProvider.GetService<ApplicationDbContext>(); // Applies any pending migrations for the context to the database. // Will create the database if it does not already exist. context.Database.Migrate(); } } } }
کار متد Migrate، ایجاد بانک اطلاعاتی در صورت عدم وجود و سپس اعمال تمام مراحل migration ایی است که در جدول __EFMigrationsHistory ثبت نشدهاند (دقیقا همان کار دستور dotnet ef database update را انجام میدهد).
تفاوت متد Database.EnsureCreated با متد Database.Migrate
اگر به متدهای context.Database دقت کنید، یکی از آنها EnsureCreated نام دارد. این متد نیز سبب تولید بانک اطلاعاتی بر اساس ساختار Context برنامه میشود. اما هدف آن صرفا استفادهی از آن در آزمونهای واحد سریع است. از این جهت که جدول __EFMigrationsHistory را تولید نمیکند (برخلاف متد Migrate). بنابراین بجز آزمونهای واحد، در جای دیگری از آن استفاده نکنید چون به دلیل عدم تولید جدول __EFMigrationsHistory توسط آن، قابلیت استفادهی از بانک اطلاعاتی تولید شدهی توسط آن با امکانات migrations وجود ندارد. در پایان آزمون واحد نیز میتوان از متد EnsureDeleted برای حذف این بانک اطلاعاتی موقتی استفاده کرد.
در قسمت بعد، مطالب تکمیلی مهاجرتها را بررسی خواهیم کرد. برای مثال چگونه میتوان کلاسهای موجودیتها را به اسمبلیهای دیگری منتقل کرد.
در C# 1.0 برای تعریف خواص، نیاز به نوشتن مقدار زیادی کد بود:
public class Person { public string _firstName; public string FirstName { get { return _firstName; } set { _firstName = value; } } }
در C# 2.0 از لحاظ ساده سازی این تعاریف، اتفاق خاصی رخنداد. فقط امکان تعریف سطوح دسترسی مانند private بر روی getterها و setterها میسر شد:
public string _firstName; public string FirstName { get { return _firstName; } private set { _firstName = value; } }
public class Person { public string FirstName { get; set; } }
در C# 6.0، امکان حذف private setterها از تعریف یک خاصیت میسر شد. یعنی مثال زیر را
public class User { public string Name { get; private set; } }
public class User { public string Name { get; } }
public class Foo { public string FirstName { get; set; } = "Initial Value"; }
همچنین در C# 6.0 با معرفی expression bodied members که بر روی خواص نیز قابل اعمال است، امکان تعریف خواص readonly محاسبه شدهی بر اساس مقدار سایر خواص نیز میسر شد:
public class Foo { public DateTime DateOfBirth { get; set; } public int Age => DateTime.Now.Year - DateOfBirth.Year; }
و در C# 9.0، با معرفی واژهی کلیدی init، امکان تعریف سادهتر خواص immutable ممکن شدهاست که در مطلب جاری به آن خواهیم پرداختیم.
روش غیرقابل مقدار دهی کردن خواص، در نگارشها پیش از C# 9.0
در بسیاری از موارد میخواهیم که خاصیتی از یک کلاس مدل، در خارج از آن قابل تغییر نباشد (مانند خواص شیءای که به محتوای فایل config ثابت برنامه اشاره میکند). راه حل فعلی آن تا پیش از C# 9.0 به صورت زیر است:
public class User { public string Name { get; private set; } }
var user = new User { Name = "User 1" // Compile Error };
The property or indexer 'User.Name' cannot be used in this context because the set accessor is inaccessible [CS9Features]csharp(CS0272)
- تنظیم مقدار خاصیت Name در سازندهی کلاس
- و یا تنظیم این مقدار در یک متد ثالث دیگر مانند SetName
public class User { public User(string name) { this.Name = name; } public void SetName(string name) { this.Name = name; } public string Name { get; private set; } }
معرفی خواص Init-Only در C# 9.0
برای رفع دو مشکل یاد شده (امکان تنظیم مقدار خاصیتها با همان روش متداول object initializer و همچنین غیرقابل تغییر شدن آنها)، اکنون در C# 9.0 میتوان بجای private set از واژهی کلیدی init استفاده کرد:
public class User { public string Name { get; init; } }
الف) امکان مقدار دهی خاصیت Name، در خارج بدنهی کلاس User و توسط روش متداول کار با object initializerها هنوز هم وجود دارد و در این حالت الزامی به تعریف یک سازنده و یا متد خاصی درون کلاس User برای مقدار دهی آن نیست:
var user = new User { Name = "User 1" };
// Compile Time Error // Init-only property or indexer 'User.Name' can only be assigned in an object initializer, // or on 'this' or 'base' in an instance constructor or an 'init' accessor. [CS9Features]csharp(CS8852) user.Name = "Test";
public class User { public string Name { get; init; } public User(string name) { this.Name = name; // Works fine } public void SetName(string name) { this.Name = name; // Compile Time Error } }
روش تعریف immutable properties در نگارشهای پیشین #C
با استفاده از واژهی readonly در نگارشهای قبلی #C نیز میتوان به صورت زیر، یک خاصیت را به صورت غیرقابل تغییر یا immutable در آورد:
public class Product { public Product(string name) { _name = name; } private readonly string _name; public string Name => _name; }
یک نکته: امکان استفادهی از فیلدهای readonly با خواص init-only هم وجود دارد؛ از این جهت که این نوع خواص تنها در زمان نمونه سازی اولیهی شیء، اجرا و مقدار دهی میشوند، با مفهوم readonly، سازگاری دارند:
public class Person { private readonly string _name; public string Name { get => _name; init => _name = value; } }
ایجاد یک پروژهی جدید Blazor WASM
برای تکمیل پیاده سازی قسمت سمت کلاینت پروژهی این سری، نیاز به یک پروژهی جدید Blazor WASM را داریم که میتوان آنرا با اجرای دستور dotnet new blazorwasm در یک پوشهی خالی، ایجاد کرد. کدهای این پروژه را میتوانید در پوشهی HotelManagement\BlazorWasm\BlazorWasm.Client فایل پیوستی انتهای بحث مشاهده کنید.
افزودن فایلهای جاوااسکریپتی مورد نیاز
شبیه به کاری که در مطلب «Blazor 5x - قسمت یازدهم - مبانی Blazor - بخش 8 - کار با جاوا اسکریپت» انجام دادیم، در اینجا هم قصد افزودن یکسری کتابخانهی جاوااسکریپتی و CSS ای را داریم که توسط LibMan آنها را مدیریت خواهیم کرد.
- بنابراین در ابتدا به پوشهی BlazorWasm.Client\wwwroot\css وارد شده و پوشههای پیشفرض bootstrap و open-iconic آنرا حذف میکنیم؛ چون تحت مدیریت هیچ package manager ای نیستند و در این حالت، مدیریت به روز رسانی و یا بازیابی آنها به صورت خودکار میسر نیست.
- سپس فایل wwwroot\css\app.css را هم ویرایش کرده و سطر زیر را از ابتدای آن حذف میکنیم:
@import url('open-iconic/font/css/open-iconic-bootstrap.min.css');
dotnet tool update -g Microsoft.Web.LibraryManager.Cli libman init libman install bootstrap --provider unpkg --destination wwwroot/lib/bootstrap libman install open-iconic --provider unpkg --destination wwwroot/lib/open-iconic libman install jquery --provider unpkg --destination wwwroot/lib/jquery libman install toastr --provider unpkg --destination wwwroot/lib/toastr
- بعد از نصب بستههای ذکر شده، فایل wwwroot\index.html را به صورت زیر به روز رسانی میکنیم تا به مسیرهای جدید بستههای CSS و JS نصب شده، اشاره کند:
<!DOCTYPE html> <html> <head> <meta charset="utf-8" /> <meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no" /> <title>BlazorWasm.Client</title> <base href="/" /> <link href="lib/toastr/build/toastr.min.css" rel="stylesheet" /> <link href="lib/open-iconic/font/css/open-iconic-bootstrap.min.css" rel="stylesheet" /> <link href="lib/bootstrap/dist/css/bootstrap.min.css" rel="stylesheet" /> <link href="css/app.css" rel="stylesheet" /> <link href="BlazorWasm.Client.styles.css" rel="stylesheet" /> </head> <body> <div id="app">Loading...</div> <div id="blazor-error-ui"> An unhandled error has occurred. <a href="" class="reload">Reload</a> <a class="dismiss">🗙</a> </div> <script src="lib/jquery/dist/jquery.min.js"></script> <script src="lib/toastr/build/toastr.min.js"></script> <script src="js/common.js"></script> <script src="_framework/blazor.webassembly.js"></script> </body> </html>
- محتویات فایل wwwroot\css\app.css را هم به صورت زیر تغییر میدهیم تا یک spinner و شیوه نامههای نمایش تصاویر، به آن اضافه شوند:
.valid.modified:not([type="checkbox"]) { outline: 1px solid #26b050; } .invalid { outline: 1px solid red; } .validation-message { color: red; } #blazor-error-ui { background: lightyellow; bottom: 0; box-shadow: 0 -1px 2px rgba(0, 0, 0, 0.2); display: none; left: 0; padding: 0.6rem 1.25rem 0.7rem 1.25rem; position: fixed; width: 100%; z-index: 1000; } #blazor-error-ui .dismiss { cursor: pointer; position: absolute; right: 0.75rem; top: 0.5rem; } .spinner { border: 16px solid silver !important; border-top: 16px solid #337ab7 !important; border-radius: 50% !important; width: 80px !important; height: 80px !important; animation: spin 700ms linear infinite !important; top: 50% !important; left: 50% !important; transform: translate(-50%, -50%); position: absolute !important; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } .room-image { display: block; width: 100%; height: 150px; background-size: cover !important; border: 3px solid green; position: relative; } .room-image-title { position: absolute; top: 0; right: 0; background-color: green; color: white; padding: 0px 6px; display: inline-block; }
window.ShowToastr = (type, message) => { if (type === "success") { toastr.success(message, "Operation Successful", { timeOut: 10000 }); } if (type === "error") { toastr.error(message, "Operation Failed", { timeOut: 10000 }); } };
- در قسمت 11، در بخش «کاهش کدهای تکراری فراخوانی متدهای جاوا اسکریپتی با تعریف متدهای الحاقی» آن، کلاس JSRuntimeExtensions را تعریف کردیم که سبب کاهش تکرار کدهای استفاده از تابع ShowToastr میشود. این فایلرا در پروژهی BlazorServer.App\Utils\JSRuntimeExtensions.cs این سری نیز استفاده کردیم. یا میتوان مجددا آنرا به پروژهی جاری کپی کرد؛ یا آنرا در یک پروژهی اشتراکی قرار داد. برای مثال اگر آنرا به پوشهی BlazorWasm.Client\Utils کپی کردیم، نیاز است فضای نام آنرا اصلاح کرده و سپس آنرا به انتهای فایل BlazorWasm.Client\_Imports.razor نیز اضافه کنیم تا در تمام کامپوننتهای برنامه قابل استفاده شود:
@using BlazorWasm.Client.Utils
تغییر و ساده سازی منوی برنامهی کلاینت
در برنامهی کلاینت جاری دیگر نمیخواهیم منوی پیشفرض سمت چپ صفحه را شاهد باشیم. به همین جهت ابتدا فایل Shared\MainLayout.razor را به صورت زیر ساده میکنیم:
@inherits LayoutComponentBase <NavMenu /> <div> @Body </div>
<nav class="navbar navbar-expand-sm navbar-dark bg-dark p-0"> <a class="navbar-brand mx-4" href="#">Navbar</a> <button class="navbar-toggler" type="button" data-toggle="collapse" data-target="#navbarSupportedContent" aria-controls="navbarSupportedContent" aria-expanded="false" aria-label="Toggle navigation"> <span class="navbar-toggler-icon"></span> </button> <div class="collapse navbar-collapse pr-2" id="navbarSupportedContent"> <ul class="navbar-nav mr-auto"></ul> <ul class="my-0 navbar-nav"> <li class="nav-item p-0"> <NavLink class="nav-link" href="registration"> <span class="p-2"> Register </span> </NavLink> </li> <li class="nav-item p-0"> <NavLink class="nav-link" href="login"> <span class="p-2"> Login </span> </NavLink> </li> </ul> </div> </nav>
تغییر محتوای صفحهی آغازین برنامه
صفحهی ابتدایی برنامه، یعنی کامپوننت Pages\Index.razor را نیز به صورت زیر تغییر میدهیم:
@page "/" <form> <div class="row p-0 mx-0 mt-4"> <div class="col-12 col-md-5 offset-md-1 pl-2 pr-2 pr-md-0"> <div class="form-group"> <label>Check In Date</label> <input type="text" class="form-control" /> </div> </div> <div class="col-8 col-md-3 pl-2 pr-2"> <div class="form-group"> <label>No. of nights</label> <select class="form-control"> @for (var i = 1; i <= 10; i++) { <option value="@i">@i</option> } </select> </div> </div> <div class="col-4 col-md-2 p-0 pr-2"> <div class="form-group"> <label> </label> <input type="submit" value="Go" class="btn btn-success btn-block" /> </div> </div> </div> </form>
تعریف View Model رابط کاربری Pages\Index.razor
پس از تعریف محتوای ثابت برنامه، اکنون نوبت به پویا سازی آن است. به همین جهت نیاز است مدلی را برای صفحهی آغازین برنامه تعریف کرد تا بتوان فرم آنرا به این مدل متصل کرد. این مدل چون مختص به برنامهی کلاینت است، آنرا در پوشهی جدید Models\ViewModels ایجاد میکنیم:
using System; namespace BlazorWasm.Client.Models.ViewModels { public class HomeVM { public DateTime StartDate { get; set; } = DateTime.Now; public DateTime EndDate { get; set; } public int NoOfNights { get; set; } = 1; } }
پس از این تعریف، بهتر است فضای نام آنرا نیز به فایل BlazorWasm.Client\_Imports.razor افزود، تا کار با آن در کامپوننتهای برنامه، سادهتر شود:
using BlazorWasm.Client.Models.ViewModels
- ابتدا فیلدی که ارائه کنندهی شیء ViewModel فرم است را تعریف میکنیم:
@code{ HomeVM HomeModel = new HomeVM(); }
<EditForm Model="HomeModel"> // ... </EditForm>
<InputDate min="@DateTime.Now.ToString("yyyy-MM-dd")" @bind-Value="HomeModel.StartDate" type="text" class="form-control" />
<select @bind="HomeModel.NoOfNights">
تعریف Local Storage سمت کلاینت
در ادامه میخواهیم اگر کاربری زمان شروع رزرو اتاقی را به همراه تعداد شب مدنظر، انتخاب کرد، با کلیک بر روی دکمهی Go، به یک صفحهی مشاهدهی جزئیات منتقل شود. بنابراین نیاز داریم تا اطلاعات انتخابی کاربر را به نحوی ذخیره سازی کنیم. برای یک چنین سناریوی سمت کلاینتی، میتوان از local storage استاندارد مرورگرها استفاده کرد که امکان کار آفلاین با برنامه را نیز فراهم میکند.
برای این منظور کتابخانهای به نام Blazored.LocalStorage طراحی شدهاست که پس از نصب آن توسط دستور زیر:
dotnet add package Blazored.LocalStorage
namespace BlazorWasm.Client { public class Program { public static async Task Main(string[] args) { var builder = WebAssemblyHostBuilder.CreateDefault(args); // ... builder.Services.AddBlazoredLocalStorage(); // ... } } }
@using Blazored.LocalStorage
@page "/" @inject ILocalStorageService LocalStorage @inject IJSRuntime JsRuntime <EditForm Model="HomeModel" OnValidSubmit="SaveInitialData">
@code{ HomeVM HomeModel = new HomeVM(); private async Task SaveInitialData() { try { HomeModel.EndDate = HomeModel.StartDate.AddDays(HomeModel.NoOfNights); await LocalStorage.SetItemAsync("InitialRoomBookingInfo", HomeModel); } catch (Exception e) { await JsRuntime.ToastrError(e.Message); } } }
برای مثال اگر تاریخ و عددی را انتخاب کنیم، نتیجهی حاصل از کلیک بر روی دکمهی Go را میتوان در قسمت Local storage مرورگر جاری مشاهده کرد:
البته با توجه به اینکه میخواهیم از کلید InitialRoomBookingInfo در سایر کامپوننتهای برنامه نیز استفاده کنیم، بهتر است آنرا به یک پروژهی مشترک مانند BlazorServer.Common که پیشتر نام نقشهایی مانند Admin را در آن تعریف کردیم، منتقل کنیم:
namespace BlazorServer.Common { public static class ConstantKeys { public const string LocalInitialBooking = "InitialRoomBookingInfo"; } }
<Project Sdk="Microsoft.NET.Sdk.BlazorWebAssembly"> <ItemGroup> <ProjectReference Include="..\..\BlazorServer\BlazorServer.Common\BlazorServer.Common.csproj" /> </ItemGroup> </Project>
@using BlazorServer.Common
await LocalStorage.SetItemAsync(ConstantKeys.LocalInitialBooking, HomeModel);
در آخر قصد داریم با کلیک بر روی Go، به یک صفحهی جدید مانند نمایش لیست اتاقها هدایت شویم. به همین جهت کامپوننت جدید Pages\HotelRooms\HotelRooms.razor را ایجاد میکنیم:
@page "/hotel/rooms" <h3>HotelRooms</h3> @code { }
@page "/" @inject ILocalStorageService LocalStorage @inject IJSRuntime JsRuntime @inject NavigationManager NavigationManager @code{ HomeVM HomeModel = new HomeVM(); private async Task SaveInitialData() { try { HomeModel.EndDate = HomeModel.StartDate.AddDays(HomeModel.NoOfNights); await LocalStorage.SetItemAsync(ConstantKeys.LocalInitialBooking, HomeModel); NavigationManager.NavigateTo("hotel/rooms"); } catch (Exception e) { await JsRuntime.ToastrError(e.Message); } } }
کدهای کامل این مطلب را از اینجا میتوانید دریافت کنید: Blazor-5x-Part-26.zip
مراحل فعال سازی آپلود فایلها در 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; } }
با پیشرفت بیشتر تکنولوژی وب در سالهای اخیر و رشد کاربران فضای اینترنتی، خدمات و پیچیدگیهای بیشتری به نرم افزارها اضافه شده و به همین دلیل استفاده از میکروسرویسها بجای حالت قدیمی مونولوتیک (یک برنامه همه کاره) طرفداران بیشتری پیدا کردهاست. در این حالت برنامه به قسمتهای خرد و مجزایی تبدیل شده و هر پروژه ساختار و تکنولوژی مخصوص به خود را مدیریت میکند و در این بین با استفاده روشهای متفاوتی به ایجاد ارتباط با یکدیگر میپردازند .
مشکلی که در این حالت میتواند رخ دهد، زیاد شدن مسیرهای متفاوت برای اتصال به هر یک از سرویسها و سختتر شدن به روزرسانی این مسیرها میباشد. به همین دلیل در این بخش، نیاز به ابزاری میباشد تا بتوان از طریق آن، مسیردهی سادهای را ایجاد کرد و در پشت صحنه مسیردهیهای متفاوتی را کنترل نمود. با ایجاد چنین ابزاری در واقع شما API Gateway ایجاد نمودهاید. یکی از معروفترین کتابخانههای این حوزه، Ocelot میباشد. کار با این ابزار بسیار ساده بوده و امکانات بسیار زیاد و قدرتمندی را فراهم مینماید.
برای اینکار ابتدا سه پروژه را میسازیم که موارد زیر را شامل میگردد:
پروژه اول نوع Api : با دریافت Id در اکشنمتد مورد نظر، شیء user بازگردانده میشود:
public class User { public int Id { get; set; } public string FirstName { get; set; } public string LastName { get; set; } public string UserName { get; set; } public static List<User> GetUsers() { return new List<User>() { new() { Id = 1, FirstName = "علی", LastName = "یگانه مقدم", UserName = "yeganehaym" }, new () { Id = 2, FirstName = "وحید", LastName = "نصیری", UserName = "VahidN" }, }; } }
[ApiController] [Route("/api/[controller]/{id?}")] public class UserController : ControllerBase { [HttpGet] public User GetUser(int id) { var users = Users.User.GetUsers(); var user = users.FirstOrDefault(x => x.Id == id); return user; } }
پروژه دوم نوع Api : دریافت لیستی از محصولات:
public class Product { public int Id { get; set; } public string Name { get; set; } public int Price { get; set; } public int Quantity { get; set; } public static List<Product> GetProducts() { return new List<Product>() { new() { Id = 1, Name = "LCD", Price = 20000, Quantity = 10 }, new() { Id = 1, Name = "Mouse", Price = 320000, Quantity = 15 }, new() { Id = 1, Name = "Keyboard", Price = 50000, Quantity = 25 }, }; } }
[ApiController] [Route("api/[controller]")] public class ProductController : ControllerBase { [HttpGet] public List<Product> GetProducts() { return Product.GetProducts(); } }
پروژه سوم همان ApiGateway هست و همینکه یک پروژهی وب خالی باشد، کفایت میکند. در این پروژه Ocelot را نصب نموده و سپس فایلی با نام ocelot.json را با محتوای زیر به ریشهی پروژه همانند فایلهای appsettings.json اضافه میکنیم:
{ "Routes":[ { "DownstreamPathTemplate":"/api/User/{id}", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7279" } ], "UpstreamPathTemplate":"/GetUser/{id}", "UpstreamHttpMethod":[ "GET" ]}, { "DownstreamPathTemplate":"/api/Product", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7261" } ], "UpstreamPathTemplate":"/Products", "UpstreamHttpMethod":[ "GET" ] } ] }
این فایلها شامل دو قسمتUpStream و DownStream میشوند. آپاستریمها در واقع آدرسی است که شما قصد اتصال به آنرا دارید و قسمت داوناستریم، سرویس مقصدی است که ocelot باید درخواست شما را به سمت آن ارسال نماید. بهعنوان مثل شما با ارسال درخواستی به آدرس Products ، در پشت صحنه به آدرس localhost:7261/api/product ارسال میگردد. بدین صورت سیستم نهایی تنها به یک دامنه و آدرس منسجم ارسال شده، ولی در پشت صحنه این آدرسها ممکن است به تعداد زیادی سرویس در آدرسهای متفاوتی ارسال گردند.
جهت راه اندازی نهایی، کد زیر را به فایل Program.cs اضافه میکنیم:
builder.Services.AddOcelot();
app.UseOcelot();
پس از اضافه کردن پیکربندی و middleware آن، کد زیر را نیز جهت شناسایی فایل ocelot به فایل Program.cs نیز اضافه مینماییم:
builder.Configuration.SetBasePath(builder.Environment.ContentRootPath) .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true);
همچنین
در صورت تمایل میتوانید کد را به شکل زیر هم نوشته تا بتوانید تنظیمات متفاوتی را برای محیط اجرایی متفاوتی ایجاد نمایید:
builder.Configuration.SetBasePath(builder.Environment.ContentRootPath) .AddJsonFile("ocelot.json", optional: false, reloadOnChange: true) .AddJsonFile($"ocelot.{builder.Environment.EnvironmentName}.json", optional: false, reloadOnChange: true);
هر
سه برنامه را با هم اجرا نمایید و با استفاده از برنامهی PostMan درخواستی را برای هر یک از موارد مورد نظر /Products و /GetUser/{1,2} به سمت پروژه ApiGateway
ارسال نمایید.
Ocelot موارد دیگری از قبیل تنظیم Load Balancer بین سرویس ها، اتصال به سرویسهای Service Discoveryچون Consul یا یوریکا و کش کردن و ... را نیز فراهم مینماید.
عملیات کشینگ
جهت بحث کشینگ، ابتدا بسته زیر را اضافه نمایید:
Install-Package Ocelot.Cache.CacheManager
سپس
پیکربندی ابتدایی را به شکل زیر تغییر دهید:
builder.Services.AddOcelot() .AddCacheManager(x => x.WithDictionaryHandle());
در ادامه در فایل Ocelot جیسون،
برای هر بخشی که مدنظر شماست تا کشی را انجام دهد، کد زیر اضافه نمایید:
"FileCacheOptions":{ "TtlSeconds":30, "Region":"custom" }
TtlSeconds : مدت
زمان کش به ثانیه
Region : یک عبارت رشتهای همانند یک عنوان یا نام که بعدا میتوانید از طریق api ها به آن متصل شوید و عملیاتی چون خالی کردن کش را صادر نمایید.
حال برای بخش محصولات این تنظیمات ذکر میگردد:
{ "Routes":[ { "DownstreamPathTemplate":"/api/User/{id}", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7279" } ], "UpstreamPathTemplate":"/GetUser/{id}", "UpstreamHttpMethod":[ "GET" ] }, { "DownstreamPathTemplate":"/api/Product", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7261" } ], "UpstreamPathTemplate":"/Products", "UpstreamHttpMethod":[ "GET" ], "FileCacheOptions":{ "TtlSeconds":30, "Region":"custom" } } ] }
برای اینکه متوجه عملکرد آن شوید یک نقطه توقف را در اکشن دریافت محصول قرار دهید و سپس برنامه را در حالت دیباگ اجرا نمایید. در مرتبه اول باید نقطه توقف بتواند اجرای کد را به شما نمایش دهد ولی تا 30 ثانیه آینده هر چقدر از طریق Postman درخواستی را ارسال نمایید نقطه توقف اجرا نخواهد گردید، ولی نتیجهی قبل برای شما ارسال خواهد شد.
این مورد را برای بخش کاربران هم انجام دهید و میبینید که برای هر userId و هر شکل Url، یک پاسخ منحصر به فرد، دریافت و کش خواهد شد.
جلوگیری از درخواستهای بیش از حد
یکی دیگر از ویژگیهای Ocelot، جلوگیری از درخواست بیش از حد میباشد. به همین علت ابتدا کد زیر را به هر درخواستی که مدنظر شماست اضافه نمایید:
"RateLimitOptions":{ "ClientWhitelist":[ ], "EnableRateLimiting":true, "Period":"5s", "PeriodTimespan":1, "Limit":1, "HttpStatusCode":429 }
WhiteClients : برای مشخص کردن کلاینتهایی که نباید اعمال محدودیت روی آنها صورت بگیرد.
EnableRateLimiting : این مورد باعث فعالسازی آن میگردد.
Period: مدت زمانیکه حداکثر تعداد درخواست باید در آن بازه صورت بگیرد. به ترتیب برای ثانیه، دقیقه، ساعت و روز حروف s - m - h و d استفاده میگردد.
PeriodTimespan: بعد از محدود شدن، بعد از چه مدتی دوباره بتواند درخواستی را ارسال نماید. در اینجا بعد از محدودیت ارسال درخواست، بعد از یک ثانیه مجدد اجازه ارسال درخواست باز میگردد.
Limit: در بازه زمانی مشخص شده چند درخواست مورد قبول واقع میشود و بعد از آن دیگر اجازه ارسال درخواست را نخواهد داشت.
HttpStatusCode: در صورت فیلتر شدن درخواستهای رسیده، چه کد وضعیتی باید برگردانده شود که عدد 429 به معنای Too Many Request میباشد.
با تنظیمات بالا هر کلاینت میتواند در 5 ثانیه، نهایتا یک درخواست را ارسال نماید و با ارسال بقیه درخواستها، Ocelot بجای هدایت درخواست به سرویس مربوطه، کد وضعیت 429 را باز میگرداند و یک ثانیه بعد از گذشت 5 ثانیه میتواند مجددا درخواست خود را ارسال نماید.
در نهایت به یک فایل مشابه زیر میرسیم:
{ "Routes":[ { "DownstreamPathTemplate":"/api/User/{id}", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7279" } ], "UpstreamPathTemplate":"/GetUser/{id}", "UpstreamHttpMethod":[ "GET" ], "FileCacheOptions":{ "TtlSeconds":30, "Region":"custom" } }, { "DownstreamPathTemplate":"/api/Product", "DownstreamScheme":"https", "DownstreamHostAndPorts":[ { "Host":"localhost", "Port":"7261" } ], "UpstreamPathTemplate":"/Products", "UpstreamHttpMethod":[ "GET" ], "RateLimitOptions":{ "ClientWhitelist":[ ], "EnableRateLimiting":true, "Period":"5s", "PeriodTimespan":1, "Limit":1, "HttpStatusCode":429 } } ], "DangerousAcceptAnyServerCertificateValidator": true }
برای تست آن با استفاد از PostMan مرتبا به آدرس Products/ درخواست ارسال نمایید.
فایل پروژه : Ocelot.zip
تزریق وابستگیهای AutoMapper در لایه سرویس برنامه
public IList<AdvertismentViewModel> GetAdvertisementsByMe(int userId) { var adsList = _advertisements.Where(x => x.UserId == userId).ToList(); var adsViewModel = new List<AdvertismentViewModel>(); _mappingEngine.Map(source: adsList, destination: adsViewModel); return adsViewModel; }
public class AdvertismentViewModel { public string Image { get; set; } public string Title { get; set; } public string ExpireDate { get; set; } }
Missing type map configuration or unsupported mapping. Mapping types: Advertisement -> AdvertismentViewModel Project.DomainClasses.Advertisement -> Project.Models.AdvertismentViewModel Destination path: List`1[0] Source value: System.Data.Entity.DynamicProxies.Advertisement_E82DFF273E08C95AA785F8F7A0D2B5ABC8E54C4566DFE1C8A92D8D3C447608AE