Migration progressive

Retour d'expérience - Veolys

Problématiques

  • Comment migrer une application existante vers Symfony3 dans les meilleures conditions
  • Comment faire cohabiter 2 applicatifs différents sur un même projet pendant un laps de temps plus ou moins long
  • Comment exploiter des fonctionnalités ou modules de l'ancien système avec le nouveau sans pénaliser l’applicatif
  • Comment développer des cycles courts et un niveau d'efficacité optimum tout en conservant un code de qualité

D'une migration progressive

Veolys

Un outils de gestion d'ensemble immobilier

 

L'existant

La nouvelle application

La (petite) liste de features

Statistiques

1er commit git- Gilles 2007
(une précédente version éxistait)

Legacy: 200 000 lignes de codes

Symfony3: 65 000 lignes de codes

500 routes hors webservice/api

Migration de la stack

PHP 5.3

Apache 2.2

Mysql 5.5

Memcache

PHP 7

Apache 2.4

Percona Server - Mysql 5.7

Redis

Mentions honorables

symfony 1.0 (beta) avec vendor commité

Mot de passe en clair parce que c'est plus facile à lire/modifier pour le client

Un webservice en SOAP, avec un seul point d'entré qui accepte un fichier XML sans règles de validation

clement.bertillon - Francoisgueguen - Jules Pietri - Jérémy DERUSSE - Jérémy Derussé - Loïc Chardonnet - Mathieu MARCHOIS - Michel ROCA D'HUYTEZA - Olga ROTACH - PaskR - Quentin GUILLEMINEAU - Romain GAUTIER - Salah MEHARGA - Sylvain Jaune - alexandre.salome - fabien - fred - fx.deguillebon - geoffrey.bachelet - gilles - gregoire.hubert - gregoire.pineau - illusionOfParadise - inal.djafar - itkg-nanne - jeremy.derusse - julien.levasseur - loic.chardonnet - mRoca - mathieu.cavanne - maxime.douailin - pierre.cahard - romain.dorgueil - romain.gautier - roupen.torossian - sensio - simon.franquet - sofany.ong - thibaut.prim - vincent.dravert - xavier.briand - yohann.giarelli

git shortlog --summary

Workflow

Analyse

Audit d'architecture

Etude de migration
progressive

Problématiques

- Modèle

- Tests

- Cohabitation

- Sécurité

Cohabitation

Maintenir une application PHP tiers au sein d'une application Symfony

Legacy

<?php

// web/index.php

require_once __DIR__.'/../Symfony2/app/bootstrap.php.cache';
require_once __DIR__.'/../Symfony2/app/StaticKernel.php';
$staticKernel = StaticKernel::getInstance();

$request = Request::createFromGlobals();
$matcher = $staticKernel->getUrlMatcher($request);

try {
    $attributes = $matcher->match($request->getPathInfo());
} catch (ResourceNotFoundException $e) {
    chdir(__DIR__.'/../symfony1/web');
    require_once __DIR__.'/../symfony1/web/index.php';

    return;
} catch (MethodNotAllowedException $e) {
}

$kernel = $staticKernel->getKernel();
$kernel->loadClassCache();
$response = $kernel->handle($request);
$response->send();
$kernel->terminate($request, $response);

V1 : Passer par le front controller

Symfony2

Symfony2

symfony1

Inconvénients

  • Si on tombe sur une route Symfony2, on doit appeler le routing à deux reprises
  • Les erreurs client/serveur peuvent être gérés par le legacy
  • On ajoute pleins de règles spécifiques dans un script PHP qui ne resemble plus à rien

Solution

C'est la nouvelle application qui prend la main et qui va wrapper le legacy

Veolys-reboot

Symfony3

TheodoEvolutionLegacyWrapperBundle

Supporte: symfony1.4 / symfony1.5 / CodeIgniter
et custom kernel

Comment ça marche ?

Symfony3 cherche un controller
qui match la request

HTTP Request

Si aucune route Sf3, on passe la main au legacy pour faire le même travail

/**
 * @param GetResponseEvent $event
 *
 * @return GetResponseEvent
 */
public function onKernelRequest(GetResponseEvent $event)
{
    try {
        $this->routerListener->onKernelRequest($event);
    } catch (NotFoundHttpException $e) {
        // Log ...

        $response = $this->legacyKernel->handle($event->getRequest(), 
            $event->getRequestType(), true);

        if ($response->getStatusCode() !== 404) {
            $event->setResponse($response);

            return $event;
        }
    }
}

Router Listener

Custom kernel

class Symfony10Kernel extends LegacyKernel
{
    /** @var ContainerInterface */
    private $container;

