浏览器缓存

若北

前言

缓存如果不能用以提升性能,那么它就毫无用处。
以HTTP缓存为例,如果缓存未过期那么就减少了网络请求,如果缓存通过验证那么就减少了传输资源大小。而关于过期与验证机制的讲解将在下文中展开。

注:本文将提供详细参考资料,如果你对其中细节感兴趣对某个细节进行更为深入的了解。



浏览器缓存概述

浏览器缓存可以从多个维度进行抽象分类。在广义上来讲无论是memory cache、service worker、push cache、http cache都属于浏览器缓存的概念,而大部分时候我们提到浏览器缓存的概念往往是指http cache。其实对于浏览器而言还有一种回退缓存(page cache),

以下我们来关注几种浏览器可能会发生缓存的场景:

  • 资源预加载: 如preloader ,preload、prefetch。preloader与preload不同是资源预加载期,例如在标记化时,可能需要的css资源就已经被预先加载到memory cache中了。而资源预加载技术,通过link实现。可以将项目中可能用到的数据先请求过来以备页面使用。数据存放于内存缓存( memory cache)。参考:https://calendar.perfplanet.com/2013/big-bad-preloader/
  • 服务端推送: 这里是指http2的服务端推送,而非客户端轮询。是一种服务器根据某种规则推送客户端将可能用到的资源来减少请求时间的技术。数据存放于push cache。推送缓存中的数据仅可以使用一次,之后将可能根据协议头存在于http缓存中。参考:https://jakearchibald.com/2017/h2-push-tougher-than-i-thought/
  • service worker: Service workers 本质上充当 客户端与服务端之间的代理服务器。这个 API 创建了有效的离线体验,它会拦截网络请求并根据网络是否可用采取来适当的动作、更新来自服务器的的资源。它还提供入口以推送通知和访问后台同步 API。server woker的缓存不同于http 缓存,由server worker自身接管,存储在server worker 参考:https://w3c.github.io/ServiceWorker/#cache-objects
  • 重复的网络资源请求: 常见的网络资源请求,可以根据协议头将资源存储在硬存中,以备下一次使用(http cache),相对于内存缓存,可以进行持久化的存储,而不会局限于单次会话。参考:https://www.rfc-editor.org/rfc/inline-errata/rfc7234.html
  • 页面回退:想象有这样一种场景,你点进了一个博客,顺着博客的链接你进了另一篇文章,当你回退的时候是否会觉得上一个页面似乎很快就会退了而非重新进行了一遍加载。这就是浏览器为了浏览器性能实现的页面回退机制(Page Cache)。不过此种机制往往不存在于页面内资源寻找的过程,是一种浏览器自身不受开发者控制的实现机制。
    参考:https://calendar.perfplanet.com/2013/big-bad-preloader/

以上缓存的读取顺序为: (Memory Cache/Preload Cache) -> Service Worker -> (Disk Cache/HTTP cache) -> Push Cache

而本文主要以Http Cache的描述为主,关于Service worker以及Server Push如果感兴趣可以通过参考链接进行过了解。


HTTP缓存概述

缓存的目标是通过重用先前的响应消息以满足当前请求,来显着提高性能。

让我们来看一个小例子以便于理解:
这天浏览器请求一个叫做海绵宝宝.jpg的资源,服务器给了浏览器一张图片。当浏览器再一次请求服务器海绵宝宝.jpg时,
服务器说:大哥,未来30天图都不会变,你就不能存起来下次别来管我要了吗?我太累了。并在响应里写到,这个图30天都不变。
于是浏览器在这30天里遇到这张图的请求都会使用缓存的图片以响应。
第31天时,浏览器又遇到了海绵宝宝.jpg的请求。于是他问服务器:海绵宝宝.jpg变了吗
服务器答道:没变
又过了一段时间,遇到这个请求时浏览器又去问服务器
服务器说:变了。并给了浏览器一张图片。
浏览器这次就用新的图片响应了请求。

