Spring Cloudコンポーネント: Zuulの基本概念と実装ガイド

一、Zuulの概要

1、ゲートウェイの役割

分散アーキテクチャでは、サービスノードの数が多くなります。クライアントにとって、複数のサービスノードが公開するAPIは統一されるべきです。そうでなければ、各ノードのアドレスが異なるため、クライアントはすべてのサービスノードのアドレスを管理し、その中から一つを選んでアクセスする必要があり、クライアントのメンテナンスコストが明らかに高くなります。

このような場合、複数のサービスノードをカプセル化し、統一されたAPIを公開するゲートウェイサービスが必要になります。クライアントはゲートウェイにアクセスし、ゲートウェイがクライアントのリクエストをカプセル化されたサービスノードに分散させることで、クライアントとサービス間の直接のやり取りを回避できます。

さらに、ゲートウェイは動的ルーティング機能を実現できるだけでなく、パラメータ検証、認証、ログ記録、キャッシュ、負荷分散、監視、レートリミットなどの機能も実現できます。要するに、ビジネス要件ではない統一機能はすべてゲートウェイに任せることができます。設計思想はSpringのアスペクト指向プログラミングに似ています。

2、Zuulとは何か

ZuulはNetflixがオープンソースで公開したマイクロサービスシステムのゲートウェイコンポーネントです。

Zuulの役割は多岐にわたります。主な機能は以下の通りです:

  1. RibbonとEurekaと連携して、スマートな動的ルーティングと負荷分散機能を実現し、リクエストを戦略に基づいてクラスタ内の適切なサービスインスタンスに分散します。

  2. サービスとクライアント間の結合を解除し、クライアントはゲートウェイが公開する統一APIにアクセスします。クライアントはサービスの稼働状況を気にする必要がありません。

  3. 統一された認証、監視、ログ記録、キャッシュ、レートリミット、パラメータ検証などの機能。

  4. トラフィック監視に基づき、トラフィックに応じてサービスのデグレードを実行できます。

3、Zuulの動作フロー

Zuulは本質的にリクエストを制御するServletであり、一連のフィルターを作成するのが核心です。Zuulには以下の4種類のフィルターが含まれています:

  1. PREフィルター:リクエストが特定のサービスにルーティングされる前に実行され、セキュリティ検証、アイデンティティ検証、パラメータ検証などの事前作業に使用できます。

  2. ROUTINGフィルター:リクエストを特定のサービスインスタンスにルーティングするために使用され、デフォルトではHttpClientを使用してネットワークリクエストを実行します。

  3. POSTフィルター:リクエストがマイクロサービスにルーティングされた後に実行され、通常は統計情報や指標を収集し、クライアントに応答を返すために使用されます。

  4. ERRORフィルター:他のフィルターで例外が発生した場合に実行されます。

各タイプのフィルターの実行順序は、PREフィルター→ROUTINGフィルター→POSTフィルターの順であり、例外が発生した場合はERRORフィルターが実行されます。

二、Zuulの実践

Zuulもサービスであるため、Zuul-Serverサービスを構築する必要があります。

Zuul関連の依存関係を追加します。

 1 <dependencies>
 2         <dependency>
 3             <groupId>org.springframework.cloud</groupId>
 4             <artifactId>spring-cloud-starter-eureka</artifactId>
 5             <version>1.4.7.RELEASE</version>
 6         </dependency>
 7         <dependency>
 8             <groupId>org.springframework.cloud</groupId>
 9             <artifactId>spring-cloud-starter-zuul</artifactId>
10             <version>1.4.7.RELEASE</version>
11         </dependency>
12         <dependency>
13             <groupId>org.springframework.boot</groupId>
14             <artifactId>spring-boot-starter-web</artifactId>
15             <version>2.5.0</version>
16         </dependency>
17         <dependency>
18             <groupId>org.springframework.boot</groupId>
19             <artifactId>spring-boot-starter-test</artifactId>
20         </dependency>
21     </dependencies>

Zuul関連の設定をapplication.ymlに追加します。

 1 server:
 2   port: 5000
 3 spring:
 4   application:
 5     name: zuul-server
 6 eureka:
 7   client:
 8     serviceUrl:
 9       defaultZone: http://localhost:8761/eureka/
