Spring Cloud Gateway - 使用测试案例

配置一个最基础路由规则

关于 Spring Cloud Gateway 的原理和核心组件,之前已经有过介绍,可以参考《Spring Cloud Gateway - 从 0 到 1 构建一个网关》。这里我们直接聚焦 Spring Cloud Gateway 的常见的使用套路。首先我们先跑通一个最简单的网关配置。为方便起见,我们在本地起两个测试微服务:

  • 用户微服务:内部访问路径为 localhost:8180
  • 订单微服务:内部访问路径为 localhost:8081、localhost:8082

以上两个服务的构建可以参考 演示项目源码。 我们的目标是通过新搭建的 SGC,去访问这两个内部服务。


搭建基础 SGC 服务

父项目的 pom 配置可以参考 《Spring Cloud Alibaba 基础案例》

依赖配置:

1
2
3
4
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>

启动类:

1
2
3
4
5
6
@SpringBootApplication
public class GatewayApp {
public static void main(String[] args) {
SpringApplication.run(GatewayApp.class, args);
}
}

直接开动 GatewayApp,我们就拥有了最简单的 gateway 服务。


配置目标服务的路由规则

编写 SGC 的 application.yml

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
server:
port: 8080

spring:
application:
name: zdemo-gateway
cloud:
gateway:
# 配置路由规则
routes:
#### 用户模块的路由规则
- id: user-route
uri: http://localhost:8180
predicates:
# 首先使用内置断言配置:访问 localhost:8080/user-serv/** 可以匹配到 localhost:8180/user-serv/**
- Path=/user-serv/**
filters:
# 然通过内置过滤器可以脱掉第一层路径,这样上述匹配规则就变成了:访问 localhost:8080/user-serv/** 可以匹配到 localhost:8180/**
- StripPrefix=1

#### 订单模块的路由规则
- id: order-route-high
uri: http://localhost:8081
predicates:
- Weight=order_group, 8
- Path=/order-serv/**
filters:
- StripPrefix=1
- id: user-route-low
uri: http://localhost:8082
predicates:
- Weight=order_group, 2
- Path=/order-serv/**
filters:
- StripPrefix=1

logging:
level:
org.springframework.cloud.gateway: debug

以上,我们使用到了SGC 的内置断言和过滤器:

  • 内部用户服务对外暴露的访问路径:sgc_host:8080/user-serv/**。
  • 内部订单服务对外暴露的访问路径:sgc_host:8080/order-serv/**,order 的多节点按权重分配访问。

关于 SGC 的所有内置和过滤器的说明,可以参考官网 Route Predicate FactoriesGatewayFilter Factories


整合 nacos

整合 nacos 注册中心

关于 nacos 的安装和配置,请参考之前文章片段 使用 zk 充当服务注册中心

继续引入依赖:

1
2
3
4
5
6
7
8
9
10
<!--注册中心支持-->
<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-loadbalancer</artifactId>
</dependency>

配置文件的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
server:
port: 8080

spring:
application:
name: zdemo-gateway
cloud:
nacos:
# 使用 nacos 实现服务的注册和发现
discovery:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
namespace: prod-zdemo
cluster-name: DEFAULT
gateway:
routes:
####
- id: user-route
# 使用 nacos 服务名及其负载均衡策略
# 这里的 lb:// 其实是用到了全局过滤器 ReactiveLoadBalancerClientFilter 支持的负载均衡
uri: lb://zdemo-scloud-user
predicates:
- Path=/user-serv/**
filters:
- StripPrefix=1

####
- id: order-route
# 使用 nacos 服务名及其负载均衡策略
uri: lb://zdemo-scloud-order
predicates:
- Weight=order_group, 8
- Path=/order-serv/**
filters:
- StripPrefix=1

logging:
level:
org.springframework.cloud.gateway: debug
org.springframework.cloud.loadbalancer: debug

nacos 还有一个更加简化的约定大于配置的高级玩法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
spring:
application:
name: zdemo-gateway
cloud:
nacos:
discovery:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
namespace: prod-zdemo
cluster-name: DEFAULT
gateway:
discovery:
locator:
# 开启自动识别 nacos 服务(默认关闭),这样就会采用默认的路由规则
# 访问 localhost:8080/{user-service-name}/xxx 或 localhost:8080/{order-service-name}/xxx 就可访问对应模块的服务
enabled: true

这样即使你不配置具体的路由规则,也可以采用 nacos 提供的默认的路由规则进行服务的转发。不过实际生产不建议这样的配置,可读性和灵活性都不太好。


整合 nacos 配置中心

关于 nacos 作为配置中心的使用,可以参考之前的文章 Nacos 作为配置中心的使用探究

继续引入依赖:

1
2
3
4
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>

本地 application.yml 配置文件的修改:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
server:
port: 8080

spring:
application:
name: zdemo-gateway
config:
import:
- optional:nacos:${spring.application.name}.${spring.cloud.nacos.config.file-extension}?refresh=true
cloud:
nacos:
discovery:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
namespace: prod-zdemo
cluster-name: DEFAULT
config:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
file-extension: yml
namespace: prod-zdemo
cluster-name: DEFAULT
group: DEFAULT_GROUP

路由规则的详细配置,直接交给 nacos 进行托管。在 nacos 配置中心新建 zdemo-gateway.yml:

  • 命名空间:prod-zdemo
  • Data ID:zdemo-gateway.yml
  • Group:DEFAULT_GROUP
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
spring:
cloud:
gateway:
# 配置路由规则
routes:
#### 用户模块的路由规则
- id: user-route
uri: http://localhost:8180
predicates:
- Path=/user-serv/**
filters:
- StripPrefix=1

#### 订单模块的路由规则
- id: order-route-high
uri: http://localhost:8081
predicates:
- Weight=order_group, 8
- Path=/order-serv/**
filters:
- StripPrefix=1
- id: user-route-low
uri: http://localhost:8082
predicates:
- Weight=order_group, 2
- Path=/order-serv/**
filters:
- StripPrefix=1

上述准备工作完成之后,访问如下接口,依然OK,说明整合成功。

1
$ curl http://localhost:8080/user-serv/dubbo/user/order?orderNo=1


自定义断言

SCG 自定义断言非常简单,但它严格遵循一套 “约定大于配置” 的命名规范。实现自定义断言的需要遵循的规范:

  • 类名命名规范:必须以 RoutePredicateFactory 结尾。例如我们的断言叫 UserLevel,那么类名必须叫 UserLevelRoutePredicateFactory。
  • 继承基类:必须继承官方的 AbstractRoutePredicateFactory,并在构造方法中将自定义的 Config 内部类传给 super。
  • 注册为 Bean:必须将该类加上 @Component 注解(或者其他注册方式),让 Spring 容器接管。

下面我们就遵循这样一套规则,来定义一个自己的断言工厂:只有当请求头(或参数)中携带的 user-level 达到配置的级别时,网关才允许放行路由。


编写断言工厂类

新增 UserLevelRoutePredicateFactory 类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
import org.springframework.cloud.gateway.handler.predicate.AbstractRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.GatewayPredicate;
import org.springframework.stereotype.Component;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.server.ServerWebExchange;
import java.util.Collections;
import java.util.List;
import java.util.function.Predicate;

@Component // 必须注册为 Spring 的 Bean
public class UserLevelRoutePredicateFactory extends AbstractRoutePredicateFactory<UserLevelRoutePredicateFactory.Config> {

// 1. 定义一个内部配置类,用来接收 application.yml 传入的参数值
@Validated
public static class Config {
private String userLevel;

public String getUserLevel() {
return userLevel;
}

public void setUserLevel(String userLevel) {
this.userLevel = userLevel;
}
}

// 2. 在构造方法中指定配置类
public UserLevelRoutePredicateFactory() {
super(Config.class);
}

// 3. 核心快捷映射:定义 yml 中逗号分隔的参数,如何映射到 Config 对象的属性上
@Override
public List<String> shortcutFieldOrder() {
// 对应 Config 类中的 userLevel 字段,这样在 yml 中就可以简写为:UserLevel=gold
return Collections.singletonList("userLevel");
}

// 4. 核心断言逻辑判断
@Override
public Predicate<ServerWebExchange> apply(Config config) {
// 返回一个标准的 Java Predicate 表达式
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange exchange) {
// 从 HTTP 请求头中获取用户的 level 字段
String clientUserLevel = exchange.getRequest().getHeaders().getFirst("user-level");

// 如果请求头里的用户级别与 yml 配置文件里期望的级别一致,则断言成功(true),允许路由
if (config.getUserLevel().equalsIgnoreCase(clientUserLevel)) {
System.out.println("[Custom-Predicate] 匹配用户级别成功,放行! ");
return true;
} else {
System.out.println("[Custom-Predicate] 匹配用户级别失败,期望: " + config.getUserLevel() + ", 实际: " + clientUserLevel);
}

// 否则匹配失败(false)
return false;
}
};
}
}


使用自定义断言

现在,我们可以在 user-route 中加入这个刚写好的自定义断言了。在 nacos 配置文件 application.yml 修改:

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
gateway:
routes:
- id: user-route
uri: lb://zdemo-scloud-user
predicates:
- Path=/user-serv/**
# 引入自定义断言:只有 Path 满足,且请求头包含 user-level=gold 的用户才能路由到这个服务
- UserLevel=gold
filters:
- StripPrefix=1

注意:- UserLevel=gold 的名字必须去掉代码中的 RoutePredicateFactory 后缀。SCG 会在底层自动通过前缀名字去匹配。


测试验证

配置完成后重启网关服务,使用 curl 命令行进行双向测试:

1
2
3
4
5
6
7
8
# 测试 1:不传或者传入错误的请求头
# 结果都会直接返回 404 Not Found,因为断言没通过,网关认为没有匹配到任何路由规则。
$ curl http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1
$ curl -H "user-level: silver" http://192.168.1.5:8080/user-serv/info

# 测试 2:传入正确的匹配请求头
# 结果断言通过,请求被顺利负载均衡分发给 zdemo-scloud-user 服务
$ curl -H "user-level: gold" http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1

如果想要自定义其它的断言工厂,也可以参考 AbstractRoutePredicateFactory 常见的内置断言的实现,这里不再赘述。


自定义过滤器

比如我们要实现的业务场景:自定义一个黑名单过滤器。如果请求头中携带的 source-ip 或者用户的 client-id 在黑名单中,我们直接在过滤器中予以拦截,拒绝该请求(返回 403 Forbidden),不再转发给下游服务。

在 Spring Cloud Gateway 中实现自定义过滤器,同样有一套极约定命名规范:

  • 类名命名规范:必须以 GatewayFilterFactory 结尾。例如,你的过滤器叫 Blacklist,那么类名必须叫 BlacklistGatewayFilterFactory。
  • 继承基类:必须继承官方的 AbstractGatewayFilterFactory,并在构造方法中将自定义的 Config 内部类传给 super。


编写自定义过滤器工厂类

BlacklistGatewayFilterFactory

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
@Component // 必须注册为 Spring 的 Bean
public class BlacklistGatewayFilterFactory
extends AbstractGatewayFilterFactory<BlacklistGatewayFilterFactory.Config> {

// 1. 定义一个内部配置类,接收 yml 中配置的拦截目标
public static class Config {
private List<String> blockUsers;

public List<String> getBlockUsers() {
return blockUsers;
}

public void setBlockUsers(List<String> blockUsers) {
this.blockUsers = blockUsers;
}
}

// 2. 在构造方法中传递我们自定义的 Config 类
public BlacklistGatewayFilterFactory() {
super(Config.class);
}

// 3. 快捷映射:定义 yml 中逗号分隔的参数,如何映射到 Config 对象的属性上
@Override
public List<String> shortcutFieldOrder() {
// 映射到 Config 里的 blockUsers 列表
return Collections.singletonList("blockUsers");
}

/**
* 关键修改:由于在 YML 中参数是用逗号分隔的,SCG 默认的快捷解析模式(ShortcutType.DEFAULT)
* 会把逗号当成多个【配置字段(而我们只有一个字段 blockUsers)】的分隔符,从而报错。
* 我们必须显式重写这个方法,告诉网关:“这整串逗号分隔的文本,全部属于第一个字段!”
*/
@Override
public ShortcutType shortcutType() {
return ShortcutType.GATHER_LIST;
}


