Fork me on GitHub

【转】基于ASP.NET MVC3 Razor的模块化/插件式架构实现——Mainz

  本文主要探讨了一种基于ASP.NET MVC3 Razor的模块化(Plugin)/插件(plugin)式架构的实现方法。本文借鉴了《Compile your asp.net mvc Razor views into a seperate dll》作者提供的方法。敬请注意。其实ASP.NET MVC的模块化(Plugin)/插件(plugin)式架构讨论的很多,但基于Razor视图引擎的很少(如:MVC2插件架构例子都是基于WebForm的,MVCContrib Portable Areas也是,还有这个Plugin架构)。要么就是非常复杂非常重量级的框架,例如Orchard CMS的模块化做的很好,可惜太重量级了,也没独立的模块可以剥离出来。所以我们追寻的是简单的基于ASP.NET MVC3 Razor的模块化(Plugin)/插件(plugin)式架构的实现方法。本文最后实现的项目结构如下图:(插件都放到~/Plugin目录下,按功能划分模块,每个模块都有M,V,C)

  3-5-2012 5-07-19 PM

  其中,业务模块(class library project)包含其所有的视图、控制器等,模型可以放在里面也可以单独放一个project。主web项目没有引用业务模块,业务模块会编译到主web项目的~/plugin目录下面(注意:不是bin目录),然后当web应用启动的时候自动加载plugin目录下面的模块。最后运行起来的效果如下图:

  3-6-2012 10-28-51 AM

其中红色的区域都是plugin进去的,那个tab的标题plugin到母版页的主菜单,tab内容也来自plugin。下面说说如何实现这样的ASP.NET MVC插件式plugin架构(模块化架构)。

