码桶

发现社区成员的开源项目

geekssl /

wstats

公开
main
wstats/app/Support/RedisClient.php
RedisClient.php14.7 KB
<?php
/**
 * 自研纯 PHP Redis 客户端(RESP2 协议)。
 * 零扩展依赖:不要求 phpredis / predis。通过 TCP socket 直连。
 * 支持本系统所需命令;解析器可离线单测(php://temp 模拟流)。
 */
declare(strict_types=1);

namespace Wstat\Support;

use RuntimeException;

class RedisClient
{
    /** @var resource|null */
    private $fp = null;
    private array $cfg;

    public function __construct(array $cfg)
    {
        $this->cfg = $cfg + [
            'host' => '127.0.0.1', 'port' => 6379, 'auth' => null,
            'db' => 0, 'prefix' => '', 'timeout' => 2.0,
        ];
    }

    /** 连接(惰性)。失败抛出,供上层降级处理。 */
    private function connect(): void
    {
        if (is_resource($this->fp)) {
            return;
        }
        $errno = 0;
        $errstr = '';
        $fp = @stream_socket_client(
            sprintf('tcp://%s:%d', $this->cfg['host'], $this->cfg['port']),
            $errno, $errstr, (float) $this->cfg['timeout']
        );
        if ($fp === false) {
            throw new RuntimeException('Redis connect failed: ' . $errstr);
        }
        stream_set_timeout($fp, (int) ceil((float) $this->cfg['timeout']));
        $this->fp = $fp;
        if (!empty($this->cfg['auth'])) {
            $this->raw(['AUTH', $this->cfg['auth']]);
        }
        if ((int) $this->cfg['db'] > 0) {
            $this->raw(['SELECT', (string) $this->cfg['db']]);
        }
    }

    /* ================= RESP 编解码 ================= */

    /** 发送命令并返回解析结果(+OK→true, :n→int, $-1→null, *-1→null) */
    public function raw(array $args)
    {
        $this->connect();
        $out = '*' . count($args) . "\r\n";
        foreach ($args as $a) {
            $s = (string) $a;
            $out .= '$' . strlen($s) . "\r\n" . $s . "\r\n";
        }
        fwrite($this->fp, $out);
        return self::parseReply($this->fp);
    }

    /**
     * 解析一个 RESP 回复。
     * @param resource $stream 任何可 fgets/fread 的流(socket 或 php://temp,便于离线测试)
     */
    public static function parseReply($stream)
    {
        $line = self::readLine($stream);
        if ($line === null || $line === false) {
            return null;                        // 超时 / 断开
        }
        $type = $line[0] ?? '';
        $body = substr($line, 1);
        switch ($type) {
            case '+':
                return $body;
            case '-':
                throw new RuntimeException('Redis error: ' . $body);
            case ':':
                return (int) $body;
            case '$': {
                $len = (int) $body;
                if ($len === -1) {
                    return null;
                }
                $buf = '';
                while (strlen($buf) < $len + 2) {
                    $chunk = fread($stream, $len + 2 - strlen($buf));
                    if ($chunk === false || $chunk === '') {
                        break;
                    }
                    $buf .= $chunk;
                }
                return substr($buf, 0, $len);
            }
            case '*': {
                $count = (int) $body;
                if ($count === -1) {
                    return null;
                }
                $arr = [];
                for ($i = 0; $i < $count; $i++) {
                    $arr[] = self::parseReply($stream);
                }
                return $arr;
            }
            default:
                throw new RuntimeException('Unknown RESP type: ' . $type);
        }
    }

    private static function readLine($stream)
    {
        $line = fgets($stream);
        if ($line === false) {
            return false;
        }
        return rtrim($line, "\r\n");
    }

    /** 读一个整块(key 名用) */
    private function withPrefix(string $key): string
    {
        return $this->cfg['prefix'] . $key;
    }

    /** 带前缀的真实键名(诊断输出用) */
    public function fullKey(string $key): string
    {
        return $this->withPrefix($key);
    }

    /* ================= 命令封装 ================= */

    public function ping(): bool
    {
        try {
            return $this->raw(['PING']) === 'PONG';
        } catch (RuntimeException $e) {
            return false;
        }
    }

    public function get(string $key)
    {
        return $this->raw(['GET', $this->withPrefix($key)]);
    }

    public function set(string $key, string $val, ?int $ttl = null): bool
    {
        $args = ['SET', $this->withPrefix($key), $val];
        if ($ttl !== null) {
            $args[] = 'EX';
            $args[] = (string) $ttl;
        }
        return $this->raw($args) === 'OK';
    }

    public function setnx(string $key, string $val, ?int $ttl = null): bool
    {
        $args = ['SET', $this->withPrefix($key), $val, 'NX'];
        if ($ttl !== null) {
            $args[] = 'EX';
            $args[] = (string) $ttl;
        }
        $r = $this->raw($args);
        return $r === 'OK';
    }

