You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何在Stancl Tenancy中安全重写Laravel CacheManager以实现兼容多驱动的租户感知缓存?

如何在Stancl Tenancy中安全重写Laravel CacheManager以实现兼容多驱动的租户感知缓存?

我之前也碰到过一模一样的坑——stancl/tenancy默认的缓存标签方案在database这类不支持标签的驱动上直接炸锅,而且自己写的自定义管理器还总因为初始化太早拿不到租户,报null错误。折腾了好一阵,终于摸出了一套靠谱的方案,完全兼容多驱动,还不破坏Laravel和tenancy的生命周期,给你唠唠细节:

核心思路:用装饰器模式替代直接替换CacheManager

直接替换整个CacheManager是踩坑的根源——Laravel的CacheManager在应用启动早期就会被解析,这时候stancl/tenancy的租户还没初始化(租户是通过请求中间件加载的),所以必然会拿到null的租户实例。

正确的姿势是装饰器模式:不替换整个管理器,而是给每个缓存仓库(Store)加一层“包装”,让包装类接管缓存操作,根据当前是否有租户、仓库是否支持标签来自动选择用标签还是键前缀。这种方式完全兼容Laravel原生缓存逻辑,而且只有在实际执行缓存操作时才会检查租户状态,完美避开初始化时机问题。

具体实现步骤

1. 编写租户感知的缓存仓库装饰器

创建一个TenantAwareCacheStore类,实现Laravel的Store接口,包装原有的缓存仓库,自动处理租户隔离逻辑:

<?php

namespace App\Cache;

use Illuminate\Contracts\Cache\Store;
use Stancl\Tenancy\Facades\Tenancy;

class TenantAwareCacheStore implements Store
{
    protected $originalStore;

    public function __construct(Store $originalStore)
    {
        $this->originalStore = $originalStore;
    }

    // 辅助方法:获取当前租户的唯一标识
    protected function getTenantId(): ?string
    {
        $tenant = Tenancy::getTenant();
        return $tenant?->getTenantKey();
    }

    // 处理缓存键:支持标签用标签,不支持就加前缀
    protected function resolveKey(string $key): string
    {
        $tenantId = $this->getTenantId();
        if (!$tenantId) {
            return $key; // 无租户场景直接返回原键,不影响系统级缓存
        }

        // 支持标签的驱动不用改键,后面用标签包裹
        if ($this->originalStore->supportsTags()) {
            return $key;
        }

        // 不支持标签的驱动,手动给键加租户前缀
        return "tenant_{$tenantId}_{$key}";
    }

    // 重写get方法
    public function get($key)
    {
        $processedKey = $this->resolveKey($key);
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->get($key);
        }

        return $this->originalStore->get($processedKey);
    }

    // 重写put方法
    public function put($key, $value, $seconds)
    {
        $processedKey = $this->resolveKey($key);
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->put($key, $value, $seconds);
        }

        return $this->originalStore->put($processedKey, $value, $seconds);
    }

    // 重写forget方法
    public function forget($key)
    {
        $processedKey = $this->resolveKey($key);
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->forget($key);
        }

        return $this->originalStore->forget($processedKey);
    }

    // 处理flush操作:注意不支持标签的驱动flush会清空所有缓存
    public function flush()
    {
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->flush();
        }

        // 不支持标签的驱动:这里会清空所有缓存,包括其他租户的
        // 如果需要只清空当前租户,可自行实现前缀匹配删除,但效率较低
        return $this->originalStore->flush();
    }

    // 以下为兼容Laravel Store接口的必填方法,直接委托给原仓库
    public function increment($key, $value = 1)
    {
        $processedKey = $this->resolveKey($key);
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->increment($key, $value);
        }

        return $this->originalStore->increment($processedKey, $value);
    }

    public function decrement($key, $value = 1)
    {
        $processedKey = $this->resolveKey($key);
        $tenantId = $this->getTenantId();

        if ($tenantId && $this->originalStore->supportsTags()) {
            return $this->originalStore->tags(["tenant_{$tenantId}"])->decrement($key, $value);
        }

        return $this->originalStore->decrement($processedKey, $value);
    }

    public function getPrefix()
    {
        return $this->originalStore->getPrefix();
    }
}

2. 在ServiceProvider中绑定装饰器

在App\Providers\AppServiceProvider的boot方法中,给所有缓存仓库实例添加自动装饰逻辑:

public function boot()
{
    // 每当Laravel解析出缓存仓库实例时,自动用我们的装饰器包装
    $this->app->resolving('cache.store', function ($originalStore, $app) {
        return new \App\Cache\TenantAwareCacheStore($originalStore);
    });

    // 监听租户初始化事件,确保租户切换后缓存逻辑自动适配
    \Stancl\Tenancy\Events\TenantInitialized::listen(function () {
        // 装饰器是无状态的,租户切换后自动使用新的租户ID,无需额外操作
    });
}

关键细节与注意事项

  1. 无租户场景兼容:装饰器会自动判断当前是否有租户,无租户时直接使用原键,完全不影响系统级缓存(比如后台管理系统的缓存)。
  2. flush操作的坑:对于不支持标签的驱动(如database),flush会清空所有缓存。如果需要只清空当前租户的缓存,你可以自行实现前缀匹配的删除逻辑,但这种方式效率较低,建议只在必要时使用。
  3. 租户切换的自动适配:因为装饰器在每次缓存操作时都会实时获取当前租户ID,所以租户切换后(比如多租户SaaS中切换不同租户),缓存会自动切换到新租户的隔离空间,无需手动重置缓存实例。
  4. 完全兼容Laravel原生缓存API:你可以继续使用Laravel的所有缓存方法(Cache::put(), Cache::remember()等),完全不用改业务代码,装饰器会在底层自动处理租户隔离。

为什么这个方案比自定义Manager靠谱?

  • 不破坏Laravel原生缓存系统:我们只是包装了原有的仓库实例,CacheManager的原有逻辑完全保留,不会出现驱动兼容问题。
  • 完美避开初始化时机问题:装饰器只有在实际执行缓存操作时才会检查租户状态,即使在应用启动早期获取了缓存实例,也不会因为租户未初始化而报错。
  • 完全兼容stancl/tenancy的生命周期:租户切换、初始化等逻辑都由tenancy包处理,我们的装饰器只做适配,不干预tenancy的核心流程。

内容来源于stack exchange

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.04.07 12:04:30