最近接手了一个老的Go项目,代码写得一言难尽。全局变量满天飞,函数动辄几百行,错误处理全是忽略,命名也是各种缩写和拼音。

我花了两周时间,用Go 1.23+的新特性把这个项目重构了一遍。重构之后,代码量减少了30%,可读性大幅提升,bug也少了很多。

这篇文章我想分享一下这次重构的经验和技巧。从Go 1.23+的新特性到重构的方法论,聊聊如何把烂代码重构成优雅的Go代码。如果你也在维护老的Go项目,或者想学习Go 1.23+的新特性,希望这篇文章能给你一些参考。

先说明一下,这个项目是一个后端服务,大概五万行代码,用的是Go 1.18写的。我把它升级到了Go 1.23,并用上了1.23的新特性。重构过程中没有改变外部接口,只是优化了内部实现。

一、烂代码长什么样

先看看这个项目里的烂代码长什么样,大家可以对照一下自己的项目有没有类似的问题。

1. 超长函数

最典型的问题就是超长函数。一个函数动辄几百行,甚至上千行,做了十几件事情。比如有一个处理订单的函数,里面包含了参数校验、查询用户、查询商品、计算价格、创建订单、扣减库存、发送通知、记录日志,全部揉在一个函数里。

这样的函数,读起来非常痛苦,你需要在几百行代码里来回跳,才能理解它的逻辑。而且因为函数太长,变量作用域很大,很容易出现变量名冲突和逻辑混乱。

2. 全局变量满天飞

项目里定义了大量的全局变量,数据库连接、配置、缓存、计数器,全部都是全局的。任何函数都可以直接访问和修改这些全局变量,导致代码的耦合度非常高。

全局变量的问题是:你无法追踪它在哪里被修改了,也无法在测试中mock它。而且全局变量会导致并发安全问题,多个goroutine同时修改全局变量,就会出现数据竞争。

3. 错误处理全是忽略

项目里大量的错误都被忽略了,用下划线把错误丢掉。比如:

result, _ := someFunction()

这样写的后果是,一旦函数返回错误,程序就会在后续的操作中panic,或者产生不可预期的行为。而且因为错误被忽略了,出了问题很难排查,你根本不知道是哪里出了错。

4. 命名混乱

变量名和函数名各种缩写和拼音,比如usrInfoordMgrchuliDingdan。你需要猜半天才能明白这个变量是干什么的。还有一些变量名没有意义,比如datatempresult,你根本不知道它存的是什么。

Go语言的命名应该简洁但有意义,优先使用完整的英文单词,避免缩写和拼音。好的命名能让代码自文档化,读代码就像读文档一样。

5. 重复代码

项目里有大量的重复代码,同样的逻辑在不同的地方复制粘贴了好几遍。比如错误处理的逻辑、数据库查询的逻辑、HTTP请求的逻辑,都是复制粘贴的。

重复代码的问题是:一旦需要修改,你需要修改好几个地方,很容易漏掉某个地方,导致不一致。而且重复代码会让代码量膨胀,增加维护成本。

6. 没有测试

项目里几乎没有测试代码,所有的功能都是手动测试的。每次修改代码都需要手动验证,非常耗时,而且很容易漏掉一些场景。没有测试的代码,重构的时候心里也没底,不知道改完之后会不会引入新的bug。

二、重构的原则和步骤

重构不是随便改代码,而是有原则和步骤的。我按照以下的原则和步骤来进行重构。

重构的原则

第一,不改变外部行为。重构的目标是改善代码的内部结构,而不是改变外部行为。在重构过程中,函数的输入输出、接口的行为、错误的返回,都应该保持不变。这样才能保证重构不会引入新的bug。

第二,小步快跑。不要一次性重构整个项目,那样风险太大。应该一个函数一个函数地改,一个模块一个模块地改。每改完一部分,就运行测试验证,确保没有问题。小步快跑,风险可控。

第三,每一步都可回退。重构过程中,每一步都应该是可回退的。用Git管理代码,每完成一个小的重构就提交一次,这样如果发现问题可以快速回退到上一个版本。

第四,先补测试再重构。对于没有测试的代码,先补测试再重构。有了测试作为保障,重构的时候心里才有底,改完之后跑一遍测试,就能知道有没有改坏。

第五,持续重构。重构不是一次性的工作,而是持续的过程。在日常开发中,每次修改代码的时候,都应该顺便重构一下周围的烂代码。这样代码质量会持续提升,不会积累太多技术债务。

