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

通过多个子域调用实现更快的 AJAX Web 服务

starIconstarIconstarIcon
emptyStarIcon
starIcon
emptyStarIcon

3.94/5 (12投票s)

2008 年 8 月 11 日

CPOL

22分钟阅读

viewsIcon

55236

downloadIcon

288

如何通过多个子域的 XMLHttpRequest 调用来获得更快的 AJAX Web 服务。

引言

AJAX 是 Web 2.0 中最重要的技术之一。如今,几乎每个 Web 开发人员都在以某种方式使用 AJAX 调用。Web Service AJAX 调用是 ASP.NET 中一个非常好的功能。当您在服务器端创建一个 Web Service 类时,可以为您的类添加 [System.Web.Script.Services.ScriptService] 属性,ASP.NET 会自动生成相应的 JavaScript 文件,让您能够轻松地从客户端调用您的 Web Service。

在使用 AJAX 调用的过程中,我发现了几种增强 Web Service 调用用法的方法,并最终创建了一个客户端 JavaScript 类 WSAjaxCallsClass 来帮助完成这项任务。如果您使用 ASP.NET AJAX Web Services,就不应该错过这篇文章。请注意,这里描述的技术不仅限于 ASP.NET 本身;但是,本文中给出的实现是针对 ASP.NET 的。

在确定本文标题之前,我曾想过几个标题:

  1. 通过多个子域调用 AJAX Web 服务以提高吞吐量。
  2. 一个 AJAX Web Service 调用管理器,每个 ASP.NET AJAX 开发人员必备。
  3. AJAX Web Service 节流。

但我决定还是用现在的这个标题。

WSAjaxCallsClass 类为您带来了几个好处,但最重要的一个已在本文标题中体现。我将首先介绍这个类,然后详细讨论您能获得的好处。

Web Service 调用管理器类

