Java | Spring Cloud Gateway 使用和一些实现细节
网关中间件
所谓的API网关,就是指系统的统一入口,它封装了应用程序的内部结构,为客户端提供统一服务,一些与业务本身功能无关的公共逻辑可以在这里实现,诸如认证、鉴权、监控、路由转发等。
中间件 | Nginx | Kong | Netflix Zuul | Spring Cloud Gateway | shenyu |
---|---|---|---|---|---|
主要开发语言 | C | Lua | Java | Java | Java |
依赖关系 | 无 | 基于 Nginx_Lua模块 | 无 | 无 | 无 |
支持协议 | HTTP | HTTP, GRPC | HTTP, | HTTP | HTTP, WebSocket, Dubbo, GRPC |
扩展 | 基于 Lua 脚本 | 基于 Lua 脚本 | Java 写过滤器 | Java 写过滤器、断言 | Java 写插件 |
编程模型 | 多进程 + io多路复用 | 基于 Nginx | Zuul 1 采用 Servlet, Zuul 2 采用 Netty | Spring WebFlux(Netty Reactor) | Netty Reactor |
配置页面 | 无 | 丰富 | 无 | 无 | 丰富 |
负载均衡 | 写死的 | 支持 Consul(间接可以支持使用 Consul 的 Spring Cloud) | Spring Cloud 相关 | Spring Cloud 相关 | 通过各种插件实现 |
GitHub | nginx/nginx | Kong/kong | Netflix/zuul | spring-cloud/spring-cloud-gateway | apache/incubator-shenyu |
Netflix Zuul 使用和一些实现
Spring Cloud Gateway 使用和一些实现细节
官网地址:https://docs.spring.io/spring-cloud-gateway/docs/2.2.8.RELEASE/reference/html/
默认已经提供的功能:
http 请求转发和负责均衡
websocket 的请求转发和负载均衡
限流
Spring Boot 项目中引入依赖,具体的版本号视情况而定。
1<dependency>
2 <groupId>org.springframework.cloud</groupId>
3 <artifactId>spring-cloud-starter-gateway</artifactId>
4</dependency>
如果需要开启负载均衡,需要引入对应的依赖,比如使用 Nacos 则需要引入
1<dependency>
2 <groupId>com.alibaba.cloud</groupId>
3 <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
4</dependency>
日常使用
Spring Cloud Gateway 相关配置均在 spring.cloud.gateway
下,需要配置均在这里
一、全局跨域配置
1spring:
2 cloud:
3 gateway:
4 globalcors:
5 cors-configurations:
6 '[/**]': // 全部请求
7 allow-credentials: true
8 allowed-origins: "*"
9 allowed-headers: "*"
10 allowed-methods: "*"
11 max-age: 3600
各个参数可以定制化
二、负载均衡失效的配置
如果请求时,配置了负载均衡,且无法找对对应的服务实例,默然返回 502,通过 loadbalancer.use404 可以将其改为 404 返回
1spring:
2 cloud:
3 gateway:
4 loadbalancer:
5 use404: true
三、各种谓词路由的配置
1. 时间谓词路由
这个主要控制某个时间范围走指定的路由
指定时间点之前
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: before_route
6 uri: https://example.org
7 predicates:
8 - Before=2017-01-20T17:42:47.789-07:00[America/Denver]
指定时间段范围内
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: between_route
6 uri: https://example.org
7 predicates:
8 - Between=2017-01-20T17:42:47.789-07:00[America/Denver], 2017-01-21T17:42:47.789-07:00[America/Denver]
指定时间点之后
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: after_route
6 uri: https://example.org
7 predicates:
8 - After=2017-01-20T17:42:47.789-07:00[America/Denver]
2. Cookie 谓词路由
cookie 中指定 key 的 值符合指定正则
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: cookie_route
6 uri: https://example.org
7 predicates:
8 - Cookie=chocolate, ch.p
此路由匹配具有名为 Chocolate 的 cookie 的请求,该 cookie 的值与 ch.p 正则表达式匹配。
3. Header 谓词路由
和 Cookie 谓词路由功能一样,只不过这次是从 headers 里面判断
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: header_route
6 uri: https://example.org
7 predicates:
8 - Header=X-Request-Id, \d+
以下内容太多,看官网吧:https://docs.spring.io/spring-cloud-gateway/docs/2.2.8.RELEASE/reference/html/
4. Host 谓词路由
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: host_route
6 uri: https://example.org
7 predicates:
8 - Host=**.somehost.org,**.anotherhost.org
5. 请求方法谓词路由
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: method_route
6 uri: https://example.org
7 predicates:
8 - Method=GET,POST
6. 路径参数谓词路由
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: path_route
6 uri: https://example.org
7 predicates:
8 - Path=/red/{segment},/blue/{segment}
可以通过来过去占位命名变量值
1Map<String, String> uriVariables = ServerWebExchangeUtils.getPathPredicateVariables(exchange);
2
3String segment = uriVariables.get("segment");
7. 查询参数谓词路由
请求参数中有 key 为 green 的请求参数
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: query_route
6 uri: https://example.org
7 predicates:
8 - Query=green
查询参数中有 key 为 name 的变量,且值符合 gree. 正则
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: query_route
6 uri: https://example.org
7 predicates:
8 - Query=name, gree.
8. RemoteAddr 地址谓词路由
可以做白名单
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: remoteaddr_route
6 uri: https://example.org
7 predicates:
8 - RemoteAddr=192.168.1.1/24
9. 权重谓词路由
第一个参数是所在组,另一个是权重
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: weight_high
6 uri: https://weighthigh.org
7 predicates:
8 - Weight=group1, 8
9 - id: weight_low
10 uri: https://weightlow.org
11 predicates:
12 - Weight=group1, 2
如何实现一个谓词
默认提供的谓词实现都在 org.springframework.cloud.gateway.handler.predicate
包下,通过如果想自定义实现一个谓词,只需继承AbstractRoutePredicateFactory
, 即可,看一下 时间谓词路由 Before 是怎么实现的
1package org.springframework.cloud.gateway.handler.predicate;
2
3import java.time.ZonedDateTime;
4import java.util.Collections;
5import java.util.List;
6import java.util.function.Predicate;
7
8import org.springframework.web.server.ServerWebExchange;
9
10
11public class BeforeRoutePredicateFactory extends AbstractRoutePredicateFactory<BeforeRoutePredicateFactory.Config> {
12
13 public static final String DATETIME_KEY = "datetime";
14
15 public BeforeRoutePredicateFactory() {
16 super(Config.class);
17 }
18
19 @Override
20 public List<String> shortcutFieldOrder()
21 // 这里返回的 list 变量名需要和配置文件中一一对应,顺序和变量名都得对应上
22 // - Before=2017-01-20T17:42:47.789-07:00[America/Denver]
23 // 比如这样顺序决定时间和变量名的对应关系,这里的情况则为
24 // datetime 对应 2017-01-20T17:42:47.789-07:00[America/Denver],
25 // 其中 datatime 又需要对应上 Config.class 中的属性名,这样才能通过反射
26 // 将 2017-01-20T17:42:47.789-07:00[America/Denver] 映射到 Config.class 的 datetime 属性上
27 // 所以这里需要注意下顺序和变量名,否则可能会出现 Config.class 无法取到值的情况
28 return Collections.singletonList(DATETIME_KEY);
29 }
30
31 @Override
32 public Predicate<ServerWebExchange> apply(Config config) {
33 return new GatewayPredicate() {
34
35 // 这里返回 boolean 来确定是否能命中断言
36 @Override
37 public boolean test(ServerWebExchange serverWebExchange) {
38 final ZonedDateTime now = ZonedDateTime.now();
39 return now.isBefore(config.getDatetime());
40 }
41 };
42 }
43
44 public static class Config {
45
46 private ZonedDateTime datetime;
47
48 public ZonedDateTime getDatetime() {
49 return datetime;
50 }
51
52 public void setDatetime(ZonedDateTime datetime) {
53 this.datetime = datetime;
54 }
55 }
56}
通过上面的代码可以确定出,只要 test 方法即可
注意 实现谓词时,需要以 XxxxRoutePredicateFactory 命名,其中 Xxxx 就是以后配置时的前缀了
四、过滤器的配置
过滤器分两种:GlobalFilter 针对全局路由使用;GatewayFilter 针对指定的路由的使用
GatewayFilter
通过为 route 配置 filters 来显示的生效
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: add_request_header_route
6 uri: https://example.org
7 filters:
8 - AddRequestHeader=X-Request-red, blue
如何自定义个 GatewayFilter,在日常的开发中,有些接口是需要登录,有些不需要登录,这里以验证为例,看一下如何定制 GatewayFilter
定制 GatewayFilter 需要实现的是 AbstractGatewayFilterFactory
1// 实现 AbstractGatewayFilterFactory 这里也需要一个 Config 对象
2// 和实现谓词基本一致
3@Component
4@Slf4j
5public class AuthGatewayFilterFactory extends AbstractGatewayFilterFactory<Config> {
6
7 private String message = "{\n"
8 + " \"code\": 401,\n"
9 + " \"errorMessage\": \"用户身份信息失效,请重新登录\""
10 + "}";
11
12 public AuthGatewayFilterFactory() {
13 super(Config.class);
14 }
15
16 @Override
17 public List<String> shortcutFieldOrder() {
18 // 这里也是注意顺序和名称
19 return Arrays.asList("executor");
20 }
21
22 @Override
23 public GatewayFilter apply(Config config) {
24 return (exchange, chain) -> {
25 boolean valid = 这里应该是验证逻辑,如果验证通过返回 true;
26 if (valid) {
27 // 如果验证通过了,就继续走过滤链
28 return chain.filter(exchange);
29 } else {
30 // 验证没通过,直接返回 401
31 ServerHttpResponse response = exchange.getResponse();
32 //设置 headers
33 response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
34 //设置body
35 DataBuffer bodyDataBuffer = response.bufferFactory().wrap(message.getBytes());
36 response.setStatusCode(HttpStatus.UNAUTHORIZED);
37 return response.writeWith(Mono.just(bodyDataBuffer));
38 }
39 };
40 }
41
42 @ToString
43 public static class Config {
44
45 @Getter
46 @Setter
47 private String executor;
48
49 }
50
51}
为指定的路由配置该过滤器
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: add_request_header_route
6 uri: https://example.org
7 filters:
8 - Auth=JWT
看到这里应该可以看出,这里也是感觉名字前缀来配置的
GlobalFilter
GlobalFilter 对全部的路由都有效,不要额外进行配置,注入就能用。
自定义 GlobalFilter 直接实现 GlobalFilter 即可
1@Component
2@Slf4j
3public class TimeStatisticalFilter implements GlobalFilter, Ordered {
4
5 private static final String START_TIME = "startTime";
6
7 @Override
8 public int getOrder() {
9 // 指定此过滤器位于NettyWriteResponseFilter之后
10 // 即待处理完响应体后接着处理响应头
11 return Ordered.LOWEST_PRECEDENCE;
12 }
13
14 @Override
15 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
16 exchange.getAttributes().put(START_TIME, System.currentTimeMillis());
17
18 return chain.filter(exchange).then(Mono.fromRunnable(() -> {
19 Long startTime = exchange.getAttribute(START_TIME);
20 if (startTime != null) {
21 long executeTime = (System.currentTimeMillis() - startTime);
22 log.info(exchange.getRequest().getURI().getRawPath() + " : " + executeTime + "ms");
23 }
24 }));
25 }
26}
注意事项
exchange 本身中对 request、response 不能直接修改,如果需要修改,需要生成一个新的 exchange 对象进行修改,调用链本身有顺序,如果要自定义 Filter 注意优先级的设置
常见过滤器的优先级和功能
每个版本的 Spring Cloud Gateway 可能不一样,具体看 org.springframework.cloud.gateway.config.GatewayAutoConfiguration
里面相关配置
名称 | 优先级 | 是否启用 | 请求阶段 | 响应阶段 |
---|---|---|---|---|
RemoveCachedBodyFilter | HIGHEST_PRECEDENCE | 是 | 如果发现有 RequestBody 就去除 | |
AdaptCachedBodyGlobalFilter | HIGHEST_PRECEDENCE + 1000 | 是 | 把 requestBody 缓存到 cachedRequestBody Attribute 中 | |
DefaultValue | 什么都没干,还抛出了一个异常 | |||
ForwardPathFilter | 0 | 是 | set the path in the request URI if the {@link Route} URI has the scheme | |
GatewayMetricsFilter | 0 | 是 | 记录下发起时间 | 统计耗时 |
NettyWriteResponseFilter | -1 | 是 | 将最终的 exchange 请求写回客户端 | |
WebClientWriteResponseFilter | -1 | 否,代码中无任何开启的方式 | 与 NettyWriteResponseFilter 类似 | |
RouteToRequestUrlFilter | 10000 | 是 | 将原始请求地址和路由配置的地址进行替换,将替换成的新地址放在 GATEWAY_REQUEST_URL_ATTR 属性中 | |
ReactiveLoadBalancerClientFilter | 10150 | 是 | 如果是 lb 则根据服务发现找到应的实例将实例地址设置成当前请求的 host | |
NoLoadBalancerClientFilter | 10150 | 当 ReactorLoadBalancer 不存在且 spring.cloud.gateway.loadbalancer 属性存在 | ||
WebsocketRoutingFilter | LOWEST_PRECEDENCE - 1 | WebSocket 的请求转发 | ||
ForwardRoutingFilter | LOWEST_PRECEDENCE | 如果 shema 中含有 forward 则转发 | ||
NettyRoutingFilter | LOWEST_PRECEDENCE | 是 | 如果 shema 中为 http 则转发并写入 response | |
WebClientHttpRoutingFilter | LOWEST_PRECEDENCE | 否,代码中无任何开启的方式 | 和 NettyRoutingFilter 功能一样,转发请求的方式改为了 WebClient |
一些常见的操作
1. 修改 response headers
1ServerHttpResponse response = exchange.getResponse();
2response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8); // 这句如果出现异常,直接 catch 即可,不影响修改
2. 修改 response 状态码
1ServerHttpResponse response = exchange.getResponse();
2response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
3. 修改 response 示例
1ServerHttpResponse response = exchange.getResponse();
2
3//设置 headers
4response.getHeaders().setContentType(MediaType.APPLICATION_JSON_UTF8);
5
6//设置body
7DataBuffer bodyDataBuffer = response.bufferFactory().wrap("{}".getBytes());
8response.setStatusCode(HttpStatus.INTERNAL_SERVER_ERROR);
9return response.writeWith(Mono.just(bodyDataBuffer));
4. 设置或者添加属性
1exchange.getAttributes().put(key, value);
2exchange.getAttribute(key)
5. 统计请求时间示例
1public class TimeStatisticalFilter implements GlobalFilter, Ordered {
2
3 private static final String START_TIME = "startTime";
4
5 @Override
6 public int getOrder() {
7 // 这里可以通过设置不同的优先级来统计不同的阶段的时间
8 return Ordered.HIGHEST_PRECEDENCE;
9 }
10
11 @Override
12 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
13 exchange.getAttributes().put(START_TIME, System.currentTimeMillis());
14 return chain.filter(exchange).then(Mono.fromRunnable(() -> {
15 Long startTime = exchange.getAttribute(START_TIME);
16 if (startTime != null) {
17 long executeTime = (System.currentTimeMillis() - startTime);
18 log.info(exchange.getRequest().getURI().getRawPath() + " : " + executeTime + "ms");
19 }
20 }));
21 }
22}
6. 读取 Request Body
有一些情况,我们可能要读取 Request Body,比如要对 Request Body 加解密或者其他的判断,如果只是读取操作,可以使用 ReadBodyRoutePredicateFactory 来实现,ReadBodyRoutePredicateFactory 配置有两个参数需要配置:1. inClass 用来配置将body 转换的类型;2. Predicate 判断什么情况下可以转。
先配置一个永真的 Predicate 来确定执行这个谓词
1@Configuration
2public class Config {
3
4 @Bean
5 public Predicate bodyPredicate(){
6 return new Predicate() {
7 @Override
8 public boolean test(Object o) {
9 return true;
10 }
11 };
12 }
13}
增加配置 route 配置,对需要读取 Request Body 的路由进行配置,这里配置将 Request Body 转换成 String,也方便后面使用的直接进行其他转换操作,例如 JSON。
1spring:
2 cloud:
3 gateway:
4 routes:
5 - id: common
6 uri: lb://hhhh
7 predicates:
8 - Path=/hhhh
9 - name: ReadBody
10 args:
11 inClass: '#{T(String)}'
12 predicate: '#{@bodyPredicate}'
在后面的操作中,可以直接使用以下语句来获取 Request Body 来进行其他操作
1String requestBody = exchange.getAttribute("cachedRequestBodyObject");
7. 删除重复的 headers
1@Component
2public class RemoveDuplicateResponseHeaderFilter implements GlobalFilter, Ordered {
3
4 @Override
5 public int getOrder() {
6 // 指定此过滤器位于NettyWriteResponseFilter之后
7 // 即待处理完响应体后接着处理响应头
8 return NettyWriteResponseFilter.WRITE_RESPONSE_FILTER_ORDER + 1;
9 }
10
11 @Override
12 public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
13 return chain.filter(exchange).then(Mono.defer(() -> {
14 exchange.getResponse().getHeaders().entrySet().stream()
15 .filter(kv -> (kv.getValue() != null && kv.getValue().size() > 1))
16 .forEach(kv -> {
17 kv.setValue(new ArrayList<String>() {{
18 add(kv.getValue().get(0));
19 }});
20 });
21 return chain.filter(exchange);
22 }));
23 }
24}
为什么使用网关
正如开始提到的它封装了应用程序的内部结构,为客户端提供统一服务,一些与业务本身功能无关的公共逻辑可以在这里实现,诸如认证、鉴权、监控、路由转发等。既然 Nginx 也可以实现类似的功能,为什么还用 Spring Cloud Gateway ?
转发:Nginx 性能更好,Spring Cloud Gateway 的性能差之,不过其可以整合服务发现,更加灵活,谓词方式更多
可扩展性:Spring Cloud Gateway 可以自己定义过滤器更加的灵活
开发:相对于 Nginx 其对 Java 开发更友好
具体实现转发的细节见 Java | Spring Cloud Gateway 是如何工作的