Рефакторинг 500 000 строк легаси-кода: стратегия и результаты

Состояние на входе: масштабы катастрофы

Первичный аудит кодовой базы показал картину, которая знакома многим:

  • PHP 5.6 — EOL ещё в 2018 году, критические CVE не закрыты
  • 0 тестов. Буквально ноль. Ни одного unit-теста, ни одного интеграционного
  • Единый index.php на 12 000 строк как точка входа для всего
  • SQL-запросы формируются конкатенацией строк — множество SQL-инъекций
  • Глобальные переменные: 347 глобальных переменных, используемых повсеместно
  • Copy-paste: 23% кода — дубликаты (по анализу phpcpd)
  • Деплой: FTP-загрузка на production-сервер вручную
  • Бизнес-логика: расчёт тарифов, трекинг грузов, ERP-интеграция, финансы — всё в одном котле

При этом система обрабатывала 15 000 заказов в день и приносила компании 2 млрд руб. в год. Выключить и переписать — невозможно.

Методология оценки

Мы начали с количественного анализа. Написали скрипт, который строил карту зависимостей и метрики сложности:

#!/bin/bash
# code_analysis.sh — сбор метрик легаси-кодовой базы

echo "=== Code Metrics for TransCargo Legacy ==="

# Общий объём
echo "Total lines:"
find /var/www/transcargo -name "*.php" | xargs wc -l | tail -1

# Цикломатическая сложность (через phpmetrics)
phpmetrics --report-html=/tmp/metrics /var/www/transcargo

# Дубликаты
phpcpd /var/www/transcargo --min-lines=10 --min-tokens=50 \
  --log-pmd=/tmp/cpd.xml

# Статический анализ уязвимостей
phpstan analyse /var/www/transcargo --level=0 \
  --error-format=json > /tmp/phpstan.json

# Подсчёт глобальных переменных
echo "Global variables:"
grep -r '\$GLOBALS\|global \$' /var/www/transcargo --include="*.php" \
  | grep -v vendor | wc -l

# Файлы без единого класса (процедурный код)
echo "Procedural files (no class):"
find /var/www/transcargo -name "*.php" -exec grep -L "^class " {} \; | wc -l

Результаты phpmetrics были ужасающими: средняя цикломатическая сложность — 47 (норма <10), maintainability index — 12 из 171 (ниже 65 — красная зона).

Матрица приоритизации: риск vs бизнес-ценность

Нельзя рефакторить всё сразу. Мы разработали матрицу приоритизации:

# prioritization_matrix.yaml
modules:
  tariff_calculator:
    business_value: 10  # Ядро бизнеса
    risk_level: 9       # Баги = финансовые потери
    change_frequency: 8  # Меняют 3 раза в месяц
    complexity: 9
    priority_score: 36   # sum -> P0 (первоочередной)

  shipment_tracking:
    business_value: 8
    risk_level: 6
    change_frequency: 7
    complexity: 7
    priority_score: 28   # P1

  customer_portal:
    business_value: 7
    risk_level: 5
    change_frequency: 9
    complexity: 6
    priority_score: 27   # P1

  accounting_integration:
    business_value: 6
    risk_level: 8
    change_frequency: 3  # Почти не меняется
    complexity: 8
    priority_score: 25   # P2

  internal_reports:
    business_value: 4
    risk_level: 3
    change_frequency: 2
    complexity: 5
    priority_score: 14   # P3 — оставляем на потом

Strangler Fig: обёрнуть и постепенно заменить

Паттерн Strangler Fig — это единственный безопасный способ модернизации работающей системы. Идея: ставим reverse proxy перед легаси, и постепенно перенаправляем маршруты на новые сервисы.

# nginx/transcargo.conf — reverse proxy для strangler fig
upstream legacy_php {
    server 10.0.1.10:80;
    server 10.0.1.11:80;
}

