مطالب
Implementing second level caching in EF code first
هدف اصلی از انواع و اقسام مباحث caching اطلاعات، فراهم آوردن روش‌هایی جهت میسر ساختن دسترسی سریعتر به داده‌هایی است که به صورت متناوب در برنامه مورد استفاده قرار می‌گیرند، بجای مراجعه مستقیم به بانک اطلاعاتی و خواندن اطلاعات از دیسک سخت.

عموما در ORMها دو سطح کش می‌تواند وجود داشته باشد:
الف) سطح اول کش
که نمونه بارز آن در EF Code first استفاده از متد context.Entity.Find است. در بار اول فراخوانی این متد، مراجعه‌ای به بانک اطلاعاتی صورت گرفته تا بر اساس primary key ذکر شده در آرگومان آن، رکورد متناظری بازگشت داده شود. در بار دوم فراخوانی متد Find، دیگر مراجعه‌ای به بانک اطلاعاتی صورت نخواهد گرفت و اطلاعات از سطح اول کش (یا همان Context جاری) خوانده می‌شود.
بنابراین سطح اول کش در طول عمر یک تراکنش معنا پیدا می‌کند و به صورت خودکار توسط EF مدیریت می‌شود.

ب) سطح دوم کش
سطح دوم کش در ORMها طول عمر بیشتری داشته و سراسری است. هدف از آن کش کردن اطلاعات عمومی و پر مصرفی است که در دید تمام کاربران قرار دارد و همچنین تمام کاربران می‌توانند به آن دسترسی داشته باشند. بنابراین محدود به یک Context نیست.
عموما پیاده سازی سطح دوم کش خارج از ORM مورد استفاده قرار می‌گیرد و توسط اشخاص و شرکت‌های ثالث تهیه می‌شود.
در حال حاضر پیاده سازی توکاری از سطح دوم کش در EF Code first وجود ندارد و قصد داریم در مطلب جاری به یک پیاده سازی نسبتا خوب از آن برسیم.


تلاش‌های صورت گرفته

تا کنون دو پیاده سازی نسبتا خوب از سطح دوم کش در EF صورت گرفته:

Entity Framework Code First Caching
Caching the results of LINQ queries

مورد اول برای ایده گرفتن خوب است. بحث اصلی پیاده سازی سطح دوم کش، یافتن کلیدی است که معادل کوئری LINQ در حال فراخوانی است. سطح دوم کش را به صورت یک Dictionary تصور کنید. هر آیتم آن تشکیل شده است از یک کلید و یک مقدار. از کلید برای یافتن مقدار متناظر استفاده می‌شود.
اکنون مشکل چیست؟ در یک برنامه ممکن است صدها کوئری لینک وجود داشته باشد. چطور باید به ازای هر کوئری LINQ یک کلید منحصربفرد تولید کرد؟
در مطلب «Entity Framework Code First Caching» از متد ToString استفاده شده است. اگر این متد، بر روی یک عبارت LINQ در EF Code first فراخوانی شود، معادل SQL آن نمایش داده می‌شود. بنابراین یک قدم به تولید کلید منحصربفرد متناظر با یک کوئری نزدیک شده‌ایم. اما ... مشکل اینجا است که متد ToString پارامترها را لحاظ نمی‌کند. بنابراین این روش اصلا قابل استفاده نیست. چون کاربر به ازای تمام پارامترهای ارسالی، همواره یک نتیجه را دریافت خواهد کرد.
در مقاله «Caching the results of LINQ queries» این مشکل برطرف شده است. با parse کامل expression tree یک عبارت LINQ کلید منحصربفرد معادل آن یافت می‌شود. سپس بر این اساس می‌توان نتیجه کوئری را به نحو صحیحی کش کرد. در این روش پارامترها هم لحاظ می‌شوند و مشکل مقاله قبلی را ندارد.
اما این مقاله دوم یک مشکل مهم را به همراه دارد: روشی را برای حذف آیتم‌ها از کش ارائه نمی‌دهد. فرض کنید مقالات سایت را در سطح دوم کش قرار داده‌اید. اکنون یک مقاله جدید در سایت ثبت شده است. اصطلاحا برای invalidating کش در این روش، راهکاری پیشنهاد نشده است.


پیاده سازی بهتری از سطح دوم کش در EF Code fist

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

using System;
using System.Collections.Generic;
using System.Data;
using System.Data.Entity;
using System.Data.Objects;
using System.Diagnostics;
using System.Linq;
using System.Web;
using System.Web.Caching;

namespace EfSecondLevelCaching.Core
{
    public static class EfHttpRuntimeCacheProvider
    {
        #region Methods (6)

        // Public Methods (2) 

        public static IList<TEntity> ToCacheableList<TEntity>(
                            this IQueryable<TEntity> query,
                            int durationMinutes = 15,
                            CacheItemPriority priority = CacheItemPriority.Normal)
        {
            return query.Cacheable(x => x.ToList(), durationMinutes, priority);
        }

