65.9K
CodeProject 正在变化。 阅读更多。
Home

MVC5、JQuery Mobile 与实时数据库通知

starIconstarIconstarIconstarIconstarIcon

5.00/5 (8投票s)

2014年1月14日

CPOL

6分钟阅读

viewsIcon

66431

downloadIcon

2587

MVC5 - JQM - SignalR2.0.1 - SqlDependency - Sql Server 2008R2

引言

在我之前的一些博文中,我曾使用 Microsoft 的双工通信(尤其是在 Silverlight 环境中)来实现客户端回调,或者单独使用 WebSockets 来实现客户端通知。但这一次,我希望使用 SignalR 作为传输通道,来通知客户端(或多个客户端)数据库上执行的操作(创建、更新、删除)。

技术

  1. Visual Studio 2013 Express
  2. Sql Server 2008 R2 Express
  3. MVC 5 Nuget
  4. SqlDependency MSDN
  5. SignalR 2.0.1 Nuget
  6. Jquery 2.0.3 Nuget
  7. JQuery Mobile Nuget最新版
  8. Opera 模拟器 (Windows)
  9. jPlot
  10. Toast 通知

场景

我希望开发一个端到端的示例,同时尽可能使其成为一个真实世界的示例(技术上)。MVC5 项目基本上模拟了一个希望及时了解项目 Bug 状态的经理。

项目结构

完整解决方案结构

您可以看到,项目仍然非常面向 MVC,但增加了一个 SignalR Hub 文件夹来存放 Hub 服务器类。除了少量配置(修改 Start.cs 类)外,与普通 MVC 项目相比,需要更改的地方非常少。

图 1

使用的 JavaScript 脚本

应用程序中使用了许多第三方 JQuery 控件, namely jPlot 和 Toast,分别用于显示饼图和通知。JQuery Mobile 脚本和样式用于移动页面的渲染风格。我们有一个自定义脚本 "Initialise.js",用于执行控件绑定、连接到 SignalR Hub 并从服务器接收数据。

图 2

数据库 Broker 设置

附加 BugTracker 数据库

附加数据库之前,请在 SQL Server 登录中创建一个名为 "GeneralUser"、密码为 "Passw0rd" 的用户。附加压缩的数据库,然后将用户 "GeneralUser" 指定为该数据库的所有者。

创建 Broker 和 Service 的脚本

在 SQL 查询窗格中运行以下脚本,为 BugTracker 数据库创建消息 Broker/Service(如果您随本文一起附加了数据库文件,则无需运行以下脚本)。