upstream new_tariff_service {
    server 10.0.2.10:3000;
    server 10.0.2.11:3000;
}

upstream new_tracking_service {
    server 10.0.2.20:3000;
    server 10.0.2.21:3000;
}

server {
    listen 443 ssl;
    server_name app.transcargo.ru;

    # Новые сервисы (постепенно добавляем маршруты)
    location /api/v2/tariffs {
        proxy_pass http://new_tariff_service;
        proxy_set_header X-Request-ID $request_id;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    location /api/v2/tracking {
        proxy_pass http://new_tracking_service;
        proxy_set_header X-Request-ID $request_id;
    }

    # Всё остальное — легаси (объём уменьшается с каждым спринтом)
    location / {
        proxy_pass http://legacy_php;
        proxy_set_header Host $host;
        proxy_set_header X-Request-ID $request_id;
    }
}

Характеризационные тесты: фиксируем поведение до рефакторинга

Прежде чем менять код, нужно зафиксировать его текущее поведение. Мы писали characterization tests — тесты, которые не проверяют «правильность», а фиксируют факт. Подход Майкла Фезерса из «Working Effectively with Legacy Code»:

// tests/characterization/tariff.test.ts
// Характеризационные тесты: фиксируем поведение старого API
import axios from 'axios';

const LEGACY_URL = 'http://legacy-php.internal';

describe('Tariff Calculator — characterization', () => {
  // Записываем реальные ответы легаси как "золотой стандарт"
  const goldenCases = [
    {
      name: 'Москва-Владивосток, 20 тонн, стандарт',
      input: { from: 'MSK', to: 'VVO', weight_kg: 20000, type: 'standard' },
      // Этот ответ получен от legacy — мы не знаем, правильный ли он
      // Но мы знаем, что бизнес на него рассчитывает
      expected_price_range: { min: 145000, max: 155000 },
    },
    {
      name: 'Москва-Питер, 500 кг, экспресс',
      input: { from: 'MSK', to: 'LED', weight_kg: 500, type: 'express' },
      expected_price_range: { min: 8500, max: 9500 },
    },
    {
      name: 'Негабаритный груз с перегрузкой',
      input: {
        from: 'MSK', to: 'NSK', weight_kg: 45000,
        type: 'oversized', reload_points: ['KZN'],
      },
      expected_price_range: { min: 380000, max: 420000 },
    },
  ];

  goldenCases.forEach(({ name, input, expected_price_range }) => {
    test(`Legacy: ${name}`, async () => {
      const resp = await axios.post(`${LEGACY_URL}/api/tariff/calculate`, input);
      expect(resp.status).toBe(200);
      expect(resp.data.price).toBeGreaterThanOrEqual(expected_price_range.min);
      expect(resp.data.price).toBeLessThanOrEqual(expected_price_range.max);
      // Сохраняем полный ответ для сравнения с новым сервисом
      expect(resp.data).toMatchSnapshot();
    });
  });

  // Тест на shadow mode: запрос идёт к обоим, сравниваем результаты
  goldenCases.forEach(({ name, input }) => {
    test(`Shadow compare: ${name}`, async () => {
      const [legacyResp, newResp] = await Promise.all([
        axios.post(`${LEGACY_URL}/api/tariff/calculate`, input),
        axios.post('http://new-tariff.internal/api/v2/tariffs/calculate', input),
      ]);

      const diff = Math.abs(legacyResp.data.price - newResp.data.price);
      const diffPercent = (diff / legacyResp.data.price) * 100;

      // Допускаем расхождение до 0.01% (ошибки округления)
      expect(diffPercent).toBeLessThan(0.01);
    });
  });
});

Новые модули на TypeScript/Node.js

Новые сервисы мы писали на TypeScript + Node.js (Express). Выбор обусловлен: команда клиента лучше всего знала JavaScript, хороший тулинг, строгая типизация через TS.

// src/tariff/tariff.service.ts
import { Injectable } from '@nestjs/common';
import { TariffRequest, TariffResponse, RouteSegment } from './tariff.types';
import { DistanceService } from '../distance/distance.service';
import { PricingEngine } from './pricing.engine';

@Injectable()
export class TariffService {
  constructor(
    private readonly distanceService: DistanceService,
    private readonly pricingEngine: PricingEngine,
  ) {}

  async calculate(request: TariffRequest): Promise<TariffResponse> {
    // 1. Строим маршрут с промежуточными точками
    const segments: RouteSegment[] = await this.distanceService.buildRoute(
      request.from,
      request.to,
      request.reload_points ?? [],
    );

    // 2. Рассчитываем базовую стоимость по сегментам
    let totalPrice = 0;
    const breakdown: TariffResponse['breakdown'] = [];

    for (const segment of segments) {
      const segmentPrice = this.pricingEngine.calculateSegment({
        distance_km: segment.distance_km,
        weight_kg: request.weight_kg,
        cargo_type: request.type,
        region_coefficients: segment.region_coefficients,
      });

      totalPrice += segmentPrice.total;
      breakdown.push({
        from: segment.from,
        to: segment.to,
        distance_km: segment.distance_km,
        price: segmentPrice.total,
        components: segmentPrice.components,
      });
    }

    // 3. Применяем скидки и наценки
    const adjustments = this.pricingEngine.applyAdjustments(totalPrice, {
      customer_tier: request.customer_tier,
      is_return_load: request.is_return_load,
      urgency: request.type === 'express' ? 'high' : 'normal',
      seasonal_factor: this.pricingEngine.getSeasonalFactor(new Date()),
    });

    return {
      price: adjustments.final_price,
      currency: 'RUB',
      breakdown,
      adjustments: adjustments.applied,
      valid_until: new Date(Date.now() + 24 * 60 * 60 * 1000), // 24 часа
    };
  }
}

Внедрение CI/CD

Легаси деплоился через FTP. Мы внедрили полноценный pipeline:

# .gitlab-ci.yml
stages:
  - lint
  - test
  - build
  - deploy-staging
  - characterization-test
  - deploy-production

lint:
  stage: lint
  image: node:20-alpine
  script:
    - npm ci
    - npm run lint
    - npm run typecheck

unit-tests:
  stage: test
  image: node:20-alpine
  services:
    - postgres:15
    - redis:7
  script:
    - npm ci
    - npm run test:unit -- --coverage
  coverage: '/All files[^|]*\|[^|]*\s+([\d.]+)/'
  artifacts:
    reports:
      coverage_report:
        coverage_format: cobertura
        path: coverage/cobertura-coverage.xml

integration-tests:
  stage: test
  image: node:20-alpine
  services:
    - postgres:15
    - redis:7
  script:
    - npm ci
    - npm run test:integration

build:
  stage: build
  image: docker:24
  services:
    - docker:24-dind
  script:
    - docker build -t registry.transcargo.ru/tariff-service:$CI_COMMIT_SHA .
    - docker push registry.transcargo.ru/tariff-service:$CI_COMMIT_SHA

deploy-staging:
  stage: deploy-staging
  script:
    - kubectl set image deployment/tariff-service
        tariff-service=registry.transcargo.ru/tariff-service:$CI_COMMIT_SHA
        -n staging
    - kubectl rollout status deployment/tariff-service -n staging --timeout=120s

characterization-tests:
  stage: characterization-test
  script:
    - npm run test:characterization -- --env=staging
  allow_failure: false  # Блокируем деплой в прод при расхождениях

deploy-production:
  stage: deploy-production
  script:
    - kubectl set image deployment/tariff-service
        tariff-service=registry.transcargo.ru/tariff-service:$CI_COMMIT_SHA
        -n production
    - kubectl rollout status deployment/tariff-service -n production --timeout=180s
  when: manual  # Только ручной trigger
  only:
    - main

Нормализация схемы БД

Старая схема была ужасна: таблица orders имела 87 колонок, включая JSON-blob с вложенными данными. Мы нормализовали постепенно, добавляя views для обратной совместимости:

-- migration_v042_normalize_orders.sql
BEGIN;

-- Выносим адреса в отдельную таблицу
CREATE TABLE addresses (
    id BIGSERIAL PRIMARY KEY,
    city VARCHAR(255) NOT NULL,
    street VARCHAR(500),
    building VARCHAR(50),
    postal_code VARCHAR(10),
    lat DECIMAL(10, 7),
    lng DECIMAL(10, 7),
    created_at TIMESTAMPTZ DEFAULT NOW()
);

-- Заполняем из старых колонок
INSERT INTO addresses (city, street, building, postal_code)
SELECT DISTINCT
    sender_city, sender_street, sender_building, sender_postal
FROM orders WHERE sender_city IS NOT NULL;

-- Добавляем FK в orders
ALTER TABLE orders ADD COLUMN sender_address_id BIGINT REFERENCES addresses(id);
ALTER TABLE orders ADD COLUMN receiver_address_id BIGINT REFERENCES addresses(id);

-- Обновляем ссылки
UPDATE orders o SET sender_address_id = a.id
FROM addresses a
WHERE a.city = o.sender_city
  AND COALESCE(a.street, '') = COALESCE(o.sender_street, '')
  AND COALESCE(a.building, '') = COALESCE(o.sender_building, '');

-- View для обратной совместимости с легаси-кодом
CREATE OR REPLACE VIEW orders_legacy_compat AS
SELECT
    o.*,
    sa.city as sender_city_new,
    sa.street as sender_street_new,
    ra.city as receiver_city_new,
    ra.street as receiver_street_new
FROM orders o
LEFT JOIN addresses sa ON o.sender_address_id = sa.id
LEFT JOIN addresses ra ON o.receiver_address_id = ra.id;

COMMIT;

Повышение квалификации команды

Мы не просто написали код — мы обучили команду клиента работать по-новому:

  • Code review — обязательный для каждого PR, минимум 1 approval
  • Парное программирование — наш разработчик + разработчик клиента
  • Внутренние tech talks каждую пятницу
  • Книжный клуб: «Clean Code», «Working Effectively with Legacy Code», «Refactoring»

Результаты за 18 месяцев

МетрикаДоПосле
Покрытие тестами0%72%
Время деплоя2 часа (ручной FTP)12 минут (автоматический)
Частота релизов1 раз в месяц8-10 раз в неделю
Инциденты в production12 в месяц1-2 в месяц
Время исправления бага3-5 дней2-4 часа
Объём мигрированного кода65% (325K строк заменены)
Цикломатическая сложность (средняя)478
Скорость обработки заказа4.2 сек0.8 сек

Оставшиеся 35% легаси-кода — это внутренние отчёты и accounting-интеграция, которые работают стабильно и меняются редко. Их модернизация запланирована, но уже не горит.

Главные выводы

  1. Никогда не переписывайте с нуля. Strangler Fig — единственный безопасный путь для работающей системы.
  2. Тесты до рефакторинга. Characterization tests — ваша страховка.
  3. Приоритизация решает. Начните с самого болезненного и часто меняющегося модуля.
  4. CI/CD — первое, что нужно внедрить. Без автоматического деплоя любой рефакторинг превращается в мучение.
  5. Инвестируйте в людей. Новый код без новой культуры разработки деградирует за год.

Нужна помощь с проектом?

Специалисты АйТи Фреш помогут с архитектурой, DevOps, безопасностью и разработкой — 15+ лет опыта

📞 Связаться с нами
#ci/cd — первое, что нужно внедрить.#cicd#copy-paste:#devops#php 5.6#sql-запросы#strangler#typescriptnodejs
Комментарии 0

Оставить комментарий

загрузка...