posted @ 2006-11-14 13:12 永不言败 阅读(964) 评论(0) 编辑
发布日期: 2006-4-19 | 更新日期: 2006-4-19

下载本文的代码: WickedCode0510.exe (123KB)

*
本页内容
ASP.NET 1.x 中的异步页 ASP.NET 1.x 中的异步页
ASP.NET 2.0 中的异步页 ASP.NET 2.0 中的异步页
异步数据绑定 异步数据绑定
异步调用 Web 服务 异步调用 Web 服务
异步任务 异步任务
包装它 包装它

ASP.NET 2.0 提供了大量新功能,其中包括声明性数据绑定和母版页,成员和角色管理服务等。但我认为最棒的功能是异步页,接下来让我告诉您其中的原因。

当 ASP.NET 接收针对页的请求时,它从线程池中提取一个线程并将请求分配给该线程。一个普通的(或同步的)页在该请求期间保留线程,从而防止该线程用于处理其他请求。如果一个同步请求成为 I/O 绑定(例如,如果它调用一个远程 Web 服务或查询一个远程数据库,并等待调用返回),那么分配给该请求的线程在调用返回之前处于挂起状态。这影响了可伸缩性,原因是线程池的可用线程是有限的。如果所有请求处理线程全部阻塞以等待 I/O 操作完成,则其他请求排入队列等待线程释放。最好的情况是吞吐量减少,因为请求等待较长的时间才能得到处理。最坏的情况则是该队列填满,并且 ASP.NET 因 503“Server Unavailable”错误使后续请求失败。

异步页为由 I/O 绑定的请求引起的问题提供优秀的解决方案。页处理从线程池线程开始,但是当一个异步 I/O 操作开始响应 ASP.NET 的信号之后,该线程返回线程池。当该操作完成时,ASP.NET 从线程池提取另一个线程,并完成该请求的处理。由于线程池线程得到了更高效的使用,因此提高了可伸缩性。那些挂起等待 I/O 完成的线程现在可用于服务其他请求。直接的受益方是不执行长时间 I/O 操作并因此可以快速进出管线的请求。长时间等待进入管线会对此类请求的性能带来不小的负面影响。

ASP.NET 2.0 Beta 2 异步页基础结构的相关文档很少。让我们展望一下异步页的前景,从而弥补这点不足。请记住,本专栏涉及 ASP.NET 2.0 和 .NET Framework 2.0 的测试版本。

ASP.NET 1.x 中的异步页

ASP.NET 1.x 本质上不支持异步页,但是通过坚韧的努力和不懈地创新可以生成异步页。有关更多概述信息,请参阅 MSDN®Magazine 2003 年 6 月刊的文章“Use Threads and Build Asynchronous Handlers in Your Server-Side Web Code”,该文章的作者是 Fritz Onion。

这里的技巧是,在一个页的代码隐藏类中实现 IhttpAsyncHandler,从而提示 ASP.NET 通过调用 IHttpAsyncHandler.BeginProcessRequest 来处理请求,而不是通过调用该页的 IHttpHandler.ProcessRequest 方法。然后,您的 BeginProcessRequest 实现可以启动另一个线程。该线程调用 base.ProcessRequest,使得页进入其常规请求处理生命周期(完成诸如 Load 和 Render 的事件),但是在非 ThreadPool 线程上例外。同时,启动新线程之后 BeginProcessRequest 立即返回,从而允许执行 BeginProcessRequest 的线程返回线程池。

这是基本思想,但细节中还有很多注意事项。其中,您需要实现 IAsyncResult,并从 BeginProcessRequest 中返回它。这通常意味着创建一个 ManualResetEvent 对象,并且当 ProcessRequest 在后台线程中返回时向其发送信号。此外,您必须提供调用 base.ProcessRequest 的线程。遗憾的是,多数用于将工作移到后台线程的常规技术(包括 Thread.Start、ThreadPool.QueueUserWorkItem 和异步委托)在 ASP.NET 应用程序中都是起反作用的,因为它们或者从线程池“偷盗”线程,或者有不受限制的线程增长的危险。正确的异步页实现使用自定义线程池,但自定义线程池类不容易编写(有关更多信息,请参阅 MSDN Magazine 2005 年 2 月刊的 .NET Matters 专栏)。

主要是在 ASP.NET 1.x 中生成异步页并非不可能,而是有些乏味。在尝试一、两次之后,您不禁会想一定会有更好的方法。目前,这个好方法就是 ASP.NET 2.0。

ASP.NET 2.0 中的异步页

ASP.NET 2.0 极大地简化了生成异步页的方式。首先使用该页的 @ Page 指令引入 Async=“true” 属性,如下所示:

在后台,这会通知 ASP.NET 在该页中实现 IhttpAsyncHandler。接下来,您在该页生存期的早期(例如,在 Page_Load 时)调用新的 Page.AddOnPreRenderCompleteAsync 方法来注册一个 Begin 方法和一个 End 方法,如以下代码所示:

AddOnPreRenderCompleteAsync (
new BeginEventHandler(MyBeginMethod),
new EndEventHandler (MyEndMethod)
);

接下来的操作比较有趣。该页经历其常规处理生命周期,直到 PreRender 事件刚刚引发之后。然后,ASP.NET 调用使用 AddOnPreRenderCompleteAsync 注册的 Begin 方法。Begin 方法的任务是启动诸如数据库查询或 Web 服务调用的异步操作,并立即返回。此时,分配给该请求的线程返回到线程池。此外,Begin 方法返回 IAsyncResult,它允许 ASP.NET 确定异步操作完成的时间,这个时候 ASP.NET 从线程池提取线程并调用 End 方法。当 End 返回之后,ASP.NET 执行该页生命周期其余的部分,包括呈现阶段。在 Begin 返回以及调用 End 之间,该请求处理线程可以自由地服务于其他请求,直至调用 End 且延迟呈现为止。由于 2.0 版的 .NET Framework 提供多种执行异步操作的方式,因此,您甚至无需实现 IasyncResult。反之,Framework 替您实现。

1 中的代码隐藏类提供一个示例。响应页包含一个 ID 为“Output”的 Label 控件。该页使用 System.Net.HttpWebRequest 类提取 http://MSDN.microsoft.com 的内容。然后,它分析返回的 HTML,并将它发现的全部 HREF 目标列表写出到 Label 控件。

