「是不是所有上下文依赖都不用全局变量、全塞进 svc?」「svc 从上到下一直传递?」「util/tool 想用 svc 咋办?」这篇把这三个连环问题一次讲透。
关键在于区分两件事:进程级共享依赖 和 请求级上下文数据。它们归宿完全不同,svc 只接前者。
你问的「上下文依赖」如果指一次请求里的数据(userId、traceId、租户ID、token),那它不进 svc,它走 context.Context。svc 装的是跨所有请求、进程启动时建一次、全局复用的连接/客户端(DB、Redis、RPC、HTTP)。
一句话:所有「进程启动时建一次、之后所有请求共享复用」的有状态依赖。下面是一份典型的 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 }
const 常量、模板字符串、logx(框架自带、包级无状态)。共性:要么随请求生灭,要么本来无状态。是传递,但不是「每次都重建」——它从头到尾只有一个实例,靠指针从上往下一级级传引用。开销几乎为零。
// ① 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,也不会有多份连接。真正「一直传递」的是指针,不是数据拷贝。
这是真实项目里最高频的困惑:logic 能拿到 svcCtx,可我的工具函数在 internal/util 里,它又没被传进来。三种正解 + 一个反例。
var DB *gorm.DB 然后 init 里赋值——这正是 go-zero 想让你戒掉的写法,别在 util 重蹈覆辙。// 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
// 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
// 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)
var DB *gorm.DB 全局变量;② 别让 util import 本项目的 svc 包(极易循环依赖),用「接口在 util / 实现在 svc」化解;③ 如果一个工具函数到处都要 svc 才跑得起来,说明它根本不是「纯 util」,该挪到 service / logic 层。| 你的疑问 | 答案 |
|---|---|
| 所有上下文依赖都存进 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 把依赖传下去,而不是自己偷偷挂全局。