网关 Zuul 科普

6,891 阅读17分钟

为什么要使用网关?

不同的微服务一般会有不同的网络地址,而外部客户端(例如手机 APP)可能需要调用多个服务的接口才能完成一个业务需求。例如一个电影购票的手机 APP,可能会调用多个微服务的接口,才能完成一次购票的业务流程,如下图所示。

如果让客户端直接与各个微服务通信,会有以下的问题:

  • 客户端会多次请求不同的微服务,增加了客户端的复杂性。
  • 存在跨域请求,在一定场景下处理相对复杂。
  • 认证复杂,每个服务都需要独立认证。
  • 难以重构,随着项目的迭代,可能需要重新划分微服务。例如,可能将多个服务合并成一个或者将一个服务拆分成多个。如果客户端直接与微服务通信,那么重构将会很难实施。
  • 某些微服务可能使用了防火墙/浏览器不友好的协议,直接访问会有一定的困难。

以上问题可借助微服务网关解决。微服务网关是介于客户端和服务器端之间的中间层,所有的外部请求都会先经过微服务网关。使用微服务网关后,架构如下所示。

此时,微服务网关封装了应用程序的内部结构,客户端只须跟网关交互,而无须直接调用特定微服务的接口。这样,开发就可以得到简化。不仅如此,使用微服务网关还有以下优点:

  • 易于监控。可在微服务网关收集监控数据并将其推送到外部系统进行分析。
  • 易于认证。可在微服务网关上进行认证,然后再将请求转发到后端的微服务,而无须在每个微服务中进行认证。
  • 减少了客户端与各个微服务之间的交互次数。

Zuul 简介

Zuul 是 Netflix 开源的一个 API 网关(代码托管地址:github.com/Netflix/zuu… ), 本质上是一个Web Servlet 应用。Zuul 也是 Spring Cloud 全家桶中的一员, 它可以和 Eureka、Ribbon、Hystrix 等组件配合使用。

Zuul 的核心是一系列的过滤器,这些过滤器帮助我们完成以下功能:

  • 验证与安全保障: 识别面向各类资源的验证要求并拒绝那些与要求不符的请求。
  • 审查与监控: 在边缘位置追踪有意义数据及统计结果,从而为我们带来准确的生产状态结论。
  • 动态路由: 以动态方式根据需要将请求路由至不同后端集群处。
  • 压力测试: 逐渐增加指向集群的负载流量,从而计算性能水平。
  • 负载分配: 为每一种负载类型分配对应容量,并弃用超出限定值的请求。
  • 静态响应处理: 在边缘位置直接建立部分响应,从而避免其流入内部集群。
  • 多区域弹性: 跨越 AWS 区域进行请求路由,旨在实现ELB使用多样化并保证边缘位置与使用者尽可能接近。

除此之外,Netflix 公司还利用 Zuul 的功能通过金丝雀版本实现精确路由与压力测试。

注:以上介绍来自 Zuul 官方文档,但其实开源版本的 Zuul 以上功能一个都没有——开源的 Zuul 只是几个 Jar 包而已,以上能力指的应该是 Netflix 官方自用的 Zuul 的能力。

快速入门

定义 2 个服务:hello-server 和 user-server,他们分别都注册到 eureka 服务上,示例如下(这里将下面讲到的 Zuul 也注册上去了):

在未经过网关时,我们可以通过以下 2 个接口来分别访问 hello-server 和 user-server:

http://localhost:8081/hello
http://localhost:8082/user

现在我们来定义 Zuul 服务,相关的 Maven 依赖如下:

<dependencies>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
        <version>2.2.2.RELEASE</version>
    </dependency>
    <dependency>
        <groupId>org.springframework.cloud</groupId>
        <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
        <version>2.2.2.RELEASE</version>
    </dependency>
</dependencies>

application.yml 文件中添加如下配置:

spring:
  application:
    name: zuul-service

eureka:
  client:
    service-url:
      defaultZone: http://localhost:8761/eureka

server:
  port: 6069

启动类中添加 @EnableZuulProxy 注解

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

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

启动程序之后,我们可以通过服务网关访问上面的 2 个接口:

curl http://localhost:6069/hello-server/hello
curl http://localhost:6069/user-server/user

注意: 默认的 Zuul 结合 Eureka 会注册到 Eureka 的服务名作为访问的 ContextPath。在 Zuul 中我们可以自定义配置各种路由规则,这里就不再做相关赘述了。

请求过滤

上面的示例中,我们通过 Zuul 实现了请求路由的功能,这样我们的微服务应用提供的接口就可以通过统一的 API 网关入口被客户端访问到了。

