淘系技术部前端技术专家 张挺
淘宝在 2017 年之前就开始探索 TypeScript 的落地方式,随着时间的推移已经将新的模块和框架全部迁移到 TypeScript 体系,在 2019 年, TypeScript 应用已经遍地开花,提前完成了非常不错的布局。
GMTC 大会上,淘系技术部前端技术专家 张挺,分享了淘宝的 Midway 部分想法和实践,本次分享主要介绍淘宝最近开源的 Midway 框架在新的场景、新的体系下如何和现有的 Egg 体系保持良好的兼容性,同时又能在 TypeScript 的使用中有着独特的体验,通过针对不同场景的情况,我们引入相同的解决方案,为未来打下了夯实的基础。
▐ 跨平台方案
整个分享的内容基调是基于当前的 Node.js 开发背景来的。
BFF 应用
对于阿里集团来说,大部分的应用都是 BFF 应用,这些应用表现为长尾应用,维护人不断的迭代流失,可能最后也找不到人维护,而这 70% 的应用被逐步放弃,但是依旧有一些同学还在不断的使用,对于一个 BU 来说,是很不利的。而 Serverless 的出现,可能是给这些应用一个机会,一个能摆脱维护,摆脱人员投入的机会,但是具体如何,还要看今年的发展,毕竟 Serverless 和传统的应用开发有很大的不同。
全栈应用
剩下的就是全栈应用了,去除那些不重要的 BFF 应用,我们还对其做了核心和非核心的划分,在这些应用中,不乏有承载千万流量的大应用。而这些应用都由前端同学来维护,整个研发,测试,发布的流程都必须非常谨慎。
TypeScript应用
在集团应用中,TS 的使用没有想象的那么多,据我们采集的数据,也就只占 5% 左右,基本都是 midway(TS 版本,内部还有 JS版本),而今年,我们希望新应用全量使用 TS。
在这种场景下,对于业务同学来说,也有很多苦恼,比如业务复杂,接口没有定义,以前使用 schema,但是没有很大的推广开来,这些都需要自己去拿时间来填,反而并不友好。在集团内发布 RPC 服务,也需要写 jsdoc,用于匹配 java 的类型,在 js 场景场景下,这些都是不得已的选择。
这个时候引入 TypeScript,来帮助我们解决这些质量,习惯,方法上的问题,就拿 midway 团队来说,自从使用了 TypeScript,质量提升的非常明显,平常需要测试很久的代码,几乎不会出现低级的问题,反而暴露出的大多都是逻辑问题。
面向接口编程,也成为了大家的习惯,每次多人协作,也只需要先定义 interface,再根据 interface 的约定去各自实现,效率也非常高。同时,我们将 RPC 生成的工具替换成了 TypeScript 解析,将 Java 类型和 TS 类型做了一些映射,也避免了再使用 JsDoc 描述的问题。
讲了这么多 TS 的使用,下面来解决具体的问题。
Midway 是淘宝去年开源,面向未来的全栈框架,所谓面向未来,我们希望在未来能够不断的迭代,而主代码不需要做过多的变更,同时在技术迭代的浪潮中,我们的框架也能不断的适用于新的场景。
Egg.js 解决了 Web 开发的场景,在不断的演进中,淘宝产生了全栈场景,Egg.js 已经无法满足目前的需求,一方面集团内需要编写上层框架,另一方面我们希望有原生的 TS 体验。
在现有的 Controller - Service 架构中,除了 Controller 是明确意义的,Service 承载了非常多的职能,把 API,服务,逻辑其实都放在了一起,如果想单独拆分目录,也不是特别方便。
在 Egg.js 的更新之后,加入 ts-helper 填补了 TS 方面的空缺,不过目前由于目录约定,编译前后的文件是在一起的,略微有一些不舒服。
在体验方面,不同之处,例如:Egg.js 是支持过程式写法的,在类的写法中,由于请求链路的关系,比如手动继承一个基类,这在业务中,如果想要自行再继承就无法满足。
同时,核心的 Loader 机制把属性方法都挂载到了 app 上,显得不是特别优雅。这促使我们做了第一代的设计。
▐ 第一代的设计
淘宝使用 IoC 非常早,我们有许多熟悉 Java 的同学非常喜欢 spring,一开始沿用了 XML 的写法来配置,但是转到前端来写,XML 就变成了桎梏,负累重重。
在参考了轻量的 inversify 之后,我们觉得提供两个简单的装饰器是一个最好的办法。
@injectable() 提供了暴露类可以被 IoC 注入的能力。而 @inject() 提供了相应的注入属性的能力。同时 inversify 有个 bindding 的包,提供了自动绑定的能力,我们也沿用了里面的装饰器,这才有了自研的 injection 包,里面包含了 @provide 和 @inject 两个装饰器方法。
经过了 IoC 之后,我们把所有的对象统一放在了 IoC 容器中管理,不再需要关心实例的来源,也不在需要自行去创建实例(new)。
在使用了 IoC 之后,我们发现所有的写法都可以变成传统的 class 形式,封装继承多态三大特性都可以完美的使用,不再受到其他限制。
抛开装饰器,代码就是原生的 class,不管是测试也好,开发也好,都方便的使用 TS 的类型描述,最直观,也最简单。
在集团内,大约有 10 来个中间件,为了让使用者有 TS 定义,将原有的代码进行了增强,这都是一次性的工作量,可以造福后人。
▐ 第二代设计
和 Egg.js 解耦
之前我们解决了 Service 的问题, 通过 IoC,我们可以随便创建目录,调用 API,以及测试。但是在 Web 层,和 egg 耦合的地方还是沿用了 egg 的写法,虽然有变通的办法,但是需要在体验上更进一步。
Midway 基于 Egg.js 进行迭代开发,要实现 egg 的插件化能力,是直接在 package.json 中依赖了 egg 包,同时由于 IoC 的产出,又希望能够让各种开发体验保持一致,全部使用 class 的写法,这也促使我们和 egg 进行了解耦,使用装饰器完成各种 web 层的能力。
- 通过 @config 能力,和 app.config 解耦
- 通过 @plugin,和 app.xxx 插件解耦
- 通过 @inject() ctx 和请求链路解耦
此外还有 @logger 等装饰器,提供额外的能力。
和目录结构解耦
在做完 IoC 自扫描能力之后,已经完全不需要考虑目录结构了,如果还需要 egg 的插件能力,目录还需要保留,如果不需要插件,就可以自由定义目录,扫描能力会完成一切。
通过自扫描能力,在极端情况下,可以将原有应用按功能划分,也可以随意拆分成子模块,甚至是 npm 包,而每一个模块都可以随时独立开发部署,也可以随时聚合成一个大应用。
和自己解耦
在做完这些之后,我们觉得未来可能要面向不同的场景去了,这个时候如果一味的只考虑一个框架入口,可能会被受限制,虽然我们将 Midway 的代码分开抽象,但是核心还是在一起的,各个装饰器的实现和定义都是在同一个包,这样扩展插件或者新增装饰器都需要改动到 Midway 本身。所以需要一次重构把和 Midway 依赖的东西都解耦掉。
首先将装饰器的定义都单独分离出来,形成一个新包,这个包中有所有的装饰器,以及他们最基本的函数(装饰器定义)。
抽离完定义之后,我们就可以将实现部分单独成为新的包,这个时候才有 midway-web 等包的产生。
▐ 面向未来的设计
所谓面向未来,就要为未来考虑和设计,而几年 Serverless 的大热,也为 Node.js 开发者提供了新的机会,而作为集团唯一的 Node.js 架构团队,自然当仁不让的投入到了研究的浪潮中。
在考虑跨场景之时,正逢将装饰器定义与实现分离的时候,我们顺便也将通用的能力沉淀了下来,这样未来不同的场景都可以共享这些能力。
我们沉淀出了 midway-core 这个包,包含以下几种能力。
第一种是自扫描注入 IoC 的能力,injection 提供通用绑定能力。
第二种是适配 midway 的请求作用域能力,不同的场景必然有请求,这个能力也属于通用的能力之一。
第三是统一的装饰器扩展能力,比如 @config 的扩展。
在 Midway-core 之外,我们也实现了一个 Decorator Manager 用于装饰器的编码和管理。
以新创建一个装饰器为例,比如 @autoload,某些类加了这个装饰器,希望能在应用启动时自动被实例化,执行 init 方法。在新的分离体系下,只需要定义一个装饰器(标准函数),将这个装饰器的 key 通过 saveModule 方法进行保存。
在模块、插件等任意你希望实现这个装饰器能力的地方,通过 listModule 就可以把用到这个装饰器的类通通拿出来,接下去你只要循环,然后实例化这个类,执行方法就行了。通过这样的机制,我们把所有的装饰器都进行了改造,实现了整个模式。
在这次改造之后,我们觉得多场景的方案基本可行,在 koa/express 上做了试点,通过编码之后,基本上在 200 行左右就完成整个功能,同时达到整个代码使用相同的装饰器,并且逻辑基本不变。
在这之后,又逐步实现了其他的一些场景,同时对这些场景完成了一些工具链,配套等等。这些工具链有些是复用的,例如:Midway-bin,有些又是特定场景使用。
Serverless 场景,也是我们的整个场景之一。Serverless 整体分为很多部分,这里我们只将将函数代码部分。
FaaS 是 Serverless 的实现之一,我们本来觉得在 FaaS 体系中代码比较简单,无需框架的帮助,但是在实际调研中,我们发现用户的代码还是有不少,同时文件和复杂度还是有一些,所以也同样需要框架的帮助。
但是这个框架必须是非常精简,非常小,只需要完成基本的功能即可。由于我们多场景的设计,代码的整体结构也和原来的基本保持一致,最终我们实现的 midway-faas,大概在 120 行代码,保留最基本的 IoC 能力。
可以看到代码写法基本一致,只有装饰器的区别。
可以看到除了包名不同,入口的装饰器略有差异外,整个写法上依旧保持基本的 class 形态。
除了写法一致之外,对于 FaaS 本身,我们还有一些诉求。
1、代码一致,能力一致,这个通过 IoC,基本能够做到了
2、我们希望一套代码,能够部署到多个云环境
对于不同的平台来说,调用方式(回调,async),函数参数(event),以及描述文件(spec)都是不同的,要把他们统一其实比较困难,但是经过内部验证,我们依旧可以在一些地方进行统一。
我们针对不同平台的入口文件进行包裹,一般来说,入口文件是通过描述文件 (spec) 的 handler 字段指定的,例如: index.handler ,指的就是 index.js 文件的 handler 方法。但是由于 TypeScript 目录结构的关系,所有的文件都在 src/dist 目录下,正好在根目录空缺出了这个文件,使得我们可以进行一些黑科技操作。
举个例子,针对阿里云 FC,我们可以做一些 callback 转 async 的包裹操作,使得用户端调用的代码格式保持统一,这部分代码目前还未开源,这部分方案我们希望尽快,比如在下半年能够提供给社区。通过这样的黑科技操作,我们能够在多个平台之间使得用户代码保持一致性。
当然 Midway-faas 我们还在演进中,除了保持小体积,基本完整的功能外也想提供更多的能力。我们通过不断的改进,从解决实际问题出发,和各个模块解耦,实现不同场景相同的代码编写方式,这些都是不断的思考,不断的沉淀,未来可能还会有很多挑战和变化,我们希望也能够一如既往的迭代下去。
▐ 总结
- 我们通过 IOC,解决了困扰我们多年的全栈开发问题。
- 我们通过装饰器,解决了和某个框架依赖过深的问题。
- 我们通过多场景,拓宽了 Node.js 的开发职能,也创造了前端的新场景。
本文是淘宝从 Midway5 到 Midway6 开发的实践积累,过程中的点点滴滴都在字里行间流出,不知道大家有没有Get到其中的每次变化的原因,从中能够理解为什么要做这些事情,做了之后能够带来什么影响,最后希望本文能够帮助各位思考和改进。有更多疑问,欢迎在文章留言区回复交流。