由于 HTTP 请求需要较长时间才能返回,因此,AsyncPage.aspx.cs 异步执行对它的处理。它在 Page_Load 中注册 Begin 和 End 方法,并且在 Begin 方法中,它调用 HttpWebRequest.BeginGetResponse 启用一个异步 HTTP 请求。BeginAsyncOperation 将由 BeginGetResponse 返回的 IAsyncResult 返回到 ASP.NET,导致当 HTTP 请求完成时,ASP.NET 调用 EndAsyncOperation。EndAsyncOperation 进而分析该内容并将结果写入 Label 控件,之后进行呈现,并且 HTTP 响应返回到浏览器。


图 2 同步和异步页处理

图 2 说明 ASP.NET 2.0 同步和异步页之间的区别。当请求同步页时,ASP.NET 为该请求分配线程池中的一个线程,并在该线程上执行页。如果该请求停止执行 I/O 操作,则挂起线程,直到完成操作,从而可以完成该页的生命周期。相反,异步页通常通过 PreRender 事件执行。然后,调用使用 AddOnPreRenderCompleteAsync 注册的 Begin 方法,之后,该请求处理线程返回线程池。Begin 启动一个异步 I/O 操作,当该操作完成时,ASP.NET 从线程池提取另一个线程并调用 End 方法,并且在该线程上执行该页生命周期的其余部分。


图 3 跟踪输出显示异步页的异步点

对 Begin 的调用标记该页的“异步点”。图 3 中的跟踪准确显示异步点发生在何处。如果调用,则必须在异步点之前调用 AddOnPreRenderCompleteAsync — 即,不晚于该页的 PreRender 事件。

异步数据绑定


通常情况下,ASP.NET 页并不使用 HttpWebRequest 直接请求其他页,但它们通常查询数据库并对结果进行数据绑定。因此,您将如何使用异步页执行异步数据绑定呢? 4 中的代码隐藏类显示进行此操作的一种方式。

AsyncDataBind.aspx.cs 与 AsyncPage.aspx.cs 使用相同的 AddOnPreRenderCompleteAsync 模式。但是,AsyncDataBind.aspx.cs 的 BeginAsyncOperation 方法调用 ADO.NET 2.0 中的新方法 SqlCommand.BeginExecuteReader(而非 HttpWebRequest.BeginGetResponse),以执行一个异步数据库查询。当调用完成时,EndAsyncOperation 调用 SqlCommand.EndExecuteReader 以获取 SqlDataReader,然后将其存储在私有字段中。在用于 PreRenderComplete 事件(在异步操作完成但呈现该页之前引发)的事件处理程序中,AsyncDataBind.aspx.cs 之后将 SqlDataReader 绑定到 Output GridView 控件。从外观上看,该页类似于使用 GridView 呈现数据库查询结果的普通(同步)页。但是在内部,该页更具可伸缩性,因为它并不挂起线程池线程以等待查询返回。

异步调用 Web 服务


另一个通常由 ASP.NET Web 页执行的、与 I/O 相关的任务是调出 Web 服务。由于 Web 服务调用花费较长时间才能返回,因此,执行它们的页是用于异步处理的理想选择。

5 显示生成调出 Web 服务的异步页的方式。它使用 1 4 中相同的 AddOnPreRenderCompleteAsync 机制。该页的 Begin 方法通过调用 Web 服务代理的异步 Begin 方法启动一个异步 Web 服务调用。该页的 End 方法在私有字段中缓存对 Web 方法返回的 DataSet 的引用,并且 PreRenderComplete 处理程序将 DataSet 绑定到 GridView。作为参考,该调用的目标 Web 方法如以下代码所示:

[WebMethod]
public DataSet GetTitles ()
{
string connect = WebConfigurationManager.ConnectionStrings
["PubsConnectionString"].ConnectionString;
SqlDataAdapter adapter = new SqlDataAdapter
("SELECT title_id, title, price FROM titles", connect);
DataSet ds = new DataSet();
adapter.Fill(ds);
return ds;
}

这只是其中一种方式,但并不是唯一的方式。.NET Framework 2.0 Web 服务代理支持两种对 Web 服务进行异步调用的机制。一个是 .NET Framework 1.x 和 2.0 Web 服务代理中的每方法 Begin 和 End 方法。另一个是仅由 .NET Framework 2.0 的 Web 服务代理提供的新 MethodAsync 方法和 MethodCompleted 事件。

如果一个 Web 服务有一个名为 Foo 的方法,那么除了具有名为 Foo、BeginFoo 和 EndFoo 的方法外,.NET Framework 版本 2.0 Web 服务代理还包括名为 FooAsync 的方法和名为 FooCompleted 的事件。可以通过注册 FooCompleted 事件的处理程序并调用 FooAsync 来异步调用 Foo,如下所示:

proxy.FooCompleted += new FooCompletedEventHandler (OnFooCompleted);
proxy.FooAsync (...);
...
void OnFooCompleted (Object source, FooCompletedEventArgs e)
{
// Called when Foo completes
}

当异步调用由于 FooAsync 完成而开始时,将引发 FooCompleted 事件,从而导致调用 FooCompleted 事件处理程序。包装该事件处理程序 (FooCompletedEventHandler) 的委托和传递给它的第二个参数 (FooCompletedEventArgs) 都随 Web 服务代理一起生成。可通过 FooCompletedEventArgs.Result 访问 Foo 的返回值。

6 展示使用 MethodAsync 模式异步调用 Web 服务的 GetTitles 方法的代码隐藏类。从功能上讲,该页等同于 5 中的页。但其内部实现则大为不同。AsyncWSInvoke2.aspx 包括一个 @ Page Async=“true” 指令,类似于 AsyncWSInvoke1.aspx。但是,AsyncWSInvoke2.aspx.cs 并不调用 AddOnPreRenderCompleteAsync;它注册一个用于 GetTitlesCompleted 事件的处理程序,并调用 Web 服务代理上的 GetTitlesAsync。ASP.NET 仍然延迟呈现该页,直到 GetTitlesAsync 完成。在内部,当异步调用开始以及完成时,它使用 System.Threading.SynchronizationContext 的一个实例(2.0 的一个新类)接收通知。

