ASP.NET MVC به همراه HtmlHelper توکاری جهت نمایش یک ChekBoxList نیست؛ اما سیستم Model binder آن، این نوع کنترلها را به خوبی پشتیبانی میکند. برای مثال، یک پروژه جدید خالی ASP.NET MVC را آغاز کنید. سپس یک کنترلر Home جدید را نیز به آن اضافه کنید. در ادامه، برای متد Index آن، یک View خالی را ایجاد نمائید. سپس محتوای این View را به نحو زیر تغییر دهید:
@{
ViewBag.Title = "Index";
}
<h2>
Index</h2>
@using (Html.BeginForm())
{
<input type='checkbox' name='Result' value='value1' />
<input type='checkbox' name='Result' value='value2' />
<input type='checkbox' name='Result' value='value3' />
<input type="submit" value="submit" />
}
و کنترلر Home را نیز مطابق کدهای زیر ویرایش کنید:
using System.Web.Mvc;
namespace MvcApplication21.Controllers
{
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
return View();
}
[HttpPost]
public ActionResult Index(string[] result)
{
return View();
}
}
}
یک breakpoint را در تابع Index دوم که آرایهای را دریافت میکند، قرار دهید. سپس برنامه را اجرا کرده، تعدادی از checkboxها را انتخاب و فرم نمایش داده شده را به سرور ارسال کنید:
بله. همانطور که ملاحظه میکنید، تمام عناصر ارسالی انتخاب شده که دارای نامی مشابه بودهاند، به یک آرایه قابل بایند هستند و سیستم model binder میداند که چگونه باید این اطلاعات را دریافت و پردازش کند.
از این مقدمه میتوان به عنوان پایه و اساس نوشتن یک HtmlHelper سفارشی CheckBoxList استفاده کرد.
برای این منظور یک پوشه جدید را به نام app_code، به ریشه پروژه اضافه نمائید. سپس یک فایل خالی را به نام Helpers.cshtml نیز به آن اضافه کنید. محتوای این فایل را به نحو زیر تغییر دهید:
@helper CheckBoxList(string name, List<System.Web.Mvc.SelectListItem> items)
{
<div class="checkboxList">
@foreach (var item in items)
{
@item.Text
<input type="checkbox" name="@name"
value="@item.Value"
@if (item.Selected) { <text>checked="checked"</text> }
/>
< br />
}
</div>
}
و برای استفاده از آن، کنترلر Home را مطابق کدهای زیر ویرایش کنید:
using System.Collections.Generic;
using System.Web.Mvc;
namespace MvcApplication21.Controllers
{
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
ViewBag.Tags = new List<SelectListItem>
{
new SelectListItem { Text = "Item1", Value = "Val1", Selected = false },
new SelectListItem { Text = "Item2", Value = "Val2", Selected = false },
new SelectListItem { Text = "Item3", Value = "Val3", Selected = true }
};
return View();
}
[HttpPost]
public ActionResult GetTags(string[] tags)
{
return View();
}
[HttpPost]
public ActionResult Index(string[] result)
{
return View();
}
}
}
و در این حالت View برنامه به شکل زیر درخواهد آمد:
@{
ViewBag.Title = "Index";
}
<h2>
Index</h2>
@using (Html.BeginForm())
{
<input type='checkbox' name='Result' value='value1' />
<input type='checkbox' name='Result' value='value2' />
<input type='checkbox' name='Result' value='value3' />
<input type="submit" value="submit" />
}
@using (Html.BeginForm(actionName: "GetTags", controllerName: "Home"))
{
@Helpers.CheckBoxList("Tags", (List<SelectListItem>)ViewBag.Tags)
<input type="submit" value="submit" />
}
با توجه به اینکه کدهای Razor قرار گرفته در پوشه خاص app_code در ریشه سایت، به صورت خودکار در حین اجرای برنامه کامپایل میشوند، متد Helpers.CheckBoxList در تمام Viewهای برنامه در دسترس خواهد بود. در این متد، یک نام و لیستی از SelectListItemها دریافت میگردد. سپس به صورت خودکار یک CheckboxList را تولید خواهد کرد. برای دریافت مقادیر ارسالی آن به سرور هم باید مطابق متد GetTags تعریف شده در کنترلر Home عمل کرد. در اینجا Value عناصر انتخابی به صورت آرایهای از رشتهها در دسترس خواهد بود.
روشی جامعتر
در آدرس زیر میتوانید یک HtmlHelper بسیار جامع را جهت تولید CheckBoxList در ASP.NET MVC بیابید. در همان صفحه روش استفاده از آن، به همراه چندین مثال ارائه شده است:
https://github.com/devnoob/MVC3-Html.CheckBoxList-custom-extension
CORS چیست؟
CORS و یا cross origin resource sharing، یک مکانیزم امنیتی است که در تمام مرورگرهای جدید جهت جلوگیری از ارسال اطلاعات یک وب سایت، به وب سایتی دیگر، درنظر گرفته شدهاست. درخواست دسترسی به یک منبع (Resource) مانند تصویر، داده و غیره، خارج از سرآغاز آن، یک درخواست cross-origin نامیده میشود. برای مدیریت یک چنین درخواستهایی، استانداردی به نام CORS طراحی شدهاست. میتوان به آن مانند نگهبانی یک ساختمان نگاه کرد که تا مجوز خاصی به آنها ارائه نشود، امکان دسترسی به منابع ساختمان را صادر نخواهند کرد.
Origin چیست؟
سرآغاز یک درخواست از سه قسمت تشکیل میشود:
- Protocol/Scheme مانند HTTP/HTTPS
- Host مانند نام دومین
- شماره پورت مانند 443 برای پروتکل HTTPS یا پورت 80 برای HTTP که عموما هر دو مورد به علت پیشفرض بودن، ذکر نمیشوند
بنابراین URLای مانند https://www.dntips.ir یک Origin را مشخص میکند. در اینجا به تمام منابعی که از این سرآغاز شروع میشوند و سه قسمت یاد شدهی آنها یکی است، same-origin گفته میشود.
در این حالت اگر منابعی به URLهایی مانند https://www.dntips.ir (پروتکل متفاوت) و یا https://github.com (با host متفاوت) اشاره کنند، به آنها Different-Origin گفته خواهد شد.
سیاست امنیتی Same-Origin چیست؟
سیاست امنیتی Same-Origin که به صورت توکار در تمام مرورگرهای امروزی تعبیه شدهاست، مانع تعامل سرآغازهایی متفاوت، جهت جلوگیری از حملات امنیتی مانند Cross-Site Request Forgery یا CSRF میشود. این سیاست امنیتی بسیار محدود کنندهاست و برای مثال مانع این میشود که بتوان توسط کدهای JavaScript ای، درخواستی را به سرآغاز دیگری ارسال کرد؛ حتی اگر بدانیم درخواست رسیده از منبعی مورد اطمینان صادر شدهاست. برای مثال اگر سایت جاری یک درخواست Ajax ای را به https://anotherwebsite.com/api/users ارسال کند، چون قسمت host مربوط به origin آنها یکی نیست، این عملیات توسط مرورگر غیرمجاز شمرده شده و مسدود میشود.
چگونه میتوان از تنظیمات CORS، برای رفع محدودیتهای سیاستهای دسترسی Same-Origin استفاده کرد؟
استاندارد CORS تعدادی header ویژه را جهت تبادل اطلاعات بین سرور و مرورگر مشخص کردهاست تا توسط آنها بتوان منابع را بین سرآغازهای متفاوتی به اشتراک گذاشت. در این حالت ابتدا کلاینت درخواستی را به سمت سرور ارسال میکند. سپس سرور پاسخی را به همراه هدرهای ویژهای که مشخص میکنند به چه منابعی و چگونه میتوان دسترسی یافت، به سمت کلاینت ارسال خواهد کرد. اکثر این هدرها با Access-Control-Allow شروع میشوند:
• Access-Control-Allow-Origin
در آن لیست سرآغازهایی را که سرور مایل است منابع خود را با آنها به اشتراک بگذارد، مشخص میشوند. در حالت توسعهی برنامه میتوان از مقدار * نیز برای آن استفاده کرد تا هر سرآغازی بتواند به منابع سرور دسترسی پیدا کند. اما از استفادهی این مقدار سراسری و کلی، در حالت انتشار برنامه خودداری کنید.
• Access-Control-Allow-Headers
• Access-Control-Allow-Methods
• Access-Control-Allow-Credentials
درخواستهای ویژهی Pre-Flight
در بسیاری از موارد، مرورگر زمانیکه تشخیص میدهد درخواست صادر شدهی از طرف برنامه، قرار است به یک Origin دیگر ارسال شود، خودش یک درخواست ویژه را به نام Pre-Flight و از نوع OPTIONS به سمت سرور ارسال میکند. علت آن این است که سرورهای قدیمی، مفهوم CORS را درک نمیکنند و در برابر درخواستهای ویژهای مانند Delete که از سرور جاری صادر نشده باشند، مقاومت میکنند (صرفا درخواستهای Same-Origin را پردازش میکنند). به همین جهت است که اگر به برگهی network ابزارهای توسعه دهندگان مرورگر خود دقت کنید، درخواستهای cross-origin به نظر دوبار ارسال شدهاند. بار اول درخواستی که method آن، options است و در خواست دوم که درخواست اصلی است و برای مثال method آن put است. این درخواست اول، Pre-Flight Request نام دارد. هدف آن این است که بررسی کند آیا سرور مقصد، استاندارد CORS را متوجه میشود یا خیر و آیا اینقدر قدیمی است که صرفا درخواستهای Same-Origin را پردازش میکند و مابقی را مسدود خواهد کرد. اگر سرور درخواست Pre-Flight را درک نکند، مرورگر درخواست اصلی را صادر نخواهد کرد.
البته درخواستهای pre-flight بیشتر در حالتهای زیر توسط مرورگر صادر میشوند:
- اگر متد اجرایی مدنظر، متدی بجز GET/POST/HEAD باشد.
- اگر content type درخواستهای از نوع POST، چیزی بجز application/x-www-form-urlencoded, multipart/form-data و یا text/plain باشند.
- اگر درخواست، به همراه یک هدر سفارشی باشد نیز سبب صدور یک pre-flight request خواهد شد.
بنابراین در برنامههای SPA خود که اطلاعات را با فرمت JSON به سمت یک Web API ارسال میکنند (و Origin آنها یکی نیست)، حتما شاهد درخواستهای Pre-Flight نیز خواهید بود.
تنظیمات CORS در ASP.NET Core
اکنون که با مفهوم CORS آشنا شدیم، در ادامه به نحوهی انجام تنظیمات آن در برنامههای ASP.NET Core خواهیم پرداخت.
تنظیمات ابتدایی CORS در فایل Startup.cs و متد ConfigureServices آن انجام میشوند:
public void ConfigureServices(IServiceCollection services) { // Add service and create Policy with options services.AddCors(options => { options.AddPolicy("CorsPolicy", builder => builder.AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader() .AllowCredentials() ); }); // Make sure MVC is added AFTER CORS services.AddMvc(); }
در اینجا CorsPolicy که به عنوان پارامتر مشخص شدهاست، نام این سیاست دسترسی سفارشی است و در قسمتهای مختلف برنامه میتوان ارجاعاتی را به این نام تعریف کرد.
و برای تعریف سرآغازی خاص و یا متدی مشخص، میتوان به صورت زیر عمل کرد (ذکر صریح WithOrigins برای حالت توزیع برنامه مناسب است و از دیدگاه امنیتی، استفادهی از AllowAnyOrigin کار صحیحی نیست ):
services.AddCors(options => { options.AddPolicy("AllowOrigin", builder => builder.WithOrigins("http://localhost:55294") .WithMethods("GET", "POST", "HEAD")); });
پس از تنظیم سیاست دسترسی مدنظر، اکنون نوبت به اعمال آن است. اینکار در متد Configure فایل Startup.cs انجام میشود:
public void Configure(IApplicationBuilder app) { // ... app.UseCors("CorsPolicy"); // ... app.UseMvc(routes => { routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); }); }
متد app.UseCors، یک متد سراسری است و کل سیستم را داخل این سیاست دسترسی CORS قرار میدهد. اگر میخواهید صرفا به کنترلر خاصی این تنظیمات اعمال شوند، میتوان از فیلتر EnableCors با ذکر نام سیاست دسترسی تعریف شده، استفاده کرد (در این حالت ذکر services.AddCors ضروری است و از ذکر app.UseCors صرفنظر میشود):
[EnableCors("CorsPolicy")] public class MyApiController : Controller
و یا اگر میخواهید از app.UseCors سراسری استفاده کنید، اما آنرا برای کنترلر و یا حتی اکشن متد خاصی غیرفعال نمائید، میتوان از فیلتر [DisableCors] استفاده کرد.
چند نکته:
- به نام رشتهای سیاست دسترسی تعریف شده، دقت داشته باشید. اگر این نام را درست ذکر نکنید، هدری تولید نخواهد شد.
- میانافزار CORS، برای درخواستهای same-origin نیز هدری را تولید نمیکند.
امکان تعریف سیاستهای دسترسی بدون نام نیز وجود دارد
در هر دو روش فوق که یکی بر اساس app.UseCors سراسری و دیگری بر اساس فیلتر EnableCors اختصاصی کار میکنند، ذکر نام سیاست دسترسی options.AddPolicy ضروری است. اگر علاقهای به ذکر این نام ندارید، میتوان به طور کامل از تنظیم services.AddCors صرفنظر کرد (قسمت پارامتر و تنظیمات آنرا ذکر نکرد) و متد app.UseCors را به صورت زیر تنظیم نمود که قابلیت داشتن تنظیمات خاص خود را نیز دارا است:
public void ConfigureServices(IServiceCollection services) { services.AddCors(); services.AddMvc(); } public void Configure(IApplicationBuilder app, IHostingEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseCors(policyBuilder => { string[] origins = new string[] { "http://localhost:2000", "http://localhost:2001" }; policyBuilder .AllowAnyHeader() .AllowAnyMethod() .WithOrigins(origins); }); app.UseMvc(); }
روش دیگر انجام اینکار، تعریف یک DefaultPolicy است:
public void ConfigureServices(IServiceCollection services) { services.AddCors(options => { options.AddDefaultPolicy( builder => { builder .AllowAnyOrigin() .AllowAnyMethod() .AllowAnyHeader(); }); options.AddPolicy("MyCORSPolicy", builder => { builder.WithOrigins("http://localhost:49373") .AllowAnyHeader() .AllowAnyMethod(); }); }); services.AddMvc().AddNewtonsoftJson(); }
خطاهای متداول حین کار با CORS
خطای کار با SignalR و ارسال اطلاعات اعتبارسنجی کاربر
Access to XMLHttpRequest at 'http://localhost:22135/chat/negotiate?jwt=<removed the jwt key>' from origin 'http://localhost:53150' has been blocked by CORS policy: Response to preflight request doesn't pass access control check: The value of the 'Access-Control-Allow-Origin' header in the response must not be the wildcard '*' when the request's credentials mode is 'include'. The credentials mode of requests initiated by the XMLHttpRequest is controlled by the withCredentials attribute.
services.AddCors(options => { options.AddPolicy("AllowAllOrigins", builder => { builder .AllowAnyOrigin() .AllowAnyHeader() .AllowAnyMethod() .AllowCredentials(); }); });
تمام اعتبارسنجیها و اطلاعات کوکیها در سمت سرور قابل دسترسی نیستند
CORS به صورت پیشفرض اطلاعات کوکیها را به دومینی دیگر ارسال نمیکند. در این حالت اگر حتما نیاز به انجام اینکار است، باید در درخواست Ajax ای ارسالی، خاصیت withCredentials به true تنظیم شود. همچنین سمت سرور نیز هدر Access-Control-Allow-Credentials را تنظیم کند (همان متد AllowCredentials فوق). در اینجا Credentials به کوکیها، هدرهای اعتبارسنجی (مانند هدرهای JWT) کاربران و یا مجوزهای TLS کلاینتها اشاره میکند.
var xhr = new XMLHttpRequest(); xhr.open('get', 'http://www.example.com/api/test'); xhr.withCredentials = true; //In jQuery: $.ajax({ type: 'get', url: 'http://www.example.com/home', xhrFields: { withCredentials: true } //ES6 fetch API: fetch( 'http://remote-url.com/users', { 'PUT', headers: { 'Accept': 'application/json', 'Content-Type': 'application/json' }, mode: 'cors', credentials: 'include', body: JSON.stringify(body) } ) .then((response) => response.json()) .then((response) => console.log(response)) .catch((error) => console.error(error));
CORS حساس به حروف کوچک و بزرگ است
روش دیگر تنظیم فیلتر EnableCors را در اینجا مشاهده میکنید:
[EnableCors(origins: "http://MyWebsite.com", headers: "*", methods: "*")] //is not the same as this: [EnableCors(origins:"http://mywebsite.com", headers: "*", methods: "*")]
در حالت بروز خطای مدیریت نشدهای در سمت سرور، ASP.NET Core هدرهای CORS را ارسال نمیکند
اطلاعات بیشتر
ارسال فایل Ajax ایی آن توسط HTML5 File API صورت میگیرد که در تمام مرورگرهای جدید پشتیبانی خوبی از آن وجود دارد. در مرورگرهای قدیمیتر، به صورت خودکار همان حالت متداول ارسال همزمان فایلها را فعال میکند (یا همان post back معمولی).
فعال سازی مقدماتی kendoUpload
ابتداییترین حالت کار با kendoUpload، فعال سازی حالت post back معمولی است؛ به شرح زیر:
<form method="post" action="submit" enctype="multipart/form-data"> <div> <input name="files" id="files" type="file" /> <input type="submit" value="Submit" class="k-button" /> </div> </form> <script> $(document).ready(function() { $("#files").kendoUpload(); }); </script>
فعال سازی حالت ارسال فایل Ajax ایی kendoUpload
برای فعال سازی ارسال Ajax ایی فایلها در Kendo UI نیاز است خاصیت async آنرا به نحو ذیل مقدار دهی کرد:
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { // async configuration saveUrl: "@Url.Action("Save", "Home")", // the url to save a file is '/save' removeUrl: "@Url.Action("Remove", "Home")", // the url to remove a file is '/remove' autoUpload: false, // automatically upload files once selected removeVerb: 'POST' }, multiple: true, showFileList: true }); }); </script>
[HttpPost] public ActionResult Save(IEnumerable<HttpPostedFileBase> files) { if (files != null) { // ... // Process the files and save them // ... } // Return an empty string to signify success return Content(""); } [HttpPost] public ContentResult Remove(string[] fileNames) { if (fileNames != null) { foreach (var fullName in fileNames) { // ... // delete the files // ... } } // Return an empty string to signify success return Content(""); }
دو دکمهی حذف با کارکردهای متفاوت در ویجت kendoUpload وجود دارند. در ابتدای کار، پیش از ارسال فایلها به سرور:
کلیک بر روی دکمهی حذف در این حالت، صرفا فایلی را از لیست سمت کاربر حذف میکند.
پس از ارسال فایلها به سرور:
اما پس از پایان عملیات ارسال، اگر کاربر بر روی دکمهی حذف کلیک کند، توسط آدرس مشخص شده توسط خاصیت removeUrl، نام فایلهای مورد نظر، برای حذف از سرور ارسال میشوند.
چند نکتهی تکمیلی
- تنظیم خاصیت autoUpload به true سبب میشود تا پس از انتخاب فایلها توسط کاربر، بلافاصله و به صورت خودکار عملیات ارسال فایلها به سرور آغاز شوند. اگر به false تنظیم شود، دکمهی ارسال فایلها در پایین لیست نمایش داده خواهد شد.
- شاید علاقمند باشید تا removeVerb را به DELETE تغییر دهید؛ بجای POST. به همین منظور میتوان خاصیت removeVerb در اینجا مقدار دهی کرد.
- با تنظیم خاصیت multiple به true، کاربر قادر خواهد شد تا توسط صفحهی دیالوگ انتخاب فایلها، قابلیت انتخاب بیش از یک فایل را داشته باشد.
- showFileList نمایش لیست فایلها را سبب میشود.
تعیین پسوند فایلهای صفحهی انتخاب فایلها
هنگامیکه کاربر بر روی دکمهی انتخاب فایلها برای ارسال کلیک میکند، در صفحهی دیالوگ باز شده میتوان پسوندهای پیش فرض مجاز را نیز تعیین کرد.
برای این منظور تنها کافی است ویژگی accept را به input از نوع فایل اضافه کرد. چند مثال در این مورد:
<!-- Content Type with wildcard. All Images --> <input type="file" id="demoFile" title="Select file" accept="image/*" /> <!-- List of file extensions --> <input type="file" id="demoFile" title="Select file" accept=".jpg,.png,.gif" /> <!-- Any combination of the above --> <input type="file" id="demoFile" title="Select file" accept="audio/*,application/pdf,.png" />
نمایش متن کشیدن و رها کردن، بومی سازی برچسبها و نمایش راست به چپ
همانطور که در تصاویر فوق ملاحظه میکنید، نمایش این ویجت راست به چپ و پیامهای آن نیز ترجمه شدهاند.
برای راست به چپ سازی آن مانند قبل تنها کافی است input مرتبط، در یک div با کلاس k-rtl محصور شود:
<div class="k-rtl k-header"> <input name="files" id="files" type="file" /> </div>
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { //... }, //... localization: { select: 'انتخاب فایلها برای ارسال', remove: 'حذف فایل', retry: 'سعی مجدد', headerStatusUploading: 'در حال ارسال فایلها', headerStatusUploaded: 'پایان ارسال', cancel: "لغو", uploadSelectedFiles: "ارسال فایلها", dropFilesHere: "فایلها را برای ارسال، کشیده و در اینجا رها کنید", statusUploading: "در حال ارسال", statusUploaded: "ارسال شد", statusWarning: "اخطار", statusFailed: "خطا در ارسال" } }); }); </script>
<style type="text/css"> div.k-dropzone { border: 1px solid #c5c5c5; /* For Default; Different for each theme */ } div.k-dropzone em { visibility: visible; } </style>
تغییر قالب نمایش لیست فایلها
لیست فایلها در ویجت kendoUpload دارای یک قالب پیش فرض است که امکان بازنویسی کامل آن وجود دارد. ابتدا نیاز است یک kendo-template را بر این منظور تدارک دید:
<script id="fileListTemplate" type="text/x-kendo-template"> <li class='k-file'> <span class='k-progress'></span> <span class='k-icon'></span> <span class='k-filename' title='#=name#'>#=name# (#=size# bytes)</span> <strong class='k-upload-status'></strong> </li> </script>
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { // ... }, // ... template: kendo.template($('#fileListTemplate').html()), // ... }); }); </script>
رخدادهای ارسال فایلها
افزونهی kendoUpload در حالت ارسال Ajax ایی فایلها، رخدادهایی مانند شروع به ارسال، موفقیت، پایان، درصد ارسال فایلها و امثال آنرا نیز به همراه دارد که لیست کامل آنها را در ذیل مشاهده میکنید:
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { // async configuration //... }, //... localization: { }, cancel: function () { console.log('Cancel Event.'); }, complete: function () { console.log('Complete Event.'); }, error: function () { console.log('Error uploading file.'); }, progress: function (e) { console.log('Uploading file ' + e.percentComplete); }, remove: function () { console.log('File removed.'); }, select: function () { console.log('File selected.'); }, success: function () { console.log('Upload successful.'); }, upload: function (e) { console.log('Upload started.'); } }); }); </script>
ارسال متادیتای اضافی به همراه فایلهای ارسالی
فرض کنید میخواهید به همراه فایلهای ارسالی به سرور، پارامتر codeId را نیز ارسال کنید. برای این منظور باید خاصیت e.data رویداد upload را به نحو ذیل مقدار دهی کرد:
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { //... }, //... localization: { }, upload: function (e) { console.log('Upload started.'); // Sending metadata to the save action e.data = { codeId: "1234567", param2: 12 //, ... }; } }); }); </script>
[HttpPost] public ActionResult Save(IEnumerable<HttpPostedFileBase> files, string codeId)
فعال سازی ارسال batch
اگر در متد Save سمت سرور یک break point قرار دهید، مشاهده خواهید کرد که به ازای هر فایل موجود در لیست در سمت کاربر، یکبار متد Save فراخوانی میشود و عملا متد Save، لیستی از فایلها را در طی یک فراخوانی دریافت نمیکند. برای فعال سازی این قابلیت تنها کافی است خاصیت batch را به true تنظیم کنیم:
<script type="text/javascript"> $(function () { $("#files").kendoUpload({ name: "files", async: { // .... batch: true }, }); }); </script>
در یک چنین حالتی باید دقت داشت که تنظیم maxRequestLength در web.config برنامه الزامی است؛ زیرا به صورت پیش فرض محدودیت 4 مگابایتی ارسال فایلها توسط ASP.NET اعمال میشود:
<?xml version="1.0" encoding="utf-8"?> <configuration> <system.web> <!-- The request length is in kilobytes, execution timeout is in seconds --> <httpRuntime maxRequestLength="10240" executionTimeout="120" /> </system.web> <system.webServer> <security> <requestFiltering> <!-- The content length is in bytes --> <requestLimits maxAllowedContentLength="10485760"/> </requestFiltering> </security> </system.webServer> </configuration>
قصد داریم تا مانند مثال قسمت قبل، مجموعه ای از اطلاعات مربوط به مرورگرهای مختلف را در یک جدول نشان دهیم، اما این بار منبع داده ما فرق میکند. منبع داده از طرف سرور فراهم میشود. هر مرورگر - همان طور که در قسمت قبل مشاهده نمودید - شامل اطلاعات زیر خواهد بود:
- موتور رندرگیری (Engine)
- نام مرورگر (Name)
- پلتفرم (Platform)
- نسخه موتور (Version)
- نمره سی اس اس (Grade)
به همین دلیل در سمت سرور، کلاسی خواهیم ساخت که نمایانگر یک مرورگر باشد. بدین صورت:
public class Browser { public int Id { get; set; } public string Engine { get; set; } public string Name { get; set; } public string Platform { get; set; } public float Version { get; set; } public string Grade { get; set; } }
این روش، یکی از امکانات jQuery DataTables است که با استفاده از آن، کلاینت تنها یک مصرف کننده صرف خواهد بود و وظیفه پردازش اطلاعات - یعنی تعداد رکوردهایی که برگشت داده میشود، صفحه بندی، مرتب سازی، جستجو، و غیره - به عهده سرور خواهد بود.
برای به کار گیری این روش، اولین کار این است که ویژگی bServerSide را true کنیم، مثلا بدین صورت:
var $table = $('#browsers-grid'); $table.dataTable({ "bServerSide": true, "sAjaxSource": "/Home/GetBrowsers" });
همچنین ویژگی sAjaxSource را به Url ی که باید دادهها از آن دریافت شوند مقداردهی میکنیم.
به صورت پیش فرض مقدار ویژگی bServerSide مقدار false است؛ که یعنی منبع داده این پلاگین از سمت سرور خوانده نشود. اگر true باشد منبع داده و خیلی اطلاعات دیگر مربوط به دادههای درون جدول باید از سرور به مرورگر کاربر پس فرستاده شوند. با true کردن مقدار bServerSide، آنگاه DataTables اطلاعاتی را راجع به شماره صفحه جاری، اندازه هر صفحه، شروط فیلتر کردن داده ها، مرتب سازی ستون ها، و غیره را به سرور میفرستد. همجنین انتظار میرود تا سرور در پاسخ به این درخواست، دادههای مناسبی را به فرمت JSON به مرورگر پس بفرستد. در حالتی که bServerSide مقدار true به خود بگیرد، پلاگین فقط رابطه متقابل بین کاربر و سرور را مدیریت میکند و هیچ پردازشی را انجام نمیدهد.
در این درخواست XHR یا Ajax ی پارامترهایی که به سرور ارسال میشوند اینها هستند:
iDisplayStart عدد صحیح
نقظه شروع مجموعه داده جاری
iDisplayLength عدد صحیح
تعداد رکوردهایی که جدول میتواند نمایش دهد. تعداد رکوردهایی که از طرف سرور برگشت داده میشود باید با این عدد یکسان باشند.
iColumns عدد صحیح
تعداد ستونهایی که باید نمایش داده شوند.
sSearch رشته
فیلد جستجوی عمومی
bRegex بولین
اگر true باشد معنی آن این است که میتوان از عبارات باقاعده برای جستجوی عبارتی خاص در کل ستونهای جدول استفاده کرد. مثلا در کادر جستجو نوشت :
^[1-5]$
sSearch_(int) رشته
فیلتر مخصوص هر ستون. اگر از ویژگی multi column filtering پلاگین استفاده شود به صورت sSearch0 ، sSearch1 ، sSeach2 و ... به طرف سرور ارسال میشوند. شماره انتهای هر کدام از پارامترها بیانگر شماره ستون جدول است.
bRegex_(int) بولین
اگر true باشد، بیان میکند که میتوان از عبارت با قاعده در ستون شماره int جهت جستجو استفاده کرد.
bSortable_(int) بولین
مشخص میکند که آیا یک ستون در سمت کلاینت، قابلیت مرتب شدن بر اساس آن وجود دارد یا نه. (در اینجا int اندیس ستون را مشخص میکند)
iSortingCols عدد صحیح
تعداد ستون هایی که باید مرتب سازی بر اساس آنها صورت پذیرد. در صورتی که از امکان multi column sorting استفاده کنید این مقدار میتواند بیش از یکی باشد.
iSortCol_(int) عدد صحیح
شماره ستونی که باید بر اساس آن عملیات مرتب سازی صورت پذیرد.
sSortDir_(int) رشته
نحوه مرتب سازی ؛ شامل صعودی (asc) یا نزولی (desc)
mDataProp_(int) رشته
اسم ستونهای درون جدول را مشخص میکند.
sEcho رشته
اطلاعاتی که datatables از آن برای رندر کردن جدول استفاده میکند.
شکل زیر نشان میدهد که چه پارامترهایی به سرور ارسال میشوند.
بعضی از این پارامترها بسته به تعداد ستونها قابل تغییر هستند. (آن پارامترهایی که آخرشان یک عدد هست که نشان دهنده شماره ستون مورد نظر میباشد)
در پاسخ به هر درخواست XHR که datatables به سرور میفرستد، انتظار دارد تا سرور نیز یک شیء json را با فرمت مخصوص که شامل پارامترهای زیر میشود به او پس بفرستد:
iTotalRecords عدد صحیح
تعداد کل رکوردها (قبل از عملیات جستجو) یا به عبارت دیگر تعداد کل رکوردهای درون آن جدول از دیتابیس که دادهها باید از آن دریافت شوند. تعداد کل رکوردهایی که در طرف سرور وجود دارند. این مقدار فقط برای نمایش به کاربر برگشت داده میشود و نیز از آن برای صفحه بندی هم استفاده میشود.
iTotalDisplayRecords عدد صحیح
تعداد کل رکوردها (بعد از عملیات جستجو) یا به عبارت دیگر تعداد کل رکوردهایی که بعد از عملیات جستجو پیدا میشوند نه فقط آن تعداد رکوردی که به کاربر پس فرستاده میشوند. تعداد کل رکوردهایی که با شرط جستجو مطابقت دارند. اگر کاربر چیزی را جستجو نکرده باشد مقدار این پارامتر با پارامتر iTotalRecords یکسان خواهد بود.
sEcho عدد صحیح
یک عدد صحیح است که در قالب رشته در تعامل بین سرور و کلاینت جا به جا میشود. این مقدار به ازاء هر درخواست تغییر میکند. همان مقداری که مرورگر به سرور میدهد را سرور هم باید به مرورگر تحویل بدهد. برای جلوگیری از حملات XSS باید آن را تبدیل به عدد صحیح کرد. پلاگین DataTables مقدار این پارامتر را برای هماهنگ کردن و منطبق کردن درخواست ارسال شده و جواب این درخواست استفاده میکند. همان مقداری که مروگر به سرور میدهد را باید سرور تحویل به مرورگر بدهد.
sColumns رشته
اسم ستونها که با استفاده از کاما از هم جدا شده اند. استفاده از آن اختیاری است و البته منسوخ هم شده است و در نسخههای جدید jQuery DataTables از آن پشتیبانی نمیشود.
aaData آرایه
همان طور که قبلا هم گفتیم، مقادیر سلول هایی را که باید در جدول نشان داده شوند را در خود نگهداری میکند. یعنی در واقع دادههای جدول در آن ریخته میشوند. هر وقت که DataTables دادههای مورد نیازش را دریافت میکند، سلولهای جدول html مربوطه اش را از روی آرایه aaData ایجاد میکند. تعداد ستونها در این آرایه دو بعدی، باید با تعداد ستونهای جدول html مربوطه به آن یکسان باشد
شکل زیر پارامترها دریافتی از سرور را نشان میدهند:
همان طور که گفتیم، کلاینت به سرور یک سری پارامترها را ارسال میکند و آن پارامترها را هم شرح دادیم. برای دریافت این پارامترها طرف سرور، احتیاج به یک مدل هست. این مدل به صورت زیر پیاده سازی خواهد شد:
/// <summary> /// Class that encapsulates most common parameters sent by DataTables plugin /// </summary> public class jQueryDataTableParamModel { /// <summary> /// Request sequence number sent by DataTable, /// same value must be returned in response /// </summary> public string sEcho { get; set; } /// <summary> /// Text used for filtering /// </summary> public string sSearch { get; set; } /// <summary> /// Number of records that should be shown in table /// </summary> public int iDisplayLength { get; set; } /// <summary> /// First record that should be shown(used for paging) /// </summary> public int iDisplayStart { get; set; } /// <summary> /// Number of columns in table /// </summary> public int iColumns { get; set; } /// <summary> /// Number of columns that are used in sorting /// /// </summary> public int iSortingCols { get; set; } /// <summary> /// Comma separated list of column names /// </summary> public string sColumns { get; set; } }
مدل بایندر mvc وظیفه مقداردهی به خصوصیات درون این کلاس را بر عهده دارد، بقیه پارامترهایی که به سرور ارسال میشوند و در این کلاس نیامده اند، از طریق شیء Request در دسترس خواهند بود.
اکشن متدی که مدل بالا را دریافت میکند، میتواند به صورت زیر پیاده سازی شود. این اکشن متد وظیفه پاسخ دادن به درخواست DataTables بر اساس پارامترهای ارسال شده در مدل DataTablesParam را دارد. خروجی این اکشن متد شامل پارارمترهای مورد نیاز پلاگین DataTables برای تشکیل جدول است که آنها را هم شرح دادیم.
public JsonResult GetBrowsers(jQueryDataTableParamModel param) { IQueryable<Browser> allBrowsers = new Browsers().CreateInMemoryDataSource().AsQueryable(); IEnumerable<Browser> filteredBrowsers; // Apply Filtering if (!string.IsNullOrEmpty(param.sSearch)) { filteredBrowsers = new Browsers().CreateInMemoryDataSource() .Where(x => x.Engine.Contains(param.sSearch) || x.Grade.Contains(param.sSearch) || x.Name.Contains(param.sSearch) || x.Platform.Contains(param.sSearch) ).ToList(); float f; if (float.TryParse(param.sSearch, out f)) { filteredBrowsers = filteredBrowsers.Where(x => x.Version.Equals(f)); } } else { filteredBrowsers = allBrowsers; } // Apply Sorting var sortColumnIndex = Convert.ToInt32(Request["iSortCol_0"]); Func<Browser, string> orderingFunction = (x => sortColumnIndex == 0 ? x.Engine : sortColumnIndex == 1 ? x.Name : sortColumnIndex == 2 ? x.Platform : sortColumnIndex == 3 ? x.Version.ToString() : sortColumnIndex == 4 ? x.Grade : x.Name); var sortDirection = Request["sSortDir_0"]; // asc or desc filteredBrowsers = sortDirection == "asc" ? filteredBrowsers.OrderBy(orderingFunction) : filteredBrowsers.OrderByDescending(orderingFunction); // Apply Paging var enumerable = filteredBrowsers.ToArray(); IEnumerable<Browser> displayedBrowsers = enumerable.Skip(param.iDisplayStart). Take(param.iDisplayLength).ToList(); return Json(new { sEcho = param.sEcho, iTotalRecords = allBrowsers.Count(), iTotalDisplayRecords = enumerable.Count(), aaData = displayedBrowsers }, JsonRequestBehavior.AllowGet); }
تشریح اکشن متد GetBrowsers :
این اکشن متد از مدل jQueryDataTableParamModel به عنوان پارامتر ورودی خود استفاده میکند. این مدل همان طور هم که گفتیم، شامل یک سری خصوصیت است که توسط پلاگین jQuery DataTables مقداردهی میشوند و همچنین مدل بایندر mvc وظیفه بایند کردن این مقادیر به خصوصیات درون این کلاس را بر عهده خواهد داشت. درون بدنه اکشن متد GetBrowsers دادهها بعد از اعمال عملیات فیلترینگ، مرتب سازی، و صفحه بندی به فرمت مناسبی درآمده و به طرف مرورگر فرستاده خواهند شد.
برای پیاده سازی کدهای طرف کلاینت نیز، درون یک View کدهای زیر قرار خواهند گرفت:
$(function () { var $table = $('#browsers-grid'); $table.dataTable({ "bProcessing": true, "bStateSave": true, "bServerSide": true, "bFilter": true, "sDom": 'T<"clear">lftipr', "aLengthMenu": [[5, 10, 25, 50, -1], [5, 10, 25, 50, "All"]], "bAutoWidth": false, "sAjaxSource": "/Home/GetBrowsers", "fnServerData": function (sSource, aoData, fnCallback) { $.ajax({ "dataType": 'json', "type": "POST", "url": sSource, "data": aoData, "success": fnCallback }); }, "aoColumns": [ { "mDataProp": "Engine" }, { "mDataProp": "Name" }, { "mDataProp": "Platform" }, { "mDataProp": "Version" }, { "mDataProp": "Grade" } ], "oLanguage": { "sUrl": "/Content/dataTables.persian.txt" } }); });
تشریح کدها:
fnServerData :
این متد، در واقع نحوه تعامل سرور و کلاینت را با استفاده از درخواستهای XHR مشخص خواهد کرد.
oLanguage :
برای فعال سازی زبان فارسی، فیلدهای مورد نیاز ترجمه شده و در یک فایل متنی قرار داده شده اند. کافی است آدرس این فایل متنی به ویژگی oLanguage اختصاص داده شوند.
مثال این قسمت را از لینک زیر دریافت کنید:
DataTablesTutorial04.zip
لازم به ذکر است پوشه bin، obj، و packages جهت کاهش حجم این مثال از solution حذف شده اند. برای اجرای این مثال از اینجا کمک بگیرید.
مطالعه بیشتر
برای مطالعه بیشتر در مورد این پلاگین و نیز پیاده سازی آن در MVC میتوانید به لینک زیر نیز مراجعه بفرمائید که بعضی از قسمتهای این مطلب هم از مقاله زیر استفاده کرده است:
jQuery DataTables and ASP.NET MVC Integration - Part I
ایجاد ساختار ابتدایی پروژه
برای ساخت پروژه، به خط فرمان مراجعه کرده و با دستور زیر، یک پروژهی react از نوع typescript را ایجاد میکنیم.
npx create-react-app todo-mobx --template typescript cd todo-mobx
برای توسعهی این مثال، از محیط توسعهی VSCode استفاده میکنیم. اگر VSCode بر روی سیستم شما نصب باشد، در همان مسیری که خط فرمان باز است، دستور زیر را اجرا کنید؛ پروژهی شما در VSCode باز میشود:
code
سپس در محیط VSCode، دکمههای ctrl+` را فشرده (ctrl+back-tick) و دستورات زیر را در ترمینال ظاهر شده وارد کنید:
npm install --save-dev typescript @types/node @types/react @types/react-dom @types/jest npm install mobx mobx-react-lite --save
در ادامه برای استایل بندی بهتر برنامه از کتابخانههای bootstrap و font-awesome استفاده میکنیم:
npm install bootstrap --save npm install font-awesome --save
سپس فایل index.tsx را باز کرده و دو خط زیر را به آن اضافه میکنیم:
import "bootstrap/dist/css/bootstrap.css"; import "font-awesome/css/font-awesome.css";
کتابخانهی MobX، از تزئین کنندهها یا decorators استفاده میکند. بنابراین نیاز است به tsconfig پروژه مراجعه کرده و خط زیر را به آن اضافه کنیم:
"compilerOptions": { .... , "experimentalDecorators": true }
ایجاد مخازن حالت MobX
در ادامه نیاز است storeهای MobX را ایجاد کنیم و بعد آنها را به react اتصال دهیم. بدین منظور یک پوشهی جدید را در مسیر src، به نام stores ایجاد میکنیم و سپس فایل جدیدی را به نام todo-item.ts در آن با محتوای زیر ایجاد میکنیم:
import { observable, action } from "mobx"; export default class TodoItem { id = Date.now(); @observable text: string = ''; @observable isDone: boolean = false; constructor(text: string) { this.text = text; } @action toggleIsDone = () => { this.isDone = !this.isDone } @action updateText = (text: string) => { this.text = text; } }
در همان مسیر stores، فایل دیگری را نیز به نام todo-list.ts، با محتوای زیر ایجاد میکنیم:
import { observable, computed, action } from "mobx"; import TodoItem from "./todo-item"; export class TodoList { @observable.shallow list: TodoItem[] = []; constructor(todos: string[]) { todos.forEach(this.addTodo); } @action addTodo = (text: string) => { this.list.push(new TodoItem(text)); } @action removeTodo = (todo: TodoItem) => { this.list.splice(this.list.indexOf(todo), 1); }; @computed get finishedTodos(): TodoItem[] { return this.list.filter(todo => todo.isDone); } @computed get openTodos(): TodoItem[] { return this.list.filter(todo => !todo.isDone); } }
توضیحات:
مفهوم observable@: کل شیء state را به صورت یک شیء قابل ردیابی JavaScript ای ارائه میکند.
مفهوم computed@: این نوع خواص، مقدار خود را زمانیکه observableهای وابستهی به آنها تغییر کنند، به روز رسانی میکنند.
مفهوم action@: جهت به روز رسانی state و سپس نمایش تغییرات یا نمایش نمونهی دیگری در DOM میباشند.
import { createContext, useContext } from "react"; import { TodoList } from "../stores/todo-list"; export const StoreContext = createContext<TodoList>({} as TodoList); export const StoreProvider = StoreContext.Provider; export const useStore = (): TodoList => useContext(StoreContext);
import React from 'react'; import ReactDOM from 'react-dom'; import './index.css'; import App from './App'; import * as serviceWorker from './serviceWorker'; import "bootstrap/dist/css/bootstrap.css"; import "font-awesome/css/font-awesome.css"; import { TodoList } from './stores/todo-list'; import { StoreProvider } from './providers/store-provider'; const todoList = new TodoList([ 'Read Book', 'Do exercise', 'Watch Walking dead series' ]); ReactDOM.render( <StoreProvider value={todoList}> <App /> </StoreProvider> , document.getElementById('root')); // If you want your app to work offline and load faster, you can change // unregister() to register() below. Note this comes with some pitfalls. // Learn more about service workers: https://bit.ly/CRA-PWA serviceWorker.unregister();
import React, { useState } from 'react'; import { useStore } from '../providers/store-provider'; export const TodoNew = () => { const [newTodo, setTodo] = useState(''); const todoList = useStore(); const addTodo = () => { todoList.addTodo(newTodo); setTodo(''); }; return ( <div className="input-group mb-3"> <input type="text" className="form-control" placeholder="Add To do" value={newTodo} onChange={(e) => setTodo(e.target.value)} /> <div className="input-group-append"> <button className="btn btn-success" type="submit" onClick={addTodo}>Add Todo</button> </div> </div> ) };
import React from 'react'; import { TodoItem } from "./TodoItem"; import { useObserver } from "mobx-react-lite"; import { useStore } from '../providers/store-provider'; export const TodoList = () => { const todoList = useStore(); return useObserver(() => ( <div> <h1>Open Todos</h1> <table className="table"> <thead className="thead-dark"> <tr> <th>Name</th> <th className="text-left">Do It?</th> <th>Actions</th> </tr> </thead> <tbody> { todoList.openTodos.map(todo => <tr key={`${todo.id}-${todo.text}`}> <TodoItem todo={todo} /> </tr>) } </tbody> </table> <h1>Finished Todos</h1> <table className="table"> <thead className="thead-light"> <tr> <th>Name</th> <th className="text-left">Do It?</th> <th>Actions</th> </tr> </thead> <tbody> { todoList.finishedTodos.map(todo => <tr key={`${todo.id}-${todo.text}`}> <TodoItem todo={todo} /> </tr>) } </tbody> </table> </div> )); };
import React, { useState } from 'react'; import TodoItemClass from "../stores/todo-item"; import { useStore } from '../providers/store-provider'; interface Props { todo: TodoItemClass; } export const TodoItem = ({ todo }: Props) => { const todoList = useStore(); const [newText, setText] = useState(''); const [isEditing, setEdit] = useState(false); const saveText = () => { todo.updateText(newText); setEdit(false); setText(''); }; return ( <React.Fragment> { isEditing ? <React.Fragment> <td> <input className="form-control" placeholder={todo.text} type="text" onChange={(e) => setText(e.target.value)} /> </td> <td></td> <td> <button className="btn btn-xs btn-success " onClick={saveText}>Save</button> </td> </React.Fragment> : <React.Fragment> <td> {todo.text} </td> <td className="text-left"> <input className="form-check-input" type="checkbox" onChange={todo.toggleIsDone} defaultChecked={todo.isDone}></input> </td> <td> <button className="btn btn-xs btn-warning " onClick={() => setEdit(true)}> <i className="fa fa-edit"></i> </button> <button className="btn btn-xs btn-danger ml-2" onClick={() => todoList.removeTodo(todo)}> <i className="fa fa-remove"></i> </button> </td> </React.Fragment> } </React.Fragment> ) };
امن سازی برنامههای ASP.NET Core توسط IdentityServer 4x - قسمت چهاردهم- آماده شدن برای انتشار برنامه
public void ConfigureServices(IServiceCollection services) { services.AddControllersWithViews(); IdentityModelEventSource.ShowPII = true; services.AddAuthentication(options => { options.DefaultScheme = "Cookies"; options.DefaultChallengeScheme = "oidc"; options.DefaultSignOutScheme = "oidc"; }) .AddCookie("Cookies", options => { options.AccessDeniedPath = "/Authorization/AccessDenied"; // set session lifetime options.ExpireTimeSpan = TimeSpan.FromHours(8); // sliding or absolute options.SlidingExpiration = false; // host prefixed cookie name options.Cookie.Name = "MVC"; // strict SameSite handling options.Cookie.SameSite = SameSiteMode.Strict; }) .AddOpenIdConnect("oidc", options => { options.SignInScheme = "Cookies"; options.Authority = Configuration["IDPBaseAddress"]; options.ClientId = Configuration["ClientId"]; options.ClientSecret = Configuration["ClientSecret"]; options.ResponseType = "code id_token"; options.ResponseMode = "query"; options.RequireHttpsMetadata = false; options.CallbackPath = new PathString("/Home/"); options.SignedOutCallbackPath = new PathString("/Home/"); options.MapInboundClaims = true; options.Scope.Clear(); options.Scope.Add("openid"); options.Scope.Add("profile"); options.Scope.Add("roles"); options.Scope.Add("PS.WebApi.Read"); options.Scope.Add("offline_access"); options.SaveTokens = true; options.GetClaimsFromUserInfoEndpoint = true; //options.UsePkce = true; //options.ClaimActions.MapJsonKey(claimType: "role", jsonKey: "role"); // for having 2 or more roles options.TokenValidationParameters = new TokenValidationParameters { NameClaimType = JwtClaimTypes.GivenName, RoleClaimType = JwtClaimTypes.Role }; }); //ServicePointManager.Expect100Continue = true; //ServicePointManager.SecurityProtocol = SecurityProtocolType.Tls // | SecurityProtocolType.Tls11 // | SecurityProtocolType.Tls12 // | SecurityProtocolType.Ssl3; } public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } else { app.UseExceptionHandler("/Home/Error"); app.UseHsts(); } app.UseHttpsRedirection(); app.UseStaticFiles(); app.UseRouting(); app.UseAuthentication(); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllerRoute( name: "areas", pattern: "{area:exists}/{controller=Home}/{action=Index}/{id?}" ); //.RequireAuthorization(); endpoints.MapControllerRoute( name: "default", pattern: "{controller=Home}/{action=Index}/{id?}" ); //.RequireAuthorization(); }); //[HttpPost] //public IActionResult Logout() //{ // return SignOut("Cookies", "oidc"); //} }
پروژههای بنده به ترتیب Endpoint هاشون اینگونه هست:
"IDPBaseAddress": "https://localhost:44310",
"ClientId": "Mvc_ClientId",
"ClientSecret": "WebMvc"
{ "IdentityServerData": { "IdentityResources": [ { "Name": "roles", "Enabled": true, "DisplayName": "Roles", "UserClaims": [ "role" ] }, { "Name": "openid", "Enabled": true, "Required": true, "DisplayName": "Your user identifier", "UserClaims": [ "sub" ] }, { "Name": "profile", "Enabled": true, "DisplayName": "User profile", "Description": "Your user profile information (first name, last name, etc.)", "Emphasize": true, "UserClaims": [ "name", "family_name", "given_name", "middle_name", "nickname", "preferred_username", "profile", "picture", "website", "gender", "birthdate", "zoneinfo", "locale", "updated_at" ] }, { "Name": "email", "Enabled": true, "DisplayName": "Your email address", "Emphasize": true, "UserClaims": [ "email", "email_verified" ] }, { "Name": "address", "Enabled": true, "DisplayName": "Your address", "Emphasize": true, "UserClaims": [ "address" ] } ], "ApiScopes": [ { "Name": "Idp_Admin_ClientId_api", "DisplayName": "Idp_Admin_ClientId_api", "Required": true, "UserClaims": [ "role", "name" ] }, { "Name": "WebApi.Read", "DisplayName": "WebApi Read", "Required": true, "UserClaims": [ "role", "WebApi.Read" ] }, { "Name": "WebApi.Write", "DisplayName": "WebApi Write", "Required": true, "UserClaims": [ "role", "WebApi.Write" ] } ], "ApiResources": [ { "Name": "Idp_Admin_ClientId_api", "Scopes": [ "Idp_Admin_ClientId_api" ] }, { "Name": "WebApi", "Scopes": [ "WebApi.Read", "WebApi.Write" ] } ], "Clients": [ { "ClientId": "Idp_Admin_ClientId", "ClientName": "Idp_Admin_ClientId", "ClientUri": "https://localhost:44303", "AllowedGrantTypes": [ "authorization_code" ], "RequirePkce": true, "ClientSecrets": [ { "Value": "Idp_Admin_ClientSecret" } ], "RedirectUris": [ "https://localhost:44303/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44303/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44303/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44303" ], "AllowedScopes": [ "openid", "email", "profile", "roles" ] }, { "ClientId": "Idp_Admin_ClientId_api_swaggerui", "ClientName": "Idp_Admin_ClientId_api_swaggerui", "AllowedGrantTypes": [ "authorization_code" ], "RequireClientSecret": false, "RequirePkce": true, "RedirectUris": [ "https://localhost:44302/swagger/oauth2-redirect.html" ], "AllowedScopes": [ "Idp_Admin_ClientId_api" ], "AllowedCorsOrigins": [ "https://localhost:44302" ] }, //WebApi { "ClientId": "WebApi_ClientId", "ClientName": "WebApi_ClientId", "ClientUri": "https://localhost:44365", "AllowedGrantTypes": [ "authorization_code" ], "RequirePkce": true, "ClientSecrets": [ { "Value": "WebApi" } ], "RedirectUris": [ "https://localhost:44303/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44303/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44303/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44303", "https://localhost:44310" ], "AllowedScopes": [ "openid", "email", "profile", "roles" ] }, //Mvc { "ClientId": "Mvc_ClientId", "ClientName": "Mvc_ClientId", "ClientUri": "https://localhost:44332", "AllowedGrantTypes": [ "hybrid" ], //"RequirePkce": true, "AllowPlainTextPkce": false, "ClientSecrets": [ { "Value": "WebMvc" } ], "RedirectUris": [ "https://localhost:44332/signin-oidc" ], "FrontChannelLogoutUri": "https://localhost:44332/signout-oidc", "PostLogoutRedirectUris": [ "https://localhost:44332/signout-callback-oidc" ], "AllowedCorsOrigins": [ "https://localhost:44332", "https://localhost:44310" ], "AllowedScopes": [ "openid", "email", "profile", "roles", "address", "PS.webApi" ], "AllowAccessTokensViaBrowser": true, "RequireConsent": false, "AllowOfflineAccess": true //"UpdateAccessTokenClaimsOnRefresh": true } ] } }
public class HomeController : Controller { private readonly ILogger<HomeController> _logger; public HomeController(ILogger<HomeController> logger) { _logger = logger; } public IActionResult Default() { return View(); } public IActionResult Index() { return View(); } [Authorize] public IActionResult Privacy() { return View(); } [ResponseCache(Duration = 0, Location = ResponseCacheLocation.None, NoStore = true)] public IActionResult Error() { return View(new ErrorViewModel { RequestId = Activity.Current?.Id ?? HttpContext.TraceIdentifier }); } }
اما در نهایت بعد از اجرا و مراجعه به آدرس https://localhost:44332/home/privacy که مزین به اتریبیوت
[Authorize]میباشد با خطای زیر مواجه میشوم:
لازم به توضیح هست که پروپرتی RequireHttpsMetadata = false میباشد.
مفهوم Areas
Areas یکی از روشهای ساماندهی برنامههای بزرگ، به نواحی کوچکتری مانند قسمتهای مدیریتی، پشتیبانی از کاربران و غیره است. به این ترتیب میتوان کنترلرها، Viewها و مدلهای هر قسمت را از قسمتی دیگر، جدا کرد و مدیریت پروژه را سادهتر نمود. هر Area دارای ساختار پوشههای مرتبط به خود میباشد و به این نحو است که جداسازی این نواحی مختلف را میسر میکند؛ تا بهتر مشخص باشد که هر المانی متعلق است به چه ناحیهای. به علاوه در این حالت میتوان پروژه را بین چندین توسعه دهندهی مختلف نیز تقسیم کرد؛ بدون اینکه در کار یکدیگر تداخلی ایجاد کنند.
ایجاد Areas
اگر با ASP.NET MVC 5.x کار کرده باشید، میدانید که ویژوال استودیو با کلیک راست بر روی پروژهی جاری، گزینه افزودن یک Area جدید را به همراه دارد. یک چنین قابلیتی تا ASP.NET Core 1.1 به ابزارهای همراه آن افزوده نشدهاست. بنابراین تمام مراحل ذیل را باید دستی ایجاد کنید و هنوز قالب از پیش تعریف شده و ساده کنندهای برای اینکار وجود ندارد:
همانطور که در تصویر نیز ملاحظه میکنید، نیاز است در ریشهی پروژه، پوشهی جدیدی را به نام Areas ایجاد کرد. سپس در داخل این پوشه میتوان نواحی مختلفی را با پوشه بندیهای مجزایی ایجاد نمود. برای مثال در اینجا ناحیهی Blog ایجاد شدهاست که در این ناحیه نیز پوشههای Controllers و Views آن باید به صورت دستی ایجاد شوند.
افزودن مسیریابی مرتبط با Areas
پس از اضافه کردن دستی پوشههای Areas و ناحیهی جدید، به همراه ساختار پوشههای کنترلرها و Viewهای آن، اکنون نیاز است این ناحیهی جدید را به سیستم مسیریابی معرفی نمود. برای این منظور به فایل آغازین برنامه مراجعه کرده و در متد Configure آن، تعریف جدید ذیل را اضافه میکنیم:
app.UseMvc(routes => { routes.MapRoute( name: "areas", template: "{area:exists}/{controller=Home}/{action=Index}/{id?}"); routes.MapRoute( name: "default", template: "{controller=Home}/{action=Index}/{id?}"); });
علامتگذاری کنترلرهای یک ناحیه
تمام اصول کار کردن با کنترلرهای یک ناحیه، مانند سایر کنترلرهای دیگر برنامهاست؛ با یک تفاوت:
[Area("Blog")] public class HomeController : Controller { public IActionResult Index() { return View(); } }
یک نکته: اگر از Attribute routing استفاده میکنید، توکن مرتبط با نواحی، [area] نام دارد:
[Route("[area]/app/[controller]/actions/[action]/{id:weekday?}")]
فعالسازی Layout و Tag Helpers در Areas
اگر در همین حال، برنامه را اجرا و به مسیر http://localhost/blog مراجعه کنید، هرچند اطلاعات View متناظر با کنترلر Home و اکشن متد Index آن نمایش داده میشوند، اما این View نه layout دارد و نه Tag helpers آن پردازش شدهاند. برای فعالسازی این دو مورد، دو فایل ViewStart.cshtml_ و ViewImports.cshtml_ را از پوشهی views اصلی پروژه، به پوشهی views این Area جدید کپی کنید. فایل ViewStart، نام و مسیر فایل layout پیش فرض ناحیه را مشخص میکند و فایل ViewImports حاوی تعاریف فعالسازی Tag helpers است:
@addTagHelper *, Microsoft.AspNetCore.Mvc.TagHelpers
@{ Layout = "_Layout"; }
@{ Layout = "~/Areas/Blog/Views/Shared/_Layout.cshtml"; }
Areas و تاثیر آنها در حین لینک دهی به قسمتهای مختلف برنامه
اگر قرار است لینکی به قسمتی واقع در همان Area جاری مرتبط شود، نیازی نیست تا هیچ نکتهی خاصی را درنظر گرفت و تولید لینکها به نحو صحیحی صورت میگیرند:
<a asp-action="Index" asp-controller="Home">Link</a>
<a asp-area="Services" asp-controller="Home" asp-action="Index">Go to Services’ Home Page</a>
<a asp-action="Index" asp-controller="Home" asp-area="">Link</a>
تاثیر Areas بر روی تنظیمات توزیع برنامه
فایلهای View موجود در Areas نیز باید در حین توزیع نهایی برنامه ارائه شوند؛ مگر اینکه آنها را از پیش کامپایل کرده باشیم. اگر از حالت از پیش کامپایل کردن Viewها استفاده نمیشود، نیاز است قسمت publishOptions فایل project.json را به نحو ذیل در جهت الحاق فایلهای Viewهای نواحی مختلف، ویرایش و تکمیل کرد:
"publishOptions": { "include": [ "Areas/**/*.cshtml", .... .... ]
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
کتاب معرفی SQL Server 2016
Chapter one: Faster queries
Chapter two: Better security
Chapter three: Higher availability
Chapter four: Improved database engine
Chapter five: Broader data access
Chapter six: More analytics
Chapter seven: Better reporting
Chapter eight: Improved Azure SQL Database
Chapter nine: Expanding your options with Azure SQL Data Warehouse