Статьи

Извлечение, Внедрение, Убить: Разрушение иерархий — Часть 1

Несколько лет назад, до того как я обнаружил ошибку TDD, я любил шаблон шаблонов . Я действительно думал, что это отличный способ иметь алгоритм с полиморфными частями. У меня не было проблем с наследованием. Но да, это было много лет назад.

На протяжении многих лет меня ранил этот «стиль дизайна». Это дизайн, созданный разработчиками, которые не используют TDD.

Ситуация

Совсем недавно я работал над одной частью нашего старого кода и нашел шестиуровневую иерархию классов. Было довольно много шаблонных методов, определенных в более чем одном из классов. Все классы были абстрактными, за исключением нижних классов, которые только что реализовали один или два метода шаблона. Во всей иерархии был только один публичный метод, прямо на самом верху.

Мы должны были внести изменения в один из классов внизу. Одна из (защищенных) реализаций метода шаблона должна быть изменена.

Эта проблема

Как вы это тестируете? Само собой разумеется, что были нулевые тесты для иерархии.
Мы знаем, что мы никогда не должны тестировать частные или защищенные методы. Класс должен «всегда» тестироваться из его открытого интерфейса. Мы всегда должны писать тесты, которые выражают и проверяют «что» делает метод, а не «как». Это все хорошо. Однако в этом случае изменение должно быть сделано в защищенном методе (реализация метода шаблона), который является частью реализации открытого метода, определенного на шестом уровне выше по иерархии. Чтобы протестировать этот метод, вызывая открытый метод его пра-пра-пра-пра-прародителя, нам нужно понять всю иерархию, смоделировать все зависимости, создать соответствующие данные, настроить макеты так, чтобы они имели четко определенное поведение, чтобы мы могли получить эту часть кода вызывается и затем проверяется.

Хуже того, представьте, что у этого класса внизу есть братья и сестры, перекрывающие тот же метод шаблона. Когда нужно изменить братьев и сестер, усилия по написанию тестов для них будут такими же, как и для нашего исходного класса. У нас будет множество дубликатов, а также нам нужно будет понять весь код внутри всех классов в иерархии. Лед в торте: во всех родительских классах нужно понимать сотни строк.

Нарушая правила

Тестирование с помощью общедоступного метода, определенного на самом верху иерархии, доказало, что оно того не стоит. Основная причина в том, что, кроме болезненных, мы уже знали, что весь дизайн был неправильным. Когда мы смотрим на классы в иерархии, они даже не следовали правилу наследования IS-A . Они наследуются друг от друга, поэтому некоторый код можно использовать повторно.

Спустя какое-то время я подумал: винт правила и этот дизайн. Я собираюсь просто протестировать защищенный метод и затем начать нарушать иерархию.

Подход: извлечь, ввести, убить

Общая идея такова:
1. Извлеките все поведение из метода шаблона в класс.
2. Вставьте новый класс в родительский класс (где определен шаблон), заменив вызов метода шаблона вызовом метода в новом классе.
3. Убейте дочерний класс (тот, у которого была реализация метода шаблона).

Повторяйте эти шаги, пока не избавитесь от всей иерархии.

Сначала это было сделано при написании тестов, что сделало реализацию защищенного метода шаблона общедоступной .

ПРИМЕЧАНИЯ 1. Это может быть не так просто, если у нас есть методы, вызывающие стек в иерархии. 2. Если в классе есть братья и сестры, мы должны извлечь все поведение из братьев и сестер, прежде чем мы сможем внедрить их в родителя и убить братьев и сестер.

Вот более конкретный пример того, как разбить глубокие иерархии, используя подход Извлечь, Внедрить, Убить. Представьте себе следующую иерархию.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
public abstract class PrincingService {
     
