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

从 SQL Server 进行 HTTP 推送 — Comet SQL

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.91/5 (55投票s)

2010年3月5日

CPOL

10分钟阅读

viewsIcon

227602

downloadIcon

4250

本文提供了一个示例解决方案,用于在 HTML 浏览器中“实时”呈现来自 Microsoft SQL Server 的数据。文章介绍了如何在 ASP.NET 中实现 Comet 功能以及如何将 Comet 与 SQL Server 的查询通知结合起来。

目录

引言

本文介绍了如何使用 ASP.NET 和 SQL Server 实现所谓的实时数据呈现功能。该功能通过在 ASP.NET 中实现 Comet 功能并将其与 SQL Server 的查询通知相结合来实现。

Application Basic Idea

提供的代码也可以用于为基于 SQL Server 的遗留系统添加 Web 界面,而无需更改遗留系统。

提供的代码也可以替代现有 Web 界面中使用 ASP.NET AJAX Timer 的周期性数据刷新。在我的解决方案适应后,Web 浏览器中的数据将在 SQL Server 中更新后立即刷新。没有人工延迟。这就是我称之为实时数据的原因(不要与实时计算混淆)。

背景

我想实现一个由 SQL Server 发起到 Internet 浏览器的消息,但我想这样做,而不使用 Java Applet、Flash 或专门的 Comet 服务器。只使用 JavaScript 和 AJAX。我还决定不在客户端使用高级 Comet 消息解析。我的 Comet 消息只包含一个带有简单信息的事件:“发生了某些更改”。我将在稍后解释原因。

本文**不**是关于从 SQL Server 或浏览器页面周期性(例如,每 10 秒)刷新数据。在这种情况下,用户将看到更新的信息,延迟最多为 10 秒。本文介绍的是无法在较短刷新时间和服务器资源(CPU、RAM、网络)之间取得平衡的情况。本文介绍的是当周期性刷新延迟过大,并且您无法减小此时间,因为这会使您的服务器崩溃的情况。

从 Comet 理念到 ASP.NET 的实现

Comet 理念

Comet 及其名为长轮询的实现背后的一些理念在Wikipedia上有描述。我只能添加一些 UML 图来可视化这个理念。

Comet Idea

以及使用循环的相同方式

Comet Idea - Loop

长连接 HTTP 请求

在前几张图中,您可以看到 Internet 浏览器由 WWW 服务器通知。仅使用纯 HTTP 而不使用扩展(如 HTML 5 或类似标准所提出的),这并不简单。Internet 浏览器并非设计用于接收服务器的通知。我们必须使用一些变通方法来获得此通知。

一种可能性是发出某个 HTTP 请求并等待响应。服务器**不会**在某个事件触发之前返回响应。

Long HTTP Request Implementation of Comet - Impractical

当客户端收到响应(“通知”箭头)时,这意味着事件已被触发。此事件是我们的 Comet 消息。

这个长连接的 HTTP 请求到服务器的等待时间可能非常长,甚至可能是无限的。在没有引入任何超时的情况下实现这一点是不切实际的:考虑与所有可能的超时以及服务器上由于连接中断而产生的“死”请求进行斗争。让我们为请求可能持续的时间设置一些限制……

长轮询

为了防止网络超时和其他与无限请求相关的问题,我们不是无限期等待,而是等待很长时间,但不至于太长。如果请求等待时间过长,则会生成一个特殊响应,告知客户端没有通知,并且它必须再次请求通知。

Long Polling Implementation of Comet

这个“太长”的响应和“再次请求”的组合周期性地循环进行。它可防止超时,我们可以称之为“Comet Keep-alive”。Keep-alive 的数量,当然,当事件在第一个“太长”消息到达之前发生时,可以为 0。

ASP.NET 中长轮询 Comet 的实现

解析 Comet 消息然后使用页面上的 DOM 操作不仅对 JavaScript 初学者(比如我)来说可能很难,它还将一些逻辑(页面的外观逻辑)从 ASP.NET 转移到了 JavaScript。我认识到这是不希望的,因为在这种情况下,要更改或向 Comet 消息添加内容,您还必须更改 Comet JavaScript 如何解析此消息。因此,我决定仅将 Comet 用于简单的通知,告知某些内容已更改。收到此消息后,客户端将通过向当前 ASPX 页面发送回发(postback)来刷新数据(参见“刷新”和“页面”箭头)。

