函数职责必须单一且边界清晰,输入输出和副作用需明确,依赖应显式注入,错误要分层处理,接口按需定义、小而精,避免过早抽象。

函数职责必须单一,且边界清晰
Go 语言没有类和继承,高内聚主要靠函数职责收敛来实现。一个函数只做一件事,而且这件事的输入、输出、副作用都得明确——比如 ParseJSON 只负责反序列化,不负责读文件或处理错误重试。
常见错误是把“读配置 → 解析 → 校验 → 赋默认值”全塞进一个函数里。结果一改校验逻辑就得测全部,加个新格式(如 TOML)还得改这个函数。
- 输入尽量用具体参数,避免传入
*Config这种大结构体;需要多个字段就显式传timeout, retries, endpoint - 返回值优先用值类型或小结构体,别返回
map[string]interface{}这类模糊类型 - 副作用(如写日志、发 HTTP)尽量抽成回调函数参数,例如:
func Process(data []byte, onErr func(error)) error
依赖要显式注入,别藏在全局或 init 里
低耦合的关键不是“不用依赖”,而是“依赖谁、怎么用,一眼能看懂”。Go 里最常踩的坑是函数内部直接调用 http.Get、database/sql.Open 或引用包级变量(如 log.Default()),导致无法 mock、难以单元测试。
正确做法是把依赖作为参数传入,哪怕多写几行:
立即学习“go语言免费学习笔记(深入)”;
type HTTPClient interface {
Do(*http.Request) (*http.Response, error)
}
func FetchUser(client HTTPClient, id string) (User, error) { ... }
- 接口定义越小越好,按需定义(如只要
Get(url string) ([]byte, error)就别硬套HTTPClient) - 数据库操作别传
*sql.DB,传interface{ QueryRow(...), Exec(...) }更轻量 - 避免在函数里 new 依赖(如
newRedisClient()),除非是纯构造逻辑且无外部状态
错误处理要分层,别让业务逻辑被 if err != nil 淹没
内聚低的典型表现是:一个函数里混着领域判断(如 “余额不足”)、基础设施错误(如 “Redis 连接超时”)、协议错误(如 “JSON 解析失败”),全都用 if err != nil 统一处理,结果加个新错误类型就得改所有地方。
使用 uber-go/dig 在 Golang 中实现依赖注入——基于反射容器,提供 Provide/Invoke、dig.In/dig.Out 参数与结果对象,支持命名依赖等
应该按错误语义分层包装:
- 底层错误(I/O、网络)保留原样或用
fmt.Errorf("failed to fetch from cache: %w", err)包裹 - 领域错误用自定义类型,比如
var ErrInsufficientBalance = errors.New("insufficient balance") - 对外暴露的错误,用
errors.Is(err, ErrInsufficientBalance)判断,而不是字符串匹配
这样上层函数才能干净地写:if errors.Is(err, ErrInsufficientBalance) { return handleChargeFailure() },而不关心底层是 Redis 还是 BoltDB。
别为了“解耦”强行抽象,Go 的接口是用出来的不是设计出来的
很多团队过早定义 UserRepository、NotificationService 接口,结果只有 1 个实现、测试也只用 mock,反而增加调用跳转成本和理解负担。
真实经验是:等出现第二个实现(比如从本地内存缓存换成 Redis 缓存),或者需要隔离测试(比如不想真发邮件),再提取接口。在此之前,直接传函数更轻量:
func Charge(payment Payment, sendReceipt func(Receipt) error) error { ... }
- 函数类型比接口更简单、更易组合,适合 Go 的务实风格
- 接口命名优先用行为(
Reader,Writer,Marshaler),别用名词(UserManager) - 一个函数如果只依赖 2–3 个方法,别为它单独建接口;够用就行
真正难的不是写出接口,而是判断什么时候该拆、什么时候该合——这取决于变化频率和影响范围,不是语法能决定的。

















