Skip to content

一、什么是API网关1什么是API网关? API网关作用就是把各个服务对外提供的API汇聚起来,让外界看起来是一个统一的接口。同时也可在网关中提供额外的功能。 总结:网关就是所有项目的一个统一入口。

2****API网关的重要性 API 网关并不是微服务场景中必须的组件,如下图,不管有没有 API 网关,后端微服务都可以通过 API 很好地支持客户端的访问。

但对于服务数量众多、复杂度比较高、规模比较大的业务来说,引入 API 网关也有一系列的好处: ●聚合接口使得服务对调用者透明,客户端与后端的耦合度降低 ●聚合后台服务,节省流量,提高性能,提升用户体验 ●提供安全、流控、过滤、缓存、计费、监控等 API 管理功能 所以,在分布式微服务架构中,API网关的重要性是非常高的。 3****API网关的组成 API网关由路由转发和过滤器(编写额外功能)组成。 Application Program Interface 张三,下周开始,给你安排任务,放心,不难,就是写3、5个接口(功能)! 3.1****路由转发 接收外界请求,通过网关的路由转发,转发到后端的服务上。 3.2****过滤器 网关非常重要的功能就是过滤器。在Gateway中,过滤器默认提供了25种内置功能还支持额外的自定义功能。 对于我们来说比较常用的功能有网关的容错、限流以及请求相应的额外处理等。 4****Spring Cloud中提供的API网关解决方案4.1****Spring Cloud Gateway 由Spring 自己推出的网关产品,完全依赖Spring自家产品。符合Spring战略意义,其更新版本等都由Spring自己把控。目前很多项目中都是使用Gateway替代Zuul。在本套课程中讲解的也是Gateway。 4.2****Spring Cloud Netflix Zuul 属于Spring Cloud Netflix下的一个组件,具有灵活、简单的特点。在早期Spring Cloud中使用的比较多。其版本更新都依赖于Netflix Zuul。 二、Spring Cloud Gateway介绍1****Spring Cloud Gateway简介 Spring Cloud Gateway是Spring Cloud 的二级子项目,提供了用于在Spring网络流程中构建API网关的代码库,旨在提供一个简单有效的方式,路由到API;并提供了必要的各种功能,如:路由、权限安全、监控/指标等。

2****Spring Cloud Gateway名词 在学习Gateway时里面有一些名词需要提前了解,这对于后面的学习是很有帮助的。 2.1****Route - 路由 Route中文称为路由,Gateway里面的Route是主要学习内容,一个Gateway项目可以包含多个Route。 一个路由包含ID、URI、Predicate集合、Filter集合。在Route中ID是自定义的,URI就是一个地址。剩下的Predicate和Filter学习明白了,Route就学习清楚了。 2.2****Predicate - 谓词 谓词是学习Gateway非常重要的一点,简单点理解:谓词就是一些路由前的条件和内容。 2.3****Filter - 过滤器 在Gateway运行过程中,Filter负责在路由后,代理服务“之前”或“之后”做的一些事情。 3****Spring Cloud Gateway执行流程

4****Spring Cloud Gateway结构 SpringCloud Gateway的底层基于Netty,主要组成有Predicates(谓词)、Route(路由)、Filter(过滤器)

三、Gateway入门案例1准备Eureka注册中心 使用前面课程中的Eureka注册中心即可。 2准备一个微服务工程 使用前面课程中的任意微服务工程即可,本案例中使用Hystrix课程中的application service工程。 3搭建Gateway网关微服务3.1POM依赖

xml
<dependencies>
 <dependency>
 <groupId>org.springframework.cloud</groupId>
 <artifactId>spring-cloud-starter-gateway</artifactId>
 </dependency>
 <dependency>
 <groupId>org.springframework.cloud</groupId>
 <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
 </dependency>
</dependencies>

3.2编辑配置文件

yaml
server:
 port: 9999
eureka:
 client:
 service-url:
 defaultZone: http://localhost:8761/eureka/