每个客户端用户请求服务应用提供的接口时,他们的访问权限往往都有一定的限制,系统并不会将所有的微服务接口对他们开放。然而,目前的服务路由并没有限制权限这样的功能,所有请求都会被毫无保留的转发到具体的应用并返回结果,为了实现对客户端请求的安全校验和权限控制,最简单和粗暴的方法就是在每个微服务应用都实现一套用于校验签名和鉴别权限的过滤器或拦截器。这样有个问题就是功能实现太过冗余。比较好的做法就是将这些校验逻辑剥离出去,构建一个独立的鉴权服务。在完成剥离之后,直接在微服务应用中通过调用鉴权系统服务来实现校验,但是这样仅仅只是解决了鉴权逻辑的分离,并没有在本质上将这部分不属于冗余的逻辑从原有的微服务应用中拆出去,冗余的拦截器或者过滤器依然会存在。

对于这样的问题,更好的做法是通过前置的网关服务来完成这些非业务性质的校验。由于网关服务的加入,外部客户端访问我们的系统已经有了统一的入口,既然这些校验与具体的业务无关,那何不在请求到达的时候就完成校验和过滤,微服务应用端就可以去除各种复杂的过滤器和拦截器了,这使得微服务应用接口的开发和测试复杂度也得到了相应的降低。这就涉及到了 Zuul 的另一个主要功能,请求过滤。

下面通过一个简单的示例来了解一下过滤器的使用:

import com.netflix.zuul.ZuulFilter;
import com.netflix.zuul.context.RequestContext;
import com.netflix.zuul.exception.ZuulException;
import lombok.extern.log4j.Log4j2;

import javax.servlet.http.HttpServletRequest;

@Log4j2
public class AccessFilter extends ZuulFilter {

    //过滤器的类型,它决定过滤器在请求的哪个生命周期中执行,这里定义为pre,代表会在请求被理由之前执行。
    @Override
    public String filterType() {
        return "pre";
    }

    //过滤器的执行顺序。当请求在一个阶段中存在多个过滤器时,需要根据该方法返回的值来依次执行
    @Override
    public int filterOrder() {
        return 0;
    }

    //判断该过滤器是否需要被执行。这里我们直接返回了true,因此该过滤器对所有的请求都生效。实际运行中我们可以利用该函数
    //来指定过滤器的有效范围。
    @Override
    public boolean shouldFilter() {
        return true;
    }

    //过滤器的具体执行逻辑。
    @Override
    public Object run() throws ZuulException {
        RequestContext ctx = RequestContext.getCurrentContext();
        HttpServletRequest request = ctx.getRequest();

        log.info("send {} request to {}", request.getMethod(), request.getRequestURI().toString());

        Object accessToken = request.getParameter("accessToken");
        if (accessToken == null) {
            log.warn("access token is empty");
            ctx.setSendZuulResponse(false);
            ctx.setResponseStatusCode(401);
        } else {
            log.info("access token ok");
        }

        return null;
    }
}

代码示例中的 ZuulFilter 接口中定义了4个方法:

  • filterType:过滤器的类型(Type),它决定过滤器在请求的哪个生命周期中执行,这里定义为 pre,代表会在请求被理由之前执行。(有关过滤器的类型会在下面的篇幅中详细描述)
  • filterOrder:过滤器的执行顺序(Execution Order)。当请求在一个阶段中存在多个过滤器时,需要根据该方法返回的值来依次执行。
  • shouldFilter:判断该过滤器是否需要被执行(Criteria)。这里我们直接返回了 true,因此该过滤器对所有的请求都生效。实际运行中我们可以利用该函数。
  • run:过滤器的具体执行逻辑(Action)。

在启动类中添加上这个过滤器:

@Bean
public AccessFilter accessFilter(){
    return new AccessFilter();
}

此时,再访问 curl http://localhost:6069/hello-server/hello 接口时会报错,状态码为 401,正确的访问姿势是:

curl http://localhost:6069/hello-server/hello?accessToken=666 

过滤器的生命周期

Zuul 中定义了 4 种标准的过滤器:pre、routing、post 以及 error,这些过滤器类型对应于请求的典型生命周期。我们参考下面的生命周期图来讲述一下这 4 种过滤器的作用以及执行顺序。

外部 HTTP 请求到达 API 网关服务的时候,首先它会进入第一个阶段 pre,在这里它会被 pre 类型的过滤器进行处理。该类型过滤器的主要目的是在进行请求路由之前做一些前置加工,比如权限限制等。

在完成了 pre 类型的过滤器处理之后,请求进入第二个阶段 routing,也就是路由请求转发阶段,请求将会被 routing 类型的过滤器处理。这里的具体处理内容就是将外部请求转发到具体服务实例上去的过程,当服务实例请求结果都返回之后,routing 阶段完成,请求进入第三个阶段 post。

此时请求将会被 post 类型的过滤器处理,这些过滤器在处理的时候不仅可以获取到请求信息,还能获取到服务实例的返回信息,所以在 post 类型的过滤器中,我们可以对处理结果进行一些加工或转换等内容,比如为响应添加标准的 HTTP Header、收集统计信息和指标等。

