模块系统设计
模块系统及配套包管理工具影响了语言的使用体验,是语言生态的基础设施之一,需要精心设计。
调研
在设计之前,先调研下看其他语言是怎么设计的。
| 语言 | 官方 / 主流包管理 | 官方仓库 |
|---|---|---|
| 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 |
包配置文件
每个语言都有一个包配置文件,配置文件里面一般都有:
nameversiondependencies
配置文件的文件名和格式各不相同:
| 语言 | 配置文件 | 格式 |
|---|---|---|
| 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.2^1.2~1.2
只是符号略有区别。
版本锁
大多有锁文件,以避免版本被自动更新:
package-lock.jsonCargo.lockpoetry.lockpnpm-lock.yamlcomposer.lock
只有 Go 有区别,因为它不支持自动更新,相当于默认就是带锁的效果。
工作空间
工作空间可以将多个包放在一个仓库里统一管理。
很多语言开始都不支持工作空间,但后续新版本几乎都已支持。
| 语言 / 工具 | 工作空间配置方式 | 配置示例 |
|---|---|---|
| Rust | Cargo.toml 中的 workspace.members |
Cargo.tomlmembers = ["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 等更具可读性的配置格式;
- 也有部分语言选择直接使用代码作为配置方式。
首先,包文件不应该是可执行的代码
虽然使用代码进行配置非常灵活,但:
- 灵活性并非核心需求。事实上,许多项目更倾向于“零配置”或统一模板化配置,以降低认知负担。
- 行为不确定。一旦包文件是可执行代码,就可能引入运行时行为差异,导致相同的项目在不同环境或不同时间出现不同的解析结果。这会显著增加调试与维护成本。
- 不安全。如果包文件中包含恶意代码,一旦被 IDE 或构建工具在解析阶段执行,就可能在用户无感知的情况下触发不安全行为。
因此,包文件更适合采用声明式、非执行型的配置格式,以保证其可解析性、安全性和工具链的一致性。
其次,包文件不需要过度强调“编辑便利性”
包文件本质上是结构稳定的纯配置数据,完全可以通过专门的工具(例如图形化界面或可视化编辑器)进行维护,而不必依赖手写文本的方式。
比如 Visual Studio 虽然底层依赖 XML 作为配置格式,但开发者在大多数情况下并不会直接编辑这些 XML 文件,而是通过 IDE 提供的可视化界面完成配置操作。
这说明,“编辑便利性”并不是包文件的核心需求。相比之下,更重要的是可解析性、可一致性以及工具支持能力。
因此,没有必要为包文件专门设计一套新的语法或同时支持多套语法来优化书写体验。
最终决策:JSON
在现有的数据交换格式中,JSON 已经成为事实标准之一,被绝大多数开发者熟悉和使用。即使开发者没有系统学习过 JSON,也通常能够在短时间内理解其基本结构。
因此 JSON 是包文件格式的首选。
同时为了方便用户临时切换配置,JSON 内可以额外支持注释语法。
包文件名
包文件必须使用固定文件名,以确保包管理工具与 IDE 能够以统一方式识别与解析项目结构。
包文件和源文件不能使用相同扩展名
如果包文件和源文件扩展名相同,那用户命名文件时就会存在一个心智负担:不能和包文件同名。这种设计不仅增加认知复杂度,也容易在实际开发中引发误用或命名冲突。
不应使用已有的通用包文件命名
如果采用诸如 package.json 这样的通用、中立文件名,容易与其他语言生态或既有项目习惯产生冲突,从而降低可识别性,也增加工具链解析的歧义空间。
文件名应该和语言关联
在一些语言中,包管理工具是一个独立工具,具有单独的名称,其配置文件通常采用与工具同名的方式,以形成清晰的生态标识。
但我希望将包管理系统将作为语言本身的一部分统一设计,没有必要为其引入单独的名称。相应地,包文件的命名应直接体现语言身份,使其能够被直观识别为“该语言的配置文件”,从而减少认知负担并强化生态一致性。
一种方案是将语言标识直接作为文件名的一部分,例如 teal.json。
但这种设计存在两个问题:
- 扩展性不足。比如用户可能想要定义针对不同环境的配置:
teal-prod.json。 - 区分度不够。使用
.json作为扩展名时,文件容易被自然理解为普通数据结构,而忽略其在语言生态中的特殊语义角色。
因此,语言标识应该作为扩展名的一部分,但又不能和源码使用相同扩展名。
最终决策:main.tpkg
使用 tpkg(Teal Package)作为扩展名,明确表示该文件属于 Teal 语言的包配置体系,从而避免与其他语言或通用配置文件发生命名冲突。
文件名使用 main,其设计灵感来源于 C 语言以 main 作为程序入口的传统约定。这一命名隐含的语义是:在理解一个项目时,开发者应优先查看 main.tpkg,以快速把握项目的整体结构与依赖关系。
如果需要,开发者可以定义 prod.tpkg、test.tpkg 等,表示针对不同环境的打包配置。
结论
使用 main.tpkg 文件定义包,该文件是一个带注释的 JSON 文件。
工作空间
在实际工程中,许多项目同时包含前端与后端,并希望在同一个代码仓库中进行统一管理与协同开发。
从模块化设计的角度来看,前端与后端通常具有不同的依赖体系与构建配置,因此在理想情况下,它们应当被划分为两个独立的包。
然而,如果完全将前端与后端拆分为独立包,又会削弱它们在同一项目中的关联性,例如统一版本管理、共享代码以及整体构建协调等能力。
因此,这类“多包协同”的工程场景,需要引入一个更高层级的抽象来进行组织,这就是工作空间(Workspace)。
工作空间配置
工作空间需要配置包含哪些包。防止隐式依赖污染。
【未完待续】