上一篇中,我們構(gòu)建了一個簡單的Spring Cloud Demo項(xiàng)目,涵蓋了服務(wù)注冊/發(fā)現(xiàn),服務(wù)間的相互調(diào)用,以及熔斷降級等內(nèi)容。但如果服務(wù)需要暴露給外部進(jìn)行使用,比如移動端,或者web端,則還需要考慮更多的事情。整個服務(wù)端的部署情況對于外部調(diào)用方應(yīng)該是一個黑盒,外部調(diào)用方無法了解到每個服務(wù)具體是部署到哪一個IP或者域名下面,為了安全性也不太可能允許外部調(diào)用方直接連接到Consul去查詢服務(wù)注冊的情況,這樣我們就需要一個服務(wù)網(wǎng)關(guān)來集中對外部請求進(jìn)行路由和負(fù)載均衡,同時驗(yàn)證調(diào)用方的權(quán)限和身份。如下圖所示:

基礎(chǔ)介紹
服務(wù)網(wǎng)關(guān)的概念有點(diǎn)類似于傳統(tǒng)的反向代理服務(wù)器(如nginx),但反向代理一般都只是做業(yè)務(wù)無關(guān)的轉(zhuǎn)發(fā)請求,而服務(wù)網(wǎng)關(guān)與服務(wù)的整合程度更高,可以看作也是整個服務(wù)體系的組成部分,通過過濾器等組件可以在網(wǎng)關(guān)中集成一些業(yè)務(wù)處理的操作(比如權(quán)限認(rèn)證等)。Spring Cloud Gateway正是Spring官方推出的服務(wù)網(wǎng)關(guān)的實(shí)現(xiàn)框架,它主要包含三個核心的概念:
- Route: 負(fù)責(zé)將某個外部請求路由到一個合適的地址,包含一個ID,一個目標(biāo)地址,一系列的Predicate和Filter;
- Predicate: 基于Java 8 Function Predicate的斷言機(jī)制,用于將請求匹配到某一個Route
- Filter: 類似于Servlet filter,可以在請求傳遞給下一級處理器之前對請求或響應(yīng)進(jìn)行修改,用于實(shí)現(xiàn)權(quán)限驗(yàn)證,日志記錄,限流等功能
整個工作流程如下圖所示:

