Вопрос, который мне задают чаще всего, — как разговаривать о рефакторинге с руководителем?Мартин Фаулер, «Рефакторинг. Улучшение существующего кода»
В таких случаях я даю несколько спорный совет: не говорите ему ничего!
Устаревание кода, трудности с поддержкой, непредсказуемые баги — эти термины один за другим появляются в жизни разработчика по мере разработки продукта. И если первое — это скорее интересы разработчика, то последнее — это прямая проблема бизнеса.
В этой статье я хочу поделиться опытом переписывания большого проекта и как бонус привести пару кусков кода, которые помогли нам и, надеюсь, помогут вам начать этот интересный путь.
Разбор полетов
Проблемы
Они обычно начинаются по известному сценарию:
- Прибегает начальник с воплями «У нас ничего не работает, главный клиент под угрозой!»;
- или менеджер с просьбой прикрутить нереализуемую фишку;
- реже мы, разработчики, настолько устаем копаться в
говне«легаси»-коде, что решаем переписать всё.
И обычно это заканчивается всеобщим негодованием и разладом, потому что фишка нужна срочно, клиенты тоже ждать не могут, а из-за печального наследия команда стремится разбежаться. Ситуацию портит отсутствие «денег на рефакторинг» (бездействие команды в понятиях бизнеса)
Насчет последнего пункта нужно добавить, что ситуацию с новым человеком в команде, который рвется всё переписать, я не рассматриваю, однако он может запросто аргументировать описанный подход для развития проекта.
Задачи
- Перевести проект на современную архитектуру
- Обеспечить минимальные затраты на рефакторинг
Принципиальная схема реализации
Наш проект был изначально написан на Kohana, переписывали мы его на Symfony2, поэтому все примеры приведены в контексте этих систем. Однако, данный подход можно применять с любыми фреймворками. Необходимое требование — единая точка входа в приложение.
Изначально приложение обрабатывает запросы пользователя через точку входа «app_kohana.php»
Мы будем оборачивать начальную точку входа в новой системе, организовывая своеобразный «прокси».
Рефакторинг
Контроллер — обертка для старой системы
Идея довольно проста и заключается в следующем:
- Разворачиваем параллельно две системы (kohana + symfony)
- Меняем точку входа на новую (symfony)
- Организуем универсальный контроллер, который по умолчанию будет пробрасывать все запросы в старую систему
И если с первыми двумя пунктами проблем возникнуть не должно, то третий представляет интерес, потому как в нем могут обнаружиться подводные камни.
Первое, что приходит в голову — обернуть инклюд в 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 и требовалось, чтобы после рефакторинга она же и осталась (переконфигурирование многочисленных серверов выглядело бесперспективным). Выбран был следующий алгоритм:
- Переименовываем app.php в app_kohana.php
- Точку входа симфони размещаем в app.php
- 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 и новые модели доктрины.
Напоследок
Плюсы реализации
- Мы сделали первый шаг для дальнейшего развития системы.
- Новая система независима от старой. Весь новый код работает без участия старого
- Минимум потраченного времени
Минусы реализации
- Дополнительные накладные ресурсы на «обертку» во время работы со старой частью системы. Однако, по сравнению с задержками в старой системе, оверхедом (как по памяти, так и по процессору) можно пренебречь.
- Новая система независима от старой. Мы не можем использовать старый код в новой, но это скорее плюс, раз уж мы решились переписывать.
- Приходится поддерживать модели в двух местах.
Спасибо, что дочитали до конца, желаю успехов в рефакторинге, смахните накопившуюся пыль со старого кода!
И простите за ужасные шрифты на диаграммах :(