为了账号安全,请及时绑定邮箱和手机立即绑定

Session,有没有必要使用它?

标签:
架构

阅读目录

  • 开始

  • Session的来龙去脉

  • Session对并发访问的影响

  • Session的缺点总结

  • 不使用Session的替代方法

  • Asp.net MVC 中的Session

  • 现有的代码怎么办?

今天来说说 Session 。这个东西嘛,我想每个Asp.net开发人员都知道它,尤其是初学Asp.net时,肯定也用过它,因为用它保存会话数据确实非常简单。与前二篇博客不同,这次我不打算细说它的使用,而是打算说说它的缺点,同时我还会举个实际的例子,来看看它到底有什么不好的影响。当然了,光批评是没有意义,事情也得解决,没有会话也不行,所以,本文将也给出一个自认为能替代Session的解决方案。

回到顶部

Session的来龙去脉

当我们新建一个网站时,VS20XX 生成的网站模板代码中,Session就是打开。是的,如果你没有关闭它,Session其实是一直在工作着。您只需要在Page中用一行代码就能判断您的网站是否在使用Session,

Session["key1"] = DateTime.Now;

很简单,就是写一下Session,如果代码能运行,不出现异常,就表示您的网站是支持Session的。我们可以去web.config从全局关闭它,

<sessionState mode="Off"></sessionState>

再运行上面的代码,就能看到黄页了。换句话说:当您访问Session时发生以下异常即表示您的网站(或者当前页面)是不支持Session的。

这里要说明一下:如果您在某个页面中访问Session时,出现以上黄页,也有可能是页面级别关闭了Session 。在每个aspx页的Page指令行,只要我们设置一下EnableSessionState即可,这个属性有3个可选项。我创建了三个页面,分别接受IDE给的默认名称。

// Default.aspx<%@ Page Language="C#" AutoEventWireup="true"  CodeFile="Default.aspx.cs" EnableSessionState="True" Inherits="_Default" %>// Default2.aspx<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Default2.aspx.cs" EnableSessionState="ReadOnly" Inherits="Default2" %>// Default3.aspx<%@ Page Language="C#" AutoEventWireup="true" CodeFile="Default3.aspx.cs" EnableSessionState="False" Inherits="Default3" %>

对于Default.aspx来说,EnableSessionState这个设置可以不用显式指定,因为它就是默认值。页面的这个参数的默认值也可以在web.config中设置,如:<pages enableSessionState="ReadOnly">
以上三个设置就分别设置了三个不同的Session使用方法。下面我们再来看一下,这个设置对于Session来说,是如何起作用的。

如果您的web.config中有如下设置:

<compilation debug="true">

那么,可以在x:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\Temporary ASP.NET Files\websiteName\xxxxxx\xxxxxxxx中找到这么三个aspx页面的【编译前版本】:
说明:Asp.net的编译临时目录也可以在web.config中指定,如:<compilation debug="true" tempDirectory="D:\Temp">

// Default.aspxpublic partial class _Default : System.Web.SessionState.IRequiresSessionState {// Default2.aspxpublic partial class Default2 : System.Web.SessionState.IRequiresSessionState, System.Web.SessionState.IReadOnlySessionState {// Default3.aspxpublic partial class Default3 {

或者您也可以编译整个网站,从生成的程序集去看这些类的定义,也能看到以上结果。

也就是说:Page指令中的设置被编译器转成一些接口【标记】,那么,您或许有点好奇,为什么搞这么几个接口,它们在哪里被使用?下面我们来看看这个问题,当然了,也只能反编译.net framework的代码找线索了。最终发现在Application的PostMapRequestHandler事件中

