代码改变世界

开发设计模式 -- Asp.net中实现观察者模式

2008-09-17 09:16  TTlive  阅读(133)  评论(0编辑  收藏  举报

在asp.net中实现观察者模式?难道asp.net中的观察者模式有什么特别么?嗯,基于Http协议的Application难免有些健忘,我是这样实现的,不知道有没有更好的办法?

先谈谈需求吧,以免陷入空谈

最近一个Case, 这样的需求:很多客户端不断的向Web Application提交数据,管理员进入Web的管理页面可以即时的看到这些数据,有多个管理员可以同时浏览,且管理员浏览的数据从管理员开始监视那个时刻起,不能显示以前的数据。从这个场景一看,明显的观察者模式,管理员开始监视时,订阅数据,数据到达的时候向所有订阅了数据的管理员广播数据。

需求如下图:

有了发布者还需要订阅者,我们实现管理员类,来订阅数据

public class Admin
   {
     /**//// <summary>
     /// 用这个保存所有收到的数据
     /// </summary>
     public IList<string> MessageList
     { get; set; }
     public Admin(Monitor monitor)
     {
         MessageList = new List<string>();
         monitor.DataIn += new EventHandler< DataEventArgs>(ReciveMessage);
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     private void ReciveMessage(object sender, DataEventArgs e)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]         
MessageList.Add(e.Message);
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]
   }
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]

Ok,需要具备的元素我们都写好了,但是如何让它们工作起来?如果使Winform程序,那将毫无悬念。

分析:我们碰到的问题

第一个问题:当客户端发送一个数据包,我们是实例化一个新的Monitor么?如果是,哪么每次实例化一个全新的Monitor,所有在它上面订阅的事件将全部消失了,如果不是那这个Monitor将如何存在呢?总不能真空吧,两个http请求之间如何保存数据呢?不过再把需求一读,好像整个应用程序中就只需要也只能有一个这样的Monitor呢,该是单件模式上场的时候了。

在上面的Monitor的实现中添加下面的代码:

[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
private static Monitor _instance = null;
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]public static 
Monitor Current
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   get 
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]   
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     if (_instance == null)
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
       _instance = new Monitor();
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     return _instance;
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
   }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]}

但是本系统存在多个客户端,所以为了避免多线程造成问题,还是来Double Check一下吧,修改上面的代码如下:

[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
public static Monitor Current
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]     
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
         get 
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
         [img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]           
object o = new object();
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
           if (_instance == null)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
           [img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]             
lock (o)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
             [img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]                 
if (_instance == null)
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
                   _instance = new Monitor();
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
             }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
           }
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]           
return _instance;
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
         }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]

(PS:为什么使用单件就可以跨请求保存实例了呢?因为这里使用了一个static member保存Monitor的引用,static member在.net的GC里面是被作为Root的,详细内容请参见框架程序设计那本书)

第二个问题: 当管理员页面的ajax请求的时候,每两个请求如何保存数据?呵呵,上面那个问题不是说了么,用单件,但是单件是全局存在的,我们的管理员是多个,每个管理员可以决定是否订阅数据,以及什么时候订阅。想起来没?除了全局数据外我们还有Session

在管理页面上我放置一个“开始监视”的按钮,这个按钮使用ajax请求服务器端的一个HttpHandler,在Handler的ProcessRequest方法里这样来做:

[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
Admin admin = context.Session["monitor_listener"] as Admin;
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
if(admin == null)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   admin = new Admin(Monitor.Current);
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Session["monitor_listener"] = admin;
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]}
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]

注意,由于这个Handler需要访问Session,所以你需要让这个Handler继承IRequiresSessionState接口(为什么使用继承而不用实现这个术语?实际上这个接口是一个标记接口,没有任何需要实现的成员,只是标记这个Handler可以访问Session,我不知道为什么MS不使用Attribute,是不是更合理些)

在管理页面还有个一个SetInterval不断的调用一个含有ajax的方法,去请求另外一个Handler,这个Handler将Admin收到的数据返回到web页面,让我们来看看这个Handler的部分实现:

[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
public void ProcessRequest(HttpContext context)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]     
context.Response.Buffer = true;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]     
context.Response.ExpiresAbsolute = System.DateTime.Now.AddSeconds(-1);
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Response.Expires = 0;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Response.CacheControl = "no-cache";
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   Admin admin = context.Session["monitor_listener"] as Admin;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   if (admin == null || admin.MessageCollection == null ||
 admin.MessageCollection.Count <= 0)
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
return;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   string[] messages = new string[admin.MessageCollection.Count];
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
admin.MessageCollection.CopyTo(messages, 0);
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   StringBuilder sb = new StringBuilder();
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
   for (int i = 0; i < messages.Length; i++)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]   
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]     
sb.AppendFormat("<li>{0}</li>", messages);
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
   }
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
admin.MessageCollection.Clear();
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Session["monitor_listener"] = admin;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Response.Write(sb);
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]   
context.Response.Flush();
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]}
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]

