「pkg 和 internal 都是干啥的?标准规范怎么定义的?其他项目框架又怎么用?」—— 这篇把 Go 的 internal 强制规则、pkg 约定俗成、go-zero 项目里的实际布局,以及业内主流项目的做法一次讲清。
internal/ 是 Go 语言级特性(Go 1.4+):任何父级 internal 目录下的包,只能被以 internal 的父目录为根的代码 import,外部模块根本 import 不进来。pkg/ 不是语言特性,是社区约定:放进来的包「理论上可以被外部项目安全复用」,但编译器并不强制。
记得住的一句话:不想被外人 import 的 → 放 internal/(编译器帮你挡);想公开给别人复用的 → 放 pkg/(只是个约定信号,靠自觉)。在 go-zero 里,goctl 生成的那一堆业务代码(config/handler/logic/svc/types)天然就落在 internal/ 下。
一个由工具链强制,一个靠团队约定。
| 维度 | internal/ | pkg/ |
|---|---|---|
| 是谁定的 | Go 语言规范(编译器强制) | 社区约定(golang-standards/project-layout) |
| 外部能否 import | 不能,编译报错 | 能,但「作者声明它稳定可复用」 |
| 约束力 | 硬约束(工具链层面) | 软约束(靠代码评审和自觉) |
| 典型内容 | 业务代码、不想暴露的实现细节 | 通用工具、可被多服务/外部复用的库 |
| 放 go-zero 哪 | goctl 生成的 config/handler/logic/svc/types | 自己加的共享库(common client、工具) |
internal/:Go 编译器强制的「私有边界」这是 Go 语言自带的封装机制,不是 go-zero 发明的。
/internal/ 路径段,那么这个包只能被以 internal 目录的父目录为根的那棵代码树中的代码导入。编译器会直接拒绝外部导入。// repo/user/main.go —— 允许(同属 repo/ 树) import "repo/internal/foo" // 另一个 module / 外部项目 —— 编译报错: // "use of internal package repo/internal/foo not allowed" import "github.com/you/repo/internal/foo" // ❌ forbidden
user/internal/ 就只有 user/ 下能 import;放在仓库根的 internal/ 则整个仓库都能用、但仓库外不行。go-zero 的 goctl 把生成的业务代码放在服务根的 internal/(如 user/internal/...),所以别的微服务 import 不到 这个服务的 handler/logic——这正是我们想要的封装。
pkg/:社区约定,不是语言特性它只是一个「我打算公开给你复用」的语义信号,编译器不管。
pkg/ 就等于对外许诺了稳定性,将来想改会很被动。不确定要不要公开时,先放 internal/,需要再搬出来。
goctl 生成的服务代码天然落在 internal/;pkg 由你自己按需要加。
为什么 go-zero 的业务代码在 internal:服务的 handler / logic / svc / types 是「这个服务私有的实现细节」,不应该被别的微服务直接 import(否则服务间就靠代码耦合而不是靠 RPC 调用了)。Go 编译器通过 internal 在工具链层面帮你守住这条边界——你 想 import 也 import 不进来。
pkg/xxx,让各服务 import。注意 pkg 里的包不要反向 import 某个服务的 internal(跨服务靠 RPC,不靠代码)。
社区广泛采用的 golang-standards/project-layout(非官方,但事实标准)。
cmd/,每个二进制一个子目录。go-zero 服务常把 main 直接放在服务根(user/user.go),也符合「一个 module 一个服务一个 main」的简化版。一套可落地的判断标准。
| 场景 | 放哪 | 理由 |
|---|---|---|
| go-zero 生成的服务业务代码(config/handler/logic/svc/types) | internal/ | 服务私有实现,不该被别的模块 import |
| 只在本仓库内、多个包共享,但不想对外暴露 | internal/(或 internal/pkg) | 编译强制私有,安全 |
| 与业务无关、可能被多服务/外部复用 | pkg/ | 对外承诺稳定 API |
| 小工具、还在演化、不确定要不要公开 | 先放 internal/ | 将来要公开再搬,避免提前许诺 |
| 简单库、作者就想要公开、不想多一层目录 | 仓库根 | Go 官方风格,少一层嵌套 |
internal/——否则「公开库依赖私有实现」会破坏封装,且跨 module 根本 import 不到。pkg 应只依赖自己或标准库/第三方公开库。
internal/pkg 分工最常被问到的实际应用——用「是否跨服务复用」一票定生死。
一句话:对第三方服务(OCR、支付、短信、对象存储、地图……)的客户端封装,默认放 internal/——因为它本质是当前服务的实现细节(怎么调、怎么容错、怎么适配字段)。只有当你要把它做成「多个服务甚至外部项目都能 import 的通用 SDK」时,才提升为 pkg/;更彻底的做法是直接独立成一个 module / 私有 repo。
| 场景 | 放哪 | 理由 |
|---|---|---|
| 只有当前这个 go-zero 服务用 OCR | internal/ocr/(或 internal/client/ocr) | 服务私有实现,不该被别的模块 import |
| 2 个以上服务都要调同一个 OCR | pkg/ocr/ 或独立 module/repo | 跨服务复用 → 提升为公开库;独立 module 依赖关系最干净 |
| OCR 厂商自己提供了 Go SDK(go get 来的) | 不自己建包,直接在 svc 装配 | 第三方 module 已是公开库,你只需在 ServiceContext 里 new 一个 client |
// internal/config/config.go —— 在 RestConf 旁加 OCR 配置 type Config struct { rest.RestConf OCR struct { Endpoint string `json:",optional"` ApiKey string `json:",optional"` } } # etc/user-api.yaml OCR: Endpoint: ${OCR_ENDPOINT} # 真实值由部署时 env / Secret 注入 ApiKey: ${OCR_API_KEY}
package ocr import ( "bytes" "context" "encoding/json" "net/http" "time" ) // Client 抽象成接口,单测时可轻松 mock type Client interface { Recognize(ctx context.Context, imageURL string) (string, error) } type ocrClient struct { endpoint string apiKey string http *http.Client } func NewClient(endpoint, apiKey string) Client { return &ocrClient{ endpoint: endpoint, apiKey: apiKey, http: &http.Client{Timeout: 10 * time.Second}, } } func (c *ocrClient) Recognize(ctx context.Context, imageURL string) (string, error) { body, _ := json.Marshal(map[string]string{"image_url": imageURL}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.endpoint+"/recognize", bytes.NewReader(body)) if err != nil { return "", err } req.Header.Set("Authorization", "Bearer "+c.apiKey) req.Header.Set("Content-Type", "application/json") resp, err := c.http.Do(req) if err != nil { return "", err } defer resp.Body.Close() var out struct { Text string `json:"text"` } if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return "", err } return out.Text, nil }
httpc 封装(见「调用外部接口」篇),可用 httpc.Service.Do 替换上面的原生 net/http,获得熔断/超时统一治理;这里用标准库只为示例最稳、零歧义。客户端用接口抽象,方便 logic 层单测时注入 mock。客户端是「长生命周期依赖」,不要在每个 logic 里现 new(每次请求重建连接、浪费且难统一超时/重试)。在 ServiceContext 里 new 一次、注入进去:
// internal/svc/servicecontext.go type ServiceContext struct { Config config.Config OcrClient ocr.Client // 注入 OCR 客户端 } func NewServiceContext(c config.Config) *ServiceContext { return &ServiceContext{ Config: c, OcrClient: ocr.NewClient(c.OCR.Endpoint, c.OCR.ApiKey), } }
// internal/logic/xxxlogic.go func (l *XxxLogic) Xxx(req *types.Req) (*types.Resp, error) { // 从注入的 svcCtx 拿 OCR 客户端,请求级上下文用 l.ctx 透传 text, err := l.svcCtx.OcrClient.Recognize(l.ctx, req.ImageURL) if err != nil { return nil, err } return &types.Resp{Text: text}, nil }
etc/*.yaml(${ENV} 注入密钥)→ config 解析 → svc 装配 ocr.NewClient → logic 通过 svcCtx 调用。这套「依赖在 svc 装配、请求级数据走 ctx」正是 go-zero 的统一范式(呼应 svc 深度解析 / ctx 传递规范篇)。var ocrClient = ... 包级全局变量——那样配置加载顺序、测试隔离都会失控。几乎所有大型 Go 项目都遵循同一套思路,差异只在取舍。
cmd/ + pkg/ + internal/ 三件套。pkg 放对外库(k8s 的 client-go 就是从 pkg 抽出的),internal 放各组件私有实现。是 project-layout 的标杆范本。internal/ 放不希望被外部模仿的实现)。官方偏好「公开包直接放根」,pkg 是社区而非官方选择。internal/,省略 pkg、cmd。go-zero 单服务就接近这种:服务根放 main,业务全在 internal/,没有顶层 pkg 直到需要共享。internal/,跨服务共享代码抽到仓库根的 pkg/ 或独立 common/ module。go-zero 多服务模式正是这么组织。private/包访问控制做封装(靠关键字,不是目录);Python 用 _ 前缀约定(无编译强制)。Go 的 internal 是目录级、编译强制的封装,比前缀约定硬得多。
· internal/ = 编译器强制的私有:外部 import 直接报错,边界是「internal 的父目录」;
· pkg/ = 约定俗成的公开:只是个「可复用」信号,编译器不强制,别滥用;
· go-zero 的业务代码(config/handler/logic/svc/types)天然在 internal/ 下——服务间靠 RPC 而非代码耦合;
· 多服务共享的通用库抽成 pkg/,且 pkg 不反向依赖某服务的 internal;
· 不确定要不要公开 → 先放 internal,要了再搬;小项目可省 pkg、甚至省 cmd。