65.9K
CodeProject 正在变化。 阅读更多。
Home

客户端缓存 .NET 应用程序

starIconstarIconstarIconstarIcon
emptyStarIcon
starIcon

4.62/5 (12投票s)

2005 年 1 月 26 日

14分钟阅读

viewsIcon

109000

downloadIcon

1542

使用分布式 .NET 应用程序的客户端缓存来提高性能。

Demo application for caching library

引言

当今的应用程序经常处理远程数据源,同时用户期望比上个世纪的基于 Web 的应用程序具有更高的交互性。带宽和服务器容量在不断增加,但仍然不足以满足用户的需求。为了让程序在用户看来响应迅速,每次响应用户的小操作都通过服务器查询并等待处理的成本有时是无法承受的。幸运的是,许多应用程序不需要这样的处理;一旦一个请求被处理过一次,其结果就可以被保存起来,并在下次需要时重新应用。

本文介绍了一个程序集,它解决了所有这些问题,包括支持多线程、多用户环境中多个带参数的相关数据副本和版本控制。存储的数据可以是 DataSet,或者任何支持流式序列化/反序列化的数据。

目录

客户端缓存的必要性

微软看到了这个问题的其中一半,并为我们提供了 Caching Application Block。这个块对于减轻服务器的计算或数据库检索负载很有用,但不能解决任何带宽问题。使用它,如果您的用户希望一遍又一遍地查看整个产品目录,这不会给您的服务器带来不当的压力。但是,如果您的目录很大,它仍然会轻易地耗尽您的带宽。

Kirby Turner 在他的 Code Project 文章 Using Cache in Your WinForms Applications 中解决了这个问题。他在此基础上扩展了微软的解决方案,使其能够在客户端工作。通过将缓存数据存储在客户端,一旦缓存被填充,就不需要使用任何带宽。不幸的是,他提出的模型仅提供了一个简化的缓存数据概念。

缓存比看起来要复杂

当我刚开始处理这个问题时,我就知道我需要一种方法将“一个东西”放入缓存,并在以后检索它。我很快就发现我将“一个东西”的想法太模糊了,我需要确切地定义是什么标识一个缓存项。我需要找到区分同一数据的多个实例的方法,并跟踪“容器”的版本以处理服务器软件的升级。

考虑一个电子商务系统,其中用户想要查看产品详细信息。一个天真的实现可能只会在用户点击 ProductX 的“详细信息”按钮时存储一个名为“ProductDetail”的项。这样,如果他再次单击该按钮,我们就可以直接从缓存中提取数据。但是当用户切换到 ProductQ 时会发生什么?显然,当我们将在 ProductQ 保存到缓存时,我们不想覆盖 ProductX 的缓存详细信息。LocalCaching 解决方案开发了一种参数化方法来解决这个问题。

现在假设服务器端代码被更改了,导致返回到客户端的数据“形状”发生了变化。也许它是一个 DataSet,并且在其一个 DataTable 中添加了一个新列。或者数据库代码中存在一个 bug,并且某些行被错误地省略了。这种更改应该会使所有具有旧版本的数据无效。LocalCaching 解决方案提供了一种在检测到新版本时清除所有过时的“ProductDetail”版本的方法。

如果您非常关注应用程序的响应能力,您可能已经在 M 了多线程,至少是为了防止在检索数据时用户界面冻结。防止缓存损坏是必要的,因此缓存管理器必须是线程安全的。此外,如果应用程序有多个实例同时运行,我们需要防止它们相互损坏缓存。

多个用户也有可能共享同一台计算机。在我们假设的电子商务应用程序中,我们希望确保每个用户都能获得自己对“AccountDetails”的私有答案。按用户划分数据也允许一个用户完全清除缓存,而不会干扰另一个用户的缓存。

在我的环境中,大多数需要缓存的东西都是 DataSet 格式的。然而,有时也需要存储 XML 等其他形式的数据。LocalCaching 解决方案最容易与 DataSet 一起使用,但它支持任何可以流式序列化/反序列化的内容。

使用 LocalCaching 库

该库由一个名为 LocalCaching.dll 的程序集组成。您需要将它添加到项目的引用中。该程序集定义了一个名为 LocalCaching 的单个命名空间,您可能需要为其添加一个 using 指令。

创建 CacheManager

您需要做的第一件事就是获取一个 CacheManager 对象。定义了两个构造方法;调用默认构造方法会将缓存放在 C:\Documents and Settings\username\Local Settings\Application Data\LocalCaching 文件夹中。

private LocalCaching.ICacheService mCache;
// ...
mCache = new LocalCaching.CacheManager();
      

存储和检索简单数据

