How to rewrite a large project or refactoring painless for a business

  • Tutorial

The most frequently asked question is how to talk about refactoring with a manager.
In such cases, I give somewhat controversial advice: do not tell him anything!
Martin Fowler, Refactoring. Improving existing code »

Deprecation of code, difficulties with support, unpredictable bugs - these terms appear one after another in the life of the developer as the product develops. And if the former is more likely the interests of the developer, then the latter is a direct business problem.

In this article I want to share the experience of rewriting a large project and, as a bonus, bring a couple of pieces of code that have helped us and, hopefully, will help you begin this interesting path.

Debriefing


Problems


They usually begin according to a well-known scenario:
  1. The chief runs up with shouts “Nothing works for us, the main client is in danger!”;
  2. or a manager asking them to fasten an unrealizable chip;
  3. less we, the developers, so tired to dig in shit "Legacy" -code that decide to rewrite everything.

And usually this ends with general indignation and frustration, because the chip is urgently needed, customers also can’t wait, and because of the sad legacy, the team is trying to scatter. The situation is spoiled by the lack of “money for refactoring” (inaction of the team in terms of business).

As for the last point, we need to add that I do not consider the situation with a new person in the team who is eager to rewrite everything, but he can easily justify the described approach for the development of the project.

Tasks


  1. Transfer the project to modern architecture
  2. Minimize refactoring costs

Schematic diagram of implementation


Our project was originally written in Kohana, we rewrote it in Symfony2, so all examples are given in the context of these systems. However, this approach can be applied with any framework. A prerequisite is a single entry point to the application.

Initially, the application processes user requests through the entry point "app_kohana.php"

We will wrap the initial entry point in the new system, organizing a kind of "proxy".

Refactoring


Controller - a wrapper for the old system


The idea is quite simple and is as follows:
  1. We deploy two systems in parallel (kohana + symfony)
  2. Change the entry point to a new one (symfony)
  3. We organize a universal controller, which by default will forward all requests to the old system


And if there shouldn’t be any problems with the first two points, the third is of interest, because pitfalls can be found in it.

The first thing that comes to mind is to wrap the inclusion in ob_start. So let's do it:
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);
    }
}

Routing for the universal controller
application.passthrough_kohana:
    path: /{slug}
    defaults:
        _controller: ApplicationBundle:PassThrough:kohana
    requirements:
        slug: .*



The system is already working in this format, but after a while the first bug arrives. For example, incorrect handling of ajax errors. Or on the website errors are given with the code 200 instead of 404.

Here we understand that the buffer swallows the headers, so they need to be processed explicitly
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);
    }
}

After that, the flight is normal.

Problems of the old system affecting the functioning of the new


exit ()


We found places in the system where at the end of the controller operation exit () was joyfully called. This is practiced, for example, in Yii (CApplication :: end ()). This does not cause a particular headache until you start using the event model in the new system and process events that occur after the controller is executed. The most striking example is the Symfony Profiler, which stops working for requests with exit.
This case must be borne in mind and appropriate measures should be taken if necessary.

ob_end _ * ()


The ill-considered use of the ob_end functions can easily break the work of the new system by clearing the buffer of the new proxy controller. It should also be borne in mind.

Kohana_Controller_Template :: $ auto_render


The variable is responsible for automatically rendering the data received from the controller in the global template (it can greatly depend on the template engine used). During the adaptation of the new system, this can save time for debugging in places where, for example, json is displayed by simple echo $ json; exit (); . The controller will look like this:
$this->auto_render = false;
echo $json;
return;


What else to take care of


The entry points described above are ideal. Our initial entry point was app.php and it was required that after refactoring it should remain the same (reconfiguration of numerous servers looked futile). The following algorithm was selected:
  1. Rename app.php to app_kohana.php
  2. Symphony entry point is placed in app.php
  3. Profit

And everything, it would seem, started up, except for console commands, which in kohan were launched through the same file. Therefore, at the beginning of the new app.php, the following crutch was born for backward compatibility:
if (PHP_SAPI == 'cli') {
    include 'app_kohana.php';
    return;
}


Life after refactoring


New controllers


We try to write all new controllers in symfony. Separation occurs at the routing level, the necessary one is added before the “universal” route, and Kohana does not load further. So far we are writing only ajax controllers in the new system, so the question of reusing templates (Twig) remains open.

DB and Configuration


To access the database, models were generated from the current database using standard Doctrine methods. As necessary, new methods for working with the database are added to the repository. However, the database connection configuration is used from the existing Kohana. To do this, a configuration file is written that pulls data from the Kohana config and converts them into symphony configuration parameters. The logic of config search, depending on the platform, alas, is duplicated so as not to connect the Kohana classes in the new system.
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'],
]);

The config is connected in the standard way
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');
    }
}

How to continue: putting functionality into services


Further movement from Kohana to symphony fits very well into putting functional into the symphony services and using them in the old system via a DI container. It so happened that we started using the DI component before connecting the symphony to the project, so this process went quite smoothly, but nothing prevents it from scratch. The main task will be to throw the DI container from the symphony into the kohan. We did it in the Kohana style through a static property, in another framework you can find the appropriate approach.

We redefine the system class of kohan, add the property for the container there.
class Kohana extends Kohana_Core {
    /**
    * @var Symfony\Component\DependencyInjection\ContainerBuilder
    */
    public static $di;
}

And then you need to crank up a couple more frauds in order to put a DI container in this property between initializing the Kohana and executing the controller code. To do this, we divide our initialization file app_kohana.php into two parts, highlighting directly the initialization of the system and the launch of the controller itself.

/** 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';


We modify our controller, doing operations similar to app_kohana.php, but adding container forwarding between inclusions
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);
}


After that, in the old system, we can use the DI container and all services announced in the new system, including the EntityManager and new doctrine models.

In the end


Implementation advantages


  • We took the first step for the further development of the system.
  • The new system is independent of the old. All new code works without the old
  • Minimum time spent


Cons of implementation


  • Additional overhead resources for the “wrapper” while working with the old part of the system. However, compared to the delays in the old system, the overhead (both from memory and the processor) can be neglected.
  • The new system is independent of the old. We can’t use the old code in the new one, but it’s rather a plus, since we decided to rewrite it.
  • We have to support the model in two places.


Thank you for reading to the end, I wish you success in refactoring, brush away the accumulated dust from the old code!
And sorry for the awful fonts in the diagrams :(

Also popular now: