vlambda博客
学习文章列表

架构设计:远程调用服务架构设计及zookeeper技术详解

Hadoop是一个技术生态圈,zookeeper是hadoop生态圈里一个非常重要的技术。
当我研究学习hadoop的相关技术时候,有两块知识曾经让我十分的困惑,一个是hbase,一个就是zookeeper。
hbase的困惑源自于它在颠覆了我对数据库建模的理解,而zookeeper的困惑却是我无法理解它到底是干嘛的。

  

远程调用服务的架构设计总述

首先我们要再深入理解下为什么应用软件服务里需要一个远程调用服务,远程调用服务解决了软件设计中的什么问题,它的架构设计又有什么理论根据了?

3.0版本的网站架构带来了新的网站架构总图,如下所示:

架构设计:远程调用服务架构设计及zookeeper技术详解

有了远程调用服务,我们可以做到业务级别的集群。

例如:一个制造企业,一般都会有采购业务,生产业务、销售业务以及财务业务。按照传统的思路我们都会给每个业务独立开发一个系统,如果引用了远程调用服务,我们可以将这些业务都做成独立的服务,这些服务组成业务集群,而这些服务都是用统一的远程调用服务作为操作的入口。

而服务端这边,完全可以摆脱传统的客户端和服务端耦合的开发,增强了整个服务端的专业性和稳定性,这样更易于服务端的扩展性和可维护性。

如果服务端之间也需要相互调用也可以通过远程调用服务实现,由于远程调用服务的统一性,这样就避免了服务调用之间报文和调用方式的不统一,规范了整个开发的流程。

如果远程调用服务还有负载均衡功能,整个服务集群就变成了一个私有的云,所以说远程调用服务是云计算的重要组成部分,这个说法一点都不为过。

远程调用服务的理论依据是什么,这个问题的表述可能有点问题,其实我要讲的是远程调用服务的技术原型就是SOA(Service-Oriented Architecture)。

在云计算出现前,SOA曾一度是IT的技术热点,虽然之后很多人说中国的SOA做的一点不好,就和早年的DHTML一样,诟病远多于赞赏,写本文时候我在京东里搜索了下SOA,从书籍的出版日期和书籍评价数就可以看出SOA已经有点无人问津的凄凉了。

下面我要简单介绍下SOA,SOA的定义:

SOA是一个软件架构,它包含四个关键概念:应用程序前端、服务、服务库和服务总线。一个服务包含一个合约、一个或多个接口以及一个实现。

应用程序前端可以理解为我上面所讲述的调用者和前端系统,服务库可以理解为服务集群,这里还有个服务是什么呢?

服务就是调用者和服务提供者完成某一个特定业务的合约,换句话说就是封装的业务规则。

打个比方,我们在淘宝去购物,下订单,付款,查物流,确定付款这些操作在服务端都有独立的服务提供,但是从购物这个概念去理解,这些独立的服务才能构成这个完整的购物行为,如果其中有地方出了问题,会有相应不同的操作,那么这个就绝对不是调用者简单调用服务接口的问题,需要更高层次的业务封装,将上面这些操作封装为一个统一的服务,这个就是所谓的服务。

最后一个要素服务总线,这就是我们本文所谈的重要主题:远程调用服务了。

这里谈谈SOA的目的是想起到抛砖引玉的作用,让那些想深入研究远程调用服务的人可以从SOA的角度理解远程调用服务,而那些还是不明白远程调用是何物的童鞋可以通过SOA的概念来理解远程调用服务。


  

远程调用服务技术详解

远程调用服务技术详解,详解,呜呜~~,这两个字很有压力,我怕有童鞋看了这个标题会以为我会将整套技术实现方案写到里面,这个难度太高了,写几万字估计都说不清楚,再说真的写的那么细致,估计很多人都看不懂了(嘿嘿,我自己也没有技术实现过哦,这些都是构思,构思哦),所以详解就是详解原理。

下面我将上篇文章的架构图放进来,大家再仔细看看这张图:

架构设计:远程调用服务架构设计及zookeeper技术详解

传统的服务调用都是服务提供者和服务调用者的直接调用,从架构图里我们看到这里多了一个远程调用管理组件,远程调用管理组件是一个独立的服务系统,为了保证该系统的稳定性。

