模块系统设计

模块系统及配套包管理工具影响了语言的使用体验,是语言生态的基础设施之一,需要精心设计。

调研

在设计之前,先调研下看其他语言是怎么设计的。

语言 官方 / 主流包管理 官方仓库
Python pip PyPI
JavaScript / TypeScript npm、pnpm、Yarn npm Registry
Java Maven、Gradle Maven Central
C# NuGet NuGet Gallery
Go go modules proxy.golang.org
Rust Cargo crates.io
Swift Swift Package Manager Swift Package Index / Git
Kotlin Gradle / Maven Maven Central
PHP Composer Packagist
Dart pub pub.dev

包配置文件

每个语言都有一个包配置文件,配置文件里面一般都有:

  1. name
  2. version
  3. dependencies

配置文件的文件名和格式各不相同:

语言 配置文件 格式
npm package.json JSON
Cargo Cargo.toml TOML
C# csproj XML
Python pyproject.toml / requirements.txt TOML/自定义
Go go.mod 自定义
Java pom.xml / build.gradle XML / DSL
Swift Package.swift Swift 代码
Dart pubspec.yaml YAML

版本合并

每个语言都支持语义版本(SemVer),同个包的不同版本会被合并,依赖可以指定版本规则:

  1. >=1.2
  2. ^1.2
  3. ~1.2

只是符号略有区别。

版本锁

大多有锁文件,以避免版本被自动更新:

  1. package-lock.json
  2. Cargo.lock
  3. poetry.lock
  4. pnpm-lock.yaml
  5. composer.lock

只有 Go 有区别,因为它不支持自动更新,相当于默认就是带锁的效果。

工作空间

工作空间可以将多个包放在一个仓库里统一管理。

很多语言开始都不支持工作空间,但后续新版本几乎都已支持。

语言 / 工具 工作空间配置方式 配置示例
Rust Cargo.toml 中的 workspace.members Cargo.toml
members = ["a", "b", "c"]
Go go.work pkg 1.0
npm package.json 中的 workspaces workspaces: ["./a/package.json"]
pnpm pnpm-workspace.yaml packages: ["./a/package.json"]
Gradle settings.gradle(.kts) 中使用 include(...) include("a", "b", "c")

几乎每个语言都设计为:当有工作空间时,包的配置依然独立,不会自动从工作空间继承。

调研总结

经过对多种编程语言模块系统的调研,我发现它们在核心设计思想上并没有本质区别,差异主要体现在命名风格和配置方式上。

因此,只需要选择一种成熟的方案作为基础,再根据自身需求进行适当调整,就能够满足绝大多数场景。

开始设计

综合对比后,我最终选择以 Go 的模块系统作为设计基础。

原因在于,Go 的模块系统拥有较少的配置项和较低的理解成本,整体设计简单、清晰,并且已经在大量实际项目中得到了充分验证。

当然,我并不会完全照搬 Go 的设计,而是在保留其核心思想的基础上,结合本语言的设计目标和 AI 时代的开发需求,对部分细节进行调整和优化。

包文件

几乎所有编程语言都定义一个配置文件,用于描述项目的依赖关系和构建信息。我将这个配置文件统一称为“包文件(Package File)”

包文件的语法格式

在历史演进中,不同语言对包文件格式的选择各不相同:

  • 早期许多语言采用 XML 作为配置格式;
  • 随后逐渐转向更简洁的 JSON;
  • 近年来,一些新语言倾向于使用 TOML 等更具可读性的配置格式;
  • 也有部分语言选择直接使用代码作为配置方式。

首先,包文件不应该是可执行的代码

虽然使用代码进行配置非常灵活,但:

  1. 灵活性并非核心需求。事实上,许多项目更倾向于“零配置”或统一模板化配置,以降低认知负担。
  2. 行为不确定。一旦包文件是可执行代码,就可能引入运行时行为差异,导致相同的项目在不同环境或不同时间出现不同的解析结果。这会显著增加调试与维护成本。
  3. 不安全。如果包文件中包含恶意代码,一旦被 IDE 或构建工具在解析阶段执行,就可能在用户无感知的情况下触发不安全行为。

因此,包文件更适合采用声明式、非执行型的配置格式,以保证其可解析性、安全性和工具链的一致性。

其次,不需要为包文件发明一种新语法来简化编写

包文件本质上是结构稳定的纯配置数据,完全可以通过专门的工具(例如图形化界面或可视化编辑器)进行维护,而不必依赖手写文本的方式。