使用 MethodAsync 而非 AddOnPreRenderCompleteAsync 实现异步页有两个优势。首先,MethodAsync 将模拟、区域性和 HttpContext.Current 注入 MethodCompleted 事件处理程序,而 AddOnPreRenderCompleteAsync 则不然。其次,如果该页进行多个异步调用,而且必须延迟呈现直到所有调用完成,则使用 AddOnPreRenderCompleteAsync 要求您生成一个在所有调用完成前保持无信号状态的 IasyncResult。使用 MethodAsync,这样的操作就不是必需的;您只需放置这些调用(数量不限),ASP.NET 引擎延迟该呈现阶段,直到最后一个调用返回。

异步任务


MethodAsync 是从异步页进行多个异步 Web 服务调用并延迟呈现阶段直到所有调用完成的一个简便方法。但如果您想在一个异步页中执行若干异步 I/O 操作,而且这些操作不涉及 Web 服务,那该如何呢? 这么说,可以反过来生成一个 IAsyncResult,它可以返回到 ASP.NET 以允许它了解最后一个调用何时完成的吗? 幸运的是,答案是否定的。

在 ASP.NET 2.0 中,System.Web.UI.Page 类引入了另一个方法来简化异步操作: RegisterAsyncTask。RegisterAsyncTask 比 AddOnPreRenderCompleteAsync 具有四个优势。首先,除了 Begin 和 End 方法,RegisterAsyncTask 还允许您注册当异步操作长时间无法完成时调用的超时方法。您可以通过在该页的 @ Page 指令中包含 AsyncTimeout 属性以声明性方式设置超时。AsyncTimeout="5" 将超时设置为 5 秒。第二个优势是,您可以在一个请求中多次调用 RegisterAsyncTask 来注册若干异步操作。和使用 MethodAsync 一样,ASP.NET 延迟呈现该页,直到所有操作完成。第三,您可以使用 RegisterAsyncTask 的第四个参数将状态传递给 Begin 方法。最后,RegisterAsyncTask 将模拟、区域性和 HttpContext.Current 注入 End 和 Timeout 方法。正如本文前面提到的,使用 AddOnPreRenderCompleteAsync 注册的 End 方法的情况则不然。

在其他方面,依赖于 RegisterAsyncTask 的异步页与依赖于 AddOnPreRenderCompleteAsync 的异步页相类似。它仍然需要 @ Page 指令(或等效的编程指令,它会将该页的 AsyncMode 属性设置为 true)中的 Async=“true” 属性,而且它仍然与平时一样通过 PreRender 事件执行,此时调用使用 RegisterAsyncTask 注册的 Begin 方法,而且进一步保持请求处理直到最后一个操作完成。例如, 7 中的代码隐藏类在功能上与 1 中的等效,但是它使用 RegisterTaskAsync 而非使用 AddOnPreRenderCompleteAsync。请注意名为 TimeoutAsyncOperation 的超时处理程序,如果 HttpWebRequest.BeginGetRequest 长时间无法完成,将调用该处理程序。相应的 .aspx 文件包括一个将超时间隔设置为 5 秒的 AsyncTimeout 属性。还请注意传给 RegisterAsyncTask 的第四个参数(可用于将数据传送到 Begin 方法)的 null。

RegisterAsyncTask 的主要优势在于,它允许异步页引发多个异步调用,并延迟呈现直到所有调用完成。它也很好地适用于单个异步调用,而且它提供了 AddOnPreRenderCompleteAsync 不具有的超时选项。如果生成一个只进行一个异步调用的异步页,您可以使用 AddOnPreRenderCompleteAsync 或 RegisterAsyncTask。但对于放置两个以上异步调用的异步页,RegisterAsyncTask 极大地简化了您的操作。

由于超时值是每页而非每调用设置,因此您可能想知道是否能改变单个调用的超时值。简单的回答是否。您可以通过以编程方式修改页的 AsyncTimeout 属性,逐个请求地更改超时,但是您无法将不同超时分配给从同一请求初始化的不同调用。

包装它


现在,您已经了解了 ASP.NET 2.0 中异步页的实质。它们在即将推出的 ASP.NET 版本中非常易于实现,并且其体系结构允许您在一个请求中批处理多个异步 I/O 操作,并延迟该页的呈现直到所有操作完成。通过与异步 ADO.NET 和 .NET Framework 中的其他新异步功能相结合,异步 ASP.NET 页针对因充满线程池而限制可伸缩性的 I/O 绑定请求问题提供了解决方案。

当生成异步页时最后需要注意的一点是,不应该启动来自 ASP.NET 使用的同一线程池的异步操作。例如,在页的异步点调用 ThreadPool.QueueUserWorkItem 会起反作用,因为该方法来自线程池,从而导致纯粹获取用于处理请求的零线程。相反,调用内置于 Framework 中的异步方法(例如,方法 HttpWebRequest.BeginGetResponse 和 SqlCommand.BeginExecuteReader)通常认为是安全的,因为这些方法倾向于使用完成端口实现异步行为。

请将针对 Jeff 的问题和评论发送到 wicked@microsoft.com

Jeff ProsiseMSDN Magazine 的资深编辑以及若干书籍的作者,其中包括 Programming Microsoft .NET (Microsoft Press, 2002)。他也是 Wintellect(一家软件咨询和教育公司)的创始人之一。

posted @ 2006-11-14 10:35 永不言败 阅读(153) 评论(0) 编辑
发布日期: 2006-4-11 | 更新日期: 2006-4-11

Ted Pattison

下载本文的代码: BasicInstincts0602.exe (763KB)

*
本页内容
创建用于 ASP.NET 2.0 应用程序的 Web 部件 创建用于 ASP.NET 2.0 应用程序的 Web 部件
设计可连接的 Web 部件 设计可连接的 Web 部件
为一个 Web 部件连接计时 为一个 Web 部件连接计时
定义静态 Web 部件连接 定义静态 Web 部件连接
命名连接点 命名连接点
动态建立连接 动态建立连接

当您开始使用 Microsoft® .NET Framework 2.0 和 ASP.NET 时,您会发现新的 Web 部件基础结构将一些非常强大的功能添加到了基础平台中。在 MSDN®Magazine 的 2005 年 9 月号中,我和 Fritz Onion 撰写了一篇关于对 Web 部件进行编程的文章,题为“ASP.NET 2.0: Personalize Your Portal with User Controls and Custom Web Parts”。在本月的专栏中,我要通过讨论 Web 部件连接的工作方式,扩充那篇文章中的信息。

对于本专栏,我假设您已经对 Web 部件的基本知识有所了解,例如,如何使用 WebPartManager 控件、Web 部件区域、编辑器、目录和持久性属性。如果您还不了解,建议您在继续阅读本文之前先阅读刚才提到的那篇文章。

