PHP Tutorial: Online-Statistik
1unitedpower
- mysql
- php
- wiki
Wie versprochen folgt hier mein Ersatz für den Onlinezähler-Artikel. Ihr dürft ihn gerne für euer Wiki benutzen. Ich musste ihn für das Forum leider aufteilen.
In diesem Tutorial lernst du wie du mit PHP und SQL eine Zugriffstatistik erstellen kannst.
Das Statistik-Programm soll zwei Werte anzeigen: Zum einen ist das die Anzahl der Personen, die die Seite in der vergangenen x
Sekunden aufgerufen haben. Zum anderen ist das die Gesamtanzahl der Personen, die seit Start der Webseite einen Aufruf getätigt haben. Wir benutzen die IP-Adresse, um Nutzer*innen wiederzuerkennen. Das ist kein verlässliches Kriterium, deshalb ist unsere Statistik nur eine Schätzung und keine exakte Repräsentation.
Für dieses Tutorial solltest du die Grundlagen von PHP und SQL beherrschen. Wenn du etwas nicht gleich verstehst, frag uns gerne in unserem Forum.
Du brauchst außerdem mindestens die PHP-Version 7.3
und die MySQL-Version 8.0
oder MariaDB-Version 10.2.0
.
Wenn wir Personen wiedererkennen möchten, dann brauchen wir eine Datenbank, in der wir uns die einzelnen Seiten-Zugriffe merken. Dafür benutzen wir SQL. Unsere Datenbank wird sehr simpel strukturiert sein, wir brauchen nur eine einzige Tabelle mit jeweils einer Spalte für die IP-Adresse und einer Spalte für die Zugriffszeit. Aus konventionellen Gründen fügen wir noch eine Spalte mit einer eindeutigen ID für jede Zeile hinzu.
CREATE TABLE `access_log` (
`id` MEDIUMINT NOT NULL AUTO_INCREMENT,
`ip` VARCHAR(255) NOT NULL,
`access_time` DATETIME DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id)
);
Für den PHP-Teil entwickeln wir mit dir zusammen zwei Module: Das erste Modul dient der Kommunikation mit der Datenbank. Das zweite Modul dient der Ausgabe in HTML. Im letzten Teil des Tutorials fügen wir die beiden Module zu einer Anwendung zusammen.
Für die Datenbank-Schicht brauchen wir zunächst drei Funktionen: Eine Funktion zum Speichern eines neues Logbuch-Eintrags; eine Funktion, um die Anzahl der Personen auszulesen, die in der vergangen Minute die Seite aufgerufen haben; und eine Funktion, die die Gesamtanzahl der Personen ausliest, die jemals die Seite aufgerufen haben. Diese drei Funktionen dienen dazu, zwischen der Datenbank-Schicht (auch Persistenz-Schicht genannt) und der Geschäftslogik zu vermitteln. Die Funktionen sind also verwandt miteinander, wir implementieren sie deshalb als drei Methoden einer gemeinsamen Klasse, die wir AccessLogRepository
nennen. Hier ist das grobe Schema (auch Signatur genannt) unserer Klasse, die Methoden-Rümpfe werden wir im Anschluss einzeln implementieren.
<?php
declare(strict_types=1);
namespace SelfHtml\Counter;
use \PDO;
final class AccessLogRepository
{
private $pdo;
public function __construct(PDO $pdo)
{
}
/**
* Erzeugt einen neuen Datenbank-Eintrag für die angebene IP-Adresse
* und den aktuellen Zeitstempel.
*
* @param string $ip IP-Adresse des zu speichernden Log-Eintrags
*
* @return void
*/
public function log(string $ip) : void
{
}
/**
* Liest die Anzahl der verschiedenen IP-Adressen aus, die in den vergangenen
* X Sekunden einen Zugriff durchgeführt haben.
*
* @param int $maxAge Das maximale Alter in Sekunden, das ein Eintrag haben darf,
* damit die dazugehörige IP-Adresse in die Zählung aufgenommen wird.
*
* @return int
*/
public function countIpsYoungerThan(int $maxAge) : int
{
}
/**
* Liest die Anzahl allers Sessions aus der Datenbank aus.
* Eine Session wird von Log-Einträgen der selben IP-Adresse gebildet,
* die zeitlich nahe beianander stehen.
*
* @param int $sessionLifeTime Die maximale Dauer in Sekunden, die zwischen zwei
* Log-Einträgen der selben IP-Adresse vergangen sein darf, damit sie zur selben
* Session gezählt werden.
*
* @return int
*/
public function countAllSessions(int $sessionLifeTime) : int
{
}
}
Wir beginnen mit der einfachsten Funktion, dem Konstruktor, und arbeiten uns dann schrittweise von oben nach unten zu den schwierigeren Funktionen vor.
Der Konstruktor bekommt als Parameter eine Datenbank-Verbindung in Form einer PDO-Instanz übergeben. Diese wollen wir in den späteren Funktionen nutzen, wir merken sie uns deshalb in einer Instanz-Variablen.
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
Als nächstes kümmern wir uns, um die log-Funktion. Sie bekommt als Parameter eine IP-Adresse, die gespeichert werden soll. Sonst soll die Funktion nichts machen. Der Rückgabe-Typ void
signalisiert uns, dass die Funktion auch nichts zurückgeben soll.
/**
* Erzeugt einen neuen Datenbank-Eintrag für die angebene IP-Adresse
* und den aktuellen Zeitstempel.
*
* @param string $ip IP-Adresse des zu speichernden Log-Eintrags
*
* @return void
*/
public function log(string $ip) : void
{
static $query = 'INSERT INTO `access_log` (`ip`) VALUES (:ip);';
$this->pdo->prepare($query)->execute([':ip' => $ip]);
}
Die Insert-Anfrage in $query
beschreibt einen Einfüge-Vorgang in SQL. Die Spalten id
und access_time
werden von SQL automatisch befüllt, weil wir das in unserem Schema so vorgesehen haben. Die Spalte ip
müssen wir selber befüllen. Dafür haben wir einen Platzhalter :ip
in der Abfrage notiert. Die eigentliche Ausführung der Datenbank-Anfrage findet in der nächsten Zeile in zwei Phasen statt: Die Methode prepare
teilt der Datenbank mit, dass hier eine Anfrage mit potenziellen Platzhaltern folgt. Der Aufruf von execute
setzt den Wert der tatsächlichen IP-Adresse $ip
für den Platzhalter ein und löst den Schreibvorgang in der Datenbank aus.
Als nächsten kümmern wir uns um die countIpsYoungerThan
-Funtkion. Sie bekommt ein Zeitinterval in Sekunden übergeben, und soll die Anzahl der verschiedenen IP-Adressen auslesen, die in den vergangenen Sekunden, eine Anfrage ausgeführt haben.
/**
* Liest die Anzahl der verschiedenen IP-Adressen aus, die in den vergangenen
* X Sekunden einen Zugriff durchgeführt haben.
*
* @param int $maxAge Das maximale Alter in Sekunden, das ein Eintrag haben darf,
* damit die dazugehörige IP-Adresse in die Zählung aufgenommen wird.
*
* @return int
*/
public function countIpsYoungerThan(int $maxAge) : int
{
static $query = <<<SQL
SELECT COUNT(DISTINCT `ip`)
FROM `access_log`
WHERE TIMESTAMPDIFF(
SECOND,
`access_time`,
NOW()
) <= :maxAge;
SQL;
return (int) $this->pdo->prepare($query)
->execute(['mageAge' => (string) $magAge])
->fetchColumn();
}
Die Methode folgt dem selben Schema, wie die vorherige: Erst notieren wir die nötige SQL-Anfrage, dann rufen wir prepare
auf, um die Datenbank auf den Platzhalter :maxAge
aufmerksam zu machen, anschließend führen wir die Anfrage mit execute
aus. Diesmal kommt noch ein Schritt fetchColumn
hinzu, weil wir einen Lesezugriff auf die Datenbank ausgeführt haben und wir das SQL-Ergebnis als Rückgabe-Wert unserer PHP-Funktion weitereichen möchten.
SELECT COUNT(DISINCT ip)
sorgt dafür, dass wir nur verschiedene IP-Adresse zählen. Der Krux hier ist die WHERE
-Klausel. Dort berechnen wir das Alter eines Log-Eintrags in Sekunden und vergleichen es mit dem maximalen Alter, dass der Log-Eintrag haben darf, um gezählt zu werden.
Die letzte Funktion countAllSessions
ist etwas komplizierter. Wir wollen hier nicht bloß die Anzahl der verschiedenen IP-Adressen zählen. Stattdessen wollen wir alle Log-Einträge, die innerhalb eines vorgebenen Zeitraumes von der selben IP-Adresse verursacht worden, als zusammengehörige Session nur einmal zählen. Die SQL-Abfrage dafür erarbeiten wir uns schrittweise. Stellen wir uns zunächst vor, wir hätten eine Tabelle mit nur einer Spalte namens delta
, die die Zeitabstände in Sekunden von Zugriffen der selben IP-Adresse enthält, oder den Sonderwert NULL
falls es keinen vorherigen Log-Eintrag der selben IP-Adresse gibt. Dann könnten wir die folgende SQL-Anfrage stellen:
SELECT COUNT(*) FROM ???
WHERE `delta` IS NULL OR `delta` > :session_life_time;
Die Frage ist, was gehört an die Stelle, die mit ???
gekennzeichnet ist. Denn die Tabelle, die wir uns vorgestellt haben ist ja nur fiktiv. An dieser Stelle muss nicht unbedingt ein Tabellenname stehen, wir können dort auch eine Unterabfrage platzieren. Diese Unterabfrage soll in einem Zwischenschritt unsere fiktive Tabelle simulieren.
Wir haben bereits gesehen, wie wir Zeitabstände in SQL berechnen. Die folgende Skizze bringt uns einen Schritt näher zum Ziel:
SELECT
TIMESTAMPDIFF(
SECOND,
`access_time`,
???
) as `delta`
FROM `access_log`;
Wir müssen nun abermals die durch ???
gekennzeichnete Lücke schließen. Zuvor haben wir dort den aktuellen Zeitstempel mit NOW()
eingesetzt. Diesmal jedoch, wollen wir dort den Zeitstempel des Log-Eintrags haben, der zur selben IP-Adresse gehört, und der zeitlich unmittelbar davor stattgefunden hat. Diesen Eintrage bekommen wir mit dem folgenden Ausdruck:
LEAD(`access_time`) OVER (
PARTITION BY `ip`
ORDER BY `access_time`
)
Insgesamt ergibt sich somit die Anfrage:
SELECT COUNT(*) FROM (
SELECT
TIMESTAMPDIFF(
SECOND,
`access_time`,
LEAD(`access_time`) OVER (
PARTITION BY `ip`
ORDER BY `access_time`
)
) as `delta`
FROM `access_log`
) `t`
WHERE `delta` IS NULL OR `delta` > :session_life_time;
Und in die resultierende PHP-Funktion sieht wie folt aus:
/**
* Liest die Anzahl allers Sessions aus der Datenbank aus.
* Eine Session wird von Log-Einträgen der selben IP-Adresse gebildet,
* die zeitlich nahe beianander stehen.
*
* @param int $sessionLifeTime Die maximale Dauer in Sekunden, die zwischen zwei
* Log-Einträgen der selben IP-Adresse vergangen sein darf, damit sie zur selben
* Session gezählt werden.
*
* @return int
*/
public function countAllSessions(int $sessionLifeTime) : int
{
static $query = <<<SQL
SELECT COUNT(*) FROM (
SELECT
TIMESTAMPDIFF(
SECOND,
`access_time`,
LEAD(`access_time`) OVER (
PARTITION BY `ip`
ORDER BY `access_time`
)
) as `delta`
FROM `access_log`
) `t`
WHERE `delta` IS NULL OR `delta` > :session_life_time;
SQL;
return (int) $this->pdo->prepare($query)
->execute([':session_life_time' => (string) $sessionLifeTime]))
->fetchColumn();
}
Setzen wir alle Funktions-Definitionen nun in das Rohgerüst unser AccessLogRepository
-Klasse ein, enthalten wir die folgende, vollständige Klassen-Definition.
Den Quelltext speichern wir in der Datei mit dem Namen AccessLogRepository.php
.
<?php
declare(strict_types=1);
namespace SelfHtml\Counter;
use \PDO;
final class AccessLogRepository
{
private $pdo;
public function __construct(PDO $pdo)
{
$this->pdo = $pdo;
}
/**
* Erzeugt einen neuen Datenbank-Eintrag für die angebene IP-Adresse
* und den aktuellen Zeitstempel.
*
* @param string $ip IP-Adresse des zu speichernden Log-Eintrags
*
* @return void
*/
public function log(string $ip) : void
{
static $query = 'INSERT INTO `access_log` (`ip`) VALUES (:ip);';
$this->pdo->prepare($query)->execute([':ip' => $ip]);
}
/**
* Liest die Anzahl der verschiedenen IP-Adressen aus, die in den vergangenen
* X Sekunden einen Zugriff durchgeführt haben.
*
* @param int $maxAge Das maximale Alter in Sekunden, das ein Eintrag haben darf,
* damit die dazugehörige IP-Adresse in die Zählung aufgenommen wird.
*
* @return int
*/
public function countIpsYoungerThan(int $maxAge) : int
{
static $query = <<<SQL
SELECT COUNT(DISTINCT `ip`)
FROM `access_log`
WHERE TIMESTAMPDIFF(
SECOND,
`access_time`,
NOW()
) <= :maxAge;
SQL;
return (int) $this->pdo->prepare($query)
->execute(['mageAge' => (string) $magAge])
->fetchColumn();
}
/**
* Liest die Anzahl allers Sessions aus der Datenbank aus.
* Eine Session wird von Log-Einträgen der selben IP-Adresse gebildet,
* die zeitlich nahe beianander stehen.
*
* @param int $sessionLifeTime Die maximale Dauer in Sekunden, die zwischen zwei
* Log-Einträgen der selben IP-Adresse vergangen sein darf, damit sie zur selben
* Session gezählt werden.
*
* @return int
*/
public function countAllSessions(int $sessionLifeTime) : int
{
static $query = <<<SQL
SELECT COUNT(*) FROM (
SELECT
TIMESTAMPDIFF(
SECOND,
`access_time`,
LEAD(`access_time`) OVER (
PARTITION BY `ip`
ORDER BY `access_time`
)
) as `delta`
FROM `access_log`
) `t`
WHERE `delta` IS NULL OR `delta` > :session_life_time;
SQL;
return (int) $this->pdo->prepare($query)
->execute([':session_life_time' => (string) $sessionLifeTime]))
->fetchColumn();
}
}
Wir brauchen als nächstes noch eine Funktion für die Ausgabe in HTML. Die Funktion bekommt die beiden Werte des Statistik-Moduls als Parameter übergeben, setzt diese in die richtigen Stellen des HTML-Codes ein und gibt den resultierenden HTML-String zurück. Den Quelltext legen wir in einer Datei namens View.php
ab.
<?php
declare(strict_types=1);
namespace SelfHtml\Counter;
function html(int $visits, int $count) : string
{
$visitsEscaped = htmlspecialchars((string) $visits);
$onlineEscpaed = htmlspecialchars((string) $online);
return <<<html
<!DOCTYPE html>
<html lang="de">
<head>
<title>SelfHtml Counter Beispiel</title>
</head>
<body>
<label for="vistis">Anazahl Besucher Ingesamt</label>
<output id="visits">$visitsEscaped</output>
<label for="online">Anzah Beucher Online</label>
<output id="online">$onlineEscpaed</output>
</body>
</html>
html;
}
Wir haben nun alle Module, dir wir brauchen: Ein Modul, das zwischen der Datenbank und der Geschäftslogik vermittelt, und ein Modul, das die Ausgabe in HTML formatiert. Im letzten Teil dieses Turoials, müssen wir die Module nun noch zu einer Gesamtanwendung verkabeln.
Als erstes müssen wir unsere beiden Module in die Anwendungen einbinden. Das machst du mit require_once
. Danach müssen wir ein paar Konfigurations-Werte setzen. Dazu gehören unsere Datenbankverindungsdaten; die Dauer in Sekunden, die eine Person als online gezählt werden soll; und die Dauer in Sekunden, die zwischen zwei Anfragen der selben IP-Adresse vergangen sein muss, damit sie in der Gesamtzählung einzeln berücksichtigt werden.
Wenn wir alle Konfigurations-Werte haben, können wir die Datenbank-Verbindung aufbauen und unser AccessLogRepository
-Modul initialiseren.
Nach der Initialisierung, loggen wir die aktuelle IP-Adresse, lesen unsere beiden Statistik-Werte aus und übergeben sie an unsere Ausgabe-Funktion.
<?php
declare(strict_types=1);
namespace SelfHtml\Counter;
use \PDO;
// Module einbinden
require_once('AccessLogRepository.php');
require_once('View.php');
// Konfiguration
$dsn = 'mysql:host=localhost;dbname=testdb';
$username = 'username';
$password = 'password';
$sessionLifetime = 86400; // 86400 Sekunden = 1 Tag
$onlineTime = 600; // 600 Sekunden = 10 Minuten
// Persistenz-Schicht initialisieren
$pdo = new PDO($dsn, $username, $password);
$repository = new AccessLogRepository($pdo);
// Aktuellen Zugriff loggen
$repository->log($_SERVER['REMOTE_ADDR']);
// Statistiken auslesen
$visits = $repository->countAllSessions($sessionLifetime);
$online = $repository->countIpsYoungerThan($onlineTime);
// Ausgabe
echo html($visits, $online);
Das war es. Du kannst den Quelltext zum Beispiel in einer Datei mit dem Namen index.php
speichern.
Als Übung, um das Material dieses Tutorials zu vertiefen, könntest du die Statistik um einen dritten Wert erweitern, der die Gesamtanzahl aller Aufrufe unabhängig von der IP-Adresse und der verstrichenen Zeit zwischen zwei Log-Einträgen ausgibt. Überlege dir dazu schrittweise, wie du das Datenbank-Modul und das Ausgabe-Modul anpassen musst. Erst im letzten Schritt solltest du die index.php
-Datei entsprechend anpassen.
Hallo 1unitedpower,
dankeschön.
Bis demnächst
Matthias
Hallo 1unitedpower,
hier mein Ersatz für den Onlinezähler-Artikel.
du hast dir sehr viel Mühe gegeben und das sollte man auch honorieren. Aber es darf auch Kritik dabei sein? Ein Ersatz ist das auf keinen Fall, wenn ich den Originalartikel als Referenz nehme. Denn der ist sicherlich dafür gedacht auch Anfängern die Möglichkeit eines einfachen Zählers zu zeigen. Dein Vorschlag ist da wohl eher schon was für Leute mit genug Basiswissen und wenn dieses nicht gegeben ist, dann stellt sich die Frage, warum mit Kanonen auf Spatzen schießen. Jemand der PDO und OOP kennt, weiss sicherlich auch wie ein Zähler funktioniert.
Daher glaube ich ein einfacher Zähler mit einer Textdatei als DB macht mehr Sinn, als Ersatz.
Gruss
Henry
du hast dir sehr viel Mühe gegeben und das sollte man auch honorieren.
Danke dir.
Aber es darf auch Kritik dabei sein?
Ich bitte darum.
Ein Ersatz ist das auf keinen Fall, wenn ich den Originalartikel als Referenz nehme. Denn der ist sicherlich dafür gedacht auch Anfängern die Möglichkeit eines einfachen Zählers zu zeigen. Dein Vorschlag ist da wohl eher schon was für Leute mit genug Basiswissen und wenn dieses bereits gegeben ist, dann stellt sich die Frage, warum mit Kanonen auf Spatzen schießen. Jemand der PDO und OOP kennt, weiss sicherlich auch wie ein Zähler funktioniert.
OOP-Anfänger*innen haben häufig das Problem, dass sie die Theorie nicht unmittelbar auf die Praxis übertragen können, und Schwierigkeiten damit konzeptionelle Grenzen zwischen verschiedenen Teilaspekten einer Anwendung zu ziehen. In dem Tutorial präsentiere ich das Repository-Pattern zur Trennung der Persistenzschicht und der Geschäftslogik. Ein Entwurfsmuster, das man heute in vielen PHP-Anwendungen wiederfindet und das deshalb gut auf die Praxis vorbeireitet.
Daher glaube ich ein einfacher Zähler mit einer Textdatei als DB macht mehr Sinn, als Ersatz.
Das habe ich auch überlegt, für mich war ausschlaggebend, dass eine Textdatei für die dauerhaufte Speicherung von Daten nur oberflächlich einfacher ist. Bei einer Textdatei muss man sich selber um die (De)kodierung der Daten kümmern und die Zugriffe konkurrierender Lese- und Schreibvorgänge synchronisieren, um die Konsistenz der Daten zu gewährleisten. Außerdem muss man Aggregat-Funktionen, die in MySQL bereits zur Verfügung stehen, in PHP selber nachprogrammieren. Das ist viel Stoff, den eine Anfänger*in vermutlich erstmal blind akzeptiert, ohne die Materie wirklich zu verstehen.
Ich habe tatsächlich auch mit einem AccessLogRepostory
experimentiert, das mit der selben Schnittstelle funktioniert, aber mit einem Dateispeicher anstelle einer Datenbank-Verbindung. Das hat in meinen Augen zu sehr viel Komplexität beigetragen.
Aloha ;)
Daher glaube ich ein einfacher Zähler mit einer Textdatei als DB macht mehr Sinn, als Ersatz.
Der Konsens in der internen Diskussion war, dass der Sinn eines Artikels, der einen Besucherzähler anbietet, überhaupt in Frage steht. Es macht eigentlich wenig Sinn, auf der einen Seite zu sagen, dass Benutzerzähler völlig aus der Zeit gefallen sind und auch sachlich kaum Aussagekraft besitzen, und dann auf der anderen Seite selbst einen anzubieten. Das lockt ein Stück weit auch eine falsche Zielgruppe an.
Mit der Sache (deutlich höheres Anforderungsniveau als der andere Artikel) hast du natürlich recht. Das ist aber nicht unbedingt ein Plädoyer dafür, den Besucherzähler-Artikel in einfach neu zu schreiben, sondern mehr ein Plädoyer dafür, sich nochmal rauszupicken, um was es Lernziel-technisch eigentlich ging, und das dann in einem weiteren sinnvollen Artikel auf geringerem Anforderungsniveau aufzuarbeiten.
Grüße,
RIDER
Der Konsens in der internen Diskussion war, dass der Sinn eines Artikels, der einen Besucherzähler anbietet, überhaupt in Frage steht. Es macht eigentlich wenig Sinn, auf der einen Seite zu sagen, dass Benutzerzähler völlig aus der Zeit gefallen sind und auch sachlich kaum Aussagekraft besitzen, und dann auf der anderen Seite selbst einen anzubieten. Das lockt ein Stück weit auch eine falsche Zielgruppe an.
Da gehe ich inhaltlich komplett mit, am besten trennen wir uns gleich wieder von dem Artikel. Ich hatte ihn noch halb fertig rumfliegen und habe ihn so ziemlich aus den selben Gründen begraben. Ich wusste nicht, dass diese interne Diskussion stattgefunden hat, und habe dann geglaubt euch einen Gefallen zu tun, nachdem ich bemerkt hatte, dass der ehemalige Artikel aus dem Wiki verschwunden war. Aber wie gesagt, ich hänge da nicht dran und schließe mich eurem Konsens an.
Bei Zeiten werde ich daraus vielleicht mal eine Todo-App gießen, das wäre wohl sinnvoller.