// 4. 编写核心拦截逻辑
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
System.out.println("[Custom-Filter] -> 进入多黑名单检查过滤器...");

// 获取客户端传入的身份标识
String clientId = exchange.getRequest().getHeaders().getFirst("client-id");

// 去除 YML 配置中可能存在的两端空格,并将其转换为统一格式
List<String> blackList = config.getBlockUsers().stream()
.map(String::trim)
.toList();

// 检查当前用户是否包含在小黑屋名册中
if (clientId != null && blackList.contains(clientId)) {
System.out.println("[Custom-Filter] 拦截成功!用户 [" + clientId + "] 属于黑名单成员: " + blackList);

ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.FORBIDDEN);
return response.setComplete();
}

// 放行
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
System.out.println("[Custom-Filter] -> 离开多黑名单检查过滤器(后置阶段)");
}));
};
}
}


使用自定义拦截器

1
2
3
4
5
6
7
8
9
10
11
12
spring:
cloud:
gateway:
routes:
- id: user-route
uri: lb://zdemo-scloud-user
predicates:
- Path=/user-serv/**
filters:
- StripPrefix=1
# 传入列表:支持逗号、空格混排,代码里会自动 trim() 去空
- Blacklist=badGuy, tom, jack, hacker


测试验证

重启网关服务,在终端使用 curl 挨个测试这个新写的过滤器:

1
2
3
4
5
6
7
# 测试 1:拦截 badGuy 和 jack -> 返回 403
$ curl -H "client-id: badGuy" http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1
$ curl -H "client-id: jack" http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1 -v

# 测试 3:未作恶的正常用户 -> 放行通过
$ curl -H "client-id: rose" http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1
$ curl http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1


全局过滤器

什么是全局过滤器

在 SCG)中,过滤器按作用范围可以分为两大类:普通过滤器 (GatewayFilter) 和 全局过滤器 (GlobalFilter)。全局过滤器的最大特点是无须配置。 只要你把它注入到 Spring 容器中,它就会自动拦截网关接收到的所有路由请求。通常用于 全局通用业务,例如全局日志统计、分布式链路追踪、全局统一 JWT 鉴权等。

其实我们之前一直在享受全局过滤器的服务,SCG 核心功能大半都是由内置全局过滤器完成的,官方内置的常用全局过滤器:

  • NettyRoutingFilter(最核心):负责底层的反应式网络转发,把请求通过 HttpClient 发送给下游服务。
  • GatewayMetricsFilter:负责收集网关的监控指标(如请求耗时、成功率),对外对接 Prometheus。
  • ForwardPathFilter:负责微服务内部的 forward:// 转发。
  • LoadBalancerClientFilter:负责结合 Nacos 解析 lb:// 协议,动态选择一个健康的服务实例。


生效的顺序

当全局过滤器和普通过滤器同时存在时,SCG 会把它们融合进同一个过滤器链条中。决定谁先执行的唯一标准是:Order 值。

  • Order 值越小,优先级越高(Pre 阶段越先执行,Post 阶段越后执行)。
  • 全局过滤器可以通过实现 Ordered 接口或加上 @Order 注解来指定数字。
  • 普通过滤器的 Order 值默认由它在 YML 中从上到下的配置顺序决定(从 1 开始递增)。


自定义全局过滤器

我们来实现一个生产环境最常用的全局过滤器:统一 JWT 鉴权与全局耗时统计过滤器。

  • 拦截所有请求,检查 Header 里有没有携带有合法的 Authorization 令牌。
  • 如果没有,直接拒绝(返回 401 Unauthorized)。
  • 如果有,放行,并在请求结束后打印出本次请求的全局总耗时。

GlobalAuthAndLogFilter:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.HttpStatus;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

@Component // 只要加了 @Component 成为 Spring Bean,它就会自动全局生效!
public class GlobalAuthAndLogFilter implements GlobalFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// --- Pre 前置拦截阶段 ---
String path = exchange.getRequest().getPath().value();
long startTime = System.currentTimeMillis();
System.out.println("[Global-Filter] ===> 开始拦截全局流量,请求路径: " + path);

// 1. 简单的放行白名单(比如登录接口、管理后台接口不需要验 token)
if (path.contains("/admin/") || path.contains("/login")) {
return chain.filter(exchange);
}

// 2. 鉴权校验:获取 Header 中的 Authorization
String token = exchange.getRequest().getHeaders().getFirst("Authorization");
if (token == null || token.isEmpty()) {
System.out.println("[Global-Filter] 拒绝访问!缺少 Authorization 令牌。路径: " + path);

ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED); // 401 状态码
return response.setComplete(); // 截断请求,直接返回
}

// 3. 令牌存在(假设这里包含了复杂的 JWT 解密逻辑),调用 chain.filter 放行
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
// --- Post 后置执行阶段 ---
long costTime = System.currentTimeMillis() - startTime;
System.out.println("[Global-Filter] <=== 请求结束,路径: " + path + ",总耗时: " + costTime + "ms");
}));
}

/**
* 定义过滤器的执行顺序
* 数字越小优先级越高。设置为最低(或者靠近 -100),可以确保它挡在最外层,优先执行鉴权。
*/
@Override
public int getOrder() {
return -1;
}
}