Long Polling Implementation of Comet - Details

ASP.NET 中的长轮询

为什么不使用 ASP.NET AJAX Timer

ASP.NET AJAX Timer 可用于周期性页面刷新(或部分页面刷新)。我决定使用 asp:Timer 来发出段落“长轮询”中描述和显示的长时间请求。它运行良好且简单,直到我想停止观察实时刷新并单击某个按钮。按钮单击发送的回发被阻止。它被排队,并在 asp:Timer 的长连接“滴答”之后执行。使用 MSDN 上的“取消异步回发”文章中止当前回发,并没有达到我需要的效果:“对服务器上发生的事情没有影响”。有关详细信息,请参阅 MSDN 上的“使用 ASP.NET AJAX 取消服务器任务”文章,或查看 ASP.net 论坛上的“取消 Async Postback”主题。如果我们使用 Session,我们的回发将被排队,并在(仍在运行的)已取消的回发之后执行!

好的,不再有糟糕的解决方案了。在接下来的段落中,我将展示我的实现,逐个箭头地描述顺序

页面请求 - 第一个箭头

前三个箭头很简单。用户输入 URL 或单击链接,页面生成,其中 GridView 使用 DataBind() 填充数据,然后此页面返回到客户端 - 没有什么不寻常的。

private void RefreshData()
{
    // . . . 

    int lastRecId;
    List<string> data = MessageDal.GetMessageData(out lastRecId, ...);

    // . . .
    
    Session["LastRecId"] = lastRecId;
    GridView1.DataSource = data;
    GridView1.DataBind();
}

“通知的 HTTP 请求”箭头

被描述为“通知的 HTTP 请求”的箭头是用 JavaScript 使用 AJAX 和 jQuery 实现的。对我(JavaScript 初学者)来说,使用 jQuery 比直接使用 XMLHttpRequest 或使用 Microsoft AJAX 库要简单得多。

页面加载后,将调用 longPolling() 函数。

$(document).ready(function(){
    longPolling(); // Start the initial request 
});

longPolling() 函数向 CometAsyncHandler.ashx(一个 IHttpAsyncHandler)发出 AJAX 请求,该请求模拟某种页面,该页面被计算为非常耗时。此时间以秒为单位在请求参数(即 waitTime=60)中指定。

function longPolling()
{
$.ajax({
    type: "GET",
    url: "CometAsyncHandler.ashx?waitTime=60", // one minute
    cache: false,
    success: function(data){ 
        isPolling--;
        if(data == "NEWDATAISAVAILABLE")
            RefreshData(); // this function is generated by
                           // using RegisterFunctionToPostBack()
        else if( data == "TOOLONG-DOITAGAIN" )
            setTimeout("longPolling()", 0 );
        else
            addLongPollingError("error",
                "Error on server side. Received data: \"" +
                data + " \"");
    },
    error: function(XMLHttpRequest, textStatus, errorThrown){
        isPolling--;
        addLongPollingError("error",
            textStatus + " (" + errorThrown + ")");
    }
});
}

此请求在服务器端由派生自 IHttpAsyncHandlerCometAsyncHandler 类处理。在 ASP.NET 服务器端,我们检查是否有新数据。如果有新数据,则立即生成 HTTP 响应,其中包含信息:"NEWDATAISAVAILABLE"。如果没有新数据,那么我们注册接收查询通知(在 WaitMessageDataAsync() 中实现)并等待新数据。(稍后将解释如何进行注册。)

public class CometAsyncHandler : IHttpAsyncHandler, IReadOnlySessionState
{
    public static List<CometAsyncResult> AllWaitingClients =
        new List<CometAsyncResult>();
    public static object AllWaitingClientsSync = new object();
    private static bool threadForTimeoutsWorking = false;

    // . . .
    // . . .
    // . . .
    
