Categories
程式開發

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路


引言

在參與電商工作第一年,我從事客戶端開發工作。 雖然團隊規模不大,但是對接的中間層團隊人數,卻相當於團隊近四分之一的規模。 工作第四年,我又加入國內一家知名的電商公司。 這家公司的主要業務形態是特賣,中間層團隊佔團隊的人數近三分之一。 而現在,我所帶領的團隊,在發展初期,中間層團隊也是接近這個規模。

三個團隊都是電商團隊,用戶規模較大,在並發上要求較高,並且採用微服務架構,由中台底層提供各種電商服務(如訂單、庫存)和通用服務(如搜索) ,所以中間層團隊需要經過各種授權和認證調用各個BU的服務,從而組裝出前端適配的接口。 因為對C端業務的接口繁多,所以中間層佔用著團隊寶貴的人力資源。 而且團隊成立時間越久,累積的接口越多,管理如此繁多的接口是一個令人頭疼的問題。

中間層的問題

1.開發調試問題

中間層在Web網站上的部署偏前,一般部署於防火牆及Nginx之後,更多面向C端用戶服務,所以在性能並發量上有較高要求,大部分團隊在選型上會選擇異步框架。 正因為其直接面向C端,變化較多,大部分需要經常性地變更或者配置的代碼都會安排在這一層次,發布非常頻繁。 此外,很多團隊使用編譯型語言進行編碼,而非解釋型語言。 這三個因素組合在一起,使得開發者調試與開發非常痛苦。

比如,我們曾經選擇Play2框架,這是一個異步Java框架,需要開發者能夠流暢地編寫異步,但是熟悉調試技巧的同事也不多。 在代碼裡面配置了各種請求參數,以及結果處理,看似非常簡單,但是聯調、單元測試、或者配置文件修改之後等待Java編譯花費的時間和精力是巨大的。 如果異步編碼規範也有問題,這對開發者來說無疑是一種折磨。

public F.Promise> getGoodsByCondi(final StringBuilder searchParams, final GoodsQueryParam param) {
        final Map params = new TreeMap();
        final OutboundApiKey apiKey = OutboundApiKeyUtils.getApiKey("search.api");
        params.put("apiKey", apiKey.getApiKey());
        params.put("service", "Search.getMerchandiseBy");
        if(StringUtils.isNotBlank(param.getSizeName())){
            try {
                searchParams.append("sizes:" + URLEncoder.encode(param.getSizeName(), "utf-8") + ";");
            } catch (UnsupportedEncodingException e) {
                e.printStackTrace();
            }
        }
        if (param.getStock() != null) {
            searchParams.append("hasStock:" + param.getStock() + ";");
        }
        if (param.getSort() != null && !param.getSort().isEmpty()) {
            searchParams.append("orderBy:" + param.getSort() + ";");
        }
        searchParams.append("limit:" + param.getLimit() + ";page:" + param.getStart());
        params.put("traceId", "open.api.vip.com");
        ApiKeySignUtil.getApiSignMap(params,apiKey.getApiSecret(),"apiSign");
        String url = RemoteServiceUrl.SEARCH_API_URL;
        Promise promise = HttpInvoker.get(url, params);
        final GoodListBaseDto retVal = new GoodListBaseDto();
        Promise> goodListPromise = promise.map(new Function>() {
            @Override
            public BaseDto apply(HttpResponse httpResponse)throws Throwable {
                JsonNode json = JsonUtil.toJsonNode(httpResponse.getBody());
                if (json.get("code").asInt() != 200) {
                    Logger.error("Error :" + httpResponse.getBody());
                    return new BaseDto(CommonError.SYS_ERROR);
                }
                JsonNode result = json.get("items");
                Iterator iterator = result.elements();
                final List goods = new ArrayList();
                while (iterator.hasNext()) {
                    final Good good = new Good();
                    JsonNode goodJson = iterator.next();
                    good.setGid(goodJson.get("id").asText());
                    good.setDiscount(String.format("%.2f", goodJson.get("discount").asDouble()));
                    good.setAgio(goodJson.get("setAgio").asText());     

                    if (goodJson.get("brandStoreSn") != null) {
                        good.setBrandStoreSn(goodJson.get("brandStoreSn").asText());
                    }
                    Iterator whIter = goodJson.get("warehouses").elements();
                    while (whIter.hasNext()) {              
                        good.getWarehouses().add(whIter.next().asText());
                    }
                    if (goodJson.get("saleOut").asInt() == 1) {
                        good.setSaleOut(true);
                    }       good.setVipPrice(goodJson.get("vipPrice").asText());
                    goods.add(good);
                }
                retVal.setData(goods);
                return retVal;
            }
        });

        if(param.getBrandId() != null && !param.getBrandId().isEmpty()))){
            final Promise pmsPromise = service.getActiveTipsByBrand(param.getBrandId());
            return goodListPromise.flatMap(new Function, Promise>>() {
                @Override
                public Promise> apply(BaseDto listBaseDto) throws Throwable {
                    return pmsPromise.flatMap(new Function>>() {
                        @Override
                        public Promise> apply(List activeTips) throws Throwable {
                            retVal.setPmsList(activeTips);
                            BaseDto baseDto = (BaseDto)retVal;
                            return Promise.pure(baseDto);
                        }
                    });

                }
            });
        }
        return goodListPromise;
    }

