Введение в рефакторинг 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