记住本文的主角:浏览器和海绵宝宝.jpg,我们将在后文多处看到他们。(是的,服务器在本文只是配角)
前情提要:在后面我们会讲述:

  • 服务器如何告知图片资源海绵宝宝.jpg的有效期
  • 浏览器如何计算图片是否过期(要知道这图片是服务器转交给浏览器的)
  • 服务器如何根据信息得知浏览器的资源是不是有效的(毕竟服务器不止和一个浏览器对话,无法记忆只能计算)
  • 如果某次,服务器没有告知海绵宝宝.jpg这张图片资源的过期信息,浏览器还会存储资源吗
  • 浏览器的缓存中可能有很多张海绵宝宝.jpg资源吗,如果有会怎么选择呢
  • 浏览器会如何缓存海绵宝宝.jpg(要知道浏览器要处理很多请求,除了海绵宝宝.jpg,可能还有派大星.jpg,他们需要被区分)

简单的来说当我们请求一个请求一个本地存在响应缓存的资源时,浏览器并不会立即发起网络请求。而是对缓存的新鲜度(freshness)进行一个判定,如果该响应是可以使用的,那么就会直接使用缓存资源以减少延迟和网络开销)。

如果缓存资源已经陈旧了,那么就会对缓存资源进行验证。如果验证通过,那么浏览器仍然可以复用资源,以减少网络传输的资源大小。如果没有通过,则源服务器应当在验证请求中返回资源,而不是仅仅告诉浏览器该缓存不可使用。

强缓存与协商缓存:现在的许多资料中都将未过期可直接使用的缓存称为强制缓存。过期了需要验证的缓存称为协商缓存。但是实际上RFC文档中并未给出这样的定义。也就是说这两个概念属于理解性的概念而非规范性的概念

为了简单理解可以先参考下面这张图。但是这里隐去很多细节,随着后文对内容的不断扩充,我们会完善这张图。
在这里插入图片描述

以上简述,描述了网络资源请求使用缓存的一个大致过程。以下将详细描述过期验证机制。


过期机制

还记得上面海绵宝宝图片的例子吗,我们现在需要来解决第一个问题,即服务器如何告知图片资源海绵宝宝.jpg的有效期。为了解决这个问题,则需要一种规范来明确定义如何说明进行资源缓存机制。这种规范必须是双方都可以理解的。在HTTP1.1中,可以使用Cache-Control的缓存指令,以实现缓存机制。

在使用浏览器决定对一个内容进行缓存之前,他将会判定内容是否为可以缓存的。

  • 如果缓存指令被设置为no-store,则不可以使用缓存
  • 如果缓存指令被设置为private,则不可以使用作为共享缓存。即代理服务器不可以对资源进行缓存。
  • 除非响应中明确包含缓存字段,否则不应该缓存Authorization首部字段的请求
  • 一般来说,响应如果既没有验证机制也没有过期机制,那么通常不缓存,不过并不会禁止这样的缓存行为

当资源缓存之后,则在重用时需要判定资源是否过期。max-age被用以设置缓存存储的最大周期,超过这个时间缓存被认为过期(单位秒)。
对于共享缓存来说(比如各个代理),s-maxage将覆盖max-age或者Expires头,私有缓存会忽略它。

因此服务器如果想要告知资是30天过期时间,则需要设置:
Cache-Control: max-age=2592000

现在服务器成功的设置了过期时间,我们来到第二个问题,浏览器如何计算资源过期了?
其实只需要保证资源的可缓存时间大于资源的存在时间,那么缓存就没有过期。反之,则缓存则过期了。以下我们将讨论可缓存时间(freshness LifeTime)与存在周期(Age)的具体算法。

freshness LifeTime的算法:

  • 如果缓存是共享的,如存在s-maxage则使用
  • 如果存在max-age响应指令,则使用其值
  • 如果存在Expires首部字段,则用它的值减去Date首部字段的值
  • 当响应中没有明确指出过期时间。 将可能可能使用启发式freshness计算

还记得,我们之前讨论的问题:如果没有约定缓存相关的内容,那么还会缓存吗?
答案是:不一定。一般来说如果既没有过期说明,也没有明确进行协商验证。那么不缓存。但不禁止缓存。可由浏览器自由发挥。一般这种自由发挥被称之为启发式缓存

关于启发式缓存的算法,通常采用Last-Modified与Date时间差的1/10来作为freshness LifeTime。关于启发式缓存我们有两点需要注意。

  • 启发式缓存是一个非规范性的行为,实现上会随着浏览器不同存在一些差异,也可能没有。规范上不禁止
  • 对于那些响应头中明确指出该如何缓存的资源,明确禁止使用启发式缓存

