引言
Zuul is the front door for all requests from devices and web sites to the backend of the Netflix streaming application. As an edge service application, Zuul is built to enable dynamic routing, monitoring, resiliency and security. It also has the ability to route requests to multiple Amazon Auto Scaling Groups as appropriate.
—— Zuul 官方wiki
Zuul 简介
Zuul 是 Netflix OOS 中的一员,是一个基于 JVM 路由和服务端的负载均衡。提供路由、监控、弹性、安全等方面的服务框架。Zuul 能够与 Eureka 、Ribbon 、Hystrix 等组件配合使用。
Zuul 的核心是过滤器,通过这些过滤器我们可以扩展出很多功能,比如:
动态路由:动态的将客户端的请求路由到后端的不同服务,做一些逻辑处理,比如聚合多个服务的数据返回。
请求监控:可以对整个系统的请求进行监控,记录详细的请求响应日志,可以实时统计出当前系统的访问量以及监控状态。
认证鉴权:对每一个访问的请求做认证,拒绝非法请求,保护好后端的服务。
压力测试:压力测试是一项很重要的工作,像电商公司需要模拟更多的真实的用户并发量来保证重大活动时系统的稳定。通过 Zuul 可以动态地将请求转发到后端服务的集群中,还可以识别测试流量和真实流量,从而做一些特殊处理。
灰度发布:灰度发布可以保证系统的稳定,在初始灰度的时候就可以发现、调整问题、以保证其影响度。
——《spring cloud 微服务 入门、进阶与实战》第103页
使用 Zuul 构建微服务网关 简单使用 建立一个 spring cloud 项目。配置如下:
1 2 3 4 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-netflix-zuul</artifactId > </dependency >
1 2 3 4 5 6 7 spring.application.name =zuul-demo server.port =2103 zuul.routes.demo1.path =/demo1/** zuul.routes.demo1.url =https://blog.wu-zy.com/
启动类加上 @EnableZuulProxy 注解
1 2 3 4 5 6 7 8 @SpringBootApplication @EnableZuulProxy public class ZuulDemoApplication { public static void main (String[] args) { SpringApplication.run(ZuulDemoApplication.class, args); } }
启动项目我们访问:http://localhost:2103/demo1/myblog
结果跳转到了我的博客:https://blog.wu-zy.com/myblog/
集成 Eurekas 结合 Eureka 实现动态路由
1 2 3 4 <dependency > <groupId > org.springframework.cloud</groupId > <artifactId > spring-cloud-starter-netflix-eureka-client</artifactId > </dependency >
1 2 eureka.client.serviceUrl.defaultZone =http://WuZhiYong:123456@localhost:8761/eureka/,http://WuZhiYong:123456@localhost:8762/eureka/
我们可通过: API网关地址 + 访问的服务实例的名称 + 服务接口的URL
我这里注册中心有个 eureka-client-service 服务 并且 该服务有 /user/hello 接口
我们可通过 网关这样调用。
http://localhost:2103/eureka-client-service/user/hello
Zuul 路由配置
指定具体服务的路由
配置:
1 zuul.routes.eureka-client-service.path =/api-user/**
访问 http://localhost:2103/api-user/user/hello 即可调用 eureka-client-service 的接口了。
以前面的配置为例:
zuul.routes.demo1.path=/demo1/** zuul.routes.demo1.url=https://blog.wu-zy.com/
这里给我的感觉是:如果我们只配置了path zuul 会尝试将 demo1 尝试当作 服务实例名进行解析,如果我们配置了 url zuul 就会根据我们指定的 url 进行转发。
zuul.routes.${server_name}.path=/demo1/** zuul.routes.${server_name}.url=https://blog.wu-zy.com/
路由前缀
对前缀的设置,分为 统一前缀 和每条转发路线的前缀
1 2 3 4 5 6 7 8 9 10 11 12 zuul.prefix =/user zuul.strip-prefix =false zuul.routes.demo1.path =/demo1/** zuul.routes.demo1.url =https://blog.wu-zy.com/ zuul.routes.eureka-client-service.path =/api-user/** zuul.routes.eureka-client-service.strip-prefix =true
本地跳转
1 2 3 4 5 6 zuul.routes.demo1.path =/demo1/** zuul.routes.demo1.url =forward:/local
1 2 3 4 5 6 7 8 9 @RestController public class LocalController { @GetMapping("/local/{id}") public String local (@PathVariable String id) { return id; } }
Zuul 中的过滤器 本文简介中也说到 zuul 中的很多高级功能都是通过 zuul 的过滤器实现的。与我们了解的传统 servlet 过滤器不同的是 zuul 中的过滤器分为 4 种。每一种都有对应的使用场景。
过滤器类型
pre:可以在请求被路由之前调用。适用于身份认证的场景,认证通过后再继续执行下面的流程。
route:在路由请求时被调用。适用于灰度发布场景,在将要路由的时候可以做一些自定义的逻辑。
post:在 route和eror过滤器之后被调用。这种过滤器将请求路由到达具体的服务之后执行。适用于需要添加响应头,记录响应日志等应用场景。
eror:处理请求时发生错误时被调用。在执行过程中发送错误时会进入eror过滤器,可以用来统一记录错误信息。
——《spring cloud 微服务 入门、进阶与实战》第107页
请求生命周期
通过上面的图可以清楚地知道整个执行的顺序,请求发过来首先到pre过滤器,再到routing过滤器,最后到post过滤器,任何一个过滤器有异常都会进入eror过滤器。
在源码中也可看到这种执行顺序:
源码包地址: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 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 public class MyPreFilter extends ZuulFilter { private List<String> blackIpList = Arrays.asList("127.0.0.1" ); @Override public String filterType () { return "pre" ; } @Override public int filterOrder () { return 1 ; } @Override public boolean shouldFilter () { return true ; } @Override public Object run () throws ZuulException { RequestContext ctx = RequestContext.getCurrentContext(); String ip = IpUtils.getIpAddr(ctx.getRequest()); if (StringUtils.isNotBlank(ip) && blackIpList.contains(ip)) { ctx.setSendZuulResponse(false ); ctx.set("sendForwardFilter.ran" , true ); 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 ; } }
过滤器定义完成之后我们需要配置过滤器才能生效,
1 2 3 4 5 6 7 8 @Configuration public class FilterConfig { @Bean public MyPreFilter ipFilter () { return new MyPreFilter (); } }
禁用过滤器
利用 shouldFilter 方法中的 return false 让过滤器不再执行
利用配置文件方式。格式为:“zuul.过滤器类名.过滤器类型.disable=true” 例:
1 2 zuul.MyPreFilter.pre.disable =true
过滤器中传递数据 在前面的过滤器中我们可以这样存入数据:
1 2 3 4 5 6 7 import com.netflix.zuul.context.RequestContext;... ... RequestContext ctx = RequestContext.getCurrentContext();ctx.set("msg" ,"hello" );
在后面的过滤器中我们可以这样取:
1 2 RequestContext ctx = RequestContext.getCurrentContext();ctx.get("msg" );
过滤器拦截请求 拦截和返回信息从前面代码里就可以看出:
1 2 3 4 5 6 7 8 ctx.setSendZuulResponse(false ); ctx.set("sendForwardFilter.ran" , true ); ctx.setResponseBody("返回信息" ); return null ;
由于 zuul 拦截器和普通拦截器的逻辑的不同,如果 zuul 中有多个拦截器,即使 我们设置了 ctx.setSendZuulResponse(false); 和 ctx.set(“sendForwardFilter.ran”, true);。虽然请求最终不会转发到后端服务,但在后面的拦截器依然会执行。
如果我们不想让后面的拦截器执行,可通过 转递数据的方法 例如:
1 2 3 RequestContext ctx = RequestContext.getCurrentContext();ctx.set("is_success" ,false );
在后面的拦截器 shouldFilter 方法中
1 2 3 4 5 6 @Override public boolean shouldFilter () { RequestContext ctx = RequestContext.getCurrentContext(); Object success = ctx.get("is_success" ); return success == null ? true : Boolean.parseBoolean(success.toString()); }
过滤器中的异常处理
对于异常来说,无论在哪个地方都需要处理。过滤器中的异常主要发生在run方法中,可以用 try catch来处理。Zuul中也为我们提供了一个异常处理的过滤器,当过滤器在执行过程中发生异常,若没有被捕获到,就会进入 error过滤器中。
我们可以定义一个eror过滤器来记录异常信息
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 ErrorFilter extends ZuulFilter { private Logger log = 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 () { RequestContext ctx = RequestContext.getCurrentContext(); Throwable throwable = ctx.getThrowable(); log.error("Filter Erroe : {}" , throwable.getCause().getMessage()); return null ; } }
并在 ipFilter run 方法中 System.err.println(2/0); 这行代码取消注释。重启项目,在访问接口:
得到响应:
这种情况自然不符合和统一接口规范
在 《spring cloud 微服务 入门、进阶与实战》书中说这种情况下使用 @ControllerAdvice 方式也是无效的,并解释:@ControllerAdvice 注解主要是针对 Controller 中的方法做处理的,作用于 @RequestMappping 标注的方法上,只针对与我们定义的方法有效,在 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 @RestController public class ErrorHandlerController 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, ResponseCode.SERVER_ERROR_CODE.getCode()); } private Map<String, Object> getErrorAttributes (HttpServletRequest request) { return errorAttributes.getErrorAttributes(new ServletWebRequest (request), true ); } }
再次请求发现返回了我们想要的格式的数据了。
Zuul 容错和回退 容错机制 当某个服务不可用的时候能够切换到其他可用的服务上去。也就是需要重试机制。在 zuul 中开启重试机制需要依赖于 spring-retry
1 2 3 4 <dependency > <groupId > org.springframework.retry</groupId > <artifactId > spring-retry</artifactId > </dependency >
properties
1 2 3 4 5 6 7 8 9 10 11 12 13 14 zuul.retryable =true ribbon.ConnectTimeout =1000 ribbon.ReadTimeout =1000 ribbon.MaxAutoRetries =1 ribbon.MaxAutoRetriesNextServer =3 ribbon.OkToRetryOnAllOperations =true ribbon.retryableStatusCodes =500,404,502
测试:
服务提供方的集群我们启动多个服务,通过 zuul 不停调用,期间停掉集群中一台机器。发现确实达到了容错的目的。
回退机制 在 spring cloud 中,zuul 默认整合了 hystrix ,当后端服务异常时可以为 zuul 添加回退的功能,返回默认的数据给客户端。
实现回退机制需要实现 FallbackProvider 接口
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 95 96 97 98 99 100 101 102 103 104 105 106 import com.study.base.ResponseCode;import com.study.base.ResponseData;import com.study.utils.JsonUtils;import org.slf4j.Logger;import org.slf4j.LoggerFactory;import org.springframework.cloud.netflix.zuul.filters.route.FallbackProvider;import org.springframework.http.HttpHeaders;import org.springframework.http.HttpStatus;import org.springframework.http.MediaType;import org.springframework.http.client.ClientHttpResponse;import org.springframework.stereotype.Component;import java.io.ByteArrayInputStream;import java.io.IOException;import java.io.InputStream;import java.nio.charset.Charset;@Component public class ServiceConsumerFallbackProvider implements FallbackProvider { private Logger log = LoggerFactory.getLogger(ServiceConsumerFallbackProvider.class); @Override public String getRoute () { return "*" ; } @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 ) { log.error("" , cause.getCause()); } ResponseData data = ResponseData.fail(route+"服务内部错误" , ResponseCode.SERVER_ERROR_CODE.getCode()); return new ByteArrayInputStream (JsonUtils.toJson(data).getBytes()); } @Override public HttpHeaders getHeaders () { HttpHeaders headers = new HttpHeaders (); MediaType mt = new MediaType ("application" , "json" , Charset.forName("UTF-8" )); headers.setContentType(mt); return headers; } }; } }
重启 zuul 后我们停掉集群服务。再通过 zuul 访问接口。发现返回我们想要的数据了
1 2 3 4 5 { "code" : 500 , "message" : "eureka-client-service服务内部错误" , "data" : null }
Zuul 使用小经验 结合 Actuator 查看相关信息 zuul 在结合 Actuator 使用的时候可以暴露一些端点来方面我们来查看管理 zuul 路由。
首先我们添加 Actuator 并配置暴露出所有端点。
1 2 3 4 <dependency > <groupId > org.springframework.boot</groupId > <artifactId > spring-boot-starter-actuator</artifactId > </dependency >
1 2 management.endpoints.web.exposure.include =*
访问:
1 http://localhost:2103/actuator/routes
响应:
1 2 3 4 5 { "/demo1/**" : "forward:/local" , "/api-server/**" : "eureka-client-service" , "/eureka-client-service/**" : "eureka-client-service" }
访问:
1 http://localhost:2103/actuator/filters
响应:
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 { "error" : [ { "class" : "org.springframework.cloud.netflix.zuul.filters.post.SendErrorFilter" , "order" : 0 , "disabled" : false , "static" : true } ] , "post" : [ { "class" : "org.springframework.cloud.netflix.zuul.filters.post.SendResponseFilter" , "order" : 1000 , "disabled" : false , "static" : true } ] , "pre" : [ { "class" : "org.springframework.cloud.netflix.zuul.filters.pre.Servlet30WrapperFilter" , "order" : -2 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.pre.ServletDetectionFilter" , "order" : -3 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter" , "order" : 5 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.pre.DebugFilter" , "order" : 1 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.pre.FormBodyWrapperFilter" , "order" : -1 , "disabled" : false , "static" : true } , { "class" : "com.study.customFilter.MyRouteFilter" , "order" : 2 , "disabled" : false , "static" : true } , { "class" : "com.study.customFilter.MyPreFilter" , "order" : 1 , "disabled" : false , "static" : true } ] , "route" : [ { "class" : "org.springframework.cloud.netflix.zuul.filters.route.SimpleHostRoutingFilter" , "order" : 100 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter" , "order" : 10 , "disabled" : false , "static" : true } , { "class" : "org.springframework.cloud.netflix.zuul.filters.route.SendForwardFilter" , "order" : 500 , "disabled" : false , "static" : true } ] }
文件上传 创建一个 spring cloud 应用 zuul-file-demo。并注册到 Eureka
文件上传控制器接口
1 2 3 4 5 6 7 8 9 10 11 12 @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 上传测试。并成功返回了文件路径
然后换个大文件 35M。上传失败了
在 zuul-demo 和 zuul-file-demo 都加上
1 2 spring.servlet.multipart.max-file-size =100MB spring.servlet.multipart.max-request-size =100MB
在 zuul-demo 调整
1 2 3 4 ribbon.ConnectTimeout =90000 ribbon.ReadTimeout =90000
重启两个服务再次测试便成功了。
在请求地址前面加上 /zuul 就可以绕过 zuul-demo 服务,这样就可以不用配置文件大小。但接收文件的服务 zuul-file-demo 还是得配置上
访问:
1 http://localhost:2103/zuul/zuul-file-demo/file/upload
注意:在Hystrix 隔离模式为线程模式下。需要设置 Hystrix 线程的超时时间。
execution.isolation.thread.timeoutInMilliseconds (详细参看 Hystrix服务容错 文章)
请求响应详细信息输出 实际开发当中,详细的日志能够帮助我们快速排查和定位系统的问题。在 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 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 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 import java.io.IOException;import java.io.InputStream;import java.util.Enumeration;import java.util.List;import javax.servlet.ServletInputStream;import javax.servlet.http.HttpServletRequest;import org.apache.commons.io.IOUtils;import com.netflix.util.Pair;import com.netflix.zuul.ZuulFilter;import com.netflix.zuul.context.RequestContext;import org.springframework.cloud.netflix.ribbon.RibbonHttpResponse;public class DebugRequestFilter extends ZuulFilter { @Override public String filterType () { return "post" ; } @Override public int filterOrder () { return 1 ; } @Override public boolean shouldFilter () { return true ; } @Override public Object run () { HttpServletRequest req = (HttpServletRequest)RequestContext.getCurrentContext().getRequest(); System.err.println("REQUEST:: " + req.getScheme() + " " + req.getRemoteAddr() + ":" + req.getRemotePort()); StringBuilder params = new StringBuilder ("?" ); Enumeration<String> names = req.getParameterNames(); if ( req.getMethod().equals("GET" ) ) { while (names.hasMoreElements()) { String name = (String) 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()); } System.err.println("REQUEST:: > " + req.getMethod() + " " + req.getRequestURI() + params + " " + req.getProtocol()); Enumeration<String> headers = req.getHeaderNames(); while (headers.hasMoreElements()) { String name = (String) headers.nextElement(); String value = req.getHeader(name); System.err.println("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 ) { body = IOUtils.toString(inp); System.err.println("REQUEST:: > " + body); } } catch (IOException e) { e.printStackTrace(); } } List<Pair<String, String>> headerList = RequestContext.getCurrentContext().getOriginResponseHeaders(); for (Pair<String, String> pair : headerList) { System.err.println("RESPONSE HEADER:: > " +pair.second()); } try { Object zuulResponse = RequestContext.getCurrentContext().get("zuulResponse" ); if (zuulResponse != null ) { RibbonHttpResponse resp = (RibbonHttpResponse) zuulResponse; String body = IOUtils.toString(resp.getBody()); System.err.println("RESPONSE:: > " + body); resp.close(); RequestContext.getCurrentContext().setResponseBody(body); } } catch (IOException e) { e.printStackTrace(); } return null ; } }
控制台输出:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 REQUEST:: http 0:0:0:0:0:0:0:1:9596 REQUEST:: > GET /api-server/user/hello HTTP/1.1 REQUEST:: > content-type:application/json REQUEST:: > user-agent:PostmanRuntime/7.24.1 REQUEST:: > accept:*/* REQUEST:: > cache-control:no-cache REQUEST:: > postman-token:9d887b8d-42d2-4787-bae8-11c63447be8c REQUEST:: > host:localhost:2103 REQUEST:: > accept-encoding:gzip, deflate, br REQUEST:: > connection:keep-alive REQUEST:: > content-length:28 REQUEST:: > {"username":"22","pwd":"33"} RESPONSE HEADER:: > eureka-client-service RESPONSE HEADER:: > text/plain;charset=UTF-8 RESPONSE HEADER:: > 5 RESPONSE HEADER:: > Mon, 11 May 2020 08:37:33 GMT RESPONSE HEADER:: > timeout=60 RESPONSE HEADER:: > keep-alive RESPONSE:: > hello
Zuul 高可用
跟业务相关的服务我们都是注册到 Eureka 中,通过 Ribbon 来进行负载均衡,服务可以通过水平扩展来实现高可用。现实使用中,API网关这层往往是给 APP、 Webapp、客户来调用接口的,如果我们将 Zuul 也注册到 Eureka 中是达不到高可用的,因为你不可能让你的客户也去操作你的注册中心。这时最好的办法就是用额外的负载均衡器来实现 Zuul 的高可用,比如我们最常用的 Nginx,或者 Haproxy、F5等。
这种方式也是单体项目最常用的负载方式,当用户请求一个地址的时候,通过Ngin去做转发,当一个服务挂掉的时候, Nginx会把它排除掉。
如果想要API网关也能随时水平扩展,那么我们可以用脚本来动态修改 Nginx 的配置,通过脚本操作 Eureka,发现有新加人的网关服务或者下线的网关服务,直接修改 Nginx 的 upstream,然后通过重载( reload)配置来达到网关的动态扩容。
如果不用脚本结合注册中心去做的话,就只能提前规划好N个节点,然后手动配置上去。
参考:
《spring cloud 微服务 入门、进阶与实战》
书中本章代码:
API 网关