Рефакторинг legacy кода на Go: продвинутые техники и стратегии

Онлайн-тренажер Python для начинающих

Изучайте Python легко и без перегрузки теорией. Решайте практические задачи с автоматической проверкой, получайте подсказки на русском языке и пишите код прямо в браузере — без необходимости что-либо устанавливать.

Начать курс

Введение: Почему 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)
}

// Теперь везде используем                

Блоги

Рекомендаций по книгам