Tackling Technical Debt
– breathe new life into a legacy project with Symfony2
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
- 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
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
- 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
Book recommendations
by Matthias Noback
by Paul M. Jones
Thank you !
Please leave feedback
at