    public function handle(Request $request, $type = self::MASTER_REQUEST, $catch = true)
    {
        $context = $this->setUpContext($request);

        ob_start();
        // Dispatch request
        try {
            $context->getController()->dispatch();
        } catch (\sfStopException $e) {
        } catch (\Exception $e) {
            throw $e;
        } finally {
            $context->shutdown();
        }
        $stdout = ob_get_contents();
        ob_end_clean();

        return $this->convertResponse($context->getResponse(), $stdout);
    }

    // ...
}
// \sfContext
# app/config/config.yml

theodo_evolution_legacy_wrapper:
    root_dir: %kernel.root_dir%/../legacy
    kernel:
        id: legacy.kernel.symfony10
        options:
            application: frontend
            environment: '%kernel.environment%'
            debug:       '%kernel.debug%'

Configuration

Authentification cross application

Lecture de session: TheodoEvolutionSessionBundle

 

Authentification:
PreAuthenticatedListener custom

Authentification géré par l'application legacy

TheodoEvolutionSessionBundle

Supporte: symfony1.x / CodeIgniter

class BagConfiguration implements BagManagerConfigurationInterface
{
    private $namespaces = array(
        BagManagerConfigurationInterface::LAST_REQUEST_NAMESPACE => 'symfony/user/sfUser/lastRequest',
        BagManagerConfigurationInterface::AUTH_NAMESPACE         => 'symfony/user/sfUser/authenticated',
        BagManagerConfigurationInterface::CREDENTIAL_NAMESPACE   => 'symfony/user/sfUser/credentials',
        BagManagerConfigurationInterface::ATTRIBUTE_NAMESPACE    => 'symfony/user/sfUser/attributes',
        BagManagerConfigurationInterface::CULTURE_NAMESPACE      => 'symfony/user/sfUser/culture',
    );

    /**
     * {@inheritdoc}
     */
    public function getNamespaces()
    {
        return $this->namespaces;
    }

    /**
     * {@inheritdoc}
     */
    public function getNamespace($key)
    {
        return $this->namespaces[$key];
    }
}

PreAuthenticatedListener

namespace Veolys\LegacyBundle\Security\Firewall;

/**
 * Retrieves the user ID from the symfony1 session.
 */
class Symfony1SessionListener extends AbstractPreAuthenticatedListener
{
    /**
     * Gets the user and credentials from the Request.
     */
    protected function getPreAuthenticatedData(Request $request)
    {
        if (!$this->veolysSession->isAuthenticated()) {
            // For call the entryPoint and redirect the user to /
            throw new BadCredentialsException();
        }

        return [(string) $this->veolysSession->getUserId(), null];
    }
}

Security
=
Authentication
+
???

Authorization

mysql> select id, name from veolys_permission;

+-----+-------------------------------------------------+
| id  | name                                            |
+-----+-------------------------------------------------+
|   1 | Building/Edit                                   |
|   3 | HotlineRequest/View                             |
|   5 | HotlineRequest/ViewAll                          |
|   6 | HotlineRequest/Post                             |
|   7 | HotlineRequest/PostForAll                       |
|   8 | HotlineRequest/DetailsChangeDescription         |
|   9 | HotlineRequest/DetailsDecideConclusion          |
|  16 | HotlineRequest/IsDispatcher                     |

# ...

| 158 | HotlineRequest/DelayAlert                       |
| 159 | HotlineRequest/Indicators                       |
| 160 | HotlineRequest/Lots                             |
+-----+-------------------------------------------------+
149 rows in set (0.00 sec)

Permissions utilisateurs

namespace Veolys\LegacyBundle\Security\Authentication\Provider;

/**
 * Retrieves the user and his rights from the database
 */
class Symfony1SessionProvider implements AuthenticationProviderInterface
{
    public function authenticate(TokenInterface $token)
    {
        $username = $token->getUsername();
        $user = $this->userProvider
            ->loadUserByUsername($username) // l'Id qui vient du PreAuthenticatedListener
        ;

        if (!$user) {
            throw new AuthenticationException('The symfony1 session authentication failed.');
        }

        $roles = $this->em
            ->getRepository('VeolysApplicationBundle:VeolysUser')
            ->getRoles($user)
        ;
        $user->setRoles($roles);

        $authenticatedToken = new PreAuthenticatedToken($user, null, 'veolys', $roles);

        return $authenticatedToken;
    }
}

AuthenticationProvider

    public function getRoles($user)
    {
        $permissions = $this->getUserPermissions();
  
        $roles = array_map(
            function ($permission) {
                preg_match_all('#((?:^|[A-Z])[a-z]+)/?#', $permission['name'], $matches);
                $name = strtoupper(implode('_', $matches[1]));

                return 'ROLE_'.$name;
            },
            $permissions
        );

        if ($user->isSuperAdmin()) {
            $roles[] = 'ROLE_SUPER_ADMIN';
        }

        $roles[] = 'ROLE_USER';

        return $roles;
    }