# 自动发现工具的本地路由规则是:
# 请求路径 - http://网关IP:网关端口/微服务的服务名/要访问的具体地址
# gateway自动解析,把请求地址中的'微服务的服务名'截取,从`Eureka Client`发现的服务列表中查看,如果有同名服务,则开始转发。
spring:
 application:
 name: cloud-gateway
 cloud: # spring cloud相关配置的常用前缀
 gateway: # 网关技术配置前缀
 discovery: # 自动发现工具
 locator: # 本地逻辑
 enabled: true # 开启自动发现工具的本地路由逻辑
 lower-case-service-id: true # 把从EurekaServer上发现的服务名称,转换成全小写

3.3编辑启动类型

java
`@SpringBootApplication`
public class GatewayApplication {
 public static void main(String[] args) {
 SpringApplication.run(GatewayApplication.class,args);
 }
}

3.4启动Gateway并访问测试

访问 http://localhost:9999/application-service/test ,可看到下述结果:

xml
http://			表示使用协议http协议
localhost		表示浏览器访问的主机IP
9999. 				表示端口号,也就是找到上面IP的电脑的这个端口进入
application-service		表示你要通过gateway访问的微服务名称
test				表示访问的资源路径

四、谓词-Predicate

1概述 谓词:当满足条再进行路由转发。 在Spring Cloud Gateway中谓词实现GatewayPredicate接口。其中类名符合:XXXRoutePredicateFactory,其中XXX就是在配置文件中谓词名称。 所有的谓词都设置在predicates属性中,当设置多个谓词时取逻辑与条件,且一个谓词只能设置一组条件,如果需要有多个条件,添加多个相同谓词。

2****Path2.1****介绍 用于匹配路由地址规则的谓词。 2.2****使用方式 修改配置文件application.yml:

yaml
spring:
 application:
 name: cloud-gateway
 cloud:
 gateway:
 discovery:
 locator:
 enabled: false # 关闭自动发现工具的本地路由逻辑
 lower-case-service-id: true
 routes: # 配置多路由策略的属性,类型是List。配置方案是:回车 + 缩进 + - + 空格。集合中的每个对象的属性,对齐多行配置
 - id: application-service # 路由的唯一名称
 uri: lb://application-service # 规则满足后,转发到的地址。lb是spring cloud gateway支持的一种协议名
 predicates: # 谓词
 - Path=/service/** # 路由地址规则
 filters: # 过滤器,先使用,后续课程细致讲解。后续案例配置统一,文档中省略
 - StripPrefix=1

访问测试

访问 http://localhost:9999/service/test ,可看到下述结果:

3****Query3.1****介绍 用于校验请求中是否包含指定的请求参数,同时也可校验请求参数值是否符合要求。 注意:只能校验请求地址参数,也就是 /path?参数 3.2****使用方式3.2.1****请求必须包含指定参数 下述配置代表,请求中必须包含命名为name的参数。 修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates: # 谓词
 - Path=/service/**
 - Query=name # 请求参数必须包含name

3.2.2请求中必须包含指定参数,且参数值必须满足某要求

下述配置代表,请求中必须包含命名为name和age的参数,且参数值必须已'lhp'开头。下述配置中lhp.*是一个正则表达式。在正则表达式中点(.)表示匹配任意一个字符。所以当请求参数name=lhp或name=lhpAdmin等都能满足谓词条件。

如果设定请求中必须包含多个参数及值。则设置多个Query。在此处演示多个相同谓词配置,其他谓词中就不在强调如何配置多个谓词。

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Query=name,lhp.* # 请求参数必须包含name,请求参数的值必须以 lhp 开头
 - Query=age # 请求参数必须包含age

4.Header

4.1介绍

用于匹配路由地址规则的谓词。

用于校验请求中是否包含指定的请求头,同时可也校验请求头数值是否符合要求。配置方式和Query类似。

4.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Query=name,lhp.*
 - Query=age
 - Header=Host,.* # 请求头必须有Host,值为任意字符串

5Method

5.1介绍

Method表示请求方式。支持多个值,使用逗号分隔,多个值之间为or条件。

5.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Method=GET,POST # 请求方式必须是GET或POST

6RemoteAddr

6.1介绍

允许访问的客户端地址。

6.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - RemoteAddr=192.168.41.252 # 客户端IP必须是192.168.41.252,需要通过192.168.41.252:端口号等访问

7Host

7.1介绍

匹配请求中Host请求头的值。满足Ant模式(之前在Spring Security中学习过)可以使用:

  • ? 匹配一个字符
    • 匹配0个或多个字符
  • ** 匹配0个或多个目录

7.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Host=127.0.0.1:9999 # 请求头Host值必须是127.0.0.1:9999

8Cookie

8.1介绍

要求请求中包含指定Cookie名和满足特定正则要求的值。

Cookie必须有两个值,第一个Cookie包含的参数名,第二个表示参数对应的值,正则表达式。不支持一个参数写法。

8.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Cookie=name,lhp.* # 请求必须包含名称是name,值符合lhp开头的cookie。

postman中发送请求时添加Cookie的方式:

9Before

9.1介绍

在指定时间点之前。

9.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Before=2022-10-01T18:00:00.000+08:00[Asia/Shanghai] # 2022-10-01晚18点前可以访问

10After

10.1介绍

在指定时间点之后。

10.2使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - After=2020-10-01T08:00:00.000+08:00[Asia/Shanghai] # 2020-10-01早8点后可以访问

11.Between

11.1介绍

请求时必须在设定的时间范围内,才进行路由转发。

11.2.使用方式

修改配置文件application.yml:

yaml
- id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 - Between=2020-10-01T08:00:00.000+08:00[Asia/Shanghai],2022-10-01T18:00:00.000+08:00[Asia/Shanghai] # 2020-10-01早8点后,2022-10-01晚18点前可以访问

12.Weight

12.1介绍

多版本服务发布的时候,偶尔使用。如v1.0+v1.1两个版本同时发布服务。内容一致,实现机制不同。发布两个不同命名的服务集群。使用Gateway做负载均衡并设置权重。

代表同一个组中URI进行负载均衡。语法:Weight=组名,负载均衡权重

在Eureka中注册两个服务,这个服务(项目)是相同的,应用程序名分别叫做application-service1和application-service2。

Gateway在路由匹配时application-service1将占20%,application-service2将占80%。

12.2使用方式

修改配置文件application.yml:

yaml
- id: application-service1
 uri: lb://application-service1
 predicates:
 - Path=/service/**
 - Weight=group1,2
 filters:
 - StripPrefix=1
- id: application-service2
 uri: lb://application-service2
 predicates:
 - Path=/service/**
 - Weight=group1,8
 filters:
 - StripPrefix=1

五、过滤器-Filter

Filter的作用

在路由转发到代理服务之前和代理服务返回结果之后额外做的事情。Filter是在路由转发之后,被代理的服务执行前后运行的。只要Filter执行了,说明一定满足了谓词条件。

在Spring Cloud Gateway的路由中Filter分为:

  • 路由过滤器:框架内置的Filter实现都是路由过滤器,都是GatewayFilter实现类型。本章节所有案例都是路由过滤器。
  • 全局过滤器:框架未内置全局过滤器实现,需自定义。全局过滤器需实现接口GlobalFilter。

路由过滤器

之前使用StripPrefix就是框架内置的路由过滤器,所有内置Filter都实现GatewayFilter接口。使用时Filters属性中过滤器名为XXXGatewayFilterFactory的类对应的名称为XXX。

所有的过滤器都设置在filters属性中,且一个过滤器只能设置一组附加功能,如果需要有多个附加功能,添加多个相同过滤器。

StripPrefix

介绍

跳过路由uri中前几段后发送给下游。

使用方式

后续案例只提供filters属性部分配置。

yaml
spring:
 application:
 name: cloud-gateway
 cloud:
 gateway:
 discovery:
 locator:
 enabled: false
 lower-case-service-id: true
 routes:
 - id: application-service
 uri: lb://application-service
 predicates:
 - Path=/service/**
 filters: # 过滤器
 - StripPrefix=1 # 跳过路由uri中前1段后发送给下游。

AddRequestHeader

介绍

添加请求头参数,参数名和值之间使用逗号分隔。

使用方式

yaml
filters:
 - StripPrefix=1
 - AddRequestHeader=company,lhp

AddRequestParameter

介绍

添加请求表单参数,多个参数需要有多个过滤器。

使用方式

yaml
filters:
 - StripPrefix=1
 - AddRequestParameter=name,lhp
 - AddRequestParameter=age,18

AddResponseHeader

介绍

添加响应头。

使用方式

yaml
filters:
 - StripPrefix=1
 - AddResponseHeader=company,lhp

DedupeResponseHeader

介绍

对指定响应头去重复。配置语法:DedupeResponseHeader=响应头参数 或 DedupeResponseHeader=响应头参数,strategy。

去重策略strategy可选值:

  • RETAIN_FIRST :默认值,保留第一个
  • RETAIN_LAST 保留最后一个
  • RETAIN_UNIQUE 保留唯一的,出现重复的属性值,会保留一个。例如有两个My:bbb的属性,最后会只留一个。

使用方式

yaml
filters:
 - StripPrefix=1
 - DedupeResponseHeader=MyHeader,RETAIN_UNIQUE

六、使用Gateway实现限流

1.什么是限流 顾名思义,限流就是限制流量。就像你手机流量包是1个G的流量,用完了就没了。通过限流,我们可以很好地控制系统的QPS(Queries per Second),从而达到保护系统的目的。 2常见限流算法2.1计数器算法 以QPS(每秒查询率Queries-per-second)为100举例。 从第一个请求开始计时。每个请求让计数器加一。当到达100以后,其他的请求都拒绝。到下一秒开始时,计数清零,重新开始计数。

这种算法的问题是:如果1秒钟内前200ms请求数量已经到达了100,后面800ms中到来的所有请求都会被拒绝,这种情况称为“突刺现象”。 2.2****漏桶算法 漏桶算法可以解决突刺现象。 和生活中边玩手机边充电的情况一样,假定玩手机时耗电量是一定的(即服务器处理能力),手机电池容量有上限(请求缓存队列容量有上限),充电方式不同则充电速率不同(请求并不均匀,有瞬间到来大量请求的可能),导致电池充满的时间也不同(请求缓存队列已满),电池充满了,即使继续插电玩手机,也不会有充电现象(后续请求拒绝处理)。假设电池充满,继续充电,手机会爆炸,那就需要时刻关注电池电量了。

2.3****令牌桶算法 令牌桶算法可以说是对漏桶算法的一种改进。 在桶中放令牌,请求获取令牌后才能继续执行。如果桶中没有令牌,请求可以选择进行等待或者直接拒绝。 由于桶中令牌是按照一定速率放置的,所以可以一定程度解决突发访问。如果桶中令牌最多有100个,QPS最大为100。

3

使用RequestRateLimiter实现限流

RequestRateLimiter是基于Redis和Lua脚本实现的令牌桶算法。既然基于Redis记录令牌数据,那么必须有Spring Data Redis相关依赖。

POM依赖

修改Gateway网关工程中的POM依赖,增加下述依赖:

xml
<dependency>
 <groupId>org.springframework.boot</groupId>
 <artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

新建Key解析器

新建密钥解析器,com.lhp.resolver.MyKeyResolver

java
/**
 * 限流过滤器配置必要的类型。
 * 令牌桶算法中令牌工厂的组件之一,用于生成一个个与客户端对应的令牌key。 提供一个能对应客户端的数据。
 * 如:IP,用户登录名等。
 *
 * 当前类型对象,需要Spring容器管理。
 */