另外,还有一个特殊的阶段 error,该阶段只有在上述三个阶段中发生异常的时候才会触发。我们通过下面的过滤器执行流程图来加深一下对 error 过滤器的理解。

一般来讲,正常流程是 pre -> route -> post。如果在 pre 过滤器阶段抛出异常,那么流程是: pre -> error -> post;如果在 route 过滤阶段抛出异常,那么流程是: pre -> route -> error -> post;如果在post 过滤阶段抛出异常,最终流程是:pre -> route -> post -> error。

除了默认的过滤器类型,Zuul 还允许我们创建自定义的过滤器类型。例如,我们可以定制一种 STATIC 类型的过滤器,直接在 Zuul 中生成响应,而不将请求转发到后端的微服务。

过滤器是 Zuul 实现 API 网关功能最核心的部件,每一个进入 Zuul 的 HTTP 请求都会经过一系列的过滤器处理链得到请求响应并返回给客户端。就以 Zuul 的路由功能为例,路由功能在真正运行时,它的路由映射和请求转发都是由几个不同的过滤器完成的。其中,路由映射主要通过 pre 类型的过滤器完成,它将请求路径与配置的路由规则进行匹配,以找到需要转发的目标地址;而请求转发的部分则是由 route 类型的过滤器来完成,对 pre 类型过滤器获得的路由地址进行转发。

Zuul 的架构

上图展示了 Zuul Core 的工作原理,根据此图,我们可以更好地理解 Zuul。

Zuul 的过滤器基本上是由 Groovy 语言编写的,这些过滤器起初以文件(以 .groovy 结尾)的形式存放在特定的目录下面。Zuul 中的 FilterFileManager 会定期轮训这些目录,新加入的或者修改过的过滤器会被动态的加载进来。FilterFileManager 读取完 .groovy 文件之后会使用 GroovyComplier 将其编译成为JVM Class,之后再实例化(Class.newInstance)成 ZuulFilter 对象(即过滤器),最终保存在 FilterRegistry 中。FilterRegistry 是图中 FilterLoader 包含的一个对象,所以我们可以说成是:ZuulFilter 对象最终保存在 FilterLoader 中。

FilterRegistry 可以看成是一个ConcurrentHashMap,其中 key 为 .groovy 文件的路径,value 是动态加载之后的 ZuulFilter 对象。

Zuul 的过滤器之间没有直接的相互通信,他们之间通过一个 RequestContext(也可以看成是一个ConcurrentHashMap)来进行数据传递的。RequestContext 类中由 ThreadLocal 变量来记录每个 Request 所需要传递的数据。

当一个请求进入 Zuul 时,首先是交由 ZuulServlet 处理,ZuulServlet 中有一个ZuulRunner对象,该对象中初始化了前面所说的 RequestContext。ZuulRunner 中还有一个 FilterProcessor,这个FilterProcessor 从 FilterLoader(FilterRegistry)中获取 ZuulFilter(s)。有了这些 ZuulFilter(s)之后,ZuulServlet 首先执行的 pre 类型的过滤器,再执行 route 类型的过滤器,最后执行的是 post 类型的过滤器,如果在执行这些过滤器有错误的时候则会执行 error 类型的过滤器。执行完这些过滤器,最终将请求的结果返回给客户端。

Zuul 2.x

5 月 21 日,Netflix 在其官方博客上宣布正式开源微服务网关组件 Zuul 2(Zuul 是 Netflix 于 2013 年 6 月 12 日开源的,为了便于区分,下面都将前面所讲的 Zuul 表述为 Zuul 1)。Zuul 2 和 Zuul 1 在架构方面的主要区别在于,Zuul 2 运行在异步非阻塞的框架上,比如 Netty。Zuul 1 依赖多线程来支持吞吐量的增长,而 Zuul 2 使用的 Netty 框架依赖事件循环和回调函数。

Zuul 2 大体架构如上图所示,和 Zuul 1 没有本质上的区别。之前 ZuulFilter 分为了 pre、post、routing、error,Zuul 2 的Filter分为三种类型: inbound、endpoint、outbound。在 Zuul 2 中,过滤器前端用Netty Server 代替了原本 Zuul 1中的 Servlet,后端过滤器使用 Netty Client 代替了 HttpClient,这样前后端都可以支持异步(Zuul1 可以使用 Servlet 3.0 规范支持的 AsyncServlet 进行优化,可以实现前端异步,支持更多的连接数,达到和 Zuul2 一样的效果)。相比如 Zuul 1,Zuul 2 在功能上也丰富和优化了很多,比如对 HTTP/2、WebSocket 的支持。

Zuul 1 vs Zuul 2