本文中介绍的代码为您带来以下好处;我将逐一说明并详细解释每个好处。

  • 队列管理器

    根据 Omar Al Zabir 的一篇文章,如果您让浏览器一次执行超过两个 AJAX 调用,它会挂起,在 IE 中情况更糟。我亲自测试过,但无法重现这个问题,也许当前版本的 IE 已经修复了此问题。然而,这并不是我决定做一个队列管理器的主要原因。有时,在您的代码中,开发人员没有注意到这类情况,您会通过响应最终用户的一些事件或操作来发起 AJAX 调用。我当时正在开发一个发送 SMS(短信服务)的应用程序,其中一部分代码是根据消息文本长度、是否为 Unicode、选择的收件人数量以及每个收件人的目的地来计算当前要发送的短信成本。有时,如果用户的输入足够快,会在第一个调用尚未完成时生成对 Calculate 函数的多个调用。有时,我最终会得到多达 5 或 6 个不必要的调用,而实际上只需要一个。通过将调用排队,我们可以检测到在第一个调用返回之前多次调用同一个 AJAX 函数的情况,我们可以决定忽略多余的调用,或者我们可以决定总是保留队列中的最后一个调用,这对于大多数情况来说是更合乎逻辑的选择。

  • 通用的成功、失败方法

    当您调用 WSAjaxCallsClass 类的 Initialize 方法时,它允许您为所有 AJAX 调用提供通用的成功、失败方法,之后控制权才会传递给 AJAX 调用原始的成功/失败方法。让我解释一下为什么这很重要。当我在 Web 应用程序中使用 AJAX 调用时,我倾向于从所有的 Web Service 调用中返回同一个对象,并且不允许我服务器上的 Web Service 方法抛出异常(稍后我会告诉您原因)。为了让我的想法更清晰,并解释为什么这很重要,让我向您展示我使用的类:GenericReturnObject

    该类的第一个成员是一个名为 errorLevel 的枚举。errorLevel 的值是以下之一:

    • None:如果没有错误。
    • Warning:如果异常或错误不是致命的,只是一个警告,比如“您的点数不足以发送此短信”。
    • Fatal:如果抛出了致命错误,比如数据库连接错误。
    • SessionExpired:这是一个特殊的错误,表示服务器上的会话不存在(已过期)。客户端脚本对此错误的反应是,将最终用户重定向到登录页面并要求他重新登录。

    下一个成员 ret 是一个布尔值,表示 AJAX 调用的成功或失败。如果为 false,则会检查 errorLevel 的值以获取更多详细信息。成员 message 包含错误的描述。成员 payload 是您想传递给客户端的额外对象。得益于 JSON,这个对象将被正确序列化并发送到客户端。成员 payloadTypeName 包含填充在 payload 成员中的对象类型的名称,以备您在客户端需要时使用。除了这些成员之外,您还会发现一些静态方法,可以帮助您以紧凑的方式创建此对象。让我们看一个我定义的典型 Web Service 方法:

    [WebMethod(true)]
    public GenericReturnObject GetContactNameFromID(int contactid)
    {            
       try
       {
          if (!IsLoggedIn)
             return GenericReturnObject.CreateSessionExpired();
          
          string contactName = Application.IBusinessLayer().IContacts.
                               GetContactNameFromID(AccountID, contactid);
          return GenericReturnObject.CreateSuccess(“”, 
                 new { ContactName = contactName });
       }
       catch (Exception ex)
       {
          return GenericReturnObject.CreateFromException(ex);
       }
    }

    一个典型的 Web Service 方法会首先检查用户是否已登录(会话是否过期),如果会话已过期,它将创建一个具有相应 errorLevel 值的 GenericReturnObject 并将其返回给客户端以正确处理这种情况。接下来,我们继续执行函数的核心部分并将其传递给业务逻辑层,然后我们创建一个成功的 GenericReturnObject 并返回一个包含我们想要的返回值的匿名对象(ContactName)。请注意我如何捕获所有异常并返回一个带有致命错误级别和异常信息的 GenericReturnObject,异常信息在对象的 message 成员中。如果我让 Web Service 方法抛出异常,那么在客户端,failure 函数将被调用。因此,您将不得不在客户端的 failure 函数中处理两种类型的错误:来自您的 Web Service 调用的异常(可能是业务逻辑错误或致命错误),以及来自调用 AJAX Web Service 方法本身和浏览器与服务器之间通信的错误;这会使事情变得复杂。

    为了让我们的工作更轻松,我们不允许 Web Service 调用抛出异常。这样,您客户端的 failure 函数可以只专注于 AJAX 通信问题,您可以有一个通用的 failure 函数和一种通用的处理方式。这意味着异常现在被返回到客户端的 success 函数,您将必须处理业务调用 Web Service 不成功的情况(当 GenericReturnObject 具有 errorLevel 时)。嗯,这就是通用 success 函数的亮点所在。通用 success 函数现在可以处理返回的 GenericReturnObject 中包含通用错误的情况,例如会话过期、警告(通过弹出消息响应),或通过显示致命错误弹出窗口等方式处理致命错误。最后,您特定 Web Service 调用的 success 函数(而不是通用 success 函数)可以专注于您刚刚调用的方法的业务逻辑,而不必担心更常见的错误。

  • 加速您的 AJAX 调用

    最后但同样重要的是,本文的主题——主要好处。WSAjaxCallsClass 类如何加速我的 AJAX 调用?开发人员有时会使用一种技术来加速网站加载,即从网站的不同域或子域加载不同的资源。例如,如果我们的网站在 example.com 下,那么我们会将图片放在 images.example.com 域下,而不是主域。浏览器每个域会打开有限的最大连接数(通常是两个),所以如果您有 10 个资源,那么这 10 个资源将以最多同时两个连接的方式加载。将图片放在不同的域下会使浏览器为图片打开两个连接,为网站的其余部分打开两个连接。如果您愿意,可以为多个子域这样做,而不仅仅是两个。但是,添加太多子域最终会降低性能。一个良好、平衡的方法是为图片、脚本和 CSS 文件使用不同的域。

    XMLHttpRequest (XHR) 也受到页面所属域的连接数限制(通常每个域两个)。现在,如果我们想遵循同样的技术,将我们的 Web Service 调用划分到几个子域中,我们很快就会悲哀地发现这是做不到的,因为浏览器会阻止您,不允许您的脚本访问除您运行脚本的页面域之外的其他域的 AJAX 调用。那么,为什么 XHR 与其他资源不同,为什么我们有这个限制呢?嗯,这是出于安全原因,这样如果您的网站存在 XSS 漏洞,有人就无法注入代码通过 AJAX 与页面域之外的位置(比如攻击者自己的域)通信,这使得 XSS 比现在更加危险。幸运的是,有一种方法可以绕过这个限制,我们可以通过允许同时进行更多的并行连接来使 AJAX 调用更快。我们将打破每个域两个连接的限制。

    那么,我们如何打破这个限制呢?我们将使用隐藏的 IFrame,每个 IFrame 将连接到主站的一个独立子域,我们的 WSAjaxCallsClass 类将负责根据哪个 IFrame 空闲并可以接受调用,来适当地将 AJAX 请求分配到这些 IFrame 上。

    比如说,我们希望同时进行四个并行连接(而不是浏览器默认的两个),那么我们创建四个隐藏的 IFrame,并让每个 IFrame 连接到一个独立的子域。例如,如果主站是 example.com,那么子域将是 1.example.com、2.example.com、3.example.com 和 4.example.com。现在,要进行此设置,我们需要做两件事。首先,我们需要设置我们的 DNS 服务器来解析这些子域。我们可以轻松地在我们的 DNS 服务器中添加一条记录,使用通配符来解析所有这些子域(*.example.com)。如果像这样在您的顶级域中添加通配符让您感觉不舒服,那么您可以选择任何一个子域并为其添加通配符(*.ajax.example.com)。接下来,我们需要告诉 IIS 将这些额外的域映射到同一个网站。IIS 不允许您使用通配符,所以您需要手动添加您的子域。您可以通过为网站添加额外的“主机头”来实现。在 IIS 6 中,您可以通过在网站的“属性”对话框的“网站”选项卡上按“高级”按钮来做到这一点。

    现在,一些开发人员可能会对与隐藏 IFrame 的通信感到疑惑。如果 IFrame 与父页面位于不同的域(例如 example.com 和 *.example.com),父页面将无法(通过 JavaScript)与隐藏的 IFrame 通信(反之亦然),那么我们如何克服这个障碍呢?答案是 document.domain。如果您的 IFrame 和父页面都在其脚本中将 document.domain 变量设置为一个共同的上级域,那么它们就能够通信。在这种情况下,我们将它们都设置为 example.com。

    既然我们已经在父文档和隐藏的 IFrame 之间建立了适当的通信,现在是时候将适当的 AJAX 调用委托给 IFrame,以使我们的 ASP.NET AJAX 调用保持透明。我们如何实现这一点?答案深藏在 ASP.NET AJAX 库中。如果您在进行 AJAX 调用时进行一些调试,您会到达一个名为 Sys$Net$WebServiceProxy$invoke 的函数,然后最终到达一个名为 Sys$Net$XMLHttpExecutor$executeRequest 的函数。这两个函数被我们的类拦截并替换为不同的版本。

    第一个函数 Sys$Net$WebServiceProxy$invoke 在请求新的 Web Service 调用时被调用。它被重写的新版本将停止 Web Service 调用,并仅将其添加到 WSAjaxCallsClass 类的内部队列中以供后续处理。第二个函数 Sys$Net$XMLHttpExecutor$executeRequest 是一个很棒的函数,因为它负责进行实际的 AJAX 调用。它创建 XMLHttpRequest 对象并启动对服务器的实际调用,幸运的是,它是一个非常内聚的函数。它所需要的只是在其上下文中运行的“this”对象。这个函数被重写的新版本将在 AJAX 调用发生前拦截它们,并根据哪个 IFrame 空闲,将每个拦截的调用发送到我们的一个 IFrame,以便该 IFrame 可以运行它自己加载的 Sys$Net$XMLHttpExecutor$executeRequest 副本,但使用的是我们给它的“this”上下文。

    下一节将展示一个概念验证 Web 应用程序,向您展示我们获得的速度优势,但不要只相信我的话,您可以下载并自己运行以查看结果,或者您可以在这里查看一个正在运行的版本。

    概念验证应用程序附于本文,请查看我的博客以获取最新版本。请阅读下一节,了解如何在本地计算机上运行该项目的一些说明。

