行者无疆
When I was young , I used to think that money was the most important thing in life , now I am old , I know it is.
第12章  状态管理——记录你网站访客的活动本章将逐一介绍ASP.NET中的各种状态管理机制,并在最后完善论坛的注册和登录。开发Web应用程序必须了解程序的运行状态和状态管理等,理解状态管理的一些基本原理,可以帮助我们开发出高性能的应用程序,同时避免出现潜在的安全问题。
12.1 状态管理概述12.1.1 状态管理与数据库状态管理是你对同一页或不同页的多个请求维护状态和页信息的过程。与所有基于 HTTP 的技术一样,Web 窗体页是无状态的,这意味着它们不自动指示序列中的请求是否全部来自相同的客户端,或者单个浏览器实例是否一直在查看页或站点。此外,到服务器的每一往返过程都将销毁并重新创建页;因此,如果超出了单个页的生命周期,页信息将不存在。比如,我们在代码中声明一个DataSet从数据库获取记录,页面回发(也就是重新请求)后这个DataSet是空的,这就是为什么在ASP.NET应用程序中,甚至在一个页面中需要多次连接数据库获取记录。正是由于这个原因,状态管理对于Web编程来说非常重要,从第一代动态Web编程语言开始就支持多种状态管理以弥补HTTP无状态的不足。
现在的Web应用程序,通常都是数据驱动的,但是在状态处理中,我们应该尽量减少对数据库的依赖,原因如下。
数据库是存放在磁盘上的。如果把数据存放在数据库中的话,性能会比较差。很多数据是和用户相关的。如果把数据存放在数据库中的话,我们没有一个唯一的标志来区分哪条记录对应哪个客户端(浏览器)。很多数据是临时的,用户关闭了浏览器这些数据就不再需要了。如果把数据存放在数据库中的话,我们不知道是哪个用户关闭了浏览器,也就不能及时把数据删除。通常来讲,状态管理的作用主要概括为以下几点。
指示用户信息,关联浏览器实例。使得页与页之间,请求与请求之间能够共享信息。更为快速的数据存储与读取。12.1.2 状态管理的比较参数ASP.NET提供了很多状态管理机制,各有各的特点。一般来说,我们是在以下几个方面来比较各种状态管理机制:
存储的物理位置。比如是存储在客户端还是服务端。存储的类型限制。比如是可以存放任意类型还是仅仅可以存放字符串。状态使用的范围。比如是否可以跨应用程序?是否可以跨用户?是否可以跨页面?存储的大小限制。比如是任意大小还是有一定字节限制。生命周期。什么时候建立?什么时候销毁?安全与性能。比如是否加密存储?是否适合存储大量数据?优点缺点与注意事项。只有对这些概念有一个清晰的认识后,我们才能灵活使用各种状态管理机制。状态管理是Web应用程序的利器,但是它是一把双刃剑,不合理的使用各种状态管理机制会对整个网站的性能造成很大的影响,甚至使网站瘫痪。因此,使用状态管理的重要原则就是在有必要的时候用,而不能因为方便到处都用。
12.2  Cookie12.2.1 Cookie概述Cookie 为 Web 应用程序保存用户相关信息提供了一种有用的方法。例如,当用户访问站点时,可以利用 Cookie 保存用户首选项或其他信息,这样,当用户下次再访问站点时,应用程序就可以检索以前保存的信息。
从技术上讲,Cookie是小段保存在客户端的数据(如果你安装的是XP,可以看一下< Windows>:\Documents and Settings\< >\Cookies文件夹)。用户访问网站的时候,网站会给用户一个包含过期时间的Cookie,浏览器收到Cookie后就存放在客户端的文件夹下。以后用户每次访问网站页面的时候,浏览器会根据网站的URL在本地Cookie文件夹内查找是否存在当前网站关联的Cookie,如果有的话就连同页面请求一起发送到服务器。
关于Cookie的知识还需要了解以下几点。
Cookie只是一段字符串,并不能执行。大多数浏览器规定Cookie大小不超过4K,每个站点能保存的Cookie不超过20个,所有站点保存的Cookie总和不超过300个。除了Cookie外,几乎没有其他的方法在客户端的机器上写入数据(就连Cookie的写入操作也是浏览器进行的)。当然,连Cookie都可以通过浏览器安全配置来禁止。如果你使用IE浏览器,可以看一下“工具”→“Internet”选项→“隐私”一页。现在的大多数网站都利用Cookie来保存一些数据(比如你的ID),以便你下一次访问网站时能直接“继续”以前的配置,所以我还是建议你不要轻易关闭Cookie。在使用Cookie时,必须意识到其固有的安全弱点。Cookie毕竟是存放于客户端的。因此,不要在Cookie中保存保密信息,如用户名、密码、信用卡号等。在Cookie中不要保存不应该由用户掌握的内容,也不要保存可能被其他窃取Cookie的人控制的内容。
12.2.2  Cookie的使用下面,我们就来讨论如何保存、读取、删除和修改Cookie。首先在页面上添加4个按钮用来完成这4个操作。
Text=”保存Cookie” /<
OnClick=”btn_ReadCookie_Click” /<
Text=”修改Cookie” /<
OnClick=”btn_DelCookie_Click” /<
保存Cookie的方法如下。
protected void btn_SaveCookie_Click(object sender, EventArgs e)
{
HttpCookie SingleValueCookie = new HttpCookie(”test1″, “单值Cookie”);
SingleValueCookie.Expires = DateTime.Now.AddDays(1);
Response.Cookies.Add(SingleValueCookie);
HttpCookie MultiValueCookie = new HttpCookie(”test2″);
MultiValueCookie.Values.Add(”key1″, “value1″);
MultiValueCookie.Values.Add(”key2″, “value2″);
MultiValueCookie.Expires = DateTime.Now.AddDays(1);
Response.Cookies.Add(MultiValueCookie);
}
我们可以看到,一个Cookie中允许保存单个值也可以保存多个值。HttpCookie类型表示一个Cookie,Expires属性用于修改Cookie的过期时间。对于单值Cookie,既可以直接在构造方法中指定值也可以使用Value属性指定值。对于多值Cookie,既可以使用Values属性的Add方法添加子键和值,也可以直接使用Values属性的索引设置子键和值。上面这段代码等价于下面这段代码。
protected void btn_SaveCookie_Click(object sender, EventArgs e)
{
HttpCookie SingleValueCookie = new HttpCookie(”test1″);
SingleValueCookie.Value = “单值Cookie”;
SingleValueCookie.Expires = DateTime.Now.AddDays(1);
Response.Cookies.Add(SingleValueCookie);
HttpCookie MultiValueCookie = new HttpCookie(”test2″);
MultiValueCookie.Values["key1"] = “value1″;
MultiValueCookie.Values["key2"] = “value2″;
MultiValueCookie.Expires = DateTime.Now.AddDays(1);
Response.Cookies.Add(MultiValueCookie);
}
在添加完值以后,务必记得使用Response对象把Cookie重新返回给浏览器。我们的服务器不能直接在客户端机器上写Cookie,而是由浏览器完成这一工作,当然用户也可以设置是否允许浏览器读写Cookie。
下面是读取Cookie的操作。
protected void btn_ReadCookie_Click(object sender, EventArgs e)
{
HttpCookie SingleValueCookie = Request.Cookies["test1"];
if (SingleValueCookie != null)
{
Response.Write(string.Format(”Key:{0} Value:{1} Expires:{2}
”, “test1″,
SingleValueCookie.Value, SingleValueCookie.Expires));
}
HttpCookie MultiValueCookie = Request.Cookies["test2"];
if (MultiValueCookie!= null)
{
Response.Write(string.Format(”Key:{0} Value:{1}
”, “test2″, MultiValueCookie.
Value));
foreach (string subkey in MultiValueCookie.Values.AllKeys)
{
Response.Write(string.Format(”SubKey:{0} Value:{1} Expires:{2}
”,
subkey, MultiValueCookie.Values[subkey], MultiValueCookie.Expires));
}
}
}
对于多值Cookie,我们通过遍历AllKeys属性返回的字符串数组获取所有子键Key,从而获得子键的值。要注意的是,在访问Cookie以前,需要检测一下Cookie是否存在。打开页面,先单击“保存Cookie”按钮,然后单击“读取Cookie”按钮,得到以下输出:
Key:test1 Value:单值Cookie Expires:0001-1-1 0:00:00
Key:test2 Value:key1=value1&key2=value2
SubKey:key1 Value:value1 Expires:0001-1-1 0:00:00
SubKey:key2 Value:value2 Expires:0001-1-1 0:00:00
这里要说明以下几点。
我们发现,所有Cookie的过期时间都不能正常显示。这是因为浏览器返回给服务器的Cookie是不包含过期时间的,而服务器返回给浏览器的Cookie是包含过期时间的。过期时间只对客户端浏览器有意义,对服务器来说没有什么意义。直接读取多值Cookie的Value,它会把所有子键和子键值都使用key=value方法显示,多个子键使用“&”连接(类似URL的方式)。下面是删除Cookie的操作。
protected void btn_DelCookie_Click(object sender, EventArgs e)
{
HttpCookie SingleValueCookie = Request.Cookies["test1"];
SingleValueCookie.Expires = DateTime.MinValue;
Response.Cookies.Add(SingleValueCookie);
}
如果你想删除所有Cookie,可以遍历删除。
protected void btn_DelCookie_Click(object sender, EventArgs e)
{
foreach (string key in Request.Cookies.AllKeys)
{
HttpCookie cookie = Request.Cookies[key];
cookie.Expires = DateTime.MinValue;
Response.Cookies.Add(cookie);
}
}
我们始终要记住,服务器不能直接删除Cookie,删除Cookie的操作是浏览器进行的。说是删除,其实是把它的过期时间设置为过去的时间,让Cookie过期。因此,对于删除操作来说有三个步骤。
1.从Request对象中获取Cookie。
2.把Cookie的过期时间设置为过去的时间。
3.把Cookie重新写回Response中。
4.修改Cookie的操作也非常简单。
protected void btn_ModifyCookie_Click(object sender, EventArgs e)
{
HttpCookie SingleValueCookie = Request.Cookies["test1"];
SingleValueCookie.Value = “修改后的单值Cookie”;
Response.Cookies.Add(SingleValueCookie);
}
12.2.3 Cookie总结Cookie虽然是一个简单实用的对象,但是我们也要注意Cookie的工作原理、大小限制以及安全性等,大致可以归纳为以下几点。
存储的物理位置。客户端的Cookies文件夹内。存储的类型限制。字符串。状态使用的范围。当前请求上下文的上下文都能访问到Cookie,Cookie对每个用户来说都是独立的。存储的大小限制。每个Cookie不超过4K数据。每个网站不超过20个Cookie。所有网站的Cookie总和不超过300个。生命周期。每个Cookie都有自己的过期时间,超过了过期时间后失效。安全与性能。存储在客户端,安全性差。对于敏感数据建议加密后存储。优点缺点与注意事项。可以很方便地关联网站和用户,长久保存用户设置。12.3  Session12.3.1 Session概述Session又称为会话状态,是Web系统中最常用的状态,用于维护和当前浏览器实例相关的一些信息。举个例子来说,我们可以把已登录用户的用户名放在Session中,这样就能通过判断Session中的某个Key来判断用户是否登录,如果登录的话用户名又是多少。
我们知道,Session对于每一个客户端(或者说浏览器实例)是“人手一份”,用户首次与Web服务器建立连接的时候,服务器会给用户分发一个SessionID作为标识。SessionID是一个由24个字符组成的随机字符串。用户每次提交页面,浏览器都会把这个SessionID包含在HTTP头中提交给Web服务器,这样Web服务器就能区分当前请求页面的是哪一个客户端。那么,ASP.NET 2.0提供了哪些存储SessionID的模式呢:
Cookie(默认)。如果客户端禁止了Cookie的使用,Session也将失效。URL。Cookie是否开启不影响Session使用,缺点是不能再使用绝对链接了。前面说了SessionID可以存储在客户端的Cookie或者URL中,那么Session真正的内容存储在哪里呢?ASP.NET 2.0对于Session内容的存储也提供了多种模式。
InProc(默认)。Session存储在IIS进程中(Web服务器内存)。StateServer。Session存储在独立的Windows服务进程中(可以不是Web服务器)。SqlServer。Session存储在SqlServer数据库的表中(SqlServer服务器)。虽然InProc模式的Session直接存储在Web服务器IIS进程中,速度比较快,但是每次重新启动IIS都会导致Session丢失。利用后两种模式,我们就完全可以把Session从Web服务器中独立出来,从而减轻Web服务器的压力,同时减少Session丢失的概率。
因此,SessionID存储在客户端(可以是Cookie或者URL),其他都存储在服务端(可以是IIS进程、独立的Windows服务进程或者SQL Server数据库中)。
12.3.2Session的使用让我们先来实践一下如何使用Session,进而回答第二个问题:Session存储的类型限制。Session不需要进行任何配置就可以使用(默认是InProc模式并且依赖Cookie)。首先,在页面上建立两个按钮。
在btn_WriteSession按钮的Click事件处理方法中,写入两个Session,一个是简单的字符串,另外一个是自定义的类。
protected void btn_WriteSession_Click(object sender, EventArgs e)
{
Session["SimpleString"] = “编程快乐”;
MyUser user = new MyUser();
user.sUserName = “小朱”;
user.iAage = 24;
Session["CustomClass"] = user;
}
Session的使用非常简单,直接对某个Key的Session进行赋值即可。自定义类MyUser如下:
class MyUser
{
public string sUserName;
public int iAage;
public override string ToString()
{
return string.Format(”姓名:{0},年龄:{1}”, sUserName, iAage);
}
}
在这里,我们覆写了ToString()方法直接返回实例的一些信息。然后,双击btn_ReadSession按钮来实现从Session中读取数据的代码:
protected void btn_ReadSession_Click(object sender, EventArgs e)
{
if (Session["SimpleString"]==null)
{
Response.Write(”读取简单字符串失败
”);
}
else
{
string s=Session["SimpleString"].ToString();
Response.Write(s + “
”);
}
if (Session["CustomClass"]==null)
{
Response.Write(”读取简单自定义类失败
”);
}
else
{
MyUser user=Session["CustomClass"] as MyUser;
Response.Write(user.ToString()+”
”);
}
}
在每次读取Session的值以前请务必先判断Session是否为空,否则很有可能出现“未将对象引用设置到对象的实例”的异常。我们看到,从Session中读出的数据都是object类型的,我们需要进行类型转化后才能使用。打开页面,先单击写入Session按钮,再单击读取Session按钮,页面输出如    图12-1所示。
12.3.3 把Session存储在独立的进程中由此看来,Session能存储任意对象,是这样吗?现在得出这个结论还太早了一点,因为我们并没有实践过StateServer和SqlServer模式的Session。要把Session存储在Windows服务进程中需要进行以下几个步骤。
第1步是打开状态服务。依次打开“控制面板”→“管理工具”→“服务”命令,找到ASP.NET状态服务一项,右键单击服务选择启动,如图12-2所示。
图12-2  启动ASP.NET状态服务
如果你正式决定使用状态服务存储Session前,别忘记修改服务为自启动(在操作系统重启后服务能自己启动)以免忘记启动服务而造成网站Session不能使用,如图12-3所示,双击服务把服务的启动类型设置为自动。
图12-3  修改服务启动类型为自动
服务正常启动后可以观察任务管理器的进程页,其中的aspnet_state.exe进程就是状态服务进程,如图12-4所示。
图12-4  观察任务管理器的进程页
第2步,在system.web节点中加入:
stateNetworkTimeout=”20″<
stateConnectionString表示状态服务器的通信地址(IP:服务端口号)。由于我们现在在本机进行测试,这里设置成本机地址127.0.0.1。状态服务默认的监听端口为42422。当然,您也可以通过修改注册表来修改状态服务的端口号。
1.在运行中输入regedit启动注册表编辑器。
2.依次打开HKEY_LOCAL_MACHINE\SYSTEM\CurrentControlSet\Services\aspnet_state\Parameters节点,双击Port选项,如图12-5所示。
选择基数为十进制,然后输入一个端口号即可。stateNetworkTimeout属性表示从状态服务器请求Session数据最长的时间,默认为10秒,如果网络连接不是很好,请把这个数字适当设置得大一点。
第3步打开页面,单击“写入Session”按钮,系统会报错,如图12-6所示。
图12-5  修改状态服务端口号            图12-6  向StateServer默认的Session中写入自定义类出错
提示已经说得很清楚了,只有把对象标注为可序列化后才能在服务中进行存储。什么是序列化呢?序列化是指将对象实例的状态存储到存储媒体的过程。在此过程中,先将对象的公共字段和私有字段以及类的名称转换为字节流,然后再把字节流写入数据流。在随后对对象进行反序列化时,将创建出与原对象完全相同的副本。要使一个类可序列化,最简单的方法是使用 Serializable 属性对它进行标记。
[Serializable]
class MyUser
{
public string sUserName;
public int iAage;
public override string ToString()
{
return string.Format(”姓名:{0},年龄:{1}”, sUserName, iAage);
}
}
第4步现在重新打开页面进行测试,得到的结果和使用InProc模式是一样的。
12.3.4 把Session存储在数据库中要把Session存储在SqlServer中,基本上也是这么几个步骤。
1.在命令行窗口输入cmd并在命令行中运行如下命令。
C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\aspnet_regsql.exe -S .\SqlExpress -E –ssadd
其中C:\Windows用你自己Windows的目录代替,v2.0.50727用你安装的2.0框架的版本号代替。-S指定SqlServer服务器地址,-E表示采用信任连接,-ssadd表示为SqlServer服务器添加状态服务的支持。操作结束后,你可以使用IDE的服务器资源管理器连接SqlExpress数据库,可以看到多了一个ASPState数据库,但是奇怪的是数据库中没有任何表却有很多存储过程,如图12-7所示。
其实,所有Session的数据都存放在了tempdb数据库内,如图12-8所示。
图12-7  使用服务器资源管理器浏览ASPState数据库      图12-8  存放Session数据的tempdb数据库
其实,aspnet_regsql.exe有一个-sstype参数可以用来指定Session的内容和操作的存储过程存放的表。由于篇幅关系,在这里就不详细介绍了,读者可以使用aspnet_regsql.exe/?来浏览程序详细的使用方式。
2.打开Web.config文件,修改前面建立的sessionState节点。
Trusted_Connection=True” sqlCommandTimeout=”60″<
为sqlConnectionString属性指定以前一直用的连接字符串,唯一不同的是不需要再指定数据表的名字了。sqlCommandTimeout属性表示允许执行Sql命令最长的时间,默认为30秒,可以根据自己的需要适当调整这个数字。最后,重新打开页面进行测试,得到的结果和使用InProc模式是一样的(同样你需要确保在自定义类前标注了[Serializable]),不过我们能感到速度有些慢了,毕竟数据是从数据库中进行读取或保存的,而且在使用前还需要经过序列化和反序列化操作。
因此Session能存储的类型为: 对于InProc模式是一切类型,而对于StateServer和SqlServer模式是一切可以序列化的类型。
12.3.5 Session的使用范围与大小限制那么,会话状态使用的范围和大小限制又是怎么样的呢?我们可以分析一下图12-8,系统使用两个表来存储Session的状态。其中有一个ASPStateTempApplication表,用来存储Session所在的应用程序,一定程度上反映了Session是不能跨应用程序的。举例来说,我们在计算机上建立了两个网站,同时都使用Session[“UserName”]来保存登录的用户名,一个网站的用户登录后,另一个网站直接访问Session[“UserName”]是取不到任何值的。那么,Session是否可以跨用户呢?通过前面的分析我们知道,肯定是不行的,Session通过SessionID来区分用户,一般来说SessionID是不可能出现重复的现象,也就是说Session一般是不会“串号”的。既然页面每次提交的时候都会附加上当前用户的SessionID,那么Session应该是可以跨页面的,也就是说一个网站中所有的页面都使用同一份Session。你可以自己来做个试验,请读者打开刚才那个页面,然后按Ctrl+N组合键再打开第二个同样的页面,单击第一个页面中的“写入Session”按钮,单击第二个页面中的“读取Session”按钮,可以发现Session的值被正确读出了。第三个问题的答案有了。
Session状态使用的范围:使用同一个客户端(浏览器实例)访问同一个应用程序的所有页面。我们再来做一个试验,看看Session的容量有多大,在测试以前请修改Web.config,把Session设置为StateServer模式。然后,把写入Session的代码修改成如下(别忘记using System.Data.SqlCient):
DataSet ds = new DataSet();
using (SqlConnection conn = new SqlConnection(@”server=(local)\SQLEXPRESS;database=Forum;
Trusted_Connection=True”))
{
SqlDataAdapter da = new SqlDataAdapter(”select * from tbUser;select * from tbBoard;
select * from tbTopic;”, conn);
da.Fill(ds);
}
ArrayList al = new ArrayList();
for(int i = 0;i<10000000 ip="">al.Add(ds);
Session["LargeData"] = al;
我们把包含三个表的DataSet重复加入ArrayList中1000万次。由于这些表几乎每个表只有几条记录,这样可以模拟大数据量的情况。启动页面,单击“写入Session”按钮后可以发现,Windows服务进程一下子占用了多达70MB的内存,如图12-9所示。
图12-9  把大量数据存放到Session中
Session对于网站和用户是独立的,试想一下,如果服务器上有两个网站,每个网站的在线人数是100人,那么占用内存就要14G。是不是很恐怖的数字?因此,虽然Session的大小没有限制,但是我们千万不能滥用Session。笔者推荐你在Session中存储少于100K的数据。
如果你使用InProc模式的Session,存储过多的数据会导致IIS进程被回收,引发Session不断丢失。如果你使用StateServer存储Session,那么数据在存入Session以前需要进行序列化,序列化会消耗大量的CPU资源。如果你使用SqlServer模式的Session,数据不但要序列化而且还是存储在磁盘上,更不适合存储大量数据。12.3.6 Session的生命周期在了解了Session中存储的数据无大小限制后,我们可能要更多地关心Session的生命周期了。我们已经知道,Session是在用户第一次访问网站的时候创建的,那么Session是什么时候销毁的呢?Session使用一种平滑超时的技术来控制何时销毁Session。默认情况下,Session的超时时间(Timeout)是20分钟,用户保持连续20分钟不访问网站,则Session被收回,如果在这20分钟内用户又访问了一次页面,那么20分钟就重新计时了,也就是说,这个超时是连续不访问的超时时间,而不是第一次访问后20分钟必过时。这个超时时间同样也可以通过调整Web.config文件进行修改:
当然你也可以在程序中进行设置:
Session.Timeout = “30″;
一旦Session超时,Session中的数据将被回收,如果再使用Session系统,将给你分配一个新的SessionID。本节一开始我们就介绍了可以在URL中存储SessionID,现在请你配置Web.config文件,设置Session超时时间为1分钟,SessionID在URl中存放。打开页面后单击“写入Session”按钮,过1分钟再次单击按钮并观察SessionID是否变化。
如图12-10所示,SessionID的确发生了变化。
图12-10  超时后SessionID发生变化
不过,你可别太相信Session的Timeout属性,如果你把它设置为24小时,则很难相信24小时之后用户的Session还在。Session是否存在,不仅仅依赖于Timeout属性,以下的情况都可能引起Session丢失(所谓丢失就是在超时以前原来的Session无效)。
bin目录中的文件被改写。asp.net有一种机制,为了保证dll重新编译之后,系统正常运行,它会重新启动一次网站进程,这时就会导致Session丢失,所以如果有access数据库位于bin目录,或者有其他文件被系统改写,就会导致Session丢失。SessionID丢失或者无效。如果你在URL中存储SessionID,但是使用了绝对地址重定向网站导致URL中的SessionID丢失,那么原来的Session将失效。如果你在Cookie中存储SessionID,那么客户端禁用Cookie或者Cookie达到了IE中Cookie数量的限制(每个域20个),那么Session将无效。如果使用InProc的Session,那么IIS重启将会丢失Session。同理,如果使用StateServer的Session,服务器重新启动Session也会丢失。一般来说,如果在IIS中存储Session而且Session的Timeout设置得比较长,再加上Session中存储大量的数据,非常容易发生Session丢失的问题。
最后,Session的安全性怎么样呢?我们知道,Session中只有SessionID是存储在客户端的,并且在页面每次提交的过程中加入HTTP头发送给服务器。SessionID只是一个识别符,没有任何内容,真正的内容是存储在服务器上的。总的来说安全性还是可以的,不过笔者建议你不要使用cookieless和SqlServer模式的Session。把SessionID暴露在URL中,把内容存储在数据库中可能会发生攻击隐患。
12.3.7 遍历与销毁SessionSession虽然很方便,但是要用好Session还需要自己不断实践,根据自己网站的特点灵活使用各种模式的Session。关于使用程序访问Session,笔者还想补充两点。
如何遍历当前的Session集合。System.Collections.IEnumerator SessionEnum = Session.Keys.GetEnumerator();
while (SessionEnum.MoveNext())
{
Response.Write(Session[SessionEnum.Current.ToString()].ToString() + “
”);
}
对于我们这个例子,输出和图12-1一样。如果你仅仅为了监视Session,也可以通过trace来获得详细信息。在Web.config的system.Web节点中添加:
打开页面后单击“写入Session”按钮,页面显示如图12-11所示。
图12-11  使用trace观察会话状态
如何立刻让Session失效。比如用户退出系统后,Session中保存的所有数据全部失效,可以使用以下代码来让Session失效。Session.Abandon();
12.3.8  Session的常见问题与总结Session的基本知识就介绍到这里,现在再回头看第一节中的几个问题,你是否都能回答了呢?为了强化大家的概念,笔者就三种模式的Session进行了一个比较(假设都使用Cookie来存储SessionID)。
表12.1  三种模式的Session比较
InProc StateServer SQLServer
存储物理位置 IIS进程(内存) Windows服务进程(内存) SQLServer数据库(磁盘)
存储类型限制 无限制 可以序列化的类型 可以序列化的类型
存储大小限制 无限制
使用范围 当前请求上下文,对于每个用户独立
生命周期
第一次访问网站的时候创建Session超时后销毁
优点 性能比较高 Session不依赖Web服务器,不容易丢失
缺点 容易丢失 序列化与反序列化消耗CPU资源 序列化与反序列化消耗CPU资源,从磁盘读取Session比较慢
使用原则
不要存放大量数据
在使用Session的过程中你可能还会遇到很多奇怪的问题,结束本节之前笔者列出了几条常见的FAQ,供大家参考:
为什么每次请求的SessionID都不相同?可能是没有在Session里面保存任何信息引起的,即程序中任何地方都没有使用Session。只有在Session中保存了内容后,Session才会和浏览器进行关联,此时的SessionID将不会再变化。
为什么当我设置cookieless为true后,在重定向的时候会丢失Session?当使用cookieless时,你必须使用相对路径替换程序中的绝对路径,如果使用绝对路径,ASP.NET将无法在URL中保存SessionID。
有办法知道应用程序的Session在运行时占用了多少内存吗?没有办法,你可以通过观察IIS进程(InProc模式)或者aspnet_state进程(StateServer模式)大致估计。
有没有可能知道整个网站使用Session的用户列表?对于InProc模式和StateServer模式很难,对于SqlServer模式你可以查询存储Session的表进行尝试。
当页面中设了frameset,发现在每个frame中显示页面的SessionID在第一次请求时都不相同,为什么?原因是你的frameset是放在一个HTML页面上而不是ASPX页面。在一般情况下,如果frameset是aspx页面,当你请求页面时,它首先将请求发送到Web服务器,此时已经获得了SessionID,接着浏览器会分别请求Frame中的其他页面,这样所有页面的SessionID就是一样的,就是FrameSet页面的SessionID。然而如果你使用HTML页面做FrameSet页面,第一个请求将是HTML页面,当该页面从服务器上返回时并没有任何Session产生,接着浏览器会请求Frame里面的页面,这样,这些页面都会产生自己的SessionID,所以在这种情况下就可能出现这种问题。当你重新刷新页面时,SessionID就会一样,并且是最后一个请求页面的SessionID。
12.4  Application12.4.1 全局应用程序类从Application这个单词上大致可以看出Application状态是整个应用程序全局的。在ASP时代我们通常会在Application中存储一些公共数据,而ASP.NET中Application的基本意义没有变:在服务器内存中存储数量较少又独立于用户请求的数据。由于它的访问速度非常快而且只要应用程序不停止,数据一直存在,我们通常在Application_Start的时候去初始化一些数据,在以后的访问中可以迅速访问和检索。
我们可以来实践一下。首先,右键单击网站,选择“添加新项”命令,如图12-12所示,选择全局应用程序类。
图12-12  添加一个Global.asax
Global.asax(通常我们不改名)是一个用来处理应用程序全局的事件。打开文件,系统已经为我们定义了一些事件的处理方法。
void Application_Start(object sender, EventArgs e)
{
// 在应用程序启动时运行的代码
}
void Application_End(object sender, EventArgs e)
{
//  在应用程序关闭时运行的代码
}
void Application_Error(object sender, EventArgs e)
{
// 在出现未处理的错误时运行的代码
}
void Session_Start(object sender, EventArgs e)
{
// 在新会话启动时运行的代码
}
void Session_End(object sender, EventArgs e)
{
// 在会话结束时运行的代码
// 注意: 只有在 Web.config 文件中的 sessionstate 模式设置为InProc 时,才会引发 Session_
End 事件
// 如果会话模式设置为 StateServer 或 SQLServer,则不会引发该事件
}
通过这些注释我们可以看到,这些事件是整个应用程序的事件,和某一个页面没有关系。
12.4.2 使用Application统计网站访问假设我们希望使用Application统计网站的访问情况。
页面单击数。页面被单击一次+1,不管是否是同一个用户多次单击页面。用户访问数。来了一个用户+1,一个用户打开多个页面不会影响这个数字。我们首先需要在Application_Start中去初始化两个变量。
void Application_Start(object sender, EventArgs e)
{
// 在应用程序启动时运行的代码
Application["PageClick"]=0;
Application["UserVisit"]=0;
}
用户访问数根据Session来判断,因此可以在Session_Start的时候去增加这个变量:
void Session_Start(object sender, EventArgs e)
{
Application.Lock();
Application["UserVisit"]=(int)Application["UserVisit"]+1;
Application.UnLock();
}
我们看到,Application的使用方法和Session差不多。唯一要注意的是,Application的作用范围是整个应用程序,可能有很多用户在同一个时间访问Application造成并发混乱,因此在修改Application的时候需要先锁定Application,修改完成后再解锁。
页面单击数则在页面Page_Load的时候去修改。
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
Application.Lock();
Application["PageClick"] = (int)Application["PageClick"] + 1;
Application.UnLock();
Response.Write(string.Format(”页面单击数:{0}
”, Application["PageClick"]));
Response.Write(string.Format(”用户访问数:{0}
”, Application["UserVisit"]));
}
}
由于在应用程序开始的时候我们已经为两个变量初始化了,所以在这里可以直接使用。首次执行效果如图12-13所示。
连续刷新页面几次,效果如图12-14所示。
使用Ctrl+N组合键打开几个页面,可以发现用户访问数还是没有变化。前一节中介绍过,Session是每个客户端一份,而不是每个浏览器一份。
关闭页面,再重新打开。由于前一个用户的Session还没有超时,所以这次用户访问数增加了1,如图12-15所示。
图12-15  Session_Start导致用户访问数增长
图12-14 页面单击数随着页面刷新增长
图12-13  使用Application进行站点统计
我们知道,Visual Studio 2005有一个内置的服务器(不依赖IIS)。因此,我们不能通过IIS来重新启动应用程序。如图12-16所示。
单击“停止”选项,然后重新打开页面,如图12-17所示,我们可以看到两个变量都重新初始      化了。
图12-16  停止IDE内置的Web服务器      图12-17  重新启动Web服务器导致Application_Start触发
12.4.3 Application总结在ASP.NET 2.0中,Application已经变得不是非常重要了。因为Application的自我管理功能非常薄弱,它没有类似Session的超时机制。也就是说,Application中的数据只有通过手动删除或者修改才能释放内存,只要应用程序不停止,Application中的内容就不会消失。在下一节中,我们会看到,可以使用Cache实现类似Application的功能,同时Cache又有丰富而强大的自我管理机制。
在结束本节以前,让我们来根据第一节中提出的几个问题总结一下Application的特性。
存储的物理位置。服务器内存。存储的类型限制。任意类型。状态使用的范围。整个应用程序。存储的大小限制。任意大小。生命周期。应用程序开始的时候创建(准确来说是用户第一次请求某URL的时候创建),应用程序结束的时候销毁。安全与性能。数据总是存储在服务端,安全性比较高,但不易存储过多数据。优缺点与注意事项。检索数据速度快,但缺乏自我管理机制,数据不会自动释放。12.5  Cache12.5.1 Cache概述Cache和Application一样是整个应用程序共用一份的,而且所有用户访问的都是相同的Cache。Cache从字面上说是缓存的意思,我们知道计算机系统本身就是一个多级缓存的结构。CPU的缓存中存放了部分内存中的数据,内存中又存放了部分硬盘中的数据。把最常用的数据放在读取最快速的硬件中存储能大大提高效率。对于Web系统来说也一样,从数据库(硬盘)中读取数据的速度肯定比从Cache(内存)中读取的效率低,基于这个特性,我们通常把改动不大而查询次数又比较多的数据放到Cache中。
既然缓存中的数据其实是来自数据库的,那么缓存中的数据如何和数据库进行同步呢?一般来说,缓存中应该存放改动不大或者对数据的实时性没有太多要求的数据。这样,我们只需要定期更新缓存就可以了。相反,如果缓存的更新频率过快的话,使用缓存的意义就不是很大了,因此更新缓存的时候需要一次性从数据库中读取大量的数据,过于频繁地更新缓存反而加重了数据库的负担。
那么ASP.NET中的Cache又提供了哪些缓存的过期策略呢?
永不过期。和Application一样,缓存永不过期。绝对时间过期。缓存在某一时间过期,比如5分钟后。变化时间过期(平滑过期)。缓存在某一时间内未访问则超时过期,这个和Session有点类似,比如我们可以设定缓存5分钟没有人访问则过期。依赖过期。缓存依赖于数据库中的数据或者文件中的内容。一旦数据库中某些表的数据发生变动或者文件内容发生变动,则缓存自动过期。缓存过期后我们就要更新缓存了,ASP.NET提供了两种更新策略。
被动更新。缓存过期以后手动进行更新。主动更新。缓存过期以后在回调方法中更新。12.5.2 Cache性能与过期策略首先,在页面上添加两个按钮,并双击按钮实现Click事件处理方法。
Text=”从缓存中读取数据” /<
Text=”从数据库中读取数据” /<
第一个按钮实现从缓存读取数据。
注意:本例需要using以下命名空间。
using System.Diagnostics;   // 用于精确测定时间间隔
using System.Web.Caching;   // 用于缓存的策略
using System.IO;            // 用于文件操作
protected void btn_GetData_Click(object sender, EventArgs e)
{
InsertRecord();
Stopwatch sw=new Stopwatch();
sw.Start();
if (Cache["Data"]==null)
{
Response.Write(”缓存无效
”);
}
else
{
DataSet ds = Cache["Data"] as DataSet;
Response.Write(string.Format(”查询结果:{0}
”, ds.Tables[0].Rows[0][0]));
Response.Write(string.Format(”耗费时间:{0}
”, sw.ElapsedTicks));
}
}
在这里有几点需要说明。
一开始的InsertRecord()方法是我们自己创建的,用来向数据库插入一条记录。这样,我们就能看出来数据是否是从缓存中读取的了。InsertRecord()方法如下:
private void InsertRecord()
{
using (SqlConnection conn = new SqlConnection(@”server=(local)\SQLEXPRESS;
database=Forum;Trusted_Connection=True”))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand(”Insert into CacheTest (Test) values
(’Test’)”, conn))
{
cmd.ExecuteNonQuery();
}
}
}
如果缓存存在则输出查询结果和查询耗费的时间,如果缓存不存在则输出“缓存无效”。Stopwatch类用于精确测定逝去的时间,ElapsedTicks属性返回了间隔的计数器刻度,所谓计数器刻度就是系统的计数器走过了多少次。当然,Stopwatch还有ElapsedMilliseconds能返回间隔的总毫秒数。之所以使用ElapsedTicks,因为它是一个更小的时间单位。第二个按钮直接从数据库读取数据。
protected void btn_GetDataFromDb_Click(object sender, EventArgs e)
{
InsertRecord();
Stopwatch sw = new Stopwatch();
sw.Start();
DataSet ds = GetData();
Response.Write(string.Format(”查询结果:{0}
”, ds.Tables[0].Rows[0][0]));
Response.Write(string.Format(”耗费时间:{0}”, sw.ElapsedTicks));
}
在这里,我们把读取数据的操作使用一个GetData()方法进行了封装,方法实现如下:
private DataSet GetData()
{
DataSet ds = new DataSet();
using (SqlConnection conn = new SqlConnection(@”server=(local)\SQLEXPRESS;
database=Forum;Trusted_Connection=True”))
{
SqlDataAdapter da = new SqlDataAdapter(”select count(*) from CacheTest”, conn);
da.Fill(ds);
}
return ds;
}
为了能体现出缓存的效率,我们在Forum数据库中又新建立了一个CacheTest数据表,表结构很简单,如图12-18所示。
图12-18  CacheTest表结构
我们在表中插入了10万条以上的记录,使得表的大小达到了100MB左右。
运行程序,单击“从数据库中读取数据”按钮,如图12-19所示,我们可以看到,这个操作耗费了相当多的时间。
因为我们直接从数据库读取count(*),所以每次单击按钮查询结果显示的数字都会+1。现在你单击“从缓存中读取数据”肯定是显示“缓存无效”,因为我们还没有添加任何缓存。
然后,我们在页面上添加三个按钮并双击按钮创建事件处理方法,三个按钮使用不同的过期策略添加缓存。
OnClick=”btn_InsertNoExpirationCache_Click” /<
过期缓存” /<
过期缓存” OnClick=”btn_InsertSlidingExpirationCache_Click” /<
三个按钮的Click事件处理方法如下:
protected void btn_InsertNoExpirationCache_Click(object sender, EventArgs e)
{
DataSet ds = GetData();
Cache.Insert(”Data”, ds);
}
protected void btn_InsertAbsoluteExpirationCache_Click(object sender, EventArgs e)
{
DataSet ds = GetData();
Cache.Insert(”Data”, ds,null, DateTime.Now.AddSeconds(10), TimeSpan.Zero);
}
protected void btn_InsertSlidingExpirationCache_Click(object sender, EventArgs e)
{
DataSet ds = GetData();
Cache.Insert(”Data”, ds, null, DateTime.MaxValue, TimeSpan.FromSeconds(10));
}
我们来分析一下这三种过期策略。
永不过期。直接赋值缓存的Key和Value即可绝对时间过期。DateTime.Now.AddSeconds(10)表示缓存在10秒后过期,TimeSpan.Zero表示不使用平滑过期策略。变化时间过期(平滑过期)。DateTime.MaxValue表示不使用绝对时间过期策略,TimeSpan.FromSeconds(10)表示缓存连续10秒没有访问就过期。在这里,我们都使用了Insert()方法来添加缓存。其实,Cache还有一个Add()方法也能向缓存中添加项。不同之处在于Add()方法只能添加缓存中没有的项,如果添加缓存中已有的项将失败(但不会抛出异常),而Insert()方法能覆盖原来的项。
注意:和Application不同,这里不需要使用在插入缓存的时候进行锁操作,Cache会自己处理     并发。
现在,我们就可以打开页面对这三种过期策略进行测试了。
1.单击“从缓存中读取数据”按钮,提示“缓存无效”。
2.单击“从数据库中读取数据”按钮,查询结果显示现在记录总数为100646。
3.单击“插入永不过期缓存”按钮,然后连续单击“从缓存中读取数据”按钮,可以发现,无论过去多久,缓存始终没有过期,而且观察记录查询结果可以发现值始终没有发生变化。不同的是,从缓存中读取数据的效率比从数据库中读取数据提高了几个数量级,如图12-20所示,你可以和图12-19进行比较。
4.单击“插入绝对时间过期缓存”,然后连续单击“从缓存中读取数据”按钮,大约10秒过期后,页面提示“缓存无效”,说明缓存过期了。
5.单击“插入变化时间过期缓存”,然后连续单击“从缓存中读取数据”按钮,缓存始终不过期,如果我们等待10秒后再去单击按钮,页面提示“缓存无效”,说明缓存过期了。
我们再来看一下依赖过期策略。所谓依赖过期就是缓存的依赖项(比如一个文件)的内容改变之后缓存也就失效了。由于篇幅关系,这里只介绍文件依赖。我们在页面上再加两个按钮并双击按钮添加Click事件处理方法。
Click” /<
OnClick=”btn_AddFileDependencyCache_Click” /<
在本例中,我们将使缓存依赖一个txt文本文件。因此,首先在项目中添加一个test.txt文本文件。单击“修改文件”按钮实现文件的修改。
protected void btn_ModifyFile_Click(object sender, EventArgs e)
{
FileStream fs = new FileStream(Server.MapPath(”test.txt”), FileMode.Append,
FileAccess.Write);
StreamWriter sw = new StreamWriter(fs);
sw.WriteLine(DateTime.Now.ToString());
sw.Close();
fs.Close();
}
我们通过在文件的最后写入当前的时间来修改文件。插入文件依赖缓存按钮的事件处理方法如下:
protected void btn_AddFileDependencyCache_Click(object sender, EventArgs e)
{
CacheDependency cd = new CacheDependency(Server.MapPath(”test.txt”));
DataSet ds = GetData();
Cache.Insert(”Data”, ds, cd);
}
添加文件依赖缓存同样简单,通过CacheDependency关联了一个文件依赖。
现在就可以打开页面进行测试了。
1.单击“从缓存中读取数据”按钮,提示“缓存无效”。
2.单击“从数据库中读取数据”按钮,查询结果显示现在记录总数为100710。
3.单击“插入文件依赖缓存”按钮,然后连续单击“从缓存中读取数据”按钮,可以发现,无论过去多久,缓存始终没有过期,而且观察记录查询结果可以发现值始终没有发生变化。
4.单击“修改文件”按钮,然后单击“从缓存中读取数据”按钮,提示“缓存无效”。由于文件已经修改了,依赖这个文件的缓存立刻失效了。
12.5.3 Cache的更新策略最后,我们来讨论缓存的更新策略。在Web程序中我们通常会使用被动更新。所谓被动更新,就是在调用数据的时候判断缓存是否为空,如果为空则先更新缓存然后再从缓存中读取数据,如果不为空则直接从缓存中读取数据。可以把“从缓存中读取数据”按钮的Click事件处理方法修改成如下,实现被动更新。
protected void btn_GetData_Click(object sender, EventArgs e)
{
InsertRecord();
DataSet ds = new DataSet();
Stopwatch sw = new Stopwatch();
sw.Start();
if (Cache["Data"] == null)
{
ds = GetData();
Cache.Insert(”Data”, ds, null, DateTime.Now.AddSeconds(10), TimeSpan.Zero);
}
else
{
ds = Cache["Data"] as DataSet;
}
Response.Write(string.Format(”查询结果:{0}”, ds.Tables[0].Rows[0][0]));
Response.Write(string.Format(”耗费时间:{0}”, sw.ElapsedTicks));
}
我们可以看出,如果没有人访问数据缓存是不会更新的,只有缓存被访问的时候发现缓存无效才会去更新。这样很明显的一个缺点就是,如果缓存过期了更新操作将花费很长时间,这个时候的查询也需要花费很多时间。我们可以利用缓存的回调功能让缓存过期后自动续建实现自动更新的目的。
protected void btn_InsertActiveUpdateCache_Click(object sender, EventArgs e)
{
DataSet ds = GetData();
Cache.Insert(”Data”, ds, null, DateTime.Now.AddSeconds(10), TimeSpan.Zero,
CacheItemPriority.Default, CacheRemovedCallback);
}
最后一个参数表明缓存被移除以后自动调用CacheRemovedCallback()方法,方法实现如下。
private void CacheRemovedCallback(String key, object value, CacheItemRemovedReason
removedReason)
{
DataSet ds = GetData();
Cache.Insert(key, ds, null, DateTime.Now.AddSeconds(10), TimeSpan.Zero, CacheItemPriority.
Default, CacheRemovedCallback);
}
在回调方法中,我们再次插入一个支持回调的缓存。这样,缓存被移除以后又能自动更新了。说了这么多创建缓存的方法,读者可能会问怎么手动移除缓存呢?比如我们要移除Key=”Data”的缓存只需要:
Cache.Remove(”Data”);
你可能会马上想到用Cache.RemoveAll()方法移除所有缓存,可是Cache没有提供这样的方法,我们只能通过遍历来实现移除所有缓存。
IDictionaryEnumerator CacheEnum = HttpRuntime.Cache.GetEnumerator();
while (CacheEnum.MoveNext())
{
Cache.Remove(CacheEnum.Key.ToString());
}
12.5.4 Cache总结同样,我们以第一节中的几个问题结束对Cache的讨论。
存储的物理位置。服务器内存。存储的类型限制。任意类型。状态使用的范围。当前请求上下文,所有用户共用一份。存储的大小限制。任意大小。生命周期。有多种过期策略控制缓存的销毁。安全与性能。数据总是存储在服务端,安全性比较高,但不易存储过多数据。优缺点与注意事项。检索数据速度快,过期策略丰富。注意别把对实时性要求很高的数据放到Cache中,不断更新Cache会对数据库造成压力。12.6 隐藏域/ViewState/ControlState——保存数据的另一个场所12.6.1 使用隐藏域Session、Application和Cache都是保存在服务器内存中的。一般来说我们是无权访问客户端的机器,把数据直接保存在客户端的(Cookie是一个例外,不过Cookie只能保存不超过4K的字符串)。我们可以想一下还有哪里可以让我们暂时保存数据的?那就是页面!如果我们在Web页面中放置一个Label控件,然后设置它隐藏。那么我们就可以使用这个Label来保存一些临时数据,供当前页面的程序使用。
在ASP.NET中,我们还可以使用隐藏域来进行类似的工作,和Label不同的是,在隐藏域中填写的内容不会直接显示在IDE的设计视图中。由于我们保存的这些数据根本不需要显示给用户看,所以用隐藏域更合理一些。
在代码中可以直接访问隐藏域的Value属性获得其值。
Response.Write(HiddenField1.Value);
不过,这样做还有几个不合理的地方。
数据直接暴露给用户。只能存储字符串数据。12.6.2 使用ViewStateASP.NET引入了ViewState(视图状态)的概念。从这个名字上我们大概可以体会出,ViewState主要是用来存放和视图有关的一些状态。比如,在用户注册时用户填写了一大堆数据,提交页面后系统返回了一个“用户名重复”的出错信息,此时先前用户在页面上填写的一些注册资料全部没有了。用户会是什么感觉呢?我想大多数用户会很恼火。ASP.NET通过ViewState自动保存控件的状态。你可能也发现了,文本框中的数据在页面提交后还是存在的。
同时,我们也可以利用ViewState来保存一些程序需要的数据。ViewState中的数据默认是使用base64进行编码的,因此,用户不能直接看到里面的数据。我们在代码中可以这样添加一个ViewState项:
ViewState["test"] = “编程快乐”;
打开页面,观察源代码,ViewState就在这里:
CMUgAMjbpmAwtMtwPE+b5Ii8uRFaO42AgKyR+u9T0Be” /<
既然ViewState是存在页面上的,那么ViewState肯定是不能跨页面使用的,而且每个用户访问到的ViewState都是独立的。此外,ViewState也没有什么声明周期的概念,页面在ViewState就在,页面关闭了ViewState就关闭了。
观察上面的ViewState,是不是找不到“编程快乐”这几个字的影子呢?请在页面上随便加入一个按钮,按钮的Click事件处理方法如下:
Response.Write(System.Text.Encoding.UTF8.GetString(Convert.FromBase64String(
Request["__VIEWSTATE"])));
如图12-21所示,单击按钮后页面显示如下。
图12-21  对ViewState数据进行base64解码
我们对ViewState数据进行base64解码后就能看到“编程快乐”的字样了。不过,现在的那串字符串还是很乱。其实,ASP.NET首先对ViewState中的数据进行序列化,然后再使用base64编码后存储在页面的隐藏域中。base64不是什么加密算法,只是一种编码算法,任何人都能对base64进行反     编码。
12.6.3ViewState的安全与性能如果我们需要在ViewState中保存一些相对比较机密的数据(当然,非常机密的数据不建议你保存在ViewState中),又如何保证ViewState的安全性呢?一般来说可以从两个方面入手。
1.保证客户端提交过来的ViewState没有被修改。我们做Web应用程序,心中要有这样一个意识,那就是客户端的一切都是不可相信的。大家可能以为只有我们提供了诸如TextBox等控件,用户才能修改。其实这种观点是错误的,虽然DropDownList中的内容只允许选择不允许修改,但完全可以伪造一个页面进行提交。对于ViewState也是同样道理,为了进一步的安全,我们需要验证客户端发回的ViewState是否已经被修改了。
2.保证用户不能直接看到ViewState中的数据。说白了就是对ViewState进行加密。
在ASP.NET 2.0中,我们只需要进行简单地配置就能对ViewState进行验证和加密,在页面头部添加EnableViewStateMac(验证)和ViewStateEncryptionMode(加密)属性:
< Page EnableViewStateMac="”true”" ViewStateEncryptionMode="”Always”" >
当然,如果你希望为所有页面的ViewState应用验证和加密,可以在Web.config的system.Web节点中添加:
既然ViewState中的数据是序列化后加入的,那么我们就可以把一些复杂的类型也存放到ViewState中。在介绍Session的时候我们曾建立过一个MyUser自定义类,并把它的实例存放到了Session中,后来为了让StateServer和SqlServer模式的Session也能保存MyUser类型,我们又为MyUser标记了[Serializable]。在ViewState中保存自定义类型同样需要为类型标记[Serializable],那么在这里我们使用ViewState保存MyUser实例的代码就和使用Session差不多。
MyUser user = new MyUser();
user.sUserName = “小朱”;
user.iAage = 24;
ViewState["CustomClass"] = user;
读取代码:
MyUser user = ViewState["CustomClass"] as MyUser;
Response.Write(user.ToString());
那么,ViewState中能保存多少数据呢?暂且不说表单Post的数据是有大小上限的,ViewState是经过序列化和编码后保存在页面中的。如果我们在ViewState中保存一个拥有100条记录的DataSet,恐怕页面就很难打开了。不信,你可以自己做一个试验。
DataSet ds = new DataSet();
using (SqlConnection conn = new SqlConnection(@”server=(local)\SQLEXPRESS;database=
Forum;Trusted_Connection=True”))
{
SqlDataAdapter da = new SqlDataAdapter(”select top 100 * from CacheTest”, conn);
da.Fill(ds);
}
ViewState["Data"] = ds;
仅仅只有100条记录,在ViewState就成这样了,如图12-22所示。
图12-22  滥用ViewState的结果
而且这些数据还要在浏览器和服务器之间往返,占用的网络流量很客观。因此,笔者建议你在ViewState中保存尽量少的数据。如果实在需要在ViewStatge中放置大量数据建议使用maxPageState- FieldLength对ViewState启用分块传输。
< Page maxPageStateFieldLength="”100″%" >
如图12-23所示就设置了单个ViewState,不超过100字节,ViewState分成了几个部分。
图12-23  使用maxPageStateFieldLength控制每个ViewState不超过100字节
我们知道,ViewState不仅仅是我们在使用,ASP.NET会把控件交互相关的一些数据都存放到ViewState中,但是对于一些不实现任何交互的控件(比如显示10条记录的GridView),你可以设置控件的EnableViewState属性为false来让控件不使用ViewState,从而减少页面体积。
12.6.4ControlState概述最后,我们再简单提一下,ASP.NET 2.0提供了ControlState。它用于保存(自定义)控件的关键信息。就算页面或者控件的ViewState被关闭它还能起作用,弥补了ViewState能被禁止的不足。不过使用ControlState稍显复杂,我们需要自己序列化复杂对象进行存储。下面的代码演示了如何在ControlState中保存和读取简单字符串:
PageStatePersister.ControlState = “编程快乐”;
Response.Write(PageStatePersister.ControlState.ToString());
12.6.5 总结其实隐藏域、ViewState和ControlState的原理差不多,我们来总结一下。
存储的物理位置。表单隐藏域。存储的类型限制。可序列化类型(直接在隐藏域中保存内容需要自己序列化)。状态使用的范围。当前页面(当前控件),对用户独立。存储的大小限制。存储过大数据会导致页面不能正常打开,不能正常提交。生命周期。页面在就在,页面不在也就不在了。三者始终是依附在页面的隐藏域中的。安全与性能。在客户端存储,安全性低。不过,ViewState提供了验证和加密。优缺点与注意事项。存储少量数据非常方便简单。但需要注意不要存储敏感数据,不要存储过大的数据。它们和前面说的Cookie、Session与Application不同。虽然Cookie也是存储在客户端,每次提交都附加在HTTP头中进行提交,但是它的数据量毕竟不大,起了一个标记的作用。Session和Application都是存储在服务器端的,不会参与页面往返过程。隐藏域、ViewState和ControlState始终参与往返,而且序列化和反序列化会消耗一定资源,因此,存储过大的数据会导致网页加载过慢,浪费服务器带宽。12.7 以人为本的Profile12.7.1 使用Profile制作个性化页面一个人性化的网站往往提供给用户很多个性化选择。比如让用户选择所喜欢的网站风格,让用户选择是否自动弹出消息提醒等。这些数据需要在用户把浏览器关闭后还能保存下来,因此只能选择数据库进行保存。对于登录过的用户比较好办,我们可以根据用户名和用户的选择存放在数据库中,对于非登录用户(匿名用户)怎么保存用户的选择呢?唯一的方法只能像Session那样分配给用户一个ID,把这个ID存放在Cookie中(当然也可以放在URL中),然后在数据库中保存这个ID相关的一些配置信息。
ASP.NET 2.0提供了Profile机制,能帮助我们完成类似的功能。Profile不仅仅支持登录用户还支持匿名用户,存储的数据也可以是任何可序列化类型。几乎无需写一行代码就能轻松实现用户个性化数据的保存。我们配置了一个Web.config文件,如下所示。
< xml version="”1.0″?" >
表示对匿名用户也启用Profile,系统会给匿名用户分配一个随机字符串组成的ID。表示自动在页面请求结束的时候保存Profile的设置到数据库中。中就是正式定义Profile的格式了,我们使用标签把Profile分成了两组,和。在每一个中的才是真正的Profile。name表示Profile的名字,defaultValue表示默认值,allowAnonymous表示匿名用户是否可以使用,type表示数据类型。表示为系统启用了表单认证(这是ASP.NET认证方式的一种,以后的章节中会详细介绍),然后,我们为页面添加一些控件来个性化页面。
文字颜色:
Black
Blue
Red
“btn_SaveSettings_Click” /< 
Login_Click” Text=”登录” /<
体” Font-Size=”50pt” Height=”77px” Text=”编程快乐” Width=
“293px”<
效果如图12-24所示。
通过下拉框我们可以设置文字的颜色为黑色、蓝色或者红色,通过复选框我们可以设置文字是否是粗体。单击“保存个性化设置”按钮保存设置。
protected void btn_SaveSettings_Click(object sender, EventArgs e)
{
Profile.UI.ForeColor = ddl_TextColor.SelectedValue;
Profile.UI.EnableBold = cb_IsBlod.Checked;
ApplyUISettings();
}
看到这里读者会不会很惊讶,我们仅仅在Web.config文件中配置了Profile的信息,怎么在代码中就直接能访问到强类型的Profile了呢?其实,系统会在App_Code下生成临时的代码文件,如图12-25所示。
图12-25  系统生成的临时代码文件
在这里我们又自定义了一个ApplyUISettings()方法来向页面中的标签应用样式。
private void ApplyUISettings()
{
lab_Text.ForeColor = Color.FromName(Profile.UI.ForeColor);
lab_Text.Font.Bold = Profile.UI.EnableBold;
ddl_TextColor.SelectedValue = Profile.UI.ForeColor;
cb_IsBlod.Checked = Profile.UI.EnableBold;
}
同时为了保证页面首次加载的时候也能按照用户的个性化配置来显示,我们在Page_Load()的时候也需要应用配置。
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
ApplyUISettings();
}
}
现在打开页面,并把样式设置为蓝色文字,粗体显示,如图12-26所示。
首次操作的时候比较慢,因为系统正在为你生成保存信息的数据库。默认是使用SQL Express数据库,文件就放在网站的App_Data文件夹下,如图12-27所示。
图12-26  保存自己的个性化设置        图12-27  App_Data下的ASPNETDB数据库文件
关闭页面后再打开,可以发现页面仍然保持了原来的样式设置。你可能会问,系统怎么知道我们还是原来的那个用户呢?其实,系统为匿名用户生成了一个ID字符串保存于Cookie中,页面加载的时候根据这个ID从数据库中读出数据填充Profile。在测试了匿名用户的Profile后,我们来为登录按钮添加Click事件处理方法。
protected void btn_Login_Click(object sender, EventArgs e)
{
FormsAuthentication.SetAuthCookie(”test”, false);
Response.Redirect(Request.Path);
}
在这里,我们假设一个名为test的用户登录了系统,并把页面重定向到本页。然后,我们在页面上添加一个PlaceHolder控件,在其中放置一些控件让用户输入Profile的信息和退出登录。
姓名:
年龄:
“btn_SaveUserInfo_Click” /< 
出” /<
保存用户信息按钮Click事件实现如下:
protected void btn_SaveUserInfo_Click(object sender, EventArgs e)
{
Profile.UserInfo.UserName = tb_Name.Text;
Profile.UserInfo.UserAge = int.Parse(tb_Age.Text);
GetUserInfo();
}
这里的GetUserInfo用于显示用户的信息(登录用户的信息和非登录用户的信息)。
private void GetUserInfo()
{
if (User.Identity.IsAuthenticated)
{
ph_UserInfo.Visible = true;
Response.Write(”当前登录用户:” + User.Identity.Name + “”);
Response.Write(”Profile关联用户:” + Profile.UserName + “”);
Response.Write(”Profile.UserInfo.UserName:” + Profile.UserInfo.UserName +
“”);
Response.Write(”Profile.UserInfo.UserAge:” + Profile.UserInfo.UserAge +
“”);
}
else
{
ph_UserInfo.Visible = false;
Response.Write(”Profile关联用户:” + Profile.UserName + “”);
}
}
退出登录按钮Click事件实现如下:
protected void btn_Logout_Click(object sender, EventArgs e)
{
FormsAuthentication.SignOut();
Profile.UserInfo.UserAge = 0;
Profile.UserInfo.UserName = “”;
Response.Redirect(Request.Path);
}
同时,我们修改一下Page_Load,让页面首次加载的时候也显示用户信息。
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
ApplyUISettings();
GetUserInfo();
}
}
再次打开页面,如图12-28所示。
我们看到,对于匿名用户来说,Profile.UserName属性为一个随机字符串,单击“登录”按钮后如图12-29所示。
图12-28  匿名用户关联了一个随机字符串作为ID           图12-29  单击登录按钮后显示用户信息
现在可以看到Profile关联的用户UserName已经为登录的test用户了。现在,我们把姓名设置为“小朱”,年龄设置为24,单击“保存用户信息”按钮。然后把样式修改为红色粗体,单击“保存个性化设置”按钮。如图12-30所示。
如果你现在关闭窗口再重新打开就会发现个性化设置还是原来在匿名状态下保存的蓝色粗体。因此现在已经不在登录状态了,比较一下发现现在的ID和上次的ID还是一样的。再次单击“登录”按钮后,页面又加载了test用户的个性化设置。
12.7.2 Profile总结Profile的知识远远不止这些,我们这里仅仅对它进行了简单的介绍,在结束以前我们以第一节中的几个问题结束对Profile的讨论。
存储的物理位置。客户端Cookie/URL和服务器数据库。存储的类型限制。可序列化类型。状态使用的范围。当前请求的上下文,对每一个用户独立。存储的大小限制。任意大小,读取写入频繁的数据不建议存入Profile。生命周期。与关联的Cookie的生命周期一样。安全与性能。数据总是存储在服务端,安全性比较高,但不易存储过多数据。优缺点与注意事项。可以很方便地保存用户(匿名用户和已登录用户)的设置。12.8 其他12.8.1QueryString除了Cookie、Session、Application、Cache、HiddenField、ViewState、ControlState、Profile等重要状态机制外,ASP.NET还提供了一些其他的方法让我们暂时保存数据。
在很多时候我们希望跨页面传输数据,最常用的一个办法就是使用GET方式提交数据。也就是在URL中附加一段QUERYSTRING(类似 news.aspx?ID=1这样的效果),因此,有的时候我们不需要通过程序就能传输QUERYSTRING。不过,我们需要注意以下几点。
IE浏览器对URL长度限制在2083个字符内。由于QUERYSTRING是在URL中传输内容,所以也就受到了这个限制。在URL中传输的数据都是明文的,而且客户端随时能修改,因此千万别使用QUERYSTRING传敏感数据。在URL中传汉字或者一些特殊字符需要进行URL编码后传递,接收的时候再反编码,否则传递的数据可能会出现乱码或者被截断。string s = “编程快乐!@#$%^&*()”;
Response.Redirect(Request.Url.AbsolutePath+”?data=”+HttpUtility.UrlEncode(s));
接收的时候:
if (Request.QueryString["data"] != null)
Response.Write(HttpUtility.UrlDecode(Request.QueryString["data"]));
12.8.2 跨页提交QUERYSTRING毕竟只能传输字符串,如果我们希望在一个页面中直接访问另外一个页面,可以使用ASP.NET 2.0的跨页提交功能。比如我们建立一个CrossPageSubmitTest.aspx页面,在任意页面建立一个按钮提交到CrossPageSubmitTest.aspx。
Click” PostBackUrl=”CrossPageSubmitTest.aspx” Text=”跨页面提交” /<
然后在CrossPageSubmitTest.aspx的Page_Load中加入以下代码就能输出前一个页面上btn_CrossPageSubmit按钮的Text属性。
Response.Write((PreviousPage.FindControl(”btn_CrossPageSubmit”) as Button).Text);
12.9 完善论坛在上一章中,我们实现了论坛的一些前台页面。
论坛分类和版块显示页面。帖子列表页面。注册和导航用户控件。对于我们的论坛,只有注册用户才能发帖。在学习了这么多状态管理机制以后,我们要使用其中的一两种机制来实现用户的登录和验证。
首先,我们要实现用户注册功能(为了简单,以后的页面不再包含多语言和导航功能)。
1.新建一个Reg.aspx,代码如下:
用户注册
用户名
密码
确认密码
TextBox<
邮箱地址
个人信息
“Horizontal” RepeatLayout=”Flow”<
头像
看了这些代码你应该能知道怎么创建每一个控件,笔者就不多说了,页面效果如图12-31所示。
图12-31  论坛注册页面
2.在注册的时候还需要一定的验证。
用户名、密码、邮箱地址不能为空。密码和确认密码需要一致。这些验证操作可以使用验证控件实现,在这里我们就顺便复习一下验证控件的使用。验证不为空可以使用RequiredFieldValidator。从工具箱中拖一个RequiredFieldValidator控件到tb_UserName文本框的后面,单击RequiredFieldValidator控件,在属性窗口进行设置。
把它的ErrorMessage属性设置为“请填写用户名”,ErrorMessage代表验证不通过时显示的     文字。把它的Display属性设置为Dynamic,表示没有出错的时候出错信息不占用空间。把它的ControlToValidate属性设置为tb_UserName,表示这个验证控件和tb_UserName文本框关联。现在,为tb_Password1文本框和tb_UserEmail文本框各自添加一个RequiredFieldValidator控件,步骤和上面的相同,ErrorMessage属性分别设置为“请填写密码”和“请填写邮箱地址”。
密码和确认密码一致的验证操作可以使用CompareValidator验证控件来实现。从工具箱中拖一个CompareValidator控件到tb_Password2文本框的后面,通过属性窗口进行设置。
把ControlToCompare属性设置为tb_Password1,表示和tb_Password1控件的值进行比较。把ControlToValidate属性设置为tb_Password2,表示关联到tb_Password2控件。把ErrorMessage属性设置为“两次密码不一致”。把Display属性设置为Dynamic。完成后,设计视图的效果如图12-32所示。
3.启动Reg.aspx,读者可以检测验证控件的效果,可以发现当光标从一个控件转到了另一个控件的时候就触发了  验证。
4.在编写注册代码之前我们还需要把论坛中的头像绑定到头像下拉框中,并实现切换下拉菜单就能浏览头像的效果,在页面的Page_Load事件处理方法中添加如下代码:
protected void Page_Load(object sender, EventArgs e)
{
if (!IsPostBack)
{
System.IO.DirectoryInfo di = new System.IO.DirectoryInfo(Server.MapPath
(”images/faces/”));
ddl_UserFace.DataSource = di.GetFiles(”*.jpg”);
ddl_UserFace.DataBind();
ddl_UserFace.Attributes.Add(”onchange”, “document.getElementById(’img_ face’).
src=’images/faces/’+document.getElementById(’” + ddl_UserFace.ClientID +
“‘).value”);
}
}
结合以前学的IO类库、控件绑定和JS的一些知识应该能理解,这段代码读取images/faces/目录下的所有jpg文件,把文件名绑定到下拉框中,并且为下拉框添加了onchange的事件,实现改变下拉框的选项就能立即预览头像。
5.接下来就是双击“注册”按钮,完成注册的代码(别忘记using System.Data.SqlClient;)。
事实上,我们还:
应该判断注册用户名是否发生重名。应该把用户密码不可逆加密进行保存。但为了简单,我们不去考虑上述两点的实现。
protected void btn_Reg_Click(object sender, EventArgs e)
{
string sConnectionString = ConfigurationManager.ConnectionStrings["ForumConnString"].
ToString(); //从配置文件中读取连接字符串
string sUserID = Guid.NewGuid().ToString(); //获取一个GUID作为主键ID
string sUserName = tb_UserName.Text;
using (SqlConnection conn = new SqlConnection(sConnectionString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand(”insert into tbUser (UserID, UserName,
UserPassword,UserEmail,UserGender,UserIsMarried,UserFace) values (@UserID,
@UserName,@UserPassword,@UserEmail,@UserGender,@UserIsMarried,@UserFace)”, conn))
{
cmd.Parameters.AddWithValue(”@UserID”, sUserID);
cmd.Parameters.AddWithValue(”@UserName”, sUserName);
cmd.Parameters.AddWithValue(”@UserPassword”, tb_Password1.Text);
cmd.Parameters.AddWithValue(”@UserEmail”, tb_UserEmail.Text);
cmd.Parameters.AddWithValue(”@UserGender”, rbl_UserGender.SelectedValue);
cmd.Parameters.AddWithValue(”@UserIsMarried”, cb_UserIsMarried.Checked);
cmd.Parameters.AddWithValue(”@UserFace”, ddl_UserFace.SelectedValue);
cmd.ExecuteNonQuery();
}
}
Session["UserID"] = sUserID; // 写入Session
Session["UserName"] = sUserName;
ClientScript.RegisterStartupScript(this.GetType(), “”, “
location.href=’Default.aspx’ ”); // 提示注册成功并转向首页
}
插入新用户的操作读者应该一看就能明白,这里要说一下最后的几行代码。在这么多状态管理机制中哪一种是对于每一个用户独立,且能跨页面保存,又是保存在服务器端以保证数据的安全的呢?找来找去只有Session符合要求,通常我们都会把诸如登录用户等一些和用户相关的信息放到Session中,只要用户不关闭浏览器,Session都是有效的。在这里,我们把用户ID、用户名存放到Session中,其他页面以此就可以判断用户是否已经登录。最后一行代码向客户端输出了一段JS脚本,弹出一个提示框并把页面转向论坛首页。
还记得那个用户登录的用户控件UserLogin.ascx吗?我们希望用户在没有登录的时候显示登录界面,而在用户登录后显示“您好:[用户名]”。因此,先把用户控件修改成:
 Text=”用户名:”<meta:resourcekey=”tb_UsernameResource1″<
 Text=”密码:”<“Password” meta:resourcekey=”tb_PasswordResource1″<
 Text=” 登录 “/<
 Text=” 注册 “/<
