Spring
在因 Netflix
开源流产事件后,在不断的更换 Netflix
相关的组件,比如:Eureka
、Zuul
、Feign
、Ribbon
等,Zuul
的替代产品就是 SpringCloud Gateway
,这是 Spring
团队研发的网关组件,可以实现安全认证、限流、重试、支持长连接等新特性。
如果有三个服务 account-service
,product-service
,order-service
。现在有客户端 WEB应用
或 APP应用
需要访问后端服务获取数据那么就需要在客户端维护好三个服务的访问路径。
这样的架构会有如下几个典型的问题:
所以需要在微服务之前加一个网关服务,让所有的客户端只要访问网关,网关负责对请求进行转发;将权限校验逻辑放到网关的过滤器中,后端服务不需要再关注权限校验的代码;只需要对外提供一个可供外网访问的域名地址,新增服务后也不需要再让运维人员进行网络配置了,这样上面的架构就变成了如下所示:
在 SpringCloud 体系架构中,需要部署一个单独的网关服务对外提供访问入口,然后网关服务根据配置好的规则将请求转发至具体的后端服务。
Spring Cloud Gateway
是 SpringCloud
的全新子项目,该项目基于 Spring5.x
、SpringBoot2.x
技术版本进行编写,意在提供简单方便、可扩展的统一 API 路由管理方式。
Route(路由)
路由是网关的基本单元,由 ID、URI、一组 Predicate、一组 Filter 组成,根据 Predicate 进行匹配转发
Predicate(断言)
指的是 Java 8 的 Function Predicate,输入类型是 Spring 框架中的 ServerWebExchange。作为路由转发的判断条件,目前 SpringCloud Gateway
支持多种方式,常见如:Path
、Query
、Method
、Header
等
Filter(过滤器)
过滤器是路由转发请求时所经过的过滤逻辑,GatewayFilter 的实例可用于修改请求、响应内容
客户端向 Spring Cloud Gateway
发出请求。如果网关处理程序映射确定请求与路由匹配,则将其发送到网关 Web 处理程序。此处理程序运行时通过特定于请求的筛选链发送请求。过滤器被虚线分隔的原因是过滤器可以在发送代理请求之前或之后执行逻辑。
当满足这种条件后才会被转发,如果是多个,那就是都满足的情况下被转发。
匹配方式 | 说明 | 样例 |
---|---|---|
Before | 某一个时间点之前 | Before=2019-05-01T00:00:00+08:00[Asia/Shanghai] |
After | 某一个时间点之后 | After=2019-04-29T00:00:00+08:00[Asia/Shanghai] |
Between | Before +After | Between=2019-04-29T00:00:00+08:00[Asia/Shanghai], 2019-05-01T00:00:00+08:00[Asia/Shanghai] |
Cookie | Cookie 值 | Cookie=hacfin, langyastudio |
Header | Header 值 | Header=X-Request-Id, \d+ |
Host | 主机名 | Host=**.langyastudio.com |
Method | 请求方式 | Method=POST |
Query | 请求参数 | Query=xxx, zzz |
Path | 请求路径 | Path=/article/{articleId} |
RemoteAddr | 请求IP | RemoteAddr=192.168.1.56/24 |
Weight | 权重 | Weight=group1, 8 |
Weight 示例:
80% 的请求会被路由到 localhost:8201,20% 会被路由到 localhost:8202
spring: cloud: gateway: routes: - id: weight_high uri: http://localhost:8201 predicates: - Weight=group1, 8 - id: weight_low uri: http://localhost:8202 predicates: - Weight=group1, 2
路由过滤器可用于修改进入的 HTTP 请求和返回的 HTTP 响应。Spring Cloud Gateway 内置了多种路由过滤器,他们都由 GatewayFilter 的工厂类来产生,下面介绍下常用路由过滤器的用法。
给请求添加参数的过滤器
spring: cloud: gateway: routes: - id: add_request_parameter_route uri: http://localhost:8201 filters: - AddRequestParameter=username, langyastudio predicates: - Method=GET
以上配置会对 GET 请求添加 username=langyastudio
的请求参数,通过 curl 工具使用以下命令进行测试
curl http://localhost:9201/user/getByUsername
相当于发起该请求:
curl http://localhost:8201/user/getByUsername?username=langyastudio
对指定数量的路径前缀去除的过滤器
spring: cloud: gateway: routes: - id: strip_prefix_route uri: http://localhost:8201 predicates: - Path=/user-service/** filters: - StripPrefix=2
以上配置会把以 /user-service/
开头的请求的路径去除两位,通过 curl 工具使用以下命令进行测试
curl http://localhost:9201/user-service/a/user/1
相当于发起该请求:
curl http://localhost:8201/user/1
与 StripPrefix 过滤器恰好相反,会对原有路径前缀增加操作的过滤器
spring: cloud: gateway: routes: - id: prefix_path_route uri: http://localhost:8201 predicates: - Method=GET filters: - PrefixPath=/user
以上配置会对所有 GET 请求添加 /user
路径前缀,通过 curl 工具使用以下命令进行测试
curl http://localhost:9201/1
相当于发起该请求:
curl http://localhost:8201/user/1
RequestRateLimiter 过滤器可以用于限流,使用 RateLimiter 实现来确定是否允许当前请求继续进行,如果请求太大默认会返回 HTTP 429 -太多请求状态。
在 pom.xml 中添加相关依赖
<dependency> <groupId>org.springframework.boot</groupId> <artifactId>spring-boot-starter-data-redis-reactive</artifactId> </dependency>
添加限流策略的配置类,这里有两种策略一种是根据请求参数中的 username 进行限流,另一种是根据访问 IP 进行限流
@Configuration public class RedisRateLimiterConfig { @Bean KeyResolver userKeyResolver() { return exchange -> Mono.just(exchange.getRequest().getQueryParams().getFirst("username")); } @Bean public KeyResolver ipKeyResolver() { return exchange -> Mono.just(exchange.getRequest().getRemoteAddress().getHostName()); } }
使用 Redis 来进行限流,所以需要添加 Redis 和 RequestRateLimiter 的配置,这里对所有的 GET 请求都进行了按 IP来限流的操作
server: port: 9201 spring: redis: host: localhost password: 123456 port: 6379 cloud: gateway: routes: - id: requestratelimiter_route uri: http://localhost:8201 filters: - name: RequestRateLimiter args: #每秒允许处理的请求数量 redis-rate-limiter.replenishRate: 1 #令牌桶的容量,允许在一秒钟内完成的最大请求数 redis-rate-limiter.burstCapacity: 2 #限流策略,对应策略的Bean #SpEL 表达式根据#{@beanName}从 Spring 容器中获取 Bean 对象 key-resolver: "#{@ipKeyResolver}" predicates: - Method=GET logging: level: org.springframework.cloud.gateway: debug
多次请求该地址:http://localhost:9201/user/1 ,会返回状态码为 429 的错误
对路由请求进行重试的过滤器,可以根据路由请求返回的 HTTP 状态码来确定是否进行重试
修改配置文件:
spring: cloud: gateway: routes: - id: retry_route uri: http://localhost:8201 predicates: - Method=GET filters: - name: Retry args: retries: 1 #需要进行重试的次数 statuses: BAD_GATEWAY #返回哪个状态码需要进行重试,返回状态码为5XX进行重试 backoff: firstBackoff: 10ms maxBackoff: 50ms factor: 2 basedOnPreviousValue: false
当调用返回 500 时会进行重试,访问测试地址:http://localhost:9201/user/111
可以发现 user-service 控制台报错 2 次,说明进行了一次重试
2019-10-27 14:08:53.435 ERROR 2280 --- [nio-8201-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is java.lang.NullPointerException] with root cause
例如:使用 SpringCloud 架构后希望所有的请求都需要经过网关才能访问,在不作任何处理的情况下是可以绕过网关直接访问后端服务的。
防止绕过网关直接请求后端服务的解决方案主要有三种:
网络隔离
后端普通服务都部署在内网,通过防火墙策略限制只允许网关应用访问后端服务
应用层拦截
请求后端服务时通过拦截器校验请求是否来自网关,如果不来自网关则提示不允许访问
使用 Kubernetes 部署
在使用 Kubernetes 部署 SpringCloud 架构时给网关的 Service 配置 NodePort,其他后端服务的 Service 使用ClusterIp,这样在集群外就只能访问到网关了
如果采用应用层拦截,在请求经过网关时添加额外的 Header 示例:
@Component @Order(0) public class GatewayRequestFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { byte[] token = Base64Utils.encode("GATEWAY_TOKEN_VALUE".getBytes()); String[] headerValues = {new String(token)}; ServerHttpRequest build = exchange.getRequest() .mutate() .header("geteway_token", headerValues) .build(); ServerWebExchange newExchange = exchange.mutate().request(build).build(); return chain.filter(newExchange); } }
源码地址:https://github.com/langyastudio/langya-tech/tree/master/spring-cloud
使用 Nacos Discovery Starter 、 Spring Cloud Gateway Starter 完成 Spring Cloud 服务路由。
通过修改官方示例 nacos-gateway-example 来演示 API 网关的功能
修改 pom.xml 文件,引入 Nacos Discovery Starter、Spring Cloud Gateway Starter 依赖
<dependency> <groupId>com.alibaba.cloud</groupId> <artifactId>spring-cloud-starter-alibaba-nacos-discovery</artifactId </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-gateway</artifactId> </dependency> <dependency> <groupId>org.springframework.cloud</groupId> <artifactId>spring-cloud-starter-loadbalancer</artifactId> </dependency>
配置文件中配置 Nacos Server 地址 与 Spring Cloud Gateway 路由
Spring Cloud Gateway
目前有两种方式进行配置:
application.yml
配置文件方式RouteLocator
方法返回值spring: main: #springcloudgateway 的内部是通过 netty+webflux 实现的 #webflux 实现和 spring-boot-starter-web 依赖冲突 web-application-type: reactive application: name: nacos-gateway-discovery cloud: #Nacos config nacos: username: nacos password: nacos discovery: server-addr: 127.0.0.1:8848 #spring cloud gateway config gateway: routes: - id: nacos-gateway uri: lb://nacos-discovery-provider # 网关的 /nacos 映射为 nacos-discovery-provider 服务 predicates: - Path=/nacos/** filters: - StripPrefix=1
使用 @EnableDiscoveryClient
注解开启服务注册与发现功能
@SpringBootApplication @EnableDiscoveryClient public class GatewayApplication { public static void main(String[] args) { SpringApplication.run(GatewayApplication.class, args); } }
此时执行 http://192.168.123.100:18061/nacos/**
请求时,其实转发到 nacos-discovery-provider 服务,如下图所示:
#由于使用了 StripPrefix=1 #实际转发到 nacos-discovery-provider 服务的 /echo/aaa curl 'http://192.168.123.100:18061/nacos/echo/aaa' hello Nacos Discovery aaa
在SpringCloud gateway中默认使用 DefaultErrorWebExceptionHandler
来处理异常。这个可以通过配置类 ErrorWebFluxAutoConfiguration
得之。
在 DefaultErrorWebExceptionHandler
类中的默认异常处理逻辑如下:
public class DefaultErrorWebExceptionHandler extends AbstractErrorWebExceptionHandler { ... protected RouterFunction<ServerResponse> getRoutingFunction(ErrorAttributes errorAttributes) { return RouterFunctions.route(this.acceptsTextHtml(), this::renderErrorView).andRoute(RequestPredicates.all(), this::renderErrorResponse); } ... }
根据请求头确认返回什么资源格式。
返回的数据内容在 DefaultErrorAttributes
类中构建而成。
public class DefaultErrorAttributes implements ErrorAttributes { ... public Map<String, Object> getErrorAttributes(ServerRequest request, boolean includeStackTrace) { Map<String, Object> errorAttributes = new LinkedHashMap(); errorAttributes.put("timestamp", new Date()); errorAttributes.put("path", request.path()); Throwable error = this.getError(request); MergedAnnotation<ResponseStatus> responseStatusAnnotation = MergedAnnotations.from(error.getClass(), SearchStrategy.TYPE_HIERARCHY).get(ResponseStatus.class); HttpStatus errorStatus = this.determineHttpStatus(error, responseStatusAnnotation); errorAttributes.put("status", errorStatus.value()); errorAttributes.put("error", errorStatus.getReasonPhrase()); errorAttributes.put("message", this.determineMessage(error, responseStatusAnnotation)); errorAttributes.put("requestId", request.exchange().getRequest().getId()); this.handleException(errorAttributes, this.determineException(error), includeStackTrace); return errorAttributes; } ... }
阅读到这里就可以看到为什么上面会返回那样的数据格式,接下来需要改写返回格式。
这里可以自定义一个 CustomErrorWebExceptionHandler
类用来继承 DefaultErrorWebExceptionHandler
,然后修改生成前端响应数据的逻辑。再然后定义一个配置类,写法可以参考 ErrorWebFluxAutoConfiguration
,简单将异常类替换成 CustomErrorWebExceptionHandler
类即可。
这种方法大家请自行研究,基本都是复制代码,改写不复杂,这种方法就不演示了,这里给大家介绍另外一种写法:
定义一个全局异常类 GlobalErrorWebExceptionHandler
让其直接实现顶级接口 ErrorWebExceptionHandler
重写 handler()
方法,在 handler()
方法中返回自定义的响应类。但是需要注意重写的实现类优先级一定要小于内置 ResponseStatusExceptionHandler
经过它处理的获取对应错误类的响应码。
代码如下:
/** * 网关全局异常处理 */ @Slf4j @Order(-1) @Configuration @RequiredArgsConstructor(onConstructor = @__(@Autowired)) public class GlobalErrorWebExceptionHandler implements ErrorWebExceptionHandler { private final ObjectMapper objectMapper; @Override public Mono<Void> handle(ServerWebExchange exchange, Throwable ex) { ServerHttpResponse response = exchange.getResponse(); if (response.isCommitted()) { return Mono.error(ex); } // 设置返回JSON 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 { //返回响应结果 return bufferFactory.wrap(objectMapper.writeValueAsBytes(ResultData.fail(500,ex.getMessage()))); } catch (JsonProcessingException e) { log.error("Error writing response", ex); return bufferFactory.wrap(new byte[0]); } })); } }
SpringCloud 体系中如何防止内部隐私接口被网关调用?解决方案主要有:
黑名单机制
即将这些接口放入“黑名单”中存储起来,在网关启动时读取黑名单配置,然后校验是否在黑名单中
接口路径
即给接口指定访问路径时采用这样的格式 : /访问控制/接口。访问控制可以有以下几个规则(参考JAVA包规范),可根据业务需要进行扩展。
pb - public 所有请求均可访问 pt - protected 需要进行token认证通过后方可访问 pv - private 无法通过网关访问,只能微服务内部调用 df - default 网关请求token认证,并且请求参数和返回结果进行加解密 ...
有了这套接口规范以后,就可以灵活控制接口访问权限,然后在网关对接口路径进行校验,如果命中对应的访问控制规则就进行对应的逻辑处理。
@Component @Order(0) @Slf4j public class GatewayRequestFilter implements GlobalFilter { @Override public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) { //获取请求路径 String rawPath = exchange.getRequest().getURI().getRawPath(); if(isPv(rawPath)){ throw new HttpServerErrorException(HttpStatus.FORBIDDEN,"can't access private API"); } return chain.filter(newExchange); } /** * 判断是否内部私有方法 * @param requestURI 请求路径 * @return boolean */ private boolean isPv(String requestURI) { return isAccess(requestURI,"/pv"); } /** * 网关访问控制校验 */ private boolean isAccess(String requestURI, String access) { //后端标准请求路径为 /访问控制/请求路径 int index = requestURI.indexOf(access); return index >= 0 && StringUtils.countOccurrencesOf(requestURI.substring(0,index),"/") < 1; } }
/** * 参考:org.springframework.cloud.loadbalancer.core.ZonePreferenceServiceInstanceListSupplier */ @Log4j2 public class VersionServiceInstanceListSupplier extends DelegatingServiceInstanceListSupplier { public VersionServiceInstanceListSupplier(ServiceInstanceListSupplier delegate) { super(delegate); } @Override public Flux<List<ServiceInstance>> get() { return delegate.get(); } @Override public Flux<List<ServiceInstance>> get(Request request) { return delegate.get(request).map(instances -> filteredByVersion(instances,getVersion(request.getContext()))); } /** * filter instance by requestVersion */ private List<ServiceInstance> filteredByVersion(List<ServiceInstance> instances, String requestVersion) { log.info("request version is {}",requestVersion); if(StringUtils.isEmpty(requestVersion)){ return instances; } List<ServiceInstance> filteredInstances = instances.stream() .filter(instance -> requestVersion.equalsIgnoreCase(instance.getMetadata().getOrDefault("version",""))) .collect(Collectors.toList()); if (filteredInstances.size() > 0) { return filteredInstances; } return instances; } private String getVersion(Object requestContext) { if (requestContext == null) { return null; } String version = null; if (requestContext instanceof RequestDataContext) { version = getVersionFromHeader((RequestDataContext) requestContext); } return version; } /** * get version from header */ private String getVersionFromHeader(RequestDataContext context) { if (context.getClientRequest() != null) { HttpHeaders headers = context.getClientRequest().getHeaders(); if (headers != null) { //could extract to the properties return headers.getFirst("version"); } } return null; } }
实现原理跟自定义负载均衡策略一样,根据 version 匹配符合要求的服务实例。
VersionServiceInstanceListSupplierConfiguration
,用于替换默认服务实例筛选逻辑public class VersionServiceInstanceListSupplierConfiguration { @Bean ServiceInstanceListSupplier serviceInstanceListSupplier(ConfigurableApplicationContext context) { ServiceInstanceListSupplier delegate = ServiceInstanceListSupplier.builder() .withDiscoveryClient() .withCaching() .build(context); return new VersionServiceInstanceListSupplier(delegate); } }
@LoadBalancerClient(value = "nacos-discovery-provider", configuration = VersionServiceInstanceListSupplierConfiguration.class)
,对于 nacos-discovery-provider 启用自定义负载均衡算法 或通过 @LoadBalancerClients(defaultConfiguration = VersionServiceInstanceListSupplierConfiguration.class)
为所有服务启用自定义负载均衡算法Spring Cloud GateWay 路由转发规则介绍
Spring Cloud Gateway:新一代API网关服务
nacos-gateway-example
隐私接口禁止外部访问
实现网关的灰度发布