Pull to refresh

Как переписать большой проект или безболезненный для бизнеса рефакторинг

Reading time7 min
Views25K

Вопрос, который мне задают чаще всего, — как разговаривать о рефакторинге с руководителем?
В таких случаях я даю несколько спорный совет: не говорите ему ничего!
Мартин Фаулер, «Рефакторинг. Улучшение существующего кода»

Устаревание кода, трудности с поддержкой, непредсказуемые баги — эти термины один за другим появляются в жизни разработчика по мере разработки продукта. И если первое — это скорее интересы разработчика, то последнее — это прямая проблема бизнеса.

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

Разбор полетов


Проблемы


Они обычно начинаются по известному сценарию:
  1. Прибегает начальник с воплями «У нас ничего не работает, главный клиент под угрозой!»;
  2. или менеджер с просьбой прикрутить нереализуемую фишку;
  3. реже мы, разработчики, настолько устаем копаться в говне «легаси»-коде, что решаем переписать всё.

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

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

Задачи


  1. Перевести проект на современную архитектуру
  2. Обеспечить минимальные затраты на рефакторинг

Принципиальная схема реализации


Наш проект был изначально написан на Kohana, переписывали мы его на Symfony2, поэтому все примеры приведены в контексте этих систем. Однако, данный подход можно применять с любыми фреймворками. Необходимое требование — единая точка входа в приложение.

Изначально приложение обрабатывает запросы пользователя через точку входа «app_kohana.php»

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

Рефакторинг


Контроллер — обертка для старой системы


Идея довольно проста и заключается в следующем:
  1. Разворачиваем параллельно две системы (kohana + symfony)
  2. Меняем точку входа на новую (symfony)
  3. Организуем универсальный контроллер, который по умолчанию будет пробрасывать все запросы в старую систему


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

Первое, что приходит в голову — обернуть инклюд в ob_start. Так и сделаем:
class PassThroughController extends Symfony\Bundle\FrameworkBundle\Controller\Controller {
    public function kohanaAction()
    {
        ob_start();
        $kohanaPath = $this->container->getParameter('kernel.root_dir') . '/../app_kohana.php';
        include $kohanaPath;
        $response = ob_get_clean();
        return new Response($response);
    }
}

Роутинг для универсального контролера
application.passthrough_kohana:
    path: /{slug}
    defaults:
        _controller: ApplicationBundle:PassThrough:kohana
    requirements:
        slug: .*



В таком формате система уже работает, но спустя какое-то время прилетает первый баг. Например, некорректная обработка ajax-ошибок. Или на сайте ошибки отдаются с кодом 200 вместо 404.

Тут мы понимаем, что буфер проглатывает заголовки, поэтому их нужно обрабатывать явным образом
class PassThroughController extends Symfony\Bundle\FrameworkBundle\Controller\Controller {
    public function kohanaAction()
    {
        ob_start();
        $kohanaPath = $this->container->getParameter('kernel.root_dir') . '/../app_kohana.php';
        include $kohanaPath;

        $headers = headers_list();
        $code = http_response_code();
        $response = ob_get_clean();

        return new Response($response, $code, $headers);
    }
}

После этого полёт нормальный.

Проблемы старой системы, влияющие на функционирование новой


exit()


У нас в системе нашлись места, где в конце работы контроллера радостно вызывался exit(). Это практикуется, например, в Yii (CApplication::end()). Особой головной боли это не доставляет до тех пор, пока не начинаешь использовать событийную модель в новой системе и обрабатывать события, случающиеся после выполнения контроллера. Самый яркий пример — Symfony Profiler, который прекращает работать для запросов с exit'ом.
Данный случай нужно иметь в виду и при необходимости предпринимать соответствующие меры.

ob_end_*()


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

Kohana_Controller_Template::$auto_render


Переменная отвечает за автоматическую отрисовку полученных из контроллера данных в глобальном шаблоне (может сильно зависеть от используемого шаблонизатора). Во время адаптации новой системы это может сэкономить время на отладку в местах, где, например, json выводится простым echo $json; exit();. Контроллер примет примерно следующий вид:
$this->auto_render = false;
echo $json;
return;


О чем еще стоит позаботиться


Описанные выше точки входа — это идеальная ситуация. У нас изначально точка входа была app.php и требовалось, чтобы после рефакторинга она же и осталась (переконфигурирование многочисленных серверов выглядело бесперспективным). Выбран был следующий алгоритм:
  1. Переименовываем app.php в app_kohana.php
  2. Точку входа симфони размещаем в app.php
  3. Profit

И все, казалось бы, завелось, кроме консольных команд, которые в кохане запускались через тот же файл. Поэтому в начале нового app.php родился следующий костылик для обратной совместимости:
if (PHP_SAPI == 'cli') {
    include 'app_kohana.php';
    return;
}


Жизнь после рефакторинга


Новые контроллеры


Все новые контроллеры мы стараемся писать в symfony. Разделение происходит на уровне роутинга, перед «универсальным» маршрутом дописывается нужный, и Kohana дальше не загружается. Пока мы пишем в новой системе только ajax-контроллеры, поэтому вопрос с переиспользованием шаблонов (Twig) остается открытым.