Zuul1 设计比较简单,代码不多也比较容易读懂,它本质上就是一个同步 Servlet,采用多线程阻塞模型,如下图所示。

同步 Servlet 使用 thread per connection 方式处理请求。简单讲,对于每一个新入站的请求,Servlet 容器都要为其分配一个线程,直到响应返回客户端这个线程才会被释放返回容器线程池。如果后台服务调用比较耗时,那么这个线程就会被阻塞,阻塞期间线程资源被占用,不能执行其他任务。Servlet 容器线程池的大小是有限制的,当前端请求量大,而后台慢服务比较多时,很容易耗尽容器线程池内的线程,造成容器无法接受新的请求,Netflix 为此还专门研发了 Hystrix 熔断组件来解决慢服务耗尽资源问题。

这种同步阻塞模式编程模型比较简单,整个请求->处理->响应的流程(call flow)都是在一个线程中处理的,开发调试也便于理解,Debug 也比较方便。不过,同步阻塞模式一般会启动很多的线程,必然引入线程切换开销。另外,同步阻塞模式下,容器线程池的数量一般是固定的,造成对连接数有一定限制,当后台服务慢,容器线程池易被耗尽,一旦耗尽容器会拒绝新的请求,这个时候容器线程其实并不忙,只是被后台服务调用 IO 阻塞,但是干不了其它事情。

总体上,同步阻塞模式比较适用于计算密集型(CPU bound)应用场景。对于 IO 密集型场景(IO bound),同步阻塞模式会白白消耗很多线程资源,它们都在等待 IO 的阻塞状态,没有做实质性工作。

Zuul2 的设计相对比较复杂,代码也不太容易读懂,它采用了 Netty 实现异步非阻塞编程模型,如下图所示。

如果需要阅读 Zuul 2 源码,通过《Zuul2 源码分析》[9]这篇文章辅助一下也许会事半功倍。

在上图中,你可以简单理解为前端有一个队列专门负责处理用户请求,后端有个队列专门负责处理后台服务调用,中间有个事件环线程(Event Loop Thread),它同时监听前后两个队列上的事件,有事件就触发回调函数处理事件。这种模式下需要的线程比较少,基本上每个 CPU 核上只需要一个事件环处理线程,前端的连接数可以很多,连接来了只需要进队列,不需要启动线程,事件环线程由事件触发,没有多线程阻塞问题。

异步非阻塞模式启动的线程很少,使用的线程资源少,上下文切换开销也少。非阻塞模式可以接受的连接数大大增加,可以简单理解为请求来了只需要进队列,这个队列的容量可以设得很大,只要不超时,队列中的请求都会被依次处理。异步模式让编程模型变得复杂。异步模型没有一个明确清晰的请求->处理->响应执行流程,它的流程是通过事件触发的,请求处理的流程随时可能被切换断开,内部实现要通过一些关联id机制才能把整个执行流再串联起来,这就给开发调试运维引入了很多复杂性,比如你在IDE里头调试异步请求流就非常困难。

总体上,异步非阻塞模式比较适用于 IO 密集型(IO bound)场景,这种场景下系统大部分时间在处理 IO,CPU 计算比较轻,少量事件环线程就能处理。

至于 Zuul1 和 Zuul2 的性能比对,Netflix 给出了一个比较模糊的数据,大致 Zuul2 的性能比 Zuul1 好20% 左右,这里的性能主要指每节点每秒处理的请求数。为什么说模糊呢?因为这个数据受实际测试环境,流量场景模式等众多因素影响,你很难复现这个测试数据。即便这个 20% 的性能提升是确实的,其实这个性能提升也并不大,和异步引入的复杂性相比,这 20% 的提升是否值得是个问题。Netflix 本身在其 Blog[5] 和 ppt[8] 中也是有点含糊其词,甚至自身都有一些疑问的。

参考资料

  1. netflixtechblog.com/announcing-…
  2. www.jianshu.com/p/9c1041865…
  3. www.itmuch.com/spring-clou…
  4. www.itmuch.com/spring-clou…
  5. netflixtechblog.com/zuul-2-the-…
  6. mp.weixin.qq.com/s/QkeIVTn97…
  7. blog.csdn.net/yang75108/a…
  8. github.com/strangeloop…
  9. Zuul2 源码分析

加入我们

我们来自字节跳动飞书商业应用研发部(Lark Business Applications),目前我们在北京、深圳、上海、武汉、杭州、成都、广州、三亚都设立了办公区域。我们关注的产品领域主要在企业经验管理软件上,包括飞书 OKR、飞书绩效、飞书招聘、飞书人事等 HCM 领域系统,也包括飞书审批、OA、法务、财务、采购、差旅与报销等系统。欢迎各位加入我们。

扫码发现职位&投递简历

官网投递:job.toutiao.com/s/FyL7DRg