在Spring Cloud微服务体系中,由于限流熔断组件Hystrix开源版本不在维护,因此国内不少有类似需求的公司已经将眼光转向阿里开源的Sentinel框架。而以下要介绍的正是作者最近两个月的真实项目实践过程,这中间被不少网络Demo示例级别水文误导过,为了以正视听特将实践过程加以总结,希望能够帮到有类似需要的朋友!
一、Sentinel概述
在基于Spring Cloud构建的微服务体系中,服务之间的调用链路会随着系统的演进变得越来越长,这无疑会增加了整个系统的不可靠因素。在并发流量比较高的情况下,由于网络调用之间存在一定的超时时间,链路中的某个服务出现宕机都会大大增加整个调用链路的响应时间,而瞬间的流量洪峰则会导致这条链路上所有服务的可用线程资源被打满,从而造成整体服务的不可用,这也就是我们常说的“雪崩效应”。
而在微服务系统设计的过程中,为了应对这样的糟糕情况,最常用的手段就是进行”流量控制“以及对网络服务的调用实现“熔断降级”。所谓流量控制就是根据服务的承载能力指定一个策略,对一定时间窗口内的网络调用次数进行限制,例如1s内某个服务最多只能处理10个请求,那么1s内的第11+的请求会被被限制丢弃;而熔断降级的概念则是说在A服务→B服务调用过程中,按照一定的规则A服务发现调用B服务经常失败或响应时间过长,如果触发了A服务对B服务调用的熔断降级规则,那么在一定时间窗口内,A服务在处理请求的过程中对于B服务的调用将会直接在A服务的逻辑中被熔断降级,请求则不会通过网络打到B服务,从而避免A服务由于过长的超时时间导致自身资源被耗尽的情况发生。
虽然我们知道以上两种手段非常有用,但若没有合适的技术来支持,就好像一句话说的“虽然明白很多道理,但是依然过不好这一生”一样。而Sentinel就是这样一种技术,它是阿里巴巴开源的一款客户端限流组件,可以与Spring Cloud微服务体系无缝地集成;而与之对应的是另外一款Netflix公司推出的知名度也比较高的Hystrix组件,Hystrix也是Spring Cloud官方集成熔断限流组件,只不过相对于Sentinel来说,Hystrix所提供的功能和灵活度比较低,并且它目前已经处于开源版本暂停维护的状态,因此目前国内很多基于Spring Cloud搞微服务的公司都转向了Sentinel。关于二者的对比由于不是本文的重点,这里就不再赘述,大家搜索下就好(ps:可能网上也没几篇能说明白的文章,关键还在于大家实际使用对比)。
二、Sentinel+Apollo架构说明
Sentinel开源版本架构
在Github Sentinel官方Wiki说明以及网上一大堆的水文中,关于Sentinel的资料已经很多了,但是大多数属于Demo级别,所以本文不想过多的耗费大家的精力(因为在学习过程中,作者也被误导过)。以下将从实际生产的使用方式上来阐述如何构建Sentinel的使用架构。
从本质上说Sentinel与Hystrix是一类性质的熔断限流组件,之所以说它们只是组件就在于它们都需要内嵌于微服务应用本身的主进程之中,所有的限流、熔断策略及指标信息的收集等逻辑都是基于客户端的(这里不要对客户端有所误会,它指的是处于调用端上游的微服务本身)。而这一点是明显区别于Service Mesh(服务网格)架构中将熔断、限流等逻辑抽象在SideCar(边车)而不是微服务应用本身的。
因此从这种意义上说,Sentinel的使用应该是并不复杂的,它应该与Hystrix一样,在Spring Cloud微服务应用中引入相关依赖即可。事实上从某种程度来说的确如此,只不过Sentinel提供了比Hystrix要强一点的规则配置能力,提供了可以进行限流、熔断降级以及热点、授权等其他规则统一配置和管理的控制台服务->sentinel-dashboard。
虽然如此,但这也并没有改变Sentinel作为客户端限流组件性质,通过控制台配置的规则依然要推送到微服务应用Sentinel客户端本身才能生效,而微服务之间的调用链路等指标信息也需要推送给Sentinel控制台,才能比较方便地使用Sentinel提供的一些能力,因此在开源的架构版本中需要微服务应用本身开启独立端口与sentinel-dashboard进行通信,从而获取配置规则以及上送微服务应用各类指标信息。而这一点,显然也会占用微服务额外的资源,并且由于sentinel-dashboard在此条件下并不具备集群部署能力,因此也会形成一个单节点问题,但是有一套控制台总好过于没有,如果希望比较方便快速地应用Sentinel这也是一种代价。此时的Sentinel架构如下图所示:
在开源版本架构中,通过sentinel-dashboard控制台配置的限流、熔断降级等规则都是存储于Sentinel控制台服务内存之中的,如果控制台服务重启或者微服务应用重启都会导致规则丢失。而这在生产环境下是不可接受的,因此Sentinel在官方的生产架构指导中也是推荐使用第三方数据源(如本文的Apollo)作为永久存储中心,这样各个微服务的限流、降级规则都可以永久存储。虽然Sentinel官方推荐使用第三方数据源作为规则存储中心,目前也提供了针对Apollo、Nacos、Zookeeper、Redis、Consul、Spring Cloud Config等多种存储源的依赖集成Jar,但是却并没有针对这些数据源提供一个可以实际使用的sentinel-dashboard第三方数据源存储版本,所以当你选择了一种数据源那么就需要你自己对sentinel-dashboard项目进行改造,这里作者针对Sentinel 1.7.0(成文时最新版本)使用Apollo数据源改造了一个版本,所有规则基本可用,但可能会有细节的Bug需要自行Fix。具体代码改造点见Github链接:
https://github.com/manongwudi/Sentinel/commit/f3a27adb6fdbf13d9eaa4510e317c1b55c206e89
关于以上sentinel-dashboard接入Apollo数据源的代码改造情况,大家可以详细参考上述链接,这里作者只说以下几个重点:
目前官方推荐的方式是通过Apollo的开放平台授权的方式进行写入,因此我们需要在sentinel-dashboard项目pom.xml文件引入以下依赖:
<!-- Apollo配置依赖 -->
<dependency>
<groupId>com.ctrip.framework.apollo</groupId>
<artifactId>apollo-openapi</artifactId>
<version>1.5.0</version>
</dependency>
之后我们需要在Apollo Portal创建一个针对sentinel-dashboard的应用,具体创建方法如下图所示:
以上我们创建了一个针对Sentinel控制台的应用(这里的应用是Apollo配置中心的基本概念,具体微服务接入Apollo的方法,大家可以自行搜索)。
创建应用后,未来Sentinel控制台在启动是需要指定Apollo应用ID才能接入Apollo,而接入Apollo之后Sentinel的规则需要写入该应用下的namespace空间,因此还需要创建针对该应用的namespace空间,具体创建方式如下图所示:
点击进入应用,然后点击“添加Namespace",创建一个具体存储Sentinel各种限流、熔断降级等规则的Apollo存储空间,这里需要注意的是所创建的空间类型一定要是"public"公共空间,因为最终这些规则是需要具体的微服务应用去获取的,而在Apollo中应用下只有公共Namecspace才能被其他应用继承。
最后我们在Apollo控制台选择“管理员工具->开放平台授权管理”创建基于该应用的开放授权信息。
此时生成的Token信息将作为sentinel-dashboard与Apollo接口对接的重要凭证被配置。通过上述几个步骤,我们基本上就完成了sentinel-dashboard对接Apollo的准备工作,剩下的就是针对sentinel-dashboard的具体代码改造,可参考前面的Github链接。改造中能够抽离的配置如下:
#Apollo本地演示环境
#Apollo应用ID
apollo.app.id=sentinel
#Apollo应用下对应的具体集群标识
apollo.cluster.name=local
#Apollo存储空间名称
apollo.namespace.name=sentinel-rule
#Apollo控制台地址
apollo.portal.url=http://127.0.0.1:8070
#Apollo控制台用户名
apollo.modify.user=apollo
apollo.release.user=apollo
#Apollo开放平台凭证
apollo.application.token=2647efacc9d55445f4055247cd028af60dd604b6
以上配置在编写具体的连接代码时会使用到,详情请参考具体改造代码!
为什么要使用Apollo?Apollo是一款携程开源的配置中心,在目前基于Spring Cloud的微服务体系中也有一款官方的配置中心Spring Cloud Config。从实际的使用情况看,目前Apollo比起Spring Cloud Config从功能上说要更全一些,如果你的公司在使用Spring Cloud Config司目前使用的是Apollo作为配置中心,因此选择的是Apollo作为Sentinel第三方存储数据源(需要注意Apollo的版本,如果你所使用的Apollo版本比较老,可能会不兼容)。
引入Apollo作为Sentinel数据存储源后,此时的Sentinel架构如下图所示:
三、Spring Cloud微服务集成Sentinel
讲到这里,我们还只是完成了Sentinel控制台与Apollo数据存储源之间的打通,那么对于具体的Spring Cloud微服务应用而言,在代码编程上该如何接入和使用Sentinel呢?
微服务连接Sentinel控制台
在默认情况下微服务应用可以直接连接Sentinel控制台,从而通过Sentinel控制台获取限流、熔断降级等规则信息。具体步骤如下:
首先我们需要在项目pom.xml文件中引入Sentinel相关依赖Jar,代码如下:
<!--Sentinel熔断限流组件依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
<version>2.1.1.RELEASE</version>
<!--根据实际情况决定是否排除冲突依赖-->
<exclusions>
<exclusion>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
</exclusion>
</exclusions>
</dependency>
之后我们需要在项目的配置文件中加入Spring Cloud微服务连接sentinel dashboard的配置(此时微服务还尚未引入Apollo配置中心,引入Apollo配置中心后也可以加在配置中心),如下:
#sentinel
#在微服务应用中开启连接sentinel-dashboard的连接端口
spring.cloud.sentinel.transport.port = 8719
#sentinel-dashboard控制台地址
spring.cloud.sentinel.transport.dashboard = http://127.0.0.1:9090
在不考虑第三方数据源永久存储的情况下,以上方式也可以直接使用Sentinel对微服务进行限流、熔断降级等逻辑,只不过这些规则并不能永久存储!
微服务连接Apollo配置中心
接下来我们将Spring Cloud微服务接入Apollo配置中心,并通过Apollo配置中心获取从Sentinel控制台持久化到Apollo应用存储空间的Sentinel规则。
引入Sentinel规则Apollo数据源依赖,该依赖也会默认包含Apollo本身的客户端依赖,因此也不用在额外引入其他JAR,代码如下:
<!--Sentinel规则Apollo数据源依赖-->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-apollo</artifactId>
<version>1.7.0</version>
<!--根据实际情况决定是否排除冲突依赖-->
<exclusions>
<exclusion>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-extension</artifactId>
</exclusion>
<exclusion>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-core</artifactId>
</exclusion>
</exclusions>
</dependency>
接下来配置Spring Cloud微服务连接Apollo配置中心的基本信息,如下:
#Apollo中创建的微服务应用ID
app.id = pay-notify
#打开Apollo接入开关
apollo.bootstrap.enabled = true
apollo.bootstrap.eagerLoad.enabled = true
#apollo configserver地址不是portal
apollo.meta = http://127.0.0.1:8080
# 自定义本地配置文件缓存路径
apollo.cacheDir = ./config
#指定apollo命名空间
apollo.namespace =application,db,logback
#指定apollo集群
apollo.cluster=local
如果希望在Apollo中生效的配置能够及时被Spring Cloud微服务感知到,我们还需要在微服务主类中加入@EnableApolloConfig注解,代码如下:
@SpringBootApplication
@EnableFeignClients(basePackageClasses = {PayChannelFeignService.class})
@EnableDiscoveryClient
@EnableTransactionManagement
@EnableApolloConfig
public class PayNotifyApplication {
public static void main(String[] args) {
SpringApplication.run(PayNotifyApplication.class, args);
}
// 注解支持的配置Bean
@Bean
public SentinelResourceAspect sentinelResourceAspect() {
return new SentinelResourceAspect();
}
}
此时抛开Sentinel本身不说,Spring Cloud微服务也可以通过Apollo进行配置管理了!
那么嵌入Spring Cloud微服务应用的Sentitle客户端该如何获取Apollo中关于Sentinel规则的配置呢?在文章前面关于Sentinel+Apollo架构的说明中,sentinel-dashboard是将规则写入它在Apollo所在应用的公共空间下,因此其他微服务本身是可以通过Apollo继承并读取到这些配置的。只是我们在进行sentinel-dashboard的改造将规则的写入编成了一定的前/后缀标示,所以Spring Cloud微服务要想匹配到相应的规则,也需要在自身服务的配置中约定读取方式,具体以限流、熔断降级这两个规则为例进行配置,如下:
#指定该数据源为限流规则
spring.cloud.sentinel.datasource.flow.apollo.rule-type = flow
spring.cloud.sentinel.datasource.degrade.apollo.rule-type = degrade
#指定该规则在apollo应用中的key,从而实现约定读取
#spring.cloud.sentinel.datasource.ds1.apollo.flow-rules-key = ${spring.application.name}-${spring.cloud.sentinel.datasource.flow.apollo.rule-type}
spring.cloud.sentinel.datasource.flow.apollo.namespaceName = sentinel-rule
spring.cloud.sentinel.datasource.flow.apollo.flowRulesKey = ${spring.application.name}-${spring.cloud.sentinel.datasource.flow.apollo.rule-type}
#降级规则
spring.cloud.sentinel.datasource.degrade.apollo.namespaceName = sentinel-rule
spring.cloud.sentinel.datasource.degrade.apollo.flowRulesKey = ${spring.application.name}-${spring.cloud.sentinel.datasource.degrade.apollo.rule-type}
通过上述配置可以看出,我们是通过Sentinel客户端依赖约定的配置方式,对各类规则通过命名规则进行了匹配(这里Sentinel规则的命名规则可以结合实际的管理需求进行约定,确保sentinel-dashboard写入与微服务读取匹配就行)!例如:如果从管理角度分类,可以加上{部门名称}.sentinel-rule,这要求创建namespace公共空间时带上部门名前缀。
四、微服务使用Sentinel的编程方式
通过上面操作,我们已经从配置及环境方面完成了Sentinel与Spring Cloud微服务的接入,接下来我们以实际的服务间调用为例演示如何在Spring Cloud微服务体系下,使用Sentinel进行限流、熔断降级等操作!以作者之前做过的支付系统为例,其中有两个微服务存在如下调用关系:
以上两个服务的调用示例,是在支付系统中对支付订单状态进行实时检查的逻辑,目的是防止在出现支付调用链路中断,导致的支付掉单问题。pay-check服务会在支付请求发送到第三方后接受一条延迟消息,并在一定时间后通过对比支付流水状态与第三方渠道支付状态,如发现状态不一致,会通过Spring Cloud微服务间的Feign调用方式触发支付通知服务pay-notify,从而实现支付链路的补偿。这里pay-notify提供的服务接口为“/internel/pay/checkNotify”,关于这个服务接口,这里我们要求pay-notify服务要对该资源进行限流,从而防止流量过大而导致正常的通知链路受影响;而对于pay-check服务则需要实现对pay-notify服务该接口资源的熔断降级逻辑防止由于故障或网络原因导致pay-notify服务无法被正常调用,从而影响pay-check服务的稳定性。
Sentinel限流编程
需要明确的是,限流动作本身是服务提供方做出的,所以如果需要针对某个微服务的应用接口使用Sentinel进行限流处理,那么我们可以在该服务的入口通过@SentinelResource注解进行Sentinel资源配置,以上述实例为例,我们对pay-notify微服务接口/internel/pay/checkNotify进行如下资源定义:
@SentinelResource(value = "/pay/checkNotify", blockHandlerClass = SentinelFallback.class, blockHandler = "fallbackHandlerForCheckNotify")
@RequestMapping(value = "/pay/checkNotify", method = RequestMethod.POST)
public boolean checkNotify(@RequestParam(value = "paymentId") String paymentId,
@RequestParam(value = "tradeNo") String tradeNo, @RequestParam(value = "status") int status,
@RequestParam(value = "platform") String platform, @RequestParam(value = "platformtag") String platformtag,
@RequestParam(value = "tradeTime") String tradeTime,
@RequestParam(value = "notifyOrignMsg") String notifyOrignMsg,
@RequestParam(value = "tradeStatus") String tradeStatus) {
PayCoreNotifyEntity payCoreNotifyEntity = PayCoreNotifyEntity.builder().paymentId(paymentId).tradeNo(tradeNo)
.status(status).platform(platform).platformtag(platformtag).tradeTime(tradeTime)
.orignPlaintext(notifyOrignMsg).tradeStatus(tradeStatus).build();
boolean result = false;
try {
result = payCheckNotifyServiceImpl.payCheckCallBack(payCoreNotifyEntity);
} catch (CheckNotifyMsgException e) {
log.error(e.toString() + "_" + e.getMessage(), e);
CounterUtil.counter(Arrays.asList(Tag.of("exceptionType", e.getMessage())), "payNotifyExceptionMonitor");
}
return result;
}
在针对该接口进行Sentinel资源的定义时,为了服务端不直接抛出BlockException异常,我们配置了异常处理类及异常处理方法。blockHandler函数会在原资源方法被限流系统保护时被调用,而在SentinelFallback类中,针对该资源方法也定义了相应的地处理方法fallbackHandlerForCheckNotify,代码如下:
@Slf4j
public class SentinelFallback {
public static boolean fallbackHandlerForCheckNotify(String paymentId,
@RequestParam(value = "tradeNo") String tradeNo, @RequestParam(value = "status") int status,
@RequestParam(value = "platform") String platform, @RequestParam(value = "platformtag") String platformtag,
@RequestParam(value = "tradeTime") String tradeTime,
@RequestParam(value = "notifyOrignMsg") String notifyOrignMsg,
@RequestParam(value = "tradeStatus") String tradeStatus, BlockException e) {
log.error("对不起,该请求限流了,{}", e.toString());
return false;
}
需要注意的是Block异常处理函数,参数最后多一个BlockException,其余参数则需要与原函数一致,否则限流规则触发后将无法正常进入该fallback方法,而是直接抛出异常,服务消费方则直接收到500错误,输出上会显得不是很友好!关于限流资源异常处理代码编程方式,以上只是参考,大家可以写的更优雅,例如可以参考Feign的fallback编程方式。
定义Sentinel资源后,此时如果需要针对该资源进行限流规则的配置,我们可以使用sentinel-dashboard进行配置,如图所示:
在pay-notify微服务节点上,选择流控规则,并按照前面定义的Sentinel资源名称进行限流规则配置,这里我们为了便于测试,限流规则配置的极端些,选择QPS方式,并定义阀值为0。此时由于已经将sentinel-dashbord与Apollo配置中心打通,因此也能从Apollo中看到已经持久化存储的限流规则,如下图所示:
此时如果针对该资源方法进行网络调用就会被Sentinel规则限流掉,例如通过postman对/internel/pay/checkNotify接口进行网络调用,服务端代码运行如下:
可以看到此时针对该方法的调用已经触发限流规则,并在抛出BlockException异常后,进入了我们前面通过@SentinelResource注解定义的blockHandler方法!
Sentinel熔断降级编程
熔断降级针对的是对其他服务资源进行网络调用时,为了防止外部服务的不稳定拖垮自身,当该服务出现不稳定状态(例如调用超时或者异常比例升高等情况),对该资源的调用动作进行限制,从而让请求快速失败,避免出现级联错误的情况。而当资源被降级后,在接下来的降级时间窗口内,对该资源的服务调用都会自动熔断,而不会真正进行网络调用,而在Sentinel中则默认会抛出DegradeException异常。
从使用方向上看熔断降级规则逻辑的发生,是发生在服务消费方,而不是服务提供方。以上述例子举例,pay-notify服务除了针对自身接口进行限流外,pay-check对pay-notify服务的调用也可以进行熔断降级处理。这里我们将pay-check服务中对pay-notify服务接口的调用方法进行Sentinel资源定义,代码如下:
@SentinelResource(value = "/pay/goCheckNotify", blockHandler = "testNotifyFallback")
public boolean testNotify(String paymentId, String tradeNo, int status, String platform, String platformtag,
String tradeTime, String notifyOrignMsg, String tradeStatus) throws BlockException {
return notifyClient
.checkNotify(paymentId, tradeNo, status, platform, platformtag, tradeTime, notifyOrignMsg, tradeStatus);
}
//熔断降级异常处理方法
public boolean testNotifyFallback(String paymentId, String tradeNo, int status, String platform, String platformtag,
String tradeTime, String notifyOrignMsg, String tradeStatus, BlockException ex) {
log.error("服务被降级了!");
//todo 调用其他降级服务
return false;
}
之后我们通过Sentinel控制台在微服务节点pay-check中针对该资源配置降级规则,如下图所示:
这里的意思是对该资源方法的调用按照平均响应时间进行熔断降级,当1S内持续进入5个请求,对应时刻的资源平均响应时间(秒级)均超过阀值,这里配置的是200ms,那么在接下来的时间窗口内,这里配置的是10s,对该资源的调用都会自动进行熔断,默认抛出DegradeException。为了演示效果,这里我们将pay-notify服务的接口响应时间故意sleep(600ms),代码如下:
try {
Thread.sleep(600);
} catch (InterruptedException e) {
e.printStackTrace();
}
接下来我们通过JMeter调用pay-check服务,而pay-check服务将以Spring Cloud微服务的调用方式通过Feign来调用pay-notify的服务,如下图所示:
这里我们设置的请求方式为1s内发送7次请求调用,按照熔断降级规则,由于前5个请求的响应时间都将超过200ms的阀值,因此第6、7个请求将被直接熔断而进入fallback方法。代码运行效果如下:
从演示效果看熔断降级规则已经生效,Sentinel抛出了DegradeException异常!
Sentinel与Feign的集成关系
在实际的Spring Cloud微服务开发中,微服务之间的调用可以通过Feign来实现,与Spring Cloud微服务官方集成的Hystrix框架一样,在Feign中如果需要开启Sentinel熔断降级逻辑,需要在调用端(示例中为pay-check服务)配置中进行如下配置:
feign.sentinel.enabled = true
而作为Spring Cloud微服务调用端,在基于Feign对其他微服务存在接口调用的话,一般情况下我们还需要编写基于Feign的调用代码,并指定其fallback逻辑,以本文示例为例:
@FeignClient(value = "pay-notify", configuration = PayNotifyClientConfiguration.class, fallbackFactory = PayNotifyClientFallbackFactory.class)
public interface PayNotifyClient {
/**
* 支付状态核对发生掉单现象时,通过此接口完成补单回调操作
*/
@RequestMapping(value = "/internal/pay/checkNotify", method = RequestMethod.POST)
boolean checkNotify(@RequestParam(value = "paymentId") String paymentId,
@RequestParam(value = "tradeNo") String tradeNo, @RequestParam(value = "status") int status,
@RequestParam(value = "platform") String platform, @RequestParam(value = "platformtag") String platformtag,
@RequestParam(value = "tradeTime") String tradeTime,
@RequestParam(value = "notifyOrignMsg") String notifyOrignMsg,
@RequestParam(value = "tradeStatus") String tradeStatus);
其所指定的Fallback逻辑代码如下:
public class PayNotifyClientFallbackFactory implements FallbackFactory<PayNotifyClient> {
@Override
public PayNotifyClient create(Throwable throwable) {
return new PayNotifyClient() {
@Override
public boolean checkNotify(String paymentId, String tradeNo, int status, String platform,
String platformtag, String tradeTime, String notifyOrignMsg, String tradeStatus) {
log.info("enter flow limit/fallback logic");
log.error(throwable.getMessage());
return false;
}
};
}
}
@Slf4j
@Configuration
public class PayNotifyClientConfiguration {
@Bean
PayNotifyClientFallbackFactory payNotifyClientFallbackFactory() {
return new PayNotifyClientFallbackFactory();
}
}
需要说明的是,在微服务调用时,如果发送调用超时等情况会直接进入以上Feign所指定的fallback逻辑;而Sentinel熔断降级规则被触发时在某些场景下,例如在上述以平均响应时间为例的降级规则中,则只会直接进入@SentinelResource所指定的fallback方法,这一点也是Sentinel与Feign整合不够优雅的地方,因此在编写容错代码时并不能像Hystrix那样做到那么优雅统一!
五、后记
如果你能读到这里,恭喜你已经对微服务限流、熔断这一关键问题有了从实践层面最深刻的体会了。
原文地址:https://blog.51cto.com/14570694/2469019