概念验证和结果

我将在这里展示屏幕截图,并在图片下方附上详细说明。

屏幕 1

screen1.gif

第一个屏幕介绍了该应用程序。概念验证项目向您展示了一个包含以下列的表格:名称、开始、结束和时间线。

“名称”列包含被调用方法的名称,“开始”列包含开始时间,“结束”列包含结束时间,“时间线”列则是一个简单的时间线图形表示。时间以分钟、秒和毫秒给出。表格中有 20 行,对应 20 个独立的 AJAX 调用。在表格标题中,有一个启动调用的按钮(这里您可以看到它被禁用了,因为它已经被按下并运行了),还有一个神奇的复选框“使用多个子域”。当您选择多域选项时,IFrame 的魔力就会发生。复选框旁边的下拉列表显示了您希望队列管理器同时调用的并行连接数。第一个屏幕是在没有多域选项的情况下,一次运行一个连接。请看调用是如何一个接一个完成的(忽略最后三个框,它们似乎是同时运行的,但这是因为 HTML 表格不够宽)。这些调用在服务器端等待 1000 毫秒后返回,您看到的额外毫秒数(例如,第一个调用:1028 毫秒)是网络通信的开销时间。请注意总时间,大约 20 秒,每个调用 1 秒左右。请注意,这些屏幕截图是我在本地机器上运行应用程序时截取的,以使结果更清晰。