上述代碼只是摘抄了其中一個過程函數。 如果我們將中間層的場景設置得更為複雜一些,我們要解決的就不僅僅是編碼性能、編碼質量、編碼時間的問題。

2.“複雜”場景問題

微服務顆粒度較細,為了實現簡潔的前端邏輯以及較少的服務調用次數,我們針對C端的大部分輸出是聚合的結果。 比如,我們一個搜索的中間層邏輯,其服務是這樣一個過程:

  1. 獲取會員信息、會員卡列表、會員積分餘額,因為不同級別的會員會有不同價格;
  2. 獲取用戶的優惠券信息,這部分會對計算出來的價格產生影響;
  3. 獲取搜索的結果信息,結果來自三部分,商旅商品的庫存價格,猜你喜歡的庫存價格,推薦位的庫存價格,海外商品的庫存價格。

這其中涉及到的服務有:中間層服務(聚合服務)、會員服務、優惠券服務、推薦服務、企業服務、海外搜索服務、搜索服務。 此外,還有各種類型的緩存設施以及數據庫的配置服務。

 public List searchProduct(String traceId, ExtenalProductQueryParam param, MemberAssetVO memberAssetVO, ProductInfoResultVO resultVO,boolean needAddPrice) {
        // 用户可用优惠券的configId
        String configIds = memberAssetVO == null ? null : memberAssetVO.getConfigIds();
        // 特殊项目,限制不能使用优惠券功能
        if(customProperties.getIgnoreChannel().contains(param.getChannelCode())) {
            configIds = null;
        }
        final String configIdConstant = configIds;
        // 主搜索列表信息
        Mono innInfos = this.search(traceId, param, configIds, resultVO);
        return innInfos.flatMap(inns -> {
            // 商旅产品推荐
            Mono busiProduct = this.recommendProductService.getBusiProduct(traceId, param, configIdConstant);
            // 会员产品推荐(猜您喜欢)
            Mono guessPref = this.recommendProductService.getGuessPref(traceId, param, configIdConstant);
            // 业务相关查询
            String registChainId = memberAssetVO == null || memberAssetVO.getMember() == null ? null : memberAssetVO.getMember().getRegistChainId();
            Mono registChain = this.recommendProductService.registChain(traceId, param, configIdConstant, registChainId);
            // 店长热推产品
            Mono advert = this.recommendProductService.advert(traceId, param, configIdConstant);
            return Mono.zip(busiProduct, guessPref, registChain, advert).flatMap(product -> {
                // 推荐位(广告位)包装
                List products = recommendProductService.setRecommend(inns, product.getT1(), product.getT2(), product.getT3(), product.getT4(), param);
                // 设置其他参数
                return this.setOtherParam(traceId, param, products, memberAssetVO);
            });
        }).block();
    }

這個服務的Service層會經常性地根據產品需求和底層微服務接口的變更做出調整改變,而研發的接口調用時序圖卻因為團隊的這些更改對應不上代碼。
除了上述問題外,該服務中的多個微服務異步調用聚合的編碼問題也未能被妥善處理,因為其使用的Spring-MVC框架編碼風格是同步的,而Service層卻使用了異步的Mono,只能不合時宜地用block。 這些代碼更改、文檔缺失、編碼質量共同組成了中間層的代碼管理問題。

3.野蠻發展問題