比如 Visual Studio 虽然底层依赖 XML 作为配置格式,但开发者在大多数情况下并不会直接编辑这些 XML 文件,而是通过 IDE 提供的可视化界面完成配置操作。

这说明,“编辑便利性”并不是包文件的核心需求。相比之下,更重要的是可解析性、可一致性以及工具支持能力。

因此,没有必要为包文件专门设计一套新的语法或同时支持多套语法来优化书写体验。

结论:包文件应该是一种通用的数据存储格式

数据存储是一种非常普遍的需求。除了包文件之外,项目中还会涉及各种配置文件、元数据文件等。如果每种用途都采用不同的格式,不仅会增加学习成本,也会增加解析器和工具链的维护成本。因此,所有数据应尽可能采用同一个数据存储格式。

目前,能够承担这一角色的主要有 XML、JSON 及其各种衍生格式。

最终,我选择 JSON,主要基于以下几点考虑:

  1. 结构与代码更接近:JSON 天然对应对象、数组等数据结构,与语言中的类能够直接映射,理解和处理都更加自然。
  2. 解析高效:解析 JSON 比其他格式快。
  3. 像 YAML、TOML 等格式,本质上都是为了提升 JSON 的编写体验而设计的。但手写数据文件不是主流需求,因此没有必要引入新的数据格式。

不过,为了兼顾配置文件的可维护性,可以在 JSON 的基础上适当放宽语法限制,例如允许注释、尾随逗号等。

包文件名

包文件必须使用固定文件名,以确保包管理工具与 IDE 能够以统一方式识别与解析项目结构。

包文件和源文件不能使用相同扩展名

如果包文件和源文件扩展名相同,那用户命名文件时就会存在一个心智负担:不能和包文件同名。这种设计不仅增加认知复杂度,也容易在实际开发中引发误用或命名冲突。

不应使用已有的通用包文件命名

如果采用诸如 package.json 这样的通用、中立文件名,容易与其他语言生态或既有项目习惯产生冲突。

包文件名应该和语言关联

在一些语言中,包管理工具是一个独立工具,拥有自己的名称,因此其配置文件通常直接以工具名命名,例如 Grade、Cargo。

但对于一门新语言而言,包管理本身就是语言的一部分,没有必要再为其创造一个独立的工具名称,而应该直接使用语言名来标识包文件,就像 go.mod 一样。

按照这个思路,最直接的方案是将语言名作为文件名,例如 teal.json

然而,这种设计存在两个问题:

  1. 扩展性不足。如果用户希望针对不同环境维护多份配置,那只能用类似 teal-prod.json 命名,但这种命名语义感不强。
  2. 区分度不够。使用 .json 作为扩展名时,文件容易被理解为普通 JSON 数据,而忽略其在语言生态中的特殊语义角色。

因此,将语言名放在文件名不如放在扩展名中,这样可以规避以上两个问题。

不过不能直接使用语言名作为扩展名,因为会和源码冲突。

包文件名应该具有独特的语言相关的扩展名

使用 tpkg(Teal Package)作为扩展名,可以明确表示该文件属于 Teal 语言的包配置体系,同时避免了冲突。

文件名使用 main,其设计灵感来源于 C 语言以 main 作为程序入口的传统约定。这一命名隐含的语义是:在理解一个项目时,开发者应优先查看 main.tpkg,以快速把握项目的整体结构与依赖关系。

如果需要,开发者可以创建 prod.tpkgtest.tpkg 来配置不同环境。

最终决策

使用 main.tpkg 文件定义包,该文件是一个带注释的 JSON 文件。

工作空间

一开始,我认为工作空间的概念过于复杂,不打算支持。

如果没有工作空间,一个项目中的所有源码就必须属于同一个包。

然而,在实际工程中,一个仓库往往同时包含前端和后端,而它们的依赖、编译目标和编译配置通常并不相同。为了在同一个包实现这个效果,就必须引入类似“子包”的机制,使不同目录能够拥有各自的依赖和编译配置。但这样一来,实际上只是换了一个概念来实现多包管理,那还不如直接将前端和后端划为两个独立的包更容易理解。

不过,完全独立的包又会削弱它们之间的关联性。例如,当后端重命名了一个导出的符号时,前端无法通过工程级工具自动完成同步重命名,因为它们已经被视为两个互不相关的包。

因此,相比于设计一套“主包—子包”的层级结构,引入“工作空间—包”的模型更加简单。包负责依赖和编译配置,工作空间负责组织多个相关的包,使它们既保持独立,又能作为一个整体进行管理和开发。这种职责划分更加清晰,也更符合大型项目的组织方式。

