Spring Cloud-Zuul API网关

Spring Cloud-Zuul API网关

第一节 简介

1.1 什么是API网关

  API网关是对外服务的一个入口,其隐藏了内部架构的实现,是微服务架构中必不可少的一个组件。API网关可以为我们管理大量的API接口,还可以对接客户适配协议进行安全认证转发路由限制流量监控日志防止爬虫进行灰度发布等。

1.2 API网关的作用

  随着业务发展,服务越来越多,前端用户如何调用微服务就成了一个难题。比如用户评估一个小区,评估完成后需要展示小区详情、房价走势、成交数据、挂牌数据等,这些信息都在不同的服务中,前端系统想要实现这样一个功能就需要和众多的服务进行交互,调用它们提供的接口,这样性能肯定是低的。而且前端的逻辑变得更复杂了,它需要知道所有提供信息的微服务。这个时候API网关的作用就体现出来了,通过API聚合内部服务,提供统一对外的API接口给前端系统,屏蔽内部实现细节。

1.3 Zuul

  Zuul是Netflix OSS中的一员,是一个基于JVM路由和服务端的负载均衡器。提供路由监控弹性安全等方面的服务框架。Zuul能够与Eureka、Ribbon、Hystrix等组件配合使用。

  Zuul的核心是过滤器,通过这些过滤器我们可以扩展出很多功能,如下所示。

  • 动态路由:动态地将客户端的请求路由到后端不同的服务,做一些逻辑处理,比如聚合多个服务的数据返回。
  • 请求监控:可以对整个系统的请求进行监控,记录详细的请求响应日志,可以实时统计出当前系统的访问量以及监控状态。
  • 认证鉴权:对每一个访问的请求做认证,拒绝非法请求,保护好后端的服务。
  • 压力测试:压力测试是一项很重要的工作,像一些电商公司需要模拟更多真实的用户并发量来保证重大活动时系统的稳定。通过Zuul可以动态地将请求转发到后端服务的集群中,还可以识别测试流量和真实流量,从而做一些特殊处理。
  • 灰度发布:灰度发布可以保证整体系统的稳定,在初始灰度的时候就可以发现、调整问题,以保证其影响度。

第二节 使用Zuul构建微服务网关

2.1 简单使用

  创建一个Maven项目zuul-demo,在pom.xml中增加Spring Cloud项目的依赖,然后加入Zuul的依赖。

1
2
3
4
5
<!-- zuul -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>

  配置属性文件,增加如下配置。

1
2
3
4
5
spring.application.name=zuul-demo
server.port=2103

