《深入理解 Spring Cloud 与微服务构建》第十章 路由网关 Spring Cloud Zuul


文章目录

  • 《深入理解 Spring Cloud 与微服务构建》第十章 路由网关 Spring Cloud Zuul
  • 一、Zuul 简介
  • 二、Zuul 的工作原理
  • 三、案例实战
  • 1.搭建 Zuul 服务
  • 2.在 Zuul 上配置 API 接口的版本号
  • 3.在 Zuul 上配置熔断器
  • 4.在 Zuul 中使用过滤器
  • 5.Zuul 的常见使用方式


一、Zuul 简介

Zuul 作为微服务系统的网管组件,用于构建边界服务(Edge Service),致力于动态路由、过滤、监控、弹性伸缩和安全。Zuul 在微服务架构中的重要作用主要体现在以下 6 个方面:

  • Zuul、Ribbon 以及 Eureka 相结合,可以实现智能路由和负载均衡的功能,Zuul 能够将请求流量按某种策略分发到集群状态的多个服务实例
  • 网关将所有服务的 API 接口统一聚合,并统一对外暴露。外界系统调用 API 接口时,都是由网关对外暴露的 API 接口,外界系统不需要知道微服务系统中各服务相互调用的复杂性。微服务系统也保护了其内部微服务单元的 API 接口,防止其被外界直接调用,导致服务的敏感信息对外暴露
  • 网关服务可以做到用户身份认证和权限认证,防止非法请求操作 API 接口,对服务器起到保护作用
  • 网关可以实现监控功能,实时日志输出,对请求进行记录
  • 网关可以用来实现流量监控,在高流量的情况下,对服务进行降级
  • API 接口从内部服务分离出来,方便做测试

二、Zuul 的工作原理

Zuul 是通过 Servlet 来实现的,Zuul 通过自定义的 ZuulServlet(类似于 Spring MVC 的 DispatchServlet)来对请求进行控制。Zuul 的核心是一系列过滤器,可以在 HTTP 请求的发起和响应期间执行一系列的过滤器。Zuul 包括以下 4 种过滤器:

  • PRE 过滤器:它是在请求路由到具体的服务之前执行的,这种类型的过滤器可以做安全验证,例如身份验证、参数验证等
  • ROUTING 过滤器:它用于将请求路由到具体的微服务实例。在默认情况下,它使用 Http Client 进行网络请求
  • POST 过滤器:它是在请求已被路由到微服务后执行的。一般情况下,用作收集统计信息、指标,以及将响应传输到客户端
  • ERROR 过滤器:它是在其它过滤器发生错误时执行的

Zuul 采取了动态读取、编译和运行这些过滤器。过滤器之间不能直接相互通信,而是通过 RequestContext 对象来共享数据,每个请求都会创建一个 RequestContext 对象。Zuul 过滤器具有以下关键特性:

  • Type(类型):Zuul 过滤器的类型,这个类型决定了过滤器在请求的那个阶段起作用,例如 Pre、Post 阶段等
  • Execution Order(执行顺序):规定了过滤器的执行顺序,Order 的值越小,越先执行
  • Criteria(标准):过滤器执行所需的条件
  • Action(行动):如果服务执行条件,则执行 Action(即逻辑代码)

Zuul 请求的生命周期如图所示:

springsecurity 路由放行_ide

当一个客户端 Request 请求进入 Zuul 网关服务时,网关先进入 “pre filter”,进行一系列的验证、操作或者判断。然后交给 “routing filter” 进行路由转发,转发到具体的服务实例进行逻辑处理、返回数据。当具体的服务处理完后,最后由 “post filter” 进行处理,该类型的处理器处理完之后,将 Response 信息返回给客户端

ZuulServlet 是 Zuul 的核心 Servlet。ZuulServlet 的作用是初始化 ZuulFilter,并编排这些 ZuulFilter 的执行顺序。该类中有一个 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 {
                this.preRoute();
            } catch (ZuulException var12) {
                this.error(var12);
                this.postRoute();
                return;
            }

            try {
                this.route();
            } catch (ZuulException var13) {
                this.error(var13);
                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();
        }
    }

