Go项目中公共工具包应放在pkg/目录下,按功能拆分为pkg/httpx、pkg/strutil等子包;internal/仅用于仅限本项目内部使用的模块,如internal/handler。

公共工具包该放在项目哪个目录
Go 项目里没有强制的“utils”目录规范,但社区普遍接受 internal/pkg 或 pkg 作为公共工具包根路径。不建议放 internal/utils——internal 下的包默认禁止被外部项目导入,而你封装的工具包很可能需要被本项目多个模块复用(比如 cmd 和 api),放这里会导致循环依赖或不可见。
更稳妥的选择是:pkg/(导出友好)+ 按功能拆分子包,例如:
-
pkg/httpx:封装 HTTP 客户端、重试、超时、日志埋点 -
pkg/strutil:字符串安全截断、模糊匹配、正则提取 -
pkg/errutil:错误包装、分类判断(如errors.Is(err, ErrNotFound))
避免把所有函数塞进一个 pkg/utils.go——Go 不支持跨包重载,命名冲突和测试隔离会很快变成噩梦。
如何设计可测试、不依赖全局状态的工具函数
新手常写带全局配置的工具函数,比如:
立即学习“go语言免费学习笔记(深入)”;
var defaultTimeout = 5 * time.Secondfunc DoRequest(url string) error { ctx, cancel := context.WithTimeout(context.Background(), defaultTimeout) defer cancel() // ... }
这类函数看似简洁,实际无法控制超时、无法 mock、无法并行测试。正确做法是显式传参 + 提供配置选项:
- 基础函数只接收必要参数,不读环境变量、不读全局变量
- 用函数选项模式(Functional Options)封装可选行为,例如
WithTimeout(10*time.Second) - 返回值包含错误,不 panic(除非是开发期断言,如
assert.NotNil(t, x))
示例(pkg/httpx/client.go):
type ClientOption func(*Client)func WithTimeout(d time.Duration) ClientOption { return func(c *Client) { c.timeout = d } }
type Client struct { timeout time.Duration client *http.Client }
func NewClient(opts ...ClientOption) Client { c := &Client{timeout: 5 time.Second} for _, opt := range opts { opt(c) } c.client = &http.Client{Timeout: c.timeout} return c }
什么时候该用 internal/ 而不是 pkg/
答案很直接:当某段逻辑**只服务于本项目内部模块,且明确不希望被其他项目 import** 时,才放进 internal/。
-
internal/handler:HTTP 路由处理,强耦合本项目路由框架和中间件 -
internal/storage:封装了本项目专用的 Redis 分片策略或 MySQL 分表逻辑 -
internal/config:解析config.yaml并注入到各模块,结构体含私有字段
反例:把 internal/uuid 封装成通用 UUID 生成器——它不依赖项目上下文,应该进 pkg/uuid,否则别人想复用还得 copy-paste。
Go 编译器会在构建时检查 internal/ 的导入路径合法性,一旦违规会报错:import "xxx/internal/yyy" is not allowed to refer to package "xxx/internal/yyy" —— 这个限制是保护机制,不是障碍。
新手最容易忽略的初始化与副作用问题
工具包里藏 init() 函数、全局变量赋值、log.SetOutput()、pprof 注册,是线上事故高发区。这些操作在包被任意导入时就触发,顺序不可控。
- 绝对不要在
pkg/下任何文件里写init() - 日志配置、指标注册、信号监听等,统一收口到
cmd/root.go的 main 入口处 - 如果某个工具函数必须初始化(比如连接池),提供
NewXXX()构造函数,并让调用方显式调用
比如 pkg/cache/redis.go 应该这样设计:
type RedisCache struct {
client *redis.Client
}
func NewRedisCache(addr string, password string) (*RedisCache, error) {
client := redis.NewClient(&redis.Options{
Addr: addr,
Password: password,
})
if err := client.Ping(context.Background()).Err(); err != nil {
return nil, fmt.Errorf("failed to connect redis: %w", err)
}
return &RedisCache{client: client}, nil
}
而不是在包级变量里直接 new 并 ping。
真正的复杂点不在目录名,而在每个函数是否清楚自己「能做什么、不能做什么、依赖谁、被谁依赖」——工具包越早建立这种边界感,后期重构成本越低。










