分享
最近在公司成功落地了一個用ASP.NET Core 開發前台的CMS項目,雖然對於表層的開發是兼容MVC5的,但是作為愛好者當然要用盡量多的ASP.NET Core新功能了。
背景
在項目開發的過程中,為了滿足需求,還是有許多功能要自己“發明”,也就是已有技術的組(qi)合ji)運(yin)用(qiao)。本例先講講如果用中間件開發所有CMS都需要的服務端靜態緩存方法。
CMS系統的一大痛點是一個頁面要查詢的內容很多,所以常常為了減輕服務器壓力,都會使用到各種緩存技術。比如靜態文件的CDN緩存、客戶端緩存、還有就是服務端靜態化緩存。
服務端靜態化緩存在這里指的是把頁面事先生成出來保存為靜態文件,當用戶請求服務器時,可以直接把頁面輸出給用戶,而不再進行查詢數據庫之類的操作,已達到提高響應速度、減輕服務器壓力的效果。
原理
由於服務端靜態化緩存的實現核心是需要攔截請求並且直接返回HTML內容,在MVC5時代,我們可以用過濾器(Filter)來處理,類似如下代碼:
[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = false)]
public class StaticFileHandlerFilterAttribute : ActionFilterAttribute
{
/// <summary>
/// 過期時間,以小時為單位
/// </summary>
public int Expiration { get; set; }
public override void OnResultExecuted(ResultExecutedContext filterContext)
{
var actionResult = filterContext.Result;
if (actionResult is ViewResult)
{
var fileInfo = GetFileInfo(filterContext);
if (fileInfo.Exists && fileInfo.CreationTime.AddHours(Expiration <= 0 ? 1 : Expiration) > DateTime.Now)
return;
if (fileInfo.Exists)
{
fileInfo.Delete();
}
using (FileStream fs = new FileStream(fileInfo.FullName, FileMode.CreateNew, FileAccess.Write, FileShare.None))
{
using (StreamWriter sw = new StreamWriter(fs, Encoding.UTF8))
{
var viewResult = actionResult as ViewResult;
var viewContext = new ViewContext(filterContext.Controller.ControllerContext, viewResult.View, viewResult.ViewData, viewResult.TempData, sw);
viewResult.View.Render(viewContext, sw);
}
}
}
}
public override void OnActionExecuting(ActionExecutingContext filterContext)
{
var fileInfo = GetFileInfo(filterContext);
if (fileInfo.Exists)
{
using (FileStream fs = File.Open(fileInfo.FullName, FileMode.Open))
{
using (StreamReader sr = new StreamReader(fs, Encoding.UTF8))
{
ContentResult contentresult = new ContentResult();
contentresult.Content = sr.ReadToEnd();
contentresult.ContentType = "text/html";
filterContext.Result = contentresult;
}
}
}
}
}
而在ASP.NET Core中,我們也可以在Filte中實現,但是,更方便、更牛、更快的方式就是在中間件中實現了。
中間件運行在請求管道中,個人理解是可以看成是一個遞歸方法,只不過是調用不同的中間件,中間件中調用下一個中間件、知道最后一個執行完成,會沿路返回上一個中間件……說得可能不清楚,但是看代碼和調試一下就能明白了。下面用代碼層錯誤攔截的中間件作為一個中間件生命周期的示例:
app.Use(async (context, next) =>
{
try
{
//執行下一個中間件前執行的代碼
await next();
//上一個中間件執行后執行
}
catch (Exception ex)
{
context.Response.StatusCode = 500;
}
//執行完后會繼續執行上一個中間件next委托之后的語句
});
中間件是寫在Startup.cs文件的Configure
方法中,以app.Use()
方法的執行順序添加中間件,然后會從上往下運行,遇到next
委托就會跳入下一個中間件,不執行next
委托就會返回上一個中間件。
好了,關於中間件的具體介紹可以看官方文檔
實現服務端靜態化緩存中間件
下面介紹思路,實現靜態化緩存的核心是需要從請求響應中獲取內容,並且轉換為字符串保存到文件中。
這個功能點用以下代碼和注釋進行講解:
//獲取響應體的引用
var originalBody = context.Response.Body;
try
{
//因為響應體實例是只讀的,需要創建一個內存流實例,用來獲取響應流內容
using (var memStream = new MemoryStream())
{
//把內存流的引用設置到Response.Body,假裝是真的響應體接收數據
context.Response.Body = memStream;
//然后執行下一個中間件,等待有響應返回
await next();
//需要判斷響應是否正確,總不能把不正確的內容緩存起來吧
if (context.Response.StatusCode == (int)HttpStatusCode.OK)
{
//檢測文件存放目錄
if (!Directory.Exists(filePath))
Directory.CreateDirectory(filePath);
//把內存流的當前操作位置設為0,因為響應寫入的過程中位置會在末尾。
memStream.Position = 0;
//讀取內存流的內容,轉換為字符串
var responseBody = new StreamReader(memStream).ReadToEnd();
//把字符串寫入文件,這里還稍微壓縮了一下
await File.WriteAllTextAsync(fullPath, Regex.Replace(responseBody, "\\n+\\s+", string.Empty));
//在此把內存流的當前操作位置設為0
memStream.Position = 0;
//還需要把流復制到之前引用的響應體實例
await memStream.CopyToAsync(originalBody);
}
}
}
finally
{
//把響應體實例引用重新設置到響應體
context.Response.Body = originalBody;
}
好了,這樣響應的內容終於可以緩存起來了,下次請求直接把靜態文件輸出就是了。但是,好像還有問題:
- 如何對特定的請求的url做緩存;
- 如何設置過期時間。
解決這兩個問題的關鍵就在文件名上了,對於第一點很容易理解,直接把url作為文件名不就完了。沒錯,但是要注意特殊字符的問題,我這里就用md5處理一下了,這樣相同的請求url就會返回相同的靜態文件里的內容。
但是網站要更新,不能永久都是一樣的數據呀,這時就需要設置緩存時間了。這里又會產生兩個問題:
- 如何在文件上標識產生緩存時的時間;
- 如何判斷時間是否超時。
針對這兩個問題,我也考慮了很久,總是覺得直接在文件名上記錄緩存時間和判斷超時就好了,這也有兩種方法:
- 讀取文件名上的時間,與當前時間比對;
- 在超時時間內都能產生相同的文件名,判斷是否存在這個文件。
從代碼簡潔的考慮上,我選擇挑戰難度大一點的第二個方法。那么如何使程序在超時時間內都產生相同的文件名呢?其實只需要實現一個算法,在一段時間內產生的時間對象都是相同的時間,忽略中間的時間變化。這個是不是很像小學數學求近似數里的去尾法?或者我們經常用到的"/"
運算符,會吧小數點去掉。而在本例中,實現利用整除發忽略一段時間中的時間變化,生成同一個時間的算法和代碼如下:
var timeTicks = new DateTime(DateTime.Now.Ticks / 10000000 / expire * 10000000 * expire);
哈哈,只要一行代碼,整除去掉多余的時間再乘回來構造一段時間內不變的時間。其實運行一下可以發現,如果超時時間設置成60秒,那么單位秒上的值會變成00。設置為3600秒,那么分秒兩個單位都是0,因此會有一個弊端,真正緩存的時間很可能比設置的值短的,這個要看需求的容忍度啦!
就這樣,問題一個個被解決,下面給出完整的中間件代碼:
var hasExpire = int.TryParse(Configuration["html_cache_expire_time"], out var expire);
if (hasExpire && expire > 0)
{
//文件緩存
app.Use(async (context, next) =>
{
var url = context.Request.Path.ToString();
var th = new MD5CryptoServiceProvider();
var data = th.ComputeHash(Encoding.Unicode.GetBytes(url));
var key = Convert.ToBase64String(data, Base64FormattingOptions.None);
var path = HttpUtility.UrlEncode(key);
var timeTicks = new DateTime(DateTime.Now.Ticks / 10000000 / expire * 10000000 * expire);
const string filePath = "static/cache/";
var fileName = path + "." + timeTicks.ToString("yyyyMMddHHmmss") + ".html";
var fullPath = Path.Combine(filePath, fileName);
if (File.Exists(fullPath))
{
await context.Response.SendFileAsync(fullPath);
}
else
{
var originalBody = context.Response.Body;
try
{
using (var memStream = new MemoryStream())
{
context.Response.Body = memStream;
await next();
if (context.Response.StatusCode == (int)HttpStatusCode.OK)
{
if (!Directory.Exists(filePath))
Directory.CreateDirectory(filePath);
memStream.Position = 0;
var responseBody = new StreamReader(memStream).ReadToEnd();
await File.WriteAllTextAsync(fullPath, Regex.Replace(responseBody, "\\n+\\s+", string.Empty));
memStream.Position = 0;
await memStream.CopyToAsync(originalBody);
}
}
}
finally
{
context.Response.Body = originalBody;
}
}
});
}
反思
其實這個中間件還是有很多改進的地方,比如定義一套規則來給不同頁面設置不同的超時時間,這樣有的需要實時更新的內容就可以被區分開,而基本不變的內容就可以一直使用緩存。