当缓存(单例)依赖于计时器(瞬态)时,俘虏依赖性是否可行

Is captive depencendy OK when cache (singleton) depends on timer (transient)

TL;DR: 我有一个依赖计时器的缓存。缓存是单例的,计时器必须是瞬态的(否则,需要计时器的不同组件将共享同一个计时器)。这是一个俘虏依赖,这是一个 Bad Sign™。那么这个设计有什么问题呢?在我看来,单例组件 A 应该能够使用另一个组件 Z 并且必须为每个组件唯一实例化 A, B, C, ... 就靠它了.


我正在开发一个 ASP.NET 网站 API 来提供汇总的销售数据。在我的后端,我有一个由 CachingOrderRepository 装饰的 OrderRepository。我使用 SimpleInjector 注册了两者。在我看来,这两个都应该注册为单例是合乎逻辑的——至少缓存必须是(否则 AFAIK new/empty 缓存将为每个 Web 请求实例化,从而破坏缓存的目的)。

CachingOrderRepository 通过每晚静默更新自身来工作(将所有订单保存在内存中),因此依赖于 ITimerSystem.Timers.Timer 的可模拟包装器)。我对长寿命计时器没有问题(这个特定的计时器在设计上实际上是一个单例),但它需要注册为一个瞬态依赖项,因为每个需要计时器的组件都需要一个唯一的实例。

然而,由于缓存被注册为单例,定时器被注册为瞬态,这是一个俘虏依赖,并在 SimpleInjector 中给出生活方式不匹配警告,他们说 should not be ignored:

When to Ignore Warnings

Do not ignore these warnings. False positives for this warning are rare and even when they occur, the registration or the application design can always be changed in a way that the warning disappears.

我认为错误出在我的设计上,这不是那些罕见的误报之一。那么我的问题是:具体来说,我的设计有什么问题?