它也一定是一个分布式的系统,但是这个分布式系统和Web的分布式系统是完全不同的分布式系统,传统Web应用集群是基于HTTP协议的无状态的特点设计的。

因为每个HTTP请求都是一个独立的事务,不同请求之间是没有任何关系的,所以我们可以将Web应用部署到不同服务器上,请求不管到了那台服务器,都能正常的给用户提供相应的服务,但是Web应用的session机制是有状态的,所以传统Web集群都是要有session同步的操作,大型网站往往会把session功能抽象为独立的缓存系统。

但是这里的远程调用管理组件的集群原理,或者说分布式原理是有别于Web应用集群分布式原理的,远程调用管理组件可以当做一个注册中心,它会记录下服务提供者和服务调用者的相关信息,并将这些信息推送给服务提供者或者服务调用者,为了保证系统的执行效率,这些注册信息都是记录在内存里。

我们试想下,如果这些注册信息丢失,整个系统将会不可用,因此远程调用管理组件的集群是一种保证数据可靠性和服务提供健壮性的集群,而不是建立在HTTP无状态特性基础上的集群。

我们这里假想下远程调用服务的集群运行场景,我们假如有5台服务器作为远程调用服务运行的服务器,那么每台服务器都必须有注册信息的冗余备份,当服务运行时候其中一台服务器发生了故障,这台故障的服务器上的数据不会丢失,此外集群应该还要有一个检查故障的机制,当发现有台服务器不可用的时候,能及时剔除该服务器,而zookeeper就是解决这种问题的技术框架。

此外除了保证系统的稳定性和可用性外,集群的数据存储方式也是很重要的,前面我讲到集群的数据存储要有一个冗余机制,除了冗余机制还要有一个很适合快速访问和读写的数据模型,而zookeeper正好包含这种数据模型,所以我设计的远程调用服务是一个很适合zookeeper应用的场景。

远程调用管理组件还有一个心跳机制,心跳机制的作用是,检测服务提供者的健康性及服务提供者是否可用。

如果不可用远程调用管理组件会反复尝试几次,这个次数和多久检测心跳都是可以配置的,如果反复几次还是不通,那么就认定该服务不可用了。

有网友问我,为什么不检测服务调用者的心跳,这个完全没必要哦,调用者是主动方,提供者是被动方,这就好比你访问网站,如果你生病了不去访问了,系统没有必要检查你是否已经生病了。

远程调用框架需要使用序列化和反序列化技术,这点也让很多童鞋不太理解,不理解的原因,还是对序列化和反序列化技术的不理解。

序列化技术主要是应用于数据持久化(数据存到硬盘)或者网络通讯,不管是数据存储到硬盘还是进行网络通讯,这些数据都会转化为二进制,序列化就是将正在运行的对象转化为可以存储和传输的二进制数据。

而反序列化是可以将这些二进制数据反向还原成原来的对象信息,还原的对象还是可以被程序操作的,而我们设计的远程调用框架传递就是不同系统之间可以相互使用的程序代码,所以我们需要使用序列化和反序列化技术。

这里就有一个问题,例如我们传输一个对象,这个对象对应的类是N多个类的继承子类,而且这个对象里可能还会引用其他的对象例如String,ArrayList等等。

那么为了让反序列化的对象可用,序列化的时候就会将这些信息也包含在二进制数据里,并且这些信息一起进行网络传输,这就导致数据传输量特别大,而jdk自带的序列化机制会导致这些附带信息更大,所以有必要使用比jdk更好的序列化机制,让数据量变小,并且序列化和反序列化的效率更高。

对于服务提供者和服务调用者我会提供一个jar包,这些工程都要引入这个jar包,同时还需要一个配置文件来定义一些需要用户定义的参数。

