网站高性能架构设计——web前端与池化
从公众号转载,关注微信公众号掌握更多技术动态
---------------------------------------------------------------
一、高性能浏览器访问
1.减少HTTP请求
HTTP协议是无状态的应用层协议,也就是说每次HTTP请求都需要建立通信链路、进行数据传输,而在服务器端,每个HTTP都需要启动独立的线程进行处理。这些通信和服务的开销很昂贵,因此减少HTTP请求的数量可以有效提高访问性能。
(1)合并CSS、合并JS
将浏览器一次访问需要的JS、CSS合并成一个文件、这样浏览器只需要一次请求。然而这种方式不利于模块化代码的编写,会让代码的组织变得杂乱无章,同时可能导致一个页面加载时加载了多于自己所需要的样式或者脚本,因此该种方式需要进行权衡利弊后再进行使用。
(2)合并图片
可以将多张图片合并成一张,如果每张图片都有不同的超链接,可通过CSS偏移坐标响应鼠标点击操作。
<div>
<span id="image1" class="nav"></span>
<span id="image2" class="nav"></span>
<span id="image3" class="nav"></span>
<span id="image4" class="nav"></span>
<span id="image5" class="nav"></span>
</div>
.nav {
width: 50px;
height: 50px;
display: inline-block;
border: 1px solid #000;
background-image: url('E:/1.png');
}
#image1 {
background-position: 0 0;
}
#image2 {
background-position: -95px 0;
}
#image3 {
background-position: -185px 0;
}
#image4 {
background-position: -275px 0;
}
#image5 {
background-position: -366px -3px;
}
2.使用浏览器缓存
对于一个网站来说,CSS、JS、图标这些静态资源文件更新的频率比较低,而这些文件又几乎是每次HTTP请求都需要的,如果将这些文件缓存在浏览器中,可以极好地改善性能。通过服务端设置HTTP头中Cache-Control和Expires的属性,可设定浏览器缓存,缓存时间可以是数天甚至是几个月。
在某些时候,静态资源文件变化需要及时引用到客户端浏览器,这种情况,可通过改变文件名实现,使用浏览器缓存策略的时候网站在更新静态资源时,应采用逐量更新的方法,比如要更新100个图片文件,不应该同时更新,而是应该根据情况选择五个五个更新以此来避免大量缓存失效,集中更新缓存,造成服务器负载骤、网络堵塞的情况。
3.启用压缩
在服务端对文件进行压缩,在浏览器端对文件进行解压缩,可有效减少通信传输的数据量。文本文件的压缩效率可达80%以上,一次HTML、CSS、JS文件启用GZIP压缩可达到良好的效果。但是压缩对服务器和浏览器会产生一定的压力,在通信带宽良好,而服务器资源不足的情况下不推荐使用。
4.CSS放在页面头部,JS放在页面底部
浏览器会在下载全部CSS后对页面进行渲染,因此最好是将CSS放在页面的最上面,让浏览器尽快下载CSS.和JS则相反,浏览器在加载JS后立即执行,有可能阻塞整个页面,造成页面显示缓慢,因此JS最好放在页面最下面。
5.减少cookie传输
一方面cookie包含在每次请求和响应中,太大的cookie会严重影响数据传输,因此哪些数据需要写入Cookie需要慎重考虑,尽量减少Cookie的数据量。另一方面对于大多数静态资源来说,发送cookie没有意义,可以考虑静态资源使用独立的域名进行访问,避免请求静态资源的时候也包含Cookie。
二、CDN加速
CDN的本质仍然是一个缓存,而且是将数据缓存在离用户最近的地方,使用户以最快速度获取数据。由于CDN部署在网络运营商的机房,这些运营商又是终端运营商的网络服务提供商,因此用户请求路由第一跳就到达了CDN服务器,当CDN中存在浏览器请求的资源时,从CDN直接返回给浏览器,最短路径返回响应(即北京用户访问北京的数据,杭州用户访问杭州 的数据),加快用户访问速度,减少数据中心复杂压力。CDN 就是将静态的资源分发到位于多个地理位置机房中的服务器上,因此它 能很好地解决数据就近访问的问题,也就加快了静态资源的访问速度。
CDN缓存的一般是一些静态资源,图片、CSS、JS、静态网页等,这些文件访问率高,放在CDN可以极大改善网页的打开速度。
1.如何让用户的请求到达 CDN 节点
可能会觉得这很简单,只需要告诉用户 CDN 节点的 IP 地址,然后请求这个 IP 地址上面部署的 CDN 服务就可以了 啊。但是这样会有一个问题:就是使用的是第三方厂商的 CDN 服务,CDN 厂商会给一个 CDN 的节点 IP,比如说这个 IP 地址是“111.202.34.130”,那么电商系统中的图片的地址很可能是这样的:“http://111.202.34.130/1.jpg”, 这个地址是 要存储在数据库中的。
那么如果这个节点 IP 发生了变更怎么办?或者如果更改了 CDN 厂商怎么办?是不是要修改所有的商品的 url 域名呢?这就是一个比较大的工作量了。所以要做的事情是 将第三方厂商提供的 IP 隐藏起来,给到用户的最好是一个本公司域名的子域名。
那么如何做到这一点呢?这就需要依靠 DNS 来解决域名映射的问题了。DNS(Domain Name System,域名系统)实际上就是一个存储域名和 IP 地址对应关系 的分布式数据库。而域名解析的结果一般有两种,一种叫做“A 记录”,返回的是域名对应 的 IP 地址;另一种是“CNAME 记录”,返回的是另一个域名,也就是说当前域名的解析 要跳转到另一个域名的解析上,实际上 www.baidu.com 域名的解析结果就是一个 CNAME 记录,域名的解析被跳转到 www.a.shifen.com 上了,正是利用 CNAME 记 录来解决域名映射问题的,具体是怎么解决的呢?
比如一级域名叫做 example.com,那么可以图片服务的域名定义 为“img.example.com”,然后将这个域名的解析结果的 CNAME 配置到 CDN 提供的域名上,比如 uclound 可能会提供一个域名是“80f21f91.cdn.ucloud.com.cn”这个域名。这样你的电商系统使用的图片地址可以是“http://img.example.com/1.jpg”。用户在请求这个地址时,DNS 服务器会将域名解析到 80f21f91.cdn.ucloud.com.cn 域名 上,然后再将这个域名解析为 CDN 的节点 IP,这样就可以得到 CDN 上面的资源数据了。
2.如何找到离用户最近的 CDN 节点
GSLB(Global Server Load Balance,全局负载均衡), 它的含义是对于部署在不同地域 的服务器之间做负载均衡,下面可能管理了很多的本地负载均衡组件。它有两方面的作用:GSLB 可以通过多种策略,来保证返回的 CDN 节点和用户尽量保证在同一地缘区域,比如 说可以将用户的 IP 地址按照地理位置划分为若干的区域,然后将 CDN 节点对应到一个区 域上,然后根据用户所在区域来返回合适的节点;也可以通过发送数据包测量 RTT 的方式 来决定返回哪一个节点。有了 GSLB 之后,节点的解析过程变成了下图中的样子:一方面,它是一种负载均衡服务器,负载均衡,顾名思义嘛,指的是让流量平均分配使 得下面管理的服务器的负载更平均;另一方面,它还需要保证流量流经的服务器与流量源头在地缘上是比较接近的。
当然,是否能够从 CDN 节点上获取到资源还取决于 CDN 的同步延时。一般会通过 CDN 厂商的接口将静态的资源写入到某一个 CDN 节点上,再由 CDN 内部的同步机制将 资源分散同步到每个 CDN 节点,即使 CDN 内部网络经过了优化,这个同步的过程是有延 时的,一旦无法从选定的 CDN 节点上获取到数据,就不得不从源站获取数据,而 用户网络到源站的网络可能会跨越多个主干网,这样不仅性能上有损耗,也会消耗源站的带 宽,带来更高的研发成本。所以在使用 CDN 的时候需要关注 CDN 的命中率和源站 的带宽情况。
三、动静分离
动静分离是指,静态页面与动态页面解耦分离,用不同系统承载对应流量的架构设计方法。
1.动静分离简介
“动静分离”就是把用户请求的数据(如 HTML 页面)划分为“动态数据”和“静态数据”。简单来说,“动态数据”和“静态数据”的主要区别就是看页面中输出的数据是否和 URL、浏览者、时间、地域相关,以及是否含有 Cookie 等私密数据。比如说:
-
很多媒体类的网站,某一篇文章的内容不管是你访问还是我访问,它都是一样的。所以 它就是一个典型的静态数据,但是它是个动态页面。
-
如果现在访问淘宝的首页,每个人看到的页面可能都是不一样的,淘宝首页中包含 了很多根据访问者特征推荐的信息,而这些个性化的数据就可以理解为动态数据了。
静态数据,不能仅仅理解为传统意义上完全存在磁盘上的 HTML 页面,它也可能是经过 Java 系统产生的页面,但是它输出的页面本身不包含上面所 说的那些因素。也就是所谓“动态”还是“静态”,并不是说数据本身是否动静,而是数据 中是否含有和访问者相关的个性化数据。还有一点要注意,就是页面中“不包含”,指的是“页面的 HTML 源码中不含有”,这一 点务必要清楚。分离了动静数据,就可以对分离出来的静态数据做缓存,有了缓存之后,静态数据 的“访问效率”自然就提高了。
2.动静分离的实现
静态页面与动态页面解耦分离,用不同系统承载对应流量的架构
-
静态页面访问路径短,访问速度快,几毫秒
-
动态页面访问路径长,访问速度相对较慢(数据库的访问,网络传输,业务逻辑计算),几十毫秒甚至几百毫秒,对架构扩展性的要求更高
-
静态页面与动态页面以不同域名区分
3.静态页面缓存
(1)静态数据缓存
把静态数据缓存到离用户最近的地方。静态数据就是那些相对不会变化的数 据,因此我们可以把它们缓存起来。缓存到哪里呢?常见的就三种,用户浏览器里、CDN 上或者在服务端的 Cache 中。
(2)页面静态化技术
将原本需要动态生成的站点提前生成好,使用静态页面加速技术来访
-
用户端访问/detail/12348888x.shtml 详情页;
-
web-server层从RESTful接口中,解析出帖子id是12348888;
-
service通过DAO层拼装SQL,访问数据库;
-
最终获取数据,拼装html返回浏览器;
而“页面静态化”是指,将帖子ID为12348888的帖子12348888x.shtml提前生成好,由静态页面相关加速技术来加速,这样的话,将极大提升访问速度,减少访问时间,提高用户体验。
并不是所有的业务场景都适合页面静态化,滥用该技术,反而会降低系统性能。页面静态化,适用于:总数据量不大,生成静态页面数量不多的业务。
-
快狗打车的城市页只有几百个,就可以用这个优化,只需提前生成几百个城市的“静态化页面”即可;
-
一些二手车业务,只有几万量二手车库存,也可以提前生成这几万量二手车的静态页面;
-
像58同城这样的信息模式业务,有几十亿的帖子量,就不太适合于静态化(碎片文件多,反而访问慢);
(3)谁来缓存静态数据
不同语言写的 Cache 软件处理缓存数据的效率也各 不相同。以 Java 为例,因为 Java 系统本身也有其弱点(比如不擅长处理大量连接请求, 每个连接消耗的内存较多,Servlet 容器解析 HTTP 协议较慢),所以你可以不在 Java 层 做缓存,而是直接在 Web 服务器层上做,这样你就可以屏蔽 Java 语言层面的一些弱点;而相比起来,Web 服务器(如 Nginx、Apache、Varnish)也更擅长处理大并发的静态文 件请求。
4.动静分离的改造实践
(1)分离动态内容和静态内容
以典型的商品详情系统为例来详细介绍。这里,你可以先打开京东或者淘宝的商品 详情页,看看这个页面里都有哪些动静数据。从以下 5 个方面来分离出动态内容。
-
URL 唯一化。商品详情系统天然地就可以做到 URL 唯一化,比如每个商品都由 ID 来标 识,那么 http://item.xxx.com/item.htm?id=xxxx 就可以作为唯一的 URL 标识。为啥 要 URL 唯一呢?前面说了我们是要缓存整个 HTTP 连接,那么以什么作为 Key 呢?就 以 URL 作为缓存的 Key,例如以 id=xxx 这个格式进行区分。
-
分离浏览者相关的因素。浏览者相关的因素包括是否已登录,以及登录身份等,这些相 关因素我们可以单独拆分出来,通过动态请求来获取。
-
分离时间因素。服务端输出的时间也通过动态请求获取。
-
异步化地域因素。详情页面上与地域相关的因素做成异步方式获取,当然你也可以通过 动态请求方式获取,只是这里通过异步获取更合适。
-
去掉 Cookie。服务端输出的页面包含的 Cookie 可以通过代码软件来删除,如 Web 服 务器 Varnish 可以通过 unset req.http.cookie 命令去掉 Cookie。注意,这里说的去掉 Cookie 并不是用户端收到的页面就不含 Cookie 了,而是说,在缓存的静态数据中不含 有 Cookie。
(2)数据组织
分离出动态内容之后,如何组织这些内容页就变得非常关键了。因为这其中很多动态内容都会被页面中的其他模块用到,如判断该用户是否已登录、用户 ID 是 否匹配等,所以这个时候应该将这些信息 JSON 化(用 JSON 格式组织这些数据), 以方便前端获取。动态内容的处理通常有两种方案:ESI(Edge Side Includes)方案和 CSI(Client Side Include)方案。
-
ESI 方案(或者 SSI):即在 Web 代理服务器上做动态内容请求,并将请求插入到静态 页面中,当用户拿到页面时已经是一个完整的页面了。这种方式对服务端性能有些影 响,但是用户体验较好。
-
CSI 方案。即单独发起一个异步 JavaScript 请求,以向服务端获取动态内容。这种方式 服务端性能更佳,但是用户端页面可能会延时,体验稍差。
(3)动静分离的几种架构方案
-
实体机单机部署;
-
统一 Cache 层;
-
上 CDN。
方案 1:实体机单机部署
这种方案是将虚拟机改为实体机,以增大 Cache 的容量,并且采用了一致性 Hash 分组的 方式来提升命中率。这里将 Cache 分成若干组,是希望能达到命中率和访问热点的平衡。Hash 分组越少,缓存的命中率肯定就会越高,但短板是也会使单个商品集中在一个分组 中,容易导致 Cache 被击穿,所以应该适当增加多个相同的分组,来平衡访问热点和命中率的问题。这里我给出了实体机单机部署方案的结构图,如下:
实体机单机部署有以下几个优点:
-
没有网络瓶颈,而且能使用大内存;
-
既能提升命中率,又能减少 Gzip 压缩;
-
减少 Cache 失效压力,因为采用定时失效方式,例如只缓存 3 秒钟,过期即自动失效。
这个方案中,虽然把通常只需要虚拟机或者容器运行的 Java 应用换成实体机,优势很明显,它会增加单机的内存容量,但一定程度上也造成了 CPU 的浪费,因为单个的 Java 进程很难用完整个实体机的 CPU。另外就是,一个实体机上部署了 Java 应用又作为 Cache 来使用,这造成了运维上的高复 杂度,所以这是一个折中的方案。如果没有更多的系统有类似需求,那么这样 做也比较合适,如果有多个业务系统都有静态化改造的需求,那还是建议把 Cache 层 单独抽出来公用比较合理。
方案 2:统一 Cache 层
将单机的 Cache 统一分离出来,形成一个单独的 Cache 集群。统一 Cache 层是个更理想的可推广方案,该方案的结构图如下:
将 Cache 层单独拿出来统一管理可以减少运维成本,同时也方便接入其他静态化系统。此 ,它还有一些优点。
-
单独一个 Cache 层,可以减少多个应用接入时使用 Cache 的成本。这样接入的应用只 要维护自己的 Java 系统就好,不需要单独维护 Cache,而只关心如何使用即可。
-
统一 Cache 的方案更易于维护,如后面加强监控、配置的自动化,只需要一套解决方案 就行,统一起来维护升级也比较方便。
-
可以共享内存,最大化利用内存,不同系统之间的内存可以动态切换,从而能够有效应 对各种攻击。
这种方案虽然维护上更方便了,但是也带来了其他一些问题,比如缓存更加集中,导致:
-
Cache 层内部交换网络成为瓶颈;
-
缓存服务器的网卡也会是瓶颈;
-
机器少风险较大,挂掉一台就会影响很大一部分缓存数据。要解决上面这些问题,可以再对 Cache 做 Hash 分组,即一组 Cache 缓存的内容相同,这 样能够避免热点数据过度集中导致新的瓶颈产生。
方案 3:上 CDN
在将整个系统做动静分离后,自然会想到更进一步的方案,就是将 Cache 进一步前移到 CDN 上,因为 CDN 离用户最近,效果会更好。但是要想这么做,有以下几个问题需要解决。
-
失效问题。前面我们也有提到过缓存时效的问题,不知道你有没有理解,我再来解释一 下。谈到静态数据时,我说过一个关键词叫“相对不变”,它的言外之意是“可能会变 化”。比如一篇文章,现在不变,但如果你发现个错别字,是不是就会变化了?如果你 的缓存时效很长,那用户端在很长一段时间内看到的都是错的。所以,这个方案中也 是,我们需要保证 CDN 可以在秒级时间内,让分布在全国各地的 Cache 同时失效,这 对 CDN 的失效系统要求很高。
-
命中率问题。Cache 最重要的一个衡量指标就是“高命中率”,不然 Cache 的存在就失 去了意义。同样,如果将数据全部放到全国的 CDN 上,必然导致 Cache 分散,而 Cache 分散又会导致访问请求命中同一个 Cache 的可能性降低,那么命中率就成为一个 问题。
-
发布更新问题。如果一个业务系统每周都有日常业务需要发布,那么发布系统必须足够 简洁高效,而且你还要考虑有问题时快速回滚和排查问题的简便性。
从前面的分析来看,将商品详情系统放到全国的所有 CDN 节点上是不太现实的,因为存在失效问题、命中率问题以及系统的发布更新问题。那么是否可以选择若干个节点来尝试实施 呢?答案是“可以”,但是这样的节点需要满足几个条件:
-
靠近访问量比较集中的地区;
-
离主站相对较远;
-
节点到主站间的网络比较好,而且稳定;
-
节点容量比较大,不会占用其他 CDN 太多的资源。
-
节点不要太多。
基于上面几个因素,选择 CDN 的二级 Cache 比较合适,因为二级 Cache 数量偏少,容量 也更大,让用户的请求先回源的 CDN 的二级 Cache 中,如果没命中再回源站获取数据, 部署方式如下图所示:
使用 CDN 的二级 Cache 作为缓存,可以达到和当前服务端静态化 Cache 类似的命中率, 因为节点数不多,Cache 不是很分散,访问量也比较集中,这样也就解决了命中率问题, 同时能够给用户最好的访问体验,是当前比较理想的一种 CDN 化方案。除此之外,CDN 化部署方案还有以下几个特点:
-
把整个页面缓存在用户浏览器中;
-
如果强制刷新整个页面,也会请求 CDN;
-
实际有效请求,只是用户对“刷新抢宝”按钮的点击。
这样就把 90% 的静态数据缓存在了用户端或者 CDN 上,当真正秒杀时,用户只需要点击 特殊的“刷新抢宝”按钮,而不需要刷新整个页面。这样一来,系统只是向服务端请求很少 的有效数据,而不需要重复请求大量的静态数据。秒杀的动态数据和普通详情页面的动态数据相比更少,性能也提升了 3 倍以上。
存储在浏览器或 CDN 上区别很大,因为在 CDN 上,可以做主动失效,而在用户的浏览器里就更不可控,如果用户不主动刷新的 话,你很难主动地把消息推送给用户的浏览器。另外,在什么地方把静态数据和动态数据合并并渲染出一个完整的页面也很关键,假如在用 户的浏览器里合并,那么服务端可以减少渲染整个页面的 CPU 消耗。如果在服务端合并的 话,就要考虑缓存的数据是否进行 Gzip 压缩了:如果缓存 Gzip 压缩后的静态数据可以减 少缓存的数据量,但是进行页面合并渲染时就要先解压,然后再压缩完整的页面数据输出给 用户;如果缓存未压缩的静态数据,这样不用解压静态数据,但是会增加缓存容量。
四、池化技术
开发过程中会用到很多的连接池,像是数据库连接池、HTTP 连接池、Redis 连接池等等。而连接池的管理是连接池设计的核心
1.连接池
(1)连接池管理的关键点
数据库连接池有两个最重要的配置:最小连接数和最大连接数,它们控制着从连接池中获取连接的流程:
-
如果当前连接数小于最小连接数,则创建新的连接处理数据库请求;
-
如果连接池中有空闲连接则复用空闲连接;
-
如果空闲池中没有连接并且当前连接数小于最大连接数,则创建新的连接处理请求;
-
如果当前连接数已经大于等于最大连接数,则按照配置中设定的时间(C3P0 的连接池配 置是 checkoutTimeout)等待旧的连接可用;
-
如果等待超过了这个设定时间则向用户抛出错误。
在这里,需要注意池子中连接的维护问题,有的连接虽然还存在,但有的时候会有故障:
-
数据库的域名对应的 IP 发生了变更,池子的连接还是使用旧的 IP,当旧的 IP 下的数据 库服务关闭后,再使用这个连接查询就会发生错误;
-
MySQL 有个参数是“wait_timeout”,控制着当数据库连接闲置多长时间后,数据库会 主动的关闭这条连接。这个机制对于数据库使用方是无感知的,所以当使用这个被关闭 的连接时就会发生错误。 有以下解决方案
-
启动一个线程来定期检测连接池中的连接是否可用,比如使用连接发送“select 1”的命 令给数据库看是否会抛出异常,如果抛出异常则将这个连接从连接池中移除,并且尝试关闭。目前 C3P0 连接池可以采用这种方式来检测连接是否可用。
-
在获取到连接之后,先校验连接是否可用,如果可用才会执行 SQL 语句。比如 DBCP 连 接池的 testOnBorrow 配置项,就是控制是否开启这个验证。这种方式在获取连接时会引 入多余的开销,在线上系统中还是尽量不要开启,在测试服务上可以使用。
-
(2)httpclient连接池设置
HttpConnectionManager httpConnectionManager = new MultiThreadedHttpConnectionManager();
HttpConnectionManagerParams params = httpConnectionManager.getParams();
params.setConnectionTimeout(5000);
params.setSoTimeout(20000);
params.setDefaultMaxConnectionsPerHost(32);//每个host路由的默认最大连接
params.setMaxTotalConnections(256);//qps*建立连接时间*预留时间(一般是1.7)
(3)为什么不用IO多路复用
DB 访问一般采用连接池这种现象是生态造成的。历史上的 BIO + 连接池的做法经过多年的发展,已经解决了主要的问题。在 Java 的大环境下,这个方案是非常靠谱的,成熟的。而基于 IO 多路复用的方式尽管在性能上可能有优势,但是其对整个程序的代码结构要求过多,过于复杂。当然,如果有特定的需要,希望使用 IO 多路复用管理 DB 连接,是完全可行的。
(4)数据库连接为什么费资源
MySQL 的通信协议是基于 TCP 传输协议的,所以需要经过三次握手和四次挥手
2.用线程池预先创建线程
(1)线程池简介
JDK 1.5 中引入的 ThreadPoolExecutor 就是一种线程池的实现,它有两个重要 的参数:coreThreadCount 和 maxThreadCount
-
如果线程池中的线程数少于 coreThreadCount 时,处理新的任务时会创建新的线程;如果线程数大于 coreThreadCount 则把任务丢到一个队列里面,由当前空闲的线程执 行;
-
当队列中的任务堆积满了的时候,则继续创建线程,直到达到 maxThreadCount;当线程数达到 maxTheadCount 时还有新的任务提交,那么我们就不得不将它们丢弃 了。
(2)线程池设置
最佳线程数目 = ((线程等待时间+线程CPU时间)/线程CPU时间 )* CPU数目
比如平均每个线程CPU运行时间为0.5s,而线程等待时间(非CPU运行时间,比如IO)为1.5s,CPU核心数为8,那么根据上面这个公式估算得到:((0.5+1.5)/0.5)*8=32。这个公式进一步转化为:
最佳线程数目 = (线程等待时间与线程CPU时间之比 + 1)* CPU数目
线程等待时间所占比例越高,需要越多线程。线程CPU时间所占比例越高,需要越少线程。
3.什么时候考虑使用池化
当遇到下面的场景,就可以考虑使用池化来增加系统性能:
-
对象的创建或者销毁,需要耗费较多的系统资源;
-
对象的创建或者销毁,耗时长,需要繁杂的操作和较长时间的等待;
-
对象创建后,通过一些状态重置,可被反复使用。
将对象池化之后,只是开启了第一步优化。要想达到最优性能,就不得不调整池的一些关键参数,合理的池大小加上合理的超时时间,就可以让池发挥更大的价值。和缓存的命中率类似,对池的监控也是非常重要的。
原文地址:https://blog.csdn.net/qq_39403646/article/details/135688614
免责声明:本站文章内容转载自网络资源,如本站内容侵犯了原著者的合法权益,可联系本站删除。更多内容请关注自学内容网(zxcms.com)!