服务网关-Gateway
服务网关是微服务的第一道关卡,目前Nginx是应用最广泛的反向代理技术,在各个大厂的核心业务系统中都有大量应用,不过Nginx可不是使用Java来配置的,使用和配置Nginx需要掌握它的语法树。Spring Cloud则为广大的Java技术人员提供了更加“编程友好”的方式来构建网关层,那就是Gateway网关层组件。我们可以通过Java代码或者是yml配置文件的方式编写自己的路由规则,并通过内置过滤器或自定义过滤器来实现复杂的业务需求(比如在网关层做令牌验证)。Gateway本身也集成了强大的限流功能,结合使用Redis+SpEL表达式,可以对业务系统进行精准限流。
Gateway可以做什么?
在Spring Cloud中,Gateway是借助Eureka的服务发现机制来实现服务寻址的,负载均衡则依靠Ribbon。
路由功能
Gateway中可以定义很多个Route,一个Route就是一套包含完整转发规则的路由,主要由三部分组成:
-
断言(Predicate)集合:断言是路由处理的第一个环节,它是路由的匹配规则,它决定了一个网络请求是否可以匹配给当前路由来处理。之所以它是一个集合的原因是我们可以给一个路由添加多个断言,当每个断言都匹配成功以后才算过了路由的第一关。
-
过滤器(filter)集合:如果请求通过了前面的断言匹配,那就表示它被当前路由正式接手了,接下来这个请求就要经过一系列的过滤器集合。过滤器的功能就是八仙过海各显神通了,可以对当前请求做一系列的操作,比如说权限验证,或者将其他非业务性校验的规则提到网关过滤器这一层。在过滤器这一层依然可以通过修改Response里的Status Code达到中断效果,比如对鉴权失败的访问请求设置Status Code为403之后中断操作。
-
URI:如果请求顺利通过过滤器的处理,接下来就到了最后一步,那就是转发请求。URI是统一资源标识符,它可以是一个具体的网址,也可以是IP+端口的组合,或者是Eureka中注册的服务名称。
关于负载均衡: 对最后一步寻址来说,如果采用基于Eureka的服务发现机制,那么在Gateway的转发过程中可以采用服务注册名的方式来调用,后台会借助Ribbon实现负载均衡(可以为某个服务指定具体的负载均衡策略),其配置方式如:lb://FEIGN-SERVICE-PROVIDER/
,前面的lb就是指代Ribbon作为LoadBalancer。
常用断言(Predicate)介绍
一个请求在抵达网关层后,首先就要进行断言匹配,在满足所有断言之后才会进入Filter阶段。
1、路径匹配:
Path断言是最常用的一个断言请求,几乎所有路由都要使用到它,我们来看一下它的例子
.route(r -> r.path("/gateway/**")
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
.route(r -> r.path("/baidu")
.uri("http://baidu.com:80/")
)
Path断言的使用非常简单,就像我们在Controller中配置@RequestPath的方式一样,在Path断言中填上一段URL匹配规则,当实际请求的URL和断言中的规则相匹配的时候,就下发到该路由中URI指定的地址,这个地址可以是一个具体的HTTP地址,也可以是Eureka中注册的服务名称。在上面的例子中,如果我们访问“/gateway/test”,这个路径将匹配到第一个路由。
2、M:ethod断言:
这个断言是专门验证HTTP Method的,在下面的例子中,我们把Method断言和Path断言通过一个and连接符合并起来,共同作用于路由判断,当我们访问“/gateway/sample”并且HTTP Method是GET的时候,将适配下面的路由
.route(r -> r.path("/gateway/**")
.and().method(HttpMethod.GET)
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
3、RequestParam匹配:
请求断言也是在业务中经常使用的,它会从ServerHttpRequest中的Parameters列表中查询指定的属性,有如下两种不同的使用方式
.route(r -> r.path("/gateway/**")
.and().method(HttpMethod.GET)
.and().query("name", "test")
.and().query("age")
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
- 属性名验证:
query("age")
,此时断言只会验证QueryPrameters列表中是否包含了一个叫age的属性,并不会验证它的值; - 属性值验证:
query("name", "test")
,它不仅会验证name属性是否存在,还会验证它的值是不是和断言相匹配,比如当前的断言会验证请求参数中的name属性值是不是test,第二个参数实际上是一个用作模式匹配的正则表达式。
4、Header断言:
这个断言会检查Header中是否包含了响应的属性,通常可以用来验证请求是否携带了访问令牌,比如如下设置:
.route(r -> r.path("/gateway/**")
.and().header("Authorization")
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
上面的断言指定了Header中必须包含一个Authorization属性,Header断言和Query断言一样,也可以通过传入两个参数的形式对属性值进行检查。
5、Cookie断言:
顾名思义,Cookie验证的是Cookie中保存的信息,Cookie断言和上面介绍的两种断言使用方式大同小异,唯一的不同是它必须连同属性值一同验证,不能单独只验证属性是否存在,示例如下:
.route(r -> r.path("/gateway/**")
.and().cookie("name", "test")
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
6、时间片匹配:
时间匹配有三种模式,分别是Before、After和Between,这些断言指定了在什么时间范围内路由才会生效
.route(r -> r.path("/gateway/**")
.and().before(ZonedDateTime.now().plusMinutes(1))
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
以Before断言为例,它接受的是一个ZonedDateTime参数,用来表示生效的时间。比如上面的例子中我们使用了ZonedDateTime.now().plusMinutes(1)
表示当前时间的后一分钟,由于路由的规则是在项目启动时加载的,那么这里的当前时间也就是项目加载完成的时间,因此该路由的有效时间就是服务启动后的一分钟以内。
过滤器(filter)的实现方式
在Gateway中实现一个过滤器非常简单,只要实现GatewayFilter接口的默认方法就好了
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 随意发挥,例如此处可以写鉴权的逻辑
return chain.filter(exchange);
}
- ServerWebExchange:这是Spring封装的HTTP request-response交互协议,从中我们可以获取request和response中的各种请求参数,也可以向其中添加内容。
- GatewayFilterChain:它是过滤器的调用链,在方法结束的时候我们需要将exchange对象传入调用链中的下一个对象。
过滤器排序:
在Gateway中我们可以通过实现org.springframework.core.Ordered接口,来给过滤器指定执行顺序,比如下面的代码实现了Ordered接口方法,将过滤器执行顺序设置为0:
@Override
public int getOrder() {
return 0;
}
过滤器示例
Gateway的组件库相当丰富,有二十多个过滤器,下面随便展示几个:
1、Header过滤器:
这个系列有很多组过滤器,AddRequestHeader和AddResponseHeader,分别向Request和Response里加入指定Header。相应的RemoveRequestHeader和RemoveResponseHeader分别做移除操作,用法也很简单:
.filters(f -> f.addResponseHeader("who", "gateway-header"))
上面的例子会向header中添加一个who的属性,对应的值是gateway-header。
2、StripPrefix过滤器:
这是个比较常用的过滤器,它的作用是去掉部分URL路径。比如我们的过滤器配置如下:
.route(r -> r.path("/gateway-test/**")
.filters(f -> f.stripPrefix(1))
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
假如HTTP请求访问的是/gateway-test/sample/update
,如果没有StripPrefix过滤器,那么转发到FEIGN-SERVICE-PROVIDER服务的访问路径也是一样的。当我们添加了这个过滤器之后,Gateway就会根据“stripPrefix(1)”中的值截取URL中的路径,比如这里我们设置的是1,那么就去掉一个前缀,最终发送给后台服务的路径变成了“/sample/update”。
3、PrefixPath过滤器:
它和StripPrefix的作用是完全相反的,会在请求路径的前面加入前缀
.route(r -> r.path("/gateway-test/**")
.filters(f -> f.prefixPath("go"))
.uri("lb://FEIGN-SERVICE-PROVIDER/")
)
比如说我们访问“/gateway-test/sample”的时候,上面例子中配置的过滤器就会把请求发送到“/go/gateway-test/sample”。
4、RedirectTo过滤器:
它可以把收到特定状态码的请求重定向到一个指定网址:
.filters(f -> f.redirect(302, "https://www.imooc.com/"))
上面的例子接收HTTP status code和URL两个参数,如果请求结果是404,则重定向到第二个参数指定的页面,这个功能也可以做统一异常处理,将Unauthorized或Forbidden请求重定向到登录页面。
5、SaveSession过滤器:
我们知道微服务是无状态的会话,所以大多都不依赖session机制,但是如果你有分布式session的需求,比如说某些功能是基于spring-session和spring-security来实现的,那么这个过滤器或许对你有用,它在调用服务之前都会强制保存session
.filters(f -> f.saveSession())
代码示例
pom
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<parent>
<artifactId>spring-cloud-demo</artifactId>
<groupId>com.jinsh</groupId>
<version>0.0.1-SNAPSHOT</version>
<relativePath>../../pom.xml</relativePath>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>gateway-sample</artifactId>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
</dependencies>
</project>
启动类
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.client.discovery.EnableDiscoveryClient;
@SpringBootApplication
@EnableDiscoveryClient
public class GatewayApplication {
public static void main(String[] args) {
SpringApplication.run(GatewayApplication.class, args);
}
}
配置文件
spring:
application:
name: gateway-service
cloud:
gateway:
discovery:
locator:
enabled: true # 这个设置为true,它就会去注册中心拉取所有服务的路由规则
lower-case-service-id: true # 路径改为小写
# yml配置路由规则
routes:
- id: feigncClient
uri: lb://FEIGN-CLIENT # lb负载均衡,FEIGN-CLIENT服务
predicates:
- Path=/yml/** # 断言规则,/yml/**的请求将匹配到FEIGN-CLIENT
filters:
- StripPrefix=1 # 去除了第一个路径 /yml/sayHi -> /sayHi
eureka:
client:
service-url:
defaultZone: http://localhost:20000/eureka/
server:
port: 63001
management:
endpoints:
web:
exposure:
include: '*'
endpoint:
health:
show-details: always
自定义过滤器实现接口计时功能
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.stereotype.Component;
import org.springframework.util.StopWatch;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* 自定义过滤器实现接口计时功能
*/
@Slf4j
@Component
// 实现GlobalFilter接口,就可以成为全局过滤器
public class TimerFilter implements GatewayFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
StopWatch timer = new StopWatch();
timer.start(exchange.getRequest().getURI().getRawPath());
return chain.filter(exchange).then(
Mono.fromRunnable(() -> {
timer.stop();
log.info(timer.prettyPrint());
})
);
}
@Override
public int getOrder() {
return 0;
}
}
java代码方式配置路由规则
=import org.springframework.beans.factory.annotation.Autowired;
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;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
@Configuration
public class GatewayConfiguration {
@Autowired
private TimerFilter timerFilter;
/**
* java代码配置路由规则
* @param builder
* @return
*/
@Bean
@Order
public RouteLocator customizedRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/java/**")
.and().method(HttpMethod.GET)
.and().header("name") // header里面需要包含name属性
.filters(f -> f.stripPrefix(1)
.addRequestParameter("java-param", "gateway-config") // 在Request中添加参数
.filter(timerFilter)
)
.uri("lb://FEIGN-CLIENT")
).build();
}
}
GateWay 限流
这里采用令牌桶计数的方式做限流,下面直接上操作
准备工作:
我们的Best Practice是基于Redis来实现限流,因此要保证本地启动了Redis服务。同时将下列配置加入到Gateway的配置文件中:
spring:
application:
name: gateway-service
redis:
host: localhost
port: 6379
database: 0
这里是配置Redis连接信息的,假如你不配置的话,Gateway也会尝试用默认配置项来连接Redis。但如果你在Redis配置信息中提供了错误的IP或者Port的话,调用方法时依然会成功,不过限流功能就失效了,因为底层的Netty服务无法连接到Redis,也就无法提供限流支持。但Gateway为了保证服务可用性,限流功能的异常并不会阻碍正常的方法调用。
Key Resolver:
Gateway的限流组件要求定义一个Key Resolver用来对每次路由请求生成一个Key,这个Key就是一个限流分组的标识,每个Key相当于一个令牌桶。假如我们限定了一个服务每秒只能被调用3次,这个限制会对不同的Key单独计数,我们把调用方机器的Host Name作为限流Key,那么来自同一台机器的调用将落到同一个Key下面,也就是说在这个场景下,每台机器都独立计算单位时间调用量。
创建Key Resolver的方式很简单:
@Bean
public KeyResolver remoteAddrKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest().getRemoteAddress().getHostName());
}
上面的例子创建了基于Host Name的令牌生成器,我们可以根据自己的业务来选择合适的Key,比如说可以在接口层面做限流(使用接口的Path作为Key),还可以从Request中提取业务字段作为Key(比如用户ID等)。
配置文件方式配置过滤器:
spring:
cloud:
gateway:
routes:
- id: feignapi
uri: lb://FEIGN-SERVICE-PROVIDER
predicates:
- Path=/feign-api/**
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
key-resolver: '#{@remoteAddrKeyResolver}' # 这里注入的就是在上一步中我们定义的Key Resolver,它使用SpEL表达式从Spring上下文中获取指定Bean
redis-rate-limiter.replenishRate: 10 # 令牌桶每秒的平均填充速度
redis-rate-limiter.burstCapacity: 20 # 令牌桶总量
java代码方式配置过滤器:
package com.jinsh;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.cloud.gateway.filter.ratelimit.RedisRateLimiter;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.context.annotation.Primary;
import reactor.core.publisher.Mono;
/**
* 限流配置
*/
@Configuration
public class RedisLimitConfiguration {
// HostAddressKey
@Bean
@Primary
public KeyResolver remoteAddressKeyResolver() {
return exchange -> Mono.just(
exchange.getRequest()
.getRemoteAddress()
.getAddress()
.getHostAddress()
);
}
@Bean("redisLimiter")
@Primary
public RedisRateLimiter redisLimiter() {
// 10:令牌桶每秒的平均填充速度,20:令牌桶总量
return new RedisRateLimiter(10, 20);
}
}
路由规则
@Configuration
public class GatewayConfiguration {
@Autowired
private KeyResolver hostNameResolver;
@Autowired
@Qualifier("redisLimiter")
private RateLimiter rateLimiter;
/**
* java代码配置路由规则
* @param builder
* @return
*/
@Bean
@Order
public RouteLocator customizedRoutes(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.path("/java/**")
.filters(f -> f.requestRateLimiter(c -> {
c.setKeyResolver(hostNameResolver);
c.setRateLimiter(rateLimiter);
}))
.uri("lb://FEIGN-CLIENT")
).build();
}
}