我意识到我可以通过使缓存依赖于 ITimer 抽象工厂 来规避警告,但这似乎只是一种规避警告的方法通过将 ITimer 实例的构造从 SimpleInjector 移动到显式工厂来发出警告,这使我付出了另一层抽象的代价,需要对其进行模拟才能进行单元测试 CachingOrderRepository (我需要注入一个工厂模拟returns 计时器模拟)。如果您建议使用抽象工厂,请解释为什么这不仅仅是一种规避警告的方法(在这种特殊情况下,它会让父组件控制计时器的处理,但一般来说,返回的组件工厂可能不是一次性的,所以这不是问题。

(注意:这不是俘虏依赖项通常有什么问题的问题,在其他地方有很好的答案。这是关于上述特定设计有什么问题的问题。但是,如果我看起来对俘虏依赖有一些误解,请随时澄清。)


更新: 根据要求,这里是缓存和定时器的实现:

public class CachingOrderRepository : IOrderRepository
{
    private readonly IOrderRepository repository;
    private ITimer timer;

    private Order[] cachedOrders;
    private bool isPrimed;

    public CachingEntityReader(IOrderRepository repository, ITimer timer)
    {
        this.repository = repository;
        this.timer = timer;

        this.StartTimer();
    }

    private void StartTimer()
    {
        this.timer.Elapsed += this.UpdateCache;
        this.timer.Interval = TimeSpan.FromHours(24);
        this.timer.Start();
    }

    private void UpdateCache()
    {
        Order[] orders = this.repository.GetAll().ToArray();
        this.cachedOrders = orders;
        this.isPrimed = true;
    }

    public IEnumerable<Order> GetAllValid()
    {
        while (!this.isPrimed)
        {
            Task.Delay(100);
        }
        return this.cachedOrders;
    }
}

public class TimedEventRaiser : ITimedEventRaiser, IDisposable
{
    private readonly Timer timer;

    public TimedEventRaiser()
    {
        this.timer = new Timer();
        this.timer.Elapsed += (_, __) => this.Elapsed?.Invoke();
    }

    public event Action Elapsed;

    public TimeSpan Interval
    {
        get => TimeSpan.FromMilliseconds(this.timer.Interval);
        set => this.timer.Interval = value.TotalMilliseconds;
    }

    public void Start()
    {
        this.timer.Start();
    }

    public void Stop()
    {
        this.timer.Stop();
    }

    public void Dispose()
    {
        this.timer.Dispose();
    }
}

but it needs to be registered as a transient dependency because each component that needs a timer needs a unique instance.

您所描述的行为实际上是一种不同的生活方式,Simple Injector 不支持开箱即用,即:Instance Per Dependency. Such lifestyle is easily created,但通常不建议这样做,因为正如所描述的那样,设计可以通常会进行调整以不需要使用有状态的依赖项。

因此,本身每个依赖项都有一个实例并不是一件坏事,但总的来说,使用无状态和不可变的组件导致更清晰、更简单和更易于维护的代码。

例如,在您的情况下,您的代码将受益于从装饰器本身中提取缓存的责任。这将简化装饰器 并且 允许缓存实现也成为 Singleton

以下示例显示了 CachingOrderRepository:

的简化版本
public class CachingOrderRepository : IOrderRepository
{
    private readonly IOrderRepository repository;
    private readonly ICache cache;
    private readonly ITimeProvider time;

    public CachingOrderRepository(
        IOrderRepository repository, ICache cache, ITimeProvider time)
    {
        this.repository = repository;
        this.cache = cache;
        this.time = time;
    }

    public IEnumerable<Order> GetAllValid() =>
        this.cache.GetOrAdd(
            key: "AllValidOrders",
            cacheDuration: this.TillMidnight,
            valueFactory: () => this.repository.GetAllValid().ToList().AsReadOnly());

    private TimeSpan TillMidnight => this.Tomorrow - this.time.Now;
    private DateTime Tomorrow => this.time.Now.Date.AddHours(24);
}

这个 CachingOrderRepository 使用了如下所示的 ICache 抽象:

public interface ICache
{
    T GetOrAdd<T>(string key, TimeSpan cacheDuration, Func<T> valueFactory);
}

像 ASP.NET 缓存这样的缓存库的适配器实现很容易建立在这个抽象之上。例如,这是 MemoryCache:

的适配器
public sealed class MemoryCacheCacheAdapter : ICache
{
    private static readonly ObjectCache Cache = MemoryCache.Default;

    public T GetOrAdd<T>(string key, TimeSpan cacheDuration, Func<T> valueFactory)
    {
        object value = Cache[key];

        if (value == null)
        {
            value = valueFactory();

            Cache.Add(key, value, new CacheItemPolicy 
            {
                AbsoluteExpiration = DateTimeOffset.UtcNow + cacheDuration
            });
        }

        return (T)value;
    }
}

更新

请注意,多线程和锁定是真正困难的问题(如您的 Gist 示例所示),因此如果您需要您的解决方案在后台自动更新,我会建议有一个基础设施,在缓存和装饰器之外,触发更新。这允许该基础设施通过管道并像普通用户一样触发更新。例如:

// Start the operation 2 seconds after start-up.
Timer timer = new Timer(TimeSpan.FromSeconds(2).TotalMilliseconds);

timer.Elapsed += (_, __) =>
{
    try
    {
        // Wrap operation in the appropriate scope (if required)
        using (ThreadScopedLifestyle.BeginScope(container))
        {
            var repo = container.GetInstance<IOrderRepository>();

            // This will trigger a cache reload in case the cache has timed out.
            repo.GetAllValid();
        }        
    }
    catch (Exception ex)
    {
        // TODO: Log exception
    }

    // Will run again at 01:00 AM tomorrow to refresh the cache
    timer.Interval = DateTime.Today.AddHours(25);
};

timer.Start();

您可以在应用程序启动时添加此代码。此代码将确保在启动后 2 秒后在后台加载缓存,并在第二天上午 01:00 再次加载。

这更容易实现,因为定时器保证是唯一的,所以你不必担心多个调用者同时触发它,同时你的缓存实现仍然可以应用全局锁 while更新,更简单

这种方法的缺点是您无法与并发读者一起更新,但这对您来说可能不是问题,尤其是当 运行 在夜间更新时。