从上面的代码可知,首先执行 preRoute() 方法,这个方法执行的是 PRE 类型的过滤器的逻辑。如果执行这个方法时出错了,那么会执行 error(e) 和 postRoute()。然后执行 route() 方法,该方法是执行 ROUTING 类型过滤器的逻辑。最后执行 postRoute(),该方法执行了 POST 类型过滤器的逻辑

三、案例实战

1.搭建 Zuul 服务

本案例基于上一章案例的基础上进行搭建,新建一个 Spring Boot 工程,取名为 eureka-zuul-client,在 pom 文件中引入相关依赖,包括继承了主 Maven 工程的 pom 文件,引入 Eureka Client 的起步依赖 spring-cloud-starter-netflix-eureka-client、Zuul 的起步依赖 spring-cloud-starter-netflix-zuul、Web 功能的起步依赖 spring-boot-starter-web,以及 Spring Boot 测试的起步依赖 spring-boot-starter-test。代码如下:

<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
         xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
         xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
    <parent>
        <artifactId>Eureka</artifactId>
        <groupId>org.sisyphus</groupId>
        <version>1.0-SNAPSHOT</version>
    </parent>
    <modelVersion>4.0.0</modelVersion>

    <artifactId>eureka-zuul-client</artifactId>

    <dependencies>
        <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-web</artifactId>
        </dependency>
        <dependency>
            <groupId>org.springframework.boot</groupId>
            <artifactId>spring-boot-starter-test</artifactId>
        </dependency>
    </dependencies>

</project>

在程序的启动类 EurekaZuulClientApplication 加上 @EnableEurekaClient 注解,开启 EurekaClient 的功能;加上 @SpringBootApplication 注解,表明自己是一个 Spring Boot 工程;加上 @EnableZuulProxy 注解,开启 Zuul 的功能。代码如下:

package com.sisyphus;

import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.cloud.netflix.eureka.EnableEurekaClient;
import org.springframework.cloud.netflix.zuul.EnableZuulProxy;

@EnableZuulProxy
@EnableEurekaClient
@SpringBootApplication
public class EurekaZuulClientApplication {
    public static void main(String[] args) {
        SpringApplication.run(EurekaZuulClientApplication.class, args);
    }
}

在工程的配置文件 application.yml 中做相关的配置,包括配置服务注册中心的地址为 http://localhost:8761/eureka,程序的端口号为 5000,程序名为 service-zuul