Age算法:
Age首部字段被用于描述一个缓存接收到响应消息的估算时长(Age)。Age 字段的值是指消息被源服务器创建或者验证之后以来缓存的秒数估算值。
重要的是,Age值是响应沿源服务器的路径驻留在每个缓存中的时间的总和,并需要加上在网络路径中的传输时间

以下数据被用于计算age

  • age_value: "age_value"以适合算数运算的形式表示Age首部字段。如果不可用,则为0。
  • date_value: “ date_value”以适合于算术运算的形式表示Date标头字段的值。
  • now: now表示时钟的当前值。应该使用ntp或一些类似的协议,使其时间同步UTC时间
  • request_time: 发起请求使得存储响应被触发的时间
  • response_time: 收到响应时主机时钟的当前值

响应的age可以以两种完全独立的方式计算

  • apparent_age:如果本地时钟与原始服务器的时钟是协调同步的,response_time减去date_value。 否则,结果将被替换为零
  • corrected_age_value:如果沿响应路径的所有缓存实现HTTP / 1.1,缓存必须相对于启动请求的时间来解释此值,而非收到响应的时间

这里简单说下,为什么http1.1需要使用request时间进行校正。因为http1.1的存储最大周期时间是相对于请求的时间的。

apparent_age = max(0, response_time - date_value);  
response_delay = response_time - request_time;  
corrected_age_value = age_value + response_delay

合并为

corrected_initial_age = max(apparent_age, corrected_age_value)

如果缓存对Age首部字段的值置信(例如,没有HTTP / 1.0 hops存在于Via首部字段中),则在这种情况下,corrected_age_value可以用作corrected_initial_age

存储响应时间可以通添加存储响应最后一次被源服务器验证(以秒为单位)与corrected_initial_age的和值来计算

resident_time = now - response_time;  
current_age = corrected_initial_age + resident_time

看到这,可能会令人头秃。简单的总结一下:
Age 消息头里包含对象在缓存代理中存贮的时长,以秒为单位。这里描述了Age首部字段的算法。
而缓存存在周期的算法则是:上一次收到服务器答复距离现在的时间的差值和在代理服务器中存贮的时间之和。即缓存在浏览器存在的时长+缓存在代理服务器路径上存在的时长。

关于过期机制的三个首部字段分别为:

Cache-Cotrol:Cache-Control是缓存控制的重要字段,如果要系统的了解它的各项指令,那么最好的方式是读RFC文档,或者MDN文档。本文不会详解cache-control的每个指令,而会去一些容易混淆的点击进行概述。

  • 缓存指令是从多个维度描述缓存的。例如Cache-Control:public, max-age=31536000包含了资源的可缓存性以及过期特性。
  • no-cache不是禁用缓存,no-store才是。如果显式的使用了no-store,则浏览器不能使用启发式缓存。
  • 如果希望缓存每次都使用验证机制,则可以使用no-cache,或者把max-age设置为0(如果是Expires,则可以把它设置为一个显然过期了的时间)。

Expires:该字段提供了一个日期,在该日期之后的资源被认为是过期的。关于Expires需要注意的是:

  • 如果存在max-age,则该值被忽略。
  • 如果共享缓存存在s-maxage,则该值被忽略
  • 采用极大值时,将可能导致问题(例如,由于对时间值使用32位整数而导致时钟溢出)
  • 如果存在多个Expires字段,则被视为无效

Pragma:Pragma 是一个在 HTTP/1.0 中规定的通用首部,这个首部的效果依赖于不同的实现,所以在“请求-响应”链中可能会有不同的效果。它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器,那时候 HTTP/1.1 协议中的 Cache-Control 还没有出来。

  • 当请求中没有 Cache-Control 头字段时,缓存必须将无缓存请求 pragma-directive 视为具有与“ Cache-Control: no-cache”存在时相同的效果
  • 在发送无缓存请求时,客户机应该同时包含pragma和-control指令
  • no-cache这个指令不是规范性的,因此本方案不能可靠替代Cache-Control

根据以上的过期机制,如果缓存被判定为未过期的,则可以在不与源服务器连接的情况下直接使用缓存响应消息。否则则应当使用验证机制。以下将展开讲述验证机制。


验证机制

