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

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

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

Начать курс

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

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

Почему рефакторинг legacy кода на Go сложен?

Legacy код на Go часто имеет характерные проблемы:

  • Глобальные переменные — усложняют тестирование и параллельное выполнение.
  • Монолитные функции — функции длиной в сотни строк, которые делают всё сразу.
  • Отсутствие интерфейсов — жёсткая привязка к конкретным реализациям.
  • Нет тестов — рефакторинг без тестов похож на хождение по минному полю.

Но Go предоставляет мощные инструменты для постепенного улучшения кода. Главное — двигаться маленькими шагами, сохраняя работоспособность системы на каждом этапе.

Шаг 1: Подготовка и тестирование

Прежде чем менять код, необходимо создать «сеть безопасности» — тесты. Даже если код не тестировался, вы можете написать характеризующие тесты (characterization tests). Они фиксируют текущее поведение системы, чтобы вы могли убедиться, что после рефакторинга ничего не сломалось.

package main

import "testing"

func TestCalculateTotal(t *testing.T) {
    // Предположим, у нас есть legacy функция CalculateTotal
    result := CalculateTotal(100, 20)
    expected := 120.0

    if result != expected {
        t.Errorf("CalculateTotal(100, 20) = %f; want %f", result, expected)
    }
}

Запустите тесты и убедитесь, что они проходят. Теперь у вас есть база для безопасных изменений.

Шаг 2: Выделение интерфейсов

Одна из главных проблем legacy кода на Go — жёсткая связь между компонентами. Решение — выделение интерфейсов. Это позволяет заменить реальные зависимости на моки при тестировании, а также упрощает замену реализации в будущем.

Рассмотрим пример. Допустим, у нас есть функция, которая сохраняет данные в базу данных напрямую:

package main

import "database/sql"

type User struct {
    ID   int
    Name string
}

func SaveUser(db *sql.DB, user User) error {
    _, err := db.Exec("INSERT INTO users (name) VALUES ($1)", user.Name)
    return err
}

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

package main

// UserStorage определяет контракт для хранения пользователей
type UserStorage interface {
    Save(user User) error
}

type UserSaver struct {
    storage UserStorage
}

func NewUserSaver(storage UserStorage) *UserSaver {
    return &UserSaver{storage: storage}
}

func (s *UserSaver) SaveUser(user User) error {
    return s.storage.Save(user)
}

Теперь мы можем создать мок для тестирования:

package main

import "testing"

type mockStorage struct{}

func (m *mockStorage) Save(user User) error {
    return nil // всегда успешно
}

func TestUserSaver_SaveUser(t *testing.T) {
    saver := NewUserSaver(&mockStorage{})
    err := saver.SaveUser(User{ID: 1, Name: "Alice"})
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
}

Шаг 3: Инъекция зависимостей

После выделения интерфейсов следующим шагом является инъекция зависимостей (Dependency Injection, DI). Вместо того чтобы создавать зависимости внутри функции (например, открывать соединение с БД), мы передаём их извне.

Рассмотрим типичный legacy код:

package main

import "net/http"

func GetUserHandler(w http.ResponseWriter, r *http.Request) {
    db := openDB() // жёсткая зависимость
    user := fetchUser(db, r.URL.Query().Get("id"))
    // ... обработка
}

func openDB() *sql.DB {
    // открытие соединения
    return nil
}

После рефакторинга:

package main

import (
    "net/http"
    "database/sql"
)

type UserHandler struct {
    db *sql.DB
}

func NewUserHandler(db *sql.DB) *UserHandler {
    return &UserHandler{db: db}
}

func (h *UserHandler) GetUser(w http.ResponseWriter, r *http.Request) {
    user := fetchUser(h.db, r.URL.Query().Get("id"))
    // ... обработка
}

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

Шаг 4: Избавление от глобальных переменных

Глобальные переменные — злейший враг тестирования и параллелизма. В Go их часто используют для конфигурации, логгеров или пулов соединений. Замените их на явную передачу через структуры или контекст.

Плохой пример:

package main

var globalLogger *log.Logger

func init() {
    globalLogger = log.New(os.Stdout, "APP: ", log.LstdFlags)
}

func Process(data string) {
    globalLogger.Println("Processing:", data)
    // ...
}

Хороший пример:

package main

import "log"

type Processor struct {
    logger *log.Logger
}

func NewProcessor(logger *log.Logger) *Processor {
    return &Processor{logger: logger}
}

func (p *Processor) Process(data string) {
    p.logger.Println("Processing:", data)
    // ...
}

Теперь вы можете передать любой логгер (например, в тестах — log.New(io.Discard, "", 0)).

Заключение

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