Рефакторинг legacy кода на Java: от А до Я
Работа с унаследованным (legacy) кодом на Java — это вызов, с которым сталкивается каждый разработчик. Старый код часто лишен тестов, содержит устаревшие паттерны и трудно поддается изменениям. Однако полная переписка проекта — не всегда лучшее решение. В этой статье мы разберем проверенный подход к рефакторингу legacy кода: от анализа до внедрения изменений, с конкретными примерами на Java.
1. Диагностика и подготовка: прежде чем трогать код
Первый и самый важный шаг — понять, с чем вы имеете дело. Не начинайте рефакторинг с хаотичной правки строк. Вместо этого:
- Карта зависимостей: визуализируйте, какие классы от кого зависят. Используйте инструменты вроде IntelliJ IDEA Dependency Viewer или JDepend.
- Тестовая обертка (Characterization Tests): напишите тесты, которые фиксируют текущее поведение кода. Это ваша страховочная сетка.
- Выделите точки входа: определите, какие методы вызываются чаще всего и где происходят основные изменения.
Пример простого characterization теста для метода, который нужно отрефакторить:
import org.junit.jupiter.api.Test;
import static org.junit.jupiter.api.Assertions.assertEquals;
public class LegacyCalculatorTest {
@Test
public void testLegacyCalculate() {
LegacyCalculator calc = new LegacyCalculator();
// Фиксируем текущее поведение
int result = calc.calculate(10, 5, "add");
assertEquals(15, result, "Текущее поведение: сложение");
result = calc.calculate(10, 5, "sub");
assertEquals(5, result, "Текущее поведение: вычитание");
}
}
Такой тест не проверяет правильность логики в абсолютном смысле — он просто фиксирует, что код делает сейчас. После рефакторинга тест должен пройти, иначе вы изменили поведение.
2. Стратегия "Хирургического" рефакторинга: метод за методом
Попытка отрефакторить весь класс сразу — верный способ получить баги. Используйте подход "боевой раскопки" (Combat Archaeology):
- Выберите один метод или небольшую группу связанных методов.
- Напишите для них characterization тесты (если их нет).
- Примените одну технику рефакторинга (например, Extract Method или Rename Variable).
- Запустите тесты. Они должны пройти.
- Закоммитьте изменения с понятным описанием.
Пример: рефакторинг метода с "магическими числами" и длинным списком параметров.
Исходный код (плохой):
public class OrderProcessor {
public double process(double amount, int type, double discount) {
double result;
if (type == 1) {
result = amount * 1.2; // 20% наценка
if (discount > 0.1) {
result = result - result * 0.05; // 5% скидка
}
} else if (type == 2) {
result = amount * 1.5; // 50% наценка
} else {
result = amount;
}
return result;
}
}
После рефакторинга (улучшенный):
public class OrderProcessor {
private static final double PREMIUM_MARKUP = 1.2;
private static final double STANDARD_MARKUP = 1.5;
private static final double LOYALTY_DISCOUNT = 0.05;
private static final double DISCOUNT_THRESHOLD = 0.1;
public double process(OrderType type, double amount, double discount) {
double basePrice = applyMarkup(amount, type);
return applyLoyaltyDiscount(basePrice, discount);
}
private double applyMarkup(double amount, OrderType type) {
return switch (type) {
case PREMIUM -> amount * PREMIUM_MARKUP;
case STANDARD -> amount * STANDARD_MARKUP;
case BASIC -> amount;
};
}
private double applyLoyaltyDiscount(double price, double discount) {
if (discount > DISCOUNT_THRESHOLD) {
return price - price * LOYALTY_DISCOUNT;
}
return price;
}
}
enum OrderType {
BASIC, PREMIUM, STANDARD
}
Мы заменили магические числа на константы, выделили отдельные методы и ввели enum вместо числового типа. Тесты, фиксирующие старое поведение, должны пройти без изменений.
3. Работа с устаревшими паттернами: замена глобального состояния
Одна из самых частых проблем legacy Java-кода — синглтоны и статические методы. Они создают скрытые зависимости и делают код нетestируемым.
Рассмотрим пример с "классическим" синглтоном:
public class DatabaseConnection {
private static DatabaseConnection instance;
private Connection connection;
private DatabaseConnection() {
// Сложная инициализация
}
public static DatabaseConnection getInstance() {
if (instance == null) {
instance = new DatabaseConnection();
}
return instance;
}
public Connection getConnection() {
return connection;
}
}
// Использование в коде
public class UserRepository {
public User findUser(int id) {
Connection conn = DatabaseConnection.getInstance().getConnection();
// ... запрос к БД
}
}
Проблема: невозможно подменить соединение в тестах. Решение — внедрение зависимостей (Dependency Injection).
Рефакторинг:
// Создаем интерфейс
public interface DatabaseProvider {
Connection getConnection();
}
// Реальная реализация
public class RealDatabaseProvider implements DatabaseProvider {
@Override
public Connection getConnection() {
// Реальная инициализация
return new Connection();
}
}
// Класс теперь принимает зависимость
public class UserRepository {
private final DatabaseProvider dbProvider;
public UserRepository(DatabaseProvider dbProvider) {
this.dbProvider = dbProvider;
}
public User findUser(int id) {
Connection conn = dbProvider.getConnection();
// ... запрос к БД
}
}
// Теперь легко тестировать
public class UserRepositoryTest {
@Test
public void testFindUser() {
DatabaseProvider mockProvider = mock(DatabaseProvider.class);
when(mockProvider.getConnection()).thenReturn(new MockConnection());
UserRepository repo = new UserRepository(mockProvider);
// ... тест
}
}
Важно: не пытайтесь внедрить DI-контейнер (Spring/Guice) сразу. Сначала сделайте код тестируемым, затем подумайте об инверсии