امکان خروجی اکسل از گزارشات سیستم، یکی از بایدهای بیشتر سیستمهای اطلاعاتی میباشد؛ یکی از چالشهای اصلی در تولید این نوع خروجی، افزایش مصرف حافظه متناسب با افزایش حجم دیتا میباشد. از آنجاییکه بیشتر راهکارهای موجود از جمله ClosedXml یا Epplus کل ساختار را ابتدا تولید کرده و اصطلاحا خروجی مورد نظر را بافر میکنند، برای حجم بالای اطلاعات مناسب نخواهند بود. راهکار برای خروجی CSV به عنوان مثال خیلی سرراست میباشد و میتوان با چند خط کد، به نتیجه دلخواه از طریق مکانیزم Streaming رسید؛ ولی ساختار Excel به سادگی فرمت CSV نیست و برای مثال فرمت Excel Workbook با پسوند xlsx یک بسته Zip شدهای از فایلهای XML میباشد.
معرفی MiniExcel
MiniExcel یک کتابخانه سورس باز با هدف به حداقل رساندن مصرف حافظه در زمان پردازش فایلهای Excel در دات نت میباشد. در مقایسه با Aspose از منظر امکانات شاید حرفی برای گفتن نداشته باشد، ولی از جهت خواندن اطلاعات فایلهای Excel با قابلیت پشتیبانی از LINQ و Deferred Execution در کنار مصرف کم حافظه و جلوگیری از مشکل OOM خیلی خوب عمل میکند. در تصویر زیر مشخص است که برای عمده عملیات پیادهسازی شده، از استریمها بهره برده شده است.
همچنین در زیر مقایسهای روی خروجی ۱ میلیون رکورد با تعداد ۱۰ ستون در هر ردیف انجام شدهاست که قابل توجه میباشد:
Logic : create a total of 10,000,000 "HelloWorld" excel
LibraryMethodMax Memory UsageMean | |||
MiniExcel | 'MiniExcel Create Xlsx' | 15 MB | 11.53181 sec |
Epplus | 'Epplus Create Xlsx' | 1,204 MB | 22.50971 sec |
OpenXmlSdk | 'OpenXmlSdk Create Xlsx' | 2,621 MB | 42.47399 sec |
ClosedXml | 'ClosedXml Create Xlsx' | 7,141 MB | 140.93992 sec |
به شدت API خوش دستی برای استفاده دارد و شاید مطالعه سورس کد آن از جهت طراحی نیز درس آموزی داشته باشد. در ادامه چند مثال از مستندات آن را میتوانید ملاحظه کنید:
var path = Path.Combine(Path.GetTempPath(), $"{Guid.NewGuid()}.xlsx"); MiniExcel.SaveAs(path, new[] { new { Column1 = "MiniExcel", Column2 = 1 }, new { Column1 = "Github", Column2 = 2} });
// DataReader export multiple sheets (recommand by Dapper ExecuteReader) using (var cnn = Connection) { cnn.Open(); var sheets = new Dictionary<string,object>(); sheets.Add("sheet1", cnn.ExecuteReader("select 1 id")); sheets.Add("sheet2", cnn.ExecuteReader("select 2 id")); MiniExcel.SaveAs("Demo.xlsx", sheets); }
طراحی یک ActionResult سفارشی برای استفاده از MiniExcel
برای این منظور نیاز است تا Stream مربوط به Response درخواست جاری را در اختیار این کتابخانه قرار دهیم و از سمت دیگر دیتای مورد نیاز را به نحوی که بافر نشود و از طریق مکانیزم Streaming در EF (استفاده از Deferred Execution و Enumerableها) مهیا کنیم. برای امکان تعویض پذیری (این سناریو در پروژه واقعی و باتوجه به جهت وابستگیها میتواند ضروری باشد) از دو واسط زیر استفاده خواهیم کرد:
public interface IExcelDocumentFactory { ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> headers, Stream stream); } public interface ILargeExcelDocument : IAsyncDisposable, IDisposable { Task Write<T>( PaginatedEnumerable<T> items, int count, int sizeLimit, CancellationToken cancellationToken = default) where T : notnull; }
متد CreateLargeDocument یک وهله از ILargeExcelDocument را در اختیار مصرف کننده قرار میدهد که قابلیت نوشتن روی آن از طریق متد Write را خواهد داشت. روش واکشی دیتا از طریق Delegate تعریف شده با نام PaginatedEnumerable به مصرف کننده محول شدهاست که در ادامه امضای آن را میتوانید مشاهده کنید:
public delegate IEnumerable<T> PaginatedEnumerable<out T>(int page, int pageSize);
در ادامه پیادهسازی واسط ILargeExcelDocument برای MiniExcel به شکل زیر خواهد بود:
internal sealed class MiniExcelDocument(Stream stream, IEnumerable<ExcelColumn> columns) : ILargeExcelDocument { private const int SheetLimit = 1_048_576; private bool _disposedValue; public async Task Write<T>( PaginatedEnumerable<T> items, int count, int sizeLimit, CancellationToken cancellationToken = default) where T : notnull { ThrowIfDisposed(); // TODO: apply sizeLimit var properties = FastReflection.Instance.GetProperties(typeof(T)) .ToDictionary(p => p.Name, StringComparer.OrdinalIgnoreCase); var sheets = new Dictionary<string, object>(); var index = 1; while (count > 0) { cancellationToken.ThrowIfCancellationRequested(); IEnumerable<Dictionary<string, object>> reader = items(index, SheetLimit) .Select(item => { cancellationToken.ThrowIfCancellationRequested(); return columns.ToDictionary(h => h.Title, h => ValueOf(item, h.Name, properties)); }); sheets.Add($"sheet_{index}", reader); count -= SheetLimit; index++; } // This part is forward-only, and we are pretty sure that streaming will happen without buffering. await stream.SaveAsAsync(sheets, cancellationToken: cancellationToken); } private void Dispose(bool disposing) { if (!_disposedValue) { if (disposing) { // TODO: dispose managed state (managed objects) } // TODO: free unmanaged resources (unmanaged objects) and override finalizer // TODO: set large fields to null _disposedValue = true; } } ~MiniExcelDocument() { Dispose(disposing: false); } public void Dispose() { // Do not change this code. Put cleanup code in 'Dispose(bool disposing)' method Dispose(disposing: true); GC.SuppressFinalize(this); } public async ValueTask DisposeAsync() { Dispose(); await ValueTask.CompletedTask; } private void ThrowIfDisposed() { if (!_disposedValue) return; throw new ObjectDisposedException(nameof(MiniExcelDocument)); } private static object ValueOf<T>(T record, string prop, IDictionary<string, FastPropertyInfo> properties) where T : notnull { var property = properties[prop] ?? throw new InvalidOperationException($"There is no property with given name [{prop}]"); return NormalizeValue(property.GetValue?.Invoke(record)); } private static object NormalizeValue(object? value) { if (value == null) return null!; return value switch { DateTime dateTime => dateTime.ToShortPersianDateTimeString(), TimeSpan time => time.ToString(@"hh\:mm\:ss"), DateOnly dateTime => dateTime.ToShortPersianDateString(false), TimeOnly time => time.ToString(@"hh\:mm\:ss"), bool boolean => boolean ? "بلی" : "خیر", IEnumerable<object> values => string.Join(',', values.Select(NormalizeValue).ToList()), Enum enumField => enumField.GetEnumStringValue(), _ => value }; } }
در بدنه متد Write باتوجه به تعداد کل رکوردها، یک کوئری برای هر شیت از طریق فراخوانی متد منتسب به پارامتر items اجرا خواهد شد؛ توجه کنید که اجرای این کوئری مشخصا به تعویق افتاده و تا زمان اولین MoveNext، اجرایی صورت نخواهد گرفت (مفهوم Deferred Execution). به این ترتیب باقی کارها از جمله فرمت کردن مقادیر در سمت برنامه و از طریق Linq To Object انجام خواهد شد. همچنین پیادهسازی Factory مرتبط با آن به شکل زیر خواهد بود:
internal sealed class ExcelDocumentFactory : IExcelDocumentFactory { public ILargeExcelDocument CreateLargeDocument(IEnumerable<ExcelColumn> columns, Stream stream) { return new MiniExcelDocument(stream, columns); } }
در ادامه ActionResult سفارشی برای گرفتن خروجی اکسل را به شکل زیر می توان پیادهسازی کرد:
public class ExcelExportResult<T>(PaginatedEnumerable<T> items, int count, ExportMetadata metadata) : ActionResult where T : notnull { private const string ContentType = "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"; private const string Extension = ".xlsx"; private const int SizeLimit = int.MaxValue; private readonly IReadOnlyList<FastPropertyInfo> _properties = FastReflection.Instance.GetProperties(typeof(T)); public override async Task ExecuteResultAsync(ActionContext context) { var sp = context.HttpContext.RequestServices; var factory = sp.GetRequiredService<IExcelDocumentFactory>(); var disposition = new ContentDispositionHeaderValue(DispositionTypeNames.Attachment); disposition.SetHttpFileName(MakeFilename()); context.HttpContext.Response.Headers[HeaderNames.ContentDisposition] = disposition.ToString(); context.HttpContext.Response.Headers.Append(HeaderNames.ContentType, ContentType); context.HttpContext.Response.StatusCode = StatusCodes.Status200OK; //TODO: deal with exception, because our global exception handling cannot take into account while the response is started. await using var bodyStream = context.HttpContext.Response.BodyWriter.AsStream(); await context.HttpContext.Response.StartAsync(context.HttpContext.RequestAborted); await using (var document = factory.CreateLargeDocument(MakeColumns(), bodyStream)) { await document.Write(items, count, SizeLimit, context.HttpContext.RequestAborted); } await context.HttpContext.Response.CompleteAsync(); } private string MakeFilename() { return $"{metadata.Title} - {DateTime.UtcNow.ToEpochSeconds()}{Extension}"; } private IEnumerable<ExcelColumn> MakeColumns() { var types = _properties.ToDictionary(p => p.Name, p => p.PropertyType, StringComparer.OrdinalIgnoreCase); return metadata.Fields.Select(f => { var type = types[f.Name]; type = Nullable.GetUnderlyingType(type) ?? type; if (type.IsEnum || type == typeof(DateOnly) || type == typeof(TimeOnly) || type == typeof(bool) || type == typeof(TimeSpan) || type == typeof(DateTime)) { type = typeof(string); } return new ExcelColumn(f.Name, f.Title, type); }); } }
در اینجا از طریق ExportMetadata که از سمت کاربر تعیین میشود، مشخص خواهد شد که کدام فیلدها در فایل نهایی حضور داشته باشند. در بدنه متد ExecuteResultAsync یکسری هدر مرتبط با کار با فایلها تنظیم شدهاست و سپس از طریق BodyWriter و متد AsStream به استریم مورد نظر دست یافته و در اختیار متد Write مربوط به document ایجاد شده، قرار دادهایم. یک نمونه استفاده از آن برای موجودیت فرضی مشتری می تواند به شکل زیر باشد:
[ApiController, Route("api/customers")] public class CustomersController(IDbContext dbContext) : ControllerBase { [HttpGet("export")] public async Task<ActionResult> ExportCustomers([FromQuery] ExportMetadata metadata, CancellationToken cancellationToken) { var count = await dbContext.Set<Customer>().CountAsync(cancellationToken); return this.Export( (page, pageSize) => dbContext.Set<Customer>() .OrderBy(c => c.Id) .Skip((page - 1) * pageSize) .Take(pageSize) .AsNoTracking() .AsEnumerable(), // Enable streaming instead of buffering through deferred execution count, metadata); } }
در اینجا از طریق Extension Method مهیا شده روش کوئری کردن برای هر شیت را مشخص کردهایم؛ نکته مهم در ایجاد استفاده از متد AsEnumerable می باشد که در عمل یک Type Casting انجام می دهد که باقی متدهای استفاده شده روی خروجی، از طریق Linq To Object اعمال شود و همچنین نیاز به استفاده از ToList و یا موارد مشابه را نخواهیم داشت. نمونه درخواست GET برای این API می تواند به شکل زیر باشد:
http://localhost:5118/api/customers/export?Title=Test&Fields[0].Name=FirstName&Fields[0].Title=First name&Fields[1].Name=LastName&Fields[1].Title=Last name&Fields[2].Name=BirthDate&Fields[2].Title=BirthDate
سورس کد مثال قابل اجرا از طریق مخزن زیر قابل دسترس می باشد:
https://github.com/rabbal/large-excel-streaming
در این مثال در زمان آغاز برنامه، ۱۰ میلیون رکورد در جدول Customer ثبت خواهد شد که در ادامه می توان از آن خروجی Excel تهیه کرد.
نکته مهم: توجه داشته باشید که استفاده از این روش قابلیت از سرگیری مجدد برای دانلود را نخواهد داشت و شاید بهتر است این فرآیند را از طریق یک Job انجام داده و با استفاده از قابلیتهای Multipart Upload مربوط به یک BlobStroage مانند Minio، خروجی مورد نظر از قبل ذخیره کرده و لینک دانلودی را در اختیار کاربر قرار دهید.
public class Rider { public int Id { get; set; } public EquineBeast Mount { get; set; } } public enum EquineBeast { Donkey, Mule, Horse, Unicorn }
protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder .Entity<Rider>() .Property(e => e.Mount) .HasConversion( v => v.ToString(), v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); }
نکته 1: مقادیر نال، هیچگاه به تبدیلگرهای مقدار، ارسال نمیشوند. اینکار پیاده سازی آنها را سادهتر میکند و همچنین میتوان آنها را بین خواص نالپذیر و نالنپذیر، به اشتراک گذاشت. بنابراین برای مقادیر نال نمیتوان تبدیلگر نوشت.
نکته 2: کاری که در متد HasConversion فوق انجام شدهاست، در حقیقت وهله سازی ضمنی یک ValueConverter و استفاده از آن است. میتوان اینکار را به صورت صریح نیز انجام داد:
var converter = new ValueConverter<EquineBeast, string>( v => v.ToString(), v => (EquineBeast)Enum.Parse(typeof(EquineBeast), v)); modelBuilder .Entity<Rider>() .Property(e => e.Mount) .HasConversion(converter);
تبدیلگرهای مقدار توکار EF Core
برای بسیاری از اعمال متداول، در فضای نام Microsoft.EntityFrameworkCore.Storage.ValueConversion، تعدادی تبدیلگر مقدار تدارک دیده شدهاند که به این شرح میباشند:
BoolToZeroOneConverter: تبدیلگر bool به صفر و یک
BoolToStringConverter: تبدیلگر bool به Y و یا N
BoolToTwoValuesConverter: تبدیلگر bool به دو مقداری دلخواه
BytesToStringConverter: تبدیلگر آرایهای از بایتها به یک رشتهی Base64-encoded
CastingConverter: تبدیلگر یک نوع به نوعی دیگر
CharToStringConverter: تبدیلگر char به string
DateTimeOffsetToBinaryConverter: تبدیلگر DateTimeOffset به یک مقدار 64 بیتی باینری
DateTimeOffsetToBytesConverter: تبدیلگر DateTimeOffset به آرایهای از بایتها
DateTimeOffsetToStringConverter: تبدیلگر DateTimeOffset به رشته
DateTimeToBinaryConverter: تبدیلگر DateTime به یک مقدار 64 بیتی با درج DateTimeKind
DateTimeToStringConverter: تبدیلگر DateTime به یک رشته
DateTimeToTicksConverter: تبدیلگر DateTime به ticks آن
EnumToNumberConverter: تبدیلگر Enum به عدد متناظر با آن
EnumToStringConverter: تبدیلگر Enum به رشته
GuidToBytesConverter: تبدیلگر Guid به آرایهای از بایتها
GuidToStringConverter: تبدیلگر Guid به رشته
NumberToBytesConverter: تبدیلگر اعداد به آرایهای از بایتها
NumberToStringConverter: تبدیلگر اعداد به رشته
StringToBytesConverter: تبدیلگر رشته به آرایهای از بایتهای UTF8 معادل آن
TimeSpanToStringConverter: تبدیلگر TimeSpan به رشته
TimeSpanToTicksConverter: تبدیلگر TimeSpan به ticks آن
برای نمونه در این لیست، EnumToStringConverter نیز وجود دارد. بنابراین نیازی به تعریف دستی آن مانند مثال ابتدای بحث نیست و میتوان به صورت زیر از آن استفاده کرد:
var converter = new EnumToStringConverter<EquineBeast>(); modelBuilder .Entity<Rider>() .Property(e => e.Mount) .HasConversion(converter);
تعیین نوع تبدیلگر مقدار، جهت ساده سازی تعاریف
برای حالاتی که تبدیلگر مقدار توکاری تعریف شدهاست، صرفا تعریف نوع تبدیل، کفایت میکند:
modelBuilder .Entity<Rider>() .Property(e => e.Mount) .HasConversion<string>();
public class Rider { public int Id { get; set; } [Column(TypeName = "nvarchar(24)")] public EquineBeast Mount { get; set; } }
نوشتن تبدیلگر خودکار مقادیر خواص، به نمونهای رمزنگاری شده
پس از آشنایی با مفهوم «تبدیلگرهای مقدار» در +EF Core 2.1، اکنون میتوانیم یک نمونهی سفارشی از آنرا نیز طراحی کنیم:
namespace DbConfig.Web.DataLayer.Context { public class MyAppContext : DbContext { // … protected override void OnModelCreating(ModelBuilder builder) { var encryptedConverter = new ValueConverter<string, string>( convertToProviderExpression: v => new string(v.Reverse().ToArray()), // encrypt convertFromProviderExpression: v => new string(v.Reverse().ToArray()) // decrypt ); // Custom application mappings builder.Entity<ConfigurationValue>(entity => { entity.Property(e => e.Value).IsRequired().HasConversion(encryptedConverter); }); } } }
میتوان قسمت HasConversion را به صورت زیر خودکار کرد:
ابتدا یک Attribute جدید را به نام Encrypted به برنامه اضافه میکنیم:
using System; namespace Test { [AttributeUsage(AttributeTargets.Property, Inherited = false, AllowMultiple = false)] public sealed class EncryptedAttribute : Attribute { } }
namespace DbConfig.Web.DomainClasses { public class ConfigurationValue { public int Id { get; set; } public string Key { get; set; } [Encrypted] public string Value { get; set; } } }
namespace DbConfig.Web.DataLayer.Context { public class MyAppContext : DbContext { protected override void OnModelCreating(ModelBuilder builder) { var encryptedConverter = new ValueConverter<string, string>( convertToProviderExpression: v => new string(v.Reverse().ToArray()), // encrypt convertFromProviderExpression: v => new string(v.Reverse().ToArray()) // decrypt ); foreach (var entityType in builder.Model.GetEntityTypes()) { foreach (var property in entityType.GetProperties()) { var attributes = property.PropertyInfo.GetCustomAttributes(typeof(EncryptedAttribute), false); if (attributes.Any()) { property.SetValueConverter(encryptedConverter); } } } }
از دیدگاه برنامه، ValueConverterهای تعریف شده، هیچگونه تاثیری را بر روی کوئری نوشتن و یا ثبت و ویرایش اطلاعات ندارند و عملکرد آنها کاملا از دیدگاه سایر قسمتهای برنامه مخفی است. برای مثال در برنامه، فرمان به روز رسانی خاصیت Value را با مقدار .A new value to test صادر کردهایم (مقدار دهی متداول)، اما همانطور که ملاحظه میکنید، نمونهی رمزنگاری شدهی آن به صورت خودکار در بانک اطلاعاتی درج شدهاست (پارامتر p0):
Executed DbCommand (22ms) [Parameters=[@p1='1', @p0='.tset ot eulav wen A' (Nullable = false) (Size = 4000)], CommandType='Text', CommandTimeout='180'] SET NOCOUNT ON; UPDATE [Configurations] SET [Value] = @p0 WHERE [Id] = @p1; SELECT @@ROWCOUNT;
و یا کوئری زیر
db.Set<ConfigurationValue>().Where(x => x.Value.EndsWith("world!"))
SELECT [x].[Id], [x].[Key], [x].[Value] FROM [Configurations] AS [x] WHERE RIGHT([x].[Value], LEN(N'world!')) = N'!dlrow'
برای تهیه یک RadioButtonList نیز میتوان از همان نکتهی CheckBoxList استفاده کرد: نام عناصر radio button اضافه شده به صفحه را یکسان وارد میکنیم. به این ترتیب یک گروه تشکیل خواهد شد و زمانیکه اطلاعات این عناصر به سرور ارسال میشود، اینبار بجای یک آرایه، تنها مقدار کنترل انتخاب شده، ارسال میگردد. یک مثال:
یک پروژه جدید و خالی ASP.NET MVC را آغاز کنید. سپس کنترلر Home و View خالی Index را نیز ایجاد نمائید. محتویات این دو را به نحو زیر تغییر دهید:
@{
ViewBag.Title = "Index";
}
<h2>
Index</h2>
<fieldset>
<legend>HandleForm1 (Normal)</legend>
@using (Html.BeginForm(actionName: "HandleForm1", controllerName: "Home"))
{
@:your favorite tech: <br />
@Html.RadioButton(name: "tech", value: ".NET", isChecked: true) @:DOTNET <br />
@Html.RadioButton(name: "tech", value: "JAVA", isChecked: false) @:JAVA <br />
@Html.RadioButton(name: "tech", value: "PHP", isChecked: false) @:PHP <br />
<input type="submit" value="Submit" />
}
</fieldset>
using System.Collections.Generic;
using System.Web.Mvc;
namespace MvcApplication23.Controllers
{
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
return View();
}
[HttpPost]
public ActionResult HandleForm1(string tech)
{
return RedirectToAction("Index");
}
}
}
در اینجا سه RadioButton با نامی یکسان در صفحه اضافه شدهاند. سپس داخل متد HandleForm1 یک breakpoint قرار دهید. اکنون برنامه را اجرا کنید و فرم را به سرور ارسال نمائید. پارامتر tech با value عنصر انتخابی مقدار دهی خواهد شد.
تهیه یک RadioButtonList عمومی
اطلاعات فوق را میتوان تبدیل به یک HtmlHelper با قابلیت استفاده مجدد نیز نمود:
@helper RadioButtonList(string groupName, IEnumerable<System.Web.Mvc.SelectListItem> items)
{
<div class="RadioButtonList">
@foreach (var item in items)
{
@item.Text
<input type="radio" name="@groupName"
value="@item.Value"
@if (item.Selected) { <text>checked="checked"</text> }
/>
<br />
}
</div>
}
برای مثال یک فایل را در مسیر app_code\Helpers.cshtml ایجاد کرده و اطلاعات فوق را به آن اضافه نمائید.
اینبار برای استفاده از آن خواهیم داشت:
using System.Collections.Generic;
using System.Web.Mvc;
namespace MvcApplication23.Controllers
{
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
ViewBag.Tags = new[]
{
new SelectListItem { Text = ".NET", Value = "Val1", Selected = true },
new SelectListItem { Text = "JAVA", Value = "Val2", Selected = false },
new SelectListItem { Text = "PHP", Value = "Val3", Selected = false }
};
return View();
}
[HttpPost]
public ActionResult HandleForm2(string preferredTechnology)
{
return RedirectToAction("Index");
}
}
}
@{
ViewBag.Title = "Index";
}
<h2>
Index</h2>
<fieldset>
<legend>HandleForm2 (Helper)</legend>
@using (Html.BeginForm(actionName: "HandleForm2", controllerName: "Home"))
{
@:your favorite tech: <br />
@Helpers.RadioButtonList("preferredTechnology", (SelectListItem[])ViewBag.Tags)
<input type="submit" value="Submit" />
}
</fieldset>
متد سفارشی تهیه شده، یک آرایه از SelectListItem ها را دریافت کرده و به صورت خودکار تبدیل به RadioButtonList میکند. بر اساس نام آن میتوان به مقدار انتخاب شده ارسالی به سرور در کنترلر مرتبط، دسترسی یافت.
تهیه یک Templated helper سفارشی
در عمل زمانیکه با مدلها کار میکنیم و اطلاعات برنامه قرار است Strongly typed باشند، مرسوم است لیستی از انتخابها را به صورت یک enum تعریف کنند. برای مثال مدل زیر را به برنامه اضافه کنید:
using System.ComponentModel.DataAnnotations;
namespace MvcApplication23.Models
{
public enum Gender
{
[Display(Name = "مرد")]
Male,
[Display(Name = "زن")]
Female,
}
public class User
{
[ScaffoldColumn(false)]
public int Id { set; get; }
[Display(Name = "نام")]
public string Name { set; get; }
[Display(Name = "جنسیت")]
[UIHint("EnumRadioButtonList")]
public Gender Gender { set; get; }
}
}
قصد داریم یک Templated helper سفارشی را به نام EnumRadioButtonList، ایجاد کنیم تا در زمان فراخوانی متد Html.EditorForModel، به صورت خودکار enum تعریف شده را به صورت یک RadioButtonList نمایش دهد.
برای این منظور فایل جدید Views\Shared\EditorTemplates\EnumRadioButtonList.cshtml را به برنامه اضافه کنید. محتوای آنرا به نحو زیر تغییر دهید:
@using System.ComponentModel.DataAnnotations
@using System.Globalization
@model Enum
@{
Func<Enum, string> getDescription = enumItem =>
{
var type = enumItem.GetType();
var memInfo = type.GetMember(enumItem.ToString());
if (memInfo != null && memInfo.Any())
{
var attrs = memInfo[0].GetCustomAttributes(typeof(DisplayAttribute), false);
if (attrs != null && attrs.Any())
return ((DisplayAttribute)attrs[0]).GetName();
}
return enumItem.ToString();
};
var listItems = Enum.GetValues(Model.GetType())
.OfType<Enum>()
.Select(enumItem =>
new SelectListItem()
{
Text = getDescription(enumItem),
Value = enumItem.ToString(),
Selected = enumItem.Equals(Model)
});
string prefix = ViewData.TemplateInfo.HtmlFieldPrefix;
ViewData.TemplateInfo.HtmlFieldPrefix = string.Empty;
int index = 0;
foreach (var li in listItems)
{
string fieldName = string.Format(CultureInfo.InvariantCulture, "{0}_{1}", prefix, index++);
<div class="editor-radio">
@Html.RadioButton(prefix, li.Value, li.Selected, new { @id = fieldName })
@Html.Label(fieldName, li.Text)
</div>
}
ViewData.TemplateInfo.HtmlFieldPrefix = prefix;
}
در اینجا به کمک Reflection به اطلاعات enum دریافتی دسترسی خواهیم داشت. بر این اساس میتوان نام عناصر آنرا یافت و تبدیل به یک RadioButtonList کرد. البته کار به همینجا ختم نمیشود. در این بین باید دقت داشت که ممکن است از ویژگی Display (مانند مدل نمونه فوق) بر روی تک تک عناصر یک enum نیز استفاده شود. به همین جهت این مورد نیز باید پردازش گردد.
نهایتا برای استفاده از این Templated helper سفارشی، کنترلر و View برنامه را به نحو زیر میتوان تغییر داد:
using System.Collections.Generic;
using System.Web.Mvc;
using MvcApplication23.Models;
namespace MvcApplication23.Controllers
{
public class HomeController : Controller
{
[HttpGet]
public ActionResult Index()
{
var user = new User { Id = 1, Name = "name 1", Gender = Gender.Male };
return View(user);
}
[HttpPost]
public ActionResult HandleForm3(User user)
{
return RedirectToAction("Index");
}
}
}
@model MvcApplication23.Models.User
@{
ViewBag.Title = "Index";
}
<h2>
Index</h2>
<fieldset>
<legend>HandleForm3 (EditorForModel)</legend>
@using (Html.BeginForm(actionName: "HandleForm3", controllerName: "Home"))
{
@Html.EditorForModel()
<input type="submit" value="Submit" />
}
</fieldset>
برای استفاده از یک templated helper سفارشی چندین روش وجود دارد:
الف) همانند مثال فوق از ویژگی UIHint استفاده شود.
ب) نام فایل را به enum.cshtml تغییر دهیم. به این ترتیب از این پس کلیه enumها در صورت استفاده از متد Html.EditorForModel، به صورت خودکار تبدیل به یک RadioButtonList میشوند.
ج) متد زیر نیز همین کار را انجام میدهد:
@Html.EditorFor(model => model.EnumProperty, "EnumRadioButtonList")
قسمت چهار آشنایی با Refactoring به معرفی روش «انتقال متدها» اختصاص دارد؛ انتقال متدها به مکانی بهتر. برای نمونه به کلاسهای زیر پیش از انجام عمل Refactoring دقت کنید:
namespace Refactoring.Day3.MoveMethod.Before
{
public class BankAccount
{
public int AccountAge { get; private set; }
public int CreditScore { get; private set; }
public BankAccount(int accountAge, int creditScore)
{
AccountAge = accountAge;
CreditScore = creditScore;
}
}
}
namespace Refactoring.Day3.MoveMethod.Before
{
public class AccountInterest
{
public BankAccount Account { get; private set; }
public AccountInterest(BankAccount account)
{
Account = account;
}
public double InterestRate
{
get { return CalculateInterestRate(); }
}
public bool IntroductoryRate
{
get { return CalculateInterestRate() < 0.05; }
}
public double CalculateInterestRate()
{
if (Account.CreditScore > 800)
return 0.02;
if (Account.AccountAge > 10)
return 0.03;
return 0.05;
}
}
}
قسمت مورد نظر ما در اینجا، متد AccountInterest.CalculateInterest است. کلاس AccountInterest مرتبا نیاز دارد که از اطلاعات فیلدها و خواص کلاس BankAccount استفاده کند (نکته تشخیص نیاز به این نوع Refactoring). بنابراین بهتر است که این متد را به همان کلاس تعریف کنندهی فیلدها و خواص اصلی آن انتقال داد. پس از این نقل و انتقالات خواهیم داشت:
namespace Refactoring.Day3.MoveMethod.After
{
public class BankAccount
{
public int AccountAge { get; private set; }
public int CreditScore { get; private set; }
public BankAccount(int accountAge, int creditScore)
{
AccountAge = accountAge;
CreditScore = creditScore;
}
public double CalculateInterestRate()
{
if (CreditScore > 800)
return 0.02;
if (AccountAge > 10)
return 0.03;
return 0.05;
}
}
}
namespace Refactoring.Day3.MoveMethod.After
{
public class AccountInterest
{
public BankAccount Account { get; private set; }
public AccountInterest(BankAccount account)
{
Account = account;
}
public double InterestRate
{
get { return Account.CalculateInterestRate(); }
}
public bool IntroductoryRate
{
get { return Account.CalculateInterestRate() < 0.05; }
}
}
}
به همین سادگی!
یک مثال دیگر:
در ادامه به دو کلاس خودرو و موتور خودروی زیر دقت کنید:
namespace Refactoring.Day4.MoveMethod.Ex2.Before
{
public class CarEngine
{
public float LitersPerCylinder { set; get; }
public int NumCylinders { set; get; }
public CarEngine(int numCylinders, float litersPerCylinder)
{
NumCylinders = numCylinders;
LitersPerCylinder = litersPerCylinder;
}
}
}
namespace Refactoring.Day4.MoveMethod.Ex2.Before
{
public class Car
{
public CarEngine Engine { get; private set; }
public Car(CarEngine engine)
{
Engine = engine;
}
public float ComputeEngineVolume()
{
return Engine.LitersPerCylinder * Engine.NumCylinders;
}
}
}
در اینجا هم متد Car.ComputeEngineVolume چندینبار به اطلاعات داخلی کلاس CarEngine دسترسی داشته است؛ بنابراین بهتر است این متد را به جایی منتقل کرد که واقعا به آن تعلق دارد:
namespace Refactoring.Day4.MoveMethod.Ex2.After
{
public class CarEngine
{
public float LitersPerCylinder { set; get; }
public int NumCylinders { set; get; }
public CarEngine(int numCylinders, float litersPerCylinder)
{
NumCylinders = numCylinders;
LitersPerCylinder = litersPerCylinder;
}
public float ComputeEngineVolume()
{
return LitersPerCylinder * NumCylinders;
}
}
}
namespace Refactoring.Day4.MoveMethod.Ex2.After
{
public class Car
{
public CarEngine Engine { get; private set; }
public Car(CarEngine engine)
{
Engine = engine;
}
}
}
بهبودهای حاصل شده:
یکی دیگر از اصول برنامه نویسی شیء گرا "Tell, Don't Ask" است؛ که در مثالهای فوق محقق شده. به این معنا که: در برنامه نویسی رویهای متداول، اطلاعات از قسمتهای مختلف کد جاری جهت انجام عملی دریافت میشود. اما در برنامه نویسی شیء گرا به اشیاء گفته میشود تا کاری را انجام دهند؛ نه اینکه از آنها وضعیت یا اطلاعات داخلیاشان را جهت اخذ تصمیمی دریافت کنیم. به وضوح، متد Car.ComputeEngineVolume پیش از Refactoring ، اصل کپسوله سازی اطلاعات کلاس CarEngine را زیر سؤال برده است. بنابراین به اشیاء بگوئید که چکار کنند و اجازه دهید تا خودشان در مورد نحوهی انجام آن، تصمیم گیرنده نهایی باشند.
«از این پس حین استفاده از انواع و اقسام لیستها، آرایهها، IEnumerableها و امثال آنها، جهت بررسی خالی بودن یا نبودن آنها تنها از متد Any فراهم شده توسط LINQ استفاده نمائید.»
اکنون پس از سالها، قصد داریم صحت این مساله را با NET 5.0. بررسی کنیم که آیا هنوز هم متد Any، بهترین متد بررسی خالی بودن مجموعهها و آرایههای NET 5.0. است یا خیر؟
نحوهی بررسی کارآیی روشهای مختلف خالی بودن مجموعهها و آرایهها در C# 9.0
در ابتدا یک لیست، یک Enumerable و یک آرایه را به صورت زیر مقدار دهی میکنیم و هر سهی اینها میتوانند نال هم باشند:
private IList<int>? _idsList; private IEnumerable<int>? _idsEnumerable; private int[]? _idsArray; [GlobalSetup] public void Setup() { _idsEnumerable = Enumerable.Range(0, 10000); _idsList = _idsEnumerable.ToList(); _idsArray = _idsEnumerable.ToArray(); }
اکنون که C# 9.0 در اختیار ما است به همراه pattern matching و همچنین Null Conditional Operator و غیره، میتوان روشهای زیر را برای بررسی خالی بودن این مجموعهها و آرایهها بکار گرفت:
1- استفاده از Null coalescing برای بررسی نال بودن مجموعه و سپس استفاده از متد Any برای بررسی خالی بودن آن:
var list = _idsList ?? new List<int>(); if (list.Any() is false) { }
2- استفاده از pattern matching برای بررسی نال بودن مجموعه و سپس استفاده از متد Any برای بررسی خالی بودن آن:
if (_idsList is null || _idsList.Any() is false) { }
3- استفاده از روش سنتی مقایسهی مستقیم با null و سپس استفاده از متد Any برای بررسی خالی بودن آن:
if (_idsList == null || _idsList.Any() is false) { }
4- استفاده از Null Conditional Operator برای بررسی نال بودن و سپس استفاده از متد Any برای بررسی خالی بودن آن:
if (_idsList?.Any() is false) { }
5- استفاده از pattern matching برای بررسی مقدار خاصیت Count یک لیست یا آرایه. البته Enumerableها به همراه این خاصیت نیستند و یا باید آنها را به لیست و یا آرایه تبدیل کرد و یا میتوان متد ()Count آنها را فراخوانی نمود:
if (_idsList is { Count: > 0 } is false) { }
6- استفاده از Null Conditional Operator برای بررسی نال بودن و سپس استفاده از مقدار خاصیت Count لیست، برای بررسی خالی بودن آن:
if (_idsList?.Count == 0) { }
7- استفاده از روش سنتی مقایسهی مستقیم با null و سپس استفاده از مقدار خاصیت Count لیست، برای بررسی خالی بودن آن:
if (_idsList == null || _idsList.Count == 0) { }
کدهای کامل این بررسی به صورت زیر هستند: AnyCountBenchmark.zip
ابتدا ارجاعی به BenchmarkDotNet به برنامه اضافه شدهاست:
<Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <OutputType>Exe</OutputType> <TargetFramework>net5.0</TargetFramework> <Nullable>enable</Nullable> <TreatWarningsAsErrors>true</TreatWarningsAsErrors> </PropertyGroup> <ItemGroup> <PackageReference Include="BenchmarkDotNet" Version="0.12.1" /> </ItemGroup> </Project>
و سپس کدهای زیر، بررسی کارآیی روشهای مختلف تعیین خالی بودن مجموعهها را انجام میدهند:
using BenchmarkDotNet.Running; namespace AnyCountBenchmark { public static class Program { static void Main(string[] args) { #if DEBUG System.Console.WriteLine("Please set the project's configuration to Release mode first."); #else BenchmarkRunner.Run<Scenarios>(); #endif } } }
به همراه سناریوهای مختلف زیر:
using System; using System.Collections.Generic; using System.Linq; using BenchmarkDotNet.Attributes; using BenchmarkDotNet.Jobs; using BenchmarkDotNet.Order; namespace AnyCountBenchmark { [SimpleJob(RuntimeMoniker.NetCoreApp50)] [Orderer(SummaryOrderPolicy.FastestToSlowest, MethodOrderPolicy.Declared)] [RankColumn] public class Scenarios { private IList<int>? _idsList; private IEnumerable<int>? _idsEnumerable; private int[]? _idsArray; [GlobalSetup] public void Setup() { _idsEnumerable = Enumerable.Range(0, 10000); _idsList = _idsEnumerable.ToList(); _idsArray = _idsEnumerable.ToArray(); } #region Any_With_Null_coalescing [Benchmark] public void List_Any_With_Null_coalescing() { var list = _idsList ?? new List<int>(); if (list.Any() is false) { } } [Benchmark] public void Array_Any_With_Null_coalescing() { var array = _idsArray ?? Array.Empty<int>(); if (array.Any() is false) { } } [Benchmark] public void Enumerable_Any_With_Null_coalescing() { var enumerable = _idsEnumerable ?? Enumerable.Empty<int>(); if (enumerable.Any() is false) { } } #endregion #region Any_With_Is_Null_Check [Benchmark] public void List_Any_With_Is_Null_Check() { if (_idsList is null || _idsList.Any() is false) { } } [Benchmark] public void Array_Any_With_Is_Null_Check() { if (_idsArray is null || _idsArray.Any() is false) { } } [Benchmark] public void Enumerable_Any_With_Is_Null_Check() { if (_idsEnumerable is null || _idsEnumerable.Any() is false) { } } #endregion #region Any_Any_With_Null_Equality_Check [Benchmark] public void List_Any_With_Null_Equality_Check() { if (_idsList == null || _idsList.Any() is false) { } } [Benchmark] public void Array_Any_With_Null_Equality_Check() { if (_idsArray == null || _idsArray.Any() is false) { } } [Benchmark] public void Enumerable_Any_With_Null_Equality_Check() { if (_idsEnumerable == null || _idsEnumerable.Any() is false) { } } #endregion #region Any_With_Null_Conditional_Operator [Benchmark] public void List_Any_With_Null_Conditional_Operator() { if (_idsList?.Any() is false) { } } [Benchmark] public void Array_Any_With_Null_Conditional_Operator() { if (_idsArray?.Any() is false) { } } [Benchmark] public void Enumerable_Any_With_Null_Conditional_Operator() { if (_idsEnumerable?.Any() is false) { } } #endregion #region Count_With_Pattern_Matching [Benchmark] public void List_Count_With_Pattern_Matching() { if (_idsList is { Count: > 0 } is false) { } } [Benchmark] public void Array_Length_With_Pattern_Matching() { if (_idsArray is { Length: > 0 } is false) { } } [Benchmark] public void Enumerable_Count_With_Pattern_Matching() { var list = _idsEnumerable?.ToList(); if (list is { Count: > 0 } is false) { } } #endregion #region Count_With_Null_Conditional_Operator [Benchmark] public void List_Count_With_Null_Conditional_Operator() { if (_idsList?.Count == 0) { } } [Benchmark] public void Array_Length_With_Null_Conditional_Operator() { if (_idsArray?.Length == 0) { } } [Benchmark] public void Enumerable_Count_With_Null_Conditional_Operator() { if (_idsEnumerable?.Count() == 0) { } } #endregion #region Count_With_Null_Equality_Check [Benchmark] public void List_Count_With_Null_Equality_Check() { if (_idsList == null || _idsList.Count == 0) { } } [Benchmark] public void Array_Length_With_Null_Equality_Check() { if (_idsArray == null || _idsArray.Length == 0) { } } [Benchmark] public void Enumerable_Count_With_Null_Equality_Check() { if (_idsEnumerable == null || _idsEnumerable.Count() == 0) { } } #endregion } }
نتایج حاصل:
- بررسی خالی بودن آرایهها، بسیار سریعتر از بررسی خالی بودن لیستها و این مورد نیز سریعتر از Enumerableها است.
- اگر از آرایهها و یا لیستها استفاده میکنید، بررسی خاصیت Length و یا خاصیت Count آنها، بسیار سریعتر از بکارگیری متد Any بر روی آنها است.
- اگر از Enumerableها استفاده میکنید، استفاده از متد Any بر روی آنها، بسیار سریعتر از بکارگیری متد ()Count و یا تبدیل آنها به لیست و سپس بررسی خاصیت Count آنها است.
- بررسی نال بودن با pattern matching یا همان is null، نسبت به روشی سنتی استفادهی از null ==، سریعتر است. علت آنرا در مطلب «روش ترجیح داده شدهی مقایسه مقادیر اشیاء با null از زمان C# 7.0 به بعد» میتوانید مطالعه کنید.
بنابراین برای بررسی خالی بودن آرایهها و لیستها، بهتر است از خاصیت Length و یا Count آنها استفاده کرد و برای Enumerableها از متد ()Any.
آشنایی با NHibernate - قسمت هشتم
فرض کنید در Table CoursesToStudents فیلدی به نام IsApproved را می خواهیم داشته باشیم، در اینصورت کلاس های نگاشت به چه صورت خواهد بود؟ در کدام کلاس نگاشت پیاده سازی می شود؟ اگر کلاس جداگانه ایی تعریف کنیم ایا باز هم رابطه ManyToMany برقرار خواهد بود؟
با تشکر
CREATE TABLE EmployeeJsonAttributes ( Id int NOT NULL AUTO_INCREMENT, EmployeeId int NOT NULL, Attributes json DEFAULT NULL, PRIMARY KEY (Id), FOREIGN KEY (EmployeeId) REFERENCES EmployeeEav (Id) ON DELETE CASCADE )
INSERT INTO EmployeeJsonAttributes VALUES ( 101, '{ "name": "Jon", "lastName": "Doe", "dateOfBirth": "1989-01-01 10:10:10+05:30", "skills": [ "C#", "JS" ], "address": { "country": "UK", "city": "London", "email": "jon.doe@example.com" } }' ) INSERT INTO efcoresample.EmployeeJsonAttributes VALUES ( 101, JSON_OBJECT( "name", "Jon", "lastName", "Doe", "dateOfBirth", "1989-01-01 10:10:10+05:30", "skills", JSON_ARRAY("C#", "JS"), "address", JSON_OBJECT( "country", "UK", "city", "London", "email", "jon.doe@example.com" ) ) )
SELECT JSON_EXTRACT(Attributes, '$.address.country') as Country FROM EmployeeJsonAttributes WHERE EmployeeId = 101; -- Conutry -- "UK"
SELECT Attributes -> '$.address.country' as Country FROM EmployeeJsonAttributes WHERE EmployeeId = 101; -- Conutry -- "UK"
SELECT EmployeeId, Attributes ->> '$.DateOfBirth' AS BirthDate FROM EmployeeJsonAttributes WHERE Attributes ->> '$.DateOfBirth' > DATE_SUB(CURRENT_DATE(), INTERVAL 25 YEAR)
// Fluent API protected override void OnModelCreating(ModelBuilder modelBuilder) { modelBuilder.Entity<Employee>(entity => { entity.Property(e => e.Attributes).HasColumnType("json"); }); } // Data Annotations [Column(TypeName = "json")] public string Attributes { get; set; }
public class EmployeeJsonAttribute { public int Id { get; set; } public virtual EmployeeEav Employee { get; set; } public int EmployeeId { get; set; } [Column(TypeName = "json")] public string Attributes { get; set; } }
dbContext.EmployeeJsonAttributes.Add(new EmployeeJsonAttribute { EmployeeId = 101, Attributes = JsonSerializer.Serialize(new { FirstName = "Sirwan", LastName = "Afifi", DateOfBirth = DateTime.Now.AddYears(-31) }) }); dbContext.SaveChanges();
var employee = dbContext.EmployeeJsonAttributes.Find(201); Console.WriteLine(JsonSerializer.Deserialize<Employee>(employee.Attributes).DateOfBirth);
<Reference Include="NHibernate, Version=2.1.0.4000, Culture=neutral, PublicKeyToken=aa95f207798dfdb4, processorArchitecture=MSIL">
همانطور که ملاحظه میکنید، شماره نگارش فایل، PublicKeyToken و غیره دقیقا ذکر میشوند. حال اگر همین پروژه را بخواهید به نگارش 3.2 ارتقاء دهید، احتمالا به روش متداول کپی اسمبلی جدید در پوشه bin برنامه اکتفاء خواهید کرد. برنامه هم پس از یک Rebuild، به خوبی کامپایل میشود و مشکلی ندارد. اما به محض اجرا و دیباگ در VS.NET، با خطای زیر مواجه خواهید شد:
Could not load file or assembly 'NHibernate, Version=2.0.0.4000, Culture=neutral, PublicKeyToken=aa95f207798dfdb4'
or one of its dependencies. The located assembly's manifest definition does not match the assembly reference.
(Exception from HRESULT: 0x80131040)
بله! هنوز به دنبال نگارش 2 میگردد و به نظر، نگارش 3.2 جدید را ندید گرفته است. مشکل هم به همان مدخل دقیق موجود در فایل پروژه برنامه، مرتبط است. این مدخل صرفا با copy/paste فایلهای جدید در پوشه bin برنامه یا rebuild پروژه، «به روز نمیشود» !
یا باید دستی این فایل csproj یا vbproj را ویرایش کنید، یا یکبار باید از داخل VS.NET این ارجاعات را حذف کرده و مجددا بر اساس فایلهای جدید ایجاد کنید تا فایل پروژه برنامه بر این اساس به روز شود.
این مشکلی هست که حداقل با تمام مثالهای NHibernate دریافتی از این سایت خواهید داشت.
روش دیگر حل این مشکل، مراجعه به خواص اسمبلی اضافه شده در لیست ارجاعات پروژه در VS.NET و خاموش کردن گزینهی "Specific Version" آن است.
به صورت خلاصه حین به روز رسانی اسمبلیهای دارای امضای دیجیتال:
- یا باید ارجاعات دارای امضای دیجیتال را حذف و بار دیگر اضافه کنید.
- یا باید فایل پروژه برنامه را با یک ویرایشگر متنی ساده باز کرده و شماره نگارشها را اصلاح کنید. (سادهترین روش ممکن)
- یا خاموش کردن بررسی Specific Version را هم آزمایش کنید.
namespace ShowAlertSignalR.Models { public class Product { public int Id { get; set; } public string Title { get; set; } public string Description { get; set; } public float Price { get; set; } public Category Category { get; set; } } public enum Category { [Display(Name = "دسته بندی اول")] Cat1, [Display(Name = "دسته بندی دوم")] Cat2, [Display(Name = "دسته بندی سوم")] Cat3 } }
namespace ShowAlertSignalR.Models { public class ProductDbContext : DbContext { public ProductDbContext() : base("productSample") { Database.Log = sql => Debug.Write(sql); } public DbSet<Product> Products { get; set; } } }
[HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(Product product) { if (ModelState.IsValid) { db.Products.Add(product); db.SaveChanges(); return RedirectToAction("Index"); } return View(product); }
public ActionResult Index(bool notifyUsers = false) { ViewBag.NotifyUsers = notifyUsers; return View(db.Products.ToList()); }
[HttpPost] [ValidateAntiForgeryToken] public ActionResult Create(Product product) { if (ModelState.IsValid) { db.Products.Add(product); db.SaveChanges(); return RedirectToAction("Index", new { notifyUsers = true }); } return View(product); }
namespace ShowAlertSignalR.Hubs { public class NotificationHub : Hub { public void SendNotification() { Clients.Others.ShowNotification(); } } }
@section scripts { <script src="~/Scripts/jquery.signalR-2.0.2.min.js"></script> <script src="~/signalr/hubs"></script> <script> var notify = $.connection.notificationHub; notify.client.showNotification = function() { $('#result').append("<div class='alert alert-info alert-dismissable'>" + "<button type='button' class='close' data-dismiss='alert' aria-hidden='true'>×</button>" + "رکورد جدیدی هم اکنون ثبت گردید، برای مشاهده آن صفحه را بروزرسانی کنید" + "</div>"); }; $.connection.hub.start().done(function() { @{ if (ViewBag.NotifyUsers) { <text>notify.server.sendNotification();</text> } } }); </script> }
سورس مثال جاری : ShowAlertSignalR.zip