Spring Cloud Gateway入門demo
網關描述
在微服務的架構中,每一個服務都是在獨立的運行的,而一個完整的微服務系統,都是由這些一個個獨立運行的服務組成的。每個服務各施其職。各個微服務之間的聯系通過REST API或者RPC完成通信。 比如一個場景是: 用戶要查看一個商品信息,我們知道一個商品的頁面會有: 商品的信息,廣告,評論,庫存等等。到這里就會涉及到有4個服務了,如果我們沒有網關的話,可能就要調用多個服務去獲取信息,但是可能會出現一些問題,問題如下:
- 客戶端需要發起多次請求,增加了網絡通信的成本及客戶端處理的復雜性。(如:多個服務的話就要知道多個服務的url地址)
- 服務的鑒權會分布在每個微服務中處理,客戶端對於每個服務的調用都需要重復鑒權。
- 在后端的微服務架構中,可能不同的服務采用的協議不同,比如有 HTTP、RPC 等。客戶端如果需要調用多個服務,需要對不同協議進行適配
網關的功能
- 性能:API高可用,負載均衡,容錯機制。
- 安全:權限身份認證、脫敏,流量清洗,后端簽名(保證全鏈路可信調用),黑名單(非法調用的限制)。
- 日志:日志記錄(spainid,traceid)一旦涉及分布式,全鏈路跟蹤必不可少。
- 緩存:數據緩存。
- 監控:記錄請求響應數據,api耗時分析,性能監控。
- 限流:流量控制,錯峰流控,可以定義多種限流規則。
- 灰度:線上灰度部署,可以減小風險。
- 路由:動態路由規則
常見的網關方案:
- OpenResty(Nginx+lua)
- Kong,是基於openresty之上的一個封裝,提供了更簡單的配置方式。 它還提供了付費的商業插件
- Tyk(開源、輕量級),Tyk 是一個開源的、輕量級的、快速可伸縮的 API 網關,支持配額和速度限制,支持認證和數據分析,支持多用戶多組織,提供全 RESTful API。它是基於go語言開發的組件。
- Zuul,是spring cloud生態下提供的一個網關服務,性能相對來說不是很高
- Spring Cloud Gateway,是Spring團隊開發的高性能網關
Spring Cloud Gateway概述:
spring-cloud-Gateway
是spring-cloud
的一個子項目。而zuul
則是netflix
公司的項目,只是spring將zuul
集成在spring-cloud中使用而已。因為zuul2.0
連續跳票和zuul1
的性能表現不是很理想,所以催生了spring團隊開發了Gateway
項目。
zuul1.x和spring gateway對比:
- Zuul1.x構建於 Servlet 2.5,兼容 3.x,使用的是阻塞式的 API,不支持長連接,比如 websockets。
- Spring Cloud Gateway構建於 Spring 5+,基於 Spring Boot 2.x 響應式的、非阻塞式的 API。同時,它支持 websockets,和 Spring 框架緊密集成,開發體驗相對來說十分不錯。
注意:現在zuul2.x已經開發出來了。但是spring cloud沒有將zuul2.x集成到spring cloud當中,現在的spring cloud zuul組件還是1.x的。所以用網關還是優先使用spirng cloud gateway把
spring cloud gateway組成和執行過程
spring cloud 的路由信息可以通過RouteDefinition
這個類去查看。 這個類里面包含了 id, predicates斷言, filters過濾器,uri 轉發的地址等等這幾個的成員變量,當請求到達網關時 ,首先會基於predicates斷言去判斷該請求滿不滿足需求,滿足的話就進行下面一系列的filter , 然后再轉發到目標uri去。
spring cloud gateway的demo搭建
pom.xml
<dependencies>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-dependencies</artifactId>
<version>Hoxton.SR4</version>
<type>pom</type>
<scope>import</scope>
</dependency>
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-gateway</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>
注意:Hoxton.SR4這個版本是需要添加spring-boot-starter-validation的,不然會報錯的。其他版本不清楚會不會
yml配置:
spring:
application:
name: gateway-demo
cloud:
gateway:
enabled: true
routes:
#路徑的匹配,StripPrefix代表信
- id: path_route
predicates:
- Path=/baidu
filters:
- StripPrefix=1
uri: https://www.bilibili.com
#cookie的匹配
- id: cookie_route
predicates:
- Cookie=chocolate,ch.p
uri: https://www.bilibili.com
#請求頭匹配
- id: header_route
predicates:
- Header=X-Request-Id, \d+
uri: https://www.bilibili.com
# 組合匹配
- id: compose
predicates:
- Path=/compose
- Header=name, cong
uri: https://www.bilibili.com
filters:
- StripPrefix=1
可以看到有許多的路由信息,大概都是會有:id,predicates,filters,uri這幾個參數。
注意點:
-
uri真的是只是取uri而已,比如你設置uri:http://127.0.0.1:8071/orders,他還是只是取http://127.0.0.1:8071這一段而已
-
請求網關的地址,會把網關的uri后面那段地址添加到,uri后面上去。比如 請求網關地址: http://127.0.0.1:8080/abc ,
uri是https://www.bilibili.com, 最后訪問的地址就是 https://www.bilibili.com/abc了, 至於如果想去掉/abc,可以使用StripPrefix=1這個過濾器去掉。
-
路由的優先級級別是按照你配置的順序來的,如果前面的斷言已經匹配上了。后面的斷言就不會走了
例如下面這個例子:
- id: path_route1
predicates:
- Header=name, cong
uri: https://www.bilibili.com
- id: path_route2
predicates:
- Path=/abc
filters:
- StripPrefix=1
uri: https://baidu.com
至於為什么為什么沒跳到bilbili,是因為最終訪問的是https://www.bilibili.com/abc 所以肯定是返回出錯的.這里也驗證了注意點的第二點了
斷言和過濾器配置方式:
斷言和過濾配置方式分成兩種:一種是Shortcut Configuration,一種是Fully Expanded Arguments,以下的配置方式功能是相同的,都是如果cookie存在mycookie=mycookievalue的話,斷言判斷成功的。
- id: Fully_Expanded
predicates:
- name: Cookie
args:
name: mycookie
regexp: mycookievalue
uri: https://www.bilibili.com
- id: short_cut
predicates:
- Cookie=mycookie,mycookievalue
uri: https://www.bilibili.com
至於args的信息在哪里找。因為是通過Cookie斷言的,所以可在CookieRoutePredicateFactory.Config類上面看到相應的參數。
斷言的解析
常用的斷言有:
- 路徑匹配,實現的類是PathRoutePredicateFactory
- cookie匹配, 實現的類是CookieRoutePredicateFactory
- 頭部匹配, 實現的類是HeaderRoutePredicateFactory
其他的可以去到官網上面看。
自定義斷言
自定義一個自己的斷言,其實這個斷言跟Header頭部匹配差不多一樣的。功能就是判斷頭部有沒有key為Authorization,value為token
仿照HeaderRoutePredicateFactory,寫了一個自己的自定義類
代碼實現:
* project name : cloud-demo
* Date:2020/10/3
* Author: yc.guo
* DESC: 需要頭部帶上authentication才能讓其通過
*/
@Component
public class AuthRoutePredicateFactory extends AbstractRoutePredicateFactory<AuthRoutePredicateFactory.Config> {
public AuthRoutePredicateFactory() {
super(AuthRoutePredicateFactory.Config.class);
}
public List<String> shortcutFieldOrder() {
return Arrays.asList("name", "value");
}
@Override
public Predicate<ServerWebExchange> apply(AuthRoutePredicateFactory.Config config) {
return serverWebExchange -> {
List<String> list = serverWebExchange.getRequest().getHeaders().get(config.name);
if(list!=null && list.size() > 0 && list.contains(config.value)){
return true;
}
return false;
};
}
@Validated
public static class Config {
@NotEmpty
private String name;
private String value;
public String getName() {
return name;
}
public AuthRoutePredicateFactory.Config setName(String name) {
this.name = name;
return this;
}
public String getValue() {
return value;
}
public AuthRoutePredicateFactory.Config setValue(String value) {
this.value = value;
return this;
}
public Config() {
}
}
}
yml:
- id: define
predicates:
- Auth=Authorization,token
uri: https://www.bilibili.com
注意點:
- 要加上@Component,注入到容器中
- 至於Auth=Authorization,token中的Auth表達式是怎樣得到的,程序會把實現類AuthRoutePredicateFactory后面RoutePredicateFactory的這部分截取掉,最后就只剩下Auth,所以就是這樣子得到的Auth表達式的
- 如果想使用shortcut配置方式的話,要重寫shortcutFieldOrder這個方法。比如上面的配置- Auth=Authorization,token , 因為我重寫的實現是這樣的Arrays.asList("name", "value"); 最后按照順序來解析就成了: name=Authorization value=token
過濾器
Filter分為全局過濾器和路由過濾器。路由過濾器只是針對單個路由的,全局過濾器是針對於所有的路由的,優先級應該是路由過濾器先執行,再到全局過濾器執行
路由過濾器
RouteFilter路由過濾器基本有:
-
StripPrefix - 實現類:StripPrefixGatewayFilterFactory 。
列子:路徑是http://127.0.0.1/a/b/c/d ,StripPrefix=2的話 ,得到的結果http://127.0.0.1/c/d
-
限流過濾器RequestRateLimiter-實現類:RequestRateLimiterGatewayFilterFactory,注意:這個類不能使用shortCut方式因為他沒有實現shortcutFieldOrder
限流過濾器
限流過濾器通過redis還有令牌桶算法去實現的。 令牌桶算法簡單的可以把他認為是: 一個很有特色的奶茶店,但是他是每天只能供應50杯奶茶。 那每天只賣50杯了,想喝的話明天請早。令牌桶的意思就是比如,每秒鍾可以補充10個令牌桶(),總令牌桶容量可以達到30個。
第一秒內拿走了25個 30-25=5; 第一秒之后補充10個:15個
第二秒沒人拿走: 15 第二秒后補充10個: 15+10=25
第三秒沒人拿走: 25 第三秒后補充10個: 這里因為容量是30,所以就是30
第四秒需要拿走35: 0,還有5個吃閉門羹了 第四秒后補充10個: 10個
pom.xml文件:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis-reactive</artifactId>
</dependency>
yml配置:
filters:
- StripPrefix=1
- name: RequestRateLimiter
args:
deny-empty-key: true
keyResolver: '#{@ipAddressKeyResolver}' #通過ip作為key值
redis-rate-limiter.replenishRate: 1 #每秒補充的令牌數
redis-rate-limiter.burstCapacity: 2 #令牌容量大小
#redis的配置
spinrg:
redis:
host: 127.0.0.1
port: 6379
- KeyResolver默認的實現是PrincipalNameKeyResolver,它從ServerWebExchange中檢索Principal,並調用Principal.getName()方法。如果你直接限流請求這個路徑的所有請求,keyResolver感覺用默認就行了。當然也有一種比較常見的keyResolver情況,比如: ?userId=admin 這種帶參數的,這里我們也可以,拿取userId去做為key,這樣就可以做到用戶的限流。
注意點: redis-rate-limiter.replenishRate:2 , redis-rate-limiter.burstCapacity: 1 這樣子雖然每秒補充2個,但是容量只有1個的話,也是只允許一個請求,所以這里還是一秒鍾只能訪問一個請求。
ipAddressKeyResolver:
@Component
public class ipAddressKeyResolver implements KeyResolver {
@Override
public Mono<String> resolve(ServerWebExchange exchange) {
return Mono.just(exchange.getRequest().getRemoteAddress().getAddress().getHostAddress());
}
}
現象如下:
可以看到如果不允許訪問的時候會返回一個429的狀態碼。HTTP 429 - Too Many Requests
自定義一個自己的路由過濾器
實現的功能是路由的時候打印一下日志:
/**
* project name : cloud-demo
* Date:2020/10/4
* Author: yc.guo
* DESC:
*/
@Component
public class LogsGatewayFilterFactory extends AbstractGatewayFilterFactory<LogsGatewayFilterFactory.Config> {
Logger logger= LoggerFactory.getLogger(LogsGatewayFilterFactory.class);
@Override
public List<String> shortcutFieldOrder() {
return Arrays.asList("name");
}
public LogsGatewayFilterFactory() {
super(LogsGatewayFilterFactory.Config.class);
}
@Override
public GatewayFilter apply(Config config) {
return (exchange, chain) ->{
logger.info("pre: 執行前的日志!" + config.name);
chain.filter(exchange); //繼續走下去的方法
logger.info("post: 執行后的日志" + config.name);
return Mono.empty();
};
}
public static class Config {
String name;
public String getName() {
return name;
}
public void setName(String name) {
this.name = name;
}
}
}
yml
- id: logs_filter
predicates:
- Path=/logs/orders
filters:
- StripPrefix=1
uri: http://127.0.0.1:8071
這個注意的點和上面的自定義斷言的一樣的。要按照上面自定義斷言的注意點去做。
全局過濾器
全局過濾器常用到的有:
- 負載均衡的全局過濾器
負載均衡全局過濾器
spirng cloud集成的負載均衡器有ribbon,所以gateway應該是可以無縫對接ribbon的。ribbon可以從yml配置文件上得到負載均衡的地址,也可以讀取eureka上面得到負載均衡的地址,
spirng cloud gateway和ribbon集成
pom.xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
application.yml:
#結合ribbon的負載均衡
- id: lb_fiter
predicates:
- Path=/lb/**
filters:
- StripPrefix=1
uri: lb://service1
service1:
ribbon:
listOfServers: 127.0.0.1:8071
提醒:uri是lb://serviceId ,負債均衡需要lb://開頭才能識別得鳥
官網上建議使用ReactiveLoadBalancerClientFilter,只需要這樣spring.cloud.loadbalancer.ribbon.enabledto
false`就行了
注意點:
如果通過文件配置的方式實現負載均衡,這個不能注冊上eureka。一注冊上了服務地址就會讀取eureka上面了。不會讀取本地配置文件了。之前我實現ribbon成功了,然后去實驗去讀取eureka的時候,然后回過頭去測試ribbon就不行了
spirng cloud gateway通過讀取eureka上的地址列表實現負載均衡
pom.xml
<dependency>
<groupId>org.springframework.cloud</groupId>
<artifactId>spring-cloud-starter-netflix-eureka-client</artifactId>
</dependency>
@SpringBootApplication
@EnableEurekaClient //開啟注冊到eureka上
public class GatewayApp {
public static void main(String[] args) {
SpringApplication.run(GatewayApp.class,args);
}
}
spring:
application:
name: gateway-demo
cloud:
gateway:
enabled: true
routes:
#結合eureka的負載均衡
- id: discovery_filter
predicates:
- Path=/eureka/lb/**
filters:
- StripPrefix=2
uri: lb://eureka-client
#設置發現讀取遠端地址為true
discovery:
locator:
enabled: true
這里也是需要lb://開頭的
默認情況下,當一個服務實例在LoadBalancer中沒有找到時,將返回503。你可以通過配spring.cloud.gateway.loadbalancer.use404=true來讓它返回404。
動態路由
如果使用正常的配置方式,我們都是通過配置application.yml,來配置路由的,但是這樣不太好的就是如果我們需要改變路由信息的話,就得需要重新修改application.yml並且重新啟動項目才能讓路由生效(我現在的公司用的zuul,也是這樣,這樣做的話缺點很明顯的,因為你修改一次路由就得重啟一次項目)。
按照正常的設定我的想法是:spring cloud通過讀取配置文件的路由信息,創建了一些路由對象放到內存中,然后當請求網關時,再通過這些路由信息進行一個處理。當項目啟動的時候,那我們也可以直接修改他內存里的路由信息,不就可以完成動態路由了嗎?
spring cloud真的提供了一個接口出來給我們做路由信息的管理,接口名字就是:RouteDefinitionRepository,而默認的實現類就只有一個:InMemoryRouteDefinitionRepository(利用內存管理路由信息)。下面這個圖是RouteDefinitionRepository的結構圖:
動態路由實際上運行的流程:
- 首先讀取配置文件的路由信息和
RouteDefinitionRepository
的路由信息,加載到內存中 - 然后定時去讀取RouteDefinitionRepository的路由信息(默認30s),做一個合並。提示:RouteDefinitionRepository里面不會存有配置文件配置的路由信息
使用redis來存儲路由信息的類:(通過仿照InMemoryRouteDefinitionRepository來實現的)
@Component
public class RedisRouteDefinitionRepository implements RouteDefinitionRepository {
private final static String GATEWAY_ROUTE_KEY="gateway_dynamic_route";
Logger logger= LoggerFactory.getLogger(RedisRouteDefinitionRepository.class);
private ObjectMapper objectMapper = new ObjectMapper();
@Autowired
private RedisTemplate<String,String> redisTemplate;
@Override
public Flux<RouteDefinition> getRouteDefinitions() {
List<RouteDefinition> list = new ArrayList();
redisTemplate.opsForHash().values(GATEWAY_ROUTE_KEY).stream().forEach(route ->{
try {
list.add(objectMapper.readValue((String) route,RouteDefinition.class));
}catch (Exception e){
logger.error(e.getMessage(),e);
throw new RuntimeException("解析失敗");
}
});
return Flux.fromIterable(list);
}
@Override
public Mono<Void> save(Mono<RouteDefinition> route) {
return route.flatMap(routeDefinition -> {
try {
System.out.println(objectMapper.writeValueAsString(route));
redisTemplate.opsForHash().put(GATEWAY_ROUTE_KEY,
routeDefinition.getId(),
objectMapper.writeValueAsString(routeDefinition));
}catch (Exception e){
logger.error(e.getMessage(),e);
throw new RuntimeException("保存失敗");
}
return Mono.empty();
}
);
}
@Override
public Mono<Void> delete(Mono<String> routeId) {
return routeId.flatMap( id -> {
redisTemplate.opsForHash().delete(GATEWAY_ROUTE_KEY,id);
return Mono.empty();
});
}
}
存儲redis的數據結構,使用hash來存儲。value我是直接使用json格式來存儲路由信息,路由信息的json格式可以參考一下:actuator下面添加路由信息的body里面的結構。
存儲redis的數據結構:
使用nacos來存儲路由信息的類:(等我學習完nacos時補上)
提示:如果我們沒有引入actuator的話,我們可以直接操作存儲的媒介來達到目的,比如redis,nacos。
按照官網的說法,可以通過Restful Api來管理動態路由的信息,不過需要引入spring-boot-starter-actuator。
xml:
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-actuator</artifactId>
</dependency>
配置文件:
management.endpoint.gateway.enabled=true # default value
management.endpoints.web.exposure.include=gateway
查看所有的路由信息:
http://127.0.0.1:8080/actuator/gateway/routes
添加路由信息:
post請求,url: http://127.0.0.1:8080/actuator/gateway/routes/first_route
body
{
"id": "first_route",
"predicates": [{
"name": "Path",
"args": {"_genkey_0":"/first"}
}],
"filters": [{
"name" : "StripPrefix",
"args":{"parts":"1"}
}],
"uri": "https://www.bilibili.com",
"order": 0
}
刪除一個路由信息(delete請求)
http://127.0.0.1:8080/actuator/gateway/routes/first_route
刷新加載RouteDefinitionRepository的路由信息到緩存中(post請求):
http://127.0.0.1:8080/actuator/gateway/refresh
一些小發現:查看路由信息:
這兩個路由信息我是沒有沒有配的,但他卻出現在路由信息上面,按照我的想法,應該是如果配置了讀取eureka上的地址列表實現負載均衡的話,網關就會讀取eureka上面的apps信息,並且把apps信息轉換成相應的路由信息,保存到內存上去。