Spring Cloud 专题之四:Zuul网关

书接上回:

SpringCloud专题之一:Eureka

Spring Cloud专题之二:OpenFeign

Spring Cloud专题之三:Hystrix

经过前面三章对Spring Cloud的基本组件的介绍,我们可以构建一个简单的微服务架构系统了。比如,通过使用Spring Cloud Eureka实现高可用的服务注册中心以及实现微服务的注册与发现;通过Spring Cloud OpenFeign 实现服务间负载均衡的接口调用;同时,为了使分布式系统更为健壮,对于依赖的服务调用使用SpringCloud Hystrix来进行包装,实现线程隔离并加入熔断机制,以避免在微服务架构中因个别服务出现异常而引起级联故障蔓延。

上面的架构实现系统功能是完全没有问题,但是还可以进一步思考,这样的架构还有不足的地方会使运维人员或开发人员感到很痛苦。

​ 首先,我们从运维人员的角度来看看,他们平时都需要做一些什么工作来支持这样的架构。当客户端应用单击某个功能的时候往往会发出一些对微服务获取资源的请求到后端,这些请求通过F5、Nginx等设施的路由和负载均衡分配后,被转发到各个不同的服务实例上。而为了让这些设施能够正确路由与分发请求,运维人员需要手工维护这些路由规则与服务实例列表,当有实例增减或是地址变动等情况发生的时候,也需要手工地去同步修改这些信息以保持实例信息与中间件配置内容的一致性。在系统规模不大的时候,维护这些信息的工作还不会太过复杂,但是如果当系统规模不断增大,那么这些看似简单的维护任务会变得越来越难,并且出现配置错误的概率也会逐渐增加。很显然,这样的做法并不可取,所以我们需要一套机制来有效降低维护路由规则与服务实例列表的难度。

​ 其次,我们再从开发人员的角度来看看,在这样的架构下,会产生一些怎样的问题呢?大多数情况下,为了保证对外服务的安全性,我们在服务端实现的微服务接口,往往都会有一定的权限校验机制,比如对用户登录状态的校验等;同时为了防止客户端在发起请求时被篡改等安全方面的考虑,还会有一些签名校验的机制存在。这时候,由于使用了微服务架构的理念,我们将原本处于一个应用中的多个模块拆成了多个应用,但是这些应用提供的接口都需要这些校验逻辑,我们不得不在这些应用中都实现这样一套校验逻辑。随着微服务规模的扩大,这些校验逻辑的冗余变得越来越多,突然有一天我们发现这套校验逻辑有个BUG需要修复,或者需要对其做一些扩展和优化,此时我们就不得不去每个应用里修改这些逻辑,而这样的修改不仅会引起开发人员的抱怨,更会加重测试人员的负担。所以,我们也需要一套机制能够很好地解决微服务架构中,对于微服务接口访问时各前置校验的冗余问题。

为了解决上面的架构问题,API网关应运而生,而Spring Cloud Zuul就是Spring Colud 提供的这样的一个API网关。Zuul提供了动态路由、监控、弹性负载和安全功能。Zuul底层利用各种filter实现如下功能:

  • 认证和安全:识别每个需要认证的资源,拒绝不符合要求的请求。
  • 性能监测:在服务边界追踪并统计数据,提供精确的生产视图。
  • 动态路由:根据需要将请求动态路由到后端集群。
  • 压力测试:逐渐增加对集群的流量以了解其性能。
  • 负载卸载:预先为每种类型的请求分配容量,当请求超过容量时自动丢弃。
  • 静态资源处理:直接在边界返回某些响应。

代码实践

本次的代码实践还是在前几篇文章的代码的基础上所作的。

1.创建zuul-gateway的工程并引入依赖

<!--zuul的依赖-->
<dependency>
	<groupId>org.springframework.cloud</groupId>
	<artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>
<!--eureka-client-->
<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

2.创建应用主类,使用@EnableZuulProxy注解开启Zuul的API网关服务功能

@SpringBootApplication
@EnableZuulProxy
public class ZuulGatewayApplication {

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

}