БД и Конфигурация


Для доступа к БД были сгенерированы модели из текущей базы стандартными методами Doctrine. В репозитории по мере необходимости добавляются новые методы работы с БД. Однако, конфигурация подключения к БД используется существующая из коханы. Для этого написан конфигурационный файл, который подтягивает данные из конфига коханы и преобразует их в параметры конфигурации симфони. Логика поиска конфига в зависимости от платформы, увы, продублирована, чтобы не подключать классы коханы в новой системе.
Application/Resources/config/kohana.php
/** @var \Symfony\Component\DependencyInjection\ContainerBuilder $container */
$kohanaDatabaseConfig = [];
$kohanaConfigPath = $container->getParameter('kernel.root_dir') . '/config';

if (!defined('SYSPATH')) {
    define('SYSPATH', realpath($container->getParameter('kernel.root_dir') . '/../vendor/kohana/core') . '/');
}

$mainConfig = $kohanaConfigPath . '/database.php';
if (file_exists($mainConfig)) {
    $kohanaDatabaseConfig = include $mainConfig;
}

if (isset($_SERVER['PLATFORM'])) {
    $kohanaEnvConfig = $kohanaConfigPath . '/' . $_SERVER['PLATFORM'] . '/database.php';
    if (file_exists($kohanaEnvConfig)) {
        $kohanaDatabaseConfig = array_merge($kohanaDatabaseConfig, include $kohanaEnvConfig);
    }
}

if (empty($kohanaDatabaseConfig['default'])) {
    throw new \Symfony\Component\Filesystem\Exception\FileNotFoundException('Could not load database config');
}

$dbParams = $kohanaDatabaseConfig['default'];

$container->getParameterBag()->add([
    'database_driver'   => 'pdo_mysql',
    'database_host'     => $dbParams['connection']['hostname'],
    'database_port'     => null,
    'database_name'     => $dbParams['connection']['database'],
    'database_user'     => $dbParams['connection']['username'],
    'database_password' => $dbParams['connection']['password'],
]);

Подключается конфиг стандартным способом
Application/DependencyInjection/ApplicationExtension.php
class ApplicationExtension extends Symfony\Component\HttpKernel\DependencyInjection\Extension {
    public function load(array $configs, ContainerBuilder $container) {
        $loader = new Loader\PhpFileLoader($container, new FileLocator(__DIR__.'/../Resources/config'));
        $loader->load('kohana.php');
    }
}

Как продолжать: вынесение функционала в сервисы


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

Переопределяем системный класс коханы, добавляем туда свойство для контейнера.
class Kohana extends Kohana_Core {
    /**
    * @var Symfony\Component\DependencyInjection\ContainerBuilder
    */
    public static $di;
}

А дальше нужно провернуть еще пару махинаций, чтобы положить в этой свойство DI-контейнер между инициализацией коханы и выполнением кода контроллера. Для этого разделим наш файл инициализации app_kohana.php на две части, выделив непосредственно инициализацию системы и сам запуск контроллера.

/** app_kohana_init.php */
// тут инициализация фреймворка, включая системные константы и bootstrap

/** app_kohana_run.php */
echo Request::factory(TRUE, array(), FALSE)
    ->execute()
    ->send_headers(TRUE)
    ->body();

/** app_kohana.php */
include 'app_kohana_init.php';
include 'app_kohana_run.php';


Модифицируем наш контроллер, проделывая похожие с app_kohana.php операции, но добавляя между инклюдами проброс контейнера
public function kohanaAction() {
    ob_start();

    $kohanaPath = $this->container->getParameter('kernel.root_dir') . '/..';
    include $kohanaPath . '/app_kohana_init.php';
    \Kohana::$di = $this->container;
    include $kohanaPath . '/app_kohana_run.php';

    $headers = headers_list();
    $code = http_response_code();
    $response = ob_get_clean();

    return new Response($response, $code, $headers);
}


После этого мы в старой системе можем использовать DI-контейнер и все объявленные в новой системе сервисы, включая EntityManager и новые модели доктрины.

Напоследок


Плюсы реализации


  • Мы сделали первый шаг для дальнейшего развития системы.
  • Новая система независима от старой. Весь новый код работает без участия старого
  • Минимум потраченного времени


Минусы реализации


  • Дополнительные накладные ресурсы на «обертку» во время работы со старой частью системы. Однако, по сравнению с задержками в старой системе, оверхедом (как по памяти, так и по процессору) можно пренебречь.
  • Новая система независима от старой. Мы не можем использовать старый код в новой, но это скорее плюс, раз уж мы решились переписывать.
  • Приходится поддерживать модели в двух местах.


Спасибо, что дочитали до конца, желаю успехов в рефакторинге, смахните накопившуюся пыль со старого кода!
И простите за ужасные шрифты на диаграммах :(
Tags:
Hubs:
Total votes 24: ↑19 and ↓5+14
Comments37

Articles