例如我们使用一个名字叫ycdy_config.properties配置文件,里面的key值介绍如下:

  • Config_center_url=ip:port;这个就是配置远程管理中心的ip地址和端口号;
  • Server_type=provider/consumer;配置是服务调用者还是提供者,不配置默认是提供者;
  • Provider_post=9999,这是服务提供者的端口号,调用者可以不配置,其实调用配置了也没啥用,如果提供者不配置,会有默认值的;
  • Provider_session_timeout=9000;服务提供者的超时时间,如果实际调用超过了这个时间,那么说明服务调用超时,适用于服务调用者,提供者无效
  • Tick_time=3000;心跳时间,这是远程调用中心检测服务端心跳的间隔时间,适用于服务提供者;
  • Again_time=3;当服务提供者不通的时候,心跳反复检测的次数,超过了次数就标记该服务不可用;Provider_session_timeout、Tick_time和Again_time三者之间是有一定关系,这个关系要实现这具体把控了;
  • Ip_include_pattern=172\\.17\\.138.*|192\\.168\\.0\\..*,这个适用于服务提供者,因为一台服务器可能存在多个ip地址,当远程调用服务组件接收到提供者的ip,用这个配置项来辨认那个ip可用,这里采用正则表达式的方式;
  • Ip_exclude_pattern=用于服务提供者,需要忽略的ip;
  • Consumer_policy=random/rotate;适用于调用者,调用者向提供者请求的负载均衡策略,我熟悉的只有两种一种是是用随机数,一种使轮询,所以这里目前就这两种选项;
  • Monitor_log=true/false;是否开启监控日志,适用于服务提供者,任何系统日志是最重要的,否则没法查生产问题,其实这个配置项应该可以充实点,但是我现在还没想好,所以先给个提示,具体到了生产看如何实现吧。

大家看到了不管是作为服务提供者还是服务调用者使用的配置文件是一致的,而且一个应用既可以配置成服务的调用者也可以配置成服务的提供者,非常的灵活。

那么在应用里远程调用服务到底如何使用了?

哈哈,这时候spring就要上场了,我们看看服务调用者和服务提供者的spring配置,如下所示:

<!-- 服务提供者配置 --><bean id="serverProvider" class="cn.com.sharpxiajun.RmifSpringProviderBean"> <property name="interfaceName" value="cn.com.ITest"></property><!-- 远程调用的接口 --> <property name="target" ref="clsTest"></property><!-- clsTest实现ITest的实现类,clsTest这里是一个bean的id值 --></bean>
<!-- 服务调用者配置 --><bean id="clientConsumer" class="cn.com.sharpxiajun.RmifSpringConsumerBean"> <property name="interfaceName" value="cn.com.ITest"></property><!-- value就是Provider定义的target的接口实现类 --> <property name="serialType" value="hessian"></property><!--序列化方式 --> <property name="compressEnabled" value="true"></property><!-- 压缩标记 --></bean>

我们发现这个新配置和以前不同了,这个配置将更加适合生成的开发。

我们首先看看serverProvider的设计,这个bean对应的class是
cn.com.sharpxiajun.RmifSpringProviderBean,里面有个参数是一个interfaceName,即提供者对外的接口。

这里我会使用反射机制将接口注入到RmifSpringProviderBean,而target则是具体的实现对象了,这就是业务对象,注意interfaceName一定要是接口,因为调用者会根据接口进行转化,如果是类的话,那么通用性就很差了。

clientConsumer的设计,这个bean所对应的class是
cn.com.sharpxiajun.RmifSpringConsumerBean,其中interfaceName的value值对应的就是远程定义的接口,和提供者的interfaceName保持一致,当提供者的数据传导调用者后,就会根据这个双方约定好的接口反序列化成可以操作的对象。

serialType是选择序列化机制,不写的话就是调用jdk的序列化机制,这里附带提下啊,外部的序列化程序也是放到jar包里的哦,还有一个选项是compressEnabled作用是是否启用传输报文压缩。

当调用者调用提供者服务时候,jar包里netty程序会根据推送的信息(主要是ip,端口)和spring配置的bean结合起来就可以完成一次服务的调用。


  

远程调用服务技术详解

远程调用管理组件和服务提供者之间有心跳机制检测,它的作用是检测服务提供者是否能正常对外提供服务。

我在上篇提出的检测方式是使用ping的方式检测ip和端口号,其实具体实现中还可以有另外的方式。

因为服务提供者都会引入远程调用服务提供的jar包,在这个jar包里其实包含一个心跳测试接口,远程调用管理组件采用服务提供者和服务调用者同样的通讯方式去调用这个心跳测试接口,如果该接口能调通,那么心跳测试成功;

如果发现一次调不通那么再反复测试几次,要是还是不通,再标记该服务提供者的这台服务器不可用,这种心跳检测优于使用ping的方式,因为心跳检测是完全模拟服务提供者和调用者的通讯场景,检测的结果更加符合真实的情况。

