diff --git a/.gitattributes b/.gitattributes index 6f4c549..c20634c 100644 --- a/.gitattributes +++ b/.gitattributes @@ -7,6 +7,7 @@ /.github export-ignore /.gitattributes export-ignore /.gitignore export-ignore +/.travis export-ignore /.*.yml export-ignore /phpcs.xml export-ignore /phpunit.xml.dist export-ignore diff --git a/.travis.yml b/.travis.yml index 141629e..d15db3c 100644 --- a/.travis.yml +++ b/.travis.yml @@ -7,11 +7,15 @@ php: env: - PREFER_LOWEST="--prefer-lowest --prefer-stable" - PREFER_LOWEST="" +services: + - memcached matrix: fast_finish: true allow_failures: - php: nightly before_script: + - phpenv config-add .travis/memcached.ini + - phpenv config-add .travis/memcache.ini - composer update $PREFER_LOWEST script: - ./vendor/bin/phpunit --coverage-clover clover.xml diff --git a/.travis/memcache.ini b/.travis/memcache.ini new file mode 100644 index 0000000..587455f --- /dev/null +++ b/.travis/memcache.ini @@ -0,0 +1 @@ +extension=memcache.so diff --git a/.travis/memcached.ini b/.travis/memcached.ini new file mode 100644 index 0000000..7d09664 --- /dev/null +++ b/.travis/memcached.ini @@ -0,0 +1 @@ +extension=memcached.so diff --git a/README.md b/README.md index 44d699e..5f258cc 100644 --- a/README.md +++ b/README.md @@ -100,6 +100,28 @@ $compute = function() { $result = $memoize->memoizeCallable('myLongOperation', $compute, 3600); ``` +### Memcache +The memcache provider uses the [memcache](https://www.php.net/manual/en/book.memcache.php) library to +cache the results in Memcache. It supports the `$cacheTime` parameter so that +results can be recomputed after the time expires. + +This memoizer can be used in a way that makes it persistent between processes +rather than only caching computation for the current process. + +#### Example +```php +$memcache = new Memcache; +$memcacheInstance = $memcache->connect('127.0.0.1', 11211); +$memoize = new \TraderInteractive\Memoize\Memcache($memcacheInstance); + +$compute = function() { + // Perform some long operation that you want to memoize +}; + +// Cache he results of $compute for 1 hour. +$result = $memoize->memoizeCallable('myLongOperation', $compute, 3600); +``` + ### Memory This is a standard in-memory memoizer. It does not support `$cacheTime` at the moment and only keeps the results around as long as the memoizer is in memory. diff --git a/composer.json b/composer.json index 7342fc5..d2bbb0f 100644 --- a/composer.json +++ b/composer.json @@ -14,16 +14,21 @@ "sort-packages": true }, "require": { - "php": "^7.0 || ^8.0" + "php": "^7.0 || ^8.0", + "ext-json": "*" }, "require-dev": { "php-coveralls/php-coveralls": "^1.0", "phpunit/phpunit": "^6.0 || ^7.0 || ^8.0 || ^9.0", "predis/predis": "^1.0", - "squizlabs/php_codesniffer": "^3.2" + "squizlabs/php_codesniffer": "^3.2", + "ext-memcache": "*", + "ext-memcached": "*" }, "suggest": { - "predis/predis": "Allows for Redis-based memoization." + "predis/predis": "Allows for Redis-based memoization.", + "ext-memcache": "Allows for Memcache-based memoization.", + "ext-memcached": "Allows for Memcache-based memoization." }, "autoload": { "psr-4": { "TraderInteractive\\Memoize\\": "src/" } diff --git a/src/Memcache.php b/src/Memcache.php new file mode 100644 index 0000000..cd809d4 --- /dev/null +++ b/src/Memcache.php @@ -0,0 +1,95 @@ +client = $client; + $this->refresh = $refresh; + } + + /** + * The value is stored in memcache as a json_encoded string, + * so make sure that the value you return from $compute is json-encode-able. + * + * @see Memoize::memoizeCallable + * + * @param string $key + * @param callable $compute + * @param int|null $cacheTime + * @param bool $refresh + * + * @return mixed + */ + public function memoizeCallable(string $key, callable $compute, int $cacheTime = null, bool $refresh = false) + { + if (!$this->refresh && !$refresh) { + try { + $cached = $this->client->get($key, $flags, $flags); + if ($cached !== false && $cached != null) { + $data = json_decode($cached, true); + return $data['result']; + } + } catch (\Exception $e) { + return call_user_func($compute); + } + } + + $result = call_user_func($compute); + + // If the result is false/null/empty, then there is no point in storing it in cache. + if ($result === false || $result == null || empty($result)) { + return $result; + } + + $this->cache($key, json_encode(['result' => $result]), $cacheTime); + + return $result; + } + + /** + * Caches the value into memcache with errors suppressed. + * + * @param string $key The key. + * @param string $value The value. + * @param int $cacheTime The optional cache time + * + * @return void + */ + private function cache(string $key, string $value, int $cacheTime = null) + { + try { + $this->client->set($key, $value, 0, $cacheTime); + } catch (\Exception $e) { + // We don't want exceptions in accessing the cache to break functionality. + // The cache should be as transparent as possible. + // If insight is needed into these exceptions, + // a better way would be by notifying an observer with the errors. + } + } +} diff --git a/tests/MemcacheTest.php b/tests/MemcacheTest.php new file mode 100644 index 0000000..c599a23 --- /dev/null +++ b/tests/MemcacheTest.php @@ -0,0 +1,134 @@ + + */ +class MemcacheTest extends TestCase +{ + /** + * @test + * @covers ::__construct + * @covers ::memoizeCallable + */ + public function memoizeCallableWithCachedValue() + { + $count = 0; + $key = 'foo'; + $value = 'bar'; + $cachedValue = json_encode(['result' => $value]); + $compute = function () use (&$count, $value) { + $count++; + + return $value; + }; + + + $client = $this->getMemcacheMock(); + $client->expects( + $this->once() + )->method('get')->with($this->equalTo($key))->will($this->returnValue($cachedValue)); + + $memoizer = new Memcache($client); + + $this->assertSame($value, $memoizer->memoizeCallable($key, $compute)); + $this->assertSame(0, $count); + } + + /** + * @test + * @covers ::__construct + * @covers ::memoizeCallable + */ + public function memoizeCallableWithExceptionOnGet() + { + $count = 0; + $key = 'foo'; + $value = 'bar'; + $compute = function () use (&$count, $value) { + $count++; + + return $value; + }; + + $client = $this->getMemcacheMock(); + $client->expects( + $this->once() + )->method('get')->with($this->equalTo($key))->will($this->throwException(new \Exception())); + + $memoizer = new Memcache($client); + + $this->assertSame($value, $memoizer->memoizeCallable($key, $compute)); + $this->assertSame(1, $count); + } + + /** + * @test + * @covers ::__construct + * @covers ::memoizeCallable + */ + public function memoizeCallableWithUncachedKey() + { + $count = 0; + $key = 'foo'; + $value = 'bar'; + $cachedValue = json_encode(['result' => $value]); + $cacheTime = 1234; + $compute = function () use (&$count, $value) { + $count++; + + return $value; + }; + + $client = $this->getMemcacheMock(); + $client->expects($this->once())->method('get')->with($this->equalTo($key))->will($this->returnValue(null)); + $client->expects($this->once())->method('set') + ->with($this->equalTo($key), $this->equalTo($cachedValue), $this->equalTo(0), $this->equalTo($cacheTime)); + + $memoizer = new Memcache($client); + + $this->assertSame($value, $memoizer->memoizeCallable($key, $compute, $cacheTime)); + $this->assertSame(1, $count); + } + + /** + * @test + * @covers ::__construct + * @covers ::memoizeCallable + */ + public function memoizeCallableWithUncachedKeyWithExceptionOnSet() + { + $count = 0; + $key = 'foo'; + $value = 'bar'; + $cachedValue = json_encode(['result' => $value]); + $compute = function () use (&$count, $value) { + $count++; + + return $value; + }; + + $client = $this->getMemcacheMock(); + $client->expects( + $this->once() + )->method('get')->with($this->equalTo($key))->will($this->returnValue(null)); + $setExpectation = $client->expects( + $this->once() + )->method('set')->with($this->equalTo($key), $this->equalTo($cachedValue)); + $setExpectation->will($this->throwException(new \Exception())); + + $memoizer = new Memcache($client); + + $this->assertSame($value, $memoizer->memoizeCallable($key, $compute)); + $this->assertSame(1, $count); + } + + public function getMemcacheMock() : \Memcache + { + return $this->getMockBuilder('\Memcache')->setMethods(['get', 'set'])->getMock(); + } +}