go-zero 的 pkg 与 internal 目录

「pkg 和 internal 都是干啥的?标准规范怎么定义的?其他项目框架又怎么用?」—— 这篇把 Go 的 internal 强制规则、pkg 约定俗成、go-zero 项目里的实际布局,以及业内主流项目的做法一次讲清。

一句话结论 一眼区别 internal 强制规则 pkg 约定 go-zero 实际布局 标准项目布局 规范清单 第三方服务放哪 其他项目 结论

internal 是「编译器强制的私有」,pkg 是「约定俗成的公开」

internal/ 是 Go 语言级特性(Go 1.4+):任何父级 internal 目录下的包,只能被以 internal 的父目录为根的代码 import,外部模块根本 import 不进来。pkg/ 不是语言特性,是社区约定:放进来的包「理论上可以被外部项目安全复用」,但编译器并不强制。

记得住的一句话:不想被外人 import 的 → 放 internal/(编译器帮你挡);想公开给别人复用的 → 放 pkg/(只是个约定信号,靠自觉)。在 go-zero 里,goctl 生成的那一堆业务代码(config/handler/logic/svc/types)天然就落在 internal/ 下。

internal 与 pkg 一眼区别

一个由工具链强制,一个靠团队约定。

维度internal/pkg/
是谁定的Go 语言规范(编译器强制)社区约定(golang-standards/project-layout)
外部能否 import不能,编译报错能,但「作者声明它稳定可复用」
约束力硬约束(工具链层面)软约束(靠代码评审和自觉)
典型内容业务代码、不想暴露的实现细节通用工具、可被多服务/外部复用的库
放 go-zero 哪goctl 生成的 config/handler/logic/svc/types自己加的共享库(common client、工具)

internal/:Go 编译器强制的「私有边界」

这是 Go 语言自带的封装机制,不是 go-zero 发明的。

📜 规则原文(Go 1.4 引入)
如果一个包的导入路径包含 /internal/ 路径段,那么这个包只能被以 internal 目录的父目录为根的那棵代码树中的代码导入。编译器会直接拒绝外部导入。
repo/ ├── internal/ ← 父目录是 repo/ │ └── foo/ ← 只有 repo/ 下的代码能 import repo/internal/foo │ └── foo.go ├── user/ ← 在 repo/ 这棵树下,所以可以 import internal/foo │ └── main.go └── other-mod/ ← 若它是另一个 module / 另一个仓库,则❌不能 import repo/internal/foo
// 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
关键点:internal 的「父目录」才是可见性边界。放在 user/internal/ 就只有 user/ 下能 import;放在仓库根的 internal/ 则整个仓库都能用、但仓库外不行。go-zero 的 goctl 把生成的业务代码放在服务根的 internal/(如 user/internal/...),所以别的微服务 import 不到 这个服务的 handler/logic——这正是我们想要的封装。

pkg/:社区约定,不是语言特性

它只是一个「我打算公开给你复用」的语义信号,编译器不管。

📣 它表达什么
「这里的代码是经过设计、可以被外部项目安全 import 的库」。放了 pkg 就等于对外承诺:API 相对稳定、不轻易破坏性变更。
🔓 编译器不强制
和 internal 不同,任何人都 import 你的 pkg。所以「能不能 import」不靠 pkg 决定,靠你是否愿意对外暴露。
🤔 争议:要不要 pkg
Go 官方(如 stdlib、一些核心团队)倾向于不放 pkg,直接把公开包放在仓库根。pkg 是社区 layout popularized 的,并非官方强制。小项目常省略它。
✅ 适合放什么
与具体业务无关、能被多个服务甚至外部复用的东西:HTTP/RPC 公共客户端、加密/校验工具、错误码定义、通用中间件、领域无关的算法。
别滥用 pkg:把一堆还没想清楚的代码塞进 pkg/ 就等于对外许诺了稳定性,将来想改会很被动。不确定要不要公开时,先放 internal/,需要再搬出来。

go-zero 项目里实际怎么摆

