Введение: почему legacy-код на Go требует рефакторинга?
Go (Golang) славится своей простотой, производительностью и встроенными инструментами для параллелизма. Однако любой проект со временем обрастает "техническим долгом": глобальные переменные, отсутствие интерфейсов, магические числа, нечитаемые функции и отсутствие тестов. Такой код называют legacy. Рефакторинг — это не переписывание всего с нуля, а постепенное улучшение структуры без изменения внешнего поведения. В этой статье мы разберем пошаговый план рефакторинга legacy кода на Go, от анализа до внедрения.
Шаг 1: Диагностика legacy-кода
Прежде чем что-то менять, нужно понять, с чем мы имеем дело. Основные "красные флаги" в Go-проектах:
- Глобальные переменные (
var db *sql.DB) — усложняют тестирование и параллельную работу. - Функции-монстры (более 50-100 строк) — делают код нечитаемым.
- Отсутствие интерфейсов — жесткая связь с конкретными реализациями.
- Магические строки/числа — затрудняют поиск и изменение.
- Отсутствие тестов — рефакторинг без тестов — это хождение по тонкому льду.
Инструменты для анализа: используйте go vet и staticcheck для поиска подозрительных конструкций. Также полезен gocyclo для вычисления цикломатической сложности.
Пример проблемного кода:
package main
import "fmt"
var config map[string]string // глобальная переменная
func main() {
config = map[string]string{"port": "8080"}
startServer()
}
func startServer() {
// магическая строка "port"
fmt.Println("Starting on port:", config["port"])
// ... 100 строк кода
}
Шаг 2: Создание защитной сети из тестов
Без тестов рефакторинг превращается в гадание. Ваша задача — покрыть ключевую логику тестами до изменений. Используйте подход Golden Files (эталонные файлы) для сложных выводов или интеграционных тестов.
Пример добавления теста для функции из legacy-кода:
// legacy.go (оригинал)
func CalculateDiscount(price float64, isPremium bool) float64 {
if isPremium {
return price * 0.9
}
return price * 0.95
}
// legacy_test.go
package main
import "testing"
func TestCalculateDiscount_Premium(t *testing.T) {
result := CalculateDiscount(100.0, true)
expected := 90.0
if result != expected {
t.Errorf("Expected %f, got %f", expected, result)
}
}
func TestCalculateDiscount_Regular(t *testing.T) {
result := CalculateDiscount(100.0, false)
expected := 95.0
if result != expected {
t.Errorf("Expected %f, got %f", expected, result)
}
}
Совет: используйте go test -cover для оценки покрытия. Старайтесь покрыть не менее 70% бизнес-логики перед началом рефакторинга.
Шаг 3: Постепенная декомпозиция и выделение интерфейсов
Основная цель рефакторинга — сделать код тестируемым и гибким. Начните с выделения интерфейсов для внешних зависимостей (базы данных, HTTP-клиенты, файловые системы).
3.1 Выделение интерфейса вместо прямого использования БД
Допустим, у нас есть функция, которая напрямую работает с *sql.DB:
// legacy code
func GetUser(db *sql.DB, id int) (*User, error) {
row := db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)
// ...
}
Рефакторинг: создаем интерфейс UserRepository:
type UserRepository interface {
GetByID(id int) (*User, error)
}
type userRepo struct {
db *sql.DB
}
func (r *userRepo) GetByID(id int) (*User, error) {
row := r.db.QueryRow("SELECT id, name FROM users WHERE id = ?", id)
// ...
return &User{}, nil
}
// Теперь бизнес-логика зависит от интерфейса, а не от конкретной БД
func GetUserService(repo UserRepository, id int) (*User, error) {
return repo.GetByID(id)
}
Это позволяет легко подменить реализацию в тестах (например, на in-memory хранилище).
3.2 Избавление от глобальных переменных
Глобальные переменные — главный враг тестирования. Замените их на внедрение зависимостей (Dependency Injection):
// Вместо глобальной переменной
var logger *log.Logger
// Создаем структуру, которая принимает логгер
type Service struct {
logger *log.Logger
}
func NewService(logger *log.Logger) *Service {
return &Service{logger: logger}
}
func (s *Service) DoSomething() {
s.logger.Println("Doing something")
}
Шаг 4: Рефакторинг больших функций и устранение дублирования
Функции длиной более 30-40 строк — кандидаты на разбиение. Используйте принцип единой ответственности (Single Responsibility).
Пример: функция, которая парсит данные и отправляет их в БД:
// До рефакторинга
func ProcessData(data string) error {
// 10 строк парсинга
// 15 строк валидации
// 20 строк сохранения в БД
return nil
}
// После рефакторинга
func ParseData(data string) (ParsedData, error) {
// только парсинг
}
func ValidateData(d ParsedData) error {
// только валидация
}
func SaveData(d ParsedData) error {
// только сохранение
}
func ProcessData(data string) error {
d, err := ParseData(data)
if err != nil {
return err
}
if err := ValidateData(d); err != nil {
return err
}
return SaveData(d)
}
Такой код легче тестировать, отлаживать и изменять.
Заключение
Рефакторинг legacy кода на Go — это не страшная задача, если подходить к ней системно. Помните три главных правила:
- Не ломайте