zuul.routes.customname.path=/linyishui/**
zuul.routes.customname.url=http://linyishui.top/

  通过zuul.routes配置路由转发,linyishui是自定义的名称,当访问linyishui/**开始的地址时,就会跳转到http://linyishui.top/

  启动类通过注解@EnableZuulProxy开启路由代理功能。

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

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

}

  启动服务,访问http://localhost:2103/linyishui

  最终会跳转至http://linyishui.top/

2.2 集成Eureka

  通常的使用中是用Zuul来代理请求转发到内部的服务上去,统一为外部提供服务。内部服务的数量会很多,而且可以随时扩展,我们不可能每增加一个服务就改一次路由的配置,所以也得通过结合Eureka来实现动态的路由转发功能。

  添加Eureka的依赖。

1
2
3
4
5
<!-- Eureka -->
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

  启动类不用再添加@EnableDiscoveryClient,因为@EnableZuulProxy已经包含了前者。只需要增加Eureka地址配置即可。

1
eureka.client.service-url.defaultZone=http://linyishui:123456@localhost:8761/eureka,http://linyishui:123456@localhost:8762/eureka

  重启服务,我们可以通过默认的转发规则来访问Eureka中的服务,比如访问hystrix-demo的callHello接口,如下图所示8085端口对应服务。

  直接访问接口,如下图所示。

  通过转发访问接口,如下图所示。

  访问的规则为:API网关地址 + 访问的服务名称 + 接口URI


第三节 Zuul路由配置

  当Zuul集成Eureka之后,其实就可以为Eureka中所有的服务进行路由操作,所以在给服务指定名称时可以尽量的简短一些,这样适合直接通过默认访问规则来访问,而不用给每个服务都制定一个路由规则,这样就算新增了服务,API网关也不需要进行修改和重启(反之则需要)。

默认访问规则如下所示:

  • API网关地址:http://localhost:2103
  • 用户服务名称:user-service
  • 用户登录接口:/user/login

所以应该访问:http://localhost:2103/user-service/user/login

3.1 指定具体服务路由

  可以为每一个服务都配置一个路由转发规则:

1
2
# 为单个服务指定路由转发规则
zuul.routes.eureka-client-hystrix-demo.path=/hystrix-demo/**

  上述将服务eureka-client-hystrix-demo路由地址配置为hystrix-demo,两个星号表示适配任意层级的URL,一个星号则只适配一层。

  重启服务,用新的转发地址访问,如下所示,当然默认的访问地址依旧能用。

3.2 路由前缀

  我们可能会想在API前面配置一个统一的前缀,比如xxx.com/user/login这样的接口,变为xxx.com/rest/user/login,也就是在前面加一个rest,通过增加以下配置实现。

1
2
# 增加统一前缀
zuul.prefix=/rest

  重启服务如下所示,会导致旧的访问路径失效。

3.3 本地跳转

  Zuul的API路由还提供了本地跳转功能,通过forward就可以实现。

1
2
3
# 添加本地跳转
zuul.routes.zuul-demo.path=/api/**
zuul.routes.zuul-demo.url=forward:/local

  上述配置会使访问api的请求路由到本地的local上去,所以我们在项目中添加对应接口。

1
2
3
4
5
6
7
@RestController
public class LocalController {
@GetMapping("/local/{id}")
public String local(@PathVariable String id){
return id;
}
}

  重启服务并访问,结果如下。


第四节 Zuul过滤器讲解

  Zuul通过过滤器来实现如限流和认证等功能。

4.1 过滤器类型

  Zuul的过滤器和我们常用的javax.servlet.Filter不一样,javax.servlet.Filter只有一种类型,可以通过配置urlPatterns来拦截对应的请求。而Zuul过滤器总共有4种类型,且每种类型都有对应的使用场景。

  • pre:可以在请求被路由之前调用。适用于身份认证的场景,认证通过后再继续执行下面的流程。
  • route:在路由请求时被调用。适用于灰度发布场景,在将要路由的时候可以做一些自定义的逻辑。
  • post:在route和error过滤器之后被调用。这种过滤器将请求路由到达具体的服务之后执行。适用于需要添加响应头,记录响应日志等应用场景。
  • error:处理请求时发生错误时被调用。在执行过程中发送错误时会进入error过滤器,可以用来统一记录错误信息。

4.2 请求生命周期

  过滤器执行生命周期如下图所示,图片来源自Zuul GitHub Wiki

  请求首先发送到pre过滤器,再到routing过滤器,最后到post过滤器,任意过滤器出现异常都会进入error过滤器。

  通过com.netflix.zuul.http.ZuulServlet源码也可以看到完整的执行顺序,ZuulServlet类似于Spring MVC的DispatcherServlet,所有的Request都要经过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
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
public class ZuulServlet extends HttpServlet {
private static final long serialVersionUID = -3374242278843351500L;
private ZuulRunner zuulRunner;

public ZuulServlet() {
}

public void init(ServletConfig config) throws ServletException {
super.init(config);
String bufferReqsStr = config.getInitParameter("buffer-requests");
boolean bufferReqs = bufferReqsStr != null && bufferReqsStr.equals("true");
this.zuulRunner = new ZuulRunner(bufferReqs);
}

public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
try {
this.init((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse);
RequestContext context = RequestContext.getCurrentContext();
context.setZuulEngineRan();

try {
//最先经过pre过滤器
this.preRoute();
} catch (ZuulException var13) {
this.error(var13);
this.postRoute();
return;
}

try {
//然后是route过滤器
this.route();
} catch (ZuulException var12) {
this.error(var12);
this.postRoute();
return;
}

try {
//最后是post过滤器
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();
}
}

void postRoute() throws ZuulException {
this.zuulRunner.postRoute();
}

void route() throws ZuulException {
this.zuulRunner.route();
}

void preRoute() throws ZuulException {
this.zuulRunner.preRoute();
}

void init(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
this.zuulRunner.init(servletRequest, servletResponse);
}

void error(ZuulException e) {
RequestContext.getCurrentContext().setThrowable(e);
this.zuulRunner.error();
}
}

4.3 使用过滤器

  我们创建一个pre过滤器,来实现IP黑名单的过滤操作。

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 IpFilter extends ZuulFilter {
// IP黑名单列表
private List<String> blackIpList = Arrays.asList("127.0.0.1");

public IpFilter() {
super();
}

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

@Override
public String filterType() {
return "pre";
}

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

@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
String ip = getIpAddr(ctx.getRequest());
// 黑名单禁用
if(StringUtils.isNotBlank(ip) && blackIpList.contains(ip)){
ctx.setSendZuulResponse(false);
ResponseData data = ResponseData.fail(" 非法请求", ResponseCode.NO_AUTH_CODE.getCode());
ctx.setResponseBody(JsonUtils.toJson(data));
ctx.getResponse().setContentType("application/json; charset=utf-8");
return null;
}
return null;
}
}

作者未提供上述部分工具类来源,所以根据功能找了一些替代工具或自己实现

自定义过滤器需要继承抽象类ZuulFilter,并实现以下方法:

  • shouldFilter:是否执行该过滤器,true表示执行,false则不执行。也可以通过配置中心来实现动态的开启和关闭过滤器。
  • filterType:过滤器类型,可选值包括:pre、route、post、error。
  • filterOrder:过滤器的执行顺序,数值越小,优先级越高。
  • run:执行自己的业务逻辑,本段代码中是通过判断请求的IP是否在黑名单中,决定是否进行拦截。通过设置ctx.setSendZuulResponse(false)告诉Zuul不需要将当前请求转发到后端服务,通过setResponseBody返回数据到客户端。

  使用过滤器还需要最后一步,配置类中声明过滤器。

1
2
3
4
5
6
7
@Configuration
public class FilterConfig {
@Bean
public IpFilter ipFilter() {
return new IpFilter();
}
}

4.4 禁用过滤器

禁用过滤器有两种方式:

  • 利用shouldFilter方法return false使过滤器不再执行。
  • 通过配置方式禁用过滤器,格式为”zuul.<过滤器类名>.<过滤器类型>.disable=true”。
1
zuul.IpFilter.pre.disable=true

4.5 过滤器中传递数据

  我们可能会有这样的需求,前一个执行的过滤器需要传递数据给后面的过滤器。实现这种传值的方式首先可以想到ThreadLocal,而Zuul也有其解决方案,即通过RequestContext的set方法进行传递,当然RequestContext也是通过ThreadLocal来实现的。

  前一个过滤器如下。

1
2
3
// 过滤器间传递数据
RequestContext ctx = RequestContext.getCurrentContext();
ctx.set("msg","hello");

  后一个过滤器如下。

1
2
RequestContext ctx = RequestContext.getCurrentContext();
ctx.get("msg");

  源码如下,RequestContext继承了ConcurrentHashMap,是一个线程安全的哈希映射集合,通过threadLocal进行线程内访问。

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 RequestContext extends ConcurrentHashMap<String, Object> {
private static final Logger LOG = LoggerFactory.getLogger(RequestContext.class);
protected static Class<? extends RequestContext> contextClass = RequestContext.class;
private static RequestContext testContext = null;
protected static final ThreadLocal<? extends RequestContext> threadLocal = new ThreadLocal<RequestContext>() {
protected RequestContext initialValue() {
try {
return (RequestContext)RequestContext.contextClass.newInstance();
} catch (Throwable var2) {
throw new RuntimeException(var2);
}
}
};

......


public static RequestContext getCurrentContext() {
if (testContext != null) {
return testContext;
} else {
RequestContext context = (RequestContext)threadLocal.get();
return context;
}
}

public void set(String key) {
this.put(key, Boolean.TRUE);
}

public void set(String key, Object value) {
if (value != null) {
this.put(key, value);
} else {
this.remove(key);
}
}

......
}

4.6 过滤器拦截请求

  在过滤器中对请求进行拦截并返回结果。

1
2
3
4
5
6
7
8
RequestContext ctx = RequestContext.getCurrentContext();
// 告知Zuul不需要将当前请求转发给后端服务,主要是通过shouldFilter来拦截
ctx.setSendZuulResponse(false);
// 用来拦截本地转发请求,如果我们配置了forward:/local的路由,setSendZuulResponse(false)是不能阻止forward的。
ctx.set("sendForwardFilter.ran", true);
//
ctx.setResponseBody(" 返回消息 ");
return null;

  RibbonRoutingFilter中shouldFilter()方法会同时判断setSendZuulResponse对应的布尔值。

1
2
3
4
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return ctx.getRouteHost() == null && ctx.get("serviceId") != null && ctx.sendZuulResponse();
}

  SendForwardFilter中shouldFilter()方法会同时判断”sendForwardFilter.ran”对应的布尔值。

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 SendForwardFilter extends ZuulFilter {
protected static final String SEND_FORWARD_FILTER_RAN = "sendForwardFilter.ran";

public SendForwardFilter() {
}

public String filterType() {
return "route";
}

public int filterOrder() {
return 500;
}

public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
return ctx.containsKey("forward.to") && !ctx.getBoolean("sendForwardFilter.ran", false);
}

public Object run() {
try {
RequestContext ctx = RequestContext.getCurrentContext();
String path = (String)ctx.get("forward.to");
RequestDispatcher dispatcher = ctx.getRequest().getRequestDispatcher(path);
if (dispatcher != null) {
ctx.set("sendForwardFilter.ran", true);
if (!ctx.getResponse().isCommitted()) {
dispatcher.forward(ctx.getRequest(), ctx.getResponse());
ctx.getResponse().flushBuffer();
}
}
} catch (Exception var4) {
ReflectionUtils.rethrowRuntimeException(var4);
}

return null;
}
}

  以上确实可以实现拦截请求并返回结果给客户端,但当项目中出现多个过滤器时,假设需要过滤的过滤器是第一个执行,发现非法请求后进行拦截,如果是javax.servlet.Filter进行拦截在chain.doFilter之前返回就可以使过滤器不继续执行,但对于Zuul的过滤器,即使增加了上述配置,后续等待执行的过滤器依然会继续执行。

  通过以下ZuulServlet源码可以得知,service方法执行对应的过滤器,分别通过zuulRunner调用执行过滤器。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
public class ZuulServlet extends HttpServlet {
private static final long serialVersionUID = -3374242278843351500L;
private ZuulRunner zuulRunner;

public ZuulServlet() {
}

public void init(ServletConfig config) throws ServletException {
super.init(config);
String bufferReqsStr = config.getInitParameter("buffer-requests");
boolean bufferReqs = bufferReqsStr != null && bufferReqsStr.equals("true");
this.zuulRunner = new ZuulRunner(bufferReqs);
}

// service方法执行对应的过滤器
public void service(ServletRequest servletRequest, ServletResponse servletResponse) throws ServletException, IOException {
try {
this.init((HttpServletRequest)servletRequest, (HttpServletResponse)servletResponse);
RequestContext context = RequestContext.getCurrentContext();
context.setZuulEngineRan();

try {
//最先经过pre过滤器
this.preRoute();
} catch (ZuulException var13) {
this.error(var13);
this.postRoute();
return;
}

try {
//然后是route过滤器
this.route();
} catch (ZuulException var12) {
this.error(var12);
this.postRoute();
return;
}

try {
//最后是post过滤器
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();
}
}

// 分别通过zuulRunner调用执行过滤器
void postRoute() throws ZuulException {
this.zuulRunner.postRoute();
}

void route() throws ZuulException {
this.zuulRunner.route();
}

void preRoute() throws ZuulException {
this.zuulRunner.preRoute();
}

void init(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
this.zuulRunner.init(servletRequest, servletResponse);
}

void error(ZuulException e) {
RequestContext.getCurrentContext().setThrowable(e);
this.zuulRunner.error();
}
}

  ZuulRunner中实例化FilterProcessor对象,并调用对应过滤器方法。

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 ZuulRunner {
private boolean bufferRequests;

public ZuulRunner() {
this.bufferRequests = true;
}

public ZuulRunner(boolean bufferRequests) {
this.bufferRequests = bufferRequests;
}

public void init(HttpServletRequest servletRequest, HttpServletResponse servletResponse) {
RequestContext ctx = RequestContext.getCurrentContext();
if (this.bufferRequests) {
ctx.setRequest(new HttpServletRequestWrapper(servletRequest));
} else {
ctx.setRequest(servletRequest);
}

ctx.setResponse(new HttpServletResponseWrapper(servletResponse));
}

public void postRoute() throws ZuulException {
//实例化FilterProcessor对象,并调用对应过滤器方法
FilterProcessor.getInstance().postRoute();
}

public void route() throws ZuulException {
FilterProcessor.getInstance().route();
}

public void preRoute() throws ZuulException {
FilterProcessor.getInstance().preRoute();
}

public void error() {
FilterProcessor.getInstance().error();
}
}

  FilterProcessor通过统一的runFilters函数,根据过滤器类型参数获取所有此类型的过滤器,并调用ZuulFilter.runFilter()执行。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
public class FilterProcessor {

......

public void preRoute() throws ZuulException {
try {
this.runFilters("pre");
} catch (ZuulException var2) {
throw var2;
} catch (Throwable var3) {
throw new ZuulException(var3, 500, "UNCAUGHT_EXCEPTION_IN_PRE_FILTER_" + var3.getClass().getName());
}
}

public Object runFilters(String sType) throws Throwable {
if (RequestContext.getCurrentContext().debugRouting()) {
Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
}

boolean bResult = false;
// 根据过滤器类型参数获取所有此类型的过滤器
List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
if (list != null) {
// 遍历并
for(int i = 0; i < list.size(); ++i) {
ZuulFilter zuulFilter = (ZuulFilter)list.get(i);
Object result = this.processZuulFilter(zuulFilter);
if (result != null && result instanceof Boolean) {
bResult |= (Boolean)result;
}
}
}

return bResult;
}

// 执行ZuulFilter
public Object processZuulFilter(ZuulFilter filter) throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
boolean bDebug = ctx.debugRouting();
String metricPrefix = "zuul.filter-";
long execTime = 0L;
String filterName = "";

try {
long ltime = System.currentTimeMillis();
filterName = filter.getClass().getSimpleName();
RequestContext copy = null;
Object o = null;
Throwable t = null;
if (bDebug) {
Debug.addRoutingDebug("Filter " + filter.filterType() + " " + filter.filterOrder() + " " + filterName);
copy = ctx.copy();
}

// runFilter()执行过滤器
ZuulFilterResult result = filter.runFilter();
ExecutionStatus s = result.getStatus();
execTime = System.currentTimeMillis() - ltime;
switch(s) {
case FAILED:
t = result.getException();
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), execTime);
break;
case SUCCESS:
o = result.getResult();
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.SUCCESS.name(), execTime);
if (bDebug) {
Debug.addRoutingDebug("Filter {" + filterName + " TYPE:" + filter.filterType() + " ORDER:" + filter.filterOrder() + "} Execution time = " + execTime + "ms");
Debug.compareContextState(filterName, copy);
}
}

if (t != null) {
throw t;
} else {
this.usageNotifier.notify(filter, s);
return o;
}
} catch (Throwable var15) {
if (bDebug) {
Debug.addRoutingDebug("Running Filter failed " + filterName + " type:" + filter.filterType() + " order:" + filter.filterOrder() + " " + var15.getMessage());
}

this.usageNotifier.notify(filter, ExecutionStatus.FAILED);
if (var15 instanceof ZuulException) {
throw (ZuulException)var15;
} else {
ZuulException ex = new ZuulException(var15, "Filter threw Exception", 500, filter.filterType() + ":" + filterName);
ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), execTime);
throw ex;
}
}
}

  所以虽然第一个过滤器阻止了请求,但后续过滤器依然会被循环调用执行。解决方案就是由前一个过滤器告知后一个过滤器是否要执行,在拦截器run()函数中增加传递数据isSuccess。

1
ctx.set("isSuccess", false);

  后续过滤器通过此布尔值来判断是否需要继续执行。

1
2
3
4
5
public boolean shouldFilter() {
RequestContext ctx = RequestContext.getCurrentContext();
Object success = ctx.get("isSuccess");
return success == null ? true : Boolean.parseBoolean(success.toString());
}

4.7 过滤器中异常处理

  过滤器中的异常主要发生在run方法中,可以用try-catch来捕捉处理。Zuul中也为我们提供了一个异常处理的过滤器,当过滤器在执行过程中发生异常,若没有被捕获到就会进入error过滤器中。

  我们可以自定义一个error过滤器来记录异常信息,不要忘记在配置类中声明ErrorFilter的Bean。

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
public class ErrorFilter extends ZuulFilter {
private Logger logger = LoggerFactory.getLogger(ErrorFilter.class);

@Override
public String filterType() {
return "error";
}

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

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

@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
Throwable throwable = ctx.getThrowable();
logger.error("Filter Error : {}", throwable.getCause().getMessage());
return null;
}
}

  然后可以在其他过滤器中模拟一下发生异常,并启动项目观察效果。

1
2
3
4
5
6
7
@Override
public Object run() throws ZuulException {
RequestContext ctx = RequestContext.getCurrentContext();
// 异常代码
System.out.println(2/0);
return null;
}

  显式如下500错误白页。

  控制台也输出了对应日志。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
2019-11-14 16:20:58.058  WARN 7744 --- [nio-2103-exec-2] o.s.c.n.z.filters.post.SendErrorFilter   : Error during filtering

com.netflix.zuul.exception.ZuulException: Filter threw Exception
at com.netflix.zuul.FilterProcessor.processZuulFilter(FilterProcessor.java:227) ~[zuul-core-1.3.1.jar:1.3.1]
at com.netflix.zuul.FilterProcessor.runFilters(FilterProcessor.java:157) ~[zuul-core-1.3.1.jar:1.3.1]
at com.netflix.zuul.FilterProcessor.preRoute(FilterProcessor.java:133) ~[zuul-core-1.3.1.jar:1.3.1]
......

Caused by: java.lang.ArithmeticException: / by zero
at com.lai.zuuldemo.IpFilter.run(IpFilter.java:61) ~[classes/:na]
at com.netflix.zuul.ZuulFilter.runFilter(ZuulFilter.java:117) ~[zuul-core-1.3.1.jar:1.3.1]
at com.netflix.zuul.FilterProcessor.processZuulFilter(FilterProcessor.java:193) ~[zuul-core-1.3.1.jar:1.3.1]
... 62 common frames omitted

2019-11-14 16:20:58.067 ERROR 7744 --- [nio-2103-exec-2] com.lai.zuuldemo.ErrorFilter : Filter Error : / by zero

  我们的后端都是REST风格的API,返回的数据理应是固定的Json格式,可以通过ErrorController来解决此问题。

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
@RestController
public class ErrorHandleController implements ErrorController {
@Autowired
private ErrorAttributes errorAttributes;

@Override
public String getErrorPath() {
return "error";
}

@RequestMapping("/error")
public ResponseData error(HttpServletRequest request) {
Map<String, Object> errorAttributes = getErrorAttributes(request);
String message = (String) errorAttributes.get("message");
String trace = (String) errorAttributes.get("trace");
if(StringUtils.isNotBlank(trace)){
message += String.format(" and trace %s", trace);
}
return ResponseData.fail(message, ResponseData.Code.SERVER_ERROR_CODE);
}

private Map<String, Object> getErrorAttributes(HttpServletRequest request) {
return errorAttributes.getErrorAttributes(new ServletWebRequest(request), true);
}
}

  ResponseData是返回消息格式,作者未提供代码,所以这边是博主自定义的。

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
public class ResponseData {
private int code;
private String message;
private Object data;

public ResponseData(int code, String message, Object data) {
this.code = code;
this.message = message;
this.data = data;
}

public static ResponseData fail(String message,Code code){
return new ResponseData(code.code,message,null);
}

public enum Code {
SERVER_ERROR_CODE(500),
NO_AUTH_CODE(203);

private int code;

Code(int code) {
this.code = code;
}

public int getCode() {
return code;
}
}

//省略了空构造器和Getter/Setter
}

  重启项目,并访问。


第五节 Zuul容错和回退

  Zuul主要功能是转发,在转发过程中我们无法保证被转发的服务是可用的,所以需要容错机制和回退机制。

5.1 容错机制

  容错,即当某个服务不可用时能够切换到其他可用的服务上去,也就是重试机制

  Zuul中开启重试机制需要spring-retry,首先在项目中引入依赖。

1
2
3
4
5
<!-- Retry -->
<dependency>
<groupId>org.springframework.retry</groupId>
<artifactId>spring-retry</artifactId>
</dependency>

  配置文件中增加以下配置开启重试机制。

1
2
# 开启重试,默认为false
zuul.retryable=true

  也可以指定路由进行重试。

1
2
3
4
# 开启重试
zuul.retryable=false
# 通过routes端点指定路由进行重试
zuul.routes.eureka-client-user-service.retryable=true

  zuul请求是通过Ribbon负载均衡客户端去调用其他服务的,所以我们的重试策略也是在具体的ribbon配置中指定。

1
2
3
4
5
6
7
8
9
10
11
12
# 设置请求连接的超时时间
ribbon.connectTimeout=500
# 设置请求处理的超时时间
ribbon.readTimeout=5000
# 设置当前实例的重试次数
ribbon.maxAutoRetries=1
# 设置切换实例的最大重试次数
ribbon.maxAutoRetriesNextServer=3
# 设置多所有操作请求都进行重试
ribbon.okToRetryOnAllOperations=true
# 设置对指定的Http响应码进行重试
ribbon.retryableStatusCodes=500,404,502

  启动两个user-service服务,默认Ribbon的转发规则是轮询,然后停掉其中一个服务。如果没加重试机制,请求接口时必然会有一次被转发到停掉的服务上,返回异常信息。而加了重试机制后,可以尝试循环请求接口,不会返回异常信息,Ribbon会根据重试配置进行重试,把失败的请求转发到可用的服务上。

  服务中心中可以看到开启了两个user-service服务。

  通过Zuul路由到注册服务接口,调用/user/hello接口,访问7次。

  8081端口对应user-service打印如下内容。

  8083端口对应user-service打印如下内容。

  关闭8083端口对应user-service,继续请求/user/hello接口,会一次成功接着一次失败,如下所示。

  开启重试配置,无论多少次请求接口,都不再返还错误结果。

5.2 回退机制

  在Spring Cloud中,Zuul默认整合了Hystrix,当后端服务异常时可以为Zuul添加回退功能,返回默认的数据给客户端。

  实现回退机制需要实现FallbackProvider接口,Json工具使用的是阿里巴巴的FastJson。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
@Component
public class ServiceConsumerFallbackProvider implements FallbackProvider {
private Logger logger = LoggerFactory.getLogger(ServiceConsumerFallbackProvider.class);

// 返回*表示对所有服务进行回退操作,指定服务名就可以只针对某个服务进行回退,当然一定要在Eureka中进行注册
@Override
public String getRoute() {
return "*";
}

// 通过ClientHttpResponse构造回退内容
@Override
public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
return new ClientHttpResponse() {
// 返回响应的状态码
@Override
public HttpStatus getStatusCode() throws IOException {
return HttpStatus.OK;
}

@Override
public int getRawStatusCode() throws IOException {
return this.getStatusCode().value();
}

// 返回响应状态码对应的文本
@Override
public String getStatusText() throws IOException {
return this.getStatusCode().getReasonPhrase();
}

@Override
public void close() {

}

// 返回响应的内容
@Override
public InputStream getBody() throws IOException {
if(cause != null) {
logger.error("", cause.getCause());
}
RequestContext ctx = RequestContext.getCurrentContext();
ResponseData data = ResponseData.fail(" 服务器内部错误 ", ResponseData.Code.SERVER_ERROR_CODE);
return new ByteArrayInputStream(JSONObject.toJSONBytes(data));
}

// 返回响应的头信息
@Override
public HttpHeaders getHeaders() {
HttpHeaders httpHeaders = new HttpHeaders();
MediaType mt = new MediaType("application","json", Charset.forName("UTF-8"));
httpHeaders.setContentType(mt);
return httpHeaders;
}
};
}
}

  重启并访问已停掉的服务,如图我们选择eureka-client-hystrix-demo。

  先正常访问,结果如下。

  停掉eureka-client-hystrix-demo,返回我们自定义的结果。


第六节 Zuul使用小经验

6.1 /routes端点

  当Zuul和Actuator一起使用时,会增加一个/routes端点,访问/actuator/routes,如下图所示。

6.2 /filters端点

  访问/actuator/filters会返回Zuul中所有过滤器的信息,可以查看当前有哪些过滤器,以及过滤器是否被禁用等。

6.3 文件上传

  新建一个Maven项目zuul-file-demo,并编写一个文件上传接口,然后注册到Eureka。

1
2
3
4
5
6
7
8
9
10
@RestController
public class FileController {
@PostMapping("/file/upload")
public String fileUpload(@RequestParam(value = "file") MultipartFile file) throws IOException {
byte[] bytes = file.getBytes();
File fileToSave = new File(file.getOriginalFilename());
FileCopyUtils.copy(bytes, fileToSave);
return fileToSave.getAbsolutePath();
}
}

  如下图所示。

  通过Postman测试文件上传,结果如图所示。

  现在我们换一个大一点的文件,新的图片大小1.81 MB,结果如图所示

  通过Zuul上传文件时会默认限制大小不能超过1MB,所以增加如下配置(注意要同时配置zuul-demo和zuul-file-demo)。

1
2
3
# 设置Zuul上传文件大小限制,默认1MB
spring.servlet.multipart.max-file-size=1000Mb
spring.servlet.multipart.max-request-size=1000Mb

  重启服务,再次上传,如果只配置zuul-demo,结果如下。

  两个都配置,成功上传。

  还有一种解决方法是在网关的请求地址前加上/zuul,从而达到绕过Spring DispatcherServlet上传大文件的效果。

1
2
3
4
# 正常地址
http://localhost:2103/zuul-file-demo/file/upload
# 绕过地址
http://localhost:2103/zuul/zuul-file-demo/file/upload

  虽然这种方法使Zuul不用配置文件上传大小,但接收文件的zuul-file-demo还是要配置文件上传大小。

  还有就是上传大文件的耗时比较长,可以设置合理的超时时间来避免超时导致上传失败。

1
2
3
# 设置超时时间避免大文件上传失败
ribbon.ConnectTimeout=3000
ribbon.RealTimeout=60000

  在Hystrix隔离模式为线程下zuul.ribbon-isolation-strategy=thread,此时需要设置Hystrix超时时间。

1
hystrix.command.default.execution.isolation.thread.timeoutInMilliseconds=60000

6.4 请求响应信息输出

  我们日常使用无法避免通过日志来定位问题所在,所以要在Zuul中输出请求响应的信息。Zuul的四种过滤器每个都有其特定的使用场景,打印日志必然要在请求到达具体的服务后,并返回了才能有数据,所以应该选择post过滤器来实现。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
public class LogFilter extends ZuulFilter {
private Logger logger = LoggerFactory.getLogger(LogFilter.class);

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

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

@Override
public String filterType() {
return "post";
}

@Override
public Object run() throws ZuulException {
HttpServletRequest req = RequestContext.getCurrentContext().getRequest();
logger.info("REQUEST:: " + req.getScheme() + " " + req.getRemoteAddr() + ":" + req.getRemotePort());
StringBuilder params = new StringBuilder("?");
// 获取URL参数
Enumeration<String> names = req.getParameterNames();
if( req.getMethod().equals("GET") ) {
while (names.hasMoreElements()) {
String name = names.nextElement();
params.append(name);
params.append("=");
params.append(req.getParameter(name));
params.append("$=");
}
}
if(params.length() > 0) {
params.delete(params.length()-1,params.length());
}
logger.info("REQUEST:: > " + req.getMethod() + " " + req.getRequestURI() + params + " " + req.getProtocol());
Enumeration<String> headers = req.getHeaderNames();
while (headers.hasMoreElements()) {
String name = headers.nextElement();
String value = req.getHeader(name);
logger.info("REQUEST:: > " + name + ":" + value);
}
final RequestContext ctx = RequestContext.getCurrentContext();
// 获取请求体参数
if (!ctx.isChunkedRequestBody()) {
ServletInputStream inp = null;
try {
inp = ctx.getRequest().getInputStream();
String body = null;
if(inp != null) {
// 此处是Apache IOUtils
body = IOUtils.toString(inp);
logger.info("REQUEST:: > " + body);
}
} catch (IOException ex) {
ex.printStackTrace();
}
}
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
// 获取响应消息
// 第一种方式
try {
Object zuulResponse = RequestContext.getCurrentContext().get("zuulResponse");
if(zuulResponse != null) {
RibbonHttpResponse rep = (RibbonHttpResponse) zuulResponse;
String body = IOUtils.toString(rep.getBody());
logger.info("RESPONSE:: > " + body);
rep.close();
RequestContext.getCurrentContext().setResponseBody(body);
}
} catch (IOException ex) {
ex.printStackTrace();
}
// 第二种方式
InputStream stream = RequestContext.getCurrentContext().getResponseDataStream();
try {
if (stream != null) {
String body = IOUtils.toString(stream);
logger.info("RESPONSE:: > " + body);
RequestContext.getCurrentContext().setResponseBody(body);
}
} catch (IOException ex){
ex.printStackTrace();
}

  启动服务并访问,输出如下日志。

  源码RibbonRoutingFilter或SimpleHostRoutingFilter中,会在run()函数中调用forward方法,拿到响应结果,并通过setResponse方法进行响应的设置。

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
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
public class RibbonRoutingFilter extends ZuulFilter {    

......

public Object run() {
RequestContext context = RequestContext.getCurrentContext();
this.helper.addIgnoredHeaders(new String[0]);

try {
RibbonCommandContext commandContext = this.buildCommandContext(context);
ClientHttpResponse response = this.forward(commandContext);
this.setResponse(response);
return response;
} catch (ZuulException var4) {
throw new ZuulRuntimeException(var4);
} catch (Exception var5) {
throw new ZuulRuntimeException(var5);
}
}

......

protected void setResponse(ClientHttpResponse resp) throws ClientException, IOException {
// 直接把响应内容加到RequestContext
RequestContext.getCurrentContext().set("zuulResponse", resp);
this.helper.setResponse(resp.getRawStatusCode(), resp.getBody() == null ? null : resp.getBody(), resp.getHeaders());
}

}

public class SimpleHostRoutingFilter extends ZuulFilter {

......

public Object run() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletRequest request = context.getRequest();
MultiValueMap<String, String> headers = this.helper.buildZuulRequestHeaders(request);
MultiValueMap<String, String> params = this.helper.buildZuulRequestQueryParams(request);
String verb = this.getVerb(request);
InputStream requestEntity = this.getRequestBody(request);
if (this.getContentLength(request) < 0L) {
context.setChunkedRequestBody();
}

String uri = this.helper.buildZuulRequestURI(request);
this.helper.addIgnoredHeaders(new String[0]);

try {
CloseableHttpResponse response = this.forward(this.httpClient, verb, uri, request, headers, params, requestEntity);
this.setResponse(response);
return null;
} catch (Exception var9) {
throw new ZuulRuntimeException(this.handleException(var9));
}
}

......

private void setResponse(HttpResponse response) throws IOException {
RequestContext.getCurrentContext().set("zuulResponse", response);
this.helper.setResponse(response.getStatusLine().getStatusCode(), response.getEntity() == null ? null : response.getEntity().getContent(), this.revertHeaders(response.getAllHeaders()));
}
}

  第二种方式通过this.helper.setResponse设置响应内容,同样会把数据写入RequestContext。

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 ProxyRequestHelper {

......

public void setResponse(int status, InputStream entity, MultiValueMap<String, String> headers) throws IOException {
RequestContext context = RequestContext.getCurrentContext();
context.setResponseStatusCode(status);
if (entity != null) {
context.setResponseDataStream(entity);
}

boolean isOriginResponseGzipped = false;
Iterator var6 = headers.entrySet().iterator();

while(var6.hasNext()) {
Entry<String, List<String>> header = (Entry)var6.next();
String name = (String)header.getKey();
Iterator var9 = ((List)header.getValue()).iterator();

while(var9.hasNext()) {
String value = (String)var9.next();
context.addOriginResponseHeader(name, value);
if (name.equalsIgnoreCase("Content-Encoding") && HTTPRequestUtils.getInstance().isGzipped(value)) {
isOriginResponseGzipped = true;
}

if (name.equalsIgnoreCase("Content-Length")) {
context.setOriginContentLength(value);
}

if (this.isIncludedHeader(name)) {
context.addZuulResponseHeader(name, value);
}
}
}

context.setResponseGZipped(isOriginResponseGzipped);
}

......

}

6.5 Zuul自带的Debug功能

  Zuul中自带了一个DebugFilter,源码如下。

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
public class DebugFilter extends ZuulFilter {
// 开启此过滤器的两个参数
private static final DynamicBooleanProperty ROUTING_DEBUG = DynamicPropertyFactory.getInstance().getBooleanProperty("zuul.debug.request", false);
private static final DynamicStringProperty DEBUG_PARAMETER = DynamicPropertyFactory.getInstance().getStringProperty("zuul.debug.parameter", "debug");

public DebugFilter() {
}

public String filterType() {
return "pre";
}

public int filterOrder() {
return 1;
}

// 只要满足两个条件即可开启此过滤器
public boolean shouldFilter() {
HttpServletRequest request = RequestContext.getCurrentContext().getRequest();
return "true".equals(request.getParameter(DEBUG_PARAMETER.get())) ? true : ROUTING_DEBUG.get();
}

public Object run() {
RequestContext ctx = RequestContext.getCurrentContext();
ctx.setDebugRouting(true);
ctx.setDebugRequest(true);
return null;
}
}

  DynamicBooleanProperty是Netflix的配置管理框架Archaius提供的API,可以从配置中心获得配置,但由于Netflix没有开源Archaius的服务端,所以这边使用的是默认值debug。如果需要动态的获取这个值,可以用携程的Apollo来对接Archaius。

  可以在请求地址追加debug=true来开启这个过滤器,参数名称debug也可以在配置文件中进行覆盖,用zuul.debug.parameter指定,否则就是从Archaius中获取,没有对接Archaius那就是默认值debug。

  第二个条件可以通过zuul.debug.request来决定,可以在配置文件中配置zuul.debug.request=true开启DebugFilter过滤器。

  观察如FilterProcessor会有以下代码。

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
public class FilterProcessor {

......

public Object runFilters(String sType) throws Throwable {
// 当debugRouting为true时,就会添加一些Debug信息到RequestContext中
if (RequestContext.getCurrentContext().debugRouting()) {
Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
}

boolean bResult = false;
List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
if (list != null) {
for(int i = 0; i < list.size(); ++i) {
ZuulFilter zuulFilter = (ZuulFilter)list.get(i);
Object result = this.processZuulFilter(zuulFilter);
if (result != null && result instanceof Boolean) {
bResult |= (Boolean)result;
}
}
}

return bResult;
}

......

}

  SendResponseFilter中addResponseHeaders()函数会根据配置来决定是否将RequestContext中所存信息添加到响应头中返回。

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
44
45
46
47
48
public class SendResponseFilter extends ZuulFilter {

......

private void addResponseHeaders() {
RequestContext context = RequestContext.getCurrentContext();
HttpServletResponse servletResponse = context.getResponse();
List zuulResponseHeaders;
// 判断配置文件中是否设置debug-header,满足时才会把RequestContext中所存信息添加到响应头中返回
if (this.zuulProperties.isIncludeDebugHeader()) {
zuulResponseHeaders = (List)context.get("routingDebug");
if (zuulResponseHeaders != null) {
StringBuilder debugHeader = new StringBuilder();
Iterator var5 = zuulResponseHeaders.iterator();

while(var5.hasNext()) {
String it = (String)var5.next();
debugHeader.append("[[[" + it + "]]]");
}

servletResponse.addHeader("X-Zuul-Debug-Header", debugHeader.toString());
}
}

zuulResponseHeaders = context.getZuulResponseHeaders();
if (zuulResponseHeaders != null) {
Iterator var7 = zuulResponseHeaders.iterator();

while(var7.hasNext()) {
Pair<String, String> it = (Pair)var7.next();
servletResponse.addHeader((String)it.first(), (String)it.second());
}
}

if (this.includeContentLengthHeader(context)) {
Long contentLength = context.getOriginContentLength();
if (this.useServlet31) {
servletResponse.setContentLengthLong(contentLength);
} else if (this.isLongSafe(contentLength)) {
servletResponse.setContentLength(contentLength.intValue());
}
}

}

......

}

  配置文件中添加如下配置。

1
2
3
4
zuul.debug.parameter=true
zuul.debug.request=true
# 开启将Zuul Debug信息添加到响应头
zuul.include-debug-header=true

  重启服务并访问,结果如下。


第七节 Zuul高可用

  跟业务相关的服务我们都是注册到Eureka中,通过Ribbon来进行负载均衡,服务可以通过水平扩展来实现高可用。现实使用中,API网关这层往往是给APP、Webapp、客户来调用接口的,如果我们将Zuul也注册到Eureka中是达不到高可用的,因为你不可能让你的客户也去操作你的注册中心。这是最好的办法就是用额外的负载均衡器来实现Zuul的高可用,比如我们最常用的Nginx,或者HAProxy、F5等。

  这种方式也是单体项目最常用的负载方式,当用户请求一个地址的时候,通过Nginx去做转发,当一个服务挂掉的时候,Nginx会把它排除掉。

  如果想要API网关也能随时水平扩展,那么我们可以用脚本来动态修改Nginx的配置,通过脚本操作Eureka,发现有新加入的网关服务或者下线的网关服务,直接修改Nginx的upstream,然后通过重载(reload)配置来达到网关的动态扩容。

  如果不用脚本结合注册中心去做的话,就只能提前规划好N个节点,然后手动配置上去。


参考博客和文章书籍等:

《Spring Cloud微服务-入门、实战和进阶》

因博客主等未标明不可引用,若部分内容涉及侵权请及时告知,我会尽快修改和删除相关内容