Cod [kɒd] 是一套动态 tr 解决方案,包括 Egg 插件服务配置平台两部分。通过 cod ,可以直接在前端消费 tr 服务(sofa-rpc),增加、修改接口不需要修改和发布代码。

本文将从 cod 的诞生开始,到 cod 动态 tr 实现原理,详细介绍 cod 诞生的过程,cod 方案可以用于解决什么问题,以及 cod 的未来。

起源

有一天和同事们讨论一个技术方案,有人偶然提及 common.invokeTr ,说这个技术方案用的非常多,很好用。我乍一听,感觉这个 common.invokeTr 有点黑科技。

common.invokeTr 做的事情就是转发 tr 接口,在 BFF 中配置白名单,然后就可以直接在前端请求 tr 接口了。做的事情并不复杂,但这个想法我觉得非常有意思。这给了我一个新的思路,一直以来,我都在尝试引入 TypeScript ,通过 tr-snapshot 简化测试用例开发成本。

这些技术方案在复杂应用(全站 node)场景下有用。但 BFF 所做的事情,大都只是请求转发、简单数据处理,BFF 本身就是一个转发层。common.invokeTr 这种方式,是从减少代码量的角度来提高开发效率的。如何让代码写的更快,写的更好维护,都没有减少代码开发量来的更有效。

当时,我隐隐约约感觉到,BFF 代码可以通过某种方式进行抽象,大部分代码是可以简化,是不需要开发的

后面有很长一段时间,我开始关注这个问题,开始思考如何减少 BFF 代码量。从公司内部来看,还是有挺多人思考,并且在这方面尝试突破了。

最初,我想到的是和 graphql 结合,经过一段时间开发,然后 model-bus 诞生了。

model-bus

model-bus 设计了一套类似于 graphql 的查询语法,相对于 graphql 更加简单,只支持单层数据结构查询,我称之为 simpleql

之所以要重新设计一套查询语句,而不是直接使用 graphql ,是因为 graphql 必须首先定义模型结构,这导致接入成本非常搞。而我们的 BFF 应用,数据都是来源于外部服务,我们并不需要关注模型的结构。另外,graphql 只支持数据结构描述,如果要描述一些逻辑、依赖关系就无能为力的。因此,我重新设计了一套语法,这样方便扩展,更适合 BFF 技术体系。

simpleql

简单来说,simpleql 的解析过程,就是遍历查询语句字符串,遇到关键字,进入到下一个状态,并且把上一个状态数据记录下来。字符串遍历一遍之后,就可以得到语法树结构了。

一般情况,大家一听到说要写一套新的语法,就会比较担心其中可能存在性能问题。实际上,simpleql 语法解析的复杂度是 O(n) ,只需要遍历一遍字符串就够了

比如一条简单的查询语句 foo: Fengdie(insmutual_clause) { a, b} 解析,通过状态图可以了解具体过程。

Cod 起源和用武之地 - 图1 这个图表示一个模型语法的描述,可能的路径也是可以枚举的,大致分为一下四种情况

  1. foo: Fengdie // 1 -> 7
  2. foo: Fengdie(insmutual_clause) // 1 -> 2 -> 6
  3. foo: Fengdie { a, b, c: originField } // 1 -> 4 -> 5
  4. foo: Fengdie(insmutual_clause) { a, b, c: originField } // 1 -> 2 -> 3 -> 5

simpleql 的解析过程,也是遵循上面状态机来解析的,有兴趣的可以看看源码

模型调度

simpleql 是查询语句,只是用来描述数据模型以及数据之间的关系。最终,需要调用 BFF 服务,来完成数据查询,最终给输出数据,这个过程称之为模型调用。

我们可以把 simpleql 描述的查询语句,看成一系列任务。每一个 model ,都需要找到对应的模型处理器,模型处理器来负责真正的模型查询。

首先,在 model-bus 中,暴露出一个方法 defineModel ,通过这个方法来统一注册模型。举个简单的例子

  1. import { defineModel } from '@alipay/model-bus';
  2. export default class InsiopService extends Service {
  3. @defineModel(DataType.InsIopData, 'scene')
  4. async getInsIopData(query: { userId?: string; source: string, scene: string }) {
  5. const { moduleList, scm } = await this.getPageRenderData(params);
  6. return { scm, moduleList };
  7. }
  8. }

然后需要进行真正的调用了,一开始对查询语句解析,得到多个执行任务,这一批任务会并发查询。查询结束后,统一返回。