当心跳机制检测出了服务提供者有些服务器出现了故障,我们应该还需要有一套机制,及时通知运维人员或者相应的开发人员,做到故障的及时处理,所以远程调用框架里应该还要包含一个监控系统健康性的监控模块,如果你的公司有专门的监控系统那就更好了。

远程调用管理组件和服务调用者还有一个推送信息的关系,这里我再描述下这个推送的关系。

还有个细节我没有讲清楚,远程调用管理组件怎么知道服务调用者需要调用那些服务呢?

好了,服务调用者和服务提供者的关系建立好了,那远程调用管理组件是如何推送服务信息到服务调用者的呢?

我们发现远程调用管理组件和服务调用者之间关系是一个典型的发布-订阅的关系,发布-订阅的模式在设计模式里有一套解决方案:观察者模式,这里我先简单介绍下观察者模式:

观察者模式定义了一种一对多的依赖关系,让多个观察者对象同时监听某一个主题对象,这个主题对象的状态发生变化的时候会通知所有观察者对象,使得它们能够自动更新自己。

通过观察者模式的定义,我们发现远程调用管理组件即是主题对象,服务调用者则是观察者,其实程序里信息推送的模式都是使用观察者模式来实现的。

在上篇里我给大伙展示服务提供者和服务调用者的spring配置文件,接着告诉大家提供者和调用者就是使用RmifSpringProviderBean和RmifSpringConsumerBean这两个bean就能完成相互的通讯,我开始以为自己已经讲的很清楚了,有代码有真相,但是结果是马上有童鞋跟我私聊,问道这到底是怎么做到的呢?事后我再想想,如果这个问题不解释细点还真是有很多人不明白。

这里我使用的是代理模式,下面是对代理模式的简单介绍:

代理模式及Proxy,是为其他对象提供一种代理控制对这个对象的访问。在某些情况下,一个对象不适合或者不能直接引用另一个对象,而代理对象可以在客户端和目标对象之间起到中介作用。

远程调用服务的一个核心设计就是要保证服务提供者和服务调用者之间的调用规则要一致,但是具体到业务的处理又能多种多样,这个场景就是一个典型的代理模式。

在远程调用服务里的代理模式不是一个普通的代理模式,因为这种代理关系的建立都是在程序运行中进行的,所以我们必须选择使用反射机制实现的动态代理模式,读完上面我引用的文章,大家发现了吧,我在spring里使用的是interface方式,这说明我的方案是使用jdk自带的代理模式,不过使用cglib的动态代理,用实现类来实现代理我感觉可能程序设计会更加灵活些。

远程调用服务的框架开发里,还有两个重要的技术:线程和通讯,这两个技术是远程调用框架的核心技术也是难点技术,因为线程和通讯都比较复杂,所以本文不展开它们具体的实现方式,而是谈谈它们在远程调用框架里的作用。

在谈作用之前我要插入一点内容,上篇里我讲到远程调用服务框架就是几年前很火的SOA里的服务总线,如果有童鞋接触过这种骨灰级的技术,一定知道当时的服务总线有个简称叫ESB总线。而ESB总线的通讯介质几乎都是WebService,当年的SOA是源自企业级的解决方案。

而我现在设计的远程调用服务是针对于互联网的应用,互联网的远程调用框架没有采用WebService而是使用新的通讯介质。

例如netty,或者是apache的顶级项目mina。其实当下webservice也是一种被淘汰的技术,淘汰的原因是它的效率低下,不管是传输的数据大小,还是请求响应的效率都很烂。

互联网的远程调用服务是一个高性能的框架,它的性能更好,而且它包含了原来ESB总线的所有特点,由此可见互联网的技术远远领先企业应用,企业级的应用企业相对于互联网企业就是传统手工业,而当下互联网的技术已经开始引领企业的应用,这就是落后就要挨打的道理,没有创新的意识就会被别人革命。

既然我们要设计的是高性能的远程调用服务框架,那么高性能的一个指标就是高并发,而高并发就是指你的服务到底能开启多少个线程,所以线程写的好坏直接关系到远程调用服务可用性的好与坏。

使用线程的时候要注意以下几个问题:

第一,服务器的内核一般是多核的,所以编写线程的时候要把这些内核都利用上去,大量的线程要使用那么一定要使用上池技术。

