نظرات مطالب
ASP.NET MVC #18
سلام آقای نصیری
با توجه به این نوع پیاده سازی[لایه دسترسی به داده‌ها توسط service layer] (+ ) اگر خواسته باشیم نقش‌های یک کاربر را بدست بیاوریم، باید از لایه‌ی سرویس استفاده کنیم؟ یعنی شبیه به تصویر اول در این کامنت (+ ) لازم است که متغیر هایی را از نوع اینترفیس‌های لایه سرویس تعریف و بعد استفاده کنیم؟
چون در صورت استفاده از لایه سرویس ،مشکلاتی در کوئری گرفتنم به وجود میومد. یا بهتره بگم طرز استفاده از اونها رو نمیدونم.
آیا این کد قابل قبوله؟ 
public class CustomRoleProvider : System.Web.Security.RoleProvider
    {
        public override bool IsUserInRole(string username, string roleName)
        {
            //if (username.ToLowerInvariant() == "ali" && roleName.ToLowerInvariant() == "User")
            //    return true;
            // blabla ...  
            return true;
        }

        public override string[] GetRolesForUser(string username)
        {
            using (var context = new PublishingContext())
            {
                var user = context.Users.Where(x => x.Username == username).FirstOrDefault();

                var roles = from ur in user.Roles
                            from r in context.Roles
                            where ur.Id == r.Id
                            select r.Role; //نام نقش
                if (roles != null)
                {
                    return roles.ToArray();
                }
            }
            
            return new string[] {};
        }
}
مطالب
نحوه‌ی نگاشت فیلدهای فرمول در Fluent NHibernate

اگر با SQL Server کار کرده باشید حتما با مفهوم و امکان Computed columns (فیلدهای محاسبه شده) آن آشنایی دارید. چقدر خوب می‌شد اگر این امکان برای سایر بانک‌های اطلاعاتی که از تعریف فیلدهای محاسبه شده پشتیبانی نمی‌کنند، نیز مهیا می‌شد. زیرا یکی از اهداف مهم استفاده‌ی صحیح از ORMs ، مستقل شدن برنامه از نوع بانک اطلاعاتی است. برای مثال امروز می‌خواهیم با MySQL‌ کار کنیم، ماه بعد شاید بخواهیم یک نسخه‌ی سبک‌تر مخصوص کار با SQLite را ارائه دهیم. آیا باید قسمت دسترسی به داده برنامه را از نو بازنویسی کرد؟ اینکار در NHibernate فقط با تغییر نحوه‌ی اتصال به بانک اطلاعاتی میسر است و نه بازنویسی کل برنامه (و صد البته شرط مهم و اصلی آن هم این است که از امکانات ذاتی خود NHibernate استفاده کرده باشید. برای مثال وسوسه‌ی استفاده از رویه‌های ذخیره شده را فراموش کرده و به عبارتی ORM مورد استفاده را به امکانات ویژه‌ی یک بانک اطلاعاتی گره نزده باشید).
خوشبختانه در NHibernate امکان تعریف فیلدهای محاسباتی با کمک تعریف نگاشت خواص به صورت فرمول مهیا است. برای توضیحات بیشتر لطفا به مثال ذیل دقت بفرمائید:
در ابتدا کلاس کاربر تعریف می‌شود:

using System;
using NHibernate.Validator.Constraints;

namespace FormulaTests.Domain
{
public class User
{
public virtual int Id { get; set; }

[NotNull]
public virtual DateTime JoinDate { set; get; }

[NotNullNotEmpty]
[Length(450)]
public virtual string FirstName { get; set; }

[NotNullNotEmpty]
[Length(450)]
public virtual string LastName { get; set; }

[Length(900)]
public virtual string FullName { get; private set; } //از طریق تعریف فرمول مقدار دهی می‌گردد

public virtual int DayOfWeek { get; private set; }//از طریق تعریف فرمول مقدار دهی می‌گردد
}
}
در این کلاس دو خاصیت FullName و DayOfWeek به صورت فقط خواندنی به کمک private set ذکر شده، تعریف گردیده‌اند. قصد داریم روی این دو خاصیت فرمول تعریف کنیم:

using FluentNHibernate.Automapping;
using FluentNHibernate.Automapping.Alterations;

namespace FormulaTests.Domain
{
public class UserCustomMappings : IAutoMappingOverride<User>
{
public void Override(AutoMapping<User> mapping)
{
mapping.Id(u => u.Id).GeneratedBy.Identity(); //ضروری است
mapping.Map(x => x.DayOfWeek).Formula("DATEPART(dw, JoinDate) - 1");
mapping.Map(x => x.FullName).Formula("FirstName + ' ' + LastName");
}
}
}
نحوه‌ی انتساب فرمول‌های مبتنی بر SQL را در نگاشت فوق ملاحظه می‌نمائید. برای مثال FullName از جمع دو فیلد نام و نام خانوادگی حاصل خواهد شد و DayOfWeek از طریق فرمول SQL دیگری که ملاحظه می‌نمائید (یا هر فرمول SQL دلخواه دیگری که صلاح می‌دانید).
اکنون اگر Fluent NHibernate را وادار به تولید اسکریپت متناظر با این دو کلاس کنیم حاصل به صورت زیر خواهد بود:
    create table Users (
UserId INT IDENTITY NOT NULL,
JoinDate DATETIME not null,
FirstName NVARCHAR(450) not null,
LastName NVARCHAR(450) not null,
primary key (UserId)
)
همانطور که ملاحظه می‌کنید در اینجا خبری از دو فیلد محاسباتی تعریف شده نیست. این فیلدها در تعاریف نگاشت‌ها به صورت خودکار ظاهر می‌شوند:
<hibernate-mapping xmlns="urn:nhibernate-mapping-2.2"
default-access="property" auto-import="true" default-cascade="none" default-lazy="true">
<class xmlns="urn:nhibernate-mapping-2.2" mutable="true"
name="FormulaTests.Domain.User, FormulaTests, Version=1.0.0.0, Culture=neutral, PublicKeyToken=null" table="Users">
<id name="Id" type="System.Int32" unsaved-value="0">
<column name="UserId" />
<generator class="identity" />
</id>
<property name="DayOfWeek" formula="DATEPART(dw, JoinDate) - 1" type="System.Int32" />
<property name="FullName" formula="FirstName + ' ' + LastName" type="System.String" />
<property name="JoinDate" type="System.DateTime">
<column name="JoinDate" />
</property>
<property name="FirstName" type="System.String">
<column name="FirstName" />
</property>
<property name="LastName" type="System.String">
<column name="LastName" />
</property>
</class>
</hibernate-mapping>
اکنون اگر کوئری زیر را در برنامه اجرا نمائیم:
var list = session.Query<User>.ToList();
foreach (var item in list)
{
Console.WriteLine("{0}:{1}", item.FullName, item.DayOfWeek);
}
به صورت خودکار به SQL ذیل ترجمه خواهد شد و اکنون نحوه‌ی بکارگیری فیلدهای فرمول، بهتر مشخص می‌گردد:
select
user0_.UserId as UserId0_,
user0_.JoinDate as JoinDate0_,
user0_.FirstName as FirstName0_,
user0_.LastName as LastName0_,
DATEPART(user0_.dw, user0_.JoinDate) - 1 as formula0_, --- همان فرمول تعریف شده است
user0_.FirstName + ' ' + user0_.LastName as formula1_ ---از طریق فرمول تعریف شده حاصل گردیده است
from
Users user0_

مطالب
پشتیبانی آنلاین سایت با SignalR ،ASP.NET MVC و AngularJS
  پشتیبانی آنلاین سایت، روشی مناسب برای افزایش سطح تماس مشتریان با فروشندگان، برای جلوگیری از اتلاف وقت در برقراری تماس میباشد.
قصد داریم در این بخش پشتیبانی آنلاین سایت را با استفاده از AngularJs /Asp.Net Mvc / Signalr تهیه کنیم.
امکانات این برنامه:
* امکان مکالمه متنی به همراه ارسال شکلک
* امکان انتقال مکالمه
* مشاهده آرشیو گفتگوها
* امکان ارسال فایل (بزودی)
* امکان ذخیره گفتگو و ارسال گفتگو به ایمیل  (بزودی)
* امکان ارسال تیکت در صورت آفلاین بودن کارشناسان (بزودی) 
* رعایت مسائل امنیتی(بزودی)

مراحل نحوه اجرای برنامه:
1-  باز کردن دو tab، یکی برای کارشناس یکی  برای مشتری .
2-  تعدادی کارشناس تعریف شده است که با کلیک بر روی هر کدام وارد پنل کارشناس خواهیم شد.
3- شروع مکالمه توسط مشتری با کلیک بر روی chatbox پایین صفحه (سمت راست پایین).
4- شروع کردن مکالمه توسط کارشناس. 
5- ادامه،خاتمه یا انتقال مکالمه توسط کارشناس.

نصب کتابخانه‌های زیر:
//client
Install-Package angularjs 
Install-Package angular-strap 
Install-Package Microsoft.AspNet.SignalR.JS 
install-package AngularJs.SignalR.Hub 
Install-Package jQuery.TimeAgo
Install-Package FontAwesome
Install-Package toastr
Install-Package Twitter.Bootstrap.RTL 
bower install angular-smilies  

//server
Install-Package Newtonsoft.Json
Install-Package Microsoft.AspNet.SignalR 
Install-Package EntityFramework

گام‌های برنامه:
1-ایجاد جداول 
جدول Message: هر پیام دارای فرستنده و گیرنده‌ای، به همراه زمان ارسال میباشد.
جدول Session: شامل لیستی از پیام‌ها به همراه ارجاعی به خود (استفاده هنگام انتقال مکالمه )
 public partial class Message
    {
        public int Id { get; set; }
        public string Sender { get; set; }
        public string Receiver { get; set; }
        public string Body { get; set; }
        public DateTimeOffset? CreationTime { get; set; }
        public int? SessionId { get; set; }
        public virtual Session Session { get; set; }
    }
    public partial class Session
    {
        public Session()
        {
           Messages = new List<Message>();
           Sessions = new List<Session>();
        }
        public int Id { get; set; }
        public string AgentName { get; set; }
        public string CustomerName { get; set; }
        public DateTime CreatedDateTime { get; set; }
        public int? ParentId { get; set; }
        public virtual Session Parent { get; set; }
        public virtual ICollection<Message> Messages { get; set; }
        public virtual ICollection<Session> Sessions { get; set; }
    }

2- ایجاد ویو مدلهای زیر
    public class UserInformation
    {
        public string ConnectionId { get; set; }
        public bool IsOnline { get; set; }
        public string UserName { get; set; }
    }
    public class ChatSessionVm
    {
        public string Key { get; set; }
        public List<string> Value { get; set; }
    }
    public class AgentViewModel
    {
        public int Id { get; set; }
        public string CustomerName { get; set; }
        public int Lenght { get; set; }
        public DateTimeOffset? Date { get; set; }
    }

3- ایجاد Hub در سرور
 [HubName("chatHub")]
    public class ChatHub : Microsoft.AspNet.SignalR.Hub
    {
    }

4- فراخوانی chathub توسط کلاینت: برای آشنایی با سرویس  hub کلیک نمایید.

listeners متدهای سمت کلاینت
methods آرایه ای از متدهای سمت سرور

 $scope.myHub = new hub("chatHub", {
  listeners: {},
  methods: []
})
در صورت موفقیت آمیز بودن اتصال به هاب، متد init سمت سرور فراخوانی میشود و وضعیت آنلاین بودن کارشناسان برای کلاینت مشخص میشود.
 $scope.myHub.promise.done(function () {
     $scope.myHub.init();
     $scope.myHub.promise.done(function () { });
  });
 public void Init()
        {
            _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>());
            _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>());
            Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }

5-وضعیت کارشناسان :
در صورت آنلاین بودن کارشناسان: ارسال اولین پیام و تقاضای شروع مکالمه
در صورت آفلاین بودن کارشناسان: ارسال تیکت(بزودی)
اگر برای اولین بار  پیامی را ارسال میکنید، برای شما session ایی ایجاد نشده است. در اینصورت مکان تقاضای مشتری از سایت http://ipinfo.io دریافت شده و به سرور ارسال می‌گردد و  متد logvist سرور، تقاضای شروع مکالمه مشتری را به اطلاع  تمام کارشناسان میرساند و وضعیت chatbox را تغییر میدهد.
اگر session برای مشتری تعریف شده باشد، مکالمه مشتری با کارشناس مربوطه انجام میگردد.
 $scope.requestChat = function (msg) {
                if (!defaultCustomerUserName) {
                    //گرفتن کاربر لاگین شده
                    //ما از آرایه تصادفی استفاده میکنیم
                    var nameDefaultArray = [
                        'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه'
                    ];
                    defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)];
                }
                var userName = defaultCustomerUserName;
                if (!$scope.chatId) {
                    $scope.chatId = sessionStorage.getItem(chatKey);
                    $http.get("http://ipinfo.io")
                      .success(function (response) {
                          $scope.myHub.logVisit(response.city, response.country, msg, userName);
                      }).error(function (e, status, headers, config) {
                          $scope.myHub.logVisit("Tehran", "Ir", msg, userName)
                      });
                    $scope.myHub.requestChat(msg);
                    $scope.chatTitle = $scope.options.waitingForOperator;
                    $scope.pendingRequestChat = true;
                } else {
                    $scope.myHub.clientSendMessage(msg, userName);
                };
                $scope.message = "";
            };

6-مشاهده تقاضای مکالمه کاربران  توسط کارشناسان
کارشناسان در صورت تمایل، شروع به مکالمه با کاربر مینمایند و مکالمه آغاز میگردد.با شروع مکالمه توسط کارشناس، متد acceptRequestChat  سرور فراخوانی میشود.
 پیام‌های مناسب برای کارشناس مربوطه، برای مشتری و تمام کارشناسان (به تمام کارشناسان اطلاع داده می‌شود که مشتری با چه کارشناسی در حال مکالمه میباشد) ارسال میگردد و مقادیر مربوطه در دیتابیس ذخیره میگردد.
public void AcceptRequestChat(string customerConnectionId, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key));
            if (session == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = agent.Key,
                    Value = new List<string> { customerConnectionId }
                });
            }
            else
            {
                session.Value.Add(customerConnectionId);
            }
            Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName);
            Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName);
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId);
            }
       var session = _db.Sessions.Add(new Session
            {
                AgentName = agent.Key,
                CustomerName = userName,
                CreatedDateTime = DateTime.Now
            });
            _db.SaveChanges();

            var message = new Message
            {
                CreationTime = DateTime.Now,
                Sender = agent.Key,
                Receiver = userName,
                body=body,
                Session = session
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
7-خاتمه مکالمه توسط کارشناس یا مشتری امکان پذیر میباشد:
متد closeChat  سرور فراخوانی میگردد. پیام مناسبی به مشتری و تمام کارشناسان ارسال میگردد.
public void CloseChat(string id)
        {
            var findAgent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
            if (session == null) return;
            Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است");

            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id);
            }
            _chatSessions.Remove(session);
        }

8-انتقال مکالمه مشتری به کارشناسی دیگر
مکالمه از کارشناس فعلی گرفته شده و به کارشناس جدید داده می‌شود؛ به همراه ارسال پیام‌های مناسب به طرف‌های مربوطه
   public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId)
        {
            #region remove session of current agent
            var currentAgent = FindAgent(Context.ConnectionId);
            var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId));
            if (currentSession != null)
            {
                _chatSessions.Remove(currentSession);
            }
            #endregion

            #region add  session to new agent
            var newAgent = FindAgent(newAgentId);
            var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key));
            if (newSession == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = newAgent.Key,
                    Value = new List<string> { cumtomerId }
                });
            }
            else
            {
                newSession.Value.Add(cumtomerId);
            }
            #endregion

            Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key,
                "ادامه مکالمه به کارشناس  " + newAgent.Key + "مقابل  منتقل شد");
            Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key,
                "لطفا مکالمه را ادامه دهید.با تشکر");

            Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName,
                "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key);

            var session = _db.Sessions.FirstOrDefault
                (item => item.AgentName.Equals(currentAgent.Value.UserName)
                 && item.CustomerName.Equals(customerName));
            if (session != null)
            {
                var sessionId = session.Id;
                var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId));
                var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings);
                Clients.Client(newAgentId).visitorSwitchConversation
                    (Context.ConnectionId, customerName, result, clientSessionId);
            }
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = newAgent.Key,
                CustomerName = customerName,
                CreatedDateTime = DateTime.Now,
                Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key)
                      && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault()
            });
            _db.SaveChanges();
        }