    public IAsyncResult BeginProcessRequest(HttpContext context,
        AsyncCallback cb, object extraData)
    {
        context.Response.ContentType = "text/plain";

        // Get wait time from request
        int waitTime;
        ParseRequest(context.Request, out waitTime);

        // Get last seen record ID from Session
        int lastRecId = (int)context.Session["LastRecId"];

        // . . .

        CometAsyncResult result = new CometAsyncResult(
        context, cb, waitTime, lastRecId);
        lock (AllWaitingClientsSync)
        {
            // register to Query Notification or complete
            // request synchronously in case if there is
            // already new data:
            if (!MessageDal.WaitMessageDataAsync(lastRecId))
            {
                // if not waiting (there is new data)
                // result is to be completed synchronously
                result.IsCompleted = true;
                result.CompletedSynchronously = true;
                result.Result = true; // new data is available
                WriteResponseToClient(result);
                return result;
            }
            else
            {
                // asynchronous (normal case):
                AllWaitingClients.Add(result);

                if (AllWaitingClients.Count == 1)
                    StartClientTimeouter();
            }
        }
        return result;
    }
    
    // . . .
    // . . .
    // . . .
}

“太长”响应

为了防止长时间等待(或无限等待),我们创建一个“while”线程,该线程检查所有正在等待(未响应)的客户端,看它们是否等待时间过长。如果某个客户端等待时间过长,则将其从列表中移除,并调用与该客户端关联的 Callback()。此回调是 BeginProcessRequest() 方法中的 AsyncCallback cb 参数。

以下是 StartClientTimeouter() 的一部分(为展示而修改,仅包含主要思想)

while( AllWaitingClients.Count > 0)
{
    // Call Callback() to all timeouted requests and
    // remove from list.
    lock (AllWaitingClientsSync)
    {
        DateTime now = DateTime.Now;
        AllWaitingClients.RemoveAll(
            delegate(CometAsyncResult asyncResult)
                {
                    if (asyncResult.StartTime.Add(asyncResult.WaitTime) < now)
                    {
                        asyncResult.Result = false; // timeout

                        asyncResult.Callback(asyncResult);
                        return true; // true for remove from list
                    }

                    return false; // not remove (because not timed out)
                });
    }

    // This sleep causes that some timeouted clients are removed with delay
    // Example: if timeout=60s, sleep=1s then timeouted client can be removed after 60,7s.
    // In some cases this can be considered as bug. TODO: Change it to WaitOne() and
    // calculate proper sleep time.
    Thread.Sleep(1000); 
}

调用 Callback()(与 BeginProcessRequest() 方法中的 AsyncCallback cb 参数相同)后,ASP.NET 框架将调用 EndProcessRequest() 方法。在此方法中,我们有机会完成 HTTP 响应的生成。

public void EndProcessRequest(IAsyncResult result)
{
    WriteResponseToClient((CometAsyncResult) result);
}

public void WriteResponseToClient(
    CometAsyncResult cometAsyncResult)
{
    if (cometAsyncResult.Result)
        cometAsyncResult.Context.Response.Write(
            "NEWDATAISAVAILABLE");
    else
        cometAsyncResult.Context.Response.Write(
            "TOOLONG-DOITAGAIN"); // timeout - client must make request again
}

因此,对于每个超时的客户端(超时线程将其结果设置为 false),都会返回一个 "TOOLONG-DOITAGAIN" 响应。此响应由发出 AJAX/Comet 请求的 JavaScript 代码(片段)处理。

    // . . . part of <a href="#longPollingFunction%22">longPolling()</a> function
    else if( data == "TOOLONG-DOITAGAIN" )
        setTimeout("longPolling()", 0 );
    // <a href="#longPollingFunction%22">. . .</a>

“再次请求”箭头

上面的代码将导致,在“太长”消息之后,当前函数将再次被调用。这将导致客户端再次发出“通知的 HTTP 请求”。

“通知”箭头

当查询通知从 SQL Server 到达 ASP.NET 服务器时(参见粗体箭头),将调用 ProcessAllWaitingClients() 方法。此方法将遍历等待的客户端列表,将 Result 字段设置为 true,并调用回调(先前作为参数传递给 BeginProcessRequest() 方法)。

public static void ProcessAllWaitingClients()
{
    // . . . 
    foreach (CometAsyncResult asyncResult in AllWaitingClients)
    {
        asyncResult.Result = true; // New data available
        asyncResult.Callback(asyncResult);
    }
    AllWaitingClients.Clear();
    // . . .
}

