網關一詞較早出現在網絡設備里面,比如兩個相互獨立的局域網段之間通過路由器或者橋接設備進行通信, 這中間的路由或者橋接設備我們稱之為網關。
相應的 API 網關將各系統對外暴露的服務聚合起來,所有要調用這些服務的系統都需要通過 API 網關進行訪問,基於這種方式網關可以對 API 進行統一管控,例如:認證、鑒權、流量控制、協議轉換、監控等等。
API 網關的流行得益於近幾年微服務架構的興起,原本一個龐大的業務系統被拆分成許多粒度更小的系統進行獨立部署和維護,這種模式勢必會帶來更多的跨系統交互,企業 API 的規模也會成倍增加,API 網關(或者微服務網關)就逐漸成為了微服務架構的標配組件。
如下是我們整理的 API 網關的幾種典型應用場景:
1、面向 Web 或者移動 App
這類場景,在物理形態上類似前后端分離,前端應用通過 API 調用后端服務,需要網關具有認證、鑒權、緩存、服務編排、監控告警等功能。
2、面向合作伙伴開放 API
這類場景,主要為了滿足業務形態對外開放,與企業外部合作伙伴建立生態圈,此時的 API 網關注重安全認證、權限分級、流量管控、緩存等功能的建設。
3、企業內部系統互聯互通
對於中大型的企業內部往往有幾十、甚至上百個系統,尤其是微服務架構的興起系統數量更是急劇增加。系統之間相互依賴,逐漸形成網狀調用關系不便於管理和維護,需要 API 網關進行統一的認證、鑒權、流量管控、超時熔斷、監控告警管理,從而提高系統的穩定性、降低重復建設、運維管理等成本。
設計目標
- 純 Java 實現;
- 支持插件化,方便開發人員自定義組件;
- 支持橫向擴展,高性能;
- 避免單點故障,穩定性要高,不能因為某個 API 故障導致整個網關停止服務;
- 管理控制台配置更新可自動生效,不需要重啟網關;
應用架構設計
整個平台拆分成 3 個子系統,Gateway-Core(核心子系統)、Gateway-Admin(管理中心)、Gateway-Monitor(監控中心)。
- Gateway-Core 負責接收客戶端請求,調度、加載和執行組件,將請求路由到上游服務端,處理上游服務端返回的結果等;
- Gateway-Admin 提供統一的管理界面,用戶可在此進行 API、組件、系統基礎信息的設置和維護;
- Gateway-Monitor 負責收集監控日志、生成各種運維管理報表、自動告警等;
系統架構設計
說明:
- 網關核心子系統通過 HAProxy 或者 Nginx 進行負載均衡,為避免正好路由的 LB 節點服務不可用,可以考慮在此基礎上增加 Keepalived 來實現 LB 的失效備援,當 LB Node1 停止服務,Keepalived 會將虛擬 IP 自動飄移到 LB Node2,從而避免因為負載均衡器導致單點故障。DNS 可以直接指向 Keepalived 的虛擬 IP。
- 網關除了對性能要求很高外,對穩定性也有很高的要求,引入 Zookeeper 及時將 Admin 對 API 的配置更改同步刷新到各網關節點。
- 管理中心和監控中心可以采用類似網關子系統的高可用策略,如果嫌麻煩管理中心可以省去 Keepalived,相對來說管理中心沒有這么高的可用性要求。
- 理論上監控中心需要承載很大的數據量,比如有 1000 個 API,平均每個 API 一天調用 10 萬次,對於很多互聯網公司單個 API 的量遠遠大於 10 萬,如果將每次調用的信息都存儲起來太浪費,也沒有太大的必要。可以考慮將 API 每分鍾的調用情況匯總后進行存儲,比如 1 分鍾的平均響應時間、調用次數、流量、正確率等等。
- 數據庫選型可以靈活考慮,原則上網關在運行時要盡可能減少對 DB 的依賴,否則 IO 延時會嚴重影響網關性能。可以考慮首次訪問后將 API 配置信息緩存,Admin 對 API 配置更改后通過 Zookeeper 通知網關刷新,這樣一來 DB 的訪問量可以忽略不計,團隊可根據自身偏好靈活選型。
非阻塞式 HTTP 服務
管理和監控中心可以根據團隊的情況采用自己熟悉的 Servlet 容器部署,網關核心子系統對性能的要求非常高,考慮采用 NIO 的網絡模型,實現純 HTTP 服務即可,不需要實現 Servlet 容器,推薦 Netty 框架(設計優雅,大名鼎鼎的 Spring Webflux 默認都是使用的 Netty,更多的優勢就不在此詳述了),內部測試在相同的機器上分別通過 Tomcat 和 Netty 生成 UUID,Netty 的性能大約有 20% 的提升,如果后端服務響應耗時較高的話吞吐量還有更大的提升。(補充:Netty4.x 的版本即可,不要采用 5 以上的版本,有嚴重的缺陷沒有解決)
采用 Netty 作為 Http 容器首先需要解決的是 Http 協議的解析和封裝,好在 Netty 本身提供了這樣的 Handler,具體參考如下代碼:
1、構建一個單例的 HttpServer,在 SpringBoot 啟動的時候同時加載並啟動 Netty 服務
int sobacklog = Integer.parseInt(AppConfigUtil.getValue("netty.sobacklog")); ServerBootstrap b = new ServerBootstrap(); b.group(bossGroup, workerGroup) .channel(NioServerSocketChannel.class) .localAddress(new InetSocketAddress(this.portHTTP)) .option(ChannelOption.SO_BACKLOG, sobacklog) .childHandler(new ChannelHandlerInitializer(null)); // 綁定端口 ChannelFuture f = b.bind(this.portHTTP).sync(); logger.info("HttpServer name is " + HttpServer.class.getName() + " started and listen on " + f.channel().localAddress());
2、初始化 Handler
@Override protected void initChannel(SocketChannel ch) throws Exception { ChannelPipeline p = ch.pipeline(); p.addLast(new HttpRequestDecoder()); p.addLast(new HttpResponseEncoder()); int maxContentLength = 2000; try { maxContentLength = Integer.parseInt(AppConfigUtil.getValue("netty.maxContentLength")); } catch (Exception e) { logger.warn("netty.maxContentLength 配置異常,系統默認為:2000KB"); } p.addLast(new HttpObjectAggregator(maxContentLength * 1024));// HTTP 消息的合並處理 p.addLast(new HttpServerInboundHandler()); }
HttpRequestDecoder 和 HttpResponseEncoder 分別實現 Http 協議的解析和封裝,Http Post 內容超過一個數據包大小會自動分組,通過 HttpObjectAggregator 可以自動將這些數據粘合在一起,對於上層收到是一個完整的 Http 請求。
3、通過 HttpServerInboundHandler 將網絡請求轉發給網關執行器
@Override public void channelRead0(ChannelHandlerContext ctx, Object msg) throws Exception { try { if (msg instanceof HttpRequest && msg instanceof HttpContent) { CmptRequest cmptRequest = CmptRequestUtil.convert(ctx, msg); CmptResult cmptResult = this.gatewayExecutor.execute(cmptRequest); FullHttpResponse response = encapsulateResponse(cmptResult); ctx.write(response); ctx.flush(); } } catch (Exception e) { logger.error("網關入口異常," \+ e.getMessage()); e.printStackTrace(); } }
設計上建議將 Netty 接入層代碼跟網關核心邏輯代碼分離,不要將 Netty 收到 HttpRequest 和 HttpContent 直接給到網關執行器,可以考慮做一層轉換封裝成自己的 Request 給到執行器,方便后續可以很容易的將 Netty 替換成其它 Http 容器。(如上代碼所示,CmptRequest 即為自定義的 Http 請求封裝類,CmptResult 為網關執行結果類)
組件化及自定義組件支持
組件是網關的核心,大部分功能特性都可以基於組件的形式提供,組件化可以有效提高網關的擴展性。
先來看一個簡單的微信認證組件的例子:
如下實現的功能是對 API 請求傳入的 Token 進行校驗,其結果分別是認證通過、Token 過期和無效 Token,認證通過后再將微信 OpenID 攜帶給上游服務系統。
/** * 微信 token 認證,token 格式: * {appID:'',openID:'',timestamp:132525144172,sessionKey: ''} * public class WeixinAuthTokenCmpt extends AbstractCmpt { private static Logger logger = LoggerFactory.getLogger(WeixinAuthTokenCmpt.class); private final CmptResult SUCCESS_RESULT; public WeixinAuthTokenCmpt() { SUCCESS_RESULT = buildSuccessResult(); } @Override public CmptResult execute(CmptRequest request, Map<String, FieldDTO> config) { if (logger.isDebugEnabled()) { logger.debug("WeixinTokenCmpt ......"); } CmptResult cmptResult = null; //Token 認證超時間 (傳入單位: 分) long authTokenExpireTime = getAuthTokenExpireTime(config); WeixinTokenDTO authTokenDTO = this.getAuthTokenDTO(request); logger.debug("Token=" + authTokenDTO); AuthTokenState authTokenState = validateToken(authTokenDTO, authTokenExpireTime); switch (authTokenState) { case ACCESS: { cmptResult = SUCCESS_RESULT; Map<String, String> header = new HashMap<>(); header.put(HeaderKeyConstants.HEADER\_APP\_ID_KEY, authTokenDTO.getAppID()); header.put(CmptHeaderKeyConstants.HEADER\_WEIXIN\_OPENID_KEY, authTokenDTO.getOpenID()); header.put(CmptHeaderKeyConstants.HEADER\_WEIXIN\_SESSION_KEY, authTokenDTO.getSessionKey()); cmptResult.setHeader(header); break; } case EXPIRED: { cmptResult = buildCmptResult(RespErrCode.AUTH\_TOKEN\_EXPIRED, "token 過期, 請重新獲取 Token!"); break; } case INVALID: { cmptResult = buildCmptResult(RespErrCode.AUTH\_INVALID\_TOKEN, "Token 無效!"); break; } } return cmptResult; } ... }
上面例子看不懂沒關系,接下來會詳細闡述組件的設計思路。
1、組件接口定義
public interface ICmpt { /** * 組件執行入口 * * @param request * @param config,組件實例的參數配置 * @return */ CmptResult execute(CmptRequest request, Map<String, FieldDTO> config); /** * 銷毀組件持有的特殊資源,比如線程。 */ void destroy(); }
execute 是組件執行的入口方法,request 前面提到過是 http 請求的封裝,config 是組件的特殊配置,比如上面例子提到的微信認證組件就有一個自定義配置 -Token 的有效期,不同的 API 使用該組件可以設置不同的有效期。
FieldDTO 定義如下:
public class FieldDTO { private String title; private String name; private FieldType fieldType = FieldType.STRING; private String defaultValue; private boolean required; private String regExp; private String description; }
CmptResult 為組件執行后的返回結果,其定義如下:
public class CmptResult { RespErrMsg respErrMsg;// 組件返回錯誤信息 private boolean passed;// 組件過濾是否通過 private byte\[\] data;// 組件返回數據 private Map<String, String> header = new HashMap<String, String>();// 透傳后端服務響應頭信息 private MediaType mediaType;// 返回響應數據類型 private Integer statusCode = 200;// 默認返回狀態碼為 200 }
2、組件類型定義
執行器需要根據組件類型和組件執行結果判斷是要直接返回客戶端還是繼續往下面執行,比如認證類型的組件,如果認證失敗是不能繼續往下執行的,但緩存類型的組件沒有命中才繼續往下執行。當然這樣設計存在一些缺陷,比如新增組件類型需要執行器配合調整處理邏輯。(Kong 也提供了大量的功能組件,沒有研究過其網關框架是如何跟組件配合的,是否支持用戶自定義組件類型,知道的朋友詳細交流下。)
初步定義如下組件類型:
認證、鑒權、流量管控、緩存、路由、日志等。
其中路由類型的組件涵蓋了協議轉換的功能,其負責調用上游系統提供的服務,可以根據上游系統提供 API 的協議定制不同的路由組件,比如:Restful、WebService、Dubbo、EJB 等等。
3、組件執行位置和優先級設定
執行位置:Pre、Routing、After,分別代表后端服務調用前、后端服務調用中和后端服務調用完成后,相同位置的組件根據優先級決定執行的先后順序。
4、組件發布形式
組件打包成標准的 Jar 包,通過 Admin 管理界面上傳發布。
附 - 組件可視化選擇 UI 設計
組件熱插拔設計和實現
JVM 中 Class 是通過類加載器 + 全限定名來唯一標識的,上面章節談到組件是以 Jar 包的形式發布的,但相同組件的多個版本的入口類名需要保持不變,因此要實現組件的熱插拔和多版本並存就需要自定義類加載器來實現。
大致思路如下:
網關接收到 API 調用請求后根據請求參數從緩存里拿到 API 配置的組件列表,然后再逐一參數從緩存里獲取組件對應的類實例,如果找不到則嘗試通過自定義類加載器載入 Jar 包,並初始化組件實例及緩存。
附 - 參考示例
public static ICmpt newInstance(final CmptDef cmptDef) { ICmpt cmpt = null; try { final String jarPath = getJarPath(cmptDef); if (logger.isDebugEnabled()) { logger.debug("嘗試載入 jar 包,jar 包路徑: " + jarPath); } // 加載依賴 jar CmptClassLoader cmptClassLoader = CmptClassLoaderManager.loadJar(jarPath, true); // 創建實例 if (null != cmptClassLoader) { cmpt = LoadClassUtil.newObject(cmptDef.getFullQualifiedName(), ICmpt.class, cmptClassLoader); } else { logger.error("加載組件 jar 包失敗! jarPath: " + jarPath); } } catch (Exception e) { logger.error("組件類加載失敗,請檢查類名和版本是否正確。ClassName=" + cmptDef.getFullQualifiedName() + ", Version=" + cmptDef.getVersion()); e.printStackTrace(); } return cmpt; }
補充說明:
自定義類加載器可直接需要繼承至 URLClassLoader,另外必須指定其父類加載器為執行器的加載器,否則組件沒法引用網關的其它類。
API 故障隔離及超時、熔斷處理
在詳細闡述設計前先講個實際的案例,大概 12 年的時候某公司自研了一款 ESB 的中間件(企業服務總線跟 API 網關很類似,當年 SOA 理念大行其道的時候都推崇的是 ESB,側重服務的編排和異構系統的整合。),剛開始用的還行,但隨着接入系統的增多,突然某天運維發現大量 API 出現緩慢甚至超時,初步檢查發現 ESB 每個節點的線程幾乎消耗殆盡,起初判斷是資源不夠,緊急擴容后還是很快線程占滿,最終導致上百個系統癱瘓。
最終找到問題的症結是某個業務系統自身的原因導致服務不可用,下游業務系統請求大量堆積到 ESB 中,從而導致大量線程堵塞。
以上案例說明了一個在企業應用架構設計里面的經典原則 - 故障隔離,由於所有的 API 請求都要經過網關,必須隔離 API 之間的相互影響,尤其是個別 API 故障導致整個網關集群服務中斷。
接下來分別介紹故障隔離、超時管控、熔斷的實現思路。
1、故障隔離
有兩種方式可以實現,一是為每個 API 創建一個線程池,每個線程分配 10~20 個線程,這也是常用的隔離策略,但這種方式有幾個明顯的缺點:
- 線程數會隨着 API 接入數量遞增,1000 個 API 就需要 2 萬個線程,光線程切換對 CPU 就是不小的開銷,而其線程還需要占用一定的內存資源;
- 平均分配線程池大小導致個別訪問量較大且響應時間相對較長的 API 吞吐量上不去;
- Netty 本身就有工作線程池了,再增加 API 的線程池,導致某些需要 ThreadLocal 特性的編程變得困難。
二是用信號量隔離,直接復用 Netty 的工作線程,上面線程池隔離提到的 3 個缺點都可以基本避免, 建議設置單個 API 的信號量個數小於等於 Netty 工作線程池數量的 1/3,這樣既兼顧了單個 API 的性能又不至於單個 API 的問題導致整個網關堵塞。
具體實現可以考慮直接引用成熟的開源框架,推薦 Hystrix,可以同時解決超時控制和熔斷。
參考配置如下:
Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey)) .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey )) .andCommandPropertiesDefaults(HystrixCommandProperties.Setter() // 艙壁隔離策略 - 信號量 .withExecutionIsolationStrategy(HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE) // 設置每組 command 可以申請的信號量最大數 .withExecutionIsolationSemaphoreMaxConcurrentRequests(CmptInvoker.maxSemaphore) /* 開啟超時設置 */ .withExecutionIsolationThreadInterruptOnTimeout(true) /* 超時時間設置 */ .withExecutionIsolationThreadTimeoutInMilliseconds(timeout) .withCircuitBreakerEnabled(true)// 開啟熔斷 .withCircuitBreakerSleepWindowInMilliseconds(Constants.DEFAULT_CIRCUIT_BREAKER_SLEEP_WINDOW_IN_MILLISECONDS)// 5 秒后會嘗試閉合回路
2、超時管控
API 的超時控制是必須要做的,否則上游服務即便是間歇性響應緩慢也會堵塞大量線程(雖然通過信號量隔離后不會導致整個網關線程堵塞)。
其次,每個 API 最好可以單獨配置超時時間,但不建議可以讓用戶隨意設置,還是要有個最大閾值。(API 網關不適合需要長時間傳輸數據的場景,比如大文件上傳或者下載、DB 數據同步等)
實現上可以直接復用開源組件的功能,比如:HttpClient 可以直接設置獲取連接和 Socket 響應的超時時間,Hystrix 可以對整個調用進行超時控制等。
3、熔斷
熔斷類似電路中的保險絲,當超過負荷或者電阻被擊穿的時候自動斷開對設備起到保護作用。在 API 網關中設置熔斷的目的是快速響應請求,避免不必要的等待,比如某個 API 后端服務正常情況下 1s 以內響應,但現在因為各種原因出現堵塞大部分請求 20s 才能響應,雖然設置了 10s 的超時控制,但讓請求線程等待 10s 超時不僅沒有意義,反而會增加服務提供方的負擔。
為此我們可以設置單位時間內超過多少比例的請求超時或者異常,則直接熔斷鏈路,等待一段時間后再次嘗試恢復鏈路。
實現層面可以直接復用 Hystrix。
運行時配置更新機制
前面章節提到過出於性能考慮網關在運行時要盡可能減小對 DB 的訪問,設計上可以將 API、組件等關鍵內容進行緩存,這樣一來性能是提升了,但也帶來了新的問題,比如 Admin 對 API 或者組件進行配置調整后如何及時更新到集群的各個網關節點。
解決方案很多,比如引入消息中間件,當 Admin 調整配置后就往消息中心發布一條消息,各網關節點訂閱消息,收到消息后刷新緩存數據。
我們在具體實現過程中采用的是 Zookeeper 集群數據同步機制,其實現原理跟消息中間件很類似,只不過網關在啟動的時候就會向 ZK 節點進行注冊,也是被動更新機制。
性能考慮
性能是網關一項非常重要的衡量指標,尤其是響應時間,客戶端本來可以直連服務端的,現在增加了一個網關層,對於一個本身耗時幾百毫秒的服務接入網關后增加幾毫秒,影響倒是可以忽略不計;但如果服務本身只需要幾毫秒,因為接入網關再增加一倍的延時,用戶感受就會比較明顯。
建議在設計上需要遵循如下原則:
- 核心網關子系統必須是無狀態的,便於橫向擴展。
- 運行時不依賴本地存儲,盡量在內存里面完成服務的處理和中轉。
- 減小對線程的依賴,采用非阻塞式 IO 和異步事件響應機制。
- 后端服務如果是 HTTP 協議,盡量采用連接池或者 Http2,測試連接復用和不復用性能有幾倍的差距。(TCP 建立連接成本很高)
附 -HttpClient 連接池設置:
PoolingHttpClientConnectionManager cmOfHttp = new PoolingHttpClientConnectionManager(); cmOfHttp.setMaxTotal(maxConn); cmOfHttp.setDefaultMaxPerRoute(maxPerRoute); httpClient = HttpClients.custom() .setConnectionManager(cmOfHttp) .setConnectionManagerShared(true) .build();
說明:
httpClient 對象可以作為類的成員變量長期駐留內存,這個是連接池復用的前提。
結語
API 網關作為企業 API 服務的匯聚中心,其良好的性能、穩定性和可擴展性是基礎,只有這個基礎打扎實了,我們才能在上面擴展更多的特性。
這篇文章主要介紹網關的總體架構設計, 后面的篇幅在詳細探討下各種組件的具體設計和實現。