Abhängigkeiten in Legacy-Systemen verwaltbar machen

Ob Bibliotheken, Frameworks oder einfach nur andere Klassen – mit wachsendem Funktionsumfang steigt meist die Anzahl der Codeabhängigkeiten. Um alles aktuell zu halten, lassen sich auch in älteren Systemen Maßnahmen ergreifen.

In Pocket speichern vorlesen Druckansicht
Abhängigkeiten in Legacy-Systemen verwaltbar machen
Lesezeit: 18 Min.
Von
  • Jörg Basedow
Inhaltsverzeichnis

In jedem Umfeld, in dem Individualsoftware zum Einsatz kommt, treffen Entwickler früher oder später auf sogenannte Legacy-Systeme. Dabei muss es sich nicht immer um Software handeln, die seit Jahrzehnten existiert – manche Systeme haben bereits nach ein paar Monaten Legacy-Status.

Beispielsweise ist gerade in der Start-up-Welt häufig schnell etwas zu programmieren, um das Geschäft zum Laufen zu bekommen und Geldgeber zufrieden zu stellen. Dabei häufen die Verantwortlichen schnell technische Schulden an und verzichten oft auf das Schreiben von Tests. Grund für Letzteres ist die Annahme, dass das System irgendwann neu implementiert wird. Wenn sie dann noch ein veraltetes Framework nutzen und es eine hohe Entwicklerfluktuation gibt, stehen Projektneulinge vor den gleichen Herausforderungen, die auch beim Umgang mit klassischen Legacy-Systemen verbreitet sind: Die ursprünglichen Anforderungen sind im Code verborgen, Entwickler scheuen große Änderungen aus Angst, Funktionen unbenutzbar zu machen, und die Implementierung ist meist stark vom eingesetzten Framework abhängig.

Eine Neuentwicklung des Systems ist in der Regel zeitaufwendig und geht gegebenenfalls sogar mit Umsatzeinbußen einher. Um das "Alt"-System wieder in den Griff zu bekommen, sind daher zunächst automatische Tests nötig, die helfen, Sicherheit für größere Änderungen zu schaffen und den Funktionsumfang zu dokumentieren. Dabei kann man in den meisten Fällen vorerst nur aufwendig zu schreibende, langsam laufende, funktionale Tests verfassen, da der Code noch zu schlecht strukturiert ist. Um nach und nach Unit-Tests einführen zu können, müssen die Klassen ihre Abhängigkeiten explizit gereicht bekommen, damit sie sich für den Test durch Mock-Objekte ersetzen lassen.

Wie man Code entsprechend überarbeitet und die wachsende Menge an Abhängigkeiten managen kann, zeigt der erste Teil dieses Artikels an einem PHP-Beispiel. Im zweiten geht es darum, die starke Verflechtung mit dem eingesetzten Framework und anderem Drittanbieter-Code zu minimieren. Dadurch sollte sich die Software einfacher aktualisieren lassen, damit sie nicht auf alten Versionen hängen bleibt, die eventuell sicherheitskritische Fehler enthalten.

Software entsteht häufig unter Zeitdruck, wodurch das Überarbeiten und Verbessern des Codes auf der Strecke bleibt. Es entstehen lange Methoden und große Klassen und insbesondere Controller und Models, die meist eine starke Abhängigkeit zum Framework haben, sind zu umfangreich.

Ein typischer Controller könnte wie im folgenden Beispiel gezeigt aussehen:

class UserController extends FrameworkController
{
public function actionView($id)
{
$user = $this->getDb("SELECT * FROM user WHERE id = :id", ↵
['id'] => $id);

$photoPath = APP_PATH.'/data/users'.user[photo_name];

$this->render('view', ['user' => $user, 'photoPath' => ↵
$photoPath]);
}
}

Um den Code zu prüfen, ist eine Datenbank mit passenden Inhalten zu füllen, eventuell noch eine Datei ins Dateisystem zu legen und nach Testabschluss aufzuräumen.

