ASP.NET Core中的缓存[1]:如何在一个ASP.NET Core应用中使用缓存


.NET Core针对缓存提供了很好的支持 ,我们不仅可以选择将数据缓存在应用进程自身的内存中,还可以采用分布式的形式将缓存数据存储在一个“中心数据库”中。对于分布式缓存,.NET Core提供了针对Redis和SQL Server的原生支持。除了这个独立的缓存系统之外,ASP.NET Core还借助一个中间件实现了“响应缓存”,它会按照HTTP缓存规范对整个响应实施缓存。不过按照惯例,在对缓存进行系统介绍之前,我们还是先通过一些简单的实例演示感知一下如果在一个ASP.NET Core应用中如何使用缓存。

目录
一、将数据缓存在内存中
二、基于Redis的分布式缓存
三、基于SQL Server的分布式缓存
四、缓存整个HTTP响应

一、将数据缓存在内存中

与针对数据库和远程服务调用这种IO操作来说,应用针对内存的访问性能将提供不止一个数量级的提升,所以将数据直接缓存在应用进程的内容中自然具有最佳的性能优势。与基于内存的缓存相关的应用编程接口定义在NuGet包“Microsoft.Extensions.Caching.Memory”中,具体的缓存实现在一个名为MemoryCache的服务对象中,后者是我们对所有实现了IMemoryCache接口的所有类型以及对应对象的统称。由于是将缓存对象直接置于内存之中,中间并不涉及持久化存储的问题,自然也就无需考虑针对缓存对象的序列化问题,所以这种内存模式支持任意类型的缓存对象。

针对缓存的操作不外乎对缓存数据的存与取,这两个基本的操作都由上面介绍的这个MemoryCache对象来完成。如果我们在一个ASP.NET Core应用对MemoryCache服务在启动时做了注册,我们就可以在任何地方获取该服务对象设置和获取缓存数据,所以针对缓存的编程是非常简单的。

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {        
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs => svcs.AddMemoryCache())
   8:             .Configure(app => app.Run(async context =>
   9:                 {
  10:                     IMemoryCache cache = context.RequestServices.GetRequiredService();
  11:                     DateTime currentTime;
  12:                     if (!cache.TryGetValue("CurrentTime", out currentTime))
  13:                     {
  14:                         cache.Set("CurrentTime", currentTime = DateTime.Now);
  15:                     }
  16:                     await context.Response.WriteAsync($"{currentTime}({DateTime.Now})");
  17:                 }))
  18:             .Build()
  19:             .Run();        
  20:     }
  21: }

在上面这个演示程序中,我们在WebHostBuilder的ConfigureServices方法中通过调用ServiceCollection的扩展方法AddMemoryCache完成了针对MemoryCache的服务注册。在WebHostBuilder的Configure方法中,我们通过调用ApplicationBuilder的Run方法注册了一个中间件对请求做了简单的响应。我们先从当前HttpContext中得到对应的ServiceProvider,并利用后者得到MemoryCache对象。我们接下来调用MemoryCache的Set方法将当前时间缓存起来(如果尚未缓存),并指定一个唯一的Key(“CurrentTime”)。通过指定响应的Key,我们可以调用另一个名为TryGetValue的方法获取缓存的对象。我们最终写入的响应内容实际上是缓存的时候和当前实施的时间。由于缓存的是当前时间,所以当我们通过浏览器访问该应用的时候,显示的时间在缓存过期之前总是不变的

虽然基于内存的缓存具有最高的性能,但是由于它实际上是将缓存数据存在承载ASP.NET Core应用的Web服务上,对于部署在集群式服务器中的应用会出现缓存数据不一致的情况。对于这种部署场景,我们需要将数据缓存在某一个独立的存储中心,以便让所有的Web服务器共享同一份缓存数据,我们将这种缓存形式称为“分布式缓存”。ASP.NET Core为分布式缓存提供了两种原生的存储形式,一种是基于NoSQL的Redis数据库,另一种则是微软自家关系型数据库SQL Server。

二、基于Redis的分布式缓存

