Bläddra i källkod

feat: 新增 RedisCache,业务缓存直连 Redis Cluster

- App\Services\RedisCache + 门面 App\Facades\RedisCache:phpredis RedisCluster
  手写连接,配置在 mint.redis_cluster;接口对齐 Laravel Cache 的
  get/put/remember/has/forget/increment,不提供 tags/flush
- 值存 JSON;key 为 prefix/key(key 自带 / 不重复加)
- 不给 TTL 时默认 30 天(RedisCache::$defaultTtl);increment 不设过期
- 测试默认 RedisCache::fake() 用内存实现;契约测试连真实集群,不可用时跳过
- 框架内部(RateLimiter、调度锁、snowflake)仍走 Laravel Cache

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
visuddhinanda 6 dagar sedan
förälder
incheckning
fe50d5e6f1

+ 36 - 0
api-v13/app/Facades/RedisCache.php

@@ -0,0 +1,36 @@
+<?php
+
+namespace App\Facades;
+
+use App\Services\RedisCacheFake;
+use Illuminate\Support\Facades\Facade;
+
+/**
+ * 业务代码用的缓存门面,取代 Illuminate\Support\Facades\Cache。
+ *
+ * @method static mixed get(string $key, mixed $default = null)
+ * @method static bool put(string $key, mixed $value, \DateTimeInterface|\DateInterval|int|null $ttl = null)
+ * @method static mixed remember(string $key, \DateTimeInterface|\DateInterval|int|null $ttl, \Closure $callback)
+ * @method static bool has(string $key)
+ * @method static bool forget(string $key)
+ * @method static int increment(string $key, int $value = 1)
+ *
+ * @see \App\Services\RedisCache
+ */
+class RedisCache extends Facade
+{
+    /**
+     * 换成内存实现,测试用。
+     */
+    public static function fake(): RedisCacheFake
+    {
+        static::swap($fake = new RedisCacheFake);
+
+        return $fake;
+    }
+
+    protected static function getFacadeAccessor(): string
+    {
+        return \App\Services\RedisCache::class;
+    }
+}

+ 186 - 0
api-v13/app/Services/RedisCache.php