HotlineRequest/Post => ROLE_HOTLINE_REQUEST_POST
/**
 * @Route("/edit", name="i_love_veolys")
 *
 * @Security("has_role('ROLE_HOTLINE_REQUEST_POST')")
 */
public function editAction(Request $request): Response
{
}

Cas d'étude
Première migration

Première étape

Reverse engineering du legacy (symfony1)

Legacy - Type de notification

Legacy - Permissions utilisateurs

Deux options

  • On migre tout maintenant
Estimation: il faudra {{ random(20) + 20 }} jours 
+ 3 jours de récup 
  • On migre pas, enfin on migrera plus tard...

Solution

Pas de migration, on va appeler le legacy

// src/LegacyBundle/Kernel/Symfony10Kernel.php

class Symfony10Kernel extends LegacyKernel
{
    public function handle(Request $request)
    {
    }

    public function notify($object)
    {
    }

    // ...
}

Comment ?

Deuxième étape

Identifier le code legacy que l'on souhaite appeler

  // legacy/plugins/appVeolysHotlineModule/lib/model/HotlineRequest.php

  public function notifyChange()
  {
    // ...

    $broadcastList = $this->getOrCreateUserBroadcastList();
    $broadcastList->notify($this)
  }
// src/LegacyBundle/Kernel/Symfony10Kernel.php

class Symfony10Kernel extends LegacyKernel
{
    /**
     * Call the notify method from the legacy UserBroadCastList model.
     */
    public function notify($objectSF3)
    {
        // Map the doctrine entity to the propel model
        $legacyObject = $this->transformToLegacy($objectSF3);

        // Call the symfony1 method to get a UserBroadcastList
        $userBroadCastList = $legacyObject->getOrCreateUserBroadcastList();

        // Call the legacy method notify
        try {
            $broadCastList->notify($legacyObject);
        } catch (\Exception $e) {
        }
    }

    // ...
}

Prototype

Workflow

namespace AppBundle\Event;

class NotifyObjectEvent extends Event
{
    const NAME = 'veolys.notify_object';

    protected $object;

    public function __construct(NotifiableObjectInterface $object)
    {
        $this->object = $object;
    }

    public function getObject()
    {
        return $this->object;
    }
}

Evènement

class NotifyObjectListener implements EventSubscriberInterface
{
    /** @var LoggerInterface */
    protected $logger;

    /** @var Symfony10Kernel */
    protected $kernel;

    /** @var Request */
    protected $request;

    public function notify(NotifyObjectEvent $event)
    {
        $object = $event->getObject();
        if (!$this->supports($object)) {
            return;
        }

        $this->kernel->notify($this->request, $object);

        $this->logger->notice('Legacy notification', [
            'object' => get_class($object),
        ]);
    }

    public function supports($object): bool
    {
        return $object instanceof NotifiableObjectInterface;
    }
}

EventListener

// src/LegacyBundle/Kernel/Symfony10Kernel.php

class Symfony10Kernel extends LegacyKernel
{
    /**
     * Call the notify method from the legacy UserBroadCastList model.
     */
    public function notify(Request $request, NotifiableObjectInterface $object)
    {
        // Map the doctrine entity to the propel model
        $legacyObject = $this->transformToLegacy($object);

        // Call the symfony1 method to get a UserBroadcastList
        $userBroadCastList = $legacyObject->getOrCreateUserBroadcastList();

        // Call the legacy method notify
        try {
            $broadCastList->notify($legacyObject);
        } catch (\Exception $e) {
        }
    }

    // ...
}

Prototype (rappel)

/** @var NotifiableObjectInterface $object */
$id = $object->getId();

$legacyRepositoryName = sprintf('%sPeer', (new \ReflectionClass($object))->getShortName());
if (!class_exists($legacyRepositoryName)) {
    throw new \Exception(sprintf('Invalid legacy repository peer : %s', $legacyRepositoryName));
}

$legacyObject = $legacyRepositoryName::retrieveByPK($id);
if (empty($legacyObject)) {
    throw new \Exception(sprintf('Can\'t found the object with id: %s and legacy repository peer: %s', 
        $id, $legacyRepositoryName));
}

if (!$legacyObject instanceof \Notifiable) {
    throw new \Exception(sprintf('The object % should implement Notifiable interface from legacy', 
        get_class($legacyObject)));
}

// Call the method with Reflection Api because the method getOrCreateUserBroadcastList can be private...
$method = new \ReflectionMethod(get_class($legacyObject), 'getOrCreateUserBroadcastList');
$method->setAccessible(true);

/** @var \UserBroadcastList $broadCastList */
$broadCastList = $method->invoke($legacyObject);
public function notify(Request $request, NotifiableObjectInterface $object)

Passer de Doctrine à Propel

Migration progressive

By skigun

Migration progressive

  • 289