背景
在之前的文章中,我們介紹過微服務網關Spring Cloud Netflix Zuul,前段時間有兩篇文章專門介紹了Spring Cloud的全新項目Spring Cloud Gateway,以及其中的過濾器工廠。本文将會介紹将微服務網關由Zuul遷移到Spring Cloud Gateway。
Spring Cloud Netflix Zuul是由Netflix開源的API網關,在微服務架構下,網關作為對外的門戶,實作動态路由、監控、授權、安全、排程等功能。
Zuul基于servlet 2.5(使用3.x),使用阻塞API。 它不支援任何長連接配接,如websockets。而Gateway建立在Spring Framework 5,Project Reactor和Spring Boot 2之上,使用非阻塞API。 比較完美地支援異步非阻塞程式設計,先前的Spring系大多是同步阻塞的程式設計模式,使用thread-per-request處理模型。即使在Spring MVC Controller方法上加@Async注解或傳回DeferredResult、Callable類型的結果,其實仍隻是把方法的同步調用封裝成執行任務放到線程池的任務隊列中,還是thread-per-request模型。Gateway 中Websockets得到支援,并且由于它與Spring緊密內建,是以将會是一個更好的開發體驗。
在一個微服務內建的項目中microservice-integration,我們整合了包括網關、auth權限服務和backend服務。提供了一套微服務架構下,網關服務路由、鑒權和授權認證的項目案例。整個項目的架構圖如下:
具體參見:微服務架構中整合網關、權限服務。本文将以該項目中的Zuul網關更新作為示例。
Zuul網關
在該項目中,Zuul網關的主要功能為路由轉發、鑒權授權和安全通路等功能。
Zuul中,很容易配置動态路由轉發,如:
zuul:
ribbon:
eager-load:
enabled: true #zuul饑餓加載
host:
maxTotalConnections: 200
maxPerRouteConnections: 20
routes:
user:
path: /user/**
ignoredPatterns: /consul
serviceId: user
sensitiveHeaders: Cookie,Set-Cookie
預設情況下,Zuul在請求路由時,會過濾HTTP請求頭資訊中的一些敏感資訊,這裡我們不過多介紹。
網關中還配置了請求的鑒權,結合Auth服務,通過Zuul自帶的Pre過濾器可以實作該功能。當然還可以利用Post過濾器對請求結果進行适配和修改等操作。
除此之外,還可以配置限流過濾器和斷路器,下文中将會增加實作這部分功能。
遷移到Spring Cloud Gateway筆者建立了一個gateway-enhanced的項目,因為變化很大,不适合在之前的gateway項目基礎上修改。實作的主要功能如下:路由轉發、權重路由、斷路器、限流、鑒權和黑白名單等。本文基于主要實作如下的三方面功能:
- 路由斷言
- 過濾器(包括全局過濾器,如斷路器、限流等)
- 全局鑒權
- 路由配置
- CORS
依賴
本文采用的Spring Cloud Gateway版本為2.0.0.RELEASE。增加的主要依賴如下,具體的細節可以參見Github上的項目。
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
<!--<version>2.0.1.RELEASE</version>-->
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-gateway-webflux</artifactId>
</dependency>
</dependencies>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Finchley.RELEASE</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
路由斷言
Spring Cloud Gateway對于路由斷言、過濾器和路由的定義,同時支援配置檔案的shortcut和Fluent API。我們将以在本項目中實際使用的功能進行講解。
路由斷言在網關進行轉發請求之前進行判斷路由的具體服務,通常可以根據請求的路徑、請求體、請求方式(GET/POST)、請求位址、請求時間、請求的HOST等資訊。我們主要用到的是基于請求路徑的方式,如下:
spring:
cloud:
gateway:
routes:
- id: service_to_web
uri: lb://authdemo
predicates:
- Path=/demo/**
我們定義了一個名為service_to_web的路由,将請求路徑以/demo/**的請求都轉發到authdemo服務執行個體。
我們在本項目中路由斷言的需求并不複雜,下面介紹通過Fluent API配置的其他路由斷言:
@Bean
public RouteLocator routeLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route(r -> r.host("**.changeuri.org").and().header("X-Next-Url")
.uri("http://blueskykong.com"))
.route(r -> r.host("**.changeuri.org").and().query("url")
.uri("http://blueskykong.com"))
.build();
}
在如上的路由定義中,我們配置了以及請求HOST、請求頭部和請求的參數。在一個路由定義中,可以配置多個斷言,采取與或非的關系判斷。
以上增加的配置僅作為擴充,讀者可以根據自己的需要進行配置相應的斷言。
過濾器
過濾器分為全局過濾器和局部過濾器。我們通過實作GlobalFilter、GatewayFilter接口,自定義過濾器。
全局過濾器
本項目中,我們配置了如下的全局過濾器:
- 基于令牌桶的限流過濾器
- 基于漏桶算法的限流過濾器
- 全局斷路器
- 全局鑒權過濾器
定義全局過濾器,可以通過在配置檔案中,增加spring.cloud.gateway.default-filters,或者實作GlobalFilter接口。
基于令牌桶的限流過濾器
随着時間流逝,系統會按恒定 1/QPS 時間間隔(如果 QPS=100,則間隔是 10ms)往桶裡加入 Token,如果桶已經滿了就不再加了。每個請求來臨時,會拿走一個 Token,如果沒有 Token 可拿了,就阻塞或者拒絕服務。
令牌桶的另外一個好處是可以友善的改變速度。一旦需要提高速率,則按需提高放入桶中的令牌的速率。一般會定時(比如 100 毫秒)往桶中增加一定數量的令牌,有些變種算法則實時的計算應該增加的令牌的數量。
在Spring Cloud Gateway中提供了預設的實作,我們需要引入redis的依賴:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
并進行如下的配置:
spring:
redis:
host: localhost
password: pwd
port: 6378
cloud:
default-filters:
- name: RequestRateLimiter
args:
key-resolver: "#{@remoteAddrKeyResolver}"
rate-limiter: "#{@customRateLimiter}" # token
注意到,在配置中使用了兩個SpEL表達式,分别定義限流鍵和限流的配置。是以,我們需要在實作中增加如下的配置:
@Bean(name = "customRateLimiter")
public RedisRateLimiter myRateLimiter(GatewayLimitProperties gatewayLimitProperties) {
GatewayLimitProperties.RedisRate redisRate = gatewayLimitProperties.getRedisRate();
if (Objects.isNull(redisRate)) {
throw new ServerException(ErrorCodes.PROPERTY_NOT_INITIAL);
}
return new RedisRateLimiter(redisRate.getReplenishRate(), redisRate.getBurstCapacity());
}
@Bean(name = RemoteAddrKeyResolver.BEAN_NAME)
public RemoteAddrKeyResolver remoteAddrKeyResolver() {
return new RemoteAddrKeyResolver();
}
在如上的實作中,初始化好RedisRateLimiter和RemoteAddrKeyResolver兩個Bean執行個體,RedisRateLimiter是定義在Gateway中的redis限流屬性;而RemoteAddrKeyResolver使我們自定義的,基于請求的位址作為限流鍵。如下為該限流鍵的定義:
public class RemoteAddrKeyResolver implements KeyResolver {
private static final Logger LOGGER = LoggerFactory.getLogger(RemoteAddrKeyResolver.class);
public static final String BEAN_NAME = "remoteAddrKeyResolver";
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
LOGGER.debug("token limit for ip: {} ", exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
RemoteAddrKeyResolver實作了KeyResolver接口,覆寫其中定義的接口,傳回值為請求中的位址。
如上,即實作了基于令牌桶算法的鍊路過濾器,具體細節不再展開。
基于漏桶算法的限流過濾器
漏桶(Leaky Bucket)算法思路很簡單,水(請求)先進入到漏桶裡,漏桶以一定的速度出水(接口有響應速率),當水流入速度過大會直接溢出(通路頻率超過接口響應速率),然後就拒絕請求,可以看出漏桶算法能強行限制資料的傳輸速率。
這部分實作讀者參見GitHub項目以及文末配套的書,此處略過。
全局斷路器
關于Hystrix斷路器,是一種服務容錯的保護措施。斷路器本身是一種開關裝置,用于在電路上保護線路過載,當線路中有發生短路狀況時,斷路器能夠及時的切斷故障電路,防止發生過載、起火等情況。
微服務架構中,斷路器模式的作用也是類似的,當某個服務單元發生故障之後,通過斷路器的故障監控,直接切斷原來的主邏輯調用。關于斷路器的更多資料和Hystrix實作原理,讀者可以參考文末配套的書。
這裡需要引入spring-cloud-starter-netflix-hystrix依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-hystrix</artifactId>
<optional>true</optional>
</dependency>
并增加如下的配置:
default-filters:
- name: Hystrix
args:
name: fallbackcmd
fallbackUri: forward:/fallbackcontroller
如上的配置,将會使用HystrixCommand打包剩餘的過濾器,并命名為fallbackcmd,我們還配置了可選的參數fallbackUri,降級邏輯被調用,請求将會被轉發到URI為/fallbackcontroller的控制器處理。定義降級處理如下:
@RequestMapping(value = "/fallbackcontroller")
public Map<String, String> fallBackController() {
Map<String, String> res = new HashMap();
res.put("code", "-100");
res.put("data", "service not available");
return res;
}
全局鑒權過濾器
我們通過自定義一個全局過濾器實作,對請求合法性的鑒權。具體功能不再贅述了,通過實作GlobalFilter接口,差別的是Webflux傳入的是ServerWebExchange,通過判斷是不是外部接口(外部接口不需要登入鑒權),執行之前實作的處理邏輯。
public class AuthorizationFilter implements GlobalFilter, Ordered {
//....
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
ServerHttpRequest request = exchange.getRequest();
if (predicate(exchange)) {
request = headerEnhanceFilter.doFilter(request);
String accessToken = extractHeaderToken(request);
customRemoteTokenServices.loadAuthentication(accessToken);
LOGGER.info("success auth token and permission!");
}
return chain.filter(exchange);
}
//提出頭部的token
protected String extractHeaderToken(ServerHttpRequest request) {
List<String> headers = request.getHeaders().get("Authorization");
if (Objects.nonNull(headers) && headers.size() > 0) { // typically there is only one (most servers enforce that)
String value = headers.get(0);
if ((value.toLowerCase().startsWith(OAuth2AccessToken.BEARER_TYPE.toLowerCase()))) {
String authHeaderValue = value.substring(OAuth2AccessToken.BEARER_TYPE.length()).trim();
// Add this here for the auth details later. Would be better to change the signature of this method.
int commaIndex = authHeaderValue.indexOf(',');
if (commaIndex > 0) {
authHeaderValue = authHeaderValue.substring(0, commaIndex);
}
return authHeaderValue;
}
}
return null;
}
}
定義好全局過濾器之後,隻需要配置一下即可:
@Bean
public AuthorizationFilter authorizationFilter(CustomRemoteTokenServices customRemoteTokenServices,
HeaderEnhanceFilter headerEnhanceFilter,
PermitAllUrlProperties permitAllUrlProperties) {
return new AuthorizationFilter(customRemoteTokenServices, headerEnhanceFilter, permitAllUrlProperties);
}
局部過濾器
我們常用的局部過濾器有增減請求和相應頭部、增減請求的路徑等多種過濾器。我們這裡用到的是去除請求的指定字首,這部分字首隻是使用者網關進行路由判斷,在轉發到具體服務時,需要去除字首:
- id: service_to_user
uri: lb://user
order: 8000
predicates:
- Path=/user/**
filters:
- AddRequestHeader=X-Request-Foo, Bar
- StripPrefix=1
還可以通過Fluent API,如下:
@Bean
public RouteLocator retryRouteLocator(RouteLocatorBuilder builder) {
return builder.routes()
.route("retry_java", r -> r.path("/test/**")
.filters(f -> f.stripPrefix(1)
.retry(config -> config.setRetries(2).setStatuses(HttpStatus.INTERNAL_SERVER_ERROR)))
.uri("lb://user"))
.build();
}
除了設定字首過濾器外,我們還設定了重試過濾器,可以參見:Spring Cloud Gateway中的過濾器工廠:重試過濾器
路由配置
路由定義在上面的示例中已經有列出,可以通過配置檔案和定義RouteLocator的對象。這裡需要注意的是,配置中的uri屬性,可以是具體的服務位址(IP+端口号),也可以是通過服務發現加上負載均衡定義的:lb://user,表示轉發到user的服務執行個體。當然這需要我們進行一些配置。
引入服務發現的依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-consul-discovery</artifactId>
</dependency>
網關中開啟spring.cloud.gateway.discovery.locator.enabled=true即可。
CORS配置
在Spring 5 Webflux中,配置CORS,可以通過自定義WebFilter實作:
private static final String ALLOWED_HEADERS = "x-requested-with, authorization, Content-Type, Authorization, credential, X-XSRF-TOKEN";
private static final String ALLOWED_METHODS = "GET, PUT, POST, DELETE, OPTIONS";
private static final String ALLOWED_ORIGIN = "*";
private static final String MAX_AGE = "3600";
@Bean
public WebFilter corsFilter() {
return (ServerWebExchange ctx, WebFilterChain chain) -> {
ServerHttpRequest request = ctx.getRequest();
if (CorsUtils.isCorsRequest(request)) {
ServerHttpResponse response = ctx.getResponse();
HttpHeaders headers = response.getHeaders();
headers.add("Access-Control-Allow-Origin", ALLOWED_ORIGIN);
headers.add("Access-Control-Allow-Methods", ALLOWED_METHODS);
headers.add("Access-Control-Max-Age", MAX_AGE);
headers.add("Access-Control-Allow-Headers",ALLOWED_HEADERS);
if (request.getMethod() == HttpMethod.OPTIONS) {
response.setStatusCode(HttpStatus.OK);
return Mono.empty();
}
}
return chain.filter(ctx);
};
}
上述代碼實作比較簡單,讀者根據實際的需要配置ALLOWED_ORIGIN等參數。
總結
在高并發和潛在的高延遲場景下,網關要實作高性能高吞吐量的一個基本要求是全鍊路異步,不要阻塞線程。Zuul網關采用同步阻塞模式不符合要求。
Spring Cloud Gateway基于Webflux,比較完美地支援異步非阻塞程式設計,很多功能實作起來比較友善。Spring5必須使用java 8,函數式程式設計就是java8重要的特點之一,而WebFlux支援函數式程式設計來定義路由端點處理請求。
通過如上的實作,我們将網關從Zuul遷移到了Spring Cloud Gateway。在Gateway中定義了豐富的路由斷言和過濾器,通過配置檔案或者Fluent API可以直接調用和使用,非常友善。在性能上,也是勝于之前的Zuul網關。