@@ -0,0 +1,186 @@
+<?php
+
+namespace App\Services;
+
+use Closure;
+use DateInterval;
+use DateTimeInterface;
+use Illuminate\Support\InteractsWithTime;
+use RedisCluster;
+
+/**
+ * 直接操作 Redis 的缓存,取代 Laravel 原生 Cache。
+ *
+ * 前身是 api-v8 的 App\Tools\RedisClusters。接口与 Laravel 的 Cache 门面对齐
+ * (方法名、参数、返回值、null 与 TTL 语义一致),但只实现 api-v13 真正用到的
+ * get / put / remember / has / forget / increment 六个;tags、flush 这类在
+ * Redis Cluster 上跨 slot 或会清空整个集群的操作故意不提供。
+ *
+ * 连的是 Redis Cluster,不走 Laravel 的 Redis 门面,用 phpredis 的 RedisCluster 手写连接
+ * (https://github.com/phpredis/phpredis/blob/develop/cluster.md):
+ * - 连接:config('mint.redis_cluster.host') : config('mint.redis_cluster.port') 作为种子节点,
+ *   其余节点由 phpredis 从集群拓扑自动发现;首次读写时才建连
+ * - key:config('mint.redis_cluster.prefix') . '/' . $key($key 已以 / 开头就不再加),
+ *   不再叠加 REDIS_PREFIX / CACHE_PREFIX。所以 `foo` 与 `/foo` 是同一个 key
+ * - 值:JSON。读回来对象一律是关联数组,数字是 int / float(不再是数字字符串);
+ *   整数编码后就是纯文本数字,ai-translate 用 int() 直接读 `/task/{id}/pointer` 不受影响。
+ *   存不了 JSON 的值(非 UTF-8 字符串、INF/NAN)写入时抛 JsonException
+ *
+ * 集群与 Laravel Cache 用的单机 Redis 不是同一个库,旧缓存不迁移,切换后冷启动。
+ *
+ * 业务代码通过门面 App\Facades\RedisCache 调用;测试用 RedisCache::fake()
+ * 换成内存实现(见 RedisCacheFake)。
+ */
+class RedisCache
+{
+    use InteractsWithTime;
+
+    /**
+     * 调用方没给 TTL(或传 null)时的默认过期秒数:30 天。不提供永久保存。
+     */
+    public static int $defaultTtl = 30 * 24 * 60 * 60;
+
+    /** @var list<string> 种子节点,形如 `host:port` */
+    protected array $seeds;
+
+    protected string $prefix;
+
+    protected ?RedisCluster $client = null;
+
+    /**
+     * @param  list<string>|null  $seeds
+     */
+    public function __construct(?array $seeds = null, ?string $prefix = null)
+    {
+        $this->seeds = $seeds ?? [config('mint.redis_cluster.host').':'.config('mint.redis_cluster.port')];
+        $this->prefix = $prefix ?? (string) config('mint.redis_cluster.prefix', '');
+    }
+
+    /**
+     * 读取;不存在时返回 $default(可以是闭包)。
+     */
+    public function get(string $key, mixed $default = null): mixed
+    {
+        $value = $this->read($this->key($key));
+
+        return $value === null ? value($default) : $this->unserialize($value);
+    }
+
+    /**
+     * 写入。$ttl 为 null 时用 static::$defaultTtl;算出来的秒数 <= 0 时等同 forget()。
+     */
+    public function put(string $key, mixed $value, DateTimeInterface|DateInterval|int|null $ttl = null): bool
+    {
+        $seconds = $this->secondsUntil($ttl ?? static::$defaultTtl);
+
+        if ($seconds <= 0) {
+            return $this->forget($key);
+        }
+
+        return $this->write($this->key($key), $this->serialize($value), $seconds);
+    }
+
+    /**
+     * 命中就返回缓存,否则执行 $callback 并写入。
+     *
+     * 与 Laravel 一致:缓存值为 null 视同未命中,下次会重新计算。
+     */
+    public function remember(string $key, DateTimeInterface|DateInterval|int|null $ttl, Closure $callback): mixed
+    {
+        $value = $this->get($key);
+
+        if ($value !== null) {
+            return $value;
+        }
+
+        $value = $callback();
+        $this->put($key, $value, $ttl);
+
+        return $value;
+    }
+
+    /**
+     * 与 Laravel 一致:值为 null 视同不存在。
+     */
+    public function has(string $key): bool
+    {
+        return $this->get($key) !== null;
+    }
+
+    public function forget(string $key): bool
+    {
+        return $this->delete($this->key($key));
+    }
+
+    /**
+     * 原子自增(INCRBY),返回自增后的值。key 不存在时从 0 开始,且不带过期时间
+     * (不套用 $defaultTtl:版本号 key 靠它永久递增,过期归零会让旧缓存重新命中)。
+     */
+    public function increment(string $key, int $value = 1): int
+    {
+        return $this->incrementBy($this->key($key), $value);
+    }
+
+    /**
+     * 前缀与 key 之间用 / 隔开:`dev` + `foo` → `dev/foo`;`/task/1` 本身带 /,得 `dev/task/1`。
+     */
+    protected function key(string $key): string
+    {
+        return $this->prefix.(str_starts_with($key, '/') ? $key : '/'.$key);
+    }
+
+    /**
+     * 以下四个是仅有的 Redis 存取原语,RedisCacheFake 只需覆盖它们。
+     * 参数 $key 已带前缀。
+     */
+    protected function read(string $key): ?string
+    {
+        $value = $this->redis()->get($key);
+
+        return $value === false ? null : $value;
+    }
+
+    /**
+     * 一律用一条 `SET key value EX seconds`:写值与设过期原子完成。
+     * phpredis 的写法是 `set($key, $value, ['EX' => $seconds])`,不是 Laravel 的 `'EX', $seconds`。
+     * 不要拆成 SET + EXPIRE(api-v8 的写法)——两条之间出错会留下永不过期的 key。
+     */
+    protected function write(string $key, string $value, int $seconds): bool
+    {
+        return (bool) $this->redis()->set($key, $value, ['EX' => $seconds]);
+    }
+
+    protected function delete(string $key): bool
+    {
+        return (bool) $this->redis()->del($key);
+    }
+
+    protected function incrementBy(string $key, int $value): int
+    {
+        return (int) $this->redis()->incrby($key, $value);
+    }
+
+    /**
+     * 第一个参数 null 表示不读 php.ini 里的具名集群,直接用种子节点。
+     */
+    protected function redis(): RedisCluster
+    {
+        return $this->client ??= new RedisCluster(null, $this->seeds);
+    }
+
+    /**
+     * 不转义中文与斜杠,省空间也方便在 redis-cli 里直接看;1.0 保持为 1.0,读回来仍是 float。
+     */
+    protected function serialize(mixed $value): string
+    {
+        return json_encode(
+            $value,
+            JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES | JSON_PRESERVE_ZERO_FRACTION | JSON_THROW_ON_ERROR,
+        );
+    }
+
+    protected function unserialize(string $value): mixed
+    {
+        return json_decode($value, true, 512, JSON_THROW_ON_ERROR);
+    }
+}