Redis数目前较为流行NoSQL数据库,很多的编程平台都将它作为分布式缓存的首选,接下来我们来演示如何在一个ASP.NET Core应用中如何采用基于Redis的分布式缓存。考虑到一些人可能还没有体验过Redis,所以我们先来简单介绍一下如何安装Redis。Redis最简单的安装方式就是采用Chocolatey(https://chocolatey.org/) 命令行,后者是Windows平台下一款优秀的软件包管理工具(类似于NPM)。

   1: PowerShell prompt :
   2: iwr https://chocolatey.org/install.ps1 -UseBasicParsing | iex
   3:  
   4: CMD.exe:
   5: @powershell -NoProfile -ExecutionPolicy Bypass -Command "iex ((New-Object System.Net.WebClient).DownloadString('https://chocolatey.org/install.ps1'))" && SET "PATH=%PATH%;%ALLUSERSPROFILE%\chocolatey\bin"

我们既可以采用PowerShell (要求版本在V3以上)命令行或者普通CMD.exe命令行来安装Chocolatey ,具体的命令如上所示。在确保Chocolatey 被本地正常安装情况下,我们可以执行执行如下的命令安装或者升级64位的Redis。

   1: C:\>choco install redis-64
   2: C:\>choco upgrade redis-64

Redis服务器的启动也很简单,我们只需要以命令行的形式执行redis-server命令即可。如果在执行该命名之后看到如下图所示的输出,则表示本地的Redis服务器被正常启动,输出的结果会指定服务器采用的网络监听端口。

接下来我们会对上面演示的实例进行简单的修改,将基于内存的本地缓存切换到针对Redis数据库的分布式缓存。针对Redis的分布式缓存实现在NuGet包“Microsoft.Extensions.Caching.Redis”之中,所以我们需要确保该NuGet包被正常安装。不论采用Redis、SQL Server还是其他的分布式存储方式,针对分布式缓存的操作都实现在DistributedCache这个服务对象向,该服务对应的接口为IDistributedCache。

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs => svcs.AddDistributedRedisCache(options =>
   8:                 {
   9:                     options.Configuration    = "localhost";
  10:                     options.InstanceName     = "Demo";
  11:                 }))
  12:             .Configure(app => app.Run(async context =>
  13:                 {
  14:                     var cache = context.RequestServices.GetRequiredService();
  15:                     string currentTime = await cache.GetStringAsync("CurrentTime");
  16:                     if (null == currentTime)
  17:                     {
  18:                         currentTime = DateTime.Now.ToString();
  19:                         await cache.SetAsync("CurrentTime", Encoding.UTF8.GetBytes(currentTime));
  20:                     }
  21:                     await context.Response.WriteAsync($"{currentTime}({DateTime.Now})");
  22:                 }))
  23:             .Build()
  24:             .Run();
  25:     }
  26: }

从上面的代码片段可以看出,针对分布式缓存和内存缓存在总体编程模式上是一致的,我们需要先注册针对DistributedCache的服务注册,但是利用依赖注入机制提供该服务对象来进行缓存数据的设置和缓存。我们调用IServiceCollection的另一个扩展方法AddDistributedRedisCache注册DistributedCache服务,在调用这个方法的时候借助于RedisCacheOptions这个对象的Configuration和InstanceName属性设置Redis数据库的服务器和实例名称。由于采用的是本地的Redis服务器,所以我们将前者设置为“localhost”。其实Redis数据库并没有所为的实例的概念,RedisCacheOptions的InstanceName属性的目的在于当多个应用共享同一个Redis数据库的时候,缓存数据可以利用它来区分,当缓存数据被保存到Redis数据库中的时候,对应的Key会以它为前缀。修改后的应用启动后(确保Redis服务器被正常启动),如果我们利用浏览器来访问它,依然会得到与前面类似的输出。

对于基于内存的本地缓存来说,我们可以将任何类型的数据置于缓存之中,但是对于分布式缓存来说,由于涉及到网络传输甚至是持久化存储,放到缓存中的数据类型只能是字节数组,所以我们需要自行负责对缓存对象的序列化和反序列化工作。如上面的代码片段所示,我们先将表示当前时间的DateTime对象转换成字符串,然后采用UTF-8编码进一步转换成字节数组,最终调用DistributedCache的SetAsync方法将后者缓存起来。实际上我们也可以直接调用另一个扩展方法SetStringAsync,它会负责将字符串编码为字节数组。在获取缓存的时候,我们调用的是DistributedCache的GetStringAsync方法,它会将字节数组转换成字符串。

缓存数据在Redis数据库中是以散列(Hash)的形式存放的,对应的Key会将设置的InstanceName作为前缀(如果进行了设置)。为了查看究竟存放了哪些数据在Redis数据库中,我们可以按照如图3所示的形式执行Redis命名来获取存储的数据。从下图呈现的输出结果我们不难看出,存入的不仅仅包括我们指定的缓存数据(Sub-Key为“data”)之外,还包括其他两组针对该缓存条目的描述信息,对应的Sub-Key分别为“absexp”和“sldexp”,表示缓存的绝对过期时间(Absolute Expiration Time)和滑动过期时间(Slidding Expiration Time)。

三、基于SQL Server的分布式缓存

除了使用Redis这种主流的NoSQL数据库来支持分布式缓存,微软在设计分布式缓存时也没有忘记自家的关系型数据库采用SQL Server。针对SQL Server的分布式缓存实现在“Microsoft.Extensions.Caching.SqlServer”这个NuGet包中,我们先得确保该NuGet包被正常装到演示的应用中。

