路由中心-Gateway

0
字数 4.4k
阅读时间 8 分钟

Spring Cloud Gateway

Gateway 的 Github 页面 Readme 对 Gateway 描述如下:

This project provides an API Gateway built on top of the Spring Ecosystem, including: Spring 5, Spring Boot 2 and Project Reactor. Spring Cloud Gateway aims to provide a simple, yet effective way to route to APIs and provide cross cutting concerns to them such as: security, monitoring/metrics, and resiliency.

该项目提供了一个建立在 Spring Ecosystem 之上的 API 网关,包括:Spring 5,Spring Boot 2 和 Project Reactor。Spring Cloud Gateway 旨在提供一种简单而有效的方式来路由到 API,并为他们提供横切关注点,例如:安全性,监控/指标和弹性。

简单来讲(从名字看)官方对 Gateway 的定位是api 网关

创建 Gateway 项目

创建 gateway 项目依赖继承 route-server 项目,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>
<parent>
<groupId>com.github.liuzhuoming23</groupId>
<artifactId>route-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<packaging>jar</packaging>

<artifactId>gateway</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gateway</name>
<description>Demo project for Spring Cloud</description>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

切记不要添加 spring-boot-starter-web 依赖,不然会报错No qualifying bean of type 'org.springframework.core.convert.ConversionService' available: expected at least 1 bean which qualifies as autowire candidate. Dependency annotations: {@org.springframework.beans.factory.annotation.Qualifier(value=webFluxConversionService)},找了大半天才找到这个问题。然而启动项目就有请勿添加 spring-boot-starter-web 依赖的提示,是我没看见。
application.yml 配置为:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
cloud:
gateway:
discovery:
locator:
#是否开启默认路由方式(按照注册到Eureka的实例名自动创建路由)
enabled: true

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

这样就创建了用 Eureka 注册的服务实例名称为路由名称的路由,即图中圈起来的名称:
02.jpg
启动项目,访问http://localhost:8030/FEIGN/port/🐕,返回结果:
🐕: client port | 8011 | feign
说明默认路由创建成功。

自定义路由

默认路由名称是按照在 Eureka 里面注册的实例名称来分配的,Eureka 实例名称全大写,所以路由名称也是全大写,不太符合平时全小写的习惯,要解决这个问题可以使用自定义路由。

基于 yml 的配置方式

屏蔽默认路由,添加自定义路由,即修改 gateway 项目的 application.yml 为:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
cloud:
gateway:
# discovery:
# locator:
# enabled: true
routes:
#路由名称,自定义
- id: feign
#需要转发到的服务名称
uri: lb://FEIGN
predicates:
#向gateway请求的路径
- Path=/feign/**
#过滤器工厂配置
filters:
#gateway向注册的服务请求的路径需要消除的前缀数量(此处是删除`/feign`)(示例:当请求http://gateway.com/feign/test.html时会转发到FEIGN/test.html)
- StripPrefix=1

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

配置里面的 filters.StripPrefix 参数对应的是 StripPrefixGatewayFilterFactory,即 gateway 自定义过滤器工厂,关于过滤器工厂后面再说,总之它的作用是,根据配置的数字删除 gateway 转发到注册服务时候 url 的前缀数量,比如向 gateway 发起请求为http://localhost:8030/feign/port/🐕,这个值配置为 1 的话,gateway 向注册的服务发起的请求就成了lb://FEIGN/port/🐕,这个值配置为 2 的话,gateway 向注册的服务发起的请求就成了lb://FEIGN/🐕
重启服务,访问http://localhost:8030/feign/port/🐕,返回结果:
🐕: client port | 8011 | feign
说明自定义路由创建成功。

基于 Spring 配置类的配置方式

先把上一步修改的 application.yml 内有关路由的配置注释掉,因为这两种方式本质上其实是同一种配置方式,参数相同的话实现的功能完全等价,相同 id 的路由应该会导致不可控的错误:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
# cloud:
# gateway:
# discovery:
# locator:
# enabled: true
# routes:
# - id: feign
# uri: lb://FEIGN
# predicates:
# - Path=/feign/**
# filters:
# - StripPrefix=1

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

创建 route.RouteLocators 类,内容为:

package com.github.liuzhuoming23.gateway.route;

import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 路由配置
*
* @author liuzhuoming
*/
@Configuration
public class RouteLocators {

@Bean
public RouteLocator serviceFeignRouteLocator(RouteLocatorBuilder builder) {
return builder.routes().route(r ->
r.path("/feign/**")
.filters(
f -> f.stripPrefix(1)
)
.uri("lb://FEIGN")
.id("feign")
).build();
}
}

