深入理解Session与Cookie

此文知识来自于:《深入分析Java_Web技术》第十章
现代session与cookie的应用


本章概要:
当我们的一个应用系统有几百台服务器时,如何解决Session在多台服务器之间共享的问题?它们还有一些安全问题,如Cookie被盗、Cookie伪造等问题应如何避免?Session与Cookie的作用都是为了保持访问用户与后端服务器的交互状态。例如,使用Cookie来传递信息时,随着Cookie个数的增多和访问量的增加,它占用的网络带宽也很大,试想假如Cookie占用200个字节,如果一天的PV有几亿,那么它要占用多少带宽?所以有大访问量时希望用Session,但是Session的致命弱点是不容易在多台服务器之间共享,这也限制了Session的使用。

1. 理解Cookie

Cookie的作用通俗地说就是当一个用户通过HTTP访问一个服务器时,这个服务器会将一些Key/Value键值对返回给客户端浏览器,并给这些数据加上一些限制条件,在条件符合时这个用户下次访问这个服务器时,数据又被完整地带回给服务器。

当初W3C设计Cookie时实际考虑的是为了记录用户在一段时间内访问Web应用的行为路径。由于HTTP是一种无状态协议,当用户的一次访问请求结束后,后端服务器就无法知道下一次来访问的还是不是上次访问的用户。例如,在一个很短的时间内,如果与用户相关的数据被频繁访问,可以针对这个数据做缓存,这样可以大大提高数据的访问性能。Cookie的作用正是如此,由于是同一个客户端发出的请求,每次发出的请求都会带有第一次访问时,服务端设置的信息,这样服务端就可以根据Cookie值来划分访问的用户了。

1.1 Cookie属性项

当前Cookie有两个版本:Version0和Version1,它们有两种设置响应头的标识,分别是“Set-Cookie”和“Set-Cookie2”。它们属性项有些不同。

Version0属性项:

属性项 属性项介绍
NAME=VALUE 设置要保存的Key/Value,注意这里的NAME不能和其它属性项的名字一样
Expires 过期时间
Domain 生成该Cookie的域名
Path 该Cookie是在当前哪个路径下生成的
Secure 如果设置了这个属性,那么只会在SSH连接时才会回传该Cookie
Expires 过期时间

在Java Web的Servlet规范并不支持Set-Cookie2响应头,在实际应用中Set-Cookie2的一些属性项却可以设置在Set-Cookie中。博主在查看Cookie源码,发现是支持的:


另外也可以从源码可以得知,一般所说的Cookie键值对,都是值NAME和VALUE属性,其实Cookie还有其他的属性,通过Get/Set方法进行获取和设置。
另外下面是博主在使用Chrome浏览器查看的Cookie:

1.2 Cookie如何工作

当我们用如下方式创建Cookie时:

String getCookie(Cookie[] cookies, String key) {
    if (cookies != null) {
        for (Cookie cookie : cookies) {
            if (cookie.getName().equals(key)) {
                return cookie.getValue();
            }
        }
    }
    return null;
}

@Override
public void doGet(HttpServletRequest req, HttpServletResponse res) {
    Cookie[] cookies = req.getCookies();
    String userName = getCookie(cookies, "userName");
    String userAge = getCookie(cookies, "userAge");
    if (userName == null) {
        res.addCookie(new Cookie("userName", "liwenguang"));
    }
    if (userAge == null) {
        res.addCookie(new Cookie("userAge", "22"));
    }
    res.getHeaders("Set-Cookie");
}

以下几点需要注意:

  • 所创建Cookie的NAME不能和Set-Cookie或者Set-Cookie2的属性项值一样。
  • 所创建Cookie的NAME和VALUE的值不能设置成非ASCII字符,如果要使用中文,可以通过URLEncoder将其编码。
  • 当NAME和VALUE的值出现一些TOKEN字符(如“\”、“,”等)时,构建返回头会将该Cookie的Version自动设置为1。
  • 当在该Cookie的属性项中出现Version为1的属性项时,构建HTTP响应头同样会将Version设置为1。

1.3 使用Cookie的限制

任何语言对Cookie的操作,其实都是让浏览器对Cookie的操作,Cookie的浏览器的特性,而浏览器对Cookie有数量限制(50个/每个域名),总大小限制(4096,Chrome没有这个限制)。

2 理解Session