屏幕 2

screen2.gif

此屏幕显示了在没有多域选项的情况下,使用两个并行连接运行的情况。注意每两个调用是如何同时运行的。同时注意总时间已从 20 秒降至 10 秒,时间减半,因为我们现在是两个一组地运行。此时,我们仍未使用 IFrame,我们使用的是正常的浏览器行为。

屏幕 3

screen3.gif

屏幕 3 显示我们请求同时进行三个并行连接,并且仍未使用多域选项。请注意时间线列;注意即使我们一次向浏览器发送三个调用,浏览器也只同时运行两个调用,并将第三个调用在内部排队。总时间仍然是 10 秒。

屏幕 4

screen4.gif

屏幕 4 显示我们请求同时进行四个并行连接,并且仍未使用多域选项。再次注意时间线列;注意即使我们一次向浏览器发送四个调用,浏览器也只同时运行两个调用,并将第三和第四个调用在内部排队。总时间仍然是 10 秒。

屏幕 5

screen5.gif

屏幕 5 显示我们请求一次一个连接,但这次我们使用了多域功能(复选框已勾选)。该页面现在内部使用一个隐藏的 IFrame 来进行 AJAX 调用。注意每个调用,注意这些调用比屏幕 1 中的调用稍微长一些。这是将调用委托给 IFrame 并让 IFrame 将结果返回给我们的额外开销。忽略最后的调用一个叠一个,这只是因为 HTML 表格不够宽。总时间约为 20 秒。

屏幕 6

screen6.gif

屏幕 6 显示我们请求一次两个连接,但这次我们使用了多域功能(复选框已勾选)。该页面现在内部使用两个隐藏的 IFrame 来进行 AJAX 调用。注意每个调用,注意这些调用比屏幕 2 中的调用稍微长一些。同样,这是将调用委托给 IFrame 并让 IFrame 将结果返回给我们的额外开销。总时间约为 10 秒。下一个屏幕是有趣的部分。

屏幕 7

screen7.gif

屏幕 7 是第一个开始显示速度优势的屏幕。现在我们请求使用多域选项同时进行三个并行连接。请注意我们现在如何能够突破浏览器每个域两个并行连接的限制。我们的 AJAX 调用现在以三个并行调用的方式被处理。请注意总时间现在降到了 10 秒以下,大约是 7.5 秒。

屏幕 8

screen8.gif