这里我推荐的是jdk自带的Executor框架,Executor框架设计的非常好,同时和netty的兼容性也非常不错,最后就是线程安全的问题,这里最难的就是线程安全,如果让我自己实现线程,我对写线程安全的信心是最不足的。

高性能的另一个指标就是网络通讯了,这里我采用的是netty框架,Netty是由JBOSS提供的一个java开源框架。

Netty提供异步的、事件驱动的网络应用程序框架和工具,用以快速开发高性能、高可靠性的网络服务器和客户端程序。

Netty有很多优点,但是它最大的优点是netty的长连接和事件驱动,netty能帮我们屏蔽很多socket难以控制的细节,它的长连接设计的非常不错,减少了tcp开启和关闭的开销,而事件驱动模式又大大降低了开发的难度,但是netty本身有个缺陷,这个缺陷就是netty是一个异步的通讯框架。

但是大家看到了我们调用的场景都是同步,因此我们必须把异步请求转为同步请求,在前面的分布式网站架构里,我使用到了这种技术,哎,可惜当时使用时候是拿来主义,异步转同步的详情我现在还不是很明了,这个也是我以后需要研究研究的,这里先预留下来,以后明白了再写博客告诉大家。

好了,远程调用服务的架构设计详情到此结束了,下面是本文的另一个主题zookeeper了。


  

Zookeeper技术详解

在远程调用服务里zookeeper使用在远程调用管理组件里,而服务调用者则是zookeeper的客户端,远程调用管理组件也是远程调用服务的核心所在。

如果远程调用管理组件在运行时挂掉了,那么整个应用也将不可用,因此远程调用管理组件必须是否可靠,这种可靠性的要求甚至要高于服务提供者和服务调用者的可靠性的级别,因此远程调用管理组件的设计必须是分布式的,而且一定要是可靠的分布式。

远程调用管理组件是一个完全符合zookeeper场景的应用或者说是一个标准的zookeeper应用,为了便于后面的论述,这里我的更进一步的讲解下远程调用管理组件的功能。

对于服务提供者,远程调用管理组件还提供心跳机制检测服务提供者的健康状态,如果远程调用管理组件发现服务提供者一些服务器出问题了,它会及时更新服务提供者的配置信息,并将这些变化及时推送给服务调用者。

由上所述,从配置信息存储的角度,远程调用管理组件其实就是一个远程存储配置信息的系统,而心跳机制和推送机制这是一种观察者模式,而上面这些功能都是在分布式环境下功能,需要很高的可靠性。

本文上篇的开头,我曾提到在hadoop技术生态圈里,zookeeper曾是最让我困惑的技术,这种解困的好奇心是促使我一直很关注zookeeper技术的一个重要诱因,现在我有点理解zookeeper为何物了(不管说深入理解,因为每个技术都是博大精深,永无止尽的,特别是很优秀的技术)。

Zookeeper最典型的一个应用就是可以做分布式应用的配置服务,具体点就是像我们平时写的配置文件,到了分布式系统里也是需要一个独立的系统来完成,并且是动态的配置服务。

既然zookeeper可以做分布式的配置服务,那么我们可以通过分布式配置服务的特点反向的理解zookeeper的作用。

我是一名java工程师,在做java的web开发的时候,会使用大量的配置文件,一般这些文件是用properties属性文件完成的,服务启动时候,属性文件里的信息会读到内存中,web系统从内存中读取这些配置信息。

这个配置信息有几个特点:

属性文件一般不台大(这里指的是系统运行相关的配置文件,大伙不要把国际化的也拿过来理解啊),配置信息是持久化,使用时候是先加载在内存中的,从内存读取。

zookeeper也可以完成这样的事情,而且其特点和传统的配置文件使用几乎一样,zookeeper有一个文件系统,这个文件系统是用来存储小文件的,我们读取配置信息时候是在内存里读的,效率很高,写信息的时候zookeeper会将配置信息持久化。

这就是为什么有的书里介绍zookeeper的性能:

Zookeeper的基准吞吐量可以超过10000个操作,而对于读操作为主的工作负载,吞吐量更是高出好几倍。

这句话很有道理,小文件写速度很快,10000个操作木有啥问题,都是通过内存,高好几倍是理所当然的。