回调将以与超时线程相同的方式执行 EndProcessRequest()。区别在于在这种情况下 Result 被设置为 true。因此,在 HTTP 响应生成过程中,将写入 "NEWDATAISAVAILABLE"

此响应由发出 AJAX/Comet 请求的相同 JavaScript 代码(片段)处理。

// . . . part of <a href="#longPollingFunction%22">longPolling()</a> function
if(data == "NEWDATAISAVAILABLE")
    RefreshData(); // this function is generated
                   // by using RegisterFunctionToPostBack()
// <a href="#longPollingFunction%22">. . .</a>

在这种情况下,不会再次执行 longPolling() 函数,因此长轮询循环不会停止。而不是复杂的数据,我们只有关于新数据的消息。

页面刷新

收到 Comet 消息后,我们通过向 asp:UpdatePanelUpdatePanel1)发送回发来执行部分 AJAX 刷新。

function RefreshData()
{
    __doPostBack('UpdatePanel1','')
}

此函数由 RegisterFunctionToPostBack() 方法生成。

// I decided to generate JavaScript Refresh() function, but you can
// write it by yourself and include in "LongPolling.js"
//
// Thanks to:
// http://geekswithblogs.net/mnf/articles/102574.aspx
// http://www.xefteri.com/articles/show.cfm?id=18 How postback works in ASP.NET
// and thanks to Dave Ward hint for calling __doPostBack("UpdatePanel1","") ,
public bool RegisterFunctionToPostBack(string sFunctionName, Control ctrl)
{ 
    // call the postback function with the right ID
    // __doPostBack('" + UniqueIDWithDollars(ctrl) + @"','');
    string js = "    function " + sFunctionName + @"()
            {
            " + ctrl.Page.ClientScript.GetPostBackEventReference(ctrl, "") + @"

            }";
    ctrl.Page.ClientScript.RegisterStartupScript(this.GetType(), sFunctionName, js, true);
    return true;
}

因此,我们不是在 JavaScript 中编写 Comet 消息的解析器并执行页面上的 DOM 操作,而是仅触发 ASP.NET 引擎进行页面的部分刷新。

查询通知

在本文的这一部分,我将尝试展示如何使用 SQL Server 的查询通知来触发 Comet 事件。

SqlDependency 类

要接收“查询通知”,我们可以使用 SqlDependency 类。在 SqlDependency 的 MSDN 文档中,您可以阅读到需要将 SqlDependency 对象与 SqlCommand 对象关联,并订阅 OnChange 事件。然后您必须推测,在完成这些步骤后,您必须执行此命令。执行命令时,您将获得一些数据。当数据从命令更改时,会引发 OnChange 事件。

表格

在我们的例子中,我们对 TestTable 表的新行感兴趣。显然,可以收到关于任何类型更新的通知。

CREATE TABLE [dbo].[TestTable](
    [RecId] [int] IDENTITY(1,1) NOT NULL,
    [Text] [nvarchar](400) NULL,
    [Time] [datetime] NOT NULL CONSTRAINT [DF_TestTable_Time]  DEFAULT (getdate()),

    CONSTRAINT [PK_TestTable] PRIMARY KEY CLUSTERED ( [RecId] ASC )
        WITH (
            PAD_INDEX  = OFF,
            STATISTICS_NORECOMPUTE  = OFF,
            IGNORE_DUP_KEY = OFF,
            ALLOW_ROW_LOCKS  = ON,
            ALLOW_PAGE_LOCKS  = ON)
        ON [PRIMARY]
) ON [PRIMARY]

我们可以使用简单的 INSERT 向此表中插入数据。

INSERT INTO TestTable (Text)
VALUES(N'Hello World!')

步骤 1 - 检查是否需要等待更改

当浏览器请求 ASP.NET 服务器进行新数据通知时,ASP.NET 服务器会检查是否有新数据。如果有,浏览器将在不使用“查询通知”的情况下收到关于新数据的通知。

在我们的例子中,我们只监视插入,所以查询非常简单。我们只检查 MAX(RecId)

// Query for making decision whether data was changed or we must wait.
// In our case we are interested in new records so we select MAX.
private const string queryForCheck = @"
SELECT MAX(RecId)
FROM dbo.TestTable";