其实我们仅仅是使用了一个PlaceHolder控件包围了原先的登录模块,并创建了另外一个PlaceHolder包围了一个Label控件用于显示登录信息,以及一个用于退出登录的按钮。现在在用户控件的Page_Load事件处理方法中就可以通过读取Session判断用户是否登录。
protected void Page_Load(object sender, EventArgs e)
{
if (Session["UserName"] != null)
{
ph_Login.Visible = true;
ph_NotLogin.Visible = false;
lb_HelloMessage.Text = “您好:” + Session["UserName"].ToString();
}
else
{
ph_Login.Visible = false;
ph_NotLogin.Visible = true;
}
}
现在你可以打开Reg.aspx进行注册,注册完成后页面会自动转向首页,页面上方的登录模块会立刻显示“您好:[用户名]”字样。现在,我们仅仅完成了用户注册,用户的登录和退出没有完成。首先,为登录按钮和注册按钮分别添加Click事件处理方法(别忘记using System.Data.SqlClient;)。
protected void btn_Login_Click(object sender, EventArgs e)
{
string sConnectionString = ConfigurationManager.ConnectionStrings ["ForumConnString"].
ToString(); //从配置文件中读取连接字符串
using (SqlConnection conn = new SqlConnection(sConnectionString))
{
conn.Open();
using (SqlCommand cmd = new SqlCommand())
{
cmd.Connection = conn;
cmd.CommandText = “select count(*) from tbUser where UserName=@UserName”;
// 检测用户名是否存在
cmd.Parameters.AddWithValue(”@UserName”, tb_Username.Text); // 添加用户名参数
if (cmd.ExecuteScalar().ToString() == “0″) // 记录数为0说明没有找到指定用户名
Page.ClientScript.RegisterStartupScript(this.GetType(), “”,
“”); // 弹出提示框
else
{
cmd.CommandText = “select UserID from tbUser where UserName=@UserName
and UserPassword=@UserPassword”; // 如果密码正确则返回UserID
cmd.Parameters.AddWithValue(”@UserPassword”, tb_Password.Text);
// 添加密码参数(因为使用的是同一个SqlCommand对象,所以用户名已经添加过了)
if (cmd.ExecuteScalar() == null) // 没有读取到任何信息说明密码不匹配
Page.ClientScript.RegisterStartupScript(this.GetType(), “”,”
alert(’密码不正确’) ”);
else
{
// 为Session赋值
Session["UserID"] = cmd.ExecuteScalar().ToString();
Session["UserName"] = tb_Username.Text;
// 提示登录成功并刷新页面
Page.ClientScript.RegisterStartupScript(this.GetType(), “”, “
alert(’登录成功’);location.href=location.href ”);
}
}
}
}
}
protected void btn_Reg_Click(object sender, EventArgs e)
{
Response.Redirect(”Reg.aspx”); // 转向Reg.aspx
}
在验证通过后,我们同样在Session中保存用户ID和用户名来表示用户已经登录。最后,来完成退出登录按钮的Click事件处理方法。
protected void btn_Logout_Click(object sender, EventArgs e)
{
Session.Abandon();
Page.ClientScript.RegisterStartupScript(this.GetType(), “”, “
销成功’);location.href=location.href ”);
}
通过前面的学习我们知道,可以使用Session.Abandon()方法来销毁Session。之后,向客户端输出JS提示“注销成功”并且刷新页面。
现在,论坛已经具有了注册、登录和注销功能,不过Session在用户关闭浏览器后会失效,用户再次打开论坛页面会发现还是处于未登录状态。感兴趣的读者可以使用Cookie来修改这个程序,使之支持“记住用户”的功能,在这里就不再列出代码了。
12.10 回顾与总结本章介绍了ASP.NET 2.0的各种状态管理机制。由于HTTP的无状态特性,所有状态管理对开发Web应用程序来说都非常重要。几乎每一个网站都会或多或少使用到状态管理,目的无非如下。
利用状态存储一些常用数据提升网站效率。利用状态存储用户信息使得网站能记住用户。利用状态存储在网站的各个页面中传递数据。ASP.NET提供了各种状态管理机制,各有各的特点。只有在充分理解了各种状态管理机制的特性以后,我们才能根据自己的需要选择合适的机制来使用。作为总结,我们列出一个总表对各种重要的状态管理进行比较。
存储位置 类型
限制
大小
限制
适用
对象
使用
范围
过期
策略
安全性
Cookie 客户端Cookie文件夹 字符串 每个4K 单个用户 当前请求
上下文
绝对时
间过期
Session-InProc 客户端Cookie和Web服务器内存 任何
类型
受内存
限制
单个用户 当前请求
上下文
平滑时
间过期
比较高
Session-StateServer 客户端Cookie和
Windows服务所在服务器内存
可序列
化类型
受内存
限制
单个用户 当前请求
上下文
平滑时
间过期
比较高
Session-SqlServer 客户端Cookie和SQL Server服务器硬盘 可序列
化类型
受数据
库限制
单个用户 当前请求
上下文
平滑时
间过期
比较高
Application Web服务器内存 任何
类型
受内存
限制
所有用户 当前请求
上下文
应用程
序结束
则无效
Cache Web服务器内存 任何
类型
受内存
限制
所有用户 当前请求
上下文
平滑时间
过期、绝
对时间过
期、依赖
过期
ViewState 页面隐藏域 可序列
化类型
受表单
提交大
小限制
单个用户 当前页面 页面关
闭则无
比较低
Profile 客户端Cookie和数据库 可序列
化类型
受数据
库限制
单个用户 当前请求
上下文
Cookie
过期时
间或者长
期存在
比较高
Querystring 页面URL 字符串 不超过
2K
单个用户 当前页面 页面关闭
则无效
posted on 2012-07-13 15:15  衣不如新  阅读(244)  评论(0编辑  收藏  举报