创建用于 ASP.NET 2.0 应用程序的 Web 部件


您可以用两种方法创建 Web 部件。第一种方法涉及创建一个自定义的 Web 部件类,该类从 System.Web.UI.WebControls.WebParts 命名空间中定义的 WebPart 类继承。使用该方法时,将自定义的 Web 部件类打包到一个程序集 DLL 中通常是有意义的,因为这样可以提供对重用、版本控制和 Visual Studio® 2005 集成的更多控制。如果您对使用以前的 ASP.NET 版本生成自定义控件很熟悉,则许多相同的技术适用于将自定义的 Web 部件生成到 DLL 程序集中。

用于创建 ASP.NET 2.0 Web 部件的第二种方法涉及使用用户控件。虽然该方法不产生相同的重用和版本控制级别,但是它的确允许您使用 Visual Studio 窗体设计器来创建 Web 部件的用户界面部分。如果您想通过将用于用户输入、验证和数据绑定的控件拖放到设计界面上来创建应用程序,则该方法适合于您。当然,如果您已经花时间创建了一个您想用作 Web 部件的用户控件,它也是个可以采用的好方法。

当创建一个专门设计为 Web 部件的用户控件时,建议您实现 IWebPart 接口。这样,Web 部件后台的代码就可通过编程方式分配自己的几个内部 Web 部件属性,如它的 Title 和 TitleIconUrl。

本月专栏附带的代码示例使用一个名为 WebPartBase 的自定义基类,该基类从 UserControl 继承并实现 IwebPart。该基类的定义部署在 App_Code 目录中名为 WebPartBase.vb 的源文件中。每当您使用用户控件创建一个新 Web 部件时,只需在该代码隐藏文件中更改该基类以利用该技术:

Partial Class WebParts_Customers
Inherits WebPartBase
Sub New()
Title = "Northwind Customer List"
TitleIconImageUrl = "~\img\Customers.gif"
End Sub
End Class

设计可连接的 Web 部件


使用 Web 部件连接,您可以使用户更轻松地形象化数据各项之间存在的关系。例如,Web 部件连接可以建模一个主-从方案,其中显示客户列表的 Web 部件连接到另一个显示当前所选客户详细信息的 Web 部件。图 1 的示例说明这种设计可能生成的用户界面外观。

Web 部件连接还可用于建模一对多关系。例如,显示客户列表的 Web 部件可以连接到另一个显示针对当前所选客户的所有定单的 Web 部件。

通常使用 Web 部件连接建模的另一个方案是表单查询。在这种方案中,一个 Web 部件提供一个用户界面,该用户界面允许用户选择查询数据(如数据库表)时所用的搜索或筛选条件。然后,该 Web 部件连接到另一个显示查询结果的 Web 部件。Web 部件连接用于在运行查询前,将筛选条件从一个 Web 部件传递到另一个 Web 部件。

Web 部件连接基于提供者和使用者的概念。提供者 Web 部件通过一个编程接口为一个或多个使用者 Web 部件提供信息。提供者和使用者之间交换的信息可以是简单的数据项(如数字或字符串),也可以是较特殊的内容(如对一个复杂数组或自定义对象集合的引用)。

如果针对 Windows® SharePoint® Services 2.0 (WSS) 编写了 Web 部件,您可能已经熟悉它用于连接 Web 部件的模型。在 WSS 中,Web 部件只能使用一组预定义的接口对连接。这些接口对的示例包括 ICellProvider 和 ICellConsumer,以及 IRowProvider 和 IRowConsumer。

ASP.NET 2.0 中的 Web 部件连接模型比 WSS 中的旧式模型更容易、更灵活,因为您可以使用自己的自定义接口。这意味着您无需使用由 Microsoft 人员创建的接口定义。而且,您无需对接口对进行任何操作,它们必须由提供者和使用者实现。使用 ASP.NET 2.0,只有提供者需要实现一个接口。

要了解其工作方式,我们先在两个 Web 部件之间创建一个连接。对于我要在本月专栏中提供的示例,我决定使用 Northwind 数据库,因为它有一个 Customers 表和一个 Orders 表。这使我能为您展示如何针对主-从和一对多关系设计 Web 部件。有一点需要注意,如果使用 SQL Server® 2005,则在产品安装过程中并不安装该示例 Northwind 数据库。要安装它,您必须下载并运行 Microsoft Web 站点上可用的脚本(请参阅 Microsoft SQL Server 主页)。

现在,假设您要在一个显示客户列表的 Web 部件和一个显示当前所选客户的详细信息的客户 Web 部件之间建立一个 Web 部件连接,如图 1所示。显示客户列表的 Web 部件将扮演提供者的角色,而显示当前所选客户的详细信息的 Web 部件则作为使用者。在这种情况下,您希望提供者为使用者提供当前所选客户的 CustomerID 字段。

首先,创建一个名为 ICustomerIDProvider 的简单接口:

Public Interface ICustomerIDProvider
ReadOnly Property CustomerID() As String
End Interface

在本月专栏附带的代码示例中,我使用带有 SqlDataSource 和 GridView 控件的用户控件创建了提供者 Web 部件,以显示来自 Northwind 的客户。Web 部件源文件是 Customers.ascx 和 Customers.ascx.vb,如图 2 所示。

您可以看到,WebParts_Customers 作为提供者并实现用于该 Web 部件连接的接口。在本例中,WebParts_Customers 实现 ICustomerIDProvider 接口。虽然最常见的模式是提供者本身实现该连接接口,但是它不需要这么做。唯一的实际要求是,ConnectionProvider 方法返回指定接口的一个实例。因此,作为替代方案,该提供者 Web 部件可以返回一个实现该连接接口的 helper 对象。如果一个提供者 Web 部件具有多个接口类型相同的连接点,这通常是有必要的。

通过返回 GridView 控件的 SelectedDataKey 属性的值,WebParts_Customers 类实现 CustomerID 属性。GridView 控件已经进行了设置以便显示来自 Northwind Customers 表的记录,而且它还将 CustomerID 字段识别为 SelectedDataKey 值。

您应该注意到 WebParts_Customers 类有一个名为 GetCustomerProvider 的方法,该方法具有一个根据 ICustomerIDProvider 接口定义的返回类型。在这种情况下,由于 Web 部件本身实现所需的接口,因此 GetCustomerProvider 可以只返回对该类的当前实例的 Me 引用。您还要注意该方法已经使用 ConnectionProvider 属性进行了定义:

