مطالب
طراحی افزونه پذیر با ASP.NET MVC 4.x/5.x - قسمت اول
در طی چند قسمت، نحوه‌ی طراحی یک سیستم افزونه پذیر را با ASP.NET MVC بررسی خواهیم کرد. عناوین مواردی که در این سری پیاده سازی خواهند شد به ترتیب ذیل هستند:
1- چگونه Area‌های استاندارد را تبدیل به یک افزونه‌ی مجزا و منتقل شده‌ی به یک اسمبلی دیگر کنیم.
2- چگونه ساختار پایه‌ای را جهت تامین نیازهای هر افزونه جهت تزریق وابستگی‌ها تا ثبت مسیریابی‌ها و امثال آن تدارک ببینیم.
3- چگونه فایل‌های CSS ، JS و همچنین تصاویر ثابت هر افزونه را داخل اسمبلی آن قرار دهیم تا دیگر نیازی به ارائه‌ی مجزای آ‌ن‌ها نباشد.
4- چگونه Entity Framework Code-First را با این طراحی یکپارچه کرده و از آن جهت یافتن خودکار مدل‌ها و موجودیت‌های خاص هر افزونه استفاده کنیم؛ به همراه مباحث Migrations خودکار و همچنین پیاده سازی الگوی واحد کار.


در مطلب جاری، موارد اول و دوم بررسی خواهند شد. پیشنیازهای آن مطالب ذیل هستند:
الف) منظور از یک Area چیست؟
ب) توزیع پروژه‌های ASP.NET MVC بدون ارائه فایل‌های View آن
ج) آشنایی با تزریق وابستگی‌ها در ASP.NET MVC و همچنین اصول طراحی یک سیستم افزونه پذیر به کمک StructureMap
د) آشنایی با رخدادهای Build


تبدیل یک Area به یک افزونه‌ی مستقل

روش‌های زیادی برای خارج کردن Areaهای استاندارد ASP.NET MVC از یک پروژه و قرار دادن آن‌ها در اسمبلی‌های دیگر وجود دارند؛ اما در حال حاضر تنها روشی که نگهداری می‌شود و همچنین اعضای آن همان اعضای تیم نیوگت و ASP.NET MVC هستند، همان روش استفاده از Razor Generator است.
بنابراین ساختار ابتدایی پروژه‌ی افزونه پذیر ما به صورت ذیل خواهد بود:
1) ابتدا افزونه‌ی Razor Generator را نصب کنید.
2) سپس یک پروژه‌ی معمولی ASP.NET MVC را آغاز کنید. در این سری نام MvcPluginMasterApp برای آن در نظر گرفته شده‌است.
3) در ادامه یک پروژه‌ی معمولی دیگر ASP.NET MVC را نیز به پروژه‌ی جاری اضافه کنید. برای مثال نام آن در اینجا MvcPluginMasterApp.Plugin1 تنظیم شده‌است.
4) به پروژه‌ی MvcPluginMasterApp.Plugin1 یک Area جدید و معمولی را به نام NewsArea اضافه کنید.
5) از پروژه‌ی افزونه، تمام پوشه‌های غیر Area را حذف کنید. پوشه‌های Controllers و Models و Views حذف خواهند شد. همچنین فایل global.asax آن‌را نیز حذف کنید. هر افزونه، کنترلرها و Viewهای خود را از طریق Area مرتبط دریافت می‌کند و در این حالت دیگر نیازی به پوشه‌های Controllers و Models و Views واقع شده در ریشه‌ی اصلی پروژه‌ی افزونه نیست.
6) در ادامه کنسول پاور شل نیوگت را باز کرده و دستور ذیل را صادر کنید:
  PM> Install-Package RazorGenerator.Mvc
این دستور را باید یکبار بر روی پروژه‌ی اصلی و یکبار بر روی پروژه‌ی افزونه، اجرا کنید.


همانطور که در تصویر نیز مشخص شده‌است، برای اجرای دستور نصب RazorGenerator.Mvc نیاز است هربار پروژه‌ی پیش فرض را تغییر دهید.
7) اکنون پس از نصب RazorGenerator.Mvc، نوبت به اجرای آن بر روی هر دو پروژه‌ی اصلی و افزونه است:
  PM> Enable-RazorGenerator
بدیهی است این دستور را نیز باید همانند تصویر فوق، یکبار بر روی پروژه‌ی اصلی و یکبار بر روی پروژه‌ی افزونه اجرا کنید.
همچنین هربار که View جدیدی اضافه می‌شود نیز باید این‌کار را تکرار کنید یا اینکه مطابق شکل زیر، به خواص View جدید مراجعه کرده و Custom tool آن‌را به صورت دستی به RazorGenerator تنظیم نمائید. دستور Enable-RazorGenerator این‌کار را به صورت خودکار انجام می‌دهد.


تا اینجا موفق شدیم Viewهای افزونه را داخل فایل dll آن مدفون کنیم. به این ترتیب با کپی کردن افزونه به پوشه‌ی bin پروژه‌ی اصلی، دیگر نیازی به ارائه‌ی فایل‌های View آن نیست و تمام اطلاعات کنترلرها، مدل‌ها و Viewها به صورت یکجا از فایل dll افزونه‌ی ارائه شده خوانده می‌شوند.


کپی کردن خودکار افزونه به پوشه‌ی Bin پروژه‌ی اصلی

پس از اینکه ساختار اصلی کار شکل گرفت، هربار پس از کامپایل افزونه (یا افزونه‌ها)، نیاز است فایل‌های پوشه‌ی bin آن‌را به پوشه‌ی bin پروژه‌ی اصلی کپی کنیم (پروژه‌ی اصلی در این حالت هیچ ارجاع مستقیمی را به افزونه‌ی جدید نخواهد داشت). برای خودکار سازی این کار، به خواص پروژه‌ی افزونه مراجعه کرده و قسمت Build events آن‌را به نحو ذیل تنظیم کنید:


در اینجا دستور ذیل در قسمت Post-build event نوشته شده است:
 Copy "$(ProjectDir)$(OutDir)$(TargetName).*" "$(SolutionDir)MvcPluginMasterApp\bin\"
و سبب خواهد شد تا پس از هر کامپایل موفق، فایل‌های اسمبلی افزونه به پوشه‌ی bin پروژه‌ی MvcPluginMasterApp به صورت خودکار کپی شوند.


تنظیم فضاهای نام کلیه مسیریابی‌های پروژه

در همین حالت اگر پروژه را اجرا کنید، موتور ASP.NET MVC به صورت خودکار اطلاعات افزونه‌ی کپی شده به پوشه‌ی bin را دریافت و به Application domain جاری اعمال می‌کند؛ برای اینکار نیازی به کد نویسی اضافه‌تری نیست و خودکار است. برای آزمایش آن فقط کافی است یک break point را داخل کلاس RazorGeneratorMvcStart افزونه قرار دهید.
اما ... پس از اجرا، بلافاصله پیام تداخل فضاهای نام را دریافت می‌کنید. خطاهای حاصل عنوان می‌کند که در App domain جاری، دو کنترلر Home وجود دارند؛ یکی در پروژه‌ی اصلی و دیگری در پروژه‌ی افزونه و مشخص نیست که مسیریابی‌ها باید به کدامیک ختم شوند.
برای رفع این مشکل، به فایل NewsAreaAreaRegistration.cs پروژه‌ی افزونه مراجعه کرده و مسیریابی آن‌را به نحو ذیل تکمیل کنید تا فضای نام اختصاصی این Area صریحا مشخص گردد.
using System.Web.Mvc;
 
namespace MvcPluginMasterApp.Plugin1.Areas.NewsArea
{
    public class NewsAreaAreaRegistration : AreaRegistration
    {
        public override string AreaName
        {
            get
            {
                return "NewsArea";
            }
        }
 
        public override void RegisterArea(AreaRegistrationContext context)
        {
            context.MapRoute(
                "NewsArea_default",
                "NewsArea/{controller}/{action}/{id}",
                // تکمیل نام کنترلر پیش فرض
                new { controller = "Home", action = "Index", id = UrlParameter.Optional },
                // مشخص کردن فضای نام مرتبط جهت جلوگیری از تداخل با سایر قسمت‌های برنامه
                namespaces: new[] { string.Format("{0}.Controllers", this.GetType().Namespace) }
            );
        }
    }
}
همینکار را باید در پروژه‌ی اصلی و هر پروژه‌ی افزونه‌ی جدیدی نیز تکرار کرد. برای مثال به فایل RouteConfig.cs پروژه‌ی اصلی مراجعه کرده و تنظیم ذیل را اعمال نمائید:
using System.Web.Mvc;
using System.Web.Routing;
 
namespace MvcPluginMasterApp
{
    public class RouteConfig
    {
        public static void RegisterRoutes(RouteCollection routes)
        {
            routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
 
            routes.MapRoute(
                name: "Default",
                url: "{controller}/{action}/{id}",
                defaults: new { controller = "Home", action = "Index", id = UrlParameter.Optional },
                // مشخص کردن فضای نام مرتبط جهت جلوگیری از تداخل با سایر قسمت‌های برنامه
                namespaces: new[] { string.Format("{0}.Controllers", typeof(RouteConfig).Namespace) }
            );
        }
    }
}
بدون تنظیم فضاهای نام هر مسیریابی، امکان استفاده‌ی بهینه و بدون خطا از Areaها وجود نخواهد داشت.


طراحی قرارداد پایه افزونه‌ها

تا اینجا با نحوه‌ی تشکیل ساختار هر پروژه‌ی افزونه آشنا شدیم. اما هر افزونه در آینده نیاز به مواردی مانند منوی اختصاصی در منوی اصلی سایت، تنظیمات مسیریابی اختصاصی، تنظیمات EF و  امثال آن نیز خواهد داشت. به همین منظور، یک پروژه‌ی class library جدید را به نام MvcPluginMasterApp.PluginsBase آغاز کنید.
سپس قرار داد IPlugin را به نحو ذیل به آن اضافه نمائید:
using System;
using System.Reflection;
using System.Web.Optimization;
using System.Web.Routing;
using StructureMap;
 
namespace MvcPluginMasterApp.PluginsBase
{
    public interface IPlugin
    {
        EfBootstrapper GetEfBootstrapper();
        MenuItem GetMenuItem(RequestContext requestContext);
        void RegisterBundles(BundleCollection bundles);
        void RegisterRoutes(RouteCollection routes);
        void RegisterServices(IContainer container);
    }
 
    public class EfBootstrapper
    {
        /// <summary>
        /// Assemblies containing EntityTypeConfiguration classes.
        /// </summary>
        public Assembly[] ConfigurationsAssemblies { get; set; }
 
        /// <summary>
        /// Domain classes.
        /// </summary>
        public Type[] DomainEntities { get; set; }
 
        /// <summary>
        /// Custom Seed method.
        /// </summary>
        //public Action<IUnitOfWork> DatabaseSeeder { get; set; }
    }
 
    public class MenuItem
    {
        public string Name { set; get; }
        public string Url { set; get; }
    }
}
پروژه‌ی این قرارداد برای کامپایل شدن، نیاز به بسته‌های نیوگت ذیل دارد:
PM> install-package EntityFramework
PM> install-package Microsoft.AspNet.Web.Optimization
PM> install-package structuremap.web
همچنین باید به صورت دستی، در قسمت ارجاعات پروژه، ارجاعی را به اسمبلی استاندارد System.Web نیز به آن اضافه نمائید.


توضیحات قرار داد IPlugin

از این پس هر افزونه باید دارای کلاسی باشد که از اینترفیس IPlugin مشتق می‌شود. برای مثال فعلا کلاس ذیل را به افزونه‌ی پروژه اضافه نمائید:
using System.Web.Mvc;
using System.Web.Optimization;
using System.Web.Routing;
using MvcPluginMasterApp.PluginsBase;
using StructureMap;
 
namespace MvcPluginMasterApp.Plugin1
{
    public class Plugin1 : IPlugin
    {
        public EfBootstrapper GetEfBootstrapper()
        {
            return null;
        }
 
        public MenuItem GetMenuItem(RequestContext requestContext)
        {
            return new MenuItem
            {
                Name = "Plugin 1",
                Url = new UrlHelper(requestContext).Action("Index", "Home", new { area = "NewsArea" })
            };
        }
 
        public void RegisterBundles(BundleCollection bundles)
        {
            //todo: ...
        }
 
        public void RegisterRoutes(RouteCollection routes)
        {
            //todo: add custom routes.
        }
 
        public void RegisterServices(IContainer container)
        {
            // todo: add custom services.
 
            container.Configure(cfg =>
            {
                //cfg.For<INewsService>().Use<EfNewsService>();
            });
        }
    }
}
در قسمت جاری فقط از متد GetMenuItem آن استفاده خواهیم کرد. در قسمت‌های بعد، تنظیمات EF، تنظیمات مسیریابی‌ها و Bundling و همچنین ثبت سرویس‌های افزونه را نیز بررسی خواهیم کرد.
برای اینکه هر افزونه در منوی اصلی ظاهر شود، نیاز به یک نام، به همراه آدرسی به صفحه‌ی اصلی آن خواهد داشت. به همین جهت در متد GetMenuItem نحوه‌ی ساخت آدرسی را به اکشن متد Index کنترلر Home واقع در Area‌ایی به نام NewsArea، مشاهده می‌کنید.


بارگذاری و تشخیص خودکار افزونه‌ها

پس از اینکه هر افزونه دارای کلاسی مشتق شده از قرارداد IPlugin شد، نیاز است آن‌ها را به صورت خودکار یافته و سپس پردازش کنیم. این‌کار را به کتابخانه‌ی StructureMap واگذار خواهیم کرد. برای این منظور پروژه‌ی جدیدی را به نام MvcPluginMasterApp.IoCConfig آغاز کرده و سپس تنظیمات آن‌را به نحو ذیل تغییر دهید:
using System;
using System.IO;
using System.Threading;
using System.Web;
using MvcPluginMasterApp.PluginsBase;
using StructureMap;
using StructureMap.Graph;
 
namespace MvcPluginMasterApp.IoCConfig
{
    public static class SmObjectFactory
    {
        private static readonly Lazy<Container> _containerBuilder =
            new Lazy<Container>(defaultContainer, LazyThreadSafetyMode.ExecutionAndPublication);
 
        public static IContainer Container
        {
            get { return _containerBuilder.Value; }
        }
 
        private static Container defaultContainer()
        {
            return new Container(cfg =>
            {
                cfg.Scan(scanner =>
                {
                    scanner.AssembliesFromPath(
                        path: Path.Combine(HttpRuntime.AppDomainAppPath, "bin"),
                            // یک اسمبلی نباید دوبار بارگذاری شود
                        assemblyFilter: assembly =>
                        {
                            return !assembly.FullName.Equals(typeof(IPlugin).Assembly.FullName);
                        });
 
                    scanner.WithDefaultConventions(); //Connects 'IName' interface to 'Name' class automatically.
                    scanner.AddAllTypesOf<IPlugin>().NameBy(item => item.FullName);
                });
            });
        }
    }
}
این پروژه‌ی class library جدید برای کامپایل شدن نیاز به بسته‌های نیوگت ذیل دارد:
PM> install-package EntityFramework
PM> install-package structuremap.web
همچنین باید به صورت دستی، در قسمت ارجاعات پروژه، ارجاعی را به اسمبلی استاندارد System.Web نیز به آن اضافه نمائید.

کاری که در کلاس SmObjectFactory انجام شده، بسیار ساده است. مسیر پوشه‌ی Bin پروژه‌ی اصلی به structuremap معرفی شده‌است. سپس به آن گفته‌ایم که تنها اسمبلی‌هایی را که دارای اینترفیس IPlugin هستند، به صورت خودکار بارگذاری کن. در ادامه تمام نوع‌های IPlugin را نیز به صورت خودکار یافته و در مخزن تنظیمات خود، اضافه کن.


تامین نیازهای مسیریابی و Bundling هر افزونه به صورت خودکار

در ادامه به پروژه‌ی اصلی مراجعه کرده و در پوشه‌ی App_Start آن کلاس ذیل را اضافه کنید:
using System.Linq;
using System.Web.Optimization;
using System.Web.Routing;
using MvcPluginMasterApp;
using MvcPluginMasterApp.IoCConfig;
using MvcPluginMasterApp.PluginsBase;
 
[assembly: WebActivatorEx.PostApplicationStartMethod(typeof(PluginsStart), "Start")]
 
