CQRS应用程序中的缓存失效

时间:2014-10-13 18:42:35

标签: c# caching architecture dependency-injection cqrs

我们在我们的应用程序中练习CQRS体系结构,即我们有许多实现ICommand的类,并且每个命令都有处理程序:ICommandHandler<ICommand>。数据检索的方式相同 - 我们IQUery<TResult>IQueryHandler<IQuery, TResult>。这些日子很常见。

经常使用某些查询(对于页面上的多个下拉菜单),并且缓存执行结果是有意义的。所以我们有一个围绕IQueryHandler的装饰器来缓存一些查询执行 查询实现接口ICachedQuery,装饰器缓存结果。像这样:

public interface ICachedQuery {
    String CacheKey { get; }
    int CacheDurationMinutes { get; }
}

public class CachedQueryHandlerDecorator<TQuery, TResult> 
    : IQueryHandler<TQuery, TResult> where TQuery : IQuery<TResult>
{
    private IQueryHandler<TQuery, TResult> decorated;
    private readonly ICacheProvider cacheProvider;

    public CachedQueryHandlerDecorator(IQueryHandler<TQuery, TResult> decorated, 
        ICacheProvider cacheProvider) {
        this.decorated = decorated;
        this.cacheProvider = cacheProvider;
    }

    public TResult Handle(TQuery query) {
        var cachedQuery = query as ICachedQuery;
        if (cachedQuery == null)
            return decorated.Handle(query);

        var cachedResult = (TResult)cacheProvider.Get(cachedQuery.CacheKey);

        if (cachedResult == null)
        {
            cachedResult = decorated.Handle(query);
            cacheProvider.Set(cachedQuery.CacheKey, cachedResult, 
                cachedQuery.CacheDurationMinutes);
        }

        return cachedResult;
    }
}

关于我们是否应该在查询或属性上设置接口存在争议。当前使用接口是因为您可以根据缓存的内容以编程方式更改缓存键。即你可以添加实体&#39; id进入缓存键(即具有&#34; person_55&#34;,&#34; person_56&#34;等等)。

问题当然是缓存失效(命名和缓存失效,嗯?)。问题在于查询与命令或实体一对一不匹配。并且执行单个命令(即修改人员记录)应该呈现无效的多个缓存记录:人员记录和下载人员&#39;名。

目前,我有几位候选人参与解决方案:

  1. 在实体类中以某种方式记录所有缓存键,将实体标记为ICacheRelated并将所有这些键作为此接口的一部分返回。当EntityFramework更新/创建记录时,获取这些缓存密钥并使其无效。 (哈克!)
  2. 命令应该使所有缓存无效。或者更确切地说ICacheInvalidatingCommand应该返回应该失效的缓存键列表。并且在ICommandHandler上有一个装饰器,它会在执行命令时使缓存失效。
  3. 不要使缓存无效,只需设置较短的缓存生命周期(多短?)
  4. 魔豆。
  5. 我不喜欢任何选项(可能除了4号)。但我认为选项2是我要放弃的选择。问题是,缓存密钥生成变得混乱,我需要在知道如何生成密钥的命令和查询之间有一个共同点。另一个问题是,它太容易添加另一个缓存查询并错过命令的无效部分(或者不是所有应该无效的命令都将失效)。

    有更好的建议吗?

2 个答案:

答案 0 :(得分:11)

我想知道你是否应该真的在这里进行缓存,因为SQL服务器在缓存结果方面相当不错,所以你应该看到返回固定下拉值列表的查询非常快。

当然,当你进行缓存时,它取决于数据缓存持续时间应该是多少。这取决于系统的使用方式。例如,如果管理员添加了新值,则很容易解释在其他用户看到他的更改之前需要几分钟。

另一方面,如果普通用户需要添加值,那么在使用具有此类列表的屏幕时,情况可能会有所不同。但在这种情况下,通过向用户提供下拉或让他可以选择在那里添加新值,让用户的体验更流畅甚至可能更好。这个新值不是在同一个交易中处理的,一切都会好的。

如果你想做缓存失效,我会说你需要让你的命令发布域事件。这样,系统的其他独立部分可以对此操作做出反应,并且可以(除其他外)缓存失效。