Um den Code zu entkoppeln, könnten Entwickler beispielsweise zwei Services einführen:

class UserController extends FrameworkController
{
public function actionView($id)
{
$user = $this->getUserRepository()->find($id);

$photoPath = $this->getUserService()->getPhotoForUser($user);

$this->render('view', ['user' => $user, 'photoPath' => ↵
$photoPath]);
}
}

Auf die Art müssen sie nicht mehr den Controller prüfen, da er keine Logik enthält, sondern widmen sich stattdessen den Service-Methoden mit zwei entsprechend kleineren Tests. Die Services sollten nach dem Single-Responsibility-Prinzip angelegt werden. Dadurch entstehen allerdings Abhängigkeiten zwischen ihnen.

Ältere Frameworks arbeiten oft mit einer tiefen Vererbungshierarchie, die das Einfügen ergänzender Funktionen an der richtigen Stelle erschwert. Es empfiehlt sich daher, lieber auf die Komposition von Klassen zu setzen, obwohl auch sie die Anzahl der Anhängigkeiten erhöhen. Dependency-Injection- (DI) oder Service-Container können helfen, damit besser umzugehen. In Java enthält etwa das Spring-Framework einen DI-Container, in PHP steht beispielsweise Pimple zur Verfügung. Das Laravel-Framework bringt einen eigenen Service-Container mit, der sich über sogenannte Provider konfigurieren lässt.

Mehr Infos

Sonderheft "Altlasten im Griff"

Mehr Artikel zum Thema Legacy-Code sind im Sonderheft iX Developer 01/2017 zu finden, dass sich unter anderem im heise Shop erwerben lässt.

Im vorliegenden Fall kommt die Symfony-Dependency-Injection-Komponente zum Einsatz, die Programme unabhängig vom Symfony-Framework nutzen können. Beispielhaft wird ein Legacy-Projekt betrachtet, das mit dem Yii-Framework umgesetzt ist.

Im ersten Schritt sollte der Container zentral verfügbar gemacht werden. Die meisten Frameworks haben eine Registry für Services, die sich dazu nutzen lässt, den Container einzuhängen. Im Beispielprojekt wurde der Symfony Container Builder als Yii-Komponente realisiert:

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Reference;

class YiiContainerComponent extends ContainerBuilder implements ↵
IApplicationComponent
{
/**
* @var bool
*/
private $isInitialized = false;

/**
* @var array
*/
private $configuration = [];

public function setConfiguration(array $configuration) {
$this->configuration = $configuration;
}

public function init()
{
foreach ($this->configuration as $aServiceId => ↵
$aServiceConfiguration) {
$this->register($aServiceId, ↵
$aServiceConfiguration['class']);

foreach ($aServiceConfiguration['arguments'] as ↵
$anArgument) {
if (strpos($anArgument, '@') === 0) {
$anArgument = new Reference(substr($anArgument,↵
1));
}
$definition->addArgument($anArgument);
}
}

$this->isInitialized = true;
}

/**
* @return bool
*/
public function getIsInitialized()
{
return $this->isInitialized;
}
}

Das Programm initialisiert und registriert Letztere in der Bootstrap-Datei. Da Services zudem Abhängigkeiten zu anderen Framework-Komponenten haben können, gibt es die Möglichkeit, synthetische Dienste zu definieren. Statt dass ein Container sie instanziiert, reichen andere Komponenten die Services von außen herein:

// ...

$app = Yii::createWebApplication($config);
$container = new YiiContainerComponent();
$container->setConfiguration([
'userRepository' => [
'class' => 'Module\User\Service\UserRepository',
'arguments' => ['@db'],
],
'userService' => [
'class' => 'Module\User\Service\UserService',
'arguments' => ['/data/users'],
],
]);

// set synthetic services
$container->set('db', Yii::app()->db);
$app->setComponent('container', $container);
$app->run();

Im Controller lassen sie sich dann aus dem Container holen, der sie bei Bedarf per Lazy Loading instanziiert:

class UserController extends FrameworkController
{
public function actionView($id)
{
$user = $this->getUserRepository()->find($id);

$photoPath = $this->getUserService()->getPhotoForUser($user);

$this->render('view', ['user' => $user, 'photoPath' => ↵
$photoPath]);
}

/**
* @return \Module\User\Service\UserRepository
*/
private function getUserRepository()
{
return $this->getContainer()->get('userRepository');
}

/**
* @return \Module\User\Service\UserService
*/
private function getUserService()
{
return $this->getContainer()->get('userService');
}

/**
* Diese Convenience-Methode sollte in einem Basis-Controller oder ↵
als Trait zur Verfügung gestellt werden.
*
* @return \Symfony\Component\DependencyInjection ↵
\ContainerInterface
*/
protected function getContainer()
{
return Yii::app()->getComponent('container');
}
}

Container lassen sich direkt in Controllern verwenden, andere Klassen (insbesondere bei neu entwickeltem Code) sollten ihre Abhängigkeiten allerdings explizit hereingereicht bekommen. Während der Migration des Legacy-Codes kann es praktisch sein, den Container auch direkt aus einem Service zu benutzen, um die Migrationsschritte klein zu halten. Für Tests sind Mocks der benötigten Services im Container zu registrieren. Nach der Überarbeitung sollte die Abhängigkeit zum Container allerdings nicht mehr nötig sein.

use Symfony\Component\DependencyInjection\ContainerBuilder;
use Symfony\Component\DependencyInjection\Dumper\PhpDumper;
use Symfony\Component\Config\ConfigCache;

class CachedContainerLoader
{
/**
* @var array
*/
private $config;

/**
* @param array $config
*/
public function __construct(array $config)
{
$this->config = $config;
}

/**
* Initializes the service container.
*
* The cached version of the service container is used ↵
when fresh, otherwise the container is built.
*
* @param bool $forceCacheRefresh
*
* @return YiiContainerComponent
*/
public function getContainer($forceCacheRefresh = false)
{
$class = 'CachedContainer';
$cache = new ConfigCache(APP_ROOT.'/runtime/'.$class.'.php',↵
YII_DEBUG);

if (!$cache->isFresh() || $forceCacheRefresh) {
/* @var $component YiiContainerComponent */
$container = new YiiContainerComponent();
$container->setConfiguration($this->config);
$container->init();

$this->dumpContainer($cache, $container, $class, ↵
'YiiContainerComponent');
}

/** @noinspection PhpIncludeInspection */
require_once $cache;
/* @var $container YiiContainerComponent */
$container = new $class();

// set synthetic services
$container->set('db', Yii::app()->db);

$container->setIsInitialized(); // prevent reinitialisation

return $container;
}

/**
* Dumps the service container to PHP code in the cache.
*
* @param ConfigCache $cache The config cache.
* @param ContainerBuilder $container The service container.
* @param string $class The name of the class to ↵
generate.
* @param string $baseClass The name of the container's ↵
base class.
*/
private function dumpContainer(ConfigCache $cache, ↵
ContainerBuilder $container, $class, $baseClass)
{
$dumper = new PhpDumper($container);

$content = $dumper->dump(['class' => $class, 'base_class' =>
$baseClass, 'file' => (string) $cache]);

$cache->write($content, $container->getResources());
}
}

Wenn das Programm den Container mit vielen Services bei jedem Request neu bauen muss, wirkt sich das merklich auf die Performance aus. Deswegen gibt es die Option, den fertig konfigurierten Controller als PHP-Klasse zu generieren und zwischenzuspeichern. Dabei ist zu berücksichtigen, dass der Cache bei Änderungen der Konfiguration, Klassennamen oder Namespaces ungültig zu machen ist. Für den Zweck enthält das vorangegangene Listing eine Loader-Klasse, die in der Bootstrap-Datei zum Einsatz kommt:

// ...