namespace MvcPluginMasterApp
{
    public static class PluginsStart
    {
        public static void Start()
        {
            var plugins = SmObjectFactory.Container.GetAllInstances<IPlugin>().ToList();
            foreach (var plugin in plugins)
            {
                plugin.RegisterServices(SmObjectFactory.Container);
                plugin.RegisterRoutes(RouteTable.Routes);
                plugin.RegisterBundles(BundleTable.Bundles);
            }
        }
    }
}
بدیهی است در این حالت نیاز است ارجاعی را به پروژه‌ی MvcPluginMasterApp.PluginsBase به پروژه‌ی اصلی اضافه کنیم.
دراینجا با استفاده از کتابخانه‌ای به نام WebActivatorEx (که باز هم توسط نویسندگان اصلی Razor Generator تهیه شده‌است)، یک متد PostApplicationStartMethod سفارشی را تعریف کرده‌ایم.
مزیت استفاده از اینکار این است که فایل Global.asax.cs برنامه شلوغ نخواهد شد. در غیر اینصورت باید تمام این کدها را در انتهای متد Application_Start قرار می‌دادیم.
در اینجا با استفاده از structuremap، تمام افزونه‌های موجود به صورت خودکار بررسی شده و سپس پیشنیازهای مسیریابی و Bundling و همچنین تنظیمات IoC Container مورد نیاز آن‌ها به هر افزونه به صورت مستقل، تزریق خواهد شد.


اضافه کردن منو‌های خودکار افزونه‌ها به پروژه‌ی اصلی

پس از اینکه کار پردازش اولیه‌ی IPluginها به پایان رسید، اکنون نوبت به نمایش آدرس اختصاصی هر افزونه در منوی اصلی سایت است. برای این منظور فایل جدیدی را به نام PluginsMenu.cshtml_، در پوشه‌ی shared پروژه‌ی اصلی اضافه کنید؛ با این محتوا:
@using MvcPluginMasterApp.IoCConfig
@using MvcPluginMasterApp.PluginsBase
@{
    var plugins = SmObjectFactory.Container.GetAllInstances<IPlugin>().ToList();
}
 
@foreach (var plugin in plugins)
{
    var menuItem = plugin.GetMenuItem(this.Request.RequestContext);
    <li>
        <a href="@menuItem.Url">@menuItem.Name</a>
    </li>
}
در اینجا تمام افزونه‌ها به کمک structuremap یافت شده و سپس آیتم‌های منوی آن‌ها به صورت خودکار دریافت و اضافه می‌شوند.
سپس به فایل Layout.cshtml_ پروژه‌ی اصلی مراجعه و توسط فراخوانی Html.RenderPartial، آن‌را در بین سایر آیتم‌های منوی اصلی اضافه می‌کنیم:
<div class="navbar navbar-inverse navbar-fixed-top">
    <div class="container">
        <div class="navbar-header">
            <button type="button" class="navbar-toggle" data-toggle="collapse" data-target=".navbar-collapse">
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
                <span class="icon-bar"></span>
            </button>
            @Html.ActionLink("MvcPlugin Master App", "Index", "Home", new { area = "" }, new { @class = "navbar-brand" })
        </div>
        <div class="navbar-collapse collapse">
            <ul class="nav navbar-nav">
                <li>@Html.ActionLink("Master App/Home", "Index", "Home", new {area = ""}, null)</li>
                @{ Html.RenderPartial("_PluginsMenu"); }
            </ul>
        </div>
    </div>
</div>
اکنون اگر پروژه را اجرا کنیم، یک چنین شکلی را خواهد داشت:



بنابراین به صورت خلاصه

1) هر افزونه، یک پروژه‌ی کامل ASP.NET MVC است که پوشه‌های ریشه‌ی اصلی آن حذف شده‌اند و اطلاعات آن توسط یک Area جدید تامین می‌شوند.
2) تنظیم فضای نام مسیریابی‌های تمام پروژه‌ها را فراموش نکنید. در غیر اینصورت شاهد تداخل پردازش کنترلرهای هم نام خواهید بود.
3) جهت سهولت کار، می‌توان فایل‌های bin هر افزونه را توسط رخداد post-build، به پوشه‌ی bin پروژه‌ی اصلی کپی کرد.
4) Viewهای هر افزونه توسط Razor Generator در فایل dll آن مدفون خواهند شد.
5) هر افزونه باید دارای کلاسی باشد که اینترفیس IPlugin را پیاده سازی می‌کند. از این اینترفیس برای ثبت اطلاعات هر افزونه یا دریافت اطلاعات سفارشی از آن کمک می‌گیریم.
6) با استفاده از استراکچرمپ و قرارداد IPlugin، منوهای هر افزونه را به صورت خودکار یافته و سپس به فایل layout اصلی اضافه می‌کنیم.



کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید:
MvcPluginMasterApp-Part1.zip
مطالب
ذخیره و بازیابی فایل در Mongodb (بخش سوم)
در قسمت‌های پیشین (^ ،^ ) در مورد عملیات CRUD در سطح دیتابیس و به طور کلی در مورد ایندکس گذاری صحبت کردیم. در این بخش قصد داریم یکی از موارد بسیار مهم، یعنی ذخیره‌ی فایل‌های باینری را در دیتابیس، مورد بررسی قرار دهیم. روش‌های مختلفی برای اینکار وجود دارند؛ ولی بعضی از این روش‌ها در حال حاضر منسوخ شده اعلام شده‌اند که در اینجا ما آخرین روش را که در حال حاضر هیچ ویژگی منسوخ شده‌ای ندارد، به کار می‌گیریم.
از آنجاکه نهایت اندازه‌ی یک سند BSON نمی‌تواند بیشتر از 16 مگابایت باشد، قابلیتی به نام GridFS ایجاد شده‌است تا بتوان فایل‌های باینری را در آن ذخیره کرد. GridFS شامل دو بخش مختلف برای ذخیره اطلاعات یک فایل باینری است:
- fs.chunks که برای ذخیره اطلاعات قطعه‌های یک فایل باینری به کار میرود.
- fs.files که برای ذخیره اطلاعات و متادیتاها به کار می‌رود.

قبل از هر چیزی باید بدانید که کتابخانه مربوط به GridFs در یک پکیج جداگانه عرضه شده است و باید آن از طریق nuget نصب کنیم:
install-package MongoDB.Driver.GridFS

برای آپلود یک فایل باینری به داخل سیستم از کد زیر استفاده میکنیم:
var client = new MongoClient();
var db = client.GetDatabase("publisher");
IGridFSBucket bucket = new GridFSBucket(db);
ابتدا یک شیء GridFsBucket را ایجاد میکنیم که از ما اطلاعات دیتابیس مورد نظر را برای ارسال فایل میخواهد و نتیجه‌ی آن یک کلاس از جنس اینترفیس IGridFSBucket می‌باشد. این باکت یا سطل در واقع همانند کالکشن رفتار میکند.
byte[] source=File.ReadAllBytes(@"D:\Untitled.png");
var options = new GridFSUploadOptions
            {
                ChunkSizeBytes = 64512, // 63KB
                Metadata = new BsonDocument
                {
                    { "CoverType", "Front" }, 
                    { "copyrighted", true }
                }
            };
در مرحله بعد فایل باینری را به صورت آرایه‌ای از بایت‌ها میخوانیم (البته حالت‌های مختلفی چون استریم را نیز پشتیبانی میکند). بعد از خواندن فایل، یک شیء از جنس کلاس GridFSUploadOptions را ایجاد و اطلاعات فایل آپلودی را مشخص می‌کنیم. به عنوان مثال اولین خصوصیتی که پر میکنیم خصوصیت تعیین سایز قطعات فایل باینری می‌باشد که به طور پیشفرض بر روی 64 مگابایت قرار گرفته است و عموما هم برای اکثر موارد، پاسخگوی نیاز‌ها است (در بخش بعدی مقالات، بیشتر این مورد را بررسی میکنیم).
مورد دومی که مقداردهی شده‌است، متادیتا‌ها هستند و این قابلیت را داریم که پرس و جوی خود را بر اساس آن‌ها نیز فیلتر کنیم. این خصوصیت مقدار دریافتی از جنس BsonDocument را دریافت میکند. ولی اگر شما برای فایل خود، کلاس اختصاصی برای متادیتاها در نظر گرفته‌اید میتوانید از یک Extension Method به نام ToBsonDocument استفاده کنید و شیء خود را به این نوع تبدیل کنید:
 var options = new GridFSUploadOptions
            {
                ChunkSizeBytes = 64512, // 63KB
                Metadata = metaData.ToBsonDocument()
            };


در نهایت آن را آپلود میکنیم:
 var id = bucket.UploadFromBytes("GoneWithTheWind", source, options);
پارامتر اول آن، نامی برای بسته آپلودی است. پارامتر دوم، خود فایل آپلودی میباشد و با پارمتر آخر هم تنظیماتی را که برای فایل مورد نظر توسط کلاس GridFSUploadOptions  تعیین کرده‌ایم، مشخص میکنیم. موقعیکه آپلود انجام شود، به ازای این کد، یک شناسه‌ی اختصاصی از جنس ObjectId را دریافت میکنیم که می‌توانیم آن را به یک خصوصیت در سند اصلی نسبت دهیم تا ارتباط بین سند و فایل هایش را داشته باشیم.

نکته: اگر در یک کلاس، چند فیلد از جنس ObjectId دارید، مونگو در بین تشخیص شناسه اصلی سند و شناسه تصاویر، با توجه به نام خصوصیت‌ها و غیره، تا حد زیادی هوشمند عمل میکند. ولی اگر خواستید صریحا شناسه اصلی را ذکر کنید و آن را متمایز از بقیه نشان دهید، می‌توانید از خصوصیت BsonId در بالای نام فیلد ID استفاده کنید:
[BsonId]
public ObjectId Id { get; set; }

جهت خواندن فایل آپلود شده، تنها کافی است از طریق شناسه‌ی دریافتی در مرحله‌ی آپلود، اقدام نماییم:
var bytes = bucket.DownloadAsBytes(id);

نکته: تمام متدهای آپلود و دانلود دیتا، هم به صورت آرایه ای از بایت‌ها و هم به صورت استریم میتوانند مورد استفاده قرار بگیرند و به ازای هر کدام، متدهای همزمان و غیرهمزمان نیز موجود هستند.


اگر قصد دارید بر اساس نام داده شده، فایل را دریافت کنید، ممکن است که چندین فایل، تحت یک نام ذخیره شده باشند که میتوانید در حالت‌های مختلفی این تصاویر را واکشی نمایید:
0 : فایل اصلی
1: اولین نسخه فایل
2: دومین نسخه فایل
و الی آخر...

1-: جدیدترین نسخه فایل (مقدار پیش فرض)
2-: نسخه ماقبل جدیدترین نسخه فایل
و الی آخر...


منظور از نسخه، فایل‌هایی با نامی موجود و از قبل ذخیره شده هستند که نسخه جدیدی از فایل قبلی بوده و فایل اول، فایل اصلی محسوب میشود.
برای درک بهتر مسئله، من تصاویر زیر را به ترتیب از سمت راست به سمت چپ، تحت یک نام، وارد سیستم میکنم:


اولین تصویر، تصویر اصلی محسوب می‌شود و بعد از آن، نسخه اول و نسخه دوم تصویر، وارد سیستم می‌شوند و تکه کد زیر از آنجاکه با مقدار پیش فرض پر شده‌است باید آخرین تصویر، تصویر سمت چپ را برای شما بر روی دیسک ذخیره کند:
var client = new MongoClient();
var db = client.GetDatabase("publisher");
IGridFSBucket bucket = new GridFSBucket(db);
var image=bucket.DownloadAsBytesByName("City of Glass-cover");
File.WriteAllBytes(@"D:\a.jpg",image);

برای مقداردهی خواص بالا به شکل زیر عمل میکنیم:
var client = new MongoClient();
var db = client.GetDatabase("publisher");
IGridFSBucket bucket = new GridFSBucket(db);
var options = new GridFSDownloadByNameOptions
            {
                Revision = 0
            };
var image=bucket.DownloadAsBytesByName("City of Glass-cover",options);
File.WriteAllBytes(@"D:\a.jpg",image);
با پاس کردن مقدار 0 به مونگو، اولین تصویر وارد شده، یعنی تصویر اصلی را دریافت می‌کنیم که اولین تصویر از سمت راست می‌شود. اگر مقادیر 1 یا 1- را پاس دهیم، چون تنها سه تصویر بیشتر نیست، در هر دو حالت تصویر دوم بازگردانده می‌شود.

برای بازگردانی تصاویر از طریق مقادیر موجود در متادیتا، باید از کلاس ویژه‌ای به نام GridFSFileInfo استفاده کنیم. در اینجا هم همانند روزهای اول، از کلاس بیلدر جهت ایجاد شرط استفاده میکنیم:
var client = new MongoClient();
var db = client.GetDatabase("publisher");
IGridFSBucket bucket = new GridFSBucket(db);
var filter = Builders<GridFSFileInfo>.Filter.Gte(x => x.Length , 600);
var sort = Builders<GridFSFileInfo>.Sort.Descending(x => x.UploadDateTime);
var options =new GridFSFindOptions()
            {
                Limit = 3,
                Sort = sort
            };
var cursor = bucket.Find(filter, options);
var list = cursor.ToList();
در اینجا ابتدا مشخص کرده‌ایم که فایل مورد نظر باید حجمی بیشتر از 600 بایت داشته باشد و مرتب سازی آن به صورت نزولی، بر اساس زمان آپلود باشد. در عبارت لامبدا تعریف شده، می‌توانید خصوصیت‌های مختلف یک فایل از قبیل نام، حجم (سایز) ، زمان آپلود و ... را ببینید. سپس مرتب سازی و تعداد رکورد برگشتی از ابتدای جدول را مشخص میکنیم و از متد Find یا FindAsync جهت جست‌وجو استفاده میکنیم. با شکل گرفتن کوئری درخواست، لیستی از آن را تهیه میکنیم. مقدار بازگشتی این شیء، در واقع اسنادی از تصاویر هستند که میتوانید از طریق Id یا نام آن‌ها، فایل اصلی را واکشی نمایید.
برای یافتن تصاویر بر اساس متادیتاهای تعریف شده، از کد زیر استفاده میکنیم:
var filter = Builders<GridFSFileInfo>.Filter.Eq("metadata.CoverType","Front");

تغییر نام تصاویر
جهت ویرایش یک نام فایل از طریق متدهای زیر اقدام می‌نماییم:
bucket.Rename(id, newFilename);

//یا در حالت غیرهمزمان
await bucket.RenameAsync(id, newFilename);

حذف تصویر
bucket.Delete(id);

//یا
await bucket.DeleteAsync(id);

برای حذف کل bucket از طریق کد زیر اقدام می‌نماییم:
bucket.Drop();

//یا
await bucket.DropAsync();
 
در بخش بعدی Chunk را مورد بررسی قرار می‌دهیم.
مطالب
امن سازی برنامه‌های ASP.NET Core توسط IdentityServer 4x - قسمت هفتم- امن سازی Web API
تا اینجا بجای قرار دادن مستقیم قسمت مدیریت هویت کاربران، داخل یک یا چند برنامه‌ی مختلف، این دغدغه‌ی مشترک (common concern) بین برنامه‌ها را به یک برنامه‌ی کاملا مجزای دیگری به نام Identity provider و یا به اختصار IDP منتقل و همچنین دسترسی به کلاینت MVC برنامه‌ی گالری تصاویر را نیز توسط آن امن سازی کردیم. اما هنوز یک قسمت باقی مانده‌است: برنامه‌ی کلاینت MVC، منابع خودش را از یک برنامه‌ی Web API دیگر دریافت می‌کند و هرچند دسترسی به برنامه‌ی MVC امن شده‌است، اما دسترسی به منابع برنامه‌ی Web API آن کاملا آزاد و بدون محدودیت است. بنابراین امن سازی Web API را توسط IDP، در این قسمت پیگیری می‌کنیم. پیش از مطالعه‌ی این قسمت نیاز است مطلب «آشنایی با JSON Web Token» را مطالعه کرده و با ساختار ابتدایی یک JWT آشنا باشید.


بررسی Hybrid Flow جهت امن سازی Web API

این Flow را پیشتر نیز مرور کرده بودیم. تفاوت آن با قسمت‌های قبل، در استفاده از توکن دومی است به نام access token که به همراه identity token از طرف IDP صادر می‌شود و تا این قسمت از آن بجز در قسمت «دریافت اطلاعات بیشتری از کاربران از طریق UserInfo Endpoint» استفاده نکرده بودیم.