上节中讲到了过期机制的判定。当一个资源过期,或者初始时就被设置为强制验证等原因导致缓存无法提供响应时,它可以使用条件请求机制在转发请求到源服务器以选择一个有效响应。这个过程被称为验证。关于条件请求机制如果你感兴趣可以参阅条件请求分布式创作及版本控制

还记得上文海绵宝宝的例子吗?下来我们将讨论第三个问题服务器如何验证缓存资源。值得一提的是在这个部分服务器化身主角了。

如果需要服务器能够快速验证本地资源相对于缓存资源的变更,我们需要有一个标示帮助服务器进行快速比对。如果每次有效更新这个值都会变更,反之则不会变更,那么服务器就能快速判断本地资源相对于缓存资源是否有变更了。我们将可以帮助我们验证的方式为验证器

在正式介绍验证器之前我们不妨想想什么样的标示可以用于判断资源变更比对。
如果文件内容变更了,因此内容散列也会变更,反正内容散列则不会变更。因此我们可以使用内容散列作为验证器,记录内容散列的字段是ETag。
而另一种比较简单粗暴的方式则是判定文件的最后一次修改时间。如果文件的最后一次修改时间变更了,我们认为文件变更了。反之,则认为没有变更。记录文件最后一次变更时间的字段是Last-Modified。

强验证器与弱验证器: 我们将验证器分为两种:强验证器与弱验证器。弱验证器是易于生成,但对验证来熟存在许多限制甚至缺陷。强验证器是比较的理想选择,但可能非常困难(并且有时是不可能的)以高效地生成。Last-Modified是显式弱验证器除非能证明是强选择器。而ETag默认为强验证器,但我们可以显示的将其指为弱验证器。

当然了相比于内容散列,使用最后一次修改时间会有一些缺陷,所以通常作为候补方案来使用。下面我们将详细介绍这两种验证器:

Last-Modified:其中包含源头服务器认定的资源做出修改的日期及时间。 它通常被用作一个验证器来判断接收到的或者存储的资源是否彼此一致。由于精确度比 ETag 要低,所以这是一个备用机制。
这里我们给出一个示例:

Last-Modified: Tue, 15 Nov 1994 12:45:26 GMT

对应条件请求机制:请求可以在请求首部If-Modified-Since中携带上需要验证的响应用于响应验证。服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回,状态码为200 。如果请求的资源从那时起未经修改,那么返回一个不带有消息主体的304响应。

下面我们做一个总结:

  • Last-Modified/If-Modified-Since用于资源验证。如果资源未变更,响应中不会发返回消息主体。
  • 其验证优先级将低于下面要讲到的ETag。因此验证时,如果存在If-None-Match,则If-Modified-Since会被忽略
  • Last-Modified值也可以被If-Unmodified-SinceIf-Range字段携带以表示条件选择。在这种情况下,只有当资源在指定的时间之后没有进行过修改的情况下,服务器才会返回请求的资源。如果所请求的资源在指定的时间之后发生了修改,那么会返回412错误。需要注意这是条件请求机制而非验证机制。此处提及,是避免与If-Modified-Since。因为看到有些文章中觉得这两个字段都用于缓存校验,这种说法是不正确的。

ETag:ETag响应头是资源的特定版本的标识符。其用法如下:

  • ETag实体标签是不透明的验证器,用以区分相同的多个表示资源,而不管资源状态是否随时间变化。
  • 可以通过添加W/将Etag指为弱验证器。例如以下表示中,不带w/的默认使用强验证器,而w/则显示标明使用弱验证器

    ETag: W/"<etag_value>"
    ETag: "<etag_value>"
  • ETag/If-None-Match被用于进行缓存校验。ETag 属性之间的比较采用的是弱比较算法,即两个文件除了每个比特都相同外,内容一致也可以认为是相同的。例如,如果两个页面仅仅在页脚的生成时间有所不同,就可以认为二者是相同的。
  • If-Match:表示这是一个条件请求。如果资源匹配才返回,不匹配返回416.需要注意这是条件请求机制而非验证机制。这里提出只是区分上文的If-None-Match。避免混淆。这种机制一般用于解决空中碰撞问题而非缓存验证
