Tackling Technical Debt

– breathe new life into a legacy project with Symfony2





November Camp 14.11.2014

by Carl Vuorinen / @cvuorinen

Legacy

Image by Michael Leunig

Full rewrite


  • Costly & risky
  • Often fails
  • Usually not an option

Gradual migration


  • Legacy & Fullstack Symfony2 side by side
  • Write new features on Symfony side
  • Maintain old code on legacy side
  • Gradually modernize and migrate old code to Symfony side

Goals


  • No "downgrade" or regressions
  • Smoothly migrate old code over time
  • End-user doesn't know there are two systems
    or notice the difference between old and new

Objectives


  • Same layout and UI
  • Shared authentication
    • User only logs in once
  • Shared configuration
    • Parameters only defined in one place
  • Easy for developers to work with and deploy

Alternatives


  1. Totally separate applications, using same database
    • Running on the same server, but different doc root
    • Web server handles routing (subdomain etc.)
  2. Use Symfony components inside the legacy app
  3. Run legacy app through Symfony fullstack framework
    • Write new features into Symfony bundles
    • Integrate legacy app with Symfony

Introducing reference project


  • About 12 years old
    • Which is like 84 in Internet years!
  • Lots of different developers over the years
  • 116 748 LOC
  • No tests
  • Let's look at some code

index.php "autoloader"


// Load configuration
require_once ("config.php");
require_once ("includes/funcs.php");
require_once ("includes/common.php");

// Load classes
require_once ("classes/info.php");
require_once ("classes/user.php");
require_once ("classes/dir.php");
// ... ~100 lines more
                    

index.php "router"


switch ($page) {
    case "user":
        $user = new User($user_id);

        if ($action == 'edit') {
            $body = $user->editUser();
        } else {
            $body = $user->userInfo();
        }
        break;
    case "page2":
        $heading = "Some page";
        $body = get_some_page();
        break;
    // ... ~1500 lines more
}

Classes (A.K.A. God objects)


class User extends Db
{
    // inherits from Db (but sometimes overridden)
    function save() {}
    function delete() {}
    function log() {}
    function checkAccess() {}
    function formTextInput() {}
    function validateDate() {}
    // ... lots more

    function printMenu() {}
    function printInfo() {}
    function printUpdateForm() {}
    // ... lots more
}

Forward non-Symfony routes to legacy

Forward non-Symfony routes to legacy

Forward non-Symfony routes to legacy


class LegacyKernel implements HttpKernelInterface
{
    public function handle(Request $request, ...)
    {
        ob_start();

        $legacyDir = dirname($this->legacyAppPath);
        chdir($legacyDir);

        require_once $this->legacyAppPath;

        $response = new Response(ob_get_clean());

        return $response;
    }
}
                    

class LegacyKernelListener implements EventSubscriberInterface
{
    public function onKernelException($event)
    {
        $exception = $event->getException();

        if ($exception instanceof NotFoundHttpException) {
            $request = $event->getRequest();
            $response = $this->legacyKernel->handle($request);

            // Override 404 status code with 200
            $response->headers->set('X-Status-Code', 200);

            $event->setResponse($response);
        }
    }
}
                    

Alternatives to LegacyKernel
& LegacyKernelListener


  • Try to include all 'filename.php' routes
  • Whitelist all possible 'filename.php' routes
  • Watch for certain GET param
  • etc.


LegacyController + "Catch All" route


class LegacyController extends Controller
{
    /**
     * @Route("/{filename}.php", name="_legacy")
     */
    public function legacyAction($filename)
    {
        $legacyPath = $this->container
                      ->getParameter('legacy.path');

        ob_start();
        chdir($legacyAppPath);
        require_once $legacyAppPath . $filename . '.php';
        $response = new Response(ob_get_clean());

        return $response;
    }
}

There's a bundle for that


Integrate legacy app with Symfony

  • Goal: Use Symfony services and parameters in legacy app
  • Assign Request & Service Container
    to a variable in legacy scope
  • Get services & parameters from the container in legacy app
    • Any Symfony component or Symfony framework built-in service
    • Any custom service written in Symfony bundles
    • Any configuration parameters from parameters.yml

class LegacyKernel implements HttpKernelInterface
{
    public function handle(Request $request, ...)
    {
        // ...

        // Assign Container to a local variable
        // so it can be used in legacy app
        $container = $this->container;
        // Request is already in a local variable

        require_once $this->legacyAppPath;

        // ...
    }
}
                    

Legacy index.php


// Make Symfony Container and Request global
// so they can be used in other functions & classes

/** @var \Symfony\Component\DependencyInjection\Container $container */
$GLOBALS['container'] = $container;

/** @var \Symfony\Component\HttpFoundation\Request $request */
$GLOBALS['request'] = $request;
                    

Exception detected! Global container!! OMG!!1!

500 Internal Server Conflict - RuntimeOmgException







Perfect is the enemy of better.

Version Control


  • Goal: easy to work with and deploy
  • Different repos for Symfony app & Legacy app
  • Legacy in subdirectory as git submodule
    • OR
  • Legacy as a composer dependency

composer.json


{
    "require": {
        ...
        "cvuorinen/legacy-example": "dev-symfony-integration",
    },
    ...
    "repositories": [
        {
            "type": "vcs",
            "url": "git@github.com:cvuorinen/legacy-example.git"
        }
    ]
}
                    

Assets

  • Goal: legacy works without modification & easy to deploy
  • Symlink legacy asset directories from Symfony web/ dir
