点赞再看,养成习惯,微信搜寻【牧小农】关注我获取更多资讯,风里雨里,小农等你,很快乐可能成为你的敌人。
我的项目源码地址:公众号回复 sentinel,即可收费获取源码
背景
在微服务架构中,通常一个零碎会被拆分为多个微服务,面对这么多微服务客户端应该如何去调用呢?如果没有其余更优办法,咱们只能记录每个微服务对应的地址,别离去调用,然而这样会有很多的问题和潜在因素。
- 客户端屡次申请不同的微服务,会减少客户端代码和配置的复杂性,保护老本比价高。
- 认证简单,每个微服务可能存在不同的认证形式,客户端去调用,要去适配不同的认证,
- 存在跨域的申请,调用链有肯定的绝对复杂性(防火墙 / 浏览器不敌对的协定)。
- 难以重构,随着我的项目的迭代,可能须要从新划分微服务
为了解决下面的问题,微服务引入了 网关 的概念,网关为微服务架构的零碎提供简略、无效且对立的 API 路由治理,作为零碎的对立入口,提供外部服务的路由直达,给客户端提供对立的服务,能够实现一些和业务没有耦合的专用逻辑,次要性能蕴含认证、鉴权、路由转发、安全策略、防刷、流量管制、监控日志等。
网关在微服务中的地位:
网关比照
- Zuul 1.0 : Netflix 开源的网关,应用 Java 开发,基于 Servlet 架构构建,便于二次开发。因为基于 Servlet 外部提早重大,并发场景不敌对,一个线程只能解决一次连贯申请。
- Zuul 2.0 : 采纳 Netty 实现异步非阻塞编程模型,一个 CPU 一个线程,可能解决所有的申请和响应,申请响应的生命周期通过事件和回调进行解决,缩小线程数量,开销较小
- GateWay : 是 Spring Cloud 的一个全新的 API 网关我的项目,替换 Zuul 开发的网关服务,基于 Spring5.0 + SpringBoot2.0 + WebFlux(基于⾼性能的 Reactor 模式响应式通信框架 Netty,异步⾮阻塞模型)等技术开发,性能高于 Zuul
- Nginx+lua : 性能要比下面的强很多,应用 Nginx 的反向代码和负载平衡实现对 API 服务器的负载平衡以及高可用,lua 作为一款脚本语言,能够编写一些简略的逻辑,然而无奈嵌入到微服务架构中
- Kong : 基于 OpenResty(Nginx + Lua 模块)编写的高可用、易扩大的,性能高效且稳固,反对多个可用插件(限流、鉴权)等,开箱即可用,只反对 HTTP 协定,且二次开发扩大难,不足更易用的治理和配置形式
GateWay
官网文档:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-starter
Spring Cloud Gateway 是 Spring Cloud 的一个全新的 API 网关我的项目,目标是为了替换掉 Zuul1,它基于 Spring5.0 + SpringBoot2.0 + WebFlux(基于⾼性能的 Reactor 模式响应式通信框架 Netty,异步⾮阻塞模型)等技术开发,性能⾼于 Zuul,官⽅测试,Spring Cloud GateWay 是 Zuul 的 1.6 倍,旨在为微服务架构提供⼀种简略无效的统⼀的 API 路由治理⽅式。
- 能够与 Spring Cloud Discovery Client(如 Eureka)、Ribbon、Hystrix 等组件配合应用,实现路由转发、负载平衡、熔断、鉴权、门路重写、⽇志监控等
- Gateway 还内置了限流过滤器,实现了限流的性能。
- 设计优雅,容易拓展
基本概念
路由(Route)是 GateWay 中最根本的组件之一,示意一个具体的路由信息载体,次要由上面几个局部组成:
- id:路由惟一标识,区别于其余的 route
- url:路由指向的目的地 URL,客户端申请最终被转发到的微服务
- order:用于多个 Route 之间的排序,数值越小越靠前,匹配优先级越高
- predicate:断言的作用是进行条件判断,只有断言为 true,才执行路由
- filter: 过滤器用于批改申请和响应信息
外围流程
外围概念:
Gateway Client
向Spring Cloud Gateway
发送申请- 申请首先会被
HttpWebHandlerAdapter
进行提取组装成网关上下文 - 而后网关的上下文会传递到
DispatcherHandler
,它负责将申请分发给RoutePredicateHandlerMapping
RoutePredicateHandlerMapping
负责路由查找,并依据路由断言判断路由是否可用- 如果过断言胜利,由
FilteringWebHandler
创立过滤器链并调用 - 通过特定于申请的
Fliter
链运行申请,Filter
被虚线分隔的起因是 Filter 能够在发送代理申请之前(pre)和之后(post)运行逻辑 - 执行所有 pre 过滤器逻辑。而后进行代理申请。收回代理申请后,将运行“post”过滤器逻辑。
- 处理完毕之后将
Response
返回到Gateway
客户端
Filter 过滤器:
- Filter 在 pre 类型的过滤器能够做参数效验、权限效验、流量监控、日志输入、协定转换等。
- Filter 在 post 类型的过滤器能够做响应内容、响应头的批改、日志输入、流量监控等
核心思想
当用户发出请求达到 GateWay
之后,会通过一些匹配条件,定位到真正的服务节点,并且在这个转发过程前后,进行一些细粒度的管制,其中 Predicate(断言) 是咱们的匹配条件,Filter 是一个拦截器,有了这两点,再加上 URL,就能够实现一个具体的路由,核心思想:路由转发 + 执行过滤器链
这个过程就好比考试,咱们考试首先要找到对应的考场,咱们须要晓得考场的地址和名称(id 和 url),而后咱们进入考场之前会有考官查看咱们的准考证是否匹配(断言),如果匹配才会进入考场,咱们进入考场之后,(路由之前)会进行身份的注销和考试的科目,填写考试信息,当咱们考试实现之后(路由之后)会进行签字交卷,走出考场,这个就相似咱们的过滤器
Route(路由):构建网关的根底模块,由 ID、指标 URL、过滤器等组成
Predicate(断言):开发人员能够匹配 HTTP 申请中的内容(申请头和申请参数),如果申请断言匹配贼进行路由
Filter(过滤):GateWayFilter 的实例,应用过滤器,能够在申请被路由之前或者之后对申请进行批改
框架搭建
通过上述解说曾经理解了根底概念,咱们来入手搭建一个 GateWay
我的项目,来看看它到底是如何运行的
新建我的项目:cloud-alibaba-gateway-9006
版本对应:
GateWay 属于 SprinigCloud 且有 web 依赖,在咱们导入对应依赖时,要留神版本关系,咱们这里应用的版本是 2.2.x 的版本,所以配合应用的 Hoxton.SR5
版本
在这里咱们要留神的是引入 GateWay 肯定要删除 spring-boot-starter-web 依赖,否则会有抵触无奈启动
父类 pom 援用:
<spring-cloud-gateway-varsion>Hoxton.SR5</spring-cloud-gateway-varsion>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud-gateway-varsion}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
子类 POM 援用:
<dependencies>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<version>2.2.5.RELEASE</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
</dependency>
</dependencies>
yml 配置
server:
port: 9006
spring:
application:
name: cloud-gateway-service
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
discovery:
locator:
enabled: false #开启注册核心路由性能
routes: # 路由
- id: nacos-provider #路由 ID,没有固定要求,然而要保障惟一,倡议配合服务名
uri: http://localhost:9001/nacos-provider # 匹配提供服务的路由地址 lb:// 示意开启负载平衡
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
咱们在之前的 cloud-alibaba-nacos-9001
我的项目中增加上面测试代码
@RestController
@RequestMapping("/mxn")
public class DemoController {@Value("${server.port}")
private String serverPort;
@GetMapping(value = "/hello")
public String hello(){return "hello world,my port is:"+serverPort;}
}
启动 Nacos、cloud-alibaba-nacos-9001
、cloud-alibaba-gateway-9006
通过 gateway 网关去拜访 9001 的 mxn/order 看看。
首先咱们在 Nacos 中看到咱们服务是注册到 Nacos 中了
而后咱们拜访 http://localhost:9001/mxn/hello
,确保是胜利的,在通过http://localhost:9006/mxn/hello
去拜访,也是 OK,阐明咱们 GateWay 搭建胜利,咱们进入下一步
在上述办法中咱们是通过 YML 去实现的配置,GateWay
还提供了另外一种配置形式,就是通过代码的形式进行配置,@Bean
注入一个 RouteLocator
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
@Configuration
public class GateWayConfig {
/*
配置了一个 id 为 path_mxn 的路由规定
当拜访地址 http://localhost:9999/mxn/**
就会转发到 http://localhost:9001/nacos-provider/mxn/ 任何地址
*/
@Bean
public RouteLocator gateWayConfigInfo(RouteLocatorBuilder routeLocatorBuilder){
// 构建多个路由 routes
RouteLocatorBuilder.Builder routes = routeLocatorBuilder.routes();
// 具体路由地址
routes.route("path_mxn",r -> r.path("/mxn/**").uri("http://localhost:9001/nacos-provider")).build();
// 返回所有路由规定
return routes.build();}
}
咱们能够将路由正文掉之后看一下,重启 9006 服务,拜访地址http://localhost:9006/mxn/hello
就能够转发到 9001 中具体的接口中
这里并不举荐,应用代码的形式来进行配置gateWay
,大家有个理解就能够,因为代码的配置保护的老本比拟高,而且对于一些须要批改的项,须要改代码才能够实现,这样不利于保护和拓展,所以还是举荐大家应用 yml 进行配置。
GateWay 负载平衡
在上述的解说中,咱们曾经把握了 GateWay
的一些根本配置和两种应用形式,上面咱们就来解说一下 GateWay
如何实现负载平衡
咱们只须要在 9006 中增加 lb://nacos-provider
就能够显示负载平衡。
当咱们去拜访 http://localhost:9006/mxn/hello
的时候,就能够看到 9001 和 9002 不停的切换
Predicate 断言
在这一篇中咱们来钻研一下 断言,咱们能够了解为:当满足条件后才会进行转发路由,如果是多个,那么多个条件须要同时满足
在官网提供的断言品种有 11 种(最新的有 12 种类型):
具体地址:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories
- After:匹配在指定日期工夫之后产生的申请。
- Before:匹配在指定日期之前产生的申请。
- Between:须要指定两个日期参数,设定一个工夫区间,匹配此工夫区间内的申请。
- Cookie:须要指定两个参数,别离为 name 和 regexp(正则表达式),也能够了解 Key 和 Value,匹配具备给定名称且其值与正则表达式匹配的 Cookie。
- Header:须要两个参数 header 和 regexp(正则表达式),也能够了解为 Key 和 Value,匹配申请携带信息。
- Host:匹配以后申请是否来自于设置的主机。
- Method:能够设置一个或多个参数,匹配 HTTP 申请,比方 GET、POST
- Path:匹配指定门路下的申请,能够是多个用逗号分隔
- Query:须要指定一个或者多个参数,一个必须参数和一个可选的正则表达式,匹配申请中是否蕴含第一个参数,如果有两个参数,则匹配申请中第一个参数的值是否合乎正则表达式。
- RemoteAddr:匹配指定 IP 或 IP 段,符合条件转发。
- Weight:须要两个参数 group 和 weight(int),实现了路由权重性能,依照路由权重抉择同一个分组中的路由
1. After:示意配置工夫之后才进行转发
工夫戳获取代码, 用于工夫代码的获取:
public static void main(String[] args) {ZonedDateTime zbj = ZonedDateTime.now();// 默认时区
System.out.println(zbj);
}
spring:
application:
name: cloud-gateway-service
cloud:
nacos:
discovery:
server-addr: localhost:8848
gateway:
discovery:
locator:
enabled: true #开启注册核心路由性能
routes: # 路由
- id: nacos-provider #路由 ID,没有固定要求,然而要保障惟一,倡议配合服务名
uri: lb://nacos-provider # 匹配提供服务的路由地址 lb:// 示意开启负载平衡
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
- After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
如果在时间段之前拜访则 404
Before
匹配 ZonedDateTime
类型的工夫,示意匹配在指定日期工夫之前的申请,之后的申请则回绝 404 谬误
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
- Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
Between
Between
能够匹配 ZonedDateTime
类型的工夫,由两个 ZonedDateTime
参数组成,第一个参数为开始工夫,第二参数为完结工夫,逗号进行分隔,匹配在指定的开始工夫与完结工夫之内的申请,配置如下:
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
- Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
Cookie
由两个参数组成,别离为 name(Key)
和regexp(正则表达式)(Value
),匹配具备给定名称且其值与正则表达式匹配的 Cookie。
路由规定会通过获取 Cookie name 值和正则表达式去匹配,如果匹配上就会执行路由,如果匹配不上则不执行。
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
- Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
小写字母匹配胜利:
数字匹配不胜利:
Header
由两个参数组成,第一个参数为Header 名称
,第二参数为Header 的 Value 值
, 指定名称的其值和正则表达式相匹配的 Header 的申请
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
# - Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
- Header=headerName, \d+ # \d 示意数字
申请头携带数字断言申请胜利,
断言字母匹配失败:
Host
匹配以后申请是否来自于设置的主机。
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
# - Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
# - Header=headerName, \d+ # \d 示意数字
- Host=**.muxiaonong.com #匹配以后的主机地址收回的申请
满足 Host 断言,申请胜利
不满足 Host 断言失败
Method
能够设置一个或多个参数,匹配 HTTP 申请,比方POST,PUT,GET,DELETE
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
# - Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
# - Header=headerName, \d+ # \d 示意数字
# - Host=**.muxiaonong.com #匹配以后的主机地址收回的申请
- Method=POST,GET
GET 断言胜利
PUT 断言申请失败
Query
由两个参数组成,第一个为参数名称(必须),第二个为参数值(可选 - 正则表达式),匹配申请中是否蕴含第一个参数,如果有两个参数,则匹配申请中第一个参数的值是否合乎第二个正则表达式。
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
# - Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
# - Header=headerName, \d+ # \d 示意数字
# - Host=**.muxiaonong.com #匹配以后的主机地址收回的申请
# - Method=POST,GET
- Query=id,.+ # 匹配任意申请参数,这里如果须要匹配多个参数,能够写多个 - Query=
断言匹配 申请胜利
RemoteAddr
参数由 CIDR 表示法(IPv4 或 IPv6)字符串组成,也就是匹配的 ID 地址,配置如下:
predicates: # 断言
- Path=/mxn/** # 断言,门路相匹配进行路由
# - After=2022-06-11T16:30:40.785+08:00[Asia/Shanghai] #在这个工夫之后的申请够能够进行通过,之前的则不能进行拜访
# - Before=2022-06-11T15:30:40.785+08:00[Asia/Shanghai]
# - Between=2022-06-11T15:30:40.785+08:00[Asia/Shanghai],2022-06-11T16:30:40.785+08:00[Asia/Shanghai]
# - Cookie=muxiaonong,[a-z]+ # 匹配 Cookie 的 key 和 value(正则表达式)示意任意字母
# - Header=headerName, \d+ # \d 示意数字
# - Host=**.muxiaonong.com #匹配以后的主机地址收回的申请
# - Method=POST,GET
# - Query=id,.+ # 匹配任意申请参数,这里如果须要匹配多个参数,能够写多个 Query
- RemoteAddr=192.168.1.1/24
RemoteAddr
须要两个参数 group 和 weight(int)权重数值,实现了路由权重性能,示意将雷同的申请依据权重跳转到不同的 uri 地址,要求 group 的名称必须统一
routes: # 路由
- id: weight_high #路由 ID,没有固定要求,然而要保障惟一,倡议配合服务名
uri: https://blog.csdn.net/qq_14996421
predicates: # 断言
- Weight=groupName,8
- id: weight_low #路由 ID,没有固定要求,然而要保障惟一,倡议配合服务名
uri: https://juejin.cn/user/2700056290405815
predicates: # 断言
- Weight=groupName,2
间接拜访 http://localhost:9006/
能够看到咱们申请的地址成 8 / 2 比例交替显示,80% 的流量转发到 https://blog.csdn.net/qq_14996421,将约 20% 的流量转发到 https://juejin.cn/user/2700056290405815
Predicate 就是为了实现一组匹配规定,让申请过去找到对应的 Route 进行解决。如果有多个断言则全副命中后进行解决
GateWay Filter
路由过滤器容许批改传入的 HTTP 申请或者返回的 HTTP 响应,路由过滤器的范畴是特定的路由.
Spring Cloud GateWay 内置的 Filter 生命周期有两种:pre(业务逻辑之前)、post(业务逻辑之后)
GateWay 自身自带的 Filter 分为两种:GateWayFilter(繁多)、GlobalFilter(全局)
GateWay Filter 提供了丰盛的过滤器的应用,繁多的有 32 种,全局的有 9 种,有趣味的小伙伴能够理解一下。
官网参考网址:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#global-filters
StripPrefix
StripPrefix 在咱们以后申请中,通过规定值去掉某一部分地址,比方咱们有一台服务中退出了一个前端 nacos-provider
想要通过这个去拜访,咱们在我的项目 cloud-alibaba-nacos-9001
中退出 context-path
server:
port: 9001
servlet:
context-path: /nacos-provider
当初 9001 的拜访门路变为http://localhost:9001/nacos-provider/mxn/hello
,然而如果咱们通过网关去拜访门路就会变成http://localhost:9006/mxn/nacos-provider/mxn/hello
这个时候咱们通过这个门路去拜访是拜访不胜利的,想要解决这个办法,这个就用到了咱们FIlter
中的 StripPrefix
routes: # 路由
- id: nacos-provider #路由 ID,没有固定要求,然而要保障惟一,倡议配合服务名
uri: lb://nacos-provider
predicates: # 断言
- Path=/mxn/** # 匹配对应地址
filters:
- StripPrefix=1 # 去掉地址中的第一局部
咱们重新启动 9006 我的项目,再去拜访
自定义 Filter
尽管 Gateway 给咱们提供了丰盛的内置 Filter,然而理论我的项目中,自定义 Filter 的场景十分常见,因而独自介绍下自定义 FIlter 的应用。
想要实现 GateWay 自定义过滤器,那么咱们须要实现 GatewayFilter 接口和 Ordered 接口
@Slf4j
@Component
public class MyFilter implements Ordered, GlobalFilter {
/**
* @param exchange 能够拿到对应的 request 和 response
* @param chain 过滤器链
* @return 是否放行
*/
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 获取第一个参数
String id = exchange.getRequest().getQueryParams().getFirst("id");
// 打印以后工夫
log.info("MyFilter 以后申请工夫为:"+new Date());
// 判断用户是否存在
if(StringUtils.isEmpty(id)){log.info("用户名不存在,非法申请!");
// 如果 username 为空,返回状态码为 407,须要代理身份验证
exchange.getResponse().setStatusCode(HttpStatus.PROXY_AUTHENTICATION_REQUIRED);
// 后置过滤器
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
/**
* 设定过滤器的优先级,值越小则优先级越高
* @return
*/
@Override
public int getOrder() {return 0;}
}
当咱们拜访 http://localhost:9006/mxn/nacos-provider/mxn/hello
申请,没有携带 ID 参数,申请失败
当咱们拜访 http://localhost:9006/mxn/nacos-provider/mxn/hello?id=1
申请,申请胜利
总结
到这里咱们的 GateWay
就解说完了,对于 GateWay 的外围点次要有三个 Route\Predicate\Filter
,咱们搞懂了这三点,基本上对于GateWay
的常识就把握的差不多了,GateWay 外围的流程就是:路由转发 + 执行过滤器链,如果对文中有疑难的小伙伴,欢送留言探讨。
创作不易,如果文中对你有帮忙,记得点赞关注,您的反对是我创作的最大能源。
我是牧小农,怕什么真谛无穷,进一步有进一步的欢喜~