1、重构之始:为什么需要重构?Go项目常见的坏味道
说实话,我见过太多Go项目,一开始写得挺清爽,三个月后就成了一团乱麻。这不是代码的问题,是人的问题——嗯,准确说,是缺乏重构意识的问题。
你可能会问:「代码能跑不就行了?重构不是浪费时间吗?」
我当年也这么想。直到有一次,一个线上Bug排查了整整两天,最后发现是一个全局变量在某个协程里被意外修改了。那一刻,我恨不得把键盘吃了。
1.1 重构到底在解决什么问题?
重构不是为了炫技,也不是为了代码好看。说白了,重构是为了降低「认知负载」。
什么叫认知负载?就是你读一段代码时,大脑需要同时记住多少东西。我个人的经验是:一段代码如果超过30行还不能理解它在干什么,那它就该重构了。
Go语言本身很克制,没有继承、没有泛型(早期版本)、没有异常机制。但恰恰因为语言简单,很多人反而容易写出「面条代码」——因为觉得怎么写都能跑。
核心观点:重构不是重写。重构是在不改变外部行为的前提下,改善内部结构。你改完代码,测试用例应该全部通过。
1.2 坏味道一:面条代码(Spaghetti Code)
面条代码长什么样?我举个例子你就明白了。
func handleOrder(order Order) error {
// 验证订单
if order.Amount <= 0 {
return errors.New("invalid amount")
}
// 检查库存
if order.Quantity > getStock(order.ProductID) {
return errors.New("insufficient stock")
}
// 计算折扣
var discount float64
if order.UserLevel == "VIP" {
if order.Amount > 1000 {
discount = 0.2
} else {
discount = 0.1
}
} else if order.UserLevel == "Normal" {
if order.Amount > 500 {
discount = 0.05
}
}
// 计算最终价格
finalPrice := order.Amount * (1 - discount)
// 扣减库存
reduceStock(order.ProductID, order.Quantity)
// 生成订单号
orderNo := generateOrderNo()
// 保存订单
saveOrder(order, orderNo, finalPrice)
// 发送通知
sendNotification(order.UserID, "订单已创建")
return nil
}
这段代码有什么问题?
- 一个函数干了五件事:验证、计算、持久化、通知、库存操作
- 逻辑混杂:折扣计算和订单处理混在一起
- 难以测试:你想单独测试折扣逻辑?得先mock一堆依赖
我在项目中遇到过类似的情况,一个函数从200行膨胀到800行,最后没人敢动它。每次改需求,都是在末尾加一段if-else。这就是典型的「代码沼泽」。
我的建议:一个函数只做一件事。如果函数名里出现了「和」字,比如「验证并保存」,那就该拆分了。
1.3 坏味道二:巨型函数(God Function)
巨型函数是面条代码的升级版。它通常有这些特征:
- 超过100行(Go社区建议不超过30行)
- 嵌套超过3层
- 参数超过5个
- 返回多个不同类型的错误
为什么会有人写出巨型函数?我观察下来,原因无非两个:
- 懒得分层:觉得抽一个函数还要传参数,太麻烦
- 害怕抽象:不知道该怎么拆,拆了怕出Bug
我曾经接手过一个项目,有个函数叫 ProcessAll,里面包含了用户注册、订单处理、日志记录、消息推送……你想想看,这名字起得就有问题。「All」是什么?没人知道。
巨型函数的危害不只是可读性差,更严重的是:它让并发变得危险。Go是并发语言,一个巨型函数里如果混用了共享变量和goroutine,调试起来简直要命。
避坑指南:我曾经在一个巨型函数里发现了一个defer调用,它本应该在函数结束时释放锁,但因为函数中间有个return,导致锁一直没释放。排查了三个小时,最后发现是代码太长,写的人忘了检查所有退出路径。
1.4 坏味道三:全局变量泛滥
Go语言里全局变量是个大坑。很多人从PHP或Python转过来,习惯把配置、数据库连接、日志实例都挂在全局上。
var (
db *sql.DB
logger *log.Logger
config *Config
cache *redis.Client
queue *kafka.Producer
)
func init() {
// 初始化所有全局变量
db = initDB()
logger = initLogger()
config = loadConfig()
// ...
}
这段代码看起来简洁,但问题很大:
| 问题 | 说明 |
|---|---|
| 测试困难 | 你想换一个mock的数据库?得改全局变量,还得小心并发 |
| 隐式依赖 | 函数签名里看不到依赖,但内部用了全局变量 |
| 并发不安全 | 多个goroutine同时读写全局变量,很容易出竞态条件 |
| 初始化顺序 | 全局变量的init顺序不可控,容易引发空指针 |
我个人习惯是:能用参数传就用参数传,能用依赖注入就用依赖注入。全局变量只适合放一些真正「全局不变」的东西,比如程序版本号、编译时间。
一个简单的判断标准:如果这个全局变量在测试时需要被替换,那它就不该是全局的。
1.5 什么时候该开始重构?
不是所有代码都需要重构。我总结了三个信号:
- 信号一:改一个Bug,引出三个新Bug——说明代码耦合度太高
- 信号二:新功能无处安放——只能在现有函数里硬塞
- 信号三:代码审查变成「猜谜游戏」——没人能看懂别人写的逻辑
嗯,如果你遇到以上任意一条,那就是重构的信号了。
但记住:重构之前,先写测试。没有测试的重构,就像蒙眼走钢丝。我吃过这个亏,所以现在每重构一个模块,第一件事就是补测试用例。
一个小技巧:从最痛的地方开始重构。别想着一次把所有坏味道都干掉,那叫重写,不叫重构。每次重构只解决一个问题,改完就跑测试,绿了再改下一个。
好了,这一章我们聊了为什么需要重构,以及Go项目里最常见的三种坏味道。下一章,我会带你看看如何用「提取函数」这个手法,把面条代码拆成可维护的模块。