工作空间文件

类似包文件,工作空间文件规定为 main.twork,和 main.tpkg 保持相同的数据格式。

在工作空间指定包

最理想的方案是:工作空间无需配置包含哪些包,只要位于工作空间目录下的包,都自动归属于该工作空间。

不过,这种方案存在两个问题:

  1. 加载效率较低。为了找出所有包,工具必须遍历整个工作空间目录。
  2. 容易误包含。一些本应独立存在的包,或下载到工作区内的第三方依赖,也可能被错误地识别为工作空间的一部分。

因此,由用户显式指定工作空间包含哪些包,是更加稳妥的设计。

工作空间通过 packages 字段声明包含的包列表。为了减少配置负担,该字段支持通配符,并默认取值为:

["./*", "./src/*", "./packages/*"]

因此,遵循常见目录结构的项目无需额外配置,即可自动发现所有包。

包文件继承配置

在多包项目中,共享编译配置是一项非常常见的需求,因此将公共配置放在工作空间中是一个合理的设计。

不过,如果包默认继承工作空间配置,就会破坏包的独立性和可移植性。也就是说,同一个包在不同工作空间下,可能得到不同的编译结果。

因此,最终采用显式继承的方案:包默认不会继承工作空间配置,只有在包文件中明确指定了 "extends": "workspace" 时,才会继承工作空间中的共享配置。

版本合并

模块系统最重要的职责之一,就是在依赖树中将同一个依赖的多个版本合并为一个最终版本。

例如,依赖树中同时存在 zip@1.0zip@1.1,而软件仓库中的最新版本已经发布到了 zip@1.2。此时,最终应该选择哪个版本?

很多包作者倾向于默认选择兼容的最新版,即 zip@1.2,这样做的优点是依赖能够自动升级,开发者无需手动同步版本。

然而,这种设计也带来了一个严重的问题:同一份源码,在不同时间可能得到不同的依赖结果。
例如,项目第一次发布时解析得到 zip@1.2。几个月后,zip@1.3 发布,而该版本恰好存在严重缺陷。开发者只是重新发布了自己的项目,并没有修改任何依赖配置,却因为重新解析依赖而自动升级到了 zip@1.3,最终导致生产环境出现问题。

为了保证构建结果可复现,大多数包管理工具又不得不引入版本锁(Lock File),将实际解析出的版本记录下来,避免后续构建发生变化。

既然如此,为什么不一开始就设计成不允许自动更新呢?没错,Go 设计者也是这样想的。

因此,我采用了 Go 的版本合并策略:只在依赖树中已经声明的版本之间进行选择,而不关心仓库中是否存在更新的版本。

如果开发者希望升级依赖,则必须主动修改依赖版本(这一过程可以由工具自动完成)。这样,每一次版本升级都是显式且可审查的,构建结果也始终保持可预测和可复现。

最终的版本合并策略非常简单,只需两步:

  1. 从入口包开始,递归遍历整个依赖树,收集每个依赖库所引用的版本。
  2. 对于同一个依赖库的多个版本,按照版本兼容规则进行合并:如果高版本兼容低版本,则只保留最高版本;否则,同时保留两个版本。

包是编译、发布的最小单位。

包的组成

一个包由一个包文件及若干个源文件组成。
当一个文件夹下存在 main.tpkg 时,这个文件夹里所有的源文件都自动归属这个包。

那是否应该允许排除某些文件呢?这个功能有两个使用场景:

  1. 单元测试文件可能和源码放在一起,但它们应该被排除在外。
  2. 在开发时,可能需要临时排除某些文件。

因此,支持排除文件是有意义的。
有些项目的习惯是将包文件放在根目录,然后将源码放在 src 目录,因此最好也应该支持通过指定根目录的方式来排除其他文件。

根据以上结论,包文件可以设置 rootDirincludeexclude 三个配置,用于确定这个包由哪些文件组成。

如果包中内嵌另一个包,为了简单起见,文件只属于最近的包。

对于任何一个文件来说,它可能有三种情况:

  1. 上层文件夹存在包文件,且包文件包含了它,称为包内文件。
  2. 上层文件夹存在包文件,但包文件排除了它,称为包外文件。
  3. 上层文件夹不存在包文件,称为独立文件。

无论是包内文件还是包外文件,它们都属于包,是非独立文件。

包的功能

包内文件可以直接互相引用导出,而不需要导入。

【未完待续】