Spring Cloud 服务端负载工具 Zuul

Zuul是Netflix公司提供的服务端负载工具,Spring Cloud基于它做了一些整合。试想一下微服务场景下服务端有服务A、服务B、服务C等,每个服务对应不同的地址,作为服务提供者,你不想直接对外暴露服务A、服务B、服务C的地址,而且每种服务又有N台机器提供服务。使用Zuul后,可以同时聚合服务A、服务B、服务C,又可实现服务的负载均衡,即同时聚合多个服务A的提供者。Zuul是作用于服务端的,同时它在提供负载均衡时是基于Ribbon实现的。其实也很好理解,Zuul对于真正的服务提供者来说它又是作为客户端的,所以它使用了客户端负载工具Ribbon。Zuul会把每个请求封装为Hystrix Command,所以它也可能会触发断路器打开。

Spring Cloud应用使用Zuul的第一步是在pom.xml中引入spring-cloud-starter-netflix-zuul

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-zuul</artifactId>
</dependency>

然后在主程序Class上加上@EnableZuulProxy以启用Spring Cloud内置的Zuul反向代理。

@EnableZuulProxy
@SpringBootApplication
public class Application {
    public static void main(String[] args) {
        SpringApplication.run(Application.class, args);
    }
  
}

然后可以在application.properties或application.yml中配置服务对应的路由关系。如下指定了服务hello对应的映射路径是/api/**,即当接收到请求为/api/abc/def时会转发到服务hello对应的/abc/def,因为Zuul在转发时默认会把前缀去掉(默认会去掉*以前的内容)。

zuul.routes.hello=/api/**

由于Zuul是基于Ribbon进行负载均衡的,所以我们可以通过Ribbon配置服务地址的方式给Zuul的某个服务配置服务地址。如下配置了服务hello对应的服务地址是localhost:8900localhost:8901

server.port=8888
zuul.routes.hello=/api/**
hello.ribbon.listOfServers=localhost:8900,localhost:8901

当我们请求http://localhost:8888/api/hello时就会转发为请求http://localhost:8900/hellohttp://localhost:8901/hello。可以通过zuul.prefix=/api为所有的请求指定一个通用的前缀,而这个前缀默认也是会去掉的,比如当进行了下面的配置,在访问http://localhost:8888/api/hello/abc时会转发到http://localhost:8900/abc,因为通用的前缀默认会去掉,特定服务的路由前缀也会去掉。

zuul.prefix=/api
zuul.routes.hello=/hello/**
hello.ribbon.listOfServers=localhost:8900

如果希望通用的前缀不去掉,可以加上zuul.stripPrefix=false,此时通过zuul.prefix指定的前缀就不会去掉了。所以当访问http://localhost:8888/api/hello/abc时会转发到http://localhost:8900/api/abc。如果需要特定服务路由的前缀也不去掉,可以使用zuul.routes..stripPrefix=false,对于hello服务来说就是zuul.routes.hello.stripPrefix=false。此时除了加上这个外,hello服务的路径信息也需要通过zuul.routes..path来配置,所以此时的配置会是如下这样。

zuul.prefix=/api
zuul.stripPrefix=false
zuul.routes.hello.path=/hello/**
zuul.routes.hello.stripPrefix=false
hello.ribbon.listOfServers=localhost:8900

@EnableZuulProxy的自动配置由org.springframework.cloud.netflix.zuul.ZuulProxyAutoConfiguration负责,所有的配置信息由org.springframework.cloud.netflix.zuul.filters.ZuulProperties负责接收。我们可以通过ZuulProperties查看可以配置哪些信息,也可以查看Zuul的一些默认配置。比如可以通过下面的方式指定最大的连接数为100,默认是200;指定Socket的超时时间为3秒,默认是10秒。

zuul.host.maxTotalConnections=100
zuul.host.socketTimeoutMillis=3000

之前定义的zuul.prefixzuul.stripPrefixzuul.routes.*等都来自于ZuulProperties,更多可配置的信息请参考ZuulProperties的API文档或源码。

也可以不使用Ribbon,直接写死服务转发地址。如下配置当请求http://localhost:8888/api/hello/abc时会转发为请求http://localhost:8900/api/hello/abc

server.port=8888
zuul.prefix=/api
zuul.stripPrefix=false
zuul.routes.hello.path=/hello/**
zuul.routes.hello.url=http://localhost:8900
zuul.routes.hello.stripPrefix=false

自定义HttpClient

Zuul默认会使用Apache的Http Client作为向后端服务发起请求的客户端,如果用户想对HttpClient进行一些自定义,则可以定义自己的HttpClient类型的bean。比如下面的代码自定义了HttpClient,其每次请求时都往Header里面写入名为abc,值为123的Header。

@Configuration
public class HttpClientConfig {
    @Bean
    public CloseableHttpClient httpClient() {
        List<Header> defaultHeaders = new ArrayList<>();
        defaultHeaders.add(new BasicHeader("abc", "123"));
        CloseableHttpClient httpClient = HttpClientBuilder.create().setDefaultHeaders(defaultHeaders).build();
        return httpClient;
    }
  
}

用户也可选择自定义自己的HttpClientBuilder类型的bean,因为Spring Cloud在创建HttpClient时会获取HttpClientBuilder类型的bean创建HttpClient,当我们没有自定义HttpClientBuilder类型的bean时Spring Cloud会自动创建一个。

敏感性Header和Cookie

Zuul服务接收到的请求Header可以被转发到负载的底层服务,但是有时候可能你不希望这些Header被转发到底层服务,此时可以通过sensitiveHeaders指定敏感Header。比如我们的Zuul服务是直接面向浏览器客户的,我们不希望浏览器的信息被转发到底层服务,则可以在application.properties中添加如下配置信息,这样就不会往底层服务传递user-agent和cache-control这两个Header。

zuul.sensitiveHeaders=user-agent,cache-control

通过zuul.sensitiveHeaders指定的配置将对所有的服务生效,如果我们只想对某个服务隐藏一些Header,则可以通过该服务的路由配置sensitiveHeaders,比如不希望user-agent和cache-control这两个Header转发到服务hello,则可以进行如下配置。

zuul.routes.hello.sensitiveHeaders=user-agent,cache-control

当同时配置了特定服务配置的sensitiveHeaders和通用的sensitiveHeaders时,特定服务的sensitiveHeaders将拥有更高的优先级,即特定服务的sensitiveHeaders会覆盖通用的sensitiveHeaders。比如通用的sensitiveHeaders配置了敏感Header为ABC,服务hello配置了sensitiveHeaders为BCD,那么请求转发到服务hello时将不会转发Header BCD,但是会继续转发Header ABC。

默认的敏感Header是Cookie、Set-Cookie和Authorization,当自己指定了sensitiveHeaders时,默认的sensitiveHeaders自动失效。

忽略Header

除了敏感性Header可以不转发到底层服务外,还可以通过ignoredHeaders指定需要忽略的Header。ignoredHeaders指定的Header将不转发到底层服务,同时将从底层服务的响应中自动移除。如下配置指定了将忽略user-agent和cache-control这两个Header。

zuul.ignoredHeaders=user-agent,cache-control

ignoredHeaders只能指定通用的,没有特定服务级别的。

Endpoint

当使用@EnableZuulProxy会自动引入routes和filters这两个Endpoint,可以单独发布这两个Endpoint,也可以通过如下方式发布所有的Endpoint。

management.endpoints.web.exposure.include=*

之后可以通过actuator/routes查看所有的路由信息,即服务对应的映射路径信息,类似如下这样。

{
/api/hello/**: "hello"
}

还可以在后面加上/details得到路由的详细信息,即请求/actuator/routes/details,得到的路由详细信息是类似如下这样的。

{
    "/api/hello/**": {
        "id": "hello",
        "fullPath": "/api/hello/**",
        "location": "hello",
        "path": "/hello/**",
        "prefix": "/api",
        "retryable": false,
        "sensitiveHeaders": [
            "upgrade-insecure-requests",
            "accept"
        ],
        "customSensitiveHeaders": true,
        "prefixStripped": false
    }
}

可以通过actuator/filters查看所有的com.netflix.zuul.ZuulFilter及对应的Filter类型等信息,类似如下这样。

{
    "error": [
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.post.SendErrorFilter",
            "order": 0,
            "disabled": false,
            "static": true
        }
    ],
    "post": [
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.post.SendResponseFilter",
            "order": 1000,
            "disabled": false,
            "static": true
        }
    ],
    "pre": [
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.pre.DebugFilter",
            "order": 1,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.pre.FormBodyWrapperFilter",
            "order": -1,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.pre.Servlet30WrapperFilter",
            "order": -2,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.pre.ServletDetectionFilter",
            "order": -3,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.pre.PreDecorationFilter",
            "order": 5,
            "disabled": false,
            "static": true
        }
    ],
    "route": [
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.route.SimpleHostRoutingFilter",
            "order": 100,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.route.RibbonRoutingFilter",
            "order": 10,
            "disabled": false,
            "static": true
        },
        {
            "class": "org.springframework.cloud.netflix.zuul.filters.route.SendForwardFilter",
            "order": 500,
            "disabled": false,
            "static": true
        }
    ]
}

本地转发

Zuul除了把请求转发到外部服务外,还可以把请求转发到本地的@RequestMapping请求。比如Zuul所在应用有如下Controller,其可以接收/local/abc请求。

@RestController
@RequestMapping("local")
public class LocalFowardController {
    @GetMapping("abc")
    public String abc() {
        return "ABC" + LocalDateTime.now();
    }
  
}

然后我们配置名为local1的路由信息,其将把/local1/**请求转发到本地的/local。即当接收到请求/local1/abc时会转发为请求本地的/local/abc,即请求LocalFowardController.abc()

zuul.routes.local1.path=/local1/**
zuul.routes.local1.url=forward:/local

禁用ZuulFilter

Spring Cloud对Zuul的支持是由一系列的ZuulFilter来实现的,它们都定义在org.springframework.cloud.netflix.zuul.filters.xxx下,其中xxx指对应的ZuulFilter。每个ZuulFilter是相互独立的,它们之间会通过com.netflix.zuul.context.RequestContext交互数据,RequestContext还持有当前请求的HttpServletRequest和HttpServletResponse的引用。使用@EnableZuulProxy时会自动创建这些ZuulFilter的bean。如果想禁用其中的某个ZuulFilter,则可以通过设置zuul...disable=true来禁用它。比如想要禁用org.springframework.cloud.netflix.zuul.filters.post.SendResponseFilter,则可以设置zuul.SendResponseFilter.post.disable=true

这里只是拿SendResponseFilter来举个例,实际使用时禁用了SendResponseFilter将不会把代理的Response写入到当前请求的Response中,所以千万不要禁用它。

自定义ZuulFilter

如果有需要也可以定义自己的ZuulFilter,并把它加入到ZuulFilter链中。假设我们想从Zuul开始追踪整个请求,我们可以定义一个ZuulFilter,往Header中写入一个唯一的请求标识,然后在多个ZuulFilter以及后端服务之间进行共享。为此我们定义了如下这样一个ZuulFilter。只需要把它定义为bean即可自动把它加入ZuulFilter链中。

@Component
public class AddRequestIdZuulFilter extends ZuulFilter {
    private static final String REQUEST_ID_HEADER = "X-REQUEST-ID";
  
    @Override
    public boolean shouldFilter() {
        return !RequestContext.getCurrentContext().getZuulRequestHeaders().containsKey(REQUEST_ID_HEADER);
    }
    @Override
    public Object run() throws ZuulException {
        RequestContext context = RequestContext.getCurrentContext();
        context.addZuulRequestHeader(REQUEST_ID_HEADER, UUID.randomUUID().toString());
        return null;
    }
    @Override
    public String filterType() {
        return FilterConstants.PRE_TYPE;
    }
    @Override
    public int filterOrder() {
        return 0;
    }
}

Zuul histrix

Zuul会自动把请求封装为一个Hystrix Command,且@EnableZuulProxy上使用了@EnableCircuitBreaker。可以对路由的服务使用Histrix fallback,当熔断器打开时将调用对应的fallback。需要为特定的路由(或serviceId)指定fallback,可以定义一个FallbackProvider类型的bean,然后通过其getRoute()返回该fallback对应的路由(或serviceId),其fallbackResponse()将在需要发生fallback时调用。

@Component
public class HelloFallbackProvider implements FallbackProvider {
    @Override
    public String getRoute() {
        return "hello";
    }
    @Override
    public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
        return new ClientHttpResponse() {
            @Override
            public InputStream getBody() throws IOException {
                return new ByteArrayInputStream("hello fallback".getBytes());
            }
            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
            @Override
            public HttpStatus getStatusCode() throws IOException {
                return HttpStatus.OK;
            }
            @Override
            public int getRawStatusCode() throws IOException {
                return 200;
            }
            @Override
            public String getStatusText() throws IOException {
                return "OK";
            }
            @Override
            public void close() {
            
            }
        
        };
    }
}

比如上述代码我们定义了FallbackProvider是对应于路由hello的。当该路由拥有下述配置时,如果Zuul请求http://localhost:8900/xxx网络不通,则会转而返回上述的fallback的结果。

zuul.routes.hello=/hello/**
hello.ribbon.listOfServers=localhost:8900

FallbackProvider也可以是作用于所有的路由的,此时只需指定FallbackProvider的getRoute()的返回值为*。其作用类似于默认FallbackProvider,当同时指定了默认的FallbackProvider和作用于特定的路由的FallbackProvider时,特定路由的FallbackProvider拥有更高的优先级。

@Component
public class DefaultFallbackProvider implements FallbackProvider {
    @Override
    public String getRoute() {
        return "*";
    }
    @Override
    public ClientHttpResponse fallbackResponse(String route, Throwable cause) {
        return new ClientHttpResponse() {
            @Override
            public InputStream getBody() throws IOException {
                return new ByteArrayInputStream("hello fallback".getBytes());
            }
            @Override
            public HttpHeaders getHeaders() {
                HttpHeaders headers = new HttpHeaders();
                headers.setContentType(MediaType.APPLICATION_JSON);
                return headers;
            }
            @Override
            public HttpStatus getStatusCode() throws IOException {
                return HttpStatus.OK;
            }
            @Override
            public int getRawStatusCode() throws IOException {
                return 200;
            }
            @Override
            public String getStatusText() throws IOException {
                return "OK";
            }
            @Override
            public void close() {
            
            }
        
        };
    }
}

指定超时时间

Zuul调用后端服务使用Ribbon时可以通过Ribbon的配置属性来指定建立连接的超时时间和调用远程服务的超时时间。如下配置指定了Zuul使用Ribbon进行负载,且与后端服务建立连接的超时时间是3秒,调用后端服务的接口的超时时间是2秒。

zuul.routes.hello=/hello/**
hello.ribbon.listOfServers=localhost:8900
ribbon.ReadTimeout=2000
ribbon.ConnectTimeout=3000

如果Zuul不使用Ribbon进行负载,而是直接指定路由对应的后端服务地址,则超时时间需要通过如下方式指定。

zuul.routes.hello.path=/hello/**
zuul.routes.hello.url=http://localhost:8900
zuul.host.socket-timeout-millis=2000
zuul.host.connect-timeout-millis=3000

和Eureka一起使用

Zuul底层使用Ribbon进行负载,所以Zuul和Eureka一起使用相当于Ribbon和Eureka一起使用。先在pom.xml中加上Eureka client的依赖。

<dependency>
    <groupId>org.springframework.cloud</groupId>
    <artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>

然后在application.properties中加上Eureka的配置,Ribbon会自动使用Eureka进行服务发现。

zuul.routes.hello=/hello/**
eureka.client.registerWithEureka=false
eureka.client.serviceUrl.defaultZone=http://localhost:8089/eureka/

Zuul默认会把serviceId映射为/serviceId/**,即如果有一个服务hello,其对应的映射路径默认是/hello/**。所以当我们的服务可以满足这种需求时可以不通过zuul.routes.serviceId指定服务的映射路径。这样的话如果你通过Eureka注册了10个服务,那他们都会通过Zuul进行自动映射,如果你的Zuul是直接对外的,那么可能你不希望其中的某些服务通过Zuul对外暴露。此时可以zuul.ignored-services属性指定需要忽略的服务id。比如下面的配置指定了将忽略服务hello1和hello2。

zuul.ignored-services=hello1,hello2

也可以像如下这样忽略所有的服务,然后再通过routes指定需要对外暴露的映射信息,如下就指定了需要对外暴露hello服务,且对应的映射路径是/hello/**

zuul.ignored-services=*
zuul.routes.hello=/hello/**

自动重试

有时候可能由于网络波动等原因,Zuul在转发请求到后端服务会失败。Zuul可以设置在转发请求到后端服务失败时自动发起重试。使用这种自动重试机制需要先在pom.xml中引入Spring retry依赖。

<dependency>
    <groupId>org.springframework.retry</groupId>
    <artifactId>spring-retry</artifactId>
</dependency>

然后在application.properties文件中配置zuul.retryable=true以启用自动重试。这样转发请求失败时默认将对GET请求发起重试,且默认在同一目标机器发起的重试次数是0,最多跨域一台目标机器。即当调用的服务S同时有机器A、B、C提供服务的时候,如果第一次调用的是机器A,失败后不会再调用A,会转而调用B或C一次。可以通过ribbon.OkToRetryOnAllOperations=true指定对所有请求类型都可以进行重试,不管是GET还是POST,还是其它。可以通过ribbon.MaxAutoRetries指定在同一机器上的最大重试次数。可以通过ribbon.MaxAutoRetriesNextServer指定最多重试的机器数。比如当拥有下面配置时,如果我们请求的hello服务同时有机器A、B、C提供服务,第一次调用A如果失败了,会在A继续重试两次,如果重试了两次都没成功,就会转而重试B,B一共最多重试3次,第一次不算重试,最终如果还是失败的,那C也是一样的重试。还可以通过ribbon.retryableStatusCodes来指定需要进行重试的Http状态码,比如只希望在状态码为500或502时进行重试,则配置ribbon.retryableStatusCodes=500,502。默认情况只要服务器通讯正常都不会重试,即状态码不管是404还是502等都不会发起重试,只有建立连接失败或者请求超时会重试。所以如果我们需要在状态码为502的时候也能发起重试则需要指定retryableStatusCodes。

zuul.retryable=true
ribbon.OkToRetryOnAllOperations=true
hello.ribbon.MaxAutoRetries=2
ribbon.MaxAutoRetriesNextServer=2

使用ribbon.xxx配置的是对所有服务都通用的配置,使用<serviceId>.ribbon.xxx配置的是对特定服务的配置,如上面的hello.ribbon.MaxAutoRetries

也可以通过zuul.routes.routename.retryable来单独控制某个服务是否允许重试。比如单独指定可以对hello服务进行重试则可以配置zuul.routes.hello.retryable=true。如果全局的zuul.retryable=true,则也可以通过zuul.routes.hello.retryable=false指定hello服务不重试。

参考文档