为什么需要网关?网关的基本功能是什么?
当我们将应用拆成微服务之后,客户端对于这些服务的调用就会变得十分复杂(每个客户端只能自己去记录每一个服务的调用地址在进行调用)
,所以我们可以通过网关来统一管理这些服务的路由。
除此之外网关还有以下好处:
- 统一认证,将所有服务都使用网关作为入口,避免每个服务调用都需要认证。
- 统一处理跨域请求,避免不同场景处理的复杂度。
- 统一配置网关路由,避免客户端代码请求地址编写的复杂性。
Spring Cloud Gateway和Nginx有什么区别?
两者使用的场景不同,nginx
作为请求的第一道关卡,其作为开源免费的的反向代理服务器,其并发处理能力以及非常小的内存开销是gateway
未能拥有的。
而gateway
作为请求到达每一个微服务应用前的最后一道关卡,在和nacos
整合之后,即可通过nacos
获取各个服务信息,结合配置的路由配置即可将请求转发实际请求的服务上。
路由、断言、过滤器是什么?
路由(route)
:由id
、目标uri
、断言集合和过滤器组成,只有符合断言的请求地址才能真正请求到这条配置的uri
地址。断言(predicate)
:路由的组成部分,通过predicate
可以决定要请求到目标地址的请求条件。过滤器(filter)
: 可以对请求之前或者请求之后的参数进行修改。
如何搭建一个微服务网关?如何使用断言进行路由匹配?
第一步肯定是基于Spring Boot
创建一个网关应用了,然后引入相关依赖了,注意版本间是有兼容性的,具体可以参照下面这张表格:
以笔者为例,笔者父pom
中的版本如下
<properties>
<java.version>1.8</java.version>
<spring-cloud.version>Hoxton.SR3</spring-cloud.version>
<spring-boot.version>2.2.5.RELEASE</spring-boot.version>
<spring-cloud-alibaba.version>2.2.1.RELEASE</spring-cloud-alibaba.version>
</properties>
完成后我们就可以通过properties
配置自己的路由规则了,如下predicates
的配置,name
为path
意为通过路径进行断言,args
为/system
,如果笔者希望访问system
的服务,那么我们就可以键入127.0.0.1:9000/system/xxx
即可将结果转发到127.0.0.1:9001/system/xxx
上
# 应用名称为gateway
spring.application.name=gateway
# 端口号
server.port=9000
# 将gateway注册到nacos上
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
# 命名空间
spring.cloud.nacos.discovery.namespace=dev
# 组名
spring.cloud.nacos.discovery.group=myGroup
# 路由id名称,必须唯一
spring.cloud.gateway.routes[0].id=system
# 转发的路由地址
spring.cloud.gateway.routes[0].uri=http://127.0.0.1:9001
# 断言配置,使用path进行匹配,只要是/system/**都进行转发
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[0]=/system/**
如下所示,笔者需要访问system
的/test/test
,直接通过gateway访问即可
curl 127.0.0.1:9000/system/test/test
可以看到请求确实来到的system
服务上
更多断言可以参考官方文档
https://docs.spring.io/spring-cloud-gateway/docs/2.2.9.RELEASE/reference/html/
Gateway中的过滤器是什么?
假如我们想配置一个局部过滤器,我们可以集成AbstractGatewayFilterFactory
编写一个类,如下所示,可以看到笔者编写了一个LoginAdminGatewayFilterFactory
,然后我们就来实现LoginAdminGatewayFilter
@Component
public class LoginAdminGatewayFilterFactory extends AbstractGatewayFilterFactory<Object> {
@Resource
LoginAdminGatewayFilter loginAdminGatewayFilter;
@Override
public GatewayFilter apply(Object config) {
return loginAdminGatewayFilter;
}
}
可以看到笔者基于gateway
实现了一个自定义的权限校验,通过集成GatewayFilter
即可完成路由请求前的拦截,通过请求映射、参数的信息实现个性化拦截处理。
@Component
public class LoginAdminGatewayFilter implements GatewayFilter, Ordered {
private static final Logger LOG = LoggerFactory.getLogger(LoginAdminGatewayFilter.class);
@Resource
private RedisTemplate redisTemplate;
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String path = exchange.getRequest().getURI().getPath();
// 请求地址中不包含/admin/的,不是控台请求,不需要拦截
if (!path.contains("/admin/")) {
return chain.filter(exchange);
}
if (path.contains("/system/admin/user/login")
|| path.contains("/system/admin/user/logout")
|| path.contains("/system/admin/kaptcha")) {
LOG.info("不需要控台登录验证:{}", path);
return chain.filter(exchange);
}
//获取header的token参数
String token = exchange.getRequest().getHeaders().getFirst("token");
LOG.info("控台登录验证开始,token:{}", token);
if (token == null || token.isEmpty()) {
LOG.info( "token为空,请求被拦截" );
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
Object object = redisTemplate.opsForValue().get(token);
if (object == null) {
LOG.warn( "token无效,请求被拦截" );
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
} else {
LOG.info("已登录:{}", object);
// 增加权限校验,gateway里没有LoginUserDto,所以全部用JSON操作
LOG.info("接口权限校验,请求地址:{}", path);
boolean exist = false;
JSONObject loginUserDto = JSON.parseObject(String.valueOf(object));
JSONArray requests = loginUserDto.getJSONArray("requests");
// 遍历所有【权限请求】,判断当前请求的地址是否在【权限请求】里
for (int i = 0, l = requests.size(); i < l; i++) {
String request = (String) requests.get(i);
if (path.contains(request)) {
exist = true;
break;
}
}
if (exist) {
LOG.info("权限校验通过");
} else {
LOG.warn("权限校验未通过");
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
return chain.filter(exchange);
}
}
@Override
public int getOrder() {
return 1;
}
}
完成后,我们希望system
服务可以应用这个过滤器只需将LoginAdminGatewayFilterFactory
的前半部分LoginAdmin
配置到filters
即可,如下所示
# 路由id名称,必须唯一
spring.cloud.gateway.routes[0].id=system
# 转发的路由地址
spring.cloud.gateway.routes[0].uri=http://127.0.0.1:9001
# 断言配置,使用path进行匹配,只要是/system/**都进行转发
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[0]=/system/**
# gateway的system路由引入一个局部过滤器LoginAdminGatewayFilterFactory
spring.cloud.gateway.routes[0].filters[0].name=LoginAdmin
spring.cloud.gateway.routes[0].filters[0].args[0]=true
我们再次发送上文的system
请求,可以看到这个局部过滤器确实拦截到了请求,而且参数中都带着请求的各种信息。
除了局部过滤器,还有一个全局过滤器(作用于全路由上)
,示例如下所示:
@Slf4j
@Component
@Order(value = Integer.MIN_VALUE)
public class AccessLogGlobalFilter implements GlobalFilter {
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
//filter的前置处理
ServerHttpRequest request = exchange.getRequest();
String path = request.getPath().pathWithinApplication().value();
InetSocketAddress remoteAddress = request.getRemoteAddress();
return chain
//继续调用filter
.filter(exchange)
//filter的后置处理
.then(Mono.fromRunnable(() -> {
ServerHttpResponse response = exchange.getResponse();
HttpStatus statusCode = response.getStatusCode();
log.info("请求路径:{},远程IP地址:{},响应码:{}", path, remoteAddress, statusCode);
}));
}
}
如何集成Nacos注册中心并实现负载均衡?
先说第一个问题如何集成到Nacos注册中心,其实上文我们已经做到了,就是这几条配置使得gateway
指向nacos
# 将gateway注册到nacos上
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
# 命名空间
spring.cloud.nacos.discovery.namespace=dev
# 组名
spring.cloud.nacos.discovery.group=myGroup
再来说说负载均衡,我们上文中配置system
的转发地址为
spring.cloud.gateway.routes[0].uri=http://127.0.0.1:9001
这样配置当我们请求system
服务时,gateway
过滤中的LoadBalancerClientFilter
进行请求转发时,就会使用原始请求进行转发参见下面的源码
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
URI url = (URI)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR);
String schemePrefix = (String)exchange.getAttribute(ServerWebExchangeUtils.GATEWAY_SCHEME_PREFIX_ATTR);
//如果转发地址配置包含lb则进行负载均衡算法获取转发实例
if (url != null && ("lb".equals(url.getScheme()) || "lb".equals(schemePrefix))) {
ServerWebExchangeUtils.addOriginalRequestUrl(exchange, url);
if (log.isTraceEnabled()) {
log.trace("LoadBalancerClientFilter url before: " + url);
}
//获取实例
ServiceInstance instance = this.choose(exchange);
if (instance == null) {
throw NotFoundException.create(this.properties.isUse404(), "Unable to find instance for " + url.getHost());
} else {
//解析这个实例组装生成requestUrl
URI uri = exchange.getRequest().getURI();
String overrideScheme = instance.isSecure() ? "https" : "http";
if (schemePrefix != null) {
overrideScheme = url.getScheme();
}
URI requestUrl = this.loadBalancer.reconstructURI(new DelegatingServiceInstance(instance, overrideScheme), uri);
if (log.isTraceEnabled()) {
log.trace("LoadBalancerClientFilter url chosen: " + requestUrl);
}
//将requestUrl 存到exchange,继续走到下一个filter
exchange.getAttributes().put(ServerWebExchangeUtils.GATEWAY_REQUEST_URL_ATTR, requestUrl);
return chain.filter(exchange);
}
} else {
//转发地址没有配置lb则直接带着原始请求进入下一个filter,你可以理解为直接请求地址
return chain.filter(exchange);
}
}
所以我们只需将请求地址改为lb://服务名
spring.cloud.gateway.routes[0].uri=lb://system
再次curl 127.0.0.1:9000/system/test/test
,查看LoadBalancerClientFilter
可以看到这个请求确实走到了负载均衡获取实例的choose
方法上。
如何集成Nacos实现一处修改到处生效?
首先就是引入nacos
配置相关依赖
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId>
</dependency>
<dependency>
<groupId>com.alibaba.cloud</groupId>
<artifactId>spring-cloud-starter-alibaba-nacos-config</artifactId>
</dependency>
完成后配置bootstrap.properties
,注意是bootstrap
,否则配置不会生效。然后配置如下内容,可以看到笔者的应用名为gateway
,file-extension
为properties
,所以我们就在nacos
命名空间为dev
,组为myGroup
,添加一个为gateway.properties
的配置
# 端口号
server.port=9000
# 应用名称为gateway
spring.application.name=gateway
#eureka.client.service-url.defaultZone=http://localhost:8761/eureka/
# 将gateway注册到nacos上
spring.cloud.nacos.discovery.server-addr=127.0.0.1:8848
spring.cloud.nacos.discovery.namespace=dev
spring.cloud.nacos.discovery.group=myGroup
# 命名空间
spring.cloud.nacos.config.namespace=dev
# 组名
spring.cloud.nacos.config.group=myGroup
# 使用nacos上的路由配置
spring.cloud.nacos.config.file-extension=properties
spring.cloud.nacos.config.server-addr=127.0.0.1:8848
在nacos
上配置的gateway.properties
内容如下
# 路由id名称,必须唯一
spring.cloud.gateway.routes[0].id=system
# 转发的路由地址
spring.cloud.gateway.routes[0].uri=lb://system
# 断言配置,使用path进行匹配,只要是/system/**都进行转发
spring.cloud.gateway.routes[0].predicates[0].name=Path
spring.cloud.gateway.routes[0].predicates[0].args[0]=/system/**
spring.cloud.gateway.routes[0].filters[0].name=LoginAdmin
spring.cloud.gateway.routes[0].filters[0].args[0]=true
spring.cloud.gateway.routes[1].id=business
#spring.cloud.gateway.routes[1].uri=http://127.0.0.1:9002
spring.cloud.gateway.routes[1].uri=lb://business
spring.cloud.gateway.routes[1].predicates[0].name=Path
spring.cloud.gateway.routes[1].predicates[0].args[0]=/business/**
spring.cloud.gateway.routes[1].filters[0].name=LoginAdmin
spring.cloud.gateway.routes[1].filters[0].args[0]=true
spring.cloud.gateway.routes[2].id=file
#spring.cloud.gateway.routes[2].uri=http://127.0.0.1:9003
spring.cloud.gateway.routes[2].uri=lb://file
spring.cloud.gateway.routes[2].predicates[0].name=Path
spring.cloud.gateway.routes[2].predicates[0].args[0]=/file/**
spring.cloud.gateway.routes[2].filters[0].name=LoginAdmin
spring.cloud.gateway.routes[2].filters[0].args[0]=true
spring.redis.host=r-uf6ljbcdaxobsifyctpd.redis.rds.aliyuncs.com
spring.redis.port=6379
spring.redis.password=Redis000
再次键入上方的请求我们发现,请求可以正常转发
gateway全局异常处理了解过嘛?
如下所示,集成ErrorWebExceptionHandler
即可
/**
* 用于网关的全局异常处理
* @Order(-1):优先级一定要比ResponseStatusExceptionHandler低
*/
@Slf4j
@Order(-1)
@Component
@RequiredArgsConstructor
public class GlobalErrorExceptionHandler implements ErrorWebExceptionHandler {
private final ObjectMapper objectMapper;
@SuppressWarnings({"rawtypes", "unchecked", "NullableProblems"})
@Override
public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) {
ServerHttpResponse response = exchange.getResponse();
if (response.isCommitted()) {
return Mono.error(ex);
}
// JOSN格式返回
response.getHeaders().setContentType(MediaType.APPLICATION_JSON);
if (ex instanceof ResponseStatusException) {
response.setStatusCode(((ResponseStatusException) ex).getStatus());
}
return response.writeWith(Mono.fromSupplier(() -> {
DataBufferFactory bufferFactory = response.bufferFactory();
try {
//todo 返回响应结果,根据业务需求,自己定制
CommonResponse resultMsg = new CommonResponse("500",ex.getMessage(),null);
return bufferFactory.wrap(objectMapper.writeValueAsBytes(resultMsg));
}
catch (JsonProcessingException e) {
log.error("Error writing response", ex);
return bufferFactory.wrap(new byte[0]);
}
}));
}
}