<?php
/*
* This file is part of the logist\redis-bundle.
*
* Copyright 2022 logist.cloud <support@logist.cloud>
*/
declare(strict_types = 1);
namespace LogistRedisBundle\Service;
use Predis\Client;
use Predis\PredisException;
use Predis\Response\ServerException;
use LogistRedisBundle\Bridge\ThresholdCheckerInterface;
use LogistRedisBundle\Exception\LogistRedisUnserializationException;
use LogistRedisBundle\Exception\LogistRedisInvalidCounterException;
/**
* @author Grigoriy Kulin <yarboroda@gmail.com>
*/
class RedisClient implements ThresholdCheckerInterface
{
public const TTL_UNLIMITED = -1;
public const TTL_KEY_NOT_EXISTS = -2;
private const INVALID_COUNTER_MSG = 'ERR value is not an integer or out of range';
/**
* @var Client
*/
protected $client;
/**
* @var string
*/
private $serializedFalse;
/**
* @var string
*/
private $serializedNull;
/**
* @var bool
*/
private $binarySerialization;
/**
* @var string
*/
private $prefix;
/**
* @param string $host
* @param integer $port
* @param string $username
* @param string $password
* @param string $prefix
*/
public function __construct(string $host, int $port, string $username, string $password, string $prefix, bool $binarySerialization)
{
$prefix = "{$prefix}:";
$this->prefix = $prefix;
$this->binarySerialization = $binarySerialization;
$this->client = new Client([
'host' => $host,
'port' => $port,
'username' => $username,
'password' => $password,
], [
'prefix' => $prefix,
]);
$this->serializedFalse = $this->serialize(false);
$this->serializedNull = $this->serialize(null);
}
/**
* @return string
*/
public function getPrefix()
{
return $this->prefix;
}
/**
* @param string $key
* @throws PredisException
* @throws LogistRedisUnserializationException
* @return mixed
*/
public function get(string $key)
{
return $this->unserialize($this->client->get($key));
}
/**
* @param string $prefix
* @throws PredisException
* @throws LogistRedisUnserializationException
* @return array
*/
public function keys(string $prefix = '*')
{
return $this->client->keys($prefix);
}
/**
* @param string $key
* @throws PredisException
* @throws LogistRedisUnserializationException
* @return mixed
*/
public function membersGet(string $key)
{
$result = [];
$members = $this->client->smembers($key);
if(empty($members)){
return [];
}
foreach($members as $member){
$result[] = $this->unserialize($member);
}
return $result;
}
/**
* check if key exists
* @param string $key
* @throws PredisException
* @return bool
*/
public function exists(string $key)
{
$value = $this->client->exists($key);
return ($value === 1);
}
/**
* @param string[] $keys
* @throws PredisException
* @throws LogistRedisUnserializationException
* @return array
*/
public function getMultiple(array $keys)
{
if (empty($keys)) {
return [];
}
$values = $this->client->mget($keys);
$result = [];
foreach ($values as $index => $value) {
$result[$keys[$index]] = $this->unserialize($value);
}
return $result;
}
/**
* get remaining time to live of key
* -2 if key does not exists
* -1 if key exists but has no ttl
* @param string $key
* @throws PredisException
* @return int seconds
*/
public function ttl(string $key)
{
return $this->client->ttl($key);
}
/**
* @param string $key
* @param mixed $value
* @param integer|null $ttl time to live seconds
* @throws PredisException
* @return void
*/
public function set(string $key, $value, ?int $ttl = null)
{
if (is_null($ttl) || $ttl < 1) {
$this->client->set($key, $this->serialize($value));
} else {
$this->client->setex($key, $ttl, $this->serialize($value));
}
}
/**
* @param string $key
* @param array $values
* @param integer|null $ttl time to live seconds
* @throws PredisException
* @return void
*/
public function membersAdd(string $key, array $values, ?int $ttl = null)
{
foreach($values as $value){
$this->client->sadd($key, $this->serialize($value));
}
if (!is_null($ttl) && $ttl > 0) {
$this->expire($key, $ttl);
}
}
/**
* cache result of callable $fn
* @param string $key
* @param callable $fn
* @param integer|null $ttl time to live seconds
* @throws PredisException
* @throws LogistRedisUnserializationException
* @return mixed
*/
public function cache(string $key, callable $fn, ?int $ttl = null)
{
if ($this->exists($key)) {
return $this->get($key);
}
$value = $fn();
$this->set($key, $value, $ttl);
return $value;
}
/**
* set or update time to live for key
* @param string $key
* @param integer $ttl seconds
* @throws PredisException
* @return void
*/
public function expire(string $key, int $ttl)
{
$this->client->expire($key, $ttl);
}
/**
* set or update time to live for key till date
* @param string $key
* @param \DateTime $expireDate
* @throws PredisException
* @return void
*/
public function expireAt(string $key, \DateTime $expireDate)
{
$this->client->expireat($key, $expireDate->getTimestamp());
}
/**
* @param string $key
* @throws PredisException
* @return void
*/
public function delete(string $key)
{
$this->client->del($key);
}
/**
* @param string $key
* @param array $values
* @throws PredisException
* @return void
*/
public function membersRemove(string $key, array $values)
{
$this->client->srem($key, $values);
}
/**
* @param string[] $keys
* @throws PredisException
* @return void
*/
public function deleteMultiple(array $keys)
{
$this->client->del($keys);
}
/**
* get counter value and verify it is valid
* @param string $key
* @throws PredisException
* @throws LogistRedisInvalidCounterException
* @return int
*/
public function getCounter(string $key)
{
$value = $this->client->get($key);
if (empty($value) || !ctype_digit($value)) {
throw new LogistRedisInvalidCounterException("Not valid counter value in key {$key}");
}
return (int) $value;
}
/**
* increment value of integer key by $value
* @param string $key
* @param int $value
* @throws PredisException
* @throws LogistRedisInvalidCounterException
* @return int new value
*/
public function increment(string $key, int $value = 1)
{
try {
if ($value === 1) {
$result = $this->client->incr($key);
} else {
$result = $this->client->incrby($key, $value);
}
} catch (ServerException $e) {
if ($e->getMessage() == self::INVALID_COUNTER_MSG) {
throw new LogistRedisInvalidCounterException("Not valid counter value in key {$key}");
}
throw $e;
}
$result = (int) $result;
$this->checkCounterValue($result, $key);
return $result;
}
/**
* decrement value of integer key by $value
* @param string $key
* @param int $value
* @throws PredisException
* @throws LogistRedisInvalidCounterException
* @return int new value
*/
public function decrement(string $key, int $value = 1)
{
try {
if ($value === 1) {
$result = $this->client->decr($key);
} else {
$result = $this->client->decrby($key, $value);
}
} catch (ServerException $e) {
if ($e->getMessage() == self::INVALID_COUNTER_MSG) {
throw new LogistRedisInvalidCounterException("Not valid counter value in key {$key}");
}
throw $e;
}
$result = (int) $result;
$this->checkCounterValue($result, $key);
return $result;
}
/**
* @param integer $result
* @param string $key
* @throws PredisException
* @throws LogistRedisInvalidCounterException
* @return void
*/
private function checkCounterValue(int $result, string $key)
{
if ($result < 0) {
$ttl = $this->ttl($key);
$ttl = ($ttl < 0) ? null : $ttl;
$this->set($key, $result, $ttl);
throw new LogistRedisInvalidCounterException('Key value got lower then 0. It was serialized and it can not be used in counter.');
}
}
/**
* @param mixed $value
* @return string|int
*/
private function serialize($value)
{
if (is_int($value) && ($value >= 0)) {
return $value;
}
if ($this->binarySerialization) {
return igbinary_serialize($value);
}
return serialize($value);
}
/**
* @param string|null $value
* @throws LogistRedisUnserializationException
* @return mixed
*/
private function unserialize(?string $value)
{
if (is_null($value)) {
return null;
}
if ($value === '') {
return '';
}
if (ctype_digit($value)) {
return (int) $value;
}
if ($value === $this->serializedFalse) {
return false;
}
if ($value === $this->serializedNull) {
return null;
}
$result = $this->binarySerialization ? igbinary_unserialize($value) : unserialize($value);
if (($result === false) || is_null($result)) {
throw new LogistRedisUnserializationException('Stored value cannot be unserialized');
}
return $result;
}
/**
* delete all data in prefix with optional mask
* @var string mask begin of keys to reset
* @return int deleted keys counter
*/
public function resetPrefix($mask='')
{
$options = ['match' => "{$this->prefix}{$mask}*"];
$i = 0;
$cnt = 0;
do {
$response = $this->client->scan($i, $options);
$keys = array_map(function($key) {
return preg_replace("/^{$this->prefix}/", '', $key);
}, $response[1]);
if (!empty($keys)) {
$cnt += count($keys);
$this->client->del($keys);
}
$i = (int) $response[0];
} while ($i > 0);
return $cnt;
}
/**
* @param string $key
* @param int $limit
* @param int $timeInterval in seconds
* @param int $increment value to increment counter
* @return bool
*/
public function checkThreshold(string $key, int $limit, int $timeInterval, int $increment = 1)
{
$count = $this->increment($key, $increment);
if ($count > $limit) {
return false;
}
if ($count === $increment) {
$this->expire($key, $timeInterval);
}
return true;
}
}