پیاده سازی یک کلاس مبدل سه مرحله دارد:
- مرحله اول :ساخت کلاس ValueConverter
- مرحله دوم : تعریف آن به عنوان یک منبع یا ریسورس
- مرحله سوم : استفاده از آن در عملیات بایند کردن Binding
در مرحله اول، نحوه پیاده سازی کلاس ValueConverter به شکل زیر است:
public class BoolToVisibilityConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { // Do the conversion from bool to visibility } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { // Do the conversion from visibility to bool } }
در متد تبدیل باید مقداری را که کنترل نیاز دارد، بر اساس مقادیر کلاس، ایجاد و بازگشت دهید.
در مرحلهی دوم نحوه تعریف مبدل ما در پنجره XAML به صورت زیر میباشد:
<Window x:Class="test.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:نامی دلخواه="clr-namespace:فضای نامی که کلاس مبدل در آن قرار دارد" > <Window.Resources> <نام دلخواهی که در بالا تعریف کرده اید: ClassNameنام کلاس x:Key="کلید این آیتم در ریسورس"/> </Window.Resources>
پس الان باید خط چهارم برای ما روشن باشد؛ فضای نام جدیدی را در برنامه خودمان ایجاد کردهایم که این تگ به آن اشاره میکند و نام دلخواهی هم برای اشاره به این فضای نام برایش در نظر گرفتهایم. هر موقع در برنامه این نام دلخواه تعیین شده قرار گیرد، یعنی اشاره به این فضای نام که در قسمت Window.resource خط هشتم تعریف شده است.
در خط هشتم، یک ریسورس (منبع) را به برنامه معرفی کردهایم:
ریسورسها برای ذخیره سازی دادهها در سطح یک کنترل، سطح محلی در یک پنجره، یا سطح عمومی در کل پروژه به کار میروند. محدودیتی در ذخیره دادهها وجود ندارد و هر چقدر که دوست دارید میتوانید داده به آن پاس کنید. این دادهها میتوانند یک سری اطلاعات ذخیره شده در یک ساختار ساده تا یک ساختار سلسه مراتبی از کنترلها باشند. ریسورسها به شما این اجازه را میدهند تا دادهها را در یک مکان ذخیره کرده و آنها را در یک یا چندجا مورد استفاده قرار دهید.
از آن جا که مباحث ریسورسها را در یک مقالهی جداگانه بررسی میکنیم، فقط به ذکر نکات بالا جهت کد فعلی بسنده خواهیم کرد و ادامهی آن را در یک مقاله دیگر مورد بررسی قرار میدهیم.
هر ریسورس دارای یک نام یا یک کلید است که با خصوصیت x:key تعریف میشود.
ریسورس بالا یک کلاس را که در فضای نام دلخواهی قرار دارد، تعریف میکند و یک کلید هم به آن انتساب میدهد.
مرحلهی سوم معرفی ریسورس به عملیات Binding است:
{Binding نام پراپرتی کلاس, Converter={StaticResource کلید آیتم مربوطه در ریسورس}, ConverterParameter=پارامتری که به کلاس مبدل پاس میشود}
قصد داریم یک مبدل برای فیلد جنیست درست کنیم. از آنجا که این فیلد Boolean است و خصوصیت IsChecked یک RadioButton هم Boolean است، میتوان یک ارتباط مستقیم را ایجاد کرد. ولی مشکل در اینجا هست که True برای مذکر است و false برای مؤنث. در نتیجه تنها Radiobutton مربوطه به جنس مذکر به این حالت پاسخ میدهد و از آنجا که برای جنس مونث false در نظر گرفته شده است، انتخاب آن هم false خواهد بود. پس باید در مبدل، مقداری که کنترل میخواهد را شناسایی کرده و اگر مقدار با آن برابر بود، True را بازگردانیم. مقداری که هر کنترل درخواست میکند را از طریق پارامتر به تابع مبدل ارسال میکنیم. radiobutton مذکر، مقدار True را به عنوان پارامتر ارسال میکند و radiobutton مونث هم مقدار false را به عنوان پارامتر ارسال میکند. اگر تابع مبدل را ببینید، این مقدارها با پارامترها همخوانی دارند، True در غیر این صورت false بر میگرداند.
مرحله اول تعریف کلاس ValueConveter:
using System; using System.Globalization; using System.Windows; using System.Windows.Data; namespace test.ValueConverters { public class GenderConverter: IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var ParameterString = parameter as string; if (ParameterString == null) return DependencyProperty.UnsetValue; bool bparam; bool test = bool.TryParse(parameter.ToString(), out bparam); if (test) { return ((bool)value).Equals(bparam); } return DependencyProperty.UnsetValue; } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } } }
برای تعریف این مبدل در محیط XAML به صورت زیر اقدام میکنیم:
<Window x:Class="test.MainWindow" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:valueConverters="clr-namespace:test.ValueConverters" Title="MainWindow" Height="511.851" Width="525"> <Window.Resources> <valueConverters:GenderConverter x:Key="GenderConverter"/> </Window.Resources> </window>
نکته: در صورتی که بعد از تعریف ریسورس با خطای زیر روبرو شدید و محیط طراحی Design را از دست دادید یکبار پروژه را بیلد کنید تا مشکل حل شود.
The name "GenderConverter" does not exist in the namespace "clr-namespace:test.ValueConverters".
اکنون در عملیات بایندینگ دو کنترل اینگونه مینویسیم:
<RadioButton GroupName="Gender" IsChecked="{Binding Gender, Converter={StaticResource GenderConverter}, ConverterParameter=True}" Name="RdoMale" >Male</RadioButton> <RadioButton GroupName="Gender" IsChecked="{Binding Gender, Converter={StaticResource GenderConverter}, ConverterParameter=False}" Name="RdoFemale" Margin="0 5 0 0" >Female</RadioButton>
<RadioButton GroupName="Gender" IsChecked="{Binding Gender}" Name="RdoMale" >Male</RadioButton>
جهت یادآوری نگاهی به کلاس برنامه میاندازیم:
public enum FieldOfWork { Actor=0, Director=1, Producer=2 } public class Person : INotifyPropertyChanged { public bool Gender { get; set; } public string ImageName { get; set; } public string Country { get; set; } public DateTime Date { get; set; } public FieldOfWork FieldOfWork { get; set; } private string _name; public string Name { get { return _name; } set { _name = value; OnPropertyChanged(); } }
public FieldOfWork FieldOfWork { get; set; }
کد کلاس مبدل را به صورت زیر مینویسیم:
using System; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; namespace test.ValueConverters { public class EnumConverter : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var ParameterString = parameter as string; if (ParameterString == null) return DependencyProperty.UnsetValue; if (Enum.IsDefined(value.GetType(), value) == false) return DependencyProperty.UnsetValue; object paramvalue = Enum.Parse(value.GetType(), ParameterString); return paramvalue.Equals(value); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } } }
کد قسمت ریسورس را با کلاس جدید به روز میکنیم:
<Window.Resources> <valueConverters:GenderConverter x:Key="GenderConverter"/> <valueConverters:EnumConverter x:Key="EnumConverter"></valueConverters:EnumConverter> </Window.Resources>
<CheckBox Name="ChkActor" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumConverter}, ConverterParameter=Actor}" >Actor/Actress</CheckBox> <CheckBox Name="ChkDirector" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumConverter}, ConverterParameter=Director}" >Director</CheckBox> <CheckBox Name="ChkProducer" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumConverter}, ConverterParameter=Producer}" >Producer</CheckBox>
public static Person GetPerson() { return new Person() { Name = "Leo", Gender =true, ImageName ="man.jpg", Country = "Italy", FieldOfWork = test.FieldOfWork.Actor, Date = DateTime.Now.AddDays(-3) }; }
برنامه را اجرا کنید تا نتیجه کار را ببینید. باید چک باکس Actor تیک خورده باشد. میتوانید منبع داده را تغییر داده تا نتیجه کار را ببینید.
بگذارید فیلد FieldOfWork را به حالت قبلی یعنی IList برگردانیم. در بسیاری از اوقات ما چند گزینه از یک Enum را انتخاب میکنیم، مثل داشتن چند سطح دسترسی یا چند سمت کاری و ...
کلاس را به همراه کد GetPerson به شکل زیر تغییر میدهیم:
public enum FieldOfWork { Actor=0, Director=1, Producer=2 } public class Person : INotifyPropertyChanged { public bool Gender { get; set; } public string ImageName { get; set; } public string Country { get; set; } public DateTime Date { get; set; } public IList<FieldOfWork> FieldOfWork { get; set; } private string _name; public string Name { get { return _name; } set { _name = value; OnPropertyChanged(); } } public static Person GetPerson() { return new Person { Name = "Leo", Gender = true, ImageName = "man.jpg", Country = "Italy", FieldOfWork = new FieldOfWork[] { test.FieldOfWork.Actor, test.FieldOfWork.Producer }, Date = DateTime.Now.AddDays(-3) }; } }
دو Enum بازیگر و تهیه کننده را انتخاب کردهایم، پس در زمان اجرا باید این دو گزینه انتخاب شوند.
کد مبدل را به صورت زیر مینویسیم:
using System; using System.Collections; using System.Collections.Generic; using System.Globalization; using System.Linq; using System.Text; using System.Threading.Tasks; using System.Windows; using System.Windows.Data; namespace test.ValueConverters { public class EnumList : IValueConverter { public object Convert(object value, Type targetType, object parameter, CultureInfo culture) { var ParameterString = parameter as string; if (ParameterString == null) return DependencyProperty.UnsetValue; var enumlist= (IList) value; if(enumlist==null && enumlist.Count<1) return DependencyProperty.UnsetValue; if (Enum.IsDefined(enumlist[0].GetType(), ParameterString) == false) return DependencyProperty.UnsetValue; /* foreach (var item in enumlist) { object paramvalue = Enum.Parse(item.GetType(), ParameterString); bool result = item.Equals(paramvalue); if (result) return true; } return false; */ return (from object item in enumlist let paramvalue = Enum.Parse(item.GetType(), ParameterString) select item.Equals(paramvalue)).Any(result => result); } public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture) { throw new NotImplementedException(); } } }
در حلقهای که به شکل توضیح درآمده، همه آیتمهای مربوطه در لیست را بررسی کرده و اگر آیتمی برابر پارامتر باشد، True بر میگرداند و در صورتی که حلقه به اتمام برسد و آیتم پیدا نشود، مقدار False را برمیگرداند. این حلقه از آن جهت به شکل توضیح درآمده است که کد Linq آن در زیر نوشته شده است.
تعریف کلاس بالا در ریسورس:
<Window.Resources> <valueConverters:GenderConverter x:Key="GenderConverter"/> <valueConverters:EnumConverter x:Key="EnumConverter"></valueConverters:EnumConverter> <valueConverters:EnumList x:Key="EnumList"></valueConverters:EnumList> </Window.Resources>
<CheckBox Name="ChkActor" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumList}, ConverterParameter=Actor}" >Actor/Actress</CheckBox> <CheckBox Name="ChkDirector" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumList}, ConverterParameter=Director}" >Director</CheckBox> <CheckBox Name="ChkProducer" IsChecked="{Binding FieldOfWork, Converter={StaticResource EnumList}, ConverterParameter=Producer}" >Producer</CheckBox>
نتیجهی کار باید به شکل زیر باشد:
فیلد جنسیت و زمینه کاری از تابع مبدل به دست آمده است.
بدیهی است خروجیهای بالا برای کنترل هایی است که مقدار Boolean را میپذیرند و برای سایر کنترلها باید با کمی تغییر در کد و نوع برگشتی که تحویل خروجی متد مبدل میشود، دهید.دانلود فایلهای این قسمت
<input dir="ltr" class="form-control input-validation-error" type="email" data-val="true" data-val-email="'آدرس ایمیل' is not a valid email address." data-val-remote="این آدرس ایمیل هم اکنون مورد استفادهاست" data-val-remote-url="/Home/ValidateUniqueEmail" data-val-required="'آدرس ایمیل' must not be empty." id="Email" name="Email" >
افزودن آدرس ایمیل به مدل کاربران
به همان مدل قسمت قبل، قصد داریم خاصیت آدرس ایمیل را هم اضافه کنیم:
using System.ComponentModel.DataAnnotations; namespace FluentValidationSample.Models { public class UserModel { [Display(Name = "نام کاربری")] public string Username { get; set; } [Display(Name = "سن")] public int Age { get; set; } [Display(Name = "سابقه کار")] public int Experience { get; set; } [DataType(DataType.EmailAddress)] [Display(Name = "آدرس ایمیل")] public string Email { get; set; } } }
ایجاد سرویسی برای بررسی منحصربفرد بودن آدرس ایمیل
در ادامه قصد داریم سرویسی را ایجاد کنیم که برای مثال با بانک اطلاعاتی ارتباط برقرار کرده (در اینجا جهت سهولت ارائه، از یک آرایه استفاده شدهاست) و مشخص میکند که آیا ایمیل دریافتی پیشتر استفاده شدهاست یا خیر:
using System.Linq; namespace FluentValidationSample.Services { public interface IUsersService { bool IsUniqueEmail(string emailAddress); } public class UsersService : IUsersService { public bool IsUniqueEmail(string emailAddress) { string[] registedEmails = { "email@site.com", "test@gmail.com" }; return !registedEmails.Contains(emailAddress); } } }
namespace FluentValidationSample.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddScoped<IUsersService, UsersService>();
ایجاد اعتبارسنج سمت سرور بررسی منحصربفرد بودن آدرس ایمیل
در ادامه یک PropertyValidator جدید را ایجاد میکنیم تا بتوان توسط آن مقدار ایمیل دریافتی را در سمت سرور، تعیین اعتبار کرد:
using FluentValidation.Validators; using FluentValidationSample.Services; namespace FluentValidationSample.ModelsValidations { public class UniqueEmailValidator : PropertyValidator { private readonly IUsersService _usersService; public UniqueEmailValidator(IUsersService usersService) : base("این آدرس ایمیل هم اکنون مورد استفادهاست") { _usersService = usersService; } protected override bool IsValid(PropertyValidatorContext context) { return context.PropertyValue != null && _usersService.IsUniqueEmail((string)context.PropertyValue); } } }
پس از آن جهت سهولت استفادهی از آن، یک متد الحاقی جدید را نیز به نام UniqueEmail به نحو زیر تعریف میکنیم:
using FluentValidation; using FluentValidationSample.Services; namespace FluentValidationSample.ModelsValidations { public static class CustomValidatorExtensions { public static IRuleBuilderOptions<T, string> UniqueEmail<T>( this IRuleBuilder<T, string> ruleBuilder, IUsersService usersService) { return ruleBuilder.SetValidator(new UniqueEmailValidator(usersService)); } } }
یک نکته: اگر دقت کرده باشید، فضای نام این اعتبارسنج در این قسمت FluentValidationSample.ModelsValidations شدهاست:
علت اینجا است که اعتبارسنج تعریف شده نیاز دارد هم از مدلها استفاده کند و هم از سرویس کاربران. سرویس کاربران هم از مدلها استفاده میکند. به همین جهت اگر تعاریف اعتبارسنجی را داخل پروژهی مدلها قرار دهیم، یک وابستگی حلقوی رخ خواهد داد (وابستگی مدلها به سرویسها و برعکس). بنابراین بهتر است اعتبارسنجها را به یک پروژهی مجزا منتقل کنیم تا از بروز این cyclic dependency جلوگیری شود.
اعمال اعتبارسنجی منحصربفرد بودن ایمیل دریافتی به اعتبارسنج UserModel
پس از تهیهی متد الحاقی UniqueEmail، آنرا به RuleFor مخصوص خاصیت ایمیل اضافه میکنیم. در اینجا نیز تزریق وابستگی سرویس سفارشی IUsersService به سازندهی کلاس اعتبارسنج مجاز است:
using FluentValidation; using FluentValidationSample.Models; using FluentValidationSample.Services; namespace FluentValidationSample.ModelsValidations { public class UserModelValidator : AbstractValidator<UserModel> { public UserModelValidator(IUsersService usersService) { RuleFor(x => x.Username).NotNull(); RuleFor(x => x.Age).NotNull(); RuleFor(x => x.Experience).LowerThan(nameof(UserModel.Age)).NotNull(); RuleFor(x => x.Email).EmailAddress().NotNull().UniqueEmail(usersService); } } }
ایجاد متادیتای مورد نیاز جهت unobtrusive java script validation در سمت سرور
در ادامه نیاز است ویژگیهای data-val خاص unobtrusive java script validation را توسط FluentValidation ایجاد کنیم:
using FluentValidation; using FluentValidation.AspNetCore; using FluentValidation.Internal; using FluentValidation.Resources; using FluentValidation.Validators; using Microsoft.AspNetCore.Mvc.ModelBinding.Validation; namespace FluentValidationSample.ModelsValidations { public class RemoteClientValidator : ClientValidatorBase { public string RemoteUrl { set; get; } public RemoteClientValidator(PropertyRule rule, IPropertyValidator validator) : base(rule, validator) { } public override void AddValidation(ClientModelValidationContext context) { MergeAttribute(context.Attributes, "data-val", "true"); MergeAttribute(context.Attributes, "data-val-remote", GetErrorMessage(context)); MergeAttribute(context.Attributes, "data-val-remote-url", RemoteUrl); } private string GetErrorMessage(ClientModelValidationContext context) { var formatter = ValidatorOptions.MessageFormatterFactory().AppendPropertyName(Rule.GetDisplayName()); string messageTemplate; try { messageTemplate = Validator.Options.ErrorMessageSource.GetString(null); } catch (FluentValidationMessageFormatException) { messageTemplate = ValidatorOptions.LanguageManager.GetStringForValidator<NotEmptyValidator>(); } return formatter.BuildMessage(messageTemplate); } } }
<input dir="ltr" class="form-control input-validation-error" type="email" data-val="true" data-val-email="'آدرس ایمیل' is not a valid email address." data-val-remote="این آدرس ایمیل هم اکنون مورد استفادهاست" data-val-remote-url="/Home/ValidateUniqueEmail" data-val-required="'آدرس ایمیل' must not be empty." id="Email" name="Email" >
افزودن اعتبارسنجهای تعریف شده به تنظیمات برنامه
پس از تعریف UniqueEmailValidator و RemoteClientValidator، روش افزودن آنها به تنظیمات FluentValidation به صورت زیر است:
namespace FluentValidationSample.Web { public class Startup { public void ConfigureServices(IServiceCollection services) { services.AddScoped<IUsersService, UsersService>(); services.AddControllersWithViews().AddFluentValidation( fv => { fv.RegisterValidatorsFromAssembly(Assembly.GetExecutingAssembly()); fv.RegisterValidatorsFromAssemblyContaining<RegisterModelValidator>(); fv.RunDefaultMvcValidationAfterFluentValidationExecutes = false; fv.ConfigureClientsideValidation(clientSideValidation => { // ... clientSideValidation.Add( validatorType: typeof(UniqueEmailValidator), factory: (context, rule, validator) => new RemoteClientValidator(rule, validator) { RemoteUrl = "/Home/ValidateUniqueEmail" }); }); } ); }
namespace FluentValidationSample.Web.Controllers { public class HomeController : Controller { private readonly IUsersService _usersService; public HomeController(IUsersService usersService) { _usersService = usersService; } // ... public IActionResult ValidateUniqueEmail(string email) { return Ok(_usersService.IsUniqueEmail(email)); } } }
تعریف کدهای جاوا اسکریپتی مورد نیاز
پیش از هرکاری، اسکریپتهای فایل layout برنامه باید چنین تعریفی را داشته باشند:
<script src="~/lib/jquery/dist/jquery.min.js"></script> <script src="~/lib/bootstrap/dist/js/bootstrap.bundle.min.js"></script> <script src="~/lib/jquery-validation/dist/jquery.validate.js"></script> <script src="~/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.js"></script> <script src="~/js/site.js" asp-append-version="true"></script>
آزمایش برنامه
View این قسمت نیز همانند قسمت قبل است که فقط یک آدرس ایمیل به آن اضافه شدهاست:
برای آزمایش آن اگر برای مثال یکی از آدرسهای ایمیل از پیش تعریف شدهی در متد IsUniqueEmail سرویس کاربران را وارد کنیم، با خطای اعتبارسنجی سمت کلاینت فوق روبرو خواهیم شد.
کدهای کامل این سری را تا این قسمت از اینجا میتوانید دریافت کنید: FluentValidationSample-part04.zip
using System.Windows.Forms; public static class GlobalData { public static Form ScheduleForm { get; set; } }
GlobalData.ScheduleForm = this;
namespace SchedulerDemo.Jobs { using System.Linq; using System.Windows.Forms; using Quartz; public class HelloJob : IJob { private delegate void ButtonTextWriter(string buttonId, string text); MainForm form = GlobalData.ScheduleForm as MainForm; private void SetButtonText(string buttonId, string text) { (form.Controls.Find(buttonId, true).FirstOrDefault() as Button).Text = text; } public void Execute(IJobExecutionContext context) { form.BeginInvoke(new ButtonTextWriter(SetButtonText), new object[] { "myButton", "My Text" }); } } }
مدل سازی دادهها در RavenDB
تفاوتهای دوره ما با زمانیکه بانکهای اطلاعاتی رابطهای پدیدار شدند
- دنیای بانکهای اطلاعاتی رابطهای برای Write بهینه سازی شدهاند؛ از این جهت که تاریخچه پیدایش آنها به دهه 70 میلادی بر میگردد، زمانیکه برای تهیه سخت دیسکها باید هزینههای گزافی پرداخت میشد. به همین جهت الگوریتمها و روشهای بسیاری در آن دوره ابداع شدند تا ذخیره سازی اطلاعات، حجم کمتری را به خود اختصاص دهند. اینجا است که مباحثی مانند Normalization بوجود آمدند تا تضمین شود که دادهها تنها یکبار ذخیره شده و دوبار در جاهای مختلفی ذخیره نگردند. جهت اطلاع در سال 1980 میلادی، یک سخت دیسک 10 مگابایتی حدود 4000 دلار قیمت داشته است.
- تفاوت مهم دیگر دوره ما با دهههای 70 و 80 میلادی، پدیدار شدن UI و روابط کاربری بسیار پیچیده، در مقایسه با برنامههای خط فرمان یا حداکثر فرمهای بسیار ساده ورود اطلاعات در آن زمان است. برای مثال در دهه 70 میلادی تصور UI ایی مانند صفحه ابتدایی سایت Stack overflow احتمالا به ذهن هم خطور نمیکرده است.
تهیه چنین UI ایی نه تنها از لحاظ طراحی، بلکه از لحاظ تامین دادهها از جداول مختلف نیز بسیار پیچیده است. برای مثال برای رندر صفحه اول سایت استک اورفلو ابتدا باید تعدادی سؤال از جدول سؤالات واکشی شوند. در اینجا در ذیل هر سؤال نام شخص مرتبط را هم مشاهده میکنید. بنابراین اطلاعات نام او، از جدول کاربران نیز باید دریافت گردد. یا در اینجا تعداد رایهای هر سؤال را نیز مشاهده میکنید که به طور قطع اطلاعات آن در جدول دیگری نگه داری میشود. در گوشهای از صفحه، برچسبهای مورد علاقه و در ذیل هر سؤال، برچسبهای اختصاصی هر مطلب نمایش داده شدهاند. تگها نیز در جدولی جداگانه قرار دارند. تمام این قسمتهای مختلف، نیاز به واکشی و رندر حجم بالایی از اطلاعات را دارند.
- تعداد کاربران برنامهها در دهههای 70 و 80 میلادی نیز با دوره ما متفاوت بودهاند. اغلب برنامههای آن دوران تک کاربره طراحی میشدند؛ با بانکهای اطلاعاتی که صرفا جهت کار بر روی یک سیستم طراحی شده بودند. اما برای نمونه سایت استک اور فلویی که مثال زده شده، توسط هزاران و یا شاید میلیونها نفر مورد استفاده قرار میگیرد؛ با توزیع و تقسیم اطلاعات آن بر روی سرورها مختلف.
معرفی مفهوم Unit of change
همین پیچیدگیها سبب شدند تا جهت سادهسازی حل اینگونه مسایل، حرکتی به سمت دنیای NoSQL شروع شود. ایده اصلی مدل سازی دادهها در اینجا کم کردن تعداد اعمالی است که باید جهت رسیدن به یک نتیجه واحد انجام داد. اگر قرار است یک سؤال به همراه تگها، اطلاعات کاربر، رایها و غیره واکشی شوند، چرا باید تعداد اعمال قابل توجهی جهت مراجعه به جداول مختلف مرتبط صورت گیرد؟ چرا تمام این اطلاعات را یکجا نداشته باشیم تا بتوان همگی را در طی یک واکشی به دست آورد و به این ترتیب دیگر نیازی نباشد انواع و اقسام JOINها را به چند ده جدول موجود نوشت؟
اینجا است که مفهومی به نام Unit of change مطرح میشود. در هر واحد تغییر، کلیه اطلاعات مورد نیاز برای رندر یک شیء قرار میگیرند. برای مثال اگر قرار است با شیء محصول کار کنیم، تمام اطلاعات مورد نیاز آنرا اعم از گروهها، نوعها، رنگها و غیره را در طی یک سند بانک اطلاعاتی NoSQL سندگرا، ذخیره میکنیم.
محدودههای تراکنشی یا Transactional boundaries
محدودههای تراکنشی در Domain driven design به Aggregate root نیز معروف است. هر محدود تراکنشی حاوی یک Unit of change قرار گرفته داخل یک سند است. ابتدا بررسی میکنیم که در یک Read به چه نوع اطلاعاتی نیاز داریم و سپس کل اطلاعات مورد نیاز را بدون نوشتن JOIN ایی از جداول دیگر، داخل یک سند قرار میدهیم.
هر محدوده تراکنشی میتواند به محدوده تراکنشی دیگری نیز ارجاع داده باشد. برای مثال در RavenDB شمارههای اسناد، یک سری رشته هستند؛ برخلاف بانکهای اطلاعاتی رابطهای که بیشتر از اعداد برای مشخص سازی Id استفاده میکنند. در این حالت برای ارجاع به یک کاربر فقط کافی است برای مثال مقدار خاصیت کاربر یک سند به "users/1" تنظیم شود. "users/1" نیز یک Id تعریف شده در RavenDB است.
مزیت این روش، سرعت واکشی بسیار بالای دریافت اطلاعات آن است؛ دیگر در اینجا نیازی به JOINهای سنگین به جداول دیگر برای تامین اطلاعات مورد نیاز نیست و همچنین در ساختارهای پیچیدهتری مانند ساختارهای تو در تو، دیگر نیازی به تهیه کوئریهای بازگشتی و استفاده از روشهای پیچیده مرتبط با آنها نیز وجود ندارد و کلیه اطلاعات مورد نظر، به شکل یک شیء JSON داخل یک سند حاضر و آماده برای واکشی در طی یک Read هستند.
به این ترتیب میتوان به سیستمهای مقیاس پذیری رسید. سیستمهایی که با بالا رفتن حجم اطلاعات در حین واکشیهای دادههای مورد نیاز، کند نبوده و بسیار سریع پاسخ میدهند.
Denormalization دادهها
اینجا است که احتمالا ذهن رابطهای تربیت شدهی شما شروع به واکنش میکند! برای مثال اگر نام یک محصول تغییر کرد، چطور؟ اگر آدرس یک مشتری نیاز به ویرایش داشت، چطور؟ چگونه یکپارچگی اطلاعاتی که اکنون به ازای هر سند پراکنده شدهاست، مدیریت میشود؟
زمانیکه به این نوع سؤالات رسیدهایم، یعنی Denormalization رخ داده است. در اینجا سندهایی را داریم که کلیه اطلاعات مورد نیاز خود را یکجا دارند. به این مساله از منظر نگاه به دادهها در طی زمان نیز میتوان پرداخت. به این معنا که صحیح است که آدرس مشتری خاصی امروز تغییر کرده است، اما زمانیکه سندی برای او در سال قبل صادر شده است، واقعا آدرس آن مشتری که سفارشی برایش ارسال شده، دقیقا همان چیزی بوده است که در سند مرتبط، ثبت شده و موجود میباشد. بنابراین سند قبلی با اطلاعات قبلی مشتری در سیستم موجود خواهد بود و اگر سند جدیدی صادر شد، این سند بدیهی است که از اطلاعات امروز مشتری استفاده میکند.
ملاحظات اندازههای دادهها
زمانیکه سندها بسیار بزرگ میشوند چه رخ خواهد داد؟ از لحاظ اندازه دادهها سه نوع سند را میتوان متصور بود:
الف) سندهای محدود، مانند اغلب اطلاعاتی که تعداد فیلدهای مشخصی دارند با تعداد اشیاء مشخصی.
ب) سندهای نامحدود اما با محدودیت طبیعی. برای مثال اطلاعات فرزندان یک شخص را درنظر بگیرید. هرچند این اطلاعات نامحدود هستند، اما به صورت طبیعی میتوان فرض کرد که سقف بالایی آن عموما به 20 نمیرسد!
ج) سندهای نامحدود، مانند سندهایی که آرایهای از اطلاعات را ذخیره میکنند. برای مثال در یک سایت فروشگاه، اطلاعات فروش یک گروه از اجناس خاص را درنظر بگیرید که عموما نامحدود است. اینجا است که باید به اندازه اسناد نیز دقت داشت. برای مدیریت این مساله حداقل از دو روش استفاده میشود:
- محدود کردن تعداد اشیاء. برای مثال در هر سند حداکثر 100 اطلاعات فروش یک محصول بیشتر ثبت نشود. زمانیکه به این حد رسیدیم، یک سند جدید ایجاد شده و Id سند قبلی مثلا "products/1" در سند دوم ذکر خواهد شد.
- محدود کردن تعداد اطلاعات ذخیره شده بر اساس زمان
RavenDB برای مدیریت این مساله، مفهوم Includes را معرفی کرده است. در اینجا با استفاده از متد الحاقی Include، کار زنجیر کردن سندهای مرتبط صورت خواهد گرفت.
یک مثال عملی: مدل سازی دادههای یک بلاگ در RavenDB
پس از این بحث مقدماتی که جهت معرفی ذهنیت مدل سازی دادهها در دنیای غیر رابطهای NoSQL ضروری بود، در ادامه قصد داریم مدلهای دادههای یک بلاگ را سازگار با ساختار بانک اطلاعاتی NoSQL سندگرای RavenDB طراحی کنیم.
در یک بلاگ، تعدادی مطلب، نظر، برچسب (گروههای مطالب) و امثال آن وجود دارند. اگر بخواهیم این اطلاعات را به صورت رابطهای مدل کنیم، به ازای هر کدام از این موجودیتها یک جدول نیاز خواهد بود و برای رندر صفحه اصلی بلاگ، چندین و چند کوئری برای نمایش اطلاعات مطالب، نویسنده(ها)، برچسبها و غیره باید به بانک اطلاعاتی ارسال گردد، که تعدادی از آنها مستقیما بر روی یک جدول اجرا میشوند و تعدادی دیگر نیاز به JOIN دارند.
مشکلاتی که روش رابطهای دارد:
- تعداد اعمالی که باید برای نمایش صفحه اول سایت صورت گیرد، بسیار زیاد است و این مساله با تعداد بالای کاربران از دید مقیاس پذیری سیستم مشکل ساز است.
- دادههای مرتبط در جداول مختلفی پراکندهاند.
- این سیستم برای Write بهینه سازی شده است و نه برای Read. (همان بحث گران بودن سخت دیسکها در دهههای قبل که در ابتدای بحث به آن اشاره شد)
مدل سازی سازگار با دنیای NoSQL یک بلاگ
در اینجا چند کلاس مقدماتی را مشاهده میکنید که تعریف آنها به همین نحو صحیح است و نیاز به جزئیات و یا روابط بیشتری ندارند.
namespace RavenDBSample01.BlogModels { public class BlogConfig { public string Id { set; get; } public string Title { set; get; } public string Description { set; get; } // ... more items here } public class User { public string Id { set; get; } public string FullName { set; get; } public string Email { set; get; } // ... more items here } }
اما این طراحی نیز یک مشکل دارد. درست است که ساختار یک صفحه مطلب، از مطالب وبلاگ به همین نحوی است که توضیح داده شد؛ اما در صفحه اول سایت، هیچگاه کامنتهای مطالب درج نمیشوند. بنابراین نیازی نیست تا تمام کامنتها را داخل یک مطلب ذخیره کرد. به این ترتیب برای نمایش صفحه اول سایت، حجم کمتری از اطلاعات واکشی خواهند شد.
public class Post { public string Id { set; get; } public string Title { set; get; } public string Body { set; get; } public ICollection<string> Tags { set; get; } public string AuthorId { set; get; } public string PostCommentsId { set; get; } public int CommentsCount { set; get; } } public class Comment { public string Id { set; get; } public string Body { set; get; } public string AuthorName { set; get; } public DateTime CreatedAt { set; get; } } public class PostComments { public List<Comment> Comments { set; get; } public string LastCommentId { set; get; } }
به این ترتیب برای نمایش صفحه اول سایت، فقط یک کوئری صادر میشود. برای نمایش یک مطلب و کلیه کامنتهای متناظر با آن دو کوئری صادر خواهند شد.
بنابراین همانطور که مشاهده میکنید، در دنیای NoSQL، طراحی مدلهای دادهای بر اساس «سناریوهای Read» صورت میگیرد و نه صرفا طراحی یک مدل رابطهای بهینه سازی شده برای حالت Write.
سورس کامل ASP.NET MVC این بلاگرا که «راکن بلاگ» نام دارد، از GitHub نویسندگان اصلی RavenDB میتوانید دریافت کنید.
دریافت اطلاعات از سرور، توسط Axios
- ابتدا به پوشهی sample-22-backend ای که در قسمت قبل ایجاد کردیم، مراجعه کرده و فایل dotnet_run.bat آنرا اجرا کنید، تا endpointهای REST Api آن، قابل دسترسی شوند. برای مثال باید بتوان به مسیر https://localhost:5001/api/posts در مرورگر دسترسی یافت (و یا همانطور که عنوان شد، از آدرس https://jsonplaceholder.typicode.com/posts نیز میتوانید استفاده کنید؛ چون ساختار یکسانی دارند).
-سپس در برنامهی React ای که در قسمت قبل ایجاد کردیم، فایل app.js آنرا گشوده و ابتدا کتابخانهی Axios را import میکنیم:
import axios from "axios";
componentDidMount() { const promise = axios.get("https://localhost:5001/api/posts"); console.log(promise); }
تنظیمات CORS مخصوص React در برنامههای ASP.NET Core 3x
همانطور که مشاهده میکنید، پس از ذخیره سازی تغییرات، با اجرای برنامه، این Promise در حالت pending قرار گرفته و همچنین پس از پایان آن، حاوی نتیجهی عملیات نیز میباشد که در اینجا rejected است. علت شکست عملیات را در سطر بعدی آن ملاحظه میکنید که عنوان کردهاست «CORS policy» مناسبی در سمت سرور، برای این درخواست وجود ندارد؛ چرا؟ چون برنامهی React ما در مسیر http://localhost:3000/ اجرا میشود و برنامهی Web API در مسیر دیگری https://localhost:5001/ که شمارهی پورت ایندو یکی نیست. به همین جهت عنوان میکند که نیاز است در سمت سرور، هدرهای خاصی برای پردازش این نوع درخواستهای با Origin متفاوت وجود داشته باشد، تا مرورگر اجازهی دسترسی به آنرا بدهد. برای رفع این مشکل، برنامهی sample-22-backend را گشوده و تغییرات زیر را اعمال میکنیم:
ابتدا تنظیمات AddCors را با تعریف یک CORS policy جدید مخصوص آدرس http://localhost:3000، به متد ConfigureServices کلاس آغازین برنامه اضافه میکنیم:
public void ConfigureServices(IServiceCollection services) { services.AddCors(options => { options.AddPolicy("ReactCorsPolicy", builder => builder .AllowAnyMethod() .AllowAnyHeader() .WithOrigins("http://localhost:3000") .AllowCredentials() .Build()); }); services.AddSingleton<IPostsDataSource, PostsDataSource>(); services.AddControllers(); }
public void Configure(IApplicationBuilder app, IWebHostEnvironment env) { if (env.IsDevelopment()) { app.UseDeveloperExceptionPage(); } app.UseHttpsRedirection(); app.UseRouting(); //app.UseAuthentication(); //app.UseAuthorization(); app.UseCors("ReactCorsPolicy"); app.UseAuthorization(); app.UseEndpoints(endpoints => { endpoints.MapControllers(); }); }
اینبار Promise بازگشت داده شده، در حالت resolved قرار گرفتهاست که به معنای موفقیت آمیز بودن عملیات async است. وجود [[PromiseStatus]] به معنای یک internal property است که توسط dot notation قابل دسترسی نیست. در اینجا [[PromiseValue]] نیز یک internal property غیرقابل دسترسی است که نتیجهی عملیات (response دریافتی از سرور) در آن قرار میگیرد. برای مثال در data آن، آرایهی مطالب دریافتی از سرور، قابل مشاهدهاست و یا status=200 به معنای موفقیت آمیز بودن پردازش درخواست، از سمت سرور است.
البته زمانیکه درخواست افزودن رکورد جدیدی را به سمت سرور ارسال میکنیم، میتوان دو درخواست را در برگهی network ابزارهای توسعه دهندگان مرورگر، مشاهده کرد:
در اولین درخواست، Request Method: OPTIONS را داریم که دقیقا مرتبط است با بررسی CORS توسط مرورگر.
دریافت اطلاعات شیء response از یک Promise و نمایش آن
همانطور که عنوان شد، [[PromiseValue]] نیز یک internal property غیرقابل دسترسی است. بنابراین اکنون این سؤال مطرح میشود که چگونه میتوان به اطلاعات آن دسترسی یافت؟
این شیء Promise، دارای متدی است به نام then است که نتیجهی عملیات async را بازگشت میدهد. البته این روش قدیمی کار کردن با Promiseها است و ما از آن در اینجا استفاده نخواهیم کرد. در جاوا اسکریپت مدرن، میتوان از واژهی کلیدی await برای دسترسی به شیء response دریافتی از سرور، استفاده کرد:
async componentDidMount() { const promise = axios.get("https://localhost:5001/api/posts"); console.log(promise); const response = await promise; console.log(response); }
البته قطعه کد نوشته شده، صرفا جهت توضیح مراحل مختلف عملیات، به این صورت چند مرحلهای نوشته شد، وگرنه میتوان واژهی کلیدی await را پیش از فراخوانی متدهای Axios نیز قرار داد:
async componentDidMount() { const response = await axios.get("https://localhost:5001/api/posts"); console.log(response); }
class App extends Component { state = { posts: [] }; async componentDidMount() { const { data: posts } = await axios.get("https://localhost:5001/api/posts"); this.setState({ posts }); // = { posts: posts } }
ایجاد یک مطلب جدید توسط Axios
در برنامهی React ای ایجاد شده، یک دکمهی Add نیز برای افزودن مطلبی جدید درنظر گرفته شدهاست. در یک برنامهی واقعیتر، معمولا فرمی وجود دارد و نتیجهی آن در حین submit، به سمت سرور ارسال میشود. در اینجا این سناریو را شبیه سازی خواهیم کرد:
const apiEndpoint = "https://localhost:5001/api/posts"; class App extends Component { state = { posts: [] }; async componentDidMount() { const { data: posts } = await axios.get(apiEndpoint); this.setState({ posts }); } handleAdd = async () => { const newPost = { title: "new Title ...", body: "new Body ...", userId: 1 }; const { data: post } = await axios.post(apiEndpoint, newPost); console.log(post); const posts = [post, ...this.state.posts]; this.setState({ posts }); };
- چون قرار است از آدرس https://localhost:5001/api/posts در قسمتهای مختلف برنامه استفاده کنیم، فعلا آنرا به صورت یک ثابت تعریف کرده و در متدهای get و post استفاده کردیم.
- در متد منتسب به خاصیت handleAdd، یک شیء جدید post را با ساختاری مشابه آن ایجاد کردهایم. این شیء جدید، دارای Id نیست؛ چون قرار است از سمت سرور پس از ثبت در بانک اطلاعاتی دریافت شود.
- سپس این شیء جدید را توسط متد post کتابخانهی Axios، به سمت سرور ارسال کردهایم. این متد نیز یک Promise را باز میگرداند. به همین جهت از واژهی کلیدی await برای دریافت نتیجهی واقعی آن استفاده شدهاست. همچنین هر زمانیکه await داریم، نیاز به ذکر واژهی کلیدی async نیز هست. اینبار این واژه باید پیش از قسمت تعریف پارامتر متد قرار گیرد و نه پیش از نام handleAdd؛ چون handleAdd در واقع یک خاصیت است که متدی به آن انتساب داده شدهاست.
- نتیجهی دریافتی از متد axios.post را اینبار به post، بجای posts تغییر نام دادهایم و همانطور که در تصویر زیر مشاهده میکنید، خاصیت id آن در سمت سرور مقدار دهی شدهاست:
- در آخر برای افزودن این رکورد، به مجموعهی رکوردهای موجود، از روش spread operator استفاده کردهایم تا ابتدا شیء post دریافتی از سمت سرور درج شود و سپس مابقی اعضای آرایهی posts موجود در state، در این آرایه گسترده شده و یک آرایهی جدید را تشکیل دهند. سپس این آرایهی جدید را جهت به روز رسانی state و در نتیجهی آن، به روز رسانی UI، به متد setState ارسال کردهایم، که نتیجهی آن درج این رکورد جدید، در ابتدای لیست است:
به روز رسانی اطلاعات در سمت سرور
در اینجا پیاده سازی متد put را مشاهده میکنید:
handleUpdate = async post => { post.title = "Updated"; const { data: updatedPost } = await axios.put( `${apiEndpoint}/${post.id}`, post ); console.log(updatedPost); const posts = [...this.state.posts]; const index = posts.indexOf(post); posts[index] = { ...post }; this.setState({ posts }); };
- اکنون امضای متد axios.put هرچند مانند متد post است، اما متد Update تعریف شدهی در سمت API سرور، یک چنین مسیری را نیاز دارد api/Posts/{id}. به همین جهت ذکر id مطلب، در URL نهایی نیز ضروری است.
- در اینجا نیز از واژههای await و async برای دریافت نتیجهی واقعی عملیات put و همچنین عملیات گذاری این متد به صورت async، استفاده شدهاست.
- در آخر، ابتدا آرایهی posts موجود در state را clone میکنیم. چون میخواهیم در آن، در ایندکسی که شیء post جاری قرار دارد، مقدار به روز رسانی شدهی آنرا قرار دهیم. سپس این آرایهی جدید را جهت به روز رسانی state و در نتیجهی آن، به روز رسانی UI، به متد setState ارسال کردهایم:
حذف اطلاعات در سمت سرور
برای حذف اطلاعات در سمت سرور، نیاز است یک HTTP Delete را به آن ارسال کنیم که اینکار را میتوان توسط متد axios.delete انجام داد. URL ای را که دریافت میکند، شبیه به URL ای است که برای حالت put ایجاد کردیم:
handleDelete = async post => { await axios.delete(`${apiEndpoint}/${post.id}`); const posts = this.state.posts.filter(item => item.id !== post.id); this.setState({ posts }); };
کدهای کامل این قسمت را از اینجا میتوانید دریافت کنید: sample-22-backend-part-02.zip و sample-22-frontend-part-02.zip
using System; namespace WpfLargeLists.Models { public class User { public int Id { set; get; } public string FirstName { set; get; } public string LastName { set; get; } public string Address { set; get; } public DateTime DateOfBirth { set; get; } } }
<ListView ItemsSource="{Binding UsersTab1}" Grid.Row="1" Margin="3"> <ListView.View> <GridView> <GridViewColumn Header="Id" Width="50" DisplayMemberBinding="{Binding Id, Mode=OneTime}" /> <GridViewColumn Header="FirstName" Width="100" DisplayMemberBinding="{Binding FirstName, Mode=OneTime}" /> <GridViewColumn Header="LastName" Width="100" DisplayMemberBinding="{Binding LastName, Mode=OneTime}" /> <GridViewColumn Header="Address" Width="100" DisplayMemberBinding="{Binding Address, Mode=OneTime}" /> <GridViewColumn Header="DateOfBirth" Width="150" DisplayMemberBinding="{Binding DateOfBirth, Mode=OneTime}" /> </GridView> </ListView.View> </ListView>
راه حل توصیه شده برای بارگذاری تعداد بالایی رکورد در WPF : استفاده از UI Virtualization
UI Virtualization روشی است که در آن تنها المانهایی که توسط کاربر در حال مشاهده هستند، تولید و مدیریت خواهند شد. در این حالت اگر 1000 رکورد را به یک ListBox یا ListView ارسال کنید و کاربر بر اساس اندازه صفحه جاری خود تنها 10 رکورد را مشاهده میکند، WPF فقط 10 عنصر را در VisualTree مدیریت خواهد کرد. با اسکرول به سمت پایین، مواردی که دیگر نمایان نیستند dispose شده و مجموعه نمایان دیگری خلق خواهند شد. به این ترتیب میتوان حجم بالایی از اطلاعات را در WPF با میزان مصرف پایین حافظه و همچنین مصرف CPU بسیار کم مدیریت کرد. این مجازی سازی در WPF به وسیله VirtualizingStackPanel در دسترس است.
برای اینکه WPF virtualization به درستی کار کند، نیاز است یک سری شرایط مقدماتی فراهم شوند:
- از کنترلی استفاده شود که از virtualization پشتیبانی میکند؛ مانند ListBox و ListView.
- ارتفاع کنترل لیستی باید دقیقا مشخص باشد؛ یا درون یک ردیف از Grid ایی باشد که ارتفاع آن مشخص است. برای نمونه اگر ارتفاع ردیف Grid ایی که ListView را دربرگرفته است به * تنظیم شده، مشکلی نیست؛ اما اگر ارتفاع این ردیف به Auto تنظیم شده، کنترل لیستی برای محاسبه vertical scroll bar خود دچار مشکل خواهد شد.
- کنترل مورد استفاده نباید در یک کنترل ScrollViewer محصور شود؛ در غیر اینصورت virtualization رخ نخواهد داد. علاوه بر آن در خود کنترل باید خاصیت ScrollViewer.HorizontalScrollBarVisibility نیز به Disabled تنظیم گردد.
- در کنترل در حال استفاده، ScrollViewer.CanContentScroll باید به true تنظیم شود.
مورد مشخص بودن ارتفاع بسیار مهم است. برای نمونه در برنامهای پس از فعال سازی مجازی سازی، کنترل لیستی کلا از کار افتاد و حرکت scroll bar آن سبب بروز CPU Usage بالایی میشد. این مشکل با تنظیم ارتفاع آن به شکل زیر برطرف شد:
Height="{Binding Path=RowDefinitions[1].ActualHeight, RelativeSource={RelativeSource AncestorType=Grid}}"
- پس از اعمال موارد یاد شده، باید VirtualizingStackPanel کنترل را فعال کرد. ابتدا دو خاصیت زیر باید مقدار دهی شوند:
VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling"
<ListBox.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanel IsVirtualizing="True" VirtualizationMode="Recycling" /> </ItemsPanelTemplate> </ListBox.ItemsPanel>
<ListView.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanel IsVirtualizing="True" VirtualizationMode="Recycling" /> </ItemsPanelTemplate> </ListView.ItemsPanel>
<ListView ItemsSource="{Binding UsersTab2}" Grid.Row="1" Margin="3" ScrollViewer.HorizontalScrollBarVisibility="Disabled" ScrollViewer.CanContentScroll="True" VirtualizingStackPanel.IsVirtualizing="True" VirtualizingStackPanel.VirtualizationMode="Recycling"> <ListView.ItemsPanel> <ItemsPanelTemplate> <VirtualizingStackPanel IsVirtualizing="True" VirtualizationMode="Recycling" /> </ItemsPanelTemplate> </ListView.ItemsPanel> <ListView.View> <GridView> <GridViewColumn Header="Id" Width="50" DisplayMemberBinding="{Binding Id, Mode=OneTime}" /> <GridViewColumn Header="FirstName" Width="100" DisplayMemberBinding="{Binding FirstName, Mode=OneTime}" /> <GridViewColumn Header="LastName" Width="100" DisplayMemberBinding="{Binding LastName, Mode=OneTime}" /> <GridViewColumn Header="Address" Width="100" DisplayMemberBinding="{Binding Address, Mode=OneTime}" /> <GridViewColumn Header="DateOfBirth" Width="150" DisplayMemberBinding="{Binding DateOfBirth, Mode=OneTime}" /> </GridView> </ListView.View> </ListView>
در این مثال دو برگه را ملاحظه میکنید. برگه اول حالت normal ابتدای بحث است و برگه دوم پیاده سازی UI Virtualization را انجام داده است.
ساختار مورد نیاز یک Kendo UI Tree View
فرض کنید قصد دارید نظرات تو در توی مطلبی را توسط Kendo UI Tree View نمایش دهید. مدل خود ارجاع دهندهی آن میتواند چنین شکلی را داشته باشد:
namespace KendoUI11.Models { public class BlogComment { public int Id { set; get; } public string Body { set; get; } public int? ParentId { get; set; } // مخصوص کندو یو آی هستند public bool HasChildren { get; set; } public string imageUrl { get; set; } } }
چند خاصیت بعدی مانند HasChildren و imageUrl مخصوص Kendo UI هستند. از imageUrl اختیاری میتوان جهت نمایش آیکنی در کنار یک آیتم استفاده کرد و HasChildren به این معنا است که آیا گره جاری دارای عناصر فرزندی میباشد یا خیر.
تهیه یک منبع داده نمونه
شکل ابتدای مطلب، از طریق منبع داده ذیل تهیه شدهاست:
using System.Collections.Generic; namespace KendoUI11.Models { /// <summary> /// منبع داده فرضی جهت سهولت دموی برنامه /// </summary> public static class BlogCommentsDataSource { private static readonly IList<BlogComment> _cachedItems; static BlogCommentsDataSource() { _cachedItems = createBlogCommentsDataSource(); } public static IList<BlogComment> LatestComments { get { return _cachedItems; } } /// <summary> /// هدف صرفا تهیه یک منبع داده آزمایشی ساده تشکیل شده در حافظه است /// </summary> private static IList<BlogComment> createBlogCommentsDataSource() { var list = new List<BlogComment>(); var comment1 = new BlogComment { Id = 1, Body = "نظر من این است که", HasChildren = true, ParentId = null }; list.Add(comment1); var comment12 = new BlogComment { Id = 2, Body = "پاسخی به نظر اول", HasChildren = true, ParentId = 1 }; list.Add(comment12); var comment12A = new BlogComment { Id = 3, Body = "پاسخی دیگری به نظر اول", HasChildren = false, ParentId = 1 }; list.Add(comment12A); var comment121 = new BlogComment { Id = 4, Body = "پاسخی به پاسخ به نظر اول", HasChildren = false, ParentId = 2 }; list.Add(comment121); var comment2 = new BlogComment { Id = 5, Body = "نظر 2", HasChildren = true, ParentId = null, imageUrl= "images/search.png" }; list.Add(comment2); var comment21 = new BlogComment { Id = 6, Body = "پاسخ به نظر 2", HasChildren = false, ParentId = 5 }; list.Add(comment21); return list; } } }
در این لیست دو رکورد، دارای ParentId مساوی null هستند. از این null بودنها جهت کوئری گرفتن و نمایش ریشههای TreeView در ادامه استفاده خواهیم کرد.
بازگشت نظرات با فرمت JSON به سمت کلاینت
در ادامه یک کنترلر ASP.NET MVC را مشاهده میکنید که توسط اکشن متد GetBlogComments، رکوردهای مورد نظر را با فرمت JSON به سمت کلاینت ارسال میکند:
using System.Linq; using System.Web.Mvc; using KendoUI11.Models; namespace KendoUI11.Controllers { public class HomeController : Controller { public ActionResult Index() { return View(); // shows the page. } [HttpGet] public ActionResult GetBlogComments(int? id) { if (id == null) { //دریافت ریشهها return Json( BlogCommentsDataSource.LatestComments .Where(x => x.ParentId == null) // ریشهها .ToList(), JsonRequestBehavior.AllowGet); } else { //دریافت فرزندهای یک ریشه return Json( BlogCommentsDataSource.LatestComments .Where(x => x.ParentId == id) .ToList(), JsonRequestBehavior.AllowGet); } } } }
اگر مقدار id به سمت سرور ارسال شود، یعنی کاربر گره و نودی را گشودهاست. بر این اساس، تمامی فرزندان این گره را یافته و بازگشت میدهیم.
کدهای سمت کاربر نمایش Kendo UI Tree View
برای کار با Kendo UI TreeView نیاز است از منبع داده خاصی به نام HierarchicalDataSource به نحو ذیل استفاده کنیم. در قسمت transport آن مشخص میکنیم که اطلاعات باید از چه آدرسی خوانده شوند که در اینجا به آدرس اکشن متد GetBlogComments اشاره میکند.
همچنین نیاز است مشخص کنیم کدامیک از خواص مدل بازگردانده شده، همان hasChildren است که در مثال فوق دقیقا به همین نام نیز تنظیم شدهاست.
<!--نحوهی راست به چپ سازی --> <div class="k-rtl k-header demo-section"> <div id="my-treeview"></div> </div> @section JavaScript { <script type="text/javascript"> $(function () { var dataSource = new kendo.data.HierarchicalDataSource({ transport: { read: { url: "@Url.Action("GetBlogComments", "Home")", dataType: "json", contentType: 'application/json; charset=utf-8', type: 'GET' } }, schema: { model: { id: "Id", hasChildren: "HasChildren" } } }); $("#my-treeview").kendoTreeView({ //استفاده از قالب در صورت نیاز template: kendo.template($("#treeview-template").html()), checkboxes: { checkChildren: false }, dataSource: dataSource, dataTextField: "Body", //رخدادها select: function (e) { console.log("Selecting: " + this.text(e.node)); }, check: function (e) { console.log("Checkbox changed :: " + this.text(e.node)); }, change: function (e) { console.log("Selection changed"); }, collapse: function (e) { console.log("Collapsing " + this.text(e.node)); }, expand: function (e) { console.log("Expanding " + this.text(e.node)); } }); }); </script> <script id="treeview-template" type="text/kendo-ui-template"> <strong> #: item.Body # </strong> </script> <style scoped> .demo-section { width: 100%; height: 300px; } </style> }
- در ابتدا به ازای هر ردیف این TreeView، از یک قالب استفاده شدهاست. تعریف این مورد اختیاری است. اگر نیاز به سفارشی سازی نحوهی نمایش هر آیتم را داشتید، میتوان از قالبها استفاده کرد.
- قسمت checkboxes مشخص میکند که آیا نیاز است در کنار هر آیتم یک checkbox نیز نمایش داده شود یا خیر.
- dataSource را به HierarchicalDataSource تنظیم کردهایم.
- dataTextField مشخص میکند که کدام فیلد دربرگیرندهی متن هر آیتم TreeView است.
- تعدادی رخداد منتسب به TreeView نیز تنظیم شدهاند که خروجی آنها را در console تصویر ابتدای بحث مشاهده میکنید.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید.
پروژه: Interactive Pre Generated Views for Entity Framework 6
بسته نیوگت
نحوه استفاده: Using Pre-Generated Views Without Having To Pre-Generate Views
مرتب سازی پراکسیها در سرویسهای WCF
فرض کنید یک پراکسی دینامیک (dynamic proxy) از یک کوئری دریافت کرده اید. حال میخواهید این پراکسی را در قالب یک آبجکت CLR سریال کنید. هنگامی که آبجکتهای موجودیت را بصورت POCO-based پیاده سازی میکنید، EF بصورت خودکار یک آبجکت دینامیک مشتق شده را در زمان اجرا تولید میکند که dynamix proxy نام دارد. این آبجکت برای هر موجودیت POCO تولید میشود. این آبجکت پراکسی بسیاری از خواص مجازی (virtual) را بازنویسی میکند تا عملیاتی مانند ردیابی تغییرات و بارگذاری تنبل را انجام دهد.
فرض کنید مدلی مانند تصویر زیر دارید.
ما از کلاس ProxyDataContractResolver برای deserialize کردن یک آبجکت پراکسی در سمت سرور و تبدیل آن به یک آبجکت POCO روی کلاینت WCF استفاده میکنیم. مراحل زیر را دنبال کنید.
- در ویژوال استودیو پروژه جدیدی از نوع WCF Service Application بسازید. یک Entity Data Model به پروژه اضافه کنید و جدول Clients را مطابق مدل مذکور ایجاد کنید.
- کلاس POCO تولید شده توسط EF را باز کنید و کلمه کلیدی virtual را به تمام خواص اضافه کنید. این کار باعث میشود EF کلاسهای پراکسی دینامیک تولید کند. کد کامل این کلاس در لیست زیر قابل مشاهده است.
public class Client { public virtual int ClientId { get; set; } public virtual string Name { get; set; } public virtual string Email { get; set; } }
نکته: بیاد داشته باشید که هرگاه مدل EDMX را تغییر میدهید، EF بصورت خودکار کلاسهای موجودیتها را مجددا ساخته و تغییرات مرحله قبلی را بازنویسی میکند. بنابراین یا باید این مراحل را هر بار تکرار کنید، یا قالب T4 مربوطه را ویرایش کنید. اگر هم از مدل Code-First استفاده میکنید که نیازی به این کارها نخواهد بود.
- ما نیاز داریم که DataContractSerializer از یک کلاس ProxyDataContractResolver استفاده کند تا پراکسی کلاینت را به موجودیت کلاینت برای کلاینت سرویس WCF تبدیل کند. یعنی client proxy به client entity تبدیل شود، که نهایتا در اپلیکیشن کلاینت سرویس استفاده خواهد شد. بدین منظور یک ویژگی operation behavior میسازیم و آن را به متد ()GetClient در سرویس الحاق میکنیم. برای ساختن ویژگی جدید از کدی که در لیست زیر آمده استفاده کنید. بیاد داشته باشید که کلاس ProxyDataContractResolver در فضای نام Entity Framework قرار دارد.
using System.ServiceModel.Description; using System.ServiceModel.Channels; using System.ServiceModel.Dispatcher; using System.Data.Objects; namespace Recipe8 { public class ApplyProxyDataContractResolverAttribute : Attribute, IOperationBehavior { public void AddBindingParameters(OperationDescription description, BindingParameterCollection parameters) { } public void ApplyClientBehavior(OperationDescription description, ClientOperation proxy) { DataContractSerializerOperationBehavior dataContractSerializerOperationBehavior = description.Behaviors .Find<DataContractSerializerOperationBehavior>(); dataContractSerializerOperationBehavior.DataContractResolver = new ProxyDataContractResolver(); } public void ApplyDispatchBehavior(OperationDescription description, DispatchOperation dispatch) { DataContractSerializerOperationBehavior dataContractSerializerOperationBehavior = description.Behaviors .Find<DataContractSerializerOperationBehavior>(); dataContractSerializerOperationBehavior.DataContractResolver = new ProxyDataContractResolver(); } public void Validate(OperationDescription description) { } } }
- فایل IService1.cs را باز کنید و کد آن را مطابق لیست زیر تکمیل نمایید.
[ServiceContract] public interface IService1 { [OperationContract] void InsertTestRecord(); [OperationContract] Client GetClient(); [OperationContract] void Update(Client client); }
- فایل IService1.svc.cs را باز کنید و پیاده سازی سرویس را مطابق لیست زیر تکمیل کنید.
public class Client { [ApplyProxyDataContractResolver] public Client GetClient() { using (var context = new EFRecipesEntities()) { context.Cofiguration.LazyLoadingEnabled = false; return context.Clients.Single(); } } public void Update(Client client) { using (var context = new EFRecipesEntities()) { context.Entry(client).State = EntityState.Modified; context.SaveChanges(); } } public void InsertTestRecord() { using (var context = new EFRecipesEntities()) { // delete previous test data context.ExecuteSqlCommand("delete from [clients]"); // insert new test data context.ExecuteStoreCommand(@"insert into [clients](Name, Email) values ('Armin Zia','armin.zia@gmail.com')"); } } }
- حال پروژه جدیدی از نوع Console Application به راه حل جاری اضافه کنید. این اپلیکیشن، کلاینت تست ما خواهد بود. پروژه سرویس را ارجاع کنید و کد کلاینت را مطابق لیست زیر تکمیل نمایید.
using Recipe8Client.ServiceReference1; namespace Recipe8Client { class Program { static void Main(string[] args) { using (var serviceClient = new Service1Client()) { serviceClient.InsertTestRecord(); var client = serviceClient.GetClient(); Console.WriteLine("Client is: {0} at {1}", client.Name, client.Email); client.Name = "Armin Zia"; client.Email = "arminzia@live.com"; serviceClient.Update(client); client = serviceClient.GetClient(); Console.WriteLine("Client changed to: {0} at {1}", client.Name, client.Email); } } } }
Client changed to: Armin Zia at arminzia@live.com
شرح مثال جاری
مایکروسافت استفاده از آبجکتهای POCO با WCF را توصیه میکند چرا که مرتب سازی (serialization) آبجکت موجودیت را سادهتر میکند. اما در صورتی که از آبجکتهای POCO ای استفاده میکنید که changed-based notification دارند (یعنی خواص موجودیت را با virtual علامت گذاری کرده اید و کلکسیونهای خواص پیمایشی از نوع ICollection هستند)، آنگاه EF برای موجودیتهای بازگشتی کوئریها پراکسیهای دینامیک تولید خواهد کرد.
استفاده از پراکسیهای دینامیک با WCF دو مشکل دارد. مشکل اول مربوط به سریال کردن پراکسی است. کلاس DataContractSerializer تنها قادر به serialize/deserialize کردن انواع شناخته شده (known types) است. در مثال جاری این یعنی موجودیت Client. اما از آنجا که EF برای موجودیتها پراکسی میسازد، حالا باید آبجکت پراکسی را سریال کنیم، نه خود کلاس Client را. اینجا است که از DataContractResolver استفاده میکنیم. این کلاس میتواند حین سریال کردن آبجکت ها، نوعی را به نوع دیگر تبدیل کند. برای استفاده از این کلاس ما یک ویژگی سفارشی ساختیم، که پراکسیها را به کلاسهای POCO تبدیل میکند. سپس این ویژگی را به متد ()GetClient اضافه کردیم. این کار باعث میشود که پراکسی دینامیکی که توسط متد ()GetClient برای موجودیت Client تولید میشود، به درستی سریال شود.
مشکل دوم استفاده از پراکسیها با WCF مربوط به بارگذاری تبل یا Lazy Loading میشود. هنگامی که DataContractSerializer موجودیتها را سریال میکند، تمام خواص موجودیت را دستیابی خواهد کرد که منجر به اجرای lazy-loading روی خواص پیمایشی میشود. مسلما این رفتار را نمیخواهیم. برای رفع این مشکل، مشخصا قابلیت بارگذاری تنبل را خاموش یا غیرفعال کرده ایم.