    public double calculatePrice(ShoppingBasket shoppingBasket, User user, String voucher) {
        double discount = calculateDiscount(user);
        double total = 0;
        for (ShoppingBasket.Item item : shoppingBasket.items()) {
            total += calculateProductPrice(item.getProduct(), item.getQuantity());
        }
        total = applyAdditionalDiscounts(total, user, voucher);
        return total * ((100 - discount)  100);
    }
 
    protected abstract double calculateDiscount(User user);
 
    protected abstract double calculateProductPrice(Product product, int quantity);
 
    protected abstract double applyAdditionalDiscounts(double total, User user, String voucher);
 
}
 
public abstract class UserDiscountPricingService extends PrincingService {
 
    @Override
    protected double calculateDiscount(User user) {
        int discount = 0;
        if (user.isPrime()) {
            discount = 10;
        }
        return discount;
    }
}
 
public abstract class VoucherPrincingService extends UserDiscountPricingService {
 
    private VoucherService voucherService;
 
    @Override
    protected double applyAdditionalDiscounts(double total, User user, String voucher) {
        double voucherValue = voucherService.getVoucherValue(voucher);
        double totalAfterValue = total - voucherValue;
        return (totalAfterValue > 0) ? totalAfterValue : 0;
    }
 
    public void setVoucherService(VoucherService voucherService) {
        this.voucherService = voucherService;
    }
}
 
public class BoxingDayPricingService extends VoucherPrincingService {
    public static final double BOXING_DAY_DISCOUNT = 0.60;
 
    @Override
    protected double calculateProductPrice(Product product, int quantity) {
        return ((product.getPrice() * quantity) * BOXING_DAY_DISCOUNT);
    }
}
 
public class StandardPricingService extends VoucherPrincingService {
 
    @Override
    protected double calculateProductPrice(Product product, int quantity) {
        return product.getPrice() * quantity;
    }
}

Давайте начнем с StandardPricingService. Сначала напишем несколько тестов:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
public class StandardPricingServiceTest {
 
    private TestableStandardPricingService standardPricingService = new TestableStandardPricingService();
     
    @Test public void
    should_return_product_price_when_quantity_is_one() {
        Product book = aProduct().costing(10).build();
 
        double price = standardPricingService.calculateProductPrice(book, 1);
 
        assertThat(price, is(10D));
    }
     
    @Test public void
    should_return_product_price_multiplied_by_quantity() {
        Product book = aProduct().costing(10).build();
 
        double price = standardPricingService.calculateProductPrice(book, 3);
 
        assertThat(price, is(30D));
    }
 
    @Test public void
    should_return_zero_when_quantity_is_zero() {
        Product book = aProduct().costing(10).build();
 
        double price = standardPricingService.calculateProductPrice(book, 0);
 
        assertThat(price, is(0D));
    }
 
    private class TestableStandardPricingService extends StandardPricingService {
        @Override
        protected double calculateProductPrice(Product product, int quantity) {
            return super.calculateProductPrice(product, quantity);
        }
    }
}

Обратите внимание, что здесь я использовал небольшую хитрость, расширив класс StandardPricingService внутри тестового класса, чтобы иметь доступ к защищенному методу. Мы не должны использовать этот трюк в нормальных условиях. Помните, что если вы чувствуете необходимость тестировать защищенные или частные методы, то это потому, что ваш дизайн не совсем правильный, это означает, что в вашем дизайне отсутствует концепция предметной области. Другими словами, есть класс, который плачет, чтобы выйти из класса, который вы пытаетесь проверить.

Теперь давайте сделаем первый шаг в нашей стратегии «Извлечь, ввести, убить». Извлеките содержимое метода convertProductPrice () в другой класс с именем StandardPriceCalculation. Это можно сделать автоматически с помощью IntelliJ или Eclipse. После нескольких незначительных корректировок, это то, что мы получили.

1
2
3
4
5
6
public class StandardPriceCalculation {
 
    public double calculateProductPrice(Product product, int quantity) {
        return product.getPrice() * quantity;
    }
}

И теперь StandardPriceService выглядит так:

