SpringCloud GateWay 万字详解

背景

在微服务架构中,通常一个系统会被拆分为多个微服务,面对这么多微服务客户端应该如何去调用呢?如果没有其他更优方法,我们只能记录每个微服务对应的地址,分别去调用,但是这样会有很多的问题和潜在因素。

  1. 客户端多次请求不同的微服务,会增加客户端代码和配置的复杂性,维护成本比价高。
  2. 认证复杂,每个微服务可能存在不同的认证方式,客户端去调用,要去适配不同的认证,
  3. 存在跨域的请求,调用链有一定的相对复杂性(防火墙 / 浏览器不友好的协议)。
  4. 难以重构,随着项目的迭代,可能需要重新划分微服务

为了解决上面的问题,微服务引入了 网关 的概念,网关为微服务架构的系统提供简单、有效且统一的API路由管理,作为系统的统一入口,提供内部服务的路由中转,给客户端提供统一的服务,可以实现一些和业务没有耦合的公用逻辑,主要功能包含认证、鉴权、路由转发、安全策略、防刷、流量控制、监控日志等。

网关在微服务中的位置:

SpringCloud GateWay 万字详解

SpringCloud GateWay 万字详解

网关对比

  • Zuul 1.0 : Netflix开源的网关,使用Java开发,基于Servlet架构构建,便于二次开发。因为基于Servlet内部延迟严重,并发场景不友好,一个线程只能处理一次连接请求。

  • Zuul 2.0 : 采用Netty实现异步非阻塞编程模型,一个CPU一个线程,能够处理所有的请求和响应,请求响应的生命周期通过事件和回调进行处理,减少线程数量,开销较小

SpringCloud GateWay 万字详解

  • 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路由管理⽅式。

  1. 可以与Spring Cloud Discovery Client(如Eureka)、Ribbon、Hystrix等组件配合使用,实现路由转发、负载均衡、熔断、鉴权、路径重写、⽇志监控等

  2. Gateway还内置了限流过滤器,实现了限流的功能。

  3. 设计优雅,容易拓展

基本概念

路由(Route)是GateWay中最基本的组件之一,表示一个具体的路由信息载体,主要由下面几个部分组成:

  1. id:路由唯一标识,区别于其他的route
  2. url: 路由指向的目的地URL,客户端请求最终被转发到的微服务
  3. order: 用于多个Route之间的排序,数值越小越靠前,匹配优先级越高
  4. predicate:断言的作用是进行条件判断,只有断言为true,才执行路由
  5. filter: 过滤器用于修改请求和响应信息

核心流程

SpringCloud GateWay 万字详解

核心概念:

  1. Gateway ClientSpring Cloud Gateway 发送请求
  2. 请求首先会被 HttpWebHandlerAdapter 进行提取组装成网关上下文
  3. 然后网关的上下文会传递到 DispatcherHandler ,它负责将请求分发给 RoutePredicateHandlerMapping
  4. RoutePredicateHandlerMapping 负责路由查找,并根据路由断言判断路由是否可用
  5. 如果过断言成功,由 FilteringWebHandler 创建过滤器链并调用
  6. 通过特定于请求的 Fliter 链运行请求,Filter 被虚线分隔的原因是Filter可以在发送代理请求之前(pre)和之后(post)运行逻辑
  7. 执行所有pre过滤器逻辑。然后进行代理请求。发出代理请求后,将运行“post”过滤器逻辑。
  8. 处理完毕之后将 Response 返回到 Gateway 客户端

Filter过滤器:

  • Filter在pre类型的过滤器可以做参数效验、权限效验、流量监控、日志输出、协议转换等。
  • Filter在post类型的过滤器可以做响应内容、响应头的修改、日志输出、流量监控等

核心思想

当用户发出请求达到 GateWay 之后,会通过一些匹配条件,定位到真正的服务节点,并且在这个转发过程前后,进行一些细粒度的控制,其中 Predicate(断言) 是我们的匹配条件,Filter 是一个拦截器,有了这两点,再加上URL,就可以实现一个具体的路由,核心思想:路由转发+执行过滤器链

这个过程就好比考试,我们考试首先要找到对应的考场,我们需要知道考场的地址和名称(id和url),然后我们进入考场之前会有考官查看我们的准考证是否匹配(断言),如果匹配才会进入考场,我们进入考场之后,(路由之前)会进行身份的登记和考试的科目,填写考试信息,当我们考试完成之后(路由之后)会进行签字交卷,走出考场,这个就类似我们的过滤器

SpringCloud GateWay 万字详解

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依赖,否则会有冲突无法启动

SpringCloud GateWay 万字详解