網(wǎng)關(guān)集成
我們現(xiàn)在來為我們的demo項(xiàng)目加入一個服務(wù)網(wǎng)關(guān)。首先需要創(chuàng)建一個新的模塊,名字叫Gateway,在pom.xml中加入如下依賴:
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
在application.yml中加入如下內(nèi)容:
server:
port: 9000
spring:
application:
name: gateway
cloud:
consul:
host: 192.168.1.220
port: 8500
discovery:
prefer-ip-address: true
gateway:
routes:
- id: order-service
#lb協(xié)議會激活LoadBalancerClient來解析后續(xù)的地址,自動根據(jù)注冊的服務(wù)實(shí)例進(jìn)行負(fù)載均衡
uri: lb://order-service
filters:
- Log
# 轉(zhuǎn)發(fā)時去掉請求地址的服務(wù)名前綴
- StripPrefix=1
predicates:
- Path=/order-service/**
從以上配置可以很容易看出來,gateway模塊其實(shí)也會注冊到consul中成為一個服務(wù),并通過consul獲取其它服務(wù)的相關(guān)信息。上面的配置中我們加入了一個名為order-service的路由,其中predicates定義了這個路由的匹配規(guī)則,也就是訪問路徑以/order-service/開頭的請求,就會被路由到 lb://order-service的地址 (地址代表的含義參見注釋)。
斷言
predicates用于定義route的匹配規(guī)則,可以針對請求的幾乎所有內(nèi)容進(jìn)行匹配,例如針對特定的header進(jìn)行匹配:
predicates:
- Header=X-Request-Id, \d+**
針對Cookie進(jìn)行匹配:
predicates:
- Cookie=mycookie,mycookievalue
匹配特定域名的請求
predicates:
- Host=**.somehost.org,**.anotherhost.org
更多predicates種類的介紹可以查看 這里
過濾器
剛才的路由配置中,我們定義了兩個過濾器: Log,StripPrefix,這些都屬于GatewayFilter,每個Route可以定義多個GatewayFilter。Spring Cloud Gateway已經(jīng)內(nèi)置了多個很有用的GatewayFilter實(shí)現(xiàn),例如StripPrefix就是內(nèi)置的用于轉(zhuǎn)發(fā)時修改請求地址的過濾器。其它內(nèi)置過濾器的作用可以查看 這里。如果內(nèi)置過濾器不能滿足我們的需求,那就需要自行實(shí)現(xiàn)新的過濾器了。
我們現(xiàn)在來添加一個簡單的過濾器日志過濾器,用于打印出每次請求所花費(fèi)的時間:
@Slf4j
public class LogGatewayFilterFactory extends AbstractGatewayFilterFactory<LogGatewayFilterFactory.Config> {
private static final String REQUEST_START_TIME = "request_start_time";
public LogGatewayFilterFactory() {
// 這里需要將自定義的config傳過去,否則會報(bào)告ClassCastException
super(Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) -> {
exchange.getAttributes().put(REQUEST_START_TIME, System.currentTimeMillis());
return chain.filter(exchange).then(
Mono.fromRunnable(() -> {
Long startTime = exchange.getAttribute(REQUEST_START_TIME);
if (startTime != null) {
log.info("請求地址:{},消耗時間:{}ms", exchange.getRequest().getURI(), System.currentTimeMillis() - startTime);
}
})
);
};
}
public static class Config {
}
}
自定義過濾器需要實(shí)現(xiàn)一個新的GatewayFilterFactory,其類名也需要遵循XXXGatewayFilterFactory的規(guī)則,這樣的話在配置中只需要配置“XXX”的部分就可以正常被識別了,例如 LogGatewayFilterFactory就只需要配置成“Log”就行了。代碼中的內(nèi)部類Config是用于接收配置時傳遞的參數(shù)(類似于Log=true),這里不需要參數(shù)所以只是一個空類。需要注意的是Spring Cloud Gateway是使用 Spring WebFlux 來構(gòu)建的,所以filter這里的寫法是基于Reactor異步模式的,和傳統(tǒng)的同步請求模式(如Spring MVC)不太一樣。
定義了新的過濾器之后需要將其注冊到容器:
@Bean
public LogGatewayFilterFactory logGatewayFilterFactory() {
return new LogGatewayFilterFactory();
}
GatewayFilter都是基于Route進(jìn)行配置的,Spring Cloud Filter還定義了一種GlobalFilter,不需要在配置文件中配置,作用在所有的路由上。GlobalFilter同樣支持自定義新的過濾器,只需要實(shí)現(xiàn)GlobalFilter和Ordered接口即可,詳細(xì)情況我們后面在講到權(quán)限的時候再介紹。
權(quán)限管理
服務(wù)網(wǎng)關(guān)的一大作用就是可以對外部的請求進(jìn)行集中權(quán)限認(rèn)證,這樣每個具體的服務(wù)就不用操心權(quán)限管理的問題了,可以專心于業(yè)務(wù)的實(shí)現(xiàn)。基本的思路是外部客戶端首先需要獲取一個由系統(tǒng)中獨(dú)立的認(rèn)證中心負(fù)責(zé)簽發(fā)的accessToken,然后每次請求服務(wù)時在http header中攜帶該Token,服務(wù)網(wǎng)關(guān)負(fù)責(zé)校驗(yàn)accessToken的有效性以及是否具備訪問該服務(wù)的權(quán)限,具體的思路和我之前介紹單系統(tǒng)權(quán)限管理的思路比較類似,可以查看 Spring Boot整合Shiro和JWT的無狀態(tài)權(quán)限管理方案 這篇文章。
我們首先需要在服務(wù)網(wǎng)關(guān)中定義一個GlobalFilter對所有的外部請求進(jìn)行過濾,代碼如下:
@Slf4j
public class AuthGlobalFilter implements GlobalFilter, Ordered {
private AuthService authService;
private AuthConfigProperties authConfig;
public AuthGlobalFilter(AuthConfigProperties authConfig, AuthService authService) {
this.authConfig = authConfig;
this.authService = authService;
}
@Override
public Mono<Void> filter(ServerWebExchange exchange, GatewayFilterChain chain) {
String reqPath = exchange.getRequest().getURI().getPath();
String token = exchange.getRequest().getHeaders().getFirst(authConfig.getHeaderKeyOfToken());
if (!authService.verifyToken(reqPath, token)) {
log.warn("沒有授權(quán)的訪問,{}", reqPath);
exchange.getResponse().setStatusCode(HttpStatus.UNAUTHORIZED);
return exchange.getResponse().setComplete();
}
//獲取token中存儲的用戶唯一標(biāo)識,并放入request header中,供后端業(yè)務(wù)服務(wù)使用
String account = authService.getAccountByToken(token);
ServerHttpRequest request = exchange.getRequest().mutate()
.header(authConfig.getHeaderKeyOfAccount(), account).build();
return chain.filter(exchange.mutate().request(request).build());
}
/**
* 過濾器的優(yōu)先級,越低越高
*/
@Override
public int getOrder() {
return 1;
}
}
功能很簡單,就是對請求頭部的token進(jìn)行校驗(yàn),如果成功就將從token中解析出來的用戶賬戶信息放入轉(zhuǎn)發(fā)的請求頭中供后端的業(yè)務(wù)服務(wù)使用,否則返回UNAUTHORIZED。這個Filter也需要注冊到容器中:
@Bean
public AuthGlobalFilter authGlobalFilter(AuthService authService) {
return new AuthGlobalFilter(authConfig, authService);
}
對token進(jìn)行校驗(yàn)的核心邏輯在authService.verifyToken方法中,代碼如下:
/**
* 驗(yàn)證token的有效性及是否具備對該url的訪問權(quán)限,
* 判定規(guī)則參考了shiro的一些設(shè)定
*/
public boolean verifyToken(String url, String token) {
if (Strings.isNullOrEmpty(token)) {
return false;
}
//獲取每個Url所對應(yīng)的權(quán)限控制符
String urlPermission = getUrlPermission(url);
if ("anno".equals(urlPermission)) {
return true;
} else {
//獲取token中包含的用戶唯一標(biāo)識
String account = jwtHelper.getAccount(token);
if (Strings.isNullOrEmpty(account)) {
return false;
}
//獲取token的加密密鑰
String secret = getUserSecret(account);
//校驗(yàn)accessToken
if (jwtHelper.verify(token, secret) == null) {
return false;
}
// 如果url僅要求驗(yàn)證用戶有效性,則直接通過
if (Strings.isNullOrEmpty(urlPermission) ||
"authc".equals(urlPermission)) {
return true;
}
// 進(jìn)一步判斷用戶權(quán)限
if (urlPermission.startsWith("perms")) {
Set<String> userPerms = this.getUserPermissions(account);
String perms = urlPermission.substring(urlPermission.indexOf("[") + 1, urlPermission.lastIndexOf("]"));
return userPerms.containsAll(Arrays.asList(perms.split(",")));
}
}
return false;
}
服務(wù)網(wǎng)關(guān)首先需要知道不同的服務(wù)地址需要什么樣的權(quán)限才允許訪問,這里采用了類似Shiro配置的格式,類似這樣如下的格式,實(shí)際環(huán)境中可能是從數(shù)據(jù)庫或配置文件中讀取:
/**
* 獲取所有的接口url與用戶權(quán)限的映射關(guān)系,格式仿造了shiro的權(quán)限配置格式
*/
public Map<String, String> getAllUrlPermissionsMap() {
Map<String, String> urlPermissionsMap = Maps.newHashMap();
urlPermissionsMap.put("/api/order/orders", "authc");
urlPermissionsMap.put("/api/order/create-order", "perms[order]");
urlPermissionsMap.put("/api/storage/**", "perms[storage]");
return urlPermissionsMap;
}
通過Spring 提供的工具類AntPathMatcher,就可以查詢到每個請求url所需要的權(quán)限標(biāo)識符,再根據(jù)權(quán)限標(biāo)識符去檢查token對應(yīng)的用戶是否具備相應(yīng)的權(quán)限。對這部分感興趣的同學(xué)可以去查看源碼。
本文的相關(guān)代碼可以查看這里 spring-cloud-demo