空中碰撞: 想象有这样一种场景。你正在编辑一个文档,文档现在的版本是v1.0。因此你目前的变更时基于v1.0的。但等你提交的时候,由于小明比你先提交,所以服务器的版本已经变成小明提交的v1.1。如果你成功提交,则小明编辑的内容就会消失。这种情况称为空中碰撞。为了检测到这种情况,浏览器会提交If-Match或者If-Unmodified-Since进行条件请求,如果条件符合,则可以成功提交,否则返回412前提条件失败。如果对此感兴趣,可参阅:https://www.w3.org/1999/04/Editing/

整体梳理

为了方便理解,放了一张图来简述上文介绍的缓存过程:
在这里插入图片描述

还记得海绵宝宝.jpg那里我们提出的问题吗?通过上述章节的介绍我们可以来试试回答了。当然你也可以不往下翻而是回去看看那些问题,并帮助浏览器解决问题。

  1. 服务器如何告知图片资源海绵宝宝.jpg的有效期?
  2. 浏览器如何计算图片是否过期(要知道这图片是服务器转交给浏览器的)
  3. 服务器如何根据信息得知浏览器的资源是不是有效的(毕竟服务器不止和一个浏览器对话,无法记忆只能计算)
  4. 如果某次,服务器没有告知海绵宝宝.jpg这张图片资源的过期信息,浏览器还会存储资源吗
  5. 浏览器的缓存中可能有很多张海绵宝宝.jpg资源吗,如果有会怎么选择呢
  6. 浏览器会如何缓存海绵宝宝.jpg(要知道浏览器要处理很多请求,除了海绵宝宝.jpg,可能还有派大星.jpg,他们需要被区分)

下面我们来揭晓答案:
1、通过过期机制。就缓存时长而言,通常是max-age指令与Expires首部字段
详细内容可以参阅过期机制章节。
2、通过比对freshness lifetiime与age来判定。
3、通过验证机制。详细内容可以参阅验证机制章节。
4、这是一个不确定的答案,或许我们要看浏览器本身的意愿。通常不会,不过浏览器自身可以采用启发式过期周期计算。
5、这道题在上文中并没有提到,所以我们似乎还不能做出解答
6、同样,这也是我们目前了解到的内容无法解决的问题。

那么我们需要继续深入一些细节,以帮助浏览器解决所有的问题。

  • 缓存如何在浏览器内进行存储
    如果要标示缓存资源那么最直接的方式就是以url以及请求方法作为主键进行存储。实际上RFC的规范也确实如此。但是鉴于,实际上请求方法往往被限制为get,因此可以只使用url作为主键。关于浏览器具体实现可以参阅:https://www.chromium.org/developers/design-documents/network-stack/disk-cache
  • 是否可能同一资源对应多条缓存
    如果请求目标受内容协商影响,则其缓存记录可能包含多个响应存储内容,每个存储响应由原始请求选择标题字段的值作为辅助密钥来进行区分。用Vary首部字段来实现。当缓存收到了一个可以被带有Vary首部字段的存储,除非可以满足Vary字段中所有选择的首部字段,否则不应当使用该响应。
    而如果有多条缓存都可以满足条件,缓存将需要选择其中的一个进行使用。如果存在一个选择首部字段拥有一种已知机制可以进行择优(例如,Accept中的qvalues值,以及相似的请求首部字段),那么该机制就可能用作选择更优的响应。如果没有这样的机制,将会通过Date首部字段根据最近日期选择一个最近期的响应。

到了这里,我们的旅程就结束了。期间,我们帮助浏览器完成了他关于海绵宝宝.jpg的缓存使命。相信这将是一次难忘的旅程。:)

在后文中将附上一些容易出现的误解和在这个过程中参阅的资料。如果对于这趟旅程的细节你还想了解更多,不妨继续阅读下去!

易混淆内容

在阅读RFC文档及浏览器相关文档后发现目前许多关于缓存的文章都不够全面,甚至有误,这里先整理几个常见的误区。

强制缓存与协商缓存:
从便于理解的角度来讲没有问题,但这不是规范中的概念。实际上IETF中关于HTTP的Cache规范主要从过期机制与验证机制来描述缓存。可参阅RFC7234全文

强缓存与弱缓存概念:
缓存概念并不区分强弱。是缓存验证机制中的验证器分为强验证器与弱验证器。
可参阅RFC7232第2.1节

有了ETag就不需要Last-Modified:
事实上,这两种都应该存在。因为你不能保证路径上都是HTTP1.1协议。对于不能理解ETag的协议来说,缓存将失效。而假如都有,那对于可以理解ETag的则会忽略last-Modified,因此有益无害。Cache-Control与Expires同理。可参阅RFC7232第2.4节

memory cache和disk cache是http缓存的两个位置
仔细看network就会发现size那一栏有时会出现disk cache有时候会出现memory cache。所以http缓存会根据一定规则决定存进内存还是硬存?
并不是这样,memory cache和http cache是并列的缓存类型,没有包含关系。http cache作为持久化存储一定会进入disk的,所以disk cache和http cache是一种存储方式。
要证明memory cache不是http cache的一部分是很简单的。因为开发者可以在开发中工具的network里禁用缓存。首先我们直接加载一次请求
在这里插入图片描述

可以看到资源有的从memory cache加载,有的从disk cache加载。现在打开禁用缓存。
在这里插入图片描述
当显示的禁用缓存后,从disk加载的已经直接请求了。memory cache的依然从memory cache去读取。可见资源的可缓存性不影响memory cache的行为。
在前面的讲述中我们已经知道memory cache优先级大于disk cache。设若一个资源是不可长期缓存的,例如设置了no-store,但是并不会影响其内存是否缓存的行为。反之如果memory cache的资源如果被设定为可存储他最终一定也是会进入硬存持久化存储的。
我们考虑一个内容有什么用很难,我们反向思考一下没有memory cache会发生什么。我们加载了一张图用做头像框。整个页面有10个头像要加载。如果这张图被服务器标不可以缓存,浏览器真的不缓存他就要把同一张图加载10遍。这合理吗?这不合理,所以memory cache不属于http缓存的一种形式,不受协议影响,是一种短效快捷存储。
mozilla提供了关于内存缓存关闭的选项。其中对默认缓存内容进行了描述。其默认是开启的。可参见mozilla的memory cache配置。不同浏览器实现可能存在差异。


附录

关于缓存需要了解的首部字段

字段名称参考文档字段类型字段描述
Agehttps://developer.mozilla.org...响应首部Age 消息头里包含对象在缓存代理中存贮的时长,以秒为单位。.
Pragmahttps://developer.mozilla.org...通用首部它用来向后兼容只支持 HTTP/1.0 协议的缓存服务器
Datehttps://developer.mozilla.org...通用首部包含了报文创建的日期和时间。
Varyhttps://developer.mozilla.org...响应首部它被服务器用来表明在 内容协商算法中选择一个资源代表的时候应该使用哪些头部信息
Last-Modifiedhttps://developer.mozilla.org...响应首部包含源头服务器认定的资源做出修改的日期及时间。
If-Modified-Sincehttps://developer.mozilla.org...请求首部服务器只在所请求的资源在给定的日期时间之后对内容进行过修改的情况下才会将资源返回。
If-Unmodified-Sincehttps://developer.mozilla.org...请求首部如果所请求的资源在指定的时间之后发生了修改,那么会返回 412 (Precondition Failed) 错误。
ETaghttps://developer.mozilla.org...请求首部ETagHTTP响应头是资源的特定版本的标识符。
If-Matchhttps://developer.mozilla.org...请求首部服务器仅在请求的资源满足此首部列出的 ETag值时才会返回资源
If-None-Matchhttps://developer.mozilla.org...请求首部当且仅当服务器上没有任何资源的 ETag 属性值与这个首部中列出的相匹配的时候,服务器端会才返回所请求的资源
If-Rangehttps://developer.mozilla.org...请求首部If-Range字段用来使得Range头字段在一定条件下起作用:当字段值中的条件得到满足时,Range 头字段才会起作用,同时服务器回复206 部分内容状态码
Expireshttps://developer.mozilla.org...实体首部Expires 响应头包含日期/时间, 即在此时候之后,响应过期。
cache-controlhttps://developer.mozilla.org...通用首部用于在http请求和响应中,通过指定指令来实现缓存机制

参考文档

阅读 742

必备前端基础
深入前端基础
1 篇内容引用

怕什么真理无穷,进一步有进一步的欢喜

1 声望
0 粉丝
0 条评论

怕什么真理无穷,进一步有进一步的欢喜

1 声望
0 粉丝
文章目录
宣传栏