10 zuul:
11   routes:
12     product:
13       path: /product/**
14       serviceId: product-service
15     order:
16       path: /order/**
17       serviceId: order-service

ここでserverはZuulサービスの設定を示し、eurekaはZuulもEurekaクライアントであるためEurekaを設定する必要があることを示しています。最後にゲートウェイ設定zuulがあり、routesはルーティング設定です。serviceIdを設定してルーティングするサービス名を指定し、pathはルーティングするサービスアドレスを指定します。設定後、zuulは指定されたserviceIdのリクエストを指定されたサービスに分散できます。

起動クラスを追加し、関連するアノテーションを追加します。

 1 @SpringBootApplication
 2 @EnableEurekaClient
 3 @EnableZuulProxy
 4 public class GatewayApplication {
 5 
 6     public static void main(String[] args){
 7         SpringApplication.run(GatewayApplication.class);
 8         System.out.println("Gateway service starting...");
 9     }
10 }

@EnableEurekaClientアノテーションは、ZuulServerがEurekaクライアントであることを示し、@EnableZuulProxyアノテーションは現在がZuulサーバーであり、Zuul機能を有効にすることを示します。

GatewayApplicationクラスを起動すると、リクエストルーティング機能が完了します。http://localhost:5000/product/xxxxにアクセスすると、リクエストがproductサービスのAPIに転送されます。

異なるロジックのフィルターを追加します。例えばパラメータ検証を行う場合、カスタムのValidationFilterフィルターを作成し、親クラスZuulFilterを継承し、ZuulFilterの抽象メソッドを実装します。

@Component
public class ValidationFilter extends ZuulFilter{

    @Override
    public String filterType() {
        return "pre";
    }

    @Override
    public int filterOrder() {
        return 1;
    }

    @Override
    public boolean shouldFilter() {
        return true;
    }

    @Override
    public Object run() throws ZuulException {
        //リクエストコンテキストを取得
        RequestContext context = RequestContext.getCurrentContext();
        HttpServletRequest request = context.getRequest();
        
        //パラメータマップを取得して検証
        Map<String, String[]> paramMap = request.getParameterMap();
        for (Map.Entry<String, String[]> entry : paramMap.entrySet()) {
            if (entry.getValue() == null || entry.getValue().length == 0) {
                System.out.println("パラメータ検証失敗: パラメータが空です: " + entry.getKey());
                context.setSendZuulResponse(false);
                context.setResponseStatusCode(400);
                return null;
            }
        }
        return null;
    }
}

filterTypeメソッドを実装して現在がPREフィルターであることを示し、filterOrderメソッドはフィルターの順序を返し、値が小さいほど優先度が高くなります。runメソッドはフィルターの具体的な処理ロジックです。

三、Zuulの実装原理

3.1、@EnableZuulProxyアノテーション

Zuulの使用は比較的簡単で、起動クラスに@EnableZuulProxyアノテーションを追加するだけで済みます。したがって、Zuulのすべての機能はこのアノテーションを中心に展開されます。@EnableZuulProxyの役割は、ZuulProxyMarkerConfigurationのインスタンスをロードすることです。@EnableZuulProxyの定義は以下の通りです:

1 @EnableCircuitBreaker
2 @Target(ElementType.TYPE)
3 @Retention(RetentionPolicy.RUNTIME)
4 @Import(ZuulProxyMarkerConfiguration.class)
5 public @interface EnableZuulProxy {
6 }

そして、ZuulProxyMarkerConfigurationの役割はZuulProxyのマーカーインスタンスを注入することです。定義は以下の通りです:

 1 @Configuration
 2 public class ZuulProxyMarkerConfiguration {
 3     @Bean
 4     public Marker zuulProxyMarkerBean() {
 5         return new Marker();
 6     }
 7 
 8     class Marker {
 9     }
10 }

内部クラスMarkerの唯一の役割はマーカーとして使用されることです。したがって、どこでこのマーカーが使用されているかを見る必要があります。参照検索により、参照されている場所はZuulProxyAutoConfigurationであり、親クラスはZuulServerAutoConfigurationです。定義は以下の通りです:

@Configuration
@EnableConfigurationProperties({ ZuulProperties.class })
@ConditionalOnClass(ZuulServlet.class)
@ConditionalOnBean(ZuulServerMarkerConfiguration.Marker.class)
public class ZuulServerAutoConfiguration {

親クラスZuulServerAutoConfigurationでは、SpringコンテナにServletRegistrationBeanインスタンスを注入します。このインスタンスではZuulServletが作成されます。Zuulの核心機能はこのZuulServletにあります。Zuulが受信するすべてのリクエストは、最終的にZuulServletのserviceメソッドによって処理されます。

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

3.2、ZuulServlet

ZuulServletはHttpServletを継承し、initメソッドでZuulRunnerオブジェクトを作成します。核心機能はserviceメソッドにあり、すべてのリクエストはserviceメソッドに到達します。ソースコードは以下の通りです:

@Override
    public void service(javax.servlet.ServletRequest servletRequest, javax.servlet.ServletResponse servletResponse) throws ServletException, IOException {
        try {
            // 1. ZuulRunnerオブジェクトの初期化
            init((HttpServletRequest) servletRequest, (HttpServletResponse) servletResponse);
            // 2. HttpRequestリクエストコンテキストの取得
            RequestContext context = RequestContext.getCurrentContext();
            context.setZuulEngineRan();

            try {
                // 3. preフィルターの実行
                preRoute();
            } catch (ZuulException e) {
                // 例外時はerrorフィルターとpostフィルターを実行
                error(e);
                postRoute();
                return;
            }
            try {
                // 4. routeフィルターの実行
                route();
            } catch (ZuulException e) {
                // 例外時はerrorフィルターとpostフィルターを実行
                error(e);
                postRoute();
                return;
            }
            try {
                // 5. postフィルターの実行
                postRoute();
            } catch (ZuulException e) {
                // 例外時はerrorフィルターを実行
                error(e);
                return;
            }

        } catch (Throwable e) {
            error(new ZuulException(e, 500, "UNHANDLED_EXCEPTION_" + e.getClass().getName()));
        } finally {
            // ローカルキャッシュRequestContextのクリーンアップ
            RequestContext.getCurrentContext().unset();
        }
    }

全体のフローは比較的明確で、PREフィルター→ROUTEフィルター→POSTフィルターの順で実行されます。例外が発生した場合はERRORフィルターが実行されます。また、POSTフィルターの実行前に例外が発生した場合でもPOSTフィルターが実行されるため、POSTフィルターが必ず実行されることが保証されます。

ERRORフィルターは他のフィルターで例外が発生した場合にのみ実行されます。また、フィルターのロジックを実行する前にRequestContextを初期化し、フィルターの実行後にRequestContextをクリーンアップします。

ZuulServletのpreRoute、route、postRoute、errorメソッドは具体的な実装がなく、すべてZuulRunnerに対応するメソッドの実行を委譲します。ZuulRunnerも具体的な実行者ではなく、FilterProcessorに実行を委譲します。

3.3、FilterProcessor

FilterProcessorはフィルターの具体的な実行者であり、シングルトンオブジェクトです。ZuulServletの各種フィルターは最終的にFilterProcessorのpreRoute()、route()、postRoute()、error()メソッドを実行します。そして、これらの4つのメソッドはすべてFilterProcessorの内部メソッドrunFilters(String filterType)を実行します。

runFilterのソースコードは以下の通りです:

 1 public Object runFilters(String sType) throws Throwable {
 2         if (RequestContext.getCurrentContext().debugRouting()) {
 3             Debug.addRoutingDebug("Invoking {" + sType + "} type filters");
 4         }
 5         boolean bResult = false;
 6         // 1. 指定タイプのフィルターリストを取得
 7         List<ZuulFilter> list = FilterLoader.getInstance().getFiltersByType(sType);
 8         if (list != null) {
 9             for (int i = 0; i < list.size(); i++) {
10                 ZuulFilter zuulFilter = list.get(i);
11                 // 2. フィルターを順次実行
12                 Object result = processZuulFilter(zuulFilter);
13                 if (result != null && result instanceof Boolean) {
14                     bResult |= ((Boolean) result);
15                 }
16             }
17         }
18         return bResult;
19     }

まずFilterLoaderオブジェクトのgetFilterByTypeメソッドを呼び出して、フィルタータイプに基づいてフィルターリストをクエリし、次にprocessZuulFilterメソッドを呼び出してフィルターのロジックを実行します。

3.3.1、フィルターの取得

すべてのZuulフィルターはZuulFilterインターフェースを実装する必要があり、@Componentアノテーションで修飾されてSpringコンテナに注入される必要があります。ZuulServerAutoConfigurationクラスには静的内部クラスZuulFilterConfigurationがあり、このクラスの役割はSpringコンテナ内のすべてのZuulFilterインスタンスを取り出すことです。コードは以下の通りです:

/** ZuulServerAutoConfiguration内部クラス */
    @Configuration
    protected static class ZuulFilterConfiguration {

        @Autowired
        private Map<String, ZuulFilter> filters;

        @Bean
        public ZuulFilterInitializer zuulFilterInitializer(
                CounterFactory counterFactory, TracerFactory tracerFactory) {
            FilterLoader filterLoader = FilterLoader.getInstance();
            FilterRegistry filterRegistry = FilterRegistry.instance();
            return new ZuulFilterInitializer(this.filters, counterFactory, tracerFactory, filterLoader, filterRegistry);
        }
    }

