咕咕,我来了,带着写了一年的 typescript,不留一个 enum

库的依赖管理

库的依赖管理问题是我开始写非业务代码后遇到的第一个工程问题,它的复杂性在于保证依赖解析正确的前提下,怎么减少共用包的资源占用(存储、解析)。

python / js 在引包的时候不会带上版本,只用包名称来 resolve,版本控制放在项目中,让项目环境来保证版本正确性。

每种语言都需要一个合适的包管理器。

好处包括

  1. 升级依赖时,如果版本是兼容的,那么代码不需要改,对于项目管理来讲是很简便的
  2. 节省存储、运行时资源,同一个库可以被多个使用者共享(动态链接的方式

坏处有

  1. 环境管理的复杂性带到开发时、发布时、运行时。首先开发者需要工具来管理开发时依赖、固定依赖库的版本、方便地定制库的 resolve 方式(比如 resolve 到一个本地路径,一个网络地址等等);发布的时候需要维护发行版本和底层依赖的版本的对应关系、兼容关系、需要保证 deterministic build;运行时用户需要确保本机环境已经具备运行的依赖条件,引申出去的需求有环境隔离、同一个库的多个版本怎么 resolve 等等。
  2. 依赖约定规则来定义兼容性,比如语义版本 semver

python 社区一般会为每个项目开一个独立的虚拟环境,通过精简依赖,隔离环境来减少环境依赖、隔离影响,但没有解决同一个库的多个版本问题(用的最新版本策略,可能 break 依赖老版本的接口),老的版本管理可能是用 requirements.txt 来做,只记录依赖的版本,比较现代一点的 pipenv 会加上源、版本、hash、多环境的区分,环境隔离的好的话,分发的时候基本就是一个静态链接了。

node 没有做到环境隔离(都存在 node_modules,一层层往上找,直到根目录);可以保证每个库有自己的 node_modules 来确保依赖版本正确,依赖管理很灵活,但也带来了碎片文件、占用大量存储空间、误配置时查找问题比较麻烦;支持可以用 peerDependencies 来依赖环境提供,deterministic 则是用 hash + 版本号来保证,yarn 中另加入了源的 check、monorepo 中共用库提升等等优化;支持 link 来开发时修改库的 resolve 方式。

deno / go 这种设计为网络领域的语言,直接用 url 来去中心化管理依赖,要达成 deterministic build 会有天生的困难,只能约定好所有发版都用新的 url 并且能提供下载,对传输层有特殊的安全要求,版本管理也会有麻烦(url 是否需要带上版本信息?)。

rust 有着比较现代的工具链 cargo。支持环境隔离;多环境;deterministic build;可以保留同一个库的多个版本,编译时会把他们重命名成不同的库,不会有冲突;方便地指定 resolve 方式(local path 或者 url);灵活的打包、分发方式(静态链接 / 动态链接,llvm)。很好地解决了上一章节中的开发时、发布时、运行时问题。

用库还是用服务

简单对比一下两者

  • 库是内部依赖,服务可内可外(依赖网络)
  • 库可同构,也可以带上 binding 做异构、服务用中立的 IDL 来解耦
    • 异构的部署难度高,依赖基础设施(网络情况、服务发现等等)
    • 同构的好处是可以做 lib 或者很方便地做 rpc,无需区分语言特性;坏处是无法把语言解耦,技术栈相对闭塞
    • 异构的好处是可以在合适的场景中发挥每个语言的长处,比如用 C++ 做运算,用 js 做页面,用 py/C++ 做数据分析,用 JAVA/goAPI/ 大数据 等等;坏处是对团队的技术能力门槛有要求,一个体现在部署复杂性提升(需要准备多套环境),另外一个体现在对不同语言的理解和使用上有要求,对小团队来讲影响比较大
  • 多端支持(比如 js 的浏览器和 node
  • 服务引入更多环节、有网络开销、效率待评估
  • 库的效率高、故障处理简单

用库还是用服务还需要看是否对运行环境有要求,拿 js 来讲,node 跑在后端,浏览器跑在前端,虽然是同一个语言,但运行环境差别很大(还有 es version 的差别、各个浏览器对 es 支持度的差别, 代码加载方式的差别等等…),写代码时就需要注意这些分支判断。像 rust 就有 conditional compilation 的概念,支持用 cfg 注解来区分 build target, os, platform, big/litter endian 等等。

参数管理

可以是 lib 的参数,builder 参数,API 的参数,实例的参数等等

  • function 的哪些参数允许函数内部修改?传进来的是和 caller 共享的内存吗?改了会对 caller 有影响吗?
    • 这点经常会引发一些隐藏的共享变量的 mutability 问题(隐式传),在 rust 中看就很明白,所有权和 mutability 都是一眼看明白的(显式传,在签名中声明)
  • function 的参数使用 struct 还是 ...args
    • struct 的好处是可以方便的 override 和扩展,但不适合做函数式编程的 curry
    • ...args 的话虽然一些 optional 的参数可以不传,但调用的话必须按顺序传,不能跳过;反过来讲它的好处就是可以用来做 curry
  • 哪些参数是全局的 / 库级别的?哪些参数是实例级别的 context,是否允许外部传入?
    • 比如 logger 一般在生命周期内不会修改,常常使用库级别的
    • 一些阈值视情况使用库级别 / 实例级别,看是否有隔离的需求,如果需要隔离的话,这些参数到哪都得带着走,往往作为一整个 context 来传

版本号与向后兼容

前面讲的库的依赖管理、服务、参数管理都会涉及到版本号与向后兼容性,目前业界用的比较多的是 semver,约定了大版本号的更迭会出现部分 break(指去除或者修改了 API),大版本号小于 1 表示尚不稳定。一般稳定后发新功能尽量避免 break 已有的 API,如果必须 break 的话,要给到对接方一个显眼的迁移方式,一般放在 release note 或者代码注释以及迁移文档中。

PS
typescript 的 Structural type system 对 enum 真的太不友好了..