        /// <summary>
        /// Returns the result of the query; if possible from the cache, otherwise
        /// the query is materialized and the result cached before being returned.
        /// The cache entry has a one minute sliding expiration with normal priority.
        /// </summary>
        public static TResult Cacheable<TEntity, TResult>(
                            this IQueryable<TEntity> query,
                            Func<IQueryable<TEntity>, TResult> materializer,
                            int durationMinutes = 15,
                            CacheItemPriority priority = CacheItemPriority.Normal)
        {
            // Gets a cache key for a query.
            var queryCacheKey = query.GetCacheKey();

            // The name of the cache key used to clear the cache. All cached items depend on this key.
            var rootCacheKey = typeof(TEntity).FullName;

            // Try to get the query result from the cache.
            printAllCachedKeys();
            var result = HttpRuntime.Cache.Get(queryCacheKey);
            if (result != null)
            {
                debugWriteLine("Fetching object '{0}__{1}' from the cache.", rootCacheKey, queryCacheKey);
                return (TResult)result;
            }

            // Materialize the query.
            result = materializer(query);

            // Adding new data.
            debugWriteLine("Adding new data: queryKey={0}, dependencyKey={1}", queryCacheKey, rootCacheKey);
            storeRootCacheKey(rootCacheKey);
            HttpRuntime.Cache.Insert(
                    key: queryCacheKey,
                    value: result,
                    dependencies: new CacheDependency(null, new[] { rootCacheKey }),
                    absoluteExpiration: DateTime.Now.AddMinutes(durationMinutes),
                    slidingExpiration: Cache.NoSlidingExpiration,
                    priority: priority,
                    onRemoveCallback: null);

            return (TResult)result;
        }

        /// <summary>
        /// Call this method in `public override int SaveChanges()` of your DbContext class 
        /// to Invalidate Second Level Cache automatically.
        /// </summary>        
        public static void InvalidateSecondLevelCache(this DbContext ctx)
        {
            var changedEntityNames = ctx.ChangeTracker
                                      .Entries()
                                      .Where(x => x.State == EntityState.Added ||
                                                  x.State == EntityState.Modified ||
                                                  x.State == EntityState.Deleted)
                                      .Select(x => ObjectContext.GetObjectType(x.Entity.GetType()).FullName)
                                      .Distinct()
                                      .ToList();

            if (!changedEntityNames.Any()) return;

            printAllCachedKeys();
            foreach (var item in changedEntityNames)
            {
                item.removeEntityCache();
            }
            printAllCachedKeys();
        }
        // Private Methods (4) 

        private static void debugWriteLine(string format, params object[] args)
        {
            if (!Debugger.IsAttached) return;
            Debug.WriteLine(format, args);
        }

        private static void printAllCachedKeys()
        {
            if (!Debugger.IsAttached) return;
            debugWriteLine("Available cached keys list:");
            int count = 0;
            var enumerator = HttpRuntime.Cache.GetEnumerator();
            while (enumerator.MoveNext())
            {
                if (enumerator.Key.ToString().StartsWith("__")) continue; // such as __System.Web.WebPages.Deployment
                debugWriteLine("queryKey: {0}", enumerator.Key.ToString());
                count++;
            }
            debugWriteLine("count: {0}", count);
        }

        private static void removeEntityCache(this string rootCacheKey)
        {
            if (string.IsNullOrWhiteSpace(rootCacheKey)) return;
            debugWriteLine("Removing items with dependencyKey={0}", rootCacheKey);
            // Removes all cached items depend on this key.
            HttpRuntime.Cache.Remove(rootCacheKey);
        }

        private static void storeRootCacheKey(string rootCacheKey)
        {
            // The cacheKeys of a cacheDependency that are not already in cache ARE NOT inserted into the cache 
            // on the Insert of the item in which the dependency is used.
            if (HttpRuntime.Cache.Get(rootCacheKey) != null)
                return;

            HttpRuntime.Cache.Add(
                rootCacheKey,
                rootCacheKey,
                null,
                Cache.NoAbsoluteExpiration,
                Cache.NoSlidingExpiration,
                CacheItemPriority.Default,
                null);
        }

        #endregion Methods
    }
}

توضیحات کدهای فوق

در اینجا یک متدالحاقی به نام Cacheable توسعه داده شده است که می‌تواند در انتهای کوئری‌های LINQ شما قرار گیرد. مثلا:

var data = context.Products.AsQueryable().Cacheable(x => x.FirstOrDefault());