    public function del(string ...$keys): int
    {
        $args = ['DEL'];
        foreach ($keys as $k) {
            $args[] = $this->withPrefix($k);
        }
        return (int) $this->raw($args);
    }

    public function exists(string $key): bool
    {
        return (int) $this->raw(['EXISTS', $this->withPrefix($key)]) > 0;
    }

    /* ================= 元信息 / 诊断 =================
     * (键类型审计、WRONGTYPE 排查、坏键隔离都需要;不参与业务热路径) */

    /** 键类型:none|string|list|set|zset|hash|stream */
    public function type(string $key): string
    {
        $t = $this->raw(['TYPE', $this->withPrefix($key)]);
        return is_string($t) ? $t : 'unknown';
    }

    /** 剩余生存秒数:-1 无过期 / -2 键不存在 */
    public function ttl(string $key): int
    {
        return (int) $this->raw(['TTL', $this->withPrefix($key)]);
    }

    /** 重命名键(隔离坏键用);源键不存在返回 false */
    public function rename(string $key, string $newKey): bool
    {
        $r = $this->raw(['RENAME', $this->withPrefix($key), $this->withPrefix($newKey)]);
        return $r === 'OK';
    }

    public function hlen(string $key): int
    {
        return (int) $this->raw(['HLEN', $this->withPrefix($key)]);
    }

    public function scard(string $key): int
    {
        return (int) $this->raw(['SCARD', $this->withPrefix($key)]);
    }

    public function zcard(string $key): int
    {
        return (int) $this->raw(['ZCARD', $this->withPrefix($key)]);
    }

    public function strlen(string $key): int
    {
        return (int) $this->raw(['STRLEN', $this->withPrefix($key)]);
    }

    /** 列表区间读取(坏键预览用) */
    public function lrange(string $key, int $start = 0, int $stop = -1): array
    {
        $raw = $this->raw(['LRANGE', $this->withPrefix($key), (string) $start, (string) $stop]);
        return is_array($raw) ? $raw : [];
    }

    /* ================= Set ================= */

    public function sadd(string $key, string ...$vals): int
    {
        return (int) $this->raw(array_merge(['SADD', $this->withPrefix($key)], $vals));
    }

    public function srem(string $key, string ...$vals): int
    {
        return (int) $this->raw(array_merge(['SREM', $this->withPrefix($key)], $vals));
    }

    public function smembers(string $key): array
    {
        $a = $this->raw(['SMEMBERS', $this->withPrefix($key)]);
        return is_array($a) ? $a : [];
    }

    public function expire(string $key, int $sec): bool
    {
        return (bool) $this->raw(['EXPIRE', $this->withPrefix($key), (string) $sec]);
    }

    public function incr(string $key): int
    {
        return (int) $this->raw(['INCR', $this->withPrefix($key)]);
    }

    public function hset(string $key, string $field, string $val): bool
    {
        return (int) $this->raw(['HSET', $this->withPrefix($key), $field, $val]) >= 0;
    }

    public function hmset(string $key, array $map): bool
    {
        if (empty($map)) {
            return true;
        }
        $args = ['HMSET', $this->withPrefix($key)];
        foreach ($map as $f => $v) {
            $args[] = (string) $f;
            $args[] = (string) $v;
        }
        return $this->raw($args) === 'OK';
    }

    public function hmget(string $key, array $fields): array
    {
        if (empty($fields)) {
            return [];
        }
        $args = ['HMGET', $this->withPrefix($key)];
        foreach ($fields as $f) {
            $args[] = (string) $f;
        }
        $r = $this->raw($args);
        return is_array($r) ? $r : [];
    }

    public function hincrby(string $key, string $field, int $n = 1): int
    {
        return (int) $this->raw(['HINCRBY', $this->withPrefix($key), $field, (string) $n]);
    }

    public function hget(string $key, string $field)
    {
        return $this->raw(['HGET', $this->withPrefix($key), $field]);
    }

    public function hgetall(string $key): array
    {
        $raw = $this->raw(['HGETALL', $this->withPrefix($key)]);
        if (!is_array($raw)) {
            return [];
        }
        $map = [];
        for ($i = 0; $i + 1 < count($raw); $i += 2) {
            $map[$raw[$i]] = $raw[$i + 1];
        }
        return $map;
    }

    public function hdel(string $key, string ...$fields): int
    {
        $args = ['HDEL', $this->withPrefix($key)];
        foreach ($fields as $f) {
            $args[] = $f;
        }
        return (int) $this->raw($args);
    }

    public function lpush(string $key, string $val): int
    {
        return (int) $this->raw(['LPUSH', $this->withPrefix($key), $val]);
    }

    public function llen(string $key): int
    {
        return (int) $this->raw(['LLEN', $this->withPrefix($key)]);
    }

