作者:会影
来源:Alibaba F2E公众号
近年来,阿里数据中台产品发展迅速。核心产品之 Quick BI 连续 2 年成为国内唯一入选 Gartner 魔力象限的国产 BI。Quick BI 单一代码仓库源码突破了 100万行。整个开发过程涉及到的人员和模块都很多,因为下面讲的一些原则,产品能一直保持在快速的开发状态。
先分享几个关键数据:
- 代码:TypeScript 82万行,样式 Sass+Less+CSS 18万行。(cloc 统计,去除自动生成代码)
- 协同:Code Review 12,111 次,Commit 53,026 次。
很多人会问,这么多代码,为什么不切分代码库?还不赶快引入微前端、Serverless 框架?你们就不担心无法维护,启动龟速吗?
实际情况是,从第一天开始,就预估到会有这么大的代码量。启动时间也从最初的几秒钟到后面越来越慢5~10分钟,再优化到近期的5秒钟。整个过程下来,团队更感受到 Monorepo(单一代码仓库)的优势。
这个实践想说明:
- 大的 Codebase 可能是好事情,大道至简。用极其“简单”的架构更容易支持复杂灵活的业务
- 要做到简单的架构,内部需要更明确的规范,更密切的协同,更高效的执行
- 能通过工程化解决的问题,就不要通过开发规范,能通过规范来解决的不要靠自由发挥
开工
2019年4月30号,晴朗的下午,刚好是喜迎五一的前一天,发挥集体智慧,投票选出满意的仓库名。同时借 Quick BI 和 FBI 底座融合的契机,项目开启。后来底座代码转正,把上层业务代码也吸纳进来。
commit 769bf68c1740631b39dca6931a19a5e1692be48d
Date: Tue Apr 30 17:48:52 2019 +0800
A New Era of BI Begins
Why Monorepo?
在开工之前,对单一仓库(Monorepo)和多仓库(Polyrepo)团队内做了很多的讨论。
曾经我也很喜欢 Polyrepo,为每个组件建立独立 repo 独立 npm,比如2019年前,单是表单类的编辑器组件就有 43 个:
本以为这样可以做到 完美的解耦、极致的复用??
但实际上:
- 每次 Babel、React 等依赖整体升级能让人脱层皮,所以自研了脚手架。造轮子都是被逼出来的,事情做了一点点,但写脚本能力直线上升
- 每次 调试组件,npm link 一下。后来组件跨级,可以做 3 层 npm link,使用过的都知道这是多么糟糕的体验
- 版本难对齐,每次主仓库发布前,组件间版本对齐更是考验眼力,稍有不慎触发线上故障
- 方便别人复用的优势呢?最终支持自己业务都捉襟见肘,哪还敢让别人复用
最终我们把所有这些组件都合并到一个仓库,其实像 Google/Facebook/Microsoft 这些公司内部都很推崇 Monorepo。
但我们不是原教旨主义的 Monorepo,没必要把不相关的产品代码硬放到一起。在实线团队内部,单个产品可以使用 Monorepo,会极大降低协同成本。但开始的时候,团队内还是有很多疑问。
关于 Monorepo 的几个核心疑问?
1.单一仓库,体积会很大吧?
100 万行
代码的体积有多大?
先来猜一下:1GB?10GB?还是更多?
首先,按照公式计算一下:
代码的体积 = 源码的体积 + .git 的体积 + 资源文件(音视频、图片、其他文件)
我们一起来算一下源码的体积:
一般建议每行小于 120 字符,我们取每行 100 个字符来算,100 万行就是:
100 * 1000,000 = 100,000,000 B
转换之后就是 100 MB!
那我们的仓库实际多大呢?
只有 85 MB!也就是平均每行 85 个字符。
2.再来算一下 .git
的体积:
.git
里记录了所有代码的提交历史、branch 和 tag 信息。会很大体积吧?
实际上 Git 底层做了很多的优化:1. 所有 branch 和 tag 都是引用;2. 对变更是增量存储;3. 变更对象存储的时候会使用 zlib 压缩。(对于重复出现的样板代码只会存储一次,对于规范化的代码压缩比例极高)。
按照我们的经验,.git
记录 10,000
次 commit 提交只需要额外的 1~3
个代码体积即可。
3.资源文件大小
Git 做了很多针对源码的优化,但视频和音频这类资源文件除外。我们最近使用 BFG 把另一个产品的仓库从 22GB 优化到 200MB,降低 99%!而且优化后代码的提交历史和分支都得到了保留(因为 BFG 会编辑 Git 提交记录,部分 commit id 会变化)。
以前 22 GB 是因为仓库里存放视频、发布的 build 文件和 sourcemap 文件,这些都不应该放到源码仓库。
小结一下,百万行代码体积一般在 200MB ~ 400MB 之间。那来估算1000
万行代码占用体积是多少?
乘以十也就是 2GB ~ 4GB
之间。这对比 node_modules
随随便便几个 G 来说,并不算什么,很容易管理。补充个案例,Linux 内核有 2800 万行,使用 Monorepo,数千人协同。据说当时 Linus 就是为了管理 Linux 的源码而开发出 Git。
2. 启动很慢吧?5分钟还是10分钟?
听到有些团队讲,代码十几万行,启动 10+分钟,典型的“巨石”项目,已经很难维护了。赶紧拆包、或者改微前端。可能团队才 3 个人却拆了 5 个项目,协同起来非常麻烦。
我们做法有3个:
- 按照页面来拆分多 Entry,每次只需启动一个 Entry
- 梳理子包间的依赖关系,追求极致的 Lazy loading,Tree-Shaking
- Webpack 切换到 Vite
尤其是 Webpack 切换到 Vite 以后,最终项目冷启动时间由 2-5分钟 优化到 5秒 内。热编译时间由原来 5秒 优化到 1秒 内,Apple M1 电脑基本都是 500ms 以内。
3. 代码复用怎么办?Monorepo 复用的时候是否要引入全部?
传统的软件工程思想追求 DRY,但并不是越 DRY 越好。
每写一行代码,都产生了相应代价:维护的成本。为了减少代码,我们有了可复用的模块。但是代码复用有一个问题:当你以后想要修改的时候它就会成为一个障碍。
对于像 Quick BI 这样长期迭代的产品,绝大部分需求都是对原有功能的扩展,所以写出易维护的代码最重要。因此,团队不鼓励使用 magic 的特技写法;不单纯追求代码复用率,而是追求更易于修改;鼓励在未来模块下线的时候易于删除的编码方式。
对于确实存在复用的场景,我们做了拆包。Monorepo 内部我们拆了多个 package(后面有截图),比如其他产品需要 BI 搭建,可以复用 @alife/bi-designer
,并借助于 Tree-Shaking 做到依赖引入的最小化。
目前的开发体验
1.冷启动 5秒,热编译 1秒内。以前是 5~10分钟。
- 改一行代码能解决的问题,真正改一行且发布一次。而不是改 10+ 个项目,按依赖发布 N 次。
- 新人 10分钟 搭建好环境,上手开发
a.相比于以前每个组件一个 Repo,包赋权都要搞很久
4.避免了版本不对齐的问题
a.对于 2C 产品,不需要多版本多主干分支,但多个 npm 依赖对齐版本也不容易
b.对于 2B 产品,由于多环境、多版本,会更加复杂,复杂度极高。Monorepo 通过分支来统一内部依赖的版本
5.工程化升级只需要一次。目前是基于 Lerna 开发的 Pri Monorepo 方案。
这样的体验要保持并不容易,开发中还有很多问题要解决。
真正需要解决的问题
并不是把代码放到一起就完了,背后复杂的问题是 协同、技术方案、稳定性(如何避免一个人提交代码导致整个产品崩溃?)
1. 包依赖管理
内部拆分多个子包,每个子包是子文件,可以单独发布 npm,见下图:
内部包管理的核心原则是:
- 从左向右单向依赖,只能右边引用左边。避免循环依赖
- 规范还不够,开发插件来自动检测,如果左边依赖右边直接报错
对于开源 npm 的引入,应该更慎重。大部分 npm 的维护时长不超过x年,即使像 Moment.js 这样曾经标配的工具库也会终止维护。可能有 20% 的 npm 是没人维护。但未来如果你的线上用户遇到问题,你就需要靠自己啃源码,陷入被动。所以我们的原则是,引入开源 npm 要三人线下评审通过才行。
2. Code Review 文化
互相 Code Review 能帮助新人快速成长,同时也是打造团队技术文化的方式。
过去几年一直在团队内推行 100% CR,但这还不够。机械的执行很容易把 CR 流于形式,还要分场景来做。
Monorepo 有个风险是一旦有问题就可能是整体的问题。
目前我们的 Code Review 主要分为3个场景:
- 线上 MR Code Review【1对1】
- 主题式 Code Review【3-5个人】
- 大版本发布前集体 Code Review【All】
12,111 次 Code Review 的经验很多,主要是:
及时 Review,鼓励小颗粒度的 MR,不必等整个功能开发完成
代码是写给人看的,鼓励白话文一样的代码,而不是文言文
建立最佳实践(目录树结构、命名规范、数据流规范)。开发一个功能可以有 10 种方法,但团队需要选 1 种并推广
不鼓励炫技,为了未来可维护性。能用简单技术实现,不要用“高深”冷门的技术
强调开发洁癖,追求优雅代码的文化。(命名是否易于理解、注释是否完整、是否有性能隐患等)
3. 工程化建设
这个过程首先要感谢淘系前端 DEF 工程化团队的支持,在这么多代码的情况下,不断挑战极限升级 DEF 支持我们。
除了制定文档的规范之外,能够自动化工具检查的规范才是好规范。
检查器:ESLint、TS 类型校验、Prettier
语法检查器是推动规范落地的重要方法,ESLint 可以做增量,优化后 git commit 的 pre-hooks 依旧很快。但 TS type check 因为不支持增量就比较慢了,需要搭配 CI/CD 来使用。
Webpack vs Vite
发布使用 Webpack,开发使用 Vite。
开发环境使用 Vite 快速调试,生产环境依旧使用 Webpack 打包。
风险是开发和生产编译产物不一致,这一块需要上线前回归测试避免。
4. 性能优化
对于数据产品而言,性能的挑战除了来自于 Monorepo 后资源包的变大,还有大数据量对渲染计算带来的挑战。
性能优化可以分为3个环节:
- 资源加载:精细化 Tree Shaking,难在精细。Webpack 本身的 Tree-Shaking 做的并不好,不支持 Class method 做 Tree Shaking,所以有时候需要修改代码。Lazy Loading 模块做到按需加载,尤其是图表、SQL 编辑器这类大组件。合理的接口预加载,不要让网络闲下来。
- 视图渲染:让组件渲染次数降到最低,表格类组件虚拟滚动优化,闲时预加载预渲染。
- 取数请求:资源本地化缓冲方案,移动端使用 PWA 将 JS 等资源文件和数据缓存到本地。
另外还有性能检测工具,定位性能卡点。计划做代码性能门闩,代码提交前如果发现包体积增大发出提醒。
5. 数据化驱动架构优化
身在数据中台,我对数据的业务价值深信不疑。但对于开发本身而言,很少深度使用过数据。
所以 S1 重点探索了开发体验的数字化。通过采集大家的开发环境和启动耗时数据来做分析【不统计其他数据避免内卷】。发现很多有意思的事情,比如有个同学热编译 3~5 分钟,他以为别人也是这样慢,严重影响了开发效率,当从报表发现数据异常后十分钟帮他解决。
另外一个例子,为了保持线上打包产物的一致性,推动团队做 Node.js 版本统一,以前都是靠钉,钉多少次都无法知道效果如何。有了报表以后就一目了然。
更深层的经验
效率最高的方式就是一次最好
每行代码都会留下成本。长远考虑,效率最高的方法就是一次做好。
苏世民说“做大事和做小事的难度是一样的。两者都会消耗你的时间和精力”。既然如此,不妨把代码一次写好。代码中如果遗留 “TODO” 可能就永远 TO DO。客观来讲,一次做好比较难,首先是每个人认为的“好”标准不同,背后是个人的技术能力、体验的追求、业务的理解。
组织文化技术 相辅相成
技术架构和组织结构有很大关系,选择适合组织的技术架构更重要。
如果一个组织是分散的,使用 Monorepo 会有很大的协同成本。但组织如果是内聚的,Monorepo 能极大提效。
工程化和架构底座是团队的事情,靠个人很难去推动。
短期可以靠战役靠照搬,长期要形成文化才能持续迭代。
组织沟通成本高应该通过组织来解,通过技术来解的力量是渺小的。技术可以做的是充分发挥工具的优势,让变化快速发生。
简单不先于复杂,而是在复杂之后
对于一个简单的架构,总有人会想办法把它做复杂。踩了坑,下决心重构,成功则回归简单,失败就会被新的简单模式颠覆。踩坑本身也是有价值的,不然新人总是按捺不住还会再踩一次。做复杂很容易,但保持简单需要远见和克制。没有经历过过程的磨练,别人的解药对你可能是毒药。
架构不可能一成不变的,我们的图表最开始直接使用 D3、ECharts 很简单,后来定制很多逐渐复杂到难以维护,于是基于 G2 自研 bi-charts 后架构又一次变简单,前后的开发体验可能是差不多的,但背后的技术完全变了。
总结与展望
百万行代码没什么可怕,是一个正常的节点,仍然可以像几万行代码那样敏捷。
现在 Quick BI 已经向千万行迈进,向世界一流 BI 的目标迈进。以上内容更多是工程化相关,把工程化做好目的是想让开发者更专注于业务,没讲的业务挑战其实更多,因为数据分析天生就要与海量数据打交道,性能优化有长期的实践;洞察丰富异样的数据,有很多可视化及复杂表格方面的沉淀,可视化不仅是技术,也是业务本身;手机平板电视等多端展示,跨端适配的挑战。未来还希望能够把数据分析打造成一个引擎,能够快速集成到办公和商业流程中。
目前的开发模式并不完美,在迭代的过程中,不可避免会产生技术债,架构的优化本质就是在保持可维护性和减少技术债。最近团队在酝酿一次 Redux-Toolkit 的引入,会对取数和数据流有大的升级,有进展再分享。