原文地址
前言
本着能用原生就用原生的原则,我们这里使用SpringGateway来作为云服务的网关
配置
从官网的介绍来看,spring网关拥有的功能有,路由(配置,过滤,重写等),熔断以及流量控制,
首先引入包
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
动态路由
路由的配置比较简单,有两种方法:使用配置文件和代码注入,我们这里简单展示下两种方法
@Configuration
@Slf4j
public class RouterConfig {
@Resource
private GatewayProperties gatewayProperties;
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
log.info("[op:routes] loaded route = {}", JSON.toJSONString(gatewayProperties));
RouteLocatorBuilder.Builder thisBuilder = builder.routes();
thisBuilder.route("your-application", request -> request
.path("/your-application/**")
.filters(filter -> filter.stripPrefix(1))
.uri("lb://your-application"));
return thisBuilder.build();
}
}
或者使用
spring:
cloud:
gateway:
routes:
- id: your-application
uri: lb://your-application
predicates:
- Path=/your-application/**
filters:
- StripPrefix=1
路由配置中id、uri、order、predicates.path/host没什么好说的,根据需求配置即可,filters相关参数,这里最好还是参考源码相关部分或者Spring Cloud Gateway比较全面,比如常用的前缀切割
/**
* Strips the prefix from the path of the request before it is routed by the Gateway.
* @param parts the number of parts of the path to remove
* @return a {@link GatewayFilterSpec} that can be used to apply additional filters
*/
public GatewayFilterSpec stripPrefix(int parts) {
return filter(getBean(StripPrefixGatewayFilterFactory.class).apply(c -> c.setParts(parts)));
}
这里我们以常用的两种filter,流量控制和熔断降级举例
流量控制
通常我们需要限流来保证服务的可用性,保护一些不太稳定的服务不会因为高并发的请求而挂掉,这里我们一般再网关层做流量控制,减少实际进入的请求达到平波峰的目的
计数器算法
如果某个服务会在请求中数量达到201时候挂掉,请求平均时间为2s,我们给一段时间一个请求量的限制,比如2秒200次,每次请求进入就减少200计数,每2s开始时重新计数,这样就能在保证服务请求中数量在200以内。但是对于抢购类接口,可能前50ms请求数量就用完了,后面所有请求都被拒绝,即请求突刺现象,这样的用户体验是非常差的所以我们需要尽可能在所有的时间内保证接口的可用性(计数器算法就像DRAM中的集中式刷新一样不太能被接受),而且短时间内大量请求运行在相同代码段是非常危险的,在设计不好的情况很可能会出现数据库死锁等等问题
漏桶算法
我们需要让请求尽可能地能进行来,就需要平波峰填波谷,就上例而言,2s内最大请求为200,也就是每个请求占用的时间比例为10ms,我们设计一个容量为200的桶(队列)每10ms向接口发一个请求,可以让服务中请求数量不超过200的情况下,每10ms都能接受一个新的请求,这样就缓解了请求突刺现象。但是这里还有一个问题,对于抢购类接口,200个容量可能50ms就用完了,在第60ms可能还会有40个请求抢1个位置,39个请求会被取消,这样也是相对来说不能被接受的
令牌桶算法
令牌桶算法就是目前spring cloud gateway采用的算法,这里采用的用户时间换用户失败的策略,假设我们认为用户的平均忍耐时间为8秒,接口超过8秒一些用户就要骂街了,减去实际执行的2秒,也就是说我们的可以利用6秒的时间容纳更多的请求。依上文而言每10ms去调用这个端口,那么也就是说桶的设计可以更大,在桶里放上令牌,每个请求需要在桶里面拿到令牌才能调用,这里的桶容量就是6s/10ms为600个。但是我们的执行速度是不变的,也就是结果是,在请求多的情况下用户的执行时间在8秒左右,而在请求少的情况下执行速度在2s左右,这样就缓解了短时间内大量请求导致大量失败的问题了。这里比较重要的参数有两个,第一个是桶请求容量 defaultBurstCapacity,第二个是每秒执行的请求速度(也就是桶的填充速率)defaultReplenishRate
在这个例子中defaultBurstCapacity=600而defaultReplenishRate=100,这两个参数我们会在下方配置
这里我们需要引用redis包,再说明一下,本站使用的是jdk17的版本,其他版本的配置和引用可能会稍有变化,需要调整
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
<version>${spring-boot.version}</version>
</dependency>
覆写KeyResolver的实现类
@Component
public class UriKeyResolver implements KeyResolver {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getURI().getPath());
}
}
流量控制,这里同样有代码实现和配置文件实现,由于目前idea对于复杂配置文件的支持不太好,如果使用配置文件方式会疯狂报红,但是如果全部使用代码的话会不方便实现动态路由,因为gateway是先加载配置再处理代码的。所以这里我们路由使用配置,filter之类复杂的使用代码实现,下面是简单示例
@Configuration
@Slf4j
public class RouterConfig {
@Resource
private GatewayProperties gatewayProperties;
private static final String ROUTER_PREDICATE_PATH = "Path";
private static final String ROUTER_PREDICATE_PATH_ARGS_DEFAULT_KEY = "_genkey_0";
private static final Integer DEFAULT_STRIP_PREFIX = 1;
@Value("${rate-limiter.defaultReplenishRate:100}")
private Integer defaultReplenishRate;
@Value("${rate-limiter.defaultBurstCapacity:600}")
private Integer defaultBurstCapacity;
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
log.info("[op:routes] router config loaded = {}", JSON.toJSONString(gatewayProperties));
RouteLocatorBuilder.Builder thisBuilder = builder.routes();
if (null != gatewayProperties && !CollectionUtils.isEmpty(gatewayProperties.getRoutes())) {
List<RouteDefinition> configRoutes = gatewayProperties.getRoutes();
for (RouteDefinition configRoute : configRoutes) {
thisBuilder.route(configRoute.getId(), path -> path
.path(getPathFromRouteDefinition(configRoute))
.filters(filter -> {
filter.requestRateLimiter(r -> r.setRateLimiter(redisRateLimiter()));
filter.stripPrefix(DEFAULT_STRIP_PREFIX);
return filter;
})
.uri(configRoute.getUri()));
}
}
return thisBuilder.build();
}
private static String getPathFromRouteDefinition(RouteDefinition configRoute) {
return configRoute.getPredicates().stream()
.filter(pre -> pre.getName().equals(ROUTER_PREDICATE_PATH)).toList()
.get(0).getArgs().get(ROUTER_PREDICATE_PATH_ARGS_DEFAULT_KEY);
}
@Bean
public RedisRateLimiter redisRateLimiter() {
return new RedisRateLimiter(defaultReplenishRate, defaultBurstCapacity);
}
}
这样全服务层面的接口流量控制就完成了,具体的哪些服务使用流量控制,具体控制参数的配置,自行稍作修改即可
测试流量控制的话,可以将令牌回复量和令牌总容量调至比较低的水平,然后再浏览器直接curl接口,比如令牌回复量和容量为1,则单秒内curl即可触发浏览器429提示,线上大令牌容量测试能需要多线程curl了,这里参考官方文档给的lua脚本
ip限流
如果我们需要对某个ip进行限流,比如防止脚本抢货,我们这里需要KeyResolver的实现不再使用 exchange.getRequest().getURI().getPath() ,而是使用 exchange.getRequest().getRemoteAddress() 。但是这里还有一个问题,我们请求是经过层层转发的,nginx,docker等,所以我们可能并不能拿到原始的请求地址,所以这里我们需要在最外层,比如nginx中将原始地址存到header或者cookie当中,这里给出简单示例
location / {
proxy_pass http://localhost:9527;
#保留原始访问信息
proxy_set_header X-Real-IP $remote_addr;
#...
}
当然还有其他类似X-Forwarded-For的字段不再本文主要探讨范围就不多拓展了,在nginx中配置记录初始远程地址到header后,我们这里需要在程序中取出来,如果你这里使用的标准的X-Real-IP的字段去存储,那么只需要
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
XForwardedRemoteAddressResolver resolver = XForwardedRemoteAddressResolver.maxTrustedIndex(1);
InetSocketAddress inetSocketAddress = resolver.resolve(exchange);
String realAdd = inetSocketAddress.getAddress().getHostAddress();
//...
}
即可获取真实地址,如果你这里自定义了一个header的key那么需要在**exchange.getRequest().getHeaders()**里面自己找出来了
最后我们这里给出对同一个接口同时配置两种限流的示例
public class RouterConfig {
@Resource
private GatewayProperties gatewayProperties;
private static final String ROUTER_PREDICATE_PATH = "Path";
private static final String ROUTER_PREDICATE_PATH_ARGS_DEFAULT_KEY = "_genkey_0";
private static final Integer DEFAULT_STRIP_PREFIX = 1;
@Value("${rate-limiter.path.replenishRate:100}")
private Integer pathReplenishRate;
@Value("${rate-limiter.path.burstCapacity:600}")
private Integer pathBurstCapacity;
@Value("${rate-limiter.ip.replenishRate:2}")
private Integer ipReplenishRate;
@Value("${rate-limiter.ip.burstCapacity:2}")
private Integer ipBurstCapacity;
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
log.info("[op:routes] router config loaded = {}", JSON.toJSONString(gatewayProperties));
RouteLocatorBuilder.Builder thisBuilder = builder.routes();
if (null != gatewayProperties && !CollectionUtils.isEmpty(gatewayProperties.getRoutes())) {
List<RouteDefinition> configRoutes = gatewayProperties.getRoutes();
for (RouteDefinition configRoute : configRoutes) {
thisBuilder.route(configRoute.getId(), path -> path
.path(getPathFromRouteDefinition(configRoute))
.filters(filter -> {
filter.requestRateLimiter(r -> {
r.setRateLimiter(pathRedisRateLimiter());
r.setKeyResolver(new PathKeyResolver());
});
filter.requestRateLimiter(r -> {
r.setRateLimiter(ipRedisRateLimiter());
r.setKeyResolver(new IpKeyResolver());
r.setStatusCode(HttpStatus.REQUEST_HEADER_FIELDS_TOO_LARGE);
});
filter.stripPrefix(DEFAULT_STRIP_PREFIX);
return filter;
})
.uri(configRoute.getUri()));
}
}
return thisBuilder.build();
}
private static String getPathFromRouteDefinition(RouteDefinition configRoute) {
return configRoute.getPredicates().stream()
.filter(pre -> pre.getName().equals(ROUTER_PREDICATE_PATH)).toList()
.get(0).getArgs().get(ROUTER_PREDICATE_PATH_ARGS_DEFAULT_KEY);
}
@Bean
@Primary
public RedisRateLimiter pathRedisRateLimiter() {
return new RedisRateLimiter(pathReplenishRate, pathBurstCapacity);
}
@Bean
public RedisRateLimiter ipRedisRateLimiter() {
return new RedisRateLimiter(ipReplenishRate, ipBurstCapacity);
}
}
我在ip限流这里修改了返回的code由429改为了431,方便测试,这里我们将ip的限流参数设置为(2,2),将path的限流参数设置为(1,10)然后不断请求接口就发现一开始返回431错误,后续path令牌桶用完后返回429错误,即设置成功
补充
如果这里你不希望返回429,并且要求返回一个用户可读的带有json信息结果,那么比较好的业务处理方式是前端完成。如果是对外接口的话,那么我们这里就只能重写RateLimiter的实现了,不再使用RedisRateLimiter的类,而是自己去继承RateLimiter接口去实现,
public class CustomRateLimiter implements RateLimiter<Object> {
//...
}
参考SpringCloudGateway限流后,默认返回429的改造:改302跳转或增加响应body,这篇文章已经很详细,这里就不赘述了
熔断降级
熔断降级,即某个接口调用失败时使用其他接口代替,来保证整体服务对外的可用性
首先需要引入熔断包
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-circuitbreaker-reactor-resilience4j</artifactId>
<version>${spring-cloud.version}</version>
</dependency>
circuitbreaker-reactor-resilience4j 熔断的相关配置分为两个部分,熔断逻辑本身的配置以及在集成到gateway中时候,网关的配置,熔断的重要的配置有,触发熔断的接口,代替接口,熔断超时时间(当然还有其他的,比如自定义熔断HttpStatus等等,详细参数参考Spring Cloud Circuit Breaker以及resilience4j官网)
这里熔断触发接口和代替接口配置位于 gateway 中,这里我们使用代码实现,位置参考前述
@Bean
public RouteLocator routes(RouteLocatorBuilder builder) {
log.info("[op:routes] router config loaded = {}", JSON.toJSONString(gatewayProperties));
RouteLocatorBuilder.Builder thisBuilder = builder.routes();
if (null != gatewayProperties && !CollectionUtils.isEmpty(gatewayProperties.getRoutes())) {
List<RouteDefinition> configRoutes = gatewayProperties.getRoutes();
for (RouteDefinition configRoute : configRoutes) {
thisBuilder.route(configRoute.getId(), path -> path
.path(getPathFromRouteDefinition(configRoute))
.filters(filter -> {
//...
filter.circuitBreaker(config ->
{
config.setName("your_breaker_id");
config.setFallbackUri("forward:/reset/path");
});
return filter;
})
.uri(configRoute.getUri()));
}
}
return thisBuilder.build();
}
这里setName的目的是和熔断包中的配置产生对应关系,下方为熔断包的配置,这里定义默认超时时间(也就是没有匹配到name的超时时间)为10s,your_breaker_id的超时时间为3s
resilience4j:
timelimiter:
configs:
default:
timeout-duration: 10s
your_breaker_id:
timeoutDuration: 3s
最后
到这里网关的基本功能就差不多了,自定义的一些业务功能配置,比如header,cookie,以及调用方ip的处理逻辑等等其实都是在网关层处理的,可以参考Spring Cloud Gateway WebFilter Factories以及Writing Custom Spring Cloud Gateway Filters,但是这种配置基本都没什么坑,这里就不谈了
网关由于不经常作为业务逻辑被重构,所以网络上的资料相对比较少,我这里使用的又是最新的版本还是蛮多和前版本不一样的地方,尤其是webflux的一些东西,很多问题需要看源码才能解决,非常的消耗意志力。这里建议小伙伴们如果是业务使用的这种资料相对较少的架构,最好还是不要使用最新版本的比较好,毕竟万一遇到坑,踩个一两天是很正常的事情,而这种在业务场景可能就没那么容易接受了