From bfd3ddce66cd2ab13ad13cbdf5e68a391657f493 Mon Sep 17 00:00:00 2001 From: Andrey Kurov Date: Sat, 17 Apr 2021 22:50:46 +0300 Subject: [PATCH] add redis storage --- composer.json | 2 + src/Adapters/Redis/AbstractRedisStorage.php | 92 +++++++++ src/Adapters/Redis/MetricDto.php | 23 +++ src/Adapters/Redis/MetricWrapper.php | 54 ++++++ .../Redis/MutatorRedisConnectionInterface.php | 15 ++ src/Adapters/Redis/RedisConnection.php | 97 ++++++++++ .../Redis/RedisConnectionInterface.php | 14 ++ .../Redis/AbstractRedisStorageTest.php | 178 ++++++++++++++++++ tests/Adapters/Redis/RedisConnectionTest.php | 104 ++++++++++ 9 files changed, 579 insertions(+) create mode 100644 src/Adapters/Redis/AbstractRedisStorage.php create mode 100644 src/Adapters/Redis/MetricDto.php create mode 100644 src/Adapters/Redis/MetricWrapper.php create mode 100644 src/Adapters/Redis/MutatorRedisConnectionInterface.php create mode 100644 src/Adapters/Redis/RedisConnection.php create mode 100644 src/Adapters/Redis/RedisConnectionInterface.php create mode 100644 tests/Adapters/Redis/AbstractRedisStorageTest.php create mode 100644 tests/Adapters/Redis/RedisConnectionTest.php diff --git a/composer.json b/composer.json index 44877c2..f174068 100644 --- a/composer.json +++ b/composer.json @@ -16,11 +16,13 @@ }, "require-dev": { "ext-json": "*", + "ext-redis": "*", "doctrine/common": "^2.4.1 || ^3.0", "doctrine/dbal": "^2.3", "doctrine/doctrine-bundle": "~1.5 || ^2.0", "doctrine/orm": "~2.4", "phpunit/phpunit": "^7.0 || ^8.0 || ^9.0", + "predis/predis": "^1.1", "symfony/browser-kit": "~2.8 || ~3.0 || ~4.0 || ^5.0", "symfony/config": "~2.8 || ~3.0 || ~4.0 || ^5.0", "symfony/dependency-injection": "~2.8 || ~3.0 || ~4.0 || ^5.0", diff --git a/src/Adapters/Redis/AbstractRedisStorage.php b/src/Adapters/Redis/AbstractRedisStorage.php new file mode 100644 index 0000000..6b0856c --- /dev/null +++ b/src/Adapters/Redis/AbstractRedisStorage.php @@ -0,0 +1,92 @@ +redisConnection = $redisConnection; + } + + /** {@inheritdoc} */ + final public function getIterator(): \Traversable + { + return $this->getMetrics(); + } + + /** {@inheritdoc} */ + final public function receive(MetricSourceInterface $source): void + { + $metrics = []; + foreach ($source->getMetrics() as $metric) { + $metrics[] = new MetricDto( + $metric->getName(), + $metric->resolve(), + $metric->getTags() + ); + } + $this->redisConnection->setMetrics($metrics); + } + + /** {@inheritdoc} */ + final public function getMetrics(): \Traversable + { + $metricsData = $this->redisConnection->getAllMetrics(); + foreach ($metricsData as $metricDto) { + yield new MetricWrapper( + $this->redisConnection, + $this->doCreateMetric($metricDto->name, $metricDto->value, $metricDto->tags) + ); + } + } + + /** {@inheritdoc} */ + final public function findMetric(string $name, array $tags = []): ?MutableMetricInterface + { + $value = $this->redisConnection->getMetricValue($name, $tags); + if ($value === null) { + return null; + } + + return new MetricWrapper( + $this->redisConnection, + $this->doCreateMetric($name, $value, $tags) + ); + } + + /** {@inheritdoc} */ + final public function createMetric(string $name, float $value, array $tags = []): MutableMetricInterface + { + $metric = new MetricWrapper( + $this->redisConnection, + $this->doCreateMetric($name, 0, $tags) + ); + $metric->setValue($value); + + return $metric; + } + + abstract protected function doCreateMetric(string $name, float $value, array $tags = []): MutableMetricInterface; + + /** {@inheritdoc} */ + final public function setMetricValue(string $name, float $value, array $tags = []): void + { + $this->redisConnection->setMetrics([new MetricDto($name, $value, $tags)]); + } + + /** {@inheritdoc} */ + final public function adjustMetricValue(string $name, float $value, array $tags = []): float + { + return $this->redisConnection->adjustMetric($name, $value, $tags); + } +} diff --git a/src/Adapters/Redis/MetricDto.php b/src/Adapters/Redis/MetricDto.php new file mode 100644 index 0000000..4675c45 --- /dev/null +++ b/src/Adapters/Redis/MetricDto.php @@ -0,0 +1,23 @@ +name = $name; + $this->value = $value; + $this->tags = $tags; + } +} diff --git a/src/Adapters/Redis/MetricWrapper.php b/src/Adapters/Redis/MetricWrapper.php new file mode 100644 index 0000000..10d581c --- /dev/null +++ b/src/Adapters/Redis/MetricWrapper.php @@ -0,0 +1,54 @@ +redisConnection = $redisConnection; + $this->metric = $metric; + } + + /** {@inheritdoc} */ + public function adjust(float $delta): void + { + $value = $this->redisConnection->adjustMetric($this->getName(), $delta, $this->getTags()); + $this->metric->setValue($value); + } + + /** {@inheritdoc} */ + public function setValue(float $value): void + { + $this->redisConnection->setMetrics([new MetricDto($this->getName(), $value, $this->getTags())]); + $this->metric->setValue($value); + } + + /** {@inheritdoc} */ + public function getName(): string + { + return $this->metric->getName(); + } + + /** {@inheritdoc} */ + public function resolve(): float + { + return $this->metric->resolve(); + } + + /** {@inheritdoc} */ + public function getTags(): array + { + return $this->metric->getTags(); + } +} diff --git a/src/Adapters/Redis/MutatorRedisConnectionInterface.php b/src/Adapters/Redis/MutatorRedisConnectionInterface.php new file mode 100644 index 0000000..573dc3d --- /dev/null +++ b/src/Adapters/Redis/MutatorRedisConnectionInterface.php @@ -0,0 +1,15 @@ +client = $client; + $this->metricsKey = $metricsKey; + } + + /** {@inheritdoc} */ + public function getAllMetrics(): array + { + $rawMetricsData = $this->client->hgetall($this->metricsKey); + $metrics = []; + foreach ($rawMetricsData as $rawMetricData => $value) { + $metricData = json_decode($rawMetricData, true); + $metrics[] = new MetricDto( + $metricData['name'], + (float) $value, + $this->convertTagsFromStorage($metricData['tags']) + ); + } + + return $metrics; + } + + /** {@inheritdoc} */ + public function adjustMetric(string $key, float $delta, array $tags): float + { + return $this->client->hincrbyfloat($this->metricsKey, $this->buildField($key, $tags), $delta); + } + + /** {@inheritdoc} */ + public function setMetrics(array $metricsData): void + { + $fields = []; + foreach ($metricsData as $metricDto) { + $field = $this->buildField($metricDto->name, $metricDto->tags); + $fields[$field] = $metricDto->value; + } + $this->client->hmset($this->metricsKey, $fields); + } + + /** {@inheritdoc} */ + public function getMetricValue(string $key, array $tags): ?float + { + $value = $this->client->hget($this->metricsKey, $this->buildField($key, $tags)); + if ($value === false) { + return null; + } + + return (float) $value; + } + + private function buildField(string $name, array $tags) + { + return json_encode([ + 'name' => $name, + 'tags' => $this->convertTagsForStorage($tags), + ]); + } + + private function convertTagsForStorage(array $tags): string + { + return json_encode($this->normalizeTags($tags)); + } + + private function convertTagsFromStorage(string $tags): array + { + return json_decode($tags, true); + } + + private function normalizeTags(array $tags): array + { + ksort($tags); + + return $tags; + } + +} diff --git a/src/Adapters/Redis/RedisConnectionInterface.php b/src/Adapters/Redis/RedisConnectionInterface.php new file mode 100644 index 0000000..691f00e --- /dev/null +++ b/src/Adapters/Redis/RedisConnectionInterface.php @@ -0,0 +1,14 @@ +redisConnection = $this->createMock(RedisConnectionInterface::class); + $this->redisStorage = $this->createRedisStorage(); + } + + public function testReceive(): void + { + $metrics = [ + $this->createMetric('test1', 17, ['source' => 'fast', 'path' => 'inner']), + $this->createMetric('test2', 4, ['margin' => 'left']), + ]; + + $source = $this->createMock(MetricSourceInterface::class); + $source + ->expects($this->once()) + ->method('getMetrics') + ->willReturn(new \ArrayIterator($metrics)); + + $expectedDto = [ + new MetricDto('test1', 17, ['source' => 'fast', 'path' => 'inner']), + new MetricDto('test2', 4, ['margin' => 'left']), + ]; + $this->redisConnection + ->expects($this->once()) + ->method('setMetrics') + ->with($expectedDto); + + $this->redisStorage->receive($source); + } + + public function testGetMetrics(): void + { + $this->redisConnection + ->expects($this->once()) + ->method('getAllMetrics') + ->willReturn([ + new MetricDto('test1', 17, ['source' => 'fast', 'path' => 'inner']), + new MetricDto('test2', 4, ['margin' => 'left']), + ]); + + $expected = [ + new MetricWrapper( + $this->redisConnection, + new Metric('test1', 17, ['source' => 'fast', 'path' => 'inner']) + ), + new MetricWrapper( + $this->redisConnection, + new Metric('test2', 4, ['margin' => 'left']) + ), + ]; + + $actual = $this->redisStorage->getMetrics(); + + self::assertEquals($expected, iterator_to_array($actual)); + } + + public function testFindMetric(): void + { + $this->redisConnection + ->expects($this->once()) + ->method('getMetricValue') + ->with('test1', ['source' => 'fast', 'path' => 'inner']) + ->willReturn(4.0); + + $expected = new MetricWrapper( + $this->redisConnection, + new Metric('test1', 4, ['source' => 'fast', 'path' => 'inner']) + ); + + $actual = $this->redisStorage->findMetric('test1', ['source' => 'fast', 'path' => 'inner']); + + self::assertEquals($expected, $actual); + } + + public function testFindMetricWhenMetricIsNotFound(): void + { + $this->redisConnection + ->expects($this->once()) + ->method('getMetricValue') + ->with('test1', ['source' => 'fast', 'path' => 'inner']) + ->willReturn(null); + + $actual = $this->redisStorage->findMetric('test1', ['source' => 'fast', 'path' => 'inner']); + + self::assertNull($actual); + } + + public function testCreateMetric(): void + { + $expectedDto = new MetricDto('test1', 17, ['source' => 'fast', 'path' => 'inner']); + $this->redisConnection + ->expects($this->once()) + ->method('setMetrics') + ->with([$expectedDto]); + + $expected = new MetricWrapper( + $this->redisConnection, + new Metric('test1', 17, ['source' => 'fast', 'path' => 'inner']) + ); + + $actual = $this->redisStorage->createMetric('test1', 17, ['source' => 'fast', 'path' => 'inner']); + + self::assertEquals($expected, $actual); + } + + public function testSetMetricValue(): void + { + $expectedDto = new MetricDto('test1', 17, ['source' => 'fast', 'path' => 'inner']); + $this->redisConnection + ->expects($this->once()) + ->method('setMetrics') + ->with([$expectedDto]); + + $this->redisStorage->setMetricValue('test1', 17, ['source' => 'fast', 'path' => 'inner']); + } + + public function testAdjustMetricValue(): void + { + $this->redisConnection + ->expects($this->once()) + ->method('adjustMetric') + ->with('test1', 17, ['source' => 'fast', 'path' => 'inner']); + + $this->redisStorage->adjustMetricValue('test1', 17, ['source' => 'fast', 'path' => 'inner']); + } + + private function createMetric(string $name, float $value, array $tags) + { + $metric = $this->createMock(MetricInterface::class); + $metric + ->method('getName') + ->willReturn($name); + $metric + ->method('resolve') + ->willReturn($value); + $metric + ->method('getTags') + ->willReturn($tags); + + return $metric; + } + + private function createRedisStorage(): AbstractRedisStorage + { + return new class($this->redisConnection) extends AbstractRedisStorage + { + protected function doCreateMetric(string $name, float $value, array $tags = []): MutableMetricInterface + { + return new Metric($name, $value, $tags); + } + }; + } +} diff --git a/tests/Adapters/Redis/RedisConnectionTest.php b/tests/Adapters/Redis/RedisConnectionTest.php new file mode 100644 index 0000000..bbd80d6 --- /dev/null +++ b/tests/Adapters/Redis/RedisConnectionTest.php @@ -0,0 +1,104 @@ +redis = $this->createMock(\Redis::class); + $this->redisConnection = new RedisConnection($this->redis, static::METRICS_KEY); + } + + public function testReceivingAllMetrics(): void + { + $this->redis + ->expects($this->once()) + ->method('hgetall') + ->with(static::METRICS_KEY) + ->willReturn([ + '{"name":"test1","tags":"{\"status\":15,\"port\":1}"}' => '17', + '{"name":"test2","tags":"{\"severity\":\"high\"}"}' => '2', + ]); + + $expected = [ + new MetricDto('test1', 17, ['status' => 15, 'port' => 1]), + new MetricDto('test2', 2, ['severity' => 'high']), + ]; + $actual = $this->redisConnection->getAllMetrics(); + + self::assertEquals($expected, $actual); + } + + public function testAdjustMetric(): void + { + $value = 15; + $expectedField = '{"name":"test","tags":"{\"severity\":\"high\"}"}'; + $this->redis + ->expects($this->once()) + ->method('hincrbyfloat') + ->with(static::METRICS_KEY, $expectedField, $value) + ->willReturn(17); + + $actual = $this->redisConnection->adjustMetric('test', $value, ['severity' => 'high']); + self::assertEquals(17, $actual); + } + + public function testSetMetrics(): void + { + $fields = [ + '{"name":"test1","tags":"{\"port\":1,\"status\":15}"}' => 17, + '{"name":"test2","tags":"{\"severity\":\"high\"}"}' => 2, + ]; + $this->redis + ->expects($this->once()) + ->method('hmset') + ->with(static::METRICS_KEY, $fields) + ->willReturn(false); + $metrics = [ + new MetricDto('test1', 17, ['status' => 15, 'port' => 1]), + new MetricDto('test2', 2, ['severity' => 'high']), + ]; + + $this->redisConnection->setMetrics($metrics); + } + + public function testGetMetricValue(): void + { + $expectedField = '{"name":"test","tags":"{\"severity\":\"high\"}"}'; + $this->redis + ->expects($this->once()) + ->method('hget') + ->with(static::METRICS_KEY, $expectedField) + ->willReturn('17'); + + $actual = $this->redisConnection->getMetricValue('test', ['severity' => 'high']); + self::assertEquals(17, $actual); + } + + public function testFailedGetMetricValue(): void + { + $expectedField = '{"name":"test","tags":"{\"severity\":\"high\"}"}'; + $this->redis + ->expects($this->once()) + ->method('hget') + ->with(static::METRICS_KEY, $expectedField) + ->willReturn(false); + + $actual = $this->redisConnection->getMetricValue('test', ['severity' => 'high']); + self::assertEquals(0, $actual); + } +}