实现的难点在动态加载UI视图(*.cshtml, _layout.cshtml, _viewStart.cshtml)

  废话少说,直入要害。基于ASP.NET MVC3 Razor的编译发生在两个层面:

  • 控制器(Controller), 模型(Models),和其它所有的C#代码等有msbuild(或者VisualStudio)编译到bin目录下的程序集(assembly)
  • 视图(*.aspx, *.cshtml)由ASP.NET在运行时动态编译。当一个Razor视图(*.cshtml)显示前,Razor视图引擎调用BuildManager把视图(*.cshtml)编译到动态程序集assembly,然后使用Activator.CreateInstance来实例化新编译出来的对象,最后显示出来。如果视图(*.cshtml)用到@model绑定model,那么还会自动加载bin或者GAC里面的Model。

  所以如果我们要动态加载插件(plugin),用反射bin目录下的程序集(assembly)的方法很容易搞定上面的第一部分(C#代码的部分),但UI视图的部分(上面第二部分)(特别是*.cshtml, 母版_layout.cshtml, 基视图_viewStart.cshtml)就比较难搞定。而且每次报错都是一样的,那就是Controller找不到相应的视图View,基本不知所云而且根本不是要点:view …. or its master was not found or no view engine supports the searched locations. The following locations were searched: …,因此要搞定UI视图的部分(上面第二部分)(特别是*.cshtml, 母版_layout.cshtml, 基视图_viewStart.cshtml),就需要自己动手了,基本原理是:

  • 重载RazorBuildProvider,用来动态编译视图
  • 实现一个自定义VirtualPathProvider,从虚拟路径自定义判断读取资源(从插件中加载资源),如果要使用编译的视图就返回编译的VirtualFile
  • 实现一个容器Dictionary保存已编译的视图和虚拟路径,例如path <~/views/team/index.cshtml> type <Area.Module2.Views.Team._Page_Views_Team_Index_cshtml>,或者path <~/views/_viewstart.cshtml> type <Area.Module1.Views._Page_Views__ViewStart_cshtml>

代码:自定义VirtualPathProvider,从虚拟路径自定义判断读取资源(从插件中加载资源),如果要使用编译的视图就返回编译的VirtualFile

 1   using System;
 2   using System.Collections.Generic;
 3   using System.Linq;
 4   using System.Reflection;
 5   using System.Web.Caching;
 6   using System.Web.Hosting;
 7   using System.Web.WebPages;
 8    
 9   namespace Common.Framework
10   {
11       public class CompiledVirtualPathProvider: VirtualPathProvider
12       {
13           /// <summary>
14           /// Gets a value that indicates whether a file exists in the virtual file system.
15           /// </summary>
16           /// <returns>
17           /// true if the file exists in the virtual file system; otherwise, false.
18           /// </returns>
19           /// <param name="virtualPath">The path to the virtual file.</param>
20           public override bool FileExists(string virtualPath)
21           {
22               return
23                   GetCompiledType(virtualPath) != null 
24                   || Previous.FileExists(virtualPath);
25           }
26    
27           public Type GetCompiledType(string virtualPath)
28           {
29               return ApplicationPartRegistry.Instance.GetCompiledType(virtualPath);
30           }
31    
32           /// <summary>
33           /// Gets a virtual file from the virtual file system.
34           /// </summary>
35           /// <returns>
36           /// A descendent of the <see cref="T:System.Web.Hosting.VirtualFile"/> class that represents a file in the virtual file system.
37           /// </returns>
38           /// <param name="virtualPath">The path to the virtual file.</param>
39           public override VirtualFile GetFile(string virtualPath)
40           {
41               if (Previous.FileExists(virtualPath))
42               {
43                   return Previous.GetFile(virtualPath);
44               }
45               var compiledType = GetCompiledType(virtualPath);
46               if (compiledType != null)
47               {
48                   return new CompiledVirtualFile(virtualPath, compiledType);
49               }
50               return null;
51           }
52    
53           public override System.Web.Caching.CacheDependency GetCacheDependency(string virtualPath, System.Collections.IEnumerable virtualPathDependencies, DateTime utcStart)
54           {
55               if (virtualPathDependencies == null)
56                   return Previous.GetCacheDependency(virtualPath, virtualPathDependencies, utcStart);
57    
58               return Previous.GetCacheDependency(virtualPath, 
59                       from vp in virtualPathDependencies.Cast<string>()
60                       where GetCompiledType(vp) == null
61                       select vp
62                     , utcStart);
63           }
64    
65       }
66   }

   代码:容器Dictionary保存已编译的视图和虚拟路径,例如path <~/views/team/index.cshtml> type <Area.Module2.Views.Team._Page_Views_Team_Index_cshtml>,路径注册以后,会从容器库全局搜索所有注册过的视图,也就是说即使你视图引用的_layout.cshtml和_viewStart.cshtml在其他的Class library project照样可以找到。

 1  using System;
 2  using System.Collections.Generic;
 3  using System.Diagnostics;
 4  using System.Linq;
 5  using System.Reflection;
 6  using System.Web;
 7  using System.Web.WebPages;
 8   
 9  namespace Common.Framework
10  {
11      public class DictionaryBasedApplicationPartRegistry : IApplicationPartRegistry
12      {
13          private static readonly Type webPageType = typeof(WebPageRenderingBase); 
14          private readonly Dictionary<string, Type> registeredPaths = new Dictionary<string, Type>();
15   
16          /// <summary>
17          /// 
18          /// </summary>
19          /// <param name="virtualPath"></param>
20          /// <returns></returns>
21          public virtual Type GetCompiledType(string virtualPath)
22          {
23              if (virtualPath == null) throw new ArgumentNullException("virtualPath");
24   
25              //Debug.WriteLine(String.Format("---GetCompiledType : virtualPath <{0}>", virtualPath));
26   
27              if (virtualPath.StartsWith("/"))
28                  virtualPath = VirtualPathUtility.ToAppRelative(virtualPath);
29              if (!virtualPath.StartsWith("~"))
30                  virtualPath = !virtualPath.StartsWith("/") ? "~/" + virtualPath : "~" + virtualPath;
31              virtualPath = virtualPath.ToLower();
32              return registeredPaths.ContainsKey(virtualPath)
33                         ? registeredPaths[virtualPath]
34                         : null;
35          }
36   
37          public void Register(Assembly applicationPart)
38          {
39              ((IApplicationPartRegistry)this).Register(applicationPart, null);
40          }
41   
42          public virtual void Register(Assembly applicationPart, string rootVirtualPath)
43          {
44              //Debug.WriteLine(String.Format("---Register assembly <{0}>, path <{1}>", applicationPart.FullName, rootVirtualPath));
45   
46              foreach (var type in applicationPart.GetTypes().Where(type => type.IsSubclassOf(webPageType)))
47              {
48                  //Debug.WriteLine(String.Format("-----Register type <{0}>, path <{1}>", type.FullName, rootVirtualPath));
49   
50                  ((IApplicationPartRegistry)this).RegisterWebPage(type, rootVirtualPath);
51              }
52          }
53   
54          public void RegisterWebPage(Type type)
55          {
56              ((IApplicationPartRegistry)this).RegisterWebPage(type, string.Empty);
57          }
58   
59          public virtual void RegisterWebPage(Type type, string rootVirtualPath)
60          {
61              var attribute = type.GetCustomAttributes(typeof(PageVirtualPathAttribute), false).Cast<PageVirtualPathAttribute>().SingleOrDefault<PageVirtualPathAttribute>();
62              if (attribute != null)
63              {
64                  var rootRelativeVirtualPath = GetRootRelativeVirtualPath(rootVirtualPath ?? "", attribute.VirtualPath);
65   
66                  //Debug.WriteLine(String.Format("---Register path/type : path <{0}> type <{1}>", rootRelativeVirtualPath.ToLower(),
67                  //                              type.FullName));
68                  registeredPaths[rootRelativeVirtualPath.ToLower()] = type;
69              }
70          }
71   
72          static string GetRootRelativeVirtualPath(string rootVirtualPath, string pageVirtualPath)
73          {
74              string relativePath = pageVirtualPath;
75              if (relativePath.StartsWith("~/", StringComparison.Ordinal))
76              {
77                  relativePath = relativePath.Substring(2);
78              }
79              if (!rootVirtualPath.EndsWith("/", StringComparison.OrdinalIgnoreCase))
80              {
81                  rootVirtualPath = rootVirtualPath + "/";
82              }
83              relativePath = VirtualPathUtility.Combine(rootVirtualPath, relativePath);
84              if (!relativePath.StartsWith("~"))
85              {
86                  return !relativePath.StartsWith("/") ? "~/" + relativePath : "~" + relativePath;
87              }
88              return relativePath;
89          }
90      }
91  }

  下面的代码很关键,用PreApplicationStartMethod关键字(.NET 4.0开始支持)使得代码在Application_Start之前执行。

  有关[assembly: PreApplicationStartMethod(typeof(SomeClassLib.Initializer), "Initialize")]详细信息请参考这个页面这个页面

 1  using System.Web;
 2  using System.Web.Compilation;
 3  using System.Web.Hosting;
 4  using Common.Framework;
 5  using Common.PrecompiledViews;
 6   
 7  [assembly: PreApplicationStartMethod(typeof(PreApplicationStartCode), "Start")]
 8   
 9  namespace Common.Framework
10  {
11      public static class PreApplicationStartCode
12      {
13          private static bool _startWasCalled;
14   
15          public static void Start()
16          {
17              if (_startWasCalled)
18              {
19                  return;
20              }
21              _startWasCalled = true;
22   
23              //Register virtual paths
24              HostingEnvironment.RegisterVirtualPathProvider(new CompiledVirtualPathProvider());
25   
26              //Load Plugin Folder, 
27              PluginLoader.Initialize();
28          }
29      }
30  }

  代码:PluginLoader,加载plugin目录里面的东东(assembly和module配置文件)

 1  using System;
 2  using System.Collections.Generic;
 3  using System.IO;
 4  using System.Linq;
 5  using System.Reflection;
 6  using System.Text;
 7  using System.Threading;
 8  using System.Web;
 9  using System.Web.Compilation;
10  using System.Web.Hosting;
11  using Common.Framework;
12  using Common.PrecompiledViews;
13   
14  //[assembly: PreApplicationStartMethod(typeof(PluginLoader), "Initialize")]
15   
16  namespace Common.PrecompiledViews
17  {
18      public class PluginLoader
19      {
20          public static void Initialize(string folder = "~/Plugin")
21          {
22              LoadAssemblies(folder);
23              LoadConfig(folder);
24          }
25   
26          private static void LoadConfig(string folder, string defaultConfigName="*.config")
27          {
28              var directory = new DirectoryInfo(HostingEnvironment.MapPath(folder));
29              var configFiles = directory.GetFiles(defaultConfigName, SearchOption.AllDirectories).ToList();
30              if (configFiles.Count == 0) return;
31   
32              foreach (var configFile in configFiles.OrderBy(s => s.Name))
33              {
34                  ModuleConfigContainer.Register(new ModuleConfiguration(configFile.FullName));
35              }
36          }
37   
38          private static void LoadAssemblies(string folder)
39          {
40              var directory = new DirectoryInfo(HostingEnvironment.MapPath(folder));
41              var binFiles = directory.GetFiles("*.dll", SearchOption.AllDirectories).ToList();
42              if (binFiles.Count == 0) return;
43   
44              foreach (var plug in binFiles)
45              {
46                  //running in full trust
47                  //************
48                  //if (GetCurrentTrustLevel() != AspNetHostingPermissionLevel.Unrestricted)
49                  //set in web.config, probing to plugin\temp and copy all to that folder
50                  //************************
51                  var shadowCopyPlugFolder = new DirectoryInfo(AppDomain.CurrentDomain.DynamicDirectory);
52                  var shadowCopiedPlug = new FileInfo(Path.Combine(shadowCopyPlugFolder.FullName, plug.Name));
53                  File.Copy(plug.FullName, shadowCopiedPlug.FullName, true); //TODO: Exception handling here...
54                  var shadowCopiedAssembly = Assembly.Load(AssemblyName.GetAssemblyName(shadowCopiedPlug.FullName));
55   
56                  //add the reference to the build manager
57                  BuildManager.AddReferencedAssembly(shadowCopiedAssembly);
58              }
59          }
60   
61          //private static AspNetHostingPermissionLevel GetCurrentTrustLevel()
62          //{
63          //    foreach (AspNetHostingPermissionLevel trustLevel in
64          //        new AspNetHostingPermissionLevel[]
65          //            {
66          //                AspNetHostingPermissionLevel.Unrestricted,
67          //                AspNetHostingPermissionLevel.High,
68          //                AspNetHostingPermissionLevel.Medium,
69          //                AspNetHostingPermissionLevel.Low,
70          //                AspNetHostingPermissionLevel.Minimal
71          //            })
72          //    {
73          //        try
74          //        {
75          //            new AspNetHostingPermission(trustLevel).Demand();
76          //        }
77          //        catch (System.Security.SecurityException)
78          //        {
79          //            continue;
80          //        }
81   
82          //        return trustLevel;
83          //    }
84   
85          //    return AspNetHostingPermissionLevel.None;
86          //}
87   
88      }
89  }

  此外,使用SingleFileGenerator的优点是性能提升,缺点是修改了视图就要重新编译。

如何让ASP.NET加载BIN目录之外的路径的Assembly

  我们把各个模块编译出来的assembly和各个模块的配置文件自动放到一个bin平级的plugin目录,然后web应用启动的时候自动扫描这个plugin目录并加载各个模块plugin,这个怎么做到的?大家也许知道,ASP.NET只允许读取Bin目录下的assbmely,不可以读取其他路径,包括Bin\abc等,即使在web.config这样配置probing也不行:(不信你可以试一下)

1   <configuration> Element
2     <runtime> Element
3       <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
4         <probing privatePath="bin;bin\abc;plugin;"/>
5       </assemblyBinding>
6      </runtime>
7   </configuration>

  这个和TrustLevel有关,在Full Trust的情况下,可以这样读取非Bin目录下的assembly:

  首先在和Bib平级的地方建一个目录Plugin,然后在模块class library project的属性里面加一个postBuildEvent,就是说在编译完成以后把模块的assbmely自动拷贝到主web项目的plugin目录:

1 copy /Y "$(TargetDir)$(ProjectName).dll" "$(SolutionDir)ModularWebApplication\Plugin\"
2 copy /Y "$(TargetDir)$(ProjectName).config" "$(SolutionDir)ModularWebApplication\Plugin\"

  然后用下面的代码加载Plugin目录下的assembly:(只看LoadAssembly那一段)

 1         private static void LoadAssemblies(string folder)
 2          {
 3              var directory = new DirectoryInfo(HostingEnvironment.MapPath(folder));
 4              var binFiles = directory.GetFiles("*.dll", SearchOption.AllDirectories).ToList();
 5              if (binFiles.Count == 0) return;
 6   
 7              foreach (var plug in binFiles)
 8              {
 9                  //running in full trust
10                  //************
11                  //if (GetCurrentTrustLevel() != AspNetHostingPermissionLevel.Unrestricted)
12                  //set in web.config, probing to plugin\temp and copy all to that folder
13                  //************************
14                  var shadowCopyPlugFolder = new DirectoryInfo(AppDomain.CurrentDomain.DynamicDirectory);
15                  var shadowCopiedPlug = new FileInfo(Path.Combine(shadowCopyPlugFolder.FullName, plug.Name));
16                  File.Copy(plug.FullName, shadowCopiedPlug.FullName, true); //TODO: Exception handling here...
17                  var shadowCopiedAssembly = Assembly.Load(AssemblyName.GetAssemblyName(shadowCopiedPlug.FullName));
18   
19                  //add the reference to the build manager
20                  BuildManager.AddReferencedAssembly(shadowCopiedAssembly);
21              }
22          }

 

  如果不是Full Trust,例如Medium Trust的情况下参考这个帖子《Developing-a-plugin-framework-in-ASPNET-with-medium-trust》。

如何在_layout.cshtml的主菜单注入plugin的菜单

  在母版页_layout.cshtml有个主菜单,一般是这样写的:

1 <ul>
2   <li>@Html.ActionLink("Home", "Index", "Home")</li>
3   <li>@Html.ActionLink("About", "About", "Home")</li>
4   <li>@Html.ActionLink("Team", "Index", "Team")</li>
5 </ul>

 

  现在我们如何实现从模块插入plugin到这个主菜单呢?这个有点难。因为大家知道,_layout.cshml母版没有controller。怎么实现呢?方法是用controller基类,让所有controller继承自这个基类。然后在基类里面,读取plugin目录里面的配置文件,获取所有模块需要插入的主菜单项,然后放入viewBag,这样在_Layout.cshtml就可以获取viewBag,类似这样:

1  <ul>
2     @foreach (MainMenuItemModel entry in ViewBag.MainMenuItems)
3      {
4          <li>@Html.ActionLink(entry.Text, 
5                 entry.ActionName, 
6                  entry.ControllerName)</li>
7      }
8  </ul>

  代码:基类Controller,读取plugin目录里面的配置文件,获取所有模块需要插入的主菜单项,然后放入viewBag

 1 using System;
 2 using System.Collections;
 3 using System.Collections.Generic;
 4 using System.ComponentModel;
 5 using System.Linq;
 6 using System.Net.Mime;
 7 using System.Text;
 8 using System.Web.Mvc;
 9  
10 namespace Common.Framework
11 {
12     public class BaseController : Controller
13     {
14         protected override void Initialize(System.Web.Routing.RequestContext requestContext)
15         {
16             base.Initialize(requestContext);
17  
18             // retireve data from plugins
19             IEnumerable<ModuleConfiguration> ret = ModuleConfigContainer.GetConfig();
20  
21             var data = (from c in ret
22                         from menu in c.MainMenuItems
23                         select new MainMenuItemModel
24                                    {
25                                        Id = menu.Id, ActionName = menu.ActionName, ControllerName = menu.ControllerName, Text = menu.Text
26                                    }).ToList();
27             
28             ViewBag.MainMenuItems = data.AsEnumerable();
29         }
30  
31     }
32 }

  代码:ModuleConfigContainer,用到单例模式,只读取一次

 1  using System;
 2  using System.Collections.Generic;
 3  using System.Linq;
 4  using System.Text;
 5   
 6  namespace Common.Framework
 7  {
 8      public static class ModuleConfigContainer
 9      {
10          static ModuleConfigContainer()
11          {
12              Instance = new ModuleConfigDictionary();
13          }
14   
15          internal static IModuleConfigDictionary Instance { get; set; }
16   
17          public static void Register(ModuleConfiguration item)
18          {
19              Instance.Register(item);
20          }
21   
22          public static IEnumerable<ModuleConfiguration> GetConfig()
23          {
24              return Instance.GetConfigs();
25          }
26      }
27  }

  代码:ModuleConfigDictionary

 1  using System;
 2  using System.Collections.Generic;
 3  using System.Linq;
 4  using System.Text;
 5   
 6  namespace Common.Framework
 7  {
 8      public class ModuleConfigDictionary : IModuleConfigDictionary
 9      {
10          private readonly Dictionary<string, ModuleConfiguration>  _configurations = new Dictionary<string, ModuleConfiguration>();
11   
12          public IEnumerable<ModuleConfiguration> GetConfigs()
13          {
14              return _configurations.Values.AsEnumerable();
15          }
16   
17          public void Register(ModuleConfiguration item)
18          {
19              if(_configurations.ContainsKey(item.ModuleName))
20              {
21                  _configurations[item.ModuleName] = item;
22              }
23              else
24              {
25                  _configurations.Add(item.ModuleName, item);
26              }
27          }
28      }
29  }

  代码:ModuleConfiguration,读取模块的配置文件

 1  using System;
 2  using System.Collections.Generic;
 3  using System.IO;
 4  using System.Linq;
 5  using System.Text;
 6  using System.Xml;
 7  using System.Xml.Linq;
 8   
 9  namespace Common.Framework
10  {
11      public class ModuleConfiguration
12      {
13          public ModuleConfiguration(string filePath)
14          {
15              try
16              {
17                  var doc = XDocument.Load(filePath);
18                  var root = XElement.Parse(doc.ToString());
19   
20                  if (!root.HasElements) return;
21   
22                  var module = from e in root.Descendants("module")
23                               //where e.Attribute("name").Value == "xxxx"
24                               select e;
25   
26                  if (!module.Any()) return;
27   
28                  ModuleName = module.FirstOrDefault().Attribute("name").Value;
29   
30                  var menus = from e in module.FirstOrDefault().Descendants("menu")
31                              select e;
32   
33                  if (!menus.Any()) return;
34   
35                  var menuitems = menus.Select(xElement => new MainMenuItemModel
36                                                               {
37                                                                   Id = xElement.Attribute("id").Value,
38                                                                   Text = xElement.Attribute("text").Value,
39                                                                   ActionName = xElement.Attribute("action").Value,
40                                                                   ControllerName = xElement.Attribute("controller").Value
41                                                               }).ToList();
42   
43                  MainMenuItems = menuitems;
44              }
45              catch
46              {
47                  //TODO: logging
48              }
49          }
50          public string ModuleName { get; set; }
51          public IEnumerable<MainMenuItemModel> MainMenuItems { get; set; }
52      }
53  }

  每个模块的配置文件为{projectName}.config,格式如下:

1   <?xml version="1.0" encoding="utf-8" ?>
2   <configuration>
3     <module name="Module2">
4       <mainmenu>
5         <menu id="modul2" text="Team" action="Index" controller="Team"/>
6       </mainmenu>
7     </module>
8   </configuration>

  为了简单起见,只保留了注入主菜单的部分,为了让读者简单易懂。明白了以后你自己可以任意扩展…

  代码:IModuleConfigDictionary,接口

  dddd

  模块配置文件{projectName}.config的位置:

  Untitled

  为什么每个模块的Class library project都需要一个web.config呢?因为如果没有这个,那就没有Razor智能提示,大家可以参考这篇文章《How to get Razor intellisense for @model in a class library project》。

 

闲话几句插件式架构(Plugin Architecture)或者模块化(Modular)架构

  插件式架构(Plugin Architecture)或者模块化(Modular)架构是大型应用必须的架构,关于什么是Plugin,什么是模块化模式,这种架构的优缺点等我就不说了,自己百谷歌度。关于.NET下面的插件式架构和模块化开发实现方法,基本上用AppDomain实现,当检测到一个新的插件Plugin时,实例化一个新的AppDomain并加载Assembly反射类等,由于AppDomain很好的隔离各个Plugin,所以跨域通信要用MarshalByRefObject类,具体做法可以参考这篇文章《基于AppDomain的"插件式"开发》。另外,有很多框架提供了模块化/插件开发的框架,例如PrismMEF(Managed Extensibility Framework,.NET 4.0 内置)等。

客户端插件架构

  还有一种插件架构是客户端插件架构(Javascript 模块化),如jQuery UI Widget FactorySilk Project就是很好的例子。

转帖说明

  本文是转自 Mainz  的文章,原文链接:http://www.cnblogs.com/Mainz/archive/2012/03/06/2382653.html

  这是一篇难得的好文章,但原文作者排版对代码进行了 max-height:200px 的处理,可能是为了排版美观吧,但这样处理之后阅读代码老要受滚动条的影响,太不痛快,于是转帖于此,主要是为自己阅读的方便。

posted @ 2013-09-02 15:00  郭明锋  阅读(3540)  评论(0编辑  收藏  举报