Bewertung [ Bewertung abgeben ] Artikel geschrieben am 25.03.2021 um 17:48 Uhr, aktualisiert am 22.07.2023, um 21:29 Uhr.
Bleibt noch die Hauptdatei cron.php, die dafür sorgt, dass alle
Aufgaben im Hintergrund angesoßen werden.
Der Grundaufbau bleibt naturgemäß für eine Datei, die grunsätzlich nicht aktiv
ausgeführt wird, sehr nah an dem, was wir bereits für die Aufgaben programmiert
haben. Nur im Hauptprogrammteil werden wir entsprechend nicht die Aufgabe
ausführen, sondern dafür sorgen, dass die einzelnen Aufgaben angestoßen werden.
Die zentrale Frage ist: Wie führt man eine PHP-Datei im Hintergrund aus? Dazu
gibt es grundsätzlich zwei Möglichkeiten:
1. per Shell-Befehl mithilfe des Command Line Interface
oder
2. über einen HTTP(S)-Request
Ich persönlich nutze gerne den HTTP-Request. Damit sind wir in der typischen
Umgebung, wie bei einem typischen Cronjob-Aufruf, der meist ebenfalls eine URL
aufruft.
Der Vorteil eines HTTP(S)-Requests ist die Übermittlung von zusätzlichen Daten, die relativ leicht
gesendet und verarbeitet werden können. Über die Kommandozeile gibt es zwar die
Möglichkeit von Parameter, allerdings sind diese nicht so bequem und leicht
zu verarbeiten wie mit typischen POST- oder GET-Variablen
und für den normalen PHP-Entwickler ist das Auslesen der Superglobals schlicht
geläufiger als die Nutzung in einer CLI-Anwendung.
Konzentrieren wir uns für dieses Beispiel auf einen HTTP(S)-Webaufruf. Doch setzt man die Idee in die Tat um?
Auf der letzten Seite haben wir in unseren eigenen Aufgaben bereits einen Befehl
genutzt, der dafür sorgt, dass PHP-Skripte immer bis zum Ende durchlaufen.
Genau diesen "Trick" nutzen wir aus! Wir spielen Besucher und basteln
uns einen GET-Request (wenn man Daten übertragen möchte, dann gerne auch POST)
zusammen, der unsere Aufgabe aufruft.
Mit ein paar zusätzlichen Tricks trennen wir die Verbindung direkt nachdem die
wichtigen Daten übertragen wurden. Mithilfe der Anweisung \ignore_user_abort()
läuft die Aufgabe munter weiter und wir können uns direkt der nächsten Aufgabe
widmen. Nachteil: Es gibt keine Prozesskontrolle und wir können den Aufruf
nicht steuern. Wichtig hierbei: der Server sollte immer eine maximale
Ausführungszeit besitzen, damit man sich mit falsch programmierten Aufgaben
die Ressourcen blockiert. Gerade auf Hosting-Servern wird das nicht gerade
gerne gesehen.
Sollte man Zugriff auf die Shell haben, dann könnte man hier eventuell eine
Variante basteln mit der man die Prozess-IDs der Aufgaben erhält. Speichert
man diesen, dann wäre eine Prozesskontrolle denkbar, wenngleich sie deutlich
mehr Last / Speicher bedeutet.
Auch hier gilt wieder der Grundgedanke: Je nachdem, wie kritisch das System
geplant ist, lohnt sich der Forschungs- und Entwicklungsaufwand für eine solche
Variante.
Das Kernstück unserer Datei wird also das Starten des Hintergrundprozesses. Entsprechend unspektakulär ist der Aufruf in unserer Beispieldatei:
for( $i=1; $i<=5; $i++ )
{
\starteHintergrundprozess($i, $ProzessID);
}
Um den Prozess zu starten benötigen wir eine Verbindung auf unseren eigenen
Server / Domain, die wir steuern können. Die meisten werden dabei an die
Funktion file_get_contents() denken, aber diese ist für uns nicht
zielführend, da sie die Antwort des Servers abwartet.
Für unseren Aufruf nutze ich fsockopen(), allerdings ist auch die
Nutzung mithilfe der curl-Erweiterung denkbar.
$error_code = null;
$error_msg = null;
$Handler = \fsockopen("127.0.0.1", 80, $error_code, $error_msg, 5);
if( false === $Handler || !empty($error_code) || !empty($error_msg) )
{
throw new \RuntimeException("fsockopen: ".$error_msg." (#".$error_code.")", 1);
}
Wir besitzen jetzt ein offenes Socket auf unseren eigenen Webserver. Im
Internet sollte man zur Sicherheit die Verbindung über die offizielle Domain
herstellen. Auch sollte man über eine Absicherung via SSL und Basic Auth
nachdenken, um Angriffen vorzubeugen.
Wie man diese Daten übergibt, kann man über einschlägige Google-Suchen schnell erfahren.
Ich setze außerdem das Wissen voraus, wie ein
HTTP-Header
aussehen muss.
$request = [];
$request[] = "GET /taskX.php?task=".<var>$Nummer</var>."&prozess_id=".<var>$ProzessID</var>." HTTP/1.0";
$request[] = "Host: localhost";
$request[] = "Accept: */*";
$request[] = "Content-Type: application/x-www-form-urlencoded";
$request[] = "User-Agent: PHP-PseudoThreading API (v1.0.0)";
$request[] = "Connection: close";
$Header = implode("\r\n", $request);
$Header .= "\r\n\r\n";
Für unser Beispiel rufen wir natürlich immer die taskX.php auf
und unterscheiden die Aufgabe allein durch die Variable $Nummer.
In einem Projekt ist es sicherlich sinnvoller für jede Aufgabe eine separate,
eigene Datei anzulegen.
-
Der
Host-Eintrag ist relevant und sollte entsprechend korrekt gesetzt sein. Auf vielen Servern werden diverse Domains betrieben (Stichwort: Apache vHost) und der Webserver benötigt die Information für welches Ziel er eine Anfrage erhält. -
Der
Accept-Header ist schlicht für jede Rückgabe erlaubt. Da wir diese gar nicht erst abwarten spielt es keine Rolle. -
Möchte man eine
POST-Anfrage stattdesssen senden, so spielt der ParameterContent-Typeeine bedeutende Rolle. Natürlich wählt man selbst, ob die Daten wie ein normales Formular oder gar als JSON übertragen wird. Da wir keine Daten übertragen ist dieser irrelevant. -
Einen
User-Agentsollte man immer setzen. Zum einen gehört es zum guten Ton und viele Netzwerkstrukturen erwarten eine gültige Angabe in diesem Feld, sonst verweigern Sie den Zugriff bzw. weisen die Anfrage ab.
Der Inhalt ist relativ egal, aber um die eigenen Aufrufe auch ggf. in Log-Dateien verfolgen zu können, sollten sie möglichst eindeutig und klar erkennbar sein. -
Wie immer kommt das wichtigste zum Schluss. Die Angabe
Connection: closebildet die wohl relevanteste Angabe. Oft wird heutzutage die Eigenschaftkeep-alivegesetzt. Wir teilen hiermit dem Server mit, dass die Verbindung wirklich getrennt werden soll, wenn wir sie aktiv beenden.
Ein HTTP-Header wird immer mit einem doppelten Zeichenumbruch
(\r\n\r\n, Carriage Return und Newline)
beendet und danach folgt der Inhalt. Da wir keine Daten senden wollen bleibt
der Inhalt einfach leer.
Vergisst man den doppelten Zeilenumbruch nach dem Header, dann wird der
Webserver abwarten, da die Anfrage nicht vollständig ist. Ein beliebter Fehler,
wenn die Anfrage von Hand erstellt wird. Diese Gefahr besteht vor allem dann,
wenn man gerne mit trim() arbeitet.
Content-Length nicht vergessen werden.
Am Ende schreiben wir mit \fwrite($Handler, $Header); auf unsere
geöffnete Verbindung. Sobald der Webserver das letzte Bit erhalten hat wird
er diese Anfragen direkt ausführen.
Für uns bedeutet das, dass wir die Verbindung nach dem Schreiben direkt mit
\fclose($Handler); beenden können. Das PHP-Skript, also unsere
Aufgabe, wird in jedem Fall bis zum Ende abgearbeitet.
Und damit haben wir ein funktionstüchtiges Skript, dass in der Lage ist,
Aufgaben im Hintergrund aufzurufen.
Das Beispiel ist ganz bewusst sehr einfach gehalten, damit das Grundprinzip
verständlich bleibt. Eine mögliche Erweiterung ist sicherlich die zeitliche
Einschränkung von Aufgaben, analog zu echten Cronjobs.
Vollständiger Dateiinhalt
<?php
/** <cron.php>
* PHP (Pseudo-) Threading
*
* Zentrale Steuerung zum starten von Hintergrundprozessen.
*
* @version 1.0.0
*/
function starteHintergrundprozess( int $Nummer, string $ProzessID )
{
echo "<pre>";
$error_code = null;
$error_msg = null;
$Handler = \fsockopen("127.0.0.1", 80, $error_code, $error_msg, 5);
if( false === $Handler || !empty($error_code) || !empty($error_msg) )
{
throw new \RuntimeException("fsockopen: ".$error_msg." (#".$error_code.")", 1);
}
$request = [];
$request[] = "GET /test/taskX.php?task=".$Nummer."&prozess_id=".$ProzessID." HTTP/1.0";
$request[] = "Host: localhost";
$request[] = "Accept: */*";
$request[] = "Content-Type: application/x-www-form-urlencoded";
$request[] = "User-Agent: PHP-PseudoThreading API (v1.0.0)";
$request[] = "Connection: close";
$Header = implode("\r\n", $request);
# Der Header wird vom Body immer mit 2 Newlines (\r\n) abgetrennt. Wenn nur
# der Header gesendet wird, dann MUSS am Ende dieser Trenner ebenfalls
# gesendet werden!
$Header .= "\r\n\r\n";
\fwrite($Handler, $Header); # nur Header senden!
var_dump($Handler);
\fclose($Handler);
var_dump($Header);
echo "</pre>";
}
try
{
$ProzessID = \uniqid();
$AktuellesVerzeichnis = dirname(__FILE__);
$ZentraleLogdatei = $AktuellesVerzeichnis."/logs/threads.log";
file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".date("Y-m-d H:i:s").": Ausführung gestartet.\r\n", \FILE_APPEND);
for( $i=1; $i<=5; $i++ )
{
\starteHintergrundprozess($i, $ProzessID);
}
}
catch( \Throwable $Exception )
{
file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".$Exception->getMessage()." (#".$Exception->getCode().")\r\n", \FILE_APPEND);
}
finally
{
file_put_contents($ZentraleLogdatei, "[".$ProzessID."|cron] ".date("Y-m-d H:i:s").": Ausführung beendet (".\connection_status().").\r\n", \FILE_APPEND);
}