1
2
3
4
5
6
7
8
9
public class StandardPricingService extends VoucherPrincingService {
 
    private final StandardPriceCalculation standardPriceCalculation = new StandardPriceCalculation();
 
    @Override
    protected double calculateProductPrice(Product product, int quantity) {
        return standardPriceCalculation.calculateProductPrice(product, quantity);
    }
}

Все ваши тесты должны пройти.

Когда мы создадим новый класс, давайте добавим к нему несколько тестов. Это должны быть те же тесты, что и у StandardPricingService.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public class StandardPriceCalculationTest {
 
    private StandardPriceCalculation priceCalculation = new StandardPriceCalculation();
     
    @Test public void
    should_return_product_price_when_quantity_is_one() {
        Product book = aProduct().costing(10).build();
 
        double price = priceCalculation.calculateProductPrice(book, 1);
 
        assertThat(price, is(10D));
    }
     
    @Test public void
    should_return_product_price_multiplied_by_quantity() {
        Product book = aProduct().costing(10).build();
 
        double price = priceCalculation.calculateProductPrice(book, 3);
 
        assertThat(price, is(30D));
    }
 
    @Test public void
    should_return_zero_when_quantity_is_zero() {
        Product book = aProduct().costing(10).build();
 
        double price = priceCalculation.calculateProductPrice(book, 0);
 
        assertThat(price, is(0D));
    }
 
}

Отлично, один брат готов. Теперь давайте сделаем то же самое для BoxingDayPricingService.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
public class BoxingDayPricingServiceTest {
     
    private TestableBoxingDayPricingService boxingDayPricingService = new TestableBoxingDayPricingService();
 
    @Test public void
    should_apply_boxing_day_discount_on_product_price() {
        Product book = aProduct().costing(10).build();
 
        double price = boxingDayPricingService.calculateProductPrice(book, 1);
 
        assertThat(price, is(6D));
    }
 
    @Test public void
    should_apply_boxing_day_discount_on_product_price_and_multiply_by_quantity() {
        Product book = aProduct().costing(10).build();
 
        double price = boxingDayPricingService.calculateProductPrice(book, 3);
 
        assertThat(price, is(18D));
    }
 
    private class TestableBoxingDayPricingService extends BoxingDayPricingService {
         
        @Override
        protected double calculateProductPrice(Product product, int quantity) {
            return super.calculateProductPrice(product, quantity);
        }
         
    }
}

Теперь давайте выделим поведение в другой класс. Давайте назовем это BoxingDayPricingCalculation.

1
2
3
4
5
6
7
public class BoxingDayPriceCalculation {
    public static final double BOXING_DAY_DISCOUNT = 0.60;
 
    public double calculateProductPrice(Product product, int quantity) {
        return ((product.getPrice() * quantity) * BOXING_DAY_DISCOUNT);
    }
}

Новый BoxingDayPriceService теперь

1
2
3
4
5
6
7
8
public class BoxingDayPricingService extends VoucherPrincingService {
    private final BoxingDayPriceCalculation boxingDayPriceCalculation = new BoxingDayPriceCalculation();
 
    @Override
    protected double calculateProductPrice(Product product, int quantity) {
        return boxingDayPriceCalculation.calculateProductPrice(product, quantity);
    }
}

Теперь нам нужно добавить тесты для нового класса.

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public class BoxingDayPriceCalculationTest {
     
    private BoxingDayPriceCalculation priceCalculation = new BoxingDayPriceCalculation();
 
    @Test public void
    should_apply_boxing_day_discount_on_product_price() {
        Product book = aProduct().costing(10).build();
 
        double price = priceCalculation.calculateProductPrice(book, 1);
 
        assertThat(price, is(6D));
    }
 
    @Test public void
    should_apply_boxing_day_discount_on_product_price_and_multiply_by_quantity() {
        Product book = aProduct().costing(10).build();
 
        double price = priceCalculation.calculateProductPrice(book, 3);
 
        assertThat(price, is(18D));
    }
 
}