کاری که در این متد انجام می‌شود به این شرح است:
الف) ابتدا کلید منحصربفرد معادل کوئری LINQ فراخوانی شده محاسبه می‌شود.
ب) بر اساس نام کامل نوع Entity در حال استفاده، کلید دیگری به نام rootCacheKey تولید می‌گردد.
شاید بپرسید اهمیت این کلید چیست؟
فرض کنید در حال حاضر 1000 آیتم در کش وجود دارند. چه روشی را برای حذف آیتم‌های مرتبط با کش Entity1 پیشنهاد می‌دهید؟ احتمالا خواهید گفت تمام کش را بررسی کرده و آیتم‌ها را یکی یکی حذف می‌کنیم.
این روش بسیار کند است (و جواب هم نمی‌دهد؛ چون کلیدی که در اینجا تولید شده، هش MD5 معادل کوئری است و نمی‌توان آن‌را به موجودیتی خاص ربط داد) و ... نکته جالبی در متد HttpRuntime.Cache.Insert برای مدیریت آن پیش بینی شده است: استفاده از CacheDependency.
توسط CacheDependency می‌توان گروهی از آیتم‌های هم‌خانواده را تشکیل داد. سپس برای حذف کل این گروه کافی است کلید اصلی CacheDependency را حذف کرد. به این ترتیب به صورت خودکار کل کش مرتبط خالی می‌شود.
ج) مراحل بعدی آن هم یک سری اعمال متداول هستند. ابتدا توسط HttpRuntime.Cache.Get بررسی می‌شود که آیا بر اساس کلید متناظر با کوئری جاری، اطلاعاتی در کش وجود دارد یا خیر. اگر بله، نتیجه از کش خوانده می‌شود. اگر خیر، کوئری اصطلاحا materialized می‌شود تا بر روی بانک اطلاعاتی اجرا شده و نتیجه بازگشت داده شود. سپس این نتیجه را در کش قرار می‌دهیم.

مورد بعدی که باید به آن دقت داشت، خالی کردن کش، پس از به روز رسانی اطلاعات توسط کاربران است. این کار در متد InvalidateSecondLevelCache صورت می‌گیرد. به کمک ChangeTracker می‌توان نام نوع‌های موجودیت‌های تغییر کرده را یافت. چون کلید اصلی CacheDependency را بر مبنای همین نام نوع‌های موجودیت‌ها تعیین کرده‌ایم، به سادگی می‌توان کش مرتبط با موجودیت یافت شده را خالی کرد.
استفاده از متد InvalidateSecondLevelCache یاد شده به نحو زیر است:

using System.Data.Entity;
using EfSecondLevelCaching.Core;
using EfSecondLevelCaching.Test.Models;

namespace EfSecondLevelCaching.Test.DataLayer
{
    public class ProductContext : DbContext
    {
        public DbSet<Product> Products { get; set; }

        public override int SaveChanges()
        {
            this.InvalidateSecondLevelCache();
            return base.SaveChanges();
        }        
    }
}

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


نحوه استفاده از سطح دوم کش توسعه داده شده

مثالی از کاربرد متدهای الحاقی توسعه داده شده را در ذیل مشاهده می‌کنید:

using System.Data.Entity;
using System.Linq;
using EfSecondLevelCaching.Core;
using EfSecondLevelCaching.Test.DataLayer;
using EfSecondLevelCaching.Test.Models;
using System;

namespace EfSecondLevelCaching
{
    public static class TestUsages
    {
        public static void RunQueries()
        {
            using (ProductContext context = new ProductContext())
            {
                var isActive = true;
                var name = "Product1";

                // reading from db
                var list1 = context.Products
                                   .OrderBy(one => one.ProductNumber)
                                   .Where(x => x.IsActive == isActive && x.ProductName == name)
                                   .ToCacheableList();

                // reading from cache
                var list2 = context.Products
                                   .OrderBy(one => one.ProductNumber)
                                   .Where(x => x.IsActive == isActive && x.ProductName == name)
                                   .ToCacheableList();

                // reading from cache
                var list3 = context.Products
                                   .OrderBy(one => one.ProductNumber)
                                   .Where(x => x.IsActive == isActive && x.ProductName == name)
                                   .ToCacheableList();

                // reading from db
                var list4 = context.Products
                                   .OrderBy(one => one.ProductNumber)
                                   .Where(x => x.IsActive == isActive && x.ProductName == "Product2")
                                   .ToCacheableList();
            }

            // removes products cache
            using (ProductContext context = new ProductContext())
            {
                var p = new Product()
                {
                    IsActive = false,
                    ProductName = "P4",
                    ProductNumber = "004"
                };
                context.Products.Add(p);
                context.SaveChanges();
            }

            using (ProductContext context = new ProductContext())
            {
                var data = context.Products.AsQueryable().Cacheable(x => x.FirstOrDefault());
                var data2 = context.Products.AsQueryable().Cacheable(x => x.FirstOrDefault());
                context.SaveChanges();
            }
        }
    }
}

در این حالت اگر برنامه را اجرا کنیم به یک چنین خروجی در پنجره Debug ویژوال استودیو خواهیم رسید:

Adding new data: queryKey=72AF5DA1BA9B91E24DCCF83E88AD1C5F, dependencyKey=EfSecondLevelCaching.Test.Models.Product

Available cached keys list:
queryKey: EfSecondLevelCaching.Test.Models.Product
queryKey: 72AF5DA1BA9B91E24DCCF83E88AD1C5F
count: 2

Fetching object 'EfSecondLevelCaching.Test.Models.Product__72AF5DA1BA9B91E24DCCF83E88AD1C5F' from the cache.

Available cached keys list:
queryKey: EfSecondLevelCaching.Test.Models.Product
queryKey: 72AF5DA1BA9B91E24DCCF83E88AD1C5F
count: 2