در اینجا، ابتدا برنامه‌ی وب، یک درخواست اعتبارسنجی را به سمت IDP ارسال می‌کند که response type آن از نوع code id_token است (یا همان مشخصه‌ی Hybrid Flow) و همچنین تعدادی scope نیز جهت دریافت claims متناظر با آن‌ها در این درخواست ذکر شده‌اند. در سمت IDP، کاربر با ارائه‌ی مشخصات خود، اعتبارسنجی شده و پس از آن IDP صفحه‌ی اجازه‌ی دسترسی به اطلاعات کاربر (صفحه‌ی consent) را ارائه می‌دهد. پس از آن IDP اطلاعات code و id_token را به سمت برنامه‌ی وب ارسال می‌کند. در ادامه کلاینت وب، توکن هویت رسیده را اعتبارسنجی می‌کند. پس از موفقیت آمیز بودن این عملیات، اکنون کلاینت درخواست دریافت یک access token را از IDP ارائه می‌دهد. اینکار در پشت صحنه و بدون دخالت کاربر صورت می‌گیرد که به آن استفاده‌ی از back channel هم گفته می‌شود. یک چنین درخواستی به token endpoint، شامل اطلاعات code و مشخصات دقیق کلاینت جاری است. به عبارتی نوعی اعتبارسنجی هویت برنامه‌ی کلاینت نیز می‌باشد. در پاسخ، دو توکن جدید را دریافت می‌کنیم: identity token و access token. در اینجا access token توسط خاصیت at_hash موجود در id_token به آن لینک می‌شود. سپس هر دو توکن اعتبارسنجی می‌شوند. در این مرحله، میان‌افزار اعتبارسنجی، هویت کاربر را از identity token استخراج می‌کند. به این ترتیب امکان وارد شدن به برنامه‌ی کلاینت میسر می‌شود. در اینجا همچنین access token ای نیز صادر شده‌است.
اکنون علاقمند به کار با Web API برنامه‌ی کلاینت MVC خود هستیم. برای این منظور access token که اکنون در برنامه‌ی MVC Client در دسترس است، به صورت یک Bearer token به هدر ویژه‌ای با کلید Authorization اضافه می‌شود و به همراه هر درخواست، به سمت API ارسال خواهد شد. در سمت Web API این access token رسیده، اعتبارسنجی می‌شود و در صورت موفقیت آمیز بودن عملیات، دسترسی به منابع Web API صادر خواهد شد.


امن سازی دسترسی به Web API

تنظیمات برنامه‌ی IDP
برای امن سازی دسترسی به Web API از کلاس src\IDP\DNT.IDP\Config.cs در سطح IDP شروع می‌کنیم. در اینجا باید یک scope جدید مخصوص دسترسی به منابع Web API را تعریف کنیم:
namespace DNT.IDP
{
    public static class Config
    {
        // api-related resources (scopes)
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource(
                    name: "imagegalleryapi",
                    displayName: "Image Gallery API",
                    claimTypes: new List<string> {"role" })
            };
        }
هدف آن داشتن access token ای است که در قسمت Audience آن، نام این ApiResource، درج شده باشد؛ پیش از اینکه دسترسی به API را پیدا کند. برای تعریف آن، متد جدید GetApiResources را به صورت فوق به کلاس Config اضافه می‌کنیم.
پس از آن در قسمت تعریف کلاینت، مجوز درخواست این scope جدید imagegalleryapi را نیز صادر می‌کنیم:
AllowedScopes =
{
  IdentityServerConstants.StandardScopes.OpenId,
  IdentityServerConstants.StandardScopes.Profile,
  IdentityServerConstants.StandardScopes.Address,
  "roles",
  "imagegalleryapi"
},
اکنون باید متد جدید GetApiResources را به کلاس src\IDP\DNT.IDP\Startup.cs معرفی کنیم که توسط متد AddInMemoryApiResources به صورت زیر قابل انجام است:
namespace DNT.IDP
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddMvc();

            services.AddIdentityServer()
             .AddDeveloperSigningCredential()
             .AddTestUsers(Config.GetUsers())
             .AddInMemoryIdentityResources(Config.GetIdentityResources())
             .AddInMemoryApiResources(Config.GetApiResources())
             .AddInMemoryClients(Config.GetClients());
        }

تنظیمات برنامه‌ی MVC Client
اکنون نوبت انجام تنظیمات برنامه‌ی MVC Client در فایل ImageGallery.MvcClient.WebApp\Startup.cs است. در اینجا در متد AddOpenIdConnect، درخواست scope جدید imagegalleryapi را صادر می‌کنیم:
options.Scope.Add("imagegalleryapi");

تنظیمات برنامه‌ی Web API
اکنون می‌خواهیم مطمئن شویم که Web API، به access token ای که قسمت Audience آن درست مقدار دهی شده‌است، دسترسی خواهد داشت.
برای این منظور به پوشه‌ی پروژه‌ی Web API در مسیر src\WebApi\ImageGallery.WebApi.WebApp وارد شده و دستور زیر را صادر کنید تا بسته‌ی نیوگت AccessTokenValidation نصب شود:
dotnet add package IdentityServer4.AccessTokenValidation
اکنون کلاس startup در سطح Web API را در فایل src\WebApi\ImageGallery.WebApi.WebApp\Startup.cs به صورت زیر تکمیل می‌کنیم:
using IdentityServer4.AccessTokenValidation;