在本案例中,zuul.routes.hiapi.path 为 “/hiapi/**”,zuul.routes.hiapi.serviceId 为 “eureka-client”,这两个配置就可以将以 “/hiapi” 开头的 Url 路由到 eureka-client 服务。其中,zuul.routes.hiapi 中的 “hiapi” 是自己定义的,需要指定它的 path 和 serviceId,两者配合使用,就可以将指定类型的请求 Url 路由到指定的 ServiceId。同理,满足以 “/ribbonapi” 开头的请求 Url 都会被分发到 eureka-ribbon-client,满足以 “/feignapi” 开头的请求 Url 都会被分发到 eureka-feign-client 服务。如果某服务存在多个实例,Zuul 结合 Ribbon 会做负载均衡,将请求均分的部分路由到不同的服务实例

eureka:
  client:
    serviceUrl:
      defaultZone: http://localhost:8761/eureka/
      
server:
  port: 5000
  
spring:
  application:
    name: service-zuul
    
zuul:
  routes:
    hiapi:
      path: /hiapi/**
      serviceId: eureka-ribbon-client
    ribbonapi:
      path: /ribbonapi/**
      serviceId: eureka-ribbon-client
    feignapi:
      path: /feignapi/**
      serviceId: eureka-feign-client

依次启动工程 eureka-server、eureka-client、eureka-ribbon-client、eureka-feign-client 和 eureka-zuul-client,其中 eureka-client 启动两个实例,端口为 8762 和 8763。在浏览器上多次访问 http://localhost:5000/hiapi/hi?name=sisyphus,浏览器会交替显示以下内容:

hi sisyphus,i am from port:8762
hi sisyphus,i am from port:8763

可见 Zuul 在路由转发做了负载均衡。同理,多次访问 http://localhost:5000/feignapi/hi?name=sisyphus 和 http://localhost:5000/ribbonapi/hi?name=sisyphus,也可以看到相似的内容

如果不需要用 Ribbon 做负载均衡,可以指定服务实例的 Url,用 zuul.routes.hiapi.url 配置指定,这时就不需要配置 zuul.routes.hiapi.serviceId 了。一旦指定了 Url,Zuul 就不能做负载均衡了,而是直接访问指定的 Url,在实际的开发中这种做法是不可取的。修改配置的代码如下:

zuul:
  routes:
    hiapi:
      path: /hiapi/**
      url: http://localhost:8762

重新启动 eureka-zuul-service 服务,请求 http://localhost:5000/hiapi/hi?name=sisyphus,浏览器只会显示以下内容:

hi sisyphus,i am from port:8762

如果你想指定 Url,并且想做负载均衡,那么就需要自己维护负载均衡的服务注册列表。首先,将 ribbon.eureka.enable 改为 false,即 Ribbon 负载均衡客户端不向 Eureka Client 获取服务注册列表信息。然后需要自己维护一份注册列表,该注册列表对应的服务名为 hiapi-v1(这个名字可自定义),通过配置 hiapi-v1.ribbon.listOfServers 来配置多个负载均衡的 Url。代码如下:

zuul:
  routes:
    hiapi:
      path: /hiapi/**
      serviceId: hiapi-v1

ribbon:
  eureka:
    enabled: false

hiapi-v1:
  ribbon:
    listOfServers: http://localhost:8762,http://localhost:8763

重新启动 eureka-zuul-service 服务,在浏览器上访问 http://localhost:5000/hiapi/hi?name=sisyphus,浏览器会交替显示如下内容:

hi sisyphus,i am from port:8762
hi sisyphus,i am from port:8763

2.在 Zuul 上配置 API 接口的版本号

如果想给每一个服务的 API 接口加前缀,例如 http://localhost:5000/v1/hiapi/hi?name=sisyphus/,即在所有的 API 接口上加一个 v1 作为版本号。这时需要用到 zuul.prefix 的配置,配置示例代码如下:

zuul:
  routes:
    hiapi:
      path: /hiapi/**
      serviceId: hiapi-v1
    ribbonapi:
      path: /ribbonapi/**
      serviceId: eureka-ribbon-client
    feignapi:
      path: /feignapi/**
      serviceId: eureka-feign-client
  prefix: /v1

重新启动 eureka-zuul-service 服务,在浏览器上访问 http://localhost:5000/v1/hiapi/hi?name=sisyphus,浏览器会交替显示:

hi sisyphus,i am from port:8762
hi sisyphus,i am from port:8763

3.在 Zuul 上配置熔断器

Zuul 作为 Netflix 组件,可以与 Ribbon、Eureka 和 Hystrix 等组件相结合,实现负载均衡、熔断器的功能。在默认情况下,Zuul 和 Ribbon 相结合,实现了负载均衡的功能。下面来讲解如何在 Zuul 上实现熔断功能

在 Zuul 中实现熔断功能需要实现 FallbackProvider 的接口。实现该接口有两个方法,一个是 getRoute() 方法,用于指定熔断功能应用于哪些路由的服务;另一个方法 fallbackResponse() 为进入熔断功能时执行的逻辑。ZuulFallbackProvider 的源码如下:

public interface FallbackProvider {
    String getRoute();

    ClientHttpResponse fallbackResponse(String route, Throwable cause);
}

实现一个针对 eureka-client 服务的熔断器,当 eureka-client 的服务出现故障时,进入熔断逻辑,向浏览器输入一句错误提示

package com.fallbackProvider;

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;

@Component
public class MyFallbackProvider implements FallbackProvider {
    @Override
    public String getRoute() {
        return "eureka-client";
    }

    @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 200;
            }

            @Override
            public String getStatusText() throws IOException {
                return "OK";
            }

            @Override
            public void close() {

            }

            @Override
            public InputStream getBody() throws IOException {
                return new ByteArrayInputStream("oooops!error!i am the fallback.".getBytes());
            }

            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
        };
    }
}

重新启动 eureka-zuul-client 工程,并且关闭 eureka-client 的所有实例,在浏览器上访问 http://localhost:5000/hiapi/hi?name=sisyphus,浏览器会显示:

oooops!error!i am the fallback.

如果需要所有的路由服务都加熔断功能,只需要在 getRoute() 方法上返回 “*” 的匹配符,代码如下:

@Override
    public String getRoute() {
        return "*";
    }

4.在 Zuul 中使用过滤器

在前面的章节讲述了过滤器的作用和种类,下面来讲解如何实现一个自定义的过滤器。实现过滤器很简单,只需要继承 ZuulFilter,并实现 ZuulFilter 中的抽象方法,包括 filterType() 和 filterOrder(),以及 IZuulFilter 的 shouldFilter,并实现 ZuulFilter 中的抽象方法,包括 filterType() 和 filterOrder(),以及 ZuulFilter 的 shouldFilter() 和 Object run() 的两个方法。其中,filterType() 即过滤器的类型,有 4 中类型,分别是 “pre”、“post”、“routing” 和 “error”。filterOrder() 是过滤顺序,它是一个 Int 类型的值,值越小,越早执行该过滤器。shouldFilter() 表示该过滤器是否过滤逻辑,如果为 true,则执行 run() 方法;如果为 false,则不执行 run() 方法。run() 方法写具体的过滤的逻辑。在本例中,检查请求的参数中是否穿了 token 这个参数,如果没有传,则请求不被路由到具体的服务实例,直接返回响应,状态码为 401.代码如下:

package com.sisyphus.filter;

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.stereotype.Component;

import javax.servlet.http.HttpServletRequest;

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

@Component
public class MyFilter extends ZuulFilter {
    private static Logger log = LoggerFactory.getLogger(MyFilter.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 {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();
        Object accessToken = request.getParameter("token");
        if(accessToken == null){
            log.warn("token is empty");
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
            try{
                ctx.getResponse().getWriter().write("token is empty");
            }catch (Exception e){
                return null;
            }
        }
        log.info("ok");
        return null;
    }
}

重新启动服务,打开浏览器,访问 http://localhost:5000/hiapi/hi?name=sisyphus,浏览器显示:

token is empty

再次在浏览器上输入 http://localhost:5000/hiapi/hi?name=sisyphus&token=token,即加上了 token 这个请求参数,浏览器显示:

hi sisyphus,i am from port:8762

可见,MyFilter 这个 Bean 注入 IoC 容器之后,对请求进行了过滤,并在请求路由转发之前进行了逻辑判断。在实际开发中,可以用此过滤器进行安全验证

5.Zuul 的常见使用方式

Zuul 是采用了类似于 Spring MVC 的 DispatchServlet 来实现的,采用的是异步阻塞模型,所以性能比 Nginx 差。由于 Zuul 和其它 Netflix 组件可以相互配合、无缝集成,Zuul 很容易就能实现负载均衡、智能路由和熔断器等功能。在大多数情况下,Zuul 都是以集群的形式存在的。由于 Zuul 的横向扩展能力非常好,所以当负载过高时,可以通过添加实例来解决性能瓶颈

一种常见的使用方式是对不同的渠道使用不同的 Zuul 来进行路由,例如移动端用一个 Zuul 网关实例,WEB 端用一个 Zuul 网关实例,其它的客户端用一个 Zuul 实例进行路由

另一种常见的集群是通过 Nginx 和 Zuul 相互结合来做负载均衡。暴露在最外面的是 Nginx 主从双热备进行 Keepalive,Nginx 经过某种路由策略,将请求路由转发到 Zuul 集群上,Zuul 最终将请求分发到具体的服务上