از آنجاییکه اسم متدها کاملا گویا میباشد، به نظر نیازی به توضیح بیشتری ندارند.
فایل کامل  app.js 
var app = angular.module("app", ["SignalR", 'ngRoute', 'ngAnimate', 'ngSanitize', 'mgcrea.ngStrap', 'angular-smilies']); 

app.config(["$routeProvider", "$provide", "$httpProvider", "$locationProvider",
        function ($routeProvider, $provide, $httpProvider, $locationProvider) {
            $routeProvider.
               when('/', { templateUrl: 'app/views/home.html', controller: "HomeCtrl" }).
               when('/agent', { templateUrl: 'app/views/agent.html', controller: "ChatCtrl" })
                .otherwise({
                    redirectTo: "/"
                });;
        }]);
app.controller("HomeCtrl", ["$scope", function ($scope) {
    $scope.title = "home";
}])
app.controller("ChatCtrl", ["$scope", "Hub", "$location", "$http", "$rootScope",
    function ($scope, hub, $location, $http, $rootScope) {
        if (!$scope.myHub) {
            var chatKey = "angular-signalr";
            var defaultCustomerUserName = null;
            function getid(id) {
                var find = false;
                var position = null;
                angular.forEach($scope.chatConversation, function (index, i) {
                    if (index.id === id && !find) {
                        find = true;
                        position = i;
                        return;
                    }
                });
                return position;
            }
            function apply() {
                $scope.$apply();
            }
            $scope.boxheader = function () {
                var height = 0;
                $("#chat-box").slideToggle('slow', function () {
                    if ($("#chat-box-header").css("bottom") === "0px") {
                        height = $("#chat-box").height() + 20;
                    } else {
                        height = 0;
                    }
                    $("#chat-box-header").css("bottom", height);
                });
            };
            var init = function () {
                $scope.agent = {
                    id: "", name: "", isOnline: false
                };
                $rootScope.msg = "";
                $scope.alarmStatus = false;
                $scope.options = {
                    offlineTitle: "آفلاین",
                    onlineTitle: "آنلاین",
                    waitingForOperator: "لطفا منتظر بمانید تا به اپراتور وصل شوید",
                    emailSent: "ایمیل ارسال گردید",
                    emailFailed: "متاسفانه ایمیل ارسال نگردید",
                    logOut: "خروج",
                    setting: "تنظیمات",
                    conversion: "آرشیو",
                    edit: "ویرایش",
                    alarm: "قطع/وصل کردن صدا",
                    complete: "تکمیل",
                    pending: "منتظر ماندن",
                    reject: "عدم پذیرش",
                    lock: "آنلاین شدن",
                    unlock: "آفلاین شدن",
                    alarmOn: "روشن",
                    alarmOff: "خاموش",
                    upload: "آپلود"
                };
                $scope.chatConversation = [];
                $scope.chatSessions = [];
                $scope.customerVisit = [];

                $scope.agentClientMsgs = [];
                $scope.clientAgentMsg = [];
            }();
//تعریف هاب به همراه متدهای آن
            $scope.myHub = new hub("chatHub", {
                listeners: {
                    "clientChat": function (id, agentName) {
                        $scope.clientAgentMsg.push({ name: agentName, msg: "با سلام در خدمت میباشم" });
                        $scope.chatTitle = "کارشناس: " + agentName;
                        $scope.pendingRequestChat = false;
                        sessionStorage.setItem(chatKey, id);
                    }, "agentChat": function (id, firstComment, customerName) {
                        var date = new Date();
                        var position = getid(id);
                        if (position > 0) {
                            $scope.chatSessions[position].length = $scope.chatConversation[position].length + 1;
                            $scope.chatSessions[position].date = date.toISOString();
                            return;
                        }
                        else {
                            $scope.chatConversation.push({
                                id: id,
                                sessions: [{
                                    name: customerName,
                                    msg: firstComment,
                                    date: date
                                }],
                                agentName: $scope.agent.name,
                                customerName: customerName,
                                dateStartChat: date.getHours() + ":" + date.getMinutes(),
                            });
                            $scope.chatSessions.push({
                                id: id,
                                length: 1,
                                userName: customerName,
                                date: date.toISOString()
                            });
                        }
                        sessionStorage.setItem(chatKey, id);
                        apply();
                    }, 
//برروز رسانی لیست برای کارشناسان
"refreshChatWith": function (agentName, customerConnectionId) {
                        angular.forEach($scope.customerVisit, function (index, i) {
                            if (index.connectionId === customerConnectionId) {
                                $scope.customerVisit[i].chatWith = agentName;
                            }
                        });
                        apply();
                    },
//برروز رسانی لیست برای کارشناسان
 "refreshLeaveChat": function (agentName, customerConnectionId) {
                        angular.forEach($scope.customerVisit, function (index, i) {
                            if (index.connectionId === customerConnectionId) {
                                $scope.customerVisit[i].chatWith =agentName + "---" + "  به مکالمه خاتمه داده است ";
                            }
                        });
                        apply();
                    }
//وضعیت آنلاین بودن کارشناسان
                    , "onlineStatus": function (state) {
                        if (state) {
                            $scope.chatTitle = $scope.options.onlineTitle;
                            $scope.hasOnline = true;
                            $scope.hasOffline = false;
                        } else {
                            $scope.chatTitle = $scope.options.offlineTitle;
                            $scope.hasOffline = true;
                            $scope.hasOnline = false;
                        }
                        $scope.$apply()
                    }, "loginResult": function (status, id, name) {
                        if (status) {
                            $scope.agent.id = id;
                            $scope.agent.name = name;
                            $scope.agent.isOnline = true;
                            $scope.userIsLogin = $scope.agent;
                            $scope.$apply(function () {
                                $location.path("/agent");
                            });
                        } else {
                            $scope.agent = null;
                            toastr.error("کارشناسی با این مشخصات وجود ندارد");
                            return;
                        }
                    }, "newVisit": function (userName, city, country, chatWith, connectionId, firstComment) {
                        var exist = false;
                        angular.forEach($scope.customerVisit, function (index) {
                            if (index.connectionId === connectionId) {
                                exist = true;
                                return;
                            }
                        });
                        if (!exist) {
                            var date = new Date();
                            $scope.customerVisit.unshift({
                                userName: userName,
                                date: date,
                                city: city,
                                country: country,
                                chatWith: chatWith,
                                connectionId: connectionId,
                                firstComment: firstComment
                            });
                            if ($scope.alarmStatus) {
                                var snd = new Audio("/App/assets/sounds/Sedna.ogg");
                                snd.play();
                            }
                            toastr.success("تقاضای جدید دریافت گردید");
                            apply();
                        }
                    }, "addMessage": function (id, from, value) {
                        if ($scope.alarmStatus) {
                            var snd = new Audio("/App/assets/sounds/newmsg.mp3");
                            snd.play();
                        }
                        $scope.agentUserMsgs = [];
                        var date = new Date();
                        var position = getid(id);
                        if ($scope.chatConversation.length > 0 && position != null) {
                            $scope.chatConversation[position].sessions.push({ name: from, msg: value, date: date });
                        }
                        var item = $scope.chatConversation[position];
                        if (item) {
                            angular.forEach(item.sessions, function (index) {
                                $scope.agentUserMsgs.push({ name: index.name, msg: index.msg, date: date });
                            });
                            $scope.chatSessions[position].length = $scope.chatSessions[position].length + 1;
                        }
                        apply();
                    }, "clientAddMessage": function (id, from) {
                        if ($scope.alarmStatus) {
                            var snd = new Audio("/App/assets/sounds/newmsg.mp3");
                            snd.play();
                        }
                        $scope.clientAgentMsg.push({ name: id, msg: from });
                        apply();
                    }, "visitorSwitchConversation": function (id, customerName, sessions, sessionId) {
                        sessions = JSON.parse(sessions);
                        var date = new Date();
                        var sessionList = [];
                        angular.forEach(sessions, function (index) {
                            sessionList.push({
                                name: index.sender,
                                msg: index.body,
                                date: index.creationTime
                            });
                        });
                        $scope.chatConversation.push({
                            id: sessionId,
                            sessions: sessionList,
                            customerName: customerName,
                            dateStartChat: date.getHours() + ":" + date.getMinutes(),
                            agentName: $scope.agent.name
                        });
                        $scope.chatSessions.push({
                            id: sessionId,
                            length: sessions.length,
                            date: date
                        });
                    }, "receiveTicket": function (items) {
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.ticketList = [];
                            $scope.ticketList.push(index);
                        });
                    }, 
//آرشیو گفته گوهای کارشناس
"receiveHistory": function (items) {
                        $scope.agentHistory = [];
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.agentHistory.push(index);
                        });
                        apply();
                    }, 
//جزییات آرشیو گفتگوها
"detailsHistory": function (items) {
                        $scope.historyMsg = [];
                        angular.forEach(JSON.parse(items), function (index) {
                            $scope.historyMsg.push({ name: index.sender, msg: index.body, date: index.creationTime });
                        });
                        $("#detailsAgentHistory").modal();
                        apply();
                    }, 
//لیست کارشناسان آنلاین
"agentList": function (items) {
                        $scope.agentList = [];
                        angular.forEach(items, function (index) {
                            if ($scope.agent.name != index.Key) {
                                $scope.agentList.push({ name: index.Key, id: index.Value.ConnectionId });
                            }
                        });
                        $("#agentList").modal();
                        apply();
                    }
                },
                methods: ["agentConnect", "sendTicket", "requestChat", "clientSendMessage", "closeChat", "init", "logVisit",
                    "agentChangeStatus", "engageVisitor", "agentSendMessage", "transfer", "leaveChat", "acceptRequestChat",
                    "leaveChat", "detailsSessoinMessage", "showAgentList", "getAgentHistoryChat"
                ], errorHandler: function (error) {
                    console.error(error);
                }
            });
            $scope.myHub.promise.done(function () {
                $scope.myHub.init();
                $scope.myHub.promise.done(function () { });
            });

            $scope.LeaveChat = function () {
                $scope.myHub.LeaveChat();
            };
            $scope.loginAgent = function (userName) {
                // username :security user username from agent role
                if (userName == "hossein" || userName == "ali") {
                    $scope.myHub.promise.done(function () {
                        $scope.myHub.agentConnect(userName).then(function (result) {
                            $scope.agent.name = userName;
                            $scope.agent.isOnline = true;
                        });
                    });
                }
            };
            $scope.requestChat = function (msg) {
                if (!defaultCustomerUserName) {
                    //گرفتن کاربر لاگین شده
                    //ما از آرایه تصادفی استفاده میکنیم
                    var nameDefaultArray = [
                        'حسین', 'حسن', 'علی', 'عباس', 'زهرا', 'سمیه'
                    ];
                    defaultCustomerUserName=nameDefaultArray[Math.floor(Math.random() * nameDefaultArray.length)];
                }
                var userName = defaultCustomerUserName;
                if (!$scope.chatId) {
                    $scope.chatId = sessionStorage.getItem(chatKey);
                    $http.get("http://ipinfo.io")
                      .success(function (response) {
                          $scope.myHub.logVisit(response.city, response.country, msg, userName);
                      }).error(function (e, status, headers, config) {
                          $scope.myHub.logVisit("Tehran", "Ir", msg, userName)
                      });
                    $scope.myHub.requestChat(msg);
                    $scope.chatTitle = $scope.options.waitingForOperator;
                    $scope.pendingRequestChat = true;
                } else {
                    $scope.myHub.clientSendMessage(msg, userName);
                };
                $scope.message = "";
            };
            $scope.acceptRequestChat = function (customerConnectionId, firstComment, customerName) {
                $scope.myHub.acceptRequestChat(customerConnectionId, firstComment, customerName);
            };
            $scope.changeAgentStatus = function () {
                $scope.agent.isOnline = !$scope.agent.isOnline;
                $scope.myHub.agentChangeStatus($scope.agent.isOnline);
            };
            $scope.detailsChat = function (chatId, userName) {
                $scope.agentUserMsgs = [];
                angular.forEach($scope.chatConversation, function (index) {
                    if (index.id === chatId) {
                        $scope.dateStartChat = index.dateStartChat;
                        angular.forEach(index.sessions, function (value) {
                            $scope.agentUserMsgs.push({ name: value.name, msg: value.msg, date: value.date });
                        });
                    };
                });
                $scope.agentChatWithUser = chatId;
                $scope.customerName = userName;
                $("#agentUserChat").modal();
            };
            $scope.ticket = {
                submit: function () {
                    var name = $scope.ticket.name;
                    var email = $scope.ticket.email;
                    var comment = $scope.ticket.comment;
                    $scope.myHub.sendTicket(name, email, comment);
                }
            };
            $scope.showHistory = function () {
                $scope.myHub.getAgentHistoryChat($scope.agent.name);
            };
            $scope.detailsChatHistory = function (id) {
                $scope.myHub.detailsSessoinMessage(id, $scope.agent.id);
            };
            $scope.agentMsgToUser = function (msg) {
                var chatId = $scope.agentChatWithUser;
                var customerName = $scope.customerName;
                if (!customerName) {
                    angular.forEach($scope.customerVisit, function (index) {
                        if (index.connectionId == chatId) {
                            customerName = index.userName;
                        }
                    });
                }
                if (chatId !== "" && msg !== "") {
                    $scope.myHub.agentSendMessage(chatId, msg, customerName);
                }
                //not bind to scope.msg! not correctly work
                $scope.msg = "";
                $("#post-msg").val("");
            };
            $scope.closeChat = function (chatId) {
                var item = $scope.chatConversation[getid(chatId)];
                $scope.myHub.closeChat(chatId);
            };
            $scope.engageVisitor = function (newAgentId) {
                var customerId = $scope.customerId;
                var customerName = $scope.customerName;
                var clientSessionId = $scope.clientSessionId;
                $scope.myHub.engageVisitor(newAgentId, customerId, customerName, clientSessionId);
                $("[data-dismiss=modal]").trigger({ type: "click" });
            };
            $scope.selectVisitor = function (customerId, customerName, clientSessionId) {
                $scope.customerId = customerId;
                $scope.customerName = customerName;
                $scope.clientSessionId = clientSessionId;
                $scope.myHub.showAgentList();
            };
            $scope.setClass = function (item) {
                if (item === "من")
                    return "question";
                else
                    return "response";
            };
            $scope.setdirectionClass = function (item) {
                if (item === $scope.agent.name)
                    return { "float": "left" };
                else
                    return { "float": "right" };
            };
            $scope.setArrowClass = function (item) {
                if (item === $scope.agent.name)
                    return "left-arrow";
                else
                    return "right-arrow";
            };
            $scope.setAlarm = function () {
            $scope.alarmStatus = !$scope.alarmStatus;
            };
        }
    }]);
