http://repos.zend.com/deb/zend.key 404 Not Found
Wenn Du Zend Server CE auf Debian Linux (z.B. Ubuntu Server) mit apt-get installieren willst, und einer der vielen Installationsanleitungen im Netz folgst, könntest Du wie ich auf diesen Fehler stoßen:
http://repos.zend.com/deb/zend.key 404 Not Found
Die Lösung ist einfach: Die URL des Keys hat sich kürzlich geändert! Verwende http://repos.zend.com/zend.key
und es läuft.
Natürlich hat es die offizielle Dokumentation richtig drin.
Große PHP-Arrays, SPL und Sessions
Folgende Problemstellung: eine große Datenmenge wird auf einmal abgefragt, soll aber nicht direkt komplett an den Client gesendet werden, also wird sie in der Session zwischengespeichert. Vielleicht im allgemeinen nicht die geschickteste Lösung, in meinem Fall fielen die Nachteile jedoch nicht ins Gewicht. “Groß” bedeutete dabei im Bereich von 10-50 MB in 50K-100K Datensätzen.
Das ist nun leider eine Menge, bei der PHP-Arrays nur noch mit Vorsicht einzusetzen sind. Der Flaschenhals war in diesem Fall array_shift(), womit Einträge aus dem in der Session befindlichen Arrays entnommen wurden. Was läge da näher, als auf eine der SPL-Datenstrukturen zurückzugreifen? Leider sind sowohl SplStack als auch SplFixedArray nicht serialisierbar und somit nicht ohne Weiteres mit Sessions zu gebrauchen.
Dies lässt sich nachrüsten, dabei muss allerdings doch wieder auf PHP-Arrays zurückgegriffen werden. Mit dem Performance-Verlust beim Serialisieren und Deserialisieren erkauft man sich allerdings eine deutlich effizientere Daten-Verarbeitung. In meinem Fall war SplStack bzw. SplDoublyLinkedList perfekt, da die Daten nur noch der Reihe nach abgeholt werden sollten. Die Erweiterung sieht wie folgt aus:
Serialisierbare SPL-Datenstruktur
class SerializableList extends SplDoublyLinkedList { private $_data; public function __sleep() { $this->_data = array(); $this->rewind(); while ($this->valid()) { $this->_data[] = $this->current(); $this->next(); } return array('_data'); } public function __wakeup() { foreach ($this->_data as $row) { $this->push($row); } $this->_data = array (); } }
Kurz erkärt
Beim Serialisieren (__sleep()) wird die Datenstruktur in ein PHP-Array (im Attribut _data) konvertiert und mit return array('_data') festgelegt, dass genau dieses Attribut serialisiert werden soll. Beim Deserialisieren (__wakeup()) ist _data wiederhergestellt und kann zurück konvertiert werden. Anschließend wird mit $this->_data = array() der Speicher wieder freigegeben.
Vorsicht
Ob diese Lösung im konkreten Fall sinnvoll ist, kann nur durch eigene Messungen ermittelt werden. Dabei sollte vor allen Dingen darauf geachtet werden, die Anzahl der Serialisierungsvorgänge so gering wie möglich zu halten, denn wie schon gesagt sind die durch die zusätzliche Konvertierung teurer als zuvor. Als Beispiel: Bei 8 Abfragen a 10000 Datensätzen war meine Anwendung mindestens 20 mal schneller als bei 80 Abfragen a 1000 Datensätzen. Und beide Varianten schlagen die Implementierung nur mit PHP-Arrays um Längen.
Eine Herausforderung für weitere Optimierung wäre es noch, einen eigenen Serialisierer zu schreiben, der ohne PHP-Array auskommt. Das wäre allerdings eher etwas für die PECL, sprich direkt in C gehackt. In PHP selbst sehe ich da wenig Hoffnung in puncto Effizienz.
Magento: Integrationstest für Checkout
Gelegentlich funktioniert die Weiterleitung beim One Page Checkout nicht mehr, nachdem irgendein neues Modul Debug-Ausgaben macht oder Fehler wirft. Exceptions beim Versand der Bestätigungs-Mail sind auch eine häufige Ursache.
Das ist ein ziemlich gravierender Fehler, der aber leicht unbemerkt bleibt. Nachdem ich das nun ein paar Mal hatte, war klar: Der Checkout muss beim Regressionstest automatisch getestet werden, so dass ich über so einen Fehler rechtzeitig informiert werde. Alistair Steads hat glücklicherweise beispielhaft einen Checkout Integration Test online gestellt, seine Bibliothek Mage-Test benutze ich allerdings nicht mehr, habe den Test also für EcomDev PHPUnit 0.2 umgeschrieben. Am Code der Test-Methode ändert sich zunächst nicht viel, nur ein paar Methoden heißen anders. Das Setup ist allerdings mal wieder etwas kniffelig, daher will ich es hier mal etwas näher beleuchten. Links zum fertigen Test Case folgen unten.
Zunächst mal wird mindestens ein Produkt für den Warenkorb benötigt, die Unit Tests laufen ja auf ihrer eigenen Datenbank, die für jeden Test mittels Fixtures entsprechend vorbereitet werden muss (mehr dazu im EcomDev PHPUnit Manual). Hier die Minimal-Daten für unsere Zwecke:
eav: catalog_product: - entity_id: 1 stock: qty: 100 is_in_stock: 1 website_ids: - default sku: test name: Test status: 1 # enabled type_id: simple price: 1.00
Um sicherzustellen, dass die Bestellung akzeptiert wird, sollte das Empfängerland explizit erlaubt sein und die Funktion “Terms & Conditions” deaktiviert werden:
config: default/general/country/allow: DE,GB stores/default/checkout/options/enable_agreements: 0
Außerdem muss die Zahlungsmethode konfiguriert sein. Ich habe mich dazu entschieden, für den Test “Scheck / Zahlungsanweisung” statt Paypal zu verwenden, die Standard-Methode, die keine Konfiguration benötigt. Wenn explizit verschiedene Zahlungsmethoden getestet werden sollen, ist ein aufwändigeres Setup nötig, das macht meiner Meinung nach in der isolierten Testumgebung aber wenig Sinn.
Ganz wichtig ist, dass nachdem der Warenkorb gefüllt wurde, der Session-Wert cart_was_updated auf false gesetzt wird, ansonsten bricht der Test mit einer wenig aussagekräftigen “Headers already sent”-Exception ab (mehr dazu unten).
protected function _fixCheckout() { Mage::getSingleton('checkout/session')->setCartWasUpdated(false); }
Eine weitere Überraschung gab es, nachdem der Test das erste Mal erfolgreich durchlief und beim nächsten Mal ohne ersichtlichen Grund einen Fehler in der JSON-Ausgabe meldete. Gut, wenn man Exception-Logging aktiviert hat: Grund war eine PDOException aufgrund doppeltem Schlüssel in der Order-Tabelle. Das interessante: Das EcomDev Test-Framework räumt die Datenbank eigentlich nach jedem Testlauf ab (wenn nicht gerade ein Fatal Error der Ausführung der tearDown-Methoden zuvorkommt) aber für die beim Checkout generierte Bestellung (und nur die) funktionierte das nicht. Als Ursache vermute ich die zusätzliche Kontrolle im Order-Model, die verhindern soll, dass Bestellungen von irgendeinem Modul außerhalb des Admin-Bereichs gelöscht werden können. Jedenfalls hat das setzen des isSecureArea-Flags geholfen, generierte Bestellungen wieder zu löschen:
/** * Deletes any created order * * @return void */ protected function _deleteOrders() { Mage::register('isSecureArea', true); /* @var $orders Mage_Sales_Model_Mysql4_Order_Collection */ $orders = Mage::getModel('sales/order')->getCollection()->load(); $orders->walk('delete'); }
Dinge, die ich beim Debuggen gelernt habe
- Der One Page Checkout Controller reagiert auf diverse Fehler mit einem 403 Session Expired Header, was gemeinerweise vom Test-Framework nur mit einer “Headers already sent”-Exception beim verarbeiten des Response-Objekts quittiert wird. Den Fehler habe ich natürlich erst bei mir selbst gesucht, aber das Response-Objekt wurde überall sauber resettet.
Zu den verursachenden Fehlern dieses Headers gehören ein leerer Warenkorb und jegliche Fehler aus dem Quote-Model (siehe dazu die Methode OnepageController::_expireAjax()). Um so etwas zu finden, empfiehlt es sich, die Header direkt an der Stelle, wo die Exception geworfen wird zu analysieren und dann im Magento-Source nach dem Header-Text zu suchen. - Um aufzudecken, welche Exceptions zu (oft wenig hilfreichen) Magento-Fehlermeldungen geführt haben, ist das Exception-Log bei Integrationstests unentbehrlich. Die Fixture dazu sieht so aus:
stores/default/dev/log/active: 1 stores/default/dev/log/file: tests-system.log stores/default/dev/log/exception_file: tests-exception.log
Quelltext
Wie versprochen, stehen Fixture & Test Case als github:gist online zur Verfügung.
Alle SQL Queries in Magento anzeigen
Aktiviere den Zend SQL Profiler mit dem folgenden Knoten in local.xml
:
<resources> <default_setup> <connection> <profiler>1</profiler>
Dann kannst Du auf den Profiler an beliebiger Stelle im Code zugreifen und einiges an Informationen über die ausgeführten Queries erhalten:
$profiler = Mage::getSingleton('core/resource') ->getConnection('core_write')->getProfiler();
Um alle Queries auszugeben genügt z.B.:
print_r($profiler->getQueryProfiles());
Magento: Eigenes TinyMCE Plugin aktivieren
Magento bringt mit TinyMCE von Haus aus einen Rich Text Editor mit, der im Backend für Attribute, CMS-Seiten u.a. verwendet wird. Die Integration erfolgt über diverse Adapterklassen und Skripte, die quer über den Magento-Core verteilt sind, darunter das Formular-Element Varien_Data_Form_Element_Editor, ein paar Models im CMS-Modul und diverse Blocks und Helpers im Adminhtml-Modul sowie eigene JavaScripts in js/mage/adminhtml/wysiwyg.
Ich habe nun für meine letzte Extension ein eigenes TinyMCE Plugin entwickelt und stand vor dem Problem, wie ich es aktivieren könnte. Ein einfaches einbinden des Plugin-Skripts schlug fehl, da der TinyMCE Editor vom Magento-Adapter dynamisch nachgeladen wird. Leider habe ich dafür auch nach langem Suchen keine brauchbare Dokumentation gefunden und da Replacements und Hacks von Core-Dateien nicht in Frage kommen blieb mir letztlich nichts übrig als die oben genannten Klassen und Skripte zu analysieren. Als Resultat gibt es hier nun eine Anleitung zum einbinden von eigenen Plugins:
Wysiwyg Konfiguration
Die Konfiguration für den Editor wird zentral vom ‘cms/wysiwyg_config’ Singleton geladen (auch außerhalb des CMS-Moduls!). Dort ist leider sehr viel hart kodiert und es gibt z.B. keine Konfigurationsquelle für Plugins. Dafür wird dort allerdings ein Event getriggert so dass man die Konfiguration mittels Observer erweitern kann. Das Event dazu heißt cms_wysiwyg_config_prepare und hat als Parameter ein Varien_Object namens config.
Ein Observer dafür könnte so aussehen:
class SSE_Example_Model_Observer { public function onCmsWysiwygConfigPrepare(Varien_Event_Observer $observer) { $config = $observer->getEvent()->getConfig(); $this->addPlugin($config, $this->getContentBlockPluginConfig()); } private function addPlugin(Varien_Object $config, $plugin) { $config->setData('plugins', array_merge((array) $config['plugins'], array($plugin))); } private function getContentBlockPluginConfig() { return array( 'options' => array( ), 'name' => 'example', 'src' => Mage::getBaseUrl(Mage_Core_Model_Store::URL_TYPE_JS) . 'sse/example/editor_plugin.js', ); }
In der Methode getContentBlockPluginConfig() wird hier die Konfiguration für ein neues Plugin angelegt:
- options
- Das Options-Array wird einer zusätzlichen Plugin-Schnittstelle von Magento verarbeitet. Hierüber lassen sich Plugins wie der “Insert Image”-Button realisieren, die außerhalb von TinyMCE funktionieren. Dies soll uns an dieser Stelle aber nicht interessieren. 1
- name
- Der Name des Plugins, so wie er im Plugin-Skript als Parameter für tinymce.PluginManager.add() angegeben wurde
- src
- Der Pfad zum Plugin-Skript. Dieser kann relativ zum Ort von TinyMCE (tinymce.baseUrl) sein, ich empfehle allerdings der Übersichtlichkeit zuliebe wie oben einen absoluten Pfad zu nutzen und das Plugin in einem eigenen Verzeichnis abzulegen. Da das Plugin von Magento explizit über diesen Pfad geladen wird, sind die Namenskonventionen von TinyMCE hier nicht von Belang, für andere Ressourcen gelten sie weiterhin (Die Sprachdatei wäre hier z.B. in js/sse/example/langs/en.js)
Einfügen des Buttons/Steuerelements ins TinyMCE Menü
Üblicherweise muss man neue Steuerelemente über die theme_advanced_buttons{n} Parameter beim Initialisieren von TinyMCE im Menü einfügen. Da diese Initialisierung aber wie gesagt komplett von Magento übernommen wird, gibt es hier einfach die Konvention dass das Element genauso heißt wie das Plugin (Magento fügt den Namen an vorderster Position in der ersten Reihe ein):
// ... tinymce.create('tinymce.plugins.Example', { init : function(editor, url) { // ... editor.addButton('example', { // ... }); }, // ... }
Aktivierung nur für bestimmte Formulare
Dadurch dass die Konfiguration zentral über ein Singleton geladen wird, ist sie leider für jede Editor-Instanz die selbe. In meinem Fall brauchte ich das Plugin jedoch nur für die Produktbeschreibung und wollte es entsprechend nicht in anderen Formularen aktiviert haben. Das ließ sich letztendlich am besten clientseitig bewerkstelligen, indem bei der Plugin-Initialisierung die ID des Editors abgefragt wird (Die ID lässt sich am besten über den DOM-Inspektor von Firebug oder einem ähnlichen Tool herausfinden):
// ... tinymce.create('tinymce.plugins.Example', { init : function(editor, url) { if (editor.id == "description_editor") { this._init(ed, url); } }, _init : function(editor, url) { // real initialization here }, // ... }
Fazit
Wie so oft bei Magento: Eigentlich ganz einfach, wenn man nur weiß wie es geht. Ich hoffe, dem ein oder anderen hiermit das stundenlange Wühlen im Core zu ersparen was ich dafür auf mich nehmen musste 🙂
Notes:
- Zum Vertiefen empfehle ich den Quelltext von Varien_Data_Form_Element_Editor::_getPluginButtonsHtml() als Einstiegspunkt. ↩
Magento: Singletons mit Mock-Objekten/Stubs ersetzen
Und wieder etwas zum Thema Unit Test mit Magento:
Es ist möglich, Models die über Mage::getSingleton() geladen werden zur Laufzeit mit konkreten Objekten zu ersetzen, quasi der Magento way of Dependency Injection. Dazu ist allerdings ein kleiner Trick nötig. Es gibt zwar auch die Möglichkeit, Rewrites zur Laufzeit zu konfigurieren aber im Fall von Mock Objects hilft uns das nicht weiter, schließlich wollen wir Magento nicht einfach eine andere Klasse instantiieren lassen sondern einen fertig konfigurierten Stub injizieren.
Hierzu ein gekürztes Beispiel (Test Case):
//... const LIFE_CLASS = 'My_Namespace_Model_Life'; const LIFE_MODEL = 'mynamespace/life'; private $lifeStub; //... public function setUp() { parent::setUp(); $this->registerLifeStub(); } /** * @test */ public function testUseBackendModel() { $this->configureLifeStub(42); // now test something that uses Life } //... private function registerLifeStub() { $this->lifeStub = $this->getMock(self::LIFE_CLASS); // register stub as singleton $registryKey = '_singleton/'.self::LIFE_MODEL; Mage::unregister($registryKey); Mage::register($registryKey, $this->lifeStub); } private function configureLifeStub($result) { $this->lifeStub ->expects($this->any()) ->method('getMeaning') ->will($this->returnValue($result)); } //...
Die Besonderheit liegt hier in der Methode registerLifeStub(), die beim Setup aufgerufen wird, das Mock Object anlegt und registriert. Dabei wird direkt so auf die Magento-Registry zugegriffen wie es Mage::getSingleton() macht. Wichtig ist auch der Aufruf von Mage::unregister() da Mage::register() kein Überschreiben zulässt.
configureLifeStub() ist nur ein Beispiel dafür dass das Mock Object jederzeit unterschiedlich konfiguriert werden kann und dies nicht beim erstellen festgelegt werden muss.
Diese Methode ist sowohl für Core-Singletons wie die diversen Session-Klassen als auch für selbst geschriebene Provider-Klassen interessant, funktioniert aber wirklich nur, wenn diese auch mit Mage.:getSingleton() geladen werden, da Mage::getModel() immer ein neues Objekt instantiiert – dafür wäre ein deutlich tieferer Eingriff notwendig.
Magento: Testen von Configuration Backend Models
Eine professionell entwickelte Magento-Extension will natürlich mit Unit Tests entwickelt werden und der modulare Aufbau sollte dies einfach machen
Configuration Backend Models sind fürs Speichern und Laden von Werten in der Systemkonfiguration zuständig und können beispielsweise Anpassungen in beforeSave() und afterLoad() oder Validierungen vornehmen.
Mein erster Ansatz, diese Methoden zu testen, war das setzen über die Magento-Applikation:
// Hilfs-Methoden im Test Case: private function setConfig($value) { Mage::getConfig()->saveConfig( self::XML_PATH, $value)->cleanCache(); // sicher stellen dass die neue Konfiguration gespeichert und wieder geladen ist: Mage::reset(); Mage::init(); } private function getConfig() { return Mage::getStoreConfig( self::XML_PATH); }
Dabei stellte sich heraus, dass mein Backend Model gar nicht benutzt wurde und natürlich ist der Fehler immer da wo man ihn zuletzt sucht: In Magentos Methoden zum setzen und abfragen von Konfigurationswerten wird direkt auf das Resource Model zugegriffen, das Backend Model kommt nur im User Interface zum Tragen.
Die Lösung ist also, das Backend Model direkt zu instantiieren und zu testen, dabei ist folgendes zu beachten:
Zunächst muss der bestehende Datensatz geladen werden, ansonsten gibt es beim Speichern Konflikte mit dem UNIQUE Index. Ich mache das über den Konfigurations-Pfad, bei mehreren Werten (z.B. Store-spezifische Konfiguration) ist die Abfrage mit load() leider nicht mehr eindeutig und müsste entsprechend zusammengebaut werden. So lange es für den Test nicht relevant ist, mit welchem konkreten Wert gearbeitet wird, kann es allerdings trotzdem so gemacht werden.
// Aufsetzen des Test Case: public function setUp() { parent::setUp(); $this->model = Mage::getModel(self::MODEL) ->load(self::XML_PATH, 'path'); }
Die Hilf-Methoden von oben konnte ich dann refaktorisieren:
// Hilfsmethoden im Test Case: private function setConfig($value) { $this->model->setValue($value); $this->model->save(); } private function getConfig() { return $this->model->getValue(); }
Das ist so einfach dass nun eigentlich keine eigenen Methoden dafür mehr nötig wären, aber ich hatte sie nun schonmal angelegt. Weiterhin sind sie auf das Testen des Speicherns ausgerichtet (es ging mir konkret um beforeSave()), es sollte aber ein leichtes sein, bei Bedarf getConfig() um erneutes Laden zu erweitern.
Ein einfacher Test könnte nun so aussehen:
// Test /** * @test */ public function testConvertKbOnSave() { $input = '1K'; $expected = '1024'; $this->setConfig($input); $this->assertEquals($expected, $this->getConfig(), 'configuration should be changed'); $this->assertFalse($this->model->hasDataChanges(), 'configuration should be saved'); }
Viel Erfolg!
Magento: Rewrite Shipping Method
Worauf man bei Rewrites von Shipping Carriers achten muss:
Frage und Antwort on StackOverflow
PHP: “Mocking” built-in functions like time() in Unit Tests
Dieser Artikel ist nur auf Englisch erschienen.