不要像写Java一样写Go

不要像写Java一样写Go
每一层抽象都是一笔税而你每天都在为它付利息。我最近在 review 一个支付系统的代码。为了追踪一笔订单的状态变更我跳了七个文件handler→service→manager→repository→repo_impl→dao→sqlc.Querier。等终于看到那行UPDATE orders SET status$1 WHERE id$2时我已经忘记了为什么要查这笔订单。那个manager层什么都没做。它只是调用service而service也只是调用repositoryrepository调用daodao调用sqlc生成的Querier接口。七层跳转一行 SQL。这让我开始思考一个问题我们到底在保护什么你不是在写 JavaGo 的诞生背景很特殊Google 内部庞大的 C 和 Java 代码库让编译时间和认知负担都达到了难以忍受的程度。Rob Pike 说过一句话大意是“我们希望语言能让程序员感觉自己是聪明的而不是让语言本身显得很聪明。”这句话的潜台词是Go 是为人的阅读设计的不是为机器的扩展设计的。但过去几年我看到大量 Go 代码库在重蹈 Java 的覆辙// 随处可见的“防御性”代码 type UserRepository interface { ... } // 只有一个实现 type UserService struct { repo UserRepository } type UserHandler struct { svc *UserService }这种模式在 Java 里有其合理性——Spring 的依赖注入、AOP 代理、单元测试的 mock 框架使得为每个类定义一个接口成为一种技术需求。但在 Go 里你不需要 Spring不需要字节码增强不需要在没有任何消费者的情况下定义接口。我曾经维护过一个创业公司的 Go 服务上线一年多UserRepository接口的唯一实现始终是postgresRepo。团队花在保持接口和实现同步上的时间比他们省下来的“未来可能更换数据库”的时间多得多。关键是那个数据库从来没有换过未来也不会换——因为更换数据库根本不是技术决策而是产品决策。接口属于消费者而非生产者这是 Go 标准库教给我们的一课但很多人学反了。看看io.Reader// 标准库是这样定义接口的——在 consumer 侧funcCopy(dst Writer,src Reader)(writtenint64,errerror)io.Reader不是定义在os包里的也不是定义在net包里的。它定义在io包——一个消费这些接口的包。标准库的惯例是接口由使用方定义而非实现方。这意味着// 在 user_service.go 中定义你需要的东西typeuserGetterinterface{GetUser(ctx context.Context,emailstring)(*User,error)}typeServicestruct{users userGetter}而不是在repository/user.go中定义一个大而全的UserRepository接口然后让所有消费者去依赖它。这样做的好处是接口粒度由消费者控制——我只声明我需要的那个方法不需要在数据层维护一个“以防万一”的接口依赖方向更清晰——service 包不依赖 repository 包而是依赖一个行为契约Go 的结构类型系统structural typing使得这个模式自然流畅你的*postgresStore不需要显式声明implements userGetter它有那个方法就行。sqlc 让传统 Repository 彻底多余几年前我用 GORM 的时候还会为每个 model 写一个 Repository 接口——因为 ORM 的查询构造器需要一层封装来隔离业务逻辑和数据库细节。但现在我用 sqlc。sqlc 生成的代码已经包含了一个完整的类型安全查询层// sqlc 自动生成的代码typeQuerierinterface{GetUserByEmail(ctx context.Context,emailstring)(User,error)GetUserByID(ctx context.Context,id uuid.UUID)(User,error)ListActiveUsers(ctx context.Context)([]User,error)}这个接口是从 SQL 生成的它是真实的来源source of truth。你在它外面再包一层接口等于创建了一个需要手工维护的虚假来源。实际项目中我见过这样的代码// 别这么写 —— 这只是为了好看typeUserRepositoryinterface{GetByID(ctx context.Context,idstring)(*User,error)GetByEmail(ctx context.Context,emailstring)(*User,error)Create(ctx context.Context,u*User)errorUpdate(ctx context.Context,u*User)errorDelete(ctx context.Context,idstring)error}typeuserRepositorystruct{q*sqlc.Queries}// 每个方法都是单行转发func(r*userRepository)GetByID(ctx context.Context,idstring)(*User,error){returnr.q.GetUserByID(ctx,id)}这 50 行代码存在的唯一理由是“万一以后换数据库”。但我说句实话如果你用 sqlc更换数据库意味着重写所有 SQL 文件重跑代码生成。那个 Repository 接口帮不了你因为 SQL 本身变了。放弃吧。直接让 handler 依赖*sqlc.Queries或sqlc.Querier事情会变得无比清晰。什么时候抽象才是合理的我从来不是“不要抽象”的原教旨主义者。抽象有它该存在的地方只是它应该回答一个具体的问题而不是表达一种普遍的焦虑。我最近在写一个多租户的文件上传服务其中Store结构体长这样typeStorestruct{*sqlc.Queries db*pgxpool.Pool}// 事务包装器 —— 解决真实存在的问题func(s*Store)InTx(ctx context.Context,fnfunc(*sqlc.Queries)error)error{tx,err:s.db.Begin(ctx)iferr!nil{returnerr}defertx.Rollback(ctx)q:s.Queries.WithTx(tx)iferr:fn(q);err!nil{returnerr}returntx.Commit(ctx)}这个InTx方法解决的是真实的、已经发生的问题文件记录更新和存储配额扣减需要在同一个事务中完成否则会出现配额不一致。我不需要为“未来的存储后端”做准备我只需要解决今天的需求。还有另一个例子——缓存层typeCachedUserGetterstruct{underlying userGetter cache*bigcache.BigCache}func(c*CachedUserGetter)GetUser(ctx context.Context,emailstring)(*User,error){ifcached,ok:c.cache.Get(email);ok{returncached.(*User),nil}u,err:c.underlying.GetUser(ctx,email)iferr!nil{returnnil,err}c.cache.Set(email,u)returnu,nil}这个抽象存在是因为我们遇到了真实的性能问题——每天几百万次用户查询打到数据库上。缓存不是一个“万一有用”的装饰它是实际问题的直接解决方案。度量标准很简单在每一次 code review 中当看到一个新的层、一个新的接口时问三个问题现在有两个以上实现吗——不是“将来”是现在。这一层改变了行为还是仅仅转发调用如果删掉这一层今天会出什么具体的 bug如果答案指向某种模糊的“未来可能需要”删掉它。我在一个项目中做过实验删掉了一个“万能”的Service层每个方法都是 repository 的转发减少了约 400 行代码消除了 6 个 mock修复一个 bug 的时间从“定位 4 个文件”变成了“打开 1 个文件”。团队的感受是“代码好像变简单了但功能一个没少。”——这就是最好的证明。不要用 Java 的方式写 GoGo 不是 Java。没有注解没有继承没有 DI 容器。这不是缺陷这是一个设计声明代码应该线性、直接、可读。每一层额外抽象都是你对未来做的一个赌注。而根据我的经验在大部分后端服务中这个赌注赢不了。赢不了的原因是变化从来不发生在你预期的边界上。你以为你要换数据库实际上你改的是缓存策略。你以为你要抽象支付渠道实际上你真正需要的是处理不同的 webhook 格式。当变化真的来临时你的漂亮接口往往不合用——因为它假设了错误的抽象边界。到那时你反而被自己建的“灵活性”困住了。所以保持简单。让代码直白让数据流动可见只在确定的痛点上添加抽象。你的同事和未来的自己会感谢你。