1. 概述
Spring Cloud Gateway 是由 WebFlux + Netty + Reactor 实现的响应式的 API 网关。
Spring Cloud Gateway 旨在为微服务架构提供一种简单且有效的 API 路由的管理方式,并基于 Filter 的方式提供网关的基本功能,例如说安全认证、监控、限流等等。
目前Spring Cloud集成的Spring Cloud Zuul还是Zuul1.x,这一版的Zuul是基于Servlet构建的,采用的方案是阻塞式的多线程方案,即一个线程处理一次连接请求,这种方式在内部延迟严重、设备故障较多情况下会引起存活的连接增多和线程增加的情况发生。Spring Cloud自己开源的Spring Cloud Gateway则是基于Spring Webflux来构建的,Spring Webflux有一个全新的非堵塞的函数式 Reactive Web 框架,可以用来构建异步的、非堵塞的、事件驱动的服务,在伸缩性方面表现非常好。使用非阻塞API, Websockets得到支持,并且由于它与Spring紧密集成,将会得到更好的开发体验。
Spring Cloud Gateway 定位于取代 Netflix Zuul,成为 Spring Cloud 生态系统的新一代网关。目前看下来非常成功,老的项目的网关逐步从 Zuul 迁移到 Spring Cloud Gateway,新项目的网关直接采用 Spring Cloud Gateway。相比 Zuul 来说,Spring Cloud Gateway 提供更优秀的性能,更强大的有功能。
服务网关 = 路由转发 + 过滤器
- 路由转发:接收一切外界请求,转发到后端的微服务上去;
- 过滤器:在服务网关中可以完成一系列的横切功能,例如权限校验、限流以及监控等,这些都可以通过过滤器完成(其实路由转发也是通过过滤器实现的)。
Spring Cloud Gateway 的特征如下:
- 基于 Java 8 编码
- 基于 Spring Framework 5 + Project Reactor + Spring Boot 2.0 构建
- 支持动态路由,能够匹配任何请求属性上的路由
- 支持内置到 Spring Handler 映射中的路由匹配
- 支持基于 HTTP 请求的路由匹配(Path、Method、Header、Host 等等)
- 集成了 Hystrix 断路器
- 过滤器作用于匹配的路由
- 过滤器可以修改 HTTP 请求和 HTTP 响应(增加/修改 Header、增加/修改请求参数、改写请求 Path 等等)
- 支持 Spring Cloud DiscoveryClient 配置路由,与服务发现与注册配合使用
支持限流 - 支持WebSocket 的网关。
2. 为什么使用网关?
API网关接管所有的入口流量,类似Nginx的作用,将所有用户的请求转发给后端的服务器,但网关做的不仅仅只是简单的转发,也会针对流量做一些扩展,比如鉴权、限流、权限、熔断、协议转换、错误码统一、缓存、日志、监控、告警等,这样将通用的逻辑抽出来,由网关统一去做,业务方也能够更专注于业务逻辑,提升迭代的效率。 通过引入API网关,客户端只需要与API网关交互,而不用与各个业务方的接口分别通讯,但多引入一个组件就多引入了一个潜在的故障点,因此要实现一个高性能、稳定的网关,也会涉及到很多点。
3. 快速入门——基于配置中心 Nacos 实现动态路由
主要介绍以Nacos为配置中心,实现Spring Cloud GateWay 实现动态路由的功能。这样做是因为:Spring Cloud Gateway启动时候,就将路由配置和规则加载到内存里,无法做到不重启网关就可以动态的对应路由的配置和规则进行增加,修改和删除。通过nacos的配置下发的功能可以实现在不重启网关的情况下,实现动态路由。
所以我们可以引入配置中心 Nacos 来实现动态路由的功能,将spring.cloud.gateway
配置项统一存储在 Nacos 中。在服务网关Spring Cloud Gateway中开启监听,监听Nacos配置文件的修改。
Nacos配置文件一旦发生改变,则Spring Cloud Gateway重新刷新自己的路由信息。
4. 用户服务
创建 [sc-user-service
]项目,作为 user-service
用户服务。代码比较简单。最终项目如下图所示:
pom.xml
4.0.0
sc-user-service
2.2.4.RELEASE
Hoxton.SR1
2.2.0.RELEASE
org.springframework.boot
spring-boot-starter-parent
${spring.boot.version}
pom
import
org.springframework.cloud
spring-cloud-dependencies
${spring.cloud.version}
pom
import
com.alibaba.cloud
spring-cloud-alibaba-dependencies
${spring.cloud.alibaba.version}
pom
import
org.springframework.boot
spring-boot-starter-web
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
application.yaml
spring:
application:
name: user-service # Spring 应用名
cloud:
nacos:
# Nacos 作为注册中心的配置项
discovery:
server-addr: 127.0.0.1:8848 # Nacos 服务器地址 "'lb://'+serviceId"
server:
port: ${random.int[10000,19999]} # 服务器端口。默认为 8080
# port: 18080 # 服务器端口。默认为 8080
注意:端口用的是随机配置,不是通常的固定端口号方式。本项目是服务提供者,需要启动多个进行负载均衡,所以启动该项目后,勾选Allow parallel run
,即可同时启动多个服务。
5. 网关服务
pom文件
4.0.0
com.guo.springcloud
sc-gateway-demo03-config-nacos
1.0.0.RELEASE
1.8
1.8
2.2.4.RELEASE
Hoxton.SR1
2.2.0.RELEASE
org.springframework.boot
spring-boot-starter-parent
${spring.boot.version}
pom
import
org.springframework.cloud
spring-cloud-dependencies
${spring.cloud.version}
pom
import
com.alibaba.cloud
spring-cloud-alibaba-dependencies
${spring.cloud.alibaba.version}
pom
import
org.springframework.cloud
spring-cloud-starter-gateway
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-discovery
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
其中引入配置中心 Nacos 相关的依赖如下:
com.alibaba.cloud
spring-cloud-starter-alibaba-nacos-config
配置文件
① 创建 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
spring.cloud.nacos.config
配置项,为配置中心 Nacos 相关配置项。这里就不详细解释,毕竟 Nacos 不是主角。感兴趣的,可以阅读Spring Cloud Alibaba(三、Nacos配置中心)文章。
② 修改 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 配置如下:
默认将请求转发到163网站。
6. 测试
① 执行 UserServiceApplication启动 两次,启动两个 user-service
服务。
② 执行 GatewayApplication 启动网关。
使用浏览器,访问 http://127.0.0.1:8888/ 地址,返回 163首页。
③ 修改在 Nacos 的 spring.cloud.gateway
配置项,转发请求到用户服务。
配置对应文本内容如下:
spring:
cloud:
# Spring Cloud Gateway 配置项,对应 GatewayProperties 类
gateway:
# 路由配置项,对应 RouteDefinition 数组
routes:
- id: ReactiveCompositeDiscoveryClient_user-service # 路由的编号
uri: lb://user-service # 路由到的目标地址
predicates: # 断言,作为路由的匹配条件,对应 RouteDefinition 数组
- Path=/user/**
filters:
- StripPrefix=1
配置说明:
① spring.cloud.nacos.discovery
配置项,使用 Nacos 作为 Spring Cloud 注册中心的配置项。这里就不详细解释,毕竟 Nacos 不是主角。
② spring.cloud.gateway.discovery
配置项,Gateway 与 Spring Cloud 注册中心的集成,对应 DiscoveryLocatorProperties类。
-
enable
:是否开启,默认为false
关闭。这里我们设置为true
,开启与 Spring Cloud 注册中心的集成的功能。 -
url-expression
:路由的目标地址的 Spring EL 表达式,默认为"'lb://' + serviceId"
。这里,我们设置的就是默认值。
可能 url-expression
配置项有点费解,我们来重点解释下。
-
lb://
前缀,表示将请求负载均衡转发到对应的服务的实例。 -
"'lb://' + serviceId"
Spring EL 表达式,将从注册中心获得到的服务列表,每一个服务的名字对应serviceId
,最终使用 Spring EL 表达式进行格式化。
-
ANT通配符有三种:
我们来举个例子,假设我们从注册中心找到了 user-service
和 order-service
两个服务,最终效果和如下配置等价:
spring:
cloud:
gateway:
routes:
- id: ReactiveCompositeDiscoveryClient_user-service
uri: lb://user-service
predicates:
- Path=/user-service/**
filters:
- RewritePath=/user-service/(?.*), /${remaining} # 将 /user-service 前缀剔除
- id: ReactiveCompositeDiscoveryClient_order-service
uri: lb://order-service
predicates:
- Path=/order-service/**
filters:
- RewritePath=/order-service/(?.*), /${remaining} # 将 /order-service 前缀剔除
此时 IDEA 控制台看到 GatewayPropertiesRefresher 监听到 spring.cloud.gateway 配置项刷新,并打印日志如下图:
④ 访问 http://127.0.0.1:8888/user/user/get?id=8 地址,返回 JSON 结果如下:
{"id":8,"name":"8:没有昵称","gender":1}
至此,请求经过网关后,转发到 user-service 服务成功。
7.自定义过滤器
可以实现全局日志记录,统一网关鉴权等功能。
7.1 新增lombok依赖:
org.projectlombok
lombok
7.2 新建自定义过滤器类
7.2.1 例子1,参数方式判断
package com.erbadagang.springcloud.gateway.filter;
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.HttpStatus;
import org.springframework.stereotype.Component;
import org.springframework.web.server.ServerWebExchange;
import reactor.core.publisher.Mono;
/**
* @description 自定义过滤器
* @ClassName: MyFilter
* @author: 郭秀志 [email protected]
* @date: 2020/7/11 16:33
* @Copyright:
*/
@Component
@Slf4j
public class MyFilter implements GlobalFilter, Ordered {
/**
* 表示这个过滤器的优先级,数字越小优先级越高
*/
@Override
public int getOrder() {
return 0;
}
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
log.info("=================进入自定义全局过滤器=============");
// 获取id参数
String id = exchange.getRequest().getQueryParams().getFirst("id");
if (!"8".equals(id)) {
log.error("================= id不是8==================");
// 设置返回码
exchange.getResponse().setStatusCode(HttpStatus.NOT_ACCEPTABLE);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}
也就是说,如果没有id这个参数或者id参数值不为8
,都会被拦截。实际生产中可以用这个来验证请求是否携带token
。
7.2.2 例子2:修改gateway请求路径
private final static String SEGMENT = "-";
/**
* Process the Web request and (optionally) delegate to the next {@code WebFilter} through the
* given {@link GatewayFilterChain}.
*
* @param exchange the current server exchange
* @param chain provides a way to delegate to the next filter
* @return {@code Mono} to indicate when request processing is complete
*/
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
ServerHttpResponse response = exchange.getResponse();
// 获取请求路径前缀(api-dev/api-prod)
PathPattern.PathMatchInfo pathMatchInfo = exchange
.getAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE);
if (pathMatchInfo != null) {
Map uriVariables = pathMatchInfo.getUriVariables();
String api = uriVariables.get("api");
// 判断请求路径是否符合当前环境
if (!api.split(SEGMENT)[1].equals(active)) {
byte[] responseBytes = JSONUtil
.toJsonStr(Response
.failed(HttpStatus.FORBIDDEN.value(), HttpStatus.FORBIDDEN.getReasonPhrase()))
.getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(responseBytes);
response.setStatusCode(HttpStatus.FORBIDDEN);
response.getHeaders().add("Content-Type", "application/json;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
}
// 将StripPrefixGatewayFilter加入全局过滤器中
addOriginalRequestUrl(exchange, request.getURI());
String rawPath = request.getURI().getRawPath();
String newPath = "/" + Arrays.stream(StringUtils.tokenizeToStringArray(rawPath, "/"))
.skip(2L).collect(Collectors.joining("/"));
ServerHttpRequest newRequest = request.mutate()
.path(newPath)
.build();
exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, newRequest.getURI());
return chain.filter(exchange.mutate()
.request(newRequest).build());
}
7.2.3 例子3:Path Route Predicate Factory
Path Route Predicate Factory使用的是path列表作为参数,使用Spring的PathMatcher匹配path,可以设置可选变量。 application.yml:
spring:
cloud:
gateway:
routes:
- id: host_route
uri: http://www.google.com
predicates:
- Path=/foo/{segment},/bar/{segment}
上面路由可以匹配诸如:/foo/1 或 /foo/bar 或 /bar/baz等 其中的segment变量可以通过下面方式获取:
PathMatchInfo variables = exchange.getAttribute(URI_TEMPLATE_VARIABLES_ATTRIBUTE);
Map uriVariables = variables.getUriVariables();
String segment = uriVariables.get("segment");
在后续的GatewayFilter Factories
就可以做对应的操作了。
7.2.4 例子4:重定向(redirect)到指定页面
对于浏览器,通常是发现没有权限后跳转到登录页面。响应状态码需要为HttpStatus.SEE_OTHER(303)。
重定向(redirect)会丢失之前请求的参数,对于需要转发到目标URL的参数,需手工添加。
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
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;
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getQueryParams().getFirst("authToken");
//重定向(redirect)到登录页面
if (StringUtils.isBlank(token)) {
String url = "http://想跳转的网址";
ServerHttpResponse response = exchange.getResponse();
//303状态码表示由于请求对应的资源存在着另一个URI,应使用GET方法定向获取请求的资源
response.setStatusCode(HttpStatus.SEE_OTHER);
response.getHeaders().set(HttpHeaders.LOCATION, url);
return response.setComplete();
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -100;
}
}
7.2.5 返回401状态码和提示信息
只要将自定义的GlobalFilter声明成Spring Bean就会自动生效,Ordered接口用来指定拦截器生效顺序(数字越小优先级越高)。
这里假设用来验证权限的key是authToken。
import java.net.URI;
import java.nio.charset.StandardCharsets;
import org.apache.commons.lang3.StringUtils;
import org.springframework.cloud.gateway.filter.GatewayFilterChain;
import org.springframework.cloud.gateway.filter.GlobalFilter;
import org.springframework.cloud.gateway.support.ServerWebExchangeUtils;
import org.springframework.core.Ordered;
import org.springframework.core.io.buffer.DataBuffer;
import org.springframework.http.HttpHeaders;
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 org.springframework.web.util.UriComponentsBuilder;
import com.alibaba.fastjson.JSONObject;
@Component
public class AuthFilter implements GlobalFilter, Ordered {
@Override
public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String token = exchange.getRequest().getQueryParams().getFirst("authToken");
//返回401状态码和提示信息
if (StringUtils.isBlank(token)) {
ServerHttpResponse response = exchange.getResponse();
JSONObject message = new JSONObject();
message.put("status", -1);
message.put("data", "鉴权失败");
byte[] bits = message.toJSONString().getBytes(StandardCharsets.UTF_8);
DataBuffer buffer = response.bufferFactory().wrap(bits);
response.setStatusCode(HttpStatus.UNAUTHORIZED);
//指定编码,否则在浏览器中会中文乱码
response.getHeaders().add("Content-Type", "text/plain;charset=UTF-8");
return response.writeWith(Mono.just(buffer));
}
return chain.filter(exchange);
}
@Override
public int getOrder() {
return -100;
}
}
7.3 测试
- 访问 http://127.0.0.1:8888/user/user/get?id=8 地址,正确返回 JSON 结果如下:
{"id":8,"name":"8:没有昵称","gender":1}
- 访问 http://127.0.0.1:8888/user/user/get?id=1 地址,无任何返回信息。并打印出filter的日志:
8.本文源码
本文示例源码可从我的Gitee的开源仓库获取。