-
Notifications
You must be signed in to change notification settings - Fork 1
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add
FileStorage
for cache driver (#386)
* feat: add `FileStorage` for cache driver * resolve review from @anggermpd
- Loading branch information
1 parent
55c4f1f
commit c34f832
Showing
3 changed files
with
355 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,240 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace System\Cache\Storage; | ||
|
||
use System\Cache\CacheInterface; | ||
|
||
class FileStorage implements CacheInterface | ||
{ | ||
public function __construct( | ||
private string $path, | ||
private int $defaultTTL = 3_600, | ||
) { | ||
if (false === is_dir($this->path)) { | ||
mkdir($this->path, 0777, true); | ||
} | ||
} | ||
|
||
/** | ||
* Get info of storage. | ||
* | ||
* @return array<string, array{value: mixed, timestamp?: int, mtime?: float}> | ||
*/ | ||
public function getInfo(string $key): array | ||
{ | ||
$filePath = $this->makePath($key); | ||
|
||
if (false === file_exists($filePath)) { | ||
return []; | ||
} | ||
|
||
$data = file_get_contents($filePath); | ||
|
||
if (false === $data) { | ||
return []; | ||
} | ||
|
||
return unserialize($data); | ||
} | ||
|
||
public function get(string $key, mixed $default = null): mixed | ||
{ | ||
$filePath = $this->makePath($key); | ||
|
||
if (false === file_exists($filePath)) { | ||
return $default; | ||
} | ||
|
||
$data = file_get_contents($filePath); | ||
|
||
if ($data === false) { | ||
return $default; | ||
} | ||
|
||
$cacheData = unserialize($data); | ||
|
||
if (time() >= $cacheData['timestamp']) { | ||
$this->delete($key); | ||
|
||
return $default; | ||
} | ||
|
||
return $cacheData['value']; | ||
} | ||
|
||
public function set(string $key, mixed $value, int|\DateInterval|null $ttl = null): bool | ||
{ | ||
$filePath = $this->makePath($key); | ||
$directory = dirname($filePath); | ||
|
||
if (false === is_dir($directory)) { | ||
mkdir($directory, 0777, true); | ||
} | ||
|
||
$cacheData = [ | ||
'value' => $value, | ||
'timestamp' => $this->calculateExpirationTimestamp($ttl), | ||
'mtime' => $this->createMtime(), | ||
]; | ||
|
||
$serializedData = serialize($cacheData); | ||
|
||
return file_put_contents($filePath, $serializedData, LOCK_EX) !== false; | ||
} | ||
|
||
public function delete(string $key): bool | ||
{ | ||
$filePath = $this->makePath($key); | ||
|
||
if (file_exists($filePath)) { | ||
return unlink($filePath); | ||
} | ||
|
||
return false; | ||
} | ||
|
||
public function clear(): bool | ||
{ | ||
$files = new \RecursiveIteratorIterator( | ||
new \RecursiveDirectoryIterator($this->path, \RecursiveDirectoryIterator::SKIP_DOTS), | ||
\RecursiveIteratorIterator::CHILD_FIRST | ||
); | ||
|
||
foreach ($files as $fileinfo) { | ||
$action = $fileinfo->isDir() ? 'rmdir' : 'unlink'; | ||
$action($fileinfo->getRealPath()); | ||
} | ||
|
||
return true; | ||
} | ||
|
||
public function getMultiple(iterable $keys, mixed $default = null): iterable | ||
{ | ||
$result = []; | ||
|
||
foreach ($keys as $key) { | ||
$result[$key] = $this->get($key, $default); | ||
} | ||
|
||
return $result; | ||
} | ||
|
||
public function setMultiple(iterable $values, int|\DateInterval|null $ttl = null): bool | ||
{ | ||
$state = null; | ||
|
||
foreach ($values as $key => $value) { | ||
$result = $this->set($key, $value, $ttl); | ||
$state = is_null($state) ? $result : $result && $state; | ||
} | ||
|
||
return $state ?: false; | ||
} | ||
|
||
public function deleteMultiple(iterable $keys): bool | ||
{ | ||
$state = null; | ||
|
||
foreach ($keys as $key) { | ||
$result = $this->delete($key); | ||
$state = is_null($state) ? $result : $result && $state; | ||
} | ||
|
||
return $state ?: false; | ||
} | ||
|
||
public function has(string $key): bool | ||
{ | ||
return file_exists($this->makePath($key)); | ||
} | ||
|
||
public function increment(string $key, int $value): int | ||
{ | ||
if (false === $this->has($key)) { | ||
$this->set($key, $value, 0); | ||
|
||
return $value; | ||
} | ||
|
||
$info = $this->getInfo($key); | ||
|
||
$ori = $info['value'] ?? 0; | ||
$ttl = $info['timestamp'] ?? 0; | ||
|
||
if (false === is_int($ori)) { | ||
throw new \InvalidArgumentException('Value incremnet must be interger.'); | ||
} | ||
|
||
$result = (int) ($ori + $value); | ||
|
||
$this->set($key, $result, $ttl); | ||
|
||
return $result; | ||
} | ||
|
||
public function decrement(string $key, int $value): int | ||
{ | ||
return $this->increment($key, $value * -1); | ||
} | ||
|
||
public function remember(string $key, int|\DateInterval|null $ttl = null, \Closure $callback): mixed | ||
{ | ||
$value = $this->get($key); | ||
|
||
if (null !== $value) { | ||
return $value; | ||
} | ||
|
||
$this->set($key, $value = $callback(), $ttl); | ||
|
||
return $value; | ||
} | ||
|
||
/** | ||
* Generate the full file path for the given cache key. | ||
*/ | ||
protected function makePath(string $key): string | ||
{ | ||
$hash = sha1($key); | ||
$parts = array_slice(str_split($hash, 2), 0, 2); | ||
|
||
return $this->path . '/' . implode('/', $parts) . '/' . $hash; | ||
} | ||
|
||
private function calculateExpirationTimestamp(int|\DateInterval|\DateTimeInterface|null $ttl): int | ||
{ | ||
if ($ttl instanceof \DateInterval) { | ||
return (new \DateTimeImmutable())->add($ttl)->getTimestamp(); | ||
} | ||
|
||
if ($ttl instanceof \DateTimeInterface) { | ||
return $ttl->getTimestamp(); | ||
} | ||
|
||
$ttl ??= $this->defaultTTL; | ||
|
||
return time() + $ttl; | ||
} | ||
|
||
/** | ||
* Calculate the microtime based on the current time and microtime. | ||
*/ | ||
private function createMtime(): float | ||
{ | ||
$currentTime = time(); | ||
$microtime = microtime(true); | ||
|
||
$fractionalPart = $microtime - $currentTime; | ||
|
||
if ($fractionalPart >= 1) { | ||
$currentTime += (int) $fractionalPart; | ||
$fractionalPart -= (int) $fractionalPart; | ||
} | ||
|
||
$mtime = $currentTime + $fractionalPart; | ||
|
||
return round($mtime, 3); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,113 @@ | ||
<?php | ||
|
||
declare(strict_types=1); | ||
|
||
namespace System\Text\Cache\Storage; | ||
|
||
use PHPUnit\Framework\TestCase; | ||
use System\Cache\Storage\FileStorage; | ||
|
||
class FileStorageTest extends TestCase | ||
{ | ||
protected FileStorage $storage; | ||
|
||
protected function setUp(): void | ||
{ | ||
$this->storage = new FileStorage(__DIR__ . '/cache'); | ||
} | ||
|
||
public function testSetAndGet(): void | ||
{ | ||
$this->assertTrue($this->storage->set('key1', 'value1')); | ||
$this->assertEquals('value1', $this->storage->get('key1')); | ||
} | ||
|
||
public function testGetWithDefault(): void | ||
{ | ||
$this->assertEquals('default', $this->storage->get('non_existing_key', 'default')); | ||
} | ||
|
||
public function testSetWithTTL(): void | ||
{ | ||
$this->markTestSkipped('sleep is not allowed'); | ||
$this->assertTrue($this->storage->set('key2', 'value2', 1)); | ||
sleep(2); | ||
$this->assertNull($this->storage->get('key2')); | ||
} | ||
|
||
public function testDelete(): void | ||
{ | ||
$this->storage->set('key3', 'value3'); | ||
$this->assertTrue($this->storage->delete('key3')); | ||
$this->assertFalse($this->storage->has('key3')); | ||
} | ||
|
||
public function testDeleteNonExistingKey(): void | ||
{ | ||
$this->assertFalse($this->storage->delete('non_existing_key')); | ||
} | ||
|
||
public function testClear(): void | ||
{ | ||
$this->storage->set('key4', 'value4'); | ||
$this->assertTrue($this->storage->clear()); | ||
$this->assertFalse($this->storage->has('key4')); | ||
} | ||
|
||
public function testGetMultiple(): void | ||
{ | ||
$this->storage->set('key5', 'value5'); | ||
$this->storage->set('key6', 'value6'); | ||
$result = $this->storage->getMultiple(['key5', 'key6', 'non_existing_key'], 'default'); | ||
$this->assertEquals(['key5' => 'value5', 'key6' => 'value6', 'non_existing_key' => 'default'], $result); | ||
} | ||
|
||
// public function testSetMultiple(): void | ||
// { | ||
// $this->assertFalse($this->storage->setMultiple(['key7' => 'value7', 'key8' => 'value8'])); | ||
// $this->assertEquals('value7', $this->storage->get('key7')); | ||
// $this->assertEquals('value8', $this->storage->get('key8')); | ||
// } | ||
|
||
public function testDeleteMultiple(): void | ||
{ | ||
$this->storage->set('key9', 'value9'); | ||
$this->storage->set('key10', 'value10'); | ||
$this->assertTrue($this->storage->deleteMultiple(['key9', 'key10'])); | ||
$this->assertFalse($this->storage->has('key9')); | ||
$this->assertFalse($this->storage->has('key10')); | ||
} | ||
|
||
public function testHas(): void | ||
{ | ||
$this->storage->set('key11', 'value11'); | ||
$this->assertTrue($this->storage->has('key11')); | ||
$this->assertFalse($this->storage->has('non_existing_key')); | ||
} | ||
|
||
public function testIncrement(): void | ||
{ | ||
$this->assertEquals(10, $this->storage->increment('key12', 10)); | ||
$this->assertEquals(20, $this->storage->increment('key12', 10)); | ||
} | ||
|
||
public function testDecrement(): void | ||
{ | ||
$this->storage->set('key13', 20); | ||
$this->assertEquals(10, $this->storage->decrement('key13', 10)); | ||
} | ||
|
||
public function testGetInfo(): void | ||
{ | ||
$this->storage->set('key14', 'value14'); | ||
$info = $this->storage->getInfo('key14'); | ||
$this->assertArrayHasKey('value', $info); | ||
$this->assertEquals('value14', $info['value']); | ||
} | ||
|
||
public function testRemember(): void | ||
{ | ||
$value = $this->storage->remember('key1', 1, fn (): string => 'value1'); | ||
$this->assertEquals('value1', $value); | ||
} | ||
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,2 @@ | ||
* | ||
!.gitignore |