我參與過一個初創技術團隊建設。 最開始,因為快速開發的需要,我們傾向於做一個胖服務,但當團隊規模開始擴大時,我們卻需要逐步地將胖服務分拆為微服務,開始產生中間層團隊,他們的主要目的是應用於底層服務的聚合。

但是,有一段時間,我們的招聘速度並不能完全趕上服務數量的增長速度,於是寫底層的同事就需要不斷地切換編碼思路。 因為除了要編寫分拆之後的底層微服務,還要編寫聚合的中間層服務。

當我停掉某一些項目時,開始整頓人手,我又發現一個殘酷事實:每個人手上都有數十個中間層服務,因此無法換掉任何一個人。 因為經過多次地換手,同事們已經搞不清中間服務的聯繫。

另外,還有各種授權方式,因為團隊一直以來的野蠻成長,各種授權方式都混在一起,既有簡單的,又有復雜的,既有合理的,還有不合理的。 總之,團隊沒有人能搞清楚。

经过一段时间的发展后,通过整理线上服务,我们发现很多资源浪费,比如有时候,仅仅一个接口就使用了一个微服务。在早起,这些微服务是有较大规模请求的,但是后来,项目被遗弃,也没有了流量,但是运行的接口依然在线上。而作为团队管理人员的我甚至没有任何书面上接口汇总的统计信息。

當老闆告訴我,把合作公司對接的服務暫停時,我無法做到邏輯上停機返回一個業務異常。 作為一個多渠道發展的上游庫存供應商,我們對接的渠道很多,提供給客戶的接口有很多特別定制的需求,這些需求一般就在中間的邏輯控制代碼裡面,渠道下線了,也不會做任何調整,因為開發者需要根據需求來進行代碼更新。

而且,中間層團隊對外聯合調試也是長久以來存在的一個問題。 經常有前端同事向我抱怨,後端的同事不肯增加數據處理邏輯的代碼,而作為前端,他們不得不增加很多轉換數據的代碼來適配界面的邏輯。 而像在小程序這種的對包大小進行限制的環境裡,這些代碼的移動在發展後期就成為一個老大難問題。

網關的選型失敗

當時,市面上存在兩種類型的解決方案:

  • 中間層的解決方案。 中間層方案一般提供裸異步服務、其他插件以及功能根據需求自定義,部分中間層的服務經過改造後也具備網關的部分功能。

  • 網關的解決方案。 網關方案一般圍繞著微服務全家桶提供,或者自成一派,提供通用型的功能(如路由功能)。 當然,部分網關經過自定義改造也能加入中間層的業務功能。

我們的業務發展變化非常快。 如果市面上已有的網關方案能滿足需求,我們又有能力進行二次開發,我們非常樂意使用。

當時,Eolinker是我們的API 自動測試的供應商,提供了對應的管理型網關,但語言是Go。 而我們團隊的技術棧主要以Java為主,運維的部署方案也一直圍繞著Java,這意味我們的選型就偏窄,因此不得不放棄這一想法。

在之前,我們也選擇過Kong網關,但是引入一個新的複雜技術棧是一件成本不低的事情,比如,Lua的招聘與二次開發是難以避免的痛。

另外,Gravitee、Zuul、Vert.x 都是不同小規模團隊使用過的網關。 談及最多的特性是:

  • 支持熔斷、流量控制和過載保護

  • 支持特別高的並發

  • 秒殺

然而,對業務來說,熔斷、流量控制和過載保護應該是最後考慮的措施。 而且,對一個成長中的團隊來說,服務的過載崩潰是需要經歷較長時間的業務沉澱。

另外,秒殺業務的流量更多是維持一個普通水平,其偶爾的高並發也是在我們團隊處理能力範圍之內。 換句話說,選型時,更多的是需要結合實際,而不是考慮類似阿里巴巴的流量,我只需考慮中等水平以上並且具備集群擴展性的方式即可。

此前,我們團隊使用比較廣的網關是Vert.x,編碼風格是這樣的,華麗酷炫。

