服务器减少50%,研发效率提高86%,我们的管理型网关Fizz自研之路

2020 年 9 月 22 日

服务器减少50%,研发效率提高86%,我们的管理型网关Fizz自研之路

引言

在参与电商工作第一年,我从事客户端开发工作。虽然团队规模不大,但是对接的中间层团队人数,却相当于团队近四分之一的规模。工作第四年,我又加入国内一家知名的电商公司。这家公司的主要业务形态是特卖,中间层团队占团队的人数近三分之一。而现在,我所带领的团队,在发展初期,中间层团队也是接近这个规模。

三个团队都是电商团队,用户规模较大,在并发上要求较高,并且采用微服务架构,由中台底层提供各种电商服务(如订单、库存)和通用服务(如搜索),所以中间层团队需要经过各种授权和认证调用各个 BU 的服务,从而组装出前端适配的接口。因为对 C 端业务的接口繁多,所以中间层占用着团队宝贵的人力资源。而且团队成立时间越久,累积的接口越多,管理如此繁多的接口是一个令人头疼的问题。

中间层的问题

1. 开发调试问题

中间层在 Web 网站上的部署偏前,一般部署于防火墙及 Nginx 之后,更多面向 C 端用户服务,所以在性能并发量上有较高要求,大部分团队在选型上会选择异步框架。正因为其直接面向 C 端,变化较多,大部分需要经常性地变更或者配置的代码都会安排在这一层次,发布非常频繁。此外,很多团队使用编译型语言进行编码,而非解释型语言。这三个因素组合在一起,使得开发者调试与开发非常痛苦。

比如,我们曾经选择 Play2 框架,这是一个异步 Java 框架,需要开发者能够流畅地编写异步,但是熟悉调试技巧的同事也不多。在代码里面配置了各种请求参数,以及结果处理,看似非常简单,但是联调、单元测试、或者配置文件修改之后等待 Java 编译花费的时间和精力是巨大的。如果异步编码规范也有问题,这对开发者来说无疑是一种折磨。

复制代码
public F.Promise<BaseDto<List<Good>>> getGoodsByCondi(final StringBuilder searchParams, final GoodsQueryParam param) {
final Map<String, String> params = new TreeMap<String, String>();
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<HttpResponse> promise = HttpInvoker.get(url, params);
final GoodListBaseDto retVal = new GoodListBaseDto();
Promise<BaseDto<List<Good>>> goodListPromise = promise.map(new Function<HttpResponse, BaseDto<List<Good>>>() {
@Override
public BaseDto<List<Good>> 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<List<Good>>(CommonError.SYS_ERROR);
}
JsonNode result = json.get("items");
Iterator<JsonNode> iterator = result.elements();
final List<Good> goods = new ArrayList<Good>();
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<JsonNode> 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<List<ActiveTip>> pmsPromise = service.getActiveTipsByBrand(param.getBrandId());
return goodListPromise.flatMap(new Function<BaseDto<List<Good>>, Promise<BaseDto<List<Good>>>>() {
@Override
public Promise<BaseDto<List<Good>>> apply(BaseDto<List<Good>> listBaseDto) throws Throwable {
return pmsPromise.flatMap(new Function<List<ActiveTip>, Promise<BaseDto<List<Good>>>>() {
@Override
public Promise<BaseDto<List<Good>>> apply(List<ActiveTip> activeTips) throws Throwable {
retVal.setPmsList(activeTips);
BaseDto<List<Good>> baseDto = (BaseDto<List<Good>>)retVal;
return Promise.pure(baseDto);
}
});
}
});
}
return goodListPromise;
}

上述代码只是摘抄了其中一个过程函数。如果我们将中间层的场景设置得更为复杂一些,我们要解决的就不仅仅是编码性能、编码质量、编码时间的问题。

2.“复杂”场景问题

微服务颗粒度较细,为了实现简洁的前端逻辑以及较少的服务调用次数,我们针对 C 端的大部分输出是聚合的结果。比如,我们一个搜索的中间层逻辑,其服务是这样一个过程:

  1. 获取会员信息、会员卡列表、会员积分余额,因为不同级别的会员会有不同价格;
  2. 获取用户的优惠券信息,这部分会对计算出来的价格产生影响;
  3. 获取搜索的结果信息,结果来自三部分,商旅商品的库存价格,猜你喜欢的库存价格,推荐位的库存价格,海外商品的库存价格。

这其中涉及到的服务有:中间层服务(聚合服务)、会员服务、优惠券服务、推荐服务、企业服务、海外搜索服务、搜索服务。此外,还有各种类型的缓存设施以及数据库的配置服务。