3.在配置文件中配置Zuul应用的基础信息,这里不像之前的服务使用properties作为配置文件,而是菜用yaml作为配置(后面会讲)

server:
  port: 9010

spring:
  application:
    name: zuul-gateway

# 指定Eureka server的注册中心的位置,出来将Zuul的注册成服务之外,也让Zuul能够获取注册中心的实例清单
eureka:
  client:
    service-url:
      defaultZone: //eureka-server1:9001/eureka/

传统的路由方式

使用Zuul实现路由的功能非常简单,之需要对api-gateway服务增加关于路由规则的配置即可。

#Zuul实现的传统的路由配置
zuul:
  routes:
    hello-server-url:
      path: /hello-server/**
      url: //localhost:9003

该配置会将所有发往API网关服务的请求中符合/hello-server/**规则的访问都路由转发到 //localhost:9003 这个地址上。也就是:我们在访问 //localhost:9010/hello-server/sayHello的时候,API服务网关会将该请求路由到//localhost:9003/sayHello上。

注意上面一组path和url映射的路由名要相同。

这种方式直观容易理解,API网关直接根据请求的URL路径找到最匹配的path表达式,直接转发给该表达式对应的url以实现外部请求的路由。

面向服务的路由

在properties配置文件中配置路由

# Zuul面向服务的配置服务
zuul:
  routes:
    api-hello-server:
      path: /hello-server/**
      service-id: hello-server
    api-customer-server:
      path: /customer-server/**
      service-id: customer-server

在这里分别使用了api-hello-server和pi-customer-server来映射服务提供者(hello-server)和服务消费者(customer-server)的路由。通过上面的配置方式,我们不足要再为每个路由维护微服务的具体实例的位置,而是通过path和service-id的映射,使得维护工作变得非常简单。

这种方式,整合了Eureka来实现。将API网关看作Eureka的一个应用服务,除了将自己注册到Eureka服务注册中心上之外,也会从注册中心获取所有的服务以及他们的实例清单。在Eureka的帮助下,API网关服务就已经维护了所有serviceId与实例地址的映射关系,那么只需要通过Ribbon的负载均衡策略,直接在这些清单种选择一个具体的实例进行转发就能完成路由工作了。

为啥选择yaml作为配置文件

随着版本的迭代,可能会对服务做一个功能的拆分,将原本属于hello-service的某些共鞥你拆分到了另一个全新的hello-service-ext服务中。而这些拆分的外部调用URL路径希望能够符合规则/hello-service/ext/**。所以需要做如下配置:

zuul.routes.hello-service.path=/hello-service/**
zuul.routes.hello-service.serviceId=hello-service
zuul.routes.hello-service-ext.path=/hello-service/ext/**
zuul.routes.hello-service-ext.serviceId=hello-service-ext

此时,调用hello-service-ext服务的 URL路径实际上会同时被/hello-service/** 和/hello-service/ext/** 两个表达式所匹配。在逻辑上,API网关服务需要优先选择/hello-service/ext/** 路由,然后再匹配/hello-service/** 路由才能实现上述需求。但是如果使用上面的配置方式,实际上是无法保证这样的路由优先顺序的。

由于properties的配置内容无法保证有序,所以为了保证路由的优先顺序,需要使用yaml文件来配置,这也是为啥配置zuul的时候要选择使用yaml作为配置文件。

请求过滤

在实现了请求路由功能之后,我们的微服务应用提供的接口就可以通过统一的API网关入口被客户端访问到了。但是每个客户端用户请求微服务应用提供的接口时,他们的访问权限往往都有一定的限制,系统并不会将所有的微服务接口都对他们开放。

为了实现对客户端请求的安全校验和权限控制,最简单的方法就是为每个微服务应用都实现一套用于检验签名和鉴别权限的过滤器或者拦截器。但是,因为同一个系统中的各种检验逻辑很多情况下都是相同或者类似的,这样做的话会出现代码冗余,后期维护异常麻烦。所以比较好的做法时将这些校验逻辑剥离出去,构建出一个独立的鉴权服务。

Zuul允许开发者在API网关上通过定义过滤器来实现对请求的拦截与过滤,实现的方法非常简单,只需要继承ZuulFilter抽象类并实现他定义的4个抽象函数就可以完成对请求的过滤和拦截了。

在这里我们实现一个简单的请求过滤功能:登录系统检验token,如果token不为空,则不可以访问。

/**
 * @className: LoginFilter
 * @description: 实现登录过滤校验
 * @author: charon
 * @create: 2021-07-04 22:46
 */