<ConnectionProvider("Customer ID Provider")>

WebPartManager 负责在运行时连接 Web 部件。当 WebPartManager 看到一个 Web 部件包含一个使用 ConnectionProvider 属性定义的方法时,它知道该 Web 部件公开一个连接点,因此可以作为提供者并且连接到使用者。当需要将两个 Web 部件连接在一起时,WebPartManager 将调用 GetCustomerProvider 方法获取对提供者 Web 部件的强类型引用。

定义一个提供者 Web 部件是否接受到使用者的多个连接是可能的。在某种情况下,一个提供者同时具有到多个使用者的连接是很有用的。在其他情况下,您可能想限制提供者,使它最多可以有一个到使用者 Web 部件的连接。默认情况下,提供者允许多个连接,而使用者则不然。要改变这一点,当您应用 ConnectionProvider 属性时,可以使用命名参数 AllowsMultipleConnections,如下所示:

<ConnectionProvider("Customer ID Provider", _
AllowsMultipleConnections:=False)>

既然您已经看到如何创建一个在提供者 Web 部件中公开连接点的方法,我们来看看这在使用者 Web 部件中是如何实现的。使用者 WebPart 通过提供一个使用 ConnectionConsumer 属性定义的方法来公开连接点。使用者的连接点方法与提供者的连接方法不同,因为它不定义返回值。相反,它获取一个用该连接的接口类型定义的参数:

<ConnectionConsumer("Customer ID Consumer")>  _
Sub RegisterCustomerProvider(ByVal provider As ICustomerIDProvider)
... ' implementation
End Sub

请记住,提供者的连接点方法名称与使用者的连接点方法名称并不重要。唯一需要注意的是,每个方法分别使用 ConnectionProvider 属性和 ConnectionConsumer 属性进行定义。

现在,我们看看 WebPartManager 在运行时是如何建立该连接的。WebPartManager 调用提供者的连接点方法以获取对提供者对象的引用。接下来,WebPartManager 调用使用者的连接点方法,以便为它传递一个对提供者的强类型引用。

一旦 WebPartManager 完成它的工作之后,使用者 Web 部件就有一个返回到提供者 Web 部件的活动连接。此时,使用者可以使用该引用访问接口中定义的方法和属性,从而与提供者直接交互。但是,ASP.NET 小组建议使用者 Web 部件在 PreRender 阶段之前不应使用提供者接口上的方法和属性。具体说来,它们不应该在 方法本身中使用提供者接口上的方法和属性。原因是这些连接可能彼此依赖。您可能有一个连接到 ProviderConsumerWebPart 进而连接到 ConsumerWebPart 的 ProviderWebPart。在这两个连接建立之前,ConsumerWebPart 无法查询提供者接口,而且连接建立的顺序取决于框架。

图 3 显示 CustomerDetails.ascx.vb 中使用者 Web 部件的完整代码列表。您可以看到,使用者 Web 部件负责持续引用,以便可以跟踪到提供者的连接。该使用者 Web 部件包含一个名为 provider 的私有字段,该字段是根据 ICustomerIDProvider 接口定义的。

当 WebPartManager 调用 RegisterCustomerProvider 时,使用者获取传入的引用参数并将它分配给提供者字段。当提供者字段分得该引用后,使用者 Web 部件就可以与提供者 Web 部件直接交互。为连接设计该接口时,您应该添加将提供您所需交互的任何方法和属性。

在某些情况下,使用者 Web 部件可能设计为无论是否有到提供者的活动连接都正常工作。在这种设计中,当提供者字段有一个 Nothing 值时,您可能希望将临时代码添加到正常工作的使用者 Web 部件中:

If provider IsNot Nothing Then
... ' interact with provider
Else
... ' contingency code goes here if required
End If

为一个 Web 部件连接计时


当您开始设计支持连接的 Web 部件时,了解所涉及的计时是非常重要的。图 4 显示在 HTTP GET 期间运行提供者 Web 部件和使用者 Web 部件的页面的一小部分。虽然有更多在 HTTP POST 期间激发的页面级事件,但是连接建立时的计时则保持相同。

图 4 中显示的跟踪信息阐释了对于标准的 ASP.NET 2.0 页面级事件而言,每个连接方法何时激发。您应该能够从该跟踪信息中看到,WebPartManager 在页面级 LoadComplete 命令中将 Web 部件连接在一起。

需要牢记的是,当页面级事件 PreInit、Init、PreLoad 和 Load 执行时,Web 部件连接尚未建立。这意味着您绝不应该尝试访问使用者 Web 部件的处理程序方法(它绑定到这些事件之一)中的提供者。在尝试访问提供者 Web 部件之前,使用者 Web 部件中的代码必须等待 LoadComplete 事件执行完毕。

在本示例中,使用者 Web 部件处理 SqlDataSource 控件的 Selecting 事件,该事件在页面级 PreRender 事件中激发。此时,访问提供者并检索客户 ID 是安全的。

定义静态 Web 部件连接


既然您已经看到如何创建支持连接的 Web 部件,并且了解了所涉及的计时,现在该探究如何将它们实际连接在一起了。您将看到,可以将标记直接添加到一个 Web 部件页面定义中,以便建立一个静态 Web 部件连接。也可以在运行时通过代码或用户交互动态建立 Web 部件连接。首先,我要展示如何在两个 Web 部件之间创建一个静态连接,因为这是最简单的方法。

要在一个页面上的两个 Web 部件之间创建静态 Web 部件连接,需要将 StaticConnections 元素添加到 WebPartManager 标记中:

<asp:WebPartManager ID="WebPartManager1" runat="server">
<StaticConnections>
<asp:WebPartConnection ID="c1"
ProviderID="Customers1"
ConsumerID="CustomerDetails1" />
</StaticConnections>
</asp:WebPartManager>

要使该代码正常运行,名为 Customers1 的提供者 Web 部件和名为 CustomerDetails1 的使用者 Web 部件也必须在同一页面上的 Web 部件区域中静态定义和正确命名。

需要牢记的是,每个页面只有一个 WebPartManager 控件。然而,在许多涉及 Web 部件的应用程序设计中,您会发现将 WebPartManager 添加到用户控件或母版页很方便,原因是可以在许多页面上重用它。

