diff --git a/README.md b/README.md index 5fb058598..ef1bcebb6 100644 --- a/README.md +++ b/README.md @@ -485,6 +485,35 @@ Here is the mapping: * Timezone: `%T` +### Cache + +Sometimes you want to implement a caching mechanism in your application to +reduce your api requests. Geocoder provide two strategy to cache results: + + * _Stale If Error_ - Strategy: + This means geocoder tries to ask the underlined provider, if they throws an exception + he tries to become a result from the cache. + + * _Expire_ - Strategy: + This is the most used cache mechanism. Geocoder cache each request xxx seconds. + +Here an example: + +``` +// $cacheDriver is a PSR - 6 compatible driver. + +$provider = new GoogleMaps(); +$cache = new Cache(new ExpireCache($cacheDriver), $provider); + +$geocoder->registerProvider($cache); + +$geocoder->geocode('Berlin'); +$geocoder->geocode('Berlin'); // This uses the cache +``` + +Geocoder is [PSR - 6](http://www.php-fig.org/psr/psr-6/) compatible and expect +an psr-6 cache driver. + Extending Things ---------------- diff --git a/composer.json b/composer.json index 3e456669d..af1c05b2d 100644 --- a/composer.json +++ b/composer.json @@ -14,7 +14,8 @@ "require": { "php": ">=5.4.0", "egeloen/http-adapter": "~0.8|~1.0", - "igorw/get-in": "~1.0" + "igorw/get-in": "~1.0", + "psr/cache": "^1.0" }, "require-dev": { "geoip2/geoip2": "~2.0", diff --git a/src/Geocoder/CacheStrategy/Expire.php b/src/Geocoder/CacheStrategy/Expire.php new file mode 100644 index 000000000..8ba654b5d --- /dev/null +++ b/src/Geocoder/CacheStrategy/Expire.php @@ -0,0 +1,49 @@ + + */ +class Expire implements Strategy +{ + private $cache; + + private $ttl; + + public function __construct(CacheItemPoolInterface $cache, $ttl = null) + { + $this->cache = $cache; + $this->ttl = $ttl; + } + + public function invoke($key, callable $function) + { + $item = $this->cache->getItem($key); + + if ($item->isHit()) { + return $item->get(); + } + + $data = call_user_func($function); + + if ($this->ttl) { + $item->expiresAfter($this->ttl); + } + + $item->set($data); + $this->cache->save($item); + + return $data; + } +} diff --git a/src/Geocoder/CacheStrategy/StaleIfError.php b/src/Geocoder/CacheStrategy/StaleIfError.php new file mode 100644 index 000000000..3a49fb3e3 --- /dev/null +++ b/src/Geocoder/CacheStrategy/StaleIfError.php @@ -0,0 +1,54 @@ + + */ +class StaleIfError implements Strategy +{ + private $cache; + + private $ttl; + + public function __construct(CacheItemPoolInterface $cache, $ttl = null) + { + $this->cache = $cache; + $this->ttl = $ttl; + } + + public function invoke($key, callable $function) + { + $item = $this->cache->getItem($key); + + try { + $data = call_user_func($function); + } catch (\Exception $e) { + if (!$item->isHit()) { + throw $e; + } + + return $item->get(); + } + + $item->set($data); + + if ($this->ttl) { + $item->expiresAfter($this->ttl); + } + + $this->cache->save($item); + + return $data; + } +} diff --git a/src/Geocoder/CacheStrategy/Strategy.php b/src/Geocoder/CacheStrategy/Strategy.php new file mode 100644 index 000000000..2fd5e5b4f --- /dev/null +++ b/src/Geocoder/CacheStrategy/Strategy.php @@ -0,0 +1,19 @@ + + */ +interface Strategy +{ + function invoke($key, callable $function); +} diff --git a/src/Geocoder/Provider/Cache.php b/src/Geocoder/Provider/Cache.php new file mode 100644 index 000000000..feaa882e9 --- /dev/null +++ b/src/Geocoder/Provider/Cache.php @@ -0,0 +1,101 @@ + + */ +class Cache implements LocaleAwareProvider +{ + use LocaleTrait; + + /** + * @var Strategy + */ + private $strategy; + + /** + * @var Provider + */ + private $delegate; + + /** + * @param CacheItemPoolInterface $cache + * @param Provider $delegate + */ + public function __construct(Strategy $strategy, Provider $delegate) + { + $this->delegate = $delegate; + $this->strategy = $strategy; + } + + /** + * {@inheritDoc} + */ + public function geocode($address) + { + $key = $this->generateKey($address); + + return $this->strategy->invoke($key, function() use ($address) { + return $this->delegate->geocode($address); + }); + } + + /** + * {@inheritDoc} + */ + public function reverse($latitude, $longitude) + { + $key = $this->generateKey(serialize([$latitude, $longitude])); + + return $this->strategy->invoke($key, function() use ($latitude, $longitude) { + return $this->delegate->reverse($latitude, $longitude); + }); + } + + /** + * {@inheritDoc} + */ + public function limit($limit) + { + $this->delegate->limit($limit); + } + + /** + * {@inheritDoc} + */ + public function getLimit() + { + return $this->delegate->getLimit(); + } + + /** + * {@inheritDoc} + */ + public function getName() + { + return 'cache'; + } + + /** + * Generate a key. + * + * @param string $value + * + * @return string + */ + private function generateKey($value) + { + return 'geocoder_'.sha1($value); + } +} diff --git a/tests/Geocoder/Tests/CacheStrategy/ExpireCacheTest.php b/tests/Geocoder/Tests/CacheStrategy/ExpireCacheTest.php new file mode 100644 index 000000000..cc56b3000 --- /dev/null +++ b/tests/Geocoder/Tests/CacheStrategy/ExpireCacheTest.php @@ -0,0 +1,52 @@ +pool = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->strategy = new Expire($this->pool->reveal(), 100); + } + + public function testInvokeWithCache() + { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + + $item->isHit()->willReturn(true); + $item->get()->willReturn('test'); + $item->set()->shouldNotBeCalled(); + + $this->pool->getItem('foo')->willReturn($item->reveal()); + + $data = $this->strategy->invoke('foo', function() {}); + + return $this->assertEquals('test', $data); + } + + public function testInvokeWithoutCache() + { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + + $item->isHit()->willReturn(false); + $item->get()->shouldNotBeCalled(); + $item->set('test')->shouldBeCalled(); + $item->expiresAfter(100)->shouldBeCalled(); + + $item = $item->reveal(); + + $this->pool->getItem('foo')->willReturn($item); + $this->pool->save($item)->willReturn(true); + + $data = $this->strategy->invoke('foo', function() { + return 'test'; + }); + + return $this->assertEquals('test', $data); + } +} diff --git a/tests/Geocoder/Tests/CacheStrategy/StaleIfErrorTest.php b/tests/Geocoder/Tests/CacheStrategy/StaleIfErrorTest.php new file mode 100644 index 000000000..21b469c1d --- /dev/null +++ b/tests/Geocoder/Tests/CacheStrategy/StaleIfErrorTest.php @@ -0,0 +1,71 @@ +pool = $this->prophesize('Psr\Cache\CacheItemPoolInterface'); + $this->strategy = new StaleIfError($this->pool->reveal(), 100); + } + + public function testValidResponse() + { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + + $item->expiresAfter(100)->shouldBeCalled(); + $item->set('test')->shouldBeCalled(); + + $item->get()->shouldNotBeCalled(); + + $item = $item->reveal(); + + $this->pool->getItem('foo')->willReturn($item); + $this->pool->save($item)->willReturn(true); + + $data = $this->strategy->invoke('foo', function() { + return 'test'; + }); + + $this->assertEquals('test', $data); + } + + public function testCatchExceptionAndReturnCache() + { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->isHit()->willReturn(true); + $item->get()->willReturn('test'); + + $item = $item->reveal(); + + $this->pool->getItem('foo')->willReturn($item); + $this->pool->save()->shouldNotBeCalled(); + + $data = $this->strategy->invoke('foo', function() { + throw new \Exception(); + }); + + $this->assertEquals('test', $data); + } + + /** + * @expectedException \Exception + */ + public function testCatchExceptionAndHaveNoCache() + { + $item = $this->prophesize('Psr\Cache\CacheItemInterface'); + $item->isHit()->willReturn(false); + $item->get()->shouldNotBeCalled(); + + $this->pool->getItem('foo')->willReturn($item->reveal()); + + $this->strategy->invoke('foo', function() { + throw new \Exception(); + }); + } +} diff --git a/tests/Geocoder/Tests/Provider/CacheTest.php b/tests/Geocoder/Tests/Provider/CacheTest.php new file mode 100644 index 000000000..8501c7cc3 --- /dev/null +++ b/tests/Geocoder/Tests/Provider/CacheTest.php @@ -0,0 +1,57 @@ +strategy = $this->prophesize('Geocoder\CacheStrategy\Strategy'); + $this->delegate = $this->prophesize('Geocoder\Provider\Provider'); + + $this->provider = new Cache($this->strategy->reveal(), $this->delegate->reveal()); + } + + + public function testGeocode() + { + $this->strategy->invoke( + 'geocoder_3bd614cd786c546800755606b56dfce3863cffa6', + Argument::type('Closure') + )->willReturn('foo'); + + $this->assertEquals('foo', $this->provider->geocode('Alexander Platz 1, Berlin')); + } + + public function testReverse() + { + $this->strategy->invoke( + 'geocoder_20889549b60b05ddbac1f40d34259d8dee5f9835', + Argument::type('Closure') + )->willReturn('foo'); + + $this->assertEquals('foo', $this->provider->reverse(55.56688, 78.51426)); + } + + public function testGetName() + { + $this->assertEquals('cache', $this->provider->getName()); + } + + public function testLimit() + { + $this->delegate->limit(20)->shouldBeCalled(); + + $this->provider->limit(20); + } + + public function testGetLimit() + { + $this->delegate->getLimit()->willReturn(20); + + $this->assertEquals(20, $this->provider->getLimit()); + } +}