svc 到底装了啥?要不要一直传?util 怎么用?

「是不是所有上下文依赖都不用全局变量、全塞进 svc?」「svc 从上到下一直传递?」「util/tool 想用 svc 咋办?」这篇把这三个连环问题一次讲透。

先拆误解 svc 装了啥 要不要一直传 util 怎么用 结论速记

「所有上下文依赖都存进 svc」—— 这句话是错的

关键在于区分两件事:进程级共享依赖请求级上下文数据。它们归宿完全不同,svc 只接前者。

你问的「上下文依赖」如果指一次请求里的数据(userId、traceId、租户ID、token),那它不进 svc,它走 context.Context。svc 装的是跨所有请求、进程启动时建一次、全局复用的连接/客户端(DB、Redis、RPC、HTTP)。

svc 装这些 *gorm.DB 连接 *redis.Redis user.User RPC 客户端 httpc.Service MQ Producer / Cache ctx 装这些 userId / tenantId traceId / spanId request deadline k/v 透传值 (随请求生灭) 这俩都不装 纯函数 / 算法 const 常量 无状态工具(正则等) registry / 模板 (本来就不该有状态) 三选一:按「生命周期 + 是否随请求变」决定归宿
⚠️ 一句话记住:会随每个请求变化的 → ctx;进程级、建一次复用 → svc;本来就没状态 → 啥都不用、直接函数/常量。 所以「全塞 svc」是误解,svc 只接「进程级依赖」这一格。

svc 里到底存了什么

一句话:所有「进程启动时建一次、之后所有请求共享复用」的有状态依赖。下面是一份典型的 ServiceContext。

type ServiceContext struct {
    // 1. 配置:从 etc/xxx.yaml 读进来,全局只读
    Config   config.Config

    // 2. 数据库:连接池建一次,所有请求共用
    DB       *gorm.DB

    // 3. 缓存:redis 客户端,单例
    Redis    *redis.Redis

    // 4. 内部 RPC 客户端:goctl 生成的 stub,带 etcd 发现
    UserRpc  user.User
    OrderRpc order.Order

    // 5. 外部 HTTP 客户端:httpc,自带超时+熔断
    PayHttp  httpc.Service

    // 6. 消息队列生产者 / 消费者
    Producer kq.Producer

    // 7. 自定义业务 service(把一组依赖打包,见下文 util 章)
    OrderUtil *util.OrderUtil
}
✅ 典型该进 svc 的
DB 连接池、Redis/Mongo 客户端、zrpc 生成的 RPC stub、httpc 客户端、MQ 生产/消费端、BloomFilter、本地缓存、业务聚合 service。共性:长生命周期、建一次、并发安全、被到处用
❌ 不该进 svc 的
请求级的 userId / 分页参数 / 上传文件、纯算法函数、正则表达式、const 常量、模板字符串、logx(框架自带、包级无状态)。共性:要么随请求生灭,要么本来无状态
📌 判断标准只有一句话:「这个东西,是不是整个进程只建一份、所有请求共享?」是 → svc;否 → ctx 或函数参数/常量。

svc 是不是「从上到下一直传递、一直传递」

传递,但不是「每次都重建」——它从头到尾只有一个实例,靠指针从上往下一级级传引用。开销几乎为零。

main.go 建一次 svcCtx handler 接 svcCtx 引用 logic 存指针到字段 util/tool 靠 logic 传进来 唯一的一个 svcCtx 实例,靠指针被引用,不是每个层级 copy 一份
// ① main:只建一次
func main() {
    var c config.Config
    conf.MustLoad(*configFile, &c)
    svcCtx := svc.NewServiceContext(c)   // ★ 唯一实例

    server := rest.MustNewServer(c.RestConf)
    defer server.Stop()
    handler.RegisterHandlers(server, svcCtx)  // 引用往下传
    server.Start()
}

// ② handler(goctl 生成):接收并保存引用
func NewLoginHandler(svcCtx *svc.ServiceContext) http.HandlerFunc {
    return func(w http.ResponseWriter, r *http.Request) {
        l := logic.NewLoginLogic(r.Context(), svcCtx)  // 再往下传
        resp, err := l.Login()
        httpx.WriteJson(w, resp, err)
    }
}

// ③ logic:把指针存到结构体字段,业务里直接用
type LoginLogic struct {
    logx.Logger
    ctx    context.Context
    svcCtx *svc.ServiceContext   // ★ 只是个指针,零拷贝
}
func NewLoginLogic(ctx context.Context, svcCtx *svc.ServiceContext) *LoginLogic {
    return &LoginLogic{Logger: logx.WithContext(ctx), ctx: ctx, svcCtx: svcCtx}
}
func (l *LoginLogic) Login() (*types.LoginResp, error) {
    u, err := l.svcCtx.UserRpc.GetUser(l.ctx, &user.GetUserReq{...})  // 直接用
    ...
}