有时候,我们会有模型之间相互依赖,这种情况,第一次任务执行,在查找依赖数据的时候,会直接 throw 一个特定的异常,在模型调度的过程中,捕获这些异常,然后把这部分有依赖的任务放入到一个临时的数组中。等待本次任务完成,任务还没有全部完成,会自动执行上次等待中的任务,一直到所有任务完成,最终返回数据。说起来有些枯燥,画个流程图说明一下

Cod 起源和用武之地 - 图2

问题

model-bus 完成了第一步,把 BFF 面向页面开发,转向为面向模型开发,这样模型的复用率会更好一些。搞定之后,开始在 insxhbbff 中尝试,把几个页面接口简化了。

但仔细思考后,发现这个方案解决的查询聚合的问题,在一些中后台应用中真正需要的是,在不发布应用的情况下,直接转发 tr 接口,这种情况下,BFF 这一层是多余的

仔细想想,目前 model-bus 本身并没有比 common.invokeTr 多少提升,就多了支持聚合查询能力。聚合查询有用的前提是模型复用度足够高,实际情况可复用的模型并不多。

然后问题变成了如何实现动态 tr ,对这个问题进一步探索,最终诞生了 cod 。

Cod

动态 tr

cod 最核心需要解决的问题就是 tr 请求动态化。也就是在不发布 BFF 应用的情况,能够请求到 Java 应用提供的 tr 服务。

一开始,我的大致思路是,动态配置加上固定的插件代码,替代 jar2proxy 生成的 js 代码,实现调用 tr 服务。那样,新增 tr 服务只需依赖配置下发,没有代码的修改,就不需要发布应用了。

一开始觉得可能会很复杂,尝试一下之后发现,实现动态 tr 请求还是很简单的。我写了一个最简单的 demo

  1. const rpcClient = app.trClient || app.hsfClient;
  2. consumer = rpcClient.createConsumer({
  3. id: 'com.alipay.insxhbprod.common.service.facade.BrandFacade:1.0',
  4. appname: 'insxhbprod',
  5. targetAppName: 'insxhbprod',
  6. proxyName: 'BrandFacade',
  7. });
  8. const args = [
  9. {
  10. $class: 'com.alipay.insxhbprod.common.service.facade.model.request.BrandStatisticsQueryRequest',
  11. $: { userId: '2088302539975403' },
  12. },
  13. ];
  14. consumer.invoke('queryBrandStatisticsInfo', args, { ctx: this.ctx });

从上面的例子可以看出,只需要把 jar 包解析,拿到服务 id 和入参数据结构,然后动态发布到 BFF 应用服务器上,就可以实现动态 tr 了。

于是,我们还缺少一个平台来做服务结构配置下发。然后我们成立了一个小组,开始搞起来。

命名

一个产品的诞生,从有名字开始。

想了很久,我们觉得给这个平台命名 Cod [kɒd] 。cod 作为名词是鳕鱼的意思,同时 cod 和 code 比较接近,只少了一个 e ,寓意 code less 。

基于以上寓意,我找到外援(我老婆)帮设计了一个好看的 logo :

image.png

再经过大家一两个月的密集开发,最终,我们完成了 cod 平台管理站点 。

实现原理

cod 平台主要模型包括 应用-> Jar 包 -> 服务 三层。

Cod 起源和用武之地 - 图4 其中 Jar 包维度上功能最复杂,最核心的配置发布流程,和 jar 解析、服务新增。jar 包解析过程,基于 jar2proxy ,然后重新构建成 cod 需要的数据结构。

发布过程,基于 basement 的 filesync 服务,直接把配置文件发布到 oss 。BFF 端,通过内置的 fengdie-pull 插件,拉取配置数据,最终 tr 调用在 model-bus 插件中执行,配置数据从 fengdie 区块读取。整体实现原理如下图所示

image.png

最终通过 cod 平台加上 model-bus 组合,实现了动态 tr 能力。tr 接口发布从原来的代码开发流程,转变为区块配置发布流程。

效率

tr 接口转发,这样一个场景,我们真正做到了高效。下面新增 facade 服务两者开发流程对比图

image.png

未来

现在我们的整套方案,包括两部分能力

  • cod 实现动态 tr ,方便快捷支持 tr 接口转发类场景
  • model-bus 支持模型聚合查询

我们还打算做一个本地 sdk 工具,通过这个命令行工具,可以生成调用代码和接口类型,这样可以进一步简化开发者使用成本。整体架构图如下
image.png

总体 cod 方案,解决了动态 tr 能力。并且在此之上,会尝试覆盖更多的业务场景,把更多简单的 controller 层代码转换为配置。一言蔽之就是 To write code less and get more things done