当 Web 部件页面基于母版页,或者使用的是包含 WebPartManager 控件的用户控件时,您不能添加 WebPartManager 控件的第二个示例来定义 StaticConnections 标记。针对这些情况,ASP.NET 2.0 Web 部件控件集提供了 ProxyWebPartManager 控件。以下是关于如何使用它的示例:

<asp:ProxyWebPartManager ID="ProxyWebPartManager1" runat="server">
<StaticConnections>
<asp:WebPartConnection ID="c1"
ProviderID="Customers1"
ConsumerID="CustomerDetails1" />
</StaticConnections>
</asp:ProxyWebPartManager>

ProxyWebPartManager 控件的价值在于,它允许您在页面无法包含 WebPartManager 标记时,在页面级添加静态连接。在本专栏附带的示例页面 default.aspx 中,必须使用 ProxyWebPartManager 建立一个静态 Web 部件连接,原因是 WebPartManager 已经封装在名为 WebPartManagerPanel.ascx 的用户控件中。

命名连接点


在我目前生成的示例中,提供者 Web 部件和使用者 Web 部件之间的连接已经基于默认的连接点。但是,Web 部件连接方法提供一个命名连接点是可能的。要将一个命名连接点添加到提供者,您只需将第二个字符串参数添加到 ConnectionProvider 属性:

<ConnectionProvider("Customer ID Provider", "CustomerIDProvider")> _
Public Function GetCustomerProvider() As ICustomerIDProvider
Return Me
End Function

之所以使用命名连接点的一个原因是提供者或使用者可能有多个连接点,因此必须能区分它们。要将一个命名连接点添加到使用者 Web 部件,您可以将第二个字符串参数添加到 ConnectionConsumer 属性:

<ConnectionConsumer("Customer ID" & "Consumer", "CustomerIDConsumer")> _
Sub RegisterCustomerProvider(ByVal provider As ICustomerIDProvider)
Me.provider = provider
End Sub

当您开始使用命名连接点时,在页面级定义 StaticConnections 标记时,必须为 ProviderConnectionPointID 和 ConsumerConnectionPointID 提供两个额外的属性值。

动态建立连接


当您想将 Web 部件连接在一起时,有时却无法依赖静态 Web 部件连接。例如,如果您想连接通过自定义代码动态创建的 Web 部件,或者由使用 Web 部件目录将 Web 部件添加到页面的用户动态创建的 Web 部件,就会出现这种情况。

在无法使用静态 Web 部件连接时,您必须使用动态技术连接 Web 部件。为此,可以使用自定义代码,或者使用 ASP.NET 2.0 Web 部件控件集附带的 ConnectionsZone 控件。

让我们首先看一下创建两个 Web 部件并将它们动态连接在一起的自定义代码,如图 5 所示。该代码通过用户控件为提供者和使用者创建了 Web 部件实例,并将它们添加到宿主 Web 部件页面的现有 Web 部件区域。然后,该代码在它们之间建立一个连接。请注意,Web 部件和连接将保存为用户个性化信息的一部分,因此它们应该只添加到 WebPartManager 一次。如果您不想以这种方式保存 Web 部件和连接,也可以使用其他 API。


图 6 连接显示模式


您可以看到,图 5 中显示的技术需要使用 ProviderConnectionPoint 对象和 ConsumerConnectionPoint。通过调用 WebPartManager 提供的方法并传递那些命名连接点的字符串标识符,可以检索这些对象。

您和您的用户可以用来建立动态 Web 部件连接的另一种技术涉及到 ConnectionsZone 控件。要有效地使用该技术,您应该创建一个 Web 部件页面,其右侧有一个包含 ConnectionsZone 控件的任务窗格。当用户使该页面处于连接视图显示模式中时,Connect 命令将添加到公开连接点的每个 Web 部件的 Web 部件菜单中,如图 6 所示。

当用户选择 Connect 命令时,Web 部件页面会显示 ConnectionsZone 控件,并允许用户查看该页面上所有可连接的 Web 部件的所有兼容连接点(请参见图 7)。使用该技术,您可以将使用者连接到提供者。同样,您也可以将提供者连接到使用者。


请将有关 Ted 的问题和意见发送至 instinct@Microsoft.com

Ted Pattison 是一位作家兼讲师,他通过自己的公司提供动手培训,他的公司名为 Ted Pattison Group。Ted 目前正在研究和撰写一本新书,着重于介绍 Windows SharePoint Services "V3" 和 Office 12 服务器技术。

posted @ 2006-11-14 10:28 永不言败 阅读(93) 评论(0) 编辑
发布日期: 2006-07-31 | 更新日期: 2006-07-31

Fritz Onion

下载本文的代码:Onion2006_07.exe (176KB)

*
本页内容
Web 部件拥塞 Web 部件拥塞
异步 Web 访问 异步 Web 访问
工作原理 工作原理
异步数据访问 异步数据访问

使用 ASP.NET 2.0 的门户基础结构,可以轻松构建包括可插入 Web 部件集合在内的自定义网站。此模型具有很高的灵活性,能够让用户轻松地将 Web 部件放到网页上的任何位置,因此可以自由地自定义网站。然而这些优势也会导致影响用户体验的低效行为,因为您无法预测将同时使用哪些组件,进而不能为每个单独的组件提供特定的数据检索优化。

一个典型门户网站中最为常见的低效行为发生于多个 Web 部件同时发出网络数据请求之时。每个请求,无论是请求 Web 服务或远程数据库,最终都会增加处理页面所需的总时间,即使这些请求之间彼此独立并且确保为并行发出。

幸运的是,ASP.NET 2.0 还引入了一款易于使用的异步页面模型,在与异步 Web 服务调用和异步数据库访问结合使用时,可以显著提高多个独立 Web 部件并行收集数据时的门户页面响应速度。本文中,我们将主要关注构建执行异步数据检索的 Web 部件,以使包含这些部件的门户页面具有更高的响应速度和可伸缩性的各项技术。

Web 部件拥塞

我们首先来看一下图 1 中显示的门户页面。在该示例中,门户页面中有四个 Web 部件,各自通过不同的来源检索数据。该示例应用的完整源代码可通过 MSDN®Magazine 网站下载,建议您在阅读本专栏之后查看该应用。在该示例中,有三个 Web 部件通过 Web 服务来检索数据,在返回之前有意等待三秒钟的时间。第四个 Web 部件会将一个 ADO.NET 查询发送至 SQL Server 数据库,同样在返回之前等待三秒钟时间。这个示例有些夸张,但也不是不可能发生。

a

