Dependency Injection innerhalb von OXID Modulen
Teil 2: DI innerhalb von Modulen
Im ersten Teil dieser kleinen Serie über dependency injection im Rahmen des OXID eShop frameworks haben wir uns damit auseinandergesetzt, warum die Technik der inversion of control wichtig ist, um wart- und testbare Software zu schreiben. In diesem und dem nächsten Teil geht es um die konkrete Umsetzung.
Schauen wir uns zunächst einmal an, wie man innerhalb eines Moduls den DI container nutzen kann, um seinen eigenen Code besser zu strukturieren und inversion of control zu praktizieren. Für die Erweiterung des shop codes belassen wir es hier bei der traditionellen Methode der Erweiterung mittels des oxNew()-Mechanismus. Neuere Arten der Erweiterung lernen wir dann im dritten Teil der Serie kennen.
Symfony DI container und Inversion of Control
Wie bereits erwähnt benutzt OXID den Symfony DI container um inversion of control zu unterstützen. Bei einer vollständig auf inversion of control beruhenden Applikation kommt man in der Regel überhaupt nicht mit dem DI container kaum in Berührung, höchstens bei dessen Konfiguration. In Symfony beispielsweise injiziert die routing Komponente die services aus dem container direkt in die controller - man fasst den container also selbst gar nicht an.
In OXID geht das leider nicht ganz so einfach, da der DI container noch auf traditionelles Routing stößt. Deshalb gibt es die Möglichkeit, sich direkt den DI container zu holen und benötigte services vom container anzufordern - ihn also zumindest teilweise eher als resource locator zu benutzen. Dazu gibt es die ContainerFactory-Klasse. Diese stellt dann eine getContainer()-Methode zur Verfügung. Die factory selbst wird über einen static Aufruf instantiiert:
$container = ContainerFactory::getInstance()->getContainer();
Der container selbst ist ein PSR-11 kompatibler Symfony DI container, der im Normalfall aus einer cache-Datei gelesen wird, was ihn recht schnell macht. Diese cache-Datei heißt container_cache.php und liegt im tmp-Verzeichnis der Applikation. Wenn man also manuell die Konfiguration des containers ändert, muss man diese Datei löschen, um den container zu aktualisieren.
Den Container konfigurieren
Wie wird nun der container konfiguriert? Wir haben uns bei OXID dafür entschieden, den container vollständig mit yaml-Dateien zu konfigurieren. Dabei werden der Reihe nach die folgenden Dateien gelesen, falls sie existieren:
- Internal/services.yaml in oxideshop-ce
- Internal/services.yaml in oxideshop-pe
- Internal/services.yaml in oxideshop-ee
- var/generated/generated_services.yaml
- var/configuration/configurable_services.yaml
Die Logik für die ersten drei services.yaml-Dateien ist dabei einigermaßen offensichtlich: Zunächst wird die Konfiguration der services für die community edition gelesen. Danach bekommen die professional edition und die enterprise edition die Chance, bestimmte services erneut zu konfigurieren: In der Regel geht es dabei darum, die einfachen Services aus der community edition durch komplexere services aus den höherwertigen Editionen zu ersetzen.
Was aber hat es mit der generated_services.yaml-Datei auf sich? Hier wird es interessant für Modulentwickler: Diese Datei wird vom OXID framework selbst geschrieben. Sie sollte also weder von Hand editiert noch gelöscht werden. Unter anderem kann sich diese Datei ändern, wenn ein Modul aktiviert oder deaktiviert wird. Wenn nämlich im root-Verzeichnis eines OXID-Moduls eine services.yaml liegt, dann wird diese Datei in der generated_services.yaml-Datei inkludiert. Für Module-Schreiber heißt dies: Wenn ich mir selbst services für mein Modul schreiben will, dann muss ich weiter nichts tun als eine services.yaml-Datei mit meinen service-Definitionen in das root-Verzeichnis meines Moduls legen. Beim Aktivieren des Moduls werden meine services dann über den Container zugänglich sein.
Ein Beispiel aus der Praxis
Wie sieht das in der Praxis aus? Nehmen wir einmal an, jemand will ein Modul für die Preiskalkulation schreiben, das komplett die Preiskalkulation in der Article-Klasse überschreibt. Dann wird zunächst einmal ein Einstiegspunkt erstellt, ganz traditionell:
class MyArticle extends MyArticle_parent {
public function getPrice($dAmount = 1)
{
// Here goes our override code
}
}
Dann wird diese Klasse in der metadata.php des Moduls als Erweiterung der Article-Klasse registriert, auch das ganz traditionell. Neu ist, wie dann der eigentliche Code für die Preisberechnung ausgeführt wird:
public function getPrice($dAmount = 1)
{
$container = ContainerFactory::getInstance()->getContainer();
$priceCalculationBridge = $container->get(
PriceCalculationBridgeInterface::class);
return $priceCalculationBridge->getPrice($dAmount);
}
Wir machen nur eine Sache: Wir holen uns den container, holen uns von dort eine Einstiegsklasse und führen auf dieser genau eine Methode aus. Den gesamten Rest der Implementierung können wir dann nach dem Prinzip der inversion of control aufbauen, Tests schreiben etc.
Natürlich müssen wir unsere Implementierung auch noch im container registrieren. Dazu legen wir eine services.yaml Datei in unserem Modul an. Diese könnte typischerweise folgendermaßen aussehen (genauere Informationen über die Struktur und die Möglichkeiten einer solchen Datei finden sich in der Symfony-Dokumentation):
services:
_defaults:
autowire: true
public: false
MyCorp\MyModule\PriceCalculationBridgeInterface:
class: MyCorp\MyMOdule\PriceCalculationBridge
public: true
MyCorp\MyModule\PriceCalculationServiceInterface :
class: MyCorp\MyModule\PriceCalculationService
MyCorp\MyModule\PriceCalculationDaoInterface:
class: MyCorp\MyMOdule\PriceCalculationDao
Zunächst werden Standardparameter für die service-Definitionen erstellt: Es soll autowiring verwendet werden und die services sollen nicht public sein. Autowiring heißt, dass wenn im Konstruktor eines services das interface eines anderen services steht und dies eindeutig ist, dann braucht die Abhängigkeit gar nicht konfiguriert werden, der container löst diese Abhängigkeit automatisch auf. Das funktioniert natürlich nur, wenn als service keys die interfaces der Klassen verwendet werden. Aber das ist sowieso gute Praxis, die wir auch bei OXID verwenden. Und auch Modulschreiber sollten das beherzigen, wenn möglich.
Dass die services nicht public sein sollen ist ebenfalls gute Praxis, um die öffentliche API so klein wie möglich zu halten. Wir haben uns bei OXID entschieden, diese öffentlichen Klassen "Bridges" zu nennen und würden dies auch Moduleentwicklern empfehlen. Danach haben wir in diesem Beispiel noch zwei weitere service-Klassen, den PriceCalculationService, der die Geschäftslogik implementiert, und ein PriceCalculationDao, also ein data access object, in dem wir die Datenbanklogik kapseln.
Das dao wird in den domain service injiziert, der domain service dann in die bridge und dank autowiring geht das automatisch, wenn wir die Signatur der Konstruktoren entsprechend anlegen:
class PriceCalculationService {
public function __construct(PriceCalculationDaoInterface $dao)
{
$this->dao = $dao;
}
}
Bei der Aktivierung des Moduls wird der Import dieser services.yaml-Datei dann in die generated_services.yaml-Datei eingefügt und die services sind im container verfügbar.