Tackling Technical Debt – – breathe new life into a legacy project with Symfony2



Tackling Technical Debt – – breathe new life into a legacy project with Symfony2

0 1


symfony-legacy-slides

Slides for my talk about integrating a legacy application with Symfony2 framework.

On Github cvuorinen / symfony-legacy-slides

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

Totally separate applications, using same database
  • Running on the same server, but different doc root
  • Web server handles routing (subdomain etc.)
Use Symfony components inside the legacy app Run legacy app through Symfony fullstack framework
  • Write new features into Symfony bundles
  • Integrate legacy app with Symfony
can use different versions of PHP, nginx has pretty good options for routing Requires some bootstrapping to get everything set up, might be OK if legacy app has a decent framework to begin with PHP upgrade if < 5.3

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
}
Legacy URL scheme (/user.html /page2.html)

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

  • Use Symfony kernel events

Forward non-Symfony routes to legacy

  • Use Symfony kernel events
  • LegacyKernel + LegacyKernelListener

Forward non-Symfony routes to legacy

  • Use Symfony kernel events
  • LegacyKernel + LegacyKernelListener
  • Include legacy "front controller"
  • Capture output and return in a Response object
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;
    }
}
                    
Caveats:
  • Legacy globals scope (not global anymore)
  • No die/exit allowed (if something needs to execute in Symfony side after LegacyKernel)
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"
        }
    ]
}
                    
Works with private repos also

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",
    }
}
Works with Capifony deployments for example

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

Crawl legacy app from Symfony Load from legacy app by Symfony sub-request or ESI Port over to a Symfony service/Twig extension etc.
  • ESI=Edge Side Include
  • Might need some caching when making many separate requests
  • Sub-requests don't work without a Symfony route
  • Can't call die/exit inside sub-request

Symfony sub-request in layout.html.twig

...
<div id="sidebar">
    {% block sidebar %}
        {{ render(url('_legacy', {filename: 'menu'})) }}
    {% endblock %}
</div>
...
                    
Symfony router can be used in the legacy side to generate URLs

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
  • Best method depends on legacy app
  • Symfony custom Auth system involves many classes
  • Might be easier and better to move auth to Symfony side and get security.context from container in legacy app

Custom Authentication Provider

  • Token, Listener, AuthenticationProvider, SecurityFactory, UserProvider, User
  • SimplePreAuthenticatorInterface: just SimplePreAuthenticator & UserProvider + User

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
  • Entity & field names do not have to be same as database table/column
  • Can rename in database after not used in legacy anymore

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
Selenium etc. or even Symfony WebTestCase Symfony test env database credentials & fixtures

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
First separate all presentation logic in the original context by moving it down, then extract to template

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

@cvuorinen

cvuorinen

cvuorinen.net