前面已经介绍了Cookie可以让服务端程序跟踪每个客户端的访问,但是每次客户端的访问都必须传回这些Cookie,如果Cookie很多,则无形地增加了客户端
与服务端的数据传输量,而Session的出现正是为了解决这个问题。

同一个客户端每次和服务端交互时,不需要每次都传回所有的Cookie值,而是只要传回一个ID,这个ID是客户端第一次访问服务端时生成的,而且每个客户端是唯一的。
这样每个客户端就有了一个唯一的ID,客户端只要传回这个ID就行了,这个ID通常是NAME为JSESIONID的一个Cookie。

2.1 Session与Cookie

下面详解讲一下Session是如何基于Cookie来工作的。实际上有以下三种方式可以让Session正常工作。

  • 基于URL Path Parameter,默认支持。
  • 基于Cookie,如果没有修改Context容器的Cookies标识,则默认也是支持的。
  • 基于SSL,默认不支持,只有connector.getAttribute("SSLEnabled")为TRUE时才支持。

在第一种情况,当浏览器不支持Cookie功能时,浏览器会将用户的SessionCookieName重写到用户请求的URL参数中,传递格式如/path/Servlet;name=value;name2=value2?Name3=value3,其中“Servlet;”后面的K-V就是要传递的Path Parameters,服务器会从这个Path Parameters中拿到用户配置的SessionCookieName。关于这个SessionCookieName,如果在web.xml中配置session-config配置项,其cookie-config下的name属性就是这个SessionCookieName的值。如果没有配置sessio-config配置项,默认的SessionCookieName就是大家熟悉的“JSESSIONID”。需要说明的一点是,与Session关联的Cookie与其他Cookie没有什么不同。接着Request根据这个SessionCookieName到Parameters中拿到Session ID并设置到request.setRequestedSessionId中。

请注意,如果客户端也支持Cookie,则Tomcat仍然会解析Cookie中的Session ID,并会覆盖URL中的Session ID。

如果是第三种情况,则会根据javax.servlet.request.ssl_session属性值设置Session ID。

2.2 Session如何工作

有了Session ID,服务端就可以创建HttpSession对象了,第一次触发通过request.getSession()方法。如果当前的Session ID还没有对应的HttpSession对象,那么就创建一个新的,并将这个对象加到org.apache.catalina.Manager的session容器中保存。Manager类将管理所有Session生命周期,Session过期将被回收,服务器关闭,Sessoin将被序列化到磁盘等。只要这个HttpSession对象存在,用户就可以根据Session ID来获取这个对象,也就做到了对状态的保持。


从Request中获取的Session对象保存在org.apache.catalina.Manager类中,它的实现类是org.apache.catalina.session.StandardManager,通过requestedSessionId从StandardManager的Sessions集合取出对应的StandardSession对象。由于一个requestedSessionId对应一个访问的客户端,所以一个客户端也就对应了一个StandardSession对象,这个对象正是保存我们创建的Session值的。下面我们看一下StandardManager这个类是如何管理StandardSession的生命周期的。

StandardManager类负责Servlet容器中所有的StandardSession对象的生命周期管理。当Servlet容器重启或关闭时,StandardManager负责持久化没有过期的StandardSession对象,它会将所有的StandardSession对象持久化到一个以“SESSIONS。ser”为文件名的文件中。到Servlet容器重启时,也就是StandardManager初始化时,它会重新读取这个文件,解析出所有Session对象,重新保存在StandardManager的sessions集合中。

当Servlet容器关闭时StandardManager类会调用unload方法将session集合中的StandardSession对象写到“SESSIONS.ser”文件中,然后在启动时再重新恢复,注意要持久化保存Servlet容器中的Session对象,必须调用Servlet容器的stop的start命令,而不能直接结束(kill)Servlet容器的进程。
因为直接结束进程,Servlet容器没有机会调用unload方法来持久化这些Session对象。

另外,在StandardManager的sessions集合中的StandardSession对象并不是永远保存的,否则Servlet容器的内存将容易被消耗尽,所以必须给每个Session对象定义一个有效时间,超过这个时间则Session对象将被清除。在Tomcat中这个有效时间是60s(maxInactiveInterval属性通知),超过60s该Session将会过期。检查每个Session是否失效是Tomcat的一个后台线程中完成的。