重构的步骤

第一步,理解代码。在重构之前,先理解代码的逻辑和行为。读代码,画流程图,写注释,搞清楚每一段代码在做什么。只有理解了代码,才能安全地重构。

第二步,补测试。为要重构的代码补充测试用例,覆盖正常情况、边界情况、异常情况。测试是重构的安全网,有了测试才能放心地改。

第三步,提取函数。把超长函数拆分成多个小函数,每个函数只做一件事情。提取函数的时候,给函数起一个有意义的名字,让函数名能描述它的行为。

第四步,消除全局变量。把全局变量改成局部变量,或者通过参数传递,或者封装到结构体中。消除全局变量能降低代码的耦合度,提高可测试性和并发安全性。

第五步,统一错误处理。把所有忽略的错误都补上错误处理,统一错误处理的模式。用Go 1.23+的新特性来简化错误处理。

第六步,消除重复代码。把重复的逻辑提取成公共函数或者公共方法,消除重复代码。用泛型来处理类型不同但逻辑相同的重复代码。

第七步,优化命名。把缩写和拼音的命名改成有意义的英文命名,提高代码的可读性。

第八步,运行测试。重构完成后,运行所有测试,确保重构没有改变外部行为。如果测试不通过,回退或者修复问题。

三、用Go 1.23+新特性重构

Go 1.23引入了一些很实用的新特性,用这些新特性能让代码更简洁、更优雅。下面我分享几个在重构中用得比较多的新特性。

1. range over func(迭代器)

Go 1.23最重磅的新特性就是range over func,也就是可以用range遍历函数类型的迭代器。这个特性让自定义集合类型的遍历变得非常简洁。

在重构之前,项目里有很多自定义的集合类型,每个类型都实现了自己的遍历方法,用法很不统一。比如:

// 重构前
list := NewUserList()
for i := 0; i < list.Len(); i++ {
    user := list.Get(i)
    // 处理user
}

重构之后,用range over func实现迭代器:

// 重构后
func (l *UserList) Iter() func(func(int, *User) bool) {
    return func(yield func(int, *User) bool) {
        for i, u := range l.users {
            if !yield(i, u) {
                return
            }
        }
    }
}

// 使用
for i, user := range list.Iter() {
    // 处理user
}

这样写的好处是:遍历的方式统一了,不管是什么集合类型,都可以用range来遍历。而且迭代器是惰性的,只有在需要的时候才会生成下一个元素,性能更好。

在重构中,我把项目里所有的自定义集合类型都改成了用range over func实现迭代器,代码简洁了很多,遍历的方式也统一了。

2. 泛型的改进

Go 1.18引入了泛型,1.23对泛型做了一些改进,比如类型推断更智能了,泛型函数的使用更方便了。

在重构之前,项目里有很多类型不同但逻辑相同的代码,比如排序函数、过滤函数、映射函数,每个类型都写了一遍。重构之后,用泛型把这些函数合并成一个:

// 重构前
func FilterUsers(users []*User, pred func(*User) bool) []*User {
    var result []*User
    for _, u := range users {
        if pred(u) {
            result = append(result, u)
        }
    }
    return result
}

func FilterOrders(orders []*Order, pred func(*Order) bool) []*Order {
    // 同样的逻辑,只是类型不同
}

// 重构后
func Filter[T any](items []T, pred func(T) bool) []T {
    var result []T
    for _, item := range items {
        if pred(item) {
            result = append(result, item)
        }
    }
    return result
}

// 使用
users := Filter(allUsers, func(u *User) bool { return u.Age > 18 })
orders := Filter(allOrders, func(o *Order) bool { return o.Status == "paid" })

用泛型之后,代码量减少了很多,而且逻辑只需要维护一份,不会出现不同类型的实现不一致的问题。

在重构中,我用泛型提取了很多通用的工具函数,比如Map、Filter、Reduce、Find、Contains等,这些函数在项目中被大量使用,大大减少了重复代码。

3. 错误处理的改进

Go 1.23对错误处理也做了一些改进,比如errors包新增了一些函数,让错误处理更方便。

在重构之前,项目里的错误处理很混乱,有的地方忽略错误,有的地方直接返回原始错误,有的地方包装了错误但没有上下文。重构之后,统一了错误处理的模式:

第一,所有错误都必须处理,不能忽略。用errcheck工具检查,确保没有遗漏的错误。

第二,返回错误的时候,用fmt.Errorf加上上下文信息,方便排查问题。比如:

if err != nil {
    return fmt.Errorf("failed to process order %d: %w", orderID, err)
}

第三,用errors.Iserrors.As来判断错误类型,而不是直接比较错误字符串。

第四,用Go 1.23新增的errors.Join来合并多个错误。比如在批量处理的时候,收集所有错误,最后一起返回:

var errs []error
for _, item := range items {
    if err := process(item); err != nil {
        errs = append(errs, err)
    }
}
if len(errs) > 0 {
    return errors.Join(errs...)
}

统一错误处理之后,代码的可维护性大幅提升,出了问题也更容易排查。

4. 结构体标签的改进

Go 1.23对结构体标签也做了一些改进,支持更复杂的标签语法。在重构中,我用这个特性优化了配置解析和JSON序列化的代码。

不过这个特性用得不多,就不展开说了。

四、重构的具体案例

下面分享几个具体的重构案例,让大家更直观地感受从烂代码到优雅代码的变化。

案例一:拆分超长函数

重构前,有一个500多行的函数processOrder,里面做了十几件事情。重构之后,拆分成了多个小函数:

// 重构后
func (s *OrderService) processOrder(req *OrderRequest) (*Order, error) {
    if err := s.validateOrderRequest(req); err != nil {
        return nil, fmt.Errorf("invalid request: %w", err)
    }
    
    user, err := s.userRepo.GetByID(req.UserID)
    if err != nil {
        return nil, fmt.Errorf("get user failed: %w", err)
    }
    
    products, err := s.getProducts(req.Items)
    if err != nil {
        return nil, fmt.Errorf("get products failed: %w", err)
    }
    
    totalPrice := s.calculateTotalPrice(products, req.Items)
    
    order, err := s.createOrder(user, products, totalPrice, req)
    if err != nil {
        return nil, fmt.Errorf("create order failed: %w", err)
    }
    
    if err := s.deductInventory(products, req.Items); err != nil {
        return nil, fmt.Errorf("deduct inventory failed: %w", err)
    }
    
    if err := s.notifyUser(user, order); err != nil {
        return nil, fmt.Errorf("notify user failed: %w", err)
    }
    
    return order, nil
}

拆分之后,主函数的逻辑非常清晰,一眼就能看出处理订单的流程。每个小函数只做一件事情,代码也更容易测试和维护。

案例二:消除全局变量

重构前,数据库连接是全局变量:

// 重构前
var db *sql.DB

func InitDB() {
    db, _ = sql.Open("mysql", "user:password@tcp(localhost:3306)/db")
}

func GetUser(id int) (*User, error) {
    row := db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    // ...
}

重构后,把数据库连接封装到Repository结构体中:

// 重构后
type UserRepository struct {
    db *sql.DB
}

func NewUserRepository(db *sql.DB) *UserRepository {
    return &UserRepository{db: db}
}

func (r *UserRepository) GetByID(id int) (*User, error) {
    row := r.db.QueryRow("SELECT * FROM users WHERE id = ?", id)
    // ...
}

消除全局变量之后,代码的耦合度降低了,每个Repository只依赖自己需要的资源。而且测试的时候可以很方便地mock数据库连接,不需要依赖全局状态。

案例三:用泛型消除重复代码

重构前,项目里有多个类型的分页查询函数,每个类型都写了一遍:

// 重构前
func PaginateUsers(users []*User, page, pageSize int) ([]*User, int) {
    total := len(users)
    start := (page - 1) * pageSize
    end := start + pageSize
    if start > total {
        return []*User{}, total
    }
    if end > total {
        end = total
    }
    return users[start:end], total
}

func PaginateOrders(orders []*Order, page, pageSize int) ([]*Order, int) {
    // 完全一样的逻辑,只是类型不同
}

重构后,用泛型合并成一个函数:

// 重构后
func Paginate[T any](items []T, page, pageSize int) ([]T, int) {
    total := len(items)
    start := (page - 1) * pageSize
    end := start + pageSize
    if start > total {
        return []T{}, total
    }
    if end > total {
        end = total
    }
    return items[start:end], total
}

// 使用
users, total := Paginate(allUsers, page, pageSize)
orders, total := Paginate(allOrders, page, pageSize)

用泛型之后,重复代码消除了,逻辑只需要维护一份。而且类型是安全的,编译器会检查类型是否匹配。

案例四:用range over func简化遍历

重构前,自定义的链表类型需要手动维护游标来遍历:

// 重构前
type LinkedList struct {
    head *Node
}

type Node struct {
    value int
    next  *Node
}

func (l *LinkedList) Get(i int) (int, bool) {
    current := l.head
    for j := 0; j < i && current != nil; j++ {
        current = current.next
    }
    if current == nil {
        return 0, false
    }
    return current.value, true
}

// 使用
list := &LinkedList{}
for i := 0; ; i++ {
    val, ok := list.Get(i)
    if !ok {
        break
    }
    // 处理val
}

重构后,用range over func实现迭代器:

// 重构后
func (l *LinkedList) Iter() func(func(int) bool) {
    return func(yield func(int) bool) {
        current := l.head
        for current != nil {
            if !yield(current.value) {
                return
            }
            current = current.next
        }
    }
}

// 使用
for val := range list.Iter() {
    // 处理val
}

用range over func之后,遍历代码简洁了很多,而且性能也更好,因为不需要每次都从头遍历到第i个元素。

五、重构的效果和收获

重构完成之后,项目的代码质量有了很大的提升。

第一,代码量减少了30%。通过消除重复代码、提取公共函数、用泛型合并类型不同的逻辑,代码量从五万行减少到了三万五千行左右。

第二,可读性大幅提升。通过拆分超长函数、优化命名、统一代码风格,代码的可读性好了很多。新人接手项目,理解代码的时间从原来的一周缩短到了两天。

第三,bug减少了。通过补测试、统一错误处理、消除全局变量,代码的bug少了很多。重构后一个月的bug数量,比重构前一个月减少了60%。

第四,开发效率提升了。代码质量提升之后,添加新功能和修改现有功能的速度都快了很多。团队的开发效率提升了大约40%。

第五,团队的技术水平提升了。在重构的过程中,团队成员学习了Go 1.23+的新特性,掌握了重构的方法论,技术水平都有了提升。

除了这些具体的效果,我个人也有很多收获:

第一,深刻理解了Go语言的设计哲学。Go语言追求简洁、清晰、可读,通过这次重构,我对Go的设计哲学有了更深的理解。

第二,掌握了重构的方法论。重构不是随便改代码,而是有原则、有步骤、有技巧的。通过这次重构,我掌握了一套系统的重构方法。

第三,学会了用新特性解决老问题。Go 1.23+的新特性不是花架子,而是能真正解决实际问题的。通过这次重构,我学会了在合适的场景使用新特性,让代码更优雅。

六、重构的注意事项

最后,分享一些重构过程中的注意事项,避免大家踩坑。

第一,不要在重构的同时添加新功能。重构和新功能开发应该分开进行。如果在重构的同时添加新功能,一旦出了问题,你不知道是重构导致的还是新功能导致的,排查起来很困难。

第二,不要追求完美。重构是一个持续的过程,不要指望一次重构就能把所有问题都解决。先解决最严重的问题,其他的问题在后续的迭代中逐步优化。

第三,不要过度设计。重构的时候,不要为了"优雅"而引入不必要的抽象和设计模式。简单清晰的代码比过度设计的代码更好维护。

第四,要和团队沟通。重构会影响整个团队的工作,在重构之前要和团队成员沟通,达成共识。重构过程中要及时同步进度,让大家了解重构的进展。

第五,要关注性能。重构的时候,要注意不要引入性能问题。特别是用泛型和迭代器的时候,要注意性能开销。重构完成后,要做性能测试,确保性能没有下降。

第六,要保留历史记录。用Git管理重构过程,每完成一个小的重构就提交一次,写清楚提交信息。这样如果后续发现问题,可以追溯是哪次重构引入的。

写在最后

代码重构是一项很有价值的工作。虽然重构的过程可能很痛苦,需要读烂代码、补测试、改bug,但重构完成之后,你会收获一个更优雅、更易维护的代码库,团队的开发效率也会提升。

Go 1.23+的新特性为代码重构提供了很多好用的工具。range over func让自定义集合的遍历更简洁,泛型让消除重复代码更方便,错误处理的改进让错误处理更统一。用这些新特性能让你的Go代码更优雅。

如果你也在维护老的Go项目,不妨花点时间重构一下。你会发现,把烂代码重构成优雅代码的过程,也是一种享受。

最后,用一句话来总结:"代码是写给人看的,只是顺便能在机器上运行。" 优雅的代码,不仅能在机器上运行,更能让人读懂、维护和享受。

愿你的每一行Go代码都优雅简洁。