private void dispatchRequests(RoutingContext context) {
  int initialOffset = 5; // length of `/api/`
  // run with circuit breaker in order to deal with failure
  circuitBreaker.execute(future -> { // (1)
    getAllEndpoints().setHandler(ar -> { // (2)
      if (ar.succeeded()) {
        List recordList = ar.result();
        // get relative path and retrieve prefix to dispatch client
        String path = context.request().uri();

        if (path.length() <= initialOffset) {
          notFound(context);
          future.complete();
          return;
        }
        String prefix = (path.substring(initialOffset)
          .split("/"))[0];
        // generate new relative path
        String newPath = path.substring(initialOffset + prefix.length());
        // get one relevant HTTP client, may not exist
        Optional client = recordList.stream()
          .filter(record -> record.getMetadata().getString("api.name") != null)
          .filter(record -> record.getMetadata().getString("api.name").equals(prefix)) // (3)
          .findAny(); // (4) simple load balance

        if (client.isPresent()) {
          doDispatch(context, newPath, discovery.getReference(client.get()).get(), future); // (5)
        } else {
          notFound(context); // (6)
          future.complete();
        }
      } else {
        future.fail(ar.cause());
      }
    });
  }).setHandler(ar -> {
    if (ar.failed()) {
      badGateway(ar.cause(), context); // (7)
    }
  });
}

但是,Vert.x社區缺乏支持以及入門成本高的問題一直存在,而團隊甚至找不到更多合適的同事來維護代碼。
以上網關的選型失敗讓我們意識到,市面沒有完全符合我們公司的情況的“瑞士軍刀”,由此我們開始走上了自研之路,開始進行Fizz網關的設計。

走上自研網關之路

我們需要網關麼? 網關層解決什麼問題? 這兩個問題不言而喻。 我們需要網關,因為它可以幫我們解決負載均衡、聚合、授權、監控、限流、日誌、權限控制等一系列的問題。 同時,我們也需要中間層,細化服務顆粒度的微服務讓我們不得不通過中間層聚合它們。

而我們不需要的是複雜的編碼、冗餘的膠水代碼,以及冗長的發布流程。

Fizz的設計考量

為解決這些問題,我們需要讓網關與中間層模糊界限,抹去網關和中間層隔閡,讓網關支持中間層動態編碼,盡可能少的發布部署。 為實現這個目的,只需要用一個簡潔的網關模型並同時利用low-code特性盡可能地去覆蓋中間層的功能即可。

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 1

從原點出發的需求

在復盤當初這個選擇時,我需要再強調下從原點出發的需求:

  • Java技術棧,支持Spring全家桶;
  • 方便易用,零培訓也能編排;
  • 動態路由能力,隨時隨地能夠開啟新API;
  • 高性能且集群可橫向擴展;
  • 強熱服務編排能力,支持前後端編碼,隨時隨地更新API;
  • 線上編碼邏輯支持;
  • 可擴展的安全認證能力,方便日誌記錄;
  • API審核功能,把控所有服務;
  • 可擴展性,強大的插件開發機制;

Fizz 的技術選型

在選型Spring WebFlux後,因為其單體較強的特性,同事建議命名為Fizz(Fizz是競技遊戲《英雄聯盟》中的英雄角色之一,它是一個近戰法師,其擁有AP中數一數二的單體爆發,因此可以克制大部分法師,可以作為一個很好地反制英雄使用)。

WebFlux是一個典型非阻塞異步的框架,它的核心是基於Reactor的相關API實現的。 相對於傳統的web框架來說,它可以運行在諸如Netty、Undertow和支持Servlet3.1的容器上,因此它運行環境的可選擇性要比傳統web框架多很多。

而Spring WebFlux 是一個異步非阻塞式的 Web 框架,它能夠充分利用多核 CPU 的硬件資源去處理大量的並發請求。 其依賴Spring的技術棧,代碼風格是這樣的:

 public Mono getAll(ServerRequest serverRequest) {
        printlnThread("获取所有用户");
        Flux userFlux = Flux.fromStream(userRepository.getUsers().entrySet().stream().map(Map.Entry::getValue));
        return ServerResponse.ok()
                .body(userFlux, User.class);
    }

Fizz的核心實現

對我們而言,這是一個從零開始的項目,很多同事剛開始沒有信心。 我為這個服務寫了第一個服務編排代碼的核心包fizz,並把這個commit寫為“開工大吉”。

我打算所有的服務聚合的定義就靠一個配置文件解決。 那麼,就有這樣的模型:如果把用戶請求作為輸入,那麼響應自然就是輸出,這就是一個管道Pipe;在一個Pipe中,會有不同的Step,對應不同的串聯的步驟;而在一個Step,至少有一個存在著一個Input接收上一個步驟處理的輸出,所有的Input都是並聯的,並且可以並行執行;貫穿於Pipe的生命週期中存在唯一的Context保存中間上下文。

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 2

而在每個Input的輸入與輸出,我增加了動態腳本的擴展能力,到現在已經支持JavaScript和groove兩種能力,支持JavaScript的前端邏輯可以在後端得到必要擴展。 而我們的配置文件僅僅需要這樣一個腳本:

// 聚合接口配置
var aggrAPIConfig = {
    name: "input name", // 自定义的聚合接口名 
    debug: false, // 是否为调试模式,默认false
    type: "REQUEST", // 类型,REQUEST/MYSQL
    method: "GET/POST",
    path: "/proxy/aggr-hotel/hotel/rates", // 格式:/aggr/+服务名+路径, 分组名以aggr-开头,表示聚合接口
    langDef: { // 可选,提示语言定义,入参验证失败时依据配置提供不同语言的提示信息,目前支持中文、英文
        langParam: "input.request.body.languageCode", // 入参语言字段
        langMapping: { // 字段值与语言的映射关系
            zh: "0", // 中文
            en: "1" // 英文
        }
    },
    headersDef: { // 可选,定义聚合接口header部分参数,使用JSON Schema规范(详见:http://json-schema.org/specification.html),用于参数验证,接口文档生成
        type:"object",
        properties:{
            appId:{
                type:"string",
                title:"应用ID",
                description:"描述"
            }
        },
        required: ["appId"]
    },
    paramsDef: { // 可选,定义聚合接口parameter部分参数,使用JSON Schema规范(详见:http://json-schema.org/specification.html),用于参数验证,接口文档生成
        type:"object",
        properties:{
            lang:{
                type:"string",
                title:"语言",
                description:"描述"
            }
        }
    },
    bodyDef: { // 可选,定义聚合接口body部分参数,使用JSON Schema规范(详见:http://json-schema.org/specification.html),用于参数验证,接口文档生成
        type:"object",
        properties:{
            userId:{
                type:"string",
                title:"用户名",
                description:"描述"
            }
        },
        required: ["userId"]
    },
    scriptValidate: { // 可选,用于headersDef、paramsDef、bodyDef无法覆盖的入参验证场景
        type: "", // groovy
        source: "" // 脚本返回List对象,null:验证通过,List:错误信息列表
    },
    validateResponse:{// 入参验证失败响应,处理方式同dataMapping.response
        fixedBody: { // 固定的body
            "code": -411
        },
        fixedHeaders: {// 固定header
            "a":"b"
        },
        headers: { // 引用的header
        },
        body: { // 引用的header
            "msg": "validateMsg"
        },
        script: {
            type: "", // groovy
            source: ""
        }
    },
    dataMapping: {// 聚合接口数据转换规则
        response:{
            fixedBody: { // 固定的body
                "code":"b"
            },
            fixedHeaders: {// 固定header
                "a":"b"
            },
            headers: { // 引用的header,默认为源数据类型,如果要转换类型则以目标类型+空格开头,如:"int "
                "abc": "int step1.requests.request1.headers.xyz"
            },
            body: { // 引用的header,默认为源数据类型,如果要转换类型则以目标类型+空格开头,如:"int "
                "abc": "int step1.requests.request1.response.id",
                "inn.innName": "step1.requests.request2.response.hotelName",
                "ddd": { // 脚本, 当脚本的返回对象里包含有_stopAndResponse字段且值为true时,会终请求并把脚本的返回结果响应给浏览器
                    "type": "groovy",
                    "source": ""
                }
            },
            script: { // 脚本计算body的值
                type: "", // groovy
                source: ""
            }
        }
    },
    stepConfigs: [{ // step的配置
        name: "step1", // 步骤名称
        stop: false, // 是否在执行完当前step就返回
        dataMapping: {// step response数据转换规则
            response: { 
                fixedBody: { // 固定的body
                    "a":"b"
                },
                body: { // step result
                    "abc": "step1.requests.request1.response.id",
                    "inn.innName": "step1.requests.request2.response.hotelName"
                },
                script: {// 脚本计算body的值
                    type: "", // groovy
                    source: ""
                }
            }
        }, 
        requests:[  //每个step•可以调用多个接口
            { // 自定义的接口名
                name: "request1", // 接口名,格式request+N
                type: "REQUEST", // 类型,REQUEST/MYSQL
                url: "", // 默认url,当环境url为null时使用
                devUrl: "http://baidu.com", // 
                testUrl: "http://baidu.com", // 
                preUrl: "http://baidu.com", // 
                prodUrl: "http://baidu.com", // 
                method: "GET", // GET/POST, default GET
                timeout: 3000, // 超时时间 单位毫秒,允许1-10000秒之间的值,不填或小于1毫秒取默认值3秒,大于10秒取10秒
                condition: {
                    type: "", // groovy
                    source: "return "ABC".equals(variables.get("param1")) && variables.get("param2") >= 10;" // 脚本执行结果返回TRUE执行该接口调用,FALSE不执行
                },
                fallback: {
                    mode: "stop|continue", // 当请求失败时是否继续执行
                    defaultResult: "" // 当mode=continue时,可设置默认的响应报文(json string)
                },
                dataMapping: { // 数据转换规则
                    request:{
                        fixedBody: {
                            
                        },
                        fixedHeaders: {
                            
                        },
                        fixedParams: {
                            
                        },
                        headers: {//默认为源数据类型,如果要转换类型则以目标类型+空格开头,如:"int "
                            "abc": "step1.requests.request1.headers.xyz"
                        },
                        body:{
                            "*": "input.request.body.*", // * 用于透传一个json对象
                            "inn.innId": "int step1.requests.request1.response.id" // 默认为源数据类型,如果要转换类型则以目标类型+空格开头,如:"int "
                        },
                        params:{//默认为源数据类型,如果要转换类型则以目标类型+空格开头,如:"int "
                            "userId": "input.requestBody.userId"
                        },
                        script: {// 脚本计算body的值
                            type: "", // groovy
                            source: ""
                        }
                    },
                    response: {
                        fixedBody: {
                            
                        },
                        fixedHeaders: {
                            
                        },
                        headers: {
                            "abc": "step1.requests.request1.headers.xyz"
                        },
                        body:{
                            "inn.innId": "step1.requests.request1.response.id"
                        },
                        script: {// 脚本计算body的值
                            //type: "", // groovy
                            source: ""
                        }
                    }
                }
            }
        ]
    }]
}

