// der php hacker

// archiv

Code-Analyse mit der Reflection-API

Geschrieben am 20. Dez 2008 von Cem Derin

PHP5 kommt mit eine schicken Reflection-API daher. Bisher habe ich diese nur sehr selten und wenn auch nur in geringem Umfang benutzt. Da die API allerdings auch DocBlöcke auslesen (leider nicht interpretieren) kann, hat das gute Ding meiner Meinung nach eine ganze Menge Potential. Daher habe ich eine Suite erstellt, mit der sich schnell Source Code analysieren lässt.

Im Endeffekt ist daraus ein kleines Projekt entstanden, mehr dazu am Ende des Artikels.

Ich habe versucht die Suite so flexibel wie möglich aufzubauen. So ist es möglich einen eigenen Logger zu definieren, der gefundene Regelverstöße auf beliebige Art und Weise festhält. Der Beispiellogger gibt einfach alles direkt aus. So ist es aber auch möglich, Verstöße in einer Datenbank festzuhalten oder in Dateien zu schreiben.

Die Meldungen werden inkl. Datei, Zeile und Autor gespeichert (wenn sich dieser über den DocBlock ermitteln lässt). So sind Verstöße direkt einem Autor zuzuordnen. Als Testregeln habe ich mir folgende ausgedacht:

  • Klassennamen müssen mit Großbuchstaben beginnen
  • Klassennamen müssen der Ordner- und Dateistruktur unterhalb des Include-Pfads entsprechen
  • Properties und Methoden im private oder protected scope müssen mit einem Unterstrich beginnen
  • Jede Klasse, jede Methode und jedes Property muss dokumentiert werden

Das erstellen von Regel-Klassen ist recht einfach. An die Methode “check” wird die zu checkende reflektierte Klasse übergeben. Der Tester erwartet als Rückgabe entweder “null” oder ein Result-Objekt. Ziemlich easy, eigentlich. Schauen wir, ob wir die Testfälle abdecken können.

Klassennamen müssen mit Großbuchstaben beginnen

	class Compliance_Rule_Class_Name extends Compliance_Rule  {
		protected $_deficitMessage = 'Class name must begin with an upper case letter.';

		public function check(ReflectionClass $class) {
			$result = null;

			if(!preg_match('/^[A-Z]/', $class->getName())) {
				$result = new Compliance_Rule_Result();
				$message = new Compliance_Rule_Result_Message(
					$class->getDocComment()->getAuthor(),
					$class->getName(),
					new Compliance_Tester_Directory_File($class->getFileName()),
					$class->getStartLine(),
					$this->_deficitMessage
				);
				$result->addMessage($message);
			}

			return $result;
		}
	}

Schuss und Treffer. Einmal runtergeschrieben funktionierte sofort. Wunderbar. Wir holen uns mit getName den Klassennamen aus der Reflection und prüfen mittels eines regulären Ausdrucks, ob dieser mit einem Großbuchstaben beginnt.

Klassennamen müssen der Ordner- und Dateistruktur unterhalb des Include-Pfads entsprechen

	class Compliance_Rule_Class_Path extends Compliance_Rule  {
		protected $_deficitMessage = 'Class name must express path to file.';

		public function check(ReflectionClass $class) {
			$result = null;

			$classname = $class->getName();
			$path = str_replace('_', DIRECTORY_SEPARATOR, $classname). '.php';
			if(substr($class->getFileName(), (strlen($path) * -1)) != $path) {
				$result = new Compliance_Rule_Result();
				$message = new Compliance_Rule_Result_Message(
					$class->getDocComment()->getAuthor(),
					$class->getName(),
					new Compliance_Tester_Directory_File($class->getFileName()),
					$class->getStartLine(),
					$this->_deficitMessage
				);
				$result->addMessage($message);
			}

			return $result;
		}
	}

Wow. Das war genau so einfach! Man könnte diese Regel noch verfeinern, aber in meinem Fall reicht sie voll und ganz aus. Ich wandle den Klassennamen in einen Pfad um, in dem ich die Unterstriche durch die DIRECTORY_SEPERATOR-Konstante ersetze und die Endung “.php” hinten dran setze. Dann prüfe ich einfach, ob die Datei, in der sich die reflektierte Klasse befindet von hinten an identisch ist mit dem generierten Pfad. Kommen wir also zur nächsten Regel.

Properties und Methoden im private oder protected scope müssen mit einem Unterstrich beginnen