public class LoginFilter extends ZuulFilter {

    private static Logger log = LoggerFactory.getLogger(LoginFilter.class);

    /**
     * 过滤器的类型,它决定了过滤器在请求的那个生命周期执行,
     * 主要有四种类型:
     * pre: 可以在请求被路由之前调用
     * routing: 在路由请求时被调用
     * post: 在routing和error过滤器之后被调用
     * error: 处理请求时发生错误时被调用
     * @return
     */
    @Override
    public String filterType() {
        return "pre";
    }

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

    /**
     * 判断该过滤器市够需要被执行
     * @return
     */
    @Override
    public boolean shouldFilter() {
        return true;
    }

    /**
     * 过滤器的具体逻辑这里通过context.setSendZuulResponse(false);令zuul过滤该请求,不对其进行路由。
     *
     * @return
     * @throws ZuulException
     */
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        Object token = request.getHeader("token");
        if (Objects.isNull(token)) {
            log.error("token为空,不允许访问");
            context.setSendZuulResponse(false);
            // 防止返回给前端时出现中文乱码
            context.getResponse().setContentType("text/html;charset=utf-8");
            context.setResponseStatusCode(401);
            context.setResponseBody("当前状态未登录,请重新登录。");
            return null;
        }
        log.error("token不为空,允许正常访问");
        return null;
    }
}

为自定义的过滤器创建具体的bean才能启动该过滤器。

@Bean
public LoginFilter loginFilter(){
    return new LoginFilter();
}

在完成了上面的改造之后,重启服务,并使用下面两种请求对其进行验证:

源码分析

在使用zuul的时候,最主要的就是在启动类上添加@EnableZuulProxy的注解,所以我们先从注解开始看。

@EnableCircuitBreaker
@Target(ElementType.TYPE)
@Retention(RetentionPolicy.RUNTIME)
@Import(ZuulProxyMarkerConfiguration.class)
public @interface EnableZuulProxy {
}

可以看到,这个注解类引入了ZuulProxyMarkerConfiguration这个类。跟进这个类:

@Configuration(proxyBeanMethods = false)
public class ZuulProxyMarkerConfiguration {

	@Bean
	public Marker zuulProxyMarkerBean() {
		return new Marker();
	}

	class Marker {

	}

}

发现这个类与Eureka的EurekaServerMarkerConfiguration类一样(作者是同一人),主要就是把Marker类变成了Spring的Bean。作为自动配置Zuul的开关。又了MEurekaServerMarkerConfiguration.Marker这个bean之后,Zuul代理的自动配置类(ZuulProxyAutoConfiguration)就能加载了。

在ZuulProxyAutoConfiguration这个类里注入了一些Filters。

@Bean
@ConditionalOnMissingBean(PreDecorationFilter.class)
public PreDecorationFilter preDecorationFilter(RouteLocator routeLocator,
                                               ProxyRequestHelper proxyRequestHelper) {
    return new PreDecorationFilter(routeLocator,
                                   this.server.getServlet().getContextPath(), this.zuulProperties,
                                   proxyRequestHelper);
}

// route filters
@Bean
@ConditionalOnMissingBean(RibbonRoutingFilter.class)
public RibbonRoutingFilter ribbonRoutingFilter(ProxyRequestHelper helper,
                                               RibbonCommandFactory<?> ribbonCommandFactory) {
    RibbonRoutingFilter filter = new RibbonRoutingFilter(helper, ribbonCommandFactory,
                                                         this.requestCustomizers);
    return filter;
}

@Bean
@ConditionalOnMissingBean({ SimpleHostRoutingFilter.class,
                           CloseableHttpClient.class })