goctl 生成的服务代码天然落在 internal/;pkg 由你自己按需要加。

shop/ ← 仓库(或一个微服务 module) ├── go.mod ├── user/ ← 一个 go-zero 服务 │ ├── etc/user.yaml │ ├── user.go ← main 入口 │ ├── internal/ ← goctl 生成,全部业务代码,外部 import 不到 │ │ ├── config/config.go │ │ ├── handler/... │ │ ├── logic/... │ │ ├── svc/servicecontext.go │ │ └── types/types.go │ └── (无 pkg) ├── order/ ← 另一个 go-zero 服务(同样 internal/) │ └── internal/... └── pkg/ ← 自己加:多服务共享的公开库 ├── idgen/ ← 分布式 ID 生成 ├── cache/ ← 通用缓存封装 └── errcode/ ← 统一错误码

为什么 go-zero 的业务代码在 internal:服务的 handler / logic / svc / types 是「这个服务私有的实现细节」,不应该被别的微服务直接 import(否则服务间就靠代码耦合而不是靠 RPC 调用了)。Go 编译器通过 internal 在工具链层面帮你守住这条边界——你 import 也 import 不进来。

何时加 pkg:当你有两个以上 go-zero 服务都要用同一段代码(如统一的 id 生成、错误码、加密工具),就把它抽成 pkg/xxx,让各服务 import。注意 pkg 里的包不要反向 import 某个服务的 internal(跨服务靠 RPC,不靠代码)。

标准的 Go 项目布局长啥样

社区广泛采用的 golang-standards/project-layout(非官方,但事实标准)。

myproject/ ├── cmd/ ← 各可执行文件的 main 包(一个二进制一个子目录) ├── internal/ ← 私有代码(编译器强制,外部不可见) │ ├── app/ │ └── pkg/ ← 也可在 internal 里再放只本仓库用的小 pkg ├── pkg/ ← 公开可复用的库(约定) ├── api/ ← proto / .api / OpenAPI 定义 ├── configs/ ← 配置文件模板 ├── docs/ test/ scripts/ build/ deployments/ └── go.mod
🔹 cmd/ 放 main
可执行程序入口集中放 cmd/,每个二进制一个子目录。go-zero 服务常把 main 直接放在服务根(user/user.go),也符合「一个 module 一个服务一个 main」的简化版。
🔹 internal / pkg 分工
私有实现进 internal(编译强制),公共库进 pkg(约定)。go-zero 的单服务形态把 internal 直接沉到服务根,省掉 cmd 这一层。

什么时候放 internal、什么时候放 pkg、什么时候放根

一套可落地的判断标准。

场景放哪理由
go-zero 生成的服务业务代码(config/handler/logic/svc/types)internal/服务私有实现,不该被别的模块 import
只在本仓库内、多个包共享,但不想对外暴露internal/(或 internal/pkg)编译强制私有,安全
与业务无关、可能被多服务/外部复用pkg/对外承诺稳定 API
小工具、还在演化、不确定要不要公开先放 internal/将来要公开再搬,避免提前许诺
简单库、作者就想要公开、不想多一层目录仓库根Go 官方风格,少一层嵌套
铁律:pkg 里的包 绝不反向 import 某个服务下的 internal/——否则「公开库依赖私有实现」会破坏封装,且跨 module 根本 import 不到。pkg 应只依赖自己或标准库/第三方公开库。

第三方服务(OCR / 支付 / 短信)客户端放哪

internal/pkg 分工最常被问到的实际应用——用「是否跨服务复用」一票定生死。

一句话:对第三方服务(OCR、支付、短信、对象存储、地图……)的客户端封装,默认放 internal/——因为它本质是当前服务的实现细节(怎么调、怎么容错、怎么适配字段)。只有当你要把它做成「多个服务甚至外部项目都能 import 的通用 SDK」时,才提升为 pkg/;更彻底的做法是直接独立成一个 module / 私有 repo。