Das habe ich in zwei Regeln aufgeteilt. Und hier bin ich nun auch auf die ersten Probleme gestoßen. Fangen wir also mit einer Regel an, die prüft, ob alle private/protected Properties mit einem Unterstrich beginnen bzw. ob alle mit einem Unterstrich beginnenden Properties private oder protected sind.

	class Compliance_Rule_Class_Property_Name extends Compliance_Rule {
		protected $_deficitMessage = 'Protected and public Attributes must begin with an underscore.';

		public function check(ReflectionClass $class) {

			$result = null;

			foreach($class->getProperties() as $property) {
				$return = $this->checkProperty($property);
				if($return !== null) {
					if($result === null) {
						$result = new Compliance_Rule_Result();
					}

					$result->addMessage($return);
				}
			}

			return $result;
		}

		public function checkProperty(ReflectionProperty $property) {
			if(!$property->getDeclaringClass()->isUserDefined()) {
				return;
			}

			if(($property->isPrivate() OR $property->isProtected())
					AND
				substr($property->getName(), 0, 1) != '_') {
				$message = new Compliance_Rule_Result_Message(
					$property->getDocComment()->getAuthor(),
					$property->getDeclaringClass()->getName(),
					new Compliance_Tester_Directory_File($property->getDeclaringClass()->getFileName()),
					$property->getLineNumber(),
					$this->_deficitMessage
				);

				return $message;
			}

			if((!$property->isPrivate() AND !$property->isProtected())
					AND
				substr($property->getName(), 0, 1) == '_') {
				$message = new Compliance_Rule_Result_Message(
					$property->getDocComment()->getAuthor(),
					$property->getDeclaringClass()->getName(),
					new Compliance_Tester_Directory_File($property->getDeclaringClass()->getFileName()),
					$property->getLineNumber(),
					$this->_deficitMessage
				);

				return $message;
			}
		}
	}

Zu unserem Problem: Um den Dateinamen in das Result-Objekt eintragen zu können muss ich mir zunächst wieder die Überklasse holen. Haben wir hier nun aber ein Property dass innerhalb einer Standard-PHP-Klasse definiert wurde, von einer erweiterten Klasse übernommen wurde und nicht unserer Regel entspricht, haben wir keinen Dateinamen. So oder so sind das Verstöße, gegen die man sich (als PHP-Entwickler) nicht wehren kann.

Hier dann noch die Regel für Methoden

	class Compliance_Rule_Class_Method_Name extends Compliance_Rule {
		protected $_deficitMessage = 'Protected and private methods must begin with an underscore.';

		public function check(ReflectionClass $class) {
			$result = null;

			foreach($class->getMethods() as $method) {
				$return = $this->checkMethod($method);
				if($return !== null) {
					if($result === null) {
						$result = new Compliance_Rule_Result();
					}

					$result->addMessage($return);
				}
			}

			return $result;
		}

		public function checkMethod(ReflectionMethod $method) {
			if(!$method->getDeclaringClass()->isUserDefined()) {
				return;
			}

			if(($method->isPrivate() OR $method->isProtected())
					AND
				substr($method->getName(), 0, 1) != '_') {
				$message = new Compliance_Rule_Result_Message(
					$method->getDocComment()->getAuthor(),
					$method->getDeclaringClass()->getName(),
					new Compliance_Tester_Directory_File($method->getDeclaringClass()->getFileName()),
					$method->getStartLine(),
					$this->_deficitMessage
				);

				return $message;
			}
		}
	}

Ich habe hier den eigentlichen Check gegen die Regel in eine weitere Methode ausgelagert. Das hat einen ziemlich einfachen Grund: Ich bin faul. Ich kann in der neuen Methode Type Hinting auf die Properties/Methoden machen und kann so mein Auto-Complete in Eclipse benutzen. ;-)

Jede Klasse, jede Methode und jedes Property muss dokumentiert werden