+ 63 - 0
api-v13/app/Services/RedisCacheFake.php

@@ -0,0 +1,63 @@
+<?php
+
+namespace App\Services;
+
+/**
+ * RedisCache 的内存实现,供测试使用(相当于 Laravel Cache 的 array store)。
+ *
+ * 只替换四个存取原语,序列化、TTL 换算、null 语义仍走 RedisCache 本身,
+ * 所以测试覆盖到的是真实逻辑。通过 App\Facades\RedisCache::fake() 启用。
+ */
+class RedisCacheFake extends RedisCache
+{
+    /** @var array<string, array{value: string, expiresAt: int|null}> */
+    protected array $items = [];
+
+    protected function read(string $key): ?string
+    {
+        $item = $this->items[$key] ?? null;
+
+        if ($item === null) {
+            return null;
+        }
+
+        if ($item['expiresAt'] !== null && $item['expiresAt'] <= $this->currentTime()) {
+            unset($this->items[$key]);
+
+            return null;
+        }
+
+        return $item['value'];
+    }
+
+    protected function write(string $key, string $value, int $seconds): bool
+    {
+        $this->items[$key] = [
+            'value' => $value,
+            'expiresAt' => $this->currentTime() + $seconds,
+        ];
+
+        return true;
+    }
+
+    protected function delete(string $key): bool
+    {
+        $existed = $this->read($key) !== null;
+        unset($this->items[$key]);
+
+        return $existed;
+    }
+
+    protected function incrementBy(string $key, int $value): int
+    {
+        $current = $this->read($key);
+        $next = (int) ($current ?? 0) + $value;
+
+        $this->items[$key] = [
+            'value' => (string) $next,
+            'expiresAt' => $current === null ? null : $this->items[$key]['expiresAt'],
+        ];
+
+        return $next;
+    }
+}

+ 10 - 0
api-v13/config/mint.php

@@ -87,6 +87,16 @@ return [
         'expire' => env('CACHE_EXPIRE', 36000),
     ],
 
