CQRS应用程序中的缓存失效

tra*_*max 12 c# architecture caching 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;
    }
}
Run Code Online (Sandbox Code Playgroud)

关于我们是否应该在查询或属性上有一个接口存在争议.当前使用接口是因为您可以根据缓存的内容以编程方式更改缓存键.即你可以将实体的id添加到缓存键中(即具有"person_55","person_56"等键).

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

目前我有几个候选人的解决方案:

  1. 将所有缓存键以某种方式记录在实体类中,将实体标记为ICacheRelated并将所有这些键作为此接口的一部分返回.当EntityFramework更新/创建记录时,获取这些缓存键并使其无效.(哈克!)
  2. 命令应该使所有缓存无效.或者更确切地说ICacheInvalidatingCommand,应该返回应该失效的缓存键列表.并且有一个装饰器,在ICommandHandler执行命令时将使缓存无效.
  3. 不要使缓存失效,只需设置较短的缓存生命周期(多短?)
  4. 魔豆.

我不喜欢任何选项(可能除了4号).但我认为选项2是我要放手一个.问题是,缓存密钥生成变得混乱,我需要在知道如何生成密钥的命令和查询之间有一个共同点.另一个问题是,添加另一个缓存查询并且错过命令上的无效部分(或者不是所有应该无效的命令将无效)将太容易了.

有更好的建议吗?

Ste*_*ven 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));
    }
}
Run Code Online (Sandbox Code Playgroud)

在这里,您发布CityAdded可能如下所示的事件:

public class CityAdded : IDomainEvent
{
    public readonly Guid CityId;

    public CityAdded (Guid cityId) {
        if (cityId == Guid.Empty) throw new ArgumentException();
        this.CityId = cityId;
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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));
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

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

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);
        }
    }
}
Run Code Online (Sandbox Code Playgroud)

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

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

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

  • 虽然您可以走事件路线,但我可能只是创建另一个 CommandHandlerCacheInvalidationDecorator,它获取 ICommandHandlerInvalidator 列表或每个命令在需要时创建的内容(类似于 IValidator/Decorator 解决方案)。然后,每个命令都会在运行该命令后清除它所知道的项目的缓存。不过,如果您将类内容作为键本身,则必须小心。您还想添加查询的完整命名空间,这样您就可以删除所有受影响的查询。 (2认同)