OK,一个在asp.net环境中实现的观察者模式基本上就算完成了,不过上面只有怎样订阅,那什么时候取消订阅了,可以在Session_End事件里面取消订阅

还查看了一些关于长连接的文章,发现这个不错,准备改进一下。

完整的代码稍后提供,希望这块转头能引来一些玉

写完这个Post后本来想把完整代码实现传上来,后来看到不少园友提出异议,看了大家的留言后我也一直在思索:我为什么这样做?当初我是怎样想到这个解决方案的?我在几个解决方案之间做了取舍了么?我这样做是不是矫枉过正了?经过这些思考有了现在的这个Post。

首先我进一步谈一下需求:

这是一个Web Application,有很多客户端向服务器端提交数据(客户端是C++的,以http-post方式向服务器端提交二进制数据,服务器端解析这个二进制包,数据提交很频繁),管理员可以进入监视页面浏览这些数据,数据要即时的,客户端发来一条,管理员屏幕上要马上可以看到,允许多个管理员同时监视即时数据,所有管理员看到的数据都是一样的(目前是这样的,也许以后对管理员要分角色,各角色管理员看到的信息将不同)。

由于数据提交非常频繁,客户要求不允许频繁的数据库操作,所以我将数据保存在一个IList的缓存里面,当这个IList的大小超过了我在配置文件里定义的大小的时候就将数据批量插入到数据库。

下面我将以我当初思考的思路为主线描述:

第一个版本:

  1. //在程序里我写了一个静态类,这个静态类保存整个程序中共享的一些数据,
  2. //相当于原来的Application对象,但是静态成员是编译期类型检查的
  3. public static ApplicationData
  4. {
  5.     //这个队列用来保存客户端传递过来的数据,当队列达到一定长度的时候同步到数据库
  6.     public static Queue<DataHead> OperateDataList = new Queue<DataHead>();
  7.     这个List也是保存客户端传递过来的数据的,但它是为监视准备数据的,
  8.     //当一个监视页面的请求到来的时候将这个List的数据Response过去,然后Clear这个//List
  9.     public static IList<DataHead> MonitorDataList = new List<DataHead>();
  10. }
  11. public class ReciveDataHandler : IHttpHandler
  12. {
  13.     //……
  14.     Public void ProcessRequest(HttpContext context)
  15.     {
  16.         //解析从客户端传递过来的数据
  17.         DataHead data = GetData(context);
  18.         OperateDataList.Add(data);
  19.         If(OperateDataList.Count > BufferSize)
  20.         {
  21.             //将数据写入到数据库
  22.             AddToBase();
  23.         }
  24.         MonitorDataList.Add(data);
  25.     }
  26. }
  27. //监视页面从这里获取数据
  28. public class MonitorHandler : IHttpHandler
  29. {
  30.     //……
  31.     Public void ProcessRequest(HttpContext context)
  32.     {
  33.         If(MonitorDataList.Count > 0)
  34.         {
  35.             //将MonitorDataList里的数据Response出去
  36.             OutPut();
  37.             MonitorDataList.Clear();
  38.         }
  39.     }
  40. }

说实话,我当初做出这个的时候觉得一点问题都没有,开始的时候客户测试也没有发现任何问题,终于有一天客户和我同时测试部署在同一IIS的时候,问题出现了:只有一个监视页面有数据。看到这个后我还百思不得其解,顺着程序的执行流程一步一步走下去,没有找出任何错误。后来做了下日志,原来MonitorDataList是一个全局共享的,一个在监视把数据Clear了后别人就无法获取数据了。不知道有没有人这样做过:有时候忘记了自己正在做一个web程序,而web程序是一个并发的,对一些共享资源的访问有着微妙的问题,如果没有记住这点,按照程序流程的执行步骤是找不出任何问题的。

怎么办?再一看这不是事件订阅所描述的场景么?所以就有了上一篇Post的Solution。不过那个方案受到不少人质疑,其中金色海洋提出这样的方法:

 