除了后台进程检查Session是否失效外,当调用request.getSession()时也会检查该Session是否过期。值得注意的是,request.getSession()方法调用的StandardSession永远都会存在,即使与这个客户端关联的Session对象已经过期。如果过期,则又会重新创建一个全新的StandardSession对象,但是以前设置的Session值将会丢失。如果你取到了Session对象,但是通过session.getAttribute取不到前面设置的Session值,请不要奇怪,因为很可能已经失效了,请检查以下<Manager pathname="" maxInactiveInterval="60" />中maxInactiveInterval配置项的值,如果不想让Session过期则可以设置为-1。但是你要仔细评估一下,网站的访问量和设置的Session的大小,防止将你的Servlet容器内存撑爆。如果不想自动创建Session对象,也可以通过request.getSession(bolean create)方法来判断与该客户端关联的Session对象是否存在。

3 Cookie安全问题

Cookie通过把所有要保存的数据通过HTTP的头部从客户端传递到服务端,又从服务端传回到客户端,所有的数据都存储在客户端的浏览器里,所以这些Cookie数据可以被访问到,通过浏览器插件可以对Cookie进行修改等。

相对而言Session的安全性要高很多,因为Session是将数据保存在服务端,只是通过Cookie传递一个SessionID而已,所以Session更适合存储用户隐私和重要的数据。

4 分布式Session框架

4.1 Cookie存在哪些问题

  • 客户端Cookie存储限制
  • Cookie管理的混乱,每个应用系统都自己管理每个应用使用的Cookie会导致混乱,由于通常应用系统都在同一个域名下,Cookie又有上面一条提到的限制,所以统一管理很容易出现Cookie超出限制的情况。
  • 不安全,虽然通过设置HttpOnly属性防止一些私密Cookie被客户端访问,但是仍然不能保证Cookie无法被篡改。为了保证Cookie的私密性通常会对Cookie进行加密,但是维护这个加密Key也是一件麻烦的事情,无法保证定期更新加密Key也是会带来安全性问题的一个重要因素。

4.2 Cookie+Session可以解决哪些问题

下面是分布式Session框架可以解决的问题:

  • Session配置的统一管理。
  • Cookie使用的监控和统一规范管理。
  • Session存储的多元化。
  • Session配置的动态修改。
  • Session加密key的定期修改。
  • 充分的容灾机制,保持框架的使用稳定性。
  • Session各种存储的监控和报警支持。
  • Session框架的可扩展性,兼容更多的Session机制如wapSession。
  • 跨域名Session与Cookie如何共享的问题。现在同一个网站可能存在多个域名,如何将Session和Cookie在不同的域名之间共享是一个具有挑战性的问题。

4.3 总体实现思路

为了达成上面所说的几个目标,我们需要一个服务订阅服务器,在应用启动时可以从这个订阅服务器订阅这个应用需要的可写Session项和可写Cookie项,这些配置的Session和Cookie可以限制这个应用能够使用哪些Session和Cookie,甚至可以通知Session和Cookie可读或可写。这样可以精确地控制哪些应用可以操作哪些Session和Cookie,可以有效控制Session的安全性和Cookie的数量。


如Session的配置项可以为如下形式:

<sessions>
    <session>
        <key>sessionID</key>
        <cookiekey>sessionID</cookiekey>
        <lifeCycle>9000</lifeCycle>
        <base64>true</base64>
    </session>
    .......
</sessions>

Cookie的配置可以为如下形式:

<cookies>
    <cookie>
        <key>cookie</key>    
        <lifeCycle>10000</lifeCycle>
        <type>1</type>
        <path>/wp</path>
        <domain>liwenguang.website</domain>
        <decrypt>false</decrypt>
        <httpOnly>false</httpOnly>
    </cookie>
    ......
</cookies>

统一通过订阅服务器推送配置可以有效地几种管理资源,所以可以省去每个应用都来配置Cookie,简化Cookie的管理。如果应用要使用一个新增的Cookie,则可以通过一个统一的平台来申请,申请通过才将这个配置项增加到订阅服务器。如果是一个所有应用都要使用的全局Cookie,那么只需要将这个Cookie通过订阅服务器统一推送过去就行了,省去了要在每个应用中手动增加Cookie的配置。

关于这个订阅服务器现在有很多开源的配置服务器,如ZooKeeper集群管理服务器,可以统一管理所有服务器的配置文件。