namespace ImageGallery.WebApi.WebApp
{
    public class Startup
    {
        public void ConfigureServices(IServiceCollection services)
        {
            services.AddAuthentication(defaultScheme: IdentityServerAuthenticationDefaults.AuthenticationScheme)
               .AddIdentityServerAuthentication(options =>
               {
                   options.Authority = Configuration["IDPBaseAddress"];
                   options.ApiName = "imagegalleryapi";
               });
متد AddAuthentication یک defaultScheme را تعریف می‌کند که در بسته‌ی IdentityServer4.AccessTokenValidation قرار دارد و این scheme در اصل دارای مقدار Bearer است.
سپس متد AddIdentityServerAuthentication فراخوانی شده‌است که به آدرس IDP اشاره می‌کند که مقدار آن‌را در فایل appsettings.json قرار داده‌ایم. از این آدرس برای بارگذاری متادیتای IDP استفاده می‌شود. کار دیگر این میان‌افزار، اعتبارسنجی access token رسیده‌ی به آن است. مقدار خاصیت ApiName آن، به نام API resource تعریف شده‌ی در سمت IDP اشاره می‌کند. هدف این است که بررسی شود آیا خاصیت aud موجود در access token رسیده به مقدار imagegalleryapi تنظیم شده‌است یا خیر؟

پس از تنظیم این میان‌افزار، اکنون نوبت به افزودن آن به ASP.NET Core request pipeline است:
namespace ImageGallery.WebApi.WebApp
{
    public class Startup
    {
        public void Configure(IApplicationBuilder app, IHostingEnvironment env)
        {
            app.UseAuthentication();
محل فراخوانی UseAuthentication باید پیش از فراخوانی app.UseMvc باشد تا پس از اعتبارسنجی درخواست، به میان‌افزار MVC منتقل شود.

اکنون می‌توانیم اجبار به Authorization را در تمام اکشن متدهای این Web API در فایل ImageGallery.WebApi.WebApp\Controllers\ImagesController.cs فعالسازی کنیم:
namespace ImageGallery.WebApi.WebApp.Controllers
{
    [Route("api/images")]
    [Authorize]
    public class ImagesController : Controller
    {


ارسال Access Token به همراه هر درخواست به سمت Web API

تا اینجا اگر مراحل اجرای برنامه‌ها را طی کنید، مشاهده خواهید کرد که برنامه‌ی MVC Client دیگر کار نمی‌کند و نمی‌تواند از فیلتر Authorize فوق رد شود. علت اینجا است که در حال حاضر، تمامی درخواست‌های رسیده‌ی به Web API، فاقد Access token هستند. بنابراین اعتبارسنجی آن‌ها با شکست مواجه می‌شود.
برای رفع این مشکل، سرویس ImageGalleryHttpClient را به نحو زیر اصلاح می‌کنیم تا در صورت وجود Access token، آن‌را به صورت خودکار به هدرهای ارسالی توسط HttpClient اضافه کند:
using System;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Authentication;
using Microsoft.AspNetCore.Http;
using Microsoft.Extensions.Configuration;
using Microsoft.IdentityModel.Protocols.OpenIdConnect;

namespace ImageGallery.MvcClient.Services
{
    public interface IImageGalleryHttpClient
    {
        Task<HttpClient> GetHttpClientAsync();
    }

    /// <summary>
    /// A typed HttpClient.
    /// </summary>
    public class ImageGalleryHttpClient : IImageGalleryHttpClient
    {
        private readonly HttpClient _httpClient;
        private readonly IConfiguration _configuration;
        private readonly IHttpContextAccessor _httpContextAccessor;

        public ImageGalleryHttpClient(
            HttpClient httpClient,
            IConfiguration configuration,
            IHttpContextAccessor httpContextAccessor)
        {
            _httpClient = httpClient;
            _configuration = configuration;
            _httpContextAccessor = httpContextAccessor;
        }

        public async Task<HttpClient> GetHttpClientAsync()
        {
            var currentContext = _httpContextAccessor.HttpContext;
            var accessToken = await currentContext.GetTokenAsync(OpenIdConnectParameterNames.AccessToken);
            if (!string.IsNullOrWhiteSpace(accessToken))
            {
                _httpClient.SetBearerToken(accessToken);
            }

            _httpClient.BaseAddress = new Uri(_configuration["WebApiBaseAddress"]);
            _httpClient.DefaultRequestHeaders.Accept.Clear();
            _httpClient.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json"));

            return _httpClient;
        }
    }
}
اسمبلی این سرویس برای اینکه به درستی کامپایل شود، نیاز به این وابستگی‌ها نیز دارد:
<Project Sdk="Microsoft.NET.Sdk">
  <PropertyGroup>
    <TargetFramework>netstandard2.0</TargetFramework>
  </PropertyGroup>
  <ItemGroup>
    <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="2.1.1.0" />
    <PackageReference Include="Microsoft.AspNetCore.Http" Version="2.1.1.0" />
    <PackageReference Include="Microsoft.AspNetCore.Authentication.Abstractions" Version="2.1.1.0" />
    <PackageReference Include="Microsoft.IdentityModel.Protocols.OpenIdConnect" Version="5.2.0.0" />
    <PackageReference Include="IdentityModel" Version="3.9.0" />
  </ItemGroup>
</Project>
در اینجا با استفاده از سرویس IHttpContextAccessor، به HttpContext جاری درخواست دسترسی یافته و سپس توسط متد GetTokenAsync، توکن دسترسی آن‌را استخراج می‌کنیم. سپس این توکن را در صورت وجود، توسط متد SetBearerToken به عنوان هدر Authorization از نوع Bearer، به سمت Web API ارسال خواهیم کرد.
البته پس از این تغییرات نیاز است به کنترلر گالری مراجعه و از متد جدید GetHttpClientAsync بجای خاصیت HttpClient قبلی استفاده کرد.

اکنون اگر برنامه را اجرا کنیم، پس از لاگین، دسترسی به Web API امن شده، برقرار شده و برنامه بدون مشکل کار می‌کند.


بررسی محتوای Access Token

اگر بر روی سطر if (!string.IsNullOrWhiteSpace(accessToken)) در سرویس ImageGalleryHttpClient یک break-point را قرار دهیم و محتویات Access Token را در حافظه ذخیره کنیم، می‌توانیم با مراجعه‌ی به سایت jwt.io، محتویات آن‌را بررسی نمائیم:


که در حقیقت این محتوا را به همراه دارد:
{
  "nbf": 1536394771,
  "exp": 1536398371,
  "iss": "https://localhost:6001",
  "aud": [
    "https://localhost:6001/resources",
    "imagegalleryapi"
  ],
  "client_id": "imagegalleryclient",
  "sub": "d860efca-22d9-47fd-8249-791ba61b07c7",
  "auth_time": 1536394763,
  "idp": "local",  
  "role": "PayingUser",
  "scope": [
    "openid",
    "profile",
    "address",
    "roles",
    "imagegalleryapi"
  ],
  "amr": [
    "pwd"
  ]
}
در اینجا در لیست scope، مقدار imagegalleryapi وجود دارد. همچنین در قسمت audience و یا aud نیز ذکر شده‌است. بنابراین یک چنین توکنی قابلیت دسترسی به Web API تنظیم شده‌ی ما را دارد.
همچنین اگر دقت کنید، Id کاربر جاری در خاصیت sub آن قرار دارد.


مدیریت صفحه‌ی عدم دسترسی به Web API

با اضافه شدن scope جدید دسترسی به API در سمت IDP، این مورد در صفحه‌ی دریافت رضایت کاربر نیز ظاهر می‌شود:


در این حالت اگر کاربر این گزینه را انتخاب نکند، پس از هدایت به برنامه‌ی کلاینت، در سطر response.EnsureSuccessStatusCode استثنای زیر ظاهر خواهد شد:
An unhandled exception occurred while processing the request.
HttpRequestException: Response status code does not indicate success: 401 (Unauthorized).
 System.Net.Http.HttpResponseMessage.EnsureSuccessStatusCode()
برای اینکه این صفحه‌ی نمایش استثناء را با صفحه‌ی عدم دسترسی جایگزین کنیم، می‌توان پس از دریافت response از سمت Web API، به StatusCode مساوی Unauthorized = 401 به صورت زیر عکس‌العمل نشان داد:
        public async Task<IActionResult> Index()
        {
            var httpClient = await _imageGalleryHttpClient.GetHttpClientAsync();
            var response = await httpClient.GetAsync("api/images");

            if (response.StatusCode == System.Net.HttpStatusCode.Unauthorized ||
                response.StatusCode == System.Net.HttpStatusCode.Forbidden)
            {
                return RedirectToAction("AccessDenied", "Authorization");
            }
            response.EnsureSuccessStatusCode();


فیلتر کردن تصاویر نمایش داده شده بر اساس هویت کاربر وارد شده‌ی به سیستم

تا اینجا هرچند دسترسی به API امن شده‌است، اما هنوز کاربر وارد شده‌ی به سیستم می‌تواند تصاویر سایر کاربران را نیز مشاهده کند. بنابراین قدم بعدی امن سازی API، عکس العمل نشان دادن به هویت کاربر جاری سیستم است.
برای این منظور به کنترلر ImageGallery.WebApi.WebApp\Controllers\ImagesController.cs سمت API مراجعه کرده و Id کاربر جاری را از لیست Claims او استخراج می‌کنیم:
namespace ImageGallery.WebApi.WebApp.Controllers
{
    [Route("api/images")]
    [Authorize]
    public class ImagesController : Controller
    {
        [HttpGet()]
        public async Task<IActionResult> GetImages()
        {
            var ownerId = this.User.Claims.FirstOrDefault(claim => claim.Type == "sub").Value;
اگر به قسمت «بررسی محتوای Access Token» مطلب جاری دقت کنید، مقدار Id کاربر در خاصیت sub این Access token قرار گرفته‌است که روش دسترسی به آن‌را در ابتدای اکشن متد GetImages فوق ملاحظه می‌کنید.
مرحله‌ی بعد، مراجعه به ImageGallery.WebApi.Services\ImagesService.cs و تغییر متد GetImagesAsync است تا صرفا بر اساس ownerId دریافت شده کار کند:
namespace ImageGallery.WebApi.Services
{
    public class ImagesService : IImagesService
    {
        public Task<List<Image>> GetImagesAsync(string ownerId)
        {
            return _images.Where(image => image.OwnerId == ownerId).OrderBy(image => image.Title).ToListAsync();
        }
پس از این تغییرات، اکشن متد GetImages سمت API چنین پیاده سازی را پیدا می‌کند که در آن بر اساس Id شخص وارد شده‌ی به سیستم، صرفا لیست تصاویر مرتبط با او بازگشت داده خواهد شد و نه لیست تصاویر تمام کاربران سیستم:
namespace ImageGallery.WebApi.WebApp.Controllers
{
    [Route("api/images")]
    [Authorize]
    public class ImagesController : Controller
    {
        [HttpGet()]
        public async Task<IActionResult> GetImages()
        {
            var ownerId = this.User.Claims.FirstOrDefault(claim => claim.Type == "sub").Value;
            var imagesFromRepo = await _imagesService.GetImagesAsync(ownerId);
            var imagesToReturn = _mapper.Map<IEnumerable<ImageModel>>(imagesFromRepo);
            return Ok(imagesToReturn);
        }
اکنون اگر از برنامه‌ی کلاینت خارج شده و مجددا به آن وارد شویم، تنها لیست تصاویر مرتبط با کاربر وارد شده، نمایش داده می‌شوند.

هنوز یک مشکل دیگر باقی است: سایر اکشن متدهای این کنترلر Web API همچنان محدود به کاربر جاری نشده‌اند. یک روش آن تغییر دستی تمام کدهای آن است. در این حالت متد IsImageOwnerAsync زیر، جهت بررسی اینکه آیا رکورد درخواستی متعلق به کاربر جاری است یا خیر، به سرویس تصاویر اضافه می‌شود:
namespace ImageGallery.WebApi.Services
{
    public class ImagesService : IImagesService
    {
        public Task<bool> IsImageOwnerAsync(Guid id, string ownerId)
        {
            return _images.AnyAsync(i => i.Id == id && i.OwnerId == ownerId);
        }
و سپس در تمام اکشن متدهای دیگر، در ابتدای آن‌ها باید این بررسی را انجام دهیم و در صورت شکست آن return Unauthorized را بازگشت دهیم.
اما روش بهتر انجام این عملیات را که در قسمت بعدی بررسی می‌کنیم، بر اساس بستن دسترسی ورود به اکشن متدها بر اساس Authorization policy است. در این حالت اگر کاربری مجوز انجام عملیاتی را نداشت، اصلا وارد کدهای یک اکشن متد نخواهد شد.


ارسال سایر User Claims مانند نقش‌ها به همراه یک Access Token

برای تکمیل قسمت ارسال تصاویر می‌خواهیم تنها کاربران نقش خاصی قادر به انجام اینکار باشند. اما اگر به محتوای access token ارسالی به سمت Web API دقت کرده باشید، حاوی Identity claims نیست. البته می‌توان مستقیما در برنامه‌ی Web API با UserInfo Endpoint، برای دریافت اطلاعات بیشتر، کار کرد که نمونه‌ای از آن‌را در قسمت قبل مشاهده کردید، اما مشکل آن زیاد شدن تعداد رفت و برگشت‌های به سمت IDP است. همچنین باید درنظر داشت که فراخوانی مستقیم UserInfo Endpoint جهت برنامه‌ی MVC client که درخواست دریافت access token را از IDP می‌دهد، متداول است و نه برنامه‌ی Web API.
برای رفع این مشکل باید در حین تعریف ApiResource، لیست claim مورد نیاز را هم ذکر کرد:
namespace DNT.IDP
{
    public static class Config
    {
        // api-related resources (scopes)
        public static IEnumerable<ApiResource> GetApiResources()
        {
            return new List<ApiResource>
            {
                new ApiResource(
                    name: "imagegalleryapi",
                    displayName: "Image Gallery API",
                    claimTypes: new List<string> {"role" })
            };
        }
در اینجا ذکر claimTypes است که سبب خواهد شد نقش کاربر جاری به توکن دسترسی اضافه شود.

سپس کار با اکشن متد CreateImage در سمت API را به نقش PayingUser محدود می‌کنیم:
namespace ImageGallery.WebApi.WebApp.Controllers
{
    [Route("api/images")]
    [Authorize]
    public class ImagesController : Controller
    {
        [HttpPost]
        [Authorize(Roles = "PayingUser")]
        public async Task<IActionResult> CreateImage([FromBody] ImageForCreationModel imageForCreation)
        {
همچنین در این اکشن متد، پیش از فراخوانی متد AddImageAsync نیاز است مشخص کنیم OwnerId این تصویر کیست تا رکورد بانک اطلاعاتی تصویر آپلود شده، دقیقا به اکانت متناظری در سمت IDP مرتبط شود:
var ownerId = User.Claims.FirstOrDefault(c => c.Type == "sub").Value;
imageEntity.OwnerId = ownerId;
// add and save.
await _imagesService.AddImageAsync(imageEntity);

نکته‌ی مهم: در اینجا نباید این OwnerId را از سمت برنامه‌ی کلاینت MVC به سمت برنامه‌ی Web API ارسال کرد. برنامه‌ی Web API باید این اطلاعات را از access token اعتبارسنجی شده‌ی رسیده استخراج و استفاده کند؛ از این جهت که دستکاری اطلاعات اعتبارسنجی نشده‌ی ارسالی به سمت Web API ساده‌است؛ اما access tokenها دارای امضای دیجیتال هستند.

در سمت کلاینت نیز در فایل ImageGallery.MvcClient.WebApp\Views\Shared\_Layout.cshtml نمایش لینک افزودن تصویر را نیز محدود به PayingUser می‌کنیم:
@if(User.IsInRole("PayingUser"))
{
  <li><a asp-area="" asp-controller="Gallery" asp-action="AddImage">Add an image</a></li>
  <li><a asp-area="" asp-controller="Gallery" asp-action="OrderFrame">Order a framed picture</a></li>
}
علاوه بر آن، در کنترلر ImageGallery.MvcClient.WebApp\Controllers\GalleryController.cs نیاز است فیلتر Authorize زیر نیز به اکشن متد نمایش صفحه‌ی AddImage اضافه شود تا فراخوانی مستقیم آدرس آن در مرورگر، توسط سایر کاربران میسر نباشد:
namespace ImageGallery.MvcClient.WebApp.Controllers
{
    [Authorize]
    public class GalleryController : Controller
    {
        [Authorize(Roles = "PayingUser")]
        public IActionResult AddImage()
        {
            return View();
        }
این مورد را باید به متد AddImage در حالت دریافت اطلاعات از کاربر نیز افزود تا اگر شخصی مستقیما با این قسمت کار کرد، حتما سطح دسترسی او بررسی شود:
[HttpPost]
[Authorize(Roles = "PayingUser")]
[ValidateAntiForgeryToken]
public async Task<IActionResult> AddImage(AddImageViewModel addImageViewModel)

برای آزمایش این قسمت یکبار از برنامه خارج شده و سپس با اکانت User 1 که PayingUser است به سیستم وارد شوید. در ادامه از منوی بالای سایت، گزینه‌ی Add an image را انتخاب کرده و تصویری را آپلود کنید. پس از آن، این تصویر آپلود شده را در لیست تصاویر صفحه‌ی اول سایت، مشاهده خواهید کرد.


کدهای کامل این قسمت را از اینجا می‌توانید دریافت کنید.
برای اجرای برنامه:
- ابتدا به پوشه‌ی src\WebApi\ImageGallery.WebApi.WebApp وارد شده و dotnet_run.bat آن‌را اجرا کنید تا WebAPI برنامه راه اندازی شود.
- سپس به پوشه‌ی src\IDP\DNT.IDP مراجعه کرده و و dotnet_run.bat آن‌را اجرا کنید تا برنامه‌ی IDP راه اندازی شود.
- در آخر به پوشه‌ی src\MvcClient\ImageGallery.MvcClient.WebApp وارد شده و dotnet_run.bat آن‌را اجرا کنید تا MVC Client راه اندازی شود.
اکنون که هر سه برنامه در حال اجرا هستند، مرورگر را گشوده و مسیر https://localhost:5001 را درخواست کنید. در صفحه‌ی login نام کاربری را User 1 و کلمه‌ی عبور آن‌را password وارد کنید.
مطالب
نحوه کار با ftp - بخش اول
امروز می‌خوام نحوه کار با FTP بصورت ساده برای کاربران و برنامه نویسان مبتدی رو آموزش بدم.

برای استفاده از FTP نیاز به یک اکانت FTP در سایت مورد نظر بهمراه دسترسی به پوشه ای مشخص می‌باشد.
برای مثال ما یک اکانت FTP در سایت dotnettip.info داریم که به پوشه upload دسترسی داره.

ابتدا در فایل Web.config و در بین تگ های  appSettings مقادیر زیر را برای دسترسی به اکانت و نام کاربری و رمز عبور ذخیره می‌کنیم.
<add key="FtpAddress" value="ftp://ftp.dotnetips.info" />
<add key="FtpUser" value="uploadcenter" />
<add key="FtpPass" value="123123" />
<add key="FolderPath" value="~/Upload/" />
*نکته : برای امنیت بیشتر و دسترسی به اطلاعات اکانت می‌شود از روش‌های دیگری نیز استفاده کرد.

در ادامه یک کلاس در App_code  پروژه خود با نام FTPHelper ایجاد می‌کنیم و کد زیر را در آن قرار می‌دهیم:
تکه کد بالا برای ست کردن مقادیر نام کاربری و رمز عبور و آدرس FTP در کلاس مذکور که بصورت پیش‌فرض از web.config پر می‌شود ایجاد و بکار خواهد رفت.
using System.Net;
using System.IO;
using System.Configuration; 


public class FtpHelper
{
public FtpHelper()
{
       //Default Value Set From Application 
         _hostname = ConfigurationManager.AppSettings.GetValues("FtpAddress")[0]; 
        _username = ConfigurationManager.AppSettings.GetValues("FtpUser")[0]; 
        _password = ConfigurationManager.AppSettings.GetValues("FtpPass")[0];  
}
    
    #region "Properties"
    private string _hostname;
    /// <summary>
    /// Hostname
    /// </summary>
    /// <value></value>
    /// <remarks>Hostname can be in either the full URL format
    /// ftp://ftp.myhost.com or just ftp.myhost.com
    /// </remarks>
    public string Hostname
    {
        get
        {
            if (_hostname.StartsWith("ftp://"))
            {
                return _hostname;
            }
            else
            {
                return "ftp://" + _hostname;
            }
        }
        set
        {
            _hostname = value;
        }
    }
    private string _username;
    /// <summary>
    /// Username property
    /// </summary>
    /// <value></value>
    /// <remarks>Can be left blank, in which case 'anonymous' is returned</remarks>
    public string Username
    {
        get
        {
            return (_username == "" ? "anonymous" : _username);
        }
        set
        {
            _username = value;
        }
    }
    private string _password;
    public string Password
    {
        get
        {
            return _password;
        }
        set
        {
            _password = value;
        }
    }


    #endregion
}


سپس فضای نام‌های زیر را در کلاس خود قرار می‌دهیم.
using System.Net;
using System.IO;
 
حالا برای بارگذاری فایل می‌توانیم از یک تابع بصورت shared استفاده کنیم که بتوان با دادن آدرس فایل بصورت فیزیکی به تابع و مشخص کردن پوشه مورد نظر آنرا در هاست مقصد (FTP) بارگذاری کرد.توجه داشته باشیذ که تابع فوق نیازی به قرار گرفتن در کلاس بالا (FtpHelper) ندارد.یعنی می‌توان آنرا در هرجای برنامه پیاده سازی نمود.
public static bool Upload(string fileUrl)
        {
            if (File.Exists(fileUrl))
            {
                FtpHelper ftpClient = new FtpHelper();
                string ftpUrl = ftpClient.Hostname + System.IO.Path.GetFileName(fileUrl);


                FtpWebRequest ftp = (FtpWebRequest)FtpWebRequest.Create(ftpUrl);
                ftp.Credentials = new NetworkCredential(ftpClient.Username, ftpClient.Password);

                ftp.KeepAlive = true;
                ftp.UseBinary = true;
                ftp.Timeout = 3600000;
                ftp.KeepAlive = true;
                ftp.Method = WebRequestMethods.Ftp.UploadFile;

                const int bufferLength = 102400;
                byte[] buffer = new byte[bufferLength];
                int readBytes = 0;

                //open file for reading
                using (FileStream fs = File.OpenRead(fileUrl))
                {
                    try
                    {
                        //open request to send
                        using (Stream rs = ftp.GetRequestStream())
                        {
                            do
                            {
                                readBytes = fs.Read(buffer, 0, bufferLength);
                                fs.Write(buffer, 0, readBytes);
                            } while (!(readBytes < bufferLength));
                            rs.Close();
                        }

                    }
                    catch (Exception)
                    {
                     //Optional Alert for Exeption To Application Layer
                       //throw (new ApplicationException("بارگذاری فایل با خطا  رو به رو شد"));
 
                    }
                    finally
                    {
                        //ensure file closed
                        //fs.Close();
                    }

                }

                ftp = null;
                return true;
            }
            return false;



        }


تکه کد بالا فایل مورد نظر را در صورت وجود به صورت تکه‌های 100 کیلوبایتی بر روی ftp بارگذاری می‌کند، که می‌توانید مقدار آنرا نیز تغییر دهید.
اینکار باعث افزایش سرعت بارگذاری در فایل‌های با حجم بالا برای بارگذاری می‌شود.

در بخش‌های بعدی نحوه ایجاد پوشه ، حذف فایل ، حذف پوشه و دانلود فایل از روی FTP را بررسی خواهیم کرد.
مطالب
استفاده از Razor در فایل Css
در مقاله «استفاده از Razor در فایل‌های JavaScript و CSS» با نحوه‌ی استفاده از Razor در فایل‌های Js و Css آشنا شدید. در مقاله‌ی جاری با روش دیگری، با نحوه‌ی استفاده از Syntax Razor در فایل‌های Css آشنا خواهید شد.

در ابتدا بعد از ایجاد یک پروژه‌ی جدید، نیاز دارید تا اسمبلی RazorEngin را توسط Package Manager Console به پروژه اضافه نماید.
Install-Package RazorEngine -Version 3.7.0



در گام بعدی نیاز است در کنترلری، یک اکشن متد را تعریف نماید که خروجی آن از نوع رشته خواهد بود و دستورات زیر در آن تعریف می‌شوند:
using System.Web.Mvc;
using RazorEngine;

namespace dynamicCSS.Controllers
{
    public class StyleController : Controller
    {
        /// <summary>
        /// نام متد ارجاعی به فایل سی اس اس 
        /// </summary>
        /// <returns></returns>
        public string Index()
        {
            //The ContentType property specifies the HTTP content type for the response. If no ContentType is specified, the default is text/HTML.  
            Response.ContentType = "text/css";
            //با استفاه از متد           
            //ReadAllText
            //فایل رو خوانده و سپس از متد 
            //Parse in Razor Class
            //به صورت رشته برگشت خواهیم داد
             return Razor.Parse(System.IO.File.ReadAllText(Server.MapPath("/Content/Site.css")));
        }
    }
}
در خط 21، فایل Css موجود در پوشه‌ی Content واقع در ریشه‌ی پروژه، خوانده شده و با متد Parse در کلاس Razor پردازش و بازگشت داده می‌شود. در کد زیر تمامی متدهای موجود در کلاس Razor را می‌توانید ملاحظه کنید:
#region Assembly RazorEngine.dll, v2.1.4039.23635
// Your Address\dynamicCSS\packages\RazorEngine.2.1\lib\.NetFramework 4.0\RazorEngine.dll
#endregion

using RazorEngine.Templating;
using System;
using System.Collections.Generic;

namespace RazorEngine
{
    public static class Razor
    {
        public static TemplateService DefaultTemplateService { get; }
        public static IDictionary<string, TemplateService> Services { get; }

        public static void AddResolver(Func<string, string> resolverDelegate);
        public static void AddResolver(ITemplateResolver resolver);
        public static void Compile(string template, string name);
        public static void Compile(string template, Type modelType, string name);
        public static void CompileWithAnonymous(string template, string name);
        public static string Parse(string template, string name = null);
        public static string Parse<T>(string template, T model, string name = null);
        public static string Run(string name);
        public static string Run<T>(T model, string name);
        public static void SetActivator(Func<Type, ITemplate> activator);
        public static void SetActivator(IActivator activator);
        public static void SetTemplateBase(Type type);
    }
}


در این حالت می‌توان از دستورات Razor در فایل Css نیز استفاده کرد:
@{
    // در اینجا دو متغییر با کلمه کلیدی 
    // var
    // ساخته و به صورت پیش فرض مقدار دهی نمودیم
    var  redColor = "red";
    var sizeMode = "100px";
}

h1 {
 // روش استفاده از متغییر‌ها 
  color: @redColor !important;
  font-size : @sizeModel !impotant;
 }
و در انتها می‌بایست در Layout پروژه، آدرس فایل Css را مشخص کرد:
//تغییر ادرس فایل به اکشن متد در  کنترلر
//Home
//<link href="/Content/Site.Css" rel="stylesheet" />
//شکل صحیح آدرس دهی
<link href="@Url.Action("Style", "Home")" rel="stylesheet" />

نکته: در صورتیکه متغیری بعد از دستورات استفاده شده تعریف گردد، با خطای زیر روبرو خواهید شد:




در خروجی نهایی تگ h1  با فونت 100 پیکسل و رنگ قرمز به نمایش در می‌آید:


Image

 

 :در صورتیکه خروجی نهایی به شکل صحیح اجرا نگردید، برای تست صحیح بودن گام‌های قبلی می‌توانید اکشن متد را در مرورگر اجرا کنید
 localhost:1599/Home/Style
مطالب
طراحی یک گرید با jQuery Ajax و ASP.NET MVC به همراه پیاده سازی عملیات CRUD

هدف، ارائه راه‌حلی برای نمایش جدولی اطلاعات، جستجو، مرتب سازی و صفحه بندی و همچنین انجام عملیات ثبت، ویرایش و حذف بر روی آنها به صورت Ajaxای در بخش back office نرم افزار می‌باشد.

پیش نیازها:

ایده کار به این شکل می‌باشد که برای نمایش اطلاعات به صورت جدولی با قابلیت‌های مذکور، لازم است یک اکشن Index برای نمایش اولیه و صفحه اول اطلاعات صفحه بندی شده و اکشن متدی به نام List برای پاسخ به درخواست‌های صفحه بندی، مرتب سازی، تغییر تعداد آیتم‌ها در هر صفحه و همچنین جستجو، داشته باشیم که این اکشن متد List، بعد از واکشی اطلاعات مورد نظر از منبع داده، آنها را به همراه اطلاعاتی که در کوئری استرینگ درخواست جاری وجود دارد در قالب یک PartialView به کلاینت ارسال کند.


ایجاد مدل‌های پایه

همانطور که در مقاله «طراحی و پیاده سازی ServiceLayer به همراه خودکارسازی Business Validationها» مطرح شد، برای پیاده سازی متدهای GetPagedList در ApplicationService‌ها از الگوی Request/Response استفاده می‌کنیم. برای این منظور واسط و کلاس‌های زیر را خواهیم داشت:

واسط IPagedQueryModel

    public interface IPagedQueryModel
    {
        int Page { get; set; }
        int PageSize { get; set; }

        /// <summary>
        ///     Expression of Sorting.
        /// </summary>
        /// <example>
        ///     Examples:
        ///     "Name_ASC"
        /// </example>
        string SortExpression { get; set; }
    }

این واسط قراردادی می‌باشد برای نوع و نام پارامترهایی که توسط کلاینت به سرور ارسال می‌شود. پراپرتی SortExpression آن، نام و ترتیب مرتب سازی را مشخص می‌کند؛ برای این منظور FieldName_ASC و FieldName_DESC به ترتیب برای حالات مرتب سازی صعودی و نزولی براساس FieldName مقدار دهی خواهد شد.

برای جلوگیری از تکرار این خصوصیات در مدل‌های کوئری مربوط به موجودیت‌ها، میتوان کلاس پایه‌ای به شکل زیر در نظر گرفت که پیاده ساز واسط بالا می‌باشد:

  public class PagedQueryModel : IPagedQueryModel, IShouldNormalize
    {
        public int Page { get; set; }
        public int PageSize { get; set; }

        /// <summary>
        ///     Expression of Sorting.
        /// </summary>
        /// <example>
        ///     Examples:
        ///     "Name_ASC"
        /// </example>
        public string SortExpression { get; set; }

        public virtual void Normalize()
        {
            if (Page < 1)
                Page = 1;

            if (PageSize < 1)
                PageSize = 10;

            if (SortExpression.IsEmpty())
                SortExpression = "Id_DESC";
        }
    }

مدل بالا علاوه بر پیاده سازی واسط IPagedQueryModel، پیاده ساز واسط IShouldNormalize نیز می‌باشد؛ دلیل وجود چنین واسطی در مقاله «طراحی و پیاده سازی ServiceLayer به همراه خودکارسازی Business Validationها» توضیح داده شده است:

پیاده سازی واسط IShouldNormalize باعث خواهد شد که قبل از اجرای خود متد، این نوع پارامترها با استفاده از یک Interceptor شناسایی شده و متد Normalize آنها اجرا شود.

کلاس PagedQueryResult

    public class PagedQueryResult<TModel>
    {
        public PagedQueryResult()
        {
            Items = new List<TModel>();
        }
        public IEnumerable<TModel> Items { get; set; }
        public long TotalCount { get; set; }
    }

دلیل وجود کلاس بالا در مقاله «طراحی یک گرید با Angular و ASP.NET Core - قسمت اول - پیاده سازی سمت سرور» توضیح داده شده است:

عموما ساختار اطلاعات صفحه بندی شده، شامل تعداد کل آیتم‌های تمام صفحات (خاصیت TotalItems) و تنها اطلاعات ردیف‌های صفحه‌ی جاری درخواستی (خاصیت Items) است و چون در اینجا این Items از هر نوعی می‌تواند باشد، بهتر است آن‌را جنریک تعریف کنیم.

کلاس PagedListModel

همانطور که در اول بحث توضیح داده شد، لازم است اطلاعاتی را که کلاینت از طریق کوئری استرینگ برای صفحه بندی و ... ارسال کرده بود نیز به PartialView ارسال کنیم. این قسمت کار ایده اصلی این روش را در بر می‌گیرد؛ اگر نخواهیم اطلاعات کوئری استرینگ دریافتی از کلاینت را دوباره به PartialView ارسال کنیم، مجبور خواهیم بود تمام کارهای مربوط به تشخیص آیکن مرتب سازی ستون‌های جدول، ریست کردن المنت‌های مربوط به صفحه بندی و مرتب سازی را در در زمان انجام جستجو  و یکسری کارهای از این قبل را در سمت کلاینت مدیریت کنیم که هدف مقاله جاری پیاده سازی این روش نمی‌باشد.

    public class PagedListModel<TModel>
    {
        public IPagedQueryModel Query { get; set; }

        public PagedQueryResult<TModel> Result { get; set; }
    }

پراپرتی Query در برگیرنده پارامتر ورودی اکشن متد List می‌باشد که پراپرتی‌های آن با مقادیر موجود در کوئری استرینگ درخواست جاری مقدار دهی شده‌اند؛ البته بدون وجود کلاس بالا نیز به کمک ViewBag می‌شود این اطلاعات ترکیبی را به ویو ارسال کرد که پیشنهاد نمی‌شود.


متد GetPagedListAsync موجود در CrudApplicationService

    public abstract class CrudApplicationService<TEntity, TModel, TCreateModel, TEditModel, TDeleteModel,
        TPagedQueryModel, TDynamicQueryModel> : ApplicationService,
        ICrudApplicationService<TModel, TCreateModel, TEditModel, TDeleteModel, TPagedQueryModel, TDynamicQueryModel>
        where TEntity : Entity, new()
        where TCreateModel : class
        where TEditModel : class, IModel
        where TModel : class, IModel
        where TDeleteModel : class, IModel
        where TPagedQueryModel : PagedQueryModel, new()
        where TDynamicQueryModel : DynamicQueryModel

    {

        #region Properties

        protected IQueryable<TEntity> UnTrackedEntitySet => EntitySet.AsNoTracking();
        public IUnitOfWork UnitOfWork { get; set; }
        public IMapper Mapper { get; set; }
        protected IDbSet<TEntity> EntitySet => UnitOfWork.Set<TEntity>();

        #endregion

        #region ICrudApplicationService Members

        #region Methods

        public virtual async Task<PagedQueryResult<TModel>> GetPagedListAsync(TPagedQueryModel model)
        {
            Guard.ArgumentNotNull(model, nameof(model));

            var query = ApplyFiltering(model);

            var totalCount = await query.LongCountAsync().ConfigureAwait(false);

            var result = query.ProjectTo<TModel>(Mapper.ConfigurationProvider);

            result = result.ApplySorting(model);
            result = result.ApplyPaging(model);

            return new PagedQueryResult<TModel>
            {
                Items = await result.ToListAsync().ConfigureAwait(false),
                TotalCount = totalCount
            };
        }
        #endregion

        #endregion

        #region Protected Methods

        /// <summary>
        ///     Apply Filtering To GetPagedList and GetPagedListAsync
        /// </summary>
        /// <param name="model"></param>
        /// <returns></returns>
        protected virtual IQueryable<TEntity> ApplyFiltering(TPagedQueryModel model)
        {
            Guard.ArgumentNotNull(model, nameof(model));

            return UnTrackedEntitySet;
        }
        #endregion
    }

در بدنه این متد، ابتدا عملیات جستجو توسط متد ApplyFiltering انجام می‌شود. این متد به صورت پیش فرض هیچ شرطی را بر روی کوئری ارسالی به منبع داده اعمال نمی‌کند؛ مگر اینکه توسط زیر کلاس‌ها بازنویسی شود و فیلترهای مورد نیاز اعمال شوند. سپس تعداد کل آیتم‌های فیلتر شده محاسبه شده و بعد از عملیات Projection، مرتب سازی و صفحه بندی انجام می‌گیرد. برای مباحث مرتب سازی و صفحه بندی از دو متد زیر کمک گرفته شده‌است:

    public static class QueryableExtensions
    {
        public static IQueryable<TModel> ApplySorting<TModel>(this IQueryable<TModel> query, IPagedQueryModel request)
        {
            Guard.ArgumentNotNull(request, nameof(request));
            Guard.ArgumentNotNull(query, nameof(query));

            return query.OrderBy(request.SortExpression.Replace('_', ' '));
        }

        public static IQueryable<TModel> ApplyPaging<TModel>(this IQueryable<TModel> query, IPagedQueryModel request)
        {
            Guard.ArgumentNotNull(request, nameof(request));
            Guard.ArgumentNotNull(query, nameof(query));

            return request != null
                ? query.Page((request.Page - 1) * request.PageSize, request.PageSize)
                : query;
        }
    }

به منظور مرتب سازی از کتابخانه  System.Liq.Dynamic کمک گرفته شده‌است.

نکته: مشخص است که این روش، وابستگی به وجود متد GetPagedListAsync ندارد و صرفا برای تشریح ارتباط مطالبی که قبلا منتشر شده بود، مطرح شد.


پیاده سازی اکشن متدهای Index و List

public partial class RolesController : BaseController
{
    #region Fields
        private readonly IRoleService _service;
        private readonly ILookupService _lookupService;

        #endregion

    #region Constractor
        public RolesController(IRoleService service,  ILookupService lookupService)
        {
            Guard.ArgumentNotNull(service, nameof(service));
            Guard.ArgumentNotNull(lookupService, nameof(lookupService));

            _service = service;
            _lookupService = lookupService;
        }
        #endregion

    #region Index / List
    [HttpGet]
    public virtual async Task<ActionResult> Index()
    {
        var query = new RolePagedQueryModel();
        var result = await _service.GetPagedListAsync(query).ConfigureAwait(false);

        var pagedList = new PagedListModel<RoleModel>
        {
            Query = query,
            Result = result
        };

        var model = new RoleIndexViewModel
        {
            PagedListModel = pagedList,
            Permissions = _lookupService.GetPermissions()
        };
        return View(model);
    }
    [HttpGet, AjaxOnly, NoOutputCache]
    public virtual async Task<ActionResult> List(RolePagedQueryModel query)
    {
        var result = await _service.GetPagedListAsync(query).ConfigureAwait(false);

        var model = new PagedListModel<RoleModel>
        {
            Query = query,
            Result = result
        };

        return PartialView(MVC.Administration.Roles.Views._List, model);
    }
    #endregion
}

به عنوان مثال در بالا کنترلر مربوط به گروه‌های کاربری را مشاهده می‌کنید. به دلیل اینکه علاوه بر مباحث صفحه بندی و مرتب سازی، امکان جستجو بر اساس نام و دسترسی‌های گروه کاربری را نیز نیاز داریم، لازم است مدل زیر را ایجاد کنیم:

    public class RolePagedQueryModel : PagedQueryModel
    {
        public string Name { get; set; }
        public string Permission { get; set; }
    }

در این مورد خاص لازم است لیست دسترسی‌های موجود درسیستم به صورت لیستی برای انتخاب در فرم جستجو مهیا باشد. فرم جستجو در ویو مربوط به اکشن Index قرار می‌گیرد و قرار نیست به همراه پارشال ویو List_ در هر درخواستی از سرور دریافت شود. لذا لازم است مدلی برای ویو Index در نظر بگیریم که به شکل زیر می‌باشد:

    public class RoleIndexViewModel
    {   
        public RoleIndexViewModel()
        {
            Permissions = new List<LookupItem>();
        }
        public IReadOnlyList<LookupItem> Permissions { get; set; }
        public PagedListModel<RoleModel> PagedListModel { get; set; }
    }

پراپرتی PagedListModel در برگیرنده اطلاعات مربوط به نمایش اولیه جدول اطلاعات می‌باشد و پراپرتی Permissions لیست دسترسی‌های موجود درسیستم را به ویو منتقل خواهد کرد. اگر ویو ایندکس شما به داده اضافه ای نیاز ندارد، از ایجاد مدل بالا صرف نظر کنید.


ویو Index.cshtml

@model RoleIndexViewModel

@{
    ViewBag.Title = L("Administration.Views.Role.Index.Title");
    ViewBag.ActiveMenu = AdministrationMenuNames.RoleManagement;
}

<div class="row">
    <div class="col-md-12">
        <div id="filterPanel" class="panel-collapse collapse" role="tabpanel" aria-labelledby="filterPanel">
            <div class="panel panel-default margin-bottom-5">

                <div class="panel-body">
                    @using (Ajax.BeginForm(MVC.Administration.Roles.List(),
new AjaxOptions { UpdateTargetId = "RolesList", HttpMethod = "GET" }, new { id = "filterForm", data_submit_on_reset = "true" }))
                    {
                        <div class="row">
                            <div class="col-md-3">
                                <input type="text" name="Name" class="form-control" value="" placeholder="@L("Administration.Role.Fields.Name")" />
                            </div>
                            <div class="col-md-3">
                                @Html.DropDownList("Permission", Model.Permissions.ToSelectListItems(), L("Administration.Views.Role.FilterBy.Permission"),new {@class="form-control"})
                            </div>
                            <div class="col-md-3">

                                <button type="submit"
                                        role="button"
                                        class="btn btn-info">
                                    @L("Commands.Filter")
                                </button>
                                <button type="reset"
                                        role="button"
                                        class="btn btn-default">
                                    <i class="fa fa-close"></i>
                                    @L("Commands.Reset")
                                </button>
                            </div>
                        </div>
                    }
                </div>
            </div>
        </div>
    </div>
</div>

<div class="row">
    <div class="col-md-12" id="RolesList">
        @{Html.RenderPartial(MVC.Administration.Roles.Views._List, Model.PagedListModel);}
    </div>
</div>

فرم جستجو باید دارای ویژگی data_submit_on_reset با مقدار "true" باشد. به منظور پاکسازی فرم جستجو و ارسال درخواست جستجو با فرمی خالی از داده، برای بازگشت به حالت اولیه از تکه کد زیر استفاده خواهد شد:

  $(document).on("reset", "form[data-submit-on-reset]",
            function () {
                var form = this;
                setTimeout(function () {
                    $(form).submit();
                });
            });

در ادامه پارشال ویو List_ با داده ارسالی به ویو Index، رندر شده و کار نمایش اولیه اطلاعات به صورت جدولی به اتمام می‌رسد.


پارشال ویو List.cshtml_

@model PagedListModel<RoleModel>
@{
    Layout = null;
    var rowNumber = (Model.Query.Page - 1) * Model.Query.PageSize + 1;
    var refreshUrl = Url.Action(MVC.Administration.Roles.List().AddRouteValues(new RouteValueDictionary(Model.Query.ToDictionary())));
}

<div class="panel panel-default margin-bottom-5">
    <table class="table table-bordered table-hover" id="RolesListTable" data-ajax-refresh-url="@refreshUrl" data-ajax-refresh-update="#RolesList">
        <thead>
            <tr>
                <th style="width: 5%;">
                    #
                </th>
                <th class="col-md-3 sortable">
                    @Html.SortableColumn("Name", L("Administration.Role.Fields.Name"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
                </th>
                <th class="col-md-3 sortable">
                    @Html.SortableColumn("DisplayName", L("Administration.Role.Fields.DisplayName"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
                </th>
                <th class="col-md-3 sortable">
                    @Html.SortableColumn("IsDefault", L("Administration.Role.Fields.IsDefault"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
                </th>
               
                <th style="width: 5%;"></th>
            </tr>
        </thead>

        <tbody>
           @foreach (var role in Model.Result.Items)
            {
                <tr>
                    <td>@(rowNumber++.ToPersianNumbers())</td>
                    <td>@role.Name</td>
                    <td>@role.DisplayName</td>
                    <td class="text-center">@Html.DisplayFor(a => role.IsDefault)</td>
                    <td class="text-center operations">
                      
                        <div class="btn-group">

                            <span class="fa fa-ellipsis-h dropdown-toggle" data-toggle="dropdown" aria-haspopup="true" aria-expanded="false"></span>
                            <ul class="dropdown-menu dropdown-menu-left">
                                <li>
                                    <a href="#"
                                       role="button"
                                       data-ajax="true"
                                       data-ajax-method="GET"
                                       data-ajax-update="#main-modal div.modal-content"
                                       data-ajax-url="@Url.Action(MVC.Administration.Roles.Edit(role.Id))"
                                       data-toggle="modal"
                                       data-target="#main-modal">
                                        <i class="fa fa-pencil"></i>
                                        @L("Commands.Edit")
                                    </a>
                                </li>
                                <li>
                                    <a href="#"
                                       role="button"
                                       id="delete-@role.Id"
                                       data-delete-url="@Url.Action(MVC.Administration.Roles.Delete())"
                                       data-delete-model='{"Id":"@role.Id","RowVersion":"@Convert.ToBase64String(role.RowVersion)"}'>
                                        <i class="fa fa-trash"></i>
                                        @L("Commands.Delete")
                                    </a>
                                </li>
                            </ul>
                        </div>
                    </td>
                </tr>
            }
        </tbody>
    </table>

</div>

<div class="row">
    <div class="col-md-8">
        @Html.Pager(Model, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
        @Html.PageSize("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), new { @class = "margin-right-5" }, "filterForm")
        @Html.Refresh("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), L("Commands.Refresh"))
    </div>
</div>

به ترتیب  فایل بالا را بررسی می‌کنیم:

    var refreshUrl = Url.Action(MVC.Administration.Roles.List().AddRouteValues(new RouteValueDictionary(Model.Query.ToDictionary())));

refreshUrl برای ارسال درخواست به اکشن متد List در نظر گرفته شده‌است که در کوئری استرینگ مربوط به خود، اطلاعاتی (مرتب سازی، شماره صفحه، اطلاعات جستجو و همچنین تعداد آیتم‌های موجود در هر صفحه) را دارد که حالت فعلی گرید را می‌توانیم دوباره از سرور درخواست کنیم.

<table class="table table-bordered table-hover" id="RolesListTable" data-ajax-refresh-url="@refreshUrl" data-ajax-refresh-update="#RolesList">

دو ویژگی data-ajax-refresh-url و data-ajax-refresh-update برای جدولی که لازم است عملیات CRUD را پشتیبانی کند، لازم می‌باشد. در قسمت دوم به استفاده از این دو ویژگی در هنگام عملیات ثبت، ویرایش و حذف خواهیم پرداخت.

<th class="col-md-3 sortable">
    @Html.SortableColumn("Name", L("Administration.Role.Fields.Name"), Model.Query, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
</th>

ستونی که امکان مرتب سازی را دارد باید th آن، کلاس sortable را داشته باشد. همچنین باید از هلپری که پیاده سازی آن را در ادامه خواهیم دید، استفاده کنیم. این هلپر، نام فیلد، عنوان ستون، مدل Query و همچین یک urlFactory را در قالب یک ‎Func<RouteValueDictionary,string>‎ دریافت می‌کند.


پیاده سازی هلپر SortableColumn

        public static MvcHtmlString SortableColumn(this HtmlHelper html, string columnName,
            string columnDisplayName, IPagedQueryModel queryModel, string updateTargetId, Func<RouteValueDictionary, string> urlFactory)
        {
            var dictionary = queryModel.ToDictionary();

            var routeValueDictionary = new RouteValueDictionary(dictionary)
            {
                ["SortExpression"] = !queryModel.SortExpression.StartsWith(columnName)
                    ? $"{columnName}_DESC" : queryModel.SortExpression.EndsWith("DESC")
                        ? $"{columnName}_ASC" : queryModel.SortExpression.EndsWith("ASC")
                            ? string.Empty : $"{columnName}_DESC"
            };

            var url = urlFactory(routeValueDictionary);

            var aTag = new TagBuilder("a");
            aTag.Attributes.Add("href", "#");
            aTag.Attributes.Add("data-ajax", "true");
            aTag.Attributes.Add("data-ajax-method", "GET");
            aTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}");
            aTag.Attributes.Add("data-ajax-url", url);
            aTag.InnerHtml = columnDisplayName;

            var iconCssClass = !queryModel.SortExpression.StartsWith(columnName)
                ? "fa-sort"
                : queryModel.SortExpression.EndsWith("DESC")
                    ? "fa-sort-down"
                    : "fa-sort-up";

            var iTag = new TagBuilder("i");
            iTag.AddCssClass($"fa {iconCssClass}");

            return new MvcHtmlString($"{aTag}\n{iTag}");
        }

ابتدا مدل Query با متد الحاقی زیر تبدیل به دیکشنری می‌شود. این کار از این جهت مهم است که پراپرتی‌های لیست موجود در مدل Query، لازم است به فرم خاصی به سرور ارسال شوند که در تکه کد زیر مشخص می‌باشد.

public static IDictionary<string, object> ToDictionary(this object source)
{
    return source.ToDictionary<object>();
}

public static IDictionary<string, T> ToDictionary<T>(this object source)
{
    if (source == null)
        throw new ArgumentNullException(nameof(source));

    var dictionary = new Dictionary<string, T>();

    foreach (PropertyDescriptor property in TypeDescriptor.GetProperties(source))
    {
        AddPropertyToDictionary(property, source, dictionary);
    }
    return dictionary;
}

private static void AddPropertyToDictionary<T>(PropertyDescriptor property, object source,
    IDictionary<string, T> dictionary)
{
    var value = property.GetValue(source);

    var items = value as IEnumerable;

    if (items != null && !(items is string))
    {
        var i = 0;
        foreach (var item in items)
        {
            dictionary.Add($"{property.Name}[{i++}]", (T)item);
        }
    }
    else if (value is T)
    {
        dictionary.Add(property.Name, (T)value);
    }

}

در متد بالا، از TypeDescriptor که یکی دیگر از ابزار‌های دسترسی به متا دیتای انوع داده‌ای است، استفاده شده و خروجی نهایی آن یک دیکشنری با کلیدهایی با اسامی پراپرتی‌های وهله ورودی می‌باشد.

در ادامه پیاده سازی هلپر SortableColumn، از دیکشنری حاصل، یک وهله از RouteValueDictionary ساخته می‌شود. در زمان رندر شدن PartialView لازم است مشخص شود که برای دفعه بعدی که بر روی این ستون کلیک می‌شود، باید چه مقداری با پارامتر SortExpression موجود در کوئری استرینگ ارسال شود. از این جهت برای پشتیبانی ستون، از حالت‌های مرتب سازی صعودی، نزولی و برگشت به حالت اولیه بدون مرتب سازی، کد زیر را خواهیم داشت:

var routeValueDictionary = new RouteValueDictionary(dictionary)
{
    ["SortExpression"] = !queryModel.SortExpression.StartsWith(columnName)
        ? $"{columnName}_DESC" : queryModel.SortExpression.EndsWith("DESC")
            ? $"{columnName}_ASC" : queryModel.SortExpression.EndsWith("ASC")
                ? string.Empty : $"{columnName}_DESC"
};

در ادامه urlFactory با routeValueDictionary حاصل، Invoke می‌شود تا url نهایی برای مرتب سازی‌های بعدی را  از طریق یک لینک تزئین شده با data اتریبیوت‌های Unobtrusive Ajax در th مربوطه قرار دهیم.

برای مباحث صفحه بندی، بارگزاری مجدد و تغییر تعداد آیتم‌ها در هر صفحه، از سه هلپر زیر کمک خواهیم گرفت:

<div class="row">
    <div class="col-md-8">
        @Html.Pager(Model, "RolesList", routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)))
        @Html.PageSize("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), new { @class = "margin-right-5" }, "filterForm")
        @Html.Refresh("RolesList", Model.Query, routeValueDictionary => Url.Action(MVC.Administration.Roles.List().AddRouteValues(routeValueDictionary)), L("Commands.Refresh"))
    </div>
</div>


پیاده سازی هلپر Pager

public static MvcHtmlString Pager<TModel>(this HtmlHelper html, PagedListModel<TModel> model,
        string updateTargetId, Func<RouteValueDictionary, string> urlFactory)
{
    return html.PagedListPager(
        new StaticPagedList<TModel>(model.Result.Items, model.Query.Page, model.Query.PageSize,
            (int)model.Result.TotalCount), page =>
       {
           var dictionary = model.Query.ToDictionary();
           var routeValueDictionary = new RouteValueDictionary(dictionary) { ["Page"] = page };
           return urlFactory(routeValueDictionary);
       }, PagedListRenderOptions.EnableUnobtrusiveAjaxReplacing(
            new PagedListRenderOptions
            {
                DisplayLinkToFirstPage = PagedListDisplayMode.Always,
                DisplayLinkToLastPage = PagedListDisplayMode.Always,
                DisplayLinkToPreviousPage = PagedListDisplayMode.Always,
                DisplayLinkToNextPage = PagedListDisplayMode.Always,
                MaximumPageNumbersToDisplay = 6,
                DisplayItemSliceAndTotal = true,
                DisplayEllipsesWhenNotShowingAllPageNumbers = true,
                ItemSliceAndTotalFormat = $"تعداد کل: {model.Result.TotalCount.ToPersianNumbers()}",
                FunctionToDisplayEachPageNumber = page => page.ToPersianNumbers(),
            },
            new AjaxOptions
            {
                AllowCache = false,
                HttpMethod = "GET",
                InsertionMode = InsertionMode.Replace,
                UpdateTargetId = updateTargetId
            }));
}

در متد بالا از کتابخانه PagedList.Mvc استفاده شده‌است. یکی از overload‌های متد PagedListPager آن، یک پارامتر از نوع Func<int, string>‎ به نام generatePageUrl را دریافت می‌کند که امکان شخصی سازی فرآیند تولید لینک به صفحات بعدی و قبلی را به ما می‌دهد. ما نیز از این امکان برای افزودن اطلاعات موجود در مدل Query، به کوئری استرینگ لینک‌های تولیدی استفاده کردیم و صرفا برای لینک‌های ایجادی لازم بود مقادیر پارامتر Page موجود در کوئری استرینگ تغییر کند که در کد بالا مشخص می‌باشد.


پیاده سازی هلپر PageSize

public static MvcHtmlString PageSize(this HtmlHelper html, string updateTargetId, IPagedQueryModel queryModel, Func<RouteValueDictionary, string> urlFactory, object htmlAttributes = null, string filterFormId = null, params int[] numbers)
{
    if (numbers.Length == 0)
        numbers = new[] { 10, 20, 30, 50, 100 };

    var dictionary = queryModel.ToDictionary();

    var routeValueDictionary = new RouteValueDictionary(dictionary)
    {
        [nameof(IPagedQueryModel.Page)] = 1
    };
    routeValueDictionary.Remove(nameof(IPagedQueryModel.PageSize));

    var url = urlFactory(routeValueDictionary);

    var formTag = new TagBuilder("form");
    formTag.Attributes.Add("action", url);
    formTag.Attributes.Add("method", "GET");
    formTag.Attributes.Add("data-ajax", "true");
    formTag.Attributes.Add("data-ajax-method", "GET");
    formTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}");
    formTag.Attributes.Add("data-ajax-url", url);

    if (htmlAttributes != null)
        formTag.MergeAttributes(HtmlHelper.AnonymousObjectToHtmlAttributes(htmlAttributes));

    formTag.AddCssClass("form-inline inline");

    var items = numbers.Select(number =>
        new SelectListItem
        {
            Value = number.ToString(),
            Text = number.ToString().ToPersianNumbers(),
            Selected = queryModel.PageSize == number
        });

    formTag.InnerHtml = html.DropDownList(nameof(IPagedQueryModel.PageSize), items, new { @class = "form-control page-size", onchange = "$(this.form).submit();" }).ToString();

    if (filterFormId.IsEmpty()) return new MvcHtmlString($"{formTag}");

    // ReSharper disable once MustUseReturnValue
    var scriptBlock = $"<script type=\"text/javascript\"> if(window.jQuery){{$('form#{filterFormId}').find('input[name=\"{nameof(IPagedQueryModel.PageSize)}\"]').remove();\n $('form#{filterFormId}').append(\"<input type='hidden' name='{nameof(IPagedQueryModel.PageSize)}' value='{queryModel.PageSize}'/>\")}}</script>";

    return new MvcHtmlString($"{formTag}\n{scriptBlock}");
}

ایده کار به این صورت است که یک المنت select، درون یک المنت form قرار می‌گیرد و در زمان change آن، فرم مربوطه submit می‌شود.

    formTag.InnerHtml = html.DropDownList(nameof(IPagedQueryModel.PageSize), items, new { @class = "form-control page-size", onchange = "$(this.form).submit();" }).ToString();

در زمان تغییر تعداد نمایشی آیتم‌ها در هر صفحه، لازم است حالت فعلی گرید حفظ شود و صرفا پارامتر Page ریست شود.


نکته مهم: در این طراحی اگر فرم جستجویی دارید، در زمان جستجو هیچیک از پارامتر‌های مربوط به صفحه بندی و مرتب سازی به سرور ارسال نخواهند شد (در واقع ریست می‌شوند) و کافیست یک درخواست GET معمولی با ارسال محتویات فرم به سرور صورت گیرد؛ ولی لازم است PageSize تنظیم شده، در زمان اعمال فیلتر نیز به سرور ارسال شود. از این جهت اسکریپتی برای ایجاد یک input مخفی در فرم جستجو نیز هنگام رندر شدن PartialView در صفحه تزریق می‌شود.

  var scriptBlock = $"<script type=\"text/javascript\"> if(window.jQuery){{$('form#{filterFormId}').find('input[name=\"{nameof(IPagedQueryModel.PageSize)}\"]').remove();\n $('form#{filterFormId}').append(\"<input type='hidden' name='{nameof(IPagedQueryModel.PageSize)}' value='{queryModel.PageSize}'/>\")}}</script>";


پیاده سازی هلپر Refresh

public static MvcHtmlString Refresh(this HtmlHelper html, string updateTargetId, IPagedQueryModel queryModel,
    Func<RouteValueDictionary, string> urlFactory, string label = null)
{
    var dictionary = queryModel.ToDictionary();

    var routeValueDictionary = new RouteValueDictionary(dictionary);

    var url = urlFactory(routeValueDictionary);

    var aTag = new TagBuilder("a");
    aTag.Attributes.Add("href", "#");
    aTag.Attributes.Add("role", "button");
    aTag.Attributes.Add("data-ajax", "true");
    aTag.Attributes.Add("data-ajax-method", "GET");
    aTag.Attributes.Add("data-ajax-update", updateTargetId.StartsWith("#") ? updateTargetId : $"#{updateTargetId}");
    aTag.Attributes.Add("data-ajax-url", url);
    aTag.AddCssClass("btn btn-default");

    var iTag = new TagBuilder("i");
    iTag.AddCssClass("fa fa-refresh");

    aTag.InnerHtml = $"{iTag} {label}";

    return new MvcHtmlString(aTag.ToString());
}

متد بالا نیز به مانند refreshUrl که پیشتر مطرح شد، برای بارگزاری مجدد حالت فعلی گرید استفاده می‌شود و از این جهت است که مقادیر مربوط به کلیدهای routeValueDictionary  را تغییر نداده‌ایم.


روش دیگر برای مدیریت این چنین کارهایی، استفاده از یک المنت form و قرادادن کل گرید به همراه یک سری input مخفی معادل با پارامترهای دریافتی اکشن متد List و مقدار دهی آنها در زمان کلیک بر روی دکمه‌های صفحه بندی، بارگزاری مجدد، دکمه اعمال فیلتر و لیست آبشاری تنظیم تعداد آیتم‌ها، درون آن نیز میتواند کار ساز باشد؛ اما در زمان پیاده سازی خواهید دید که پیاده سازی آن خیلی سرراست، به مانند پیاده سازی موجود در مطلب جاری نخواهد بود. 

در قسمت دوم، به پیاده سازی عملیات ثبت، ویرایش و حذف برپایه مودال‌های بوت استرپ و افزونه Unobtrusive Ajax خواهیم پرداخت.
کدهای کامل قسمت جاری، بعد از انتشار قسمت دوم، در مخزن گیت هاب شخصی قرار خواهد گرفت.

مطالب
OpenCVSharp #15
تشخیص چهره به کمک OpenCV

OpenCV به کمک الگوریتم‌های machine learning (در اینجا Haar feature-based cascade classifiers) و داد‌ه‌های مرتبط با آن‌ها، قادر است اشیاء پیچیده‌ای را در تصاویر پیدا کند. برای پیگیری مثال بحث جاری نیاز است کتابخانه‌ی اصلی OpenCV را دریافت کنید؛ از این جهت که به فایل‌های XML موجود در پوشه‌ی opencv\sources\data\haarcascades آن نیاز داریم. در اینجا از دو فایل haarcascade_eye_tree_eyeglasses.xml و haarcascade_frontalface_alt.xml آن استفاده خواهیم کرد (این دوفایل جهت سهولت کار، به همراه مثال این بحث نیز ارائه شده‌اند).
فایل haarcascade_frontalface_alt.xml اصطلاحا trained data مخصوص یافتن چهره‌ی انسان در تصاویر است و فایل haarcascade_eye_tree_eyeglasses.xml کمک می‌کند تا بتوان در چهره‌ی یافت شده، چشمان شخص را نیز با دقت بالایی تشخیص داد؛ چیزی همانند تصویر ذیل:



مراحل تشخیص چهره توسط OpenCVSharp

ابتدا همانند سایر مثال‌های OpenCV، تصویر مدنظر را به کمک کلاس Mat بارگذاری می‌کنیم:
var srcImage = new Mat(@"..\..\Images\Test.jpg");
Cv2.ImShow("Source", srcImage);
Cv2.WaitKey(1); // do events
 
var grayImage = new Mat();
Cv2.CvtColor(srcImage, grayImage, ColorConversion.BgrToGray);
Cv2.EqualizeHist(grayImage, grayImage);
همچنین در اینجا جهت بالا رفتن سرعت کار و بهبود دقت تشخیص چهره، این تصویر اصلی به یک تصویر سیاه و سفید تبدیل شده‌است و سپس متد Cv2.EqualizeHist بر روی آن فراخوانی گشته‌است. این متد وضوح تصویر را جهت یافتن الگوها بهبود می‌بخشد.
سپس فایل xml یاد شده‌ی در ابتدای بحث را توسط کلاس CascadeClassifier بارگذاری می‌کنیم:
var cascade = new CascadeClassifier(@"..\..\Data\haarcascade_frontalface_alt.xml");
var nestedCascade = new CascadeClassifier(@"..\..\Data\haarcascade_eye_tree_eyeglasses.xml");
 
var faces = cascade.DetectMultiScale(
    image: grayImage,
    scaleFactor: 1.1,
    minNeighbors: 2,
    flags: HaarDetectionType.Zero | HaarDetectionType.ScaleImage,
    minSize: new Size(30, 30)
    );
 
Console.WriteLine("Detected faces: {0}", faces.Length);
پس از بارگذاری فایل‌های XML اطلاعات نحوه‌ی تشخیص چهره و اعضای آن، با فراخوانی متد DetectMultiScale، کار تشخیص چهره و استخراج آن از grayImage انجام خواهد شد. در اینجا minSize، اندازه‌ی حداقل چهره‌ی مدنظر است که قرار هست تشخیص داده شود. نواحی کوچکتر از این اندازه، به عنوان نویز در نظر گرفته خواهند شد و پردازش نمی‌شوند.
خروجی این متد، مستطیل‌ها و نواحی یافت شده‌ی مرتبط با چهره‌های موجود در تصویر هستند. اکنون می‌توان حلقه‌ای را تشکیل داد و این نواحی را برای مثال با مستطیل‌های رنگی، متمایز کرد:
var rnd = new Random();
var count = 1;
foreach (var faceRect in faces)
{
    var detectedFaceImage = new Mat(srcImage, faceRect);
    Cv2.ImShow(string.Format("Face {0}", count), detectedFaceImage);
    Cv2.WaitKey(1); // do events
 
    var color = Scalar.FromRgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));
    Cv2.Rectangle(srcImage, faceRect, color, 3);
  
    count++;
}
 
Cv2.ImShow("Haar Detection", srcImage);
Cv2.WaitKey(1); // do events
در اینجا علاوه بر رسم یک مستطیل، به دور ناحیه‌ی تشخیص داده شده، نحوه‌ی استخراج تصویر آن ناحیه را هم در سطر var detectedFaceImage مشاهده می‌کنید.

همچنین اگر علاقمند باشیم تا در این ناحیه‌ی یافت شده، چشمان شخص را نیز استخراج کنیم، می‌توان به نحو ذیل عمل کرد:
var rnd = new Random();
var count = 1;
foreach (var faceRect in faces)
{
    var detectedFaceImage = new Mat(srcImage, faceRect);
    Cv2.ImShow(string.Format("Face {0}", count), detectedFaceImage);
    Cv2.WaitKey(1); // do events
 
    var color = Scalar.FromRgb(rnd.Next(0, 255), rnd.Next(0, 255), rnd.Next(0, 255));
    Cv2.Rectangle(srcImage, faceRect, color, 3);
 
 
    var detectedFaceGrayImage = new Mat();
    Cv2.CvtColor(detectedFaceImage, detectedFaceGrayImage, ColorConversion.BgrToGray);
    var nestedObjects = nestedCascade.DetectMultiScale(
        image: detectedFaceGrayImage,
        scaleFactor: 1.1,
        minNeighbors: 2,
        flags: HaarDetectionType.Zero | HaarDetectionType.ScaleImage,
        minSize: new Size(30, 30)
        );
 
    Console.WriteLine("Nested Objects[{0}]: {1}", count, nestedObjects.Length);
 
    foreach (var nestedObject in nestedObjects)
    {
        var center = new Point
        {
            X = Cv.Round(nestedObject.X + nestedObject.Width * 0.5) + faceRect.Left,
            Y = Cv.Round(nestedObject.Y + nestedObject.Height * 0.5) + faceRect.Top
        };
        var radius = Cv.Round((nestedObject.Width + nestedObject.Height) * 0.25);
        Cv2.Circle(srcImage, center, radius, color, thickness: 3);
    }
 
    count++;
}
 
Cv2.ImShow("Haar Detection", srcImage);
Cv2.WaitKey(1); // do events
کدهای ابتدایی آن همانند توضیحات قبل است. تنها تفاوت آن، استفاده از nestedCascade بارگذاری شده‌ی در ابتدای بحث می‌باشد. این nestedCascade حاوی trained data استخراج چشمان اشخاص، از تصاویر است. پارامتر ورودی آن‌را نیز تصویر سیاه و سفید ناحیه‌ی چهره‌ی یافت شده‌، قرار داده‌ایم تا سرعت تشخیص چشمان شخص، افزایش یابد.


کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید.
مطالب
Blazor 5x - قسمت 18 - کار با فرم‌ها - بخش 6 - حذف اطلاعات
در این قسمت می‌خواهیم اطلاعات اتاق‌های ثبت شده را به همراه تصاویر مرتبط با آن‌ها، حذف کنیم و همچنین به یک خطای مهم در حین کار با EF-Core برسیم و متوجه شویم که روش کار با DbContext در برنامه‌های مبتنی بر Blazor Server .... با روش کار متداول با آن در برنامه‌های Web API، یکی نیست!


مشکل حذف تصاویر آپلود شده

در قسمت قبل، این امکان را مهیا کردیم که کاربران بتوانند پیش از ثبت اطلاعات یک اتاق، تصاویر آن‌را به سرور آپلود کنند. یعنی تصاویری که در ابتدای کار آپلود می‌شوند، هنوز در بانک اطلاعاتی ثبت نشده‌اند و هیچ رکوردی از آن‌ها موجود نیست. در این حالت اگر کاربری تصاویری را آپلود کرده و سپس بر روی دکمه‌ی back کلیک کند، با تعدادی تصویر آپلود شده‌ی غیرمنتسب به اتاق‌های موجود، مواجه خواهیم شد. همچنین اگر شخصی به قسمت ویرایش تصاویر مراجعه کند و با کلیک بر روی دکمه‌ی حذف یک تصویر، آن‌را حذف کند، این حذف باید در بانک اطلاعاتی هم منعکس شود؛ در غیر اینصورت باز هم کاربر می‌تواند تصویری را حذف کند، اما در آخر بر روی دکمه‌ی به روز رسانی اطلاعات رکورد کلیک نکند. در این حالت در دفعات بعدی مراجعه‌ی به اطلاعات یک چنین اتاقی، با نقص اطلاعات تصاویری مواجه می‌شویم که در لیست تصاویر منتسب به یک اتاق وجود دارند، اما اصل فایل تصویری متناظر با آن‌ها از سرور حذف شده‌است.


حذف اطلاعات تصاویر، در حالت ثبت اطلاعات


زمانیکه قرار است اطلاعات اتاقی برای اولین بار ثبت شود، حذف تصاویر آپلود شده‌ی مرتبط با آن ساده‌است؛ چون هنوز اصل رکورد اتاق ثبت نشده‌است و این تصاویر در این لحظه، به رکوردی تعلق ندارند. بنابراین ابتدا متد رویدادگردان DeletePhoto را به دکمه‌ی حذف اطلاعات هر تصویر نمایش داده شده، انتساب می‌دهیم:
@if (HotelRoomModel.HotelRoomImages.Count > 0)
{
    var serial = 1;
    foreach (var roomImage in HotelRoomModel.HotelRoomImages)
    {
        <div class="col-md-2 mt-3">
            <div class="room-image" style="background: url('@roomImage.RoomImageUrl') 50% 50%; ">
                <span class="room-image-title">@serial</span>
            </div>
            <button type="button"
                    @onclick="()=>DeletePhoto(roomImage)"
                    class="btn btn-outline-danger btn-block mt-4">Delete</button>
        </div>
        serial++;
    }
}
و سپس آن‌را به صورت زیر تکمیل می‌کنیم:
@code
{
    private const string UploadFolder = "Uploads";

    private void DeletePhoto(HotelRoomImageDTO imageDto)
    {
        var imageFileName = imageDto.RoomImageUrl.Replace($"{UploadFolder}/", "", StringComparison.OrdinalIgnoreCase);
        if (HotelRoomModel.Id == 0 && Title == "Create")
        {
            FileUploadService.DeleteFile(imageFileName, WebHostEnvironment.WebRootPath, UploadFolder);
            HotelRoomModel.HotelRoomImages.Remove(imageDto);
        }
    }
}
- با هر بار کلیک بر روی دکمه‌ی Delete، شیء HotelRoomImageDTO متناظری به متد DeletePhoto ارسال می‌شود.
- در این شیء، مقدار خاصیت RoomImageUrl، همواره با نام پوشه‌ای که فایل‌های تصویری در آن آپلود شده‌اند، شروع می‌شود. به همین جهت نام پوشه را از آن حذف کرده و بر این اساس، متد FileUploadService.DeleteFile را فراخوانی می‌کنیم تا تصویر جاری را از سرور حذف کند.
- سپس با فراخوانی متد Remove بر روی لیست تصاویر موجود، سبب به روز رسانی UI نیز خواهیم شد و به این ترتیب، تصویری که فایل آن از سرور حذف شده، از UI نیز حذف خواهد شد.


حذف تصاویر، در زمان ویرایش اطلاعات یک اتاق تعریف شده

همانطور که در ابتدای بحث نیز عنوان شد، نمی‌خواهیم در حالت ویرایش یک رکورد، با کلیک بر روی حذف یک تصویر، بلافاصله آن‌را از سرور نیز حذف کنیم. چون ممکن است کاربری تصویری را حذف کند، اما بجای ذخیره سازی اطلاعات رکورد، بر روی دکمه‌ی back کلیک کند. بنابراین در اینجا حذف تصاویر را صرفا به حذف آن‌ها از UI محدود می‌کنیم و حذف نهایی را به زمان کلیک بر روی دکمه‌ی ذخیره سازی اطلاعات در حال ویرایش، موکول خواهیم کرد.
به همین جهت در ابتدا با کلیک بر روی دکمه‌ی حذف، ابتدا با حذف آن تصویر از HotelRoomImages، سبب به روز رسانی UI خواهیم شد، اما این تصویر را واقعا حذف نمی‌کنیم. در اینجا فقط نام آن‌را در یک لیست، برای حذف نهایی، ذخیره سازی خواهیم کرد:
@code
{
    private List<string> DeletedImageFileNames = new List<string>();

    private void DeletePhoto(HotelRoomImageDTO imageDto)
    {
        var imageFileName = imageDto.RoomImageUrl.Replace($"{UploadFolder}/", "", StringComparison.OrdinalIgnoreCase);
        if (HotelRoomModel.Id == 0 && Title == "Create")
        {
            // ...              
        }
        else
        {
            // Edit Mode
            DeletedImageFileNames.Add(imageFileName);
            HotelRoomModel.HotelRoomImages.Remove(imageDto); // Update UI
        }
    }
به این ترتیب اگر کاربر بر روی دکمه‌ی back کلیک کند، اتفاق خاصی رخ نمی‌دهد؛ نه رکوردی از بانک اطلاعاتی و نه فایل تصویری از سرور حذف می‌شود.

سپس در جائیکه کار مدیریت ثبت اطلاعات صورت می‌گیرد، پس از به روز رسانی رکورد متناظر با یک اتاق، بر اساس لیست DeletedImageFileNames، فایل‌های علامتگذاری شده‌ی برای حذف را نیز واقعا از سرور حذف می‌کنیم:
    private async Task HandleHotelRoomUpsert()
    {
        // ...
 
        if (HotelRoomModel.Id != 0 && Title == "Update")
        {
            // Update Mode
            var updatedRoomDto = await HotelRoomService.UpdateHotelRoomAsync(HotelRoomModel.Id, HotelRoomModel);

            foreach(var imageFileName in DeletedImageFileNames)
            {
                FileUploadService.DeleteFile(imageFileName, WebHostEnvironment.WebRootPath, UploadFolder);
            }

            // await AddHotelRoomImageAsync(updatedRoomDto);
            await JsRuntime.ToastrSuccess($"The `{HotelRoomModel.Name}` updated successfully.");
        }
        else
        {
           // ... 
        }
    }
}
در اینجا باز هم نیازی نیست تا یک حلقه را تشکیل دهیم و اطلاعات را مستقیما از جدول تصاویر حذف کنیم. HotelRoomModel ارسال شده‌ی به متد UpdateHotelRoomAsync، چون به همراه لیست جدید HotelRoomImages است (که توسط فراخوانی HotelRoomModel.HotelRoomImages.Remove به روز شده‌است)، در حین Update، تصاویری که در این لیست وجود نداشته باشند، به صورت خودکار توسط EF-Core از سر دیگر رابطه حذف می‌شوند.


نمایش «لطفا منتظر بمانید» در حین آپلود تصاویر

در ادامه می‌خواهیم تا پایان نمایش آپلود تصاویر، پیام «لطفا منتظر بمانید» را به همراه یک spinner نمایش دهیم. بنابراین در ابتدا کلاس‌های جدید زیر را به فایل wwwroot\css\site.css اضافه می‌کنیم:
.spinner {
  border: 16px solid silver !important;
  border-top: 16px solid #337ab7 !important;
  border-radius: 50% !important;
  width: 80px !important;
  height: 80px !important;
  animation: spin 700ms linear infinite !important;
  top: 50% !important;
  left: 50% !important;
  transform: translate(-50%, -50%);
  position: absolute !important;
}

@keyframes spin {
  0% {
    transform: rotate(0deg);
  }

  100% {
    transform: rotate(360deg);
  }
}
سپس برای مدیریت نمایش spinner فوق، در ابتدای کار آپلود، فیلدIsImageUploadProcessStarted را به true تنظیم کرده و در پایان کار، آن‌را false می‌کنیم. به همین جهت نیاز به یک try/finally خواهد بود:
@code
{
    private bool IsImageUploadProcessStarted;

    private async Task HandleImageUpload(InputFileChangeEventArgs args)
    {
        try
        {
            IsImageUploadProcessStarted = true;
            // ...
        }
        finally
        {
            IsImageUploadProcessStarted = false;
        }
    }
}
پس از آن فقط کافی است بر اساس مقدار جاری این فیلد، ذیل فیلد InputFile، پیامی را نمایش دهیم:
<InputFile OnChange="HandleImageUpload" multiple></InputFile>
<div class="row">
@if (IsImageUploadProcessStarted)
{
    <div class="col-md-12">
        <span><i class="spinner"></i> Please wait.. Images are uploading...</span>
    </div>
}

دریافت تائیدیه‌ی حذف، پس از کلیک بر روی دکمه‌های حذف تصاویر


در قسمت 12 این سری، کامپوننت Confirmation.razor را توسعه دادیم. در اینجا می‌خواهیم با کلیک بر روی دکمه‌ها‌ی حذف تصاویر، ابتدا توسط این کامپوننت، تائیدیه‌ای دریافت شود و در صورت تائید، آن تصویر انتخابی را حذف کنیم.
به همین جهت در ابتدا فایل Confirmation.razor را به پوشه‌ی جدید Pages\Components کپی می‌کنیم. سپس فضای نام آن‌را به فایل BlazorServer\BlazorServer.App\_Imports.razor اضافه می‌کنیم تا در تمام کامپوننت‌های برنامه قابل استفاده شود:
@using BlazorServer.App.Pages.Components
سپس در ابتدا کامپوننت Confirmation را به صورت زیر اضافه می‌کنیم:
<Confirmation @ref="Confirmation1"
    OnCancel="OnCancelDeleteImageClicked"
    OnConfirm="@(()=>OnConfirmDeleteImageClicked(ImageToBeDeleted))">
    <div>
        Do you want to delete @ImageToBeDeleted?.RoomImageUrl image?
    </div>
</Confirmation>
- ref تعریف شده سبب می‌شود تا بتوان متدهای عمومی تعریف شده‌ی در این کامپوننت، مانند Show و Hide را فراخوانی کرد.
- سپس روال‌های رویدادگردان OnCancel و OnConfirm به متدهایی در کامپوننت جاری متصل شده‌اند.
- در آخر پیامی تعریف شده‌است.

برای اینکه کامپوننت فوق عمل کند، نیاز است تغییرات زیر را به قسمت کدها اعمال کنیم:
    private Confirmation Confirmation1;
    private HotelRoomImageDTO ImageToBeDeleted;

    private void OnCancelDeleteImageClicked()
    {
        // Confirmation1.Hide();
    }

    private void DeletePhoto(HotelRoomImageDTO imageDto)
    {
        ImageToBeDeleted = imageDto;
        Confirmation1.Show();
    }

    private void OnConfirmDeleteImageClicked(HotelRoomImageDTO imageDto)
    {
- توسط وهله‌ی Confirmation1، می‌توان متد Show را زمانیکه بر روی دکمه‌ی Delete هر تصویر کلیک می‌شود، فراخوانی کنیم. قبل از آن مشخصات شیء تصویر درخواستی را در فیلد ImageToBeDeleted ذخیره می‌کنیم تا پس از تائید کاربر، دقیقا بر اساس اطلاعات آن بتوانیم متد OnConfirmDeleteImageClicked را پردازش کنیم.
- در اینجا محتوای متد DeletePhoto اصلی را (متدی را که تا پیش از این مرحله تکمیل کردیم) به متد جدید OnConfirmDeleteImageClicked منتقل کرده‌ایم. یعنی در ابتدا فقط یک modal نمایش داده می‌شود. پس از اینکه کاربر عملیات حذف را تائید کرد، رویداد OnConfirm، سبب فراخوانی متد OnConfirmDeleteImageClicked خواهد شد (که همان DeletePhoto قبل از این تغییرات است).


حذف کامل یک اتاق به همراه تمام تصاویر منتسب به آن

مرحله‌ی آخر این قسمت، اضافه کردن دکمه‌ی حذف، به ردیف‌های کامپوننت نمایش لیست اتاق‌ها است که این مورد نیز باید به همراه دریافت تائیدیه‌ی حذف و همچنین حذف تمام وابستگی‌های اتاق ثبت شده باشد:
<td>
    <NavLink href="@($"hotel-room/edit/{room.Id}")" class="btn btn-primary">Edit</NavLink>
    <button class="btn btn-danger" @onclick="()=>HandleDeleteRoom(room)">Delete</button>
</td>
در کامپوننت BlazorServer\BlazorServer.App\Pages\HotelRoom\HotelRoomList.razor، دکمه‌ی Delete را به نحو فوق اضافه کرده‌ایم که با کلیک بر روی آن، روال رویدادگردان HandleDeleteRoom اجرا شده و room متناظری را دریافت می‌کند.
اکنون برای مدیریت دریافت تائیدیه‌ی حذف از کاربر، کامپوننت Confirmation را اضافه کرده:
<Confirmation @ref="Confirmation1"
    OnCancel="OnCancelDeleteRoomClicked"
    OnConfirm="OnConfirmDeleteRoomClicked">
    <div>
        Do you want to delete @RoomToBeDeleted?.Name?
    </div>
</Confirmation>
و به نحو زیر تکمیل می‌کنیم:
@code
{
    private List<HotelRoomDTO> HotelRooms = new List<HotelRoomDTO>();
    private HotelRoomDTO RoomToBeDeleted;
    private Confirmation Confirmation1;

    private void OnCancelDeleteRoomClicked()
    {
        // Confirmation1.Hide();
    }

    private void HandleDeleteRoom(HotelRoomDTO roomDto)
    {
        RoomToBeDeleted = roomDto;
        Confirmation1.Show();
    }

    private async Task OnConfirmDeleteRoomClicked()
    {
        if(RoomToBeDeleted is null)
        {
            return;
        }

        await HotelRoomService.DeleteHotelRoomAsync(RoomToBeDeleted.Id);
        HotelRooms.Remove(RoomToBeDeleted); // Update UI
    }
با کلیک بر روی دکمه‌ی حذف، متد HandleDeleteRoom اجرا شده و فیلد RoomToBeDeleted را مقدار دهی می‌کند. از این فیلد پس از دریافت تائید، در متد OnConfirmDeleteRoomClicked برای حذف اتاق انتخابی استفاده شده‌است.

مشکل! این روش استفاده‌ی از DbContext کار نمی‌کند!

اگر برنامه را اجرا کرده و سعی در حذف یک ردیف کنیم، به خطای زیر می‌رسیم:
An exception occurred while iterating over the results of a query for context type 'BlazorServer.DataAccess.ApplicationDbContext'.
System.InvalidOperationException: A second operation was started on this context before a previous operation completed.
This is usually caused by different threads concurrently using the same instance of DbContext.
For more information on how to avoid threading issues with DbContext, see https://go.microsoft.com/fwlink/?linkid=2097913.
عنوان می‌کند که متد OnConfirmDeleteRoomClicked، بر روی ترد دیگری نسبت به ترد اولیه‌ای که DbContext بر روی آن ایجاد شده، در حال اجرا است و چون DbContext برای یک چنین سناریوهایی، thread-safe نیست، اجازه‌ی استفاده‌ی از آن‌را نمی‌دهد. در مورد روش حل این مشکل ویژه، در قسمت بعد بحث خواهیم کرد.

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: Blazor-5x-Part-18.zip
بازخوردهای دوره
متدهای async تقلبی
در این مثال عملیات تبدیل sync به async درست انجام شده ؟
کد sync :
        public string AESEncrypt(string Text)
        {
            byte[] dataToEncrypt = Encoding.UTF8.GetBytes(Text);
            byte[] key = Convert.FromBase64String(AES_Key);
            byte[] iv = Convert.FromBase64String(AES_Iv);

            using (var aes = new AesManaged())
            {
                aes.Mode = CipherMode.CBC;
                aes.Padding = PaddingMode.PKCS7;
                aes.Key = key;
                aes.IV = iv;
                using (var ms = new MemoryStream())
                {
                    using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
                    {
                        cs.Write(dataToEncrypt, 0, dataToEncrypt.Length);
                        cs.FlushFinalBlock();
                        return Convert.ToBase64String(ms.ToArray());
                    }
                }
            }
        }
کد async :
        public async Task<string> AESEncryptAsync(string Text)
        {
            byte[] dataToEncrypt = Encoding.UTF8.GetBytes(Text);
            byte[] key = Convert.FromBase64String(AES_Key);
            byte[] iv = Convert.FromBase64String(AES_Iv);

            using (var aes = new AesManaged())
            {
                aes.Mode = CipherMode.CBC;
                aes.Padding = PaddingMode.PKCS7;
                aes.Key = key;
                aes.IV = iv;
                using (var ms = new MemoryStream())
                {
                    using (var cs = new CryptoStream(ms, aes.CreateEncryptor(), CryptoStreamMode.Write))
                    {
                        await cs.WriteAsync(dataToEncrypt, 0, dataToEncrypt.Length);
                        cs.FlushFinalBlock();
                        return Convert.ToBase64String(ms.ToArray());
                    }
                }
            }
        }
من این دو متد رو از نظر "زمان اجرا" ، "ترد مشغول" ، "هسته cpu مشغول" در asp.net mvc تست گرفتم {1000 بار فرخوانی} .
در این تست متد async  هر بار زمان بیشتری برای محاسبه برد (که خوب چون ترد جدید باز میکنه طبیعی هست) و در ترد‌ها و هسته‌های مختلف cpu اجرا میشد ، که این هم طبیعی هست ، طرف نظر از این که طبق مقاله حاضر کلا اینجا async نباید اجرا بشه چون خارج از مرز سیستم نیست ولی عملیات async کامل انجام میشه (فکر میکنم).
اما متد sync که بررسی شد ، خروجی به صورتی بود که آن هم هر بار در ترد و هسته مختلف سی پی یو اجرا میشد ! سوال دلیل چیست ؟
مگر تفاوت sync و async  دقیقا به اجرای آنها نیست ؟ پس چرا در هر دو حالت ما اجرا‌های یکسانی داریم ؟
ممنونم.
مطالب
OpenCVSharp #8
بررسی morphology (ریخت شناسی) تصاویر

به تصویر زیر دقت کنید:




فرض کنید در اینجا قصد دارید تعداد توپ‌های قرمز را شمارش کنید. از دیدگاه یک انسان، شاید سه توپ قرمز قابل مشاهده باشد. اما از دیدگاه یک برنامه، توپ وسطی به دو توپ تفسیر خواهد شد و همچنین نویزهای قرمزی که بین توپ‌ها در صفحه وجود دارند نیز شمارش می‌شوند. بنابراین بهتر است پیش از پردازش این تصویر، ریخت شناسی آن‌را بهبود بخشید. برای مثال توپ وسطی را یکی کرد،‌حفره‌های توپ‌های دیگر را پوشاند و یا نویزهای قرمز را حذف نمود. به علاوه خطوط آبی رنگی را که با یکدیگر تماس یافته‌اند نیز می‌خواهیم اندکی از هم جدا کنیم.


متدهایی که مورفولوژی تصاویر را تغییر می‌دهند

در OpenCV سه متد یا فیلتر مهم، کار تغییر مورفولوژی تصاویر را انجام می‌دهند:
1) Cv2.Erode
تحلیل/فرسایش یا erosion سبب می‌شود تا نواحی تیره‌ی تصویر «رشد» کنند.


در اینجا فیلتر Erode کار یکی کردن اجزای جدای توپ‌های رنگی را انجام داده‌است.


2) Cv2.Dilate
اتساع یا dilation سبب خواهد شد تا نواحی روشن تصویر «رشد» کنند.


بکارگیری فیلتر Dilate سبب شده‌‌است تا نویزهای تصویر محو شوند و اشیاء به هم پیوسته از هم جدا گردند.


3) Cv2.MorphologyEx
کار این متد انجام اعمال پیشرفته‌ی مورفولوژی بر روی تصاویر است و در اینجا ترکیبی از erosion و dilation، با هم انجام می‌شوند.
اگر پارامتر سوم آن به MorphologyOperation.Open تنظیم شود، ابتدا erosion و سپس dilation انجام خواهد شد:


و اگر این پارامتر به MorphologyOperation.Close مقدار دهی شود، ابتدا dilation و سپس erosion انجام می‌شود:


در تمام این حالات، پارامتر آخر که Structuring Element نام دارد، یکی از مقادیر اشیاء مستطیل، به علاوه و بیضی را می‌تواند داشته باشد. این اشیاء و اندازه‌ی آن‌ها، مشخص کننده‌ی میزان تحلیل و یا اتساع نهایی هستند.



استفاده از متدهای مورفولوژی در عمل
 
در اینجا مثالی را از نحوه‌ی بکارگیری متدهای اتساع و فرسایش، ملاحظه می‌کنید:
using (var src = new Mat(@"..\..\Images\cvmorph.Png", LoadMode.AnyDepth | LoadMode.AnyColor))
using (var dst = new Mat())
{
    src.CopyTo(dst);
 
    var elementShape = StructuringElementShape.Rect;
    var maxIterations = 10; 

    var erodeDilateWindow = new Window("Erode/Dilate", image: dst);
    var erodeDilateTrackbar = erodeDilateWindow.CreateTrackbar(
        name: "Iterations", value: 0, max: maxIterations * 2 + 1,
        callback: pos =>
        {
            var n = pos - maxIterations;
            var an = n > 0 ? n : -n;
            var element = Cv2.GetStructuringElement(
                    elementShape,
                    new Size(an * 2 + 1, an * 2 + 1),
                    new Point(an, an));
            if (n < 0)
            {
                Cv2.Erode(src, dst, element);
            }
            else
            {
                Cv2.Dilate(src, dst, element);
            }
 
            Cv2.PutText(dst, (n < 0) ?
                string.Format("Erode[{0}]", elementShape) :
                string.Format("Dilate[{0}]", elementShape),
                new Point(10, 15), FontFace.HersheyPlain, 1, Scalar.Black);
            erodeDilateWindow.Image = dst;
        });  
 
    Cv2.WaitKey(); 
    erodeDilateWindow.Dispose();
}
اینترفیس به کار گرفته شده، همان C++ API است و در اینجا ابتدا یک تصویر و کپی آن تهیه می‌شوند. سپس پنجره‌ی سازگار با C++ API ایجاد شده و به این پنجره یک شیء tracker اضافه می‌شود. این tracker یا slider، چندسکویی است. بدیهی است اگر قرار بود چنین کاری را صرفا با یک برنامه‌ی دات نتی انجام داد، می‌شد قسمت ایجاد پنجره و tracker آن‌را حذف کرد و بجای آن‌ها از یک picture box و یک slider به همراه مدیریت روال رخدادگردان تغییر مقادیر slider کمک گرفت. اما در اینجا تنها جهت آشنایی با این امکانات توکار OpenCV، از همان متدهای native آن استفاده شده‌است. در این مثال، روال رخدادگردان تغییر مقادیر tracker یا slider، یک function pointer است که معادل آن در دات نت یک delegate می‌باشد که در پارامتر callback متد ایجاد tracker قابل مشاهده است. هر بار که مقدار tracker تغییر می‌کند، مقدار pos را به callback خود ارسال خواهد کرد. از این مقدار جهت ایجاد شیء ساختاری و همچنین انتخاب بین حالات اتساع و فرسایش، کمک گرفته شده‌است.
کار متد PutText، نوشتن یک متن ساده بر روی پنجره‌ی native مربوط به OpenCV است.

همچنین در ادامه کدهای بکارگیری متد MorphologyEx را که کار ترکیب اتساع و فرسایش را با هم انجام می‌دهد، ذکر شده‌است و نکات بکارگیری آن همانند مثال اول بحث است:
    using (var src = new Mat(@"..\..\Images\cvmorph.Png", LoadMode.AnyDepth | LoadMode.AnyColor))
    using (var dst = new Mat())
    {
        src.CopyTo(dst);
 
        var elementShape = StructuringElementShape.Rect;
        var maxIterations = 10;
 
        var openCloseWindow = new Window("Open/Close", image: dst);
        var openCloseTrackbar = openCloseWindow.CreateTrackbar(
            name: "Iterations", value: 0, max: maxIterations * 2 + 1,
            callback: pos =>
            {
                var n = pos - maxIterations;
                var an = n > 0 ? n : -n;
                var element = Cv2.GetStructuringElement(
                        elementShape,
                        new Size(an * 2 + 1, an * 2 + 1),
                        new Point(an, an));
 
                if (n < 0)
                {
                    Cv2.MorphologyEx(src, dst, MorphologyOperation.Open, element);
                }
                else
                {
                    Cv2.MorphologyEx(src, dst, MorphologyOperation.Close, element);
                }
 
                Cv2.PutText(dst, (n < 0) ?
                    string.Format("Open/Erosion followed by Dilation[{0}]", elementShape)
                    : string.Format("Close/Dilation followed by Erosion[{0}]", elementShape),
                    new Point(10, 15), FontFace.HersheyPlain, 1, Scalar.Black);
                openCloseWindow.Image = dst;
            }); 

        Cv2.WaitKey();  
        openCloseWindow.Dispose();
    }

کدهای کامل این مثال را از اینجا می‌توانید دریافت کنید.