Spłata długu technologicznego – czyli migracja legacy code

As an evolving program is continually changed, its complexity, reflecting deteriorating structure, increases unless work is done to maintain or reduce it.

Meir Manny Lehman

Ile razy zaczynając nowy projekt w naszej głowie kłębią się myśli “tym razem będzie inaczej”. Zaczynamy naszą nową przygodę, która ma być usłana różami ponieważ nauczeni przeszłością, wiemy jak radzić sobie w trudnych sytuacjach. Taki stan rzeczy trwa zazwyczaj kilka tygodni, a potem przypominamy sobie ten mem z GTA San Andreas

Ah Shit, Here We Go Again | Know Your Meme

O tym jak nie dopuścić do takiej sytuacji powstało już setki artykułów. A co, jeżeli już jesteśmy w takiej sytuacji? Jak zacząć spłacać dług technologiczny, który przez lata narastał i to nie zawsze z naszej winy? Zapraszam was do lektury, jak my podeszliśmy do tematu w jednym z naszych projektów.

Wprowadzenie

Żeby dobrze zrozumieć sytuację, chciałbym przedstawić Wam w kilku zdaniach sam projekt oraz stan, od którego będziemy zaczynać naszą spłatę długu.

Opis projektu

Prace nad aplikacją rozpoczęły się blisko dekadę temu. Przez ten czas swoje cegiełki dokładało kilku programistów o różnej znajomości zarówno architektonicznej oraz czysto programistycznych umiejętnościach. Aplikacja ma kilka tysięcy aktywnych użytkowników dziennie.

Naszą bazą był framework CakePHP w wersji 2, który regularnie przechodził wszystkie aktualizacje (obecnie wersja 2.10) z pełnym wsparciem i kompatybilnością z PHP 7.3 (wersja na serwerze). Migracja do wyższej wersji frameworka lub przepisanie aplikacji oczywiście nie wchodziło w grę z racji na spore koszta oraz co ważniejsze czas. Pokrycie testami (jednostkowymi, funkcjonalnymi oraz akceptacyjnymi) wynosiło około 50% (czyli nie najgorzej).

Niestety CakePHP 2 sam w sobie nie posiadał autoloadera czy namespace’ów (poza vendorami), co mocno utrudniało nam pracę. Chęć skorzystania z dowolnej klasy (np. modelu lub jakiegoś helpera) wymagało dołączenia odpowiedniego pliku. Używało się do tego App::uses(), które robiło to za nas. Niestety takie działanie, tworzyło bardzo dużo niepotrzebnego kodu.