ZuulFilterConfigurationはまずすべてのZuulFilterオブジェクトを注入し、Mapで保存します。ZuulFilterInitiailizerの役割はフィルター登録器を初期化することです。コードは以下の通りです:

    @PostConstruct
    public void initializeFilters() {
        log.info("フィルター初期化を開始します");

        TracerFactory.initialize(tracerFactory);
        CounterFactory.initialize(counterFactory);

        for (Map.Entry<String, ZuulFilter> entry : this.filters.entrySet()) {
            filterRegistry.put(entry.getKey(), entry.getValue());
        }
    }

FilterRegistryはフィルター登録器であり、内部ではMap filtersを使用してすべてのZuulFilterフィルターインスタンスを保存しています。

ZuulRunnerクラスのrunFiltersメソッドに戻ると、FilterLoaderのgetFiltersByTypeメソッドを通じて指定タイプのフィルターを取得します。したがって、FilterLoaderの役割はFilterRegistry内のフィルターを異なるタイプに分類し、Map hashFiltersByTypeを使用して異なるタイプのフィルターリストをキャッシュすることです。

フィルターの取得ロジックは比較的単純で、SpringコンテナからすべてのZuulFilterインスタンスを取得し、異なるタイプに基づいてグループ化してキャッシュするだけです。