// . . .

// 1. First query, to check if we need to wait for changes
using (SqlCommand cmd = new SqlCommand(queryForCheck, conn))
{
    int max = Convert.ToInt32(cmd.ExecuteScalar());
    if (max > lastRecId) // if max > last seen recId
        return false; // No async! New data available right now!
}

步骤 2 - 运行依赖

如果没有新数据,我们创建一个新的 SqlDependency 并进行设置,将其与 SqlCommand 关联,并执行命令。

// This query follows rules of creating query for "Query Notification"
// and is filtered by record ID, because (in this case) we expect only
// "INSERT" of new records. We are not observing old records. To be
// compatible with Query Notification we must use schema name ("dbo"

// in our case) before table! For other compatibility issues you must
// search MSDN for "Creating a Query for Notification" or go to
// http://msdn.microsoft.com/en-us/library/ms181122.aspx
// And don't look at this: (old and incomplete list):
// "Special Considerations When Using Query Notifications" at
// http://msdn.microsoft.com/en-us/library/aewzkxxh%28VS.80%29.aspx
private const string queryForNotification = @"
SELECT RecId
FROM dbo.TestTable
WHERE RecID > @recId";

// . . .

// 2. Second query, to run dependency
SqlDataReader reader;
using (SqlCommand qnCmd = new SqlCommand(queryForNotification, conn))
{
    qnCmd.Parameters.AddWithValue("@recId", lastRecId);

    // Setup dependency which will be used to wait for changes
    depend = new SqlDependency(qnCmd);
    depend.OnChange += Depend_OnChangeAsync; // calback 1

    // Execute second query to run dependency (Query Notif.),
    // and to get content of our table data, that will be used
    // by Query Notification as a starting point for observing
    // changes.
    reader = qnCmd.ExecuteReader();
}

步骤 3 - 处理罕见情况

当执行命令以接收通知时,可能为时已晚。在执行之前,数据可能会被更改(在我们的例子中是插入),我们将不会收到通知。为了防止在“步骤 1”和“步骤 2”之间插入新数据,您可以将它们放在事务中。这将阻止新数据的插入。我倾向于在这种情况下避免在事务中阻塞表,因为我们可以简单地检查在这两个步骤之间是否插入了新数据。

// 3. Make sure that nothing has changed between point 1. and point 2.
//    (just before firing query notification)
bool newData = reader.HasRows;
reader.Close();
if (newData)
{
    // very rare case - data changed before
    // firing Query Notif. (SqlDependency)
    // TODO: test this case by making some Sleep() between 1. and 2.

    // We have new data and we decide not to receive notification:
    depend.OnChange -= Depend_OnChangeAsync;
    depend = null;

    return false; // No async! New data available right now!
}

接收通知

如果没有新数据,我们成功注册了“查询通知”。当有人或某事向 TestTable 插入数据时,我们的 Depend_OnChangeAsync() 将被调用。此方法将调用我们的 ProcessAllWaitingClients()(之前讨论过),它将向客户端传递通知。

结果

精确的时间测量在这里并不重要。最重要的是时间与轮询间隔无关,因为没有轮询间隔(就像在恒定轮询解决方案中一样)。如果您购买更快的服务器,您会更快。但让我们为了好玩而进行一次时间测量。

单击 此处 或 此处 放大。按 Esc 键停止 GIF 动画。 

时间测量使用 SQL INSERTDEFAULT (getdate()),精度为 3.33 毫秒)开始。之后,会触发查询通知,ASP.NET 会收到通知。然后,Comet 通知会发送到浏览器。浏览器发起刷新调用。服务器收到刷新调用。在服务器端,再次测量时间。这次,使用 DateTime.Now(精度为 15 毫秒)来测量时间。在本地计算机上(Intel Core 2 Duo 2.13GHz,3 GB RAM)测量的时间通常在 15 到 60 毫秒之间。

Long Polling Implementation of Comet - Time Measurement

我为什么在本地计算机上进行测量?因为我不在乎网络速度,我只关心我的代码的速度。如果您希望计算您的网络上的速度,请在图的每个箭头中添加一些“ping”或“pong”速度。

© . All rights reserved.