• 细说ASP.NET的各种异步操作(转自fishli)


    在上篇博客【C#客户端的异步操作】, 我介绍了一些.net中实现异步操作的方法,在那篇博客中,我是站在整个.net平台的角度来讲述各种异步操作的实现方式, 并针对各种异步操作以及不同的编程模型给出了一些参考建议。上篇博客谈到的内容可以算是异步操作的基础, 今天我再来谈异步,专门来谈在ASP.NET平台下的各种异步操作。在这篇博客中,我主要演示在ASP.NET中如何使用各种异步操作。
    在后续博客中,我还会分析ASP.NET的源码,解释为什么可以这样做,或者这样的原因是什么,以解密内幕的方式向您解释这些操作的实现原理。

    由于本文是【C#客户端的异步操作】的续集, 因此一些关于异步的基础内容,就不再过多解释了。如不理解本文的示例代码,请先看完那篇博文吧。

    【C#客户端的异步操作】的结尾, 有一个小节【在Asp.net中使用异步】,我把我上次写好的示例做了个简单的介绍,今天我来专门解释那些示例代码。 不过,在写博客的过程中,又做了一点补充,所以,请以前下载过示例代码的朋友,你们需要重新下载那些示例代码(还是那篇博客中)。
    说明:那些代码都是在示范使用异步的方式调用【用Asp.net写自己的服务框架】博客中所谈到的那个服务框架, 且服务方法的代码为:

    [MyServiceMethod]
    public static string ExtractNumber(string str)
    {
        // 延迟3秒,模拟一个长时间的调用操作,便于客户演示异步的效果。
        System.Threading.Thread.Sleep(3000);
    
        if( string.IsNullOrEmpty(str) )
            return "str IsNullOrEmpty.";
    
        return new string((from c in str where Char.IsDigit(c) orderby c select c).ToArray());
    }
    

    在ASP.NET中使用异步

    我在【C#客户端的异步操作】中提到一个观点: 对于服务程序而言,异步处理可以提高吞吐量。什么是服务程序,简单说来就是:可以响应来自网络请求的服务端程序。 我们熟悉的ASP.NET显然是符合这个定义的。因此在ASP.NET程序中,适当地使用异步是可以提高服务端吞吐量的。 这里所说的适当地使用异步,一般是说:当服务器的压力不大且很多处理请求的执行过程被阻塞在各种I/O等待(以网络调用为主)操作上时, 而采用异步来减少阻塞工作线程的一种替代同步调用的方法。 反之,如果服务器的压力已经足够大,或者没有发生各种I/O等待,那么,在此情况下使用异步是没有意义的。

    在.net中,几乎所有的服务编程模型都是采用线程池处理请求任务的多线程工作模式。 自然地,ASP.NET也不例外,根据【C#客户端的异步操作】的分析, 我们就不能再使用一些将阻塞操作交给线程池的方法了。比如:委托的异步调用,直接使用线程池,都是不可取的。 直接创建线程也是不合适的,因此那种方式会随着处理请求的数量增大而创建一大堆线程,最后也将会影响性能。 因此,最终能被选用的只用BeginXxxxx/EndXxxxx方式。不过,我要补充的是:还有基于事件通知的异步模式也是一个不错的选择(我会用代码来证明), 只要它是对原始BeginXxxxx/EndXxxxx方式的包装。

    【用Asp.net写自己的服务框架】中, 我说过,ASP.NET处理请求是采用了一种被称为【管线】的方式,管线由HttpApplication控制并引发的一系列事件, 由HttpHandler来处理请求,而HttpModule则更多地是一种辅助角色。 还记得我在【C#客户端的异步操作】 总结的异步特色吗:【一路异步到底】。 ASP.NET的处理过程要经过它们的处理,自然它们对于请求的处理也必须要支持异步。 幸运地是,这些负责请求处理的对象都是支持异步的。今天的博客也将着重介绍它们的异步工作方式。

    WebForm框架,做为ASP.NET平台上最主要且默认的开发框架,我自然也会全面地介绍它所支持的各种异步方式。
    MVC框架从2.0开始,也开始支持异步,本文也会介绍如何在这个版本中使用异步。

    该选哪个先出场呢?我想了很久,最后还是决定先请出处理请求的核心对象:HttpHandler 。

    异步 HttpHandler

    关于HttpHandler的接口,我在【用Asp.net写自己的服务框架】中已有介绍, 这里就不再贴出它的接口代码了,只想说一句:那是个同步调用接口,它并没有异步功能。要想支持异步,则必须使用另一个接口:IHttpAsyncHandler

    // 摘要:
    //     定义 HTTP 异步处理程序对象必须实现的协定。
    public interface IHttpAsyncHandler : IHttpHandler
    {
        // 摘要:
        //     启动对 HTTP 处理程序的异步调用。
        //
        // 参数:
        //   context:
        //     一个 System.Web.HttpContext 对象,该对象提供对用于向 HTTP 请求提供服务的内部服务器对象(如 Request、Response、Session
        //     和 Server)的引用。
        //
        //   extraData:
        //     处理该请求所需的所有额外数据。
        //
        //   cb:
        //     异步方法调用完成时要调用的 System.AsyncCallback。如果 cb 为 null,则不调用委托。
        //
        // 返回结果:
        //     包含有关进程状态信息的 System.IAsyncResult。
        IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback cb, object extraData);
        //
        // 摘要:
        //     进程结束时提供异步处理 End 方法。
        //
        // 参数:
        //   result:
        //     包含有关进程状态信息的 System.IAsyncResult。
        void EndProcessRequest(IAsyncResult result);
    }
    

    这个接口也很简单,只有二个方法,并且与【C#客户端的异步操作】 提到的BeginXxxxx/EndXxxxx设计方式差不多。如果这样想,那么后面的事件就好理解了。
    在.net中,异步都是建立在IAsyncResult接口之上的,而BeginXxxxx/EndXxxxx是对这个接口最直接的使用方式。

    下面我们来看一下如何创建一个支持异步的ashx文件(注意:代码中的注释很重要)。

    public class AsyncHandler : IHttpAsyncHandler {
    
        private static readonly string ServiceUrl = "http://localhost:22132/service/DemoService/CheckUserLogin";
        
        public void ProcessRequest(HttpContext context)
        {
            // 注意:这个方法没有必要实现。因为根本就不调用它。
            // 但要保留它,因为这个方法也是接口的一部分。
            throw new NotImplementedException();
        }
        
        public IAsyncResult BeginProcessRequest(HttpContext context, AsyncCallback cb, object extraData)
        {
            // 说明:
            //   参数cb是一个ASP.NET的内部委托,EndProcessRequest方法将在那个委托内部被调用。
            
            LoginInfo info = new LoginInfo();
            info.Username = context.Request.Form["Username"];
            info.Password = context.Request.Form["Password"];
    
            MyHttpClient<LoginInfo, string> http = new MyHttpClient<LoginInfo, string>();
            http.UserData = context;
    
            // ================== 开始异步调用 ============================
            // 注意:您所需要的回调委托,ASP.NET已经为您准备好了,直接用cb就好了。
            return http.BeginSendHttpRequest(ServiceUrl, info, cb, http);
            // ==============================================================
        }
    
        public void EndProcessRequest(IAsyncResult ar)
        {
            MyHttpClient<LoginInfo, string> http = (MyHttpClient<LoginInfo, string>)ar.AsyncState;
            HttpContext context = (HttpContext)http.UserData;
            
            context.Response.ContentType = "text/plain";
            context.Response.Write("AsyncHandler Result: ");
    
            try {
                // ============== 结束异步调用,并取得结果 ==================
                string result = http.EndSendHttpRequest(ar);
                // ==============================================================
                context.Response.Write(result);
            }
            catch( System.Net.WebException wex ) {
                context.Response.StatusCode = 500;
                context.Response.Write(HttpWebRequestHelper.SimpleReadWebExceptionText(wex));
            }
            catch( Exception ex ) {
                context.Response.StatusCode = 500;
                context.Response.Write(ex.Message);
            }
        }
    

    实现其实是比较简单的,大致可以总结如下:
    1. 在BeginProcessRequest()方法,调用要你要调用的异步开始方法,通常会是另一个BeginXxxxx方法。
    2. 在EndProcessRequest()方法,调用要你要调用的异步结束方法,通常会是另一个EndXxxxx方法。
    真的就是这么简单。

    这里要说明一下,在【C#客户端的异步操作】中, 我演示了如何使用.net framework中的API去实现完整的异步发送HTTP请求的调用过程,但那个过程需要二次异步,而这个IHttpAsyncHandler接口却只支持一次回调。 因此,对于这种情况,就需要我们自己封装,将多次异步转变成一次异步。以下是我包装的一次异步的简化版本:

    下面这个包装类非常有用,我后面的示例还将会使用它。它也示范了如何创建自己的IAsyncResult封装。因此建议仔细阅读它。 (注意:代码中的注释很重要

    /// <summary>
    /// 对异步发送HTTP请求全过程的包装类,
    /// 按IAsyncResult接口要求提供BeginSendHttpRequest/EndSendHttpRequest方法(一次回调)
    /// </summary>
    /// <typeparam name="TIn"></typeparam>
    /// <typeparam name="TOut"></typeparam>
    public class MyHttpClient<TIn, TOut>
    {
        /// <summary>
        /// 用于保存额外的用户数据。
        /// </summary>
        public object UserData;
    
        public IAsyncResult BeginSendHttpRequest(string url, TIn input, AsyncCallback cb, object state)
        {
            // 准备返回值
            MyHttpAsyncResult ar = new MyHttpAsyncResult(cb, state);
    
            // 开始异步调用
            HttpWebRequestHelper<TIn, TOut>.SendHttpRequestAsync(url, input, SendHttpRequestCallback, ar);
            return ar;
        }
    
        private void SendHttpRequestCallback(TIn input, TOut result, Exception ex, object state)
        {
            // 进入这个方法表示异步调用已完成
            MyHttpAsyncResult ar = (MyHttpAsyncResult)state;
    
            // 设置完成状态,并发出完成通知。
            ar.SetCompleted(ex, result);
        }
        
        public TOut EndSendHttpRequest(IAsyncResult ar)
        {
            if( ar == null )
                throw new ArgumentNullException("ar");
    
            // 说明:我并没有检查ar对象是不是与之匹配的BeginSendHttpRequest实例方法返回的,
            // 虽然这是不规范的,但我还是希望示例代码能更简单。
            // 我想应该极少有人会乱传递这个参数。
    
            MyHttpAsyncResult myResult = ar as MyHttpAsyncResult;
            if( myResult == null )
                throw new ArgumentException("无效的IAsyncResult参数,类型不是MyHttpAsyncResult。");
    
            if( myResult.EndCalled )
                throw new InvalidOperationException("不能重复调用EndSendHttpRequest方法。");
    
            myResult.EndCalled = true;
            myResult.WaitForCompletion();            
    
            return (TOut)myResult.Result;
        }
    }
    
    internal class MyHttpAsyncResult : IAsyncResult
    {
        internal MyHttpAsyncResult(AsyncCallback callBack, object state)
        {
            _state = state;
            _asyncCallback = callBack;
        }
    
        internal object Result { get; private set; }
        internal bool EndCalled;
    
        private object _state;
        private volatile bool _isCompleted;
        private ManualResetEvent _event;
        private Exception _exception;
        private AsyncCallback _asyncCallback;
    
    
        public object AsyncState
        {
            get { return _state; }
        }
        public bool CompletedSynchronously
        {
            get { return false; } // 其实是不支持这个属性
        }
        public bool IsCompleted
        {
            get { return _isCompleted; }
        }
        public WaitHandle AsyncWaitHandle
        {
            get {
                if( _isCompleted )
                    return null;    // 注意这里并不返回WaitHandle对象。
    
                if( _event == null )     // 注意这里的延迟创建模式。
                    _event = new ManualResetEvent(false);
                return _event;
            }
        }
    
        internal void SetCompleted(Exception ex, object result)
        {
            this.Result = result;
            this._exception = ex;
    
            this._isCompleted = true;
            ManualResetEvent waitEvent = Interlocked.CompareExchange(ref _event, null, null);
    
            if( waitEvent != null )
                waitEvent.Set();        // 通知 EndSendHttpRequest() 的调用者
    
            if( _asyncCallback != null )
                _asyncCallback(this);    // 调用 BeginSendHttpRequest()指定的回调委托
        }
    
        internal void WaitForCompletion()
        {
            if( _isCompleted == false ) {
                WaitHandle waitEvent = this.AsyncWaitHandle;
                if( waitEvent != null )
                    waitEvent.WaitOne();    // 使用者直接(非回调方式)调用了EndSendHttpRequest()方法。
            }
    
            if( _exception != null )
                throw _exception;    // 将异步调用阶段捕获的异常重新抛出。
        }
    
        // 注意有二种线程竞争情况:
        //  1. 在回调线程中调用SetCompleted时,原线程访问AsyncWaitHandle
        //  2. 在回调线程中调用SetCompleted时,原线程调用WaitForCompletion
    
        // 说明:在回调线程中,会先调用SetCompleted,再调用WaitForCompletion
    }
    

    对于这个包装类来说,最关键还是MyHttpAsyncResult的实现,它是异步模式的核心。

    ASP.NET 异步页的实现方式

    从上面的异步HttpHandler可以看到,一个处理流程被分成二个阶段了。但Page也是一个HttpHandler,不过,Page在处理请求时, 有着更复杂的过程,通常被人们称为【页面生命周期】,一个页面生命周期对应着一个ASPX页的处理过程。 对于同步页来说,整个过程从头到尾连续执行一遍就行了,这比较容易理解。但是对于异步页来说,它必须要拆分成二个阶段, 以下图片反映了异步页的页面生命周期。注意右边的流程是代表异步页的。

    这个图片是我从网上找的。原图比较小,字体较模糊,我将原图放大后又做了一番处理。本想在图片中再加点说明, 考虑到尊重原图作者,没有在图片上加上任何多余字符。下面我还是用文字来补充说明一下吧。

    在上面的左侧部分是一个同步页的处理过程,右侧为一个异步页的处理过程。
    这里尤其要注意的是那二个红色块的步骤:它们虽然只有一个Begin与End的操作, 但它们反映的是:在一个异步页的【页面生命周期】中,所有异步任务在执行时所处的阶段。 与HttpHandler不同,一个异步页可以发起多个异步调用任务。 或许用所有这个词也不太恰当,您就先理解为所有吧,后面会有详细的解释。

    引入这个图片只是为了能让您对于异步页的执行过程有个大致的印象: 它将原来一个线程连续执行的过程分成以PreRender和PreRenderComplete为边界的二段过程, 且可能会由二个不同的线程来分别处理它们。请记住这个边界,下面在演示范例时我会再次提到它们。

    异步页这个词我已说过多次了,什么样的页面是一个异步页呢?

    简单说来,异步页并不要求您要实现什么接口,只要在ASPX页的Page指令中,加一个【Async="true"】的选项就可以了,请参考如下代码:

    <%@ Page Language="C#" Async="true" AutoEventWireup="true" CodeFile="AsyncPage1.aspx.cs" Inherits="AsyncPage1" %>
    
    

    很简单吧,再来看一下CodeFile中页面类的定义:

    public partial class AsyncPage1 : System.Web.UI.Page
    
    

    没有任何特殊的,就是一个普通的页面类。是的,但它已经是一个异步页了。有了这个基础,我们就可以为它添加异步功能了。

    由于ASP.NET的异步页有 3 种实现方式,我也将分别介绍它们。请继续往下阅读。

    1. 调用Page.AddOnPreRenderCompleteAsync()的异步页

    在.net的世界里,许多支持异步的原始API都采用了Begin/End的设计方式,都是基于IAsyncResult接口的。 为了能方便地使用这些API,ASP.NET为它们设计了正好匹配的调用方式,那就是直接调用Page.AddOnPreRenderCompleteAsync()方法。 这个方法的名字也大概说明它的功能:添加一个异步操作到PreRenderComplete事件前。 我们还是来看一下这个方法的签名吧:

    // 摘要:
    //     为异步页注册开始和结束事件处理程序委托。
    //
    // 参数:
    //   state:
    //     一个包含事件处理程序的状态信息的对象。
    //
    //   endHandler:
    //     System.Web.EndEventHandler 方法的委托。
    //
    //   beginHandler:
    //     System.Web.BeginEventHandler 方法的委托。
    //
    // 异常:
    //   System.InvalidOperationException:
    //     <async> 页指令没有设置为 true。- 或 -System.Web.UI.Page.AddOnPreRenderCompleteAsync(System.Web.BeginEventHandler,System.Web.EndEventHandler)
    //     方法在 System.Web.UI.Control.PreRender 事件之后调用。
    //
    //   System.ArgumentNullException:
    //     System.Web.UI.PageAsyncTask.BeginHandler 或 System.Web.UI.PageAsyncTask.EndHandler
    //     为空引用(Visual Basic 中为 Nothing)。
    public void AddOnPreRenderCompleteAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    

    其中BeginEventHandler与EndEventHandler的定义如下:

    // 摘要:
    //     表示处理异步事件(如应用程序事件)的方法。此委托在异步操作开始时调用。
    //
    // 返回结果:
    //     System.IAsyncResult,它表示 System.Web.BeginEventHandler 操作的结果。
    public delegate IAsyncResult BeginEventHandler(object sender, EventArgs e, AsyncCallback cb, object extraData);
    
    // 摘要:
    //     表示处理异步事件(如应用程序事件)的方法。
    public delegate void EndEventHandler(IAsyncResult ar);
    

    如果单看以上接口的定义,可以发现除了“object sender, EventArgs e”是多余部分之外,其余部分则刚好与Begin/End的设计方式完全吻合,没有一点多余。

    我们来看一下如何调用这个方法来实现异步的操作:(注意代码中的注释)

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        // 准备回调数据,它将由AddOnPreRenderCompleteAsync的第三个参数被传入。
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = textbox1.Text;
    
        // 注册一个异步任务。注意这三个参数哦。
        AddOnPreRenderCompleteAsync(BeginCall, EndCall, http);
    }
    
    private IAsyncResult BeginCall(object sender, EventArgs e, AsyncCallback cb, object extraData)
    {
        // 在这个方法中,
        // sender 就是 this
        // e 就是 EventArgs.Empty
        // cb 就是 EndCall
        // extraData 就是调用AddOnPreRenderCompleteAsync的第三个参数
        Trace.Write("BeginCall ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)extraData;
        
        // 开始一个异步调用。页面线程也最终在执行这个调用后返回线程池了。
        // 中间则是等待网络的I/O的完成通知。
        // 如果网络调用完成,则会调用 cb 对应的回调委托,其实就是下面的方法
        return http.BeginSendHttpRequest(ServiceUrl, (string)http.UserData, cb, http);
    }
    
    private void EndCall(IAsyncResult ar)
    {
        // 到这个方法中,表示一个任务执行完毕。
        // 参数 ar 就是BeginCall的返回值。
    
        Trace.Write("EndCall ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)ar.AsyncState;
        string str = (string)http.UserData;
    
        try{
            // 结束异步调用,获取调用结果。如果有异常,也会在这里抛出。
            string result = http.EndSendHttpRequest(ar);
            labMessage.Text = string.Format("{0} => {1}", str, result);
        }
        catch(Exception ex){
            labMessage.Text = string.Format("{0} => Error: {1}", str, ex.Message);
        }
    }
    

    对照一下异步HttpHandler中的介绍,你会发现它们非常像。

    如果要执行多个异步任务,可以参考下面的代码:

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = textbox1.Text;
        AddOnPreRenderCompleteAsync(BeginCall, EndCall, http);
    
    
        MyHttpClient<string, string> http2 = new MyHttpClient<string, string>();
        http2.UserData = "T2_" + Guid.NewGuid().ToString();
        AddOnPreRenderCompleteAsync(BeginCall2, EndCall2, http2);
    }
    

    也很简单,就是调用二次AddOnPreRenderCompleteAsync而已。

    前面我说过,异步的处理是发生在PreRender和PreRenderComplete之间,我们来还是看一下到底是不是这样的。 在ASP.NET的Page中,我们很容易的输出一些调试信息,且它们会显示在所处的页面生命周期的相应执行阶段中。 这个方法很简单,在Page指令中加上【Trace="true"】选项,并在页面类的代码中调用Trace.Write()或者Trace.Warn()就可以了。 下面来看一下我加上调试信息的页面执行过程吧。

    从这张图片中,我们至少可以看到二个信息:
    1. 所有的异步任务的执行过程确实发生在PreRender和PreRenderComplete之间。
    2. 所有的异步任务被串行地执行了。

    2. 调用Page.RegisterAsyncTask()的异步页

    我一直认为ASP.NET程序也是一种服务程序,它要对客户端浏览器发出的请求而服务。 由于是服务,对于要服务的对象来说,都希望能尽快地得到响应,这其实也是对服务的一个基本的要求, 那就是:高吞量地快速响应。

    对于前面所说的方法,显然,它的所有异步任务都是串行执行的,对于客户端来说,等待的时间会较长。 而且,最严重的是,如果服务超时,上面的方法会一直等待,直到本次请求超时。 为了解决这二个问题,ASP.NET定义了一种异步任务类型:PageAsyncTask 。它可以解决以上二种问题。 首先我们还是来看一下PageAsyncTask类的定义:(说明:这个类的关键就是它的构造函数)

    // 摘要:
    //     使用并行执行的指定值初始化 System.Web.UI.PageAsyncTask 类的新实例。
    //
    // 参数:
    //   state:
    //     表示任务状态的对象。
    //
    //   executeInParallel:
    //     指示任务能否与其他任务并行处理的值。
    //
    //   endHandler:
    //     当任务在超时期内成功完成时要调用的处理程序。
    //
    //   timeoutHandler:
    //     当任务未在超时期内成功完成时要调用的处理程序。
    //
    //   beginHandler:
    //     当异步任务开始时要调用的处理程序。
    //
    // 异常:
    //   System.ArgumentNullException:
    //     beginHandler 参数或 endHandler 参数未指定。
    public PageAsyncTask(BeginEventHandler beginHandler, EndEventHandler endHandler, 
    			EndEventHandler timeoutHandler, object state, bool executeInParallel);
    

    注意这个构造函数的签名,它与AddOnPreRenderCompleteAsync()相比,多了二个参数:EndEventHandler timeoutHandler, bool executeInParallel 。 它们的含义上面的注释中有说明,这里只是提示您要注意它们而已。

    创建好一个PageAsyncTask对象后,只要调用页面的RegisterAsyncTask()方法就可以注册一个异步任务。 具体用法可参考我的如下代码:(注意代码中的注释)

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        // 准备回调数据,它将由PageAsyncTask构造函数的第四个参数被传入。
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = textbox1.Text;
    
        // 创建异步任务
        PageAsyncTask task = new PageAsyncTask(BeginCall, EndCall, TimeoutCall, http);
        // 注册异步任务
        RegisterAsyncTask(task);
    }
    
    private IAsyncResult BeginCall(object sender, EventArgs e, AsyncCallback cb, object extraData)
    {
        // 在这个方法中,
        // sender 就是 this
        // e 就是 EventArgs.Empty
        // cb 是ASP.NET定义的一个委托,我们只管在异步调用它时把它用作回调委托就行了。
        // extraData 就是PageAsyncTask构造函数的第四个参数
        Trace.Warn("BeginCall ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)extraData;
    
        // 开始一个异步调用。
        return http.BeginSendHttpRequest(ServiceUrl, (string)http.UserData, cb, http);
    }
    
    private void EndCall(IAsyncResult ar)
    {
        // 到这个方法中,表示一个任务执行完毕。
        // 参数 ar 就是BeginCall的返回值。
        Trace.Warn("EndCall ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)ar.AsyncState;
        string str = (string)http.UserData;
    
        try {
            // 结束异步调用,获取调用结果。如果有异常,也会在这里抛出。
            string result = http.EndSendHttpRequest(ar);
            labMessage.Text = string.Format("{0} => {1}", str, result);
        }
        catch( Exception ex ) {
            labMessage.Text = string.Format("{0} => Error: {1}", str, ex.Message);
        }
    }
    
    private void TimeoutCall(IAsyncResult ar)
    {
        // 到这个方法,就表示任务执行超时了。
        Trace.Warn("TimeoutCall ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)ar.AsyncState;
        string str = (string)http.UserData;
    
        labMessage.Text = string.Format("{0} => Timeout.", str);
    }
    

    前面我说过PageAsyncTask是支持超时的,那么它的超时功能是如何使用的呢,上面的示例只是给了一个超时的回调委托而已。

    在开始演示PageAsyncTask的高级功能前,有必要说明一下示例所调用的服务端代码。 本示例所调用的服务是【C#客户端的异步操作】中使用的演示服务, 服务代码如下:

    [MyServiceMethod]
    public static string ExtractNumber(string str)
    {
        // 延迟3秒,模拟一个长时间的调用操作,便于客户演示异步的效果。
        System.Threading.Thread.Sleep(3000);
    
        if( string.IsNullOrEmpty(str) )
            return "str IsNullOrEmpty.";
    
        return new string((from c in str where Char.IsDigit(c) orderby c select c).ToArray());
    }
    

    下面的示例我将演示开始二个异步任务,并设置异步页的超时时间为4秒钟。

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        // 设置页面超时时间为4秒
        Page.AsyncTimeout = new TimeSpan(0, 0, 4);
    
        // 注册第一个异步任务
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = textbox1.Text;
        PageAsyncTask task = new PageAsyncTask(BeginCall, EndCall, TimeoutCall, http);
        RegisterAsyncTask(task);
    
        // 注册第二个异步任务
        MyHttpClient<string, string> http2 = new MyHttpClient<string, string>();
        http2.UserData = "T2_" + Guid.NewGuid().ToString();
        PageAsyncTask task2 = new PageAsyncTask(BeginCall2, EndCall2, TimeoutCall2, http2);
        RegisterAsyncTask(task2);
    }
    

    此页面的执行过程如下:

    确实,第二个任务执行超时了。

    再来看一下PageAsyncTask所支持的任务的并行执行是如何调用的:

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        // 设置页面超时时间为4秒
        Page.AsyncTimeout = new TimeSpan(0, 0, 4);
    
        // 注册第一个异步任务
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = textbox1.Text;
        PageAsyncTask task = new PageAsyncTask(BeginCall, EndCall, TimeoutCall, http, true /*注意这个参数*/);
        RegisterAsyncTask(task);
    
        // 注册第二个异步任务
        MyHttpClient<string, string> http2 = new MyHttpClient<string, string>();
        http2.UserData = "T2_" + Guid.NewGuid().ToString();
        PageAsyncTask task2 = new PageAsyncTask(BeginCall2, EndCall2, TimeoutCall2, http2, true /*注意这个参数*/);
        RegisterAsyncTask(task2);
    }
    

    此页面的执行过程如下:

    图片清楚地反映出,这二个任务是并行执行时,所以,这二个任务能在4秒内同时执行完毕。

    在结束对PageAsyncTask的介绍前,有必要对超时做个说明。 对于使用PageAsyncTask的异步页来说,有二种方法来设置超时时间:
    1. 通过Page指令: asyncTimeout="0:00:45" ,这个值就是异步页的默认值。至于这个值的含义,我想您应该懂的。
    2. 通过设置 Page.AsyncTimeout = new TimeSpan(0, 0, 4); 这种方式。示例代码就是这种方式。
    注意:由于AsyncTimeout是Page级别的参数,因此,它是针对所有的PageAsyncTask来限定的,并非每个PageAsyncTask的超时都是这个值。

    3. 基于事件模式的异步页

    如果您看过我的博客【C#客户端的异步操作】, 那么对【基于事件模式的异步】这个词就不会再感到陌生了。在那篇博客中,我就对这种异步模式做过介绍, 只不是,上次是在WinForm程序中演示的而已。为了方便对比,我再次把那段代码贴出来:

    /// <summary>
    /// 基于事件的异步模式
    /// </summary>
    /// <param name="str"></param>
    private void CallViaEvent(string str)
    {
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(str, str);
    }
    
    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        //bool flag = txtOutput.InvokeRequired;    // 注意:这里flag的值是false,也就是说可以直接操作UI界面
        if( e.Error == null ) 
            ShowResult(string.Format("{0} => {1}", e.UserState, e.Result));
        else
            ShowResult(string.Format("{0} => Error: {1}", e.UserState, e.Error.Message));        
    }
    

    上次,我就解释过,这种方法在WinForm中非常方便。幸运的是,ASP.NET的异步页也支持这种方式。
    ASP.NET的异步页中的实现代码如下:

    private void CallViaEvent(string str)
    {
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(str, str);
    }
    
    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        Trace.Warn("client_OnCallCompleted ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
    
        if( e.Error == null )
            labMessage.Text = string.Format("{0} => {1}", e.UserState, e.Result);
        else
            labMessage.Text = string.Format("{0} => Error: {1}", e.UserState, e.Error.Message);
    }
    

    搞什么呀,这二段代码是一样的嘛。 您是不是也有这样的感觉呢?

    仔细看这二段代码,还是能发现它们有区别的。这里我就不指出它们了。它们与异步无关,说出它们意义不大, 反而,我更希望您对【基于事件模式的异步】留个好印象:它们就是一样的。

    再来看一下如何发出多个异步任务:

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
        string str = textbox1.Text;
    
        // 注意:这个异步任务,我设置了2秒的超时。它应该是不能按时完成任务的。
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl, 2000);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(str, str);        // 开始第一个异步任务
        
    
        string str2 = "T2_" + Guid.NewGuid().ToString();
        MyAysncClient<string, string> client2 = new MyAysncClient<string, string>(ServiceUrl);
        client2.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client2_OnCallCompleted);
        client2.CallAysnc(str2, str2);        // 开始第二个异步任务
    }    
    
    void client2_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        ShowCallResult(2, e);
    
    
        // 再来一个异步调用
        string str3 = "T3_" + Guid.NewGuid().ToString();
        MyAysncClient<string, string> client3 = new MyAysncClient<string, string>(ServiceUrl);
        client3.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client3_OnCallCompleted);
        client3.CallAysnc(str3, str3);        // 开始第三个异步任务
    }
    

    页面的执行过程如下图:

    这里要说明一下了:在【C#客户端的异步操作】中我就给出这个类的实现代码, 不过,这次我给它增加了超时功能,增加了一个重载的构造函数,需要在构造函数的第二个参数传入。 今天我就不贴出那个类的代码了,有兴趣的自己去下载代码阅读吧。 在上次贴的代码,你应该可以发现,在CallAysnc()时,就已经开始了异步操作。对于本示例来说,也就是在button1_click就已经开始了二个异步操作。

    这是个什么意思呢?
    可以这样来理解:前二个任务显然是和LoadComplete,PreRender事件阶段的代码在并行执行的。
    有意思的是:第三个任务是在第二个任务的结束事件中开始的,但三个任务的结束操作全在页面的PreRender事件才得到处理。 下面我再把这个例子来改一下,就更有趣了:

    protected void button1_click(object sender, EventArgs e)
    {
        Trace.Write("button1_click ThreadId = " + System.Threading.Thread.CurrentThread.ManagedThreadId.ToString());
        string str = textbox1.Text;
    
        // 注意:这个异步任务,我设置了2秒的超时。它应该是不能按时完成任务的。
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl, 2000);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(str, str);        // 开始第一个异步任务
    
        System.Threading.Thread.Sleep(3000);
    
        string str2 = "T2_" + Guid.NewGuid().ToString();
        MyAysncClient<string, string> client2 = new MyAysncClient<string, string>(ServiceUrl);
        client2.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client2_OnCallCompleted);
        client2.CallAysnc(str2, str2);        // 开始第二个异步任务
    }    
    

    现在,在第一个任务发出后,我让线程等待了3秒,也就是等到了第一个任务的超时。然后再开始第二个任务。
    也就是说:在button1_click事件还没执行完毕,第一个任务就结束了。
    现在,您可以猜一下,此时的执行过程是个什么样的。

    猜好了就来看下图吧。

    现在明白了吧:哪怕是在PostBackEvent阶段就结束的任务,也要等到PreRender之后才能得到处理。
    至于为什么会是这样的,我以后再讲。今天只要记住本文的第一张图片就好了。
    我可是好不容易才找出这张图片来的,且为了让您能看得更清楚,还花了些时间修改了它。
    在那个图片后面我还说过:在一个异步页的【页面生命周期】中,所有异步任务在执行时所处的阶段。 并在后面注明了这里的所有这个词也不太恰当。现在可以解释为什么不恰当了:
    【基于事件模式的异步】的开始阶段并不一定要PreRender事件之后,而对于前二种异步面的实现方式则是肯定在PreRender事件之后。
    至于这其中的原因,同样,您要等待我的后续博客了。

    各种异步页的实现方式比较

    前面介绍了3种异步页的实现方式,我打算在这里给它们做个总结及比较。当然,这一切只代表我个人的观点,仅供参考。

    为了能给出一个客观的评价,我认为先有必要再给个示例,把这些异步方式放在一起执行,就好像把它们放在一起比赛一样, 或许这样会更有意思,同时也会让我给出的评价更有说服力。

    在下面的示例中,我把上面说过的3种异步方式放在一起,并让每种方法执行多次(共10个异步任务),实验代码如下:

    protected void button1_click(object sender, EventArgs e)
    {        
        ShowThreadInfo("button1_click");
    
        // 为PageAsyncTask设置超时时间
        Page.AsyncTimeout = new TimeSpan(0, 0, 7);
    
        // 开启4个PageAsyncTask,其中第1,4个任务不接受并行执行,2,3则允许并行执行
        Async_RegisterAsyncTask("RegisterAsyncTask_1", false);
        Async_RegisterAsyncTask("RegisterAsyncTask_2", true);
        Async_RegisterAsyncTask("RegisterAsyncTask_3", true);
        Async_RegisterAsyncTask("RegisterAsyncTask_4", false);
    
        // 开启3个AddOnPreRenderCompleteAsync的任务
        Async_AddOnPreRenderCompleteAsync("AddOnPreRenderCompleteAsync_1");
        Async_AddOnPreRenderCompleteAsync("AddOnPreRenderCompleteAsync_2");
        Async_AddOnPreRenderCompleteAsync("AddOnPreRenderCompleteAsync_3");
    
        // 最后开启3个基于事件通知的异步任务,其中第2个任务由于设置了超时,将不能成功完成。
        Async_Event("MyAysncClient_1", 0);
        Async_Event("MyAysncClient_2", 2000);
        Async_Event("MyAysncClient_3", 0);
    }
    
    private void Async_RegisterAsyncTask(string taskName, bool executeInParallel)
    {
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = taskName;
        PageAsyncTask task = new PageAsyncTask(BeginCall_Task, EndCall_Task, TimeoutCall_Task, http, executeInParallel);
        RegisterAsyncTask(task);
    }
    private void Async_AddOnPreRenderCompleteAsync(string taskName)
    {
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = taskName;
        AddOnPreRenderCompleteAsync(BeginCall, EndCall, http);
    }
    private void Async_Event(string taskName, int timeoutMilliseconds)
    {
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl, timeoutMilliseconds);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(taskName, taskName);
    }
    

    执行过程如下图:

    不知您看到这个执行过程是否会想到为什么会是这个样子的。至于为什么会是这个样子的, 这就涉及到ASP.NET的异步页的执行过程,这个过程比较复杂,我以后再谈。 今天咱们就来根据这个图片来谈谈比较表面化的东西,谈一下这三种方式的差别。

    从上面的代码以及执行过程,可以看到一个有趣的现象,我明明是先注册的4个PageAsyncTask 。 可是呢,最先显示的却是【BeginCall AddOnPreRenderCompleteAsync_1】。 我想我这里使用显示这个词也是比较恰当的,为什么呢?因为,我前面已经解释过了, 基于事件的异步的任务应该是在button1_click事件处理器中先执行的,只是我没有让它们显示罢了。 接下来的故事也很自然,由于我将"MyAysncClient_2"设置为2秒的超时,它最先完成,只是结果为超时罢了。 紧接着,"MyAysncClient_1"和"MyAysncClient_3"也执行结束了。嗯,是的:3个事件的异步任务全执行完了。

    说到这里我要另起一段了,以提醒您的注意。
    有没有注意到,前面说到的3个事件的异步任务全执行完了。这个时候,其它的异步任务绝大部分还没有开始呢, 它们3个咋就先执行完了呢?

    有意思吧,其实何止3个,如果再来5个基于事件的异步任务,它们还是会先执行完成,不信的话,看下图:

    或许举这个例子把基于事件的异步方式捧高了。这里我也要客观的解释一下原因了:
    出现这个现象主要由2个原因造成的:
    1. 在这个例子中,"MyAysncClient_1", "MyAysncClient_2", "MyAysncClient_3", "AddOnPreRenderCompleteAsync_1" 由于都是异步任务,所以基本上是并行执行的,
    2. 由于3个基于事件的异步方式先执行的,因此它们先结束了。

    接着来解释图片所反映的现象。当基于事件的异步任务全执行完成后," EndCall AddOnPreRenderCompleteAsync_1" 也被调用了。说明"AddOnPreRenderCompleteAsync_1"这个任务彻底地执行完了。 接下来,"AddOnPreRenderCompleteAsync_2","AddOnPreRenderCompleteAsync_3"也依次执行完了。

    我一开始用RegisterAsyncTask注册的4个异步任务呢?终于,在前面的所有异步任务全部执行完成后, 才开始了这类任务的执行过程。首先执行的是"RegisterAsyncTask_1",这个好理解。 接下来,"BeginCall RegisterAsyncTask_2", "BeginCall RegisterAsyncTask_3"被连续调用了, 这也好理解吧,因为我当时创建异步任务时,指定它们是允许与其它任务并行执行的,因此它们是一起执行的。 3秒后,2个任务同时执行完了,最后启动了"RegisterAsyncTask_4",由于它不支持并行执行,所以,它排在最后, 在没有任何悬念中,"TimeoutCall RegisterAsyncTask_4"被调用了。这么正常啊,我设置过Page.AsyncTimeout = new TimeSpan(0, 0, 7); 因此,前二批PageAsyncTask赶在超时前正常结束了,留给"RegisterAsyncTask_4"的执行时间只有1秒,它当然就不能在指定时间内正常完成。

    似乎到这里,这些异步任务的执行过程都解释完了,但是,有二个很奇怪的现象您有没有发现:
    1. 为什么AddOnPreRenderCompleteAsync的任务全执行完了之后,才轮到PageAsyncTask的任务呢?
    2. 还有前面说过的,为什么是"BeginCall AddOnPreRenderCompleteAsync_1"最先显示呢?
    这一切绝非偶然,如果您有兴趣,可下载我的示例代码,你运行千遍万遍还将是这个结果。

    这些原因我以后再谈,今天的博客只是想告诉您这样一个结果就行了。
    不过,为了能让您能容易地理解后面的内容,我暂且告诉您:PageAsyncTask是建立在AddOnPreRenderCompleteAsync的基础上的。

    有了前面这些实验结果,我们再来对这3种异步页方法做个总结及比较。

    1. AddOnPreRenderCompleteAsync: 它提供了最基本的异步页的使用方法。就好像HttpHandler一样,它虽能处理请求,但不太方便,显得比较原始。 由于它提供的是比较原始的方法,您也可以自行包装您的高级功能。

    2. PageAsyncTask: 与AddOnPreRenderCompleteAsync相比,它增加了超时以及并行执行的功能,但我也说过,它是建立在AddOnPreRenderCompleteAsync的基础之上的。 如果把AddOnPreRenderCompleteAsync比作为HttpHandler,那么PageAsyncTask则就像是Page 。因此它只是做了些高级的包装罢了。

    3. 基于事件的异步方式:与前2者完全没有关系,它只依赖于AspNetSynchronizationContext。这里有必要强调一下: 【基于事件的异步方式】可以理解为一个设计模式,也可以把它理解成对最基础的异步方式的高级包装。 它能提供或者完成的功能,依赖于包装的方式及力度。 在我提供的这个包装类中,它也可以实现与PageAsyncTask一样的并行执行以及超时功能。

    后二种方法功能强大的原因是来源于高级包装,由于包装,过程也会更复杂,因此性能或许也会有微小的损失。 如果您不能接受这点性能损失,可能还是选AddOnPreRenderCompleteAsync会比较合适。 不过,我要再次提醒您:它不支持并行执行,不支持超时。

    请容忍我再夸一下【基于事件的异步模式】,从我前面的示例代码,尤其是与WinForm中的示例代码的比较中, 我们可以清楚的发现,这种方式是非常易用的。掌握了这种方式,至少在这二大编程模型中都是适用的。 而且,它能在异步页的执行周期中,较早的进入异步等待状态,因此能更快的结束执行过程。 想想【从"Begin Raise PostBackEvent"到"End PreRender"这中间还可以执行多少代码是不确定的】吧。

    【基于事件的异步模式】的优点不仅如此,我的演示代码中还演示了另一种用法: 在一个完成事件中,我还能再开启另一个异步任务。 这个优点使我可以有选择性地启动后续的异步操作。但是,这个特性是另2个不可能做到的! 这个原因可以简单地表达为:在PreRender事件后,调用AddOnPreRenderCompleteAsync会抛异常。

    异步HttpModule的实现方式

    【用Asp.net写自己的服务框架】中, 我示范过如果编写一个HttpModule,通常只要我们实现IHttpModule接口,并在Init方法中订阅一些事件就可以了:

    internal class DirectProcessRequestMoudle : IHttpModule
    {
        public void Init(HttpApplication app)
        {
            app.PostAuthorizeRequest += new EventHandler(app_PostAuthorizeRequest);
        }
    

    HttpHandler有异步接口的IHttpAsyncHandler,但HttpModule却只有一个接口:IHttpModule,不管是同步还是异步。 异步HttpModule的实现方式并不是订阅HttpApplication的事件,而是调用HttpApplication的一些注册异步操作的方法来实现的(还是在Init事件中), 这些方法可参考以下列表:

    // 将指定的 System.Web.HttpApplication.AcquireRequestState 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.AcquireRequestState事件处理程序的集合。
    public void AddOnAcquireRequestStateAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.AuthenticateRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.AuthenticateRequest事件处理程序的集合。
    public void AddOnAuthenticateRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.AuthorizeRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.AuthorizeRequest事件处理程序的集合。
    public void AddOnAuthorizeRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.BeginRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.BeginRequest事件处理程序的集合。
    public void AddOnBeginRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.EndRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.EndRequest事件处理程序的集合。
    public void AddOnEndRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    public void AddOnLogRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    public void AddOnMapRequestHandlerAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostAcquireRequestState 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostAcquireRequestState事件处理程序的集合。
    public void AddOnPostAcquireRequestStateAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostAuthenticateRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostAuthenticateRequest事件处理程序的集合。
    public void AddOnPostAuthenticateRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostAuthorizeRequest 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostAuthorizeRequest事件处理程序的集合。
    public void AddOnPostAuthorizeRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    public void AddOnPostLogRequestAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostMapRequestHandler 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostMapRequestHandler事件处理程序的集合。
    public void AddOnPostMapRequestHandlerAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostReleaseRequestState 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostReleaseRequestState事件处理程序的集合。
    public void AddOnPostReleaseRequestStateAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostRequestHandlerExecute 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostRequestHandlerExecute事件处理程序的集合。
    public void AddOnPostRequestHandlerExecuteAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostResolveRequestCache 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostResolveRequestCache事件处理程序的集合。
    public void AddOnPostResolveRequestCacheAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PostUpdateRequestCache 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PostUpdateRequestCache事件处理程序的集合。
    public void AddOnPostUpdateRequestCacheAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.PreRequestHandlerExecute 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.PreRequestHandlerExecute事件处理程序的集合。
    public void AddOnPreRequestHandlerExecuteAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.ReleaseRequestState 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.ReleaseRequestState事件处理程序的集合。
    public void AddOnReleaseRequestStateAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.ResolveRequestCache 事件处理程序
    // 添加到当前请求的异步 System.Web.HttpApplication.ResolveRequestCache事件处理程序的集合。
    public void AddOnResolveRequestCacheAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    // 将指定的 System.Web.HttpApplication.UpdateRequestCache 事件
    // 添加到当前请求的异步 System.Web.HttpApplication.UpdateRequestCache事件处理程序的集合。
    public void AddOnUpdateRequestCacheAsync(BeginEventHandler beginHandler, EndEventHandler endHandler, object state);
    
    

    每个方法的含义从它们的名字是可以看出。 异步HttpModule的实现方式需要将异步对应的Begin/End二个方法分别做为委托参数传入这些方法中。
    注意:这些方法的签名与Page.AddOnPreRenderCompleteAsync()是一致的,因此它们的具体用法也与Page.AddOnPreRenderCompleteAsync()一样。

    为什么这里不设计成订阅事件的方式?
    我想是因为:如果采用事件模式,调用者可以只订阅其中的一个事件,ASP.NET不容易控制,还有"object state"这个参数不便于在订阅事件时传入。

    异步HttpModule的示例代码如下:

    /// <summary>
    /// 【示例代码】演示异步的HttpModule
    /// 说明:这个示例一丁点意义也没有,纯粹是为了演示。
    /// </summary>
    public class MyAsyncHttpModule  : IHttpModule
    {
        public static readonly object HttpContextItemsKey = new object();
    
        private static readonly string s_QueryDatabaseListScript =
            @"select dtb.name  from master.sys.databases as dtb order by 1";
    
        private static readonly string s_ConnectionString =
            @"server=localhost\sqlexpress;Integrated Security=SSPI;Asynchronous Processing=true";
    
    
        public void Init(HttpApplication app)
        {
            // 注册异步事件
            app.AddOnBeginRequestAsync(BeginCall, EndExecuteReader, null);
        }
        
        private IAsyncResult BeginCall(object sender, EventArgs e, AsyncCallback cb, object extraData)
        {
            SqlConnection connection = new SqlConnection(s_ConnectionString);
            connection.Open();
    
            SqlCommand command = new SqlCommand(s_QueryDatabaseListScript, connection);
    
            CallbackParam cbParam = new CallbackParam {
                Command = command,
                Context = HttpContext.Current
            };
    
            return command.BeginExecuteReader(cb, cbParam);
        }
    
        private class CallbackParam
        {
            public SqlCommand Command;
            public HttpContext Context;
        }
    
        private void EndExecuteReader(IAsyncResult ar)
        {
            CallbackParam cbParam = (CallbackParam)ar.AsyncState;
            StringBuilder sb = new StringBuilder();
    
            try {
                using( SqlDataReader reader = cbParam.Command.EndExecuteReader(ar) ) {
                    while( reader.Read() ) {
                        sb.Append(reader.GetString(0)).Append("; ");
                    }
                }
            }
            catch( Exception ex ) {
                cbParam.Context.Items[HttpContextItemsKey] = ex.Message;
            }
            finally {
                cbParam.Command.Connection.Close();
            }
    
            if( sb.Length > 0 )
                cbParam.Context.Items[HttpContextItemsKey] = "数据库列表:" + sb.ToString(0, sb.Length - 2);
        }
    
        public void Dispose()
        {
        }
    }
    

    页面可以使用如下方式获得MyAsyncHttpModule的结果:

    public partial class TestMyAsyncHttpModule : System.Web.UI.Page
    {
        protected void Page_Load(object sender, EventArgs e)
        {
            string result = (string)HttpContext.Current.Items[MyAsyncHttpModule.HttpContextItemsKey]
                            ?? "没有开启MyAsyncHttpModule,请在web.config中启用它。";
            Response.Write(result);
        }
    }
    

    说明:管线处理过程中,可能有多个HttpModule,但是异步的HttpModule在执行时,只是在一个阶段内,所有的HttpModule采用异步方式工作。 当进入下一个阶段前,必须要等到所有HttpModule全部在当前阶段内执行完毕。

    通常情况下,是没有必要写异步的HttpModule的。这是我写的第一个异步HttpModule。

    异步的 Web Service

    由于Web Service也是受ASP.NET支持,且随着ASP.NET一起出现。我们再来看一下如果将一个同步的服务方法改变成异步的方法。
    注意:将方法由同步改成异步版本,是不影响客户端的。

    以下代码是一个同步版本的服务方法:

    [WebMethod]
    public string ExtractNumber(string str)
    {
        //return ........
    }
    

    再来看一下最终的异步实现版本:

    [WebMethod]
    public IAsyncResult BeginExtractNumber(string str, AsyncCallback cb, object state)
    {
        MyHttpClient<string, string> http = new MyHttpClient<string, string>();
        http.UserData = "Begin ThreadId: " + Thread.CurrentThread.ManagedThreadId.ToString();
    
        return http.BeginSendHttpRequest(ServiceUrl, str, cb, http);
    }
    
    [WebMethod]
    public string EndExtractNumber(IAsyncResult ar)
    {
        MyHttpClient<string, string> http = (MyHttpClient<string, string>)ar.AsyncState;
        try{
            return http.EndSendHttpRequest(ar) +
                ", " + http.UserData.ToString() +
                ", End ThreadId: " + Thread.CurrentThread.ManagedThreadId.ToString();
        }
        catch(Exception ex){
            return ex.ToString();
        }
    }
    

    其实,要做的修改与IHttpHandler到IHttpAsyncHandler的工作差不多,在原有的同步方法后面加二个与异步操作有关的参数, 并且返回值改为IAsyncResult,然后再添加一个EndXxxx方法就可以了,当然了,EndXxxx方法的传入参数只能是一个IAsyncResult类型的参数。

    ASP.NET MVC 中的异步方式

    在ASP.NET MVC框架中,感觉一下回到原始社会中,简直和异步页的封装没法比。来看代码吧。(注意代码中的注释)

    // 实际可处理的Action名称为 Test1 ,注意名称后要加上 Async
    public void Test1Async()
    {
        // 告诉ASP.NET MVC,要开始一个异步操作了。
        AsyncManager.OutstandingOperations.Increment();
    
        string str = Guid.NewGuid().ToString();
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl);
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client_OnCallCompleted);
        client.CallAysnc(str, str);        // 开始异步调用
    
    }
    
    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        // 告诉ASP.NET MVC,一个异步操作结束了。
        AsyncManager.OutstandingOperations.Decrement();
    
        if( e.Error == null )
            AsyncManager.Parameters["result"] = string.Format("{0} => {1}", e.UserState, e.Result);
        else
            AsyncManager.Parameters["result"] = string.Format("{0} => Error: {1}", e.UserState, e.Error.Message);
    
        // AsyncManager.Parameters["result"] 用于写输出结果。
        // 这里仍然采用类似ViewData的设计。
        // 注意:key 的名称要和Test1Completed的参数名匹配。
    }
    
    // 注意名称后要加上 Completed ,且其余部分与Test1Async的前缀对应。
    public ActionResult Test1Completed(string result)
    {
        ViewData["result"] = result;
        return View();
    }
    

    说明:如果您认为单独为事件处理器写个方法看起来不爽,您也可以采用匿名委托之类的闭包写法,这个纯属个人喜好问题。

    再来个多次异步操作的示例:

    public void Test2Async()
    {
        // 表示要开启3个异步操作。
        // 如果把这个数字设为2,极有可能会产生的错误的结果。不信您可以试一下。
        AsyncManager.OutstandingOperations.Increment(3);
    
        string str = Guid.NewGuid().ToString();
        MyAysncClient<string, string> client = new MyAysncClient<string, string>(ServiceUrl);
        client.UserData = "result1";
        client.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client2_OnCallCompleted);
        client.CallAysnc(str, str);        // 开始第一个异步任务
    
        string str2 = "T2_" + Guid.NewGuid().ToString();
        MyAysncClient<string, string> client2 = new MyAysncClient<string, string>(ServiceUrl);
        client2.UserData = "result2";
        client2.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client2_OnCallCompleted);
        client2.CallAysnc(str2, str2);        // 开始第二个异步任务
    
        string str3 = "T3_" + Guid.NewGuid().ToString();
        MyAysncClient<string, string> client3 = new MyAysncClient<string, string>(ServiceUrl);
        client3.UserData = "result3";
        client3.OnCallCompleted += new MyAysncClient<string, string>.CallCompletedEventHandler(client2_OnCallCompleted);
        client3.CallAysnc(str3, str3);        // 开始第三个异步任务
    }
    
    void client2_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        // 递减内部的异步任务累加器。有点类似AspNetSynchronizationContext的设计。
        AsyncManager.OutstandingOperations.Decrement();
    
        MyAysncClient<string, string> client = (MyAysncClient<string, string>)sender;
        string key = client.UserData.ToString();
    
        if( e.Error == null )
            AsyncManager.Parameters[key] = string.Format("{0} => {1}", e.UserState, e.Result);
        else
            AsyncManager.Parameters[key] = string.Format("{0} => Error: {1}", e.UserState, e.Error.Message);
    }
    
    public ActionResult Test2Completed(string result1, string result2, string result3)
    {
        ViewData["result1"] = result1;
        ViewData["result2"] = result2;
        ViewData["result3"] = result3;
        return View();
    }
    

    我来解释一下上面的代码是如何以异步方式工作的。首先,我们要把Controller的基类修改为AsyncController,代码如下:

    public class HomeController : AsyncController
    
    

    假如我有一个同步的Action方法:Test1,它看起来应该是这样的:

    public ActionResult Test1()
    {
        return View();
    }
    

    首先,我需要把它的返回值改成void, 并把方法名称修改为Test1Async 。
    然后,在开始异步调用前,调用AsyncManager.OutstandingOperations.Increment();
    在异步完成时:
    1. 要调用AsyncManager.OutstandingOperations.Decrement();
    2. 将结果写入到AsyncManager.Parameters[]这个集合中。注意key的名字后面要用到。

    到这里,异步开发的任务算是做了一大半了。你可能会想我在哪里返回ActionResult呢?
    再来创建一个Test1Completed方法,签名应该是这个样子的:
    public ActionResult Test1Completed(string result)
    注意:方法中的参数名要和前面说过的写AsyncManager.Parameters[]的key名一致,包括数量。
    再后面的事情,我想您懂的,我就不多说了。

    再来说说我对【ASP.NET MVC的异步方式】这个设计的感受吧。
    简单说来就是:不够完美。

    要知道在这个例子中,我可是采用的基于事件的异步模式啊,在异步页中,哪有这些额外的调用?
    对于这个设计,我至少有2点不满意:
    1. AsyncManager.OutstandingOperations.Increment(); Decrement();由使用者来控制,容易出错。
    2. AsyncManager.Parameters[]这个bag设计方式也不爽,难道仅仅是为了简单?因为我可以在完成事件时,根据条件继续后面的异步任务,最终结果可能并不确定,因此后面的XXXXCompleted方法的签名就是个问题了。

    为什么在ASP.NET MVC中,这个示例需要调用Increment(); Decrement(),而在异步页中不需要呢?
    恐怕有些人会对此有好奇,我就告诉大家吧:这与AspNetSynchronizationContext有关。

    AspNetSynchronizationContext,真是个【成也萧何,败成萧何】的东西,在异步页为什么不需要我们调用类似Increment(); Decrement()的语句是因为, 它内部也有个这样的累加器,不过,当时在设计基于事件的异步模式时,在ASP.NET运行环境中,SynchronizationContext就是使用了AspNetSynchronizationContext这个具体实现类, 但它的绝大部分成员却是internal类型的。如果可以使用它,可以用一种简便地方式设置一个统一的回调委托:

    if( this._syncContext.PendingOperationsCount > 0 ) {
        this._syncContext.SetLastCompletionWorkItem(this._callHandlersThreadpoolCallback);
    }
    
    

    就这么一句话,可以不用操心使用者到底开始了多少个异步任务,都可以在所有的异步结束后,回调指定的委托。只是可惜的是,这二个成员都是internal的!

    如果当初微软设计AspNetSynchronizationContext时,不开放SetLastCompletionWorkItem这个方法, 是担心使用者乱调用导致ASP.NET运行错误的话,现在ASP.NET MVC的这种设计显然更容易出错。 当然了,ASP.NET MVC出来的时候,这一切早就出现了,因此它也无法享受AspNetSynchronizationContext的便利性。 不过,最让我想不通的是:直到ASP.NET 4.0,这一切还是原样。 难道是因为ASP.NET MVC独立在升级,连InternalsVisibleTo的机会也不给它吗?

    就算我们不用基于事件的异步模式,异步页还有二种实现方法呢(都不需要累加器),可是ASP.NET MVC却没有实现类似的功能。 所以,这样就显得很不完善。我们也只能期待未来的版本能改进这些问题了。

    MSDN参考文章:在 ASP.NET MVC 中使用异步控制器

    受争论的【基于事件的异步模式】

    本来在我的写作计划中,是没有这段文字的,可就在我打算发布这篇博客之前,想到上篇博客中的评论,突然我想到一本书:CLR via C# 。 是的,就是这本书,我想很多人手里有这本书,想到这本书是因为上篇博客的评论中,出现一个与我的观点有着不一致的声音(来自AndersTan),而他应该是Jeffer Richter的粉丝。 我早就买了这本书了(中文第三版),其实也是AndersTan推荐的,不过一直没有看完, 因此,根本就没有发现Jeffer Richter是【基于事件的异步模式】的反对者, 这个可参考书中696页。Jeffer Richter在书中说:“由于我不是EAP的粉丝,而且我不赞同使用这个模式, 所以一直没有花太多的时间在它上面。然而,我知道有一些人确实喜欢这个模式,而且想使用它,所以我专门花了一些时间研究它。” 为了表示对大牛的敬重,我用蓝色字体突出他说的话(当然是由周靖翻译的)。看到这句话以及后面他对于此模式的评价,尤其是在 【27.11.2 APM和EAP的对比】这个小节中对于EAP的评价,让我感觉大牛其实也没有很好地了解这个模式。

    这里再补充一下,书中提到二个英文简写:EAP: Event-base Asynchronous Pattern, APM: Asynchronous Programming Model 。书中689页中,Jeffer Richter还说过:“虽然我是APM的超级粉丝,但是我必须承认它存在的一些问题。” 与之相反,虽然我不是APM的忠实粉丝,我却不认为他所说的问题真的是APM的缺点。他说的第一点,感觉就没有意义。 我不知道有多少人在现实使用中,是在调用了Begin方法后,立即去调用End方法? 我认为.net允许这种使用方式,可能还是更看中的是使用上的灵活性,毕竟微软要面对的开发者会有千奇百怪的要求。 而且MSDN中也解释了这种调用会阻塞线程。访问IAsyncResult是可以得到一个WaitHandle对象, 这个好像在上篇博客的评论中有人也提过了,我当时也不想说了,这次就把我的实现方式贴出来了,只希望告诉一些人:这个成员虽然是个耗资源的东西, 但要看你如何去实现它了:有些时候(异步完成的时候)可以返回null的,所以,通常应该设计成一种延迟创建模式才对(我再一次的提醒:在设计它时要考虑多线程的并发访问)。

    刚才扯远了,我们还是来说关于Jeffer Richter对于【27.11.2 APM和EAP的对比】这个小节的看法(699页)。这个小节有4个段话,分别从4个方面说了些EAP的【缺点】, 我也将依次来发表我的观点。

    1. Jeffer Richter认为EAP的最大优点在于和IDE的配合使用,且在后面一直提到GUI线程。 显然EAP模式被代表了,被WinForm这类桌面程序程序代表了。 我今天的示例代码全部是可以在ASP.NET环境下运行的,而且还特意和WinForm下的使用方法做了比较,结果是:使用方式基本相同。 我认为这个结果才是EAP模式最大的优点:在不同的编程模型中不必考虑线程模型问题。

    2. Jeffer Richter说:事实上,EAP必须为引发的所有进度报告和完成事件分配从EventArgs派生的对象......。 看到这句话的感觉还是和上句话差不多:被代表了。 对于这段话,我认为有必要从几个角度来表达我的观点: 
    a. 进度报告:我想问一句:ASP.NET编程模型下进度报告有什么意义,或者说如何实现? 在我今天演示的示例代码中,我一直没演示进度报告吧?事实上,我的包装类中根本就不提供这个功能,只提供了完成事件的通知功能。 再说,为什么需要进度报告?因为桌面程序需要,它们为了能让程序拥有更好的用户体验。当然也可以不提供进度报告嘛, 大不了让用户守在电脑面前傻等就是了,这样还会有性能损失吗?当然没有,但是用户可能会骂人......。 
    b. 性能损失:MyAysncClient是对一个更底层的静态方法调用的封装。我也很明白:有封装就有性能的损失。但我想:一次异步任务也就只通知一次,性能损失能有多大? 而且明知道有性能损失,我为什么还要封装呢?只为一个很简单的理由:使用起来更容易! 
    c. 对象的回收问题:如果按照Jeffer Richter的说法,多创建这几个对象就让GC为难的话,会让我对.NET失去信心,连ASP.NET也不敢用了, 因为:要知道.NET的世界是完全面向对象的世界,一次WEB请求的处理过程中,ASP.NET不知道要创建多少个对象,我真的数不清楚。

    3. Jeffer Richter说:如果在登记事件处理方法之前调用XxxAsync方法,......。看到这里,我笑了。 显然,大牛是非常讨厌EAP模式的。EAP是使用了事件,这个错误的调用顺序问题如果是EAP的错,那么.NET的事件模式就是个错误的设计。 大牛说这句真是不负责任嘛。

    4. Jeffer Richter说:“EAP的错误处理和系统的其余部分也不一致,首先,异步不会抛出。在你的事件处理方法中,必须查询;AsyncCompletedEventArgs的Exception属性,看它是不是null ......” 看到这句话,我突然想到:一个月前在同事的桌上看到Jeffery Zhao 在【2010第二届.NET技术与IT管理技术大会 的一个 The Evolution of Async Programming on .NET Platform】培训PPT,代码大致是这样写的:

    class XxxCompletedEventArgs : EventArgs {
        Exception Error { get; }
        TResult Result { get; }
    }
    

    所以,我怀疑:Jeffer Richter认为EAP模式在完成时的事件中,异常也结果也是这样分开来处理的!

    大家不妨回想一下,回到Jeffery Richter所说的APM模式下,我们为了能得到异步调用的结果,去调用End方法, 结果呢,如果异步在处理时,有异常发生了,此时会抛出来。是的,我也同意使用这种方式来明确的告之调用者:此时没有结果,只有异常。

    我们还是再来看一下我前面一直使用的一段代码:

    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        if( e.Error == null )
            labMessage.Text = string.Format("{0} => {1}", e.UserState, e.Result);
        else
            labMessage.Text = string.Format("{0} => Error: {1}", e.UserState, e.Error.Message);
    }
    

    表面上看,这段代码确实有Jeffer Richter所说的问题:有异常不会主动抛出。
    这里有必要说明一下:有异常不主动抛出,而是依赖于调用者判断返回结果的设计方式,是不符合.NET设计规范的。 那我如果把代码写成下面的这样呢?

    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        try {
            labMessage.Text = string.Format("{0} => {1}", e.UserState, e.Result);
        }
        catch( Exception ex ) {
            labMessage.Text = string.Format("{0} => Error: {1}", e.UserState, ex.Message);
        }
    }
    

    什么,您不认为我直接访问e.Result,会出现异常吗?

    再来看一下我写的事件参数类型吧,看看我是如何做的:

    public class CallCompletedEventArgs : AsyncCompletedEventArgs
    {
        private TOut _result;
    
        public CallCompletedEventArgs(TOut result, Exception e, bool canceled, object state)
            : base(e, canceled, state)
        {
            _result = result;
        }
    
        public TOut Result
        {
            get
            {
                base.RaiseExceptionIfNecessary();
                return _result;
            }
        }
    }
    

    其中,RaiseExceptionIfNecessary()方法的实现如下(微软实现的):

    protected void RaiseExceptionIfNecessary()
    {
        if( this.Error != null ) {
            throw new TargetInvocationException(SR.GetString("Async_ExceptionOccurred"), this.Error);
        }
        if( this.Cancelled ) {
            throw new InvalidOperationException(SR.GetString("Async_OperationCancelled"));
        }
    }
    

    让我们再来看前面的EAP模式中完成事件中的标准处理代码

    void client_OnCallCompleted(object sender, MyAysncClient<string, string>.CallCompletedEventArgs e)
    {
        if( e.Error == null )
            labMessage.Text = string.Format("{0} => {1}", e.UserState, e.Result);
        else
            labMessage.Text = string.Format("{0} => Error: {1}", e.UserState, e.Error.Message);
    }
    

    的确,这种做法对于EAP模式来说:是标准的处理方式:首先要判断this.Error != null ,为什么这个 不规范 的方式会成为标准呢?

    我要再问一句:为什么不用try.....catch这种更规范的处理方式呢?

    显然,我也演示了:EAP模式在获取结果时,也可以支持try.....catch这种方式的。在这里不用它的理由是因为:
    相对于if判断这类简单的操作来说,抛异常是个【昂贵】的操作。这种明显可以提高性能的做法,难道有错吗?
    在.net设计规范中,还有Tester-Doer, Try-Parse这二类模式。我想很多人也应该用过的吧,设计它们也是因为性能问题,与EAP的理由是一样的。

    再来总结一下。我的CallCompletedEventArgs类在实现时,有二个关键点:
    1. 事件类型要从AsyncCompletedEventArgs继承。
    2. 用只读属性返回结果,但在访问前,要调用基类的base.RaiseExceptionIfNecessary();
    这些都是EAP模式中,正确的设计方式。什么是模式?这就是模式。什么是规范?这就是规范!

    我们不能因为错误的设计,或者说,不尊守规范的设计,而造成的缺陷也要怪罪于EAP 。

    结束语

    异步是个很有用的技术,不管是对于桌面程序还是服务程序都是很用意义的。

    不过,相对于同步调用来说,异步也是复杂的,但它的各种使用方式也是很精彩的。

    异步很精彩,故事没讲完,请继续关注我的后续博客。

  • 相关阅读:
    Bootstrap<基础十四> 按钮下拉菜单
    Bootstrap<基础十三> 按钮组
    Bootstrap <基础十二>下拉菜单(Dropdowns)
    Bootstrap<基础十一>字体图标(Glyphicons)
    Bootstrap<基础十> 响应式实用工具
    Bootstrap<基础九>辅助类
    Bootstrap <基础八>图片
    Bootstrap <基础七>按钮
    Bootstrap<基础六> 表单
    Bootstrap <基础五>表格
  • 原文地址:https://www.cnblogs.com/sishahu/p/2964241.html
Copyright © 2020-2023  润新知