+    /**
+     * 集群使用独立的配置
+     * 而且不能用laravel 原生Redis, Cache 需要手写连接函数
+     * https://github.com/phpredis/phpredis/blob/develop/cluster.md
+     */
+    'redis_cluster' => [
+        'host' => env('REDIS_CLUSTER_HOST', '127.0.0.1'),
+        'port' => env('REDIS_CLUSTER_PORT', '6379'),
+        'prefix' => env('REDIS_CLUSTER_PREFIX', ''),
+    ],
     /*
     |--------------------------------------------------------------------------
     | 另外增添的路径

+ 211 - 0
api-v13/tests/Feature/RedisCacheTest.php

@@ -0,0 +1,211 @@
+<?php
+
+use App\Facades\RedisCache;
+use App\Services\RedisCache as RedisCacheService;
+
+/*
+| 第一部分:语义。TestCase 已把门面换成 RedisCacheFake,跑的是 RedisCache
+| 本身的 JSON 序列化 / TTL / null 逻辑;TTL 与 null 语义与 Laravel Cache 逐条对齐。
+*/
+
+test('get 不存在时返回默认值,闭包默认值会被求值', function () {
+    expect(RedisCache::get('missing'))->toBeNull()
+        ->and(RedisCache::get('missing', []))->toBe([])
+        ->and(RedisCache::get('missing', fn () => 'lazy'))->toBe('lazy');
+});
+
+test('put 后 get 原样取回数组、字符串、布尔', function () {
+    RedisCache::put('arr', ['a' => 1, '中' => '文']);
+    RedisCache::put('str', 'abc');
+    RedisCache::put('bool', false);
+
+    expect(RedisCache::get('arr'))->toBe(['a' => 1, '中' => '文'])
+        ->and(RedisCache::get('str'))->toBe('abc')
+        ->and(RedisCache::get('bool'))->toBeFalse();
+});
+
+test('JSON 往返:数字保持类型,数字字符串仍是字符串', function () {
+    RedisCache::put('int', 7);
+    RedisCache::put('float', 1.0);
+    RedisCache::put('numeric-string', '007');
+
+    expect(RedisCache::get('int'))->toBe(7)
+        ->and(RedisCache::get('float'))->toBe(1.0)
+        ->and(RedisCache::get('numeric-string'))->toBe('007');
+});
+
+test('对象读回来是关联数组', function () {
+    RedisCache::put('obj', (object) ['a' => 1, 'nested' => (object) ['b' => 'ā']]);
+    RedisCache::put('collection', collect([['id' => 1], ['id' => 2]]));
+
+    expect(RedisCache::get('obj'))->toBe(['a' => 1, 'nested' => ['b' => 'ā']])
+        ->and(RedisCache::get('collection'))->toBe([['id' => 1], ['id' => 2]]);
+});
+
+test('存不了 JSON 的值写入时抛异常', function () {
+    RedisCache::put('bad', "\xB1\x31");
+})->throws(JsonException::class);
+
+test('put 带 TTL 到期失效', function () {
+    RedisCache::put('int-ttl', 'x', 10);
+    RedisCache::put('date-ttl', 'x', now()->addSeconds(10));
+    RedisCache::put('interval-ttl', 'x', new DateInterval('PT10S'));
+
+    $this->travel(11)->seconds();
+
+    expect(RedisCache::has('int-ttl'))->toBeFalse()
+        ->and(RedisCache::has('date-ttl'))->toBeFalse()
+        ->and(RedisCache::has('interval-ttl'))->toBeFalse();
+});
+
+test('put / remember 不带 TTL 时按默认 30 天过期', function () {
+    RedisCache::put('no-ttl', 'x');
+    RedisCache::put('null-ttl', 'x', null);
+    RedisCache::remember('remember-null-ttl', null, fn () => 'x');
+
+    $this->travelTo(now()->addDays(30)->subSecond());
+    expect(RedisCache::has('no-ttl'))->toBeTrue()
+        ->and(RedisCache::has('null-ttl'))->toBeTrue()
+        ->and(RedisCache::has('remember-null-ttl'))->toBeTrue();
+
+    $this->travel(1)->seconds();
+    expect(RedisCache::has('no-ttl'))->toBeFalse()
+        ->and(RedisCache::has('null-ttl'))->toBeFalse()
+        ->and(RedisCache::has('remember-null-ttl'))->toBeFalse();
+});
+
+test('put 的 TTL <= 0 等同 forget', function () {
+    RedisCache::put('k', 'x');
+    RedisCache::put('k', 'y', 0);
+
+    expect(RedisCache::has('k'))->toBeFalse();
+});
+
+test('remember 命中时不执行回调', function () {
+    RedisCache::put('k', 'cached');
+
+    $value = RedisCache::remember('k', 60, fn () => throw new RuntimeException('不该执行'));
+
+    expect($value)->toBe('cached');
+});
+
+test('remember 回调返回 null 时不算命中,下次重新计算', function () {
+    $calls = 0;
+    $callback = function () use (&$calls) {
+        $calls++;
+
+        return null;
+    };
+
+    RedisCache::remember('k', 60, $callback);
+    RedisCache::remember('k', 60, $callback);
+
+    expect($calls)->toBe(2);
+});
+
+test('has 对 null 值返回 false', function () {
+    RedisCache::put('k', null);
+
+    expect(RedisCache::has('k'))->toBeFalse();
+});
+
+test('forget 返回是否真的删掉了', function () {
+    RedisCache::put('k', 'x');
+
+    expect(RedisCache::forget('k'))->toBeTrue()
+        ->and(RedisCache::forget('k'))->toBeFalse()
+        ->and(RedisCache::has('k'))->toBeFalse();
+});
+
+test('key 带不带开头的 / 是同一个 key', function () {
+    RedisCache::put('a/b', 1);
+    RedisCache::put('/c', 2);
+
+    expect(RedisCache::get('/a/b'))->toBe(1)
+        ->and(RedisCache::get('c'))->toBe(2);
+});
+
+test('increment 从 0 开始累加,返回新值', function () {
+    expect(RedisCache::increment('c'))->toBe(1)
+        ->and(RedisCache::increment('c', 5))->toBe(6)
+        ->and(RedisCache::get('c'))->toBe(6);
+});
+
+/*
+| 第二部分:契约。连真实 Redis Cluster(mint.redis_cluster),证明 key 名带前缀、
+| 值是 JSON、整数是纯文本(ai-translate 用 int() 读 /task/{id}/pointer)、TTL 与写值一条命令落地,
+| 以及 phpredis 对不存在的 key 返回 false 时被归一成 null。集群不可用时跳过。
+*/
+
+function redisCacheTestPrefix(): string
+{
+    return 'pest-redis-cache';
+}
+
+function rawRedisCluster(): RedisCluster
+{
+    return new RedisCluster(null, [config('mint.redis_cluster.host').':'.config('mint.redis_cluster.port')]);
+}
+
+function realRedisAvailable(): bool
+{
+    try {
+        rawRedisCluster()->get('pest-ping');
+
+        return true;
+    } catch (Throwable) {
+        return false;
+    }
+}
+
+afterEach(function () {
+    if (! realRedisAvailable()) {
+        return;
+    }
+
+    $redis = rawRedisCluster();
+    foreach (['pointer', 'ttl', 'missing', 'default-ttl'] as $key) {
+        $redis->del(redisCacheTestPrefix().'/'.$key);
+    }
+});
+
+test('数字以纯文本落在集群里,key 带前缀', function () {
+    (new RedisCacheService(prefix: redisCacheTestPrefix()))->put('pointer', 7);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/pointer'))->toBe('7');
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('带 TTL 写入时 key 带过期时间,读回原值', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+    $cache->put('ttl', ['a' => 1, 'b' => 'ā'], 60);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/ttl'))->toBe('{"a":1,"b":"ā"}')
+        ->and($cache->get('ttl'))->toBe(['a' => 1, 'b' => 'ā'])
+        ->and(rawRedisCluster()->ttl(redisCacheTestPrefix().'/ttl'))->toBeGreaterThan(0)->toBeLessThanOrEqual(60);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('不带 TTL 写入时集群里的 key 带默认 30 天过期', function () {
+    (new RedisCacheService(prefix: redisCacheTestPrefix()))->put('default-ttl', 'x');
+
+    expect(rawRedisCluster()->ttl(redisCacheTestPrefix().'/default-ttl'))
+        ->toBeGreaterThan(RedisCacheService::$defaultTtl - 10)
+        ->toBeLessThanOrEqual(RedisCacheService::$defaultTtl);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('前缀与 key 之间用 / 隔开,key 自带 / 时不重复', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+    $cache->put('pointer', 1, 60);
+    $cache->put('/ttl', 2, 60);
+
+    expect(rawRedisCluster()->get(redisCacheTestPrefix().'/pointer'))->toBe('1')
+        ->and(rawRedisCluster()->get(redisCacheTestPrefix().'/ttl'))->toBe('2')
+        ->and(rawRedisCluster()->exists(redisCacheTestPrefix().'//ttl'))->toBe(0);
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');
+
+test('不存在的 key 读到 null / 默认值,forget 返回 false', function () {
+    $cache = new RedisCacheService(prefix: redisCacheTestPrefix());
+
+    expect($cache->get('missing'))->toBeNull()
+        ->and($cache->get('missing', 'd'))->toBe('d')
+        ->and($cache->forget('missing'))->toBeFalse();
+})->skip(fn () => ! realRedisAvailable(), '没有可用的 Redis Cluster');

+ 3 - 3
api-v13/tests/Feature/TipitakaReadingRenderTest.php

@@ -1,10 +1,10 @@
 <?php
 
+use App\Facades\RedisCache;
 use App\Models\PaliText;
 use App\Models\Sentence;
 use App\Services\PaliContentService;
 use Illuminate\Foundation\Testing\RefreshDatabase;
-use Illuminate\Support\Facades\Cache;
 use Illuminate\Support\Str;
 
 uses(RefreshDatabase::class);
@@ -157,13 +157,13 @@ it('caches the paragraph and drops the cache when a sentence changes', function
     $this->getJson($url)->assertOk();
 
     $key = PaliContentService::paragraphCacheKey(9001, 1, $channel, 'html');
-    expect(Cache::has($key))->toBeTrue();
+    expect(RedisCache::has($key))->toBeTrue();
 
     $sentence = Sentence::where('book_id', 9001)->where('paragraph', 1)->orderBy('word_start')->first();
     $sentence->content = 'changed sentence';
     $sentence->save();
 
-    expect(Cache::has($key))->toBeFalse();
+    expect(RedisCache::has($key))->toBeFalse();
     expect($this->getJson($url)->json('data.0.display'))->toContain('changed sentence');
 });
 

+ 12 - 0
api-v13/tests/TestCase.php

@@ -2,6 +2,7 @@
 
 namespace Tests;
 
+use App\Facades\RedisCache;
 use Illuminate\Foundation\Testing\TestCase as BaseTestCase;
 
 abstract class TestCase extends BaseTestCase
@@ -44,4 +45,15 @@ abstract class TestCase extends BaseTestCase
 
         return parent::createApplication();
     }
+
+    /**
+     * RedisCache 默认换成内存实现,相当于原来 CACHE_STORE=array 的作用:
+     * 测试不碰开发 Redis,CI 也不需要起 Redis。
+     */
+    protected function setUp(): void
+    {
+        parent::setUp();
+
+        RedisCache::fake();
+    }
 }