Fetching object 'EfSecondLevelCaching.Test.Models.Product__72AF5DA1BA9B91E24DCCF83E88AD1C5F' from the cache.

Available cached keys list:
queryKey: EfSecondLevelCaching.Test.Models.Product
queryKey: 72AF5DA1BA9B91E24DCCF83E88AD1C5F
count: 2

Adding new data: queryKey=11A2C33F9AD7821A0A31003BFF1DF886, dependencyKey=EfSecondLevelCaching.Test.Models.Product

Available cached keys list:
queryKey: 72AF5DA1BA9B91E24DCCF83E88AD1C5F
queryKey: 11A2C33F9AD7821A0A31003BFF1DF886
queryKey: EfSecondLevelCaching.Test.Models.Product
count: 3

Removing items with dependencyKey=EfSecondLevelCaching.Test.Models.Product
Available cached keys list:
count: 0
Available cached keys list:
count: 0

Adding new data: queryKey=02E6FE403B461E45C5508684156C1D10, dependencyKey=EfSecondLevelCaching.Test.Models.Product

Available cached keys list:
queryKey: 02E6FE403B461E45C5508684156C1D10
queryKey: EfSecondLevelCaching.Test.Models.Product
count: 2


Fetching object 'EfSecondLevelCaching.Test.Models.Product__02E6FE403B461E45C5508684156C1D10' from the cache.

توضیحات:
در زمان تولید list1 چون اطلاعاتی در کش سطح دوم وجود ندارد، پیغام Adding new data قابل مشاهده است. اطلاعات از بانک اطلاعاتی دریافت شده و سپس در کش قرار داده می‌شود.
حین فراخوانی list2 که دقیقا همان کوئری list1 را یکبار دیگر فراخوانی می‌کند، به عبارت Fetching object خواهیم رسید که بر دریافت اطلاعات از کش سطح دوم بجای مراجعه به بانک اطلاعاتی دلالت دارد.
در list4 چون پارامترهای کوئری تغییر کرده‌اند، بنابراین دیگر کلید منحصربفرد معادل آن با list1 و lis2 یکی نیست و اینبار پیغام Adding new data مشاهده می‌شود؛ چون برای دریافت اطلاعات آن نیاز است که به بانک اطلاعاتی مراجعه شود.
در ادامه یک context دیگر باز شده و در آن رکوردی به بانک اطلاعاتی اضافه می‌شود. به همین دلیل اینبار پیام Removing items with dependencyKey قابل مشاهده است. به عبارتی متد InvalidateSecondLevelCache وارد عمل شده است و بر اساس تغییری که صورت گرفته، کش را غیرمعتبر کرده است.
سپس در context بعدی تعریف شده، دوبار متد FirstOrDefault فراخوانی شده است. اولین مورد Adding new data است و دومین فراخوانی به Fetching object ختم شده است (دریافت اطلاعات از کش).

کدهای کامل این پروژه را از اینجا می‌توانید دریافت کنید:
  EfSecondLevelCaching.zip
اشتراک‌ها
ارجاعات در فایل XML

XML in general is a powerful beast, with so many options available that it quickly gets really complex. The XML Digital Signatures standard is no exception to that. The extra features complexity of XML DSig compared to other signature standard is that one or more different blocks of data can be signed by the same signature block. That data can be the containing XML Document, part of an XML document or some other resource such as a web page. In this post we’ll only look at signing resources in the document containing the signature. 

ارجاعات در فایل XML
اشتراک‌ها
به تصویر کشیدن طرز کار Float

When you have a float CSS property on a box (with a value different than none), this box must be laid out according to the float positioning algorithm.

به تصویر کشیدن طرز کار Float
اشتراک‌ها
hello world با asp.net 5 و aurelia

The purpose of this post isn’t to give an exhaustive tour of Aurelia features, but to show a simple Aurelia application running in ASP.NET 5 and demystify some of the tools you’ll commonly see used with Aurelia.  

hello world با asp.net 5 و aurelia
اشتراک‌ها
سوالات مصاحبه ای ASP.NET Core
What is .NET Core?
What is ASP.NET Core?
Can ASP.NET Core work with the .NET framework?
What is Kestrel?
Dependency Injection in ASP.NET Core?
The configuration in ASP.NET Core?
Talk about Logging in ASP.NET Core?
Explain Middleware in ASP.NET Core?
Explain startup process in ASP.NET Core?
سوالات مصاحبه ای ASP.NET Core
مطالب
روش آپلود فایل‌ها به همراه اطلاعات یک مدل در برنامه‌های Blazor WASM 5x
از زمان Blazor 5x، امکان آپلود فایل به صورت استاندارد به Blazor اضافه شده‌است که نمونه‌ی Blazor Server آن‌را پیشتر در مطلب «Blazor 5x - قسمت 17 - کار با فرم‌ها - بخش 5 - آپلود تصاویر» مطالعه کردید. در تکمیل آن، روش آپلود فایل‌ها در برنامه‌های WASM را نیز بررسی خواهیم کرد. این برنامه از نوع hosted است؛ یعنی توسط دستور dotnet new blazorwasm --hosted ایجاد شده‌است و به صورت خودکار دارای سه بخش Client، Server و Shared است.