    /** @return [listName, value] | null */
    public function brpop(string $key, int $timeoutSec): ?array
    {
        // BRPOP 会阻塞 timeoutSec 才回复:读超时必须明显大于阻塞时长。
        // 否则 PHP 读超时先到 → parseReply 返回 null(假「队列空」),而 Redis 的回复
        // 滞留在缓冲区 → 后续每条命令读到的是上一条的回复(流错位),
        // 表现为消费照常、落库照常,但 hgetall/hmset 等全部静默失败。
        $this->connect();
        stream_set_timeout($this->fp, $timeoutSec + 5);
        $r = $this->raw(['BRPOP', $this->withPrefix($key), (string) $timeoutSec]);
        stream_set_timeout($this->fp, (int) ceil((float) $this->cfg['timeout']));
        if (!is_array($r) || count($r) !== 2) {
            return null;
        }
        return [$r[0], $r[1]];
    }

    /**
     * HyperLogLog(PFADD)。注意:2026-09-15 起产品**不再用 HLL 统计 UV / 独立 IP** ——
     * 估算在极小基数下会多估(出现过「概览 IP 数 4 / IP 地域页 3」的口径不一致),
     * 现在统一走 events 明细精确去重(StatsController::exactUniq / todayExact)。
     * 方法保留以便扩展其它允许近似的场景,采集链路不再调用它。
     */
    public function pfadd(string $key, string ...$vals): bool
    {
        $args = ['PFADD', $this->withPrefix($key)];
        foreach ($vals as $v) {
            $args[] = $v;
        }
        return (int) $this->raw($args) > 0;
    }

    /** HyperLogLog 基数估算(PFCOUNT);UV / 独立 IP 统计已不使用,见 pfadd() 注释 */
    public function pfcount(string $key): int
    {
        return (int) $this->raw(['PFCOUNT', $this->withPrefix($key)]);
    }

    public function zadd(string $key, float $score, string $member): bool
    {
        return (bool) $this->raw(['ZADD', $this->withPrefix($key), (string) $score, $member]);
    }

    public function zincrby(string $key, float $incr, string $member): float
    {
        return (float) $this->raw(['ZINCRBY', $this->withPrefix($key), (string) $incr, $member]);
    }

    public function zrem(string $key, string ...$members): int
    {
        $args = ['ZREM', $this->withPrefix($key)];
        foreach ($members as $m) {
            $args[] = $m;
        }
        return (int) $this->raw($args);
    }

    public function zscore(string $key, string $member): ?float
    {
        $r = $this->raw(['ZSCORE', $this->withPrefix($key), $member]);
        return $r === null ? null : (float) $r;
    }

    public function zcount(string $key, string $min, string $max): int
    {
        return (int) $this->raw(['ZCOUNT', $this->withPrefix($key), $min, $max]);
    }

    /** zrangebyscore,withscores=true 返回 [member=>score,...] */
    public function zrangebyscore(string $key, string $min, string $max, bool $withscores = false, int $limit = 0, int $offset = 0): array
    {
        $args = ['ZRANGEBYSCORE', $this->withPrefix($key), $min, $max];
        if ($limit > 0) {
            $args[] = 'LIMIT';
            $args[] = (string) $offset;
            $args[] = (string) $limit;
        }
        if ($withscores) {
            $args[] = 'WITHSCORES';
        }
        $raw = $this->raw($args);
        if (!is_array($raw)) {
            return [];
        }
        if (!$withscores) {
            return $raw;
        }
        $map = [];
        for ($i = 0; $i + 1 < count($raw); $i += 2) {
            $map[$raw[$i]] = (float) $raw[$i + 1];
        }
        return $map;
    }

    public function zremrangebyscore(string $key, string $min, string $max): int
    {
        return (int) $this->raw(['ZREMRANGEBYSCORE', $this->withPrefix($key), $min, $max]);
    }

    /** SCAN 遍历(服务端游标)。返回 [cursor, keys[]] */
    public function scan(string $pattern, int $count = 200): array
    {
        $this->connect();
        // 前缀追加到 pattern 开头
        $full = $this->withPrefix($pattern);
        // 使用游标式迭代:调用方循环调用,传入 $cursor
        throw new \LogicException('use scanNext() instead');
    }

    /** 迭代式 SCAN。$cursor 引用传递,返回匹配 key 数组(已去前缀) */
    public function scanKeys(string $pattern, int &$cursor, int $count = 200): array
    {
        $this->connect();
        $full = $this->withPrefix($pattern);
        $raw = $this->raw(['SCAN', (string) $cursor, 'MATCH', $full, 'COUNT', (string) $count]);
        if (!is_array($raw) || count($raw) !== 2) {
            return [];
        }
        $cursor = (int) $raw[0];
        $keys = [];
        $prefix = $this->cfg['prefix'];
        foreach ((array) $raw[1] as $k) {
            if ($prefix !== '' && strncmp($k, $prefix, strlen($prefix)) === 0) {
                $k = substr($k, strlen($prefix));
            }
            $keys[] = $k;
        }
        return $keys;
    }

    public function close(): void
    {
        if (is_resource($this->fp)) {
            fclose($this->fp);
        }
        $this->fp = null;
    }

    public function __destruct()
    {
        $this->close();
    }
}