重启项目,访问http://localhost:8030/feign/port/🐕,返回结果:
🐕: client port | 8010 | feign
说明自定义路由创建成功。
虽然基于配置类的路由方式在入参方式方面更自由一点(比如动态传入 responseHeader 参数等),但是我更喜欢基于 yml 的配置方式,因为 yml 和配置中心组合使用可以实现自由切换路由配置的功能。

自定义过滤器

自定义局部过滤器

假定一个需要在 console 中打印请求 uri 和 query 参数的需求(这种需求在实际开发中几乎不会存在),就可以在过滤器中完成。
首先创建 filter.PrintUriAndQueryGatewayFilter 类(注意规范命名,不然后面可能会出现不可控错误):

package com.github.liuzhuoming23.gateway.filter;

import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.core.Ordered;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* 打印请求uri和query参数局部过滤器
*
* @author liuzhuoming
*/
public class PrintUriAndQueryGatewayFilter implements GatewayFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
return chain
.filter(exchange)
.then(Mono.fromRunnable(() -> {
System.out.println(exchange.getRequest().getURI().getRawPath() + "?"
+ exchange.getRequest().getURI().getRawQuery());
})
);
}

@Override
public int getOrder() {
return Ordered.LOWEST_PRECEDENCE;
}
}

_其中实现接口 GatewayFilter 表明这个类是一个 gateway 局部过滤器,Ordered 接口为定义过滤器的优先度,Ordered.LOWEST_PRECEDENCE 为最低优先度。_
因为过滤器无法使用 yml 配置,所以此处选择修改配置类 RouteLocators:

package com.github.liuzhuoming23.gateway.route;

import com.github.liuzhuoming23.gateway.filter.PrintUriAndQueryGatewayFilter;
import org.springframework.cloud.gateway.route.RouteLocator;
import org.springframework.cloud.gateway.route.builder.RouteLocatorBuilder;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

/**
* 路由配置
*
* @author liuzhuoming
*/
@Configuration
public class RouteLocators {

@Bean
public RouteLocator serviceFeignRouteLocator(RouteLocatorBuilder builder) {
return builder.routes().route(r ->
r.path("/feign/**")
.filters(
f -> f.stripPrefix(1)
.filter(new PrintUriAndQueryGatewayFilter())
)
.uri("lb://FEIGN")
.id("feign")
).build();
}
}

重启项目,并请求http://localhost:8030/feign/port/liuzhuoming?age=14&id=1,IntelliJ IDEA 的 console 打印出日志:
/port/liuzhuoming?age=14&id=1
说明自定义过滤器配置成功。

自定义局部过滤器工厂

上面的 PrintUriAndQueryGatewayFilter 过滤器虽然可以正常使用,但是只能在路由配置类使用,并不能在 yml 配置,感觉和我习惯有悖,解决办法就是创建自定义过滤器工厂。
这次使用 yml 配置路由。首先创建 filter.factory.PrintUriAndQueryGatewayFilterFactory 类(注意规范命名,不然后面可能会出现不可控错误):

package com.github.liuzhuoming23.gateway.filter.factory;

import com.github.liuzhuoming23.gateway.filter.PrintUriAndQueryGatewayFilter;
import java.util.Collections;
import java.util.List;
import org.springframework.cloud.gateway.filter.GatewayFilter;
import org.springframework.cloud.gateway.filter.factory.AbstractGatewayFilterFactory;

/**
* 打印请求uri和query参数局部过滤器工厂
*
* @author liuzhuoming
*/
public class PrintUriAndQueryGatewayFilterFactory extends
AbstractGatewayFilterFactory<PrintUriAndQueryGatewayFilterFactory.Config> {

public PrintUriAndQueryGatewayFilterFactory() {
super(Config.class);
}

@Override
public List<String> shortcutFieldOrder() {
return Collections.singletonList("enabled");
}

@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
if (!config.isEnabled()) {
return chain.filter(exchange);
} else {
return new PrintUriAndQueryGatewayFilter().filter(exchange, chain);
}
};
}

public static class Config {

/**
* 是否开启打印
*/
private boolean enabled;

public boolean isEnabled() {
return enabled;
}

public void setEnabled(boolean enabled) {
this.enabled = enabled;
}
}
}

其中 Config 为自定义参数类,其中包含了一个叫 enabled 的 boolean 类型参数。shortcutFieldOrder 方法为定义参数列表。(参照内置过滤器工厂)
在 config.SpringCloudConfig 类添加 PrintUriAndQueryGatewayFilterFactory 的 bean:

package com.github.liuzhuoming23.gateway.config;

