Spring Cloud Gateway 实现 Token 校验
Spring Cloud Gateway 实现 Token 校验
在我看来,在某些场景下,网关就像是一个公共方法,把项目中的都要用到的一些功能提出来,抽象成一个服务。比如,我们可以在业务网关上做日志收集、Token 校验等等,当然这么理解很狭隘,因为网关的能力远不止如此,但是不妨碍我们更好地理解它。下面的例子演示了,如何在网关校验 Token,并提取用户信息放到 Header 中传给下游业务系统。
1、生成 Token
用户登录成功以后,生成 token,此后的所有请求都带着 token。网关负责校验 token,并将用户信息放入请求 Header,以便下游系统可以方便的获取用户信息。
为了方便演示,本例中涉及三个工程
公共项目:cjs-commons-jwt
认证服务:cjs-auth-service,生成 token
网关服务:cjs-gateway-example,token 校验
1.1. Token 生成与校验工具类
因为生成 token 在认证服务中,token 校验在网关服务中,因此,我把这一部分写在了公共项目 cjs-commons-jwt 中。
pom.xml
<?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">
<modelVersion>4.0.0</modelVersion>
<groupId>com.cjs.example</groupId>
<artifactId>cjs-commons-jwt</artifactId>
<version>1.0-SNAPSHOT</version>
<properties>
<project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
<maven.compiler.source>1.8</maven.compiler.source>
<maven.compiler.target>1.8</maven.compiler.target>
</properties>
<dependencies>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.0</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>com.alibaba</groupId>
<artifactId>fastjson</artifactId>
<version>1.2.66</version>
</dependency>
</dependencies>
</project>
JWTUtil.java
package com.cjs.example.utils;
import com.auth0.jwt.JWT;
import com.auth0.jwt.JWTVerifier;
import com.auth0.jwt.algorithms.Algorithm;
import com.auth0.jwt.exceptions.JWTDecodeException;
import com.auth0.jwt.exceptions.SignatureVerificationException;
import com.auth0.jwt.exceptions.TokenExpiredException;
import com.auth0.jwt.interfaces.DecodedJWT;
import com.cjs.example.enums.ResponseCodeEnum;
import com.cjs.example.exception.TokenAuthenticationException;
import java.util.Date;
/**
*
* @date 2020-03-08
*/
public class JWTUtil {
public static final long TOKEN_EXPIRE_TIME = 7200 * 1000;
private static final String ISSUER = "cheng";
/**
* 生成Token
* @param username 用户标识(不一定是用户名,有可能是用户ID或者手机号什么的)
* @param secretKey
* @return
*/
public static String generateToken(String username, String secretKey) {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
Date now = new Date();
Date expireTime = new Date(now.getTime() + TOKEN_EXPIRE_TIME);
String token = JWT.create()
.withIssuer(ISSUER)
.withIssuedAt(now)
.withExpiresAt(expireTime)
.withClaim("username", username)
.sign(algorithm);
return token;
}
/**
* 校验Token
* @param token
* @param secretKey
* @return
*/
public static void verifyToken(String token, String secretKey) {
try {
Algorithm algorithm = Algorithm.HMAC256(secretKey);
JWTVerifier jwtVerifier = JWT.require(algorithm).withIssuer(ISSUER).build();
jwtVerifier.verify(token);
} catch (JWTDecodeException jwtDecodeException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_INVALID.getCode(), ResponseCodeEnum.TOKEN_INVALID.getMessage());
} catch (SignatureVerificationException signatureVerificationException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_SIGNATURE_INVALID.getCode(), ResponseCodeEnum.TOKEN_SIGNATURE_INVALID.getMessage());
} catch (TokenExpiredException tokenExpiredException) {
throw new TokenAuthenticationException(ResponseCodeEnum.TOKEN_EXPIRED.getCode(), ResponseCodeEnum.TOKEN_INVALID.getMessage());
} catch (Exception ex) {
throw new TokenAuthenticationException(ResponseCodeEnum.UNKNOWN_ERROR.getCode(), ResponseCodeEnum.UNKNOWN_ERROR.getMessage());
}
}
/**
* 从Token中提取用户信息
* @param token
* @return
*/
public static String getUserInfo(String token) {
DecodedJWT decodedJWT = JWT.decode(token);
String username = decodedJWT.getClaim("username").asString();
return username;
}
}
ResponseCodeEnum.java
package com.cjs.example.enums;
/**
*
* @date 2020-03-08
*/
public enum ResponseCodeEnum {
SUCCESS(0, "成功"),
FAIL(-1, "失败"),
LOGIN_ERROR(1000, "用户名或密码错误"),
UNKNOWN_ERROR(2000, "未知错误"),
PARAMETER_ILLEGAL(2001, "参数不合法"),
TOKEN_INVALID(2002, "无效的Token"),
TOKEN_SIGNATURE_INVALID(2003, "无效的签名"),
TOKEN_EXPIRED(2004, "token已过期"),
TOKEN_MISSION(2005, "token缺失"),
REFRESH_TOKEN_INVALID(2006, "刷新Token无效");
private int code;
private String message;
ResponseCodeEnum(int code, String message) {
this.code = code;
this.message = message;
}
public int getCode() {
return code;
}
public String getMessage() {
return message;
}
}
ResponseResult.java
package com.cjs.example;
import com.cjs.example.enums.ResponseCodeEnum;
/**
*
* @date 2020-03-08
*/
public class ResponseResult<T> {
private int code = 0;
private String msg;
private T data;
public ResponseResult(int code, String msg) {
this.code = code;
this.msg = msg;
}
public ResponseResult(int code, String msg, T data) {
this.code = code;
this.msg = msg;
this.data = data;
}
public static ResponseResult success() {
return new ResponseResult(ResponseCodeEnum.SUCCESS.getCode(), ResponseCodeEnum.SUCCESS.getMessage());
}
public static <T> ResponseResult<T> success(T data) {
return new ResponseResult(ResponseCodeEnum.SUCCESS.getCode(), ResponseCodeEnum.SUCCESS.getMessage(), data);
}
public static ResponseResult error(int code, String msg) {
return new ResponseResult(code, msg);
}
public static <T> ResponseResult<T> error(int code, String msg, T data) {
return new ResponseResult(code, msg, data);
}
public boolean isSuccess() {
return code == 0;
}
public int getCode() {
return code;
}
public void setCode(int code) {
this.code = code;
}
public String getMsg() {
return msg;
}
public void setMsg(String msg) {
this.msg = msg;
}
public T getData() {
return data;
}
public void setData(T data) {
this.data = data;
}
}
1.2. 生成 token
这一部分在 cjs-auth-service 中
pom.xml
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cjs.example</groupId>
<artifactId>cjs-auth-service</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>cjs-auth-service</name>
<properties>
<java.version>1.8</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-lang3</artifactId>
<version>3.9</version>
</dependency>
<dependency>
<groupId>commons-codec</groupId>
<artifactId>commons-codec</artifactId>
<version>1.14</version>
</dependency>
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.8.0</version>
</dependency>
<dependency>
<groupId>com.cjs.example</groupId>
<artifactId>cjs-commons-jwt</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
LoginController.java
package com.cjs.example.controller;
import com.cjs.example.ResponseResult;
import com.cjs.example.domain.LoginRequest;
import com.cjs.example.domain.LoginResponse;
import com.cjs.example.domain.RefreshRequest;
import com.cjs.example.enums.ResponseCodeEnum;
import com.cjs.example.utils.JWTUtil;
import org.apache.commons.lang3.StringUtils;
import org.apache.tomcat.util.security.MD5Encoder;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.redis.core.HashOperations;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.validation.BindingResult;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.*;
import java.util.UUID;
import java.util.concurrent.TimeUnit;
/**
*
* @date 2020-03-08
*/
@RestController
public class LoginController {
/**
* Apollo 或 Nacos
*/
@Value("${secretKey:123456}")
private String secretKey;
@Autowired
private StringRedisTemplate stringRedisTemplate;
/**
* 登录
*/
@PostMapping("/login")
public ResponseResult login(@RequestBody @Validated LoginRequest request, BindingResult bindingResult) {
if (bindingResult.hasErrors()) {
return ResponseResult.error(ResponseCodeEnum.PARAMETER_ILLEGAL.getCode(), ResponseCodeEnum.PARAMETER_ILLEGAL.getMessage());
}
String username = request.getUsername();
String password = request.getPassword();
// 假设查询到用户ID是1001
String userId = "1001";
if ("hello".equals(username) && "world".equals(password)) {
// 生成Token
String token = JWTUtil.generateToken(userId, secretKey);
// 生成刷新Token
String refreshToken = UUID.randomUUID().toString().replace("-", "");
// 放入缓存
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
// hashOperations.put(refreshToken, "token", token);
// hashOperations.put(refreshToken, "user", username);
// stringRedisTemplate.expire(refreshToken, JWTUtil.TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS);
/**
* 如果可以允许用户退出后token如果在有效期内仍然可以使用的话,那么就不需要存Redis
* 因为,token要跟用户做关联的话,就必须得每次都带一个用户标识,
* 那么校验token实际上就变成了校验token和用户标识的关联关系是否正确,且token是否有效
*/
// String key = MD5Encoder.encode(userId.getBytes());
String key = userId;
hashOperations.put(key, "token", token);
hashOperations.put(key, "refreshToken", refreshToken);
stringRedisTemplate.expire(key, JWTUtil.TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS);
LoginResponse loginResponse = new LoginResponse();
loginResponse.setToken(token);
loginResponse.setRefreshToken(refreshToken);
loginResponse.setUsername(userId);
return ResponseResult.success(loginResponse);
}
return ResponseResult.error(ResponseCodeEnum.LOGIN_ERROR.getCode(), ResponseCodeEnum.LOGIN_ERROR.getMessage());
}
/**
* 退出
*/
@GetMapping("/logout")
public ResponseResult logout(@RequestParam("userId") String userId) {
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String key = userId;
hashOperations.delete(key);
return ResponseResult.success();
}
/**
* 刷新Token
*/
@PostMapping("/refreshToken")
public ResponseResult refreshToken(@RequestBody @Validated RefreshRequest request, BindingResult bindingResult) {
String userId = request.getUserId();
String refreshToken = request.getRefreshToken();
HashOperations<String, String, String> hashOperations = stringRedisTemplate.opsForHash();
String key = userId;
String originalRefreshToken = hashOperations.get(key, "refreshToken");
if (StringUtils.isBlank(originalRefreshToken) || !originalRefreshToken.equals(refreshToken)) {
return ResponseResult.error(ResponseCodeEnum.REFRESH_TOKEN_INVALID.getCode(), ResponseCodeEnum.REFRESH_TOKEN_INVALID.getMessage());
}
// 生成新token
String newToken = JWTUtil.generateToken(userId, secretKey);
hashOperations.put(key, "token", newToken);
stringRedisTemplate.expire(userId, JWTUtil.TOKEN_EXPIRE_TIME, TimeUnit.MILLISECONDS);
return ResponseResult.success(newToken);
}
}
HelloController.java
package com.cjs.example.controller;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestHeader;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
/**
*
* @date 2020-03-08
*/
@RestController
@RequestMapping("/hello")
public class HelloController {
@GetMapping("/sayHello")
public String sayHello(String name) {
return "Hello, " + name;
}
@GetMapping("/sayHi")
public String sayHi(@RequestHeader("userId") String userId) {
return userId;
}
}
application.yml
server:
port: 8081
servlet:
context-path: /auth-server
spring:
application:
name: cjs-auth-service
redis:
host: 127.0.0.1
password: 123456
port: 6379
lettuce:
pool:
max-active: 10
max-idle: 5
min-idle: 5
max-wait: 5000
2、校验 Token
GatewayFilter 和 GlobalFilter 都可以,这里用 GlobalFilter
pom.xml
<?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 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.5.RELEASE</version>
<relativePath/> <!-- lookup parent from repository -->
</parent>
<groupId>com.cms.example</groupId>
<artifactId>cjs-gateway-example</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>cjs-gateway-example</name>
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR1</spring-cloud.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>com.auth0</groupId>
<artifactId>java-jwt</artifactId>
<version>3.10.0</version>
</dependency>
<dependency>
<groupId>com.cjs.example</groupId>
<artifactId>cjs-commons-jwt</artifactId>
<version>1.0-SNAPSHOT</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
<optional>true</optional>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>${spring-cloud.version}</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
AuthorizeFilter.java
package com.cms.example.filter;
import com.alibaba.fastjson.JSON;
import com.cjs.example.ResponseResult;
import com.cjs.example.enums.ResponseCodeEnum;
import com.cjs.example.exception.TokenAuthenticationException;
import com.cjs.example.utils.JWTUtil;
import lombok.extern.slf4j.Slf4j;
import org.apache.commons.lang3.StringUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.data.redis.core.StringRedisTemplate;
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.Flux;
import reactor.core.publisher.Mono;
/**
*
* @date 2020-03-08
*/
@Slf4j
@Component
public class AuthorizeFilter implements GlobalFilter, Ordered {
@Value("${secretKey:123456}")
private String secretKey;
// @Autowired
// private StringRedisTemplate stringRedisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
ServerHttpResponse serverHttpResponse = exchange.getResponse();
String uri = serverHttpRequest.getURI().getPath();
// 检查白名单(配置)
if (uri.indexOf("/auth-server/login") >= 0) {
return chain.filter(exchange);
}
String token = serverHttpRequest.getHeaders().getFirst("token");
if (StringUtils.isBlank(token)) {
serverHttpResponse.setStatusCode(HttpStatus.UNAUTHORIZED);
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_MISSION);
}
//todo 检查Redis中是否有此Token
try {
JWTUtil.verifyToken(token, secretKey);
} catch (TokenAuthenticationException ex) {
return getVoidMono(serverHttpResponse, ResponseCodeEnum.TOKEN_INVALID);
} catch (Exception ex) {
return getVoidMono(serverHttpResponse, ResponseCodeEnum.UNKNOWN_ERROR);
}
String userId = JWTUtil.getUserInfo(token);
ServerHttpRequest mutableReq = serverHttpRequest.mutate().header("userId", userId).build();
ServerWebExchange mutableExchange = exchange.mutate().request(mutableReq).build();
return chain.filter(mutableExchange);
}
private Mono<Void> getVoidMono(ServerHttpResponse serverHttpResponse, ResponseCodeEnum responseCodeEnum) {
serverHttpResponse.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
ResponseResult responseResult = ResponseResult.error(responseCodeEnum.getCode(), responseCodeEnum.getMessage());
DataBuffer dataBuffer = serverHttpResponse.bufferFactory().wrap(JSON.toJSONString(responseResult).getBytes());
return serverHttpResponse.writeWith(Flux.just(dataBuffer));
}
@Override
public int getOrder() {
return -100;
}
}
application.yml
spring:
cloud:
gateway:
routes:
- id: path_route
uri: http://localhost:8081/auth-server/
filters:
- MyLog=true
predicates:
- Path=/auth-server/**
这里我还自定义了一个日志收集过滤器
package com.cms.example.filter;
import org.apache.commons.logging.Log;
import org.apache.commons.logging.LogFactory;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;
import org.springframework.http.server.reactive.ServerHttpRequest;
import org.springframework.stereotype.Component;
import reactor.core.publisher.Mono;
import java.util.Arrays;
import java.util.List;
/**
*
* @date 2020-03-08
*/
@Component
public class MyLogGatewayFilterFactory extends AbstractGatewayFilterFactory<MyLogGatewayFilterFactory.Config> {
private static final Log log = LogFactory.getLog(MyLogGatewayFilterFactory.class);
private static final String MY_LOG_START_TIME = MyLogGatewayFilterFactory.class.getName() + "." + "startTime";
public MyLogGatewayFilterFactory() {
super(Config.class);
}
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("enabled");
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
if (!config.isEnabled()) {
return chain.filter(exchange);
}
exchange.getAttributes().put(MY_LOG_START_TIME, System.currentTimeMillis());
return chain.filter(exchange).then(Mono.fromRunnable(() -> {
Long startTime = exchange.getAttribute(MY_LOG_START_TIME);
if (null != startTime) {
ServerHttpRequest serverHttpRequest = exchange.getRequest();
StringBuilder sb = new StringBuilder();
sb.append(serverHttpRequest.getURI().getRawPath());
sb.append(" : ");
sb.append(serverHttpRequest.getQueryParams());
sb.append(" : ");
sb.append(System.currentTimeMillis() - startTime);
sb.append("ms");
log.info(sb.toString());
}
}));
};
}
public static class Config {
/**
* 是否开启
*/
private boolean enabled;
public Config() {
}
public boolean isEnabled() {
return enabled;
}
public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
}
用 Postman 访问就能看到效果
http://localhost:8080/auth-server/hello/sayHi
http://localhost:8080/auth-server/hello/sayHello?name=aaa
3、Spring Cloud Gateway
@SpringBootApplication
public class DemogatewayApplication {
@Bean
public RouteLocator customRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("path_route", r -> r.path("/get")
.uri("http://httpbin.org"))
.route("host_route", r -> r.host("*.myhost.org")
.uri("http://httpbin.org"))
.route("rewrite_route", r -> r.host("*.rewrite.org")
.filters(f -> f.rewritePath("/foo/(?<segment>.*)", "/${segment}"))
.uri("http://httpbin.org"))
.route("hystrix_route", r -> r.host("*.hystrix.org")
.filters(f -> f.hystrix(c -> c.setName("slowcmd")))
.uri("http://httpbin.org"))
.route("hystrix_fallback_route", r -> r.host("*.hystrixfallback.org")
.filters(f -> f.hystrix(c -> c.setName("slowcmd").setFallbackUri("forward:/hystrixfallback")))
.uri("http://httpbin.org"))
.route("limit_route", r -> r
.host("*.limited.org").and().path("/anything/**")
.filters(f -> f.requestRateLimiter(c -> c.setRateLimiter(redisRateLimiter())))
.uri("http://httpbin.org"))
.build();
}
}
3.1. GatewayFilter Factories
路由过滤器允许以某种方式修改输入的 HTTP 请求或输出的 HTTP 响应。路由过滤器适用于特定路由。Spring Cloud Gateway 包括许多内置的 GatewayFilter 工厂。
3.1.1. AddRequestHeader GatewayFilter Factory
AddRequestHeader GatewayFilter 采用 name 和 value 参数。
例如:下面的例子,对于所有匹配的请求,将在下游请求头中添加 X-Request-red:blue
spring:
cloud:
gateway:
routes:
- id: add_request_header_route
uri: https://example.org
filters:
- AddRequestHeader=X-Request-red, blue
刚才说了,AddRequestHeader 采用 name 和 value 作为参数。而 URI 中的变量可以用在 value 中,例如:
spring:
cloud:
gateway:
routes:
- id: add_request_header_route
uri: https://example.org
predicates:
- Path=/red/{segment}
filters:
- AddRequestHeader=X-Request-Red, Blue-{segment}
3.1.2. AddRequestParameter GatewayFilter Factory
AddRequestParameter GatewayFilter 也是采用 name 和 value 参数
例如:下面的例子,对于所有匹配的请求,将会在下游请求的查询字符串中添加 red=blue
spring:
cloud:
gateway:
routes:
- id: add_request_parameter_route
uri: https://example.org
filters:
- AddRequestParameter=red, blue
同样,AddRequestParameter 也支持在 value 中引用 URI 中的变量,例如:
spring:
cloud:
gateway:
routes:
- id: add_request_parameter_route
uri: https://example.org
predicates:
- Host: {segment}.myhost.org
filters:
- AddRequestParameter=foo, bar-{segment}
3.1.3. AddResponseHeader GatewayFilter Factory
AddResponseHeader GatewayFilter 依然采用 name 和 value 参数。不在赘述,如下:
spring:
cloud:
gateway:
routes:
- id: add_response_header_route
uri: https://example.org
filters:
- AddResponseHeader=X-Response-Red, Blue
3.1.4. DedupeResponseHeader GatewayFilter Factory
DedupeResponseHeader GatewayFilter 采用一个 name 参数和一个可选的 strategy 参数。name 可以包含以空格分隔的 header 名称列表。例如:下面的例子,如果在网关 CORS 逻辑和下游逻辑都将它们添加的情况下,这将删除 Access-Control-Allow-Credentials 和 Access-Control-Allow-Origin 响应头中的重复值。
spring:
cloud:
gateway:
routes:
- id: dedupe_response_header_route
uri: https://example.org
filters:
- DedupeResponseHeader=Access-Control-Allow-Credentials Access-Control-Allow-Origin
3.1.5. PrefixPath GatewayFilter Factory
PrefixPath GatewayFilter 只有一个 prefix 参数。下面的例子,对于所有匹配的请求,将会在请求 url 上加上前缀 / mypath,因此请求 / hello 在被转发后的 url 变成 / mypath/hello
spring:
cloud:
gateway:
routes:
- id: prefixpath_route
uri: https://example.org
filters:
- PrefixPath=/mypath
3.1.6. RequestRateLimiter GatewayFilter Factory
RequestRateLimiter GatewayFilter 用一个 RateLimiter 实现来决定当前请求是否被允许处理。如果不被允许,默认将返回一个 HTTP 429 状态,表示太多的请求。
这个过滤器采用一个可选的 keyResolver 参数。keyResolver 是实现了 KeyResolver 接口的一个 bean。在配置中,通过 SpEL 表达式引用它。例如,#{@myKeyResolver} 是一个 SpEL 表达式,它是对名字叫 myKeyResolver 的 bean 的引用。KeyResolver 默认的实现是 PrincipalNameKeyResolver。
默认情况下,如果 KeyResolver 没有找到一个 key,那么请求将会被拒绝。你可以调整这种行为,通过设置 spring.cloud.gateway.filter.request-rate-limiter.deny-empty-key (true or false) 和 spring.cloud.gateway.filter.request-rate-limiter.empty-key-status-code 属性。
Redis 基于 Token Bucket Algorithm (令牌桶算法)实现了一个 RequestRateLimiter
redis-rate-limiter.replenishRate 属性指定一个用户每秒允许多少个请求,而没有任何丢弃的请求。这是令牌桶被填充的速率。
redis-rate-limiter.burstCapacity 属性指定用户在一秒钟内执行的最大请求数。这是令牌桶可以容纳的令牌数。将此值设置为零将阻止所有请求。
redis-rate-limiter.requestedTokens 属性指定一个请求要花费多少个令牌。这是每个请求从存储桶中获取的令牌数,默认为 1。
通过将 replenishRate 和 burstCapacity 设置成相同的值可以实现稳定的速率。通过将 burstCapacity 设置为高于 replenishRate,可以允许临时突发。 在这种情况下,速率限制器需要在两次突发之间保留一段时间(根据 replenishRate),因为两个连续的突发将导致请求丢弃(HTTP 429 - 太多请求)。
通过将 replenishRate 设置为所需的请求数,将 requestTokens 设置为以秒为单位的时间跨度并将 burstCapacity 设置为 replenishRate 和 requestedToken 的乘积。可以达到 1 个请求的速率限制。 例如:设置 replenishRate = 1,requestedTokens = 60 和 burstCapacity = 60 将导致限制为每分钟 1 个请求。
spring:
cloud:
gateway:
routes:
- id: requestratelimiter_route
uri: https://example.org
filters:
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 10
redis-rate-limiter.burstCapacity: 20
redis-rate-limiter.requestedTokens: 1
KeyResolver
1 @Bean
2 KeyResolver userKeyResolver() {
3 return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("user"));
4 }
上面的例子,定义了每个用户每秒运行 10 个请求,令牌桶的容量是 20,那么,下一秒将只剩下 10 个令牌可用。KeyResolver 实现仅仅只是简单取请求参数中的 user,当然在生产环境中不推荐这么做。
说白了,KeyResolver 就是决定哪些请求属于同一个用户的。比如,header 中 userId 相同的就认为是同一个用户的请求。
当然,你也可以自己实现一个 RateLimiter,在配置的时候用 SpEL 表达式 #{@myRateLimiter} 去引用它。例如:
spring:
cloud:
gateway:
routes:
- id: requestratelimiter_route
uri: https://example.org
filters:
- name: RequestRateLimiter
args:
rate-limiter: "#{@myRateLimiter}"
key-resolver: "#{@userKeyResolver}"
补充:(Token Bucket)令牌桶
https://en.wikipedia.org/wiki/Token_bucket
令牌桶是在分组交换计算机网络和电信网络中使用的算法。它可以用来检查数据包形式的数据传输是否符合定义的带宽和突发性限制(对流量不均匀性或变化的度量)。
令牌桶算法就好比是一个的固定容量桶,通常以固定速率向其中添加令牌。一个令牌通常代表一个字节。当要检查数据包是否符合定义的限制时,将检查令牌桶以查看其当时是否包含足够的令牌。如果有足够数量的令牌,并假设令牌以字节为单位,那么,与数据包字节数量等效数量的令牌将被删除,并且该数据包可以通过继续传输。如果令牌桶中的令牌数量不够,则数据包不符合要求,并且令牌桶的令牌数量不会变化。不合格的数据包可以有多种处理方式:
- 它们可能会被丢弃
- 当桶中积累了足够的令牌时,可以将它们加入队列进行后续传输
- 它们可以被传输,但被标记为不符合,如果网络负载过高,可能随后被丢弃
(PS:这句话的意思是说,想象有一个桶,以固定速率向桶中添加令牌。假设一个令牌等效于一个字节,当一个数据包到达时,假设这个数据包的大小是 n 字节,如果桶中有足够多的令牌,即桶中令牌的数量大于 n,则该数据可以通过,并且桶中要删除 n 个令牌。如果桶中令牌数不够,则根据情况该数据包可能直接被丢弃,也可能一直等待直到令牌足够,也可能继续传输,但被标记为不合格。还是不够通俗,这样,如果把令牌桶想象成一个水桶的话,令牌想象成水滴的话,那么这个过程就变成了以恒定速率向水桶中滴水,当有人想打一碗水时,如果这个人的碗很小,只能装 30 滴水,而水桶中水滴数量超过 30,那么这个人就可以打一碗水,然后就走了,相应的,水桶中的水在这个人打完以后自然就少了 30 滴。过了一会儿,又有一个人来打水,他拿的碗比较大,一次能装 100 滴水,这时候桶里的水不够,这个时候他可能就走了,或者在这儿等着,等到桶中积累了 100 滴的时候再打。哈哈哈,就是酱紫,不知道大家见过水车没有……)
令牌桶算法可以简单地这样理解:
- 每 1/r 秒有一个令牌被添加到令牌桶
- 令牌桶最多可以容纳 b 个令牌。当一个令牌到达时,令牌桶已经满了,那么它将会被丢弃。
- 当一个 n 字节大小的数据包到达时:
- 如果令牌桶中至少有 n 个令牌,则从令牌桶中删除 n 个令牌,并将数据包发送到网络。
- 如果可用的令牌少于 n 个,则不会从令牌桶中删除任何令牌,并且将数据包视为不合格。
3.1.7. RedirectTo GatewayFilter Factory
RedirectTo GatewayFilter 有两个参数:status 和 url。status 应该是 300 系列的。不解释,看示例:
spring:
cloud:
gateway:
routes:
- id: prefixpath_route
uri: https://example.org
filters:
- RedirectTo=302, https://acme.org
3.1.8. RemoveRequestHeader GatewayFilter Factory
spring:
cloud:
gateway:
routes:
- id: removerequestheader_route
uri: https://example.org
filters:
- RemoveRequestHeader=X-Request-Foo
3.1.9. RewritePath GatewayFilter Factory
spring:
cloud:
gateway:
routes:
- id: rewritepath_route
uri: https://example.org
predicates:
- Path=/foo/**
filters:
- RewritePath=/red(?<segment>/?.*), $\{segment}
3.1.10. Default Filters
为了添加一个过滤器,并将其应用到所有路由上,你可以使用 spring.cloud.gateway.default-filters,这个属性值是一个过滤器列表
1 spring:
2 cloud:
3 gateway:
4 default-filters:
5 - AddResponseHeader=X-Response-Default-Red, Default-Blue
6 - PrefixPath=/httpbin
3.2. Global Filters
GlobalFilter 应用于所有路由
3.2.1. GlobalFilter 与 GatewayFilter 组合的顺序
当一个请求请求与匹配某个路由时,过滤 Web 处理程序会将 GlobalFilter 的所有实例和 GatewayFilter 的所有特定于路由的实例添加到过滤器链中。该组合的过滤器链由 org.springframework.core.Ordered 接口排序,可以通过实现 getOrder() 方法进行设置。
由于 Spring Cloud Gateway 区分过滤器逻辑执行的 “pre” 和“post”阶段,因此,优先级最高的过滤器在 “pre” 阶段是第一个,在 “post” 阶段是最后一个。
@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;
}
}
补充:(Token Bucket)令牌桶
https://en.wikipedia.org/wiki/Token_bucket
令牌桶是在分组交换计算机网络和电信网络中使用的算法。它可以用来检查数据包形式的数据传输是否符合定义的带宽和突发性限制(对流量不均匀性或变化的度量)。
令牌桶算法就好比是一个的固定容量桶,通常以固定速率向其中添加令牌。一个令牌通常代表一个字节。当要检查数据包是否符合定义的限制时,将检查令牌桶以查看其当时是否包含足够的令牌。如果有足够数量的令牌,并假设令牌以字节为单位,那么,与数据包字节数量等效数量的令牌将被删除,并且该数据包可以通过继续传输。如果令牌桶中的令牌数量不够,则数据包不符合要求,并且令牌桶的令牌数量不会变化。不合格的数据包可以有多种处理方式:
- 它们可能会被丢弃
- 当桶中积累了足够的令牌时,可以将它们加入队列进行后续传输
- 它们可以被传输,但被标记为不符合,如果网络负载过高,可能随后被丢弃
(PS:这句话的意思是说,想象有一个桶,以固定速率向桶中添加令牌。假设一个令牌等效于一个字节,当一个数据包到达时,假设这个数据包的大小是 n 字节,如果桶中有足够多的令牌,即桶中令牌的数量大于 n,则该数据可以通过,并且桶中要删除 n 个令牌。如果桶中令牌数不够,则根据情况该数据包可能直接被丢弃,也可能一直等待直到令牌足够,也可能继续传输,但被标记为不合格。还是不够通俗,这样,如果把令牌桶想象成一个水桶的话,令牌想象成水滴的话,那么这个过程就变成了以恒定速率向水桶中滴水,当有人想打一碗水时,如果这个人的碗很小,只能装 30 滴水,而水桶中水滴数量超过 30,那么这个人就可以打一碗水,然后就走了,相应的,水桶中的水在这个人打完以后自然就少了 30 滴。过了一会儿,又有一个人来打水,他拿的碗比较大,一次能装 100 滴水,这时候桶里的水不够,这个时候他可能就走了,或者在这儿等着,等到桶中积累了 100 滴的时候再打。哈哈哈,就是酱紫,不知道大家见过水车没有……)
令牌桶算法可以简单地这样理解:
- 每 1/r 秒有一个令牌被添加到令牌桶
- 令牌桶最多可以容纳 b 个令牌。当一个令牌到达时,令牌桶已经满了,那么它将会被丢弃。
- 当一个 n 字节大小的数据包到达时:
- 如果令牌桶中至少有 n 个令牌,则从令牌桶中删除 n 个令牌,并将数据包发送到网络。
- 如果可用的令牌少于 n 个,则不会从令牌桶中删除任何令牌,并且将数据包视为不合格。
\4. Docs