運行的上下文格式為:

// 运行时上下文,用于保存客户输入和每个步骤的输入与输出结果
var stepContext = {
    // 是否DEBUG模式
    debug:false,
    // elapsed time
    elapsedTimes: [{
        [actionName]: 123, // 操作名称:耗时
    }],
    // input data
    input: {
        request:{
            path: "",
            method: "GET/POST",
            headers: {},
            body: {},
            params: {}
        },
        response: { // 聚合接口的响应
            headers: {},
            body: {}
        }
    },
    // step name
    stepName: {
        // step request data
        requests: {
            request1: {
                request:{
                    url: "",
                    method: "GET/POST",
                    headers: {},
                    body: {}
                },
                response: {
                    headers: {},
                    body: {}
                }
            },
            request2: {
                request:{
                    url: "",
                    method: "GET/POST",
                    headers: {},
                    body: {}
                },
                response: {
                headers: {},
                    body: {}
                }
            }
            //...
        },
        // step result 
        result: {}
    }
}

當我把Input從僅僅看成一個輸入以及輸出,加上數據處理的中間過程,那麼,它就具備了很大的擴展可能性。 比如,在代碼中,我們甚至可以編寫一個MysqlInput的類,其擴展Input

public class MySQLInput extends Input {
}

其僅僅需要定義Input的少量類方法,就能支持MySQL的輸入,甚至與動態解析MySQL腳本,並且做數據解析變換。

public class Input {
    protected String name;
    protected InputConfig config;
    protected InputContext inputContext;
    protected StepResponse lastStepResponse = null;
    protected StepResponse stepResponse;
    