import com.github.liuzhuoming23.gateway.filter.factory.PrintUriAndQueryGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

/**
* spring cloud config
*
* @author liuzhuoming
*/
@Configuration
public class SpringCloudConfig {

@Bean
KeyResolver hostnameKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}

@Bean
PrintUriAndQueryGatewayFilterFactory printUriAndQueryGatewayFilterFactory() {
return new PrintUriAndQueryGatewayFilterFactory();
}
}

在 application.yml 添加自定义过滤器工厂配置:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
cloud:
gateway:
# discovery:
# locator:
# enabled: true
routes:
- id: feign
uri: lb://FEIGN
predicates:
- Path=/feign/**
filters:
- StripPrefix=1
#自定义过滤器工厂,其中的true即Config类的enabled参数的值
- PrintUriAndQuery=true

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

重启项目,并请求http://localhost:8030/feign/port/liuzhuoming?age=14&id=1,IntelliJ IDEA 的 console 打印出日志:
/port/liuzhuoming?age=14&id=1
说明自定义过滤器工厂配置成功。

自定义全局过滤器

全局过滤器和局部过滤器类似,唯一区别是全局过滤器无需在路由配置即可全局生效。
假定一个根据 requestHeader 是否存在 Authorization 字段来决定返回数居还是返回 401 错误的需求(这种需求在实际开发中几乎不会存在),就可以在全局过滤器中完成。
创建 filter.AuthorizationGlobalFilter 类(注意规范命名,不然后面可能会出现不可控错误):

package com.github.liuzhuoming23.gateway.filter;

import java.util.Objects;
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.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;

/**
* 请求权限全局过滤器
*
* @author liuzhuoming
*/
public class AuthorizationGlobalFilter implements GlobalFilter, Ordered {

@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
try {
String token = Objects
.requireNonNull(exchange.getRequest().getHeaders().get("Authorization")).get(0);
if (token == null) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
} catch (Exception e) {
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}

@Override
public int getOrder() {
return Ordered.HIGHEST_PRECEDENCE;
}
}

注意这里实现的是全局过滤器的 GlobalFilter 接口,不是局部过滤器的 GatewayFilter 接口。
在 config.SpringCloudConfig 注入全局过滤器的 bean:

package com.github.liuzhuoming23.gateway.config;

import com.github.liuzhuoming23.gateway.filter.AuthorizationGlobalFilter;
import com.github.liuzhuoming23.gateway.filter.factory.PrintUriAndQueryGatewayFilterFactory;
import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

/**
* spring cloud config
*
* @author liuzhuoming
*/
@Configuration
public class SpringCloudConfig {

@Bean
KeyResolver hostnameKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}

@Bean
PrintUriAndQueryGatewayFilterFactory printUriAndQueryGatewayFilterFactory() {
return new PrintUriAndQueryGatewayFilterFactory();
}

@Bean
AuthorizationGlobalFilter authorizationGlobalFilter() {
return new AuthorizationGlobalFilter();
}
}

重启项目,并在 Postman 直接请求接口http://localhost:8030/feign/port/🐕,返回结果:
401 Unauthorized
在 Postman 的 Headers 添加字段 Authorization=1234,再次请求http://localhost:8030/feign/port/🐕,返回结果:
🐕: client port | 8011 | feign
说明全局过滤器配置成功。

内置过滤器工厂

截取请求 uri 前缀的过滤器工厂-StripPrefixGatewayFilterFactory

之前已经简单说过这个过滤器的作用,因为作用很简单,不再作详细说明。

基于令牌桶算法做限流的过滤器工厂-RequestRateLimiterGatewayFilterFactory

这里可以自定义限流 key,示例为基于 hostname 拦截,并且过滤器配置在 yml 中,如果是习惯配置在配置类里面的,也很简单,可以自行研究。
创建 config.SpringCloudConfig 配置类,在其中初始化 KeyResolver 的 bean:

package com.github.liuzhuoming23.gateway.config;

import org.springframework.cloud.gateway.filter.ratelimit.KeyResolver;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import reactor.core.publisher.Mono;

/**
* spring cloud config
*
* @author liuzhuoming
*/
@Configuration
public class SpringCloudConfig {

@Bean
KeyResolver hostnameKeyResolver() {
return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName());
}
}

其中 Mono 为 reactor 响应式编程库自带的类型,有兴趣可以自行了解,在此不作赘述。
默认限流基于 redis,所以首先开启本地 redis,然后修改 pom.xml 添加 spring-boot-starter-data-redis 依赖:

<?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>
<parent>
<groupId>com.github.liuzhuoming23</groupId>
<artifactId>route-center</artifactId>
<version>0.0.1-SNAPSHOT</version>
</parent>
<packaging>jar</packaging>