Brak namespace’ów wymuszał na nas ścisłą kontrolę nazw klas (dodatkowo CakePHP wymuszą niektóre nazwy obiektów) i tak tworząc ValueObject dodawaliśmy postfix, aby odróżniał się od nazwy modelu (przykład: Invoice (model) InvoiceVO (ValueObject).

Największym problemem, który poniekąd popchnął nas do działania związanego z rozpoczęciem prac nad spłatą zaciągniętego długu technologicznego, była konfiguracja aplikacji. Ustawienia rozbite były w wielu miejscach, bez jakiejkolwiek spójności. Część przechowywana w postaci stałych, część jako $GLOBALS, a jeszcze inne rozbite w różne obiekty w formie public static. Zarządzanie i dalsze rozwijanie oprogramowania w takich warunkach było prawie niemożliwe.

CakePHP w większości miejsc, problem z dostęp do obiektów i stworzeniem dokładnie jednej instancji obiektu klasy, radził sobie przy użyciu singleton’a oraz metod statycznych

Założenia

To, co chcieliśmy osiągnąć, to wprowadzić nowe standardy, które pozwolą nam utrzymywać tę aplikację w przez kolejne kilka lat bez wyrywania pozostałych na głowie włosów.

Nasze założenia to:

  • wprowadzenie autoloadera, aby ograniczyć ręczne dołączanie plików
  • wprowadzić Dependency Injection i zastąpić rozbite w wielu miejscach ustawienia i wyeliminować nagminne używanie metod statycznych i singletonów
  • dodanie przestrzeni nazw (w późniejszym)

Pierwsza rata – autoloader

Żeby nie wymyślać koła na nowo i nie dokładać sobie kolejnej architektury do utrzymywania przez nasz zespół, rozpoczęliśmy poszukiwania gotowego rozwiązania. Nasz wybór padł na komponenty z pakietu Symfony. Dobra znajomość frameworka Symfony była tutaj decydująca.

{
    "require": {
        "symfony/class-loader": "^3.4"
    }
}

Dlaczego nie użyliśmy standardowego composer’owego rozwiązania? Na początek chcieliśmy mieć możliwość dynamicznej konfiguracji, bez potrzeby regenerowania composera. Takie rozwiązanie pozwoliło nam elastycznie modyfikować, które foldery powinny być ładowane. W pierwszym kroku nie chcieliśmy od razu dodawać namespace’ów, ponieważ projekt posiadał kilka tysięcy plików, które trzeba byłoby edytować, co pochłonęłoby zbyt dużo czasu.

Po zainstalowaniu biblioteki do ładowania klas, przechodzimy do samego pisania kodu. Tworzymy plik konfiguracyjny “autoloader.php”, w którym będziemy trzymać potrzebny konfig (app/Config)

<?php
declare(strict_types=1);
require APP . 'Vendor/autoload.php';

use Symfony\Component\ClassLoader\ClassLoader;
$loader = new ClassLoader();
$loader->setUseIncludePath(false);
// Class load definitions
$loader->register();

Plik autoloader.php dołączamy na początku pliku core.php.

<?php
require_once __DIR__ . '/autoload.php';

Czas na odsetki -Dependency Injection

Po stworzeniu całego mechanizmu automatycznego ładowania plików, następnym krokiem jest stworzenie DependencyInjection. Pierwszym krokiem jest zainstalowanie odpowiednich komponentów.

{
    "require": {
        "symfony/yaml": "^4.2",
        "symfony/dependency-injection": "^5.0",
        "symfony/config": "^5.0"
    }
}

W pliku core.php zainicjowaliśmy nasz DependencyInjectionContainer

<?php
declare(strict_types=1);

require_once __DIR__ . '/autoload.php';

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\Config\FileLocator;
use Symfony\Component\DependencyInjection\Loader\YamlFileLoader;

$container = new ContainerBuilder();
$loader    = new YamlFileLoader($container, new FileLocator(__DIR__));
$loader->load('services.yaml');
DependencyInjectionContainer::getInstance()->setContainer($container);

W pliku tworzymy obiekt ContainerBuilder, do którego ładujemy plik service.yaml, w której będziemy przechowywać pełną listę serwisów i parametrów.

Następnie stworzyliśmy plik services.yaml w app/Config z pustą zawartością serwisów.

parameters:
services:

Kolejnym krokiem było stworzenie instancji kontenera poprzez klasę DependencyInjectionContainer (w app/Lib/Core), które może posiadać tylko jedną instancję (dlatego zdecydowaliśmy się na Singleton). Poniżej kod samej klasy:

<?php
declare(strict_types=1);

use Symfony\Component\DependencyInjection\ContainerBuilder;

class DependencyInjectionContainer
{
	/** @var DependencyInjectionContainer */
	private static $instance;

	/** @var ContainerBuilder */
	private $container;

	/** @return DependencyInjectionContainer */
	public static function getInstance()
	{
		if (self::$instance == null) {
			self::$instance = new self();
		}
		return self::$instance;
	}

	public function setContainer(ContainerBuilder $container)
	{
		$this->container = $container;
	}

	public function getContainer() : ContainerBuilder
	{
		return $this->container;
	}
}

Ostatnim krokiem było dodanie app/Lib/Core do autoloadera. Aby to zrobić, zmodyfikowaliśmy plik autoloader.php (app/Config/autoloader.php):

<?php
declare(strict_types=1);
require APP . 'Vendor/autoload.php';

use Symfony\Component\ClassLoader\ClassLoader;
$loader = new ClassLoader();
$loader->setUseIncludePath(false);
$loader->addPrefix('', __DIR__ . '/../Lib/Core');
$loader->register();

Harmonogram spłat – czyli jak to działa w praktyce?

Mamy całe podłoże gotowe. Teraz pytanie – jak to zaimplementować do samego projektu? Najprostszym rozwiązaniem, byłoby wykorzystanie DependencyInjectionContainer::getInstance() w każdym miejscu, w którym potrzebujemy użyć naszego DI. My zdecydowaliśmy się na zamkniecie całego procesu w pewnych normach. Chcieliśmy zamknąć dostęp do kontenera w określonych miejscach, dlatego zdecydowaliśmy się na zaimplementowanie następującego trait’a.

<?php
declare(strict_types=1);

use \Symfony\Component\DependencyInjection\ContainerBuilder;

trait DependencyInjectionContainerAwareTrait
{
	protected function getContainer(): ContainerBuilder
	{
		return DependencyInjectionContainer::getInstance()->getContainer();
	}
}

Proste rozwiązanie, dające nam dostęp do kontenera poprzez metodę getContainer(). Tak przygotowany kod, teraz musimy tylko wykorzystać w naszej aplikacji. Wykorzystanie DI w kontrolerze, komponencie lub dowolnym innym miejscu, wymaga od nas jedynie dodania use DependencyInjectionContainerAwareTrait w odpowiedniej klasie. Dzięki temu mamy łatwy dostęp do całego kontenera poprzez $this->getContainer();

Implementacja serwisów

Mając całość, możemy przystąpić do wdrażania gotowych rozwiązań i korzystać z pełnych możliwości DI. Docelowo chcieliśmy, aby DI pomagał nam nie tylko w przepisaniu całej konfiguracji, ale także w wykorzystaniu go do tworzenia nowych serwisów w naszej aplikacji. Zaimplementowanie DI pozwala nam także korzystać z dowolnej klasy Cake’owej takiej jak model, czy component. Oto w jaki sposób rozpoczęliśmy nowy etap naszej aplikacji.

services.yaml

imports:
    - { resource: services/cake.yaml }

parameters:
services:

cake.yaml

services:
    cake.request:
        class: CakeRequest
        factory: ['Router', 'getRequest']
        arguments: [true]

    cake.component_collection:
        class: ComponentCollection

    cake.auth:
        class: AuthComponent
        factory: ['@cake.component_collection', 'load']
        arguments: ['Auth']

    cake.session:
        class: Session
        factory: ['@cake.component_collection', 'load']
        arguments: ['Session']

Korzystanie z powyższego kodu jest bardzo proste. W kontrolerze, w którym potrzebujemy użyć dowolnego serwisu, implementujemy użycie trait’a. Poniżej przedstawiam prosty przykład

<?php
declare(strict_types=1);

class ExampleController extends AppController
{
	use DependencyInjectionContainerAwareTrait;

	public function indexAction()
	{
		/** @var SessionComponent $cakeSession */
		$cakeSession = $this->getContainer()->get('cake.session');
		$flashMessages = $cakeSession->read('Message');
		$this->set([
			'messages' => $flashMessages
        ]);
	}
}

Po co tyle zachodu?

Cały proces migracji starego systemu do zaimplementowania DI pozwoli na łatwiejsze utrzymanie projektu w przyszłości. Brak możliwości migracji z CakePHP2 do dowolnego innego frameworka jest obecnie całkowicie niemożliwe. Dlatego powolne przepisywanie i odrywanie wszystkich zależności od Cake’a jest naszym głównym zadanie. Migracja na podstawie Strangler pattern to jedyna opcja, abyśmy mogli myśleć o jakiejkolwiek transformacji.

Dodatkowym benefitem nowej architektury jest ułatwienie testowania. DI pozwoli nam na dużo łatwiejsze wstrzykiwanie mocków serwisów. Obecnie też mieliśmy taką możliwość, ale wymagało to dużo większego nakładu pracy.

Co dalej?

Obecnie czeka nas pracochłonne przepisywanie konfiguracji oraz dodanie przestrzeni nazw do całego projektu. Jest to proces, który zapewne potrwa kilka tygodni, aby doprowadzić cały projekt do stanu, jaki założyliśmy na początku. Przedstawiony stan z artykułu jest obecnie w fazie implementowania większości komponentów i helperów.

System wygląda stabilnie i działa bardzo płynnie. W samym procesie przydatny był już wcześniej wypracowany system Continuous Integration i testy automatyczne, dzięki czemu większość procesów w aplikacji jest testowanych po każdej zmianie w branchu migracyjnym.

4.5 2 votes
Article Rating
Subscribe
Powiadom o
guest
0 komentarzy
Inline Feedbacks
View all comments
You May Also Like