`@Component`
public class MyKeyResolver implements KeyResolver {
 `@Override`
 public Mono<String> resolve(ServerWebExchange exchange) {
 String ip = exchange.getRequest() // 获取请求对象
 .getRemoteAddress() // 获取客户端地址对象 InetSocketAddress
 .getAddress() // 获取客户端地址对象 InetAddress
 .getHostAddress(); // 获取客户端的主机地址(IP或唯一的主机名)
 return Mono.just(ip); // 创建返回结果对象
 }
}

解析器的返回结果,会影响到Redis中记录的令牌key。具体如下:

编辑配置文件

yaml
server:
 port: 9999
eureka:
 client:
 service-url:
 defaultZone: http://localhost:8761/eureka/
spring:
 application:
 name: cloud-gateway
 cloud:
 gateway:
 discovery:
 locator:
 enabled: false
 lower-case-service-id: true
 routes:
 - id: rateLimiter
 uri: lb://application-service
 predicates:
 - Path=/limiter/**
 filters:
 - StripPrefix=1
 - name: RequestRateLimiter
 args:
 keyResolver: '#{`@myKeyResolver`}' # 表达式, #{} 从容器找对象, `@beanId`
 # redis-rate-limiter是用于做令牌校验,和令牌生成的类型。gateway框架提供了基于Redis的实现。
 redis-rate-limiter.replenishRate: 1 # 每秒令牌生成速率
 redis-rate-limiter.burstCapacity: 2 # 令牌桶容量上限

启动并测试

使用JMeter访问 http://localhost:9999/limiter/test 若干次,结果是,第一秒可处理2个请求(令牌桶上限),后续每秒可以处理1个请求(令牌生成速率)。

七、使用Gateway实现服务降级

Spring Cloud Gateway 可以利用Hystrix实现服务降级等功能。

当Gateway进行路由转发时,如果发现下游服务连接超时允许进行服务降级。

实现原理:当连接超时时,使用Gateway自己的一个降级接口返回托底数据,保证程序继续运行。

POM依赖

增加新的依赖:

xml
<dependency>
 <groupId>org.springframework.cloud</groupId>
 <artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
</dependency>

编辑降级逻辑控制器

新建控制器com.lhp.controller.FallbackController

java
/**
 * 服务降级控制器
 */