Im Grunde ist das nun auch nur eine Zusammenführung von dem, was wir in den Regeln zuvor schon gemacht haben. Hier der Code:

	class Compliance_Rule_Class_Documentation extends Compliance_Rule  {
		protected $_deficitMessage = 'All classes, methods and attributes must be documentated.';

		public function check(ReflectionClass $class) {
			$result = null;

			if(($return = $this->checkClass($class)) !== null) {
				if($result === null) {
					$result = new Compliance_Rule_Result();
				}

				$result->addMessage($return);
			}

			foreach($class->getMethods() as $method) {
				$return = $this->checkMethod($method);
				if($return !== null) {
					if($result === null) {
						$result = new Compliance_Rule_Result();
					}

					$result->addMessage($return);
				}
			}

			foreach($class->getProperties() as $property) {
				$return = $this->checkProperty($property);
				if($return !== null) {
					if($result === null) {
						$result = new Compliance_Rule_Result();
					}

					$result->addMessage($return);
				}
			}

			return $result;
		}

		public function checkMethod(ReflectionMethod $method) {
			if(!$method->getDeclaringClass()->isUserDefined()) { return; }

			if($method->getDocComment()->__toString() == '') {
				$message = new Compliance_Rule_Result_Message(
					$method->getDocComment()->getAuthor(),
					$method->getDeclaringClass()->getName(),
					new Compliance_Tester_Directory_File($method->getDeclaringClass()->getFileName()),
					$method->getStartLine(),
					$this->_deficitMessage. ' method'
				);

				return $message;
			}
		}

		public function checkProperty(ReflectionProperty $property) {
			if(!$property->getDeclaringClass()->isUserDefined()) { return; }

			if($property->getDocComment()->__toString() == '') {
				$message = new Compliance_Rule_Result_Message(
					$property->getDocComment()->getAuthor(),
					$property->getDeclaringClass()->getName(),
					new Compliance_Tester_Directory_File($property->getDeclaringClass()->getFileName()),
					$property->getLineNumber(),
					$this->_deficitMessage. ' property'
				);

				return $message;
			}
		}

		public function checkClass(ReflectionClass $class) {
			if($class->getDocComment()->__toString() == '') {
				$message = new Compliance_Rule_Result_Message(
					$class->getDocComment()->getAuthor(),
					$class->getName(),
					new Compliance_Tester_Directory_File($class->getFileName()),
					$class->getStartLine(),
					$this->_deficitMessage. ' class'
				);

				return $message;
			}
		}
	}

Probleme

Wer bis hierhin aufgepasst hat, wird festgestellt haben, dass Zeilennummern von Properties sich nicht über die Reflection ermitteln lassen. Auch der Autor ist noch nicht automatisiert aus dem DocBlock auszulesen. Hier sind leider ein paar Erweiterungen der Reflection-API notwendig. Glücklicherweise kann man die Reflection-Klassen problemlos extenden. Bauen wir uns also zunächst eine Möglichkeit um die Zeilennummer eines Properties zu ermitteln. Auf Anhieb fiel mir nichts besseres ein, als die Datei, in der sich die Klasse befindet auszulesen. Aber auch hier wieder direkt zwei Probleme: Was, wenn sich in einer Datei zwei Klassen befinden, die beide ein Property mit dem selben Namen und den selben Eigenschaften besitzen? Und dazu kommt dann noch das Dilemma, dass man protected/private/public und static in beliebiger Reihenfolge notieren kann. Wird also doch nicht so einfach.

Reflection-API ergänzen: Zeilennummern von Properties ermitteln

Zunächst der Code, den ich benutzt habe:

		public function getLineNumber() {
			$file = $this->getDeclaringClass()->getFileName();
			$startLine = $this->getDeclaringClass()->getStartLine();
			$endLine = $this->getDeclaringClass()->getEndLine();

			$contents = file_get_contents($file);
			$contents = explode("\n", $contents);

			$classCode = '';

			for($i = $startLine -1; $i < $endLine; $i++) {
				$classCode.= $contents[$i]. "\n";
			}

			$pattern = $this->_getPattern();

			$replaced = preg_replace($pattern, 'START_LINE_COMPLIANCE$1END_LINE_COMPLIANCE', $classCode);

			$classCode = explode("\n", $replaced);

			foreach($classCode as $lineNumber => $line) {
				if(substr(trim($line), 0, strlen('START_LINE_COMPLIANCE')) == 'START_LINE_COMPLIANCE') {
					$startLineProperty = $lineNumber + $startLine;
				}

				if(substr(trim($line), (strlen('END_LINE_COMPLIANCE') * -1)) == 'END_LINE_COMPLIANCE') {
					$endLineProperty = $lineNumber + $startLine;
				}

			}

			return $startLineProperty;
		}