父类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-9001cloud-alibaba-gateway-9006通过gateway网关去访问9001的mxn/order看看。

首先我们在Nacos中看到我们服务是注册到Nacos中了

SpringCloud GateWay 万字详解

然后我们访问http://localhost:9001/mxn/hello,确保是成功的,在通过http://localhost:9006/mxn/hello去访问,也是OK,说明我们GateWay搭建成功,我们进入下一步

SpringCloud GateWay 万字详解

SpringCloud 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中具体的接口中
SpringCloud GateWay 万字详解

SpringCloud GateWay 万字详解

这里并不推荐,使用代码的方式来进行配置gateWay,大家有个了解就可以,因为代码的配置维护的成本比较高,而且对于一些需要修改的项,需要改代码才可以完成,这样不利于维护和拓展,所以还是推荐大家使用yml进行配置。

GateWay负载均衡

在上述的讲解中,我们已经掌握了 GateWay 的一些基本配置和两种使用方式,下面我们就来讲解一下 GateWay 如何实现负载均衡

我们只需要在9006中添加lb://nacos-provider就可以显示负载均衡。

SpringCloud GateWay 万字详解

当我们去访问http://localhost:9006/mxn/hello的时候,就可以看到9001和9002不停的切换

SpringCloud GateWay 万字详解

SpringCloud GateWay 万字详解

Predicate 断言

在这一篇中我们来研究一下 断言 ,我们可以理解为:当满足条件后才会进行转发路由,如果是多个,那么多个条件需要同时满足

在官方提供的断言种类有11种(最新的有12种类型):

具体地址:https://docs.spring.io/spring-cloud-gateway/docs/current/reference/html/#gateway-request-predicates-factories

SpringCloud GateWay 万字详解

  1. After:匹配在指定日期时间之后发生的请求。
  2. Before:匹配在指定日期之前发生的请求。
  3. Between:需要指定两个日期参数,设定一个时间区间,匹配此时间区间内的请求。
  4. Cookie:需要指定两个参数,分别为name和regexp(正则表达式),也可以理解Key和Value,匹配具有给定名称且其值与正则表达式匹配的Cookie。
  5. Header:需要两个参数header和regexp(正则表达式),也可以理解为Key和Value,匹配请求携带信息。
  6. Host:匹配当前请求是否来自于设置的主机。
  7. Method:可以设置一个或多个参数,匹配HTTP请求,比如GET、POST
  8. Path:匹配指定路径下的请求,可以是多个用逗号分隔
  9. Query:需要指定一个或者多个参数,一个必须参数和一个可选的正则表达式,匹配请求中是否包含第一个参数,如果有两个参数,则匹配请求中第一个参数的值是否符合正则表达式。
  10. RemoteAddr:匹配指定IP或IP段,符合条件转发。
  11. 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
SpringCloud GateWay 万字详解

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(正则表达式)表示任意字母 

小写字母匹配成功:

SpringCloud GateWay 万字详解

SpringCloud GateWay 万字详解

数字匹配不成功:

SpringCloud GateWay 万字详解

SpringCloud GateWay 万字详解

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表示数字 

请求头携带数字断言请求成功,

SpringCloud GateWay 万字详解

断言字母匹配失败:

SpringCloud GateWay 万字详解

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断言,请求成功
SpringCloud GateWay 万字详解
不满足Host断言失败

SpringCloud GateWay 万字详解

**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断言成功
SpringCloud GateWay 万字详解
PUT断言请求失败

SpringCloud GateWay 万字详解

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= 

断言匹配 请求成功
SpringCloud GateWay 万字详解

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项目,再去访问

SpringCloud GateWay 万字详解

自定义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参数,请求失败

SpringCloud GateWay 万字详解

当我们访问http://localhost:9006/mxn/nacos-provider/mxn/hello?id=1请求,请求成功

SpringCloud GateWay 万字详解

总结

到这里我们的GateWay就讲解完了,对于GateWay的核心点主要有三个RoutePredicateFilter,我们搞懂了这三点,基本上对于GateWay的知识就掌握的差不多了,GateWay核心的流程就是:路由转发+执行过滤器链,如果对文中有疑问的小伙伴,欢迎留言讨论。

创作不易,如果文中对你有帮助,记得点赞关注,您的支持是我创作的最大动力。

我是牧小农,怕什么真理无穷,进一步有进一步的欢喜~

点赞再看,养成习惯,微信搜索【牧小农】关注我获取更多资讯,风里雨里,小农等你,很高兴能够成为你的朋友。
项目源码地址:公众号回复 sentinel,即可免费获取源码

发表评论

相关文章