網(wǎng)關(guān)一詞較早出現(xiàn)在網(wǎng)絡(luò)設(shè)備里面,比如兩個相互獨(dú)立的局域網(wǎng)段之間通過路由器或者橋接設(shè)備進(jìn)行通信, 這中間的路由或者橋接設(shè)備我們稱之為網(wǎng)關(guān)。 相應(yīng)的 API 網(wǎng)關(guān)將各系統(tǒng)對外暴露的服務(wù)聚合起來,所有要調(diào)用這些服務(wù)的系統(tǒng)都需要通過 API 網(wǎng)關(guān)進(jìn)行訪問,基于這種方式網(wǎng)關(guān)可以對 API 進(jìn)行統(tǒng)一管控,例如:認(rèn)證、鑒權(quán)、流量控制、協(xié)議轉(zhuǎn)換、監(jiān)控等等。 API 網(wǎng)關(guān)的流行得益于近幾年微服務(wù)架構(gòu)的興起,原本一個龐大的業(yè)務(wù)系統(tǒng)被拆分成許多粒度更小的系統(tǒng)進(jìn)行獨(dú)立部署和維護(hù),這種模式勢必會帶來更多的跨系統(tǒng)交互,企業(yè) API 的規(guī)模也會成倍增加,API 網(wǎng)關(guān)(或者微服務(wù)網(wǎng)關(guān))就逐漸成為了微服務(wù)架構(gòu)的標(biāo)配組件。 如下是我們整理的 API 網(wǎng)關(guān)的幾種典型應(yīng)用場景: 1、面向 Web 或者移動 App 這類場景,在物理形態(tài)上類似前后端分離,前端應(yīng)用通過 API 調(diào)用后端服務(wù),需要網(wǎng)關(guān)具有認(rèn)證、鑒權(quán)、緩存、服務(wù)編排、監(jiān)控告警等功能。 2、面向合作伙伴開放 API 這類場景,主要為了滿足業(yè)務(wù)形態(tài)對外開放,與企業(yè)外部合作伙伴建立生態(tài)圈,此時的 API 網(wǎng)關(guān)注重安全認(rèn)證、權(quán)限分級、流量管控、緩存等功能的建設(shè)。 3、企業(yè)內(nèi)部系統(tǒng)互聯(lián)互通 對于中大型的企業(yè)內(nèi)部往往有幾十、甚至上百個系統(tǒng),尤其是微服務(wù)架構(gòu)的興起系統(tǒng)數(shù)量更是急劇增加。系統(tǒng)之間相互依賴,逐漸形成網(wǎng)狀調(diào)用關(guān)系不便于管理和維護(hù),需要 API 網(wǎng)關(guān)進(jìn)行統(tǒng)一的認(rèn)證、鑒權(quán)、流量管控、超時熔斷、監(jiān)控告警管理,從而提高系統(tǒng)的穩(wěn)定性、降低重復(fù)建設(shè)、運(yùn)維管理等成本。 設(shè)計目標(biāo)
應(yīng)用架構(gòu)設(shè)計 整個平臺拆分成 3 個子系統(tǒng),Gateway-Core(核心子系統(tǒng))、Gateway-Admin(管理中心)、Gateway-Monitor(監(jiān)控中心)。
系統(tǒng)架構(gòu)設(shè)計 說明:
非阻塞式 HTTP 服務(wù) 管理和監(jiān)控中心可以根據(jù)團(tuán)隊的情況采用自己熟悉的 Servlet 容器部署,網(wǎng)關(guān)核心子系統(tǒng)對性能的要求非常高,考慮采用 NIO 的網(wǎng)絡(luò)模型,實現(xiàn)純 HTTP 服務(wù)即可,不需要實現(xiàn) Servlet 容器,推薦 Netty 框架(設(shè)計優(yōu)雅,大名鼎鼎的 Spring Webflux 默認(rèn)都是使用的 Netty,更多的優(yōu)勢就不在此詳述了),內(nèi)部測試在相同的機(jī)器上分別通過 Tomcat 和 Netty 生成 UUID,Netty 的性能大約有 20% 的提升,如果后端服務(wù)響應(yīng)耗時較高的話吞吐量還有更大的提升。(補(bǔ)充:Netty4.x 的版本即可,不要采用 5 以上的版本,有嚴(yán)重的缺陷沒有解決) 采用 Netty 作為 Http 容器首先需要解決的是 Http 協(xié)議的解析和封裝,好在 Netty 本身提供了這樣的 Handler,具體參考如下代碼: 1、構(gòu)建一個單例的 HttpServer,在 SpringBoot 啟動的時候同時加載并啟動 Netty 服務(wù) 復(fù)制代碼 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 復(fù)制代碼 @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 配置異常,系統(tǒng)默認(rèn)為:2000KB'); } p.addLast(new HttpObjectAggregator(maxContentLength * 1024));// HTTP 消息的合并處理 p.addLast(new HttpServerInboundHandler()); } HttpRequestDecoder 和 HttpResponseEncoder 分別實現(xiàn) Http 協(xié)議的解析和封裝,Http Post 內(nèi)容超過一個數(shù)據(jù)包大小會自動分組,通過 HttpObjectAggregator 可以自動將這些數(shù)據(jù)粘合在一起,對于上層收到是一個完整的 Http 請求。 3、通過 HttpServerInboundHandler 將網(wǎng)絡(luò)請求轉(zhuǎn)發(fā)給網(wǎng)關(guān)執(zhí)行器 復(fù)制代碼 @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('網(wǎng)關(guān)入口異常,' \+ e.getMessage()); e.printStackTrace(); } } 設(shè)計上建議將 Netty 接入層代碼跟網(wǎng)關(guān)核心邏輯代碼分離,不要將 Netty 收到 HttpRequest 和 HttpContent 直接給到網(wǎng)關(guān)執(zhí)行器,可以考慮做一層轉(zhuǎn)換封裝成自己的 Request 給到執(zhí)行器,方便后續(xù)可以很容易的將 Netty 替換成其它 Http 容器。(如上代碼所示,CmptRequest 即為自定義的 Http 請求封裝類,CmptResult 為網(wǎng)關(guān)執(zhí)行結(jié)果類) 組件化及自定義組件支持 組件是網(wǎng)關(guān)的核心,大部分功能特性都可以基于組件的形式提供,組件化可以有效提高網(wǎng)關(guān)的擴(kuò)展性。 先來看一個簡單的微信認(rèn)證組件的例子: 如下實現(xiàn)的功能是對 API 請求傳入的 Token 進(jìn)行校驗,其結(jié)果分別是認(rèn)證通過、Token 過期和無效 Token,認(rèn)證通過后再將微信 OpenID 攜帶給上游服務(wù)系統(tǒng)。 復(fù)制代碼 /** * 微信 token 認(rèn)證,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 認(rèn)證超時間 (傳入單位: 分) 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; } ... } 上面例子看不懂沒關(guān)系,接下來會詳細(xì)闡述組件的設(shè)計思路。 1、組件接口定義 復(fù)制代碼 public interface ICmpt { /** * 組件執(zhí)行入口 * * @param request * @param config,組件實例的參數(shù)配置 * @return */ CmptResult execute(CmptRequest request, Map<String, FieldDTO> config); /** * 銷毀組件持有的特殊資源,比如線程。 */ void destroy();} execute 是組件執(zhí)行的入口方法,request 前面提到過是 http 請求的封裝,config 是組件的特殊配置,比如上面例子提到的微信認(rèn)證組件就有一個自定義配置 -Token 的有效期,不同的 API 使用該組件可以設(shè)置不同的有效期。 FieldDTO 定義如下: 復(fù)制代碼 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 為組件執(zhí)行后的返回結(jié)果,其定義如下: 復(fù)制代碼 public class CmptResult { RespErrMsg respErrMsg;// 組件返回錯誤信息 private boolean passed;// 組件過濾是否通過 private byte\[\] data;// 組件返回數(shù)據(jù) private Map<String, String> header = new HashMap<String, String>();// 透傳后端服務(wù)響應(yīng)頭信息 private MediaType mediaType;// 返回響應(yīng)數(shù)據(jù)類型 private Integer statusCode = 200;// 默認(rèn)返回狀態(tài)碼為 200 } 2、組件類型定義 執(zhí)行器需要根據(jù)組件類型和組件執(zhí)行結(jié)果判斷是要直接返回客戶端還是繼續(xù)往下面執(zhí)行,比如認(rèn)證類型的組件,如果認(rèn)證失敗是不能繼續(xù)往下執(zhí)行的,但緩存類型的組件沒有命中才繼續(xù)往下執(zhí)行。當(dāng)然這樣設(shè)計存在一些缺陷,比如新增組件類型需要執(zhí)行器配合調(diào)整處理邏輯。(Kong 也提供了大量的功能組件,沒有研究過其網(wǎng)關(guān)框架是如何跟組件配合的,是否支持用戶自定義組件類型,知道的朋友詳細(xì)交流下。) 初步定義如下組件類型: 認(rèn)證、鑒權(quán)、流量管控、緩存、路由、日志等。 其中路由類型的組件涵蓋了協(xié)議轉(zhuǎn)換的功能,其負(fù)責(zé)調(diào)用上游系統(tǒng)提供的服務(wù),可以根據(jù)上游系統(tǒng)提供 API 的協(xié)議定制不同的路由組件,比如:Restful、WebService、Dubbo、EJB 等等。 3、組件執(zhí)行位置和優(yōu)先級設(shè)定 執(zhí)行位置:Pre、Routing、After,分別代表后端服務(wù)調(diào)用前、后端服務(wù)調(diào)用中和后端服務(wù)調(diào)用完成后,相同位置的組件根據(jù)優(yōu)先級決定執(zhí)行的先后順序。 4、組件發(fā)布形式 組件打包成標(biāo)準(zhǔn)的 Jar 包,通過 Admin 管理界面上傳發(fā)布。 附 - 組件可視化選擇 UI 設(shè)計 組件熱插拔設(shè)計和實現(xiàn) JVM 中 Class 是通過類加載器 + 全限定名來唯一標(biāo)識的,上面章節(jié)談到組件是以 Jar 包的形式發(fā)布的,但相同組件的多個版本的入口類名需要保持不變,因此要實現(xiàn)組件的熱插拔和多版本并存就需要自定義類加載器來實現(xiàn)。 大致思路如下: 網(wǎng)關(guān)接收到 API 調(diào)用請求后根據(jù)請求參數(shù)從緩存里拿到 API 配置的組件列表,然后再逐一參數(shù)從緩存里獲取組件對應(yīng)的類實例,如果找不到則嘗試通過自定義類加載器載入 Jar 包,并初始化組件實例及緩存。 附 - 參考示例 復(fù)制代碼 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); // 創(chuàng)建實例 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;} 補(bǔ)充說明: 自定義類加載器可直接需要繼承至 URLClassLoader,另外必須指定其父類加載器為執(zhí)行器的加載器,否則組件沒法引用網(wǎng)關(guān)的其它類。 API 故障隔離及超時、熔斷處理 在詳細(xì)闡述設(shè)計前先講個實際的案例,大概 12 年的時候某公司自研了一款 ESB 的中間件(企業(yè)服務(wù)總線跟 API 網(wǎng)關(guān)很類似,當(dāng)年 SOA 理念大行其道的時候都推崇的是 ESB,側(cè)重服務(wù)的編排和異構(gòu)系統(tǒng)的整合。),剛開始用的還行,但隨著接入系統(tǒng)的增多,突然某天運(yùn)維發(fā)現(xiàn)大量 API 出現(xiàn)緩慢甚至超時,初步檢查發(fā)現(xiàn) ESB 每個節(jié)點(diǎn)的線程幾乎消耗殆盡,起初判斷是資源不夠,緊急擴(kuò)容后還是很快線程占滿,最終導(dǎo)致上百個系統(tǒng)癱瘓。 最終找到問題的癥結(jié)是某個業(yè)務(wù)系統(tǒng)自身的原因?qū)е路?wù)不可用,下游業(yè)務(wù)系統(tǒng)請求大量堆積到 ESB 中,從而導(dǎo)致大量線程堵塞。 以上案例說明了一個在企業(yè)應(yīng)用架構(gòu)設(shè)計里面的經(jīng)典原則 - 故障隔離,由于所有的 API 請求都要經(jīng)過網(wǎng)關(guān),必須隔離 API 之間的相互影響,尤其是個別 API 故障導(dǎo)致整個網(wǎng)關(guān)集群服務(wù)中斷。 接下來分別介紹故障隔離、超時管控、熔斷的實現(xiàn)思路。 1、故障隔離 有兩種方式可以實現(xiàn),一是為每個 API 創(chuàng)建一個線程池,每個線程分配 10~20 個線程,這也是常用的隔離策略,但這種方式有幾個明顯的缺點(diǎn):
二是用信號量隔離,直接復(fù)用 Netty 的工作線程,上面線程池隔離提到的 3 個缺點(diǎn)都可以基本避免, 建議設(shè)置單個 API 的信號量個數(shù)小于等于 Netty 工作線程池數(shù)量的 1/3,這樣既兼顧了單個 API 的性能又不至于單個 API 的問題導(dǎo)致整個網(wǎng)關(guān)堵塞。 具體實現(xiàn)可以考慮直接引用成熟的開源框架,推薦 Hystrix,可以同時解決超時控制和熔斷。 參考配置如下: 復(fù)制代碼 Setter.withGroupKey(HystrixCommandGroupKey.Factory.asKey(groupKey)) .andCommandKey(HystrixCommandKey.Factory.asKey(commandKey )) .andCommandPropertiesDefaults(HystrixCommandProperties.Setter() // 艙壁隔離策略 - 信號量 .withExecutionIsolationStrategy(HystrixCommandProperties.ExecutionIsolationStrategy.SEMAPHORE) // 設(shè)置每組 command 可以申請的信號量最大數(shù) .withExecutionIsolationSemaphoreMaxConcurrentRequests(CmptInvoker.maxSemaphore) /* 開啟超時設(shè)置 */ .withExecutionIsolationThreadInterruptOnTimeout(true) /* 超時時間設(shè)置 */ .withExecutionIsolationThreadTimeoutInMilliseconds(timeout) .withCircuitBreakerEnabled(true)// 開啟熔斷 .withCircuitBreakerSleepWindowInMilliseconds(Constants.DEFAULT_CIRCUIT_BREAKER_SLEEP_WINDOW_IN_MILLISECONDS)// 5 秒后會嘗試閉合回路 2、超時管控 API 的超時控制是必須要做的,否則上游服務(wù)即便是間歇性響應(yīng)緩慢也會堵塞大量線程(雖然通過信號量隔離后不會導(dǎo)致整個網(wǎng)關(guān)線程堵塞)。 其次,每個 API 最好可以單獨(dú)配置超時時間,但不建議可以讓用戶隨意設(shè)置,還是要有個最大閾值。(API 網(wǎng)關(guān)不適合需要長時間傳輸數(shù)據(jù)的場景,比如大文件上傳或者下載、DB 數(shù)據(jù)同步等) 實現(xiàn)上可以直接復(fù)用開源組件的功能,比如:HttpClient 可以直接設(shè)置獲取連接和 Socket 響應(yīng)的超時時間,Hystrix 可以對整個調(diào)用進(jìn)行超時控制等。 3、熔斷 熔斷類似電路中的保險絲,當(dāng)超過負(fù)荷或者電阻被擊穿的時候自動斷開對設(shè)備起到保護(hù)作用。在 API 網(wǎng)關(guān)中設(shè)置熔斷的目的是快速響應(yīng)請求,避免不必要的等待,比如某個 API 后端服務(wù)正常情況下 1s 以內(nèi)響應(yīng),但現(xiàn)在因為各種原因出現(xiàn)堵塞大部分請求 20s 才能響應(yīng),雖然設(shè)置了 10s 的超時控制,但讓請求線程等待 10s 超時不僅沒有意義,反而會增加服務(wù)提供方的負(fù)擔(dān)。 為此我們可以設(shè)置單位時間內(nèi)超過多少比例的請求超時或者異常,則直接熔斷鏈路,等待一段時間后再次嘗試恢復(fù)鏈路。 實現(xiàn)層面可以直接復(fù)用 Hystrix。 運(yùn)行時配置更新機(jī)制 前面章節(jié)提到過出于性能考慮網(wǎng)關(guān)在運(yùn)行時要盡可能減小對 DB 的訪問,設(shè)計上可以將 API、組件等關(guān)鍵內(nèi)容進(jìn)行緩存,這樣一來性能是提升了,但也帶來了新的問題,比如 Admin 對 API 或者組件進(jìn)行配置調(diào)整后如何及時更新到集群的各個網(wǎng)關(guān)節(jié)點(diǎn)。 解決方案很多,比如引入消息中間件,當(dāng) Admin 調(diào)整配置后就往消息中心發(fā)布一條消息,各網(wǎng)關(guān)節(jié)點(diǎn)訂閱消息,收到消息后刷新緩存數(shù)據(jù)。 我們在具體實現(xiàn)過程中采用的是 Zookeeper 集群數(shù)據(jù)同步機(jī)制,其實現(xiàn)原理跟消息中間件很類似,只不過網(wǎng)關(guān)在啟動的時候就會向 ZK 節(jié)點(diǎn)進(jìn)行注冊,也是被動更新機(jī)制。 性能考慮 性能是網(wǎng)關(guān)一項非常重要的衡量指標(biāo),尤其是響應(yīng)時間,客戶端本來可以直連服務(wù)端的,現(xiàn)在增加了一個網(wǎng)關(guān)層,對于一個本身耗時幾百毫秒的服務(wù)接入網(wǎng)關(guān)后增加幾毫秒,影響倒是可以忽略不計;但如果服務(wù)本身只需要幾毫秒,因為接入網(wǎng)關(guān)再增加一倍的延時,用戶感受就會比較明顯。 建議在設(shè)計上需要遵循如下原則:
附 -HttpClient 連接池設(shè)置: 復(fù)制代碼 PoolingHttpClientConnectionManager cmOfHttp = new PoolingHttpClientConnectionManager();cmOfHttp.setMaxTotal(maxConn);cmOfHttp.setDefaultMaxPerRoute(maxPerRoute);httpClient = HttpClients.custom() .setConnectionManager(cmOfHttp) .setConnectionManagerShared(true) .build(); 說明: httpClient 對象可以作為類的成員變量長期駐留內(nèi)存,這個是連接池復(fù)用的前提。 結(jié)語 API 網(wǎng)關(guān)作為企業(yè) API 服務(wù)的匯聚中心,其良好的性能、穩(wěn)定性和可擴(kuò)展性是基礎(chǔ),只有這個基礎(chǔ)打扎實了,我們才能在上面擴(kuò)展更多的特性。 |
|