• Главная»
  • Уроки»
  • PHP»
  • Рефакторинг унаследованного кода: Часть 1 - “Золотая сборка”

Этот урок связан с проектом Рефакторинг унаследованного кода

Рефакторинг унаследованного кода: Часть 1 - “Золотая сборка”

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

В идеальном мире вам приходится работать только с новым кодом. И вы будете писать его красивым и совершенным. Вам никогда не придётся править ваш код, и никогда не придётся поддерживать проекты в течение десятков лет.

К сожалению, мы живём далеко не в идеальном мире. Нам приходится разбираться, изменять и дополнять код, которому уже немало лет. Нам приходится работать с унаследованным кодом. Давайте погрузимся с головой в это руководство, возьмём код и подготовим безопасную почву для будущих изменений.

Определение “унаследованного кода”

Унаследованному кода дано столько определений, что тяжело найти какое то одно, простое и общепринятое . Первые примеры и начало этого руководства являются лишь верхушкой айсберга. Так что я не дам вам никакого официального определения. Вместо этого я процитирую мое любимое.

“Для меня, унаследованный код - это просто код без тестов.” Michael Feathers.

Это первое формальное определение выражения унаследованный код, данное Michael Feathers в книге “Эффективная работа с унаследованным кодом”. Конечно, терминология использовалась годами, обычно по отношению к коду, который тяжело изменять. Но данное определение выражает нечто иное. Оно явно описывает проблему, так что на ум приходит простое решение. “Тяжело изменять” - очень размытое понятие. Что нужно делать с кодом, чтоб его стало проще изменять? Непонятно. С другой стороны, “код без тестов” - очень конкретное понятие. И ответ на наш предыдущий вопрос очень прост - надо сделать код тестируемым и написать для него тесты. Итак, начнём.

Обзаводимся унаследованным кодом

В этой серии статей в качестве основы будет использоваться игра Trivia Game от J.B. Rainsberger, написанная для мероприятий по борьбе с унаследованным кодом. Она написана как раз в нужном нам стиле, и является огромным полигоном для применения различного рода рефакторингов, различного уровня сложности.

Выкачиваем исходный код

Код Trivia Game расположен на GitHub, и распространяется по лицензии GPLv3, так что с ним можно свободно творить, что душе угодно. Нашу серию статей начнём с выкачки официального репозитория. К этой статье также приложен код со всеми изменениями, которые мы в нем будем делать, так что если вам на каком-то этапе станет что-то непонятно - можно краем глаза заглянуть в конечный результат.

$ git clone https://github.com/jbrains/trivia.git
Cloning into 'trivia'...
remote: Counting objects: 429, done.
remote: Compressing objects: 100% (262/262), done.
remote: Total 429 (delta 100), reused 419 (delta 93)
Receiving objects: 100% (429/429), 848.33 KiB | 305.00 KiB/s, done.
Resolving deltas: 100% (100/100), done.
Checking connectivity... done.

Открыв директорию trivia можно увидеть код на нескольких языках программирования. Мы будем работать с PHP, но вы можете выбрать тот, который вам больше по душе, и применить к нему те техники, которые будут описаны далее.

Понимаем код

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

В директории у нас есть два файла.

$ cd php/
$ ls -al
total 20
drwxr-xr-x  2 csaba csaba 4096 Mar 10 21:05 .
drwxr-xr-x 26 csaba csaba 4096 Mar 10 21:05 ..
-rw-r--r--  1 csaba csaba 5568 Mar 10 21:05 Game.php
-rw-r--r--  1 csaba csaba  410 Mar 10 21:05 GameRunner.php

Похоже, запускать нужно файл GameRunner.php.

$ php ./GameRunner.php
Chet был добавлен в игру
Количество игроков:  1
Pat был добавлен в игру
Количество игроков: 2
Sue была добавлена в игру
Количество игроков: 3
Текущий игрок - Chet
Выпало число 4
Chet теперь на ячейке 4
Категория - Поп
Вопрос #0 из категории Поп
Ответ верный!!!!
У Chet теперь есть 1 золотая монета.
Текущий игрок - Pat
Выпало число 2
Pat теперь на ячейке 2
Категория вопросов - Спорт
Вопрос #0 из категории Спорт
Ответ верный!!!!
У Pat теперь есть 1 золотая монета
Текуший игрок - Sue
Выпало число 1
Sue находится в ячейке 1
Категория вопросов Наука
Вопрос #0 из категории Наука
Ответ верный!!!!
У Sue теперь есть 1 золотая монета.
Текущий игрок Chet
Выпало число 4