$app = Yii::createWebApplication($config);
$container = (new CachedContainerLoader([
'userRepository' => [
'class' => 'Module\User\Service\UserRepository',
'arguments' => ['@db'],
],
'userService' => [
'class' => 'Module\User\Service\UserService',
'arguments' => ['/data/users'],
],
]))->getContainer();
$app->setComponent('container', $container);
$app->run();

Mit einer wachsenden Zahl von Services ist die direkte Konfiguration dort allerdings nicht mehr praktikabel. Als Alternative kommen YAML-Dateien in Frage. Dabei bietet es sich in einem großen Projekt zudem an, den Code fachlich in Module zu gliedern. Es sollte dann entsprechend auch Servicekonfigurationen pro Modul geben. Die dort definierten Services werden automatisch im Container registriert:

config/services.yml

parameters:
baseDataDirectory: "/data"

db:
synthetic: true

modules/User/config/services.yml

services:
userReposirory:
class: Module\User\Service\UserRepository
arguments: [@db]

userService:
class: Module\User\Service\UserService
arguments: ["%baseDataDirectory%/user"]

Die Container-Komponente ist dafür so anzupassen, dass sie die YAML-Dateien parst, statt die Services direkt zu konfigurieren. Symfony bietet dafür einige Möglichkeiten, derer sich das folgende Quelltextbeispiel bedient:

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

class YiiContainerComponent extends ContainerBuilder implements ↵
IApplicationComponent
{
/**
* @var bool
*/
private $isInitialized = false;

/**
* @var array
*/
private $configuration = [];

public function setConfiguration(array $configuration) {
$this->configuration = $configuration;
}

public function init()
{
$loader = new YamlFileLoader($this, new ↵
FileLocator(APP_ROOT.'/config'));
foreach ($this->configuration['files'] as $aFile) {
$loader->load($aFile.'.yml');
}

foreach ($this->configuration['modules'] as $aModuleName) ↵
{ // TODO: Use Yii config to determine module names.
$this->getModule($aModuleName)->loadServices($this);
}

$this->isInitialized = true;
}

/**
* @return bool
*/
public function getIsInitialized()
{
return $this->isInitialized;
}

public function setIsInitialized()
{
$this->isInitialized = true;
}

/**
* @param $moduleName
*
* @return \Module\BaseModule|CModule
*/
private function getModule($moduleName)
{
return Yii::app()->getModule($moduleName);
}
}

Damit sich die Konfigurationen auf die Module verteilen lassen, ist die Yii-Module-Klasse wie folgt zu erweitern:

namespace Module;

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

class BaseModule extends CWebModule
{
// ...

/**
* Will load service from service.yml file located in config ↵
folder.
*
* @param ContainerBuilder $container
*/
public function loadServices(ContainerBuilder $container)
{
$loader = new YamlFileLoader($container, new ↵
FileLocator(APP_ROOT.'/modules/'.$this->getName().'/config/'));

foreach (['services'] as $aFile) { // TODO: Inject file names ↵
through Yii config.
$loader->load($aFile.'.yml');
}
}
}

Außerdem ist eine Änderung der Bootstrap-Datei nötig:

// ...

$app = Yii::createWebApplication($config);
$container = (new CachedContainerLoader([
'files' => ['services'],
'modules' => ['User'],
]))->getContainer(true);
$app->setComponent('container', $container);
$app->run();

Um das System nun weiter zu verbessern, wäre eine Option, alle existierenden Yii-Module auf Container-Konfigurationen zu prüfen, statt die Modulnamen explizit zu übergeben. Im Beispiel wurde an der Stelle bewusst auf die Yii-Konfiguration verzichtet. Statt dessen hat der Autor die benötigten Einstellungen explizit gesetzt, damit die Komplexität gering bleibt.

Eine weitere Form der Abhängigkeit, der man bei der Softwareentwicklung begegnet, ist die zu Code von Drittanbietern. Das beginnt bereits beim verwendeten Framework. Mittlerweile weisen allerdings viele Bibliotheken eine gute Qualität auf, sodass es sich lohnt zu überlegen, ob man etwa einen Mailclient oder andere Standardkomponenten tatsächlich selbst entwickeln möchte.