internal class MapHandlerExecutionStep : HttpApplication.IExecutionStep{    void HttpApplication.IExecutionStep.Execute()    {        HttpContext context = this._application.Context;        HttpRequest request = context.Request;        // .................... 注意下面这个调用        context.Handler = this._application.MapHttpHandler(                context, request.RequestType, request.FilePathObject, request.PhysicalPathInternal, false);        // ....................    }}

接着找HttpContext的Handler属性

public IHttpHandler Handler{    set    {        this._handler = value;        // ...........................        if( this._handler != null ) {            if( this._handler is IRequiresSessionState ) {                this.RequiresSessionState = true;            }            if( this._handler is IReadOnlySessionState ) {                this.ReadOnlySessionState = true;            }            // ...........................        }    }}

至此,应该大致搞清楚了,原来这二个接口也只是一个标记。我们可以看一下它们的定义:

public interface IRequiresSessionState{}public interface IReadOnlySessionState : IRequiresSessionState{}

完全就是个空接口,仅仅只是为了区分使用Session的方式而已。可能您会想HttpContext的这二个属性RequiresSessionState, ReadOnlySessionState又是在哪里被使用的。答案就是在SessionStateModule中。SessionStateModule就是实现Session的HttpModule ,它会检查了所有请求,根据HttpContext的这二个属性分别采用不同的处理方式。大致是如下方法:

bool requiresSessionState = this._rqContext.RequiresSessionState;// 后面会有一些针对requiresSessionState的判断if( !requiresSessionState ) {    // .......................}this._rqReadonly = this._rqContext.ReadOnlySessionState;// 后面会有一些针对this._rqReadonly的判断if( this._rqReadonly ) {    this._rqItem = this._store.GetItem(this._rqContext, this._rqId, out flag2, out span, 			out this._rqLockId, out this._rqActionFlags);}else {    this._rqItem = this._store.GetItemExclusive(this._rqContext, this._rqId, out flag2, out span, 			out this._rqLockId, out this._rqActionFlags);    // ..........................}

这块的代码比较散,为了对这二个参数有个权威的说明,我将直接引用MSDN中的原文。

会话状态由 SessionStateModule 类进行管理,在请求过程中的不同时间,该类调用会话状态存储提供程序在数据存储区中读写会话数据。请求开始时,SessionStateModule 实例通过调用 GetItemExclusive 方法或 GetItem 方法(如果 EnableSessionState 页属性已设置为 ReadOnly)从数据源检索数据。请求结束时,如果修改了会话状态值,则 SessionStateModule 实例调用 SessionStateStoreProviderBase.SetAndReleaseItemExclusive 方法将更新的值写入会话状态存储区。

上面的说法提到了锁定,既然有锁定,就会影响并发。我们再看看MSDN中关于并发的解释。

对 ASP.NET 会话状态的访问专属于每个会话,这意味着如果两个不同的用户同时发送请求,则会同时授予对每个单独会话的访问。但是,如果这两个并发请求是针对同一会话的(通过使用相同的 SessionID 值),则第一个请求将获得对会话信息的独占访问权。第二个请求将只在第一个请求完成之后执行。(如果由于第一个请求超过了锁定超时时间而导致对会话信息的独占锁定被释放,则第二个会话也可获得访问权。)如果将 @ Page 指令中的 EnableSessionState 值设置为 ReadOnly,则对只读会话信息的请求不会导致对会话数据的独占锁定。但是,对会话数据的只读请求可能仍需等到解除由会话数据的读写请求设置的锁定。

ASP.NET 应用程序是多线程的,因此可支持对多个并发请求的响应。多个并发请求可能会试图访问同一会话信息。假设有这样一种情况,框架集中的多个框架全部引用同一应用程序中的 ASP.NET 网页。框架集中每个框架的独立请求可以在 Web 服务器的不同线程上并发执行。如果每个框架的 ASP.NET 页都访问会话状态变量,则可能会有多个线程并发访问会话存储区。为避免会话存储区中的数据冲突和意外的会话状态行为,SessionStateModule 和 SessionStateStoreProviderBase 类提供了一种功能,能在执行 ASP.NET 页期间以独占方式锁定特定会话的会话存储项。请注意,如果 EnableSessionState 属性标记为 ReadOnly,则不会对会话存储项设置锁定。但是,同一应用程序中的其他 ASP.NET 页也许可以写入会话存储区,因此对存储区中只读会话数据的请求可能仍然必须等待锁定数据被释放。

在对 GetItemExclusive 方法的调用中,请求开始时即对会话存储数据设置锁定。请求完成后,在调用 SetAndReleaseItemExclusive 方法期间释放锁定。

如果 SessionStateModule 实例在调用 GetItemExclusive 或 GetItem 方法过程中遇到锁定的会话数据,则该实例每隔半秒重新请求一次该会话数据,直到锁定被释放或 ExecutionTimeout 属性中指定的时间已经过去。如果请求超时,SessionStateModule 将调用 ReleaseItemExclusive 方法来释放会话存储数据,然后立即请求该会话存储数据。

为当前响应调用 SetAndReleaseItemExclusive 方法之前,锁定的会话存储数据可能已经在单独的线程上由对 ReleaseItemExclusive 方法的调用释放。这可能导致 SessionStateModule 实例设置和释放已经由其他会话释放和修改的会话状态存储数据。为避免这种情况,SessionStateModule 为每个请求都提供一个锁定标识符,以便修改锁定的会话存储数据。仅当数据存储区中的锁定标识符与 SessionStateModule 提供的锁定标识符匹配时,会话存储数据才能修改。

在权威文字面前,我再解释就显得是多余的。不过,通过我上面的代码分析及MSDN解释,我们可以明白三点:

1. 它说明了,为什么在Application的一系列事件中,PostMapRequestHandler事件要早于AcquireRequestState事件的原因。因为SessionStateModule要访问HttpContext.RequiresSessionState,但是这个属性又要等到给HttpContext.Handler赋值后才能获取到,而HttpContext.Handler的赋值操作是在PostMapRequestHandler事件中完成的,有意思吧。

2. 如果你没有关闭Session,SessionStateModule就一直在工作中,尤其是全采用默认设置时,会对每个请求执行一系列的调用。

3. 使用Session时,尤其是采用默认设置时,会影响并发访问。

回到顶部

Session对并发访问的影响

如果您觉得前面的文字可能不是太好理解,没关系,我特意做了几个实验页面,请继续往下看。

第一个页面,主要HTML部分:

<div>    <b>This is Default1.aspx</b></div>

第一个页面,后台代码部分:

protected void Page_Load(object sender, EventArgs e){    // 这里故意停5秒。    System.Threading.Thread.Sleep(5000);}

第二个页面,主要HTML部分(无后台代码):

<div>    <b>This is Default2.aspx</b></div>

第三个页面,主要HTML部分(无后台代码):

<div>    <b>This is Default3.aspx</b></div>

现在轮到主框架页面上场了,主要HTML部分

<iframe class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="Default1.aspx" width="150px"></iframe><iframe class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="Default2.aspx" width="150px"></iframe><iframe class="lazyload" src="data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAAAXNSR0IArs4c6QAAAARnQU1BAACxjwv8YQUAAAAJcEhZcwAADsQAAA7EAZUrDhsAAAANSURBVBhXYzh8+PB/AAffA0nNPuCLAAAAAElFTkSuQmCC" data-original="Default3.aspx" width="150px"></iframe><h1>    <asp:Literal ID="labResult" runat="server"></asp:Literal></h1>

主框架页面,后台代码部分:

public partial class _Default : System.Web.UI.Page{    private static int count = 0;    protected void Page_Load(object sender, EventArgs e)    {        // 因为前面的页面都没有使用Session,所以就在这里简单地使用一下了。        Session["Key1"] = System.Threading.Interlocked.Increment(ref count);    }    protected override void OnPreRender(EventArgs e)    {        base.OnPreRender(e);        this.labResult.Text = Session["Key1"].ToString();    }}

以上代码实在太简单,我也不多说了。现在来看一下页面显示效果吧。首先看到的是这个样子:

5秒后,所有子框架的页面才会全部加载完成。

上面的示例代码写得很清楚,只有default1.aspx才会执行5秒,后面2个页面没有任何延迟,应该会直接显示的。但从结果可以看出:第一个页面请求阻塞了后面的所有页面请求!!

其实同样的场景还会发生在Ajax比较密集的网站中,这类网站中,一个页面也有可能发出多个请求,而且是在【上一个请求还没完成前】就发出了下一个请求,此时的请求过程其实与上面的子框架是一样的。有人可能想问:我的网站就没关Session,Ajax的使用也很多,为什么就没有这种感觉呢?其实,前面也说了:这里的并发影响只限于同一个用户的多次请求,而且如果服务器响应比较快时,我们通常也是不能察觉的,但它却实也是会阻塞后面的请求。

我们感觉不到Session的阻塞,是因为阻塞的时间不够长,而我的测试用例故意则让这种现象更明显了。不管你们信不信,反正我是信了。

对于并发问题,我想谈谈我的想法:微软在Session中,使用了锁定的设计,虽然会影响并发,但是,设计本身是安全的、周密的。因为确实有可能存在一个用户的多个请求中会有修改与读取的冲突操作。微软是做平台的,他们不得不考虑这个问题。但现实中,这种冲突的可能性应该是很小的,或者是我们能控制的,在此情况下,会显得这个问题是不可接受的。

回到顶部

Session的缺点总结

任何事情都有二面性,优缺点都是兼有的。在评价一个事物时,我们应该要全面地分析它的优缺点,否则评价也就失去了意义。今天我们还是在批评Session的缺点前,先看看它的优点:只需要一行代码就可以方便的维持用户的会话数据。这其实是个伟大的实现!

但是,现在为什么还是有人会不使用它呢?比如我就不用它,除非做点小演示,否则我肯定不会使用它。为什么?

我个人认为这个伟大的实现,还是有些局限制性,或者说是一些缺点吧。现在我们再来看看Session的缺点:
1. 当mode="InProc"时,也就是默认设置时,容易丢失数据,为什么?因为网站会因为各种原因重启。
2. 当mode="InProc"时,Session保存的东西越多,就越占用服务器内存,对于用户在线人数较多的网站,服务器的内存压力会比较大。
3. 当mode="InProc"时,程序的扩展性会受到影响,原因很简单:服务器的内存不能在多台服务器间共享。
4. 虽然Session可以支持扩展性,也就是设置mode="SQLServer"或者mode="StateServer",但这种方式下,还是有缺点:在每次请求时,也不管你用不用会话数据,都为你准备好,这其实是浪费资源的。
5. 如果你没有关闭Session,SessionStateModule就一直在工作中,尤其是全采用默认设置时,会对每个请求执行一系列的调用。浪费资源。
6. 并发问题,前面有解释,也有示例。
7. 当你使用无 Cookie 会话时,为了安全,Session默认会使用 重新生成已过期的会话标识符 的策略,此时,如果通过使用 HTTP POST 方法发起已使用已过期会话 ID 发起的请求,将丢失发送的所有数据。这是因为 ASP.NET 会执行重定向,以确保浏览器在 URL 中具有新的会话标识符。

不可否认的是,或许有些人认为这些缺点是可以接受的,他们更看中Session的简单、易使用的优点,那么,Session仍然是完美的。

回到顶部

不使用Session的替代方法

对于前面我列出的Session的一些缺点,如果您认为你有些是不能接受的,那么,可以参考一下我提出的替代解决方法。

1. 如果需要在一个页面的前后调用过程中维持一些简单的数据,可以使用<input type="hidden" />元素来保存这些数据。

2. 您希望在整个网站都能共享一些会话数据,就像mode="InProc"那样。此时,我们可以使用Cookie与Cache相结合做法,自行控制会话数据的保存与加载。具体做法也简单:为请求分配置一个Key(有就忽略),然后用这个Key去访问Cache,以完成保存与加载的逻辑。如果要使用的会话数据数量不止一个,可以自定义一个类型或者使用一个诸如Dictionary, HashTable这样的集合来保存它们。很简单吧,基本上这种方式就是与mode="InProc"差不多了。只是没有锁定问题,因此也就没有并发问题。

3. 如果您想实现mode="StateServer"类似的效果,那么可以考虑使用memcached这类技术,或者自己写个简单的服务,在内部使用一个或者多个Dictionary, HashTable来保存数据即可。这样我们可以更精确的控制读写时机。这种方法也需要使用Cookie保存会话ID。

4. 如果您想实现mode="SQLServer"类似的效果,那么可以考虑使用mongodb这类技术,同样我们可以更精确的控制读写时机。这种方法也需要使用Cookie保存会话ID。如果您没用使用过mongodb,可以参考我的博客:MongoDB实战开发 【零基础学习,附完整Asp.net示例】

从前面三种替代方法来看,如果不使用Session,那么Cookie就是必需的。其实Cookie本身就是设计用来维持会话状态的。只是它不适合保存过大的数据而已,因此,用它保存会话ID这样的数据,可以说是很恰当的。事实上,Session就是这样做的。

推荐方法:为了保持网站程序有较好的扩展性,且不需要保存过大的会话数据,那么,直接使用Cookie将是最好的选择。
由于Cookie保存在浏览器,且不安全,所以建议只保存诸如:id, key 之类的简单数据,需要其它的会话数据时再根据这些id, Key去获取。

到这里,我想我可以回答标题中的问题了:Session,其实是没有必要使用的,不用它,也能容易地实现会话数据的保存。

回到顶部

Asp.net MVC 中的Session

我们再来看一下Asp.net MVC中是如何使用Session的。Asp.net平台作为底层的框架,它提供了HttpContext.Session这个成员属性让我们可以方便地使用Session,但是在MVC中,Controller抽象类为也提供了这样一个属性,我们只要访问它就可以了(支持更好的测试性)。

回想一下,前面我们看到SessionStateModule是根据当前HttpHandler来决定是不是启用Session。但是现在Controller和Page是分开的,Controller又是如何使用Session的呢?要回答这个问题就要扯到路由了,简单地说:现在在MVC处理请求的时候,当前HttpHandler是MvcHandler类的实例,它有如下定义:

public class MvcHandler : IHttpAsyncHandler, IHttpHandler, IRequiresSessionState {

因此,在Controller.Session中,它是访问的HttpContext.Session,而MvcHandler实现了IRequiresSessionState接口,所以,访问HttpContext.Session就可以获取到Session 。注意哦,我上面的代码取自MVC 2.0,从类型实现的接口可以看出,Session将一直有效,不能关闭,而且属于影响并发的那种模式。所以,此时你只能从web.config中全局关闭它。
说明,在MVC 3.0 和Asp.net 4.0中,才可以支持Controller订制Session的访问性。

在这种使用方式下,如果您不想继续使用Session,可以使用上面我列出的替代方法。

在MVC中,还有一个地方也在使用Session,那就是Controller.TempData这个成员属性。通常我们可能会这样使用它:

TempData["mydata"] = "aaaaaaaaaa"; // or other objectreturn RedirectToAction("Index");

在这种地方,这些保存到TempData的数据其实也是存放在Session中的。你可以从web.config中关闭Session,你就能看到异常了。对于这种使用方法,你仍然可以前面的替代方法,但是,还有另一种方法也能做为替代Session的方法。我们看一下Controller的一段代码:

protected virtual ITempDataProvider CreateTempDataProvider() {    return new SessionStateTempDataProvider();}

TempData就是通过这种Provider的方式来支持其它的保存途径。而且在MvcFutures中,还有一个CookieTempDataProvider类可供使用。使用也很简单,获取MVC源码,编译项目MvcFutures,然后引用它,重写以上虚方法就可以了:

protected override ITempDataProvider CreateTempDataProvider(){    return new Microsoft.Web.Mvc.CookieTempDataProvider(this.HttpContext);}

注意哦,这里有2个陷阱:MVC 2的MvcFutures的CookieTempDataProvider并不能正常工作。至于我在尝试时,发现它是这样写的(注释部分是我加的):

public static IDictionary<string, object> DeserializeTempData(string base64EncodedSerializedTempData){    byte[] bytes = Convert.FromBase64String(base64EncodedSerializedTempData);    var memStream = new MemoryStream(bytes);    var binFormatter = new BinaryFormatter();    return binFormatter.Deserialize(memStream, null) as TempDataDictionary;    // 这里会导致一直返回null    //return binFormatter.Deserialize(memStream, null) as IDictionary<string, object>;    // 这样写才对嘛。}

就算能运行,这样做会导致生成的Cookie的长度较大,因此容易导致浏览器不支持。最终我重写了以上代码(以及另一个序列化的代码):

public static IDictionary<string, object> DeserializeTempData(string base64EncodedSerializedTempData){    try {        return (new JavaScriptSerializer()).Deserialize<IDictionary<string, object>>(                HttpUtility.UrlDecode(base64EncodedSerializedTempData));    }    catch {        return null;    }}public static string SerializeToBase64EncodedString(IDictionary<string, object> values){    if( values == null || values.Count == 0 )        return null;    return HttpUtility.UrlEncode(        (new JavaScriptSerializer()).Serialize(values));}

上面的方法虽然解决了序列化结果过长的问题,但它也引入了新的问题:由于使用IDictionary<string, object>类型,造成复杂类型在序列化时就丢失了它们的类型信息,因此,在反序列化时,就不能还原正原的类型。也正是因为此原因,这种方法将只适合保存简单基元类型数据。

回到顶部

现有的代码怎么办?

本来,这篇博客到这里就没有了。是啊,批也批过了,解决办法也给了,还有什么好说的,不过,突然想到一个很现实的问题,要是有人问我:Fish,我的代码很多的地方在使用Session,如果按你前面的方法,虽可行,但是要改动的代码比较多,而且需要测试,还要重新部署,这个工作量太大了,有没有更好的办法?

是啊,这个还真是个现实的问题。怎么办呢?

针对这个问题,我也认真的思考过,也回忆过曾经如何使用Session,以及用Session都做过些什么。一般说来,用Session基本上也就是保存一些与用户相关的临时信息,而且不同的页面使用的Session冲突的可能性也是极小的,使用方式以 mode="InProc" 为主。其实也就是Cache,只是方便了与“当前用户”的关联而已。

于是针对这个前提,继续想:现在要克服的最大障碍是并发的锁定问题。至于这个问题嘛,我们可以参考一下前面MSND中的说明,就是因为GetItemExclusive这些方法搞出来的嘛。想到这里,似乎办法也就有了:我也来实现一个使用Cache的Provider,并且在具体实现时,故意不搞锁定,不就行了嘛。

最终,我提供二个Provider,它们都是去掉了锁定相关的操作,试了一下,并发问题不存了。但有个问题需要说明一下,ProcCacheSessionStateStore采用Cache保存Session的内容,与 mode="InProc" 类似,CookieSessionStateStore则采用Cookie保存Session对象,但它有个限制,只适合保存简单基元类型数据(且不包含敏感信息),原因与CookieTempDataProvider一样。所以,请根据您的使用场景来选择合适的Provider

以下是使用方法:很简单,只要在web.config中加一段以下配置就好了:

<sessionState mode="Custom" customProvider="CookieSessionStateStore">    <providers>        <add name="ProcCacheSessionStateStore" type="Fish.SampleCode.ProcCacheSessionStateStore"/>        <add name="CookieSessionStateStore" type="Fish.SampleCode.CookieSessionStateStore"/>    </providers></sessionState>

好了,这次不用改代码了,在部署环境中,也只需要修改了一下配置就完事了。

警告:我提供的这二个Provider只是做了简单的测试,并没经过实际的项目检验,如果您需要使用,请自行测试它的可用性。


点击此处下载本文的全部示例代码

如果,您认为阅读这篇博客让您有些收获,不妨点击一下右下角的【推荐】按钮。
如果,您希望更容易地发现我的新博客,不妨点击一下右下角的【关注 Fish Li】。
因为,我的写作热情也离不开您的肯定支持。

感谢您的阅读,如果您对我的博客所讲述的内容有兴趣,请继续关注我的后续博客,我是Fish Li 。

点击查看更多内容
TA 点赞

若觉得本文不错,就分享一下吧!

评论

作者其他优质文章

正在加载中
  • 推荐
  • 评论
  • 收藏
  • 共同学习,写下你的评论
感谢您的支持,我会继续努力的~
扫码打赏,你说多少就多少
赞赏金额会直接到老师账户
支付方式
打开微信扫一扫,即可进行扫码打赏哦
今天注册有机会得

100积分直接送

付费专栏免费学

大额优惠券免费领

立即参与 放弃机会
意见反馈 帮助中心 APP下载
官方微信

举报

0/150
提交
取消