单一职责原则是为降低修改成本而设的实际防线:函数只做一件事且可准确命名,包按“同因变更”划分,接口小而具体、由调用方定义。拆分后各组件解耦,测试独立,变更影响可控。

Go 里“单一职责”不是教条,而是为减少改一处崩一片而设的实际防线。它直接决定你改 RegisterUser 时会不会顺手把邮件模板、日志字段、数据库事务全翻出来修。
函数层面:一个函数只做一件事,且这件事要能被准确命名
常见错误是把校验、保存、发邮件、打日志全塞进一个 RegisterUser 函数里。一旦邮箱服务下线或日志格式调整,这个函数就得动,哪怕用户注册逻辑本身没变。
- 拆法很简单:用
ValidateUser、SaveUser、SendWelcomeEmail、LogRegistration四个独立函数替代 - 它们之间不耦合——
SaveUser不依赖SendWelcomeEmail的返回值,也不调用它 - 参数尽量窄:
ValidateUser只收username和password,别传整个*http.Request或context.Context(除非真需要) - 测试时能单独覆盖每条路径,比如只测
ValidateUser对弱密码的拒绝,不用 mock 邮件客户端
包层面:按“谁会一起变”来划界,不是按“看起来有关”
很多人误以为“用户相关代码都该在 user 包里”,结果把 User 结构体、HTTP handler、数据库 query、Redis 缓存逻辑全堆进去。结果缓存策略一改,整个包都要 retest。
Go 配置库,使用 spf13/viper — 分层优先级(flag > env >file > KV > default),提供 BindPFlag/BindPFlags、SetEnvPrefix + SetEnvKeyReplace 等功能。
- 真正该放一起的,是那些“因同一原因变更”的东西。比如所有数据库操作(
CreateUser、FindUserByID、UpdateUserStatus)才属于同一个变化原因——DB schema 或 driver 升级 - 所以更合理的结构是:
model.User(纯数据)、repository.UserRepo(DB 操作)、service.UserService(业务编排)、handler.UserHandler(HTTP 绑定) - 注意
repository包不该 importhandler,反之亦然;循环依赖是职责混杂最直接的信号 - 如果某个包同时 import
database/sql和net/http,大概率它已经超载了
接口定义:小接口优于大接口,组合优于继承
Go 没有继承,但有人写个 UserService 接口,塞进 12 个方法,然后所有实现都得实现全部——哪怕只用其中 2 个。这违反 SRP,也违背 Go 的接口哲学。
- 按使用方需要定义接口。比如邮件发送模块只关心
Send(to, subject, body string) error,那就定义Mailer接口,别硬塞进UserService - 让结构体通过组合实现能力:一个
user.Service可以持有mailer.Mailer和repo.UserRepo字段,而不是自己实现所有行为 - 接口名体现职责:
Storer(存)、Finder(查)、Notifier(通知),比Manager或Handler更精确 - 接口定义放在调用方包里(如
service包定义mailer.Mailer接口),而非实现方(email包),避免实现绑架契约
重构时怎么判断是否拆对了?看这三个信号
拆包不是为了整齐,而是为了降低修改成本。以下信号说明你正在接近合理边界:
-
go list -f '{{.Deps}}' ./pkg输出里没有本不该出现的包,比如handler包依赖了email/template或crypto/bcrypt - 运行
go test ./... -run=^TestSaveUser$能秒过,且不启动 HTTP server、不连 DB、不发真实邮件 - 某次 PR 只改了缓存逻辑,
git diff只出现在cache/目录,没碰model/或handler/ - 新人看
service/包,能快速说出“这里只协调,不落地;具体落地在 repo/ 和 mailer/”
最容易被忽略的是:职责划分不是静态的。当支付流程从“同步扣款”变成“先冻结再异步结算”,payment.Service 就该拆出 Freezer 和 Settler 两个新包——不是因为代码多了,而是变化原因已分裂。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










