浅谈服务网关和联邦云

笔者最近参与了星环数据云平台的联邦云功能(以下简称联邦云)的设计和开发。联邦云旨在为用户提供一站式的,跨集群、跨租户的计算资源管理。它在网络,认证,API多个维度打通了租户和集群之间的隔阂,并提供一致的用户体验。

由于联邦云这种对租户资源的整合很容易让人联想到网关,所以笔者对网关进行了一些调研。如下图所示,对一种技术的调研可以先进行联想和发散,清楚每种调研对象的能力和大致的用法,就像这张脑图里展示的。

清楚了每种软件的能力以后,就可以对调研对象进行排除和收敛。考虑到公司内部web服务生态以java为主,并且联邦云本身有比较强的业务属性,像Nginx之类的网关应该无法满足需求,所以调研的主要对象还是集中在Java的网关。在网上搜集了一些关于Zuul, Zuul 2 以及 Spring cloud gateway的资料。首先它们都是优秀的网管框架,Zuul曾经是 Spring cloud 中的组件,是基于阻塞的多线程Web服务器实现的。而Zuul 2是基于netty进行实现。Spring cloud gateway 也是一个异步的方案,和 Spring Webflux高度集成,它是 Spring cloud 用于替代 Zuul 的方案。在选择Java系服务网关时可能就需要考虑到这些因素。

  • 扩展性是否能满足业务需求
  • 你的web框架是同步的还是异步的
  • 是否需要考虑到和Spring的集成程度
  • 是否需要考虑高并发,工作负载时CPU密集还是IO密集

结合以上因素和现有Web框架的特性,笔者选择Zuul 作为试行的方案,并对其进行了粗浅的学习。由于Zuul的文档不多,所以有些配置还是需要看一下源码才能知道怎么配置,也就有了这篇文章。

第一部分:网关和联邦云

比起微服务网关,联邦云的场景更加复杂,但是两者又有千丝万缕的联系。例如在Zuul中,请求路由的核心规则是url的模式匹配。通过pattern match,为请求定位到上游服务,不管是基于Servlet,还是基于Spring Dispatcher都是如此。而在联邦云的场景中,我们关心的是集群,租户,租户中的资源,甚至是租户的版本,这一类贴近业务的实体,所以请求路由变得不再聚焦于url,而是具体的资源。

虽然无法直接满足需求,但是 Zuul 提供了一个非常精简,扩展性极强的内核。这使它成为了在联邦云中进行认证注入租户定位请求转发等工作的实现框架。在一个联邦云中,最重要的是资源聚合机制和针对联邦租户专门设计的面向特定租户内资源的路由机制。而Zuul更像是作为一个可插拔的Http请求处理工具。

第二部分:Zuul简介

Zuul 是一个基于同步多线程模式(Servlet)的微服务网关,其核心思想是基于 Filter 模式来实现对HTTP请求的装饰和处理。 由于Zuul提供了通用的编程接口,它的灵活性极强。比起Nginx这样需要借助脚本来实现功能扩展的网关,Zuul可以支持作为一个SDK嵌入在Java Web服务中,所以可以很轻松地实现路由,负载均衡,熔断限流,认证鉴权等功能。除此之外,和企业内部的其他服务,中间件,甚至容器平台的对接都成为可能。

从这张图可以看出,基于请求的生命周期,Filter被分为5类,其中,我们比较常用的可能就是 pre 类型的 Filter。一个常见的场景就是,基于请求的路径,以及服务发现能力,为请求设置对应的 host,这样一来,Zuul 内置的 SimpleHostRoutingFilter 就会把请求发送到正确的位置。

根据官方文档所说,Zuul支持在 Servlet 和 Spring Dispatcher 两种模式下工作。两种模式各有特点,配置的方法也略有不同。

Zuul is implemented as a Servlet. For the general cases, Zuul is embedded into the 
Spring Dispatch mechanism. This lets Spring MVC be in control of the routing. 
In this case, Zuul buffers requests. 
If there is a need to go through Zuul without buffering requests (for example, for large file uploads), 
the Servlet is also installed outside of the Spring Dispatcher. 
By default, the servlet has an address of /zuul. This path can be changed 
with the zuul.servlet-path property.

来自官方文档

本文会对 Servlet 和 Spring MVC Dispatcher 两种模式进行分析,并简单介绍它在联邦云中扮演的角色。

源码分析 - Servlet 集成 Zuul

通过Servlet 继承的zuul就像这张图里展示的:

Zuul 和 Spring MVC 分属两个不同的 Servlet

Tomcat提供了ServletRequestWrapper类供第三方开发者继承,以实现为请求提供Servlet封装的效果。其中 ServletRequestWrapper 提供了对 ServletContext 和 Request 的双重感知。而 HttpServeletRequest 则是提供了额外的HTTP相关的封装。

其中 , HttpServletRequest 接口中提供的 getServletPath 定义了URL中用于调用servlet的部分, getHttpServletMapping()方法则会定义如何处理这个请求。 ServletRequestWrapper 因此也提供了下面两个方法。

/**
 * Servlet Path是URI路中的一部分。它以 / 开头,并指向某个Servlet的名字
 * <p>
 * 如果目标Servlet使用了通配符 /*, 这个方法应当返回空字符串
 */
public String getServletPath();

/**
  HttpServletMapping 也是提供了非常灵活的Servlet匹配策略

    <servlet>
       <servlet-name>MyServlet</servlet-name>
       <servlet-class>MyServlet</servlet-class>
   </servlet>
   <servlet-mapping>
       <servlet-name>MyServlet</servlet-name>
       <url-pattern>/MyServlet</url-pattern>
       <url-pattern>""</url-pattern>
       <url-pattern>*.extension</url-pattern>
       <url-pattern>/path/*</url-pattern>
   </servlet-mapping>
   例如有这样的 Servlet 声明,那么当有如下请求进来时,匹配情况各不相同
   如下图
   

Zuul提供的 zuul.servlet-path,那么这个配置项是如何在一个 Spring 应用中生效的呢?首先,这个配置项会对应到 ZuulProperty 这个属性类中的 servletPath 字段。在 Spring 的配置类中,会有创建一个 ServletRegistrationBean, 在实例化这个 Bean 时会调用 getServletPattern() 这个方法。如下

@Bean
@ConditionalOnMissingBean(name = "zuulServlet")
@ConditionalOnProperty(name = "zuul.use-filter", havingValue = "false", matchIfMissing = true)
public ServletRegistrationBean zuulServlet() {

   // 这里初始化了ZuulServlet,并且将它注册到配置好的pattern上
   // 后续匹配这个pattern的请求将会直接由ZuulServlet处理
   ServletRegistrationBean<ZuulServlet> servlet = new ServletRegistrationBean<>(
         new ZuulServlet(), this.zuulProperties.getServletPattern());
   // The whole point of exposing this servlet is to provide a route that doesn't
   // buffer requests.
   servlet.addInitParameter("buffer-requests", "false");
   return servlet;
}

其中,getServletPattern() 方法的实现如下

    public String getServletPattern() {
        // 在这里调用了servletPath的属性    
    String path = this.servletPath;
    if (!path.startsWith("/")) {
        path = "/" + path;
    }
    if (!path.contains("*")) {
        path = path.endsWith("/") ? (path + "*") : (path + "/*");
    }
    return path;
    }

这个 ServletRegistrationBean 提供了如下的功能

  • 向ServletContext中注册Servlet
  • 为Servlet添加Uri的映射 在这个Bean的帮助下,我们就不再需要访问下层的Servlet框架,而只需要加上 @EnableZuulProxy 的注解,然后让 Spring 自动帮我们进行配置。

注册Servlet的核心流程如下

private ServletRegistration.Dynamic addServlet(String servletName, String servletClass,
        Servlet servlet, Map<String,String> initParams) throws IllegalStateException {

    ...

    Wrapper wrapper = (Wrapper) context.findChild(servletName);

    // Context中的Child一般都是Wrapper,wrapper是对Servlet对象的一层包装。
    if (wrapper == null) {
        wrapper = context.createWrapper();
        wrapper.setName(servletName);
        context.addChild(wrapper);
    } else {
		...
    }

    ServletSecurity annotation = null;
    if (servlet == null) {
        wrapper.setServletClass(servletClass);
        Class<?> clazz = Introspection.loadClass(context, servletClass);
        if (clazz != null) {
            annotation = clazz.getAnnotation(ServletSecurity.class);
        }
    } else {
        // 把 Servlet 实例设置到 wrapper 中,以供后续调用
        wrapper.setServletClass(servlet.getClass().getName());
        wrapper.setServlet(servlet);
        if (context.wasCreatedDynamicServlet(servlet)) {
            annotation = servlet.getClass().getAnnotation(ServletSecurity.class);
        }
    }
	...
    return registration;
}
    

这个 Wrapper 会在 StandardContextValve 类中被使用,也就是。 Valve 是类似与 Filter 的层层嵌套的调用链。区别就是, Valve 是 container级别,也就是在所有servlet外面,而 FilterChain 则是对应具体的servlet。

具体的流程大概就是tomcat处理一个请求的时候会获取请求的路径,然后去先前注册的 Servlet 中去进行匹配。每次匹配到,就将对应的 Servlet 塞到 Request 的上下文中。在 Request 完成后,会调用 recycle() 对其进行清理。

@Override
public final void invoke(Request request, Response response)
    throws IOException, ServletException {

    ...

    Wrapper wrapper = request.getWrapper();
    if (wrapper == null || wrapper.isUnavailable()) {
        ...
    }

    // Acknowledge the request
    ...
    // 在这里会把请求发送到Request中对应的wrapper, 也就是代理给匹配的Servlet
    // 来进行处理
    wrapper.getPipeline().getFirst().invoke(request, response);
}

用例展示 - 用 Servlet 模式集成 Zuul Filter

有了Filter以后,我们希望将它集成到我们的Servlet服务器中。通过上面小节的源码分析,我们知道只需要做如下的配置,Spring框架就可以帮助我们将ZuulServlet注册到服务器中。

zuul.servletPath: /zuul

从上面的源码逻辑可以看出,这个配置最终会被翻译成

/zuul/* -> ZuulServlet

这样的映射关系。所以这样一来,我们直接访问对应的资源地址就可以了,比如/zuul/xxx/xxx

因为servlet会被Spring框架自动注册,所以无需任何额外的路由定义工作,非常简洁。但是有一个缺点,就是servlet path只能配置一次,缺乏灵活性。

源码分析 - 在Spring MVC中集成Zuul

如果选择使用DispatcherServlet 集成 zuul, 那么我们的软件架构就变成了下面的样子。

在这种情况下,么我们可以跳过进入 Servlet 前的所有步骤。关于这些步骤如何工作,可以参考Spring如何集成Servlet容器,以及Servlet的工作流程。Spring MVC的核心之一是 DispatcherServlet ,它支持将请求映射到被注册的 HandlerMapping 中。我们平时使用的@RequestMapping 注解实际上就是帮助我们声明式地完成这些注册。

Spring cloud zuul 也实现了这个 HandlerMapping

public ZuulHandlerMapping(RouteLocator routeLocator, ZuulController zuul) {
   
   // 这里设置了Zuul自己的路由表
   // 用户可以定义这个RouteLocator的实现,并生成Bean
   // Auto config会自动加载这些Bean
   this.routeLocator = routeLocator;
   // 这里设置Zuul Controll, 它实际上只是给
   // Zuul Servlet包了一层皮,从而让Spring把请求Dispatch到Zuul的Servlet中
   this.zuul = zuul;
   setOrder(-200);
}

AutoConfig的类里是这么写的

    @Bean
    public ZuulController zuulController() {
    // 这个Controller几乎没有任何逻辑,只是handle请求
    // Zuul servlet会调用我们定义的ZuulFilter
    return new ZuulController();
    }

    @Bean
    public ZuulHandlerMapping zuulHandlerMapping(RouteLocator routes) {
    // 这边Autowire了route locator,也就是一个composite route locator
    // 意思就是它可以把多个Route Locator的Bean合成一个
    ZuulHandlerMapping mapping = new ZuulHandlerMapping(routes, zuulController());
    mapping.setErrorController(this.errorController);
    mapping.setCorsConfigurations(getCorsConfigurations());
    return mapping;
    }

用例展示 - 用 Spring Dispatcher 集成 Zuul Filter

首先,我们自己已经定义了一些 ZuulFilter,由于 Zuul 支持spring cloud 全家桶,我们只需要写一些 Bean 就可以了。

@Bean
public ZuulFilter actorAuthenticationFilter() {
    return new ActorAuthenticationFilterFactory(ActorLocator.class).apply(actorLocator(), 1);
}

由于在Spring Dispatcher模式下,我们没有直接配置pattern,所以我们对那些需要应用 zuul filter 的请求路径进行路由规则的定义。同样的,只需要写一个 RouteLocator 类型的Bean.

@Bean
RouteLocator zuulDispatchingRouteLocator() {
    return new RouteLocator() {

        // 所有以fed开头的请求会被路由到Zuul的Handler
        // 这里无需写死目标地址,因为我们会通过服务发现机制,在Filter中动态为Context中注入这些地址
        private final Route fedRoute = new Route(
                "fed", ProxyConsts.FEDERATION_EP_PATTERN, "no://op", "", false, new HashSet<>()
        );

        @Override
        public Collection<String> getIgnoredPaths() {
            return Collections.EMPTY_LIST;
        }

        @Override
        public List<Route> getRoutes() {
            // 框架会调用这个方法获取路由,并注册Handler
            return Collections.singletonList(fedRoute);
        }

        @Override
        public Route getMatchingRoute(String path) {
            if (path.startsWith(ProxyConsts.FEDERATION_EP_PREFIX)) {
                return fedRoute;
            }
            return null;
        }
    };
}

这样一来,我们就完成了 Spring Web MVC 和 Zuul 的集成。只需要访问/fed下面的资源,即可将请求代理给我们定义的Zuul Filter,例如

/fed/api/v1/tenants

两种用法的对比

最后,我们可以对比一下 spring cloud zuul 两种用法的异同。主要看一下处理web请求时候的调用栈.

// Should filter 就是我们实现的方法了,走到这一步
// 说明已经成功进入Zuul Filter Chain
shouldFilter:16, TCCFederationPreFilter (io.transwarp.tcc.federation.filters)
runFilter:114, ZuulFilter (com.netflix.zuul)
processZuulFilter:193, FilterProcessor (com.netflix.zuul)
runFilters:157, FilterProcessor (com.netflix.zuul)
preRoute:133, FilterProcessor (com.netflix.zuul)
preRoute:105, ZuulRunner (com.netflix.zuul)
preRoute:125, ZuulServlet (com.netflix.zuul.http)
service:74, ZuulServlet (com.netflix.zuul.http)
internalDoFilter:231, ApplicationFilterChain x 8
...
Valve
...


lookupHandler:86, ZuulHandlerMapping (org.springframework.cloud.netflix.zuul.web)
getHandlerInternal:124, AbstractUrlHandlerMapping (org.springframework.web.servlet.handler)
getHandler:405, AbstractHandlerMapping (org.springframework.web.servlet.handler)
getHandler:1233, DispatcherServlet (org.springframework.web.servlet)
doDispatch:1016, DispatcherServlet (org.springframework.web.servlet)
doService:943, DispatcherServlet (org.springframework.web.servlet)
processRequest:1006, FrameworkServlet (org.springframework.web.servlet)
doGet:898, FrameworkServlet (org.springframework.web.servlet)
service:626, HttpServlet (javax.servlet.http)
service:883, FrameworkServlet (org.springframework.web.servlet)
service:733, HttpServlet (javax.servlet.http)
internalDoFilter:231, ApplicationFilterChain x 8
... 
Valve
...

可以看到,后者确实是多了一层Spring 框架中的DispatcherServlet.

两种模式的另一个不同点官方文档中也说明了,在复用 Spring Dispatcher 时,Zuul 会存在对请求的缓冲行为,这个时候不适用于体积非常大的请求,比如大文件的上传。所以在请求大小比较小的情况下,可以不必动用 zuul 的 Servlet 模式。

实战 - 编写一个用户认证 Zuul Filter

以下是一个模拟在实际开发中对请求进行过滤,认证,转发的逻辑。

public class MyFilter extends ZuulFilter {

    private final RouteService rs;

    public MyFilter(RouteService rs) {
        // 初始化
        // 这里的RouteService继承了服务发现,路由转发和认证功能
        this.rs = rs;
    }

    @Override
    public String filterType() {
        // 这个Filter会在请求被路由之前调用
        return "pre";
    }

    @Override
    public int filterOrder() {
        // 这边定义请求的次序
        // 在实践中,我推荐将所有的Filter order在同一个类中集中管理
        return 5;
    }

    @Override
    public boolean shouldFilter() {
        // 由于是多线程同步模式,一旦这个线程开始处理请求,
        // 这个请求都能通过Context直接获取,不用通过参数进行传递
        // 这里的Context使用Thread Local实现
        HttpServletRequest request = RequestContext
                .getCurrentContext().getRequest();

        // 可以通过uri进行判断是否过滤该请求
        boolean shouldFilter = request.getRequestURI().startsWith("/tdc-fed");

        // 当然也可以通过Attribute等灵活的方式进行判断
        shouldFilter = request.getAttribute("X-TDC-Fed-Remote").equals(true);
        return shouldFilter;
    }

    @Override
    public Object run() throws ZuulException {
        HttpServletRequest request = RequestContext.getCurrentContext().getRequest();
        // 为这个请求获取token
        String token = rs.getToken(request);
        if (token == null) {
            throw new ZuulException("Unauthorized", 401, "Failed to get token");
        }
        // 我们不用去直接修改请求,只需要往Context中设置请求头等参数
        // Zuul 框架会在路由前将Context中的变量覆盖到请求中,非常方便
        RequestContext.getCurrentContext().addZuulRequestHeader(
                "Authorization", "Bearer " + token
        );

        // 这里直接将目标服务的URL设置到Context中
        // 这里的locateService可以集成各种不同的服务发现机制
        RequestContext.getCurrentContext().setRouteHost(rs.locateService(request));

        // 更改请求的路径
        // 这边直接通过继承Request并设置到Context中就能实现
        RequestContext.getCurrentContext().setRequest(new HttpServletRequestWrapper(request) {
            @Override
            public String getRequestURI() {
                return rs.targetUri(request);
            }
        });

        return null;
    }
}

通过如上的 ZuulFilter 实现,我们可以完成一个请求的身份的认证。但是,在网关的实践中,也可能暗藏一些坑,导致服务出现奇怪的行为。以联邦云为例,在联邦云中,每一个成员租户都是一套完整的,包括用户权限认证的服务,在引入网关认证的情况下,很容易引起认证的冲突。如下图所示,服务1和服务2地session会通过响应中地 set-cookie 头,把网关自己的sessionId覆盖掉,导致通过网关认证的用户出现访问异常。

如果上游服务同时具备认证的功能,那么网关无法实现在服务之间流畅地切换,因为cookie会被频繁重置。Zuul 作为成熟地服务网关,当然也考虑到了这类情况。我们通过配置,可以让Zuul忽略一些敏感性地HTTP头,如下所示

zuul.ignoredHeaders:
  - set-cookie

这样,图中所示地这套简单的架构就能按照我们的想法进行工作了。

写在最后

随着异步Web框架的流行,可能很少人再去关注Zuul这类软件了。就连基于ThreadLocal实现的RequestContext 这种设计,也被人诟病为 “为了弥补之前糟糕的设计而做出的妥协”,这里所说的 “糟糕的设计” 当然就是同步多线程的Web编程模式。但是其实Zuul依然是一个足够简单,足够可靠,并且容易维护的微服务网关。基于Filter的编程模式也使得代码可以写得比较通用,有利于降低移植的成本。

而联邦云作为新生的软件,应该考虑到 Web 生态不断迭代的事实,既要合理地使用现成的软件框架来满足需求,也要适当地和它们划清界限,从而在未来技术栈和业务需求的迭代中可以更加敏捷地进行升级。

前段时间看了Complexity is killing software developers,这篇文章所引用的我们作为“技术的消费者”的角色,以及“有机械共鸣的车手”的比喻的论述,确实是很容易引起开发者的共鸣。在这个时代,从事微服务开发的开发者们就像是“糖果屋中地孩子”,不管是CNCF社区丰富的云原生项目,还是Spring Cloud全家桶集成的各种威力强大的微服务SDK,都为我们快速构建微服务提供了巨大的帮助,同时也引入了巨大的复杂度。愿我们这些“在糖果屋中地孩子”都能理性地消费技术,让技术给我们带来价值和乐趣,成为“有机械共鸣”地车手。

引用

https://www.infoworld.com/article/3

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值