所谓的针对SQL Server的分布式缓存,实际上就是将标识缓存数据的字节数组存放在SQL Server数据库中某个具有固定结构的数据表中,因为我们得先来创建这么一个缓存表,该表可以借助一个名为sql-cache 的工具来创建。在执行sql-cache 工具创建缓存表之前,我们需要在project.json文件中按照如下的形式为这个工具添加相应的NuGet包“Microsoft.Extensions.Caching.SqlConfig.Tools”。

   1: {
   2:   …
   3:   "tools": {
   4:     "Microsoft.Extensions.Caching.SqlConfig.Tools": "1.1.0-preview4-final"
   5:   }
   6: }

当针对上述这个NuGet包复原(Restore)之后,我们可以执行“dotnet sql-cache create”命令来创建,至于这个执行这个命令应该指定怎样的参数,我们可以按照如下的形式通过执行“dotnet sql-cache create --help”命令来查看。从下图可以看出,该命名需要指定三个参数,它们分别表示缓存数据库的链接字符串、缓存表的Schema和名称。

接下来我们只需要在演示应用所在的项目根目录(project.json文件所在的目录)下执行dotnet sql-cache create就可以在指定的数据库创建缓存表了。对于我们演示的实例来说,我们按照下图所示的方式执行这dotnet sql-cache create命令行在本机一个名为demodb的数据库中创建了一个名为AspnetCache的缓存表,该表采用dbo作为Schema。

在所有的准备工作完成之后,我们只需要对上面的程序做如下的修改即可将针对Redis数据库的缓存切换到针对SQL Server数据库的缓存。由于采用的同样是分布式缓存,所以针对缓存数据的设置和提取的代码不用做任何改变,我们需要修改的地方仅仅是服务注册部分。如下面的代码片段所示,我们在WebHostBuilder的ConfigureServices方法中调用IServiceCollection的扩展方法AddDistributedSqlServerCache完成了对应的服务注册。在调用这个方法的时候,我们通过设置SqlServerCacheOptions对象的三个属性的方式指定了缓存数据库的链接字符串和缓存表的Schema和名称。

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs => svcs.AddDistributedSqlServerCache(options =>
   8:             {
   9:                 options.ConnectionString   = "server=.;database=demodb;uid=sa;pwd=password";
  10:                 options.SchemaName         = "dbo";
  11:                 options.TableName          = "AspnetCache";
  12:             }))
  13:             .Configure(app => app.Run(async context =>
  14:                 {
  15:                     var cache = context.RequestServices.GetRequiredService();
  16:                     string currentTime = await cache.GetStringAsync("CurrentTime");
  17:                     if (null == currentTime)
  18:                     {
  19:                         currentTime = DateTime.Now.ToString();
  20:                         await cache.SetAsync("CurrentTime", Encoding.UTF8.GetBytes(currentTime));
  21:                     }
  22:                     await context.Response.WriteAsync($"{currentTime}({DateTime.Now})");
  23:                 }))
  24:             .Build()
  25:             .Run();
  26:     }
  27: }

如果想看看最终存入SQL Server数据库中的究竟包含哪些缓存数据,我们只需要直接在所在数据库中查看对应的缓存表了。对于演示实例缓存的数据,它会以下图所示的形式保存在我们创建的缓存表(AspnetCache)中,与基于Redis的缓存类似,与指定缓存数据的值一并存储的还包括缓存的过期信息。

四、缓存整个HTTP响应

上面演示的两种缓存都要求我们利用注册的服务对象以手工的方式存储和提取具体的缓存数据,而接下来我们演示的缓存则不再基于某个具体的缓存数据,而是将服务端最终生成的响应主体内容予以缓存,我们将这种缓存形式称为响应缓存(Response Caching)。标准的HTTP规范,不论是HTTP 1.0+还是HTTP 1.1,都会缓存做了详细的规定,这是响应规范的理论机制和指导思想。我们将在后续内容中详细介绍HTTP缓存,在这之前我们先通过一个简单的实例来演示一下整个响应内容是如何借助一个名为ResponseCachingMiddleware中间件被缓存起来的。该中间件由“Microsoft.AspNetCore.ResponseCaching”这个NuGet包提供。