处理器

通过处理器,我们可以对返回数据进行一定的处理。处理器的存在,最初是为了解决类似下面场景

  1. if (contractNo) {
  2. const c = await this.contractService.detail({
  3. source,
  4. contractNo,
  5. });
  6. this.ctx.body = {
  7. ...this._cookContract(c),
  8. clauseList: this.app.fengdie.insmutual_clause.cancer,
  9. };
  10. } else {
  11. const contractList = await this.contractService.list({
  12. source,
  13. brandCode,
  14. });
  15. const toRenewalContract = contractList.filter(c =>
  16. c.status === 'JOINED' &&
  17. c.autoRenewStatus === 'WAIT_OPEN' &&
  18. c.supportAutoRenew,
  19. )
  20. .sort((a, b) => moment(a.terminateTime).valueOf() - moment(b.terminateTime).valueOf())[0];
  21. this.ctx.body = {
  22. ...this._cookContract(toRenewalContract),
  23. clauseList: this.app.fengdie.insmutual_clause.cancer,
  24. };
  25. }

这种情况,实际上是调用 tr 查询合约列表,然后对数据进行一定的处理,这个数据处理,可以通过处理器来描述

  1. {
  2. simpleQuery: `
  3. ${ contractNo ?
  4. 'contract: Contract(contractNo: $contractNo)' :
  5. 'contract: ContractList | filter($filter) | sort($sort) | get(0)'
  6. },
  7. clauseList: Fengdie(insmutual_clause) | get(cancer),
  8. `,
  9. params: {
  10. filter: `item.status === "JOINED"
  11. && item.autoRenewStatus === "WAIT_OPEN"
  12. && item.supportAutoRenew`,
  13. sort: 'a.terminateTime > b.terminateTime ? 1 : -1'
  14. }
  15. }

这个里面使用了 model-bus 内置的几个处理器 filter 、sort 和 get 。处理器的参数是简单 js 表达式,通过 simple-evaluate 解析,是绝对安全的表达式,所以可以直接在前端写查询语法。simple-evaluate 的解析过程和 simpleql 类似,可以参考这篇文章

有了处理器,model-bus 能够做的事情还是能够强大很多,另外,我们未来我们还打算开发自定义处理器,让开发者在 BFF 中写个性化的处理器。当然,新增处理器,是需要发布应用的。目前我们遵循的原则是,Cod 只动态下发配置,如果需要修改代码,就应该走发布流程。
**

复杂场景

实际上,model-bus 提供一些简单的语法描述能力,但可以组合成非常复杂查询逻辑。我们也在不断尝试在更多的场景中应用。下面有一个相对复杂的案例(代码不展示了)

分为三个步骤

  • 并发两个 tr 查询 isSamePerson 和 hasInsUser 数据
  • 如果数据 ok ,继续查询 userNo 数据
  • 把上一个 userNo 数据作为下一个函数的参数,继续查最后一个 tr

这个通过 simpleql 语法可以这样描

  1. {
  2. simpleQuery: `
  3. isSamePerson: Cod(method: IdentityService.isSamePerson) | get(model),
  4. hasInsUser: Cod(method: InsUserQueryFacade.getInsUser) | get(model),
  5. userNo: Cod($if: "isSamePerson && hasInsUser", method: InsUserFacade.addUser) | get(data)
  6. _attachment: Empty($$userNo) | assign($attachQuery)
  7. attachment: Cod(method: insUserAuthFacade.uploadCertificateAttachment, params: $$_attachment)
  8. `,
  9. params: {
  10. attachQuery: {
  11. certType: CertTypeEnum.IDENTITY_CARD,
  12. sourceEvent: SourceEventEnum.QUERY,
  13. certUse: CertUseEnum.XHB,
  14. bizData: { policyNo },
  15. },
  16. },
  17. }

这样场景,实现是没有问题的,但还是会有一些比较麻烦之处

  • 在前端要写太多 tr 入参数据,需要前端理解太多的服务端逻辑
  • 这样的语法,理解或者解释成本都是有一些高的
  • 没法写测试用例,稳定性不那么可靠

解决的方案,可能是把这一些语法配置放在 Cod 平台上,或者允许开发者在 Cod 平台上拖拽加配置的可视化操作,完成这种复杂场景的配置。

未来,我们会首先在 model-bus 中实现更加复杂场景支持能力,经过足够的实践经验,合适的情况,会把这些配置能力直接做到 Cod 平台上,这就是所谓的服务编排,目前我们还是专注于代码(simpleql)编排。