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
- 304