例如:

public class AddCityCommandHandler : ICommandHandler<AddCityCommand>
{
    private readonly IRepository<City> cityRepository;
    private readonly IGuidProvider guidProvider;
    private readonly IDomainEventPublisher eventPublisher;

    public AddCountryCommandHandler(IRepository<City> cityRepository,
        IGuidProvider guidProvider, IDomainEventPublisher eventPublisher) { ... }

    public void Handle(AddCityCommand command)
    {
        City city = cityRepository.Create();

        city.Id = this.guidProvider.NewGuid();
        city.CountryId = command.CountryId;

        this.eventPublisher.Publish(new CityAdded(city.Id));
    }
}

您可以在此处发布可能如下所示的CityAdded事件:

public class CityAdded : IDomainEvent
{
    public readonly Guid CityId;

    public CityAdded (Guid cityId) {
        if (cityId == Guid.Empty) throw new ArgumentException();
        this.CityId = cityId;
    }
}

现在,您可以为此活动设置零个或多个订阅者:

public class InvalidateGetCitiesByCountryQueryCache : IEventHandler<CityAdded>
{
    private readonly IQueryCache queryCache;
    private readonly IRepository<City> cityRepository;

    public InvalidateGetCitiesByCountryQueryCache(...) { ... }

    public void Handle(CityAdded e)
    {
        Guid countryId = this.cityRepository.GetById(e.CityId).CountryId;

        this.queryCache.Invalidate(new GetCitiesByCountryQuery(countryId));
    }
}

这里我们有一个特殊的事件处理程序来处理CityAdded域事件,只是为了使GetCitiesByCountryQuery的缓存无效。这里的IQueryCache是一个专门用于缓存和使查询结果无效的抽象。 InvalidateGetCitiesByCountryQueryCache明确创建了查询,其结果应该是无效的。这个Invalidate方法可以使用ICachedQuery接口来确定其密钥并使结果无效(如果有的话)。

不是使用ICachedQuery来确定密钥,而是将整个查询序列化为JSON并将其用作密钥。这样,具有唯一参数的每个查询将自动获得自己的密钥和缓存,并且您不必在查询本身上实现此功能。这是一种非常安全的机制。但是,如果您的缓存应该在AppDomain循环中存活,您需要确保在应用程序重新启动时获得完全相同的密钥(这意味着序列化属性的排序must be guaranteed)。

您必须记住的一件事是,这种机制特别适用于最终一致性的情况。要采用前面的示例,您希望何时使缓存无效?在您添加城市之前或之后?如果您之前使缓存无效,则可能在执行提交之前重新填充缓存。那当然会很糟糕。另一方面,如果你刚刚完成,可能有人仍然直接观察旧值。特别是当您的事件在后台排队和处理时。

但是你可以做的是在你提交后直接执行排队事件。您可以使用命令处理程序装饰器:

public class EventProcessorCommandHandlerDecorator<T> : ICommandHandler<T>
{
    private readonly EventPublisherImpl eventPublisher;
    private readonly IEventProcessor eventProcessor;
    private readonly ICommandHandler<T> decoratee;

    public void Handle(T command)
    {
        this.decotatee.Handle(command);

        foreach (IDomainEvent e in this.eventPublisher.GetQueuedEvents())
        {
            this.eventProcessor.Process(e);
        }
    }
}

这里装饰器直接依赖于事件发布者实现,以允许调用GetQueuedEvents()接口中不可用的IDomainEventPublisher方法。我们迭代所有事件并将这些事件传递给IEventProcessor调解器(它就像IQueryProcessor一样)。

请注意有关此实现的一些事项。它不是交易性的。如果您需要确保处理所有事件,则需要将它们存储在事务队列中并从那里处理它们。然而,对于缓存失效,对我来说这似乎不是一个大问题。

这种设计对于缓存来说似乎有点过分,但是一旦你开始发布域事件,你就会开始看到很多用例,这将使你的系统使用变得更加简单。

答案 1 :(得分:1)

您使用的是单独的读写模型吗?如果是这样,也许您的“投影”类(处理来自写模型的事件并在读模型上执行CRUD的类)可能同时使相应的缓存条目无效。