public SimpleHostRoutingFilter simpleHostRoutingFilter(ProxyRequestHelper helper,
                                                       ZuulProperties zuulProperties,
                                                       ApacheHttpClientConnectionManagerFactory connectionManagerFactory,
                                                       ApacheHttpClientFactory httpClientFactory) {
    return new SimpleHostRoutingFilter(helper, zuulProperties,
                                       connectionManagerFactory, httpClientFactory);
}

@Bean
@ConditionalOnMissingBean({ SimpleHostRoutingFilter.class })
public SimpleHostRoutingFilter simpleHostRoutingFilter2(ProxyRequestHelper helper,
                                                        ZuulProperties zuulProperties, CloseableHttpClient httpClient) {
    return new SimpleHostRoutingFilter(helper, zuulProperties, httpClient);
}

而ZuulProxyAutoConfiguration的继承了ZuulServerAutoConfiguration类,引用了一些相关的配置,在缺失ZuulServletBean的情况下注入ZuulServlet,而这个类是Zuul的核心类:

@Bean
@ConditionalOnMissingBean(name = "zuulServlet")
@ConditionalOnProperty(name = "zuul.use-filter", havingValue = "false",
      matchIfMissing = true)
public ServletRegistrationBean zuulServlet() {
   ServletRegistrationBean<ZuulServlet> servlet = new ServletRegistrationBean<>(
         new ZuulServlet(), this.zuulProperties.getServletPattern());
   servlet.addInitParameter("buffer-requests", "false");
   return servlet;
}

同时在这个类中,还注入了其他的过滤器,比如:

  • pre类型的过滤器:ServletDetectionFilter、DebugFilter、Servlet30WrapperFilter
  • post类型的过滤器:SendResponseFilter
  • error类型的过滤器:SendErrorFilter
  • route类型的过滤器:SendForwardFilter

跟进ZuulServlet类,可以看到ZuulServlet直接继承了HttpServlet类,所以ZuulServlet依然是走的http通信协议,跟进ZuulServlet.service方法,这里面清晰的描绘了Zuul的路由过程。

  1. pre、route、post都不抛出异常,顺序是:pre->route->post,error不执行。
  2. pre抛出异常,顺序是:pre->error->post。
  3. route抛出异常,顺序是:pre->route->error->post。
  4. post抛出异常,顺序是:pre->route->post->error。
@Override
public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
    try {
        // 为每个请求生成request和response,存入ConcurrentHashMap中
        init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);
	   // 初始化上下文
        RequestContext context = RequestContext.getCurrentContext();
        context.setZuulEngineRan();
	   // 处理pre类型的过滤器
        try {
            preRoute();
        } catch (ZuulException e) {
            error(e);
            postRoute();
            return;
        }
        // 处理route类型的过滤器
        try {
            route();
        } catch (ZuulException e) {
            error(e);
            postRoute();
            return;
        }
        // 处理post类型的过滤器
        try {
            postRoute();
        } catch (ZuulException e) {
            error(e);
            return;
        }

    } catch (Throwable e) {
        error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName()));
    } finally {
        RequestContext.getCurrentContext().unset();
    }
}

跟进每种过滤器类型的执行方法,可以发现找到Zuul过滤器的核心处理器:FilterProcessor,在这个类中,主要有两个方法:

  • runFilters (String sType):该方法会根据传入的 filterType来调用getFiltersByType (String filterType)获取排序后的过滤器列表,然后轮询这些过滤器,并调用processZuulFilter (ZuulFilter filter)来依次执行它们。
  • processZuulFilter(ZuulFilter filter):该方法定义了用来执行 filter的具体逻辑,包括对请求上下文的设置,判断是否应该执行,执行时一些异常的处理等。

在processZuulFilter()这个方法中最后都是调用的继承了ZuulFilter抽象类的过滤器的各自实现的run()。

Zuul作为网关,主要的实现都包含在了ZuulFilter的实现当中。以一个ConcurrentHashMap实现的RequestContext来传递节点数据。如果想做一些自定义的处理可以通过继承ZuulFilter并重写4个方法即可。

参考文章:

翟永超老师的《Spring Cloud微服务实战》

//blog.csdn.net/weixin_38106322/article/details/103457742

//zhuanlan.zhihu.com/p/28376627