重构旧模块第一步是接口隔离:将原有函数抽象为接口,旧实现封装为结构体并注入依赖,新旧实现共存且返回一致类型,通过配置或灰度开关切换,禁止修改对外函数签名、error类型及行为契约。

用接口隔离旧实现,不改调用方代码
旧模块被多处直接 import 并调用具体函数或结构体时,重构第一步不是重写,而是把原有行为抽象成接口。比如原 user.GetUserByID 是一个包级函数,先在 user/interface.go 中定义:
type UserFinder interface {
GetUserByID(id int) (*User, error)
}
再新建 user/legacy_impl.go,让老逻辑实现该接口:
type legacyUserFinder struct{}
func (l *legacyUserFinder) GetUserByID(id int) (*User, error) {
return getUserByID(id) // 复用原有函数
}
这样所有调用方只需把参数类型从具体结构体改为接口,不改逻辑、不改返回值、不改错误处理路径。
- 禁止在接口里塞一堆无关方法,只放当前调用方真正需要的那 1–2 个
- 旧实现文件保留原名(如
user_db.go),新文件命名带legacy_或v1_前缀,便于 grep 定位 - 如果旧代码依赖全局变量(如
dbConn),把它作为字段注入到legacyUserFinder结构体中,而非继续用包级变量
增量替换实现,用 feature flag 控制切换
新实现写在 user/pg_impl.go 或 user/grpc_client.go 里,和旧实现并存。启动时通过配置决定用哪个:
var userFinder UserFinder
if config.UseNewUserBackend {
userFinder = &pgUserFinder{conn: pgConn}
} else {
userFinder = &legacyUserFinder{}
}
线上灰度阶段,可基于用户 ID 取模、请求 header、或 Apollo 配置中心动态开关。关键点是:两个实现必须返回完全一致的 *User 和 error 类型,且对 nil、空字符串、时间零值等边界情况行为一致。
- 不要在新实现里偷偷改 error message 格式——下游可能正用
strings.Contains(err.Error(), "not found")做判断 - 新实现的单元测试必须覆盖旧实现已有的全部 case,包括 panic 场景(如有)
- 日志打点保持字段名和层级一致,避免监控告警规则失效
重构期间禁止修改公共函数签名与 error 类型
只要对外暴露的函数还存在,它的参数、返回值、error 类型就必须冻结。例如原函数是:
Go语言(Golang)1.26.0版本提供 Go 官方 Windows amd64 MSI 安装包下载入口,版本号 1.26.0,可用于旧项目维护、兼容性测试和指定版本开发环境配置。
func CreateUser(name string, age int) (int, error)
就不能改成:
func CreateUser(req CreateUserRequest) (CreateUserResponse, error) // ❌ 破坏兼容
哪怕新逻辑内部已用 DTO,对外仍需做一层薄包装:
func CreateUser(name string, age int) (int, error) {
req := CreateUserRequest{Name: name, Age: age}
resp, err := newUserSvc.Create(req)
return resp.ID, err // 保持返回 int + error
}
这是保证“不中断”的硬约束,也是最常被忽略的红线。
- 所有新增错误码必须复用已有
errors.New或自定义 error 类型,不能引入新 error 类型(如ErrUserInvalid)除非旧逻辑也同步支持 - HTTP handler 层若用 Gin,
c.ShouldBindJSON的绑定目标结构体可以换,但 handler 函数签名(func(c *gin.Context))和最终c.JSON返回结构不能变 - go mod replace 指向本地路径时,确保
go test ./...仍能跑通,避免因路径别名导致测试误用旧实现
测试必须覆盖“切换前后行为一致性”
光有单元测试不够,得验证新旧实现对同一输入是否产出相同输出。写一个回归比对测试:
func TestUserFinderConsistency(t *testing.T) {
old := &legacyUserFinder{}
new := &pgUserFinder{...}
for _, id := range []int{1, 42, 999} {
oldUser, oldErr := old.GetUserByID(id)
newUser, newErr := new.GetUserByID(id)
if !equalUser(oldUser, newUser) || !equalError(oldErr, newErr) {
t.Errorf("mismatch for id %d", id)
}
}
}
这种测试不追求覆盖率,只锚定核心路径。上线前跑一次,切到新实现后定期再跑——它比任何文档都更能守住稳定性底线。
- 比对 error 时用
errors.Is和errors.As,别用err.Error()字符串匹配 - 对浮点字段、时间字段、map 键顺序等易变字段,要么忽略,要么在
equalUser里显式标准化 - 这个测试应放在 CI 的 critical job 里,失败即阻断发布
真正的难点不在写新代码,而在识别哪些“看似无害”的改动会悄悄破坏调用方假设——比如改一个日志字段名、加一个 context.WithTimeout、甚至只是把 time.Now().UTC() 换成 time.Now().In(time.UTC),都可能让下游依赖时间戳做幂等判断的服务出错。重构稳不稳,看的不是新代码多漂亮,而是旧契约守得多死。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!