معرفی مدل ارسالی برنامه سمت کلاینت

فرض کنید مطابق شکل فوق، قرار است اطلاعات یک کاربر، به همراه تعدادی تصویر از او، به سمت Web API ارسال شوند. برای نمونه، مدل اشتراکی کاربر را به صورت زیر تعریف کرده‌ایم:
using System.ComponentModel.DataAnnotations;

namespace BlazorWasmUpload.Shared
{
    public class User
    {
        [Required]
        public string Name { get; set; }

        [Required]
        [Range(18, 90)]
        public int Age { get; set; }
    }
}

ساختار کنترلر Web API دریافت کننده‌ی مدل برنامه

در این حالت امضای اکشن متد CreateUser واقع در کنترلر Files که قرار است این اطلاعات را دریافت کند، به صورت زیر است:
namespace BlazorWasmUpload.Server.Controllers
{
    [ApiController]
    [Route("api/[controller]/[action]")]
    public class FilesController : ControllerBase
    {
        [HttpPost]
        public async Task<IActionResult> CreateUser(
            [FromForm] User userModel,
            [FromForm] IList<IFormFile> inputFiles = null)
یعنی در سمت Web API، قرار است اطلاعات مدل User و همچنین لیستی از فایل‌های آپلودی (احتمالی و اختیاری) را یکجا و در طی یک عملیات Post، دریافت کنیم. در اینجا نام پارامترهایی را هم که انتظار داریم، دقیقا userModel و inputFiles هستند. همچنین فایل‌های آپلودی باید بتوانند ساختار IFormFile استاندارد ASP.NET Core را تشکیل داده و به صورت خودکار به پارامترهای تعریف شده، bind شوند. به علاوه content-type مورد انتظار هم FromForm است.


ایجاد سرویسی در سمت کلاینت، برای آپلود اطلاعات یک مدل به همراه فایل‌های انتخابی کاربر

کدهای کامل سرویسی که می‌تواند انتظارات یاد شده را در سمت کلاینت برآورده کند، به صورت زیر است:
using System.Collections.Generic;
using System.Linq;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Components.Forms;

namespace BlazorWasmUpload.Client.Services
{
    public interface IFilesManagerService
    {
        Task<HttpResponseMessage> PostModelWithFilesAsync<T>(string requestUri,
            IEnumerable<IBrowserFile> browserFiles,
            string fileParameterName,
            T model,
            string modelParameterName);
    }

    public class FilesManagerService : IFilesManagerService
    {
        private readonly HttpClient _httpClient;

        public FilesManagerService(HttpClient httpClient)
        {
            _httpClient = httpClient;
        }