3.3.2、フィルターの実行

 1 /** フィルターの実行 */
 2     public Object executeFilter(ZuulFilter filter) throws ZuulException {
 3         // 1. リクエストコンテキストの取得
 4         RequestContext ctx = RequestContext.getCurrentContext();
 5         boolean debugMode = ctx.debugRouting();
 6         final String metricPrefix = "zuul.filter-";
 7         long executionTime = 0;
 8         String filterName = "";
 9         try {
10             long startTime = System.currentTimeMillis();
11             filterName = filter.getClass().getSimpleName();
12 
13             RequestContext contextCopy = null;
14             Object result = null;
15             Throwable exception = null;
16 
17             if (debugMode) {
18                 Debug.addRoutingDebug("フィルター " + filter.filterType() + " " + filter.filterOrder() + " " + filterName);
19                 contextCopy = ctx.copy();
20             }
21             // 2. フィルターのrunFilterメソッドを実行
22             ZuulFilterResult filterResult = filter.runFilter();
23             ExecutionStatus status = filterResult.getStatus();
24             executionTime = System.currentTimeMillis() - startTime;
25 
26             switch (status) {
27                 // 3. 実行結果をRequestContextコンテキストに保存
28                 case FAILED:
29                     exception = filterResult.getException();
30                     ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), executionTime);
31                     break;
32                 case SUCCESS:
33                     result = filterResult.getResult();
34                     ctx.addFilterExecutionSummary(filterName, ExecutionStatus.SUCCESS.name(), executionTime);
35                     if (debugMode) {
36                         Debug.addRoutingDebug("フィルター {" + filterName + " TYPE:" + filter.filterType() + " ORDER:" + filter.filterOrder() + "} 実行時間 = " + executionTime + "ms");
37                         Debug.compareContextState(filterName, contextCopy);
38                     }
39                     break;
40                 default:
41                     break;
42             }
43 
44             if (exception != null) throw exception;
45 
46             usageNotifier.notify(filter, status);
47             // 4. 実行結果を返す
48             return result;
49 
50         } catch (Throwable e) {
51             if (debugMode) {
52                 Debug.addRoutingDebug("フィルターの実行に失敗しました " + filterName + " type:" + filter.filterType() + " order:" + filter.filterOrder() + " " + e.getMessage());
53             }
54             usageNotifier.notify(filter, ExecutionStatus.FAILED);
55             if (e instanceof ZuulException) {
56                 throw (ZuulException) e;
57             } else {
58                 ZuulException ex = new ZuulException(e, "フィルターが例外をスローしました", 500, filter.filterType() + ":" + filterName);
59                 ctx.addFilterExecutionSummary(filterName, ExecutionStatus.FAILED.name(), executionTime);
60                 throw ex;
61             }
62         }
63     }

全体のロジックは、まずグローバルリクエストコンテキストRequestContextを取得し、次にZuulFilterのrunFilterメソッドを呼び出してフィルターのロジックを実行し、最後にzuulFilterの実行結果をRequestContextコンテキストに保存して実行結果を返すだけです。

3.4、Zuulの組み込みフィルター

ZuulフィルターはカスタムのZuulFilter実装クラスを追加することで実装できます。同時に、Zuulは多くの組み込みフィルターを提供しています。主なものはRibbonRoutingFilterであり、このフィルターは負荷ルーティング転送機能を提供し、Ribbonの負荷分散機能を統合しています。

まとめ:

Zuulは本質的にServletであり、Springコンテナによって一連のZuulFilterインスタンスを管理しています。各ZuulFilterにはタイプがあり、preRoute、route、postRoute、errorの4種類があります。異なるタイプは実行順序とタイミングが異なります。リクエストがZuulに入ると、ZuulServletが受信し、serviceメソッドで処理します。serviceメソッドのロジックは、Springコンテナから各種のZuulFilterフィルターインスタンスを見つけ、順序とタイミングに基づいてフィルターの処理ロジックを順次実行することです。使用時には、カスタムのZuulFilterを通じてリクエストの異なる段階で機能を拡張できます。

タグ: Spring Cloud Zuul Microservices Gateway Eureka

7月14日 23:52 投稿