Zum Glück sind die Zeiten vorbei, in denen Code direkt auf Produktionssystemen bearbeitet oder einzelne Dateien per FTP auf den Server geladen wurden. Annähernd jedes Projekt ist heutzutage über ein Versionskontrollsystem wie Git verwaltet. Oft ist aber beispielsweise das Framework mit eingecheckt, was dessen Aktualisierung erschwert. Zum Glück gibt es Tools, die den Vorgang erleichtern.

Im Bereich der Backend-Sprachen bietet sich Bundler für Ruby oder Maven für Java an, wobei Maven gleichzeitig noch als Build-Tool fungieren kann. Fürs Frontend sind im JavaScript-Umfeld Bower und npm beliebt. Um dem PHP-Beispiel treu zu bleiben, kommt im Folgenden Composer zum Einsatz.

Es empfiehlt sich, den Dependency Manager global auf der Entwicklungsmaschine zu installieren und nicht mit ins Repository einzuchecken. Zum Einrichten liegt in Composer eine JSON-Datei vor. Eine ganz einfache composer.json, welche die Anforderungen des vorherigen Abschnitts erfüllt, könnte wie folgt aussehen:

{
"name": "joergbasedow/legacy-project",
"require": {
"symfony/dependency-injection": "2.*",
"symfony/config": "2.*",
"yiisoft/yii": "1.1.15"
}
}

In ihr ist die Version des Yii-Frameworks explizit unter Verwendung semantischer Versionierung vorgegeben. Für die Symfony-Komponenten ist hingegen nur die Hauptversion festgelegt. Composer stellt in dem Zusammenhang eine mächtige Syntax zur Konfiguration bereit.

Nachdem die JSON-Datei angelegt ist, sorgt der Befehl composer install dafür, dass die Software Bibliotheksdateien herunterlädt und im Verzeichnis vendors ablegt. Letztes sollte in der .gitignore-Datei vermerkt sein, um zu verhindern, dass es mit eingecheckt wird. Composer hat in vendors außerdem eine autoload.php abgelegt, die einmalig beispielsweise in der Bootstrap-Datei einzubinden ist. Sie konfiguriert das PHP-Autoloading, sodass das System die Namespaces automatisch den passenden Verzeichnissen zuordnet.

In Legacy-Systemen sind oft Funktionen zu finden, die besser mit einer Drittanbieter-Bibliothek realisiert wären. Beim Entwickeln des Codes setzen Programmierer oft nur das Nötigste um, was unter dem Gesichtspunkt des Ballasts auch vernünftig erscheinen mag. Soll der Funktionsumfang der Software ausgebaut werden, ist allerdings auch der Code zu erweitern, was unter Umständen zeitintensiv sein kann. Kommt stattdessen die Bibliothek eines passenden Drittanbieters zum Einsatz, die von einer größeren Community gepflegt wird, bekommt man die benötigten Features oft geschenkt. Außerdem kommen eventuell sicherheitskritische Fehler in ihr dadurch schneller ans Licht, dass viele Entwickler die Bibliothek nutzen und den Code kontinuierlich weiterentwickeln. Einmal im Composer eingebunden, lässt sie sich regelmäßig und kontrolliert aktualisieren.

Außer composer.json legt Composer eine composer.lock-Datei an, in der festgehalten ist, welche Versionen der Drittanbieter-Bibliotheken genau installiert sind. Sie ist spätestens dann der Versionsverwaltung zu übergeben, wenn das System in Produktion geht. Dadurch ist gewährleistet, dass das System beim Ausführen von composer install immer genau die gleichen Versionen installiert. Um auf neue Releases zu aktualisieren, gibt es den Befehl composer update. Die entsprechende Funktion prüft für alle Bibliotheken, ob es neue Versionen gibt, die zu den Angaben in der JSON-Datei passen. Da man oft gezielt einzelne Libraries aktualisieren möchte, lassen sich diese mit angeben (z.B. composer update symfony/dependency-injection).

