轉載本文需注明出處:微信公眾號EAWorld,違者必究。
引言:
Spring Cloud Gateway是當前使用非常廣泛的一種API網關。它本身能力并不能完全滿足企業對網關的期望,人們希望它可以提供更多的服務治理能力。但Spring Cloud Gateway并不提供數據的動態管理,甚至修改個路由都需要重啟。我們如何解決它這個短板,同時實現治理配置數據的高效動態管理呢?本文將帶來我們網關與redis組合的實踐。
目錄:
1.Spring Cloud Gateway 簡介
2.網關數據管理
3.實現細節
1.Spring Cloud Gateway 簡介
API 網關
API 網關出現的原因是微服務架構的出現,不同的微服務一般會有不同的網絡地址,而外部客戶端可能需要調用多個服務的接口才能完成一個業務需求,如果讓客戶端直接與各個微服務通信,會有以下的問題:
- 客戶端會多次請求不同的微服務,增加了客戶端的復雜性。
- 存在跨域請求,在一定場景下處理相對復雜。
- 認證復雜,每個服務都需要獨立認證。
- 難以重構,隨著項目的迭代,可能需要重新劃分微服務。例如,可能將多個服務合并成一個或者將一個服務拆分成多個。如果客戶端直接與微服務通信,那么重構將會很難實施。
- 某些微服務可能使用了防火墻 / 瀏覽器不友好的協議,直接訪問會有一定的困難。
以上這些問題可以借助 API 網關解決。API 網關是介于客戶端和服務器端之間的中間層,所有的外部請求都會先經過 API 網關這一層。也就是說,API 的實現方面更多的考慮。
使用 API 網關后的優點如下:
- 易于監控。可以在網關收集監控數據并將其推送到外部系統進行分析。
- 易于認證。可以在網關上進行認證,然后再將請求轉發到后端的微服務,而無須在每個微服務中進行認證。
- 減少了客戶端與各個微服務之間的交互次數。
Spring Cloud Gateway
Spring Cloud Gateway是Spring官方基于Spring 5.0,Spring Boot 2.0和Project Reactor等技術開發的網關,Spring Cloud Gateway旨在為微服務架構提供一種簡單而有效的統一的API路由管理方式。
Spring Cloud Gateway作為Spring Cloud生態系中的網關,目標是替代Netflix ZUUL,其不僅提供統一的路由方式,并且基于Filter鏈的方式提供了網關基本的功能,例如:安全,監控/埋點,和限流等。
SCG架構
如圖所示,SCG的架構看起來很簡單。
首先,它內部包含了一個高性能的Netty Server,用來接收各類網絡請求。請求進來之后,會根據配置的各個路由進行匹配并處理請求。每個路由都可以定義多個斷言(Predicate),用于路由匹配。
SCG默認提供了10多個內建的斷言,可以基于請求的各個方面(請求頭,路徑,路徑,時間,Cookie,http方法等)進行路由匹配。如果還不夠,用戶還可以自已擴展。
請求匹配到了合適的路由之后,就會按照路由中配置的各過濾器(filter),按順序對請求進行處理。Filter也基本上可以對請求的所有屬性做處理,修改,添加或者除請求頭,修改請求數據,修改返回的數據等,幾乎無所不能。當然,修改請求也只是一方面的用途,認證,鑒權,記錄日志等也都可以在網關中統一來做。
所有filter形成處理鏈,直到所有的filter處理完,才會交給最后面的 Netty Client,由它將處理過的請求發送至對應的微服務。
在請求發送至微服務之前,還可以定義它的負載均衡策略(LoadBalancerRule),以決定請求至底發往微服務的哪個實例。
Filter 與 LoadBalancerRule 都支持自行擴展。
2.網關數據管理
實現一個適合自已的網關,對數據管理需要考慮哪些方面的東西呢?
1.首先,我們要考慮一下,我們需要管理些什么數據。
SCG本身對數據管理的管理是很弱的。它沒有提供數據的持久化方案,它所有的數據都來自初始化,來自它的配置文件(Application.yml)。它本身雖然也對外提供了一些管理接口(Actuator API)能力不夠,但能力不夠,且這些修改都是暫時的,網關一停,數據就消失了。這就要求我們要用一套更完善的方案,把網關的這些數據管理起來,不能讓它只能寫在配置文件中,而要支持持久化,支持動態變更。再有就是我們對各微服務的治理數據。網關只用來做路由轉發,那就太浪費了,統一認證,統一鑒權,訪問日志記錄,應用訪問統計,黑白名單過濾,API訂閱管理,流量限制,甚至數據格式轉換,網絡協議轉換,都可以在網關中來做。而所有的這些能力,無不需要數據的支持。因此,這些服務的治理配置,也是網關需要管理的數據。
2.數據有了,我們還得考慮怎么把它保存起來,不能網關一重啟,所有數據就沒了。
3.還得再考慮一下數據的讀取。網關對性能的要求是很高的,每次對過關的數據進行治理,都需要去讀取這些配置信息。如果配置信息讀取太消耗資源,無疑對網關是不利的。所以,我們還得考慮數據如何緩存,以提高數據的讀取性能。
4.單個網關,可以處理的請求量是有上限的。為了應對大的流量,我們可能會需要對網關做水平擴容。當多個網關實例共存時,如何保障對網關的修改,能快速同步到每個網關實例呢?數據變更通知也得考慮。
5.最多,我們還得考慮一下方案的擴展,數據存儲能不能改個地方,通知能不能換種方式?
綜合考慮了這些方面之后,我們的網關的架構如下:
gateway-arch
如圖,以上就是我們網關的整體設計。方案設計要點如下:
- 網關對外提供治理數據管理接口, 微服務治理平臺可通過這些接口, 將治理配置推送到網關
- 網關通過治理數據統一存儲接口, 將治理配置數據保持至治理數據持久存儲(這里我們默認為Redis)
- Redis通過發布訂閱能力, 將數據的變更通知到各網關實例
- 各網關實例收到通知后, 將數據從持久存儲同步至內部高速緩存
- 內部緩存在網關啟動時, 會自動從持久存儲加載對應配置進入緩存. 同時它也支持清空, 以及按需加載
- 外部業務請求經過網關時, 對數據執行鑒權,處理轉換, 以及灰度策略時,所需要治理配置,都從內部緩存中獲取, 以提升性能
- 方案中, 外部持久存儲(默認用的Redis, 可以換成MySQL, 文件, Appolo等), 以及數據變更通知(默認使用的是Redis的發布訂閱, 可以換成Appolo通知, 消息隊列, 定時掃描等), 都是可以擴展的
3.實現細節
動態路由管理
Spring Cloud Gateway作為所有請求流量的入口,在實際生產環境中為了保證高可靠和高可用,盡量避免重啟, 需要實現Spring Cloud Gateway動態路由配置。實現動態路由其實很簡單, 重點在于 RouteDefinitionRepository 這個接口. 這個接口繼承自兩個接口, 其中 RouteDefinitionLocator 是用來加載路由的. 它有很多實現類, 其中的 PropertiesRouteDefinitionLocator 就用來實現從yml中加載路由. 另一個 RouteDefinitionWriter 用來實現路由的添加與刪除. 通過查看spring cloud gateway的源碼可以發現, 在 org.springframework.cloud.gateway.config.GatewayAutoConfiguration中這么一段:
@Bean @ConditionalOnMissingBean(RouteDefinitionRepository.class) public InMemoryRouteDefinitionRepository inMemoryRouteDefinitionRepository() { return new InMemoryRouteDefinitionRepository(); }
可以看出, 網關中如果沒有RouteDefinitionRepository的Bean, 就會采用InMemoryRouteDefinitionRepository做為實現。這個 InMemoryRouteDefinitionRepository有一個問題, 就是數據沒有持久化, 網關重啟之后,原來通過接口設置的路由就會丟失了。
這當然是不可接受的, 所以我們需要實現自已的 RouteDefinitionRepository, 來提供路由配置信息。如使用redis做為存儲, 來實現路由的存儲。實現請參考文章:https://dwz.cn/tsHfKwMe
除此以外, 每當路由更改之后, 還需要通知網關刷新路由。這需要發送 RefreshRoutesEvent 來通知網關。如下列示例:
@Component public class RouteDynamicService implements ApplicationEventPublisherAware { private ApplicationEventPublisher publisher; ? @Override public void setApplicationEventPublisher(ApplicationEventPublisher publisher) { this.publisher = publisher; } ? /** * 刷新路由表 */ public void refreshRoutes() { publisher.publishEvent(new RefreshRoutesEvent(this)); } }
刷新可以通過消息通知機制來觸發, 當然, 也可以對外接供rest接口, 手動觸發。### 數據存儲
如上述類圖所示, IGovernDataRepository為治理數據統一存儲接口。RedisGovernDataRepository為實現的它的抽像類, 它需要依賴兩個, 一個是StringRedisTemplate,用來實現redis數據的存儲。另一個為 RedisKeyGenerator, 用來為各治理對象生成對應的key。RedisGovernDataRepository下面則為各個治理數據存儲的實現類。使用Redis做為持久存儲時, 需要注意以下幾點:
- 為對象生成key時, 建議為key添加一個命名空間(就是加一段有意義的前綴)
- 在redis中進行模糊搜索時, 提供給Redis的pattern, 不能是一個正則的通配, 它支持三種通配 *(多個), ?(單個)
- 如果數據量比較大, 不建議使用keys進行模糊查詢, 應該使用scan方式
數據緩存
我們提供了內部緩存,它處于使用者與持久存儲之間,緩存數據以提升性能。緩存的實現主要有如下幾點:
- 實現了 InitializingBean 以實現在網關啟動時, 自動加載數據
- 內部使用了ConcurrentHashMap, 保證寫時的線程同步, 又保證了get時的高效(get整個過程不需要加鎖)
- 從緩存中取數據時, 如果需要懶加載, 當從持久存儲中加載不到數據時, 建議使用空數據, 或空集合占位, 避免每次都去持久存儲中查詢
代碼示例如下:
/** * 根據 appCode 獲取流量策略 * * @param appCode * @return */ public Set<ApplicationTrafficPolicy> getAppTrafficPolicies(String appCode) { // 從緩存加載 Map<String, ApplicationTrafficPolicy> map = policyMap.get(appCode); // 緩存中沒有 if (map == null) { // 嘗試從持久存儲中加載所有此網關的流量策略 Set<ApplicationTrafficPolicy> policies = trafficPolicyRepository.fuzzyQuery(); // 持久存儲中沒有任何流量策略,占個位置,防止緩存重復去加載 if (policies == null || policies.size() == 0) { map = new ConcurrentHashMap<>(); policyMap.put(appCode, map); } else { // 持久存儲中有流量策略,放入緩存 for (ApplicationTrafficPolicy policy : policies) { setTrafficPolicy(policy); } // 重新從緩存中加載一次 map = policyMap.get(appCode); // 如果還是沒有,使用空 map 占位子 if (map == null) { map = new ConcurrentHashMap<>(); policyMap.put(appCode, map); } } } return map.values().stream().collect(Collectors.toSet()); }
事件通知
事件通知,這里我們使用的是redis的發布與訂閱能力。Redis默認是不發送事件的,要讓它發布事件,需要先修改它的配置文件redis.conf,添加一個配置:
notify-keyspace-events "K$g"
上面的配置將使得Redis中發生數據的添加,修改或刪除時,發送set或del事件。
然后,我們需要配置一個RedisMessageListenerContainer,用來訂閱我們感興趣的事件。
@Bean RedisMessageListenerContainer container(MessageListenerAdapter listenerAdapter) { String gtwReidsPattern = "__keyspace@*__:" + GTW + keyGenerator.getGatewayCode() + "]*"; String cofRedisPattern = "__keyspace@*__:" + COF + cacheKey.getKeyNameSpace() + USER_NAME + "*"; log.info("Add gateway redis message listener, patternTopic is {}", gtwReidsPattern); log.info("Add coframe redis message listener, patternTopic is {}", cofRedisPattern); RedisMessageListenerContainer container = new RedisMessageListenerContainer(); container.setConnectionFactory(redisTemplate.getConnectionFactory()); // PatternTopic 參考:http://redisdoc.com/topic/notification.html container.addMessageListener(listenerAdapter, Arrays.asList(new PatternTopic(PatternUtil.fmt(gtwReidsPattern)), new PatternTopic(PatternUtil.fmt(cofRedisPattern)))); return container; } 當redis事件訂閱好了之后, 每次其中我們關心的數據有變更, 都會發送set或del事件. 我們需要定義一個 MessageListener, 來接收事件: @Service(value = RedisMessageListener.REDIS_LISTENER_NAME) public class RedisMessageListener implements MessageListener { @Override public void onMessage(Message message, byte[] pattern) { String ops = new String(message.getBody()); String channel = new String(message.getChannel()); String key = channel.split(":")[1]; ? if ("set".equals(ops)) { String value = redisTemplate.opsForValue().get(key); handleSet(key, value); } else if ("del".equals(ops)) { handleDel(key); } } ... }
接收到事件后,會調用相應的內部緩存,更新內部緩存中的數據,以實現治理數據變更的及時生效。
關于作者:將曉漁,現任普元云計算架構師。曾在PDM,云計算,數據備份,移動互聯相關領域公司工作,十年以上IT工作經驗。曾為科企桌面虛擬化產品的核心工程師,愛數容災備份云柜系統設計師,萬達信息的食安管理與追溯平臺開發經理。國內IAAS云計算的早期實踐者,容器技術專家。
關于EAWorld:微服務,DevOps,數據治理,移動架構原創技術分享。