图 1 门户页面示例

示例应用中的每个 Web 部件由用户控件构建而成,并将数据检索结果绑定到显示它的控件。每一控件的代码和标记数量保持在最低限度,因此该示例简单易懂,能让您更多地关注如何使 Web 部件实现异步。

以下是 NewsWebPart.ascx 用户控件文件:

   <%@ Control Language="C#"
AutoEventWireup="true"
CodeFile="NewsWebPart.ascx.cs"
Inherits="webparts_
NewsWebPart" %>
<asp:BulletedList ID="_newsHeadlines"
runat="server">
</asp:BulletedList>

如下是新闻标题示例 Web 部件对应的源代码:

public partial class webparts_NewsWebPart :UserControl
{
protected void Page_Load(object sender, EventArgs e)
{
PortalServices ps = new PortalServices();
_newsHeadlines.DataSource = ps.GetNewsHeadlines();
_newsHeadlines.DataBind();
}
}

请注意它与 Web 服务交互,以检索示例新闻标题的方式。股票报价 Web 部件和天气预报 Web 部件的执行过程大体相同,只不过是使用同一 Web 服务的不同方法来检索各自所需的数据。与此类似,图 2 显示的是销售报告示例 Web 部件的 SalesReportWebPart.ascx 用户控件文件及其内含代码页。请注意该控件如何使用 ADO.NET 从数据库中检索销售数据,并使用该数据来填充 GridView 控件。

a

图 3 Web 部件顺序处理

一旦示例门户页面开始运行,问题就会显现。请求的处理时间超过了 12 秒,这一延迟将会导致大多数用户不愿使用该应用。这一漫长延迟的原因如图 3 所示,其中跟踪了执行该页面时请求的执行路径。与页面控件层次结构中的其他控件一样,每一 Web 部件都是按照页面控件层次结构定义的顺序依次加载。由于该过程是按顺序进行,因此每一 Web 部件都必须等待层次结构中排在其前面的部件完成之后,才会开始其数据请求并准备响应。再加上每一数据检索中人为的 3 秒延迟,就能明白为什么需要 12 秒才能完成响应了。每一 Web 部件相继执行完全独立的数据检索。此处需要注意的最为重要的一点是,这些检索操作能够并行执行,进而节省 75% 的响应时间。这就是我想达成的目标。

异步 Web 访问

在该示例中,三个 Web 部件使用 Web 服务来检索数据,一个部件使用 ADO.NET 来访问数据库。让我们从 Web 服务异步调用开始,这是因为由 Web 服务描述语言工具 WSDL.exe(或者 Visual Studio 2005 Add Web Service Reference 工具)生成的 Web 服务代理类对执行 Web 方法异步调用提供良好的支持。

在 ASP.NET 2.0 中创建 Web 服务代理类时,实际上会生成三种不同的调用任意特定方法的方式:一种同步,两种异步。例如,Web 部件所使用的 Web 服务代理可以通过如下方法来调用 GetNewsHeadlines Web 方法:

   public string[] GetNewsHeadlines()
public IAsyncResult BeginGetNewsHeadlines(
AsyncCallback callback, object asyncState)
public string[] EndGetNewsHeadlines(       IAsyncResult asyncResult)
public void GetNewsHeadlinesAsync()
public void GetNewsHeadlinesAsync(       object userState)
public event
GetNewsHeadlinesCompletedEventHandler
GetNewsHeadlinesCompleted;

第一个方法 GetNewsHeadlines 是标准的同步方法。后两个方法,BeginGetNewsHeadlines 和 EndGetNewsHeadlines 能够用于异步调用方法,并可通过标准的 IAsyncResult 接口来嵌套到任意数量的 .NET 异步机制中。

此方案中最值得关注的是最后一个方法:GetNewsHeadlinesAsync。为使用该特定方法,必须注册代理类事件委托,此为专门生成用于捕获异步调用结果(例如示例中的 GetNewsHeadlinesCompleted 事件)。委托签名为包含有方法返回值的强类型,因而可以从方法实现中轻松提取结果。

使用此基于事件的异步方法,可以轻松地重写标题新闻 Web 部件中需要异步的 Web 方法调用,如图 4 所示。首先订阅代理类 GetNewsHeadlinesCompleted 事件的委托,然后调用 GetNewsHeadlinesAsync 方法。在订阅完成事件方法的实现过程中,将 Web 方法调用结果与 BulletedList 绑定起来显示给客户。另外需要注意的一点是,只有将 Web 部件放置在具有 Async="true" 属性设置的页面之中,这些异步方法才会起作用。可通过查看包含页面中的 IsAsync 属性,以编程方式进行检查。如果放置 Web 部件的页面并非异步,则需要求助于标准的同步绑定,如图 4 所示。

现在,为保证异步 Web 部件可以执行其异步数据检索,必须将其放在 Async 属性设置为 true 的页面上,因此,请按照如下方式来修改门户页面的 Page 指令:

<%@ Page Language="C#" AutoEventWireup="true"  Async="true" %>

另外两个使用 Web 服务进行异步数据检索的 Web 部件完成更新之后,门户页面的响应速度会更快。实际上,根据不同的部件加载顺序,客户可在 3 秒左右得到响应结果(如果首先加载销售 Web 部件则需 6 秒左右)!即使销售报告 Web 部件仍在顺序访问数据库,其他三个 Web 部件也会同时执行他们 Web 服务异步调用,因此主请求线程不会等候太长时间。当然了,其最终目的是为了使所有的 I/O 绑定实现异步工作,如此一来,客户可以同时使用 Web 服务和数据库驱动的 Web 部件,从而避免了不必要的顺序阻塞问题。

推动 I/O 绑定异步处理 I/O 请求的另一个原因是,将主线程释放回线程池以处理其他请求。我目前的做法是仅在完成销售报告数据库查询之后才重新释放线程,也就是说要占用原本可以处理其他请求的线程池线程整整 3 秒钟。如果把最后一个 I/O 绑定数据请求也设为异步,则页面使用请求线程的时间将会仅为释放所有异步 I/O 请求的时间,随后立即返回线程池。

工作原理

如果您曾经从事过异步编程工作,您可能会感觉到针对 Web 服务调用的局部更改还远远不够。我还没涉及到 IAsyncResult 接口,也无需让包含页面获悉正在执行异步操作(通过注册任务或其他技术),但一切就可以按照我希望的方式工作。