通过同样是采用基于时间的缓存场景,为此我们编写了如下这个简单的程序。我们在WebHostBuilder的ConfigureServices方法中调用了IServiceCollection接口的扩展方法AddResponseCaching注册了中间件ResponseCachingMiddleware依赖的所有的服务,而这个中间件的注册则通过调用IApplicationBuilder接口的扩展方法UseResponseCaching完成。

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs=>svcs.AddResponseCaching())
   8:             .Configure(app => app
   9:                 .UseResponseCaching()
  10:                 .Run(async context => {
  11:                     context.Response.GetTypedHeaders().CacheControl = new CacheControlHeaderValue()
  12:                         {
  13:                             Public = true,
  14:                             MaxAge = TimeSpan.FromSeconds(3600)
  15:                         };
  16:                     
  17:                     string utc = context.Request.Query["utc"].FirstOrDefault()??"";
  18:                     bool isUtc = string.Equals(utc, "true", StringComparison.OrdinalIgnoreCase);
  19:                     await context.Response.WriteAsync(isUtc? DateTime.UtcNow.ToString(): DateTime.UtcNow.ToString());
  20:                 }))
  21:             .Build()
  22:             .Run();    
  23:     }
  24: }

对于最终实现的请求处理逻辑来说,我们仅仅是为响应添加了一个Cache-Control报头,并将它的值设置为“public, max-age=3600”(public表示缓存的是可以被所有用户共享的公共数据,而max-age则表示过去时限,单位为秒)。真正写入响应的主体内容就是当前时间,不给过我们会根据请求的查询字符串“utc”决定采用普通时间还是UTC时间。

要证明整个响应的内容是否被被缓存起来,我们只需要验证在缓存过期之前具有相同路径的多个请求对应的响应是否具有相同的主体内容,为此我们采用Fiddler来生发送的请求并拦截响应的内容。如下所示的两组请求和响应是在不同时间发送的,我们可以看出响应的内容是完全一致的。由于请求发送的时间不同,所以返回的缓存副本的“年龄”(对应于响应报头Age)也是不同的。

   1: GET http://localhost:5000/ HTTP/1.1
   2: User-Agent: Fiddler
   3: Host: localhost:5000
   4:  
   5: HTTP/1.1 200 OK
   6: Date: Sun, 12 Feb 2017 13:02:23 GMT
   7: Content-Length: 20
   8: Server: Kestrel
   9: Cache-Control: public, max-age=3600
  10: Age: 82
  11:  
  12: 2/12/2017 1:02:23 PM
  13:  
  14:  
  15: GET http://localhost:5000/ HTTP/1.1
  16: User-Agent: Fiddler
  17: Host: localhost:5000
  18:  
  19: HTTP/1.1 200 OK
  20: Date: Sun, 12 Feb 2017 13:02:23 GMT
  21: Content-Length: 20
  22: Server: Kestrel
  23: Cache-Control: public, max-age=3600
  24: Age: 85
  25:  
  26: 2/12/2017 1:02:23 PM

上面这个两个请求的URL并没有携带“utc”查询字符串,所以返回的是一个非UTC时间,接下来我们采用相同的方式生成一个试图返回UTC时间的请求。从下面给出的请求和响应的内容我们可以看出,虽然请求携带了查询字符串“utc=true”,但是返回的依然是之前缓存的时间。由于此可见,ResponseCachingMiddleware中间件在默认情况下是针对请求的路径对响应实施缓存的,它会忽略请求URL携带的查询字符串,这显然不是我们希望看到的结果。

   1: GET http://localhost:5000/?utc=true HTTP/1.1
   2: User-Agent: Fiddler
   3: Host: localhost:5000
   4:  
   5: HTTP/1.1 200 OK
   6: Date: Sun, 12 Feb 2017 13:02:23 GMT
   7: Content-Length: 20
   8: Server: Kestrel
   9: Cache-Control: public, max-age=3600
  10: Age: 474
  11:  
  12: 2/12/2017 1:02:23 PM

按照REST的原则,URL是网路资源的标识,但是资源的表现形式(Representation)会由一些参数来决定,这些参数可以体现为查询字符串,也可以体现为一些请求报头,比如Language报头决定资源的描述语言,Content-Encoding报头决定资源采用的编码方式。因此针对响应的缓存不应该只考虑请求的路径,还应该综合考虑这些参数。

对于演示的这个实例来说,我们希望将查询字符串“utc”纳入缓存考虑的范畴,这可以利用一个名为ResponseCachingFeature的特性来完成,该特性对应的接口为IResponseCachingFeature。如下面的代码片段所示,在将当前时间写入响应之后,我们得到这个特性并设置了它的VaryByQueryKeys属性,该属性包含一组决定输出缓存的查询字符串名称,我们将查询字符“utc”添加到这个列表中。

   1: public class Program
   2: {
   3:     public static void Main()
   4:     {
   5:         new WebHostBuilder()
   6:             .UseKestrel()
   7:             .ConfigureServices(svcs=>svcs.AddResponseCaching())
   8:             .Configure(app => app
   9:                 .UseResponseCaching()
  10:                 .Run(async context => {
  11:                     …
  12:                     var feature = context.Features.Get();
  13:                     feature.VaryByQueryKeys = new string[] { "utc" };                     
  14:                 }))
  15:             .Build()
  16:             .Run();    
  17:     }
  18: }