由于应用是一个集群,所以不可能将创建的Session都保存在每台应用服务器的内存中,因为如果每台服务器有几十万的访问用户,那么服务器的内存可能不够用,即使内存够用,这些Session也无法同步到这个应用的所有服务器中。所以要共享这些Session必须将它们存储在一个分布式缓存中,可以随时写入和读取,而且性能要很好才能满足要求。当前能满足这个要求的系统有很多,如MemCache或者淘宝的开源分布式缓存系统Tair都是很好的选择。

解决了配置和存储问题,下面看一下如何存取Session和Cookie。

既然是一个分布式Session的处理框架,必然会重新实现HttpSession的操作接口,使得应用操作Session的对象都是我们实现的InnerHttpSession对象,这个操作必须在进入应用之前完成,所以可以配置一个filter拦截用户的请求。

先看一下如何封装HttpSession对象和拦截请求,如下时序图:


我们可以在应用的web.xml中配置一个SessionFilter,用于在请求到达MVC框架之前封装HttpServletRequest和HttpServletResponse对象,并创建我们自己的InnerHttpSession对象,把它设置到request和response对象中。这样应用系统通过request.getHttpSession()返回的就是我们创建的InnerHttpSession对象了,我们可以拦截response的addCookies设置的Cookie。

在时序图中,应用创建的所有Session对象都会保存在InnerHttpSession对象中,当用户的这次访问请求完成时,Session框架将会把这个InnerHttpSession的所有内容再更新到分布式缓存中,以便于这个用户通过其它服务器再次访问这个应用系统。另外,为了保证一些应用对Session稳定性的特殊要求,可以将一些非常关键的Session再存储到Cookie中,如当分布式缓存存在问题时,可以将部分Session存储到Cookie中,这样即使分布式缓存出现问题也不会影响关键业务的正常运行。

4.4 增加Session跨域实现

还有一个非常重要的问题就是如何处理跨域名来共享Cookie的问题。我们知道Cookie是有域名限制的,也就是在一个域名下的Cookie不能被另一个域名访问,所以如果在一个域名下已经登录成功,如何访问到另外一个域名的应用且保证登录状态仍然有效,对这个问题大型网站应该经常会遇到。如何解决这个问题呢?
下面介绍一种处理方式,流程图如下:


访问域名A时服务器A获得了session,用户访问域名B时,如果发现服务器B没有session,则302重定向跳转到中转服务器C(C你可以理解成专门取Session的域),服务器C获得了session后,则再进行302重定向到A服务器,写入session,从而完成了session跨域。(如今,大部分都是实现的单点登录SSO,来解决子系统间的跨域问题,让子系统共享顶级域名的Session、Cookie等)。

5 Cookie压缩

Cookie在HTTP的头部,所以通常的gzip和deflate针对HTTP Body的压缩不能压缩Cookie,如果Cookie的量非常大,则可以考虑将Cookie也做压缩,压缩方式是将Cookie的多个k/v对看成普通的文本,做文本压缩。压缩算法同样可以使用gzip和deflate算法,但是需要注意的一点是,根据Cookie的规范,在Cookie中不能包含控制字符,仅能包含ASCII码为34~126的可见字符。所以要将压缩后的结果再进行转码,可以进行Base32或者Base64编码。

// 使用DeflaterOutputStream压缩后再用BASE64编码
Cookie c = getCookieObject("");
HttpServletResponse res = getResponse();
ByteArrayOutputStream bos  = new ByteArrayOutputStream();
DeflaterOutputStream dos = new DeflaterOutputStream(bos);
try {
    dos.write(c.getValue().getBytes());
    dos.close();
    System.out.println("before compress length:" + c.getValue().length());
    String compress = new sun.misc.BASE64Encoder().encode(bos.toByteArray());
    res.addCookie(new Cookie("compress", compress));
    System.out.println("after compress length:" + compress.getBytes().length);
} catch (IOException e) {
    e.printStackTrace();
}
// 使用BASE64解码后再用InflaterInputStream解压
ByteArrayOutputStream out = new ByteArrayOutputStream();
try {
    byte[] compress = new sun.misc.BASE64Decoder().decodeBuffer(new String(c.getValue().getBytes()));
    ByteArrayInputStream bis = new ByteArrayInputStream(compress);
    InflaterInputStream inflater = new InflaterInputStream(bis);
    byte[] b = new byte[1024];
    int count;
    while ((count = inflater.read(b)) >= 0) {
        out.write(b, 0, count);
    }
    inflater.close();
    System.out.println(out.toByteArray());
} catch (IOException e) {
    e.printStackTrace();
}

6 表单重复提交问题