复制代码
public List<ExtenalProduct> 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<List<ExtenalProduct>> innInfos = this.search(traceId, param, configIds, resultVO);
return innInfos.flatMap(inns -> {
// 商旅产品推荐
Mono<ExtenalProduct> busiProduct = this.recommendProductService.getBusiProduct(traceId, param, configIdConstant);
// 会员产品推荐(猜您喜欢)
Mono<ExtenalProduct> guessPref = this.recommendProductService.getGuessPref(traceId, param, configIdConstant);
// 业务相关查询
String registChainId = memberAssetVO == null || memberAssetVO.getMember() == null ? null : memberAssetVO.getMember().getRegistChainId();
Mono<ExtenalProduct> registChain = this.recommendProductService.registChain(traceId, param, configIdConstant, registChainId);
// 店长热推产品
Mono<ExtenalProduct> advert = this.recommendProductService.advert(traceId, param, configIdConstant);
return Mono.zip(busiProduct, guessPref, registChain, advert).flatMap(product -> {
// 推荐位 (广告位) 包装
List<ExtenalProduct> 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<Record> 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<Record> 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 特性尽可能地去覆盖中间层的功能即可。

从原点出发的需求

在复盘当初这个选择时,我需要再强调下从原点出发的需求:

  • 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<ServerResponse> getAll(ServerRequest serverRequest) {
printlnThread("获取所有用户");
Flux<User> 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 保存中间上下文。

而在每个 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<String> 对象,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<String, Object> stepContext) {
       return Boolean.TRUE;
  }
   public Mono<Map> 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 组装,最终一个经过线上编排的服务就能实时投入使用。如果是只读接口,甚至我们建议直接在线实时测试,当然支持测试接口和正式接口隔离,支持返回上下文,可以查看整个执行过程中各个步骤和请求的输入与输出。

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 和在微信上的代码,就需要使用不同的鉴权支持。

图上图显示的是通过的配置方式的验签配置。实际上,Fizz 提供了两种方式:一种公用的内置验签,一种是自定义插件验签。用户使用时通过下拉菜单就能进行方便选择。

Fizz 的插件化设计

在 Fizz 设计初期,我们就充分考虑到插件的重要性,因此设计了方便实现的插件标准。当然,这个需要开发者会对异步编程有很深的了解,这个特性适合有定制需求的团队。插件仅仅需要继承 PluginFilter 即可,并且只有两个函数需要被实现:

复制代码
public abstract class PluginFilter {
    private static final Logger log = LoggerFactory.getLogger(PluginFilter.class);
    public Mono<Void> filter(ServerWebExchange exchange, Map<String, Object> config, String fixedConfig) {
       return Mono.empty();
    }
    public abstract Mono<Void> doFilter(ServerWebExchange exchange, Map<String, Object> config, String fixedConfig);
}

Fizz 的管理功能

中大型企业的资源保护也是相当重要。一旦所有的流量通过 Fizz,便需要在 Fizz 建立对应的路由功能,而对应的 API 审核制度也是其一大特点,所有公司 API 接口的资源都被方便的保护起来,有严格的审核机制保证每个 API 都是经过团队的管理人员审核。并且,它具备 API 快速下线功能以及降级响应功能。

Fizz 的其他功能

当然,Fizz 适配 Spring 的全家桶,使用配置中心 Apollo,能够进行均衡负载,访问日志、黑白名单等一系列我们认为该有的网关功能。

Fizz 的性能问题

虽然不以性能作为卖点,但是这并不代表着 Fizz 的性能就很差。得益与 WebFlux 的加成,我们将 Fizz 与官方 spring-cloud-gateway 进行比较,使用相同的环境和条件,测试对象均为单个节点。测试结果,我们的 QPS 比 spring-cloud-gateway 略高。当然,我们还有想当的想象空间可以优化。

Intel® Xeon® CPU X5675 @ 3.07GHz

Linux version 3.10.0-327.el7.x86_64

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 年被评为广州市产业发展和创新人才。

2020 年 9 月 22 日 10:08 2435

评论

发布
暂无评论
发现更多内容

加密数字货币钱包APP系统开发,数字货币钱包系统定制

13530558032

为什么阿里巴巴的程序员成长速度这么快,看完他们的内部资料我明白了

Java迁哥

隐秘的MySQL类型转换

架构精进之路

MySQL

大数据

GalaxyCreater

大数据

第十二周总结

Linuxer

week12 总结

雪涛公子

打开 政务上链 应用场景

CECBC区块链专委会

区块链 数字身份 政务

超声大数据应用

周冬辉

大数据

Android的特殊攻击面(三)——隐蔽的call函数

OPPO安全

android 安全攻防 安全 函数

交易所合约跟单系统源码开发,合约跟单平台搭建

13530558032

区块链技术创新应用势在必行 食品药品开启全链条溯源时代

CECBC区块链专委会

区块链 溯源 药品

第十二周作业

赵龙

第十二周学习总结

赵龙

大数据应用

GalaxyCreater

大数据

如何判断程序员的代码是否优美?

Garfield

代码质量 代码 代码优化 代码重构

【架构师训练营】第 12 周作业

花生无翼

大数据总结

周冬辉

大数据技术

第 0 期架构师训练营第 7 周作业 1

天上星亮晶晶

大数据作用

SpreadJS 纯前端表格控件应用案例:Teammark知识管理库

Geek_Willie

SpreadJS

非传统的“易观”,和他的技术驱动之路

易观大数据

Android 中的特殊攻击面(一)——邪恶的对话框

OPPO安全

android 安全攻防 安全

架构师课程第十二周总结

dongge

Git技术干货!工作中"Git"的使用实践和常用命令合集!

洋仔聊编程

git git常用命令 git常用实践 工作中git的使用

前端源码宝库

InfoQ_34a83d636158

阿里巴巴Java开发手册-日志规约

魏杰

后疫情时代 数字经济如何大显身手

CECBC区块链专委会

疫情 数字经济 数字技术

week12

强哥

极客大学架构师训练营

第 0 期架构师训练营第 7 周作业 2 ----总结

天上星亮晶晶

释放数据价值:DAYU数据运营新能力解读

华为云开发者社区

大数据 数字化转型 华为云 代码原理 数据运营

Android中的特殊攻击面(二)——危险的deeplink

OPPO安全

android 安全攻防 安全

服务器减少50%,研发效率提高86%,我们的管理型网关Fizz自研之路-InfoQ