- GraphQL نیازمند رفت و برگشتهای کمتری به server، به منظور بازیابی دادهها برای template یا view است. همراه با REST ما مجبور هستیم که چندیدن endpoint مثلا (... api/students, api/courses, api/instructors ) را برای گرفتن همه دادههای که برای template یا view نیاز داریم ملاقات کنیم؛ ولی این شرایط در GraphQL برقرار نیست. با GraphQL ما تنها یک query را ایجاد میکنیم که چندین تابع (resolver) را در سمت سرور فراخوانی میکند و همه دادهها را از منابع مختلفی، در یک درخواست برگشت میدهد.
- همراه با REST، همانطور که Application ما رشد میکند، تعداد endpointها هم زیاد میشوند که این نیازمند زمان بیشتری برای نگهداری میباشد. اما با GraphQL ما تنها یک endpoint داریم؛ همین!
- با استفاده از GraphQL، ما هرگز به مشکل گرفتن دادههایی کم یا زیاد از منبع روبرو نخواهیم شد. به این خاطر است که ما queryها را با فیلدهایی که چیزهایی را که نیاز داریم، نشان میدهند، تعریف میکنیم. در این صورت ما همیشه چیزهایی را که درخواست دادهایم، دریافت میکنیم.
query OwnersQuery { owners { name account { type } } }
{ "data": { "owners": [ { "name": "John Doe", "accounts": [ { "type": "Cash" }, { "type": "Savings" } ] } ] } }
dotnet new api -n ASPCoreGraphQL
پوشه Contracts شامل واسطهای مورد نیاز برای repository logic میباشد:
namespace ASPCoreGraphQL.Contracts { public interface IOwnerRepository { } }
namespace ASPCoreGraphQL.Contracts { public interface IAccountRepository { } }
public class Owner { [Key] public Guid Id { get; set; } [Required(ErrorMessage = "Name is required")] public string Name { get; set; } public string Address { get; set; } public ICollection<Account> Accounts { get; set; } } public class Account { [Key] public Guid Id { get; set; } [Required(ErrorMessage = "Type is required")] public TypeOfAccount Type { get; set; } public string Description { get; set; } [ForeignKey("OwnerId")] public Guid OwnerId { get; set; } public Owner Owner { get; set; } } public enum TypeOfAccount { Cash, Savings, Expense, Income }
public class ApplicationContext : DbContext { public ApplicationContext(DbContextOptions options) : base(options) { } protected override void OnModelCreating(ModelBuilder modelBuilder) { } public DbSet<Owner> Owners { get; set; } public DbSet<Account> Accounts { get; set; } }
public class OwnerRepository : IOwnerRepository { private readonly ApplicationContext _context; public OwnerRepository(ApplicationContext context) { _context = context; } }
public class AccountRepository : IAccountRepository { private readonly ApplicationContext _context; public AccountRepository(ApplicationContext context) { _context = context; } }
public void ConfigureServices(IServiceCollection services) { services.AddDbContext<ApplicationContext>(opt => opt.UseSqlServer(Configuration.GetConnectionString("DefaultConnection"))); services.AddScoped<IOwnerRepository, OwnerRepository>(); services.AddScoped<IAccountRepository, AccountRepository>(); services.AddMvc().SetCompatibilityVersion(CompatibilityVersion.Version_2_2) .AddJsonOptions(options => options.SerializerSettings.ReferenceLoopHandling = ReferenceLoopHandling.Ignore); }
dotnet add package GraphQL
dotnet add package GraphQL.Server.Transports.AspNetCore
dotnet add package GraphQL.Server.Ui.Playground
public class AppSchema : Schema { public AppSchema(IDependencyResolver resolver) :base(resolver) { } }
public class OwnerType : ObjectGraphType<Owner> { public OwnerType() { Field(x => x.Id, type: typeof(IdGraphType)).Description("Id property from the owner object."); Field(x => x.Name).Description("Name property from the owner object."); Field(x => x.Address).Description("Address property from the owner object."); } }
public interface IOwnerRepository { IEnumerable<Owner> GetAll(); } public class OwnerRepository : IOwnerRepository { private readonly ApplicationContext _context; public OwnerRepository(ApplicationContext context) { _context = context; } public IEnumerable<Owner> GetAll() => _context.Owners.ToList(); }
public class AppQuery : ObjectGraphType { public AppQuery(IOwnerRepository repository) { Field<ListGraphType<OwnerType>>( "owners", resolve: context => repository.GetAll() ); } }
public class AppSchema : Schema { public AppSchema(IDependencyResolver resolver) :base(resolver) { Query = resolver.Resolve<AppQuery>(); } }
public void ConfigureServices(IServiceCollection services) { ... services.AddScoped<IDependencyResolver>(s => new FuncDependencyResolver(s.GetRequiredService)); services.AddScoped<AppSchema>(); services.AddGraphQL(o => { o.ExposeExceptions = false; }) .AddGraphTypes(ServiceLifetime.Scoped); ... }
public void Configure(IApplicationBuilder app, IHostingEnvironment env) { ... app.UseGraphQL<AppSchema>(); app.UseGraphQLPlayground(options: new GraphQLPlaygroundOptions()); app.UseMvc(); }
dotnet run
https://localhost:5001/ui/playground
الف) Policies
ب) Role Claims
سیاستهای دسترسی یا Policies در ASP.NET Core Identity
ASP.NET Core Identity هنوز هم از مفهوم Roles پشتیبانی میکند. برای مثال میتوان مشخص کرد که اکشن متدی و یا تمام اکشن متدهای یک کنترلر تنها توسط کاربران دارای نقش Admin قابل دسترسی باشند. اما نقشها نیز در این سیستم جدید تنها نوعی از سیاستهای دسترسی هستند.
[Authorize(Roles = ConstantRoles.Admin)] public class RolesManagerController : Controller
اما نقشهای ثابت، بسیار محدود و غیر قابل انعطاف هستند. برای رفع این مشکل مفهوم جدیدی را به نام Policy اضافه کردهاند.
[Authorize(Policy="RequireAdministratorRole")] public IActionResult Get() { /* .. */ }
برای مثال اگر بخواهیم تک نقش Admin را به صورت یک سیاست دسترسی جدید تعریف کنیم، روش کار به صورت ذیل خواهد بود:
public void ConfigureServices(IServiceCollection services) { services.AddMvc(); services.AddAuthorization(options => { options.AddPolicy("RequireAdministratorRole", policy => policy.RequireRole("Admin")); }); }
و یا بجای اینکه چند نقش مجاز به دسترسی منبعی را با کاما از هم جدا کنیم:
[Authorize(Roles = "Administrator, PowerUser, BackupAdministrator")]
options.AddPolicy("ElevatedRights", policy => policy.RequireRole("Administrator", "PowerUser", "BackupAdministrator"));
[Authorize(Policy = "ElevatedRights")] public IActionResult Shutdown() { return View(); }
سیاستهای دسترسی تنها به نقشها محدود نیستند:
services.AddAuthorization(options => { options.AddPolicy("EmployeeOnly", policy => policy.RequireClaim("EmployeeNumber")); });
[Authorize(Policy = "EmployeeOnly")] public IActionResult VacationBalance() { return View(); }
سیاستهای دسترسی پویا در ASP.NET Core Identity
مهمترین مزیت کار با سیاستهای دسترسی، امکان سفارشی سازی و تهیهی نمونههای پویای آنها هستند؛ موردی که با نقشهای ثابت سیستم قابل پیاده سازی نبوده و در نگارشهای قبلی، جهت پویا سازی آن، یکی از روشهای بسیار متداول، تهیهی فیلتر Authorize سفارشی سازی شده بود. اما در اینجا دیگر نیازی نیست تا فیلتر Authorize را سفارشی سازی کنیم. با پیاده سازی یک AuthorizationHandler جدید و معرفی آن به سیستم، پردازش سیاستهای دسترسی پویای به منابع، فعال میشود.
پیاده سازی سیاستهای پویای دسترسی شامل مراحل ذیل است:
1- تعریف یک نیازمندی دسترسی جدید
public class DynamicPermissionRequirement : IAuthorizationRequirement { }
2- پیاده سازی یک AuthorizationHandler استفاده کنندهی از نیازمندی دسترسی تعریف شده
پس از اینکه نیازمندی DynamicPermissionRequirement را تعریف کردیم، در ادامه باید یک AuthorizationHandler استفاده کنندهی از آن را تعریف کنیم:
public class DynamicPermissionsAuthorizationHandler : AuthorizationHandler<DynamicPermissionRequirement> { private readonly ISecurityTrimmingService _securityTrimmingService; public DynamicPermissionsAuthorizationHandler(ISecurityTrimmingService securityTrimmingService) { _securityTrimmingService = securityTrimmingService; _securityTrimmingService.CheckArgumentIsNull(nameof(_securityTrimmingService)); } protected override Task HandleRequirementAsync( AuthorizationHandlerContext context, DynamicPermissionRequirement requirement) { var mvcContext = context.Resource as AuthorizationFilterContext; if (mvcContext == null) { return Task.CompletedTask; } var actionDescriptor = mvcContext.ActionDescriptor; var area = actionDescriptor.RouteValues["area"]; var controller = actionDescriptor.RouteValues["controller"]; var action = actionDescriptor.RouteValues["action"]; if(_securityTrimmingService.CanCurrentUserAccess(area, controller, action)) { context.Succeed(requirement); } else { context.Fail(); } return Task.CompletedTask; } }
در کلاس تهیه شده باید متد HandleRequirementAsync آنرا بازنویسی کرد و اگر در این بین، منطق سفارشی ما context.Succeed را فراخوانی کند، به معنای برآورده شدن سیاست دسترسی بوده و کاربر جاری میتواند به منبع درخواستی، بلافاصله دسترسی یابد و اگر context.Fail فراخوانی شود، در همینجا دسترسی کاربر قطع شده و HTTP status code مساوی 401 (عدم دسترسی) را دریافت میکند.
منطق سفارشی پیاده سازی شده نیز به این صورت است:
نام ناحیه، کنترلر و اکشن متد درخواستی کاربر از مسیریابی جاری استخراج میشوند. سپس توسط سرویس سفارشی ISecurityTrimmingService تهیه شده، بررسی میکنیم که آیا کاربر جاری به این سه مؤلفه دسترسی دارد یا خیر؟
3- معرفی سیاست دسترسی پویای تهیه شده به سیستم
معرفی سیاست کاری پویا و سفارشی تهیه شده، شامل دو مرحلهی زیر است:
private static void addDynamicPermissionsPolicy(this IServiceCollection services) { services.AddScoped<IAuthorizationHandler, DynamicPermissionsAuthorizationHandler>(); services.AddAuthorization(opts => { opts.AddPolicy( name: ConstantPolicies.DynamicPermission, configurePolicy: policy => { policy.RequireAuthenticatedUser(); policy.Requirements.Add(new DynamicPermissionRequirement()); }); }); }
سپس یک Policy جدید را با نام دلخواه DynamicPermission تعریف کرده و نیازمندی علامتگذار خود را به عنوان یک policy.Requirements جدید، اضافه میکنیم. همانطور که ملاحظه میکنید یک وهلهی جدید از DynamicPermissionRequirement در اینجا ثبت شدهاست. همین وهله به متد HandleRequirementAsync نیز ارسال میشود. بنابراین اگر نیاز به ارسال پارامترهای بیشتری به این متد وجود داشت، میتوان خواص مرتبطی را به کلاس DynamicPermissionRequirement نیز اضافه کرد.
همانطور که مشخص است، در اینجا یک نیازمندی را میتوان ثبت کرد و نه Handler آنرا. این Handler از سیستم تزریق وابستگیها، بر اساس آرگومان جنریک AuthorizationHandler پیاده سازی شده، به صورت خودکار یافت شده و اجرا میشود (بنابراین اگر Handler شما اجرا نشد، مطمئن شوید که حتما آنرا به سیستم تزریق وابستگیها معرفی کردهاید).
پس از آن هر کنترلر یا اکشن متدی که از این سیاست دسترسی پویای تهیه شده استفاده کند:
[Authorize(Policy = ConstantPolicies.DynamicPermission)] [DisplayName("کنترلر نمونه با سطح دسترسی پویا")] public class DynamicPermissionsSampleController : Controller
سرویس ISecurityTrimmingService چگونه کار میکند؟
کدهای کامل ISecurityTrimmingService را در کلاس SecurityTrimmingService میتوانید مشاهده کنید.
پیشنیاز درک عملکرد آن، آشنایی با دو قابلیت زیر هستند:
الف) «روش یافتن لیست تمام کنترلرها و اکشن متدهای یک برنامهی ASP.NET Core»
دقیقا از همین سرویس توسعه داده شدهی در مطلب فوق، در اینجا نیز استفاده شدهاست؛ با یک تفاوت تکمیلی:
public interface IMvcActionsDiscoveryService { ICollection<MvcControllerViewModel> MvcControllers { get; } ICollection<MvcControllerViewModel> GetAllSecuredControllerActionsWithPolicy(string policyName); }
بنابراین همینقدر که تعریف ذیل یافت شود، این اکشن متد نیز در صفحهی مدیریت سطوح دسترسی پویا لیست خواهد شد.
[Authorize(Policy = ConstantPolicies.DynamicPermission)]
ابتدا به مدیریت نقشهای ثابت سیستم میرسیم. سپس به هر نقش میتوان یک Claim جدید را با مقدار area:controller:action انتساب داد.
به این ترتیب میتوان به یک نقش، تعدادی اکشن متد را نسبت داد و سطوح دسترسی به آنها را پویا کرد. اما ذخیره سازی آنها چگونه است و چگونه میتوان به اطلاعات نهایی ذخیره شده دسترسی پیدا کرد؟
مفهوم جدید Role Claims در ASP.NET Core Identity
تا اینجا موفق شدیم تمام اکشن متدهای دارای سیاست دسترسی سفارشی سازی شدهی خود را لیست کنیم، تا بتوان آنها را به صورت دلخواهی انتخاب کرد و سطوح دسترسی به آنها را به صورت پویا تغییر داد. اما این اکشن متدهای انتخاب شده را در کجا و به چه صورتی ذخیره کنیم؟
برای ذخیره سازی این اطلاعات نیازی نیست تا جدول جدیدی را به سیستم اضافه کنیم. جدول جدید AppRoleClaims به همین منظور تدارک دیده شدهاست.
وقتی کاربری عضو یک نقش است، به صورت خودکار Role Claims آن نقش را نیز به ارث میبرد. هدف از نقشها، گروه بندی کاربران است. توسط Role Claims میتوان مشخص کرد این نقشها چه کارهایی را میتوانند انجام دهند. اگر از قسمت قبل بخاطر داشته باشید، سرویس توکار UserClaimsPrincipalFactory دارای مرحلهی 5 ذیل است:
«5) اگر یک نقش منتسب به کاربر دارای Role Claim باشد، این موارد نیز واکشی شده و به کوکی او به عنوان یک Claim جدید اضافه میشوند. در ASP.NET Identity Core نقشها نیز میتوانند Claim داشته باشند (امکان پیاده سازی سطوح دسترسی پویا).»
به این معنا که با لاگین شخص به سیستم، تمام اطلاعات مرتبط به او که در جدول AppRoleClaims وجود دارند، به کوکی او به صورت خودکار اضافه خواهند شد و دسترسی به آنها فوق العاده سریع است.
در کنترلر DynamicRoleClaimsManagerController، یک Role Claim Type جدید به نام DynamicPermissionClaimType اضافه شدهاست و سپس ID اکشن متدهای انتخابی را به نقش جاری، تحت Claim Type عنوان شده، اضافه میکند (تصویر فوق). این ID به صورت area:controller:action طراحی شدهاست. به همین جهت است که در DynamicPermissionsAuthorizationHandler همین سه جزء از سیستم مسیریابی استخراج و در سرویس SecurityTrimmingService مورد بررسی قرار میگیرد:
return user.HasClaim(claim => claim.Type == ConstantPolicies.DynamicPermissionClaimType && claim.Value == currentClaimValue);
متد HasClaim هیچگونه رفت و برگشتی را به بانک اطلاعاتی ندارد و اطلاعات خود را از کوکی شخص دریافت میکند. متد user.IsInRole نیز به همین نحو عمل میکند.
Tag Helper جدید SecurityTrimming
اکنون که سرویس ISecurityTrimmingService را پیاده سازی کردهایم، از آن میتوان جهت توسعهی SecurityTrimmingTagHelper نیز استفاده کرد:
public override void Process(TagHelperContext context, TagHelperOutput output) { context.CheckArgumentIsNull(nameof(context)); output.CheckArgumentIsNull(nameof(output)); // don't render the <security-trimming> tag. output.TagName = null; if(_securityTrimmingService.CanCurrentUserAccess(Area, Controller, Action)) { // fine, do nothing. return; } // else, suppress the output and generate nothing. output.SuppressOutput(); }
نمونهای از کاربرد آنرا در ReportsMenu.cshtml_ میتوانید مشاهده کنید:
<security-trimming asp-area="" asp-controller="DynamicPermissionsTest" asp-action="Products"> <li> <a asp-controller="DynamicPermissionsTest" asp-action="Products" asp-area=""> <span class="left5 fa fa-user" aria-hidden="true"></span> گزارش از لیست محصولات </a> </li> </security-trimming>
برای آزمایش آن یک کاربر جدید را به سیستم DNT Identity اضافه کنید. سپس آنرا در گروه نقشی مشخص قرار دهید (منوی مدیریتی،گزینهی مدیریت نقشهای سیستم). سپس به این گروه دسترسی به تعدادی از آیتمهای پویا را بدهید (گزینهی مشاهده و تغییر لیست دسترسیهای پویا). سپس با این اکانت جدید به سیستم وارد شده و بررسی کنید که چه تعدادی از آیتمهای منوی «گزارشات نمونه» را میتوانید مشاهده کنید (تامین شدهی توسط ReportsMenu.cshtml_).
مدیریت اندازهی حجم کوکیهای ASP.NET Core Identity
همانطور که ملاحظه کردید، جهت بالابردن سرعت دسترسی به اطلاعات User Claims و Role Claims، تمام اطلاعات مرتبط با آنها، به کوکی کاربر وارد شدهی به سیستم، اضافه میشوند. همین مساله در یک سیستم بزرگ با تعداد صفحات بالا، سبب خواهد شد تا حجم کوکی کاربر از 5 کیلوبایت بیشتر شده و توسط مرورگرها مورد قبول واقع نشوند و عملا سیستم از کار خواهد افتاد.
برای مدیریت یک چنین مسالهای، امکان ذخیره سازی کوکیهای شخص در داخل بانک اطلاعاتی نیز پیش بینی شدهاست. زیر ساخت آنرا در مطلب «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» پیشتر در این سایت مطالعه کردید و در پروژهی DNT Identity بکارگرفته شدهاست.
اگر به کلاس IdentityServicesRegistry مراجعه کنید، یک چنین تنظیمی در آن قابل مشاهده است:
var ticketStore = provider.GetService<ITicketStore>(); identityOptionsCookies.ApplicationCookie.SessionStore = ticketStore; // To manage large identity cookies
الف) DistributedCacheTicketStore
ب) MemoryCacheTicketStore
اولی از همان زیرساخت «تنظیمات کش توزیع شدهی مبتنی بر SQL Server در ASP.NET Core» استفاده میکند و دومی از IMemoryCache توکار ASP.NET Core برای پیاده سازی مکان ذخیره سازی محتوای کوکیهای سیستم، بهره خواهد برد.
باید دقت داشت که اگر حالت دوم را انتخاب کنید، با شروع مجدد برنامه، تمام اطلاعات کوکیهای کاربران نیز حذف خواهند شد. بنابراین استفادهی از حالت ذخیره سازی آنها در بانک اطلاعاتی منطقیتر است.
نحوهی تنظیم سرویس ITicketStore را نیز در متد setTicketStore میتوانید مشاهده کنید و در آن، در صورت انتخاب حالت بانک اطلاعاتی، ابتدا تنظیمات کش توزیع شده، صورت گرفته و سپس کلاس DistributedCacheTicketStore به عنوان تامین کنندهی ITicketStore به سیستم تزریق وابستگیها معرفی میشود.
همین اندازه برای انتقال محتوای کوکیهای کاربران به سرور کافی است و از این پس تنها اطلاعاتی که به سمت کلاینت ارسال میشود، ID رمزنگاری شدهی این کوکی است، جهت بازیابی آن از بانک اطلاعاتی و استفادهی خودکار از آن در برنامه.
کدهای کامل این سری را در مخزن کد DNT Identity میتوانید ملاحظه کنید.
سؤال: چطور میشود برنامهای را که تنظیمات پروکسی ندارد، پروکسی خور کرد؟!
روشی که با سطح دسترسی معمولی و بدون نیاز به درایورهای خاص بررسی پکتهای TCP و UDP سیستم و همچنین توسط دات نت فریم ورک قابل استفاده باشد، استفاده از کتابخانهی معظم FiddlerCore است. برنامهی Fiddler توسط یکی از کارکنان سابق مایکروسافت و عضو پیشین تیم IE تهیه شدهاست. کار اصلی این برنامه، دیباگ درخواستهای HTTP/HTTPS، FTP و امثال آن است. هستهی اصلی آن نیز به صورت یک کتابخانهی مجزا به نام FiddlerCore در اختیار برنامه نویسهای دات نت است. این برنامه اخیرا توسط شرکت تلریک پشتیبانی و تملک شدهاست.
کتابخانهی FiddlerCore و برنامهی Fiddler را از اینجا میتوانید دریافت کنید. (اگر سایت آن باز نمیشود به این علت است که هاستینگ شرکت تلریک IPهای ایرانی را بسته است)
اسکلت اصلی یک برنامهی مبتنی بر FiddlerCore
using System; using System.Net; using System.Threading; using Fiddler; using System.Net.Security; namespace FiddlerTest { class Program { static void beforeRequest(Session oSession) { } static void Main(string[] args) { try { startFiddlerApplication(); Console.WriteLine("FiddlerCore started on port " + FiddlerApplication.oProxy.ListenPort); Console.WriteLine("Press any key to exit"); Console.ReadKey(); } finally { shutdownFiddlerApplication(); } } static void onLogString(object sender, LogEventArgs e) { Console.WriteLine("** LogString: " + e.LogString); } static void onNotification(object sender, NotificationEventArgs e) { Console.WriteLine("** NotifyUser: " + e.NotifyString); } static void onValidateServerCertificate(object sender, ValidateServerCertificateEventArgs e) { if (SslPolicyErrors.None == e.CertificatePolicyErrors) return; Console.WriteLine("invalid certificate: {0}", e.ServerCertificate.Subject); e.ValidityState = CertificateValidity.ForceValid; } static void shutdownFiddlerApplication() { FiddlerApplication.OnNotification -= onNotification; FiddlerApplication.Log.OnLogString -= onLogString; FiddlerApplication.BeforeRequest -= beforeRequest; FiddlerApplication.OnValidateServerCertificate -= onValidateServerCertificate; FiddlerApplication.oProxy.Detach(); FiddlerApplication.Shutdown(); Thread.Sleep(500); } private static void startFiddlerApplication() { FiddlerApplication.OnNotification += onNotification; FiddlerApplication.Log.OnLogString += onLogString; FiddlerApplication.BeforeRequest += beforeRequest; FiddlerApplication.OnValidateServerCertificate += onValidateServerCertificate; FiddlerApplication.Startup(5656, FiddlerCoreStartupFlags.RegisterAsSystemProxy | FiddlerCoreStartupFlags.MonitorAllConnections | FiddlerCoreStartupFlags.CaptureFTP); // proxy server on 5656 } } }
در متد FiddlerApplication.Startup روی پورتی مشخص، کار تنظیم پروکسی فیدلر انجام میشود. سپس مشخص میکنیم که چه مواردی را باید تحت نظر قرار دهد. با تنظیمات RegisterAsSystemProxy و MonitorAllConnections فیدلر قادر خواهد بود ترافیک وب اکثر برنامههای ویندوزی را مونیتور و دیباگ کند.
در متد shutdownFiddlerApplication نیز روالهای رخدادگردان، آزاد شده و پروکسی آن خاموش میشود.
هدایت درخواستهای وب کلیهی برنامهها به یک پروکسی مشخص
static void beforeRequest(Session oSession) { //send each request to the next proxy oSession["X-OverrideGateway"] = "socks=" + IPAddress.Loopback + ":" + 2002; //socks on 2002 }
هدایت درخواستهای تنها یک برنامهی خاص به یک پروکسی مشخص
در متد beforeRequest، متغیر oSession.LocalProcessID مشخص کنندهی مقدار PID پروسهای است که درخواست وب آن در حال بررسی است. برای بدست آوردن این PIDها در دات نت میتوان از متد Process.GetProcesses استفاده کرد. Id هر پروسه، همان LocalProcessID فیدلر است. بر این اساس میتوان تنها یک پروسهی مشخص را تحت نظر قرار داد و نه کل سیستم را.
کاربردها
- فرض کنید برنامهای تنظیمات پروکسی ندارد. با استفاده از روش فوق میتوان برای آن پروکسی تعریف کرد.
- فرض کنید برنامهای تنظیمات HTTP پروکسی دارد، اما پروکسی سرور شما از نوع ساکس است و نمیتوان از این پروکسی سرور در برنامهی مورد نظر استفاده کرد. X-OverrideGateway ذکر شده با هر دو نوع پروکسیهای HTTP و Socks کار میکند.
اگر علاقمند به مطالعهی اطلاعات بیشتری در مورد این کتابخانه هستید، کتاب 316 صفحهای Debugging with Fiddler نویسندهی اصلی آن، Eric Lawrence توصیه میشود.
معرفی برنامهی Process Proxifier
اگر اطلاعات فوق را کنار هم قرار دهیم و یک GUI نیز برای آن طراحی کنیم، به برنامهی Process Proxifier خواهیم رسید:
کار کردن با آن نیز بسیار سادهاست. در قسمت تنظیمات پیش فرض برنامه، آدرس IP و پورت پروکسی سرور خود را وارد کنید. نوع آنرا نیز مشخص نمائید که Socks است یا از نوع HTTP Proxy.
سپس در لیست پروسهها، مواردی را که لازم است از این پروکسی عبور کنند تیک بزنید. در اینجا میشود یا از تنظیمات پیش فرض استفاده کرد، یا میتوان به ازای هر پروسه، از یک پروکسی مجزا با تنظیماتی که ذکر میکنید، کمک گرفت. اگر صرفا یک پروسه را انتخاب کنید و اطلاعاتی را وارد ننمائید، از اطلاعات پروکسی پیش فرض استفاده خواهد شد.
دریافت سورس + باینری
ProcessProxifier_V1.0.rar
using(var client = new HttpClient()) { // do something with http client }
Unable to connect to the remote server System.Net.Sockets.SocketException: Only one usage of each socket address (protocol/network address/port) is normally permitted.
HttpClient خود را Dispose نکنید
کلاس HttpClient اینترفیس IDisposable را پیاده سازی میکند. بنابراین روش استفادهی اصولی آن باید به صورت ذیل و با پیاده سازی خودکار رهاسازی منابع مرتبط با آن باشد:
using (var client = new HttpClient()) { var result = await client.GetAsync("http://example.com/"); }
for (int i = 0; i < 10; i++) { using (var client = new HttpClient()) { var result = await client.GetAsync("http://example.com/"); Console.WriteLine(result.StatusCode); } }
TCP 192.168.1.6:13996 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13997 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13998 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:13999 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14000 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14001 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14002 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14003 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14004 93.184.216.34:http TIME_WAIT TCP 192.168.1.6:14005 93.184.216.34:http TIME_WAIT
بنابراین اگر برنامهی شما تعداد زیادی کاربر دارد و یا تعداد زیادی درخواست را به روش فوق ارسال میکند، سیستم عامل به حد اشباع ایجاد سوکتهای جدید خواهد رسید.
این مشکل نیز ارتباطی به طراحی این کلاس و یا زبان #C و حتی استفادهی از using نیز ندارد. این رفتار، رفتار معمول سیستم عامل، با سوکتهای ایجاد شدهاست. TIME_WAIT ایی را که در اینجا ملاحظه میکنید، به معنای بسته شدن اتصال از طرف برنامهی ما است؛ اما سیستم عامل هنوز منتظر نتیجهی نهایی، از طرف دیگر اتصال است که آیا قرار است بستهی TCP ایی را دریافت کند یا خیر و یا شاید در بین راه تاخیری وجود داشتهاست. برای نمونه ویندوز به مدت 240 ثانیه یک اتصال را در این حالت حفظ خواهد کرد، که مقدار آن نیز در اینجا تنظیم میشود:
[HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\Tcpip\Parameters\TcpTimedWaitDelay]
بنابراین روش توصیه شدهی کار با HttpClient، داشتن یک وهلهی سراسری از آن در برنامه و عدم Dispose آن است. HttpClient نیز thread-safe طراحی شدهاست و دسترسی به یک شیء سراسری آن در برنامههای چند ریسمانی مشکلی را ایجاد نمیکند. همچنین Dispose آن نیز غیرضروری است و پس از پایان برنامه به صورت خودکار توسط سیستم عامل انجام خواهد شد.
تمام اجزای HttpClient به صورت Thread-safe طراحی نشدهاند
تا اینجا به این نتیجه رسیدیم که روش صحیح کار کردن با HttpClient، نیاز به داشتن یک وهلهی Singleton از آنرا در سراسر برنامه دارد و Dispose صریح آن، بجز اشباع سوکتهای سیستم عامل و ناپایدار کردن تمام برنامههایی که از آن سرویس میگیرند، حاصلی را به همراه نخواهد داشت. در این بین مطابق مستندات HttpClient، استفادهی از متدهای ذیل این کلاس thread-safe هستند:
CancelPendingRequests DeleteAsync GetAsync GetByteArrayAsync GetStreamAsync GetStringAsync PostAsync PutAsync SendAsync
BaseAddress DefaultRequestHeaders MaxResponseContentBufferSize Timeout
استفادهی سراسری و مجدد از HttpClient، تغییرات DNS را متوجه نمیشود
با طراحی یک کلاس مدیریت کنندهی سراسری HttpClient با طول عمر Singelton، به یک مشکل دیگر نیز برخواهیم خورد: چون در اینجا از اتصالات، استفادهی مجدد میشوند، دیگر تغییرات DNS را لحاظ نخواهند کرد.
برای حل این مشکل، در زمان ایجاد یک HttpClient سراسری، به ازای یک BaseAddress مشخص، باید از ServicePointManager کوئری گرفته و زمان اجارهی اتصال آنرا دقیقا مشخص کنیم:
var sp = ServicePointManager.FindServicePoint(new Uri("http://thisisasample.com")); sp.ConnectionLeaseTimeout = 60*1000; //In milliseconds
طراحی یک کلاس، برای مدیریت سراسری وهلههای HttpClient
تا اینجا به صورت خلاصه به نکات ذیل رسیدیم:
- HttpClient باید به صورت یک وهلهی سراسری Singleton مورد استفاده قرار گیرد. هر وهله سازی مجدد آن 35ms زمان میبرد.
- Dispose یک HttpClient غیرضروری است.
- HttpClient تقریبا thread safe طراحی شدهاست؛ اما تعدادی از خواص آن مانند BaseAddress اینگونه نیستند.
- برای رفع مشکل اتصالات چسبنده (اتصالاتی که هیچگاه پایان نمییابند)، نیاز است timeout آنرا تنظیم کرد.
بنابراین بهتر است این نکات را در یک کلاس به صورت ذیل کپسوله کنیم:
using System; using System.Collections.Generic; using System.Net.Http; namespace HttpClientTips { public interface IHttpClientFactory : IDisposable { HttpClient GetOrCreate( Uri baseAddress, IDictionary<string, string> defaultRequestHeaders = null, TimeSpan? timeout = null, long? maxResponseContentBufferSize = null, HttpMessageHandler handler = null); } }
using System; using System.Collections.Concurrent; using System.Collections.Generic; using System.Net; using System.Net.Http; using System.Threading; namespace HttpClientTips { /// <summary> /// Lifetime of this class should be set to `Singleton`. /// </summary> public class HttpClientFactory : IHttpClientFactory { // 'GetOrAdd' call on the dictionary is not thread safe and we might end up creating the HttpClient more than // once. To prevent this Lazy<> is used. In the worst case multiple Lazy<> objects are created for multiple // threads but only one of the objects succeeds in creating the HttpClient. private readonly ConcurrentDictionary<Uri, Lazy<HttpClient>> _httpClients = new ConcurrentDictionary<Uri, Lazy<HttpClient>>(); private const int ConnectionLeaseTimeout = 60 * 1000; // 1 minute public HttpClientFactory() { // Default is 2 minutes: https://msdn.microsoft.com/en-us/library/system.net.servicepointmanager.dnsrefreshtimeout(v=vs.110).aspx ServicePointManager.DnsRefreshTimeout = (int)TimeSpan.FromMinutes(1).TotalMilliseconds; // Increases the concurrent outbound connections ServicePointManager.DefaultConnectionLimit = 1024; } public HttpClient GetOrCreate( Uri baseAddress, IDictionary<string, string> defaultRequestHeaders = null, TimeSpan? timeout = null, long? maxResponseContentBufferSize = null, HttpMessageHandler handler = null) { return _httpClients.GetOrAdd(baseAddress, uri => new Lazy<HttpClient>(() => { // Reusing a single HttpClient instance across a multi-threaded application means // you can't change the values of the stateful properties (which are not thread safe), // like BaseAddress, DefaultRequestHeaders, MaxResponseContentBufferSize and Timeout. // So you can only use them if they are constant across your application and need their own instance if being varied. var client = handler == null ? new HttpClient { BaseAddress = baseAddress } : new HttpClient(handler, disposeHandler: false) { BaseAddress = baseAddress }; setRequestTimeout(timeout, client); setMaxResponseBufferSize(maxResponseContentBufferSize, client); setDefaultHeaders(defaultRequestHeaders, client); setConnectionLeaseTimeout(baseAddress, client); return client; }, LazyThreadSafetyMode.ExecutionAndPublication)).Value; } public void Dispose() { foreach (var httpClient in _httpClients.Values) { httpClient.Value.Dispose(); } } private static void setConnectionLeaseTimeout(Uri baseAddress, HttpClient client) { // This ensures connections are used efficiently but not indefinitely. client.DefaultRequestHeaders.ConnectionClose = false; // keeps the connection open -> more efficient use of the client ServicePointManager.FindServicePoint(baseAddress).ConnectionLeaseTimeout = ConnectionLeaseTimeout; // ensures connections are not used indefinitely. } private static void setDefaultHeaders(IDictionary<string, string> defaultRequestHeaders, HttpClient client) { if (defaultRequestHeaders == null) { return; } foreach (var item in defaultRequestHeaders) { client.DefaultRequestHeaders.Add(item.Key, item.Value); } } private static void setMaxResponseBufferSize(long? maxResponseContentBufferSize, HttpClient client) { if (maxResponseContentBufferSize.HasValue) { client.MaxResponseContentBufferSize = maxResponseContentBufferSize.Value; } } private static void setRequestTimeout(TimeSpan? timeout, HttpClient client) { if (timeout.HasValue) { client.Timeout = timeout.Value; } } } }
پس از تدارک این کلاس، نحوهی معرفی آن به سیستم باید به صورت Singleton باشد. برای مثال اگر از ASP.NET Core استفاده میکنید، آنرا به صورت ذیل ثبت کنید:
namespace HttpClientTips.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddSingleton<IHttpClientFactory, HttpClientFactory>(); services.AddMvc(); }
اکنون، یک نمونه، نحوهی استفادهی از اینترفیس IHttpClientFactory تزریقی به صورت ذیل میباشد:
namespace HttpClientTips.Web.Controllers { public class HomeController : Controller { private readonly IHttpClientFactory _httpClientFactory; public HomeController(IHttpClientFactory httpClientFactory) { _httpClientFactory = httpClientFactory; } public async Task<IActionResult> Index() { var host = new Uri("http://localhost:5000"); var httpClient = _httpClientFactory.GetOrCreate(host); var responseMessage = await httpClient.GetAsync("home/about").ConfigureAwait(false); var responseContent = await responseMessage.Content.ReadAsStringAsync().ConfigureAwait(false); return Content(responseContent); }
برای مطالعهی بیشتر
You're using HttpClient wrong and it is destabilizing your software
Disposable, Finalizers, and HttpClient
Using HttpClient as it was intended (because you’re not)
Singleton HttpClient? Beware of this serious behaviour and how to fix it
Beware of the .NET HttpClient
Effectively Using HttpClient
بررسی یک مثال: تهیه یک برنامهی Blazor 8x برای نمایش لیست محصولات، به همراه جزئیات آنها
به لطف وجود SSR در Blazor 8x، میتوان HTML نهایی کامپوننتها و صفحات Blazor را همانند صفحات MVC و یا Razor pages، در سمت سرور تهیه و بازگشت داد. این خروجی در نهایت یک static HTML بیشتر نیست و گاهی از اوقات ما به بیش از یک خروجی ساده HTML ای نیاز داریم.
در این مثال که بر اساس قالب dotnet new blazor --interactivity Server تهیه میشود، قصد داریم موارد زیر را پیاده سازی کنیم:
- صفحهای که یک لیست محصولات فرضی را نمایش میدهد : بر اساس SSR
- صفحهای که جزئیات یک محصول را نمایش میدهد: بر اساس SSR
- دکمهای در ذیل قسمت نمایش جزئیات یک محصول، برای دریافت و نمایش لیست محصولات مشابه و مرتبط: بر اساس Blazor server islands
یعنی تا جائیکه ممکن است قصد نداریم تمام صفحات و تمام قسمتهای برنامه را با فعالسازی سراسری حالت تعاملی Blazor server که در قسمتهای قبل در مورد آن توضیح داده شد، پیاده سازی کنیم. میخواهیم فقط قسمت کوچکی از این سناریو را که واقعا نیاز به یک چنین قابلیتی را دارد، توسط یک جزیرهی تعاملی Blazor server واقع شدهی در قسمتی از یک صفحهی استاتیک SSR، مدیریت کنیم.
مدل برنامه: رکوردی برای ذخیره سازی اطلاعات یک محصول
namespace BlazorDemoApp.Models; public record Product { public int Id { get; set; } public required string Title { get; set; } public required string Description { get; set; } public decimal Price { get; set; } public List<int> Related { get; set; } = new(); }
سرویس برنامه: سرویسی برای بازگشت لیست محصولات
چون Blazor Server و SSR هر دو بر روی سرور اجرا میشوند، از لحاظ دسترسی به اطلاعات و کار با سرویسها، هماهنگی کاملی وجود داشته و میتوان کدهای یکسان و یکدستی را در اینجا بکار گرفت.
در ادامه کدهای کامل سرویس Services\ProductStore.cs را مشاهده میکنید:
using BlazorDemoApp.Models; namespace BlazorDemoApp.Services; public interface IProductStore { IList<Product> GetAllProducts(); Product GetProduct(int id); IList<Product> GetRelatedProducts(int productId); } public class ProductStore : IProductStore { private static readonly List<Product> ProductsDataSource = new() { new Product { Id = 1, Title = "Smart speaker", Price = 22m, Description = "This smart speaker delivers excellent sound quality and comes with built-in voice control, offering an impressive music listening experience.", Related = new List<int> { 2, 3 }, }, new Product { Id = 2, Title = "Regular speaker", Price = 89m, Description = "Enjoy room-filling sound with this regular speaker. With its slick design, it perfectly fits into any room in your house.", Related = new List<int> { 1, 3 }, }, new Product { Id = 3, Title = "Speaker cable", Price = 12m, Description = "This high-quality speaker cable ensures a reliable and clear audio connection for your sound system.", }, }; public IList<Product> GetAllProducts() => ProductsDataSource; public Product GetProduct(int id) => ProductsDataSource.Single(p => p.Id == id); public IList<Product> GetRelatedProducts(int productId) { var product = ProductsDataSource.Single(x => x.Id == productId); return ProductsDataSource.Where(p => product.Related.Contains(p.Id)).ToList(); } }
این سرویس را باید در فایل Program.cs برنامه به صورت زیر معرفی کرد تا در فایلهای razor برنامهی جاری قابل دسترسی شود:
builder.Services.AddScoped<IProductStore, ProductStore>();
تکمیل صفحهی نمایش لیست محصولات
قصد داریم زمانیکه کاربر برای مثال به آدرس فرضی http://localhost:5136/products مراجعه کرد، با تصویر لیستی از محصولات مواجه شود:
کدهای این صفحه را که در فایل Components\Pages\Store\ProductsList.razor قرار میگیرند، در ادامه مشاهده میکنید:
@page "/Products" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] <h3>Products</h3> @if (_products == null) { <p>Loading...</p> } else { @foreach (var item in _products) { <a href="/ProductDetails/@item.Id"> <div> <div> <h5>@item.Title</h5> </div> <div> <h5>@item.Price.ToString("c")</h5> </div> </div> </a> } } @code { private IList<Product>? _products; protected override Task OnInitializedAsync() => GetProductsAsync(); private async Task GetProductsAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _products = Store.GetAllProducts(); } }
- جهت دسترسی به سرویس لیست محصولات، ابتدا سرویس IProductStore به این صفحه تزریق شدهاست.
- سپس در روال رویدادگردان آغازین OnInitializedAsync، کار دریافت اطلاعات و انتساب آن به لیستی، صورت گرفتهاست.
- در این متد جهت شبیه سازی یک عملیات async از یک Task.Delay استفاده شدهاست.
- چون این صفحه، یک صفحهی SSR عادی است، بدون تعریف ویژگی StreamRendering در آن، پس از اجرای برنامه، هیچگاه قسمت loading که در حالت products == null_ قرار است ظاهر شود، نمایش داده نمیشود؛ چون در این حالت (حذف نوع رندر)، صفحهی نهایی که به کاربر ارائه خواهد شد، یک صفحهی استاتیک کاملا رندر شدهی در سمت سرور است و کاربر باید تا زمان پایان این رندر در سمت سرور، منتظر بماند و سپس صفحهی نهایی را دریافت و مشاهده کند. در حالت Streaming rendering، ابتدا میتوان یک قالب HTML ای را بازگشت داد و سپس مابقی محتوای آنرا به محض آماده شدن در طی چند مرحله بازگشت داد.
- لینکهای نمایش داده شدهی در اینجا، به صفحهی ProductDetails اشاره میکنند که در آن، جزئیات محصول انتخابی نمایش داده میشوند.
تکمیل صفحهی نمایش جزئیات یک محصول
در صفحهی کامپوننت Components\Pages\Store\ProductDetails.razor، کار نمایش جزئیات محصول انتخابی صورت میگیرد:
@page "/ProductDetails/{ProductId}" @using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store @attribute [StreamRendering] @if (_product == null) { <p>Loading...</p> } else { <div> <div> <h5> @_product.Title (@_product.Price.ToString("C")) </h5> <p> @_product.Description </p> </div> @if (_product.Related.Count > 0) { <div> <RelatedProducts ProductId="Convert.ToInt32(ProductId)" /> </div> } </div> <NavLink href="/Products">Back</NavLink> } @code { private Product? _product; [Parameter] public string? ProductId { get; set; } protected override Task OnInitializedAsync() => GetProductAsync(); private async Task GetProductAsync() { await Task.Delay(1000); // Simulates asynchronous loading to demonstrate streaming rendering _product = Store.GetProduct(Convert.ToInt32(ProductId)); } }
- باتوجه به نحوهی تعریف مسیریابی این صفحه، پارامتر ProductId از طریق آدرسی مانند http://localhost:5136/ProductDetails/1 دریافت میشود.
- سپس این ProductId را در روال رخدادگردان OnInitializedAsync، برای یافتن جزئیات محصول انتخابی از سرویس تزریقی IProductStore، بکار میگیریم.
- در اینجا نیز از Task.Delay برای شبیه سازی یک عملیات طولانی async مانند دریافت اطلاعات از یک بانک اطلاعاتی، کمک گرفته شدهاست.
- همچنین برای نمایش قسمت loading صفحه در حالت SSR، بازهم از StreamRendering استفاده کردهایم.
- اگر دقت کرده باشید، ذیل تصویر اطلاعات محصول، دکمهای نیز جهت بارگذاری اطلاعات محصولات مشابه، قرار دارد که ProductId محصول انتخابی را دریافت میکند:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" />
تکمیل کامپوننت نمایش لیست محصولات مشابه و مرتبط
در فایل Components\Pages\Store\RelatedProducts.razor، کار نمایش یک دکمه و سپس نمایش لیستی از محصولات مشابه، صورت میگیرد:
@using BlazorDemoApp.Models @using BlazorDemoApp.Services @inject IProductStore Store <button @onclick="LoadRelatedProducts">Related products</button> @if (_loadRelatedProducts) { @if (_relatedProducts == null) { <p>Loading...</p> } else { <div> @foreach (var item in _relatedProducts) { <a href="/ProductDetails/@item.Id"> <div> <h5>@item.Title (@item.Price.ToString("C"))</h5> </div> </a> } </div> } } @code{ private IList<Product>? _relatedProducts; private bool _loadRelatedProducts; [Parameter] public int ProductId { get; set; } private async Task LoadRelatedProducts() { _loadRelatedProducts = true; await Task.Delay(1000); // Simulates asynchronous loading to demonstrate InteractiveServer mode _relatedProducts = Store.GetRelatedProducts(ProductId); } }
تعاملی کردن کامپوننت نمایش لیست محصولات مشابه
مشکل! اگر در این حالت برنامه را اجرا کرده و بر روی دکمهی related products کلیک کنیم، هیچ اتفاقی رخ نمیدهد! یعنی روال رویدادگران LoadRelatedProducts اصلا اجرا نمیشود. علت اینجا است که صفحات SSR، در نهایت یک static HTML بیشتر نیستند و فاقد قابلیتهای تعاملی، مانند واکنش نشان دادن به کلیک بر روی یک دکمه هستند.
محدودیتی که به همراه صفحات SSR وجود دارد این است: این نوع کامپوننتها و صفحات فقط یکبار رندر میشوند و نه بیشتر. بله میتوان بر روی آنها دهها دکمه، نوارهای لغزان، دراپداون و غیره را قرار داد، اما ... نمیتوان هیچگونه تعاملی را با آنها داشت. کامپوننت نهایی رندر شده و نمایش داده شده، دیگر در هیچجائی اجرا نمیشود. در این حالت است که میتوان تصمیم گرفت که نیاز است قسمتی از این صفحه، تعاملی شود.
به همین جهت باید نحوهی رندر کامپوننت RelatedProducts را به صورت یک جزیرهی تعاملی Blazor server درآورد تا رویداد منتسب به دکمهی related products موجود در آن، پردازش شود. بنابراین به صفحهی ProductDetails.razor مراجعه کرده و rendermode@ این کامپوننت را به صورت زیر به حالت InteractiveServer تغییر میدهیم:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@InteractiveServer"/>
نحوهی پردازش پشت صحنهی این نوع صفحات هم جالب است. برای اینکار به برگهی network مخصوص developer tools مرورگر مراجعه کرده و مراحل رسیدن به صفحهی نمایش جزئیات محصول را طی میکنیم:
- اگر دقت کنید، جابجایی بین صفحات، با استفاده از fetch انجام شده؛ یعنی با اینکه این صفحات در اصل static HTML خالص هستند، اما ... کار full reload صفحه مانند ASP.NET Web forms قدیمی انجام نمیشود (و یا حتی برنامههای MVC و Razor pages) و نمایش صفحات، Ajax ای است و با fetch استاندارد آن صورت میگیرد تا هنوز هم حس و حال SPA بودن برنامه حفظ شود. همچنین اطلاعات DOM کل صفحه را هم بهروز رسانی نمیکند؛ فقط موارد تغییر یافته در اینجا به روز رسانی خواهند شد.
این موارد توسط فایل blazor.web.js درج شدهی در کامپوننت آغازین App.razor، به صورت خودکار مدیریت میشوند:
<script src="_framework/blazor.web.js"></script>
به علاوه در این حالت ایجکسی fetch، کار دریافت مجدد فایلهای استاتیک مرتبط یک صفحه، مانند فایلهای js.، css.، تصاویر و غیره، مجددا انجام نمیشود که این مورد خود مزیتی است نسبت به حالت متداول برنامههای ASP.NET Core MVC و یا Razor pages. در حالت Blazor 8x SSR، فقط یک partial update از نوع Ajax ای انجام میشود.
به این قابلیت، enhanced navigation هم گفته میشود. برای مثال زمانیکه یک فرم SSR را در Blazor 8x به سمت سرور ارسال میکنیم، موقعیت scroll به صورت خودکار ذخیره و بازیابی میشود تا کاربر با یک full post back مواجه نشده و موقعیت جاری خود را در صفحه از دست ندهد (چنین ایدهای، یک زمانی در برنامههای ASP.NET Web forms هم برقرار بود و هست! به نظر مایکروسافت هنوز دلتنگ طراحی قدیمی ASP.NET Web forms است!).
- همچنین به محض نمایش صفحهی جزئیات محصول، پس از پایان کار نمایش آن، یک اتصال وبسوکت هم برقرار شده که مرتبط با جزیرهی تعاملی Blazor server تعریف شده، یا همان کامپوننت RelatedProducts است.
- یک disconnect را هم در اینجا مشاهده میکنید. اگر به یک صفحهی تعاملی مراجعه کنیم، همانطور که مشخص است، یک اتصال SignalR برقرار میشود (که به آن در اینجا circuit هم میگویند). اما اگر از این صفحه به سمت یک صفحهی SSR حرکت کنیم، پس از نمایش آن صفحه، اتصال SignalR قبلی که دیگر نیازی به آن نیست، بسته خواهد شد تا منابع سمت سرور، رها شوند.
در حین disconnect، شماره ID اتصال SignalR ای که دیگر به آن نیازی نیست، به برنامه ارسال میشود تا به صورت خودکار در سمت سرور بسته شود. تمام این موارد توسط blazor.web.js فریمورک، مدیریت میشوند.
در این تصویر ابتدا به آدرس http://localhost:5136/ProductDetails/1 مراجعه کردهایم که سبب برقراری اتصال یک وبسوکت شدهاست. سپس با کلیک بر روی دکمهی back، به صفحهی SSR مشاهدهی لیست محصولات برگشتهایم. در این حالت، دستور قطع اتصال SignalR قبلی صادر شدهاست.
نحوهی مدیریت Pre-rendering در جزایر تعاملی Blazor 8x
به صورت پیشفرض زمانیکه از حالت رندر InteractiveServer استفاده میکنیم، قابلیت pre-rendering آن نیز فعال است. یعنی ابتدا حداقل قالب و قسمتهای ثابت کامپوننت، در سمت سرور پردازش و رندر شده و سپس به سمت کلاینت ارسال میشوند. در این حالت کاربر، تجربهی کاربری روانتری را شاهد خواهد بود؛ چون برای مدتی نباید منتظر آماده شدن کل UI مرتبط باشد و حداقل، قسمتهایی از صفحه که تعاملی نیستند، قابل دسترسی و مشاهده هستند.
اگر به هر دلیلی نیاز به غیرفعال کردن این قابلیت را دارید، باید به صورت زیر عمل کرد:
<RelatedProducts ProductId="Convert.ToInt32(ProductId)" @rendermode="@(new InteractiveServerRenderMode(false))"/>
نحوهی تعریف خواص استاتیک InteractiveServer بکار گرفته شده و یا کلاس InteractiveServerRenderMode را در ادامه مشاهده میکنید. جهت سهولت تعریف این موارد، سطر زیر که یک using static است، به فایل Imports.razor_ اضافه شدهاست:
@using static Microsoft.AspNetCore.Components.Web.RenderMode
public static class RenderMode { public static InteractiveServerRenderMode InteractiveServer { get; } = new InteractiveServerRenderMode(); public static InteractiveWebAssemblyRenderMode InteractiveWebAssembly { get; } = new InteractiveWebAssemblyRenderMode(); public static InteractiveAutoRenderMode InteractiveAuto { get; } = new InteractiveAutoRenderMode(); } public class InteractiveServerRenderMode : IComponentRenderMode { public InteractiveServerRenderMode() : this(true) { } public InteractiveServerRenderMode(bool prerender) => this.Prerender = prerender; public bool Prerender { get; } }
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: Blazor8x-Server-Normal.zip
public AddUserStatus Add(User user) { if (ExistsByEmail(user.Email)) return AddUserStatus.EmailExist; if (ExistsByUserName(user.UserName)) return AddUserStatus.UserNameExist; _users.Add(user); return AddUserStatus.AddingUserSuccessfully; }
using System.Linq; using System.Web.Mvc; using Iris.Datalayer.Context; namespace Iris.Web.Controllers { public class MigrationController : Controller { public ActionResult RemoveDuplicateUsers() { var db = new IrisDbContext(); var lstDuplicateUserGroup = db.Users .GroupBy(u => u.UserName) .Where(g => g.Count() > 1) .ToList(); foreach (var duplicateUserGroup in lstDuplicateUserGroup) { foreach (var user in duplicateUserGroup.Skip(1).Where(user => user.UserMetaData != null)) { db.UserMetaDatas.Remove(user.UserMetaData); } db.Users.RemoveRange(duplicateUserGroup.Skip(1)); } db.SaveChanges(); return new EmptyResult(); } } }
مقایسه ساختار جداول دیتابیس کاربران IRIS با ASP.NET Identity
ساختار جداول ASP.NET Identity به شکل زیر است:
ساختار جداول سیستم کنونی هم بدین شکل است:
همان طور که مشخص است در هر دو سیستم، بین ساختار جداول و رابطهی بین آنها شباهتها و تفاوت هایی وجود دارد. سیستم Identity دو جدول بیشتر از IRIS دارد و برای جداولی که در سیستم کنونی وجود ندارند نیاز به انجام کاری نیست و به هنگام پیاده سازی Identity، این جداول به صورت خودکار به دیتابیس اضافه خواهند شد.
دو جدول مشترک در این دو سیستم، جداول Users و Roles هستندکه نحوهی ارتباطشان با یکدیگر متفاوت است. در Iris بین User و Role رابطهی یک به چند وجود دارد ولی در Identity، رابطهی بین این دو جدول چند به چند است و جدول واسط بین آنها نیز UserRoles نام دارد.
از آن جایی که من قصد دارم در سیستم جدید هم رابطهی بین کاربر و نقش چند به چند باشد، به پیش فرضهای Identity کاری ندارم. به رابطهی کنونی یک به چند کاربر و نقشش نیز دست نمیگذارم تا در انتها با یک کوئری از دیتابیس، اطلاعات نقشهای کاربران را به جدول جدیدش منتقل کنم.
جدولی که در هر دو سیستم مشترک است و هستهی آنها را تشکیل میدهد، جدول Users است. اگر دقت کنید میبینید که این جدول در هر دو سیستم، دارای یک سری فیلد مشترک است که دقیقا هم نام هستند مثل Id، UserName و Email؛ پس این فیلدها از نظر کاربرد در هر دو سیستم یکسان هستند و مشکلی ایجاد نمیکنند.
یک سری فیلد هم در جدول User در سیستم IRIS هست که در Identity نیست و بلعکس. با این فیلدها نیز کاری نداریم چون در هر دو سیستم کار مخصوص به خود را انجام میدهند و تداخلی در کار یکدیگر ایجاد نمیکنند.
اما فیلدی که برای ذخیره سازی پسورد در هر دو سیستم استفاده میشود دارای نامهای متفاوتی است. در Iris این فیلد Password نام دارد و در Identity نامش PasswordHash است.
برای اینکه در سیستم کنونی، نام فیلد Password جدول User را به PasswordHash تغییر دهیم قدمهای زیر را بر میداریم:
وارد پروژهی DomainClasses شده و کلاس User را باز کنید. سپس نام خاصیت Password را به PasswordHash تغییر دهید. پس از این تغییر بلافاصله یک گزینه زیر آن نمایان میشود که میخواهد در تمام جاهایی که از این نام استفاده شده است را به نام جدید تغییر دهد؛ آن را انتخاب کرده تا همه جا Password به PasswordHash تغییر کند.
برای این که این تغییر نام بر روی دیتابیس نیز اعمال شود باید از Migration استفاده کرد. در اینجا من از مهاجرت دستی که بر اساس کد هست استفاده میکنم تا هم بتوانم کدهای مهاجرت را پیش از اعمال بررسی و هم تاریخچهای از تغییرات را ثبت کنم.
برای این کار، Package Manager Console را باز کرده و از نوار بالایی آن، پروژه پیش فرض را بر روی DataLayer قرار دهید. سپس در کنسول، دستور زیر را وارد کنید:
Add-Migration Rename_PasswordToPasswordHash_User
اگر وارد پوشه Migrations پروژه DataLayer خود شوید، باید کلاسی با نامی شبیه به 201510090808056_Rename_PasswordToPasswordHash_User ببینید. اگر آن را باز کنید کدهای زیر را خواهید دید:
public partial class Rename_PasswordToPasswordHash_User : DbMigration { public override void Up() { AddColumn("dbo.Users", "PasswordHash", c => c.String(nullable: false, maxLength: 200)); DropColumn("dbo.Users", "Password"); } public override void Down() { AddColumn("dbo.Users", "Password", c => c.String(nullable: false, maxLength: 200)); DropColumn("dbo.Users", "PasswordHash"); } }
بدیهی هست که این کدها عمل حذف ستون Password را انجام میدهند که سبب از دست رفتن اطلاعات میشود. کدهای فوق را به شکل زیر ویرایش کنید تا تنها سبب تغییر نام ستون Password به PasswordHash شود.
public partial class Rename_PasswordToPasswordHash_User : DbMigration { public override void Up() { RenameColumn("dbo.Users", "Password", "PasswordHash"); } public override void Down() { RenameColumn("dbo.Users", "PasswordHash", "Password"); } }
سپس باز در کنسول دستور Update-Database را وارد کنید تا تغییرات بر روی دیتابیس اعمال شود.
دلیل اینکه این قسمت را مفصل بیان کردم این بود که میخواستم در مهاجرت از سیستم اعتبارسنجی خودتان به ASP.NET Identity دید بهتری داشته باشید.
تا به این جای کار فقط پایگاه داده سیستم کنونی را برای مهاجرت آماده کردیم و هنوز ASP.NET Identity را وارد پروژه نکردیم. در بخشهای بعدی Identity را نصب کرده و تغییرات لازم را هم انجام میدهیم.