淺談服務閘道器和聯邦雲

星環科技發表於2021-12-10




筆者最近參與了星環資料雲平臺的聯邦雲功能(以下簡稱聯邦雲)的設計和開發。聯邦雲旨在為使用者提供一站式的,跨叢集、跨租戶的計算資源管理。它在網路,認證,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,都為我們快速構建微服務提供了巨大的幫助,同時也引入了巨大的複雜度。願我們這些“在糖果屋中地孩子”都能理性地消費技術,讓技術給我們帶來價值和樂趣,成為“有機械共鳴”地車手。

引用

infoworld.com/article/3


來自 “ ITPUB部落格 ” ,連結:http://blog.itpub.net/69994106/viewspace-2846959/,如需轉載,請註明出處,否則將追究法律責任。

相關文章