要防止表单重复提交,就要标识用户的每一次访问请求,使得每一次访问对服务端来说都是唯一确定的。为了标识用户的每次访问请求,可以在用户请求一个表单域时增加一个隐藏表单项,这个表单项每次都是唯一的token,如:


当用户第一次请求表单页面时生成唯一的token,并存储到用户Session中,当用户第二次请求表单页面时再生成唯一的token,覆盖Session,这样就能保证每次都只能通过请求表单页面来提交表单。

7 多终端Session统一

当前大部分网站都有了无线端,对无线端的Cookie如何处理也是很多程序员必须考虑的问题。
在无线端发展初期,后端的服务系统未必和PC的服务系统是统一的,这样就涉及在一端调用多个系统时如何做到服务端Session共享的问题了。有两个明显的例子:
一个是在无线端可能会通过手机访问无线服务端系统,如果它们两个的登录系统没有统一的话,将会非常麻烦,可能会出现二次登录的情况;
另一个是在手机上登录以后再在PC上同样访问服务端数据,Session能否共享就决定了客户端是否要再次登录。

针对这两种情况,目前都有理由的解决方案。

  • 多端共享Session
    多端共享Session必须要做的工作是不管是无线端还是PC端,后端的服务系统必须统一会话架构,也就是两边的登录系统必须要基于一致的会员数据结构、Cookie与Session的统一。也就是不管是PC端登录还是无线端登录,后面对应的数据结构和存储要统一,写到客户端的Cookie也必须一样,这是前提条件。

那么如何做到这一点?就是要按照我们在前面所说的实现分布式的Session框架。


上面服务端统一Session后,在同一个终端上不管是访问哪个服务端都能做到登录状态统一。例如不管是Native还是内嵌Webview,都可以拿统一的Session ID去服务端验证登录状态。

  • 多终端登录
    目前很多网站都会出现无线端和PC端多端登录的情况,例如可以通过扫码登录等。这些是如何实现的呢?

    这里手机端在扫码之前必须是已经登录的状态,因为这样才能获取到到底是谁将要登录的信息,同时扫码的二维码也带有一个特定的标识,标识是这个客户端
    通过手机端登陆了。当手机端扫码成功后,会在服务端设置这个二维码对应的标识为已经登录成功,这时PC客户端会通过将“心跳”请求发送到服务端,来验证是否已经登录成功,这样就称为一种便捷的登录方式。(博主以微信扫码登录为例,每次微信PC端的二维码都是带有一个唯一的标识,当你用登录的手机微信扫码之后,手机将你已登录的微信信息获取,并发送给微信服务端,微信服务端将二维码的唯一标识以及手机微信的账号信息绑定,发送给微信PC端,其中,微信PC二维码客户端类似Watch了某个ZK的节点进行监听,这样避免客户端每隔一段时间发送心跳)。

8 总结

Cookie和Session都是为了保持用户访问的连续状态,之所以要保持这种状态,一方面是为了方便业务实现,另一方面就是简化服务端的程序设计,提高访问性能,但是也带来了安全问题、应用的分布式部署带来的Session的同步问题以及跨域名Session的同步问题(通过单点登录避免)。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,080评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,422评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,630评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,554评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,662评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,856评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,014评论 3 408
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,752评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,212评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,541评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,687评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,347评论 4 331
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,973评论 3 315
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,777评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,006评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,406评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,576评论 2 349

推荐阅读更多精彩内容

  • 从三月份找实习到现在,面了一些公司,挂了不少,但最终还是拿到小米、百度、阿里、京东、新浪、CVTE、乐视家的研发岗...
    时芥蓝阅读 42,214评论 11 349
  • Spring Cloud为开发人员提供了快速构建分布式系统中一些常见模式的工具(例如配置管理,服务发现,断路器,智...
    卡卡罗2017阅读 134,633评论 18 139
  • 转自 :http://blog.csdn.net/taoff/articles/1921009.aspx 一、术语...
    stone_yao阅读 6,169评论 0 31
  • 这个城市,每到过年,花市就很热闹,传统一点的,一定会买桔子,菊花,象征着来年大吉大利。花市里有各种各样的花,...
    小小熊宝阅读 314评论 1 0
  • 我们兄弟三个在小主人家住了下来,小主人很喜欢我们,一有空就趴在笼子边看我们,他现在敢抓我们了,经常抓着我的腰,把我...
    莲蕊添香阅读 206评论 0 2