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