    public void setConfig(InputConfig inputConfig) {
        config = inputConfig;
    }
    public InputConfig getConfig() {
        return config;
    }
    public void beforeRun(InputContext context) {
        this.inputContext = context;
    }
    public String getName() {
        if (name == null) {
            return name = "input" + (int)(Math.random()*100);
        }
        return name;
    }
    /**
     * 检查该Input是否需要运行,默认都运行
     * @stepContext Step上下文
     * @return TRUE:运行
     */
    public boolean needRun(StepContext stepContext) {
        return Boolean.TRUE;
    }
    public Mono run() {
        return null;
    }
    public void setName(String configName) {
        this.name = configName;
    }
    public StepResponse getStepResponse() {
        return stepResponse;
    }
    public void setStepResponse(StepResponse stepResponse) {
        this.stepResponse = stepResponse;
    }
}

而擴展編碼的內容並不會涉及異步處理問題。 這樣,Fizz已經較為友好地處理了異步邏輯。

Fizz的服務編排

可視化的後台可以進行Fizz的服務編排功能,雖然以上的核心代碼並不是很複雜,但是其已經足夠將我們整個步驟抽象化。

現在,可視化的界面通過fizz-manager只需要生成對應的配置文件,並且讓其可以快速地更新加載即可。 通過定義的Request Input中的請求頭、請求體和Query參數,以及校驗規則或者自定義腳本實現複雜的邏輯校驗,在定義其Fallback,我們實現了一個Request Input,通過一些的Step組裝,最終一個經過線上編排的服務就能實時投入使用。 如果是只讀接口,甚至我們建議直接在線實時測試,當然支持測試接口和正式接口隔離,支持返回上下文,可以查看整個執行過程中各個步驟和請求的輸入與輸出。

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 3

Fizz的腳本驗證

當內置的腳本驗證方式不足夠覆蓋場景時,Fizz還提供更靈活的腳本編程。

// javascript脚本函数名不能修改
function dyFunc(paramsJsonStr) {
  // 上下文, 数据结构请参考 context.js
  var context = JSON.parse(paramsJsonStr)['context'];
  // common为内置的上下文便捷操作工具类,详情请参考common.js;例如:
  // var data = common.getStepRespBody(context, 'step2', 'request1', 'data');
  // do something
  // 自定义•返回结果,如果返回的Object里含有_stopAndResponse=true字段时将会终止请求并把脚本结果响应给客户端(主要用于有异常情况要终止请求的场景)
  var result = {
    // _stopAndResponse: true,
    msgCode: '0',
    message: '',
    data: null
  };
  // 返回结果为Array或Object时要先转为json字符串
  return JSON.stringify(result);
}

Fizz的數據處理

Fizz具備對請求的輸入和輸出進行數據變換的能力,它充分利用了json path的特性通過加載配置文件的定義對Input的輸入以及輸出進行變化以便得到合理結果。

Fizz的強大路由

Fizz的動態路由功能也設計得較為實用。 它有一套平滑替換網關的方案。 在最初,Fizz是可以跟其他網關並存的,比如之前提到的基於Vert.x的網關。 所以,Fizz就有一個類似Nginx的反向代理方案,純粹基於路由的實現。 於是,在項目初期,通過Nginx的流量被原原本本的轉發到Fizz,然後再到Vert.x,其代理了Vert.x全部流量。 之後,流量被逐步轉發到後端的微服務,Vert.x上有一部分特別定制的公用代碼被下沉到底層微服務端,Vert.x還有中間層服務被完全廢棄,服務器的數量減少50% 。 在我們做完調整後,原先困擾我的中間層人員以及服務器的問題終於得到解決,我們可以縮減每個同事手中的那一串服務列表清單,將工作落到更有價值的項目上去。 當這一切變得清晰時,這個項目也就自然而然顯示了它的價值。

針對渠道,這裡的路由功能也有非常實用的功能。 因為Fizz服務組概念的存在,讓它能針對不同渠道設置不同的組,從而解決渠道差別的問題。 實際上,線上可以存在多組不同版本的API,也同時變相的解決API版本管理的問題。

Fizz的可擴展鑑權

Fizz針對授權也有特別的解決方案。 我們公司組建比較早,團隊裡有多年編寫的老舊代碼,所以在代碼上也會有多種鑑權方式。 同時,另外也有外部平台支持方面的問題,比如在App和在微信上的代碼,就需要使用不同的鑑權支持。

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 4

圖上圖顯示的是通過的配置方式的驗簽配置。 實際上,Fizz提供了兩種方式:一種公用的內置驗簽,一種是自定義插件驗簽。 用戶使用時通過下拉菜單就能進行方便選擇。

Fizz的插件化設計