USE BugTracker;
GO
CREATE QUEUE BugTrackerQueue;
CREATE SERVICE BugTrackerService ON QUEUE BugTrackerQueue (
  [http://schemas.microsoft.com/SQL/Notifications/PostQueryNotification]);
GRANT SUBSCRIBE QUERY NOTIFICATIONS TO GeneralUser;
ALTER DATABASE BugTracker SET SINGLE_USER WITH ROLLBACK IMMEDIATE
ALTER DATABASE BugTracker SET ENABLE_BROKER
ALTER DATABASE BugTracker SET MULTI_USER
GO

代码解释

SqlDependency

C# 数据库监听器代码(如下)初始化后,将在数据库中创建一个唯一的数据库 Broker/Service 实例(图 xx)。GetDefectlist(); 方法将从数据库检索缺陷,并在每次数据库表更新、插入新记录或删除记录时创建一个监听器。我将缺陷缓存在应用程序变量中,这样当新客户端连接到站点时,它们不必查询数据库以获取最新缺陷——当数据库发生更改时,此缓存将得到更新,因为方法 dependency_OnDataChangedDelegate(…) 将被运行——从而使缓存失效。

public class SqlListener
    {               		
        private BugStatus bugStatus;
        private Object threadSafeCode = new Object();
        
        public SqlListener()
        {
            bugStatus = new BugStatus();
        }
        
        /// <summary>
        /// Gets the employee list.
        /// </summary>
        /// <param name="startDate">The start date.</param>
        /// <param name="endDate">The end date.</param>
        /// <returns></returns>
        public string GetDefectList()
        {
            const string SelectDefectsSproc = "SelectDefectsSproc";
            const string ConnectionString = "bugsDatabaseConnectionString";
            this.bugStatus.BugDetails = new List<BugDetails>();
            this.bugStatus.BugStatusCount = new List<Tuple<string, int>>();
 
            // the connection string to your database          
            string connString = ConfigurationManager.ConnectionStrings[ConnectionString].ConnectionString;
 
            // SqlDependency.Stop(connString);
            SqlDependency.Start(connString);
 
            string proc = ConfigurationManager.AppSettings[SelectDefectsSproc];            
 
            //first we need to check that the current user has the proper permissions, otherwise display the error 
            if (!CheckUserPermissions()) return null;            
            
            using (SqlConnection sqlConn = new SqlConnection(connString))
            {
               
				using (SqlCommand sqlCmd = new SqlCommand())
                {
                    sqlCmd.Connection = sqlConn;
                    sqlCmd.Connection.Open();
 
                    //tell our command object what to execute
                    sqlCmd.CommandType = CommandType.StoredProcedure;
                    sqlCmd.CommandText = proc;
                    sqlCmd.Notification = null;
                    
                    SqlDependency dependency = new SqlDependency(sqlCmd);
                    dependency.OnChange += new OnChangeEventHandler(dependency_OnDataChangedDelegate);
                    
                    if (sqlConn.State != ConnectionState.Open) sqlConn.Open();
 
                    using (SqlDataReader reader = sqlCmd.ExecuteReader())
                    {
                        while (reader.Read())
                        {
                            BugDetails details =  new BugDetails();
                            details.BugId = reader.GetInt32(0);
                            details.Header = reader.GetString(1);
                            details.Created = reader.GetDateTime(2);
                            details.Assignee = reader.GetString(3);                           
                            details.CurrentStatus = reader.GetString(4);                           
                            this.bugStatus.BugDetails.Add(details);                            
                        }
                    }
 
                    // get the GroupBy bug stats
                    var noticesGrouped = this.bugStatus.BugDetails.GroupBy(n=> n.CurrentStatus).
                    Select(group =>
                         new
                         {
                             Notice = group.Key,
                             Count = group.Count()
                         });
 
                    foreach (var item in noticesGrouped) this.bugStatus.BugStatusCount.Add(new Tuple<string, int>(item.Notice, item.Count));                   
                }
 
                lock (threadSafeCode)
                {
                    HttpRuntime.Cache["Bugs"] =  SerializeObjectToJson(this.bugStatus);                               
                }
                return (HttpRuntime.Cache["Bugs"] as string);                
            }
        }       
 
        /// <summary>
        /// Checks the user permissions.
        /// </summary>
        /// <returns></returns>
        public bool CheckUserPermissions()
        {
            try
            {
                SqlClientPermission permissions = new SqlClientPermission(PermissionState.Unrestricted);
                permissions.Demand(); //if we cannot Demand() it will throw an exception if the current user doesn't have the proper permissions
                return true;
            }
            catch { return false; }
        }
 
        /// <summary>
        /// Handles the OnDataChangedDelegate event of the dependency control.
        /// </summary>
        /// <param name="sender">The source of the event.</param>
        /// <param name="e">The <see cref="System.Data.SqlClient.SqlNotificationEventArgs"/> instance containing the event data.</param>
        private void dependency_OnDataChangedDelegate(object sender, SqlNotificationEventArgs e)
        {
            if (e.Type != SqlNotificationType.Change) return;
 
            var context = GlobalHost.ConnectionManager.GetHubContext<DefectsHub>();
            string actionName = ((System.Data.SqlClient.SqlNotificationInfo)e.Info).ToString();
            context.Clients.All.addMessage(this.GetDefectList(), actionName);     
 
            //sql notification will have been used up at this stage - will be rebined later in code
            SqlDependency dependency = sender as SqlDependency;
            dependency.OnChange -= new OnChangeEventHandler(dependency_OnDataChangedDelegate);          
        }
 
        /// <summary>
        /// Serializes the object.
        /// </summary>
        /// <param name="pObject">The p object.</param>
        /// <returns></returns>
        public String SerializeObjectToJson(Object objBugs)
        {
            try
            {                
                return new System.Web.Script.Serialization.JavaScriptSerializer().Serialize(objBugs);
            }
            catch (Exception) { return null; }
        }
    }

当使用 SqlDependency.Start(); 命令创建 SqlDependency 时,数据库中会创建新的(唯一的)队列和服务对象(相互关联,图 3)。如果我在 Start 命令中将一个名称作为参数提供,那么队列和服务就可以使用该参数名称来调用,而不是像下面这样使用 GUID 风格的命名约定。

图 3

SignalR Hub

DefectHub 类中有两个简单的函数:一个用于将新编辑的数据库表数据推送到客户端(本例中是所有连接的客户端),另一个用于在设备首次连接时检索缓存数据(提高性能)。您会注意到我对更新缓存的代码加了锁,以避免在执行更新时出现线程冲突。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using Microsoft.AspNet.SignalR;
using MvcJqmSqlDependencySignalR.Controller;
 
namespace MvcJqmSqlDependencySignalR.Controllers
{
    public class DefectsHub : Hub
    {
        private Object threadSafeCode = new Object();
 
        public void Send(string jsonBugs, string action)
        {
            Clients.All.addMessage(jsonBugs, action);
        }
 
        public void Start()
        {
            // check if application cache has previously been populated
            if (String.IsNullOrEmpty((HttpRuntime.Cache["Bugs"] as string))) // first time in
            {
                lock (threadSafeCode)
                {
                    SqlListener listener = new SqlListener();
                    string jsonBugs = listener.GetDefectList();
                    HttpRuntime.Cache["Bugs"] = jsonBugs;
                    Clients.Caller.addMessage(jsonBugs, "Select");
                    listener = null;
                }
            }
            else
            {
                Clients.Caller.addMessage((HttpRuntime.Cache["Bugs"] as string), "Select");
            }
        } 
 
    }
}

控制器 (Controller)

主页控制器非常简单,它只会将任何流量重定向到视图,而无需事先处理或向视图传递参数。这是使用 SignalR 时一个非常普遍的概念,因为服务器代码将直接与客户端通信,因此控制器不需要任何“中间”数据处理。

using System;
using System.Collections.Generic;
using System.Linq;
using System.Web;
using System.Web.Mvc;
 
namespace MvcJqmSqlDependencySignalR.Controllers
{
    public class HomeController : System.Web.Mvc.Controller
    {
        public ActionResult Index()
        {
            ViewBag.Title = "Defects";
            return View();
        }
    }
}

自定义 JavaScript (Initialise.js)

此自定义脚本将执行与服务器上的 SignalR 类连接,并创建相应的控件和通知绑定。

$(document).ready(function () {
    // notification initialise
    $.mobile.loading('show', {
        text: 'Connecting to server...',
        textVisible: true,
        theme: 'b',
        html: ""
    });
 
    // SignalR initialise    
    var bugs = $.connection.defectsHub;
 
    // server entry point to client
    bugs.client.addMessage = function (jsonBugs, action) {        
        var header = '';
        switch (action) {
            case "Select":
                header = "Bug selection...";
                break;
            case "Update":
                header = "Bug updation...";
                break;
            case "Delete":
                header = "Bug deletion...";
                break;
            case "Insert":
                header = "Bug insertion...";
                break;
            default:
                header = "Bug status...";
        }
 
        var bugStatus = header,
                                toastMessageSettings = {
                                    text: bugStatus,
                                    sticky: false,
                                    position: 'top-right',
                                    type: 'success',
                                    closeText: ''
                                };
 
        var BugStatusCount = [];
        var BugDetails = [];
        var obj = $.parseJSON(jsonBugs);
        BugStatusCount = obj.BugStatusCount;
        BugDetails = obj.BugDetails;
 
        // build up table row from array                
        var content = '';
        $.each(BugDetails, function () {
            content += "<tr> <th>" + this['BugId'] + "</th><td>" + this['Header'] + "</td><td>" + ConvertJsonDateString(this['Created']) + "</td><td>" + this['Assignee'] + "</td><td>" + this['CurrentStatus'] + "</td> </tr>";
        });
        $('#bugGrid tbody').html(content);
 
        // convert json to array
        data = [];
        for (var prop_name in BugStatusCount) {
            data.push([BugStatusCount[prop_name].Item1, BugStatusCount[prop_name].Item2])
        }
 
        // populate graph
        var plot1 = jQuery.jqplot('chart1', [data],
                                    {
                                        title: 'Bug Report',
                                        seriesDefaults: {
                                            renderer: jQuery.jqplot.PieRenderer,
                                            rendererOptions: {
                                                showDataLabels: true
                                            }
                                        },
                                        legend: { show: true, location: 'e' }
                                    }
                                    );
 
        var myToast = $().toastmessage('showToast', toastMessageSettings); // display notification
 
    };
 
    // start SignalR    
    $.connection.hub.start().done(function () {
        bugs.server.start();
 
        $.mobile.loading('hide'); // hide spinner
 
    });
    // SignalR End
});
 
function ConvertJsonDateString(jsonDate) {
    var shortDate = null;
 
    if (jsonDate) {
        var regex = /-?\d+/;
        var matches = regex.exec(jsonDate);
        var dt = new Date(parseInt(matches[0]));
        var month = dt.getMonth() + 1;
        var monthString = month > 9 ? month : '0' + month;
        var day = dt.getDate();
        var dayString = day > 9 ? day : '0' + day;
        var year = dt.getFullYear();
        var time = dt.toLocaleTimeString();
        shortDate = dayString + '/' + monthString + '/' + year + ' : ' + time;
    }
    return shortDate;
};

视图/JQuery Mobile 标记

以下是主页的视图(HTML 5 语法)。使用 JQuery Mobile 风格,专门为移动设备渲染网页。我还在页面底部包含了脚本(从而更快地渲染页面)。布局页面将加载我需要预先加载的任何脚本/样式。

@{
 
}
<div data-role="tabs">
    <div data-role="navbar">
        <ul>
            <li><a href="#one" data-theme="a" data-ajax="false">Graph</a></li>
            <li><a href="#two" data-theme="a" data-ajax="false">Grid</a></li>
        </ul>
    </div>
    <div id="one" class="ui-content">
        <h1>Pie Chart</h1>
        <div id="chart1" style="height: 250px; width: 350px;">
        </div>
    </div>
    <div id="two" class="ui-content">
        <h1>Grid Data</h1>
        <table data-role="table" id="bugGrid" data-mode="columntoggle" class="ui-body-d ui-shadow table-stripe ui-responsive"
               data-column-btn-theme="b" data-column-btn-text="Bug Headings..." data-column-popup-theme="a">
            <thead>
                <tr class="ui-bar-d">
                    <th>
                        BugID
                    </th>
                    <th>
                        Header
                    </th>
                    <th>
                        Created
                    </th>
                    <th>
                        <abbr title="Name">Assignee</abbr>
                    </th>
                    <th>
                        Status
                    </th>
                </tr>
            </thead>
            <tbody></tbody>
        </table>
    </div>
</div>
<div data-role="footer" data-position="fixed" data-tap-toggle="false" class="jqm-footer">
</div>
 
@section scripts {
    <!--Script references. -->
    <!--The jQuery library is required and is referenced by default in _Layout.cshtml. -->
 
    <!--JQuery Plot-->
    <link href="~/Scripts/jPlot/jquery.jqplot.min.css" rel="stylesheet" type="text/css" />
    <script src="~/Scripts/jPlot/jquery.jqplot.min.js" type="text/javascript"></script>
    <script src="~/Scripts/jPlot/jqplot.pieRenderer.min.js" type="text/javascript"></script>
 
    <!--Toast-->
    <link href="~/Scripts/Toast/css/jquery.toastmessage.css" rel="stylesheet" type="text/css" />
    <script src="~/Scripts/Toast/jquery.toastmessage.js" type="text/javascript"></script>
        
    <!--Reference the autogenerated SignalR hub script. -->
    <script src="~/signalr/hubs"></script>
    
    <!--Custom page script-->
    <script src="~/Scripts/Custom/Initialise.js" type="text/javascript"></script>
} 

共享布局页面

我对布局页面所做的唯一更改是删除了任何菜单 HTML/Razor 代码。我只想包含主要的 Bundle 和我自己的几个 Bundle。

<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0">    
    <title>@ViewBag.Title - Mobile</title>
    @Styles.Render("~/Content/css")
    @Scripts.Render("~/bundles/modernizr")
    @Scripts.Render("~/bundles/jquery")
 
    <!--Custom scripts\styles-->
    @Styles.Render("~/bundles/3rdPartyCss")
    @Scripts.Render("~/bundles/3rdPartyScripts")
    
</head>
<body>
    <div class="container body-content">
        @RenderBody()
    </div>
    
    @Scripts.Render("~/bundles/bootstrap")
    @RenderSection("scripts", required: false)    
</body>
</html>

Bundle 配置

这里的唯一更改是我创建了自己的自定义脚本和样式。

using System.Web;
using System.Web.Optimization;
 
namespace MvcJqmSqlDependencySignalR
{
    public class BundleConfig
    {
        // For more information on bundling, visit http://go.microsoft.com/fwlink/?LinkId=301862
        public static void RegisterBundles(BundleCollection bundles)
        {
            bundles.Add(new ScriptBundle("~/bundles/jquery").Include(                        
                        "~/Scripts/jQuery2.0.3/jquery-2.0.3.min.js"));
                       
            bundles.Add(new ScriptBundle("~/bundles/modernizr").Include(
                        "~/Scripts/modernizr-*"));
 
            bundles.Add(new ScriptBundle("~/bundles/bootstrap").Include(
                      "~/Scripts/bootstrap.js",
                      "~/Scripts/respond.js"));
 
            bundles.Add(new StyleBundle("~/Content/css").Include(
                      "~/Content/bootstrap.css",
                      "~/Content/site.css"));
 
            bundles.Add(new ScriptBundle("~/bundles/3rdPartyScripts").Include(                
                "~/Scripts/Jqm/jquery.mobile-1.4.0.min.js",
                "~/Scripts/jquery.signalR-2.0.1.min.js"));    
        
            bundles.Add(new StyleBundle("~/bundles/3rdPartyCss").Include(
                      "~/Scripts/jquery.mobile-1.4.0.min.css"));                        
        }
    }
}

模型

BugsDetails 类

这个类只是一个普通的模型对象,由 BugStatus 类使用。

using System;
 
namespace MvcJqmSqlDependencySignalR.Models
{
    public class BugDetails
    {
        public BugDetails() { }
        
        public int BugId { get; set; }
        public string Header { get; set; }
        public DateTime Created { get; set; }
        public string Assignee { get; set; }
        public string CurrentStatus { get; set; }        
    }
}

BugsStatus 类

这个类将被序列化(为 JSON)并返回给客户端,绑定到 jPlot 控件,并使用数据数组构建一个动态表。

using System;
using System.Collections.Generic;
 
namespace MvcJqmSqlDependencySignalR.Models
{
    public class BugStatus
    {
        public List<Tuple<string, int>> BugStatusCount;
        public List<BugDetails> BugDetails;      
 
        public BugStatus() 
        {
            BugStatusCount = new List<Tuple<string, int>>();
            BugDetails = new List<BugDetails>();
        }
    }
}

应用程序运行(截图)

从开始菜单打开 Opera 模拟器 。选择任何您想在其中显示网页的平板电脑或 iPhone(图 4)。模拟的设备大部分是基于 Android 的。但这个模拟器在尺寸上比设备操作系统更好——但这在这里不是问题,因为我们只是在设备的浏览器中显示一个 Web 应用程序。因此,测试各种设备的屏幕分辨率。

图 4

选择要模拟的设备,然后单击启动按钮启动模拟器(图 5),在浏览器地址栏中输入网站的 URL,然后按 Enter。复制应用程序的 URL 到剪贴板——然后粘贴到设备浏览器 URL 中——这样就不用费力输入了。

提示:在桌面 Web 浏览器中运行应用程序,并将 URL 复制到您的设备浏览器 URL 中

图 5

编辑缺陷表,更改其中一个记录的状态(图 6),从而模拟另一个应用程序对数据库表进行的更改。这将触发一个附加到我们的 SqlListener 类的依赖事件,该事件将新数据推送到客户端,客户端将数据绑定到客户端控件并显示一个 Toast 通知。

图 6

Toast 通知出现在客户端浏览器的屏幕上(图 7)。

图 7

多个客户端连接到站点,显示当前的缺陷状态。当您更改数据库表时,每个客户端都会更新其控件并向用户显示通知(图 8)。

图 8

将一个 Bug 的状态从“已关闭”更改为“已解决”(图 9)。

图 9

应用程序的 Grid 标签更详细地显示了缺陷。我打开了两个模拟器来显示每个标签、图形和 Grid(图 10)。

图 10
© . All rights reserved.