app.directive("showtab", function () {
    return {
        link: function (scope, element, attrs) {
            element.click(function (e) {
                e.preventDefault();
                $(element).addClass("active");
                $(element).tab("show");
            });
        }
    };
});
//زمان ارسال پیام
app.directive("timeAgo", function ($q) {
    return {
        restrict: "AE",
        scope: false,
        link: function (scope, element, attrs) {
            jQuery.timeago.settings.strings =
            {
                prefixAgo: null,
                prefixFromNow: null,
                suffixAgo: "پیش",
                suffixFromNow: "از حالا",
                seconds: "کمتر از یک دقیقه",
                minute: "در حدود یک دقیقه",
                minutes: "%d دقیقه",
                hour: "حدود یگ ساعت",
                hours: "حدود %d ساعت ",
                day: "یک روز",
                days: "%d روز",
                month: "حدود یک ماه",
                months: "%d ماه",
                year: "حدود یک سال",
                years: "%d سال",
                wordSeparator: " ",
                numbers: []
            }
            var parsedDate = $q.defer();
            parsedDate.promise.then(function () {
                jQuery(element).timeago();
            });
            attrs.$observe("title", function (newValue) {
                parsedDate.resolve(newValue);
            });
        }
    };
});
فایل chathub.cs
برای آشنایی بیشتر مقاله نگاهی به SignalR Hubs   مفید خواهد بود.
   [HubName("chatHub")]
    public class ChatHub : Microsoft.AspNet.SignalR.Hub
    {
        private readonly ApplicationDbContext _db = new ApplicationDbContext();
        private static ConcurrentDictionary<string, UserInformation> _agents;
        private static List<ChatSessionVm> _chatSessions;
        private readonly JsonSerializerSettings _settings = new JsonSerializerSettings
        {
            ContractResolver = new CamelCasePropertyNamesContractResolver(),
            ReferenceLoopHandling = ReferenceLoopHandling.Ignore
        };
        public void Init()
        {
            _chatSessions = _chatSessions ?? (_chatSessions = new List<ChatSessionVm>());
            _agents = _agents ?? (_agents = new ConcurrentDictionary<string, UserInformation>());
            Clients.Caller.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }
        public void AgentConnect(string userName)
        {
            //ما برای ساده کردن مقایسه ساده ای انجام دادیم فقط کاربر حسین یا علی میتواند کارشناس باشد
            if (userName == "hossein" || userName == "ali")
            {
                var agent = new UserInformation();
                if (_agents.Any(item => item.Key == userName))
                {
                    agent = _agents[userName];
                    agent.ConnectionId = Context.ConnectionId;
                }
                else
                {
                    agent.ConnectionId = Context.ConnectionId;
                    agent.UserName = userName;
                    agent.IsOnline = true;
                    _agents.TryAdd(userName, agent);

                }
                Clients.Caller.loginResult(true, agent.ConnectionId, agent.UserName);
                Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
            }
            else
            {
                Clients.Caller.loginResult(false, null, null);
            }
        }
        public void AgentChangeStatus(bool status)
        {
            var agent = _agents.FirstOrDefault(x => x.Value.ConnectionId == Context.ConnectionId).Value;
            if (agent == null) return;
            agent.IsOnline = status;
            Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
        }
        public void LogVisit(string city, string country, string firstComment, string userName)
        {
            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).newVisit(userName, city, country, null, Context.ConnectionId, firstComment);
            }
        }
        public void AcceptRequestChat(string customerConnectionId, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Key.Equals(agent.Key));
            if (session == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = agent.Key,
                    Value = new List<string> { customerConnectionId }
                });
            }
            else
            {
                session.Value.Add(customerConnectionId);
            }
            Clients.Client(Context.ConnectionId).agentChat(customerConnectionId, body, userName);
            Clients.Client(customerConnectionId).clientChat(customerConnectionId, agent.Value.UserName);
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(agent.Value.UserName, customerConnectionId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = agent.Key,
                CustomerName = userName,
                CreatedDateTime = DateTime.Now
            });
            _db.SaveChanges();

            var message = new Message
            {
                CreationTime = DateTime.Now,
                Sender = agent.Key,
                Receiver = userName,
                Body = body,
                //ConnectionId = _agents.FirstOrDefault(item => item.Value.UserName == userName).Key,
                Session = _db.Sessions.OrderByDescending(item => item.Id)
                .FirstOrDefault(item => item.AgentName.Equals(agent.Key) && item.CustomerName.Equals(userName))
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void GetAgentHistoryChat(string userName)
        {
            var dic = new Dictionary<int, int>();
            var lenght = 0;
            var chats = _db.Sessions.OrderBy(item => item.Id).Include(item => item.Parent)
                .Where(item => item.AgentName.Equals(userName)).ToList();

            foreach (var session in chats)
            {
                Result(session, ref lenght);
                dic.Add(session.Id, lenght);
                lenght = 0;
            }
            if (!chats.Any()) return;

            var historyResult = chats.Select(item => new AgentViewModel
            {
                Id = item.Id,
                CustomerName = item.CustomerName,
                Date = item.CreatedDateTime,
                Lenght = dic.Any(di => di.Key.Equals(item.Id)) ? dic.FirstOrDefault(di => di.Key.Equals(item.Id)).Value : 0,
            }).OrderByDescending(item => item.Id).ToList();
            Clients.Caller.receiveHistory(JsonConvert.SerializeObject(historyResult, new Formatting(), _settings));
        }
        public void DetailsSessoinMessage(int sessionId, string agentId)
        {
            var session = _db.Sessions.FirstOrDefault(item => item.Id.Equals(sessionId));
            if (session == null) return;
            var list = new List<Message>();
            GetAllMessages(session, list);
            var result = JsonConvert.SerializeObject(list.OrderBy(item => item.Id), new Formatting(), _settings);
            Clients.Client(Context.ConnectionId).detailsHistory(result);
        }
        public void ClientSendMessage(string body, string userName)
        {
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(Context.ConnectionId));
            if (session == null || session.Key == null) return;
            var agentId = _agents.FirstOrDefault(item => item.Key.Equals(session.Key)).Value.ConnectionId;
            Clients.Caller.clientAddMessage("من", body);
            Clients.Client(agentId).addMessage(Context.ConnectionId, userName, body);
            var message = new Message
            {
                Sender = FindAgent(agentId).Key,
                Receiver = userName,
                Body = body,
                CreationTime = DateTime.Now,
                Session = FindSession(userName, FindAgent(agentId).Key)
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void AgentSendMessage(string id, string body, string userName)
        {
            var agent = FindAgent(Context.ConnectionId);
            Clients.Caller.addMessage(id, agent.Value.UserName, body);
            Clients.Client(id).clientAddMessage(agent.Value.UserName, body);
            var message = new Message
            {
                Sender = agent.Key,
                Receiver = userName,
                Body = body,
                Session = FindSession(agent.Key, userName),
                CreationTime = DateTime.Now
            };
            _db.Messages.Add(message);
            _db.SaveChanges();
        }
        public void CloseChat(string id)
        {
            var findAgent = FindAgent(Context.ConnectionId);
            var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
            if (session == null) return;
            Clients.Client(id).clientAddMessage(findAgent.Key, "مکالمه شما با کارشناس مربوطه به اتمام رسیده است");

            foreach (var agent in _agents)
            {
                Clients.Client(agent.Value.ConnectionId).refreshLeaveChat(agent.Value.UserName, id);
            }
            _chatSessions.Remove(session);
        }
        public void RequestChat(string message)
        {
            Clients.Caller.clientAddMessage("من", message);
        }
        public void EngageVisitor(string newAgentId, string cumtomerId, string customerName,string clientSessionId)
        {
            #region remove session of current agent
            var currentAgent = FindAgent(Context.ConnectionId);
            var currentSession = _chatSessions.FirstOrDefault(item => item.Value.Contains(cumtomerId));
            if (currentSession != null)
            {
                _chatSessions.Remove(currentSession);
            }
            #endregion

            #region add  session to new agent
            var newAgent = FindAgent(newAgentId);
            var newSession = _chatSessions.FirstOrDefault(item => item.Key.Equals(newAgent.Key));
            if (newSession == null)
            {
                _chatSessions.Add(new ChatSessionVm
                {
                    Key = newAgent.Key,
                    Value = new List<string> { cumtomerId }
                });
            }
            else
            {
                newSession.Value.Add(cumtomerId);
            }
            #endregion

            Clients.Client(currentAgent.Value.ConnectionId).addMessage(cumtomerId, newAgent.Key,
                "ادامه مکالمه به کارشناس  " + newAgent.Key + "مقابل  منتقل شد");
            Clients.Client(newAgentId).addMessage(cumtomerId, currentAgent.Key,
                "لطفا مکالمه را ادامه دهید.با تشکر");

            Clients.Client(cumtomerId).clientAddMessage(newAgent.Value.UserName,
                "مکالمه شما با کارشناس زیر برقرار گردید" + newAgent.Key);

            var session = _db.Sessions.FirstOrDefault
                (item => item.AgentName.Equals(currentAgent.Value.UserName)
                 && item.CustomerName.Equals(customerName));
            if (session != null)
            {
                var sessionId = session.Id;
                var messages = _db.Messages.Where(item => item.Session.Id.Equals(sessionId));
                var result = JsonConvert.SerializeObject(messages, new Formatting(), _settings);
                Clients.Client(newAgentId).visitorSwitchConversation
                    (Context.ConnectionId, customerName, result, clientSessionId);
            }
            foreach (var item in _agents.Where(item => item.Value.IsOnline))
            {
                Clients.Client(item.Value.ConnectionId).refreshChatWith(newAgent.Value.UserName, cumtomerId);
            }
            _db.Sessions.Add(new Session
            {
                AgentName = newAgent.Key,
                CustomerName = customerName,
                CreatedDateTime = DateTime.Now,
                Parent = _db.Sessions.Where(item => item.AgentName.Equals(currentAgent.Key)
                      && item.CustomerName.Equals(customerName)).OrderByDescending(item => item.Id).FirstOrDefault()
            });
            _db.SaveChanges();
        }
        public void ShowAgentList()
        {
            Clients.Caller.agentList(_agents.ToList());
        }
        public override Task OnDisconnected(bool stopCalled)
        {
            var id = Context.ConnectionId;
            var isAgent = _agents != null && _agents.Any(item => item.Value.ConnectionId.Equals(id));
            if (isAgent)
            {
                UserInformation agent;
                var currentAgentConnectionId = FindAgent(id).Key;
                if (currentAgentConnectionId == null)
                    return base.OnDisconnected(stopCalled);
                if (_chatSessions.Any())
                {
                    var sessions = _chatSessions.FirstOrDefault(item => item.Key.Equals(currentAgentConnectionId));
                    //اطلاع دادن به تمام کاربرانی که در حال مکالمه با کارشناس هستند
                    if (sessions != null)
                    {
                        var result = sessions.Value.ToList();
                        for (var i = 0; i < result.Count(); i++)
                        {
                            var localId = result[i];
                            Clients.Client(localId).clientAddMessage(currentAgentConnectionId, "ارتباط شما با مشاور مورد نظر قطع شده است");
                        }
                    }
                }
                _agents.TryRemove(currentAgentConnectionId, out agent);
                Clients.All.onlineStatus(_agents.Count(x => x.Value.IsOnline) > 0);
                Clients.Client(id).loginResult(false, null, null);
            }
            else
            {
                if (_chatSessions == null ||
                    !_chatSessions.Any(item => item.Value.Contains(id)
                        && _agents == null))
                    return base.OnDisconnected(stopCalled);

                var session = _chatSessions.FirstOrDefault(item => item.Value.Contains(id));
                if (session == null)
                    return base.OnDisconnected(stopCalled);

                var agentName = session.Key;
                var agent = _agents.FirstOrDefault(item => item.Key.Equals(agentName));
                if (agent.Key != null)
                {
                    Clients.Client(agent.Value.ConnectionId).addMessage(id, "کاربر", "اتصال با کاربر قطع شده است");
                }
            }
            return base.OnDisconnected(stopCalled);
        }


        private KeyValuePair<string, UserInformation> FindAgent(string connectionId)
        {
            return _agents.FirstOrDefault(item => item.Value.ConnectionId.Equals(connectionId));
        }
        private Session FindSession(string key, string userName)
        {
            return _db.Sessions.Where(item => item.AgentName.Equals(key) && item.CustomerName.Equals(userName))
                .OrderByDescending(item => item.Id).FirstOrDefault();
        }
        private static void Result(Session parent, ref int lenght)
        {
            while (true)
            {
                if (parent == null)
                    return;
                lenght += parent.Messages.Count();
                parent = parent.Parent;
            }
        }
        private static List<Message> GetAllMessages(Session node, List<Message> list)
        {
            if (node == null) return null;
            list.AddRange(node.Messages);
            if (node.Parent != null)
            {
                GetAllMessages(node.Parent, list);
            }
            return null;
        }
    }
فایل agent.html
<div>
    <div>
        <h2>
            خوش آمدید
            <span ng-bind="agent.name">
            </span>
            <a ng-click="changeAgentStatus()">
                <i ng-if="changeStatus==null"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.lock"></i>
                <i ng-if="changeStatus==true"
                   data-placement="bottom"
                   data-trigger="hover"
                   bs-tooltip="options.unlock"></i>
            </a>
        </h2>
        <div style="float: left">
            <a ng-click="setAlarm()">
                <i ng-show="alarmStatus"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.alarmOn"></i>

                <i ng-show="!alarmStatus"
                   data-placement="bottom"
                   data-trigger="hover "
                   bs-tooltip="options.alarmOff"></i>
            </a>
            <!--<a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.conversion" ng-click="showHistory()"><i></i></a>-->

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.edit"><i></i><span></span></a>

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.setting"><i></i></a>

            <a data-placement="bottom"
               data-trigger="hover "
               bs-tooltip="options.signOut" ng-click="LeaveChat()"><i></i><span></span></a>
        </div>
    </div>
    <div>
        <div>
            <div id="chat-content">
                <div>
                    <ul>
                        <li>
                            <a showtab href="#online-list">آنلاین</a>
                        </li>
                        <li>
                            <a ng-click="showHistory()" showtab href="#conversation">آرشیو گفتگوها</a>
                        </li>
                    </ul>
                    <div>
                        <div id="online-list">
                            <div>
                                <h2>
                                    <i></i><span></span>
                                    <span>نمایش آنلاین مراجعه ها</span>
                                </h2>
                            </div>
                            <div>
                                <div id="agent-chat">
                                    <div id="real-time-visits">
                                        <table id="current-visits">
                                            <thead>
                                                <tr>
                                                    <th>نام کاربر</th>
                                                    <th>زمان اولین تقاضا</th>
                                                    <th>منطقه</th>
                                                    <th>پاسخ</th>
                                                </tr>
                                            </thead>
                                            <tbody>
                                                <tr id="{{item.connectionId}}" ng-animate="animate" ng-repeat="item in customerVisit ">
                                                    <td ng-bind="item.userName"></td>
                                                    <td>
                                                        <span time-ago title="{{item.date}}"></span>
                                                    </td>
                                                    <td>
                                                        <span ng-bind="item.country"></span> /<span ng-bind="item.city"> </span>
                                                    </td>
                                                    <td>
                                                        <a style="cursor: pointer" ng-if="item.chatWith== null"
                                                           ng-click="acceptRequestChat(item.connectionId,item.firstComment,item.userName)">
                                                            شروع مکالمه
                                                        </a>
                                                        <span ng-if="item.chatWith ">
                                                            وضعیت:
                                                            <span>در حال مکالمه با</span>
                                                            <span ng-bind="item.chatWith"></span>
                                                            <a ng-show="item.chatWith==agent.name"
                                                              
                                                               ng-click="selectVisitor(item.connectionId,item.userName,item.connectionId)">
                                                                انتقال مکالمه
                                                            </a>
                                                        </span>
                                                        <ul ng-repeat="session in chatSessions track by $index" style="padding:0px;">
                                                            <li ng-if="session.id==item.connectionId" id="{{session.id}}">
                                                                <div>
                                                                    <p>
                                                                        تاریخ شروع مکالمه:
                                                                        <span time-ago title="{{session.date}}"></span>
                                                                    </p>
                                                                    <p>
                                                                        تعداد پیام ها:
                                                                        <span ng-bind="session.length"></span>
                                                                    </p>
                                                                </div>
                                                                <p>
                                                                    <a ng-click="detailsChat(session.id,session.userName)">جزییات </a>
                                                                    <a ng-click="closeChat(session.id)">
                                                                        خاتمه عملیات
                                                                    </a>
                                                                </p>
                                                            </li>
                                                        </ul>
                                                    </td>
                                                </tr>
                                            </tbody>
                                        </table>
                                    </div>
                                </div>
                            </div>
                        </div>
                        <div id="conversation">
                            <div>
                                <h2>
                                    <i></i><span></span>
                                    <span>آرشیو گفتگوهای </span>
                                    {{agent.name}}
                                </h2>
                            </div>
                            <div>
                                <div>
                                    <table id="current-visits">
                                        <thead>
                                            <tr>
                                                <th>شناسه مشتری</th>
                                                <th>نام مشتری</th>
                                                <th>تعداد محاوره ها</th>
                                                <th>تاریخ</th>
                                                <th>جزئیات</th>
                                            </tr>
                                        </thead>
                                        <tbody>
                                            <tr ng-repeat="item in agentHistory track by $index">
                                                <td ng-bind="item.id"></td>
                                                <td ng-bind="item.customerName"></td>
                                                <th ng-bind="item.lenght"></th>
                                                <td><span time-ago title="{{item.date}}"></span></td>
                                                <th>
                                                    <ang-click="detailsChatHistory(item.id)" >مشاهده جزییات گفتگو</a>
                                                </th>
                                            </tr>
                                        </tbody>
                                    </table>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div id="detailsAgentHistory" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>تاریخچه گفتگو
                    </h2>
                </div>
                <div>
                    <div style="display: block">
                        <ul ng-repeat="item in historyMsg">
                            <li>
                                <span ng-bind="item.name" ng-style="setdirectionClass(item.name)">
                                </span>
                                <span ng-style="setdirectionClass(item.name)">
                                    <span ng-class="setArrowClass(item.name)"></span>
                                    <span time-ago title="{{item.date}}"></span>
                                    <span>
                                        <p ng-bind-html="item.msg | smilies"></p>
                                    </span>
                                </span>
                            </li>
                        </ul>
                    </div>

                </div>
            </div>
        </div>
    </div>
    <div id="agentList" tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>لیست تمام کارشناسان
                    </h2>
                </div>
                <div>
                    <div style="display: block;">
                        <div ng-show="agentList.length==0">
                            کارشناس آنلاینی وجود ندارد
                        </div>
                        <ul ng-repeat="item in agentList">
                            <li>
                                <span>
                                    <a ng-click="engageVisitor(item.id)">{{item.name}}</a>
                                </span>
                            </li>
                        </ul>
                    </div>
                </div>
            </div>
        </div>
    </div>
    <div id="agentUserChat"  tabindex="-1" role="dialog" aria-labelledby="cmdLabel" aria-hidden="true">
        <div>
            <div>
                <div>
                    <div>
                        <button type="button" data-dismiss="modal" aria-hidden="true">×</button>
                    </div>
                    <h2>
                        <span></span>گفتگو
                    </h2>
                </div>
                <div>
                    <div>
                        <div>
                            <div style="display: block;">
                                <label>شروع چت در </label>:
                                <span ng-bind="dateStartChat"></span>

                                <ul>
                                    <li ng-repeat="item in agentUserMsgs">
                                        <span ng-bind="item.name" ng-style="setdirectionClass(item.name)">

                                        </span>
                                        <span ng-style="setdirectionClass(item.name)">
                                            <span ng-class="setArrowClass(item.name)"></span>
                                            <span time-ago title="{{item.date}}"></span>

                                            <span>
                                                <p ng-bind-html="item.msg | smilies"></p>
                                            </span>
                                        </span>
                                    </li>
                                </ul>
                                <div>
                                    <div>
                                        <textarea id="post-msg" ng-model="msg" placeholder="متن خود را وارد نمایید" style="overflow: hidden; word-wrap: break-word; resize: horizontal; height: 80px; max-width: 100%"></textarea>
                                        <span smilies-selector="msg" smilies-placement="right" smilies-title="Smilies"></span>
                                    </div>
                                    <div style="text-align: center; margin-top: 5px">
                                        <button ng-click="agentMsgToUser(msg)">ارسال</button>
                                    </div>
                                </div>
                            </div>
                        </div>
                    </div>
                </div>
            </div>
        </div>
    </div>
</div>
فایل index.cshtml
<html ng-app="app">
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Live Support</title>

    <link href="~/Content/bootstrap-rtl.css" rel="stylesheet" />
    <link href="~/Scripts/smilies/angular-smilies-embed.css" rel="stylesheet" />
    <link href="~/Content/font-awesome.css" rel="stylesheet" />

    <link href="~/Content/toastr.css" rel="stylesheet" />
    <link href="~/Content/liveSupport.css" rel="stylesheet" />

    <script src="~/Scripts/jquery-1.10.2.js"></script>
    <script src="~/Scripts/toastr.js"></script>
    <script src="~/Scripts/jquery.timeago.js"></script>

    <script src="~/Scripts/angular.js"></script>
    <script src="~/Scripts/angular-animate.js"></script>
    <script src="~/Scripts/angular-sanitize.js"></script>
    <script src="~/Scripts/angular-route.js"></script>

    <script src="~/Scripts/angular-strap.js"></script>
    <script src="~/Scripts/angular-strap.tpl.js"></script>

    <script src="~/Scripts/smilies/angular-smilies.js"></script>

    <script src="~/Scripts/jquery.signalR-2.2.0.js"></script>
    <script src="~/Scripts/angular-signalr-hub.js"></script>

    <script src="~/app/app.js"></script>
    @Scripts.Render("~/bundles/bootstrap")

</head>
<body ng-controller="ChatCtrl">
    <div ng-view>
    </div>

    <div id="chat-box-header" ng-click="boxheader()">
        {{chatTitle}}
    </div>
    <div id="chat-box">
        <div ng-show="hasOnline">
            <div id="style-1" style="min-height:100px;">
                <div ng-repeat="item in clientAgentMsg  track by $index">
                    <span ng-class="setClass(item.name)">
                        {{item.name}}
                    </span>
                    <br />
                    <p ng-bind-html="item.msg | smilies"></p>
                </div>
            </div>
            <div>
                <label>پیام</label>
                <div style="text-align: left; clear: both">
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.alarm" ng-click="alarm()"><i></i></a>
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.signOut" href="signOut()"><i></i><span></span></a>
                    <a data-placement="top"
                       data-trigger="hover "
                       bs-tooltip="options.upload" href="fileupload()">
                        <span><i></i></span>
                    </a>
                </div>
                <div>
                    <textarea style="height: 150px; max-height: 160px;" ng-model="message" placeholder=" متن خود را وارد نمایید"></textarea>
                    <span smilies-selector="message" smilies-placement="right" smilies-title="Smilies"></span>
                </div>
            </div>
            <div style="text-align: center">
                <button type="button" ng-disabled="pendingRequestChat" ng-click="requestChat(message)">ارسال </button>
            </div>
        </div>
        <div ng-show="hasOffline">
            <div>
                <form name="Ticket" id="form1">
                    <fieldset>
                        <div>
                            <label>نام</label>
                            <input name="email"
                                   ng-model="ticket.name"
                                  >
                        </div>
                        <div>
                            <label>ایمیل</label>
                            <input name="email"
                                   ng-model="ticket.email"
                                  >
                        </div>
                        <div>
                            <label>پیام</label>
                        </div>
                        <div>
                            <textarea ng-model="ticket.comment" placeholder="متن خود را وارد نمایید"></textarea>
                            <span smilies-selector="ticket.comment" smilies-placement="right" smilies-title="Smilies"></span>
                        </div>
                    </fieldset>
                    <div style="text-align: center">
                        <button type="button"
                                ng-click="ticket.submit(ticket)">
                            ارسال
                        </button>
                    </div>
                </form>
            </div>
        </div>
    </div>
   
</body>

</html>
LiveSupport.zip 

نکات تکمیلی :
نگاشت  Hub‌ها به برنامه در مسیر ("signalr /") در فایل  ConfigureAuth.Cs 
  app.MapSignalR();

مطالب
راهنمای تغییر بخش احراز هویت و اعتبارسنجی کاربران سیستم مدیریت محتوای IRIS به ASP.NET Identity – بخش اول
سیستم مدیریت محتوای IRIS از سیستم‌های اعتبار سنجی و مدیریت کاربران رایج نظیر ASP.NET Membership و یا ASP.NET Simple Membership استفاده نمی‌کند و از یک سیستم احراز هویت سفارشی شده مبتنی بر FormsAuthentication بهره می‌برد. زمانیکه در حال نوشتن پروژه‌ی IRIS بودم هنوز ASP.NET Identity معرفی نشده بود و به دلیل مشکلاتی که سیستم‌های قدیمی ذکر شده داشت، یک سیستم اعتبار سنجی کاربران سفارشی شده را در پروژه پیاده سازی کردم.
برای اینکه با معایب سیستم‌های مدیریت کاربران پیشین و مزایای ASP.NET Identity آشنا شوید، مقاله زیر می‌تواند شروع خیلی خوبی باشد:


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

رفع باگ ثبت کاربرهای تکراری نسخه‌ی کنونی

قبل از این که سراغ پیاده سازی Identity برویم، ابتدا باید یک باگ مهم را در نسخه‌ی قبلی، برطرف نماییم. نسخه‌ی کنونی مدیریت کاربران اجازه‌ی ثبت کاربر با ایمیل و یا نام کاربری تکراری را نمی‌دهد. جلوگیری از ثبت نام کاربر جدید با ایمیل یا نام کاربری تکراری از طریق کدهای زیر صورت گرفته است؛ اما در عمل، همیشه هم درست کار نمی‌کند.
        public AddUserStatus Add(User user)
        {
            if (ExistsByEmail(user.Email))
                return AddUserStatus.EmailExist;
            if (ExistsByUserName(user.UserName))
                return AddUserStatus.UserNameExist;

            _users.Add(user);
            return AddUserStatus.AddingUserSuccessfully;
        }
شاید الان با خود بگویید که چرا برای فیلدهای UserName و Email ایندکس منحصر به فرد تعریف نشده است؟ دلیلش این بوده که در زمان نگارش پروژه، Entity Framework پشتیبانی پیش فرضی از تعریف ایندکس نداشت و نوشتن همین شرط‌ها کافی به نظر می‌رسید. باز هم ممکن است بگویید که مسائل همزمانی چگونه مدیریت شده است و اگر دو کاربر مختلف در یک لحظه، نام کاربری یکسانی را انتخاب کنند، سیستم چگونه از ثبت دو کاربر مختلف با نام کاربری یکسان ممانعت می‌کند؟ 
جواب این است که ممانعتی نمی‌کند و دو کاربر با نام‌های کاربری یکسان ثبت می‌شوند؛ اما من برای وبسایت خودم که تعداد کاربرانش محدود بود این سناریو را محتمل نمی‌دانستم و کد خاصی برای جلوگیری از این اتفاق پیاده سازی نکرده بودم.
با این حال، در حال حاضر نزدیک به 20 کاربر تکراری در دیتابیسی که این سیستم استفاده می‌کند ثبت شده است. اما واقعا آیا دو کاربر مختلف اطلاعات یکسانی وارد کرده‌اند؟
 دلیل رخ دادن این اتفاق این است که کاربری که در حال ثبت نام در سایت است، وقتی که بر روی دکمه‌ی ثبت نام کلیک می‌کند و اطلاعات به سرور ارسال می‌شوند، در سمت سرور بعد از رد شدن از شرطهای تکراری نبودن UserName و Email، قبل از رسیدن به متد SaveChanges برای ذخیره شدن اطلاعات کاربر جدید در دیتابیس، وقفه ای در ترد این درخواست به وجود می‌آید. کاربر که احساس می‌کند اتفاقی رخ نداده است، دوباره بر روی دکمه‌ی ثبت نام کلیک می‌کند و  همان اطلاعات قبلی به سرور ارسال می‌شود و این درخواست نیز دوباره شرط‌های تکراری نبودن اطلاعات را با موفقیت رد می‌کند( چون هنوز SaveChanges درخواست اول فراخوانی نشده است) و این بار SaveChanges درخواست دوم با موفقیت فراخوانی می‌شود و کاربر ثبت می‌شود. در نهایت هم ترد درخواست اول به ادامه‌ی کار خود می‌پردازد و SaveChanges درخواست اول نیز فراخوانی می‌شود و خیلی راحت دو کاربر با اطلاعات یکسان ثبت می‌شود. این سناریو را در ویژوال استادیو با قرار دادن یک break point قبل از فراخوانی متد SaveChanges می‌توانید شبیه سازی کنید.
احتمالا این سناریو با مباحث همزمانی در سیستم عامل و context switch‌های بین ترد‌ها مرتبط است و این context switch‌ها  بین درخواست‌ها و atomic نبودن روند چک کردن اطلاعات و ثبت آن ها، سبب بروز چنین مشکلی میشود.
برای رفع این مشکل می‌توان از غیر فعال کردن یک دکمه در حین انجام پردازش‌های سمت سرور استفاده کرد تا کاربر بی حوصله، نتواند چندین بار بر روی یک دکمه کلیک کند و یا راه حل اصولی‌تر این است که ایندکس منحصر به فرد برای فیلدهای مورد نظر تعریف کنیم.
به طور پیش فرض در ASP.NET Identity برای فیلدهای UserName و Email ایندکس منحصر به فرد تعریف شده است. اما مشکل این است که به دلیل وجود کاربرانی با Email و UserName تکراری در دیتابیس کنونی، امکان تعریف Index منحصر به فرد وجود ندارد و پیش از انجام هر کاری باید این ناهنجاری را در دیتابیس برطرف نماییم.
   
به شخصه معمولا برای انجام کارهایی از این دست، یک کنترلر در برنامه خود تعریف می‌کنم و در آنجا کارهای لازم را انجام می‌دهم.
در اینجا من برای حذف کاربران با اطلاعات تکراری، یک کنترلر به نام Migration و اکشن متدی به نام RemoveDuplicateUsers تدارک دیدم.
using System.Linq;
using System.Web.Mvc;
using Iris.Datalayer.Context;

namespace Iris.Web.Controllers
{
    public class MigrationController : Controller
    {
        public ActionResult RemoveDuplicateUsers()
        {
            var db = new IrisDbContext();

            var lstDuplicateUserGroup = db.Users
                                               .GroupBy(u => u.UserName)
                                               .Where(g => g.Count() > 1)
                                               .ToList();

            foreach (var duplicateUserGroup in lstDuplicateUserGroup)
            {
                foreach (var user in duplicateUserGroup.Skip(1).Where(user => user.UserMetaData != null))
                {
                    db.UserMetaDatas.Remove(user.UserMetaData);
                }
                
                db.Users.RemoveRange(duplicateUserGroup.Skip(1));
            }

            db.SaveChanges();

            return new EmptyResult();
        }
    }
}
در اینجا کاربران بر اساس نام کاربری گروه بندی می‌شوند و گروه‌هایی که بیش از یک عضو داشته باشند، یعنی کاربران آن گروه دارای نام کاربری یکسان هستند و به غیر از کاربر اول گروه، بقیه باید حذف شوند. البته این را متذکر شوم که منطق وبسایت من به این شکل بوده است و اگر منطق کدهای شما فرق می‌کند، مطابق با منطق خودتان این کدها را تغییر دهید.

تذکر: اینجا شاید بگویید که چرا cascade delete برای UserMetaData فعال نیست و بخواهید که آن را اصلاح کنید. پیشنهاد من این است که اکنون از هدف اصلی منحرف نشوید و تمام تمرکز خود را بر روی انتقال به ASP Identity با حداقل تغییرات بگذارید. این گونه نباشد که در اواسط کار با خود بگویید که بد نیست حالا فلان کتابخانه را آپدیت کنم یا این تغییر را بدهم بهتر می‌شود. سعی کنید با حداقل تغییرات رو به جلو حرکت کنید؛ آپدیت کردن کتابخانه‌ها را هم بعدا می‌شود انجام داد.
   

 مقایسه ساختار جداول دیتابیس کاربران IRIS با ASP.NET Identity

ساختار جداول ASP.NET Identity به شکل زیر است:

ساختار جداول سیستم کنونی هم بدین شکل است:

همان طور که مشخص است در هر دو سیستم، بین ساختار جداول و رابطه‌ی بین آن‌ها شباهت‌ها و تفاوت هایی وجود دارد. سیستم Identity دو جدول بیشتر از IRIS دارد و برای جداولی که در سیستم کنونی وجود ندارند نیاز به انجام کاری نیست و به هنگام پیاده سازی Identity، این جداول به صورت خودکار به دیتابیس اضافه خواهند شد.

دو جدول مشترک در این دو سیستم، جداول Users و Roles هستندکه نحوه‌ی ارتباطشان با یکدیگر متفاوت است. در Iris بین User و Role رابطه‌ی یک به چند وجود دارد ولی در Identity، رابطه‌ی بین این دو جدول چند به چند است و جدول واسط بین آن‌ها نیز UserRoles نام دارد.

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

جدولی که در هر دو سیستم مشترک است و هسته‌ی آن‌ها را تشکیل می‌دهد، جدول Users است. اگر دقت کنید می‌بینید که این جدول در هر دو سیستم، دارای یک سری فیلد مشترک است که دقیقا هم نام هستند مثل Id، UserName و Email؛ پس این فیلد‌ها از نظر کاربرد در هر دو سیستم یکسان هستند و مشکلی ایجاد نمی‌کنند.

یک سری فیلد هم در جدول User در سیستم IRIS هست که در Identity نیست و بلعکس. با این فیلد‌ها نیز کاری نداریم چون در هر دو سیستم کار مخصوص به خود را انجام می‌دهند و تداخلی در کار یکدیگر ایجاد نمی‌کنند.

اما فیلدی که برای ذخیره سازی پسورد در هر دو سیستم استفاده می‌شود دارای نام‌های متفاوتی است. در Iris این فیلد Password نام دارد و در Identity نامش PasswordHash است.

برای اینکه در سیستم کنونی، نام  فیلد Password جدول User را به PasswordHash تغییر دهیم قدم‌های زیر را بر می‌داریم:

وارد پروژه‌ی DomainClasses شده و کلاس User را باز کنید. سپس نام خاصیت Password را به PasswordHash تغییر دهید.  پس از این تغییر بلافاصله یک گزینه زیر آن نمایان می‌شود که می‌خواهد در تمام جاهایی که از این نام استفاده شده است را به نام جدید تغییر دهد؛ آن را انتخاب کرده تا همه جا Password به PasswordHash تغییر کند.

برای این که این تغییر نام بر روی دیتابیس نیز اعمال شود باید از Migration استفاده کرد. در اینجا من از مهاجرت دستی که بر اساس کد هست استفاده می‌کنم تا هم بتوانم کدهای مهاجرت را پیش از اعمال بررسی و هم تاریخچه‌ای از تغییرات را ثبت کنم.

برای این کار، Package Manager Console را باز کرده و از نوار بالایی آن، پروژه پیش فرض را بر روی DataLayer قرار دهید. سپس در کنسول، دستور زیر را وارد کنید:

Add-Migration Rename_PasswordToPasswordHash_User

اگر وارد پوشه Migrations پروژه DataLayer خود شوید، باید کلاسی با نامی شبیه به 201510090808056_Rename_PasswordToPasswordHash_User ببینید. اگر آن را باز کنید کدهای زیر را خواهید دید: 

  public partial class Rename_PasswordToPasswordHash_User : DbMigration
    {
        public override void Up()
        {
            AddColumn("dbo.Users", "PasswordHash", c => c.String(nullable: false, maxLength: 200));
            DropColumn("dbo.Users", "Password");
        }
        
        public override void Down()
        {
            AddColumn("dbo.Users", "Password", c => c.String(nullable: false, maxLength: 200));
            DropColumn("dbo.Users", "PasswordHash");
        }
    }

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

    public partial class Rename_PasswordToPasswordHash_User : DbMigration
    {
        public override void Up()
        {
            RenameColumn("dbo.Users", "Password", "PasswordHash");
        }
        
        public override void Down()
        {
            RenameColumn("dbo.Users", "PasswordHash", "Password");
        }
    }

سپس باز در کنسول دستور Update-Database  را وارد کنید تا تغییرات بر روی دیتابیس اعمال شود.

دلیل اینکه این قسمت را مفصل بیان کردم این بود که می‌خواستم در مهاجرت از سیستم اعتبارسنجی خودتان به ASP.NET Identity دید بهتری داشته باشید.

تا به این جای کار فقط پایگاه داده سیستم کنونی را برای مهاجرت آماده کردیم و هنوز ASP.NET Identity را وارد پروژه نکردیم. در بخش‌های بعدی Identity را نصب کرده و تغییرات لازم را هم انجام می‌دهیم.

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


مدل برنامه، جهت تامین داده‌های خود ارجاع دهنده و درختی

فرض کنید قصد داریم لیستی از کامنت‌های تو در تو را مدل سازی کنیم که در آن هر کامنت، می‌تواند چندین کامنت تا بی‌نهایت سطح تو در تو را داشته باشد:
namespace BlazorTreeView.ViewModels;

public class Comment
{
    public IList<Comment> Comments = new List<Comment>();
    public string? Text { set; get; }
}
برای نمونه بر اساس این مدل، منبع داده‌ی فرضی زیر را تهیه می‌کنیم:
using BlazorTreeView.ViewModels;

namespace BlazorTreeView.Pages;

public partial class TreeView
{
    private IReadOnlyDictionary<string, object> ChildrenHtmlAttributes { get; } =
        new Dictionary<string, object>(StringComparer.Ordinal)
        {
            { "style", "list-style: none;" },
        };

    private IList<Comment> Comments { get; } =
        new List<Comment>
        {
            new()
            {
                Text = "پاسخ یک",
            },
            new()
            {
                Text = "پاسخ دو",
                Comments =
                    new List<Comment>
                    {
                        new()
                        {
                            Text = "پاسخ اول به پاسخ دو",
                            Comments =
                                new List<Comment>
                                {
                                    new()
                                    {
                                        Text = "پاسخی به پاسخ اول پاسخ دو",
                                    },
                                },
                        },
                        new()
                        {
                            Text = "پاسخ دوم به پاسخ دو",
                        },
                    },
            },
            new()
            {
                Text = "پاسخ سوم",
            },
        };
}
این قطعه کد partial class که مربوط به فایل TreeView.razor.cs برنامه‌است، در حقیقت کدهای پشت صحنه‌ی کامپوننت مثال TreeView.razor است که در ادامه آن‌را توسعه خواهیم داد. در نهایت قرار است بتوانیم آن‌را به صورت زیر رندر کنیم:



طراحی کامپوننت DntTreeView

برای اینکه بتوانیم به یک کامپوننت با قابلیت استفاده‌ی مجدد بررسیم، کدهای نمایش اطلاعات تو در تو و درختی را توسط کامپوننت سفارشی DntTreeView پیاده سازی خواهیم کرد. پیشنیازهای آن نیز به صورت زیر است:
- این کامپوننت باید جنریک باشد؛ یعنی باید به صورت زیر شروع شود:
/// <summary>
///   A custom DntTreeView
/// </summary>
public partial class DntTreeView<TRecord>
{
چون باید بتوان یک لیست جنریک <IEnumerable<TRecord را به آن، جهت رندر ارسال کرد و قرار نیست این کامپوننت، تنها به شیء سفارشی Comment مثال جاری ما وابسته باشد. بنابراین اولین خاصیت آن، شیء جنریک Items است که لیست کامنت‌ها/عناصر را دریافت می‌کند:
    /// <summary>
    ///     The treeview's self-referencing items
    /// </summary>
    [Parameter]
    public IEnumerable<TRecord>? Items { set; get; }
- هنگام رندر هر آیتم کامنت باید بتوان یک قالب سفارشی را از کاربر دریافت کرد. نمی‌خواهیم صرفا برای مثال Text شیء Comment فوق را به صورت متنی و ساده نمایش دهیم. می‌خواهیم در حین رندر، کل شیء TRecord جاری را به مصرف کننده ارسال و یک قالب سفارشی را از آن دریافت کنیم. یعنی باید یک RenderFragment جنریک را به صورت زیر نیز داشته باشیم تا مصرف کننده بتواند TRecord در حال رندر را دریافت و قالب Htmlای خودش را بازگشت دهد:
    /// <summary>
    ///     The treeview item's template
    /// </summary>
    [Parameter]
    public RenderFragment<TRecord>? ItemTemplate { set; get; }
- همچنین همیشه باید به فکر عدم وجود اطلاعاتی برای نمایش نیز بود. به همین جهت بهتر است قالب دیگری را نیز از مصرف کننده برای اینکار درخواست کنیم و نحوه‌ی رندر سفارشی این قسمت را نیز به مصرف کننده واگذار کنیم:
    /// <summary>
    ///     The content displayed if the list is empty
    /// </summary>
    [Parameter]
    public RenderFragment? EmptyContentTemplate { set; get; }
- زمانیکه با شیء از پیش تعریف شده‌ی Comment این مثال کار می‌کنیم، کاملا مشخص است که خاصیت Comments آن تو در تو است:
public class Comment
{
    public IList<Comment> Comments = new List<Comment>();
    public string? Text { set; get; }
}
اما زمانیکه با یک کامپوننت جنریک کار می‌کنیم، نیاز است از مصرف کننده، نام این خاصیت تو در تو را به نحو واضحی دریافت کنیم؛ به صورت زیر:
    /// <summary>
    ///     The property which returns the children items
    /// </summary>
    [Parameter]
    public Expression<Func<TRecord, IEnumerable<TRecord>>>? ChildrenSelector { set; get; }
دلیل استفاده از Expression Funcها را در مطلب «static reflection» می‌توانید مطالعه کنید. زمانیکه قرار است از کامپوننت DntTreeView استفاده کنیم، ابتدا نوع جنریک آن‌را مشخص می‌کنیم، سپس لیست اشیاء ارسالی به آن‌را و در ادامه با استفاده از ChildrenSelector به صورت زیر، مشخص می‌کنیم که خاصیت Comments است که به همراه Children می‌باشد و تو در تو است:
        <DntTreeView
            TRecord="Comment"
            Items="Comments"
            ChildrenSelector="m => m.Comments"
و مرسوم است جهت بالابردن کارآیی Expression Funcها، آن‌ها را کامپایل و کش کنیم که نمونه‌ای از روش آن‌را به صورت زیر مشاهده می‌کنید:
public partial class DntTreeView<TRecord>
{
    private Expression? _lastCompiledExpression;
    internal Func<TRecord, IEnumerable<TRecord>>? CompiledChildrenSelector { private set; get; }

    // ...

    protected override void OnParametersSet()
    {
        if (_lastCompiledExpression != ChildrenSelector)
        {
            CompiledChildrenSelector = ChildrenSelector?.Compile();
            _lastCompiledExpression = ChildrenSelector;
        }
    }
}
تا اینجا ساختار کدهای پشت صحنه‌ی DntTreeView.razor.cs مشخص شد. اکنون UI این کامپوننت را به صورت زیر تکمیل می‌کنیم:
@namespace BlazorTreeView.Pages.Components
@typeparam TRecord

@if (Items is null || !Items.Any())
{
    @EmptyContentTemplate
}
else
{
    <CascadingValue Value="this">
        <ul @attributes="AdditionalAttributes">
            @foreach (var item in Items)
            {
                <DntTreeViewChildrenItem TRecord="TRecord" ParentItem="item"/>
            }
        </ul>
    </CascadingValue>
}
در ابتدای کار، اگر آیتمی برای نمایش وجود نداشته باشد، EmptyContentTemplate دریافتی از استفاده کننده را رندر می‌کنیم. در غیراینصورت، حلقه‌ای را بر روی لیست Items ایجاد کرده و آن‌‌ها را یکی نمایش می‌دهیم. این نمایش، نکات زیر را به همراه دارد:
- نمایش توسط کامپوننت دومی به نام DntTreeViewChildrenItem انجام می‌شود که آن‌‌هم جنریک است و شیء item جاری را توسط خاصیت ParentItem دریافت می‌کند.
- در اینجا یک CascadingValue اشاره کننده به شیء this را هم مشاهده می‌کنید. این روش، یکی از روش‌های اجازه دادن دسترسی به خواص و امکانات یک کامپوننت والد، در کامپوننت‌های فرزند است که در ادامه از آن استفاده خواهیم کرد.


تکمیل کامپوننت بازگشتی DntTreeViewChildrenItem.razor

اگر به حلقه‌ی foreach (var item in Items) در کامپوننت DntTreeView.razor دقت کنید، یک سطح را بیشتر پوشش نمی‌دهد؛ اما کامنت‌های ما چندسطحی و تو در تو هستند و عمق آن‌ها هم مشخص نیست. به همین جهت نیاز است به نحوی بتوان یک طراحی recursive و بازگشتی را در کامپوننت‌های Blazor داشت که خوشبختانه این مورد پیش‌بینی شده‌است و هر کامپوننت Blazor، می‌تواند خودش را نیز فراخوانی کند:
@namespace BlazorTreeView.Pages.Components
@typeparam TRecord

<li @attributes="@SafeOwnerTreeView.ChildrenHtmlAttributes" @key="ParentItem?.GetHashCode()">
    @if (SafeOwnerTreeView.ItemTemplate is not null && ParentItem is not null)
    {
        @SafeOwnerTreeView.ItemTemplate(ParentItem)
    }
    @if (Children is not null)
    {
        <ul>
            @foreach (var item in Children)
            {
                <DntTreeViewChildrenItem TRecord="TRecord" ParentItem="item"/>
            }
        </ul>
    }
</li>
این‌ها کدهای DntTreeViewChildrenItem.razor هستند که در آن، ابتدا ItemTemplate دریافتی از والد یا همان DntTreeView.razor رندر می‌شود. سپس به کمک CompiledChildrenSelector ای که عنوان شد، یک شیء Children را تشکیل داده و آن‌را به خودش (فراخوانی مجدد DntTreeViewChildrenItem در اینجا)، ارسال می‌کند. این فراخوانی بازگشتی، سبب رندر تمام سطوح تو در توی شیء جاری می‌شود.

کدهای پشت صحنه‌ی این کامپوننت یعنی فایل DntTreeViewChildrenItem.razor.cs به صورت زیر است:
/// <summary>
///     A custom DntTreeView
/// </summary>
public partial class DntTreeViewChildrenItem<TRecord>
{
    /// <summary>
    ///     Defines the owner of this component.
    /// </summary>
    [CascadingParameter]
    public DntTreeView<TRecord>? OwnerTreeView { get; set; }

    private DntTreeView<TRecord> SafeOwnerTreeView =>
        OwnerTreeView ??
        throw new InvalidOperationException("`DntTreeViewChildrenItem` should be placed inside of a `DntTreeView`.");

    /// <summary>
    ///     Nested parent item to display
    /// </summary>
    [Parameter]
    public TRecord? ParentItem { set; get; }

    private IEnumerable<TRecord>? Children =>
        ParentItem is null || SafeOwnerTreeView.CompiledChildrenSelector is null
            ? null
            : SafeOwnerTreeView.CompiledChildrenSelector(ParentItem);
}
با استفاده از یک پارامتر از نوع CascadingParameter، می‌توان به اطلاعات شیء CascadingValue ای که در کامپوننت والد DntTreeView.razor قرا دادیم، دسترسی پیدا کنیم. سپس یکبار هم بررسی می‌کنیم که آیا نال هست یا خیر. یعنی قرار نیست که این کامپوننت فرزند، درجائی به صورت مستقیم استفاده شود. فقط قرار است داخل کامپوننت والد فراخوانی شود. به همین جهت اگر این CascadingParameter نال بود، یعنی این کامپوننت فرزند، به اشتباه فراخوانی شده و با صدور استثنائی این مساله را گوشزد می‌کنیم. اکنون که به SafeOwnerTreeView یا همان نمونه‌ای از شیء والد دسترسی پیدا کردیم، می‌توانیم پارامتر CompiledChildrenSelector آن‌را نیز فراخوانی کرده و توسط آن، به شیء تو در توی جدیدی در صورت وجود، جهت رندر بازگشتی آن رسید.
یعنی این کامپوننت ابتدا ParentItem، یا اولین سطح ممکن و در دسترس را رندر می‌کند. سپس با استفاده از Expression Func مهیای در کامپوننت والد، شیء فرزند را در صورت وجود یافته و سپس به صورت بازگشتی آن‌را با فراخوانی مجدد خودش ، رندر می‌کند.


روش استفاده از کامپوننت DntTreeView

اکنون که کار توسعه‌ی کامپوننت جنریک DntTreeView پایان یافت، روش استفاده‌ی از آن به صورت زیر است:
<div class="card" dir="rtl">
    <div class="card-header">
        DntTreeView
    </div>
    <div class="card-body">
        <DntTreeView
            TRecord="Comment"
            Items="Comments"
            ChildrenSelector="m => m.Comments"
            style="list-style: none;"
            ChildrenHtmlAttributes="ChildrenHtmlAttributes">
            <ItemTemplate Context="record">
                <div class="card mb-1">
                    <div class="card-body">
                        <span>@record.Text</span>
                    </div>
                </div>
            </ItemTemplate>
            <EmptyContentTemplate>
                <div class="alert alert-warning">
                    There is no item to display!
                </div>
            </EmptyContentTemplate>
        </DntTreeView>
    </div>
</div>
همانطور که مشاهده می‌کنید، چون کامپوننت جنریک است، باید نوع TRecord را که در مثال ما، شیء Comment است، مشخص کرد. سپس لیست نظرات، خاصیت تو در تو، قالب سفارشی نمایش Text نظرات (با توجه به Context دریافتی که امکان دسترسی به شیء جاری در حال رندر را میسر می‌کند) و همچنین قالب سفارشی نبود اطلاعاتی برای نمایش را تعریف می‌کنیم.

کدهای کامل این مطلب را از اینجا می‌توانید دریافت کنید: BlazorTreeView.zip
کامپوننت توسعه یافته‌ی در اینجا در هر دو حالت Blazor WASM و Blazor Server کار می‌کند.
مطالب
INPC استاندارد با بهره گیری از صفت CallerMemberName
یکی از Attribute‌های بسیار کاربردی که در سی شارپ 5 اضافه شد CallerMemberNameAttribute بود. این صفت به یک متد اجازه میدهد که از فراخواننده‌ی خود مطلع شود. این صفت را می‌توان بر روی یک پارامتر انتخابی که مقدار پیش‌فرضی دارد اعمال نمود.

استفاده از این صفت هم بسیار ساده است:

private void A ( [CallerMemberName] string callerName = "") 
{
  Console.WriteLine("Caller is " + callerName);
}

private static void B()
{
        // let's call A
        A();
}
در کد فوق، متد A به راحتی می‌تواند بفهمد چه کسی آن را فراخوانی کرده است. از جمله کاربردهای این صفت در ردیابی و خطایابی است.

ولی یک استفاده‌ی بسیار کاربردی از این صفت، در پیاده سازی رابط INotifyPropertyChanged می‌باشد.

معمولا هنگام پیاده سازی INotifyPropertyChanged کدی شبیه به این را می‌نویسیم:

    public class PersonViewModel : INotifyPropertyChanged
    {
        public event PropertyChangedEventHandler PropertyChanged;

        private void OnPropertyChanged(string propertyName)
        {
            if (PropertyChanged != null)
                PropertyChanged(this, new PropertyChangedEventArgs(propertyName));
        }

        private string name;
        public string Name
        {
            get { return name; }
            set
            {
                this.name = value;
                OnPropertyChanged("Name");
            }
        }
    }

یعنی در Setter معمولا نام ویژگی ای را که تغییر کرده است، به متد OnPropertyChanged می‌فرستیم تا اطلاع رسانی‌های لازم انجام پذیرد. تا اینجای کار همه چیز خوب و آرام است. اما به محضی که کد شما کمی طولانی شود و شما به دلایلی نیاز به Refactor کردن کد و احیانا تغییر نام ویژگی‌ها را پیدا کنید، آن موقع مسائل جدیدی بروز پیدا می‌کند.

برای مثال فرض کنید پس از نوشتن کلاس PersonViewModel تصمیم می‌گیرد نام ویژگی Name را به FirstName تغییر دهید؛ چرا که می‌خواهید اجزای نام یک شخص را به صورت مجزا نگهداری و پردازش کنید. پس احتمالا با زدن کلید F2 روی فیلد name آن را به firstName و ویژگی Name را به FirstName تغییر نام می‌دهید. همانند کد زیر:

private string firstName;
public string FirstName
{
            get { return firstName; }
            set
            {
                this.firstName = value;
                OnPropertyChanged("Name");
            }
}

برنامه را کامپایل کرده و در کمال تعجب می‌بینید که بخشی از برنامه درست رفتار نمی‌کند و تغییراتی که در نام کوچک شخص توسط کاربر ایجاد می‌شود به درستی بروزرسانی نمی‌شوند. علت ساده است: ما کد را به صورت اتوماتیک Refactor کرده ایم و گزینه‌ی Include String را در حین Refactor، در حالت پیشفرض غیرفعال رها کرده‌ایم. پس جای تعجبی ندارد که در هر جای کد که رشته‌ای به نام "Name" با ماهیت نام شخص داشته ایم، دست نخورده باقی مانده است. در واقع در کد تغییر یافته، هنگام تغییر FirstName، ما به سیستم گزارش می‌کنیم که ویژگی Name (که اصلا وجود ندارد) تغییر یافته است و این یعنی خطا.

حال احتمال بروز این خطا را در ViewModel هایی با ده‌ها ویژگی و ترکیب‌های مختلف در نظر بگیرید. پس کاملا محتمل است و برای خیلی از دوستان این اتفاق رخ داده است.

و اما راه حل چیست؟ به کارگیری صفت CallerMemberName

بهتر است که یک کلاس انتزاعی برای تمام ViewModel‌های خود داشته باشیم و پیاده سازی جدید INPC را در درون آن قرار دهیم تا براحتی VM‌های ما از آن مشتق شوند:

public abstract class ViewModelBase : INotifyPropertyChanged
{
        public event PropertyChangedEventHandler PropertyChanged;

        protected void OnPropertyChanged([CallerMemberName] string propertyName = "")
        {
            OnPropertyChangedExplicit(propertyName);
        }

        protected void OnPropertyChanged<TProperty>(Expression<Func<TProperty>> projection)
        {
            var memberExpression = (MemberExpression)projection.Body;
            OnPropertyChangedExplicit(memberExpression.Member.Name);
        }

        void OnPropertyChangedExplicit(string propertyName)
        {
            this.CheckPropertyName(propertyName);

            PropertyChangedEventHandler handler = this.PropertyChanged;

            if (handler != null)
            {
                var e = new PropertyChangedEventArgs(propertyName);
                handler(this, e);
            }
        }

        #region Check property name

        [Conditional("DEBUG")]
        [DebuggerStepThrough]
        public void CheckPropertyName(string propertyName)
        {
            if (TypeDescriptor.GetProperties(this)[propertyName] == null)
                throw new Exception(String.Format("Could not find property \"{0}\"", propertyName));
        }

        #endregion // Check property name
}

در این کلاس، ما پارامتر propertyName را از متد OnPropertyChanged، توسط صفت CallerMemberName حاشیه نویسی کرده‌ایم. این کار باعث می‌شود در Setter‌های ویژگی‌ها، به راحتی بدون نوشتن نام ویژگی، عملیات اطلاع رسانی تغییرات را انجام دهیم. بدین صورت که کافیست متد OnPropertyChanged بدون هیچ آرگومانی در Setter فراخوانی شود و صفت CallerMemberName به صورت اتوماتیک نام ویژگی ای که فراخوانی از درون آن انجام شده است را درون پارامتر propertyName قرار می‌دهد.

پس کلاس PersonViewModel را به صورت زیر می‌توانیم اصلاح و تکمیل کنیم:

public class PersonViewModel : ViewModelBase
{
        private string firstName;
        public string FirstName
        {
            get { return firstName; }
            set
            {
                this.firstName = value;

                OnPropertyChanged();
                OnPropertyChanged(() => this.FullName);
            }
        }

        private string lastName;
        public string LastName
        {
            get { return lastName; }
            set
            {
                this.lastName = value;

                OnPropertyChanged();
                OnPropertyChanged(() => this.FullName);
            }
        }

        public string FullName
        {
            get { return string.Format("{0} {1}", FirstName, LastName); }
        }
}
همانطور که می‌بینید متد OnPropertyChanged بدون آرگومان فراخوانی میشود. اکنون اگر شما اقدام به Refactor کردن کد خود بکنید دیگر نگرانی از بابت تغییر نکردن رشته‌ها و کامنت‌ها نخواهید داشت و مطمئن هستید، نام ویژگی هر چیزی که باشد، به صورت خودکار به متد ارسال خواهد شد.

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

برای استفاده از صفت CallerMemberName باید دات نت هدف خود را 4.5 یا 4.6 قرار دهید.

ارجاع:
Raise INPC witout string name
مطالب
سفارشی سازی Header و Footer در PdfReport
صورت مساله:
- می‌خواهیم footer پیش فرض PdfReport را که تاریخ را در یک سمت، و شماره صفحه را در سمتی دیگر نمایش می‌دهد، به عبارت «صفحه x از n» تغییر دهیم.
- می‌خواهیم در Header گزارش بجای Header پیش فرض PdfReport یکی از قالب‌های PDF تهیه شده توسط Open Office را نمایش دهیم (و یا هر ساختار دیگری را).

تمام اجزای PdfReport جهت امکان اعمال تغییرات کلی و توسعه آن‌ها طراحی شده‌اند؛ قالب‌ها، هدر، فوتر، منابع داده، قالب‌های نمایش سلول‌ها، تعریف توابع تجمعی سفارشی و غیره. جهت سهولت کار، به ازای هر یک از این موارد، پیاده سازی‌های پیش فرضی در PdfReport قرار دارند، امکان اگر مورد رضایت شما نیستند ... از بنیان تغییرشان دهید! (و همچنین اگر مورد جالبی را پیاده سازی کردید، می‌توانید به عنوان یک وصله جدید ارائه دهید تا به پروژه اضافه شود)
ضمنا این مطالب سفارشی سازی نیاز به آشنایی با ساختار iTextSharp را نیز دارند؛ در حد ایجاد یک جدول ساده باید با iTextSharp آشنا باشید.

مدل‌های مورد استفاده:
namespace PdfReportSamples.Models
{
    public class Task
    {
        public int Id { set; get; }
        public string Name { set; get; }
        public int PercentCompleted { set; get; }
        public bool IsActive { set; get; }
        public User Assignee { set; get; }
    }
}

using System;

namespace PdfReportSamples.Models
{
    public class User
    {
        public int Id { set; get; }
        public string Name { set; get; }
        public string LastName { set; get; }
        public long Balance { set; get; }
        public DateTime RegisterDate { set; get; }
    }
}
توسط این مدل‌ها قصد داریم تعدادی فعالیت (Task) را که به تعدادی کاربر انتساب یافته است، نمایش دهیم. همچنین نمایش مقادیر خواص تو در تو  نیز در اینجا مد نظر است؛ برای مثال ستونی مانند این:
 column.PropertyName<Task>(x => x.Assignee.Name) 
کدهای کامل مثال را در ادامه ملاحظه خواهید نمود:
using System;
using System.Collections.Generic;
using System.Drawing;
using PdfReportSamples.Models;
using PdfRpt.Core.Contracts;
using PdfRpt.FluentInterface;

namespace PdfReportSamples.CustomHeaderFooter
{
    public class CustomHeaderFooterPdfReport
    {
        readonly CustomHeader _customHeader = new CustomHeader();
        public IPdfReportData CreatePdfReport()
        {
            return new PdfReport().DocumentPreferences(doc =>
            {
                doc.RunDirection(PdfRunDirection.LeftToRight);
                doc.Orientation(PageOrientation.Portrait);
                doc.PageSize(PdfPageSize.A4);
                doc.DocumentMetadata(new DocumentMetadata { Author = "Vahid", Application = "PdfRpt", Keywords = "Test", Subject = "Test Rpt", Title = "Test" });
            })
            .DefaultFonts(fonts =>
            {
                fonts.Path(Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\tahoma.ttf",
                                  Environment.GetEnvironmentVariable("SystemRoot") + "\\fonts\\verdana.ttf");
            })
            .PagesFooter(footer =>
            {
                footer.CustomFooter(new CustomFooter(footer.PdfFont, PdfRunDirection.LeftToRight));
            })
            .PagesHeader(header =>
            {
                header.CustomHeader(_customHeader);
            })
            .MainTableTemplate(template =>
            {
                template.BasicTemplate(BasicTemplate.SilverTemplate);
            })
            .MainTablePreferences(table =>
            {
                table.ColumnsWidthsType(TableColumnWidthType.Relative);
                table.MultipleColumnsPerPage(new MultipleColumnsPerPage
                {
                    ColumnsGap = 22,
                    ColumnsPerPage = 2,
                    ColumnsWidth = 250,
                    IsRightToLeft = false,
                    TopMargin = 7
                });
            })
            .MainTableDataSource(dataSource =>
            {
                var rows = new List<Task>();
                var rnd = new Random();
                for (int i = 1; i < 210; i++)
                {
                    rows.Add(new Task
                    {
                        Assignee = new User
                        {
                            Id = i,
                            Name = "user-" + i
                        },
                        IsActive = rnd.Next(0, 2) == 1 ? true : false,
                        Name = "task-" + i
                    });
                }
                dataSource.StronglyTypedList(rows);
            })
            .MainTableColumns(columns =>
            {
                columns.AddColumn(column =>
                {
                    column.PropertyName("rowNo");
                    column.IsRowNumber(true);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(0);
                    column.Width(1);
                    column.HeaderCell("#");
                });

                columns.AddColumn(column =>
                {
                    column.PropertyName<Task>(x => x.Name);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(1);
                    column.Width(3);
                    column.HeaderCell("Task Name");
                });

                columns.AddColumn(column =>
                {
                    column.PropertyName<Task>(x => x.Assignee.Name); // nested property support
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(2);
                    column.Width(3);
                    column.HeaderCell("Assignee");
                });

                columns.AddColumn(column =>
                {
                    column.PropertyName<Task>(x => x.IsActive);
                    column.CellsHorizontalAlignment(HorizontalAlignment.Center);
                    column.IsVisible(true);
                    column.Order(3);
                    column.Width(2);
                    column.HeaderCell("Active");
                    column.ColumnItemsTemplate(template =>
                    {
                        template.Checkmark(checkmarkFillColor: Color.Green, crossSignFillColor: Color.DarkRed);
                    });
                });
            })
            .MainTableEvents(events =>
            {
                events.DataSourceIsEmpty(message: "There is no data available to display.");
            })
            .Export(export =>
            {
                export.ToExcel();
            })
            .Generate(data => data.AsPdfFile(AppPath.ApplicationPath + "\\Pdf\\CustomHeaderFooterPdfReportSample.pdf"));
        }
    }
}

به همراه Header سفارشی:
using System.Collections.Generic;
using iTextSharp.text;
using iTextSharp.text.pdf;
using PdfRpt.Core.Contracts;
using PdfRpt.Core.Helper;

namespace PdfReportSamples.CustomHeaderFooter
{
    public class CustomHeader : IPageHeader
    {
        public PdfPTable RenderingGroupHeader(Document pdfDoc, PdfWriter pdfWriter, IList<CellData> rowdata, IList<SummaryCellData> summaryData)
        {
            return null;
        }

        Image _image;
        public PdfPTable RenderingReportHeader(Document pdfDoc, PdfWriter pdfWriter, IList<SummaryCellData> summaryData)
        {
            if (_image == null) //cache is empty
            {
                var templatePath = AppPath.ApplicationPath + "\\data\\PdfHeaderTemplate.pdf";
                _image = PdfImageHelper.GetITextSharpImageFromPdfTemplate(pdfWriter, templatePath);
            }

            var table = new PdfPTable(1);
            var cell = new PdfPCell(_image, true) { Border = 0 };
            table.AddCell(cell);
            return table;
        }
    }
}

و Footer سفارشی استفاده شده:
using System.Collections.Generic;
using iTextSharp.text;
using iTextSharp.text.pdf;
using PdfRpt.Core.Contracts;

namespace PdfReportSamples.CustomHeaderFooter
{
    public class CustomFooter : IPageFooter
    {
        PdfContentByte _pdfContentByte;
        readonly IPdfFont _pdfRptFont;
        readonly Font _font;
        readonly PdfRunDirection _direction;
        PdfTemplate _template;

        public CustomFooter(IPdfFont pdfRptFont, PdfRunDirection direction)
        {
            _direction = direction;
            _pdfRptFont = pdfRptFont;
            _font = _pdfRptFont.Fonts[0];
        }

        public void ClosingDocument(PdfWriter writer, Document document, IList<SummaryCellData> columnCellsSummaryData)
        {
            _template.BeginText();
            _template.SetFontAndSize(_pdfRptFont.Fonts[0].BaseFont, 8);
            _template.SetTextMatrix(0, 0);
            _template.ShowText((writer.PageNumber - 1).ToString());
            _template.EndText();
        }

        public void PageFinished(PdfWriter writer, Document document, IList<SummaryCellData> columnCellsSummaryData)
        {
            var pageSize = document.PageSize;
            var text = "Page " + writer.PageNumber + " / ";
            var textLen = _font.BaseFont.GetWidthPoint(text, _font.Size);
            var center = (pageSize.Left + pageSize.Right) / 2;
            var align = _direction == PdfRunDirection.RightToLeft ? Element.ALIGN_RIGHT : Element.ALIGN_LEFT;

            ColumnText.ShowTextAligned(
                        canvas: _pdfContentByte,
                        alignment: align,
                        phrase: new Phrase(text, _font),
                        x: center,
                        y: pageSize.GetBottom(25),
                        rotation: 0,
                        runDirection: (int)_direction,
                        arabicOptions: 0);

            var x = _direction == PdfRunDirection.RightToLeft ? center - textLen : center + textLen;
            _pdfContentByte.AddTemplate(_template, x, pageSize.GetBottom(25));
        }

        public void DocumentOpened(PdfWriter writer, IList<SummaryCellData> columnCellsSummaryData)
        {
            _pdfContentByte = writer.DirectContent;
            _template = _pdfContentByte.CreateTemplate(50, 50);
        }
    }
}

البته لازم به ذکر است که تمام این کدها به پوشه Samples سورس پروژه نیز جهت سهولت دسترسی، اضافه شده‌اند .

توضیحات:

برای پیاده سازی Header و Footer سفارشی در PdfReport نیاز خواهید داشت تا دو اینترفیس IPageHeader و IPageFooter را پیاده سازی کنید.
ساختار IPageHeader را در ذیل ملاحظه می‌کنید:
using System.Collections.Generic;
using iTextSharp.text;
using iTextSharp.text.pdf;

namespace PdfRpt.Core.Contracts
{
    public interface IPageHeader
    {
        PdfPTable RenderingGroupHeader(Document pdfDoc, PdfWriter pdfWriter, IList<CellData> newGroupInfo, IList<SummaryCellData> summaryData);

        PdfPTable RenderingReportHeader(Document pdfDoc, PdfWriter pdfWriter, IList<SummaryCellData> summaryData);
    }
}

RenderingGroupHeader مرتبط است به مباحث گروه بندی اطلاعات و گزارشات master-detail که در قسمت‌های بعد به آن‌ها اشاره خواهد شد. چون در اینجا به آن نیازی نداشتیم، تنها کافی است متد متناظر با آن، null بر گرداند که در کلاس CustomHeader فوق قابل مشاهده است.
متد RenderingReportHeader به ازای تولید هر صفحه جدید، فراخوانی خواهد شد. به عبارتی می‌توانید در صفحات مختلف، هدرهای مختلفی را نمایش دهید.
خروجی هر دو متد در اینجا یک جدول از نوع PdfPTable است. بنابراین هر نوع ساختار دلخواهی را که علاقمند هستید به شکل یک PdfPTable ایجاد کرده و بازگشت دهید. این جدول در هدر صفحات ظاهر خواهد شد.
برای نمونه در کلاس CustomHeader، یک قالب تهیه شده توسط Open Office توسط متد توکار PdfImageHelper.GetITextSharpImageFromPdfTemplate دریافت و تبدیل به تصویر می‌شود. این تصویر از نوع تصاویر قابل درک توسط iTextSharp است و نه اینکه واقعا تبدیل به یک تصویر معمولی مثلا از نوع bmp شود. سپس این تصویر، در یک ردیف از جدولی قرار داده شده و این جدول بازگشت داده می‌شود.
در کل یا توسط کار با PdfPTable می‌توانید یک هدر غیرپیش فرض را طراحی کنید و یا می‌توانید توسط ابزارهای بصری مانند Open Office یک قالب خاص را برای آن تهیه کرده و به روشی که ذکر شد و کدهای آن‌را ملاحظه می‌کنید، بارگذاری و استفاده کنید. این قالب‌ها در مسیر Bin\Data سورس‌های پروژه قرار داده شده‌اند.

ساختار IPageFooter به صورت زیر است:
using iTextSharp.text;
using iTextSharp.text.pdf;
using System.Collections.Generic;

namespace PdfRpt.Core.Contracts
{
    public interface IPageFooter
    {
        void DocumentOpened(PdfWriter writer, IList<SummaryCellData> columnCellsSummaryData);

        void PageFinished(PdfWriter writer, Document document, IList<SummaryCellData> columnCellsSummaryData);

        void ClosingDocument(PdfWriter writer, Document document, IList<SummaryCellData> columnCellsSummaryData);
    }
}

برای طراحی یک Footer سفارشی کافی است اینترفیس فوق را پیاده سازی کنید که نمونه‌ای از آن‌را در کدهای کلاس CustomFooter ملاحظه می‌نمائید.
متد DocumentOpened، با وهله سازی شیء Document فراخوانی می‌شود.
متد PageFinished هر بار پیش از اتمام کار صفحه جاری و افزوده شدن آن به Document فراخوانی می‌گردد.
متد ClosingDocument، در زمان بسته شدن شیء Document فراخوانی خواهد شد.

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

استفاده از این کلاس‌های سفارشی نیز همواره به شکل زیر خواهد بود:
readonly CustomHeader _customHeader = new CustomHeader();
//...
.PagesFooter(footer =>
{
   footer.CustomFooter(new CustomFooter(footer.PdfFont, PdfRunDirection.LeftToRight));
})
.PagesHeader(header =>
{
  header.CustomHeader(_customHeader);
})
کلا در PdfReport هر جایی متدی به نام CustomXYZ را مشاهده کردید، این متد یک اینترفیس را دریافت می‌کند. به عبارتی این امکان را خواهید داشت تا از متدهای پیش فرض مهیا صرفنظر کرده و مطابق نیاز، نسبت به پیاده سازی و استفاده از وهله جدیدی از این اینترفیس تعریف شده، اقدام کنید.
مطالب
توسعه برنامه های Cross Platform با Xamarin Forms & Bit Framework - قسمت ششم
در پنج قسمت گذشته به نحوه نصب و راه اندازی Xamarin برای توسعه هر سه پلتفرم Android / iOS / Windows پرداختیم. حال با داشتن محیطی آماده برای کد زدن و تست، می‌توانیم به آموزش کد نویسی بپردازیم.
ابتدا بد نیست نگاهی به ساختار XamApp بیاندازیم. این پروژه‌ی مثال، خود از چهار قسمت تشکیل شده‌است:
XamApp | XamApp.Android | XamApp.UWP | XamApp.iOS
در خود XamApp درصد زیادی (گاهی تا 95%) از کد پروژه پیاده سازی می‌شود که بین سه پلتفرم مشترک است و شامل ظاهر فرم‌ها و منطق برنامه می‌شود. در پاره‌ای از مواقع، لازم می‌شود که از امکانات هر پلتفرم استفاده کنیم و در این صورت به سراغ پروژه‌های Android - iOS - Windows می‌رویم. نه تنها امکانات هر پلتفرم را در همان زبان CSharp و Visual Studio می‌توانید استفاده کنید، بلکه Documentation مربوطه به زبان CSharp برای تک تک امکانات سیستم عامل‌ها وجود دارد و اصلا لازم نیست برای فراگیری نحوه کار AR Kit در iOS به سراغ مستندات Apple بروید و یا برای کار با Intent در Android سری به سایت گوگل بزنید!
برای پیاده سازی UI از XAML استفاده می‌کنیم. XAML خود XML است، به همراه کلی امکانات هیجان انگیز که سعی می‌کنیم بجای ارائه یک توضیح کلی، به مرور و با معرفی امکاناتش، شما را با ماهیت خود XAML آشنا کنیم.
در XAML، همه چیز Tag هست (<>). برای مثال برای ایجاد یک دکمه داریم:
<Button Text="This is a test button" />
در XAML هر تگ، معادل یک کلاس CSharp ای است. در نتیجه ما کلاس Button هم داریم. همچنین Text در مثال بالا به این معنا است که کلاس Button دارای یک Property با نام Text است.

عناصر UI خود به سه دسته تقسیم می‌شوند. Page - Layout - Control
Page‌ها که شامل Content Page - MasterDetail Page - Tabbed Page و ... می‌شوند، صفحات برنامه را تشکیل می‌دهند. Content Page، ساده‌ترین نوع صفحه‌است که کل صفحه نمایش را پر می‌کند و همان طور که از اسمش بر می‌آید، Content ای برای نمایش دارد.

Tabbed Page نیز چندین Tab را نمایش می‌دهد که هر ‌Tab خود یک Content Page است. Carousel Page نیز همانند Tabbed Page است، ولی با Swipe کردن به چپ و راست می‌شود بین صفحات چرخید. هر دوی اینها Multi Page محسوب می‌شوند. MasterDetail نیز این امکان را می‌دهد که از بغل منویی برای Swipe کردن وجود داشته باشد. در نهایت Navigation Page محتوای یک Content Page را نمایش می‌دهد، ولی در بالای آن Navigation Bar دارد؛ شامل دکمه بازگشت به صفحه قبل و Title صفحه جاری و ...


علاوه بر Page ها، Layout‌ها نیز وجود دارند. برای مثال، Stack Layout برای چینش خطی (افقی یا عمودی) استفاده می‌شود. Grid برای ساختار شبکه‌ای استفاده می‌شود و Flex Layout عملکردی مشابه با Flex در وب دارد.

برای مثال، در صورتی که بخواهید چهار دکمه را هم اندازه با هم نمایش دهید، دارید:

    <Grid>
        <Button Text="1" Grid.Row="0" Grid.Column="0" />
        <Button Text="2" Grid.Row="0" Grid.Column="1" />
        <Button Text="3" Grid.Row="1" Grid.Column="0" />
        <Button Text="4" Grid.Row="1" Grid.Column="1" />
    </Grid>


در نهایت کنترل‌ها را داریم. برای مثال Label، Button و ... هر کدام از اینها نقشی را ایفا می‌کنند و امکاناتی دارند.

پس Page داریم، داخل Page از Layout استفاده می‌کنیم برای چینش کلی صفحه و در نهایت از کنترل‌های Image، ListView، Button و ... استفاده می‌کنیم تا ظاهر فرم تکمیل شود.


هر Page علاوه بر ظاهر خود، دارای یک منطق نیز هست. منطق، کاری است که آن فرم انجام می‌دهد. برای مثال فرم لاگین می‌تواند یک Stack Layout عمودی باشد، شامل یک Entry برای گرفتن نام کاربری، یک Entry برای گرفتن رمز عبور، که IsPassword آن True است و در نهایت یک دکمه که برای انجام عمل لاگین است.

در قسمت منطق که با CSharp نوشته می‌شود، ما یک Property از جنس string برای نگه داشتن نام کاربری داریم. یک Property از جنس string برای نگه داشتن رمز عبور و یک Command که عمل لاگین را انجام دهد. Property اول با نام UserName به Text آن Entry اول وصل می‌شود (به اصطلاح Bind می‌شود) و همین طور Property دوم با نام Password نیز به Text آن Entry دوم که IsPassword اش True بود وصل می‌شود و در نهایت Command لاگین به دکمه لاگین وصل می‌شود.


برای زدن ظاهر فرم لاگین، در پروژه XamApp روی فولدر Views راست کلیک نموده و از منوی Add به New Item رفته و Content Page را می‌زنیم. نام آن را LoginView.xaml می‌گذاریم که داخل تگ Content Page خواهیم داشت:

    <StackLayout Orientation="Vertical">
        <Entry Placeholder="User name" Text="{Binding UserName}" />
        <Entry IsPassword="True" Placeholder="Password" Text="{Binding Password}" />
        <Button Command="{Binding LoginCommand}" Text="Login" />
    </StackLayout>

برای زدن منطق، در پروژه XamApp روی فولدر ViewModels راست کلیک نموده و از منوی Add گزینه Class را انتخاب کرده و نام آن را LoginViewModel.cs می‌گذاریم که در داخل آن خواهیم داشت:

public class LoginViewModel : BitViewModelBase
{
        public string UserName { get; set; }

        public string Password { get; set; }

        public BitDelegateCommand LoginCommand { get; set; }

        public LoginViewModel()
        {
            LoginCommand = new BitDelegateCommand(Login);
        }

        public async Task Login()
        {
            // Login implementation ...
        }
}

BitDelegateCommand در این مثال، وظیفه اجرای متد Login را به عهده دارد و آن را اجرا می‌کند؛ زمانیکه کاربر روی دکمه لاگین Click یا Tap کند.


برای این که Content Page جدید، یعنی LoginView به همراه منطق آن، یعنی LoginViewModel در برنامه نشان داده شوند، لازم است با Navigation به آن صفحه برویم. برای این کار، ابتدا باید این زوج را رجیستر کنیم. برای این کار به متد RegisterTypes در کلاس App رفته (زیر فایل App.xaml یک فایل App.xaml.cs است) و خط زیر را به آن اضافه می‌کنیم:

containerRegistry.RegisterForNav<LoginView, LoginViewModel>("Login");

حال در متد OnInitializedAsync در چند خط بالاتر داریم:

await NavigationService.NavigateAsync("/Login", animated: false);

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

هم اکنون پروژه XamApp بروز شده و دارای این مثال است. در صورتی که آن را الآن Clone کنید و یا در صورتی که از قبل گرفته بودید، دستور git pull را برای گرفتن آخرین تغییرات بزنید، می‌توانید این کدها رو داخل پروژه داشته باشید.

برنامه را اجرا کنید و در متد Login، یک Break point بگذارید. سپس برنامه را اجرا کنید. User Name و Password را پر کنید و بر روی دکمه لاگین بزنید. خواهید دید که متد لاگین اجرا می‌شود و User Name و Password با مقادیری که نوشته بودید، پر شده‌اند.

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

نظرات مطالب
احراز هویت و اعتبارسنجی کاربران در برنامه‌های Angular - قسمت پنجم - محافظت از مسیرها
یک نکته‌ی تکمیلی 
در حالتیکه کاربر چندین نقش داشته باشد decode شده‌ی توکن به این صورت می‌باشد:
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role": [
    "Admin",
    "User"
  ],
که  آرایه می‌باشد اما در حالتیکه کاربر دارای یک نقش باشد decode شده‌ی توکن به این صورت است:
"http://schemas.microsoft.com/ws/2008/06/identity/claims/role":  "User" ,
که در این حالت متد map به دلیل آرایه نبودن roles با خطا مواجه میشود:
 const decodedToken = this.getDecodedAccessToken();
    let roles = decodedToken["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
    if (roles) {
      roles = roles.map(role => role.toLowerCase()); //error
    }
یه راهش این هست که بررسی شود decode توکن آرایه هست یا خیر:
const decodedToken = this.getDecodedAccessToken();
    let roles = decodedToken["http://schemas.microsoft.com/ws/2008/06/identity/claims/role"];
    if (roles){
      if (Array.isArray(roles)) {
        roles = roles.map(role => role.toLowerCase());
      }
      else {
        roles = roles.toLowerCase();
      }
    }

مطالب
ساخت یک Web API که از عملیات CRUD پشتیبانی می کند
در این مقاله با استفاده از ASP.NET Web API یک سرویس HTTP خواهیم ساخت که از عملیات CRUD پشتیبانی می‌کند. CRUD مخفف Create, Read, Update, Delete است که عملیات پایه دیتابیسی هستند. بسیاری از سرویس‌های HTTP این عملیات را بصورت REST API هم مدل سازی می‌کنند. در مثال جاری سرویس ساده ای خواهیم ساخت که مدیریت لیستی از محصولات (Products) را ممکن می‌سازد. هر محصول شامل فیلدهای شناسه (ID)، نام، قیمت و طبقه بندی خواهد بود.

سرویس ما متدهای زیر را در دسترس قرار می‌دهد.

 Relative URl
 HTTP method
 Action
 api/products/  GET  گرفتن لیست تمام محصولات
 api/products/id/  GET  گرفتن یک محصول بر اساس شناسه
 api/products?category=category/  GET  گرفتن یک محصول بر اساس طبقه بندی
 api/products/  POST  ایجاد یک محصول جدید
 api/products/id/  PUT  بروز رسانی یک محصول
 api/products/id/  DELETE  حذف یک محصول

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

http://hostname/api/products/28

منابع

سرویس ما آدرس هایی برای دستیابی به دو نوع منبع (resource) را تعریف می‌کند:

URI
 Resource
 api/products/  لیست تمام محصولات
 api/products/id/  یک محصول مشخص

متد ها

چهار متد اصلی HTTP یعنی همان GET, PUT, POST, DELETE می‌توانند بصورت زیر به عملیات CRUD نگاشت شوند:

  • متد GET یک منبع (resource) را از آدرس تعریف شده دریافت می‌کند. متدهای GET هیچگونه تاثیری روی سرور نباید داشته باشند. مثلا حذف رکوردها با متد اکیدا اشتباه است.
  • متد PUT یک منبع را در آدرس تعریف شده بروز رسانی می‌کند. این متد برای ساختن منابع جدید هم می‌تواند استفاده شود، البته در صورتی که سرور به کلاینت‌ها اجازه مشخص کردن آدرس‌های جدید را بدهد. در مثال جاری پشتیبانی از ایجاد منابع توسط متد PUT را بررسی نخواهیم کرد.
  • متد POST منبع جدیدی می‌سازد. سرور آدرس آبجکت جدید را تعیین می‌کند و آن را بعنوان بخشی از پیام Response بر می‌گرداند.
  • متد DELETE منبعی را در آدرس تعریف شده حذف می‌کند.

نکته: متد PUT موجودیت محصول (product entity) را کاملا جایگزین میکند. به بیان دیگر، از کلاینت انتظار می‌رود که آبجکت کامل محصول را برای بروز رسانی ارسال کند. اگر می‌خواهید از بروز رسانی‌های جزئی/پاره ای (partial) پشتیبانی کنید متد PATCH توصیه می‌شود. مثال جاری متد PATCH را پیاده سازی نمی‌کند.

یک پروژه Web API جدید بسازید

ویژوال استودیو را باز کنید و پروژه جدیدی از نوع ASP.NET MVC Web Application بسازید. نام پروژه را به "ProductStore" تغییر دهید و OK کنید.

در دیالوگ New ASP.NET Project قالب Web API را انتخاب کرده و تایید کنید.

افزودن یک مدل

یک مدل، آبجکتی است که داده اپلیکیشن شما را نمایندگی می‌کند. در ASP.NET Web API می‌توانید از آبجکت‌های Strongly-typed بعنوان مدل هایتان استفاده کنید که بصورت خودکار برای کلاینت به فرمت‌های JSON, XML مرتب (Serialize) می‌شوند. در مثال جاری، داده‌های ما محصولات هستند. پس کلاس جدیدی بنام Product می‌سازیم.

در پوشه Models کلاس جدیدی با نام Product بسازید.

حال خواص زیر را به این کلاس اضافه کنید.

namespace ProductStore.Models
{
    public class Product
    {
        public int Id { get; set; }
        public string Name { get; set; }
        public string Category { get; set; }
        public decimal Price { get; set; }
    }
}

افزودن یک مخزن

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

روی پوشه Models کلیک راست کنید و گزینه Add, New Item را انتخاب نمایید.

نوع آیتم جدید را Interface انتخاب کنید و نام آن را به IProductRepository تغییر دهید.

حال کد زیر را به این اینترفیس اضافه کنید.

namespace ProductStore.Models
{
    public interface IProductRepository
    {
        IEnumerable<Product> GetAll();
        Product Get(int id);
        Product Add(Product item);
        void Remove(int id);
        bool Update(Product item);
    }
}
حال کلاس دیگری با نام ProductRepository در پوشه Models ایجاد کنید. این کلاس قرارداد IProductRepository را پیاده سازی خواهد کرد. کد زیر را به این کلاس اضافه کنید.

namespace ProductStore.Models
{
    public class ProductRepository : IProductRepository
    {
        private List<Product> products = new List<Product>();
        private int _nextId = 1;

        public ProductRepository()
        {
            Add(new Product { Name = "Tomato soup", Category = "Groceries", Price = 1.39M });
            Add(new Product { Name = "Yo-yo", Category = "Toys", Price = 3.75M });
            Add(new Product { Name = "Hammer", Category = "Hardware", Price = 16.99M });
        }

        public IEnumerable<Product> GetAll()
        {
            return products;
        }

        public Product Get(int id)
        {
            return products.Find(p => p.Id == id);
        }

        public Product Add(Product item)
        {
            if (item == null)
            {
                throw new ArgumentNullException("item");
            }
            item.Id = _nextId++;
            products.Add(item);
            return item;
        }

        public void Remove(int id)
        {
            products.RemoveAll(p => p.Id == id);
        }

        public bool Update(Product item)
        {
            if (item == null)
            {
                throw new ArgumentNullException("item");
            }
            int index = products.FindIndex(p => p.Id == item.Id);
            if (index == -1)
            {
                return false;
            }
            products.RemoveAt(index);
            products.Add(item);
            return true;
        }
    }
}

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


افزودن یک کنترلر Web API

اگر قبلا با ASP.NET MVC کار کرده باشید، با مفهوم کنترلر‌ها آشنایی دارید. در ASP.NET Web API کنترلر‌ها کلاس هایی هستند که درخواست‌های HTTP دریافتی از کلاینت را به اکشن متدها نگاشت می‌کنند. ویژوال استودیو هنگام ساختن پروژه شما دو کنترلر به آن اضافه کرده است. برای مشاهد آنها پوشه Controllers را باز کنید.

  • HomeController یک کنترلر مرسوم در ASP.NET MVC است. این کنترلر مسئول بکار گرفتن صفحات وب است و مستقیما ربطی به Web API ما ندارد.
  • ValuesController یک کنترلر نمونه WebAPI است.

کنترلر ValuesController را حذف کنید، نیازی به این آیتم نخواهیم داشت. حال برای اضافه کردن کنترلری جدید مراحل زیر را دنبال کنید.

در پنجره Solution Explorer روی پوشه Controllers کلیک راست کرده و گزینه Add, Controller را انتخاب کنید.

در دیالوگ Add Controller نام کنترلر را به ProductsController تغییر داده و در قسمت Scaffolding Options گزینه Empty API Controller را انتخاب کنید.

حال فایل کنترلر جدید را باز کنید و عبارت زیر را به بالای آن اضافه نمایید.

using ProductStore.Models;
یک فیلد هم برای نگهداری وهله ای از IProductRepository اضافه کنید.
public class ProductsController : ApiController
{
    static readonly IProductRepository repository = new ProductRepository();
}

فراخوانی ()new ProductRepository طراحی جالبی نیست، چرا که کنترلر را به پیاده سازی بخصوصی از این اینترفیس گره می‌زند. بهتر است از تزریق وابستگی (Dependency Injection) استفاده کنید. برای اطلاعات بیشتر درباره تکنیک DI در Web API به این لینک مراجعه کنید.


گرفتن منابع

ProductStore API اکشن‌های متعددی در قالب متدهای HTTP GET در دسترس قرار می‌دهد. هر اکشن به متدی در کلاس ProductsController مرتبط است.

 Relative URl
 HTTP Method
 Action
 api/products/  GET  دریافت لیست تمام محصولات
 api/products/id/  GET  دریافت محصولی مشخص بر اساس شناسه
 api/products?category=category/  GET  دریافت محصولات بر اساس طبقه بندی

برای دریافت لیست تمام محصولات متد زیر را به کلاس ProductsController اضافه کنید.

public class ProductsController : ApiController
{
    public IEnumerable<Product> GetAllProducts()
    {
        return repository.GetAll();
    }
    // ....
}
نام این متد با "Get" شروع می‌شود، پس بر اساس قراردادهای توکار پیش فرض به درخواست‌های HTTP GET نگاشت خواهد شد. همچنین از آنجا که این متد پارامتری ندارد، به URl ای نگاشت می‌شود که هیچ قسمتی با نام مثلا id نداشته باشد.

برای دریافت محصولی مشخص بر اساس شناسه آن متد زیر را اضافه کنید.
public Product GetProduct(int id)
{
    Product item = repository.Get(id);
    if (item == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound); 
    }
    return item;
}

نام این متد هم با "Get" شروع می‌شود اما پارامتری با نام id دارد. این پارامتر به قسمت id مسیر درخواست شده (request URl) نگاشت می‌شود. تبدیل پارامتر به نوع داده مناسب (در اینجا int) هم بصورت خودکار توسط فریم ورک ASP.NET Web API انجام می‌شود.

متد GetProduct در صورت نامعتبر بودن پارامتر id استثنایی از نوع HttpResponseException تولید می‌کند. این استثنا بصورت خودکار توسط فریم ورک Web API به خطای 404 (Not Found) ترجمه می‌شود.

در آخر متدی برای دریافت محصولات بر اساس طبقه بندی اضافه کنید.
public IEnumerable<Product> GetProductsByCategory(string category)
{
    return repository.GetAll().Where(
        p => string.Equals(p.Category, category, StringComparison.OrdinalIgnoreCase));
}

اگر آدرس درخواستی پارامتر‌های query string داشته باشد، Web API سعی می‌کند پارامتر‌ها را با پارامتر‌های متد کنترلر تطبیق دهد. بنابراین درخواستی به آدرس "api/products?category=category" به این متد نگاشت می‌شود.

ایجاد منبع جدید

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

// Not the final implementation!
public Product PostProduct(Product item)
{
    item = repository.Add(item);
    return item;
}
به دو چیز درباره این متد توجه کنید:

  • نام این متد با "Post" شروع می‌شود. برای ساختن محصولی جدید کلاینت یک درخواست HTTP POST ارسال می‌کند.
  • این متد پارامتری از نوع Product می‌پذیرد. در Web API پارامترهای پیچیده (complex types) بصورت خودکار با deserialize کردن بدنه درخواست بدست می‌آیند. بنابراین در اینجا از کلاینت انتظار داریم که آبجکتی از نوع Product را با فرمت XML یا JSON ارسال کند.

پیاده سازی فعلی این متد کار می‌کند، اما هنوز کامل نیست. در حالت ایده آل ما می‌خواهیم پیام HTTP Response موارد زیر را هم در بر گیرد:

  • Response code: بصورت پیش فرض فریم ورک Web API کد وضعیت را به 200 (OK) تنظیم می‌کند. اما طبق پروتکل HTTP/1.1 هنگامی که یک درخواست POST منجر به ساخته شدن منبعی جدید می‌شود، سرور باید با کد وضعیت 201 (Created) پاسخ دهد.
  • Location: هنگامی که سرور منبع جدیدی می‌سازد، باید آدرس منبع جدید را در قسمت Location header پاسخ درج کند.

ASP.NET Web API دستکاری پیام HTTP response را آسان می‌کند. لیست زیر پیاده سازی بهتری از این متد را نشان می‌دهد.

public HttpResponseMessage PostProduct(Product item)
{
    item = repository.Add(item);
    var response = Request.CreateResponse<Product>(HttpStatusCode.Created, item);

    string uri = Url.Link("DefaultApi", new { id = item.Id });
    response.Headers.Location = new Uri(uri);
    return response;
}
توجه کنید که حالا نوع بازگشتی این متد HttpResponseMessage است. با بازگشت دادن این نوع داده بجای Product، می‌توانیم جزئیات پیام HTTP response را کنترل کنیم. مانند تغییر کد وضعیت و مقدار دهی Location header.

متد CreateResponse آبجکتی از نوع HttpResponseMessage می‌سازد و بصورت خودکار آبجکت Product را مرتب (serialize) کرده و در بدنه پاسخ می‌نویسد. نکته دیگر آنکه مثال جاری، مدل را اعتبارسنجی نمی‌کند. برای اطلاعات بیشتر درباره اعتبارسنجی مدل‌ها در Web API به این لینک مراجعه کنید.


بروز رسانی یک منبع

بروز رسانی یک محصول با PUT ساده است.

public void PutProduct(int id, Product product)
{
    product.Id = id;
    if (!repository.Update(product))
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }
}
نام این متد با "Put" شروع می‌شود، پس Web API آن را به درخواست‌های HTTP PUT نگاشت خواهد کرد. این متد دو پارامتر می‌پذیرد، یکی شناسه محصول مورد نظر و دیگری آبجکت محصول آپدیت شده. مقدار پارامتر id از مسیر (route) دریافت می‌شود و پارامتر محصول با deserialize کردن بدنه درخواست.


حذف یک منبع

برای حذف یک محصول متد زیر را به کلاس ProductsController اضافه کنید.

public void DeleteProduct(int id)
{
    Product item = repository.Get(id);
    if (item == null)
    {
        throw new HttpResponseException(HttpStatusCode.NotFound);
    }

    repository.Remove(id);
}
اگر یک درخواست DELETE با موفقیت انجام شود، می‌تواند کد وضعیت 200 (OK) را بهمراه بدنه موجودیتی که وضعیت فعلی را نمایش می‌دهد برگرداند. اگر عملیات حذف هنوز در حال اجرا است (Pending) می‌توانید کد 202 (Accepted) یا 204 (No Content) را برگردانید.

در مثال جاری متد DeleteProduct نوع void را بر می‌گرداند، که فریم ورک Web API آن را بصورت خودکار به کد وضعیت 204 (No Content) ترجمه می‌کند.