Persistent Data
Plugins often have the need to store their own data, and that is typically done in flat text files. Let us have a look at how this is done in the Online plugin:
$tmpdata = $pth['folder']['plugins'].$plugin."/data/online.txt";
if(!is_file($tmpdata)) {
$handle = fopen($tmpdata, "w");
fclose($handle);
chmod($tmpdata,0666);
}
$f = fopen($tmpdata, "r+");
flock($f,2);
while (!feof($f))
{
$user[] = chop(fgets($f,65536));
}
fseek($f,0,SEEK_SET);
ftruncate($f,0);
foreach ($user as $line)
{
list($savedip,$savedtime) = split("\|",$line);
if ($savedip == $ip) {$savedtime = $time;$found = 1;}
if ($time < $savedtime + ($minutes * 60))
{
fputs($f,"$savedip|$savedtime\n");
$users = $users + 1;
}
}
if ($found == 0)
{
fputs($f,"$ip|$time\n");
$users = $users + 1;
}
fclose ($f);
Although the logic is not really complex, the code is hard to
understand, and obiously hard to test, because it mixes the concerns
of the file storage, and the logic on how to maintain the data.
Can we do better? Let us have a look at the Document
interface
and DocumentStore
class.
First, we create a suitable class which implements the Document
methods:
class Online implements Document
{
private $times = [];
public static function fromString(string $contents, string $key)
{
$that = new static();
$lines = explode("\n", $contents);
foreach ($lines as $line) {
[$ip, $time] = explode("|", $line);
$that->times[$ip] = (int) $time;
}
return $that;
}
public function toString(): string
{
$lines = [];
foreach ($this->times as $ip => $time) {
$lines[] = $ip . "|" . $time;
}
return implode("\n", $lines);
}
}
Online::fromString()
parses the $contents
, and stores the relevant
data in Online::$times
, while Online::toString()
reassembles
the string representation from the private property.
Next, we implement the three pieces of logic, namely to
update the timestamp for the current $ip
, to
delete all entries older than $minutes * 60
seconds,
and to get the number of users who are currently online.
class Online implements Document
{
// existing code omitted for brevity
public function updateTimestamp(string $ip, int $time): void
{
$this->times[$ip] = $time;
}
public function removeOfflineUsers(int $now): void
{
$this->times = array_filter($this->times, function (int $time) use ($now) {
return $now < $time + ($minutes * 60);
});
}
public function countOnlineUsers(): int
{
return count($this->times);
}
}
Finally, we assemble that in the gonline()
function:
$store = new DocumentStore($pth["folder"]["plugins"] . "online/data/");
$model = $store->update("online.txt", Online::class);
$model->updateTimestamp($ip, $time);
$model->removeOfflineUsers($time);
$store->commit(); // save right away, we are not doing further modifications
$users = $model->countOnlineUsers();
While this is obviously more code than in the original (although not
much, because the actual file access code is provided by DocumentStore
),
it is much easier to understand and maintain.
The toplevel code is crystal clear: get the model, update the timestamp
of the current user, remove the offline users, save, and finally count
the remaining online users. Even no need for some additional comments.
And if we wanted to be a bit more robust than in the original,
because writing to a file can always fail for various reasons,
we only had to check the return value of $store->commit()
instead of checking multiple lowlevel filesystem calls (such as fputs
).
The implementation of the Online
class is also easy to understand,
and if we wanted to change the data format, we would only have to
update the ::fromString()
and ::toString()
methods – nothing else.
And we can test the Online
class without further ado:
just call Online::fromString()
with some string fixture,
then call a method, and check whether Online::toString()
returns the desired string.
Looking back at the gonline_internal()
function of the previous section,
we could also pass the DocumentStore
instead of creating that inside
the function; then we could easily test the whole gonline_internal()
function with a broad test, by passing in a DocumentStore
mock.