
微服务架构 --- Gateway网关的项目实战教学
数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验。
目录
Spring Cloud Gateway 是由 WebFlux + Netty + Reactor 实现的响应式的 API 网关,旨在为微服务架构提供一种简单且有效的 API 路由的管理方式,并基于 Filter 的方式提供网关的基本功能,例如说安全认证、监控、限流等等。
一.什么是API网关?
API Gateway(API 网关)是微服务架构的核心组件,它是所有客户端请求的入口,用于将请求路由到正确的微服务。除了路由功能外,API 网关还支持:
- 请求聚合:将多个微服务的响应汇总为一个响应。
- 负载均衡:在多个实例间分发请求。
- 身份验证和授权:拦截请求以执行鉴权。
- 流量控制和限流:保护微服务避免高并发冲击。
- 日志与监控:收集请求的日志数据用于监控。
数据在网络间传输,从一个网络传输到另一网络时就需要经过网关来做数据的路由和转发以及数据安全的校验。
前端请求不能直接访问微服务,而是要请求网关:
网关可以做安全控制,也就是登录身份校验,校验通过才放行
通过认证后,网关再根据请求判断应该访问哪个微服务,将请求转发过去
Spring Cloud Gateway
是 Spring 官方提供的 API 网关解决方案,它基于 Spring 生态系统构建,支持动态路由、过滤器、负载均衡和全局过滤等功能。
总结:网关就是所有业务集群请求的入口,以后前端指需要记住Gateway网关的地址,随后通过网关来判断将请求转给哪个微服务以此来完成请求路由,而网关是通过服务注册中心进行获取微服务的地址,我们将前端发起的请求让网关根据请求路径去判断这个请求转给哪个微服务,随后在注册中心拿取该微服务的地址即可。
而我们一般用的网关是spring-cloud-starter-gateway(响应式网关)。
二.GateWay的使用:
业务需求:
/api/order/**
路由给订单
/api/product/**
路由给商品
测试负载均衡
1.引入依赖:
<?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>eleven</artifactId>
<groupId>com.eleven</groupId>
<version>1.0.0</version>
</parent>
<modelVersion>4.0.0</modelVersion>
<artifactId>hm-gateway</artifactId>
<properties>
<maven.compiler.source>11</maven.compiler.source>
<maven.compiler.target>11</maven.compiler.target>
</properties>
<dependencies>
<!-- gateway网关 -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- nacos注册中心 -->
<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>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
在 spring-cloud-starter-gateway
依赖中,会引入 WebFlux、Reactor、Netty 等等依赖,如下图所示:
2.配置路由:
我们可以创建一个配置文件 application-router.yml ,随后在该文件内部配置路由规则,在路由规则中routes有如下属性:
随后我们根据上面进行配置:(注意!!!规则匹配是从上到下,可加入order【越小优先级越高】来配置优先级),也可以使用后面使用网关进行路径重写。
路由包含四个属性:
id
:路由的唯一标示
predicates
:路由断言,其实就是匹配条件(集合)--- 了解 SpringCloudGateway 中支持的断言类型
filters
:路由过滤条件,后面讲(集合)
uri
:路由目标地址,lb://
代表负载均衡,从注册中心获取目标微服务的实例列表,并且负载均衡选择一个访问。
spring:
cloud:
nacos:
server-addr: 192.168.150.101:8848
# Spring Cloud Gateway 配置项,对应 GatewayProperties 类
gateway:
# 路由配置项,对应 RouteDefinition 数组
routes:
- id: order # 路由规则id(给路由起个名字),自定义,唯一
uri: lb://service-order # 路由的目标服务,lb代表负载均衡,会从注册中心拉取服务列表
predicates: # 路由断言,对应 RouteDefinition 数组,判断当前请求是否符合当前规则,符合则路由到目标服务
- Path=/items/**,/search/** # 断言规则,这里是以请求路径作为判断规则,**代表后面任意路径
- id: product
uri: lb://service-product # lb是负载均衡的缩写(为了使用负载均衡必须引入依赖),用service-product代替微服务的地址
predicates:
# - Path=/api/product/**
- name: Path
args:
patterns: /api/product/** # 规则
matchTrailingSlash: true # 允许地址后面加/和不加/是同一地址
# 与 Spring Cloud 注册中心的集成,对应 DiscoveryLocatorProperties 类
discovery:
locator:
enabled: true # 是否开启与 Spring Cloud 注册中心的集成的功能,默认为 false 关闭
url-expression: "'lb://' + serviceId" # 路由的目标地址的 Spring EL 表达式,默认为 "'lb://' + serviceId",将从注册中心获得到的服务列表,每一个服务的名字对应 serviceId,最终使用 Spring EL 表达式进行格式化
# Nacos 作为注册中心的配置项
nacos:
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
这里我们主要使用了 routes
路由配置项,对应 RouteDefinition 数组。路由(Route)是 Gateway 中最基本的组件之一,由一个 ID、URI、一组谓语(Predicate)、过滤器(Filter)组成。一个请求如果满足某个路由的所有谓语,则匹配上该路由,最终过程如下图:
然后再application.yml中导入使用:
spring:
profiles:
include: router # 导入application-router配置文件来使用
application:
name: gateway
cloud:
nacos:
server-addr: 127.0.0.1:8848
server:
port: 80
然后我们需要使用 @RequestMapping("/api/order") 给两个微服务的 Controller 加入统一前缀,然后再feign中加入地址即可使用 :
@RestController
@RequestMapping("/api/order") // 统一前缀
public class OrderController {
@GetMapping("/list") // 实际路径: /api/order/list
public List<Order> list() {
// ...
}
@PostMapping("/create") // 实际路径: /api/order/create
public Order create() {
// ...
}
}
注意!!!新版的 OpenFeign 不支持使用 @RequestMapping() 注解,需使用@FeignClient注解。
@FeignClient(
name = "service-order",
path = "/api/order" // 替代旧版 @RequestMapping
)
public interface OrderFeignClient {
@GetMapping("/list") // 完整路径: /api/order/list
List<Order> getOrderList();
@PostMapping("/create") // 完整路径: /api/order/create
Order createOrder(@RequestBody Order order);
}
//---------------------------------------------------------//
@FeignClient(
name = "service-product",
path = "/api/order" // 统一前缀
)
public interface ProductFeignClient {
@GetMapping("/products") // 完整路径: /api/order/products
List<Product> getProducts();
}
3.断言 - Predicate:
(1)内置规则:
spring:
cloud:
gateway:
routes:
- id: after_route
uri: https://example.org
predicates:
- After=2017-01-20T17:42:47.789-07:00[America/Denver]
(2)自定义断言工厂:
虽然SpringCloud里面内置了非常多的断言规则,但是也无法囊括所有的业务需求,所以我们可以根据我们的需要来创建自定义规则,所以需要我们自己去写一个断言工厂。
首先我们在配置文件内使用自定义断言工厂:
spring:
cloud:
gateway:
routes:
- id: product
uri: lb://service-product
predicates:
- name: Path
args:
patterns: /search # 规则
# 自带的断言工厂
- name: Query
args:
param: q
regexp: haha
# 自定义断言工厂VipRoutePredicateFactory(两种写法)
# - Vip=user,eleven
- name: Vip # 自定义的工厂VipRoutePredicateFactory的前缀Vip就是工厂名
args:
params: user # 内部Config配置类的参数1
value: eleven # 内部Config配置类的参数2
随后我们可以在 /predicate/VipRoutePredicateFactory.java 内编写代码:
import jakarta.validation.constraints.NotEmpty;
import org.springframework.cloud.gateway.handler.predicate.AbstractRoutePredicateFactory;
import org.springframework.cloud.gateway.handler.predicate.GatewayPredicate;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.util.StringUtils;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.server.ServerWebExchange;
import java.util.Arrays;
import java.util.List;
import java.util.function.Predicate;
public class VipRoutePredicateFactory extends AbstractRoutePredicateFactory<VipRoutePredicateFactory.Config> {
/**
* 使用自定义配置类的参数
*/
public VipRoutePredicateFactory() {
super(Config.class);
}
/**
* 满足什么规则
* @param config
* @return
*/
public Predicate<ServerWebExchange> apply(Config config) {
return new GatewayPredicate() {
@Override
public boolean test(ServerWebExchange serverWebExchange) {
// localhost/search?q=haha&user=eleven,像这样三个规则都满足的地址才是VIP
ServerHttpRequest request = serverWebExchange.getRequest();
String first = request.getQueryParams().getFirst(config.param);
if (StringUtils.hasText(first) && first.equals(config.param)) {
return true;
} else {
return false;
}
}
};
}
/**
* 指定属性的顺序
* @return
*/
public List<String> shortcutFields() {
// 使用Arrays.asList将两个属性名传入
return Arrays.asList("params","value");
}
/**
* 自定义一个配置类让其作为父类的泛型(可以配置的参数)
*/
@Validated
public static class Config {
@NotEmpty
private String param; // 参数,user
@NotEmpty
private String value; // 值,eleven
public @NotEmpty String getParam() {
return param;
}
public @NotEmpty String getValue() {
return value;
}
public void setParam(@NotEmpty String param) {
this.param = param;
}
public void setValue(@NotEmpty String value) {
this.value = value;
}
}
}
4.过滤器 - Filter:
(1)路径重写 - RewritePath:
由于我们的网关会把路径原封不动的传回,所以为了添加原来的前缀“/api/order",我们可以使用路径重写【将“/api/order”前缀请求路径清除返回】。
spring:
cloud:
gateway:
routes:
- id: order
uri: lb://service-order
predicates:
- Path=/api/order/**
filters:
# 将 /api/order/ab/c 变为 /ab/c
- RewritePath=/api/order/?(?<segment>.*),/${segment}
# 将key=username,value=123封装到请求头中
- AddResponseHeader=username,123
- id: product
uri: lb://service-product
predicates:
- name: Path
args:
pattens: /api/order/**
matchTrailingSlash: true
filters:
# 将 /api/product/ab/c 变为 /ab/c
- RewritePath=/api/product/?(?<segment>.*),/${segment}
# 将key=username,value=123封装到请求头中
- AddResponseHeader=username,123
/api/product/?(?<segment>.*)
/api/product/
:匹配以/api/product/
开头的路径?
:表示前面的/
是可选的(即可以匹配/api/product
或/api/product/
)
(?<segment>.*)
:这是一个命名捕获组:
?<segment>
:将匹配的内容命名为"segment".*
:匹配任意字符(除换行外)零次或多次eg:
- 原始路径:
/api/product/ab/c
→ 重写后:/ab/c
- 原始路径:
/api/product/
→ 重写后:/
(因为segment捕获到空字符串)- 原始路径:
/api/product
→ 重写后:/
(同上)
(2)默认过滤- default-filters
如果我们所有的路径都想要加入同样的配置就可以使用默认过滤:
spring:
cloud:
gateway:
routes:
- id: order
uri: lb://service-order
predicates:
- Path=/api/order/**
filters:
# 将 /api/order/ab/c 变为 /ab/c
- RewritePath=/api/order/?(?<segment>.*),/${segment}
- id: product
uri: lb://service-product
predicates:
- name: Path
args:
pattens: /api/order/**
matchTrailingSlash: true
filters:
# 将 /api/product/ab/c 变为 /ab/c
- RewritePath=/api/product/?(?<segment>.*),/${segment}
default-filters:
# 将key=username,value=123封装到请求头中
- AddResponseHeader=username,123
(3)全局过滤 - Global Filters
下面是我们全局过滤的基本代码,在 /filter/:
@Bean
public GlobalFilter customFilter() {
return new CustomGlobalFilter();
}
public class CustomGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("custom global filter");
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -1;
}
}
下面是使用全局过滤器来记录并输出请求响应的耗时时间:
import lombok.extern.slf4j.Slf4j;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
@Slf4j
public class RTGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
String uri = request.getURI().toString();
long startTime = System.currentTimeMillis();
log.info("请求【{}】开始:时间:{}",uri,startTime);
//================== 以上前置逻辑==================
// 放行
Mono<Void> filter = chain.filter(exchange).doFinally(s -> {
// 后置逻辑
long endTime = System.currentTimeMillis();
log.info("请求【{}】结束:时间:{},耗时:{}ms",uri,endTime,endTime - startTime);
});
return filter;
}
@Override
public int getOrder() {
return 0;
}
}
(4)自定义过滤器 - GatewayFilterFactory
首先在配置文件中配置一个过滤器 OnceToken=X-Response-Token,uuid,使用自定义过滤器 OnceTokenGatewayFilterFactory 前缀名 OnceToken 定义:
spring:
cloud:
gateway:
routes:
- id: product
uri: lb://service-product
predicates:
- name: Path
args:
pattens: /api/order/**
matchTrailingSlash: true
filters:
# 将 /api/product/ab/c 变为 /ab/c
- RewritePath=/api/product/?(?<segment>.*),/${segment}
# 将key=username,value=123封装到请求头中
- OnceToken=X-Response-Token,uuid
随后创建自定义过滤器OnceTokenGatewayFilterFactory(继承AbstractNameValueGatewayFilterFactory):
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.factory.AbstractNameValueGatewayFilterFactory;
import org.springframework.http.HttpHeaders;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.UUID;
@Component
public class OnceTokenGatewayFilterFactory extends AbstractNameValueGatewayFilterFactory {
@Override
public GatewayFilter apply(NameValueConfig config) {
return new GatewayFilter() {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 每次响应前都需要添加一个一次性令牌,支持UUID,jwt等各种格式
return chain.filter(exchange).then(
// 异步线程
Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
HttpHeaders headers = response.getHeaders();
String value = config.getValue();
if("uuid".equalsIgnoreCase(value)) {
value = UUID.randomUUID().toString();
} else if("jwt".equalsIgnoreCase(value)) {
value = "jwt令牌实例";
}
headers.add(config.getName(), value);
})
);
}
};
}
}
(5)全局跨域 - CORS
spring:
cloud:
gateway:
globalcors:
cors-configurations:
'[/**]':
allowedOrigins: "https://docs.spring.io"
allowedMethods:
- GET
5.基于配置中心 Apollo 实现动态路由:
我们使用 Gateway 提供的基于注册中心来自动创建动态路由的功能。但是很多时候,这个功能并不能满足我们的需求,例如说:
- 注册中心的服务这么多,我们并不想通过网关暴露所有的服务出去
- 每个服务的路由信息可能不同,会存在配置不同过滤器的情况
因此,我们可以引入配置中心 Apollo 来实现动态路由的功能,将 spring.cloud.gateway
配置项统一存储在 Apollo 中。同时,通过通过 Apollo 的实时监听器,在 spring.cloud.gateway
发生变化时,刷新内存中的路由信息。
当然,Gateway 中我们还是会使用注册中心,目的是为了获取服务的实例列表,只是不再使用 Gateway 基于注册中心来的动态路由功能而已。
(1)引入 Apollo 依赖:
<dependencies>
<!-- Spring Cloud Gateway -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<!-- Apollo客户端 -->
<dependency>
<groupId>com.ctrip.framework.apollo</groupId>
<artifactId>apollo-client</artifactId>
<version>2.1.0</version>
</dependency>
</dependencies>
(2)Apollo 配置:
server:
port: 8888
spring:
application:
name: gateway-application
cloud:
# Spring Cloud Gateway 配置项,全部配置在 Apollo 中
# gateway:
# Nacos 作为注册中心的配置项
nacos:
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
# Apollo 相关配置项
app:
id: ${spring.application.name} # 使用的 Apollo 的项目(应用)编号
apollo:
meta: http://127.0.0.1:8080 # Apollo Meta Server 地址
bootstrap:
enabled: true # 是否开启 Apollo 配置预加载功能。默认为 false。
eagerLoad:
enable: true # 是否开启 Apollo 支持日志级别的加载时机。默认为 false。
namespaces: application # 使用的 Apollo 的命名空间,默认为 application。
为了演示 Gateway 启动时,从 Apollo 加载 spring.cloud.gateway
配置项,作为初始的路由信息,我们在 Apollo 配置如下:
配置对应文本内容如下:
spring.cloud.gateway.routes[0].id = github_route
spring.cloud.gateway.routes[0].uri = http://www.iocoder.cn/
spring.cloud.gateway.routes[0].predicates[0] = Path=/**
(3)动态路由监听器:(ApolloRouteRefresher.java
)
@Component
public class GatewayPropertiesRefresher implements ApplicationContextAware, ApplicationEventPublisherAware {
private static final Logger logger = LoggerFactory.getLogger(GatewayPropertiesRefresher.class);
private ApplicationContext applicationContext;
private ApplicationEventPublisher publisher;
@Autowired
private RouteDefinitionWriter routeDefinitionWriter;
@Override
public void setApplicationContext(ApplicationContext applicationContext) throws BeansException {
this.applicationContext = applicationContext;
}
@Override
public void setApplicationEventPublisher(ApplicationEventPublisher applicationEventPublisher) {
this.publisher = applicationEventPublisher;
}
@ApolloConfigChangeListener(interestedKeyPrefixes = "spring.cloud.gateway.") // <1>
public void onChange(ConfigChangeEvent changeEvent) {
refreshGatewayProperties(changeEvent);
}
private void refreshGatewayProperties(ConfigChangeEvent changeEvent) {
logger.info("Refreshing GatewayProperties!");
// <2>
preDestroyGatewayProperties(changeEvent);
// <3>
this.applicationContext.publishEvent(new EnvironmentChangeEvent(changeEvent.changedKeys()));
// <4>
refreshGatewayRouteDefinition();
logger.info("GatewayProperties refreshed!");
}
// ... 省略被调用的方法,一会说。
}
① <1>
处,通过 Apollo 提供的 @ApolloConfigChangeListener 注解,声明监听 spring.cloud.gateway.
配置项的刷新。
② <2>
处,调用 #preDestroyGatewayProperties(ConfigChangeEvent changeEvent)
方法,处理 spring.cloud.gateway.routes
或 spring.cloud.gateway.default-filters
配置项可能被删除光的特殊骚操作。代码如下:
private static final String ID_PATTERN = "spring\\.cloud\\.gateway\\.routes\\[\\d+\\]\\.id";
private static final String DEFAULT_FILTER_PATTERN = "spring\\.cloud\\.gateway\\.default-filters\\[\\d+\\]\\.name";
@Autowired
private GatewayProperties gatewayProperties;
private synchronized void preDestroyGatewayProperties(ConfigChangeEvent changeEvent) {
logger.info("Pre Destroy GatewayProperties!");
// 判断 `spring.cloud.gateway.routes` 配置项,是否被全部删除。如果是,则置空 GatewayProperties 的 `routes` 属性
final boolean needClearRoutes = this.checkNeedClear(changeEvent, ID_PATTERN, this.gatewayProperties.getRoutes().size());
if (needClearRoutes) {
this.gatewayProperties.setRoutes(new ArrayList<>());
}
// 判断 `spring.cloud.gateway.default-filters` 配置项,是否被全部删除。如果是,则置空 GatewayProperties 的 `defaultFilters` 属性
final boolean needClearDefaultFilters = this.checkNeedClear(changeEvent, DEFAULT_FILTER_PATTERN, this.gatewayProperties.getDefaultFilters().size());
if (needClearDefaultFilters) {
this.gatewayProperties.setRoutes(new ArrayList<>());
}
logger.info("Pre Destroy GatewayProperties finished!");
}
// 判断是否清除的标准,是通过指定配置项被删除的数量,是否和内存中的该配置项的数量一样。如果一样,说明被清空了
private boolean checkNeedClear(ConfigChangeEvent changeEvent, String pattern, int existSize) {
return changeEvent.changedKeys().stream().filter(key -> key.matches(pattern))
.filter(key -> {
ConfigChange change = changeEvent.getChange(key);
return PropertyChangeType.DELETED.equals(change.getChangeType());
}).count() == existSize;
}
6. 基于配置中心 Nacos 实现动态路由:
我们使用 Gateway 提供的基于注册中心来自动创建动态路由的功能。但是很多时候,这个功能并不能满足我们的需求,例如说:
- 注册中心的服务这么多,我们并不想通过网关暴露所有的服务出去
- 每个服务的路由信息可能不同,会存在配置不同过滤器的情况
因此,我们可以引入配置中心 Nacos 来实现动态路由的功能,将 spring.cloud.gateway
配置项统一存储在 Nacos 中。同时,通过通过 Nacos 的实时监听器,在 spring.cloud.gateway
发生变化时,刷新内存中的路由信息。
当然,Gateway 中我们还是会使用注册中心,目的是为了获取服务的实例列表,只是不再使用 Gateway 基于注册中心来的动态路由功能而已。
(1)修改 pom.xml 文件:
引入配置中心 Nacos 相关的依赖如下:
<!-- 引入 Spring Cloud Alibaba Nacos Config 相关依赖,将 Nacos 作为配置中心,并实现对其的自动配置 -->
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
(2)配置文件
① 创建 bootstrap.yaml 配置文件,添加配置中心 Nacos 相关的配置。配置如下:
spring:
application:
name: gateway-application
cloud:
nacos:
# Nacos Config 配置项,对应 NacosConfigProperties 配置属性类
config:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
namespace: # 使用的 Nacos 的命名空间,默认为 null
group: DEFAULT_GROUP # 使用的 Nacos 配置分组,默认为 DEFAULT_GROUP
name: # 使用的 Nacos 配置集的 dataId,默认为 spring.application.name
file-extension: yaml # 使用的 Nacos 配置集的 dataId 的文件拓展名,同时也是 Nacos 配置集的配置格式,默认为 properties
② 修改 application.yaml 配置文件,删除 Gateway 相关的配置。完整配置如下:
server:
port: 8888
spring:
cloud:
# Spring Cloud Gateway 配置项,全部配置在 Nacos 中
# gateway:
# Nacos 作为注册中心的配置项
nacos:
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址
spring.cloud.gateway
配置项,我们都删除了,统一在配置中心 Nacos 中进行配置。
为了演示 Gateway 启动时,从 Nacos 加载 spring.cloud.gateway
配置项,作为初始的路由信息,我们在 Nacos 配置如下:
(3)Nacos配置监听器:
在 Nacos 配置发生变化时,Spring Cloud Alibaba Nacos Config 内置的监听器 会监听到配置刷新,最终触发 Gateway 的路由信息刷新。完整流程如下图所示:
import com.alibaba.fastjson2.JSON;
import com.alibaba.nacos.api.config.ConfigService;
import com.alibaba.nacos.api.config.listener.Listener;
import org.springframework.cloud.gateway.route.RouteDefinition;
import org.springframework.context.annotation.Configuration;
import javax.annotation.PostConstruct;
import java.util.List;
import java.util.concurrent.Executor;
@Configuration
public class NacosRouteRefresher {
private final NacosRouteDefinitionRepository routeRepository;
public NacosRouteRefresher(NacosRouteDefinitionRepository routeRepository) {
this.routeRepository = routeRepository;
}
@PostConstruct
public void init() {
// 注册Nacos监听器
ConfigService configService = NacosFactory.createConfigService("127.0.0.1:8848");
configService.addListener(
"gateway-routes.yaml",
"GATEWAY_GROUP",
new Listener() {
@Override
public Executor getExecutor() {
return null; // 使用默认线程池
}
@Override
public void receiveConfigInfo(String configInfo) {
// 配置变更时触发路由更新
List<RouteDefinition> routes = JSON.parseArray(configInfo, RouteDefinition.class);
routeRepository.refreshRoutes(routes);
}
}
);
}
}
三.网关登录校验 --- 基于 jwt 令牌:
以前单体架构的登录校验是基于JWT来实现的,校验JWT的算法复杂,而且需要用到秘钥。如果每个微服务都去做登录校验,这就存在着两大问题:
-
每个微服务都需要知道JWT的秘钥,不安全
-
每个微服务重复编写登录校验代码、权限校验代码,麻烦
既然网关是所有微服务的入口,一切请求都需要先经过网关。我们完全可以把登录校验的工作放到网关去做,这样之前说的问题就解决了:
-
只需要在网关和用户服务保存秘钥
-
只需要在网关开发登录校验功能
但是出现三个问题需要思考:
网关路由是配置的,请求转发是Gateway内部代码,我们如何在转发之前做登录校验?
网关校验JWT之后,如何将用户信息传递给微服务?
微服务之间也会相互调用,这种调用不经过网关,又该如何传递用户信息?
1.网关过滤器:
Gateway的工作原理:
-
客户端请求进入网关后由
HandlerMapping
对请求做判断,找到与当前请求匹配的路由规则(Route
),然后将请求交给WebHandler
去处理。 -
WebHandler
则会加载当前路由下需要执行的过滤器链(Filter chain
),然后按照顺序逐一执行过滤器(后面称为Filter
)。 -
图中
Filter
被虚线分为左右两部分,是因为Filter
内部的逻辑分为pre
和post
两部分,分别会在请求路由到微服务之前和之后被执行。 -
只有所有
Filter
的pre
逻辑都依次顺序执行通过后,请求才会被路由到微服务。 -
微服务返回结果后,再倒序执行
Filter
的post
逻辑。 -
最终把响应结果返回。
如图中所示,最终请求转发是有一个名为 NettyRoutingFilter
的过滤器来执行的,而且这个过滤器是整个过滤器链中顺序最靠后的一个。如果我们能够定义一个过滤器,在其中实现登录校验逻辑,并且将过滤器执行顺序定义到 NettyRoutingFilter
之前,这就符合我们的需求了!
两种过滤器:
-
GatewayFilter
:路由过滤器,作用范围比较灵活,可以是任意指定的路由Route
. -
GlobalFilter
:全局过滤器,作用范围是所有路由,不可配置。
特性 | GatewayFilter(路由过滤器) | GlobalFilter(全局过滤器) |
---|---|---|
作用范围 | 作用于特定路由 | 作用于所有路由 |
可配置性 | 灵活,配置在单独的路由上 | 无法配置,所有请求都必须经过 |
实现方式 | 配置在 YAML 中 | 实现 GlobalFilter 接口 |
典型用途 | 认证、路径修改、添加响应头 | 全局日志、安全检查、限流 |
控制执行顺序 | 在特定路由下按顺序执行 | 使用 getOrder() 方法控制顺序 |
/**
* 处理请求并将其传递给下一个过滤器
* @param exchange 当前请求的上下文,其中包含request、response等各种数据
* @param chain 过滤器链,基于它向下传递请求
* @return 根据返回值标记当前请求是否被完成或拦截,chain.filter(exchange)就放行了。
*/
Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain);
FilteringWebHandler
在处理请求时,会将GlobalFilter
装饰为GatewayFilter
,然后放到同一个过滤器链中,排序以后依次执行。
2.自定义 GlobalFilter 全局过滤器的使用:
下面是全局过滤器的基本使用:
@Component
public class PrintAnyGlobalFilter implements GlobalFilter, Ordered {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
// 编写过滤器逻辑
System.out.println("未登录,无法访问");
// 放行
// return chain.filter(exchange);
// 拦截
ServerHttpResponse response = exchange.getResponse();
response.setRawStatusCode(401);
return response.setComplete();
}
@Override
public int getOrder() {
// 过滤器执行顺序,值越小,优先级越高
return 0;
}
}
AuthProperties
:配置登录校验需要拦截的路径,因为不是所有的路径都需要登录才能访问
JwtProperties
:定义与JWT工具有关的属性,比如秘钥文件位置
SecurityConfig
:工具的自动装配
JwtTool
:JWT工具,其中包含了校验和解析token
的功能
hmall.jks
:秘钥文件
其中AuthProperties
和JwtProperties
所需的属性要在application.yaml
中配置:
hm:
jwt:
location: classpath:hmall.jks # 秘钥地址
alias: hmall # 秘钥别名
password: hmall123 # 秘钥文件密码
tokenTTL: 30m # 登录有效期
auth:
excludePaths: # 无需登录校验的路径
- /search/**
- /users/login
- /items/**
下面是AuthPropperties的配置类:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.stereotype.Component;
import java.util.List;
@Data
@Component
@ConfigurationProperties(prefix = "hm.auth")
public class AuthProperties {
private List<String> includePaths;
private List<String> excludePaths;
}
下面是JwtProperties的配置类:
import lombok.Data;
import org.springframework.boot.context.properties.ConfigurationProperties;
import org.springframework.core.io.Resource;
import java.time.Duration;
@Data
@ConfigurationProperties(prefix = "hm.jwt")
public class JwtProperties {
private Resource location;
private String password;
private String alias;
private Duration tokenTTL = Duration.ofMinutes(10);
}
下面是SecurityConfig的配置类:
import org.springframework.boot.context.properties.EnableConfigurationProperties;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.rsa.crypto.KeyStoreKeyFactory;
import java.security.KeyPair;
@Configuration
@EnableConfigurationProperties(JwtProperties.class)
public class SecurityConfig {
@Bean
public PasswordEncoder passwordEncoder(){
return new BCryptPasswordEncoder();
}
@Bean
public KeyPair keyPair(JwtProperties properties){
// 获取秘钥工厂
KeyStoreKeyFactory keyStoreKeyFactory =
new KeyStoreKeyFactory(
properties.getLocation(),
properties.getPassword().toCharArray());
//读取钥匙对
return keyStoreKeyFactory.getKeyPair(
properties.getAlias(),
properties.getPassword().toCharArray());
}
}
下面是JWT令牌的工具类:
import cn.hutool.core.exceptions.ValidateException;
import cn.hutool.jwt.JWT;
import cn.hutool.jwt.JWTValidator;
import cn.hutool.jwt.signers.JWTSigner;
import cn.hutool.jwt.signers.JWTSignerUtil;
import com.hmall.common.exception.UnauthorizedException;
import org.springframework.stereotype.Component;
import java.security.KeyPair;
import java.time.Duration;
import java.util.Date;
@Component
public class JwtTool {
private final JWTSigner jwtSigner;
public JwtTool(KeyPair keyPair) {
this.jwtSigner = JWTSignerUtil.createSigner("rs256", keyPair);
}
/**
* 创建 access-token
*
* @param userId 用户信息
* @return access-token
*/
public String createToken(Long userId, Duration ttl) {
// 1.生成jwt
return JWT.create()
.setPayload("user", userId)
.setExpiresAt(new Date(System.currentTimeMillis() + ttl.toMillis()))
.setSigner(jwtSigner)
.sign();
}
/**
* 解析token
*
* @param token token
* @return 解析刷新token得到的用户信息
*/
public Long parseToken(String token) {
// 1.校验token是否为空
if (token == null) {
throw new UnauthorizedException("未登录");
}
// 2.校验并解析jwt
JWT jwt;
try {
jwt = JWT.of(token).setSigner(jwtSigner);
} catch (Exception e) {
throw new UnauthorizedException("无效的token", e);
}
// 2.校验jwt是否有效
if (!jwt.verify()) {
// 验证失败
throw new UnauthorizedException("无效的token");
}
// 3.校验是否过期
try {
JWTValidator.of(jwt).validateDate();
} catch (ValidateException e) {
throw new UnauthorizedException("token已经过期");
}
// 4.数据格式校验
Object userPayload = jwt.getPayload("user");
if (userPayload == null) {
// 数据为空
throw new UnauthorizedException("无效的token");
}
// 5.数据解析
try {
return Long.valueOf(userPayload.toString());
} catch (RuntimeException e) {
// 数据格式有误
throw new UnauthorizedException("无效的token");
}
}
}
接下来,我们定义一个登录校验的过滤器:
现在,网关已经可以完成登录校验并获取登录用户身份信息。但是当网关将请求转发到微服务时,微服务又该如何获取用户身份呢?
由于网关发送请求到微服务依然采用的是Http
请求,因此我们可以将用户信息以请求头的方式传递到下游微服务。然后微服务可以从请求头中获取登录用户信息。考虑到微服务内部可能很多地方都需要用到登录用户信息,因此我们可以利用SpringMVC的拦截器来实现登录用户信息获取,并存入ThreadLocal,方便后续使用。
import cn.hutool.core.text.AntPathMatcher;
import com.hmall.common.exception.UnauthorizedException;
import com.hmall.hmgateway.config.AuthProperties;
import com.hmall.hmgateway.utils.JwtTool;
import lombok.RequiredArgsConstructor;
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.ServerHttpRequest;
import org.springframework.http.server.reactive.ServerHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
import java.util.List;
@Component
@RequiredArgsConstructor
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private final AuthProperties authProperties;
private final JwtTool jwtTool;
//正则路径匹配,Spring提供的方法
private final AntPathMatcher antPathMatcher = new AntPathMatcher();
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//获取用户信息
ServerHttpRequest request = exchange.getRequest();
//判断是否需要做登录拦截
if(isExclude(request.getPath().toString())){
return chain.filter(exchange);
}
//获取token
String token = null;
List<String> headers = request.getHeaders().get("authorization");
if(headers != null && !headers.isEmpty()){
token = headers.get(0);
}
//校验并解析token
Long userId = null;
try{
userId = jwtTool.parseToken(token);
} catch (UnauthorizedException e){
//401 拦截
ServerHttpResponse response = exchange.getResponse();
response.setStatusCode(HttpStatus.UNAUTHORIZED);
return response.setComplete();//终止
}
//传递用户信息
System.out.println("userId = " + userId);
String userInfo = userId.toString();
ServerWebExchange swe = exchange.mutate()
.request(builder -> builder.header("user-info", userInfo))
.build();
//放行
return chain.filter(swe);
}
//判断是否为放行路径
private boolean isExclude(String path) {
//拿到所有的放行路径
for (String pathPatten : authProperties.getExcludePaths()) {
if(antPathMatcher.match(pathPatten,path)){
return true;
}
}
return false;
}
//优先级
@Override
public int getOrder() {
return 0;
}
}
然后再每个微服务组件定义拦截器:
package com.hmall.common.interceptors;
import cn.hutool.core.util.StrUtil;
import com.hmall.common.utils.UserContext;
import org.springframework.web.servlet.HandlerInterceptor;
import javax.naming.Context;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class UserInfoInterceptor implements HandlerInterceptor {
//在controller之前执行代码
@Override
public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception {
//获取登录用户信息
String userInfo = request.getHeader("user-info");
//判断
if(StrUtil.isNotEmpty(userInfo)){
UserContext.setUser(Long.valueOf(userInfo));
}
return true;
}
//controller执行结束后执行代码
@Override
public void afterCompletion(HttpServletRequest request, HttpServletResponse response, Object handler, Exception ex) throws Exception {
//清理用户
UserContext.removeUser();
}
}
3.将用户信息保存在线程中:
先导入UserContext:
public class UserContext {
private static final ThreadLocal<Long> tl = new ThreadLocal<>();
/**
* 保存当前登录用户信息到ThreadLocal
* @param userId 用户id
*/
public static void setUser(Long userId) {
tl.set(userId);
}
/**
* 获取当前登录用户信息
* @return 用户id
*/
public static Long getUser() {
return tl.get();
}
/**
* 移除当前登录用户信息
*/
public static void removeUser(){
tl.remove();
}
}
之后加入MVC配置,让拦截器生效:
import com.hmall.common.interceptors.UserInfoInterceptor;
import org.springframework.boot.autoconfigure.condition.ConditionalOnClass;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.servlet.DispatcherServlet;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
@Configuration
//DispatcherServlet条件是SpringMVC特有的API,这样配置才能让网关(不是MVC实现)不会使用这个拦截器配置类
@ConditionalOnClass(DispatcherServlet.class)
public class MvcConfig implements WebMvcConfigurer {
@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(new UserInfoInterceptor());
}
}
随后在/resources/META_INF/spring.factories文件内配置,让Spring能够扫描Config:
org.springframework.boot.autoconfigure.EnableAutoConfiguration=com.hmall.common.config.MvcConfig
四.使用OpenFeign传递用户信息:
前端发起的请求都会经过网关再到微服务,由于我们之前编写的过滤器和拦截器功能,微服务可以轻松获取登录用户信息。
但有些业务是比较复杂的,请求到达微服务后还需要调用其它多个微服务。比如下单业务,流程如下:
下单的过程中,需要调用商品服务扣减库存,调用购物车服务清理用户购物车。而清理购物车时必须知道当前登录的用户身份。但是,订单服务调用购物车时并没有传递用户信息,购物车服务无法知道当前用户是谁!
由于微服务获取用户信息是通过拦截器在请求头中读取,因此要想实现微服务之间的用户信息传递,就必须在微服务发起调用时把用户信息存入请求头。
微服务之间调用是基于OpenFeign来实现的,并不是我们自己发送的请求。我们如何才能让每一个由OpenFeign发起的请求自动携带登录用户信息呢?
这里要借助Feign中提供的一个拦截器接口:feign.RequestInterceptor
public interface RequestInterceptor {
/**
* Called for every request.
* Add data using methods on the supplied {@link RequestTemplate}.
*/
void apply(RequestTemplate template);
}
在com.hmall.api.config.DefaultFeignConfig
中添加一个Bean:
@Bean
public RequestInterceptor userInfoRequestInterceptor(){
return new RequestInterceptor() {
@Override
public void apply(RequestTemplate template) {
// 获取登录用户
Long userId = UserContext.getUser();
if(userId == null) {
// 如果为空则直接跳过
return;
}
// 如果不为空则放入请求头中,传递给下游微服务
template.header("user-info", userId.toString());
}
};
}
四.限流(Rate Limiting)
1.什么是限流:
限流是控制客户端访问请求的数量或频率,避免过多请求导致服务器过载。
常见的限流策略包括:
- 固定时间窗口:每秒/每分钟允许的请求数。
- 滑动窗口:请求频率在动态时间窗口内计算。
- 令牌桶算法:为每个客户端分配一个“令牌”限额。
- 漏桶算法:保证恒定的请求处理速率。
2.限流的实现:
Spring Cloud Gateway 使用Redis RateLimiter 作为默认的限流实现,每个客户端在固定时间内只能获得一定数量的令牌。
3.限流配置:
spring:
cloud:
gateway:
routes:
- id: user-service-route
uri: lb://user-service # 将请求路由到 user-service
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10 # 每秒生成 10 个令牌
redis-rate-limiter.burstCapacity: 20 # 允许的最大突发请求数
key-resolver: "#{@ipKeyResolver}" # 限流基于 IP 地址
replenishRate
:每秒向令牌桶中添加的令牌数,即每秒允许的平均请求数。burstCapacity
:桶中的最大令牌数,用于支持突发流量。key-resolver
:用于根据请求提取唯一标识,如客户端 IP 地址。
4.自定义 Key Resolver:
在 Spring Cloud Gateway 中,Key Resolver 是用于限流过滤器(RequestRateLimiter)中的一个核心组件。它决定了限流的维度,即根据什么来进行限流。例如,可以按 IP 地址、用户 ID 或 请求路径 来定义不同的限流策略。
Spring Cloud Gateway 的限流功能依赖于 Redis 作为分布式计数器。默认情况下,限流策略可以使用 IP 地址进行区分。但是,在一些复杂的场景下,你可能需要自定义限流的关键维度:
- 按用户 ID 限流:控制单个用户的访问频率。
- 按 API 路径限流:限制某些敏感 API 的访问。
- 按设备类型限流:区分 PC 端和移动端的流量。
自定义 Key Resolver 就是为了解决这些复杂场景下的限流需求。
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
@Component
public class IpKeyResolver implements KeyResolver {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
// 提取客户端 IP 地址作为限流的唯一标识
return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
这个 KeyResolver 从请求中获取客户端的 IP 地址,用于作为限流的标识符。
五.熔断(Circuit Breaking)
1.什么是熔断:
熔断机制的灵感来自电路断路器。
当某个服务出现延迟或错误率过高时,熔断机制会暂时切断请求,避免请求继续积压导致系统雪崩。熔断器经过一段时间后会尝试半开状态,判断服务是否恢复。
熔断的状态通常包括:
- Closed(关闭):正常工作,转发请求。
- Open(开启):触发熔断,直接拒绝请求。
- Half-Open(半开):部分请求通过,如果成功,熔断器关闭。
2.熔断的配置:
spring:
cloud:
gateway:
routes:
- id: order-service-route
uri: lb://order-service
filters:
- name: CircuitBreaker
args:
name: orderServiceCircuitBreaker # 熔断器的名字
fallbackUri: forward:/fallback # 熔断后的降级处理地址
CircuitBreaker
:启用熔断器过滤器。fallbackUri
:当熔断发生时,将请求转发到降级处理服务/fallback
。
3.自定义降级处理
在熔断器触发时,我们可以将请求转发到一个降级服务,向用户返回友好的提示信息。
@RestController
public class FallbackController {
@RequestMapping("/fallback")
public ResponseEntity<String> fallback() {
return new ResponseEntity<>("服务暂时不可用,请稍后重试", HttpStatus.SERVICE_UNAVAILABLE);
}
}
4. 限流与熔断的原理图
-
限流过程:
- 每次请求会从 Redis 中取令牌,令牌不足则拒绝请求。
- 支持突发请求,但总请求数不能超过限额。
-
熔断过程:
- 请求频繁失败时,熔断器会开启,拒绝后续请求。
- 一段时间后进入半开状态,少量请求通过判断服务是否恢复。
- 如果服务恢复,熔断器关闭;否则重新开启熔断。
更多推荐
所有评论(0)