## Удалено некоторое количество строк, чтобы не перегружать статью

Ответ верный!!!!
У Sue теперь есть 5 золотых монет.
Текущий игрок Chet
Выпало число 3
Chet попадает в штрафную ячейку
Chet теперь в ячейке 11
Категория вопросов Рок
Вопрос #5 из категории Рок
Ответ правильный!!!!
У Chet теперь есть 5 золотых монет
Текущий игрок Pat
Выпало число 1
Pat теперь в ячейке 10
Категория вопросов Спорт
Вопрос #1 из категории Спорт
Ответ верный!!!!
У Pat теперь 6 золотых монет

Да, мы оказались правы. Наш код запустился, и выдал какой-то результат. Анализируя результат мы получим базовое представление о том, что что происходит в программе.

  1. Мы знаем, что это игра называется Trivia. Мы знали это ещё тогда, когда выкачивали код;
  2. В нашем примере есть три игрока: Chet, Pat и Sue;
  3. В игре используется что-то вроде бросания костей или похожий алгоритм;
  4. У игрока есть его текущая локация. Может быть, это какой-то вид доски;
  5. Есть несколько категорий вопросов;
  6. Игроки отвечают на вопросы;
  7. За правильные ответы игрокам начисляется золото;
  8. За неверные ответы игроки отправляются на штрафную ячейку;
  9. Игрок может покинуть штрафную ячейку по какой-то не совсем понятной логике;
  10. Похоже на то, что пользователь, который первым набрал шесть золотых монет, побеждает;

Теперь у нас уже больше информации. Можно выяснить основное поведение приложения просто посмотрев на результат работы. В реальных условиях это может быть веб-страница, лог ошибок, база данных, сетевое соединение, файл дампа, и так далее. В других случаях, модуль, который вам нужно изменять, нельзя будет запустить изолировано. Если это так - то его нужно будет прогонять через другие модули большого приложения. Просто попытайтесь малыми силами получить хоть какой-то вменяемый результат работы унаследованного кода.

Анализируем код

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

Файл запуск игры

Я предпочитаю начинать с прогона кода через средства автоматического форматирования кода моей IDE. Это сильно улучшает читабельность кода, делая его похожим на то, к чему я привык:

… преобразуем в:

… так мне больше нравится. Возможно, на этом маленьком файле и не видно большой разницы, но на следующих разница будет хорошо заметна.

Посмотрев файл GameRunner.php легко можно подметить ряд ключевых моментов, которые мы наблюдали в результате работы приложения. Можно заметить строки, добавляющие игроков (9-11), вызов метода roll() и выбор победителя. Конечно, это ещё не все секреты логики игры, но мы можем начать с выявления основных методов, которые помогут нам разобрать остальную часть кода.

Файл игры

Также нам нужно отформатировать и файл Game.php.

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

Золотая Сборка

Необходимость изменений приводит нас к мысли о нехватке тестов. Методы Game.php достаточно сложны. Не переживайте, если они вам не понятны. На этом этапе они даже для меня являются загадкой. Унаследованный код - это загадка, которую надо понять и разгадать. Мы сделали первый шаг к его пониманию, и вот настало время второго шага.

Что такое Золотая Сборка?

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

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

Пишем генератор Золотой Сборки

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

В приложенном архиве с исходным кодом, в директории source, вы найдете директорию Test. В этой директории мы создадим файл GoldenMasterTest.php.

class GoldenMasterTest extends PHPUnit_Framework_TestCase {

    function testGenerateOutput() {
        ob_start();
        require_once __DIR__ . '/../trivia/php/GameRunner.php';
        $output = ob_get_contents();
        ob_end_clean();

        var_dump($output);
    }

}

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

Этот код очень простой, он сохраняет результат работы файла в переменной $output. require_once() выполнит весь код в подключаемом файле. В распечатке переменной мы увидим уже знакомый нам результат.

Но при другом запуске мы можем увидеть и немного другой результат.

Даже не смотря на то, что мы запустили тот же код, результаты его работы отличаются. Отличаются выпавшие цифры, отличается позиция игроков.

Инициализация генератора случайных чисел

do {

    $aGame->roll(rand(0, 5) + 1);

    if (rand(0, 9) == 7) {
        $notAWinner = $aGame->wrongAnswer();
    } else {
        $notAWinner = $aGame->wasCorrectlyAnswered();
    }

} while ($notAWinner);

Анализируя неотъемлемый код файла запуска игры, можно увидеть, что для генерации случайных чисел он использует функцию rand(). Нашим следующим шагом будет просмотр официальной документации для изучения функции rand().