`@Controller`
public class FallbackController {
 /**
 * 服务降级处理方法。
 * 当通过gateway转发请求的服务,不可用时,当前方法执行。返回降级数据。
 * `@return`
 */
 `@RequestMapping`(value = "/fallback", produces = {"text/html; charset=UTF-8"})
 `@ResponseBody`
 public String fallback(){
 return "<div style='color:red; text-align: center'>服务器忙,请稍后重试!</div>";
 }
}

编辑配置文件

在配置文件中filters属性添加新过滤器Hystrix。其中args.name 取值是任意的,最终会被设置为Hystrix的commandKey。但是不能省略,省略会导致org.springframework.cloud.gateway.filter.factory.HystrixGatewayFilterFactory.Config的Setter为null,因为没有设置Hystrix的commandKey等内容时就没有执行Setter的构造方法。

yaml
- id: hystrix
 uri: lb://application-service
 predicates:
 - Path=/hystrix/**
 filters:
 - StripPrefix=1
 - name: Hystrix
 args:
 	name: fallback # 随意定义的名称。相当于`@HystrixCommand注解中的commandKey属性`。
 	fallbackUri: forward:/fallback # 如果转发的服务不可用,请求转发到当前系统的哪一个路径上。

自定义过滤器

自定义全局过滤器

全局过滤器特性

全局过滤器不需要工厂,也不需要配置,只要被Spring容器管理,默认对所有的路由都生效。

可以使用GlobalFilter实现统一的权限验证、日志记录等希望对所有代理的项目都生效的内容都可以定义在全局过滤器中。且在项目中可以定义多个GlobalFilter的实现类。都可以自动执行。

编辑全局过滤器

java
/**
 * 自定义全局过滤器。
 * 必须实现接口GlobalFilter
 * 当前类型的对象,必须被spring容器管理。
 * 无须配置,所有路由都生效。
 *
 * 执行顺序:
 * 先执行网关过滤器,后执行全局过滤器
 * 多个全局过滤器,执行顺序由Spring boot扫描管理当前对象的顺序决定。
 * 每个过滤器,都是完整执行后,才执行下一个过滤器。
 */
