2
0

RedisCache.php 6.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186
  1. <?php
  2. namespace App\Services;
  3. use Closure;
  4. use DateInterval;
  5. use DateTimeInterface;
  6. use Illuminate\Support\InteractsWithTime;
  7. use RedisCluster;
  8. /**
  9. * 直接操作 Redis 的缓存,取代 Laravel 原生 Cache。
  10. *
  11. * 前身是 api-v8 的 App\Tools\RedisClusters。接口与 Laravel 的 Cache 门面对齐
  12. * (方法名、参数、返回值、null 与 TTL 语义一致),但只实现 api-v13 真正用到的
  13. * get / put / remember / has / forget / increment 六个;tags、flush 这类在
  14. * Redis Cluster 上跨 slot 或会清空整个集群的操作故意不提供。
  15. *
  16. * 连的是 Redis Cluster,不走 Laravel 的 Redis 门面,用 phpredis 的 RedisCluster 手写连接
  17. * (https://github.com/phpredis/phpredis/blob/develop/cluster.md):
  18. * - 连接:config('mint.redis_cluster.host') : config('mint.redis_cluster.port') 作为种子节点,
  19. * 其余节点由 phpredis 从集群拓扑自动发现;首次读写时才建连
  20. * - key:config('mint.redis_cluster.prefix') . '/' . $key($key 已以 / 开头就不再加),
  21. * 不再叠加 REDIS_PREFIX / CACHE_PREFIX。所以 `foo` 与 `/foo` 是同一个 key
  22. * - 值:JSON。读回来对象一律是关联数组,数字是 int / float(不再是数字字符串);
  23. * 整数编码后就是纯文本数字,ai-translate 用 int() 直接读 `/task/{id}/pointer` 不受影响。
  24. * 存不了 JSON 的值(非 UTF-8 字符串、INF/NAN)写入时抛 JsonException
  25. *
  26. * 集群与 Laravel Cache 用的单机 Redis 不是同一个库,旧缓存不迁移,切换后冷启动。
  27. *
  28. * 业务代码通过门面 App\Facades\RedisCache 调用;测试用 RedisCache::fake()
  29. * 换成内存实现(见 RedisCacheFake)。
  30. */
  31. class RedisCache
  32. {
  33. use InteractsWithTime;
  34. /**
  35. * 调用方没给 TTL(或传 null)时的默认过期秒数:30 天。不提供永久保存。
  36. */
  37. public static int $defaultTtl = 30 * 24 * 60 * 60;
  38. /** @var list<string> 种子节点,形如 `host:port` */
  39. protected array $seeds;
  40. protected string $prefix;
  41. protected ?RedisCluster $client = null;
  42. /**
  43. * @param list<string>|null $seeds
  44. */
  45. public function __construct(?array $seeds = null, ?string $prefix = null)
  46. {
  47. $this->seeds = $seeds ?? [config('mint.redis_cluster.host').':'.config('mint.redis_cluster.port')];
  48. $this->prefix = $prefix ?? (string) config('mint.redis_cluster.prefix', '');
  49. }
  50. /**
  51. * 读取;不存在时返回 $default(可以是闭包)。
  52. */
  53. public function get(string $key, mixed $default = null): mixed
  54. {
  55. $value = $this->read($this->key($key));
  56. return $value === null ? value($default) : $this->unserialize($value);
  57. }
  58. /**
  59. * 写入。$ttl 为 null 时用 static::$defaultTtl;算出来的秒数 <= 0 时等同 forget()。
  60. */
  61. public function put(string $key, mixed $value, DateTimeInterface|DateInterval|int|null $ttl = null): bool
  62. {
  63. $seconds = $this->secondsUntil($ttl ?? static::$defaultTtl);
  64. if ($seconds <= 0) {
  65. return $this->forget($key);
  66. }
  67. return $this->write($this->key($key), $this->serialize($value), $seconds);
  68. }
  69. /**
  70. * 命中就返回缓存,否则执行 $callback 并写入。
  71. *
  72. * 与 Laravel 一致:缓存值为 null 视同未命中,下次会重新计算。
  73. */
  74. public function remember(string $key, DateTimeInterface|DateInterval|int|null $ttl, Closure $callback): mixed
  75. {
  76. $value = $this->get($key);
  77. if ($value !== null) {
  78. return $value;
  79. }
  80. $value = $callback();
  81. $this->put($key, $value, $ttl);
  82. return $value;
  83. }
  84. /**
  85. * 与 Laravel 一致:值为 null 视同不存在。
  86. */
  87. public function has(string $key): bool
  88. {
  89. return $this->get($key) !== null;
  90. }
  91. public function forget(string $key): bool
  92. {
  93. return $this->delete($this->key($key));
  94. }
  95. /**
  96. * 原子自增(INCRBY),返回自增后的值。key 不存在时从 0 开始,且不带过期时间
  97. * (不套用 $defaultTtl:版本号 key 靠它永久递增,过期归零会让旧缓存重新命中)。
  98. */
  99. public function increment(string $key, int $value = 1): int
  100. {
  101. return $this->incrementBy($this->key($key), $value);
  102. }
  103. /**
  104. * 前缀与 key 之间用 / 隔开:`dev` + `foo` → `dev/foo`;`/task/1` 本身带 /,得 `dev/task/1`。
  105. */
  106. protected function key(string $key): string
  107. {
  108. return $this->prefix.(str_starts_with($key, '/') ? $key : '/'.$key);
  109. }
  110. /**
  111. * 以下四个是仅有的 Redis 存取原语,RedisCacheFake 只需覆盖它们。
  112. * 参数 $key 已带前缀。
  113. */
  114. protected function read(string $key): ?string
  115. {
  116. $value = $this->redis()->get($key);
  117. return $value === false ? null : $value;
  118. }
  119. /**
  120. * 一律用一条 `SET key value EX seconds`:写值与设过期原子完成。
  121. * phpredis 的写法是 `set($key, $value, ['EX' => $seconds])`,不是 Laravel 的 `'EX', $seconds`。
  122. * 不要拆成 SET + EXPIRE(api-v8 的写法)——两条之间出错会留下永不过期的 key。
  123. */
  124. protected function write(string $key, string $value, int $seconds): bool
  125. {
  126. return (bool) $this->redis()->set($key, $value, ['EX' => $seconds]);
  127. }
  128. protected function delete(string $key): bool
  129. {
  130. return (bool) $this->redis()->del($key);
  131. }
  132. protected function incrementBy(string $key, int $value): int
  133. {
  134. return (int) $this->redis()->incrby($key, $value);
  135. }
  136. /**
  137. * 第一个参数 null 表示不读 php.ini 里的具名集群,直接用种子节点。
  138. */
  139. protected function redis(): RedisCluster
  140. {
  141. return $this->client ??= new RedisCluster(null, $this->seeds);
  142. }
  143. /**
  144. * 不转义中文与斜杠,省空间也方便在 redis-cli 里直接看;1.0 保持为 1.0,读回来仍是 float。
  145. */
  146. protected function serialize(mixed $value): string
  147. {
  148. return json_encode(
  149. $value,
  150. JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR,
  151. );
  152. }
  153. protected function unserialize(string $value): mixed
  154. {
  155. return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
  156. }
  157. }