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





5.00/5 (8投票s)
MVC5 - JQM - SignalR2.0.1 - SqlDependency - Sql Server 2008R2
引言
在我之前的一些博文中,我曾使用 Microsoft 的双工通信(尤其是在 Silverlight 环境中)来实现客户端回调,或者单独使用 WebSockets 来实现客户端通知。但这一次,我希望使用 SignalR 作为传输通道,来通知客户端(或多个客户端)数据库上执行的操作(创建、更新、删除)。
技术
- Visual Studio 2013 Express
- Sql Server 2008 R2 Express
- MVC 5 Nuget
- SqlDependency MSDN
- SignalR 2.0.1 Nuget
- Jquery 2.0.3 Nuget
- JQuery Mobile Nuget 或 最新版
- Opera 模拟器 (Windows)
- jPlot
- Toast 通知
场景
我希望开发一个端到端的示例,同时尽可能使其成为一个真实世界的示例(技术上)。MVC5 项目基本上模拟了一个希望及时了解项目 Bug 状态的经理。
项目结构
完整解决方案结构
您可以看到,项目仍然非常面向 MVC,但增加了一个 SignalR Hub 文件夹来存放 Hub 服务器类。除了少量配置(修改 Start.cs 类)外,与普通 MVC 项目相比,需要更改的地方非常少。
使用的 JavaScript 脚本
应用程序中使用了许多第三方 JQuery 控件, namely jPlot 和 Toast,分别用于显示饼图和通知。JQuery Mobile 脚本和样式用于移动页面的渲染风格。我们有一个自定义脚本 "Initialise.js",用于执行控件绑定、连接到 SignalR Hub 并从服务器接收数据。
数据库 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 风格的命名约定。
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 应用程序。因此,测试各种设备的屏幕分辨率。
选择要模拟的设备,然后单击启动按钮启动模拟器(图 5),在浏览器地址栏中输入网站的 URL,然后按 Enter。复制应用程序的 URL 到剪贴板——然后粘贴到设备浏览器 URL 中——这样就不用费力输入了。
提示:在桌面 Web 浏览器中运行应用程序,并将 URL 复制到您的设备浏览器 URL 中
编辑缺陷表,更改其中一个记录的状态(图 6),从而模拟另一个应用程序对数据库表进行的更改。这将触发一个附加到我们的 SqlListener
类的依赖事件,该事件将新数据推送到客户端,客户端将数据绑定到客户端控件并显示一个 Toast 通知。
Toast 通知出现在客户端浏览器的屏幕上(图 7)。
多个客户端连接到站点,显示当前的缺陷状态。当您更改数据库表时,每个客户端都会更新其控件并向用户显示通知(图 8)。
将一个 Bug 的状态从“已关闭”更改为“已解决”(图 9)。
应用程序的 Grid 标签更详细地显示了缺陷。我打开了两个模拟器来显示每个标签、图形和 Grid(图 10)。