屏幕 8 继续展示速度优势。现在我们能够使用四个隐藏的 IFrame 同时运行四个并行连接。请注意总时间已降至略高于 5 秒,与通过单个域进行 AJAX 调用相比,速度提升了 50%。

屏幕 9

screen9.gif

屏幕 9 使用五个并行连接,通过五个隐藏的 IFrame 实现。请注意总时间如何进一步减少。

屏幕 10

screen10.gif

屏幕 10 使用六个并行连接,通过六个隐藏的 IFrame 实现。请注意总时间如何进一步减少。

屏幕 11

screen11.gif

屏幕 11 使用七个并行连接,通过七个隐藏的 IFrame 实现。请注意总时间已降至 3.3 秒,而标准调用 AJAX 方法的方式需要 10 秒,这大约是 70% 的速度提升。进一步增加连接数将不会有太大帮助;需要根据应用程序决定要使用的最佳并行连接数。

在我的测试中,我有一个有趣的发现。我使用 Fiddler 仔细观察了每个受支持的浏览器(IE、FF、Opera、Safari)在打开到服务器的连接顺序方面的行为。首先,我将向您展示四个屏幕截图。每个测试都是用不同的浏览器进行的。使用了 Fiddler,并且在每个屏幕中都通过 20 个隐藏的 IFrame 建立了 20 个并行连接。Fiddler 是一个 HTTP 调试应用程序,它充当代理,为您提供有关 Web 应用程序行为的宝贵信息。

屏幕:IE 使用 Fiddler,通过 20 个隐藏的 IFrame 建立 20 个并行连接

screenIEFiddler.gif

screenFiddlerIE.gif

屏幕:FF 使用 Fiddler,通过 20 个隐藏的 IFrame 建立 20 个并行连接

screenFF.gif

screenFiddlerFF.gif

屏幕:Opera 使用 Fiddler,通过 20 个隐藏的 IFrame 建立 20 个并行连接

screenOperaFiddler.gif

screenFiddlerOpera.gif

屏幕:Safari 使用 Fiddler,通过 20 个隐藏的 IFrame 建立 20 个并行连接

screenSafariFiddler.gif

screenFiddlerSafari.gif

一个有趣的发现是,IE 似乎以栈的顺序(后添加,先服务)向服务器发送请求(对于 IFrame),这对我来说似乎不合逻辑,并且自然会打乱您的调用顺序!Firefox 采用正常的顺序(队列),即先到先服务。Safari 的做法不同;它似乎没有特定的顺序,可能有一个内部排队系统,根据其他因素决定哪个连接先行。Opera 似乎也按顺序进行,很像 Firefox,但顺序并不完美,它似乎也基于其他因素做出决定。

如果您要下载项目并在本地自行测试,您需要在您的 hosts 文件(C:\Windows\System32\drivers\etc\hosts)中添加一些虚拟域。编辑此文件并添加一些条目,如下所示:

127.0.0.1    localhost.com
127.0.0.1    1.localhost.com
127.0.0.1    2.localhost.com

一直添加到 20.localhost.com,然后在 Visual Studio 中运行您的项目时,您还需要做两件事:

  1. 打开 default.aspx 文件,将文本 "http://?.localt.com:54835" 替换为您在 hosts 文件中添加的条目的顶级域。按照上面的例子,那将是 “?.localhost.com:54835”。我已将应用程序的项目文件配置为始终使用上述端口 54835。如果出于某种原因,您在运行应用程序时使用了不同的端口,那么您也需要更新端口号。有一个辅助函数叫做 GetRandomizingSubdomainFromDocument,它会从当前页面的 URL 中为您获取所需的随机子域字符串,这样您就不需要硬编码了。
  2. 您需要做的第二件事是,当您从 Visual Studio 运行应用程序时,它会用“localhost”域打开您的浏览器。如果您这样尝试运行示例,您会在 JavaScript 中得到访问被拒绝的错误,您应该在浏览器中替换 localhost,并将其指向您在 hosts 文件中创建的新的顶级虚拟域。按照上面的例子,那将是 localhost.com。我希望我已解释得足够清楚。

在您的应用程序中使用代码