秘密就在于异步方法 Web 服务代理类的实现,以及 Microsoft® .NET Framework 2.0 中引入的、名为 AsyncOperationManager 的帮助程序类。每当调用代理类 GetNewsHeadlinesAsync 方法时,会将调用映射至 SoapHttpClientProtocol 基类的内部帮助程序方法,称之为 InvokeAsync,代理类即由此派生而出。InvokeAsync 有两个重要作用:通过调用 AsyncOperationManager 静态 CreateOperation 方法来注册异步操作以及使用 WebRequest 类的 BeginGetRequestStream 方法来启动异步请求。此时调用返回,页面继续处理其生命周期,但由于页面已被标记为 Async="true" 属性,它将继续处理请求直至 PreRender 事件,然后将请求线程返回至线程池。异步 Web 请求完成之后,会通过 I/O 线程池中的一个单独线程,调用订阅到代理已完成事件的方法。如果这是最后需要完成的异步操作(基于 AsyncOperationManager 同步环境进行跟踪),将会回调页面,并且请求会从中断的位置继续完成其处理过程(从 PreRenderComplete 事件开始)。图 5 显示了在异步页面中使用异步 Web 请求这一完整的生命周期。

a

图 5 异步页面中的异步 Web 请求

AsyncOperationManager 是专门设计用来在不同环境下帮助异步方法调用管理的类。举例来说,如果从 Windows® Forms 应用中异步调用 Web 服务,会同时连接 AsyncOperationManager 类。每一环境之间的区别就是与 AsyncOperationManager 相关联的 SynchronizationContext。在基于 ASP.NET 的应用环境下运行时,SynchronizationContext 将被设置为 AspNetSynchronizationContext 类的一个实例。其主要目的是跟踪等待处理的异步请求数量,以便在这些请求处理完毕后,可以继续页面请求处理。对比之下,在基于 Windows Forms 的应用环境下运行时,SynchronizationContext 将被设置为 WindowsFormsSynchronizationContext 类的一个实例。其主要目的是使从后台线程到 UI 线程间的调用封送处理更为简单。

异步数据访问

现在,让我们回到最后的 Web 部件异步问题和使用 ADO.NET 执行异步数据检索的常见问题。遗憾的是,没有类似于 Web 服务代理所提供的简单异步机制来执行异步数据检索,所以就不得不做一些额外工作,以使最终 Web 部件真正实现异步。我可以配合使用 SqlCommand 类的新异步方法与 ASP.NET 的异步任务功能。通过 SqlCommand,可以使用下列方法之一来异步调用命令:

IAsyncResult BeginExecuteReader(AsyncCallback ac, object state)

IAsyncResult BeginExecuteNonQuery(AsyncCallback ac, object state)

IAsyncResult BeginExecuteXmlReader(AsyncCallback ac, object state)

数据流准备开始读取之后,就能够调用相应的完成方法:

SqlDataReader EndExecuteReader(IAsyncResult ar)

int EndExecuteNonQuery(IAsyncResult ar)

XmlReader EndExecuteXmlReader(IAsyncResult ar)

要想使用上述这些异步检索方法,必须在连接字符串中添加 "async=true"。这种情况下,我想通过将 GridView 绑定到 SqlDataReader 后再行填充,因此会使用 BeginExecuteReader 方法来启动异步调用。

为将此连接至异步页面,ASP.NET 2.0 允许注册需要在页面完成显示之前执行的异步任务。相比在 Web 服务代理中采用的模型而言,这是一个更为显式的模型,但也具有更大的灵活性。为注册异步任务,我创建了 PageAsyncTask 类的一个实例,并使用三个委托对其进行初始化:开始处理程序、结束处理程序和超时处理程序。开始处理程序必须返回一个 IAsyncResult 接口,以使用 BeginExecuteReader 由此启动异步数据请求。任务完成后调用结束处理程序(在本示例为准备读取数据时),此时可以使用这些结果。ASP.NET 会在开始释放请求线程之前调用开始处理程序(紧随 PreRender 事件完成之后)。图 6 显示了销售报告 Web 部件的更新实现,其使用异步任务和 SqlCommand 类的异步 BeginExecuteReader 方法来执行异步数据访问。

请注意,通过使用代理类提供的其他异步方法(例如 BeginGetNewsHeadlines),可在 Web 服务请求中使用这一相同技术。该技术的另一个潜在优势就是还可以指定一个超时处理程序。如果远程调用不能及时返回,将会调用关联的超时处理程序。请在 Page 指令中使用 AsyncTimeout 属性指定超时,默认值为 20 秒。还请注意,与使用基于事件的异步模式不同,使用 Page.RegisterAsyncTask 时,不需要根据 Page.IsAsync 结果来建立到异步调用的分支。 Web 部件的异步页面任务可以在异步页面中正常运行,甚至还能支持 Web 部件的并行执行。核心区别在于异步页面(不具有 Async="true" 属性)在执行异步操作期间,主页面线程不会被释放回线程池。

现在所有 Web 部件都在异步执行数据检索,就可以在标记为异步的所有页面中使用这些部件,并且我们知道,现在的响应时间不再是所有 Web 部件检索其数据所用时间之和,而是任一 Web 部件的最长需时。通过将页面标记为异步以及使用执行异步 I/O 的 Web 部件,能够提高网站潜在的可伸缩性。这是由于在等待数据期间,页面可以释放主请求线程为其他客户服务。此处的关键在于您是否采用 ASP.NET 2.0 来建立门户网站,您需要注意该版本中引入的所有新异步功能,并充分利用它们来改进应用的响应性和可伸缩性。有关 ASP.NET 2.0 中异步支持的详细信息,请参阅 Jeff Prosise 在 MSDN Magazine 2005 年 10 月号 Wicked Code 栏目发表的文章。

请将您的疑问和意见通过 xtrmasp@microsoft.com 发送给 Fritz。

Fritz Onion 是 Pluralsight(一家 Microsoft .NET 培训提供商)的共同创始人之一,主持 Web 开发课题。Fritz 著有 Essential ASP.NET (Addison Wesley, 2003) 和即将出版的 Essential ASP.NET 2.0 (Addison Wesley, 2006)。要了解本作者,请登录 pluralsight.com/fritz。

本文摘自 2006 年 6 月 出版的 《MSDN Magazine》

© 2006 Microsoft Corporation 版权所有。保留所有权利。使用规定。

posted @ 2006-11-14 10:27 永不言败 阅读(100) 评论(0) 编辑