当程序规模较大时,就需要将代码分解成一个个功能相对独立的模块,并通过模块接口表示该模块所提供和所要求的元素(变量、常量、函数、方法、类型、模块等),这就是所谓的模块化编程。模块是组织代码的一种方式,它将程序或系统分解为更小的部分,各模块相对独立,且可通过模块接口进行交互。模块化编程通过功能分解增强代码的可维护性;通过提供独立的命名空间避免命名冲突;通过模块间的相互引用而增强代码的可复用性。模块化编程通常和结构化编程以及面向对象编程的思想密切相关。
Go 和 Rust 都支持模块化编程,以及与模块化编程密切相关的依赖管理功能,但两者实现模块化编程的途径差异巨大,他们使用的术语体系也不一致,要讲清楚两者的异同点并不那么容易,所以本文准备采用一些稍微不同于已有文献的讲述方法,主要是由小到大逐渐剖析。
模块化编程是个大命题,两种语言都用了大量篇幅来讲述,所以本文的篇幅肯定很大。但我尽量只讲重点,其他的内容需要读者自己探索。
将代码分解为模块
要将代码分解为模块,无非通过两种方式:
- 将源文件或目录隐式地转换为模块,如一个文件对应一个模块,或一个目录对应一个模块;
- 显式地声明模块名称,并标记模块包含的代码范围。
Go 所实施的路径基本上是第一种,而 Rust 基本上属于第二种。
Go 的包
在 Go 中,常规意义的模块实际上对应着 Go 中的包(package),同一个目录下(不包括子目录)的所有 .go
源文件属于同一个包。之所以最初将这种分割代码的单元称为包而不是模块,恐怕就是因为这种与目录结构对应的打包方式。随便看看任何 Go 代码仓库的源代码,如标准库,会发现后缀为 .go
的代码文件被放置在不同的目录中,且同一目录中的每个文件(除了 *_test.go
类型的测试文件)都以 package <packagename>
开头,其中 <packagename>
对应具体的包名称,且该名称通常与目录名相同(也可以不相同)。如标准库 net
包位于 src/net
目录中,该目录内每个非测试文件第一行非注释代码均为:
package net
虽然需要在每个 .go
开头显式声明包的名称,但编译器强制要求同一目录的所有非测试文件的包名称必须相同,从而保证这些文件同属一个包,即保证一个目录对应一个包。在编译时,编译器会自动把包目录下(不包括子目录)的所有 .go
文件当作包的源文件,你不需要手动导入某个文件。请注意到 src/net
目录下还有其他目录,如 src/net/http
、src/net/url/
等,他们分别对应各自的名称为 http
和 url
的包,net
包和这两个包在物理位置上具有树状结构,但并不具有包含关系,他们相互之间也只能单向导入。
Go 中的包是可以分别编译的。若包的名称为 main
,且有一个 main
入口函数,该包就会被编译为一个可执行程序,这种包可称为 main
包、程序包或命令包,这种包不能被其他代码导入;其他形式的包可称为非 main
包、库包,这种包可以被其他代码导入。库包的名称最好要和目录的名称是一致的。
包中的元素数、文件数可多可少,只要保证其包含相对独立的、完整的、便于复用的功能即可。对于非 main
包,其中通常有一个和包名称相同的源文件,包的核心功能从此包定义。
我们已经看过很多 Go 的 main
包的示例了,更复杂的涉及到包相互导入和调用的例子,我们以后再列举。
Rust 的模块
在 Rust 中,常规意义的模块就对应着其中的模块,用 mod
关键字声明。稍微准确地说,模块是各种代码组件——项——的容器。Rust 不像 Go 那样总是一个目录对应一个模块(Go 中的包),而是需要显式地构建模块树。也不像 Go 那样各个包的代码不具备包含关系,Rust 的子模块代码经常是被包含在父模块代码中,和父模块中的结构体、枚举、trait 等类型的定义呈并列关系,并且与这些类型共用命名空间,即模块名称不能和同级类型名称相同。
具体来说,Rust 一般通过三种方式声明模块:
(1)在一个文件中嵌套声明模块:显式使用带大括号的 mod {...}
声明模块,模块代码放置于大括号内,被声明的模块属于当前模块的子模块。
// main.rs 文件
fn main() {
crate::dog::sleep();
crate::dog::husky::bark();
crate::dog::teddy::bark();
}
mod dog {
pub fn sleep() {
println!("呼...呼...");
}
pub mod husky {
pub fn bark() {
println!("嗷...呜...");
}
}
pub mod teddy {
pub fn bark() {
println!("汪!汪!");
}
}
}
以上代码显式声明了三个模块,加上一个隐式存在的根模块 crate
,各模块等层级关系如下:
crate
└── dog
├── husky
└── teddy
代码中的 pub
表示该项对模块外部的代码是可见的,我们会在下一小节讲述。在 main
函数中,我们通过路径确定了项的位置,这也将在稍后讲述。
(2)把模块内容放入单个 .rs
源文件中:当模块较大时,可将模块内代码放在一个单独的 .rs
文件中,然后在父模块中通过加入如下一行代码让 Rust 前往与其同名的 .rs
文件中加载子模块内容:
mod module_name;
注意以上模块声明语句的最后是分号而不再是代码块。
针对前面的 main.rs
文件,我们将其分割成两个文件:
src
├── dog.rs
└── main.rs
两个文件的内容分别是:
// main.rs 文件
mod dog; // 将会从 dog.rs 加载模块内容
fn main() {
crate::dog::sleep();
crate::dog::husky::bark();
crate::dog::teddy::bark();
}
// dog.rs 文件
pub fn sleep() {
println!("呼...呼...");
}
pub mod husky {
pub fn bark() {
println!("嗷...呜...");
}
}
pub mod teddy {
pub fn bark() {
println!("汪!汪!");
}
}
(3)把所有子模块放入与模块同名的目录中:当模块规模进一步加大时,仍使用与以上文件模块相同的方法定义此模块,但可将该模块的所有子模块内容都移到与模块同名的目录内,并在该模块对应的同名 .rs
文件中加入如下代码让 Rust 在同名的子目录中寻找子模块:
pub mod module_name_1;
pub mod module_name_2;
这种方式适用于一个模块包含多个子模块,放入单个源文件内容过多,不容易阅读和区分的情况。这时将每个子模块放入单独的源文件中,从而使代码更加整洁和易管理。如下所示将前面的 dog
模块的两个子模块分别放入 dog/husky.rs
和 dog/teddy.rs
文件中,形成如下目录结构:
src
├── dog
│ ├── husky.rs
│ └── teddy.rs
├── dog.rs
└── main.rs
每个源文件的内容分别如下:
// main.rs 文件
mod dog;
fn main() {
crate::dog::sleep();
crate::dog::husky::bark();
crate::dog::teddy::bark();
}
// dog.rs 文件
pub mod teddy;
pub mod husky;
pub fn sleep() {
println!("呼...呼...");
}
// husky.rs 文件
pub fn bark() {
println!("嗷...呜...");
}
// teddy.rs 文件
pub fn bark() {
println!("汪!汪!");
}
对于以上代码,我们不能把 husky.rs
和 teddy.rs
文件直接放入 src
目录而不改变其他代码,因为这两个文件对应的子模块属于 dog
模块,必须将他们放入 dog
目录中。
肯定有人要问,可不可以像 Go 那样将一个模块(包)的内容分散地写入多个文件中?——无法做到的。Rust 的每个源文件都至少对应一个模块,但不是每个模块都对应一个文件。或者说,一个 .rs
源文件属于文件名自身的模块(除了 main.rs
、lib.rs
和 mod.rs
)。不过,可以使用 pub use
从其他模块中重导出名称,其效果相当于将多个模块合并到当前模块中。我们稍后再讲解 pub use
。
对于实际的程序,为了逻辑结构的清晰,一般不会使用以上第一种模块声明方式,而是使用第二、三种方式。
模块内容的可见性
可以对模块内声明的变量(仅对 Go)、常量、函数、各种自定义类型、类型别名、接口或 trait、模块(仅对 Rust)等标识符(项)标记对外部程序/模块的可见性。也可以对所有自定义类型的方法标记可见性,对结构体的字段标记可见性。不过枚举和联合体的变体默认是可见的,即当枚举类型或联合体类型可见时,他们的变体也将可见。
Go 和 Rust 都是只支持两种可见性:可见的和不可见的。
在 Go 中,包内成员对使用该包的程序的可见性是通过成员名称首字母的大小写来控制的:当成员标识符以小写字母开头时,该成员对外是不可导出的(即为私有的),外部程序将无法访问该成员;当成员以大写字母(Unicode “Lu” 分类)开头时,该成员对外是可导出的(即为公有的),外部程序可以访问该成员。如下所示:
package visibility
import "time"
const (
Pi = 3.14159 // 可导出
e = 2.71828 // 不可导出
)
type Person struct { // 可导出
Name string // 可导出
Birthday time.Time // 可导出
age int // 不可导出
}
在 Rust 中,模块中的所有项默认对外是不可见的(私有的),要使其可见,需要在前方添加 pub
使该项变为公开的。
并不能对 Go 的包声明可见性,但可以对 Rust 的模块声明可见性。因此,Rust 的模块是额外的可见性控制。如果在使用 mod
声明模块时前面没有加 pub
,则与模块同级的函数仍可以访问该模块,但非同级的模块、外部程序就不能访问该模块了,如前面的 main
函数就可以访问私有的 dog
模块。
如前所属,Rust 需要显式地构建模块树,这远不如 Go 简单和方便。之所以如此,恐怕很大原因是其模块默认是私有的,要将其变为公开,必须通过显式的声明才行。如果 Rust 的模块默认是公开的,那将很容易通过文件结构隐式构建其模块树的。也就是说,为了得到额外的控制性,就要引入额外的复杂性代价。
让我们及时总结一下 Go 的包和 Rust 的模块之间的异同点吧:
- Go 的包和 Rust 的模块都是控制代码项对外可见性的单元,因此都算是常规意义上的模块。
- Go 的包之间总是并列关系,即便一个包目录是另外一个包目录的子目录也是如此。Rust 的模块间具有包含关系,他们构成一个模块树;即便上层模块在声明时不加
pub
,子模块也能对其进行访问。 - Go 的包和目录(不包括子目录)具有一对一的对应关系。在 Rust 中,一个源文件必然对应一个模块,但一个模块不一定对应一个源文件;Rust 需要通过
mod
关键字显式地构建模块树。 - 不可以声明 Go 的包的对外可见性,其默认总是对外可见的(可被导入)。但 Rust 可以通过控制在
mod
前面是否加pub
来声明各个模块的对外可见性,因此 Rust 的可见性控制更加精准。 - Go 的每个包都是有名称的,Rust 中显式声明的模块也有名称,但他们构成的模块树的根模块却是匿名的,需要使用箱(crate)来进行包装,统一将根模块称作
crate
模块。 - Go 的包名称全部用小写,要尽量简短,一般只用一个单词,不能出现下划线。Rust 的模块名称如果包含多个单词,应尽量使用下划线
_
而不是连字符-
分隔。 - Go 中包和类型不并列,因此不共享名字空间,而 Rust 中模块和同级类型共享名字空间。
- Go 具有模块级的变量,而 Rust 中没有模块级的变量。
- Go 的包同时也是编译单元。Rust 的模块不是,其箱才是。
- 总的来说,Go 的包是比 Rust 的模块更大型的结构,它同时具有 Rust 中模块和箱的一些功能。
这些区别造成的一条重要结果就是:在编写 Rust 程序时,你需要细致地进行模块划分,并设定模块的可见性;而在编写 Go 程序时,你会把很多的代码都塞进一个包中,他们之间是相互可见的。很难说得清这两种方式谁优谁劣。
包装模块的容器
一个程序或代码仓库往往由多个模块构成,许多代码管理的工作不是针对单个模块进行,而是针对这些模块形成的集合进行的。这些工作如编译、管理代码仓库、公开发布到代码仓库注册服务中心、给代码仓库标记版本、通过特定机制保证从外部引入的代码仓库是想要的版本(即依赖管理),等等。因此,需要引入新的东西来管理模块代码,这些新的东西包括必要的软件工具——包管理器,以及一些基本的概念和规则。
包管理器
广义的包管理器是一套软件工具,它能以一致的方式自动获取、安装、升级、配置和卸载软件程序,如各种 Linux 操作系统都自带有包管理器,像 yum、apt、pacman 等。不过我们这里说的包管理器是针对编程语言生态系统的,它是一种开发者工具,其主要功能是自动从代码仓库下载库及其依赖,以及调用语言的编译器进行构建。Go 和 Rust 都有各自的包管理器。
Go 的包管理实际上应称为模块管理,其模块管理功能已被构建到大部分 go
命令中,如 go get
、go build
、go build
、go mod
等。由于历史原因,go
命令分有两种模式,分别是模块模式和旧的 GOPATH
模式,默认是模块模式,本文也只讲模块模式。
Rust 的包管理器是 Cargo,这是安装 Rust 语言时必须要安装的一个软件工具。
我们将逐渐介绍这两个工具的使用方法。
Go 的模块
在 Go 中,需要引入的新概念就是模块(module),Go 通过模块进行依赖管理。模块是包的集合,这些包同时被发布、标记版本和分发。可以从版本控制仓库或模块代理服务器下载模块。Go 的包管理器的主要工作对象实际是是模块。
哎呀呀,讲到这里,概念已经全乱了,因为从刚开始就说 Go 的包相当于模块,这里又出来一个正式名称就叫模块的东西。其实,前面讲的包属于常规意义的模块,这里的模块更像是模块的超集——为了对比 Go 和 Rust,我们只能这样硬着头皮解释了。
当我们创建 Go 项目时,首先需要创建一个模块。以下是从零初始化一个名称为 pet
模块的方法:
$ mkdir pet
$ cd pet
$ go mod init github.com/chingli/pet
go: creating new go.mod: module github.com/chingli/pet
$ cat go.mod
module github.com/chingli/pet
go 1.18
以上模块初始化工作实际上是创建了一个 go.mod
文件,并在该文件中写入模块路径,以及所依赖的 Go 版本,后续该模块还会被加入其他信息(主要是依赖信息)。一个模块只有一个 go.mod
文件,位于模块的根目录中。一个实际的代码仓库往往集合了多个模块的功能,而我们正在编写的、导入了其他模块功能的这个模块就叫主模块,或者说我们在其目录中调用 go
命令的模块为主模块。
go.mod
文件使用扩展巴克斯范式(Extended Backus-Naur Form, EBNF)格式书写。其每行指令都是关键字加实参的形式,也可以将相同的指令成组地书写,就像成组地声明 Go 的变量一样。go.mod
文件同时适合人工和程序对其进行修改。
第一行的 module
指令后面跟的模块路径用于标识该模块,它实际上就是模块的名称。模块路径的常见形式就像是去掉了前面协议名称的 URL 一样(路径的开头或结尾不能是点号 .
或斜杠 /
),一般在浏览器地址栏中输入模块路径,就能直达模块代码仓库中的模块根目录(通常模块根目录就是代码仓库的根目录)。如以上所构建模块的路径为 github.com/chingli/pet
,不过这个演示路径实际上不能访问的。另外如 github.com/yuin/goldmark
,这个是真实存在的模块。
如果模块的主版本为 2 及以上,根据语义化版本的规则,此主版本的模块是不和以前版本的模块向后兼容的,这将体现在模块路径也不一样,即必须在模块路径的末尾添加该主版本号,如 github.com/caddyserver/caddy/v2
。这时在通过浏览器访问模块仓库时,必须去掉后面的 /v2
或 /v3
才行。
如果你所编写的模块不公开发布,可以不遵守这样的模块路径书写规则。否则,请务必遵循此规则,以便该模块能被 go get
到。
go.mod
中接下来的 go
指令后面必须是有效的 Go 发布版本,如 1.18
。该指令预期在 Go 2 发布时,可以用于支持向后的不兼容修改,目前还没有这方面用途。不过当前该指令还是有点用处的,如当指令中的版本号低于计算机中安装的 Go 版本时,可能会阻止使用一些新版的语言特性。go
命令也会根据此版本号改变其行为。因此,随着 Go 的新版发布,建议及时修改代码以使其与新版兼容,并将此版本号手工修改为最新的 Go 版本号。
模块初始化完成以后,直接在模块的根目录下添加源代码就可以了,不需要再建立单独的 src
目录以放置代码。让我们编写一个和前面讲解 Rust 模块时所给出关于狗狗示例的仿本。先是目录结构:
$ tree pet
pet
├── dog
│ ├── dog.go
│ ├── husky
│ │ └── husky.go
│ └── teddy
│ └── teddy.go
├── go.mod
└── main.go
3 directories, 5 files
下面是各源文件的内容:
// main.go 文件
package main
import (
"github.com/chingli/pet/dog"
"github.com/chingli/pet/dog/husky"
"github.com/chingli/pet/dog/teddy"
)
func main() {
dog.Sleep()
husky.Bark()
teddy.Bark()
}
// dog.go 文件
package dog
import "fmt"
func Sleep() {
fmt.Println("呼...呼...")
}
// husky.go 文件
package husky
import "fmt"
func Bark() {
fmt.Println("嗷...呜...")
}
// teddy.go 文件
package teddy
import "fmt"
func Bark() {
fmt.Println("汪!汪!")
}
以上示例中,存在 4 个包,分别是 main
、dog
、husky
和 teddy
,虽然 husky
和 teddy
包对应的目录位于 dog
之下,在这些包之间没有包含关系,因此需要在 main
包中分别导入这 3 个包才能使用。关于包的导入,我们稍后还要再讲。
Rust 的包和箱
在 Rust 中,为了进行依赖管理,需引入了两个概念,分别是包(package)和箱(crate)。许多文献不对 crate 进行翻译,但我觉得翻译了也挺好,因为把“包”和“箱”两个汉字放在一起挺和谐的。先来看看他们的定义:
- 包也可以称作 Cargo 包,是 Cargo 分发软件时用到的一个概念,它允许你构建、测试和分享箱。一个包包含一个或多个箱,并用一个
Cargo.toml
文件描述如何构建这些箱。包并不属于语言的一部分。 - 箱是 Rust 程序的编译、链接、版本控制、分发和运行时加载的单元。可以将箱理解为生成编译目标(如可执行程序或库)所需要的源代码(模块树),或者直接认为箱就是设定的编译目标,也或者是从 crates.io 这样的包注册局拉取的被压缩的包。箱是语言的一部分,但并不如模块那样被经常提及。
当我们使用 Cargo 创建 Rust 项目时,首先要创建一个包。以下从零创建一个名称为 pet
包,并查看其所生成的目录内容:
$ cargo new pet
Created binary (application) `pet` package
$ tree pet
pet
├── Cargo.toml
└── src
└── main.rs
1 directory, 2 files
$ cd pet
$ cat Cargo.toml
[package]
name = "pet"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
以上包创建工作根据模板生成了程序的整体目录结构,同时还创建了一个 Cargo.toml
清单文件,这个文件是包的描述文件,其使用 TOML 配置文件格式。其所描述的信息内容与 Go 的 go.mod
文件类似。从他们的初始内容可以看出,Go 用路径来标识模块,Rust 则用更短的名称来标识包。go.mod
文件没有给出模块的版本,Cargo.toml
却给出了包的版本。Cargo.toml
文件还可以包含很多其他内容,这里就不再介绍了。包的根目录就是 Cargo.toml
文件所处的目录(并不是 src
目录)。Rust 的包代码需要放置在专门的 src
目录中,这一点不同于 Go 的模块。
包的编译目标可以是链接库、可执行程序等,这些编译目标及相关元数据可以在 Cargo.toml
文件预先配置,每个目标对应一个箱。根据编译目标的不同,主要有两种箱,分别是库箱和二进制箱。一个包中至少有一个箱,其中最多只能有一个库箱,可以有多个二进制箱。箱总是与一个完整的模块树对应,这个模块树的根必然是一个 .rs
源文件;其中库箱的根必须是 src/lib.rs
文件,该库箱与包同名。src/main.rs
是一个与包同名的二进制箱的根。其他二进制箱对应的源文件应该被放置在 src/bin
目录中,该目录下的每个源文件都会被编译成一个独立的二进制箱。二进制箱对应的源文件必须有一个(规定签名的)main
函数。箱所对应的模块树的根模块本来是匿名的(常用 crate
指代),现在用箱正好可以指代这棵模块树。
包和箱经常被弄混淆。这是因为一个包只能有一个库箱,包和库箱总是同名。另外,在 crates.io 上发布的东西应该是包而不是箱,但当我们把这些包作为依赖箱添加到 Cargo.toml
文件中后,我们并不需要再次给出箱的名称,或许该网站的域名为 packages.io 反倒不容易引起混淆。
再举一个例子来说明。当我们把工厂生产好的商品通过网络售卖时,经常需要把商品通过物流网络邮寄给买家。这时我们会先用一个包装箱(crate)对商品进行包装后才交给物流公司。物流公司在寄出前,通常会再次对商品进行包装,以形成一个包裹(package)。然后就是货运(cargo)过程了。其中 crate 算是产品的一部分,属于生产行为。而 package 和 cargo 都属于货运行为了。
让我们把最先给出的关于狗狗示例中 src
父目录的内容也列出来:
$ tree pet
pet
├── Cargo.toml
└── src
├── dog
│ ├── husky.rs
│ └── teddy.rs
├── dog.rs
└── main.rs
2 directories, 6 files
至于各个源文件的代码,与前面“(3)把所有子模块放入与模块同名的目录中”所给出的完全一样,就不再列出了。
在包目录下运行 cargo run
命令后,再查看目录结构:
$ tree
.
├── Cargo.lock
├── Cargo.toml
├── src
│ ├── dog
│ │ ├── husky.rs
│ │ └── teddy.rs
│ ├── dog.rs
│ └── main.rs
└── target
├── CACHEDIR.TAG
└── debug
├── build
├── deps
│ ├── libpet-92857f2f51c05352.rmeta
...
├── pet
└── pet.d
14 directories, 49 files
多出来一些东西,分别是包根目录下的 Cargo.lock
文件,以及 target
目录,后者内部有很多子目录以及名字奇怪的文件。Cargo.lock
文件里面包含一些由 Cargo 自动生成的内容,这些内容准确地描述依赖信息,不应该手工编辑该文件。target
目录是 Cargo 放置编译后的文件以及中间产物的地方。如果使用的版本控制系统(VCS)是 git,就需要在 .gitignore
文件中添加 /target/
行以在代码仓库中忽略该目录。
为代码仓库添加版本
先说标题,这里的代码仓库分别指的是 Go 的模块或 Rust 的包,不知道用什么来统一说他们了,所以只能表示为代码仓库,我知道这种叫法是不准确的。
为这类代码仓库添加版本是依赖管理中的重要一环。Go 和 Rust 默认都使用语义化版本规范 2.0.0。语义化版本(semantic versioning)是一种被广泛采用的版本方案,其版本格式为:主版本号.次版本号.修订号,版本号递增规则如下:
- 主版本号:当你做了不兼容的 API 修改,
- 次版本号:当你做了向下兼容的功能性新增,
- 修订号:当你做了向下兼容的问题修正。
主版本号为零(0.y.z)的软件处于开发初始阶段,一切都可能随时被改变,这样的公共 API 不应该被视为稳定版。1.0.0 的版本号用于界定公共 API 的形成。先行版本号及版本编译信息可以加到“主版本号.次版本号.修订号”的后面,作为延伸。例如,v1.0.1-alpha
or v2.2.2-beta.2
。
或许我们会想当然地认为,Go 模块的版本应该在 go.mod
文件中做标记。然而不是,它需要通过版本控制系统进行标记,在使用 git 时,就是用 git tag
命令。不过在标记前,需要先使用 go mod tidy
移出一些不再使用的依赖,保证所有的测试都能通过,添加并提交修改,如下所示:
$ go mod tidy
$ go test ./...
$ git add go.mod go.sum hello.go hello_test.go
$ git commit -m "let's start here"
$ git tag v0.1.0
$ git push origin v0.1.0
由于是第一次提交,模块还没有稳定,主版本就从 0 开始,并需约定要在版本号前面加一个 v
以表示该标签是一个版本号。等到模块稳定后,就需要将其主版本号升级为 1 了。
Go 的这种用代码仓库地址表示模块路径,用附加于代码仓库上的标签标记版本的方法,使模块名称(路径)更不容易重复,且能直接通过网络下载特定版本,从而有效地形成一个去中心化的模块发布系统。但其坏处是一旦模块地址变动或托管模块的网站没法提供服务,将会导致这些模块不可用。
至于 Rust,直接修改 Cargo.toml
文件中的 version
项就可以了,如:
[package]
version = "0.1.0"
添加依赖
大多数情况下,我们编写的代码仓库要不可避免地依赖外部代码仓库,其中包括开源的或私有的。这需要把这些代码仓库下载下来,放置在某些地方;当这些代码仓库更新时,也能及时拉取最新的代码。所有的这些工作都应该在少量的人工干预下自动完成。
这里分 5 种情形分别解释如何添加依赖:
- 依赖标准库功能;
- 依赖同一代码仓库中其他包(Go)或模块(Rust);
- 依赖网络上的开源代码仓库;
- 依赖网络上的私有代码仓库;
- 依赖本地磁盘上的代码仓库。
Go 的依赖管理
依赖标准库中的包
由于目前 Go 的标准库还没有模块化,如果一个源代码文件依赖标准库中的包,只要使用 import
关键字导入该包的名称就可以了。下面的示例演示了包的导入语法,这些方法在导入其他类型的包时仍然有用:
// 逐个导入包。
import "errors"
// 成组地导入包。
import (
"internal/itoa"
"reflect"
"time"
)
// 可以为导入的包起一个别名。
import texttmpl "text/template"
import htmltmpl "html/template"
// 使包的导出成员直接处于当前名字空间内,如这样可调用 Println() 函数。
import . "fmt"
// 包别名为一个空白标识符,这样导入包只是为了使用其副作用,
// 即调用包的 init 函数,但不能使用其中的成员。
import _ "net/http/pprof"
导入包后,要使用该包内的导出项,只需要使用类似 fmt.Println()
这种写法就可以了。
每个 .go
文件只应导入在该文件需要的外部包,如果在同一个包内其他文件中还要使用这个包,需要重新导入。
依赖同一模块中的包
如果依赖同一 Go 模块中的其他包,同样直接导入就可以了:
import (
"fmt"
"github.com/chingli/pet/dog"
"github.com/chingli/pet/dog/husky"
"github.com/chingli/pet/dog/teddy"
)
因为当前文件和被导入的包属于同一模块,当然不用再去下载 github.com/chingli/pet
模块。又由于并没有依赖外部模块,使用 go get
等 go
命令后,其 go.mod
文件内容没有任何依赖方面的信息变化。
经常把导入的标准库包和非标准库包分开书写,中间隔一个空行,这样会显得更加整齐。
依赖网络上的开源模块
我们将通过一个示例程序来说明这种情况:假设我们要创建一个名称为 inky
的程序,其功能只是简单地将一段 Markdown 文本转换为 HTML 文本,该程序将使用 Github 上一个用 Go 编写的开源 Markdown 解析器:goldmark。
在 Go 中,需要通过如下命令创建模块:
$ mkdir inky
$ cd inky
$ go mod init github.com/chingli/inky
$ code main.go # 假设使用的是 VSCode 编辑器
以下是 main.go
文件的源码:
package main
import (
"bytes"
"fmt"
"github.com/yuin/goldmark"
)
func main() {
var source []byte = []byte(`众生皆**苦**,唯独你是*甜甜的*草莓味。`)
var buf bytes.Buffer
if err := goldmark.Convert(source, &buf); err != nil {
panic(err)
}
fmt.Println(buf.String()) // <p>众生皆<strong>苦</strong>,唯独你是<em>甜甜的</em>草莓味。</p>
}
这时运行在模块根目录下运行 go run .
命令,将出现错误:
main.go:7:2: no required module provides package github.com/yuin/goldmark; to add it:
go get github.com/yuin/goldmark
这是因为我们还没有将所依赖的 github.com/yuin/goldmark
下载下来并在 go.mod
声明依赖关系呢。按提示运行如下命令:
$ go get "github.com/yuin/goldmark"
如果不出意外,将会:
-
下载该模块最新版的代码压缩包
v1.4.12.zip
、存储压缩包哈希值的文件v1.4.12.ziphash
、该模块go.mod
的重命名文件v1.4.12.mod
到$GOPATH/pkg/mod/cache/download/github.com/yuin/goldmark/@v
目录,同时在该目录生成该模块其他信息文件v1.4.12.info
和v1.4.12.lock
; -
解压
v1.4.12.zip
到$GOPATH/pkg/mod/github.com/yuin/goldmark@v1.4.12
目录; -
在
go.mod
文件中添加如下行:require github.com/yuin/goldmark v1.4.12 // indirect
-
创建与
go.mod
并列的go.sum
文件(只有在不存在时才创建),并在其中加入:github.com/yuin/goldmark v1.4.12 h1:6hffw6vALvEDqJ19dOJvJKOoAOKe4NDaTqvd2sktGN0= github.com/yuin/goldmark v1.4.12/go.mod h1:6yULJ656Px+3vBD8DxQVa3kxgyrAnzto9xy5taEt/CY=
但你很可能会遭遇意外:等了很久,最后却显示超时。这是因为 go
命令默认会从 proxy.golang.org
这个模块代理服务器通过 HTTP 协议请求模块,而这个网址在国内是不能访问的。所幸这个代理服务器地址是可以通过 GOPROXY
环境变量修改的,其默认值是 https://proxy.golang.org,direct
,表示当该模块在 https://proxy.golang.org
服务器不存在(HTTP 状态码为 404 或 410)时才直接(direct)从模块所在原始仓库下载该模块。我们访问 https://proxy.golang.org
所返回的结果是超时,所以并不会再转到原始模块仓储。我们可以修改 GOPROXY
,这需要在命令行中输入如下命令:
$ go env -w GOPROXY=https://goproxy.cn,direct
这里给出的 https://goproxy.cn
是七牛云提供的模块代理服务器。这并不是 proxy.golang.org
的简单镜像,而是同 proxy.golang.org
一样缓存了模块。国内还有一个 Go 模块代理服务器 goproxy.io,你也可以试试。当然,也可以按如下设置:
$ go env -w GOPROXY=direct
这要求直接从原始的模块仓库下载模块,前提是你在电脑上已经安装了 git 这类版本控制工具。不过多数模块被托管在 Github 上,在国内访问速度也不快,还不如使用上面的 https://goproxy.cn
。另外,也可以按如下方式设置 GOPROXY
:
$ go env -w GOPROXY="https://goproxy.cn|direct"
即用管道符号 |
代替逗号 ,
。用逗号时,只有在返回的 HTTP 状态码为 404(Not Found)或 410(Gone)时才跳转到原始模块仓库;用管道符号时,任何出错(如超时)都将转而使用原始模块仓库。
如果你在国内,建议你在安装 Go 后的第一步就是设置 GOPROXY
环境变量。
前面给出的 go get
命令将根据针对包的命令行参数及源代码中的导入包的情况确定所需要的模块版本,在 go.mod
文件中添加对这些模块及相应版本的依赖,并下载模块源码到模块缓存。其用法如下:
$ go get [-t] [-u] [-v] [build flags] [packages]
注意该命令的参数表面上是针对包的,但实际操作结果是针对模块的。该命令主要接受这些标记(flag):
-t
:让go get
同时考虑获取测试文件所需要的模块。-u
:让go get
将所依赖模块的次版本号和修订号更新到最新,但主版本号保持不变;当同时使用-u -t
时,将同时更新测试所依赖的版本号。-u=patch
:让go get
将所依赖模块的修订号更新到最新,但主版本号和次版本号保持不变;
以下是一些 go get
命令的示例:
# 如果 go.mod 文件中没有声明 goldmark 依赖,则从网络确定 goldmark 的最新版本,
# 在 go.mod 中添加依赖声明,在 go.sum 中添加校验和,下载模块最新版本到缓存;
# 如果 go.mod 中已经具有特定版本的 goldmark 依赖项,则在需要时就该版本更新 go.sum
# 和缓存,如果一切都就绪,则什么也不做。
$ go get github.com/yuin/goldmark
# 为当前路径的包添加依赖。
$ go get .
# 对源码中导入的所有包添加依赖。
$ go get
# 将对 goldmark 的依赖升级或降级到特定版本。
$ go get github.com/yuin/goldmark@v1.3.3
# 保持当前主版本号不变的前提下将对 goldmark 的依赖升级到最高版本。
$ go get -u github.com/yuin/goldmark
# 将对 goldmark 的依赖升级到最新版本。
$ go get github.com/yuin/goldmark@latest
# 更新(切换)到 goldmark 模块的 master 分支。
$ go get github.com/yuin/goldmark@master
# 移除主模块对 goldmark 模块的依赖,并将需要它的模块降级为不需要它的版本。
# 执行该行后,go.mod 中的 goldmark 依赖项将被删除,如果模块依赖关系复杂,
# 可能还有其他改动,这里就不再探讨了。
$ go get github.com/yuin/goldmark@none
另外,go mod tidy
命令也会执行上面不带参数的 go get
命令的操作。其他 go
命令,如 go run
、go build
等,可以提供一个 -mod=mod
标记,告诉编译器自动更新 go.mod
文件,也如同先执行不带参数的 go get
命令再运行一样。如下所示:
$ go run -mod=mod .
再来看看 go.mod
文件中新添加的一行。require
指令声明一个给定的依赖模块的最小依赖版本(即实际拉取的模块版本应该大于等于此版本)。后面的 // indirect
注释表示在主模块(你正在编写的模块)中,没有直接导入该依赖模块的任何包。其实我们导入了 github.com/yuin/goldmark
,但 Go 认为我们导入的是模块而不是包,有点奇怪。如果我们在以上 main.go
中又导入了 github.com/yuin/goldmark/util
包,再重新运行 go get
,go.mod
中相应的项后面的 // indirect
注释将被删除:
require github.com/yuin/goldmark v1.4.12
go.mod
文件还有更多设置选型,具体请参见这里。
最后看一下 go.sum
文件,该文件包含所直接或间接依赖模块的加密哈希值。这里两行中的第一行是模块被压缩后的 zip 文件的哈希值,第二行是 go.mod
的哈希值。当使用 go
命令下载模块的 zip 文件或 go.mod
文件时,将计算他们的哈希值并与从全局的校验和数据库(checksum database,如 sum.golang.org)获得的哈希值比对,以确定文件是否被改动。当不匹配时,go
命令将会报错。不过 sum.golang.org 在国内还是没法访问,当设置 GOPROXY=https://goproxy.cn,direct
后,就默认使用 goproxy.cn
的校验和数据库。
被下载和缓存下来的模块对所有的 Go 模块是共享的。为了防止潜在的哈希值不一致,每个模块独立在 go.sum
文件中存储其自身的哈希值。当 go
命令访问缓存的模块文件时,它都会将主模块 go.sum
文件中的哈希值与预先计算的哈希值(这些哈希值同 zip 文件存在一起)进行对比,如果不一致,将不允许编译。因此,假如你更改了 go.sum
文件中的一个哈希值,程序将无法编译。
那么,如果更改了缓存的模块文件,还能再编译吗?例如,如果你强制更改 $GOPATH/pkg/mod/github.com/yuin/goldmark@v1.4.12
目录中的文件内容,如删除 README.md
文件,再进行编译,发现仍然能编译。这是因为每次在进行构建时,并没有重新对这些缓存的文件压缩并计算哈希值,这耗费太大,而只是利用其预计算的哈希值。如果要验证这些缓存的文件是否被修改,可使用 go mod verify
命令,该命令会再次计算缓存文件(压缩的和解压的)的哈希值,并与最初下载获得的哈希值进行对比,从而发现你是否真的已经修改了缓存的文件。
当移除模块依赖时,go.sum
文件中的校验和并不会自动被删除,可以使用 go mod tidy
进行清除。所以不要手工编辑 go.sum
文件,而应该经常使用 go mod tidy
来整理该文件。
如果你的 GOPATH/pkg/mod
目录积攒了太多的旧版本或不用的模块,可以使用 go clean -modcache
命令将他们一次性全部删除。不过这样一来,你正在使用的模块都要重新下载。
依赖网络上的私有模块
并不是所有模块都会以开源的形式发布的,对于那些私有分发的模块,仍然可以被添加到依赖中,并且仍然能使用各种 go
命令对模块进行操作。总的来说,主要可通过两种方式访问私有模块:自建模块代理服务器,或是使用版本控制系统。对于这两种服务器,既可以把服务器放在公司内部,从内部公开访问,但外部不可以访问这些服务器;也可以直接把代码放在互联网上,每次通过身份验证的方式访问。
Go 的模块代理服务器的构建原来并不复杂,可以较轻易地使用现有的开源代码构建,如 goproxy.cn 使用的 goproxy,goproxy.io 使用的 goproxy。理论上来说,Go 模块代理服务器是可以基于 HTTP 基本认证让用于通过用户名和密码访问的。但前面提到的两个开源项目(似乎)都只能提供公共的代理服务,不能进行用户认证。限于篇幅,本文将不再讲述他们具有的构建方法,感兴趣的请先参考这里,以及具体的软件文档。
要将私有模块放在互联网上时,当前阶段比较实用的方法是将他们托管在各种软件开发和版本控制的服务提供者,如 Github、Gitee 等,这里以 Github 为例,详细说明其操作步骤。
首先,需要一个 Github 帐号,这里假设我们的帐号是 chingli
。还需要在电脑上安装并配置好 git。git 可分别通过 HTTPS 或 SSH 进行身份认证,由于 SSH 使用的端口容易被屏蔽,因此在 Go 中更推荐使用 HTTPS,这里只讲述如何使用 HTTPS 进行认证。使用 HTTPS 时,仍基于上面提到的 HTTP 基本认证,这样在请求时,需要提供用户名和密码。对于 Github,规定该密码不能是常规密码,而必须是个人访问令牌,其生成方法请参见 Github 网站的创建个人访问令牌一文。
有了令牌之后,请在 $HOME
目录中创建一个 .netrc
文件,在其中输入如下内容:
machine github.com
login chingli
password 在这里输入 chingli 的有效的“个人访问令牌”
当然,你也可以不使用 .netrc
文件存储用户名和令牌,而使用其他工具将认证信息缓存在 git 中,这些将在讲到 Rust 相对应内容时再讲。
现在,我们在电脑上上传或下载 Github 上的私有代码仓库,就可以不用每次输入用户名和密码(令牌)了。
让我们开始建立一个私有示例模块吧,假设该模块只提供一个简单的 SayHello
函数,我们需要进行如下操作:
$ mkdir hello
$ cd hello
$ go mod init github.com/chingli/hello
$ code hello.go
在 hello.go
文件中输入如下内容:
package hello
import (
"fmt"
"os"
"strings"
)
func SayHello() {
var name string
if len(os.Args) < 2 {
fmt.Printf("你好!\n")
} else if len(os.Args) == 2 {
name = os.Args[1]
fmt.Printf("你好,%v!\n", name)
} else {
name = strings.Join(os.Args[1:], "、")
fmt.Printf("你们好,%v!\n", name)
}
}
现在需要在 Github 上建立一个空的、私有的代码仓库 hello
。再通过如下操作将以上所建立的模块变成一个 git 仓库,并至少把 go.mod
和 hello.go
文件提交到仓库,打上版本标签,然后推送到 Github 上:
$ git init # 初始化一个新的 git 仓库
$ git branch -M main # 将默认的 master 分支更改为 main,可以不用此步骤
$ git remote add origin https://github.com/chingli/hello.git
$ git add hello.go go.mod
$ git commit -m 'initial commit.'
$ git tag v0.1.0
$ git push -u origin main v0.1.0
因为这是个私有模块,当使用 go get
命令获取 github.com/chingli/hello
模块时,无论是从 goproxy.cn
或是直接从 Github 都无法访问,因此我们需要让 go
命令在访问该模块时跳过 GOPROXY
变量设置的代理,并不使用公共的校验和数据库,转而调用本机上的 git 命令访问模块。这需要通过设置 GOPRIVATE
环境变量实现:
$ go env -w GOPRIVATE=github.com/chingli/hello
GOPRIVATE
后面跟一个模块路径前缀的 glob 匹配模式的列表,其匹配方式如同标准库中 path
包的 Match
函数。各项之间同样用逗号 ,
或管道符号 |
分割。当设置此变量时,相当于同时将其列表中各项赋值给 GONOPROXY
和 GONOSUMDB
。前者指示 go
命令对这些项跳过模块代理,后者指示不使用公共的校验和数据库。这里想让该设置仅对 hello
模块起作用,因此给出了模块路径的全称。如果要对整个 example.com
域名起作用,就应该写为:
$ go env -w GOPRIVATE=example.com,*.example.com
我们的私有模块已经部署好了。现在,让我们再编写一个使用此私有模棱的主模块吧,假设主模块将生成一个名称为 say
的可执行命令行程序,需要通过如下操作创建该模块:
$ mkdir say
$ cd say
$ go mod init github.com/chingli/say
$ go get github.com/chingli/hello
$ code main.go
在 main.go
文件中输入如下内容:
package main
import "github.com/chingli/hello"
func main() {
hello.SayHello()
}
在以上 go get github.com/chingli/hello
命令运行过之后,主模块的 go.mod
和 go.sum
文件就被填充了有关 github.com/chingli/hello
模块的依赖信息,同时其源代码也被下载到 $GOPATH/pkg/mod
目录下的对应位置中。
现在让我们运行此程序:
$ go run . 张三 李四 王老五
你们好,张三、李四、王老五!
——大功告成了!
依赖本地磁盘上的模块
如果同时在本地开发多个模块,且相互之间有依赖关系,按照上面给出的依赖网络上的私有模块的方法先将被依赖模块提交到远程代码仓库,再通过 go get
拉取的方法固然可行,但却有点繁琐。其实可以通过其他方法直接指定本地磁盘上模块间的依赖关系,这就要用到 go.mod
文件中的替换 replace
指令。
replace
指令可以将一个被依赖模块的特定版本或所有版本替换为其他位置的模块,替换物可以是另外一个模块路径(必须加版本,且该版本不能同时出现在 require
指定的构建列表中),也可以是本机上的一个模块目录路径(不可加版本)。被替换模块和替换模块的路径必须相同。仅主模块才可以使用 replace
指令。
例如,对于上一小节给出的主模块 say
和依赖模块 hello
,如果他们都处在同一目录中,则可以按如下方式修改 say/go.mod
:
require github.com/chingli/hello v0.1.0
replace github.com/chingli/hello => ../hello
这里 replace
指令行表示将对 github.com/chingli/hello
模块所有版本的依赖都替换为处在 ../hello
目录中的模块。注意,在给出 replace
指令的同时,还应该给出 require
指令。
以下是另外一些 replace
指令示例:
replace github.com/chingli/hello v0.1.0 => github.com/somebody/hello v0.1.1
replace (
github.com/chingli/hello v1.2.3 => gitee.com/chingli/hello v1.4.5
github.com/chingli/hello => gitee.com/chingli/hello v1.4.5
github.com/chingli/hello v1.2.3 => ./fork/hello
github.com/chingli/hello => ./fork/hello
)
replace
指令的典型使用场景是当 fork 一个模块时,就该指令指向新修改的模块。
当公开发布模块时,使用 replace
将网络上的模块替换为本地磁盘模块显然不是一个好的办法,这时最好将以上的 replace github.com/chingli/hello => ../hello
行删除,提交代码后再改回来,这其实挺麻烦的。Go 提供了另外一种同时编写多个模块的机制,就是工作空间,我们将在稍后再讲。
Rust 的依赖管理
依赖标准库中的模块
Rust 的标准库属于一个名称为 std
的箱,其包含内容要比 Go 的标准库少多了。std
箱默认对所有箱可用,因此不必在 Cargo.toml
添加对其依赖项就可以直接在程序中使用其功能。如下所示:
fn main() {
let mut ready: ::std::collections::HashMap<::std::string::String, bool> =
::std::collections::HashMap::with_capacity(10);
let name = ::std::string::String::from("张三");
ready.insert(name.clone(), true);
if ready[&name] {
::std::println!("{} 已经准备好了。", name);
} else {
::std::println!("{} 还没有准备好。", name);
}
}
以上给出的 ::std::collections::HashMap
、::std::string::String
、 ::std::string::String::from
和 ::std::println
都是路径,路径用于引用模块树中的项。路径有多个分段,之间用名字空间限定符 ::
分割。这种含多个分段的路径总是指向末尾的项。实际上,以上代码中的 ready
和 name
也属于路径,不过这些路径指向的东西处在当前控制域内,只需要一个分段,这种单个分段的路径既可以是项,也可以是变量。以 ::
开头的路径属于全局路径,其第一个 ::
后面必然跟一个外部的箱。
实际上,并不需要给出这么冗长的路径名称。首先,路径可以以箱名称开头,因此 ::std
可以写作 std
。其次,标准库中的一些常用项已经被预导入(prelude)到每个模块中了,像 String
就是,因此可以使用 String
代替 std::string::String
,使用 println
代替 ::std::println
。最后,我们可以使用 use
声明将这些路径绑定为更短的名称。
以上代码可简化为:
use std::collections::HashMap;
fn main() {
let mut ready: HashMap<String, bool> = HashMap::with_capacity(10);
let name = String::from("张三");
ready.insert(name.clone(), true);
if ready[&name] {
println!("{} 已经准备好了。", name);
} else {
println!("{} 还没有准备好。", name);
}
}
Rust 的 use
和 Go 的 import
类似却又不太一样,import
相当于在单个源文件中声明对包的依赖,而 use
只是把一个长的路径绑定到一个更短的名称。一般将 Go 的 import
翻译为导入,而将 Rust 的 use
翻译为引入或导入。Go 中不允许两个或多个包形成循环引用,Rust 却允许,不过你最好别这么做。use
和 import
类似,也有多种用法:
- 通过使用类似 glob 的大括号语法,同时绑定一个具有共同前缀的路径列表,如
use a::b::{c, d, e::f, g::h::i};
; - 同时绑定一个具有共同前缀的路径列表,其中也可以使用用
self
关键字绑定该共同前缀对应的父模块,如use a::b::{self, c, d::e};
; - 使用
use p::q::r as x;
形式的语法,重新将目标名称绑定到一个新的局部使用的名称,结合以上两条,也可以写成use a::b::{self as ab, c as abc};
的形式。 - 使用星号通配符同时绑定与给定前缀相匹配的所有路径,如
use a::b::*;
。 - 多次、嵌套、组合使用以上几条进行绑定,如
use a::b::{self as ab, c, d::{*, e::f}};
。
这几条已经讲得很清楚了,所以就不再举例了。
在 Go 中,导入语句只是导入包,并不会导入包内的具体类型、函数、变量或常量;但 Rust 既可以使用 use
绑定模块,也可以绑定模块内的各种公开项,如函数、结构体、枚举、常量等。习惯上,对于函数,我们只绑定到函数所处的模块,这样需要通过模块调用函数,使该函数区别于本地函数;对于其他东西,如结构体、枚举、常量等,则一般绑定到这些项本身。
依赖同一包中的模块
一个包内最多只能有一个库箱,可以有零个到多个二进制箱,每个箱都对应一个模块树。库箱默认是对二进制箱可用的,就像可以直接使用 std
箱一样,而库箱的名称就是包的名称。所以,如果一个名称为 say
的包中有两个 .rs
源文件:main.rs
和 lib.rs
,其中 lib.rs
中有一个签名为 pub fn hello()
的函数,则在 main.rs
文件的 main
函数中可以直接调用该函数:
fn main() {
say::hello();
}
接下来主要讲述处在模块树不同位置的代码如何访问该模块树其他位置的项。分如下几条讲解:
- 下层模块总是能访问上层模块中的项,包括私有项,这和下级作用域能访问上级作用域内容的道理是一样的。
- 和模块并列的其他项能访问模块中的公共项,即便该模块是私有的;不然的话,一旦一个模块被设置为私有,则外界就完全不能访问了。
- 其他情况下要访问模块中的项,要完全依赖于模块及其中项的可见性标注。
这里需要进一步对模块树中路径的表示进行说明:
- 可用
self
指代当前模块,如self::bar()
,这个self
经常是可以省略的,如bar()
;self
相当于目录路径中的./
。 - 可用首字母大写的
Self
指代 trait 或 实现内的实现类型,这将在 trait 部分讲述。 - 可用
super
指代当前模块的父模块,它相当于目录路径中的../
。 - 可用
crate
指代模块树的根模块,或模块树对应的箱,它相当于目录路径中的/
。 - 在宏转码器中,可用
$crate
指代定义宏的箱。
依赖网络上的开源包
与 Go 那种去中心化的模块发布系统不同,开源发布的 Rust 包,一般会发布在包注册局(registry)。默认的注册局是 crates.io。当使用 Cargo 从注册局下载依赖包时,会首先从注册局得到一个索引,该索引包含一个可用包的列表。通常情况下,当我们要查找一个包,可以先到 crates.io 上找一找。
这里使用和前面介绍 Go 时类似的解析并转换 Markdown 文本的例子,来看看在一个程序中如何添加开源包依赖。
先创建一个包:
$ cargo new inky
$ cd inky
$ code src/Cargo.toml
我们需要使用的开源 Markdown 解析器是 pulldown-cmark,该包同时被发布在 crates.io 上,相应地也可以在在 docs.rs 网站查看其文档。Rust 的包主要通过 Cargo.toml
清单文件指明依赖关系。我们需要在 Cargo.toml
文件的 [dependencies]
表下面加入一行以声明对该包的依赖,只需要指定包名称和版本即可:
[dependencies]
pulldown-cmark = "0.9.1"
在 src/main.rs
文件中填入如下内容:
use pulldown_cmark::{html, Parser};
fn main() {
let source: &str = "众生皆**苦**,唯独你是*甜甜的*草莓味。";
let parser = Parser::new(source);
let mut buf: String = String::with_capacity(source.len() * 3 / 2);
html::push_html(&mut buf, parser);
println!("{}", &buf); // <p>众生皆<strong>苦</strong>,唯独你是<em>甜甜的</em>草莓味。</p>
}
现在运行 cargo run
:
$ cargo run
Updating crates.io index
Downloaded memchr v2.5.0
Downloaded unicode-width v0.1.9
Downloaded version_check v0.9.4
Downloaded pulldown-cmark v0.9.1
Downloaded unicase v2.6.0
Downloaded bitflags v1.3.2
Downloaded getopts v0.2.21
Downloaded 7 crates (277.4 KB) in 8.87s
Compiling version_check v0.9.4
Compiling memchr v2.5.0
Compiling unicode-width v0.1.9
Compiling pulldown-cmark v0.9.1
Compiling bitflags v1.3.2
Compiling getopts v0.2.21
Compiling unicase v2.6.0
Compiling inky v0.1.0 (/mnt/d/rustcode/gorust/inky)
Finished dev [unoptimized + debuginfo] target(s) in 2m 05s
Running `target/debug/inky`
<p>众生皆<strong>苦</strong>,唯独你是<em>甜甜的</em>草莓味。</p>
和 go
相关命令差不的,cargo run
也会自动从 crates.io 下载 pulldown-cmark
包及其依赖包,然后进行编译和运行。前面给出的用 Go 写的 goldmark 模块并没有再依赖其他模块,这里的 pulldown-cmark
包则依赖较多的其他包。
该运行命令同时会生成 Cargo.lock
文件,该文件类似于前面的 go.sum
文件,它包含有关依赖的确切信息。该文件由 Cargo 维护,不应手动编辑。
来看看自动生成的 Cargo.lock
文件的具体内容:
# This file is automatically @generated by Cargo.
# It is not intended for manual editing.
version = 3
[[package]]
name = "bitflags"
version = "1.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
[[package]]
name = "getopts"
version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "14dbbfd5c71d70241ecf9e6f13737f7b5ce823821063188d7e46c41d371eebd5"
dependencies = [
"unicode-width",
]
[[package]]
name = "inky"
version = "0.1.0"
dependencies = [
"pulldown-cmark",
]
[[package]]
name = "memchr"
version = "2.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2dffe52ecf27772e601905b7522cb4ef790d2cc203488bbd0e2fe85fcb74566d"
[[package]]
name = "pulldown-cmark"
version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "34f197a544b0c9ab3ae46c359a7ec9cbbb5c7bf97054266fecb7ead794a181d6"
dependencies = [
"bitflags",
"getopts",
"memchr",
"unicase",
]
# ...
其中开始的 version = 3
表示该文件的 v3 格式版本。随后的各个 [[package]]
项分别列出了每个包的名称、版本、源码 ID、校验和,以及该包的依赖项。
除了依赖 crates.io 上的包,也可以直接在 Cargo.toml
中指定依赖代码仓库的包。对于那些没有在 crates.io 上发布的包,一般需要如此。例如:
[dependencies]
pulldown-cmark = { git = "https://github.com/raphlinus/pulldown-cmark" }
这时再运行 cargo run
,将会从 Github 中下载 pulldown-cmark
包,而 pulldown-cmark
包所依赖的包可能仍将从 crates.io 下载。进行以上修改后,Cargo.lock
文件中其他项仍维持不变,仅有原来的 name = "pulldown-cmark"
行所在项将变为:
[[package]]
name = "pulldown-cmark"
version = "0.9.1"
source = "git+https://github.com/raphlinus/pulldown-cmark#d03cff15340612947174850839dd738381f220f5"
dependencies = [
"bitflags",
"getopts",
"memchr",
"unicase",
]
这里的 source
表示源码 ID,它表示从哪里获得包。如果所依赖的包来自注册局,其值的形式为 "registry+URL"
,如 crates.io 注册局对应的形式统一为 "registry+https://github.com/rust-lang/crates.io-index"
;如果所依赖的包来自 git 仓库,其值的形式为 "git+URL"
,例如 "git+https://github.com/raphlinus/pulldown-cmark#d03cff15340612947174850839dd738381f220f5"
。后者后面所跟的字符串是 git 的提交 ID,这是一个唯一的 SHA-1 字符串。
当指定包的依赖来自 git 仓库时,默认将会下载 master
分支的最后一次提交。不过,我们仍然具有一定的控制权,如可以使用 rev
键来指定具体下载哪次提交。rev
的值是提交 ID(通常前 7 位即可),如 rev = d03cff1
;或是远程仓库暴露的一个命名引用,如 rev = "refs/pull/493/head"
。以下的设置将依赖 pulldown-cmark
包某次特定的提交 ID:
[dependencies]
pulldown-cmark = { git = "https://github.com/raphlinus/pulldown-cmark", rev = "d03cff1" }
也可以设置依赖 git 仓库的某个分支,如:
[dependencies]
regex = { git = "https://github.com/rust-lang/regex", branch = "next" }
这将依赖 regex
包的 next
分支的最后一次提交。
甚至可以设置依赖那个 tag
:
[dependencies]
pulldown-cmark = { git = "https://github.com/raphlinus/pulldown-cmark", tag = "v0.9.1" }
因此,虽然 Rust 没有规定必须把版本同时标记在 git 仓库的 tag
中,但实际上我们也可以像 Go 语言这样,把语义化的版本号同时标记在 tag
中,当依赖 git 仓库时,就可以通过此 tag
指定具体的依赖版本了。
Go 模块的 go.sum
只是用于验证缓存在本地的模块是否被篡改,而 Rust 的 Cargo.lock
文件则主要是为了精确地描述依赖信息。在 Go 中,仅靠 go.mod
文件就能得出确定的依赖模块的版本;而在 Rust 中,需要同时依赖 Cargo.toml
和 Cargo.lock
文件才能确定依赖包的版本,即需要依靠 Cargo.lock
锁定模块的版本。如果包的开发正在进行中,我们经常希望它能获取最新的、API 兼容的依赖,因此一般不允许 Cargo.lock
一起随包发布,即在 .gitinore
中指定一行 Cargo.lock
将该文件排除。如果包已经用于实际生产,则经常希望其编译结果是完全确定的,这时需要将 Cargo.lock
文件纳入 git 仓库。
相对于 Go 的 go get
命令,Rust 这边也有 cargo update
命令可以用来更新依赖。以下是一些示例:
# 将 Cargo.lock 文件中描述的所有包更新到最新版本;如果不存在 Cargo.lock 文件,将同时创建该文件。
$ cargo update
# 仅更新包 foo 和 bar。
$ cargo update -p foo -p bar
# 将包 foo,以及 foo 依赖的包更新到最新版本。
$ cargo update -p --aggressive foo
# 将包 foo 精确地更新到 1.2.3 版本。
$ cargo update -p foo --precise 1.2.3
# 尝试只更新定义在工作空间中的包,其他包仅在不在 Cargo.lock 文件中时才更新。
# 当更改了工作空间包成员的版本号后,应该运行此命令更新 Cargo.lock 文件。
$ cargo update -w
# 显示将会被更新的内容,但并不真的实施更新。
$ cargo update --dry-run
除了默认的 crates.io 注册局,还可以更改其他注册局,如国内的 RsProxy.cn、中国科学技术大学 Rust 箱镜像、清华大学 Rust 箱镜像,其设置也很简单,只需要在 $HOME/.cargo/config
文件中添加类似如下内容:
[source.crates-io]
replace-with = 'ustc'
[source.ustc]
registry = "git://mirrors.ustc.edu.cn/crates.io-index"
最后提一下,通过 Cargo 下载的依赖默认被缓存在 $HOME/.cargo/
目录中,其中来自注册局的包放在该目录的 registry
目录中,来自 git 仓库的包放在 git
目录下,和 Go 一样同时缓存了包的 zip 文件(不过把后缀从 .zip
更改为 .crate
)以及解压后的文件。要清除这些缓存,直接删除对应文件即可。
依赖网络上的私有包
同 Go 一样,如果你到包是私有的,你可以通过两种方式在网络上对他们进行分发:分发在自己搭建的注册局上,或是分发到一个 git 仓库上。简单的不需要提供用户管理、身份验证、用户独立发布的注册局构建起来差不多和 Go 的模块代理服务器一样简单,但功能复杂的注册局的构建则会复杂不少。这个页面罗列了一些第三方注册局。
本小节主要讲述如何依赖在 git 仓库中发布的私有包。前面介绍过可以直接在 Cargo.toml
中设置依赖包为 git 仓库。当仓库变为私有时,Cargo 可以通过内建的 git 功能进行用户认证。当然,也可以设置 net.git-fetch-with-cli
为 true
使 Cargo 直接调用 git 命令行程序来获取依赖,就像 Go 那样。不过我们这里只介绍如何使用 Cargo 内建的 git 功能。
Cargo 支持两种认证方式,分别是 HTTPS 和 SSH,这里同样只介绍前者。HTTPS 认证需要使用认证助手(credential.helper
)机制。存在多种认证助手,这里使用一种可以用于全局 git 配置文件的一种。先在 .gitconfig
文件中,加入如下内容:
[credential]
helper = store
这样以来,除了第一次使用 git 时需要输入用户名和密码(对于 Github,此密码还是前面得到的个人访问令牌),其他时候就不用了。
让我们来尝试一下吧!仍然时先建立一个私有示例包,其中提供了一个简单的 SayHello
函数,需要进行的操作如下:
# 使用 --lib 表示创建一个库包,--vcs=git 自动将创建的包初始化为一个 git 仓库
$ cargo new hello --lib --vcs=git
$ cd hello
$ git branch -M main
$ tree -a .
.
├── .git
│ ├── HEAD
│ ...
├── .gitignore
├── Cargo.toml
└── src
└── lib.rs
10 directories, 8 files
$ cat .gitignore
/target
/Cargo.lock
$ code src/lib.rs
在 lib.rs
中填入如下内容:
pub fn say_hello() {
let mut args = std::env::args(); // args 是一个迭代器
if args.len() < 2 {
println!("你好!");
} else if args.len() == 2 {
args.next();
if let Some(name) = args.next() {
println!("你好,{}!", name);
}
} else {
// 先将 args 转换为向量,再切分向量,再将各个元素联合起来。
let names = &args.collect::<Vec<_>>()[1..].join("、");
println!("你们好,{}!", names);
}
}
把在前面试验 Go 时在 Github 上建立的私有代码仓库 hello
删除,再建立一个同名的、空的、私有的代码仓库 hello
。然后通过如下操作将以上所建立的包内容提交到仓库,推送到 Github 上:
$ git remote add origin https://github.com/chingli/hello.git
$ git add Cargo.toml src/lib.rs .gitignore
$ git commit -m "first commit."
$ git push -u origin main
再建立一个名称为 say
的二进制箱:
$ cargo new say
$ cd say
$ code Cargo.toml
在 Cargo.toml
文件中添加依赖:
[dependencies]
hello = { git = "https://github.com/chingli/hello.git", branch = "main" }
再将 src/main.rs
文件的内容替换为如下:
fn main() {
hello::say_hello();
}
运行:
$ cargo run 张三 李四 王老五
Updating git repository `https://github.com/chingli/hello.git`
Compiling hello v0.1.0 (https://github.com/chingli/hello.git?branch=main#08eefb54)
Compiling say v0.1.0 (/Users/jin/rustcode/gorust/say)
Finished dev [unoptimized + debuginfo] target(s) in 3.03s
Running `target/debug/say '张三' '李四' '王老五'`
你们好,张三、李四、王老五!
这里给出的配置 git 的 HTTPS 认证的方法同样对 Go 适用,即可以不使用 .netrc
文件。
依赖本地磁盘上的包
如果以上的 hello
和 say
包并列处在同一目录下,则很容易通过修改 say/Cargo.toml
来使 say
直接依赖磁盘上的 hello
:
[dependencies]
hello = { path = "../hello" }
这就能在 say
目录下使用 cargo run
了。
请注意,crates.io 不允许发布在其上的包仅使用 path
指定本地的依赖。
当通过 path
或 git
指定依赖时,仍然可以指定版本,如下所示:
[dependencies]
hello = { path = "../hello", version = "0.1.0" }
pulldown-cmark = { git = "https://github.com/raphlinus/pulldown-cmark", tag = "v0.9.1", version = "0.9.1" }
当此 version
版本号与实际获得的版本号不兼容时,将无法编译。
另外,Cargo 还可以使用 patch
功能将一个依赖替换为另外一个。还回到前面处理 Markdown 文本的 inky
示例,我们将其 Cargo.toml
文件内容更改为:
[package]
name = "inky"
version = "0.1.0"
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
[dependencies]
pulldown-cmark = "0.9.1"
[patch.crates-io]
pulldown-cmark = { path = "../pulldown-cmark" }
将表示将对 crates.io 中 pulldown-cmark
包的依赖替换为与 inky
目录并列的 pulldown-cmark
。因此,我们需要获取 pulldown-cmark
的源代码到本地:
$ git clone https://github.com/raphlinus/pulldown-cmark.git
现在我们也可以同时修改本地的 pulldown-cmark
代码,即给该包打补丁。
对于不在 crates.io 上的依赖,我们也可以替换其依赖:
[dependencies]
pulldown-cmark = { git = "https://github.com/raphlinus/pulldown-cmark" }
[patch."https://github.com/raphlinus/pulldown-cmark"]
pulldown-cmark = { path = "../pulldown-cmark" }
版本选择算法
Go 的模块或是 Rust 的包之间的依赖关系构成一个有向图(directed graph),其中节点是模块或包,有向边是他们之间的依赖关系。大型的程序或库一般具有复杂的依赖关系,这意味着该图的规模会很大。各个节点的版本是不断更新的,既然 Go 和 Rust 都使用语义化版本规范,那么当一个节点发生版本更新,且其版本号表明此版本和原版本是兼容时,那么是不是应该直接使用此最新版本呢?
对于 Go 来说,答案是否定的。Go 对所依赖模块的版本选择是比较保守的,一般在 go.mod
中指定了要求哪些版本的模块,就确定地使用这些这些版本的模块,Go 不会自作主张地将被依赖的模块升级到更高版本。如果多个模块依赖同一个模块的不同兼容版本,则使用这些要求版本的最高版本;同一模块不同的主版本则相当于不同的模块,具有不同的模块路径。例如,如果主模块 A 依赖模块 B 和 C,而 B 依赖 v1.3.5 的模块 D,模块 C 依赖 v1.4.0 的模块 D,最终会将 v1.4.0 的模块 D 加入构建列表。这种版本选择算法被称作最小版本选择(Minimal Version Selection,MVS)。要解释清楚这里的“最小”一词有点难,大概意思是该算法所选定的版本正好是逻辑上合理的最小版本集合,其实这种最小版本实际上是所有显式列出依赖版本的最大版本,在保持兼容的同时,所带来的意外改变的可能也是最小的。
对于 Rust 来说,以上问题的答案则是肯定的。Rust 对所依赖模块的版本选择是激进和贪婪的,它总是尽可能选择兼容的最新最大版本。例如,前面在通过 pulldown-cmark = "0.9.1"
指定依赖包的版本后,所得包并不一定就是该确切的版本,而是 >= 0.9.1,同时 < 0.10.0 的版本,即满足语义化版本中保证 API 兼容的要求,这一点与 Go 完全不同。以下示例中,左边是指定的版本,右边是实际可能得到的版本范围:
1.2.3 := >=1.2.3, <2.0.0
1.2 := >=1.2.0, <2.0.0
1 := >=1.0.0, <2.0.0
0.2.3 := >=0.2.3, <0.3.0
0.2 := >=0.2.0, <0.3.0
0.0.3 := >=0.0.3, <0.0.4
0.0 := >=0.0.0, <0.1.0
0 := >=0.0.0, <1.0.0
这样看来,Go 和 Rust 对依赖版本选择的算法存在巨大差异。其背后深层次的逻辑大致可以解释为:Go 不太信任模块开发者会完全遵守语义化版本规范,并且对新版模块中可能引入的 bug 存有很大的戒心;处于开发模式时,Rust 则完全信任包的开发者会严格遵守语义版本规范,并且认为越新版的包的 bug 就越少。
由于各自版本选择算法的原因,Go 的 go.mod
文件能确定性地得出所依赖的模块及其版本,而 Rust 的 Cargo.toml
清单文件却不能确定性地得出其所依赖的包及其版本,必须再加一个 Cargo.lock
文件进行锁定。
Go 的版本选择算法的一个潜在问题是可能使开发者难以用到模块的最近更新,从而引发一些问题。Russ Cox 解释称不会引起这些问题,他认为这种做法反而可能避免由于最近更新出现 bug 所造成的问题。在我看来,Go 的这种版本选择策略的确更稳健一些,但有些情况的确会导致难以用到模块的最近更新。而 Rust,可以通过保留或删除 Cargo.lock
选择锁定或不锁定版本,如果一次性将所有依赖升级到最新版本,的确要非常小心。Rust 也建议将 Cargo.lock
文件添加到二进制包的代码仓库中,说明大家还是意识到不确定的包依赖关系的确可能造成问题。——归根结底,我难以评价这两种算法孰优孰劣。
工作空间
正如前面所述,Go 和 Rust 都支持依赖本地磁盘上的模块或包,但都是通过 replace
替换依赖(Go)或 path
指定依赖的本地路径(Rust)等方法对模块或包的配置文件进行魔改,并且这些改动不宜在公开发布模块或包时保留。其实当我们同时编写多个模块或包时,可以通过工作空间(workspace)把他们放在一起编写,而不必要魔改 go.mod
或 Cargo.toml
文件。对于大型项目,经常需要使用工作空间组织多个模块或包。
Go 的工作空间
Go 的工作空间使你可以同时编写多个模块,但不需要像前面那样在 go.mod
文件中添加 replace
指令。在处理依赖方面,工作空间中的每个模块就像是一个单独的根模块。各种 go
命令也为工作空间的操作提供了一定便利。让我们把前面给出的 say
和 hello
模块放入 sayhello
目录中,并对应此目录初始化一个工作空间。其操作如下:
$ mkdir sayhello
$ mv say hello sayhello
$ cd sayhello
# 初始化工作空间,并把 say 和 hello 模块加入工作空间
$ go work init ./say ./hello
$ cat go.work
go 1.18
use (
./hello
./say
)
$ go run github.com/chingli/say 张三 李四 王老五
你们好,张三、李四、王老五!
# 或者也可以使用如下方法运行 say
$ go run ./say 张三 李四 王老五
你们好,张三、李四、王老五!
这里模块 say
调用的正是与其在同一目录中的模块 hello
。对了,现在我们可以把 say/go.mod
中的 replace github.com/chingli/hello => ../hello
行删除,只留下 require github.com/chingli/hello v0.1.0
行就够了。
假如将来在 sayhello
工作空间下又创建了一个模块 welcome
,则可以通过如下命令将该模块对应的目录加入工作空间:
$ go work use ./welcome
当然,手动编辑 go.work
也可起到相同的效果。也可以运行 go work use -r
命令递归地将工作空间目录中所有模块加入工作空间,并删除不存在的模块。go.work
的格式与 go.mod
相似。其中的 use
指令表示将模块加入工作空间,其值是模块相对于工作空间的相对路径。另外,go.work
中也可以有 replace
指令,其功能与 go.mod
中的 replace
指令是相同的。
当处在工作空间时,GOWORK
环境变量将是指向该工作空间对应的 .work
文件的路径,否则为空,例如:
$ go env GOWORK
/home/jin/gocode/sayhello/go.work
许多 go
命令,如 go work init
、go work sync
、go work use
、go list
、go build
、go test
、go run
、go vet
都可以运行于工作空间模式。
工作空间有点类似原来 GOPATH 的工作流程,但同时可以使用模块功能。大多数情况下,还是建议每个模块对应一个代码仓库,而不需要把整个工作空间变成一个代码仓库。
Rust 的工作空间
Rust 的工作空间和 Go 的类似,它是一系列包的集合,这些包通过共享工作空间目录内的 Cargo.lock
文件共同确定依赖版本,通过共享工作空间目录内的 target
共享输出文件,同时共享其他一些设置信息。工作空间中的每个包称作成员。
同样,让我们把 say
和 hello
两个包放进 say_hello
工作空间中:
$ mkdir say_hello
$ mv say hello say_hello
$ cd say_hello
# 输出文件现在要存储在 say_hello/target 目录了,因此把原来包中的残留删除掉,
# 同样原因也删除原来包中自动生成的 Cargo.lock 文件。
$ rm -rf say/target hello/target say/Cargo.lock hello/Cargo.lock
$ code Cargo.toml
在工作空间的 Cargo.toml
文件中输入如下内容,将 say
和 hello
包添加为工作空间的成员:
[workspace]
members = ["say", "hello"]
然后运行 say
包:
$ cargo run -p say
你好!
运行之后我们会发现,工作空间目录下多出了 target
目录和 Cargo.lock
文件,而两个包目录下的文件并没有发生改动。
以上所创建的工作空间不是任何包的根目录(即其 Cargo.toml
文件内不包含 [package]
表),这样的工作空间被称为虚拟清单(virtual manifest)风格的工作空间,工作空间中的 Cargo.toml
就是虚拟清单文件,虚拟清单文件中不能包含 [package]
和 [dependencies]
表。
另外,可以简单地在已有的包的 Cargo.toml
文件末尾加上 [workspace]
表,将此包变为一个工作空间,然后可以在工作空间中加入其他包。这样的工作空间称为根包(root package)风格的工作空间,工作空间对应的包也称作根包。
有了工作空间后,其各个成员包中 Cargo.toml
文件的 [patch]
、[replace]
和 [profile.*]
部分将被忽略,而只识别工作空间目录中的根清单文件 Cargo.toml
中的对应部分。
根清单文件 Cargo.toml
中的 [workspace]
表定义了哪些包属于工作空间的成员,其形式如下:
[workspace]
members = ["member1", "path/to/member2", "crates/*"]
exclude = ["crates/foo", "path/to/other"]
除了通过 members
键指定成员,所有通过 [dependencies]
或 [patch]
表中 path
关键字指定的、处于工作空间目录中的依赖都将自动成为工作空间的成员。基于此,我们可以简单地在一个包的 Cargo.toml
文件中,放置一个空的 [workspace]
表,使该包目录变成为一个工作空间,其用 path
指定的依赖也变成该工作空间的成员。
请注意,目前我们的 say/Cargo.toml
文件仍然是通过 hello = { path = "../hello", version = "0.1.0" }
形式指定对 hello
的依赖,这不应该是最终发布的形式,让我们将其改为依赖 git 仓库的形式吧。先修改 say/Cargo.toml
文件中对应项:
[dependencies]
hello = { git = "https://github.com/chingli/hello.git", branch = "main" }
然后再在 say_hello/Cargo.toml
文件中添加以下内容:
[patch."https://github.com/chingli/hello.git"]
hello = { path = "hello" }
通过这样,我们把魔改部分放在工作空间目录的 Cargo.toml
中了,而 say
包的 Cargo.toml
文件将始终与最终发布的形式保持一致。
当然,如果我们假定将来 hello
包准备发布到 crates.io 中,也可以这样修改 say_hello/Cargo.toml
文件:
[dependencies]
hello = "0.1.0"
相应地需要这样修改 say_hello/Cargo.toml
文件:
[patch.crates-io]
hello = { path = "hello" }
如果对每次在工作目录中以 cargo run -p say
方式运行 say
感到不方便,也可以修改 say_hello/Cargo.toml
文件将 say
包设置为默认包:
[workspace]
members = ["say", "hello"]
default-members = ["say"]
现在,又可以按如下方式运行 say
了:
$ cargo run 张三 李四 王老五
Finished dev [unoptimized + debuginfo] target(s) in 0.00s
Running `target/debug/say '张三' '李四' '王老五'`
你们好,张三、李四、王老五!
由于工作目录中 Cargo.toml
和 Cargo.lock
文件的存在,以及有可能工作目录本身就对应一个主包,导致 Rust 的工作目录和其中包的关联要比 Go 对应强很多。因此,虽然工作目录内部的各个包成员仍然能基本保持独立,但把整个工作目录当成一个代码仓库的做法也是合理的。