`@Component`
public class MyGlobalFilter implements GlobalFilter {
 /**
 * 过滤方法。
 * 实现上,只有唯一的要求。必须调用方法chain.filter(exchange),并把方法的返回值,返回。
 * `@param` exchange
 * `@param` chain
 * `@return`
 */
 `@Override`
 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
 System.out.println("前置全局过滤");
 Mono<Void> result = chain.filter(exchange);
 System.out.println("后置全局过滤");
 return result;
 }
}

自定义路由过滤器

路由过滤器自定义要求

定义针对于Router的Filter。必须经由配置才能生效。注意:

  1. 类名必须定义为XxxGatewayFilterFactory。注入到Spring容器后使用时的名称就叫做xxx。
  2. 类建议继承AbstractGatewayFilterFactory。如果不继承,则必须实现接口GatewayFilterFactory,这种方式开发成本高。
  3. 所有需要传递进来的参数都配置到当前类的静态内部类Config中。

编辑路由过滤器

java
/**
 * 自定义网关过滤器(路由过滤器),必须经过配置使用才能生效的过滤器。
 * 要求当前类型的对象必须被spring容器管理。
 * 要求必须实现接口 GatewayFilterFactory, 建议继承AbstractGatewayFilterFactory
 *
 * 多个网关过滤器执行顺序:
 * 按照配置文件中,过滤器的配置顺序,依次运行。每个过滤器完整运行结束后,执行下一个过滤规则。
 */
`@Component`
public class LoggerFilterGatewayFilterFactory
 extends AbstractGatewayFilterFactory<LoggerFilterGatewayFilterFactory.Config> {

 /**
 * 建议提供2个构造方法。一个无参数。一个有参数,参数类型就是当前类型中的Config静态内部类的类对象类型。
 * 父类型,可以帮助解析配置文件,并创建Config对象。
 */
 public LoggerFilterGatewayFilterFactory(){
 this(Config.class);
 }

 public LoggerFilterGatewayFilterFactory(Class<Config> configClass){
 super(configClass);
 }

 /**
 * 如果需要简化配置方案。提供方法shortcutFieldOrder
 * 有当前方法,配置文件使用,可以简化配置为 LoggerFilter=abc
 * 没有当前方法,配置文件完整编写,内容是:
 * name: LoggerFilter
 * args:
 * remark: abc
 */
 `@Override`
 public List<String> shortcutFieldOrder() {
 return Arrays.asList("remark");
 }

 /**
 * 创建网关过滤器的方法。
 * `@param` config 就是配置文件中的内容,就是当前类型中的静态内部类对象。
 * `@return` 一般使用匿名内部类,创建GatewayFilter接口的实现对象。
 */
 `@Override`
 public GatewayFilter apply(Config config) {

 return new GatewayFilter() {
 /**
 * 过滤方法。要求必须调用chain.filter(exchange),并返回方法的返回结果
 * `@param` exchange
 * `@param` chain
 * `@return`
 */
 `@Override`
 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
 System.out.println("前置 - 日志过滤器 - config.remark = " + config.getRemark());
 Mono<Void> result = chain.filter(exchange);
 System.out.println("后置 - 日志过滤器 - config.remark = " + config.getRemark());

 return result;
 }
 };
 }

 /**
 * 定义静态内部类,作为配置对象
 * 定义的每个属性,都是用于在配置文件中配置的对应属性。
 * 必须提供getter和setter方法。
 */
 public static class Config{
 private String remark;

 public String getRemark() {
 return remark;
 }

 public void setRemark(String remark) {
 this.remark = remark;
 }
 }
}

编辑配置文件

  • id: logger

uri: lb://application-service

predicates:

  • Path=/logger/**

filters:

  • StripPrefix=1

  • LoggerFilter=simpleTestGatewayFilter

  • name: LoggerFilter

args:

remark: fullyTestGatewayFilter

若有收获,就点个赞吧

若有收获,就点个赞吧

若有收获,就点个赞吧