Теперь и StandardPricingService, и BoxingDayPricingService не имеют собственной реализации. Единственное, что они делают, — это делегируют расчет цены соответственно StandardPriceCalculation и BoxingDayPriceCalculation. Оба класса расчета цены имеют один и тот же публичный метод, поэтому теперь давайте извлечем интерфейс PriceCalculation и сделаем так, чтобы они оба реализовали его.

1
2
3
4
5
6
7
public interface PriceCalculation {
    double calculateProductPrice(Product product, int quantity);
}
 
public class BoxingDayPriceCalculation implements PriceCalculation
 
public class StandardPriceCalculation implements PriceCalculation

Потрясающие. Теперь мы готовы к инъекционной части подхода Extract, Inject, Kill. Нам просто нужно внедрить желаемое поведение в родительский (класс, который определяет метод шаблона). CalculateProductPrice () определяется в PricingService, классе на самом верху иерархии. Вот где мы хотим внедрить реализацию PriceCalculation. Вот новая версия:

01
02
03
04
05
06
07
08
09
10
11
12
13
14
15
16
17
18
19
20
21
22
23
public abstract class PricingService {
 
    private PriceCalculation priceCalculation;
 
    public double calculatePrice(ShoppingBasket shoppingBasket, User user, String voucher) {
        double discount = calculateDiscount(user);
        double total = 0;
        for (ShoppingBasket.Item item : shoppingBasket.items()) {
            total += priceCalculation.calculateProductPrice(item.getProduct(), item.getQuantity());
        }
        total = applyAdditionalDiscounts(total, user, voucher);
        return total * ((100 - discount)  100);
    }
 
    protected abstract double calculateDiscount(User user);
 
    protected abstract double applyAdditionalDiscounts(double total, User user, String voucher);
 
    public void setPriceCalculation(PriceCalculation priceCalculation) {
        this.priceCalculation = priceCalculation;
    }
 
}

Обратите внимание на то, что метод шаблона calcProductPrice () был удален из PricingService, поскольку его поведение теперь внедряется, а не реализуется подклассами.

Поскольку мы здесь, давайте напишем несколько тестов для этого последнего изменения, проверяя, правильно ли PricingService вызывает PriceCalculation.

Отлично. Теперь мы готовы к последнему этапу рефакторинга Extract, Inject, Kill. Давайте убьем как дочерние классы StandardPricingService, так и BoxingDayPricingService.

VoucherPricingService, теперь самый глубокий класс в иерархии, может быть повышен до конкретного класса. Давайте еще раз посмотрим на иерархию:

Вот и все. Теперь нужно просто повторить те же шаги для VoucherPricingService и UserDiscountPricingService. Извлеките реализацию их шаблонных методов в классы, внедрите их в PricingService и уничтожьте классы.

При этом каждый раз, когда вы извлекаете класс, старайтесь давать им собственные имена, а не называть их Service. Предложения могут быть VoucherDiscountCalculation и PrimeUserDiscountCalculation.

Было несколько небезопасных шагов в ре-факторинге, описанном выше, и я также изо всех сил пытался описать, как именно я это сделал, так как я довольно много играл с кодом. Предложения и идеи очень приветствуются.

Для окончательного решения , пожалуйста, проверьте вторую часть этого блога.

НОТА
Если вы не привыкли использовать компоновщики в своих тестах и ​​спрашиваете себя, откуда взялись aProduct () и aShoppingBasket (), проверьте код здесь:

ProductBuilder.java
ShoppingBasketBuilder.java

Во второй части я заканчиваю упражнение, нарушая всю иерархию. Пожалуйста, посмотрите на это для окончательного решения .

Ссылка: Извлечь, Внедрить, Убить: Нарушение иерархии (часть 1) ,   Извлечение, внедрение, убийство: нарушение иерархии (часть 2) от нашего партнера по JCG Сандро Манкузо в блоге Crafted Software .