0%

一行 import 背后的 Go 包机制

一行 import 背后的 Go 包机制

新建一个项目,文件夹叫 blog,业务代码放在 internal/article 下。很多人第一次写导入,会写成这样:

1
import "blog/internal/article"

编译报错:

1
package blog/internal/article is not in std

正确的写法是:

1
import "github.com/yourname/blog/internal/article"

多出来的 github.com/yourname 是哪来的?import 写了一长串,代码里为什么只用 article?这个包为什么只有 blog 自己能导入?下面从包的定义讲起,逐个回答。

示例项目的结构如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
blog/                      // 项目根目录
├── go.mod // 模块定义:声明模块路径 github.com/yourname/blog 和依赖
├── cmd/ // 所有可执行程序的入口,每个子目录编译成一个程序
│ ├── blog/
│ │ └── main.go
│ └── blog-import/
│ └── main.go
└── internal/ // 内部代码,Go 强制只允许本项目 import,外部项目无法引用
├── article/
│ └── service.go
└── storage/
├── file.go
└── postgres.go

go.mod 的内容如下(go mod tidy 还会写入几条 // indirect 间接依赖,这里省略):

1
2
3
4
5
module github.com/yourname/blog

go 1.27.0

require github.com/jackc/pgx/v5 v5.7.5

一、包是什么:一个目录,一组一起编译的文件

1.1 官方定义

  • 语言规范:Go 程序由包构成,包的特性让依赖管理更高效。
  • 官方文档《How to Write Go Code》:包是同一目录下一起编译的一组源文件。一个文件里定义的函数、类型、变量和常量,同一个包的其他文件都能看到。

所以,包是 Go 的基本编译单元:编译器按包编译和缓存,而不是按文件。包也是封装和依赖的边界,后面会分别讲到。

1.2 目录即包

  • 同一目录下的所有 .go 文件,package 声明必须相同。internal/storage/ 下的 file.go 和 postgres.go 都写着 package storage,所以它们是同一个包。
  • 同一个包的多个文件共用包级作用域,互相直接可见,不用 import。file.go 里定义的标识符,postgres.go 可以直接用。
  • 目录嵌套看起来像父子关系,在 Go 里却不是。子目录是独立的包,和父目录之间没有包含或继承关系。Go 没有“子包”这个概念,目录层级只影响导入路径(internal 除外,见第四节)。internal/article 和 internal/storage 都在 internal/ 下,但它们是两个互不相干的包。

拿标准库的 net/http 来说。http 目录在 net 目录下面,但 net/http 不是 net 的子包:

检验 结果
包名 net/http 的包名是 http,不是 net.http
依赖关系 net/http 要用 net 的功能,源码里同样得写 import "net"
访问权限 net/http 不能访问 net 的私有成员;导入 net 也不会顺带导入 http

平时说“http 是 net 的子包”,指的只是导入路径上的层级,意思是它属于网络这一组。这是命名上的归类,语言层面并没有父子关系。

“一个目录一个包”只有一个例外:测试文件。在 internal/article/ 下写测试,service_test.go 可以有两种声明:

声明 类型 能访问的内容 用途
package article 内部测试 包内所有成员,包括私有的 validate 测试内部实现细节
package article_test 外部测试 只有导出成员,且需要 import 以使用者视角测试公开 API

用外部测试时,同一个目录下会同时有 article 和 article_test 两个包。这个例外只在 go test 时有效,go build 会忽略测试文件。

两种测试文件都必须和被测代码放在同一目录。放到别的目录,它就成了另一个包的测试,只能通过 import 访问导出成员。

1.3 可见性:包是封装的边界

  • 首字母大写:导出,包外可见。
  • 首字母小写:包内私有。结构体字段也一样。
  • 可见性的边界是包,不是文件,也不是目录树。

1.4 main 包:程序的入口

  • 声明为 package main 并且有 main() 函数的包,会编译成可执行文件。其他包只能被导入。
  • cmd/blog 和 cmd/blog-import 都是 main 包,各自生成一个可执行文件。

小结:article 就是 internal/article/ 目录下一起编译的那些源文件。它对外公开首字母大写的类型和函数,把 validate 这类实现细节藏在包内。

二、包名与导入路径:一个用来找,一个用来叫

2.1 两个不同的概念

概念 作用 来源 本例取值
导入路径 定位包的位置 模块路径 + 目录相对路径(见 3.2) github.com/yourname/blog/internal/article
包名 代码中引用包的名字 源文件的 package 子句 article
  • import 后面写的是导入路径,代码里用的是包名。
  • 按惯例,包名和导入路径的最后一段相同。但这只是惯例,不是硬性规定;两者不同时,以 package 子句为准。

本项目的外部依赖就是两者不同的例子。postgres.go 里写的是 import "github.com/jackc/pgx/v5",路径最后一段是 v5,包名却是 pgx,代码里要写 pgx.Connect。/v5 是主版本后缀,不算包名,按惯例包名取它前面那一段。

2.2 同名的包与导入冲突

  • 不同目录的包可以同名。比如标准库的 math/rand 和 crypto/rand,包名都是 rand。
  • 它们的导入路径不同,所以两个包本身不冲突,就像 cmd/blog 和 cmd/blog-import 都叫 main。
  • 但如果一个文件同时导入这两个包,代码里的名字就撞了,必须起别名:
1
2
3
4
import (
"math/rand"
crand "crypto/rand"
)

回到开头的问题:import 写的是导入路径,用来找到包;代码里的 article 是包名,用来引用包。两者各管各的,只是按惯例保持一致。

三、模块:路径前缀从哪来,代码怎么找到

3.1 模块是包的上一层

  • 模块是一组相关的、一起发布的包,由根目录下的 go.mod 定义。
  • 版本和发布以模块为单位,编译和引用以包为单位。blog 模块里有 cmd/blog、cmd/blog-import、internal/article、internal/storage 四个包。
go.mod 指令 作用
module github.com/yourname/blog 声明模块路径,它是本模块所有包导入路径的前缀
go 1.27.0 声明最低 Go 版本,也决定启用哪个版本的语言特性
require github.com/jackc/pgx/v5 v5.7.5 声明外部依赖及其最低版本,外部导入路径因此能被解析

3.2 导入路径的构成公式

导入路径 = 模块路径 + 包目录相对于 go.mod 所在目录的路径

两部分用 / 连接。以 article 包为例:

组成部分 取值 从哪里得到
模块路径 github.com/yourname/blog go.mod 的 module 行
包目录相对于 go.mod 所在目录的路径 internal/article service.go 所在目录相对于 go.mod 的位置
导入路径 github.com/yourname/blog/internal/article 两者用 / 连接

照这个规则,blog 模块四个包的导入路径是:

目录 包名 导入路径
cmd/blog main github.com/yourname/blog/cmd/blog
cmd/blog-import main github.com/yourname/blog/cmd/blog-import
internal/article article github.com/yourname/blog/internal/article
internal/storage storage github.com/yourname/blog/internal/storage

如果包就在 go.mod 所在的目录,相对路径是空的,导入路径就等于模块路径。

  • 导入路径的前缀来自 module 行,和项目文件夹叫什么无关。
  • 项目文件夹改名,导入路径不变;改了 module 行,所有内部导入都要跟着改。

3.3 go 命令如何解析导入路径

编译 blog 时,go 命令会碰到三种导入路径:

导入路径 出现在 类型
os file.go 标准库
github.com/yourname/blog/internal/article main.go、file.go、postgres.go 主模块内的包
github.com/jackc/pgx/v5 postgres.go 外部依赖

假设项目放在 /home/yourname/blog,下面看 go 命令怎样把这三个路径对应到磁盘上的目录。

第一步:确定主模块

在项目里执行 go run ./cmd/blog,go 命令会从当前目录一级级往上找 go.mod,最后找到 /home/yourname/blog/go.mod。从这里拿到两样东西:

  • 主模块路径:github.com/yourname/blog(来自 module 行)
  • 模块根目录:/home/yourname/blog(go.mod 所在的目录)

后面的解析都靠这两样。

第二步:逐个解析导入路径

os:标准库

  • 路径第一段没有点号,go 命令就当它是标准库。
  • 源码在 Go 安装目录下:$GOROOT/src/os。
  • 标准库不用在 go.mod 里声明。

开头的报错就是这么来的:blog/internal/article 不以主模块路径开头,第一段又没有点号,go 命令把它当成标准库,去 $GOROOT/src 下找,找不到就报 not in std。反过来,如果 go.mod 写的是 module blog,import "blog/internal/article" 就能编译通过。

github.com/yourname/blog/internal/article:主模块内的包

  • 路径以主模块路径 github.com/yourname/blog 开头,所以是主模块里的包。
  • 去掉前缀剩下 internal/article,接在模块根目录后面,就是 /home/yourname/blog/internal/article。
  • 整个过程只读本地磁盘,不联网。模块路径虽然是 GitHub 地址,但仓库推没推上去、是不是公开的,都不影响本地编译。

github.com/jackc/pgx/v5:外部依赖

  • 路径不以主模块路径开头,go 命令就去查 go.mod 的 require,找到前缀匹配的模块 github.com/jackc/pgx/v5 v5.7.5。
  • 源码在模块缓存里,目录名带版本号:$GOPATH/pkg/mod/github.com/jackc/pgx/v5@v5.7.5。
  • 缓存里没有就先下载(默认走 proxy.golang.org),再用 go.sum 里记的哈希值校验,对不上就拒绝使用。
  • 这里导入路径和模块路径完全一样,说明包就在模块根目录。如果改用连接池 github.com/jackc/pgx/v5/pgxpool,匹配到的模块还是 github.com/jackc/pgx/v5,再到它的缓存目录下找 pgxpool 子目录,规则和主模块一样。

如果有多个模块都能匹配前缀,go 命令选模块路径最长的那个。

第三步:匹配失败

假设要给文章生成唯一 ID,在 service.go 里加了 import "github.com/google/uuid",但 go.mod 的 require 里没有这个模块,编译时就会报错:

1
2
no required module provides package github.com/google/uuid; to add it:
go get github.com/google/uuid

执行 go get github.com/google/uuid 或 go mod tidy 后,go.mod 会多一条 require,go.sum 也会加上对应的校验和,再编译就能通过。

解析结果汇总

导入路径 匹配依据 源码位置 是否联网
os 标准库 $GOROOT/src/os 否
github.com/yourname/blog/internal/article module 行 /home/yourname/blog/internal/article 否
github.com/jackc/pgx/v5 require 行 $GOPATH/pkg/mod/github.com/jackc/pgx/v5@v5.7.5 仅在缓存中没有时下载

模块路径对内是本地目录的别名,对外是下载地址。

3.4 模块路径为什么用仓库地址

  • 本地开发:模块路径只是一个全局唯一的名字,免得和别人重名。本地编译不会去访问这个地址。
  • 别人获取:模块路径和仓库地址一致时,别人可以直接安装 blog 的程序:go install github.com/yourname/blog/cmd/blog@latest。go 命令看路径就能找到 GitHub 上的仓库,不需要额外配置。
  • 自定义域名:模块路径也可以用自己的域名,比如 example.com/blog。这时别人来获取模块,go 命令会请求 https://example.com/blog?go-get=1,从页面的 <meta name="go-import"> 标签里找到真实仓库。这叫自定义导入路径(vanity import path),这个页面得自己维护。

回到开头的问题:多出来的 github.com/yourname 来自 go.mod 的 module 行。go 命令按这个前缀,把导入路径对应到本地的模块根目录,文件夹名 blog 根本没参与。

四、internal:谁能导入这个包

  • 规则:导入路径里有 internal 时,只有以 internal 父路径开头的代码才能导入它。
  • 本例中,internal 的父路径是 github.com/yourname/blog:
    • cmd/blog 和 cmd/blog-import 的导入路径都以它开头,可以导入 article 和 storage;
    • internal/storage 自己也以它开头,所以能导入 internal/article;
    • 其他模块都不满足条件,导入不了 article 和 storage。
  • 这条规则由 go 命令强制检查,违反了就编译失败。

所以,别人可以 go install blog 的程序,却不能在自己的项目里 import article 或 storage。blog 的业务代码可以放心重构,不会影响别人的项目。

首字母大小写区分包内和包外,internal 区分模块内和模块外,两者合起来就是 Go 的两层封装。

回到开头的问题:internal 的访问范围按导入路径计算,而导入路径由模块路径决定,所以只有 blog 模块里的代码能导入 article。

五、回到这行 import:完整串联

开头那行正确的 import,在 blog 主程序里:

1
2
3
4
5
6
7
8
9
10
// cmd/blog/main.go
package main

import (
"context"
"log"

"github.com/yourname/blog/internal/article"
"github.com/yourname/blog/internal/storage"
)

在模块根目录执行 go run ./cmd/blog,go 命令处理 github.com/yourname/blog/internal/article 这行导入时,依次做六件事:

  1. 路径匹配:前缀 github.com/yourname/blog 和 go.mod 的 module 行一致,判定是主模块里的包。
  2. 定位目录:去掉前缀,把剩下的 internal/article 对应到本地目录。
  3. 权限检查:路径里有 internal,调用方 github.com/yourname/blog/cmd/blog 以它的父路径开头,检查通过。
  4. 编译包:把 internal/article/ 下的所有源文件当作一个整体编译。
  5. 绑定名字:读到 package article,在 main.go 里用 article 来引用这个包。
  6. 可见性检查:NewArticleService 首字母大写,可以调用;要是调用 article.validate,编译失败。

storage 包和 blog-import 程序的完整代码见文末附录。

六、常见误区

误区 事实
包名必须和目录名相同 只是惯例;代码中的引用名以 package 子句为准
import 后面写的是包名 import 后面写的是导入路径,代码中用的才是包名,如 github.com/jackc/pgx/v5 的包名是 pgx
导入路径的前缀是项目文件夹名 前缀是 go.mod 中的模块路径,和文件夹名无关
模块路径必须是已存在的仓库 本地开发不需要;只有别人要获取时才需要能访问
子目录是父包的“子包”,有访问特权 子目录是完全独立的包,Go 没有子包的概念;net/http 和 net 就是两个独立的包
一个目录可以放多个包 不可以;唯一例外是外部测试包 xxx_test
测试文件可以放在任意目录 单元测试必须和被测代码在同一目录;放在其他目录的测试属于另一个包,只能测试导出成员
可以用 import "./internal/article" 相对导入 模块模式下不支持相对导入,必须写完整导入路径
internal 只是命名约定 由 go 命令强制检查,违反时编译失败
实现接口要显式声明 Go 的接口是隐式实现的,方法对得上就行;需要检查时用 var _ 接口 = (*类型)(nil)
go run main.go 和 go run . 等价 前者只编译指定的文件,同包其他文件里的定义会报 undefined;应该用 go run . 或 go run ./cmd/blog
包就是模块 一个模块包含多个包;模块是发布单位,包是编译单位
主版本升级只需改 tag v2 及以上版本的模块路径必须带主版本后缀,如 github.com/jackc/pgx/v5;blog 发布 v2 时,模块路径要改成 github.com/yourname/blog/v2

七、最佳实践

7.1 包的命名

  • 用小写、简短的单个单词,不用下划线和驼峰。写 storage,不写 storage_impl 或 storageImpl。
  • 包名和导入路径的最后一段保持一致(主版本后缀 /vN 除外)。
  • 别用 util、common、base、helper 这类说明不了什么的名字,包名应该说清它提供什么。
  • 避免名字重复。调用方总是写成 包名.标识符,所以用 storage.FileStore,不用 storage.StorageFileStore。

7.2 包的划分

  • 按职责分包,不按类型分包(别搞 models、controllers、utils 那一套)。blog 按“业务”和“存储”分成 article 和 storage。
  • 别太早拆包。一个包里多放几个文件,比拆成一堆只有一个文件的小包好维护。storage 把两种实现放在同一个包的两个文件里,就是这个思路。
  • 依赖方向保持单一。blog 各个包的导入关系如下:

  • storage 导入 article,因为它要用 article.Article 类型,还要实现 article.ArticleSaver;article 不导入 storage。
  • 如果 article 反过来再导入 storage,就成了循环导入,编译报错 import cycle not allowed。Go 不允许循环导入,这逼着包和包之间划清边界。出现循环时,可以抽出接口或新建一个公共包来解开。
  • 接口由使用方定义(article),实现放在提供方(storage),组装放在 main。以后加一种存储,只要在 storage 里加一个类型,article 包不用动。

7.3 项目结构

  • 模块路径优先用代码仓库地址;用自定义域名的话,要配置 go-import 元标签。
  • 可执行程序放在 cmd/<程序名>/ 下。main 包只负责解析参数和组装,业务逻辑放到其他包里。
  • 不想被外部模块依赖的代码放进 internal/,免得过早对外承诺 API。

7.4 导入的写法

  • 导入分三组:标准库、第三方、本模块,组之间空一行。可以交给 goimports 自动整理。
  • 只有名字冲突,或包名和路径对不上时,才用别名。pgx/v5 这种主版本后缀属于惯例情况,不需要别名。
  • 不用 . 导入;_ 导入只用在需要包初始化副作用的地方(比如注册数据库驱动)。

7.5 测试、文档与依赖

  • 公开 API 用外部测试包(xxx_test)测,内部细节用同包测试测。
  • 用编译期断言 var _ 接口 = (*类型)(nil) 检查类型有没有实现接口。
  • 每个包都写包注释,以 // Package 包名 ... 开头,一个包只写一处;多文件的包可以单独放在 doc.go 里。
  • 改了依赖就执行 go mod tidy,并把 go.mod 和 go.sum 一起提交。

结语:一条线串起所有概念