标签:style blog http color 使用 os strong 文件
应用层服务器(这里一般指Web服务器)处理网站应用的业务逻辑,应用的一个最显著的特点是:应用的无状态性。
PS:提到无状态特性,不得不说下Http协议。我们常常听到说,Http是一个无状态协议,同一个会话的连续两个请求互相不了解,他们由最新实例化的环境进行解析,除了应用本身可能已经存储在全局对象中的所有信息外,该环境不保存与会话有关的任何信息。之所以我们在使用ASP.NET WebForm开发中会感觉不到Http的无状态特性,完全是因为Microsoft帮我们实现了ViewState,它是ASP.NET WebForm中保存页面信息的基本单位,本质是一个HTML中的隐藏域,回调时会将这个隐藏域中的数据提交到服务器端。
在很多场景中,用户都需要和我们的网站系统进行多次的信息交互,这时就需要一种解决方案来克服无状态特性所带来的困境。还好,在巨人的肩膀上,我们已经有了很好的解决方案,那就是浏览器端的Cookie和服务器端的Session。在一般的单机开发中(这里一般是指只有一台Web服务器的情况),服务器端我们通常使用Session来存储用户登录状态(一般是一个自定义对象实例),在多数的管理信息系统开发中(毕竟内部系统用户量不多,一台Web服务器既提供Web服务又存储Session对象内存还算是够用的)这是很常见的。
但是,在大用户量下,单机版的Session就会显得效率低下,甚至会拖累Web服务器的性能。这是因为:每个用户的Http请求发到服务器端后,每台Web服务器的服务器软件(例如:IIS、Tomcat等)都会为该请求创建一个线程来进行处理和响应,但是一台服务器同一时间可以接收的请求数毕竟是有限的(这个根据服务器的配置而定,例如CPU中i3、i5和i7类型分别可以创建的线程数都各不相同),当某个时间段出现高并发请求数的时候(比如:网购秒杀系统中经常同一时间会出现海量的并发数),那这台应用服务器将会接收前所未有的请求负载,最终可能会因为承受不了高负载而导致宕机,网站不得不停止服务。
这时,又想起了那句话:当一头牛拉不动车的时候,不要去寻找一头更强壮的牛,而是用两头牛来拉车。于是,我们可以采用服务器集群的技术来对Web服务器进行改进,增加N台Web服务器部署相同的Web应用构成Web服务器集群来对外提供服务,通过负载均衡设备或软件将海量的并发请求数平均地分摊到每台Web服务器,例如:假设某系统在促销活动期间同一时刻涌入了10万个请求,而服务器集群中有5台Web服务器同时提供服务,这时负载均衡设备就将这个10万请求通过某种算法较为均衡地分配给其中的Web服务器,平均下来每台服务器最多就只承担2万个请求。
通过服务器集群,已经较好地解决了请求负载问题,这时新的问题又来了:由于Session默认是属于进程内(InProc)的,也就是说它是存储在Web服务器的内存里边的。当构建好集群之后,用户的Session会建立在负载均衡设备所分配的其中一台Web服务器里边。但是当用户下一次访问或者访问系统中的其他子系统(比如:我首先在百度百科进行登录了,然后访问百度贴吧),由于Session会话还存储在上一次提供服务的Web服务器里边,系统校验规则(现在这台Web服务器里边检测到没有该用户的Session)会造成用户的重复登录(比如:都是在百度的网页,它却让你登录好几次,你爽吗?很明显,不爽吧)。这时,就需要我们解决Web服务器集群的Session管理,下面我们就来看看如何进行Web服务器集群的Session管理。
我们现在来看看在集群环境中,Session管理的几种常见手段:
①Session复制:该方案简单易行,集群中的几台服务器之间同步Session对象,任何一台服务器宕机都不会导致Session对象的丢失,服务器也只需要从本机获取即可。但是,该方案只适合集群规模较小的情况下。当规模较大时,大量的Session复制操作会占用服务器和网络的大量资源,系统不堪重负。
②Session绑定:利用负载均衡的源地址Hash算法,总是将源于同一IP地址的请求分发到同一台服务器上。这样的话,在整个会话期间,用户所有的请求都在同一台服务器上进行处理,即Session绑定在某台特定服务器上,保证Session总能在这台服务器上获取。(这种方案又叫做会话粘滞)。
但是,这种方案不符合高可用的需求。因为一旦某台服务器宕机,那么该机器上得Session也就不复存在了,用户请求切换到其他机器后因为没有Session而无法完成业务处理。因此,很少有网站采用此方案进行Session管理。
③Cookie记录Session:利用浏览器支持的Cookie记录Session简单易行,可用性高,并且支持服务器的线性伸缩,因此,许多网站都或多或少地使用了Cookie来记录Session。但是Cookie记录Session有缺点:比如受Cookie大小限制、每次请求响应都要传输Cookie影响性能、用户关闭了Cookie会造成访问不正常等。
④Session服务器:利用独立部署的Session服务器(集群)统一管理Session,应用服务器每次读写Session时,都访问Session服务器。这种方案实际上是将应用服务器的状态分离,分为无状态的应用服务器和有状态的Session服务器。
从上面的几种方式来看,各有利弊,但Session服务器是最符合高可用需求的方案,也是企业中经常用到的方案。那么,对于有状态的Session服务器,一种较简单的方法是利用分布式缓存(如Memcached、Redis等,有关Redis的简单介绍可以阅读我的博文:NoSQL初探之人人都爱Redis)、数据库等,在这些产品的基础上进行封装,使其符合Session的存储和访问要求。综合上述介绍,我们今天就采用Memcached来构建我们的Session服务器,解决Web服务器集群的Session的共享访问。
PS:为什么要采用分布式缓存方案而不采用数据库来存储Session?这个就得要分析一下数据访问的性能瓶颈了,一般来说,磁盘IO读写的速度是最慢的,因为数据库数据其实是存储在文件中的,虽然目前大多数的数据库都采用了B+树结构,读取一条数据最多都还是需要4次的数据读写(三次磁盘访问获得数据索引及行ID,一次数据文件读操作,终于知道数据库操作多麻烦了)。而分布式缓存例如Memcached是以Key/Value这种简单的形式存储在服务器的内存里边的,内存的随机读写速度是完爆磁盘IO的,因此内网+内存的双内模式是比较完美的方案。
磁盘又分为两种类型:
①机械硬盘:通过马达驱动磁头臂,带动磁头到指定的磁盘位置访问数据。它能够实现快速顺序读写,慢速随机读写。
②固态硬盘(又称SSD):无机械装置,数据存储在可持久记忆的硅晶体上,因此可以像内存一样快速随机访问。
在目前的网站应用中,大部分应用访问数据都是随机的,这种情况下SSD具有更好的性能表现,但是性价比有待提升(蛮贵的,么么嗒)。
(1)模拟的登录案例场景
假设我们有一个基于ASP.NET的信息系统,这个系统使用一个统一的系统登录页面进行用户登录,登陆后默认跳转到一个用户中心主页,并显示:欢迎您,{用户账号名称}。
①系统登录页面效果:
②用户主页效果:
(2)模拟的技术体系选择
ASP.Net MVC+EF Code First+MySQL+Memcached
(1)新建一个ASP.NET MVC4的空项目,视图引擎选择为“Razor”即可;
(2)在项目中新建一个文件夹,取名为“Lib”,主要存放一些必要的DLL文件;
(3)在项目中添加对这几个DLL的引用,注意这里引入EntityFramework.dll是为了支持后面的CodeFirst开发方式,EF版本必须在4.1及以上。PS:你也可以通过Package Manager来安装EntityFramework。到此,我们的准备工作就做好了,接下来就可以开始正式的工作了。
首先,EF是一种ORM(Object-relational mapping)框架,它能把我们在编程时使用对象映射到底层的数据库结构。ORM框架负责把从数据库传回的记录集转换为对象,也可以依据对象当前所处的具体状态生成相应的SQL命令发给数据库,完成数据的存取工作(常见的数据存取操作可简称为CRUD:Create、Read、Update、Delete)。
EF给数据库应用系统开发带来了更高的效率,使用它能更容易地写出易维护、易扩展的系统,而且性能虽然比不上ADO.NET,但也足够好,能满足大多数开发场景的需求。与ADO.NET不一样,EF的抽象层次较高:它把数据库映射为DbContext,把数据库中存取的数据直接映射为实体(Entity)对象,屏蔽了底层的数据库内部结构,无需直接使用下层数据存取引擎所提供的底层对象(比如ADO.NET所提供的DbConnection,DbCommands等)完成CRUD。
EF支持三种开发模式:Code First、Database First和Model First。这里我们使用Code First模式,它能帮助我们实现快速开发迭代的目标。最后,EF不是本文的重点,如果你还不了解EF或者Code First,可以参阅金旭亮老师的《EF走马观花》系列文章,这里就不再赘述了。
(1)在Models文件夹新建一个类,取名为“UserInfo”。它作为我们的实体类,映射到MySQL数据库中的UserInfo表(这里MySQL数据库中还未创建这样的数据表)
[Serializable] public class UserInfo { [Key] [DatabaseGenerated(DatabaseGeneratedOption.Identity)] public int UserId { get; set; } [Required] [MaxLength(128)] public string UserName { get; set; } [Required] [MaxLength(32)] public string UserPwd { get; set; } }
(2)再在Models文件夹新建一个类,取名为“MyDbContext”,使其继承于DbContext,作为EF操作的数据库上下文使用。需要注意的是:这里的name=MySqlDemo,MySqlDemo为数据库的名字。而这里this.Database.CreateIfNotExists()方法则用于判断MySqlDemo这个数据库是否已存在?如果不存在,那么就创建一个。
public class MyDbContext : DbContext { public MyDbContext() : base("name=MySqlDemo") { this.Database.CreateIfNotExists(); } public virtual DbSet<UserInfo> UserInfo { get; set; } }
(3)在Web.config中新增一个数据库连接字符串,database设置为“MySqlDemo”,与上面的MyDbContext中的构造函数中的name保持一致。
<connectionStrings> <!--设置对MySql的链接字符串(注意:charset=utf8是为了解决MySql中保存中文的时候出现的乱码的问题) --> <add name="MySqlDemo" connectionString="server=127.0.0.1;user id=root;password=1234;persist security info=True;database=MySqlDemo;charset=utf8;" providerName="MySql.Data.MySqlClient" /> </connectionStrings>
至此,因为我们只用到UserInfo这一个表,所以现在我们有关数据库与EF的代码就到此结束。
(1)在Web.config中新增一个AppSetting,保存Memcached服务器的地址列表:
<!-- 设置Memcached服务器集群列表 --> <add key="MemcachedServers" value="192.168.80.10:11211,192.168.80.11:11211"/>
(2)在Models中新建一个类,取名为“MemcachedHelper”,我们将其设置为静态类,所以方法全是静态的,我们无需实例化便可直接调用。可以看到,我们这里使用了静态构造函数来初始化全局静态对象,它不属于任何一个实例,所以这个构造函数只会被执行一次,而且是在创建此类的第一个实例或引用任何静态成员之前,由.NET自动调用。
/// <summary> /// 自己封装Memcached的帮助类 /// </summary> public static class MemcacheHelper { #region 全局静态对象 // 全局Socket连接池对象 private readonly static SockIOPool sockIOPool; public static SockIOPool CurrentPool { get { return sockIOPool; } } // 全局Memcached客户端对象 private static MemcachedClient mc; #endregion #region 静态构造函数 /// <summary> /// 静态构造函数 /// </summary> static MemcacheHelper() { // 初始化Memcached服务器列表 string[] serverList = ConfigurationManager.AppSettings["MemcachedServers"].Split(‘,‘); // 初始化Socket连接池 sockIOPool = SockIOPool.GetInstance("MemPool"); sockIOPool.SetServers(serverList); sockIOPool.Initialize(); // 初始化Memcached客户端 mc = new MemcachedClient(); mc.PoolName = "MemPool"; mc.EnableCompression = false; } #endregion #region 封装对外方法 /// <summary> /// Set-新增或修改 /// </summary> /// <param name="key">键</param> /// <param name="value">值</param> /// <returns>是否成功</returns> public static bool Set(string key, object value) { if (!mc.KeyExists(key)) { return mc.Add(key, value); } else { return mc.Set(key, value); } } /// <summary> /// Set-新增或修改 /// </summary> /// <param name="key">键</param> /// <param name="value">值</param> /// <param name="expiry">过期时间</param> /// <returns>是否成功</returns> public static bool Set(string key, object value, DateTime expiry) { if (!mc.KeyExists(key)) { return mc.Add(key, value, expiry); } else { return mc.Set(key, value, expiry); } } /// <summary> /// Get-获取 /// </summary> /// <param name="key">键</param> /// <returns>具体数据</returns> public static object Get(string key) { if (!mc.KeyExists(key)) { return null; } else { return mc.Get(key); } } #endregion }
这里我们没有对SockIOPool进行一列的配置,采用其默认的配置即可。但是要注意的是:SockIOPool的PoolName和MemcacheClient的PoolName必须保持一致。
(1)在Controller中新建一个控制器,取名为“LogonController”。主要用来显示系统登陆页和进行用户验证的AJAX操作(将用户Session存入Memcached也在此操作中)。至于登录页面的HTML和JS脚本在此就不再赘述,请自行下载Demo文件查看。
public class LogonController : Controller { // // GET: /Logon/ public ActionResult Index() { return View(); } [HttpPost] public JsonResult ValidateUserInfo(string userName, string userPwd) { MyDbContext dbContext = new MyDbContext(); var loginUser = dbContext.UserInfo.Where(u => u.UserName.Equals(userName)).FirstOrDefault(); JsonObject jsonReturn = new JsonObject(); if (loginUser == null) { jsonReturn.success = false; jsonReturn.msg = "您输入的用户名不存在!"; } else { if (!loginUser.UserPwd.Equals(userPwd)) { jsonReturn.success = false; jsonReturn.msg = "您输入的密码错误!"; return Json(jsonReturn, JsonRequestBehavior.DenyGet); } // 申请了一个模拟的Guid:SessionId Guid sessionId = Guid.NewGuid(); // 把sessionid写到客户端浏览器里面去累 Response.Cookies["sessionId"].Value = sessionId.ToString(); // 将用户实体对象写入到Memcache中 MemcacheHelper.Set(sessionId.ToString(), loginUser, DateTime.Now.AddMinutes(20)); // 设置成功响应消息 jsonReturn.success = true; jsonReturn.msg = "恭喜您,登录成功!"; } return Json(jsonReturn, JsonRequestBehavior.DenyGet); } }
现在来看下这个控制器的核心代码:
①Index这个Action主要用于显示登录视图,页面代码不再贴出来,只看看下面这个AJAX请求代码:借助JQuery AJAX向ValidateUserInfo这个Action提交用户名和密码,如果服务器端校验成功则返回一个JSON对象,浏览器端判断success属性是否为true,如果为true则跳转到系统主页。
$.ajax({ type: "POST", contentType: "application/json;charset=utf-8", url: "/Logon/ValidateUserInfo", data: "{ userName:‘" + txtUserName + "‘,userPwd:‘" + txtUserPwd + "‘ }", dataType: "json", beforeSend: function () { showMsg("操作提示", "正在验证中,请稍候..."); }, success: function (resultInfo) { if (resultInfo != null && resultInfo.success) { window.location.href = "/Home/Index"; } else { showMsg("登录失败", resultInfo.msg); } }, error: function () { showMsg(‘错误提示‘, ‘发生未知错误‘); } });
②ValidateUserInfo这个Action则是根据浏览器提交过来的AJAX请求,判断用户名和密码是否正确。这里用到了我们刚刚写的MyDbContext,首先对它进行实例化。(这一步非常重要,这时我们的MySQL数据库中还木有MySqlDemo这个数据库,当第一次实例化MyDbContext时,EF会帮我们在MySQL中创建MySqlDemo这个数据库,其本质其实就是帮我们生成一串:crate database mysqldemo;之类的SQL语句),然后使用Lambda表达式这种优美的语法去进行校验。如果你对Lambda表达式不熟悉,可以参阅MSDN的《Lambda表达式(C#编程指南)》一文来学习下。
③就是最核心的部分,我们采用Guid(保证作为Key的唯一性)作为SessionId写入浏览器端的Cookie中,并以此作为Key存入Memcached分布式缓存中,还给它设置了默认失效时间(这里为20分钟)。之后,每次浏览器向服务器端提交请求时,在HTTP报文中都会附带上这个Cookie,服务器端就可以通过这个Cookie作为Key去Memcached服务器中查找Session对象。
PS:一般来说,Memcached的数据Key的命名是有讲究的,这里传智的老马推荐了一个命名规则:{命名空间}-{部门名称}-{项目名称}。我们这里就简单一点,使用Guid来作为Key,因为每次生成的Guid是唯一的。
(1)在以往的信息系统项目开发中,我们在系统里边会做一个全局的校验器,判断用户的每次操作请求是否具有相应的权限,这里我们主要是校验用户是否登录,以便我们在具体的模块中获取用户的Session对象。单机情况下,我们一般就存入本机进程内的Session,因此就主要判断Session中是否存在登录状态。这里我们使用Memcached来存储Session对象,那么我们就在每个Action执行前加一段规则:判断Memcached中是否有当前用户的登录状态,如果有,则继续执行Action。如果木有,那么对不起,请进行登录。
(2)在WebForm中我们可以通过写一个BasePage,使其继承于Page,重写OnLoad事件,再让其他页面继承于BasePage,就可以对用户是否登录这个校验应用到所有继承了BasePage的Page类中。那么,在MVC模式中,请求对象不再是xxx.aspx页面类型,而是/ControllerName/ActionName的路由,因此我们需要寻找一种针对Action的全局过滤方法。额,在此我们不禁想到了一个高大上的英文:AoP(Aspect Oriented Programming),它是软件开发中一个较为热门的话题,利用AOP可以对业务逻辑的各个部分进行隔离,从而使得业务逻辑各部分之间的耦合度降低,提高程序的可重用性,同时提高了开发的效率。因此,我们对Action增加全局校验规则就是将Action的校验方法与Action的业务处理相分离,使其耦合度降低,也提高了Action的校验方法的重用性,符合了AoP的思想。那么,扯了大半天,在ASP.NET MVC中到底如何实现呢?别担心,我们可以像写BasePage一样,写一个BaseController来进行处理,使其继承于Controller,然后将其他需要使用用户登录状态校验的Controller都继承于BaseController即可。
public class BaseController : Controller { // 全局当前用户对象 protected UserInfo CurrentUser { get; private set; } // 全局Action执行前的验证操作:类似于AoP的思想,面向切面编程 protected override void OnActionExecuting(ActionExecutingContext filterContext) { base.OnActionExecuting(filterContext); // 从cookie中获取登录的sessionId string sessionId = Request["sessionId"]; if (string.IsNullOrEmpty(sessionId)) { filterContext.Result = new RedirectResult("/Logon/Index"); } else { object obj = MemcacheHelper.Get(sessionId); CurrentUser = obj as UserInfo; if (CurrentUser == null) { filterContext.Result = new RedirectResult("/Logon/Index"); } // 再次记录到Memcache分布式缓存中:实现Session的滑动窗口机制 MemcacheHelper.Set(sessionId, CurrentUser, DateTime.Now.AddMinutes(20)); } } }
(3)现在来看看上面这段代码:
①从浏览器提交过来的HTTP请求报文中取得Cookie(也就是SessionId),如果没有Cookie那么肯定还没有登录,将进行重定向到登陆页面;
②如果有Cookie那么就去Memcached服务器中查询是否有这个Key的数据内容,如果没有没有内容,那么还是重定向到登陆页面;如果有内容,则返回这个对象,由于返回的是Object类型,所以需要我们转换一下赋给全局的用户对象实例。
③获取到UserInfo对象之后,再次将其重新存储到Memcached中,这里其实是将Session延长失效时间,实现了一个Session的滑动时间机制的效果。
PS:因为我们在登录验证的Action里面给这个缓存设置的过期时间是一个绝对的时间,而非滑动过期时间。所谓绝对时间是指到了指定时间以后便会失效,而滑动时间是指在指定时间内无访问请求便失效。
(1)到此,大部分的代码都已完毕。这里我们需要进行一个小测试,新建一个Controller,取名为“HomeController”,将Index页面用作登陆后的主页,显示Session对象中的UserName属性。代码很简单,只有两行:
public ActionResult Index() { // 从Memcached中获取Session并传给View ViewBag.UserName = CurrentUser.UserName; return View(); }
至于View,请参考Demo文件,此处就不在赘述了,就一堆HTML而已。
(2)①开启虚拟机中的两台Windows Server服务器,确保Memcached服务都已成功开启。(如果你是装在本机的,直接开启服务即可)
②开启MySQL数据库服务,这里我的MySQL是装在本机的,所以只需要开启服务即可。
(3)点击调试,开始运行IIS Express服务器,进行测试吧骚年(这里需要说明的是:你需要首先随便输入一个账号和密码进行数据库的创建,因为我们采用的是Code First的方式,然后再进入MySQL添加几行测试用户数据,这里我已经添加了几行,开始进行测试。)我在数据库里边添加了一行{账号:edisonchou,密码:123456},因此我输入账号和密码,点击登录,会出现一个友好的提示:“正在验证中,请稍候...”(如果你是第一次运行,那么需要创建数据库,这个时间会比较长,不要担心)。
(4)等待系统验证完成后,便会跳转到系统主页,显示欢迎界面。可以看到,下图中的两处是根据Session的UserName来显示的。
(5)现在我们手动修改URL,输入/Logon/Index,跳转到登陆页面。然后,再修改URL为/Home/Index,看看是否需要重新登录,由于我们已经将Session存入了Memcached,所以再次进入/Home/Index这个Action时会进行判断,通过浏览器端传过去的Cookie去Memcached中取Session,如存在则继续执行。这里,我们之前已经登录了,所以不再需要重新登录。下面是再次跳转/Home/Index的HTTP报文信息,可以看出登录之后已经写入了Cookie,之后的请求都会带上Cookie传递到服务器端,服务器端也就可以通过Cookie作为Key获取Session对象。
(6)新开一个浏览器,再使用另一个用户数据进行登录:
(7)登陆后,查看HTTP报文,获取Cookie中的sessionId:
(8)根据两个用户的sessionId作为Key,在Memcached服务器中查看存储情况:经查看,两个Session都存储到了192.168.80.11这台服务器里边。为何没有平均分配,这是因为算法是根据计算Hash值来的,刚好这两个sessionId计算后的Hash值都是要分配到192.168.80.11这台服务器里边,所以就都存到了这里边。(需要注意的是:这里仅仅是在我的机器上跑的测试结果,网友们的测试结果可能会跟我的不一样,这是正常的)
至此,我们的小测试到此结束,我们的案例也到此为止。
本篇我首先通过花大力气对Session服务器场景的简介引出分布式缓存对于构建Session服务器的可行性,然后使用ASP.NET MVC+EF Code First+MySQL+Memcached来模拟了一个信息系统登录场景,其中借助了Memcached来存储Session对象,并通过Action的过滤器来实现对用户登录状态的校验。最后通过一个小测试,来验证Memcached是否存储了我们的Session对象。
当然,此案例只是一个玩具级别的Demo,还需要经过很多次性能测试和优化才能应用到实际开发中。但是,我们可以从中了解到一些有益的思想,理解这些思想对于我们的实际开发能力有所裨益的。最后,希望我的文章能够给更多的像我一样的菜鸟开发者带来一点光明,如果你觉得有用,那就麻烦点一下“推荐”吧,谢谢!
(1)李智慧,《大型网站技术架构-核心原理与案例分析》,http://item.jd.com/11322972.html
(2)马伦,《Memcached公开课》,http://bbs.itcast.cn/thread-14836-1-1.html
(3)旋风,《Memcached分布式缓存替代Session解决方案》,http://www.cnblogs.com/xuanfeng/archive/2009/06/04/1494735.html
(4)金旭亮,《Entity Framework走马观花》,http://blog.csdn.net/bitfan/article/details/12779517
(5)MSDN,《静态类和静态成员(C#编程指南)》,http://msdn.microsoft.com/zh-cn/library/79b3xss3.aspx
(6)痞子一毛,《Memcached+Cookie替代Session解决方案(MVC版)》,http://www.cnblogs.com/piziyimao/archive/2013/01/29/2882236.html
(7)瞌睡龙飞,《.NET中Cache的用法》,http://blog.csdn.net/ttotcs/article/details/7476234
(8)lulu Studio,《ASP.NET MVC:Action过滤器(Filtering)》,http://www.cnblogs.com/QLeelulu/archive/2008/03/21/1117092.html
(1)MemcachedMvcDemo:http://pan.baidu.com/s/1gd3RvKB
Key/Value之王Memcached初探:三、Memcached解决Session的分布式存储场景的应用,布布扣,bubuko.com
Key/Value之王Memcached初探:三、Memcached解决Session的分布式存储场景的应用
标签:style blog http color 使用 os strong 文件
原文地址:http://www.cnblogs.com/edisonchou/p/3866819.html