所以是「传递」没错,但本质是依赖注入的引用传递:main 建一份,handler / logic 都只是拿着同一个指针。你不用在每层重新 new,也不会有多份连接。真正「一直传递」的是指针,不是数据拷贝。

📌 和「全局变量」对比:全局变量是「谁都能从任意角落摸到」,svc 是「只有被传到的地方才摸得到」。这恰好是 go-zero 想要的——依赖可见、可替换、可测,而不是隐式全局可达。

util / tool 这种地方,想用 svc 咋办

这是真实项目里最高频的困惑:logic 能拿到 svcCtx,可我的工具函数在 internal/util 里,它又没被传进来。三种正解 + 一个反例。

✅ 解法 A:从 logic 把「窄依赖」当参数传进去
util 函数需要啥就收啥,不要收整个 svc。最常用、最干净、最易单测。
✅ 解法 B:util 里定义接口,svc 去满足它
避免 util 反向 import svc 造成循环依赖。util 只认接口,不认具体类型。
✅ 解法 C:把「有依赖的工具」升级成 struct service
依赖在 svc 里 new 一次,以 service 形态注入。适合一组强相关的工具函数。
❌ 反例:在 util 里搞全局变量
var DB *gorm.DB 然后 init 里赋值——这正是 go-zero 想让你戒掉的写法,别在 util 重蹈覆辙。

解法 A —— 传窄依赖(推荐起步)

// internal/util/sms.go —— 只认它需要的依赖,不碰 svc
package util
func SendCode(client sms.Client, phone, code string) error {
    return client.Send(context.Background(), phone, "您的验证码:"+code)
}

// internal/logic/loginlogic.go —— logic 有 svcCtx,把具体依赖传进去
resp, err := util.SendCode(l.svcCtx.SmsClient, phone, code)  // 只传 SmsClient

解法 B —— 接口解耦,杜绝循环依赖

// internal/util/order.go —— util 自己定义接口,不 import svc
package util
type Querier interface {          // ★ 接口在 util 侧
    GetPrice(ctx context.Context, id int64) (int64, error)
}
func CalcTotal(q Querier, items []Item) (int64, error) {
    for _, it := range items { p, _ := q.GetPrice(...) ; ... }
}

// logic 里:svc 里的 *gorm.DB / RPC 只要实现了 Querier 就能传
total, _ := util.CalcTotal(l.svcCtx.DB, items)  // 只要 DB 满足 Querier

解法 C —— 升级为 struct service(强相关的一组工具)

// internal/util/order_util.go
type OrderUtil struct {
    db  *gorm.DB
    rpc order.Order
}
func NewOrderUtil(db *gorm.DB, rpc order.Order) *OrderUtil {  // 构造时注入
    return &OrderUtil{db: db, rpc: rpc}
}
func (o *OrderUtil) CloseExpired(ctx context.Context) error { ... }

// svc 里 new 一次,挂到 ServiceContext,logic 直接用
// svc/servicecontext.go:
OrderUtil: util.NewOrderUtil(c.DB, c.OrderRpc),

// logic:
err := l.svcCtx.OrderUtil.CloseExpired(l.ctx)
⚠️ 三个雷区: 别给 util 写 var DB *gorm.DB 全局变量; 别让 util import 本项目的 svc 包(极易循环依赖),用「接口在 util / 实现在 svc」化解; 如果一个工具函数到处都要 svc 才跑得起来,说明它根本不是「纯 util」,该挪到 service / logic 层。
📌 决策口诀:纯计算 → 直接函数;要一个依赖 → 传那个依赖(或接口);要一组依赖 → struct service 注入 svc;要的是「这次请求是谁」 → 用 ctx,别碰 svc。

三个问题的答案,一句话版

你的疑问答案
所有上下文依赖都存进 svc 吗? 否。「进程级共享依赖」才进 svc;「请求级数据」走 ctx;「无状态函数/常量」谁都不进。svc 只接其中一格。
svc 里到底装了啥? 进程启动时建一份、全局复用的有状态依赖:DB、Redis、RPC 客户端、httpc、MQ、缓存、业务 service。判断标准:是不是整进程只建一份、所有请求共享。
svc 是不是一直传递、一直传递? 是传递,但不是重建。main 建唯一实例,靠指针经 handler → logic 一级级传引用,零拷贝、不会多份连接。
util/tool 想用 svc 咋办? 三选一:A 传窄依赖参数B util 定义接口、svc 满足(防雷循环依赖);C 升级成 struct service 注入 svc。别在 util 写全局变量,需要 ctx 数据的也别塞 svc。

终极一句话:svc 不是「全局变量的平替」,而是「进程级依赖的显式装配中心」。它管的是「建一次、大家共用」的连接;管不了「每次请求不一样」的上下文(那是 ctx 的活),也管不了「本来就没状态」的纯逻辑(那是普通函数的活)。util 要用,就靠 logic 把依赖传下去,而不是自己偷偷挂全局。