        public async Task<HttpResponseMessage> PostModelWithFilesAsync<T>(
            string requestUri,
            IEnumerable<IBrowserFile> browserFiles,
            string fileParameterName,
            T model,
            string modelParameterName)
        {
            var requestContent = new MultipartFormDataContent();
            requestContent.Headers.ContentDisposition = new ContentDispositionHeaderValue("form-data");

            if (browserFiles?.Any() == true)
            {
                foreach (var file in browserFiles)
                {
                    var stream = file.OpenReadStream(maxAllowedSize: 512000 * 1000);
                    requestContent.Add(content: new StreamContent(stream, (int)file.Size), name: fileParameterName, fileName: file.Name);
                }
            }

            requestContent.Add(
                content: new StringContent(JsonSerializer.Serialize(model), Encoding.UTF8, "application/json"),
                name: modelParameterName);

            var result = await _httpClient.PostAsync(requestUri, requestContent);
            result.EnsureSuccessStatusCode();
            return result;
        }
    }
}
توضیحات:
- کامپوننت استاندارد InputFiles در Blazor Wasm، می‌تواند لیستی از IBrowserFile‌های انتخابی توسط کاربر را در اختیار ما قرار دهد.
- fileParameterName، همان نام پارامتر "inputFiles" در اکشن متد سمت سرور مثال جاری است که به صورت متغیر قابل تنظیم شده‌است.
- model جنریک، برای نمونه وهله‌ای از شیء User است که به یک فرم Blazor متصل است.
- modelParameterName، همان نام پارامتر "userModel" در اکشن متد سمت سرور مثال جاری است که به صورت متغیر قابل تنظیم شده‌است.

- در ادامه یک MultipartFormDataContent را تشکیل داده‌ایم. توسط این ساختار می‌توان فایل‌ها و اطلاعات یک مدل را به صورت یکجا جمع آوری و به سمت سرور ارسال کرد. به این content ویژه، ابتدای لیستی از new StreamContent‌ها را اضافه می‌کنیم. این streamها توسط متد OpenReadStream هر IBrowserFile دریافتی از کامپوننت InputFile، تشکیل می‌شوند. متد OpenReadStream به صورت پیش‌فرض فقط فایل‌هایی تا حجم 500 کیلوبایت را پردازش می‌کند و اگر فایلی حجیم‌تر را به آن معرفی کنیم، یک استثناء را صادر خواهد کرد. به همین جهت می‌توان توسط پارامتر maxAllowedSize آن، این مقدار پیش‌فرض را تغییر داد.

- در اینجا مدل برنامه به صورت JSON به عنوان یک new StringContent اضافه شده‌است. مزیت کار کردن با JsonSerializer.Serialize استاندارد، ساده شدن برنامه و عدم درگیری با مباحث Reflection و خواندن پویای اطلاعات مدل جنریک است. اما در ادامه مشکلی را پدید خواهد آورد! این رشته‌ی ارسالی به سمت سرور، به صورت خودکار به یک مدل، Bind نخواهد شد و باید برای آن یک model-binder سفارشی را بنویسیم. یعنی این رشته‌ی new StringContent را در سمت سرور دقیقا به صورت یک رشته معمولی می‌توان دریافت کرد و نه حالت دیگری و مهم نیست که اکنون به صورت JSON ارسال می‌شود؛ چون MultipartFormDataContent ویژه‌ای را داریم، model-binder پیش‌فرض ASP.NET Core، انتظار یک شیء خاص را در این بین ندارد.

- تنظیم "form-data" را هم به عنوان Headers.ContentDisposition مشاهده می‌کنید. بدون وجود آن، ویژگی [FromForm] سمت Web API، از پردازش درخواست جلوگیری خواهد کرد.

- در آخر توسط متد PostAsync، این اطلاعات جمع آوری شده، به سمت سرور ارسال خواهند شد.

پس از تهیه‌ی سرویس ویژه‌ی فوق که می‌تواند اطلاعات فایل‌ها و یک مدل را به صورت یکجا به سمت سرور ارسال کند، اکنون نوبت به ثبت و معرفی آن به سیستم تزریق وابستگی‌ها در فایل Program.cs برنامه‌ی کلاینت است:
namespace BlazorWasmUpload.Client
{
    public class Program
    {
        public static async Task Main(string[] args)
        {
            var builder = WebAssemblyHostBuilder.CreateDefault(args);
            // ...

            builder.Services.AddScoped<IFilesManagerService, FilesManagerService>();

            // ...
        }
    }
}


تکمیل فرم ارسال اطلاعات مدل و فایل‌های همراه آن در برنامه‌ی Blazor WASM

در ادامه پس از تشکیل IFilesManagerService، نوبت به استفاده‌ی از آن است. به همین جهت همان کامپوننت Index برنامه را به صورت زیر تغییر می‌دهیم:
@code
{
    IReadOnlyList<IBrowserFile> SelectedFiles;
    User UserModel = new User();
    bool isProcessing;
    string UploadErrorMessage;
در اینجا فیلدهای مورد استفاده‌ی در فرم برنامه مشخص شده‌اند:
- SelectedFiles همان لیست فایل‌های انتخابی توسط کاربر است.
- UserModel شیءای است که به EditForm جاری متصل خواهد شد.
- توسط isProcessing ابتدا و انتهای آپلود به سرور را مشخص می‌کنیم.
- UploadErrorMessage، خطای احتمالی انتخاب فایل‌ها مانند «فقط تصاویر را انتخاب کنید» را تعریف می‌کند.

بر این اساس، فرمی را که در تصویر ابتدای بحث مشاهده کردید، به صورت زیر تشکیل می‌دهیم:
@page "/"

@using System.IO
@using BlazorWasmUpload.Shared
@using BlazorWasmUpload.Client.Services

@inject IFilesManagerService FilesManagerService

<h3>Post a model with files</h3>

<EditForm Model="UserModel" OnValidSubmit="CreateUserAsync">
    <DataAnnotationsValidator />
    <div>
        <label>Name</label>
        <InputText @bind-Value="UserModel.Name"></InputText>
        <ValidationMessage For="()=>UserModel.Name"></ValidationMessage>
    </div>
    <div>
        <label>Age</label>
        <InputNumber @bind-Value="UserModel.Age"></InputNumber>
        <ValidationMessage For="()=>UserModel.Age"></ValidationMessage>
    </div>
    <div>
        <label>Photos</label>
        <InputFile multiple disabled="@isProcessing" OnChange="OnInputFileChange" />
        @if (!string.IsNullOrWhiteSpace(UploadErrorMessage))
        {
            <div>
                @UploadErrorMessage
            </div>
        }
        @if (SelectedFiles?.Count > 0)
        {
            <table>
                <thead>
                    <tr>
                        <th>Name</th>
                        <th>Size (bytes)</th>
                        <th>Last Modified</th>
                        <th>Type</th>
                    </tr>
                </thead>
                <tbody>
                    @foreach (var selectedFile in SelectedFiles)
                    {
                        <tr>
                            <td>@selectedFile.Name</td>
                            <td>@selectedFile.Size</td>
                            <td>@selectedFile.LastModified</td>
                            <td>@selectedFile.ContentType</td>
                        </tr>
                    }
                </tbody>
            </table>
        }
    </div>
    <div>
        <button disabled="@isProcessing">Create user</button>
    </div>
</EditForm>
توضیحات:
- UserModel که وهله‌ی از شیء اشتراکی User است، به EditForm متصل شده‌است.
- سپس توسط یک InputText و InputNumber، مقادیر خواص نام و سن کاربر را دریافت می‌کنیم.
- InputFile دارای ویژگی multiple هم امکان دریافت چندین فایل را توسط کاربر میسر می‌کند. پس از انتخاب فایل‌ها، رویداد OnChange آن، توسط متد OnInputFileChange مدیریت خواهد شد:
    private void OnInputFileChange(InputFileChangeEventArgs args)
    {
        var files = args.GetMultipleFiles(maximumFileCount: 15);
        if (args.FileCount == 0 || files.Count == 0)
        {
            UploadErrorMessage = "Please select a file.";
            return;
        }

        var allowedExtensions = new List<string> { ".jpg", ".png", ".jpeg" };
        if(!files.Any(file => allowedExtensions.Contains(Path.GetExtension(file.Name), StringComparer.OrdinalIgnoreCase)))
        {
            UploadErrorMessage = "Please select .jpg/.jpeg/.png files only.";
            return;
        }

        SelectedFiles = files;
        UploadErrorMessage = string.Empty;
    }
- در اینجا امضای متد رویداد گردان OnChange را مشاهده می‌کنید. توسط متد GetMultipleFiles می‌توان لیست فایل‌های انتخابی توسط کاربر را دریافت کرد. نیاز است پارامتر maximumFileCount آن‌را نیز تنظیم کنیم تا دقیقا مشخص شود چه تعداد فایلی مدنظر است؛ بیش از آن، یک استثناء را صادر می‌کند.
- در ادامه اگر فایلی انتخاب نشده باشد، یا فایل انتخابی، تصویری نباشد، با مقدار دهی UploadErrorMessage، خطایی را به کاربر نمایش می‌دهیم.
- در پایان این متد، لیست فایل‌های دریافتی را به فیلد SelectedFiles انتساب می‌دهیم تا در ذیل InputFile، به صورت یک جدول نمایش داده شوند.

مرحله‌ی آخر تکمیل این فرم، تدارک متد رویدادگردان OnValidSubmit فرم برنامه است:
    private async Task CreateUserAsync()
    {
        try
        {
            isProcessing = true;
            await FilesManagerService.PostModelWithFilesAsync(
                        requestUri: "api/Files/CreateUser",
                        browserFiles: SelectedFiles,
                        fileParameterName: "inputFiles",
                        model: UserModel,
                        modelParameterName: "userModel");
            UserModel = new User();
        }
        finally
        {
            isProcessing = false;
            SelectedFiles = null;
        }
    }
- در اینجا زمانیکه isProcessing به true تنظیم می‌شود، دکمه‌ی ارسال اطلاعات، غیرفعال خواهد شد؛ تا از کلیک چندباره‌ی بر روی آن جلوگیری شود.
- سپس روش استفاده‌ی از متد PostModelWithFilesAsync سرویس FilesManagerService را مشاهده می‌کنید که اطلاعات فایل‌ها و مدل برنامه را به سمت اکشن متد api/Files/CreateUser ارسال می‌کند.
- در آخر با وهله سازی مجدد UserModel، به صورت خودکار فرم برنامه را پاک کرده و آماده‌ی دریافت اطلاعات بعدی می‌کنیم.


تکمیل کنترلر Web API دریافت کننده‌ی مدل برنامه

در ابتدای بحث، ساختار ابتدایی کنترلر Web API دریافت کننده‌ی اطلاعات FilesManagerService.PostModelWithFilesAsync فوق را معرفی کردیم. در ادامه کدهای کامل آن‌را مشاهده می‌کنید:
using System.IO;
using Microsoft.AspNetCore.Mvc;
using BlazorWasmUpload.Shared;
using Microsoft.AspNetCore.Hosting;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Http;
using System.Collections.Generic;
using Microsoft.Extensions.Logging;
using System.Text.Json;
using BlazorWasmUpload.Server.Utils;
using System.Linq;

namespace BlazorWasmUpload.Server.Controllers
{
    [ApiController]
    [Route("api/[controller]/[action]")]
    public class FilesController : ControllerBase
    {
        private const int MaxBufferSize = 0x10000;

        private readonly IWebHostEnvironment _webHostEnvironment;
        private readonly ILogger<FilesController> _logger;

        public FilesController(
            IWebHostEnvironment webHostEnvironment,
            ILogger<FilesController> logger)
        {
            _webHostEnvironment = webHostEnvironment;
            _logger = logger;
        }

        [HttpPost]
        public async Task<IActionResult> CreateUser(
            //[FromForm] string userModel, // <-- this is the actual form of the posted model
            [ModelBinder(BinderType = typeof(JsonModelBinder)), FromForm] User userModel,
            [FromForm] IList<IFormFile> inputFiles = null)
        {
            /*var user = JsonSerializer.Deserialize<User>(userModel);
            _logger.LogInformation($"userModel.Name: {user.Name}");
            _logger.LogInformation($"userModel.Age: {user.Age}");*/

            _logger.LogInformation($"userModel.Name: {userModel.Name}");
            _logger.LogInformation($"userModel.Age: {userModel.Age}");

            var uploadsRootFolder = Path.Combine(_webHostEnvironment.WebRootPath, "Files");
            if (!Directory.Exists(uploadsRootFolder))
            {
                Directory.CreateDirectory(uploadsRootFolder);
            }

            if (inputFiles?.Any() == true)
            {
                foreach (var file in inputFiles)
                {
                    if (file == null || file.Length == 0)
                    {
                        continue;
                    }

                    var filePath = Path.Combine(uploadsRootFolder, file.FileName);
                    using var fileStream = new FileStream(filePath,
                                                            FileMode.Create,
                                                            FileAccess.Write,
                                                            FileShare.None,
                                                            MaxBufferSize,
                                                            useAsync: true);
                    await file.CopyToAsync(fileStream);
                    _logger.LogInformation($"Saved file: {filePath}");
                }
            }

            return Ok();
        }
    }
}
نکات تکمیلی این کنترلر را در مطلب «بررسی روش آپلود فایل‌ها در ASP.NET Core» می‌توانید مطالعه کنید و از این لحاظ هیچ نکته‌ی جدیدی را به همراه ندارد؛ بجز پارامتر userModel آن:
[ModelBinder(BinderType = typeof(JsonModelBinder)), FromForm] User userModel,
همانطور که عنوان شد، userModel ارسالی به سمت سرور چون به همراه تعدادی فایل است، به صورت خودکار به شیء User نگاشت نخواهد شد. به همین جهت نیاز است model-binder سفارشی زیر را برای آن تهیه کرد:
using System;
using System.Text.Json;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.ModelBinding;

namespace BlazorWasmUpload.Server.Utils
{
    public class JsonModelBinder : IModelBinder
    {
        public Task BindModelAsync(ModelBindingContext bindingContext)
        {
            if (bindingContext == null)
            {
                throw new ArgumentNullException(nameof(bindingContext));
            }

            var valueProviderResult = bindingContext.ValueProvider.GetValue(bindingContext.ModelName);
            if (valueProviderResult != ValueProviderResult.None)
            {
                bindingContext.ModelState.SetModelValue(bindingContext.ModelName, valueProviderResult);

                var valueAsString = valueProviderResult.FirstValue;
                var result = JsonSerializer.Deserialize(valueAsString, bindingContext.ModelType);
                if (result != null)
                {
                    bindingContext.Result = ModelBindingResult.Success(result);
                    return Task.CompletedTask;
                }
            }

            return Task.CompletedTask;
        }
    }
}
در اینجا مقدار رشته‌ای پارامتر مزین شده‌ی توسط JsonModelBinder فوق، توسط متد استاندارد JsonSerializer.Deserialize تبدیل به یک شیء شده و به آن پارامتر انتساب داده می‌شود. اگر نخواهیم از این model-binder سفارشی استفاده کنیم، ابتدا باید پارامتر دریافتی را رشته‌ای تعریف کنیم و سپس خودمان کار فراخوانی متد JsonSerializer.Deserialize را انجام دهیم:
[HttpPost]
public async Task<IActionResult> CreateUser(
            [FromForm] string userModel, // <-- this is the actual form of the posted model
            [FromForm] IList<IFormFile> inputFiles = null)
{
  var user = JsonSerializer.Deserialize<User>(userModel);


یک نکته تکمیلی: در Blazor 5x، از نمایش درصد پیشرفت آپلود، پشتیبانی نمی‌شود؛ از این جهت که HttpClient طراحی شده، در اصل به fetch API استاندارد مرورگر ترجمه می‌شود و این API استاندارد، هنوز از streaming پشتیبانی نمی‌کند . حتی ممکن است با کمی جستجو به راه‌حل‌هایی که سعی کرده‌اند بر اساس HttpClient و نوشتن بایت به بایت اطلاعات در آن، درصد پیشرفت آپلود را محاسبه کرده باشند، برسید. این راه‌حل‌ها تنها کاری را که انجام می‌دهند، بافر کردن اطلاعات، جهت fetch API و سپس ارسال تمام آن است. به همین جهت درصدی که نمایش داده می‌شود، درصد بافر شدن اطلاعات در خود مرورگر است (پیش از ارسال آن به سرور) و سپس تحویل آن به fetch API جهت ارسال نهایی به سمت سرور.



کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: BlazorWasmUpload.zip
اشتراک‌ها
شاید مایکروسافت با واتس آپ روی اپلیکیشنی از نوع UWP کار کنند

WhatsApp for Windows Phone is one of the few apps on Windows 10 Mobile today that continues to receive frequent updates from its developer. Unfortunately, the app itself is one based on Silverlight, which is what apps built for Windows Phone 8.1 used back in 2014. This means the app isn't a Universal Windows Platform app (UWP,) and as such doesn't run across all the different Windows 10 platforms and devices available today. 

شاید مایکروسافت با واتس آپ روی اپلیکیشنی از نوع UWP کار کنند