测试验证:

1
2
3
4
5
6
7
# 测试被拦截 (不带 Token)
# 结果:直接返回 401 Unauthorized,请求没有到达下游。
$ curl http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1 -v

# 测试放行 (带上 Token)
# 请求成功通过,下游正常响应。
$ curl -H "Authorization: my-jwt-token-xxx" http://192.168.1.5:8080/user-serv/dubbo/user/order?orderNo=1


断言和过滤器的生效机制

一句话总结:断言之间是 AND(与)关系,而过滤器之间是 THEN(串联流式)关系。关于其中更透彻的机制,可以参考之前额的文章 《Spring Cloud Gateway - 从 0 到 1 构建一个网关》


断言的逻辑

路由断言(Predicates)的逻辑是 “绝对的 AND” 关系。

1
2
3
4
5
6
7
# 当一个请求到达网关时,网关会依次遍历这三个断言。
# 只有当 Path 匹配成功 且 UserLevel 匹配成功 且 请求方法是 GET 时,整个路由才算匹配成功。
# 只要其中任何一个断言返回了 false,后续的断言就不会再执行,网关会直接认为该路由不匹配,立刻去寻找下一个路由规则。
predicates:
- Path=/user-serv/**
- UserLevel=gold
- Method=GET

如果你想在断言里实现 OR(比如:路径是 /user/ 或者 请求头包含 Admin=true 都能通过),在现有的 YML 配置下是无法直接在一个 - 列表里写 OR 的。

实现 OR 的标准做法是:配置两条 id 不同、但 uri 完全相同的路由规则,让它们分别承载不同的断言。因为网关在匹配路由时,多条路由之间是 OR 的关系(只要匹配中一条就行)。


过滤器的逻辑

过滤器之间是纯粹的 THEN(串联流式)关系。

1
2
3
4
5
6
7
# SGC 的过滤器采用的是洋葱模型(责任链模式)。所有的过滤器会按照配置的顺序被组装成一条链条:
## Pre(前置阶段):请求从外向内穿过。首先执行 StripPrefix 的前置逻辑(裁剪路径),然后(THEN)执行 AddRequestHeader 的前置逻辑(加请求头)。
## Routing(路由分发):到达网关核心,发起异步的远程网络请求。
## Post(后置阶段):当下游服务返回响应后,响应会从内向外再次穿过过滤器链。此时的执行顺序刚好和 Pre 阶段相反(先处理 AddRequestHeader 的后置,再处理 StripPrefix 的后置)。
filters:
- StripPrefix=1
- AddRequestHeader=X-My-Header, hello


跨域的设置

什么是跨域问题?

跨域(Cross-Origin)是由浏览器的同源策略引起的。当一个网页去请求另一个服务器的资源时,只要以下三者中有任何一个不同,即为跨域:

  • 协议不同(如 http 与 https)
  • 域名不同(如 a.com 与 b.com,或者 api.koohub.com 与 web.koohub.com)
  • 端口不同(如 8080 与 8081)

同源策略不是为了刁难开发者,而是浏览器最核心的安全防护机制。它解决的根本问题是:防止恶意网站窃取或冒用你在信任网站上的隐私数据(防范 CSRF 跨站请求伪造攻击)。如果没有同源策略,你登录了网银网站 bank.com 留下了 Cookie,此时你又打开了一个恶意钓鱼网站 evil.com,该钓鱼网站的 JS 代码就可以肆无忌惮地异步请求 bank.com/transfer 接口。因为在同一个浏览器下,浏览器会自动带上 bank.com 的 Cookie,你的钱可能就被悄悄转走了。


微服务跨域设置标准姿势

在微服务架构中,跨域配置 千万不要网关配一套、后端服务又配一套!标准做法是只在网关层统一配置跨域,下游的目标服务必须全部关闭跨域配置。原因是如果网关和目标服务同时配置了跨域,HTTP 响应头中就会出现重复的 “Access-Control-Allow-Origin: *”,浏览器检测到重复的跨域头会直接报错拒绝访问。


网关层的统一配置

在生产环境中,千万不要盲目写 allow-credentials: true 配合 allowed-origins: “*”(新版 Spring 会直接报错)。如果需要携带 Cookie 等凭证,必须精准指定合法的域名白名单。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
spring:
cloud:
gateway:
# 统一跨域配置
globalcors:
cors-configurations:
'[/**]': # 拦截网关所有路径
allowed-origin-patterns: # 生产环境建议指定具体的前端域名,支持通配符
- "https://*.koohub.com"
- "http://localhost:[*]" # 允许本地开发多端口联调
allowed-methods: # 允许的请求方式
- "GET"
- "POST"
- "PUT"
- "DELETE"
- "OPTIONS"
allowed-headers: # 允许客户端携带的 Header
- "Authorization"
- "Content-Type"
- "client-id"
- "user-level"
exposed-headers: # 允许浏览器读取的响应头
- "Set-Cookie"
allow-credentials: true # 允许携带 Cookie/凭证
max-age: 3600 # 预检请求(OPTIONS)的缓存时间(秒),避免频繁发起二次请求


目标服务层清理

因为网关层已经做完了全盘的 CORS 握手,下游的目标微服务什么都不需要配。务必排查并删除下游微服务代码中的以下两类配置,确保它们干干净净。

删除下游服务的配置类:

1
2
3
4
5
6
7
8
@Configuration
public class WebMvcConfig implements WebMvcConfigurer {
@Override
public void addCorsMappings(CorsRegistry registry) {
// 必须删掉!否则网关转发后浏览器会报“Duplicate CORS headers”
registry.addMapping("/**").allowedOrigins("*");
}
}

删除下游 Controller 上的注解:

1
2
3
4
5
6
7
@RestController
@RequestMapping("/user")
// @CrossOrigin // <--- 必须删掉这个注解!
public class UserController {
@GetMapping("/info")
public String info() { return "user info"; }
}


兼容策略

如果目标服务必须保留跨域,网关该怎么退让?比如如果你的目标服务是个老系统,里面的 @CrossOrigin 注解多得数不清、根本不敢乱删,那么我们可以利用 Gateway 的一个极其强大的过滤器,在网关转发给浏览器之前,强行把下游服务产生的重复跨域头给裁剪掉。在网关的 application.yml 的 default-filters(全局默认过滤器)中配置:

1
2
3
4
5
6
spring:
cloud:
gateway:
default-filters:
# 当网关和下游服务跨域配置冲突出现重复 Header 时,强行保留其一,避免浏览器报错
- DedupeResponseHeader=Access-Control-Allow-Origin Access-Control-Allow-Credentials RETAIN_UNIQUE

通过这个配置,即使下游服务偷偷返回了跨域头,网关也会帮它擦干净屁股,确保浏览器接收到的是唯一且合法的 CORS 响应头。


整合 sentinel 流控降级

关于 sentinel 的各种玩法,可以参考我之前的文章 《Spring Cloud Alibaba 微服务系列 - Sentinel》。这里直接进入整合的正题。


网关应用需要的依赖

在网关模块 zdemo-gateway 的 pom.xml 中引入 Sentinel 的核心适配依赖和持久化数据源(以 Nacos 为例)。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<!--sentinel 的核心依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-sentinel</artifactId>
</dependency>
<!--sentinel 整合 gateway 的依赖-->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-alibaba-sentinel-gateway</artifactId>
</dependency>
<!--流控降级规则持久化到 nacos 需要的西依赖-->
<dependency>
<groupId>com.alibaba.csp</groupId>
<artifactId>sentinel-datasource-nacos</artifactId>
</dependency>


基础配置

编辑配置文件 application.yml 或者 nacos 对应的配置,在网关的配置文件中,指定 Sentinel 控制台的连接地址,并开启网关维度的流控支持。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
server:
port: 8080

spring:
application:
name: zdemo-gateway
config:
import:
- optional:nacos:${spring.application.name}.${spring.cloud.nacos.config.file-extension}?refresh=true
cloud:
nacos:
discovery:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
namespace: prod-zdemo
cluster-name: DEFAULT
config:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
file-extension: yml
namespace: prod-zdemo
cluster-name: DEFAULT
group: DEFAULT_GROUP
# 整合 sentinel
sentinel:
transport:
# 指定 Sentinel 控制台的地址
dashboard: 192.168.1.149:8858
# 本地启动限流客户端的端口(默认8719,冲突时会自动向下探测)
port: 8719
# 强制指定本服务的主机地址
client-ip: 192.168.1.5
scg:
# 网关专用快声明式流控和降级响应,注意它会覆盖掉我们自定义的 BlockRequestHandler
fallback:
mode: response
response-status: 429
response-body: '{"code":429,"message":"访问人数过多,请稍后再试!"}'

访问几次 zdemo-gatewa,这时候我们就可以访问 http://192.168.1.149:8858/ sentinel 的 dashboard,在显示出的 zdemo-sentinel 下去定义自己的流控或降级规则了(注意现在的流控或降级规则还只是存储在 sentinel 服务的内存中,并没有持久化)。


流控降级规则的持久化

Sentinel 默认的规则是保存在网关内存中的,网关一重启规则全部丢失。在生产环境中,必须将规则托管到 Nacos 中进行持久化。

在网关中配置 Nacos 数据源。修改 application.yml,增加 datasource 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
server:
port: 8080

spring:
application:
name: zdemo-gateway
config:
import:
- optional:nacos:${spring.application.name}.${spring.cloud.nacos.config.file-extension}?refresh=true
cloud:
nacos:
discovery:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
namespace: prod-zdemo
cluster-name: DEFAULT
config:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
file-extension: yml
namespace: prod-zdemo
cluster-name: DEFAULT
group: DEFAULT_GROUP
# 整合 sentinel
sentinel:
transport:
dashboard: 192.168.1.149:8858
port: 8719
client-ip: 192.168.1.5
scg:
fallback:
mode: response
response-status: 429
response-body: '{"code":429,"message":"访问人数过多,请稍后再试!"}'
datasource:
# 自定义数据源名称
gw-low-rules:
nacos:
server-addr: 192.168.1.149:8848
username: nacos
password: nacos
data-id: ${spring.application.name}-flow-rules.json
groupId: SENTINEL_GROUP
namespace: prod-zdemo
data-type: json
# 代表网关维度的流控规则
rule-type: gw-flow

在 Nacos 中创建规则 JSON 文件。登录 Nacos 控制台,在 prod-zdemo 命名空间下新建一个 Data ID 为 zdemo-gateway-flow-rules.json(对应你的应用名),Group 为 SENTINEL_GROUP 的 JSON 配置:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
// resource: 对应我们在网关 YML 中配置的路由 ID(user-route)。
// resourceMode: 0 代表路由 ID 模式(1 代表 API 分组模式)。
// grade: 1 代表 QPS 限流。
// count: 限制阈值为 2。
// interval: 统计时间窗口,1 代表 1 秒。
[
{
"resource": "user-route",
"resourceMode": 0,
"grade": 1,
"count": 2,
"interval": 1,
"controlBehavior": 0
}
]

也可以先在测试环境的 sentinel 配置好,导出流控降级配置,并将其配置到 nacos 配置中心。这部分技巧可以参考 《实际 Sentinel Deshboard 玩法 - 可以优化的地方》。接下来就可以进行验证:

  • 启动 Nacos 和 Sentinel 控制台。
  • 启动网关服务 zdemo-gateway。
  • 访问 xxx:8080/user-serv/dubbo/user/order?orderNo=1。
  • 打开 Sentinel 控制台,在“请求链路”或“网关流控规则”中,你会发现持久化的 Nacos 规则已经被网关自动拉取并实时生效。
  • 狂点刷新接口,当 1 秒内 QPS 超过 2 时,接口会瞬间切断并返回由 WebFlux 异步写回的流控响应。

这种结合了 WebFlux 非阻塞优势加 Nacos 动态持久化的方案,就是最稳健的网关防御术。


自定义限流异常回调

当触发限流或熔断时,网关默认会返回 Sentinel 内置的简陋文本。生产环境下,我们可以自定义非阻塞的回调处理器,使其返回与前端契合的标准 JSON 格式。

新建配置类:GatewaySentinelConfig

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.BlockRequestHandler;
import com.alibaba.csp.sentinel.adapter.gateway.sc.callback.GatewayCallbackManager;
import org.springframework.http.MediaType;
import jakarta.annotation.PostConstruct;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpStatus;
import org.springframework.web.reactive.function.server.ServerResponse;
import java.util.HashMap;
import java.util.Map;

@Configuration
public class GatewaySentinelConfig {

@PostConstruct
public void initBlockHandlers() {
// 自定义网关被限流/熔断时的非阻塞响应
BlockRequestHandler blockRequestHandler = (exchange, t) -> {
Map<String, Object> errorResult = new HashMap<>();
errorResult.put("code", 429);
errorResult.put("message", "系统繁忙,已被网关统一限流熔断!");
errorResult.put("path", exchange.getRequest().getPath().value());

// 基于 WebFlux 的响应式非阻塞写回
return ServerResponse.status(HttpStatus.TOO_MANY_REQUESTS)
.contentType(MediaType.APPLICATION_JSON)
.bodyValue(errorResult);
};

// 将处理器注册进 Sentinel 的网关回调管理器中
GatewayCallbackManager.setBlockHandler(blockRequestHandler);
}
}

并且去掉刚才我们定义的:

1
2
3
4
5
6
7
8
spring:
cloud:
sentinel:
scg:
fallback:
mode: response
response-status: 429
response-body: '{"code":429,"message":"访问人数过多,请稍后再试!"}'

这样我们自定义的流控和降级响应就会生效。