在深入研究参数、版本等内容之前,让我们先看看如何存储和检索简单数据。以下几行代码创建了一个最小的日期和版本条目以及一个用于数据参数化的空占位符。请注意 storeDataset() 方法的第一个参数。这为缓存项提供了一个名称,就像上面示例中的“ProductDetail”和“AccountDetails”一样。我通常使用要存储的 DataSet 的类型名称。第四个参数是要存储的 DataSet

LocalCaching.DateAndVersion
dav = new LocalCaching.DateAndVersion( DateTime.UtcNow, "no version" );
mCache.storeDataset( "MyCacheItemName", "", dav, dsDatasetToCache );

从缓存中提取数据稍微复杂一些,因为为了支持类型化的 DataSet,我们需要告诉库将数据加载到什么类型中。

DSDogOwners myds =
    mCache.retrieveDataset( "MyCacheItemName", "", typeof(DSDogOwners) )
    as DSDogOwners;

retrieveDataset() 方法中,我们传入了存储项时给它的名称;参数(在这种情况下为空);以及库应该实例化以加载数据的对象类型。由于库无法提前知道要返回什么类型,该方法只返回一个 DataSet 对象,因此您需要将其强制转换为正确的类型。

您可能只想检查记录是否存在而不实际检索它。这很有用,因为它确实返回该记录的日期和版本。您可以将此发送到您的 Web 服务以发出请求;这使得服务器可以选择说明“您的数据仍然是最新的;请继续使用您缓存中的数据”。

LocalCaching.DateAndVersion dav = 
        mCache.getDateAndVersion( "MyCacheItemName", "" );
if (dav == null)
{
   System.Windows.Forms.MessageBox.Show("Not found in cache");
}

理解参数

通常无法简单地保存和检索缓存的详细信息。如上所述,当数据是“ProductDetails”时,我们需要区分存储的是哪个产品的详细信息。我称这种区分数据为“参数”,因为它们通常对应于检索数据的 Web 方法的参数。

使用参数值标记数据类型很快就会变成一个复杂的问题,因为库无法提前知道参数的类型,甚至不知道有多少个参数。库通过提供 ParamHash 类来解决这个问题。您可以将参数放入该对象的实例中,然后使用其 ToString() 方法提取一个表示所有参数哈希的字符串,该字符串可用于传递给 storeDataset()retrieveDataset() 方法。

以下是使用 ParamHash 存储带参数 DataSet 的示例。

LocalCaching.DateAndVersion dav =
     new LocalCaching.DateAndVersion(dtTimestamp.Value,txtVersion.Text);
LocalCaching.ParamHash ph = new LocalCaching.ParamHash();
ph.Append( txtMyParameter.Text );
mCache.storeDataset( "MyCacheItemName", ph.ToString(), dav, dsDogOwners1 );

创建空的 ParamHash 对象后,您将实际参数值附加到其中。您可以一次一个地通过重复调用 Append() 来执行此操作。您还可以通过将它们放入对象数组并传递来一次性传递它们。

一个注意事项是,ParamHash 内部依赖于参数的 ToString() 方法。为了使其正常工作,您传入的任何对象都必须实现该方法的有意义的实现。有意义是指它必须提供一个代表该对象状态的值。例如,返回对象的类型名称将无法让 ParamHash 区分一个值与另一个值。

理解日期和版本

LocalCaching 库包含一个名为 DateAndVersion 的类,它只是一个用于捆绑特定缓存数据这两个信息的类。它们都有助于跟踪数据的“新鲜度”。要理解它们有什么用,您需要退后一步,从客户端和服务器的整体角度来看。

日期和版本都旨在成为从服务器接收到的值。这对于版本来说应该是显而易见的:由于服务器是构建数据的实体,因此它应该拥有实现该过程的代码的权威版本信息。由于时区差异(a)和客户端时钟设置不正确(b)的可能性,日期应由时间戳提供。

这的预期使用模式如下

  1. 调用 getDateAndVersion() 来检查我们是否已经有了一个值;我们发现我们确实缓存了一些东西。
  2. 将此日期和版本发送到服务器。
  3. 服务器检查版本以查看它是否与当前版本匹配;如果不匹配,则获取请求的数据并将其发送到客户端。
  4. 服务器检查日期以查看底层数据是否已更改;如果存在更新的数据,则将其发送到客户端。
  5. 由于版本和日期仍然是最新的,所以只是向客户端发送一条消息,说明您缓存的数据仍然可以接受。
  6. 如果客户端收到了新数据,则将其与新的 DateAndVersion 一起存储在缓存中;否则,只需从缓存中检索现有数据。

请注意,我们在检查数据是否已更改时经常“欺骗”服务器。在某些情况下,我们不跟踪数据何时已更改,即使我们跟踪了,有时检查成本也太高。在这些情况下,我们只需设置一个考虑数据易变性的时间阈值;如果客户端缓存比该阈值年轻,那么我们就假设它仍然是新鲜的。