Zunächst ermittle ich die Datei, in der sich die Klasse befindet und die Start- und Endzeile, zwischen der sie definiert wird. Ich lese die Datei aus, packe die Zeilen in Arrayelemente und baue mir ein neues nur aus den Zeilen, in der sich die Klasse befindet. Diese führe ich wieder als einen zusammenhängenden Text zusammen und suche nach einem Ausdruck, den ich mit Schlüsselwörtern ergänze. So kann ich die Zeile ermitteln, berücksichtige das Offset und gebe das ganze aus.

Wie der Ersetzungsausdruck generiert wird erspare ich euch, das ist einfach nur langweilig ;-) . Wen es doch interessiert, der kann im Source nachsehen.

Jetzt fragen sich sicherlich einige, warum ich einige Sachen nicht anders/einfacher gelöst habe:

Warum nicht fopen/fread?

Das Problem bei fread ist, dass es entweder eine Menge an Bytes einliest oder bis zum Zeilenende. Da wir tatsächlich Zeilen brauchen, bringt uns eine Menge Bytes überhaupt nichts. Je nach kodierung der Quelldatei liest fread einfach mal die komplette Datei (bzw. bis zur maximalen Byte-Grenze) ein, und glaubt, das war nun eine Zeile. Unbrauchbar also.

Warum nicht direkt die Array-Elemente nach dem Property durchsuchen?

Es könnte durchaus möglich sein, dass das Property über mehrere Zeilen definiert wurde. Ich entschloss mich daher, das Property zu markieren um später die Anfangs und Endzeile ermitteln zu können. Das war insofern erst einmal vergebene Liebesmüh, weil ich es derzeit nichtmal ausgeben :-)

DocBlock-Object

Hier habe ich mich bei Martin Kuckert bedient, der vor einiger Zeit eine DocParser-Klasse geschrieben hat. Die Methode “getDocComment” gibt also keinen String mehr zurück, sondern liefert ein DocBlock-Objekt. Dieses hat, in meiner Fassung, derzeit nur die Methode “getAuthor” implementiert.

Aber: Wir haben in unserem Regelset ja die Anweisung, dass alles Dokumentiert sein muss. Ist etwas nun nicht dokumentiert, so kann man auch schlecht einen Autor aus dem DocBlock ermitteln. Aus diesem Grund gibt es den Parameter “assume”, der standardmäßig auf true ist. Damit kann man den Author “vermuten”. Das Prinzip ist simple: Ist ein Property oder eine Methode nicht dokumentiert, so wird der DocBlock der Klasse genommen.

Let’s roll

Nachdem ich die Suite gegen sich selbst rennen ließ, wurde mir klar, dass ich selbst noch ein paar Kommentare schreiben muss. Das sagt mir direkt, dass das nicht ganz unnützlich ist, ein solches Tool in der Hinterhand zu haben.

Das Projekt

Ich habe jetzt ein paar Tage mit der Test Suite und den Regeln verbracht und habe richtig Spaß daran gehabt. Ich habe die Test-Suite gegen das Zend Framework testen lassen und bin über ein paar Fallstricke gestolpert – allen vorran das Memory Limit. Daher habe ich mich beim schreiben der letzten Absätze dazu entschlossen das ganze als Projekt weiterzuverfolgen. Daher hier eine kleine Projektseite für den Compliance Suite. Dort könnt ihr das ganze auch runterladen und selbst mal ausprobieren.

Geschrieben in Entwicklung, Testing 3 Kommentare

#001
13. Jul 2009
web-devel

ich habe zwar nichts verstanden, aber interessant :)
beschäftige mic hbald damit


#002
03. Dez 2009


$contents = file_get_contents($file);
$contents = explode("\n", $contents);

*hüstel*

Das Problem bei fread ist, dass es entweder eine Menge an Bytes einliest oder bis zum Zeilenende. … Je nach kodierung der Quelldatei liest fread einfach mal die komplette Datei (bzw. bis zur maximalen Byte-Grenze) ein, und glaubt, das war nun eine Zeile.

Fread() interessiert sich weder für Zeilenenden, noch für die Kodierung der einzulesenden Daten. Der Glaubensfehler liegt wohl eher auf deiner Seite. ;-)

Zum Lesen einzelner Zeilen haben die PHP-Götter die überaus praktische Funktion fgets() geschaffen.


#003
03. Dez 2009
Cem Derin

@fireweasel In der Tat. Keine Ahnung welchem Irrglauben ich da aufgesessen bin. o_O

// kommentieren

// senden
theme von mir, software von wordpress, grid von 960 grid system. funktioniert in allen browsern, aber der safari bekommt das mit der schrift am schönsten hin.