$ ls -l web/
app_dev.php
app.php
config.php
css -> ../legacy/css
favicon.ico
images -> ../legacy/images
robots.txt

Automate symlinks with composer scripts


{
    ...
    "scripts": {
        "post-install-cmd": [
            ...
            "Cvuorinen\\LegacyBundle\\Composer\\AssetSymlinks::create",
        ],
        "post-update-cmd": [
            ...
            "Cvuorinen\\LegacyBundle\\Composer\\AssetSymlinks::create",
        ]
    },
    "extra": {
        ...
        "legacy-app-dir": "legacy",
    }
}

Same layout and UI


  • Goal: both legacy and Symfony app look the same
  • Create Twig base layout by copying from legacy
  • Layout changes so rarely, it doesn't really matter that it's duplicated in two places
  • Problem: layout areas that have some logic
    • For example: navigation & menus, user personalized info/actions
    • We don't want to duplicate that logic

Shared layout components


Alternatives

  1. Crawl legacy app from Symfony
  2. Load from legacy app by Symfony sub-request or ESI
  3. Port over to a Symfony service/Twig extension etc.

Symfony sub-request in layout.html.twig


...
<div id="sidebar">
    {% block sidebar %}
        {{ render(url('_legacy', {filename: 'menu'})) }}
    {% endblock %}
</div>
...
                    

Shared Authentication


  • Goal: User logs in once, authenticated for both legacy and Symfony app
  • Alternatives:
    • Move Auth to Symfony side and refactor legacy code to use it
    • Keep legacy Auth and create a Symfony wrapper for it

Custom Authentication Provider


Database access


  • Goal: both legacy and Symfony apps use the same database
  • Symfony side uses Doctrine ORM
  • Map database tables as Doctrine Entities
    • Only when needed
    • Use meaningful names in the Entity

config.yml


...
doctrine:
    dbal:
        ...
        schema_filter: ~^(?!(^some_table$)|(^stuff$) ⏎
                           |(^super_secret_admin_stuff$) ⏎
                           ... # many, many tables here
                           (^last_table$))~
                    

Part 1: Symfony integration


  • Legacy requests go through Symfony
  • Symfony service container in legacy
  • Shared configuration
  • Same layout and UI
  • Shared authentication
  • Easy for developers to work with and deploy

Part 2: Refactoring legacy


  • Gradual migration
    • Smoothly migrate old code over time
  • Don't try to do too much at once
  • Write tests!

Write tests


  • Unit & functional tests if you can
  • If your legacy project is immune to unit testing,
    write characterization tests
    • "a characterization test is a means to describe (characterize) the actual behavior of an existing piece of software, and therefore protect existing behavior of legacy code..."
      en.wikipedia.org/wiki/Characterization_test

Database access


  • Goal: Separate database access from business logic
  • Move database queries to Repository classes
  • Can be Doctrine Entity repository but doesn't need to be
  • Get repository object from container in legacy app

UserRepository.php


class UserRepository extends EntityRepository
{
    /**
     * @param User $user
     */
    public function save(User $user)
    {
        $this->_em->persist($user);
        $this->_em->flush();
    }
}

Legacy functions.php etc.


Before


function getUsername($id)
{
    $sql = "SELECT username FROM user WHERE id=" . (int)$id;
    $result = mysql_fetch_array(mysql_query($sql));

    return $result[0];
}

Legacy functions.php etc.


After


function getUsername($id)
{
    global $container;
    $userRepository = $container->get('acme.demo.repository.user');
    $user = $userRepository->find($id);

    return $user->getUsername();
}

View templates


  • Goal: Decouple presentation logic from business logic
  • Create Twig templates in a Symfony bundle
  • Get Twig service from container in legacy app
  • Render template and pass in variables

Legacy user.php etc.


Before


function userInfo()
{
    $html = '<p><strong>Id:</strong> ' . $this->keyvalue . '</p>';
    $html .= '<p><strong>Name:</strong> ' . $this->firstname . ' '
           . $this->lastname . '</p>';
    if (isAdmin()) {
        $html .= '<h3>Actions:</h3>';
        $html .= '<a href="' . $baseUrl
               . '?action=edit&page=/user.html">Edit</a>';
    }

    return $html;
}

views/User/info.html.twig



{% extends "CvuorinenLegacyBundle::layout.html.twig" %}

{% block body %}
    <p><strong>Id:</strong> {{ user.id }}</p>
    <p><strong>Name:</strong>
        {{ user.firstname }} {{ user.lastname }}</p>
    {% if isAdmin %}
        <h3>Actions:</h3>
        <a href="{{ baseUrl }}?action=edit&page=/user.html">Edit</a>
    {% endif %}
{% endblock %}

Legacy user.php etc.


After


function userInfo()
{
    $data = [
        'user' => $this->user,
        'isAdmin' => isAdmin(),
        'baseUrl' => $baseUrl
    ];

    return $this->twig->render(
        'CvuorinenLegacyBundle:User:info.html.twig',
        $data
    );
}

Domain logic


  • Goal: separate domain logic from controller logic
  • Create domain objects, service classes, events etc.
    for domain logic
  • Only thing left should be controller logic
  • Finally move controller logic to a controller in Symfony side & add routes

Example Code in GitHub


Book recommendations



A Year With Symfony

by Matthias Noback


Modernizing Legacy
Applications In PHP

by Paul M. Jones

Thank you !


Please leave feedback
at

joind.in/12538