写点什么

浅谈服务网关和联邦云

作者:星环科技
  • 2021 年 12 月 10 日
  • 本文字数:8471 字

    阅读完需:约 28 分钟

浅谈服务网关和联邦云

浅谈服务网关和联邦云第一部分:网关和联邦云第二部分:Zuul 简介源码分析 - Servlet 集成 Zuul 用例展示 - 用 Servlet 模式集成 Zuul Filter 源码分析 - 在 Spring MVC 中集成 Zuul 用例展示 - 用 Spring Dispatcher 集成 Zuul Filter 两种用法的对比


实战 - 编写一个用户认证 Zuul Filter 写在最后引用


笔者最近参与了星环数据云平台的联邦云功能(以下简称联邦云)的设计和开发。联邦云旨在为用户提供一站式的,跨集群、跨租户的计算资源管理。它在网络,认证,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 theSpring 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 changedwith 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() 对其进行清理。


@Overridepublic 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 会自动加载这些 Beanthis.routeLocator = routeLocator;// 这里设置 Zuul Controll, 它实际上只是给// Zuul Servlet 包了一层皮,从而让 Spring 把请求 Dispatch 到 Zuul 的 Servlet 中 this.zuul = zuul;setOrder(-200);}AutoConfig 的类里是这么写的


@Beanpublic ZuulController zuulController() {// 这个Controller几乎没有任何逻辑,只是handle请求// Zuul servlet会调用我们定义的ZuulFilterreturn new ZuulController();}
@Beanpublic 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 就可以了。


@Beanpublic ZuulFilter actorAuthenticationFilter() {return new ActorAuthenticationFilterFactory(ActorLocator.class).apply(actorLocator(), 1);}由于在 Spring Dispatcher 模式下,我们没有直接配置 pattern,所以我们对那些需要应用 zuul filter 的请求路径进行路由规则的定义。同样的,只需要写一个 RouteLocator 类型的 Bean.


@BeanRouteLocator 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 ChainshouldFilter: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;}
@Overridepublic String filterType() { // 这个Filter会在请求被路由之前调用 return "pre";}
@Overridepublic int filterOrder() { // 这边定义请求的次序 // 在实践中,我推荐将所有的Filter order在同一个类中集中管理 return 5;}
@Overridepublic 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;}
@Overridepublic 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

用户头像

星环科技

关注

还未添加个人签名 2020.10.22 加入

领航大数据与人工智能基础软件新纪元

评论

发布
暂无评论
浅谈服务网关和联邦云