MVC 页面静态化缓存原理及实现

国庆假期间,得出空来,对博客文章的缓存进行调整。因调整之前博客使用内存缓存高频内容,以加快页面的渲染速度,但考虑到服务器内存的限制,打算将博客的文章页进行静态化,缓存在服务器硬盘上。由于博客使用的 ASP.NET Core MVC 开发,需要实现对 MVC 页面静态化。

1、动态页面和静态页面

说句题外话,对于新闻、博客等内容平台,对内容页面的静态化是普遍的做法。所谓的静态化,是相对于动态来说的,当前高速的内容生产时代,绝大部分内容页面是动态生成。下面,我们从一篇文章被作者发布,到被访客浏览的过程中,对动态化和静态化进行对比。

动态化页面是,作者发布文章后,该文章内容保存到数据库,当访客浏览该文章时,内容系统先从数据库查询该文章内容,然后根据文章页面模板,将文章内容动态呈现给访客,大致流程如下:

动态文章
动态化文章

而静态化页面是,作者发布文章后,该文章内容仍然会保存到数据库,但是在保存到数据库的同时,还会根据文章页面模板,将文章内容生成为静态文件保存到硬盘上。当访客浏览该文章时,内容系统获取该文章的静态文件,然后呈现给访客,流程如下:

static-post
静态化文章

从上面的对比可以看出,静态化的优势在于,访客浏览文章时,不再需要查询数据库再生成内容页面,而是直接将静态文件呈现。对于访问量很大的网站,还可以对静态文件进行 CDN 缓存处理,大大减少服务器(CPU、内存)资源的消耗,降低服务器压力。

2、MVC  页面静态化

通过上述内容弄清静态化页面的优势后,对于 MVC 页面,我们如何进行页面静态化呢?

由于 MVC 是动态页面,这里不能完全按照上述的静态化流程进行处理,但可以介于动态和静态之间的特殊静态化处理方式。即将动态页面进行静态化缓存。我们知道 MVC 的 Controller 基类中有两个方法:OnActionExecuting 和 OnActionExecuted。OnActionExecuting 为进入 Action 方法前执行,而 OnActionExecuted 为 Action 方法处理完成后执行。其中,Action 为 Controller 中文章呈现方法,包括从数据库中查询文章,将文章呈现到页面。

如果将文章页面的呈现流程调整一下:访客浏览文章时,经 MVC 路由到 Controller,在进入 Action 前(即 OnActionExecuting 中),判断是否存在静态缓存文件,如果存在,则直接返回该静态文件并呈现,如果不存在,则继续执行 Action,该方法完成后(即 OnActionExecuted 中),将 Action 执行的结果生成静态文件缓存到本地,然后将该静态文件呈现给访客。当该文章被下一访客再次浏览时,按照调整后的流程,则直接将本地缓存静态文件呈现给访客,而不再需要查询数据库。流程如下:

MVC页面静态化缓存
MVC页面静态化缓存

流程清晰后,下面就是编码实现。要实现上述流程中的逻辑,除了可以在 Controller 中的 OnActionExecuting 和 OnActionExecuted 写入具体代码,还可以使用 MVC 的过滤器,过滤器有 ActionFilterAttribute 和 IActionFilter 两种方式。考虑当前的实际情况,三种方式中,个人认为选择 ActionFilterAttribute 比较好。原因是:

  • Controller 中的 OnActionExecuting 和 OnActionExecuted,对于该 Controller 中所有的 Action 均会执行,而我需要静态化的只是 Controller 中的一个 Action 页面,这样或多或少更耗资源。
  • IActionFilter 是全局的,是对于所有的 Controller 中所有的 Action 执行相应过滤,这会比前者更耗资源。

本博客最终选择了 ActionFilterAttribute 方式,考虑文章浏览量及评论等动态数据,我将静态化文件缓存时间有效期设置为 15 分钟,当文章静态化缓存有效期达到 15 分钟后,对该文章进行再次生成。关键代码如下:

public override void OnActionExecuting(ActionExecutingContext context) 
{ 
   var year = context.RouteData.Values["year"].ToString(); 
   var month = context.RouteData.Values["month"].ToString(); 
   var slug = context.RouteData.Values["slug"].ToString(); 
   var filePath = BlogRoutes.GetPostFileCachePath(year, month, slug); 
   //判断文件是否存在 
   if (File.Exists(filePath)) 
   { 
       //获取文件信息对象 
       var fileInfo = new FileInfo(filePath); 
       //结算时间间隔,如果小于等于15分钟,就直接输出 
       TimeSpan ts = DateTime.Now - fileInfo.LastWriteTime; 
       if (ts.TotalMinutes <= 15) 
       { 
           //如果存在,直接读取文件 
           using (var fs = File.Open(filePath, FileMode.Open)) 
           { 
               using (var sr = new StreamReader(fs, Encoding.UTF8)) 
               { 
                   //通过contentresult返回文件内容 
                   var contentresult = new ContentResult(); 
                   contentresult.Content = sr.ReadToEnd(); 
                   contentresult.ContentType = "text/html"; 
                   context.Result = contentresult; 
               } 
           } 
       } 
   } 
}

public override void OnActionExecuted(ActionExecutedContext context) 
{ 
   //获取结果 
   var actionResult = context.Result; 
   //判断结果是否是一个ViewResult 
   if (actionResult is ViewResult) 
   { 
       var viewResult = actionResult as ViewResult; 
       //下面的代码就是执行这个 ViewResult,并把结果的html内容放到一个StringBuiler对象中 
       var services = context.HttpContext.RequestServices; 
       var executor = services.GetRequiredService<IActionResultExecutor<ViewResult>>() as ViewResultExecutor; 
       var option = services.GetRequiredService<IOptions<MvcViewOptions>>(); 
       var result = executor.FindView(context, viewResult); 
       result.EnsureSuccessful(originalLocations: null); 
       var view = result.View; 
       var builder = new StringBuilder(); 
       using (var writer = new StringWriter(builder)) 
       { 
           var viewContext = new ViewContext( 
               context, 
               view, 
               viewResult.ViewData, 
               viewResult.TempData, 
               writer, 
               option.Value.HtmlHelperOptions); 
           view.RenderAsync(viewContext).GetAwaiter().GetResult(); 
           //这句一定要调用,否则内容就会是空的 
           writer.Flush(); 
       } 
       //按照规则生成静态文件名称 
       var year = context.RouteData.Values["year"].ToString(); 
       var month = context.RouteData.Values["month"].ToString(); 
       var slug = context.RouteData.Values["slug"].ToString(); 
       var fileDir = BlogRoutes.GetPostFileCacheDirectory(year, month); 
       if (!Directory.Exists(fileDir)) 
       { 
           Directory.CreateDirectory(fileDir); 
       } 
       var filePath = BlogRoutes.GetPostFileCachePath(year, month, slug); 
       using (FileStream fs = File.Open(filePath, FileMode.Create)) 
       { 
           using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8)) 
           { 
               sw.Write(builder.ToString()); 
           } 
       } 
       //输出当前的结果 
       var contentresult = new ContentResult(); 
       contentresult.Content = builder.ToString(); 
       contentresult.ContentType = "text/html"; 
       context.Result = contentresult; 
   } 
}

这里还有个特别注意的地方,对于有时效性的静态化缓存文件,缓存目录一定不要放在wwwroot中,否则当被访问的文章存在静态化缓存文件时,静态资源目录wwwroot中的文件将直接返回到浏览器,不再经过 MVC 的过滤器处理,过滤器中的缓存策略失效。为避免这个问题,可另行设置一个目录(比如 cache)用于存放静态化缓存文件。

3、注意事项

当 MVC 页面使用静态化缓存后,该页面的浏览计数、评论等动态功能,不能由 View  直接调用 Action,要变更为由 JS  异步调用 Action 接口。

《MVC 页面静态化缓存原理及实现》的相关评论

发表评论

必填项已用 * 标记,邮箱地址不会被公开。