最近接手了一个老的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. 命名混乱
变量名和函数名各种缩写和拼音,比如usrInfo、ordMgr、chuliDingdan。你需要猜半天才能明白这个变量是干什么的。还有一些变量名没有意义,比如data、temp、result,你根本不知道它存的是什么。
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.Is和errors.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代码都优雅简洁。
评论(0)
暂无评论,快来抢沙发~
评论功能仅对会员开放,请先登录
登录