场景放哪理由
只有当前这个 go-zero 服务用 OCRinternal/ocr/(或 internal/client/ocr)服务私有实现,不该被别的模块 import
2 个以上服务都要调同一个 OCRpkg/ocr/ 或独立 module/repo跨服务复用 → 提升为公开库;独立 module 依赖关系最干净
OCR 厂商自己提供了 Go SDK(go get 来的)不自己建包,直接在 svc 装配第三方 module 已是公开库,你只需在 ServiceContext 里 new 一个 client

① 推荐目录(单服务自用 → internal)

user/ └── internal/ ├── ocr/ ← 第三方 OCR 客户端封装(本服务私有,放 internal) │ └── client.go ← 接口 + 实现,读 config 里的 endpoint/key ├── config/ ├── handler/ ├── logic/ ├── svc/ └── types/ # 若多个服务复用,则抽到仓库根的 pkg(或独立 module) pkg/ └── ocr/ ← 多服务共享的 OCR 客户端 SDK

② config 里加 OCR 配置(密钥走 ${ENV},呼应多环境篇)

// 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}

③ OCR 客户端实现(internal/ocr/client.go)

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
}
go-zero 也内置 httpc 封装(见「调用外部接口」篇),可用 httpc.Service.Do 替换上面的原生 net/http,获得熔断/超时统一治理;这里用标准库只为示例最稳、零歧义。客户端用接口抽象,方便 logic 层单测时注入 mock。

④ 在 svc 里集中装配(呼应初始化写在哪 / svc 篇)

客户端是「长生命周期依赖」,不要在每个 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),
    }
}

⑤ logic 里通过 svcCtx 调用(呼应 ctx 篇)

// 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 传递规范篇)。
别把第三方客户端放 logic 包顶层当全局 var。呼应「全局变量与 svc」篇:客户端应通过 config→svc 注入,别在包里写 var ocrClient = ... 包级全局变量——那样配置加载顺序、测试隔离都会失控。

其他项目 / 框架怎么用 internal 和 pkg

几乎所有大型 Go 项目都遵循同一套思路,差异只在取舍。

🔹 Kubernetes / Helm / Prometheus
典型 cmd/ + pkg/ + internal/ 三件套。pkg 放对外库(k8s 的 client-go 就是从 pkg 抽出的),internal 放各组件私有实现。是 project-layout 的标杆范本。
🔹 Go 标准库
stdlib 基本不用 pkg、也极少用 internal(少数如 internal/ 放不希望被外部模仿的实现)。官方偏好「公开包直接放根」,pkg 是社区而非官方选择。
🔹 小型 / 单服务项目
常直接把所有代码放根或只用一个 internal/,省略 pkg、cmd。go-zero 单服务就接近这种:服务根放 main,业务全在 internal/,没有顶层 pkg 直到需要共享。
🔹 微服务 monorepo
每个服务一个目录带自己的 internal/,跨服务共享代码抽到仓库根的 pkg/ 或独立 common/ module。go-zero 多服务模式正是这么组织。
🔸 与 Java / Python 对比
Java 用 private/包访问控制做封装(靠关键字,不是目录);Python 用 _ 前缀约定(无编译强制)。Go 的 internal 是目录级、编译强制的封装,比前缀约定硬得多。
🔸 小结
无论哪个 Go 大项目,internal = 强制私有 这一条是统一的;pkg 是否用、怎么用是约定差异。go-zero 在单服务形态下把 internal 沉到服务根、按需加 pkg,完全符合主流。

一句话记住 internal 与 pkg

🧠 记忆口诀

· internal/ = 编译器强制的私有:外部 import 直接报错,边界是「internal 的父目录」;
· pkg/ = 约定俗成的公开:只是个「可复用」信号,编译器不强制,别滥用;
· go-zero 的业务代码(config/handler/logic/svc/types)天然在 internal/ 下——服务间靠 RPC 而非代码耦合;
· 多服务共享的通用库抽成 pkg/,且 pkg 不反向依赖某服务的 internal;
· 不确定要不要公开 → 先放 internal,要了再搬;小项目可省 pkg、甚至省 cmd。