Die geladenen Bibliotheken haben oft eigene Abhängigkeiten, die in der jeweils mitgelieferten composer.json-Datei definiert sind. Es werden also durchaus mehr Bibliotheken installiert als in der eigenen Konfigurationsdatei stehen. Vor dem Herunterladen analysiert Composer die composer.json-Dateien aller expliziten und impliziten Abhängigkeiten. Falls sich ergibt, dass Komponenten in unterschiedlichen Versionen einzurichten sind (z.B. swiftmailer 5.4.* und 5.0.*), kann der Dependency Manager die Bibliotheken nicht installieren und gibt eine Warnung aus. Der Konflikt lässt sich beheben, indem die Entwickler entweder die explizite Abhängigkeit anpassen oder entsprechend die, die implizit den Konflikt verursacht.

Der Vorteil ist, dass von jeder Bibliothek genau eine Version installiert ist. Projektmitarbeiter wissen daher immer genau, welche jeweils im Code benutzt wird. Im Gegensatz dazu installiert der JavaScript-Paketverwalter npm unterschiedliche Versionen einer Bibliothek, die er jeweils als Unterverzeichnis in das Verzeichnis der Bibliothek packt, die sie benötigt. Das führt zu beliebig tiefen Verzeichnishierarchien. Dadurch ist etwa bei der statischen Codeanalyse die aktuell verwendete Variante nicht sofort ersichtlich.

Außer den Abhängigkeiten zu Fremdbibliotheken lassen sich auch Systemanforderungen in composer.json festhalten. Der folgende Codeauszug setzt eine bestimmte PHP-Version und einige -Erweiterungen voraus. Wenn sie nicht auf dem System installiert sind, gibt Composer beim Update eine Warnung aus:

{
"name": "joergbasedow/legacy-project",
"require": {
"php": ">=5.6.0",
"ext-gd": "*",
"ext-curl": "*",
"ext-redis": "*",
"swiftmailer/swiftmailer": "5.4.*",
"symfony/dependency-injection": "2.*",
"symfony/config": "2.*",
"yiisoft/yii": "1.1.15"
}
}

Um nicht parallel zum Composer-Autoloading noch ein eigenes implementieren zu müssen, kann man Regeln für das automatische Laden in der Konfigurationsdatei ergänzen:

{
"name": "joergbasedow/legacy-project",
"require": {
"php": ">=5.6.0",
"ext-gd": "*",
"ext-curl": "*",
"ext-redis": "*",
"swiftmailer/swiftmailer": "5.4.*",
"symfony/dependency-injection": "2.*",
"symfony/config": "2.*",
"yiisoft/yii": "1.1.15"
},
"autoload": {
"psr-4": {
"Library\\": "protected/lib",
"Module\\": "protected/modules"
},
"classmap": [
"protected/models/behaviors",
"protected/commands"
]
}
}

Hierbei lassen sich sowohl Namespaces für PSR-4-Autoloading als auch Verzeichnisse einrichten [h]. Composer durchsucht sie nach Klassen, für die er anschließend eine Zuordnung ([Namespace\]Klassenname => Datei) erstellt.

Durch das Umstellen auf ein Tool wie Composer und das Einführen eines Service-Containers lassen sich die Abhängigkeiten komplexer Software besser im Griff behalten. Im Anschluss können die Entwickler nach und nach einen großen Teil der Business-Logik aus Models und Controllern in frameworkunabhängige Klassen umziehen. Ein Wechsel des Frameworks ist dann deutlich einfacher zu bewerkstelligen.

Jörg Basedow
ist seit 2014 als Teamlead Development Purchasing Interface bei der ABOUT YOU GmbH in Hamburg angestellt und entwickelt dort das ERP-System mit dem unter anderem das Sortiment gesteuert und der Artikelbedarf gesichert wird sowie die Kommunikation mit dem Lager stattfindet.
(jul)