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

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

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

Начать курс

Введение в рефакторинг legacy кода на Go

Работа с унаследованным (legacy) кодом — неизбежная часть жизни любого Go-разработчика. Со временем проекты обрастают техническим долгом: появляются монолитные функции, дублирование кода, отсутствие тестов и обработки ошибок. Рефакторинг legacy кода на Go — это не просто переписывание, а стратегический процесс улучшения архитектуры без изменения внешнего поведения системы.

В этой статье мы разберем основные подходы к рефакторингу Go-проектов, приведем конкретные примеры кода и покажем, как сделать старую кодовую базу чище, безопаснее и производительнее.

Почему legacy код на Go требует особого подхода?

Go известен своей простотой и строгостью, но даже в нем накапливаются проблемы:

  • Отсутствие интерфейсов — жесткая связность компонентов;
  • Игнорирование ошибок — return без проверки err;
  • Глобальные переменные — неявные зависимости;
  • Дублирование логики — копипаста вместо композиции;
  • Длинные функции — более 50 строк с множеством ответственностей.

Рефакторинг без тестов — это как операция без анестезии. Поэтому первый шаг — обеспечить тестовое покрытие критических участков.

Стратегия рефакторинга: от монолита к модулям

1. Выделение интерфейсов и инверсия зависимостей

Начнем с типичного legacy кода, где бизнес-логика жестко привязана к реализации хранилища:

// До рефакторинга
type UserService struct {
    db *sql.DB
}

func (s *UserService) GetUser(id int) (*User, error) {
    row := s.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)
    var u User
    err := row.Scan(&u.ID, &u.Name)
    return &u, err
}

Проблема: такой код невозможно тестировать без реальной базы данных. Решение — ввести интерфейс:

// После рефакторинга
type Storage interface {
    GetUser(id int) (*User, error)
}

type UserService struct {
    storage Storage
}

func NewUserService(storage Storage) *UserService {
    return &UserService{storage: storage}
}

func (s *UserService) GetUser(id int) (*User, error) {
    return s.storage.GetUser(id)
}

Теперь мы можем легко подменить реализацию на mock в тестах.

2. Разделение больших функций (Single Responsibility)

Типичный legacy код — функция, делающая всё сразу:

// До рефакторинга
func processOrder(order Order) error {
    // Валидация
    if order.Amount <= 0 {
        return errors.New("invalid amount")
    }
    // Расчет скидки
    discount := 0.0
    if order.CustomerType == "vip" {
        discount = 0.1
    }
    // Сохранение в БД
    _, err := db.Exec("INSERT INTO orders ...")
    if err != nil {
        return err
    }
    // Отправка email
    return sendEmail(order.Email, "Order confirmed")
}

После рефакторинга мы разбиваем на мелкие, тестируемые функции:

// После рефакторинга
type OrderProcessor struct {
    db      Database
    notifier Notifier
}

func (p *OrderProcessor) Process(order Order) error {
    if err := validateOrder(order); err != nil {
        return err
    }
    order = applyDiscount(order)
    if err := p.db.SaveOrder(order); err != nil {
        return fmt.Errorf("save order: %w", err)
    }
    return p.notifier.SendConfirmation(order.Email)
}

func validateOrder(order Order) error {
    if order.Amount <= 0 {
        return errors.New("amount must be positive")
    }
    return nil
}

func applyDiscount(order Order) Order {
    if order.CustomerType == "vip" {
        order.Amount *= 0.9
    }
    return order
}

3. Замена глобальных переменных на DI (Dependency Injection)

Глобальные переменные — главный враг тестирования. Вот как выглядит legacy код:

// До рефакторинга
var config *Config

func init() {
    config = loadConfig()
}

func HandleRequest(w http.ResponseWriter, r *http.Request) {
    if config.DebugMode {
        log.Println("Debug request")
    }
    // ...
}

Правильный подход — передавать зависимости явно:

// После рефакторинга
type Server struct {
    cfg    *Config
    logger *log.Logger
}

func NewServer(cfg *Config, logger *log.Logger) *Server {
    return &Server{cfg: cfg, logger: logger}
}

func (s *Server) HandleRequest(w http.ResponseWriter, r *http.Request) {
    if s.cfg.DebugMode {
        s.logger.Println("Debug request")
    }
    // ...
}

Работа с ошибками: от игнорирования к оборачиванию

В legacy Go-коде часто встречается такое:

// Плохо: ошибка теряется
func GetData() (*Data, error) {
    val, _ := someFunc()
    return &Data{Value: val}, nil
}

После рефакторинга мы используем оборачивание ошибок (error wrapping):

// Хорошо: контекст ошибки сохраняется
func GetData() (*Data, error) {
    val, err := someFunc()
    if err != nil {
        return nil, fmt.Errorf("get data: %w", err)
    }
    return &Data{Value: val}, nil
}

Это позволяет вызывающему коду использовать errors.Is() и errors.As() для проверки типов ошибок.

Инструменты для рефакторинга Go-кода

Вот список инструментов, которые помогут автоматизировать рефакторинг:

  • gofmt / goimports — форматирование и сортировка импортов;
  • golint / staticcheck — статический анализ и выявление антипаттернов;
  • go vet — поиск подозрительных конструкций;
  • gorename — безопасное переименование символов;
  • gocyclo — расчет цикломатической сложности;
  • GoLand / VS Code — встроенные рефакторинги (extract method, inline).

Также обязательно используйте go

Блоги

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