在Fizz設計初期,我們就充分考慮到插件的重要性,因此設計了方便實現的插件標準。 當然,這個需要開發者會對異步編程有很深的了解,這個特性適合有定制需求的團隊。 插件僅僅需要繼承PluginFilter即可,並且只有兩個函數需要被實現:

public abstract class PluginFilter {
    private static final Logger log = LoggerFactory.getLogger(PluginFilter.class);
    public Mono filter(ServerWebExchange exchange, Map config, String fixedConfig) {
       return Mono.empty();
    }
    public abstract Mono doFilter(ServerWebExchange exchange, Map config, String fixedConfig);
}

Fizz的管理功能

中大型企業的資源保護也是相當重要。 一旦所有的流量通過Fizz,便需要在Fizz建立對應的路由功能,而對應的API審核制度也是其一大特點,所有公司API接口的資源都被方便的保護起來,有嚴格的審核機制保證每個API都是經過團隊的管理人員審核。 並且,它具備API快速下線功能以及降級響應功能。

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 5

Fizz的其他功能

當然,Fizz適配Spring的全家桶,使用配置中心Apollo,能夠進行均衡負載,訪問日誌、黑白名單等一系列我們認為該有的網關功能。

Fizz的性能問題

雖然不以性能作為賣點,但是這並不代表著Fizz的性能就很差。 得益與WebFlux的加成,我們將Fizz與官方spring-cloud-gateway進行比較,使用相同的環境和條件,測試對象均為單個節點。 測試結果,我們的QPS比spring-cloud-gateway略高。 當然,我們還有想當的想像空間可以優化。

英特爾®至強®CPU X5675 @ 3.07GHz

Linux版本3.10.0-327.el7.x86_64

服務器減少50%,研發效率提高86%,我們的管理型網關Fizz自研之路 6

Fizz的應用與成績

在設計Fizz之初,我們就考慮到企業內部複雜的中間層情況:它可以截流所有的流量,能並行且逐步替換現有網關。 所以在內部推行時,Fizz很順利。

最初研發時,我們選取了C端業務作為目標業務,發布上線時僅替換其中部分複雜的場景,經過一個季度的試用,我們解決了性能和內存等各種問題。

在版本穩定後,Fizz被推廣到整個BU的業務線替代原先繁多的應用網關,緊接著是整個公司的適用的業務都開始使用。 原來我們C端、B端兩個中間層團隊研發能夠騰出手來從事底層業務的研發,中間層人員雖然減少了,但是研發效率卻有很大提升,比如原先需要多天開發的一組複製型服務研發時間縮短為之前的七分之一。

借助Fizz,我們開展進行服務合併工作,中間層的服務器減少50%,而服務的承載能力卻是上升的。

小結

前期,Fizz僅依靠配置就開始規模化的使用,但隨著使用人數的增加,配置文件編寫和管理需要讓我們開始擴展這個項目。 現在,Fizz包含兩個主要的後端項目fizz-gateway、 fizz-manager。 fizz-admin是作為Fizz的前端配置界面,fizz-manager與fizz-admin為Fizz提供圖形化的配置界面。 所有的Pipe都能夠在操作界面進行編寫以及上線。

為了能讓更多的中大型快速發展的團隊能夠應用上這個面向管理,解決實際問題的網關,Fizz提供了fizz-gateway-community社區版本的解決方案,而且作為對外技術的交流,其技術的核心實現將會以GNU v3授權方式進行的開放。 fizz-gateway-community的所有API將會公佈以便二次開發使用。 因為fizz-gateway-professional專業版本與團隊業務綁定,所以進行商業封閉。 而對應的管理平台代碼fizz-manger-professional作為商業版本開放二進制包的免費下載,提供給使用了GNU v3開源協議的項目免費使用(如果您的項目是商業性質,請聯繫我們進行授權)。 另外,Fizz已有的豐富插件我們也會選擇合適的時機與各位交流。

無論我們的項目交流是否能幫到各位,我們真誠希望能得到各位的反饋。 不管項目技術是否牛逼,完善與否,我們始終不忘初心:Fizz,一個面向大中型企業的管理型網關。

作者簡介:

林育穎(linwaiwai):錦江WeHotel直銷研發部負責人,從事互聯網行業十年,先後服務過合生元、唯品會、鉑濤集團等公司。 2017年和2018年被評為廣州市產業發展和創新人才。