“Генератор случайных чисел инициализируется автоматически”

Документация говорит нам, что инициализация происходит автоматически. Теперь у нас другая задача. Нам нужно найти способ контролировать процесс инициализации. Нам может помочь функция srand(). Вот её определение согласно документации:

Устанавливает начальное число генератора случайных чисел в seed или случайное число, если seed не указан.

Если запустить эту функцию перед любым вызовом rand(), мы всегда должны получать один и тот же результат.

function testGenerateOutput() {
    ob_start();
    srand(1);
    require_once __DIR__ . '/../trivia/php/GameRunner.php';
    $output = ob_get_contents();
    ob_end_clean();

    var_dump($output);
}

Мы выполнили srand(1) перед require_once(). Теперь результат будет всегда одним и тем же.

Пишем результат в файл

class GoldenMasterTest extends PHPUnit_Framework_TestCase {

    function testGenerateOutput() {
        file_put_contents('/tmp/gm.txt', $this->generateOutput());
        $file_content = file_get_contents('/tmp/gm.txt');
        $this->assertEquals($file_content, $this->generateOutput());
    }

    private function generateOutput() {
        ob_start();
        srand(1);
        require_once __DIR__ . '/../trivia/php/GameRunner.php';
        $output = ob_get_contents();
        ob_end_clean();
        return $output;
    }

}

Данное изменение выглядит оправданным, не так ли? Мы вынесли генерацию кода в отдельный метод, запустили его дважды и ожидаем, что результаты будут совпадать. На самом деле это не так.

А причина в том, что require_once() не запросит один и тот же файл дважды. Второй вызов метода generateOutput() вернёт пустую строку. Так что мы можем сделать? а что если просто использовать require()? Так чтобы этот код запускался каждый раз.

Что ж, тут у нас выявилась ещё одна проблема: "Cannot redeclare echoln()" (Не могу переопределить echoln()). Но откуда оно взялось? А из самого начала файла Game.php. Причина, по которой так происходит, кроется в том, что в файле GameRunner.php есть строка include __DIR__ . '/Game.php';, которая пытается подключить файл Game.php дважды, каждый раз, как мы вызываем метод generateOutput().

include_once __DIR__ . '/Game.php';

Использовав include_once в GameRunner.php мы решили проблему. Да, мы меняем GameRunner.php без тестов. Но на 99% можно быть уверенным, что это изменение не сломает сам код. Это достаточно простое и маленькое изменение, сильно оно нас не пугает. И, что самое важное - благодаря этому наши тесты будут успешны.

Запускаем несколько раз

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

function testGenerateOutput() {
    $this->generateMany(20, '/tmp/gm.txt');
    $this->generateMany(20, '/tmp/gm2.txt');
    $file_content_gm = file_get_contents('/tmp/gm.txt');
    $file_content_gm2 = file_get_contents('/tmp/gm2.txt');
    $this->assertEquals($file_content_gm, $file_content_gm2);
}

private function generateMany($times, $fileName) {
    $first = true;
    while ($times) {
        if ($first) {
            file_put_contents($fileName, $this->generateOutput());
            $first = false;
        } else {
            file_put_contents($fileName, $this->generateOutput(), FILE_APPEND);
        }
        $times--;
    }
}

Здесь мы реализовали ещё один метод: generateMany(). У него два параметра. Один - для числа прогонов генератора, а другой для имени файла назначения. Сгенерированный результат будет выведен в файл. В первый раз файлы очищаются, а в другие разы содержимое добавляется в конец файла. Можно заглянуть в файл, и увидеть результат, сгенерированный 20 раз.

Но постойте! Каждый раз выигрывает один и тот же игрок? Это вообще возможно?

cat /tmp/gm.txt | grep "has 6 Gold Coins."
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.

Да! Это возможно! Это более чем возможно. Так и должно быть. Для нашей случайной функции каждый раз имеем одно и то же инициализирующее значение. Каждый раз мы играем в одну и ту же игру (с одними и те же условиями).

Запускаем игру с различными параметрами

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

private function generateMany($times, $fileName) {
    $first = true;
    while ($times) {
        if ($first) {
            file_put_contents($fileName, $this->generateOutput($times));
            $first = false;
        } else {
            file_put_contents($fileName, $this->generateOutput($times), FILE_APPEND);
        }
        $times--;
    }
}

private function generateOutput($seed) {
    ob_start();
    srand($seed);
    require __DIR__ . '/../trivia/php/GameRunner.php';
    $output = ob_get_contents();
    ob_end_clean();
    return $output;
}