系统运行的配置信息可靠性要求是很高的,既然我们现在使用分布式系统完成配置信息的读写操作,那么确保信息读写的准确性是非常重要,特别是写,要求绝对是要么成功要么失败,这个场景估计很多人一看就认为这不就是线程安全吗?

没错,是线程安全(能想到线程安全还是很牛的哦),但是大伙要看清楚啊,我们现在是分布式系统,不同服务器之间的操作不是线程的范畴而是进程的范畴,因此这里就需要新的保证操作安全的技术,换句话说就是进程安全的机制。

除此之外,分布式的配置服务之所以使用分布式就是为了保证配置服务系统的稳定性和安全性,这样才能持续为用户提供高质量的服务,这两个难题看似毫不相关,但是却有一种方案同时解决这个问题,这个方案就是zookeeper的Zab协议。

为了说清楚Zab协议,我们举个例子,例如我们使用5台服务器作为zookeeper服务器,我们向zookeeper集群发送指令,这个指令就是读操作或者写操作,zookeeper集群会完成下面两个操作:

操作一:

领导者选举,当zookeeper启动的时候,这5台服务器会选举出一个领导者机器,其他的机器则是追随者,如果有半数以上的追随者和这个领导者通讯完毕确认了状态,那么这个阶段也就完成了。

如果领导者一直都很健康,那么领导选举的操作就不会再促发,如果领导者出问题了,那么zookeeper就会再一次促发领导者选举的操作。

这里有个问题我不太确定,就是zookeeper的领导者检测追随者健康性,应该也会使用心跳机制吧?如果有哪位大侠知道,可以给我评论下哦。

操作二:

如果发出的指令是写请求,那么所有的写请求都会被转发到领导者,再由领导者将更新的广播发送给追随者,当半数以上的追随者将修改持久化后,领导者才会提交这个更新,然后客户端才能收到一个更新成功的响应。这个用来达成共识的方式被设计成原子性的,这个操作要不成功要不就失败。

由以上操作就保证了读写的原子性,不会发生脏数据,反复选举领导者也保证了服务的可靠性。

当然这里还有个问题,要是领导者出现故障了?这时候zookeeper集群又会重复上面的领导者选举操作。这也说明为什么zookeeper集群要求是奇数台的服务器,5台服务器2台挂了,服务仍然可以正常运行,如果是6太服务器,还是只能允许2台服务器出故障,因为如果3台挂了,剩下的服务器没有过半数,那么zookeeper自己都挂了,所以奇数服务器不会造成服务器资源浪费。

对于读操作,zookeeper任意一台服务器都可以直接给服务,附带其他操作很少,所以高效;而写操作,只有当所有服务器都持久化了数据后,zookeeper才会更新内存中对应的数据,所以会比读操作慢多了。

Zookeeper存储数据操作方式和Unix文件系统的路径操作一致,而内存数据存储的模型就是一个树状结构了,树状结构的节点叫做znode,znode就是用来存储和读取数据的地方,这个树的操作如下列表:

架构设计:远程调用服务架构设计及zookeeper技术详解

我们存储的配置信息就是使用这些操作完成的。

例如:当服务提供者启动时候将自己的配置信息推送到远程调用管理组件,组件就会做创建节点或者设置znode所保存数据的操作,当数据保存成功后,zookeeper就会马上将信息推送到服务调用者,这个推送工作zookeeper也可以完成。

zookeeper里的znode以某种形式发生了变化,每个znode上面都附带一个观察机制,也就是我们前面说的观察者模式的应用,这个观察者机制会给客户端通知,而这个客户端就是服务调用者。

如果心跳机制检测到服务提供者某个服务器出故障了,zookeeper也会修改相应的znode的信息,这时候也会促发观察机制,通知服务调用者发生了变化。

好了,本文主题就写完了。

Zookeeper的功能很强大,不仅仅包含上面我谈到的运用,本文是我深入学习zookeeper的开始,有了现在的基础,再深入学习zookeeper将会容易很多。

- END -

文源网络,仅供学习之用。 如有侵权,联系删除

架构设计:远程调用服务架构设计及zookeeper技术详解


  面试集锦  


◆  

◆  

◆  

◆  

◆  

◆  

◆  

◆  

◆  

◆  

◆  

◆  



架构设计:远程调用服务架构设计及zookeeper技术详解



关注下方二维码

每天推送优质文章

你会有意想不到的收获