Введение: Почему legacy-код на Go требует особого подхода
Go (Golang) славится своей простотой и производительностью, но даже на нём со временем появляется legacy-код: монолитные пакеты, глобальные переменные, отсутствие интерфейсов и тестов. Рефакторинг такого кода — не просто "переписать красиво", а стратегическая задача, от которой зависит стабильность и скорость разработки.
В этой статье мы рассмотрим продвинутые техники рефакторинга legacy-кода на Go: от выделения модулей до безопасного внедрения интерфейсов. Вы узнаете, как превратить "спагетти-код" в поддерживаемую архитектуру без остановки продакшена.
1. Диагностика и картографирование legacy-кода
Перед рефакторингом необходимо понять текущую структуру. В Go для этого есть встроенные инструменты и подходы:
- Анализ зависимостей: используйте
go mod graphиgo list -jsonдля визуализации связей между пакетами. - Поиск глобальных переменных:
grep -r "var " *.go— часто именно они становятся корнем проблем. - Определение "запахов кода": длинные функции, дублирование, отсутствие обработки ошибок.
// Пример: поиск всех глобальных переменных в проекте
package main
import (
"fmt"
"go/parser"
"go/token"
"os"
"path/filepath"
)
func findGlobals(root string) {
filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
if err != nil || info.IsDir() || filepath.Ext(path) != ".go" {
return nil
}
fset := token.NewFileSet()
f, _ := parser.ParseFile(fset, path, nil, parser.AllErrors)
for _, decl := range f.Decls {
if genDecl, ok := decl.(*ast.GenDecl); ok && genDecl.Tok == token.VAR {
fmt.Printf("Global var in %s: %v\n", path, genDecl.Specs)
}
}
return nil
})
}
Важно: перед рефакторингом создайте полный снимок тестов (даже если их нет — напишите хотя бы smoke-тесты для критических путей).
2. Стратегическое выделение модулей: от монолита к пакетам
Legacy-код часто содержит один гигантский пакет main или utils. Продвинутый рефакторинг начинается с гранулярного выделения:
- Выделение по ответственности: разделите код на слои (handler, service, repository).
- Создание внутренних пакетов: используйте
internal/для скрытия реализации. - Интерфейсы как контракты: не создавайте интерфейсы заранее — выделяйте их из конкретных реализаций.
// До рефакторинга: всё в одном файле
package main
type User struct {
ID int
Name string
}
func SaveUser(db *sql.DB, u User) error {
_, err := db.Exec("INSERT INTO users ...", u.ID, u.Name)
return err
}
// После рефакторинга: разделение на пакеты
// internal/repository/user.go
package repository
type User struct {
ID int
Name string
}
type UserRepository struct {
db *sql.DB
}
func (r *UserRepository) Save(u User) error {
_, err := r.db.Exec("INSERT INTO users ...", u.ID, u.Name)
return err
}
Совет: используйте паттерн "Strangler Fig" — постепенно заменяйте старые функции новыми, не удаляя старый код сразу.
3. Избавление от глобальных состояний и внедрение зависимостей
Глобальные переменные в Go — основная причина нестабильности и невозможности тестирования. Продвинутая техника — инверсия управления (IoC) через конструкторы и интерфейсы.
// Проблема: глобальный логгер и конфиг
var logger *log.Logger
var config *Config
func Init() {
logger = log.New(os.Stdout, "", log.LstdFlags)
config = loadConfig()
}
// Решение: внедрение зависимостей через структуру
type App struct {
Logger *log.Logger
Config *Config
DB *sql.DB
}
func NewApp(cfg *Config) *App {
return &App{
Logger: log.New(os.Stdout, "", log.LstdFlags),
Config: cfg,
DB: initDB(cfg),
}
}
func (a *App) Run() {
a.Logger.Println("App started with config:", a.Config)
}
Продвинутый приём: используйте функциональные опции (functional options) для гибкой инициализации без ломания обратной совместимости:
type Option func(*App)
func WithLogger(l *log.Logger) Option {
return func(a *App) {
a.Logger = l
}
}
func NewApp(cfg *Config, opts ...Option) *App {
a := &App{Config: cfg, Logger: log.Default()}
for _, opt := range opts {
opt(a)
}
return a
}
4. Рефакторинг с сохранением совместимости: интерфейсы и адаптеры
Часто legacy-код уже используется в других местах. Продвинутый рефакторинг подразумевает:
- Выделение интерфейса из существующей структуры: не пишите интерфейс заранее — просто соберите методы, которые реально используются.
- Паттерн "Адаптер": если старый код возвращает конкретный тип, создайте адаптер для нового интерфейса.
- Тестирование через mock-интерфейсы: используйте
gomockилиtestify/mock.
// Старый код: конкретная структура
type OldService struct {
client *http.Client
}
func (s *OldService) GetData(id string) ([]byte, error) {
resp, err := s.client.Get("https://api.example.com/" + id)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(resp.Body)
}
// Новый интерфейс и адаптер
type DataProvider interface {
Fetch(id string) ([]byte, error)
}
type OldServiceAdapter struct {
old *OldService
}
func (a *OldServiceAdapter) Fetch(id string) ([]byte, error) {
return a.old.GetData(id)
}
// Теперь везде используем