Наши тесты всё ещё проходят, так что у нас каждый раз генерируются файлы с одинаковым содержимым, но на каждой итерации мы играем в разную игру.

cat /tmp/gm.txt | grep "has 6 Gold Coins."
Sue now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Pat now has 6 Gold Coins.
Pat now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Sue now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Sue now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Sue now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Pat now has 6 Gold Coins.
Chet now has 6 Gold Coins.
Chet now has 6 Gold Coins.

Теперь у нас есть случайные победители в случайной последовательности. Выглядит неплохо.

Поднимаем до 20000

Первым делом надо попробовать запустить наш код 20000 раз.

function testGenerateOutput() {
    $times = 20000;
    $this->generateMany($times, '/tmp/gm.txt');
    $this->generateMany($times, '/tmp/gm2.txt');
    $file_content_gm = file_get_contents('/tmp/gm.txt');
    $file_content_gm2 = file_get_contents('/tmp/gm2.txt');
    $this->assertEquals($file_content_gm, $file_content_gm2);
}

Почти сработало. В результате, генерируются два файла размером 55 Мб.

ls -alh /tmp/gm*
-rw-r--r-- 1 csaba csaba 55M Mar 14 20:38 /tmp/gm2.txt
-rw-r--r-- 1 csaba csaba 55M Mar 14 20:38 /tmp/gm.txt

С другой стороны, тест завершится с ошибкой из-за нехватки памяти. Не имеет значения, сколько у вас памяти - он все равно упадет. У меня 8 Гб памяти и 4 Гб файла подкачки, и всё равно тест падает. Две эти строки слишком большие, чтобы их можно было сравнивать напрямую.

Другими словами, мы генерируем два правильных файла, но PHPUnit не может их сравнить. Нужен обходной путь.

$this->assertFileEquals('/tmp/gm.txt', '/tmp/gm2.txt');

Кажется неплохим способом, но все равно падает. Непорядок. Нужно углубиться в изучение ситуации.

$this->assertTrue($file_content_gm == $file_content_gm2);

А вот это работает.

Можно сравнить две строки, и выдать ошибку, если они отличаются. К сожалению, этот подход имеет свою небольшую цену. Оно не скажет, в чем именно эти строки различаются. Тест всего лишь скажет "Failed asserting that false is true." (Ошибка в утверждении, что false равен true). Но с этим мы разберемся в следующем уроке.

Заключительные мысли

С этим уроком мы закончили. Для одного урока мы узнали довольно-таки много, и задали себе хороший старт для будущей работы. Мы познакомились с кодом, анализировали его различными способами, и почти поняли важную логику. Затем мы написали набор тестов, чтобы покрыть как можно больше случаев. Да, наши тесты довольно медленные. На моем Core i7 выполнение теста занимает 24 секунды. К счастью, в дальнейшей разработке мы оставим файл gm.txt нетронутым, и будем генерировать только один файл на один запуск. Но даже 12 секунд - это слишком много для такой небольшой базы кода.

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

Данный урок подготовлен для вас командой сайта ruseller.com
Источник урока: http://code.tutsplus.com/tutorials/refactoring-legacy-code-part-1-the-golden-master--cms-20331
Перевел: Станислав Протасевич
Урок создан: 29 Октября 2014
Просмотров: 7491
Правила перепечатки


5 последних уроков рубрики "PHP"

  • Фильтрация данных с помощью zend-filter

    Когда речь идёт о безопасности веб-сайта, то фраза "фильтруйте всё, экранируйте всё" всегда будет актуальна. Сегодня поговорим о фильтрации данных.

  • Контекстное экранирование с помощью zend-escaper

    Обеспечение безопасности веб-сайта — это не только защита от SQL инъекций, но и протекция от межсайтового скриптинга (XSS), межсайтовой подделки запросов (CSRF) и от других видов атак. В частности, вам нужно очень осторожно подходить к формированию HTML, CSS и JavaScript кода.

  • Подключение Zend модулей к Expressive

    Expressive 2 поддерживает возможность подключения других ZF компонент по специальной схеме. Не всем нравится данное решение. В этой статье мы расскажем как улучшили процесс подключение нескольких модулей.

  • Совет: отправка информации в Google Analytics через API

    Предположим, что вам необходимо отправить какую-то информацию в Google Analytics из серверного скрипта. Как это сделать. Ответ в этой заметке.

  • Подборка PHP песочниц

    Подборка из нескольких видов PHP песочниц. На некоторых вы в режиме online сможете потестить свой код, но есть так же решения, которые можно внедрить на свой сайт.

^ Наверх ^