Zuul 入门教程 - 中级篇

上篇 - Zuul 入门教程(基础篇)

前言

版本说明

在本文中,默认使用的 Spring Cloud 版本是 Finchley.RELEASE,对应的 Spring Boot 版本是 2.0.3,Zuul 版本是 1.x,特别声明除外。

Zuul Filter 链

工作原理

Zuul 的核心逻辑是由一系列紧密配合工作的 Filter 来实现的,它们能够在进行 HTTP 请求或者响应的时候执行相关操作。可以说,没有 Filter 责任链,就没有如今的 Zuul,更不可能构成功能丰富的” 网关 “,Zuul Filter 的主要特性有以下几点:

  • Filter 的类型:Filter 的类型决定了此 Filter 在 Filter 链中的执行顺序,可能是路由动作发生前,可能是路由动作发生时,可能是路由动作发生后,也可能是路由过程发生异常时
  • Filter 的执行顺序:同一种类型的 Filter 可以通过 filterOrder() 方法来设定执行顺序,一般会根据业务的执行顺序需求,来设定自定义 Filter 的执行顺序
  • Filter 的执行条件:Filter 运行所需要的标准或条件
  • Filter 的执行效果:符合某个 Filter 执行条件,产生的执行效果

Zuul 内部提供了一个动态读取、编译和运行这些 Filter 的机制,Filter 之间不直接通信,在请求线程中会通过 RequestContext 来共享状态,它的内部是用 ThreadLocal 实现的,当然也可以在 Filter 之间使用 ThreadLocal 来收集自己需要的状态或数据。Zuul 中不同类型 Filter 的执行逻辑核心在 com.netflix.zuul.http.ZuulServlet 类中定义,该类相关代码和官方流程图如下所示:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
try {
this.init((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse);
RequestContext context = RequestContext.getCurrentContext();
context.setZuulEngineRan();

try {
this.preRoute();
} catch (ZuulException var13) {
this.error(var13);
this.postRoute();
return;
}

try {
this.route();
} catch (ZuulException var12) {
this.error(var12);
this.postRoute();
return;
}

try {
this.postRoute();
} catch (ZuulException var11) {
this.error(var11);
}
} catch (Throwable var14) {
this.error(new ZuulException(var14, 500, "UNHANDLED_EXCEPTION_" + var14.getClass().getName()));
} finally {
RequestContext.getCurrentContext().unset();
}

zuul-request-process

上面的官方流程图有些问题,其中 Post Filter 抛错之后进入 Error Filter,然后再进入 Post Filter 是有失偏颇的。实际上 Post Filter 抛错分两种情况:

  • 在 Post Filter 抛错之前,Pre、Route Filter 没有抛错,此时会进入 ZuulException 的逻辑,打印堆栈信息,然后再返回 status = 500 的 Error 信息
  • 在 Post Filter 抛错之前,Pre、Route Filter 已有抛错,此时不会打印堆栈信息,直接返回 status = 500 的 Error 信息
  • 也就是说,整个责任链流程终点不只是 Post Filter,还可能是 Error Filter,重新整理后的流程图

Filter 的生命周期

Zuul 一共有四种不同生命周期的 Filter,分别是:

  • pre:在 Zuul 按照映射规则路由到下级服务之前执行,如果需要对请求进行预处理,比如鉴权、限流等,都应考虑在此类 Filter 里实现
  • route:这类 Filter 是 Zuul 路由动作的执行者,是 Apache HttpClient 或 Netflix Ribbon 构建和发送原始 HTTP 请求的地方,目前已支持 OkHttp
  • post:这类 Filter 是在源服务返回结果或者异常信息发生后执行的,如果需要对返回信息做一些处理,则在此类 Filter 进行处理
  • error:在整个生命周期内如果发生异常,则会进入 Error Filter,可做全局异常处理

在实际项目中,往往需要自实现以上类型的 Filter 来对请求链路进行处理,根据业务的需求,选取相应生命周期的 Filter 来达成目的。在 Filter 之间,通过 com.netflix.zuul.context. RequestContext 类来进行通信,内部采用 ThreadLocal 保存每个请求的一些信息,包括请求路由、错误信息、HttpServletRequest、HttpServletResponse,这使得一些操作是十分可靠的,它还扩展了 ConcurrentHashMap,目的是为了在处理过程中保存任何形式的信息。

Zuul 的原生 Filter

首先官方文档提到,Zuul Server 如果使用 @EnableZuulProxy 注解搭配 Spring Boot Actuator,会多出两个管控端点,具体配置如下:

1
2
3
4
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
1
2
3
4
5
6
# 暴露所需的端点
management:
endpoints:
web:
exposure:
include: health, info, routes, filters
  • 端点 /actuator查看截图
  • 端点 /filters:返回当前 Zuul Server 中所有已注册生效的 Filter,查看截图
  • 端点 /routes:返回当前 Zuul Server 中所有已生成的映射规则,加上 /details 可查看详细信息,查看截图

从端点 /filters 返回的数据可以清楚地看到所有已注册生效的 Filter 信息,包括:Filter 实现类路径、Filter 执行次序、是否被禁用、是否静态。根据返回的内容,将前面的图稍作扩展,即可得到 Zuul 内置 Filter 与生命周期的组合流程图

zuul-default-filters

Zuul 内置了各种 Filter(见上表),以上是使用 @EnableZuulProxy 注解后注册的 Filter,如果使用 @EnableZuulServer 将缺少 PreDecorationFilterRibbonRoutingFilterSimpleHostRoutingFilter 这些原生 Filter。如果有特殊的业务需求,可以采取替代实现的方式,覆盖掉其原生代码,也可以釆取禁用策略,语法如下:zuul.<SimpleClassName>.<filterType>.disable=true

多级业务处理

自定义 Filter

在 Zuul 的 Filter 链体系中,可以把一组业务逻辑细分,然后封装到一个个紧密结合的 Filter 中,设置处理顺序,组成一组 Filter 链。这在一些业务场景下十分实用,以致除 Zuul 以外的网关中间件几乎都有类似的实现。在 Zuul 里实现自定义 Filter,只需继承 ZuulFilter 类即可,ZuulFilter 是一个抽象类,需要实现它的以下几个方法:

  • String filterType ():使用返回值设定 Filter 类型,可以设置为 pre、route、post、error 类型。
  • int filterOrder ():使用返回值设定 Filter 执行次序
  • boolean shouldFilter ():使用返回值设定该 Filter 是否执行,可以作为开关来使用
  • Object run ():Filter 里面的核心执行逻辑,业务处理在此编写

在 Zuul 里自定义 Filter 的示例代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.exception.ZuulException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE;

public class FirstPreFilter extends ZuulFilter {

private static final Logger logger = LoggerFactory.getLogger(FirstPreFilter.class);

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return 0;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() throws ZuulException {
logger.info("==> first custom zuul filter");
return null;
}
}
1
2
3
4
5
6
7
8
@Configuration
public class CommonConfiguration {

@Bean
public FirstPreFilter firstPreFilter() {
return new FirstPreFilter();
}
}

业务处理实战

Zuul 作为一个 “网关” 组件,原始的功能往往不能满足实际业务需求,为了解决这个问题,官方预留了 API,使得开发者能够实现自定义业务处理,加入 Zuul 的逻辑流程。下面模拟一个业务需求,使用 SecondPreFilter 来验证是否传入 a 参数,使用 ThirdPreFilter 来验证是否传入 b 参数,最后在 PostFilter 里边统一处理返回内容,查看流程图点击下载完整的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
public class SecondPreFilter extends ZuulFilter {

private static final Logger logger = LoggerFactory.getLogger(SecondPreFilter.class);

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return 2;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() throws ZuulException {
logger.info("==> second custom zuul pre filter");
//从RequestContext获取上下文
RequestContext context = RequestContext.getCurrentContext();
//从上下文获取HttpServletRequest
HttpServletRequest request = context.getRequest();
//从request尝试获取a参数值
String a = request.getParameter("a");
if (null == a) {
//对该请求禁止路由,也就是禁止访问下游服务
context.setSendZuulResponse(false);
//保存于上下文,作为同类型下游Filter的执行开关
context.set("logic-is-success", false);
//设定responseBody供PostFilter使用
context.setResponseBody("{\"status\":500,\"message\":\"param a is null !\"}");
return null;
}
//设置避免报空异常
context.set("logic-is-success", true);
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
public class ThirdPreFilter extends ZuulFilter {

private static final Logger logger = LoggerFactory.getLogger(ThirdPreFilter.class);

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return 3;
}

@Override
public boolean shouldFilter() {
RequestContext context = RequestContext.getCurrentContext();
return (boolean) context.get("logic-is-success");
}

@Override
public Object run() throws ZuulException {
logger.info("==> third custom zuul pre filter");
//从RequestContext获取上下文
RequestContext context = RequestContext.getCurrentContext();
//从上下文获取HttpServletRequest
HttpServletRequest request = context.getRequest();
//从request尝试获取b参数值
String b = request.getParameter("b");
if (null == b) {
//对该请求禁止路由,也就是禁止访问下游服务
context.setSendZuulResponse(false);
//保存于上下文,作为同类型下游Filter的执行开关,假定后续还有自定义Filter当设置此值
context.set("logic-is-success", false);
//设定responseBody供PostFilter使用
context.setResponseBody("{\"status\":500,\"message\":\"param b is null !\"}");
return null;
}
//设置避免报空异常
context.set("logic-is-success", true);
return null;
}
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class PostFilter extends ZuulFilter {

private static final Logger logger = LoggerFactory.getLogger(SecondPreFilter.class);

@Override
public String filterType() {
return POST_TYPE;
}

@Override
public int filterOrder() {
return 0;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() throws ZuulException {
logger.info("==> custom zuul post filter");
//从RequestContext获取上下文
RequestContext context = RequestContext.getCurrentContext();
//处理返回中文乱码
context.getResponse().setCharacterEncoding("UTF-8");
//获取上下文中保存的responseBody
String responseBody = context.getResponseBody();
//如果responseBody不为空,则说明流程有异常发生
if (null != responseBody) {
//设定返回状态码
context.setResponseStatusCode(500);
//替换响应报文
context.setResponseBody(responseBody);
}
return null;
}
}

测试效果:

  1. 依次启动 eureka-server、provider-service、zuul-server 应用
  2. 访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,日志输出如下:
1
2
SecondPreFilter         : ==> second custom zuul pre filter
SecondPreFilter : ==> custom zuul post filter
  1. 访问 http://127.0.0.1:8092/provider/service/provider/add?a=3,日志输出如下:
1
2
3
SecondPreFilter         : ==> second custom zuul pre filter
ThirdPreFilter : ==> third custom zuul pre filter
SecondPreFilter : ==> custom zuul post filter

使用 Groovy 编写 Filter

Groovy 语言是基于 JVM 的一门动态语言,它结合了 Python、 Ruby 和 Smalltalk 的许多强大特性,支持无缝引入 Java 代码与 Java 库,常常被用作 Java 的扩展语言来使用。它的语法与 Java 类似,书写起来比 Java 略为简洁,是一门很优秀的语言。Zuul 中提供 Groovy 的编译类 com.netflix.zuul.groovy.GroovyCompiler,结合 com.netflix.zuul.groovy.GroovyFileFilter 类,可以使用 Groovy 来编写自定义的 Filter。也许到这里,很多开发者认为它在 Zuul 中没有存在的必要,但是当得知它可以不用编译(不用打进工程包),可以放在服务器上任意位置,可以任何时候修改由它编写的 Filter,且修改过后还不用重启服务的时候,就会知道它有多实用了。下面使用 Groovy 编写自定义 Filter 作为例子,点击下载完整的示例代码。

首先添加 Groovy 相关的依赖,需要指定 Groovy 的版本来覆盖 SpringBoot 中的 Groovy 版本,建议这里不要使用阿里云的 Maven 仓库,否则会找不到最新版本的 Groovy:

1
2
3
4
5
6
7
8
9
10
11
12
<properties>
<groovy.version>3.0.3</groovy.version>
</properties>

<dependencies>
<dependency>
<groupId>org.codehaus.groovy</groupId>
<artifactId>groovy-all</artifactId>
<version>${groovy.version}</version>
<type>pom</type>
</dependency>
</dependencies>

使用 Groovy 编写自定义 Filter,并将 Groovy 的源码文件 GroovyFilter.groovy 保存在 /tmp/groovy/ 目录下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
import com.netflix.zuul.ZuulFilter
import com.netflix.zuul.context.RequestContext
import com.netflix.zuul.exception.ZuulException

import javax.servlet.http.HttpServletRequest

import static org.springframework.cloud.netflix.zuul.filters.support.FilterConstants.PRE_TYPE

class GroovyFilter extends ZuulFilter {

@Override
String filterType() {
return PRE_TYPE
}

@Override
int filterOrder() {
return 10
}

@Override
boolean shouldFilter() {
return true
}

@Override
Object run() throws ZuulException {
println("This is Groovy Filter!")
HttpServletRequest request = RequestContext.currentContext.request as HttpServletRequest
Iterator headerIt = request.getHeaderNames().iterator()
while (headerIt.hasNext()) {
String name = (String) headerIt.next()
String value = request.getHeader(name)
println("header: " + name + ": " + value)
}
return null
}
}

注册 GroovyFilter.groovy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
@Component
public class GroovyRunner implements CommandLineRunner {

@Override
public void run(String... args) throws Exception {
MonitoringHelper.initMocks();
FilterLoader.getInstance().setCompiler(new GroovyCompiler());
try{
FilterFileManager.setFilenameFilter(new GroovyFileFilter());
// 指定Groovy源码文件的绝对路径,对路径每隔20秒扫描一次
FilterFileManager.init(20, "/tmp/groovy");
}catch(Exception e){
throw new RuntimeException(e);
}
}
}

测试:

  1. 依次启动 eureka-server、provider-service、zuul-server 应用
  2. 访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,日志输出如下:
1
2
3
4
5
6
This is Groovy Filter!
header: host: 127.0.0.1:8092
header: connection: keep-alive
header: cache-control: max-age=0
header: upgrade-insecure-requests: 1
...
  1. /tmp/groovy/GroovyFilter.groovy 里的 println("This is Groovy Filter!") 更改为 println("This is Groovy Filter Modify!")
  2. 等待 20 秒后,访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,日志输出如下:
1
2
3
4
5
6
This is Groovy Filter Modify!
header: host: 127.0.0.1:8092
header: connection: keep-alive
header: cache-control: max-age=0
header: upgrade-insecure-requests: 1
...

Zuul 权限集成

应用权限概述

权限,是整个微服务体系乃至软件业永恒的话题,有资源的地方,就有权限约束。以往在构建单体应用的时候,比较流行的方式是使用 Apache Shiro,开发者的印象都是 Apache Shiro 比 Spring Security 上手容易,学习成本相对较小,但是到了 Spring Cloud 这里,面对成千上万的服务,而且服务之间无状态,此时 Apache Shiro 难免显得力不从心,所以 Spring Cloud 没有选择它也是有原因的。在解决方案的选择上面,传统的譬如单点登录(SSO),或者分布式 Session,要么致使权限服务器集中化导致流量臃肿,要么需要实现一套复杂的存储同步机制,都不是最好的解决方案。作为 Spring Cloud 微服务体系流量前门的 Zuul,除去与它特性毫无相关的实现方式,比较好的方式有:

自定义权限认证 Filter

由于 Zuul 对请求转发全程的可控性,可以在 Requestcontext 的基础上做任何事情,例如只需要设置一个执行顺序靠前的 Filter,就可以专门对请求的特定内容做权限认证。这种方式的优点是实现灵活度高,可整合已有权限系统,对原始系统微服务化特别友好;缺点是需要开发一套新的逻辑,维护增加成本,而且也会使得调用链路变得紊乱。

OAuth2.0 + JWT 认证

OAuth2.0 是业界对于 “授权 - 认证” 比较成熟的面向资源的授权协议。举个例子,除了可以使用本站用户名与密码登录 Spring Cloud 中国社区,还可以使用第三方应用登录,比如:GitHub、QQ 等登录方式。第三方登录功能对用户十分有亲和力,而 Oauth2.0 就是用于定义 Spring Cloud 中国社区与用户之间的那个 “授权层” 的。Oauth2.0 的认证原理图如下,在整个流程中,用户是资源拥有者,其关键还是在于客户端需要资源拥有者的授权,这个过程就相当于键入密码或者是其他第三方登录,触发了这个操作之后,客户端就可以向授权服务器申请 Token,拿到后再携带 Token 到资源所在服务器拉取相应资源。

oauth2-process

JWT(JSON Web Token)是一种使用 JSON 格式来规约 Token 或者 Session 的协议。由于传统认证方式免不了会生成一个凭证,这个凭证可以是 Token 或者 Session,保存于服务端或者其他持久化工具中,这样一来,凭证的存取就变得十分麻烦,JWT 的出现打破了这一瓶颈,实现了 “客户端 Session” 的愿景。JWT 通常由三部分组成:

  • Header 头部:指定 JWT 使用的签名算法
  • Payload 载荷:包含一些自定义与非自定义的认证信息
  • Signature 签名:将头部与载荷使用 . 连接之后,使用头部的签名算法生成签名信息并拼装到末尾

OAuth2.0 + JWT 的意义就在于,使用 0Auth2.0 协议的思想拉取认证生成 Token,使用 JWT 瞬时保存这个 Token,在客户端与资源端进行对称或非对称加密,使得这个规约具有定时、定量的授权认证功能,从而免去 Token 存储所带来的安全或系统扩展问题。

OAuth2.0 + JWT 实战

下面模拟 Zuul 结合 OAuth2.0 + JWT 的实际应用,点击下载完整的示例代码。

编写 zuul-server

zuul-server 中需要做的就是当请求接口时,判断是否登录,如果未登录,则跳转到 auth-server 的登录界面(这里使用的是 Spring Security OAuth 的默认登录界面,也可以重写相关代码定制页面),登录成功后 auth-server 颁发 jwt token,zuul-server 在访问下游服务时将 jwt token 放入 header 中即可。

zuul-server 的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>

zuul-server 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
server:
port: 8092

spring:
application:
name: zuul-server

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

zuul:
routes:
provider-service:
path: /provider/service/**
serviceId: provider-service

security:
oauth2:
client:
access-token-uri: http://127.0.0.1:8091/uaa/oauth/token #令牌端点
user-authorization-uri: http://127.0.0.1:8091/uaa/oauth/authorize #授权端点
client-id: zuul_server #OAuth2客户端ID
client-secret: secret #OAuth2客户端密钥
resource:
jwt:
key-value: springcloud123 #指定密钥,使用对称加密方式,默认算法为HS256

在 zuul-server 里重写 WebSecurityConfigurerAdapter 适配器的 configure(HttpSecurity http) 方法,声明需要鉴权的 URL 信息

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@Component
@EnableOAuth2Sso
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
protected void configure(HttpSecurity http) throws Exception {
http
.authorizeRequests()
.antMatchers("/login", "/provider/service/**")
.permitAll()
.anyRequest()
.authenticated()
.and()
.csrf()
.disable();
}
}

编写 auth-server

auth-server 是整个示例的 另一个核心,作为认证授权中心,用于颁发 jwt token 凭证。

auth-server 的 pom.xml 文件:

1
2
3
4
5
6
7
8
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

auth-server 的 application.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 8091
servlet:
context-path: /uaa

spring:
application:
name: auth-server

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

在 auth-server 里编写认证授权服务适配类 OauthConfigruatrion,主要用于指定客户端 ID、密钥,以及权限定义与作用域声明,指定 TokenStore 为 JWT,不同于以往将 TokenStore 指定为 Redis 或是其他持久化工具:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
@Configuration
@EnableAuthorizationServer
public class OauthConfigruatrion extends AuthorizationServerConfigurerAdapter {

@Autowired
private AuthenticationManager authenticationManager;

@Override
public void configure(ClientDetailsServiceConfigurer clients) throws Exception {
clients
.inMemory()
.withClient("zuul_server")
.secret("secret")
.scopes("WRIGTH", "read")
.autoApprove(true)
.authorities("WRIGTH_READ", "WRIGTH_WRITE")
.authorizedGrantTypes("implicit", "refresh_token", "password", "authorization_code");
}

@Override
public void configure(AuthorizationServerEndpointsConfigurer endpoints) throws Exception {
endpoints
.tokenStore(jwtTokenStore())
.tokenEnhancer(jwtTokenConverter())
.authenticationManager(authenticationManager);
}

@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtTokenConverter());
}

@Bean
protected JwtAccessTokenConverter jwtTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("springcloud123");
return converter;
}
}

在 auth-server 里编写安全配置类 WebSecurityConfiguration,主要声明用户 admin 具有读写权限,用户 guest 具有读权限,passwordEncoder() 用于声明用户名和密码的加密方式,这个功能在 Spring Security 5.0 之前是没有的。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
@Configuration
public class WebSecurityConfiguration extends WebSecurityConfigurerAdapter {

@Override
@Bean(name = BeanIds.AUTHENTICATION_MANAGER)
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}

@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth
.inMemoryAuthentication()
.withUser("guest").password("guest").authorities("WRIGTH_READ")
.and()
.withUser("admin").password("admin").authorities("WRIGTH_READ", "WRIGTH_WRITE");
}

@Bean
public static NoOpPasswordEncoder passwordEncoder() {
return (NoOpPasswordEncoder) NoOpPasswordEncoder.getInstance();
}
}

编写 provider-service

provider-service 作为 zuul-server 的下游服务,需要的功能很简单,能够被注册发现,以及能够按照规贝解析 jwt token 即可。

provider-service 的 pom.xml 文件

1
2
3
4
5
6
7
8
9
10
11
12
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-security</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-oauth2</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

provider-service 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
server:
port: 9090

spring:
application:
name: provider-service

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

在 provider-service 里编写配置类 ResourceServerConfiguration

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
@Configuration
@EnableResourceServer
public class ResourceServerConfiguration extends ResourceServerConfigurerAdapter {

@Override
public void configure(HttpSecurity http) throws Exception {
http
.csrf().disable()
.authorizeRequests()
.antMatchers("/**").authenticated()
.antMatchers(HttpMethod.GET, "/test")
.hasAuthority("WRIGTH_READ");
}

@Override
public void configure(ResourceServerSecurityConfigurer resources) throws Exception {
resources
.resourceId("WRIGTH")
.tokenStore(jwtTokenStore());
}

@Bean
public TokenStore jwtTokenStore() {
return new JwtTokenStore(jwtTokenConverter());
}

@Bean
protected JwtAccessTokenConverter jwtTokenConverter() {
JwtAccessTokenConverter converter = new JwtAccessTokenConverter();
converter.setSigningKey("springcloud123");
return converter;
}
}

在 provider-service 里编写测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
@RestController
public class TestController {

private static final Logger logger = LoggerFactory.getLogger(TestController.class);

@RequestMapping("/test")
public String test(HttpServletRequest request) {
logger.info("----------------header----------------");
Enumeration headerNames = request.getHeaderNames();
while (headerNames.hasMoreElements()) {
String key = (String) headerNames.nextElement();
logger.info(key + ": " + request.getHeader(key));
}
logger.info("----------------header----------------");
return "hello!";
}
}

测试效果

  1. 在测试之前,整个示例的流程图在这里
  2. 依次启动 eureka-server、provider-service、auth-server、zuul-server 应用
  3. 访问 http://127.0.0.1:8092/provider/service/test,由于未授权,该接口会返回需要授权才能访问的提示信息,查看截图
  4. 访问 http://127.0.0.1:8092,会自动跳转到 auth-server 的默认登录页面(http://127.0.0.1:8091/uaa/login),输入用户名 admin 与 密码 admin 进行登录,查看截图
  5. 再次访问 http://127.0.0.1:8092/provider/service/test,调用接口成功,控制台的日志信息如下:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
----------------header----------------
upgrade-insecure-requests: 1
user-agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/81.0.4044.129 Safari/537.36
accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9
sec-fetch-site: none
sec-fetch-mode: navigate
sec-fetch-user: ?1
sec-fetch-dest: document
accept-language: en,zh-CN;q=0.9,zh;q=0.8
authorization: bearer eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJleHAiOjE1ODg2OTE4NzAsInVzZXJfbmFtZSI6ImFkbWluIiwiYXV0aG9yaXRpZXMiOlsiV1JJ ...
x-forwarded-host: 127.0.0.1:8092
x-forwarded-proto: http
x-forwarded-prefix: /provider/service
x-forwarded-port: 8092
x-forwarded-for: 127.0.0.1
accept-encoding: gzip
content-length: 0
host: 192.168.1.130:9090
connection: Keep-Alive
----------------header----------------

Zuul 限流

构建一个自我修复型系统一直是各大企业进行架构设计的难点所在,在 Hystrix 中可以通过熔断器来实现,通过某个阈值来对异常流量进行降级处理。其实,除对异常流量进行降级处理之外,也可以做一些其他操作来保护系统免受 “雪崩之灾”,比如:流量 排队、限流、分流等。

限流算法

说到限流算法,不自觉就想到了 “漏桶” 与 “令牌桶” 算法。诚然,两种限流的祖师级算法确有其独到之处,其他实现比如滑动时间窗或者三色速率标记法等,其实质还是 “漏桶” 与 “令牌桶” 的变种,要么是将 “漏桶” 容积换成了单位时间,要么是按规则将请求标记颜色进行处理,底层还是 “令牌” 的思想。所以,掌握 “漏桶” 与 “令牌桶” 算法原理,对理解其他限流算法有一定帮助。

漏桶(Leaky Bucket)算法

漏桶的原型是一个底部有漏孔的桶,桶上方有一个入水口,水不断地流进桶内,桶下方的漏孔就会以一个相对恒定的速率漏水,在入大于岀的情况下,桶在一段时间之后就会被装满,这时候多余的水就会溢出;而在入小于出的情况下,漏桶则不起任何作用。后来将这个经典模型运用在网络流量整形上面,通过漏桶算法的约束,突发流量可以被整形为一个规整的流量(如图所示)。当请求或者具有一定体量的数据流涌来的时候,在漏桶的作用下,流量被整形,不能满足要求的部分被削减掉。所以,漏桶算法能够强制限定流量速率。注意,在企业应用中,这部分溢出的流量是可以被利用起来的,并非完全丢弃,可以把它们收集到一个队列里面,做流量排队,尽量做到合理利用所有资源。

令牌桶(Token Bucket)算法

令牌桶算法和漏桶算法有点不一样,桶里面存放令牌,而令牌又是以一个恒定的速率被加入桶内,可以积压,可以溢出。当数据流涌来时,量化请求用于获取令牌,如果取到令牌则放行,同时桶内丢弃掉这个令牌;如果不能取到令牌,请求则被丢弃(如图所示)。由于令牌桶内可以存在一定数量的令牌,那么就可能存在一定程度的流量突发,这也是决定漏桶算法与令牌桶算法适用于不同应用场景的主要原因。

限流实战

在 Zuul 中实现限流最简单的方式是使用自定义 Filter 加上相关限流算法,其中可能会考虑到 Zuul 的多节点部署,因为算法的原因,这时候需要一个 K/V 存储工具(推荐使用 Redis,充分利用 Redis 单线程的特性,可以有效避免多节点带来的一些问题)。当然如果 Zuul 是单节点应用,限流方式的选择就会广得多,完全可以将相关 prefix 放在内存之中,方便又快捷。这里介绍一个开箱即用的工具 spring-cloud-zuul-ratelimit,它是专门针对 Zuul 编写的限流库,提供了以下特性:

多种细粒度策略:

zuul-ratelimit-1

多种粒度临时变量存储方式:

zuul-ratelimit-2

父 Maven 工程的 pom.xml 文件,这里 Spring Cloud 的版本是 Hoxton.SR4,Spring Boot 的版本是 2.2.6.RELEASE点击下载完整的示例代码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.2.6.RELEASE</version>
</parent>

<!-- 利用传递依赖,公共部分 -->
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>

<!-- 管理依赖 -->
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>

<!--注意:这里需要添加以下配置,否则可能会有各种依赖问题 -->
<repositories>
<repository>
<id>spring-milestones</id>
<name>Spring Milestones</name>
<url>https://repo.spring.io/libs-milestone</url>
<snapshots>
<enabled>false</enabled>
</snapshots>
</repository>
</repositories>

zuul-server 里的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
<dependency>
<groupId>com.marcosbarbero.cloud</groupId>
<artifactId>spring-cloud-zuul-ratelimit</artifactId>
<version>2.4.0.RELEASE</version>
</dependency>

zuul-server 里的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
server:
port: 8092

spring:
application:
name: zuul-server
redis:
host: 172.175.0.3
port: 6379
password:

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

zuul:
routes:
provider-service:
path: /provider/service/**
serviceId: provider-service
ratelimit:
enabled: true
key-prefix: ratelimit
repository: REDIS
behind-proxy: true #表示代理之后
policy-list:
provider-service: #单独细化到服务粒度
- limit: 2 #在一个单位时间窗口(秒)的请求数量
quota: 1 #在一个单位时间窗口(秒)的请求时间限制
refresh-interval: 3 #刷新时间(秒)
type:
- url #指定url粒度

zuul-server 里的启动主类:

1
2
3
4
5
6
7
8
9
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulServerApplication {

public static void main(String[] args) {
SpringApplication.run(ZuulServerApplication.class, args);
}
}

测试效果:

  1. 依次启动 eureka-server、provider-service、zuul-server 应用
  2. 多次访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,在时间窗阈值内访问接口时,接口会返回正确信息;一旦超限,后台会抛出 429 异常,接口返回对应的错误信息,查看截图

Zuul 动态路由

动态路由概述

Zuul 提供了各种映射规则的配置方式,这极大地增加了在构建应用时的选择余地,这些方式称为 “静态路由(Static Routing)”。一般来说,在微服务构建前期就已经按照业务把各种映射关系制定好了,但是在后期迭代过程中,一个复杂的系统难免经历新服务的上线过程,这个时候不能轻易停掉线上某些映射链路;那么问题就来了,Zuul 是在启动的时候将配置文件中的映射规则写入内存,要新建映射规则,只能修改了配置文件之后再重新启动 Zuul 应用。那能不能有一种方法,既能按需修改映射规则,又能使服务免于重启之痛呢?答案是有的,目前有如下两种解决方案实现 “动态路由(Dynamic Routing)”,通常采用第一种方式,这是 Spring Cloud 生态推崇的方式,但是也有它的局限性,有兴趣的读者可以查阅相关资料。

  • 结合 Spring Cloud Config + Bus,动态刷新配置文件,这种方式的好处是不用 Zuul 维护映射规则,可以随时修改,随时生效;唯一不好的地方是需要单独集成一些使用并不频繁的组件,Config 没有可视化界面,维护起规则来也相对麻烦
  • 重写 Zuul 的配置读取方式,釆用事件刷新机制,从数据库读取路由映射规则,此种方式因为基于数据库,可轻松实现管理界面,灵活度较高

动态路由实现原理剖析

Zuul 动态路由实现的四个核心类:

  • DiscoveryClientRouteLocator

类中的 locateRoutes() 方法继承自 SimpleRouteLocator 类并重写了规则,该方法主要的功能就是将配置文件中的映射规则信息包装成 LinkedHashMap<String, ZuulRoute>,键是映射路径,值是配置文件的封装类,以往所见的配置映射读取进来就是使用 ZuulRoute 来封装。refiresh() 实现自 RefreshableRouteLocator 接口,添加刷新功能必须要实现此方法,doRefresh() 方法来自 SimpleRouteLocator 类。

  • SimpleRouteLocator

该类是 DiscoveryClientRouteLocator 的父类,此类基本实现了 RouteLocator 接口,对读取的配置文件信息做一些基本处理,提供了方法 doRefresh()locateRoutes() 供子类实现刷新策略与映射规则加载策略,两个方法都是使用 protected 修饰,是为了让子类不用维护此类一些成员变量就能够实现刷新或者读取路由的功能。

  • ZuulServerAutoConfiguration

在低版本的 Spring Cloud Zuul 中,这个类叫作 ZuulConfiguration,位于 org.springframework. cloud.netflix.zuul 包中,主要目的是注册各种过滤器、监听器以及其他功能。Zuul 在注册中心新增服务后刷新监听器也是在此注册的,底层是采用 Spring 的 ApplicationListener 来实现。由方法 onApplicationEvent(ApplicationEvent event) 可知,Zuul 会接收 3 种事件通知(ContextRefreshedEvent、RefreshScopeRefreshedEvent、RoutesRefreshedEvent)去刷新路由映射配置信息,此外心跳续约监视器 HeartbeatMonitor 也会触发这个动作。

  • ZuulHandlerMapping

此类是将本地配置的映射关系映射到远程的过程控制器,与事件刷新相关的代码。类里的 dirty 属性很重要,它是用来控制当前是否需要重新加载映射配置信息的标记,在 Zuul 每次进行路由操作的时候都会检査这个值,如果为 true,就会触发配置信息的重新加载,同时再将其回设为 false。由 setDirty(boolean dirty) 可知,启动刷新动作必须要实现 RefreshableRouteLocator 接口。

  • 原理总结

在构建动态路由的时候,只需要重写 SimpleRouteLocator 类的 locateRoutes() 方法,并且实现 RefreshableRouteLocator 接口的 refresh() 方法,再在内部调用 SimpleRouteLocator 类的 doRefresh() 方法,就可以构建起一个由 Zuul 内部事件触发的自定义动态路由加载器。如果不想使用内部事件触发配置更新操作,改为手动触发,可以重写 onApplicationEvent(ApplicationEvent event) 方法,事实上手动触发的控制性更好。

基于 DB 的动态路由实战

下面做一个实战例子,这里的 DB 暂且选用 MySQL,当然也可以选择其他持久化方式,目的是方便,易于管理,实际上选用 MongoDB 也是一种不错的选择,点击下载完整的示例代码。

存储映射规则的数据库表设计:

zuul-dynamic-route-db

zuul-server 的 pom.xml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>mysql</groupId>
<artifactId>mysql-connector-java</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-jdbc</artifactId>
</dependency>

zuul-server 的 application.yml 文件,如果需要防止服务侵入,这里可以将 ribbon.eureka.enabled 设置为 false

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
server:
port: 8092

spring:
application:
name: zuul-server
datasource:
url: jdbc:mysql://localhost:3306/zuul-test?useUnicode=true&characterEncoding=utf-8
driver-class-name: com.mysql.jdbc.Driver
username: root
password: 123456

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

ribbon:
eureka:
enabled: true

在 zuul-server 里编写 DAO 类,从数据库读取路由配置信息:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
@Component
public class PropertiesDao {

@Autowired
private JdbcTemplate jdbcTemplate;

private final static String SQL = "SELECT * FROM zuul_route WHERE enabled = TRUE";

public Map<String, ZuulProperties.ZuulRoute> getProperties() {
Map<String, ZuulProperties.ZuulRoute> routes = new LinkedHashMap<>();
List<ZuulRouteEntity> list = jdbcTemplate.query(SQL, new BeanPropertyRowMapper<>(ZuulRouteEntity.class));
list.forEach(entity -> {
if (StringUtils.isEmpty(entity.getPath())) {
return;
}
ZuulProperties.ZuulRoute zuulRoute = new ZuulProperties.ZuulRoute();
BeanUtils.copyProperties(entity, zuulRoute);
routes.put(zuulRoute.getPath(), zuulRoute);
});
return routes;
}
}

在 zuul-server 里编写自定义路由配置加载器类,该类是改造的核心类,locateRoutes() 方法从数据库加载配置信息,并且配合 Zuul 内部事件刷新机制,实际上每次心跳续约都会触发路由配置重新加载的操作,如果需要改为手动触发,可参考上面的动态路由实现原理剖析:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
public class DynamicZuulRouteLocator extends SimpleRouteLocator implements RefreshableRouteLocator {

@Autowired
private ZuulProperties properties;

@Autowired
private PropertiesDao propertiesDao;

public DynamicZuulRouteLocator(String servletPath, ZuulProperties properties) {
super(servletPath, properties);
this.properties = properties;
}

@Override
public void refresh() {
doRefresh();
}

@Override
protected Map<String, ZuulRoute> locateRoutes() {
LinkedHashMap<String, ZuulRoute> routesMap = new LinkedHashMap<>();
routesMap.putAll(super.locateRoutes());
routesMap.putAll(propertiesDao.getProperties());
LinkedHashMap<String, ZuulRoute> values = new LinkedHashMap<>();
routesMap.forEach((key, value) -> {
String path = key;
if (!path.startsWith("/")) {
path = "/" + path;
}
if (StringUtils.hasText(this.properties.getPrefix())) {
path = this.properties.getPrefix() + path;
if (!path.startsWith("/")) {
path = "/" + path;
}
}
values.put(path, value);
});
return values;
}
}

在 zuul-server 编写配置类,让上面的自定义路由配置加载器生效:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
@Configuration
public class DynamicZuulConfig {

@Autowired
private ZuulProperties zuulProperties;

@Autowired
private ServerProperties serverProperties;

@Bean
public DynamicZuulRouteLocator routeLocator() {
DynamicZuulRouteLocator routeLocator = new DynamicZuulRouteLocator(serverProperties.getServlet().getServletPrefix(), zuulProperties);
return routeLocator;
}
}

测试效果:

  1. 依次启动 eureka-server、provider-service、zuul-server 应用
  2. 访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,查看接口调用的结果
  3. 访问 http://127.0.0.1:8092/provider-service/provider/add?b=3,查看接口调用的结果
  4. 访问 http://127.0.0.1:8092/baidu,查看是否跳转到百度的首页

Zuul 灰度发布

灰度发布概述

灰度发布,是指在系统迭代新功能时的一种平滑过渡的上线发布方式。灰度发布是在原有系统的基础上,额外增加一个新版本,这个新版本包含需要待验证的新功能,随后用负载均衡器引入一小部分流量到这个新版本应用,如果整个过程没有出现任何差错,再平滑地把线上系统或服务一步步替换成新版本,至此完成了一次灰度发布。这种发布方式由于可以在用户无感知的情况下完成产品的升级,在许多公司都有较为成熟的解决方案。对于 Spring Cloud 微服务生态来说,粒度一般是一个服务,往往通过使用某些带有特定标记的流量来充当灰度发布过程中的 “小白鼠”,并且目前已经有比较好的开源项目来做这个事情。

灰度发布实战

灰度发布有很多种实现方式,这里要讲的是基于 Eureka 元数据(metadata)的一种方式,它的原理是通过获取 Eureka 实例信息,并鉴别元数据的含义,再分别进行路由规则下的负载均衡,点击下载完整的示例代码。

其中在 Eureka 里面,一共有两种元数据:

  • 标准元数据:这种元数据是服务的各种注册信息,比如 IP、端口、服务健康信息、续约信息等,存储于专门为服务开辟的注册表中,用于其他组件取用以实现整个微服务生态
  • 自定义元数据:自定义元数据是使用 eureka.instance.metadata-map.<key>=<value> 来配置的,其内部其实就是维护了一个 Map 来保存自定义元数据信息,可以配置在服务提供者端,随服务一并注册保存在 Eureka 的注册表中,对微服务生态的任何行为都没有影响,除非知道其特定的含义

首先编写 provider-service、provider-service-2、provider-service-3 应用,9090 与 9091 端口运行的是稳定的线上服务,将它们的 host-mark 设置成 running-host,需要上线的灰度服务的端口为 9092,host-markgray-host,最后要达成的效果是:由于服务名称都为 provider-service,但是在某一个值的作用下,部分请求被分发到 9090 与 9091 实例上,也就是 host-markrunning-host 的节点;另一部分则分发到 9092 实例,host-markgray-host 的节点。

provider-service 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 9090

spring:
application:
name: provider-service

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true
metadata-map:
host-mark: running-host

provider-service-2 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 9091

spring:
application:
name: provider-service

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true
metadata-map:
host-mark: running-host

provider-service-3 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
server:
port: 9092

spring:
application:
name: provider-service

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true
metadata-map:
host-mark: gray-host

在 zuul-server 中的 pom.xml 文件里,引入开源项目 ribbon-discovery-filter-spring-cloud-starter,该项目提供了一种基于 metadata 的负载均衡机制:

1
2
3
4
5
6
7
8
9
10
11
12
13
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>io.jmnarloch</groupId>
<artifactId>ribbon-discovery-filter-spring-cloud-starter</artifactId>
<version>2.1.0</version>
</dependency>

在 zuul-server 里创建自定义的过滤器,此过滤器的作用是将 header 里面的 gray-mark 作为指标,如果 gray-mark 等于 enable 的话,就将该请求路由到灰度节点 gray-host,如果不等于或者没有这个指标就路由到其他节点。RibbonFilterContextHolder 是该项目的一个核心类,它定义了基于 metadata 的一种负载均衡机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
public class GrayPublishFilter extends ZuulFilter {

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER - 1;
}

@Override
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return !ctx.containsKey(FORWARD_TO_KEY) && !ctx.containsKey(SERVICE_ID_KEY);
}

@Override
public Object run() throws ZuulException {
HttpServletRequest request = RequestContext.getCurrentContext().getRequest();
String mark = request.getHeader("gray-mark");
if (!StringUtils.isEmpty(mark) && "enable".equals(mark)) {
RibbonFilterContextHolder.getCurrentContext().add("host-mark", "gray-host");
} else {
RibbonFilterContextHolder.getCurrentContext().add("host-mark", "running-host");
}
return null;
}
}

在 zuul-server 里创建配置类:

1
2
3
4
5
6
7
8
@Configuration
public class CommonConfiguration {

@Bean
public GrayPublishFilter grayPublishFilter() {
return new GrayPublishFilter();
}
}

在 zuul-server 里创建启动主类:

1
2
3
4
5
6
7
8
9
@EnableZuulProxy
@EnableDiscoveryClient
@SpringBootApplication
public class ZuulServerApplication {

public static void main(String[] args) {
SpringApplication.run(ZuulServerApplication.class, args);
}
}

测试效果:

  1. 依次启动 eureka-server、provider-service、provider-service-2、provider-service-3、zuul-server 应用
  2. header 不加 gray-mark=enable,访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,请求只会路由到 9090 与 9091 端口的 provider-service 服务上(默认轮询)
  3. header 加上 gray-mark=enable,访问 http://127.0.0.1:8092/provider/service/provider/add?b=3,无论请求多少次,请求都会路由到 9092 端口的 provider-service 服务上

Zuul 文件上传

文件上传的场景,很多开发者都会遇到,Zuul 作为一个网关中间件,自然也会面临文件上传的考验。Zuul 的文件上传功能是从 Spring Boot 承袭过来的,所以也需要 Spring Boot 的相关配置,点击下载完整的示例代码。

文件上传实战

zuul-server 的 pom.xml 文件,为了上传测试方便,另外引入了 Swagger2:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger2</artifactId>
<version>2.9.2</version>
</dependency>
<dependency>
<groupId>io.springfox</groupId>
<artifactId>springfox-swagger-ui</artifactId>
<version>2.9.2</version>
</dependency>

zuul-server 的 application.yml 文件:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
server:
port: 8092

spring:
application:
name: zuul-server
servlet:
multipart:
enabled: true #使用http multipart上传处理
max-file-size: 100MB #设置单个文件的最大长度,默认1M,如不限制配置为-1
max-request-size: 100MB #设置最大的请求文件的大小,默认10M,如不限制配置为-1
file-size-threshold: 1MB #当上传文件达到1MB的时候进行磁盘写入
location: /tmp #上传的临时目录

eureka:
client:
service-url:
defaultZone: http://127.0.0.1:8090/eureka
instance:
prefer-ip-address: true

zuul:
routes:
provider-service:
path: /provider/service/**
serviceId: provider-service

##### 设置Ribbon的超时时间,如果要上传大文件,为避免超时,稍微设大一点
ribbon:
ConnectTimeout: 3000
ReadTimeout: 30000

##### Hystrix默认超时时间为1秒,如果要上传大文件,为避免超时,稍微设大一点
hystrix:
command:
default:
execution:
isolation:
thread:
timeoutInMilliseconds: 30000

在 zuul-server 里编写 Swagger2 的配置类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
@Configuration
@EnableSwagger2
public class Swagger2Config {

@Bean
public Docket createRestApi() {
return new Docket(DocumentationType.SWAGGER_2).apiInfo(apiInfo()).select()
.apis(RequestHandlerSelectors.basePackage("com.springcloud.study.controller"))
.paths(PathSelectors.any()).build();
}

private ApiInfo apiInfo() {
return new ApiInfoBuilder()
.title("Zuul文件上传")
.description("Zuul文件上传")
.version("1.0").build();
}
}

在 zuul-server 里编写文件上传的测试类:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
@RestController
@Api("Zuul文件上传")
public class UploadController {

public static final String PREFIX_PATH = "/tmp/upload/";

private static final Logger logger = LoggerFactory.getLogger(UploadController.class);

@PostMapping("/upload")
@ApiOperation("文件上传接口")
public String upload(@RequestParam(value = "file", required = true) MultipartFile file) throws Exception {
logger.info("==> file size: " + file.getSize());
byte[] bytes = file.getBytes();
String filePath = PREFIX_PATH + UUID.randomUUID().toString();
File fileToSave = new File(filePath);
FileCopyUtils.copy(bytes, fileToSave);
return filePath;
}
}

测试效果:

  1. 依次启动 eureka-server、zuul-server 应用
  2. 访问 http://127.0.0.1:8092/swagger-ui.html,选择本地文件进行上传即可

文件上传乱码

在 Spring Cloud Finchley 之前的版本,上传中文名的文件会出现文件名乱码的情况,上传英文名的文件则不会,这是由于 Zuul 内部默认使用了 Spring MVC 来上传文件,这种方式对中文字符的处理有点不友好。如果要解决这个问题,可以改为使用 Zuul Servlet 来上传文件,当需
要上传大文件的时候尤需如此,因为它自带有一个缓冲区。此时只需要在请求路径前加上 /zuul 就可以使用 Zuul Servlet 了,例如:http://127.0.0.1:8092/zuul/upload

Zuul 实用技巧

饥饿加载

Zuul 内部默认使用 Ribbon 来调用远程服务,所以由于 Ribbon 的原因,在部署好所有应用组件之后,第一次经过 Zuul 的调用往往会去注册中心读取服务注册表,初始化 Ribbon 负载均衡信息,这是一种懒加载策略,但是这个过程是极其耗时的,尤其是服务过多的时候。为了避免这个问题,可以在启动 Zuul 的时候就饥饿加载应用程序上下文信息;开启饥饿加载只需添加以下配置即可:

1
2
3
4
zuul:
ribbon:
eager-load:
enabled: true

请求体修改

在客户端对 Zuul 发送 POST 请求之后,由于某些原因,在请求到下游服务之前,需要对请求体进行修改,常见的是对 form・data 参数的增减,对 application/json 的修改,对请求体做 Uppercase 等。在 Zuul 中可以很好地解决这种需求,只需要新增一个 PRE 类型的 Filter 对请求体进行修改。由于在 Zuul 中有 Filter (FormBodyWrapperFilter) 会对请求体做封装,因此在编写此 Filter 的时候应当把它的执行次序放在该 Filter 之后,为了稳妥起见,把 ModifyRequestEntityFilter 的次序设置为 PRE 类型 Filter 的最后一级。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
public class ModifyRequestEntityFilter extends ZuulFilter {

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER + 1;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
HttpServletRequest request = ctx.getRequest();
request.getParameterMap();
Map<String, List<String>> requestQueryParams = ctx.getRequestQueryParams();
if (requestQueryParams == null){
requestQueryParams = new HashMap<>();
}
//这里添加新增参数的value,注意,只取list的0位
ArrayList<String> arrayList = new ArrayList<>();
arrayList.add("wwww");
requestQueryParams.put("test", arrayList);
ctx.setRequestQueryParams(requestQueryParams);
return null;
}
}

重试机制

在 Spring Cloud 中有多种发送 HTTP 请求的方式可以与 Zuul 结合,RestTemplate、Ribbon 或者 Feign,但是无论选择哪种,都可能出现请求失败的情况,这在复杂的互联网环境是不可避免的。Zuul 作为一个网关中间件,在出现偶然请求失败时进行适当的重试是十分必要的,重试可以有效地避免一些突发原因引起的请求丢失。Zuul 中的重试机制是配合 Spring Retry 与 Ribbon 来使用的。

pom.xml 引入 Spring Retry 的依赖包:

1
2
3
4
<dependency>
<groupld>org.springframework.retry</groupld>
<artifactld>spring-retry</artifactld>
</dependency>

application.yml 里添加重试相关的配置内容:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#Zuul开启重试,D版之后默认为false,需要手动开启
zuul:
retryable: true

#Ribbon的重试机制配置
ribbon:
ConnectTimeout: 3000
ReadTimeout: 60000
MaxAutoRetries: 1 #对第一次请求的服务的重试次数
MaxAutoRetriesNextServer: 1 #要重试的下一个服务的最大数量(不包括第一个服务)
OkToRetryOnAllOperations: true

#SpringCloud内部默认已开启负载均衡重试,这里列出来说明这个参数比较重要
spring:
cloud:
loadbalancer:
retry:
enabled: true

配置当中的 ConnectTimeoutReadTimeou 是当 HTTP 客户端使用 Apache HttpClient 的时候生效的,这个超时时间最终会被设置到 Apache HttpClient 中去。在设置的时候要结合 Hystrix 的超时时间来综合考虑,针对不同的应用场景,设置太小会导致很多请求失败,设置太大会导致熔断功能控制性变差,所以需要经过压力测试得来。Zuul 同时也支持对单个映射规则进行重试 zuul.routes.<route>.retryable=true,需要注意的是,在某些对幂等要求比较高的使用场景下,要慎用重试机制,因为如果没有相关处理的话,出现幂等问题是十分有可能的。

Header 传递

在 Zuul 中对请求做了一些处理,需要把处理结果发给下游服务,但是又不能影响请求体的原始特性,这个问题该怎么解决好呢?Zuul 提供了一个重要的类 Requestcontext,里面的 addZuulRequestHeader() 方法正好可以用来解决此问题,官方称之为 Header 的传递。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
public class HeaderDeliverFilter extends ZuulFilter {

@Override
public String filterType() {
return PRE_TYPE;
}

@Override
public int filterOrder() {
return PRE_DECORATION_FILTER_ORDER + 1;
}

@Override
public boolean shouldFilter() {
return true;
}

@Override
public Object run() throws ZuulException {
RequestContext context = RequestContext.getCurrentContext();
context.addZuulRequestHeader("result", "to next service");
return null;
}
}

使用 OkHttp 替换 Apache HttpClient

在 Spring Cloud 中各个组件之间使用的通信协议都是 HTTP,而 HTTP 客户端使用的是 Apache HttpClient,但是由于其难以扩展等诸多原因,已被许多技术栈弃用。 Square 公司开发的 okhttp 正在逐渐被接受,在 Zuul 中使用 okhttp 替换 Apache HttpClient,首先需要在 pom.xml 中增加 okhttp 的依赖包:

1
2
3
4
<dependency>
<groupld>com.squareup.okhttp3</groupld>
<artifactId>okhttp</artifactId>
</dependency>

然后在 application.yml 文件中禁用 HttpClient 并开启 okhttp 即可:

1
2
3
4
5
ribbon:
httpclient:
enabled: false
okhttp:
enabled: true

下篇 - Zuul 入门教程(高级篇)