Рефакторинг 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 — это не разовое событие, а постоянный процесс улучшения. Ключевые принципы, которые стоит запомнить:
- Всегда начинайте с тестов, чтобы зафиксировать текущее поведение.
- Выделяйте интерфейсы для ослабления связей между компонентами.
- Применяйте инъекцию зависимостей для гибкости и тестируемости.
- Избавляйтесь от глобальных переменных — передавайте зависимости явно.
Блоги
Рекомендаций по книгам
Читайте также
Как написать REST API на Kotlin за 30 минут