<artifactId>gateway</artifactId>
<version>0.0.1-SNAPSHOT</version>
<name>gateway</name>
<description>Demo project for Spring Cloud</description>

<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>

<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>

</project>

在路由的过滤器配置里添加 RequestRateLimiter 过滤器工厂配置,修改 application.yml 为:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
redis:
host: localhost
port: 6379
password:
timeout: 500
cloud:
gateway:
# discovery:
# locator:
# enabled: true
routes:
- id: feign
uri: lb://FEIGN
predicates:
- Path=/feign/**
filters:
- StripPrefix=1
#过滤器工厂名称(推断是根据工厂类名来匹配,RequestRateLimiter->RequestRateLimiterGatewayFilterFactory,即删除后面的GatewayFilterFactory)
- name: RequestRateLimiter
#过滤器工厂参数集合
args:
#每秒新增令牌数量
redis-rate-limiter.replenishRate: 1
#令牌桶最大容量
redis-rate-limiter.burstCapacity: 3
#自定义key解析器的bean名称,即在config.SpringCloudConfig配置的bean名称
key-resolver: "#{@hostnameKeyResolver}"

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

重启项目,连续并快速地访问http://localhost:8030/feign/port/🐕,在开始的几次正常返回数据后很快就会返回我们期待的结果:
429 Too Many Requests
等待几秒再次访问,数据又会变回正常,说明限流成功。并且在此时查询 redis,会发现多了两个类似 request_rate_limiter.{0:0:0:0:0:0:0:1}.timestamp 和 request_rate_limiter.{0:0:0:0:0:0:0:1}.tokens 的 key,如图:
03.jpg

其他配置

CORS 跨域处理

新建跨域测试 html 文件,cors.html:

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Title</title>
</head>
<body>
<script src="https://cdn.staticfile.org/jquery/1.10.2/jquery.min.js"></script>
<script>
$.get("http://localhost:8030/feign/port/%F0%9F%90%95", {}, function (r) {
console.log(r);
});
</script>
</body>
</html>

即直接请求接口并在浏览器 console 打印返回信息(需要提前先在 SpringCloudConfig 类把 token 验证的全局过滤器 AuthorizationGlobalFilter 的 bean 注释掉)。
之后打开浏览器控制台并打开 cors.html,会发现 console 提示:
04.jpg
即跨域请求已被阻止。通常解决方式是创建跨域处理的过滤器,但是 Gateway 可以在 yml 中以更简单的方式实现。修改 gateway 项目的 application.yml,添加 globalcors 配置:

server:
port: 8030

spring:
application:
name: @pom.artifactId@
redis:
host: localhost
port: 6379
password:
timeout: 500
cloud:
gateway:
# discovery:
# locator:
# enabled: true
#全局CORS配置
globalcors:
#CORS配置类
corsConfigurations:
#需要跨域处理的路径
'[/**]':
#允许的请求来源
allowedOrigins: "*"
#允许的请求类型(GET,POST等)
allowedMethods: "*"
#允许的请求头
allowedHeaders: "*"
#是否允许携带凭证
allowCredentials: true
routes:
- id: feign
uri: lb://FEIGN
predicates:
- Path=/feign/**
filters:
- StripPrefix=1
- PrintUriAndQuery=true
- name: RequestRateLimiter
args:
redis-rate-limiter.replenishRate: 1
redis-rate-limiter.burstCapacity: 3
key-resolver: "#{@hostnameKeyResolver}"

eureka:
client:
service-url:
defaultZone: http://admin:admin@localhost:8000/eureka/

management:
endpoints:
web:
exposure:
include: httptrace,info,health

这里的配置和 org.springframework.web.cors.CorsConfiguration 类里面的属性一一对应。
重启 gateway 项目并刷新 cors.html 页面,console 显示结果:
05.jpg
说明跨域处理成功。

参考来源

  1. Spring Cloud GateWay 官方文档

系列文章 #Spring Cloud

(1)前言

(2)注册中心-Eureka

(3)服务间调用-Feign(🔒)

(4)路由中心-Gateway

(5)配置中心-Config

(6)配置中心-Bus

(7)监控中心-Admin

(8)监控中心-Sleuth+Zipkin(🔒)

(9)监控中心-Elasticsearch+Zipkin(🔒)

(10)监控中心-HystrixDashboard+Turbine(🔒)

(11)授权中心-Oauth2+JWT

(12)注册中心/配置中心-Nacos

(13)动态路由-Gateway

(14)授权中心-Oauth2+JWT补全


配置中心-Config 服务间调用-Feign