buttons: [ // .... , "insertHTML" //custom button ], customButtons: { insertHTML: { title: 'Insert Code', icon: { type: 'font', value: 'fa fa-dollar' // Font Awesome icon class fa fa-* }, callback: function (editor) { editor.saveSelection(); var codeModal = $("<div>").addClass("froala-modal").appendTo("body"); var wrapper = $("<div>").addClass("f-modal-wrapper").appendTo(codeModal); $("<h4>").append('<span data-text="true">Insert Code</span>') .append($('<i class="fa fa-times" title="Cancel">') .click(function () { codeModal.remove(); })) .appendTo(wrapper); var dialog = "<textarea id='code_area' style='height: 211px; width: 538px;' /><br/><label>Language:</label><select id='code_lang'><option>CSharp</option><option>VB</option><option>JScript</option><option>Sql</option><option>XML</option><option>CSS</option><option>Java</option><option>Delphi</option></select> <input type='button' name='insert' id='insert_btn' value='Insert' /><br/>"; $(dialog).appendTo(wrapper); $("#code_area").text(editor.text()); if (!editor.selectionInEditor()) { editor.$element.focus(); } $('#insert_btn').click(function () { var lang = $("#code_lang").val(); var code = $("#code_area").val(); code = code.replace(/\s+$/, ""); // rtrim code = $('<span/>').text(code).html(); // encode var htmlCode = "<pre language='" + lang + "' name='code'>" + code + "</pre></div>"; var codeBlock = "<div align='left' dir='ltr'>" + htmlCode + "</div><br/>"; editor.restoreSelection(); editor.insertHTML(codeBlock); editor.saveUndoStep(); codeModal.remove(); }); } } }
<# if (!mvcHost.IsContentPage) { #> <# } } #> <div class="modal-header"> <button type="button" class="close" data-dismiss="modal" aria-hidden="true"> ×</button> <#= mvcHost.ViewDataType.Name #> </div> @using (Html.BeginForm()) { <div class="modal-body"> @Html.ValidationSummary(true, null, new { @class = "alert alert-error alert-block" }) <fieldset class="form-horizontal"> <legend><#= mvcHost.ViewDataType.Name #></legend> <# foreach (ModelProperty property in GetModelProperties(mvcHost.ViewDataType)) { if (!property.IsPrimaryKey && !property.IsReadOnly && property.Scaffold) { #> <div class="control-group"> <# if (property.IsForeignKey) { #> @Html.LabelFor(model => model.<#= property.Name #>, "<#= property.AssociationName #>",new {@class="control-label"}) <# } else { #> @Html.LabelFor(model => model.<#= property.Name #>,new {@class="control-label"}) <# } #> <div class="controls"> <# if (property.IsForeignKey) { #> @Html.DropDownList("<#= property.Name #>", String.Empty) <# } else { #> @Html.EditorFor(model => model.<#= property.Name #>) <# } #> @Html.ValidationMessageFor(model => model.<#= property.Name #>,null,new{@class="help-inline"}) </div> </div> <# } } #> </fieldset> </div> <div class="modal-footer"> <button class="btn btn-primary" type="submit"> ارسال</button> <button class="btn" data-dismiss="modal" aria-hidden="true"> انصراف</button> </div> }
حالا تنها مشکلم نحوه نمایش گروه محصولات داخل DropDownList است.
یک نکتهی تکمیلی: روش لغو صف درخواستهای مکرر fetch ارسالی به سمت سرور
ورودی جستجوی بالای صفحه را درنظر بگیرید که بهازای هربار ورود حرفی، یک درخواست fetch جدید را به سمت سرور ارسال میکند تا نتایج جستجوی حاصل را دریافت کند. مشکل اینجاست که ما تنها به آخرین درخواست fetch ارسال شدهی به سمت سرور نیاز داریم و نه به تمام درخواستهای دیگری که صادر شدهاند. به همین جهت این صف درخواستهای fetch قبلی، غیربهینه بوده و ترافیک بالایی را سبب میشوند. یک روش مواجه شدن با این مساله، استفاده از مفهومی به نام debounce است که در پشت صحنه، از یک تایمر استفاده میکند و فقط هر چند ثانیه یکبار، یک درخواست جدید را به همراه آخرین متن ورودی، به سمت سرور ارسال خواهد کرد. راه دیگری هم برای مواجه شدن با این مشکل، در مرورگرهای جدید پیشبینی شدهاست که AbortController نام دارد. با استفاده از آن میتوان «سیگنالی» را به صف درخواستهای پرتعداد fetch قبلی حاصل از ورود اطلاعات کاربر ارسال کنیم که ... «لغو شوید» و به سمت سرور ارسال نشوید.
برای توضیح بهتر آن، به مثال زیر دقت کنید:
<!DOCTYPE html> <html> <body> <input id="search" type="number" /> <script> const results = []; const search = document.getElementById("search"); let controller = new AbortController(); let signal = controller.signal; const onChange = () => { const value = search.value; if (value) { controller.abort(); controller = new AbortController(); signal = controller.signal; getPost(value, signal); } }; search.onkeyup = onChange; </script> </body> </html>
- در اینجا یک input box را داریم که ابتدا، یافت شده و سپس به رخداد onkeyup آن، متد onChange نسبت داده شدهاست تا هربار که کاربر، اطلاعاتی را وارد میکند، فراخوانی شود.
- در ابتدای اسکریپت هم نحوهی نمونه سازی شیء استاندارد جاوااسکریپتی AbortController و دسترسی به شیء signal آنرا مشاهده میکنید.
- در متد onChange، ابتدا مقدار جدید ورودی کاربر، دریافت میشود، سپس این AbortController، لغو میشود و بعد یک نمونهی جدید از آن ایجاد شده و مجددا به شیء signal آن دسترسی پیدا میکنیم تا آنرا به متد getPost ارسال کنیم. این متد هم چنین پیاده سازی را دارد:
const getPost = (value, signal) => { fetch( `https://site.com/search/${value}`, { signal } ); };
همانطور که مشاهده میکنید، تابع fetch، قابلیت پذیرش شیء signal را هم دارد. زمانیکه با هربار تایپ کاربر، متد ()controller.abort فراخوانی میشود، سیگنالی را به fetch «قبلی» متصل به آن ارسال میکند که ... دیگر به سمت سرور ارسال نشو و متوقف شو. با اینکار فقط آخرین ورودی کاربر، سبب بروز یک fetch موفق میشود و ترافیک ارسالی به سمت سرور کاهش پیدا میکند (چون تمام fetchهای قبلی، سیگنال abort را دریافت کردهاند)؛ مانند مثال زیر که کاربر، 5 بار حروفی را وارد کرده و به ازای هربار ورود حرفی، یک درخواست fetch جدید، ایجاد شده، اما ... فقط آخرین درخواست ارسالی او موفق بوده و نتیجهای را بازگشت داده و مابقی درخواستها ... abort شدهاند. این عملیات abort، در سمت کاربر اعمال میشود؛ یعنی اصلا درخواستی به سمت سرور ارسال نمیشود و این لغو درخواست، توسط برنامهی سمت سرور انجام نشدهاست.
<div id="app"> <div><video #video id="video" width="640" height="480" autoplay></video></div> <div><button id="snap" (click)="capture()">ضبط تصویر</button></div> <canvas #canvas id="canvas" width="640" height="480"></canvas> <ul> <li *ngFor="let capture of captures"> <img src="{{ capture }}" height="50" /> </li> </ul> </div>
export class AppComponent implements OnInit, AfterViewInit { @ViewChild('video') public video: ElementRef; @ViewChild('canvas') public canvas: ElementRef; public captures: Array<any>; public constructor() { this.captures = []; } public ngOnInit() { } public capture() { this.canvas.nativeElement.getContext('2d').drawImage(this.video.nativeElement, 0, 0, 640, 480); this.captures.push(this.canvas.nativeElement.toDataURL('image/png')); } public ngAfterViewInit() { if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { navigator.mediaDevices.getUserMedia({ video: true }).then(stream => { this.video.nativeElement.src = window.URL.createObjectURL(stream); this.video.nativeElement.play(); }); } } }
توضیحات :
با استفاده از local variable هایی که در کدهای HTML تعریف کردیم و ViewChild@، میتوانیم المنتها را در متغیرها، load کنیم. در این حالت این امکان وجود دارد تا المنتهای DOM را دستکاری کنیم.public ngAfterViewInit() { if (navigator.mediaDevices && navigator.mediaDevices.getUserMedia) { navigator.mediaDevices.getUserMedia({ video: true }).then(stream => { this.video.nativeElement.src = window.URL.createObjectURL(stream); this.video.nativeElement.play(); }); } }
- MediaDevices : این اینترفیس دستیابی به دستگاههای ورودی متصل شده، مثلا دوربین، میکروفن و ... را فراهم میسازد.
- MediaDevices.getUserMedia: با گرفتن مجوزی از کاربر از طریق یک هشدار، دوربین کاربر را روشن میکند و هم چنین این متد یک promise را بازگشت میدهد؛ در صورتیکه کاربر اجازه دسترسی بدهد.
- URL.createObjectURL: یک URL را برای یک BLOB مشخص شده ایجاد میکند ( BLOB: Binary large object ) که میتواند به متدی که انتظار یک URL را دارد، پاس داده شود. بعد از برگشت URL، متد ()revokeObjectURL فراخوانی میشود که کارش آزاد سازی منابع مرتبط با url ایجاد شدهی توسط createObjectURL میباشد. در ضمن طول عمر url ایجاد شده برابر با بستن سند (document) در پنجرهای (window) که در آن ایجاد شدهاست، میباشد.
public capture() { this.canvas.nativeElement.getContext('2d').drawImage(this.video.nativeElement, 0, 0, 640, 480); this.captures.push(this.canvas.nativeElement.toDataURL('image/png')); }
- ()getContext : این متد یک شیء را برگشت میدهد که فراهم کنندهی متدها و خصوصیتها، برای رسم در Canvas میباشد.
- ()drawImage: این متددر Canvas رسم را انجام میدهد و همچنین این متد میتواند بخشهایی از یک تصویر را رسم کند یا سایز یک تصویر را افزایش یا کاهش دهد.
- () toDataURL: این متد یک data URI را بازگشت میدهد که یک تصویر در فرمت مشخص شده را بر اساس پارامتر type، برگشت میدهد (پیش فرض آن png میباشد).
NavigationManager.Uri #> https://localhost:5001/counter/3?q=hi NavigationManager.BaseUri #> https://localhost:5001/ NavigationManager.NavigateTo("http://new location") #> Navigates to new location NavigationManager.LocationChanged #> An event that fires when the navigation location has changed. NavigationManager.ToAbsoluteUri("test") #> https://localhost:5001/test NavigationManager.ToBaseRelativePath(MyNavigationManager.Uri) #> counter/3?q=hi
کدهای این قسمت بهروزرسانی شده و از این ریپازیتوری قابل دسترسی است.
Event Sourcing
در این قسمت قصد داریم تا اطلاعات Commandهای خود را بعد از Process، داخل یک دیتابیس Append-Only ذخیره کنیم. با استفاده از این روش میتوانیم بفهمیم در یک تاریخ مشخص، با چه ورودیهایی ( Request )، چه جواب ( Response ) ای در آن لحظه از برنامه برگشت داده شدهاست.
برای پیاده سازی Event Sourcing از دیتابیس EventStore که سورس آن نیز در گیتهاب قابل دسترسی است، استفاده خواهیم کرد. توجه داشته باشید که شما میتوانید از دیتابیسهای دیگری مثل Elasticsearch, Redis و ... بهمنظور دیتابیس Event Store خود استفاده کنید و محدود به EventStore نیستید.
ما برای راه اندازی دیتابیس EventStore در این قسمت، از Docker استفاده خواهیم کرد. آموزش Docker قبلا طی مقالاتی (2 , 1) در سایت قرار گرفتهاست و در این مقاله به تکرار نحوه استفاده از آن نخواهیم پرداخت.
با استفاده از دستور زیر، EventStore را از روی Docker Hub که Registry پیشفرض است، Pull و اجرا میکنیم و پورتهای 2113 و 1113 آن را به بیرون Expose میکنیم تا داخل برنامه خود، از آنها استفاده کنیم:
docker run --name eventstore-node -d -p 2113:2113 -p 1113:1113 eventstore/eventstore
EventStore دارای پنل ادمینی است که از طریق http://localhost:2113 قابل دسترسی است. Username پیشفرض آن برابر با admin و کلمه عبور آن برابر با changeit است.
بعد از لاگین در پنل ادمین، با چنین Dashboard ای مواجه خواهید شد و نشان از این دارد که EventStore بهدرستی اجرا شده است:
برای استفاده از EventStore داخل برنامه خود، مانند دیگر دیتابیسها، Client موجود آن را برای #C، از NuGet نصب میکنیم:
Install-Package EventStore.Client
سپس کلاسی بنام EventStoreDbContext ایجاد و منطق ارتباط با EventStore را داخل آن قرار میدهیم :
public class EventStoreDbContext : IEventStoreDbContext { public async Task<IEventStoreConnection> GetConnection() { IEventStoreConnection connection = EventStoreConnection.Create( new IPEndPoint(IPAddress.Loopback, 1113), nameof(MediatrTutorial)); await connection.ConnectAsync(); return connection; } public async Task AppendToStreamAsync(params EventData[] events) { const string appName = nameof(MediatrTutorial); IEventStoreConnection connection = await GetConnection(); await connection.AppendToStreamAsync(appName, ExpectedVersion.Any, events); } }
همانطور که میبینید، با استفاده از IP 1113 که در بالاتر با استفاده از Docker آن را Expose کرده بودیم، به EventStore متصل شدهایم. همچنین برای متد AppendToStreamAsync خود EventStore ، یک Facade نوشتهایم که نحوه کار با آن را برایمان راحتتر کردهاست.
با توجه به اینکه EventStore در Documentation خود بیان کرده که Thread-Safe است، در DI Container خود، EventStoreDbContext را بصورت Singleton ثبت و Register میکنیم و در طول عمر برنامه، یک instance از آن خواهیم داشت:
services.AddSingleton<IEventStoreDbContext, EventStoreDbContext>();
قصد داریم Request هایی را که از نوع Command هستند، همراه با Response آنها داخل EventStore ذخیره کنیم. برای تشخیص Query/Command بودن یک Request ، از نام آنها استفاده خواهیم کرد. همانطور که در قسمتهای قبل گفتیم ، Commandها باید با ذکر "Command" در پایان نامشان همراه باشند.
این یک Convention در برنامه ماست که باید رعایت شود. ( Convention Over Configuration )
مانند Behaviorهای قبلی، یک Behavior جدید را بنام EventLoggerBehavior ایجاد و از IPipelineBehavior ارث بری کرده و EventStoreDbContext خود را به آن Inject میکنیم:
public class EventLoggerBehavior<TRequest, TResponse> : IPipelineBehavior<TRequest, TResponse> { readonly IEventStoreDbContext _eventStoreDbContext; public EventLoggerBehavior(IEventStoreDbContext eventStoreDbContext) { _eventStoreDbContext = eventStoreDbContext; } public async Task<TResponse> Handle(TRequest request, CancellationToken cancellationToken, RequestHandlerDelegate<TResponse> next) { TResponse response = await next(); string requestName = request.ToString(); // Commands convention if (requestName.EndsWith("Command")) { Type requestType = request.GetType(); string commandName = requestType.Name; var data = new Dictionary<string, object> { { "request", request }, { "response", response } }; string jsonData = JsonConvert.SerializeObject(data); byte[] dataBytes = Encoding.UTF8.GetBytes(jsonData); EventData eventData = new EventData(eventId: Guid.NewGuid(), type: commandName, isJson: true, data: dataBytes, metadata: null); await _eventStoreDbContext.AppendToStreamAsync(eventData); } return response; } }
با استفاده از این Behavior، فقط Request هایی را که Command هستند و State برنامه را تغییر میدهند، داخل EventStore ذخیره میکنیم. اکنون کافیست تا این Behavior را داخل DI Container خود اضافه کنیم :
services.AddScoped(typeof(IPipelineBehavior<,>), typeof(EventLoggerBehavior<,>));
اگر برنامه را اجرا و یکی از Commandها را مانند CreateCustomerCommand، با استفاده از api/Customers <= POST فراخوانی کنید، Request و Response شما با Type آن Command و همراه با DateTime ای که این Request رخ دادهاست، داخل EventStore ذخیره خواهد شد که در Admin Panel مربوط به EventStore، در تب Stream Browser قابل مشاهده است :
نیاز بود هنگام انتخاب یک آیتم دراپ داون لیست در کل برنامه و تمامی دراپ داونهای آن، مقدار آنها نیز به صورت یک برچسب در کنار آن نمایش داده شود.
برای مثال در لیست زیر:
<asp:DropDownList ID="ddlActive" runat="server">
<asp:ListItem Value="Active">فعال</asp:ListItem>
<asp:ListItem Value="Inactive">غیرفعال</asp:ListItem>
</asp:DropDownList>
راه حل اول:
در تمام صفحات به ازای تک تک دراپ داونها یک label اضافه کنیم و همچنین کدهای تمام قسمتهای برنامه را نیز اصلاح کنیم تا این مورد را لحاظ کند.
راه دوم:
یک کنترل دراپ داون سفارشی را با خاصیت مورد نظر (همراه بودن با یک لیبل) ایجاد کرده و سپس تمام فرمها را باید اصلاح کرد تا از این کنترل جدید استفاده کنند.
راه سوم:
استفاده از jQuery برای اعمال این مهم به کل برنامه بدون نیاز به تغییرات اساسی در آن (و همچنین سازگاری با تمام مرورگرها):
//فقط در این محدوده
$("#mainFormReq select").change(function() {
var currentId = $(this).attr("id"); //آی دی شیء جاری
var val = $(this).val(); //مقدار
var text = $('#' + currentId + ' option:selected').text(); //متن
$("#lbl" + currentId).remove(); //اگر نمونهی قبلی موجود است حذف شود
if (val && (val.length > 0) && (text != val)) {
//اگر متن و مقدار یکی نیست نمایش داده شود
$(this).after('<label id="lbl' + currentId + '">' + val + '</label>');
}
});
در یک محدوده مشخص شده با ID مساوی mainFormReq (مثلا استفاده از master page ها و نسبت دادن این ID به content آن)، به دنبال تمام select های موجود در آن ناحیه میگردیم (اگر mainFormReq حذف شود، این جستجو در کل صفحه صورت خواهد گرفت) و تغییرات آنها را تحت نظر قرار خواهیم داد.
سپس آی دی این کنترل انتخابی را دریافت میکنیم (از این ID برای تولید ID برچسب مورد نظر استفاده خواهیم کرد).
در ادامه مقدارهای text و value گزینه انتخابی دریافت میشوند (+).
سپس بررسی خواهیم کرد که آیا برچسبی با ID مشخص شده ما وجود دارد (در صورت انتخاب آیتمهای دیگر، نباید برچسبی غیر منحصربفرد و تکراری در صفحه ایجاد کرد)
در ادامه اگر این مقدار null نبود و همچنین مقدار text و value هم یکی نبودند (اگر یکی بودند لزوم وجود این برچسب بی معنا است)، با استفاده از متد after کتابخانه jQuery یک برچسب را تولید و مقدار مورد نظر را پس از محل نمایش دراپ داون خود، نمایش خواهیم داد.
بهبود کد:
صورت مساله: اکنون نیاز است بجز ناحیه mainFormReq، به سه ناحیه دیگر نیز این تغییرات اعمال گردد. آیا باید همین مقدار کد را سه بار دیگر copy/paste کرد؟
روش صحیح انجام اینکار در jQuery ، نوشتن یک افزونه بر اساس کدهای فوق است که روش انجام آن به صورت زیر میباشد (+):
//<![CDATA[
(function($) {
$.fn.dropdownlabel = function() {
return this.change(function() {
var obj = $(this);
var currentId = obj.attr("id"); //آی دی شیء جاری
var val = obj.val(); //مقدار
var text = $('#' + currentId + ' option:selected').text(); //متن
$("#lbl" + currentId).remove(); //اگر نمونهی قبلی موجود است حذف شود
if (val && (val.length > 0) && (text != val)) {
//اگر متن و مقدار یکی نیست نمایش داده شود
obj.after('<label id="lbl' + currentId + '">' + val + '</label>');
}
});
};
})(jQuery);
//]]>
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="TestDropdownlabel.aspx.cs"
Inherits="testWebForms87.TestDropdownlabel" %>
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head runat="server">
<title></title>
<script src="jquery.min.js" type="text/javascript"></script>
<script src="jquery.dropdownlabel.js" type="text/javascript"></script>
<script type="text/javascript">
$(document).ready(function() {
$("#mainFormReq select").dropdownlabel();
});
</script>
</head>
<body>
<form id="form1" runat="server">
<div id="mainFormReq">
<asp:DropDownList ID="ddlActive" runat="server">
<asp:ListItem Value=""></asp:ListItem>
<asp:ListItem Value="Active">فعال</asp:ListItem>
<asp:ListItem Value="Inactive">غیرفعال</asp:ListItem>
</asp:DropDownList>
</div>
</form>
</body>
</html>
npm install @aspnet/signalr-client --save
بررسی محتوای پوشهی node_modules\@aspnet\signalr-client
پس از نصب بستهی «aspnet/signalr-client@»، در مسیر node_modules\@aspnet\signalr-client\dist دو پوشهی src و browser را خواهید یافت. پوشهی src حاوی منبع کامل این کلاینت و همچنین فایلهای Typings مخصوص تایپاسکریپت است.
و پوشهی browser آن شامل دو گروه فایل است:
- در اینجا گروهی از فایلها، حاوی عبارت ES5 هستند و تعدادی خیر. SignalR JavaScript بر اساس ES 6 یا EcmaScript 2015 تهیه شدهاست و از مفاهیمی مانند Promises و arrow functions استفاده میکند. باید دقت داشت که تعدادی از مرورگرها مانند IE از این قابلیتها پیشتیبانی نمیکنند. در بین این فایلها، آنهایی که حاوی عبارت ES5 نیستند، یعنی بر اساس ES 6 تهیه شدهاند. سایر فایلها توسط قابلیت Transpile مربوط به TypeScript به ES5 ترجمه شدهاند. به علاوه حجم این فایلها نیز بیشتر میباشد؛ چون حاوی تعاریف وابستگیهایی هستند که در ES 5 وجود خارجی ندارند. بنابراین بسته به نوع مرورگر مدنظر، یکی از این دو گروه را باید انتخاب کرد؛ ES 6 برای مرورگرهای جدید و ES 5 برای مرورگرهای قدیمی.
- به علاوه در اینجا تعدادی از فایلها حاوی عبارت msgpackprotocol هستند. نگارش جدید SignalR از پروتکلهای هاب سفارشی مانند پروتکلهای باینری نیز پشتیبانی میکند. همچنین حاوی یک پیاده سازی توکار از پروتکلهای باینری بر اساس MessagePack نیز هست. چون حجم کدهای پشتیبانی کنندهی از این پروتکل ویژه بالا است، آنرا به یک فایل مجزا انتقال دادهاند تا در صورت نیاز مورد استفاده قرار گیرد. بنابراین اگر از این پروتکل استفاده نمیکنید، نیازی هم به الحاق آن در صفحات خود نخواهید داشت. فایل third-party-notices.txt نیز مربوط است به یادآوری مجوز استفادهی از MessagePack که MIT میباشد.
- در هر گروه نیز، دو فایل min و معمولی قابل مشاهدهاست. فایلهای min برای توزیع نهایی مناسب هستند و فایلهای غیرفشرده شده برای حالت دیباگ.
استفاده از کلاینت جاوا اسکریپتی SignalR Core
برای کار با کلاینت جاوا اسکریپتی SignalR Core از همان فایلهای موجود در پوشهی node_modules/@aspnet/signalr-client/dist/browser استفاده میکنیم. تفاوت این کلاینت با نگارش قبلی SignalR به صورت یک ذیل است:
1) ارجاع به فایل قدیمی signalR-2.2.1.min.js با فایل جدید signalR-client-1.0.0-alpha1.js جایگزین میشود. اگر میخواهید مرورگرهای قدیمی را پشتیبانی کنید، نگارش ES5 آنرا لحاظ کنید.
2) پروکسیها با new HubConnection جایگزین شدهاند.
3) برای ثبت callbackهای سمت کلاینت، از متد جدید on استفاده میشود.
4) بجای متد done مربوط به jQuery، در اینجا از متد then مربوط به ES6 کمک گرفته شدهاست.
5) کار فراخوانی متدهای هاب توسط متد invoke انجام میشود.
یک مثال: بازنویسی قسمت سمت کلاینت مثال «کار با SignalR Core از طریق یک کلاینت Angular» با jQuery
هرچند کلاینت جدید SignalR Core وابستگی به jQuery ندارد، اما جهت سهولت کار با DOM، کدهای سمت کلاینت مثال قبلی را با jQuery بازنویسی میکنیم. تمام کدهای سمت سرور این مثال با مطلب «کار با SignalR Core از طریق یک کلاینت Angular» یکی است؛ مانند ایجاد هاب، فعالسازی SiganlR در فایل آغازین برنامه و ثبت مسیرهاب. بنابراین در اینجا، این قسمت از کدهای سمت سرور را مجددا تکرار نمیکنیم و تمام نکات آن یکی هستند.
برای کار با کلاینت جاوا اسکریپتی SignalR Core، اینبار دستور ذیل را در ریشهی پروژهی وب اجرا میکنیم (یا هر پروژهای که قرار است مدیریت فایلهای سمت کلاینت و Viewهای برنامه را انجام دهد):
npm init npm install @aspnet/signalr-client --save bower install
مرحلهی بعدی کار، تنظیمات فایل bundleconfig.json است؛ تا تمام اسکریپتهای مورد نیاز جمعآوری و یکی شوند:
[ { "outputFileName": "wwwroot/css/site.min.css", "inputFiles": [ "wwwroot/lib/bootstrap/dist/css/bootstrap.min.css", "wwwroot/css/site.css" ] }, { "outputFileName": "wwwroot/js/site.min.js", "inputFiles": [ "wwwroot/lib/jquery/dist/jquery.min.js", "wwwroot/lib/bootstrap/dist/js/bootstrap.min.js", "node_modules/@aspnet/signalr-client/dist/browser/signalr-client-1.0.0-alpha1-final.min.js", "wwwroot/lib/jquery-validation/dist/jquery.validate.min.js", "wwwroot/lib/jquery-validation-unobtrusive/jquery.validate.unobtrusive.min.js", "wwwroot/lib/jquery-ajax-unobtrusive/jquery.unobtrusive-ajax.min.js", "wwwroot/js/site.js" ], "minify": { "enabled": false, "renameLocals": false }, "sourceMap": false } ]
با توجه به خروجیهای نهایی فایل bundleconfig.json، تنها نیاز است مداخل ذیل را به فایل layout برنامه اضافه کرد:
<link href="~/css/site.min.css" rel="stylesheet" asp-append-version="true" /> <script src="~/js/site.min.js" type="text/javascript" asp-append-version="true"></script>
مرحلهی بعد، تغییر نام متد send قسمت قبل به broadcastMessage است:
public class MessageHub : Hub { public Task Send(string message) { return Clients.All.InvokeAsync("broadcastMessage", message); } }
در ادامه یک کنترلر ساده را به نام JsClientController با View ذیل ایجاد میکنیم:
<form method="post" asp-action="Index" asp-controller="Home" data-ajax="true" role="form"> <div class="form-group"> <label label-for="message">Message: </label> <input id="message" name="message" class="form-control"/> </div> <button class="btn btn-primary" type="submit">Send To Home/Index</button> <button class="btn btn-success" id="sendmessageDirect" type="button">Send To /message hub directly</button> </form> <div id="discussion"> </div>
از اولین دکمه برای ارسال یک پیام به کنترلر Home که در آن توسط <IHubContext<MessageHub پیامی به تمام کلاینتها ارسال میشود، استفاده شدهاست. دومین دکمه متد Send هاب را مستقیما فراخوانی میکند؛ با این کدهای سمت کلاینت:
@section Scripts { <script type="text/javascript" asp-append-version="true"> $(function() { var connection = new signalR.HubConnection('/message'); connection.on('broadcastMessage', function (message) { // Add the message to the page. var encodedMsg = $('<div />').text(message).html(); $('#discussion').append('<li>' + encodedMsg + '</li>'); }); connection.start().then(function () { console.log('connected.'); $('#sendmessageDirect').click(function () { // Call the Send method on the hub. connection.invoke('send', $('#message').val()); }); }); }); </script> }
- سپس در متد on هست که مشخص میکنیم متد سمت کلاینتی که قرار است از سمت سرور فراخوانی شود، چه نامی دارد. نام آنرا در این مثال broadcastMessage درنظر گرفتهایم. در اینجا پارامتر message از سمت سرور دریافت شده و سپس در صفحهی جاری نمایش داده میشود.
بدیهی است متد Send میتواند تعداد پارامترهای بیشتری را بپذیرد و همچنین متد broadcastMessage نیز محدودیتی از لحاظ تعداد پارامتر ندارد. اگر پارامترهای بیشتری را تعریف کردید، در همینجا باید قید شوند.
- در ادامه کار شروع این اتصال آغاز میشود. در متد then هست که باید کار اتصال دکمهی sendmessageDirect صورت گیرد. چون عملیات اتصال ممکن است زمانبر باشد و connection ارسالی هنوز آغاز نشده باشد. در اینجا نحوهی فراخوانی مستقیم متد Send سمت سرور را با یک پارامتر ملاحظه میکنید. این متد نیز میتواند بر اساس امضای متد Send سمت سرور، تعداد پارامترهای بیشتری را قبول کند.
کدهای کامل این مثال را از اینجا میتوانید دریافت کنید: SignalRCore2WebApp02.zip
برای اجرا آن باید این دستورات را به ترتیب وارد کنید:
dotnet restore npm install npm install -g bower bower install dotnet watch run
چندی پیش در سایت جاری چند مقاله خوب توسط یکی از دوستان درباره Qunit منتشر شد. Qunit یک ابزار قدرتمند و مناسب برای تست کدهای جاوااسکریپت است و در اثبات صحت این گفته همین کافیست که بدانیم برای تست کدهای نوشته شده در پروژههای متن بازی هم چون Backbone.Js و JQuery از این فریم ورک استفاده شده است. اما به احتمال قوی در ذهن شما این سوال مطرح شده است که خب! در صورت آشنایی با Qunit چه نیاز به یادگیری Jasmine یا خدای نکرده Mocha و FuncUnit است؟ هدف صرفا معرفی یک ابزار غیر برای تست کد است نه مقایسه و نتیجه گیری برای تعیین میزان برتری این ابزارها. اصولا مهمترین دلیل برای انتخاب، علاوه بر امکانات و انعطاف پذیری، فاکتور راحتی و آسان بودن در هنگام استفاده است که به صورت مستقیم به شما و تیم توسعه نرم افزار بستگی دارد.
اما به عنوان توسعه دهنده نرم افزار که قرار است از این ابزار استفاده کنیم بهتر است با تفاوتها و شباهتهای مهم این دو فریم ورک آشنا باشیم:
»Jasmine یک فریم ورک تست کدهای جاوا اسکریپ بر مبنای Behavior-Driven Development است در حالی که Qunit بر مبنای Test-Driven Development است و همین مسئله مهمترین تفاوت بین این دو فریم ورک میباشد.
»اگر قصد دارید که از Qunit نیز به روش BDD استفاده نمایید باید از ترکیب Pavlov به همراه Qunit استفاده کنید.
»Jasmine از مباحث مربوط به Spies و Mocking به خوبی پشتیبانی میکند ولی این امکان به صورت توکار در Qunit فراهم نیست. برای اینکه بتوانیم این مفاهیم را در Qunit پیاده سازی کنیم باید از فریم ورکهای دیگر نظیر SinonJS به همراه Qunit استفاده کنیم.
»هر دو فریم ورک بالا به سادگی و راحتی کار معروف هستند
»تمام موارد مربوط به الگوهای Matching در هر دو فریم ورک به خوبی تعبیه شده است
» هر دو فریم ورک بالا از مباحث مربوط به Asynchronous Testing برای تست کدهای Ajax ای به خوبی پشتیبانی میکنند.
بررسی چند مفهوم
قبل از شروع، بهتر است که با چند مفهوم کلی و در عین حال مهم این فریم ورک آشنا شویم
describe('JavaScript addition operator', function () { it('adds two numbers together', function () { expect(1 + 2).toEqual(3); }); });
در تابع it کد بالا شما میتوانید کدهای مربوط بدنه توابع تست خود را بنویسید. برای پیاده سازی Assert در توابع تست مفهوم expectationها وجود دارد. در واقع expect برای بررسی مقادیر حقیقی با مقادیر مورد انتظار مورد استفاده قرار میگیرد و شامل مقادیر true یا false خواهد بود.
برای Setup و Teardown توابع تست خود باید از توابع beforeEach و afterEach که بدین منظور تعبیه شده اند استفاده کنید.
describe("A spec (with setup and tear-down)", function() { var foo; beforeEach(function() { foo = 0; foo += 1; }); afterEach(function() { foo = 0; }); it("is just a function, so it can contain any code", function() { expect(foo).toEqual(1); }); it("can have more than one expectation", function() { expect(foo).toEqual(1); expect(true).toEqual(true); }); });
اگر در کد تست خود قصد دارید که یک تابع describe یا it را غیر فعال کنید کافیست یک x به ابتدای آنها اضافه کنید و دیگر نیاز به هیچ کار اضافه دیگری برای comment کردن کد نیست.
xdescribe("A spec", function() { var foo; beforeEach(function() { foo = 0; foo += 1; }); xit("is just a function, so it can contain any code", function() { expect(foo).toEqual(1); }); });
درادامه قصد پیاده سازی یک مثال را با استفاده از Jasmine و RequireJs در پروژه Asp.Net MVC دارم.
- فولدر lib شامل فایلها کدهای Jasmine برای setup و tear down و spice و تست کدهای شما میباشد.
- فایل specRunner.html به واقع یک فایل برای نمایش فایلهای تست و همچنین نمایش نتیجه تست است.
- فولدر spec نیز شامل کدهای Jasmine برای کمک به نوشتن تست میباشد.
در این مثال قصد داریم فایلهای player.js و song.js که به عنوان نمونه به همراه این فریم ورک قرار دارد را در قالب یک پروژه MVC به همراه RequireJs، تست نماییم. در نتیجه این فایلها را از فولدر src انتخاب نمایید و آنها را در قسمت Scripts پروژه اصلی خود کپی کنید(ابتدا بک پوشه به نام App بسازید و فایلها را در آن قرار دهید)
برای استفاده از requireJs باید دستور define را در ابتدا این فایلها اضافه نماییم. در نتیجه فایلهای Player.js و Song.js را باز کنید و تغییرات زیر را در ابتدای این فایلها اعمال نمایید.
Song.js
define(function () { function Song() { } Song.prototype.persistFavoriteStatus = function (value) { // something complicated throw new Error("not yet implemented"); }; });
define(function () { function Player() { } Player.prototype.play = function (song) { this.currentlyPlayingSong = song; this.isPlaying = true; }; Player.prototype.pause = function () { this.isPlaying = false; }; Player.prototype.resume = function () { if (this.isPlaying) { throw new Error("song is already playing"); } this.isPlaying = true; }; Player.prototype.makeFavorite = function () { this.currentlyPlayingSong.persistFavoriteStatus(true); }; });
baseUrl در پیکر بندی requireJs به مسیر فایلهای پروژه که در پروژه اصلی MVC قرار دارد اشاره میکند. paths برای تعیین مسیر فایلهای تست که در پوشه spec در پروژه تست قرار دارد اشاره میکند. اگر دقت کرده باشید به دلیل اینگه تگهای script مربوط به لود فایلهای SpecHelper.js و PlayerSpec.js به صورت comment در آمده اند در نتیجه این فایلها لود نخواهند شد و خروجی مورد نظر مشاهده نمیشود. در این جا باید از مکانیزم AMD موجود در RequireJs استفاده نماییم و فایلهای مربوطه را لود کنیم. برای این کار نیاز به اضافه کردن دستور require در ابتدای تگ script به صورت زیر در این فایل است. در نتیجه فایلهای PlayerSpec و SpecHelper نیز توسط RequireJs لود خواهند شد.
نیاز به یک تغییر کوچک دیگر نیز وجود دارد. فایل PlayerSpec را باز نمایید و وابستگی فایلهای آن را تعیین نمایید. از آن جا که این فایل برای تست فایلهای Player , Song ایجاد شده است در نتیجه باید از define برای تعیین این وابستگیها استفاده نماییم.
یادآوری:
»دستور describe در فایل بالا برای تعریف تابع تست است. همان طور که میبینید بک نام به آن داده میشود به همراه بدنه تابع تست.
»دستور beforeEach برای آماده سازی مواردی است که قصد داریم در تست مورد استفاده قرار گیرند. همانند متدهای Setup در UnitTest.
» دستور expect نیز معادل Assert در UnitTest است و برای بررسی صحت عملکرد تست نوشته میشود.
اگر
فایل SpecRunner.html را دوباره در مرورگر خود باز نمایید تصویر زیر را
مشاهده خواهید کرد که به عنوان موفقیت آمیز بودن پیکر بندی پروژه و تستهای آن میباشد.