看似这个方案不错,我尝试着将我的程序修改为这样,但是我将上面的代码编写完,我发现我不可以再进行下去了:上面的方案满足不了我的需求,客户明确要求了客户端提交的数据要先缓存然后缓存超过配置大小(这个大小还需要可以在配置文件里面配置,以便可以经过测试找出一个最合理的值),而这种Session记录的方案是依靠数据库来保存数据,这个Session[“id”]就相当于一个游标,这个游标指向的是数据库,那好,我们将Session[“id”]指向缓存数据,但是请注意缓存随时可能超过设置大小而被同步到数据库并被清空。

  1. public class ReciveData : IHttpHandler
  2. {
  3.     //……….
  4.     //将客户端传递过来的数据存入数据库
  5. }
  6. public class MonitorHandler : IHttpHandler
  7. {
  8.     //………
  9.     //为null的时候说明该管理员第一次监视
  10.     if(Session["id"] == null)
  11.     {
  12.     //根据时间从服务器取出数据
  13.     //并将取出数据的最后一个id保存在session中
  14.     Session["id"] = id;
  15.     }
  16.     //不为null则说明该管理员已经开始监视了
  17.     else
  18.     {
  19.     //根据session里保存的最后一个id,取出大于那个id的数据
  20.     Session["id"] = currentId;
  21.     }
  22. }

经过一番思考后我还是回到我自己的Solution上,不过我又有了新的看法了。不是要将数据先缓存么?看看这个缓存,实际上她也是个观察者,至于她执行怎样的缓存策略是她的事情,如是我又有了一个新类:

  1. //这里的代码接上篇Post
  2. using System;
  3. using System.Collections.Generic;
  4. using System.Text;
  5. namespace ForyourSoft.NetTraffic.Framework
  6. {
  7.   public sealed class DataBase
  8.   {
  9.     private IList<string> _buffer = new List<string>();
  10.     private static DataBase _instance = null;
  11.     public static void Subscribe()
  12.     {
  13.         if (_instance == null)
  14.           _instance = new DataBase(Monitor.Current);
  15.     }
  16.     public DataBase(Monitor monitor)
  17.     {
  18.   monitor.OnMessage += new EventHandler<Monitor.MessageEventArgs>(monitor_OnMessage);
  19.     }
  20.     void monitor_OnMessage(object sender, Monitor.MessageEventArgs e)
  21.     {
  22.         _buffer.Add(e.Message);
  23.         if (_buffer.Count >= Config.BufferSize)
  24.         {
  25.           //将数据添加到数据库
  26.         }
  27.     }
  28.   }
  29. }

PS:由于系统中我们只需要这样唯一一个订阅者,所以我将其实现为一个单件,在Application_Start的时候调用DataBase.Subscribe()。

现在系统是这样的结构:

可以设想以后还会有更多的订阅者。果然,昨天客户要求在下一个版本中管理员分角色,各个角色看到的数据不同的,只有超级管理员才可以监视所有数据,OMG,呵呵,不过还好,我只需要添加几个订阅者就可以轻松搞定。

后记:也许是我的文章标题没有起好,也许很多人得到模式恐惧症,提到模式总是要来考察一下你的case,不是那种Enterprise级别的用了pattern就是过火了。其实这篇文章的内容里没有一点模式的气息,只是用.net的Event实现观察者模式的思想,我想如果合适,今天模式的投资,明天你会有收获的。

在.net里面我们有事件(event),那就无需使用传统的观察者模式的模型了

那么我首先实现一个Monitor类,这个类用来接收客户端传递来的数据并将数据广播出去

[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
public class DataEventArgs : EventArgs
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
         public string Message
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
         [img]http://www.cnblogs.com/Images/dot.gif[/img]{get;set;}
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
         public DataEventArgs(string message)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
         [img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]           
this.Message = message;
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
         }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]
public class Monitor
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedBlock.gif[/img]
[img]http://www.cnblogs.com/Images/dot.gif[/img]{ 
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     public event EventHandler<DataEventArgs> DataIn;
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     private void SendData(string message)

[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]     
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
         if (DataIn != null)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
         [img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]           
DataEventArgs e = new DataEventArgs(message);
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
           DataIn(this, e);
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
         }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]
     /**//// <summary>
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     /// 这个方法被一个HttpHandler调用,客户端向这个Handler发送数据
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     /// 数据处理后作为字符串传递给该方法,该方法然后将数据广播出去
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     /// </summary>
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
     /// <param name="message">处理后的数据</param>
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
     public void ReciveData(string message)
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockStart.gif[/img]
[img]http://www.cnblogs.com/Images/OutliningIndicators/ContractedSubBlock.gif[/img]     
[img]http://www.cnblogs.com/Images/dot.gif[/img]{
[img]http://www.cnblogs.com/Images/OutliningIndicators/InBlock.gif[/img]
         SendData(message);
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedSubBlockEnd.gif[/img]
     }
[img]http://www.cnblogs.com/Images/OutliningIndicators/ExpandedBlockEnd.gif[/img]}
[img]http://www.cnblogs.com/Images/OutliningIndicators/None.gif[/img]