在本节中,我将写出将本文中的代码添加到您自己的 ASP.NET Web 应用程序所需的步骤。该代码适用于 IE7、Firefox 2.0、Opera 9.5 和 Safari 3.0。首先,您需要向您的项目添加三个文件。您可以将它们添加到任何位置,但我建议您将它们添加到 scripts 子文件夹下。如果您决定将这些文件添加到其他文件夹下,您需要更新 WebServiceAjaxCallsHelper.js 文件中的变量 WSAjaxCallsClass_ProxyScriptLocation 为相应的值。

  1. vnetLinkedlist.js:一个双向链表的实现。如果您需要双向链表,可以在任何您想要的项目中使用此文件。WSAjaxCallsClass 类需要链表来跟踪排队的调用。
  2. WebServiceAjaxCallsHelper.js:这是包含大部分代码的主要 JavaScript 文件。它包含了 WSAjaxCallsClass 类。在撰写本文时,其版本为 1.0.9。
  3. WebServiceAjaxCallsHelperIframe.aspx:此表单被加载到每个隐藏的 IFrame 中。它包含执行委托的 ASP.NET AJAX 调用的代码。

接下来,您需要向希望调用 ASP.NET AJAX Web Service 方法的页面添加一个 ScriptManager(或 ScriptManagerProxy)。请确保在 ScriptManager 的 scripts 部分添加两个额外的文件,vnetLinkedlist.jsWebServiceAjaxCallsHelper.js。您可以查看本文附带的项目文件中的 default.aspx 文件。接下来,您需要调用 WSAjaxCallsClass 类的 Initialize 函数。

WSAjaxCallsClass.Initialize(GenericSuccess, GenericFailure, 
    "http://?.localt.com:54835", channels, true, true);

Initialize 方法接受六个参数。第一个参数是通用的成功方法。第二个参数是通用的失败方法。第三个参数是子域 URL,它应该包含一个问号,引擎将根据创建的不可见 IFrame 的数量用适当的数字替换它。请注意,如果将 null 传递给第三个参数,您将禁用多域 AJAX 调用功能。第四个参数接受您希望拥有的并行连接数。第五个参数接受一个布尔值,决定是否忽略在方法返回前对同一方法的重复调用。第六个参数接受一个布尔值,决定如何忽略重复调用;如果您传递 true,它将始终保留最后一个排队的调用(这是合乎逻辑的选择,并且在大多数情况下都有效);如果您传递 false,那么重复的调用将被忽略并完全丢弃。请注意,公共 JavaScript 代码已使用 XML 注释进行了适当的注释,以便在 Visual Studio 2008 中提供 JavaScript 智能感知。

现在,每当您想进行 AJAX 调用时,调用都会被透明地路由到 WSAjaxCallsClass 类。基本上就是这样了。不要忘记您需要在 IIS 和 DNS 级别上执行的步骤,本文前面已经描述过。

如果您需要一个客户端 JavaScript 双向链表实现,您也可以在自己的项目中使用 vnetLinkedlist.js 文件。我稍后会在我的博客上发文描述如何使用这个文件。

接下来呢?

这里描述的使用隐藏 IFrame 通过多个子域启用多个 AJAX 调用的技术,也可以用于 ASP.NET 以外的语言;但是,提供的实现是针对 ASP.NET 的。

我将要写的下一篇文章/代码将在 ASP.NET UpdatePanel 上实现这种技术,以同样的方式,通过多个子域进行并行 AJAX 调用,从而使 UpdatePanel 的速度更快。如果读者对这个想法有任何评论,我很想知道。

有人建议我为超时的 AJAX 调用添加自动重试功能。在我的工作中,我从未遇到过很多超时问题,也没有遇到过一次或多次重试就能解决问题的情况。如果您,读者,认为我应该添加这样一个功能,请让我听到更多关于它的信息,非常欢迎您的评论。另外,如果读者有任何其他评论或希望添加到这个类中的功能,请在我的博客上告诉我。

我希望这篇文章能让许多开发人员和 Web 应用程序受益。如果这段代码帮助您加速了您的特定网站,我很乐意听到您的成功故事。

© . All rights reserved.