同样,我们在客户端也使用这个。我注意到用户会在两个页面之间快速来回点击。当他们这样做时,即使是上面描述的简单调用的开销也可能过高。为了处理这种情况,我定义了一个非常小的阈值,通常在 3 到 60 秒之间。如果客户端数据比这个小阈值年轻(您可以使用 Age 属性进行检查),那么我们就假设数据仍然是好的。请注意,只有当您对客户端时钟有信心时,才能这样做。

管理缓存

了解要清除缓存的内容以及何时清除,与了解如何添加和检索数据同等重要。至少有四种原因会让您想要清除缓存的部分内容。

  1. 服务器告知您当前数据的日期已过期,因为已发生更改。在这种情况下,您只想通过使用方法 unStoreCache(string name,string paramhash) 来精确删除那个过期的项。
  2. 客户端已更新数据。当您知道正在发生更新时,您应该检索一个新副本。这确保了服务器添加/更改的内容被反映出来;由于错误未更新的内容可以被发现;以及任何乐观锁定的令牌都会被更新。对于这种情况,请使用 unStoreCache(string name,string paramhash) 来清除特定数据的受影响实例。如果更改更重要,即它可能影响到参数中未指示的内容,那么您可能希望调用 void clearCache(string name) 来清除该类型的所有数据。
  3. 缓存正在老化。您可能想清除任何陈旧的数据,仅仅是因为它占用了空间。为此,请使用方法 clearCache(DateTime minbirth) 指定您要保留的最旧日期。一切,无论名称和参数如何,比该日期旧的都将被清除。或者,如果您确实想清除所有内容,请使用 clearCache() — 这是终极方案。
  4. 服务器告知您您存储的版本已过时。在这种情况下,您希望摆脱具有元组 [名称, 版本] 的所有内容,因此请调用 clearCache(string name, string GoodVersion) 来清除所有名称相同但版本与服务器当前版本不同的内容。

处理非 DataSet 数据

虽然该库最常用于缓存 DataSet,但它同样乐于处理任何可以流式序列化/反序列化的内容。这与存储和检索 DataSet 的工作方式相同,只是您使用

  • getStoreStream() 当您想存储东西时;它会为您提供一个您可以写入的流对象。
  • retrieveStream() 再次检索它;它会返回一个您可以读取的流对象。

设计解决方案

虽然我希望上面的讨论足以使用该库,但也许如果您了解一些内部工作原理,您会对解决不可避免出现的问题有所启发。

实际存储了什么?

有两个需要存储的东西:实际要缓存的数据,以及缓存的索引,这样我们就可以弄清楚在哪里存储了什么。

我选择将每个记录存储在自己的文件中。这使得存储任何可序列化对象变得容易。它还使您可以使用常规工具轻松查看缓存中的内容。数据将写入的文件名以记录的名称开头,但为了确保每个条目的唯一性,我会在后面添加一个 GUID。您会注意到,可以传递“棘手”的名称来破坏此方案。不要这样做。

缓存索引与同一个目录中的文件具有一个特殊的名称“ContentIndex.xml”。您可以自己打开此文件以查看正在缓存的内容。

维护索引

缓存索引本身就是一个 DataSet。在某些方面,专门构建的对象集合可能更可取。然而,这种方式使我能够快速构建它,主要是因为它使我能够轻松地进行过滤。

线程安全

我处理多线程的方式没有花哨之处。我有一个静态锁对象,在任何可能影响索引文件的操作中都会使用它,所以它更像一个简单的信号量。一次只有一个线程可以执行可能更改缓存的操作;其他线程必须等待。实际上,这似乎不会导致任何性能问题。

多进程安全

在我们的环境中,应用程序可能同时运行多个实例。当这种情况发生时,我希望所有这些实例都能受益于相同的缓存,但同时它们必须小心不要互相干扰。这是通过不将索引保留在内存中来实现的。每次使用时,它都会从磁盘读取,每次更改时,它都会被重写。这样,每个进程都会不断地用其他进程的更改来刷新自己。

这有一个潜在的漏洞。存在一个非常小的窗口可能导致更新丢失,允许一个进程覆盖另一个进程所做的更改。我从未在现实世界中实际见过这种情况。即使发生了,在这里也无需担心。由于这里实现的所有内容都只是一个性能增强,丢失一个缓存条目只会导致该记录的性能增强丢失。

我不太满意的地方

我不喜欢的地方是,为了检索类型化的 DataSet 所需要做的工作。同时需要传递 DataSet 的类型(以便库知道要实例化什么类型)以及在返回时将其强制转换(因为方法必须定义为返回基 DataSet 类)显得很丑陋。不幸的是,我看不到任何不那么丑陋的替代方案。

版本历史

  • 1.0.0.0 - 2005-01-26

    首次发布

© . All rights reserved.