[創意料理] 在 ASP.NET Core 沒有了 OutputCache,那就自己弄一個 ResultCache 加減用。

ASP.NET Core 的 ResponseCache 觸發伺服器端快取的條件尤為嚴格,限制很多,這也是它跟過去我們所熟悉的 OutputCache 特別不一樣的地方,所以 ResponseCache 我們也沒辦法就這樣直接當做 OutputCache 來使用,缺的部分我們只好自己來補足。

我們會想要使用伺服器端快取,最主要當然是希望特定期間之內避免頻繁地執行輸出相同結果的程式,以節省伺服器的運算資源,在 ASP.NET Core 我們要快取的對象就是 Action 輸出的結果,所以我就針對 Action 輸出的結果實作快取用的 ActionFilter,我將它命名為 ResultCacheAttribute,懶得看文章內容的朋友,完整的程式碼我放在最下面的參考資料中。

起手式

先將 ResultCacheAttribute 建立起來,繼承 Attribute,實作 IAsyncActionFilterIOrderedFilter

public class ResultCacheAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
{
    public int Order { get; }

    public int Duration { get; set; }

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        // TODO
        throw new NotImplementedException();
    }
}

產生 CacheKey

既然是自己寫的快取機制,那麼 CacheKey 的產生邏輯就完全可以由自己來控制了,我這邊用的變數有 AreaNameControllerNameActionNameActionArgumentsRequestHeaders,其中 AreaName、ControllerName、ActionName 這三個變數是必要的,其他則是可選的。

public class ResultCacheAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
{
    //...

    public string VaryByArgument { get; set; }

    public string VaryByHeader { get; set; }

    //...

    private string GenerateCacheKey(ActionExecutingContext context)
    {
        void AppendByArguments(string[] varyByArguments, StringBuilder stringBuilder)
        {
            if (varyByArguments == null || varyByArguments.Length == 0) return;

            stringBuilder.Append("?");

            foreach (var argumentName in varyByArguments)
            {
                var value = "[Default]";

                if (context.ActionArguments.ContainsKey(argumentName))
                {
                    value = JsonSerializer.Serialize(context.ActionArguments[argumentName]);
                }

                stringBuilder.AppendFormat("{0}={1}&", argumentName, value);
            }

            stringBuilder.Remove(stringBuilder.Length - 1, 1);
        }

        void AppendByHeaders(string[] varyByHeaders, StringBuilder stringBuilder)
        {
            if (varyByHeaders == null || varyByHeaders.Length == 0) return;

            stringBuilder.Append("\r\n");

            foreach (var headerName in varyByHeaders)
            {
                string value = context.HttpContext.Request.Headers[headerName];

                if (string.IsNullOrEmpty(value)) value = "[Empty]";

                stringBuilder.AppendFormat("{0}: {1}\r\n", headerName, value).Append(headerName);
            }

            stringBuilder.Remove(stringBuilder.Length - 2, 2);
        }

        var keyBuilder = new StringBuilder();

        keyBuilder.Append(context.RouteData.Values["area"] ?? "[Empty]");
        keyBuilder.Append("/");
        keyBuilder.Append(context.RouteData.Values["controller"]);
        keyBuilder.Append("/");
        keyBuilder.Append(context.RouteData.Values["action"]);

        AppendByArguments(this.VaryByArgument?.Split(',', StringSplitOptions.RemoveEmptyEntries), keyBuilder);

        AppendByHeaders(this.VaryByHeader?.Split(',', StringSplitOptions.RemoveEmptyEntries), keyBuilder);

        return keyBuilder.ToString();
    }
}

輸出並快取 Result

如果我們以為只是將 ActionResult 快取起來就好,那就真的太單純了,至少 ViewResult 需要經過 Rendering 才會輸出成 HTML,JsonResult 需要經過 Serialization 才會輸出成 JSON,所以我們要快取的對象是 HTML、JSON,不是 ViewResult 及 JsonResult 物件本身,因此我們必須自己寫 Result 的輸出。

開源的好處在此展現,我們可以從 ASP.NET Core 的原始碼中挖出 ViewExecutor(ViewResult 的 Executor) 跟 SystemTextJsonResultExecutor(JsonResult 的 Executor)執行輸出結果的程式碼,參考著做,我們就可以寫出個別 Result 的輸出。

最終我選擇將輸出的 HTML 及 JSON 建立成 ContentResult 並指定 ContentType 後回傳,因為 ContentResult 的 Content 是直接寫到 Response 的 Body,中間沒有其他的輸出程序,再來就是當命中快取的時候,快取的結果是 IActionResult 會比較好處理,而其他類型的 ActionResult 我就暫時維持原樣。

public class ResultCacheAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
{
    //...

    private void OutputAndCacheResult(ActionExecutedContext context, string cacheKey)
    {
        if (context.Result is ViewResult viewResult)
        {
            var executor = (ViewResultExecutor)context.HttpContext.RequestServices.GetService<IActionResultExecutor<ViewResult>>();

            var viewEngineResult = executor.FindView(context, viewResult);

            var view = viewEngineResult.View;

            using (view as IDisposable)
            {
                var viewOptions = context.HttpContext.RequestServices.GetService<IOptions<MvcViewOptions>>();

                var writer = new StringWriter();

                var viewContext = new ViewContext(
                    context,
                    view,
                    viewResult.ViewData,
                    viewResult.TempData,
                    writer,
                    viewOptions.Value.HtmlHelperOptions);

                view.RenderAsync(viewContext).GetAwaiter().GetResult();

                context.Result = new ContentResult
                                 {
                                     Content = writer.ToString(),
                                     ContentType = "text/html; charset=utf-8",
                                     StatusCode = viewResult.StatusCode
                                 };
            }
        }
        else if (context.Result is JsonResult jsonResult)
        {
            JsonSerializerOptions jsonSerializerOptions;

            if (jsonResult.SerializerSettings == null)
            {
                var jsonOptions = context.HttpContext.RequestServices.GetService<IOptions<JsonOptions>>();

                jsonSerializerOptions = jsonOptions.Value.JsonSerializerOptions;
            }
            else
            {
                jsonSerializerOptions = jsonResult.SerializerSettings as JsonSerializerOptions;
            }

            var type = jsonResult.Value?.GetType() ?? typeof(object);

            context.Result = new ContentResult
                             {
                                 Content = JsonSerializer.Serialize(jsonResult.Value, type, jsonSerializerOptions),
                                 ContentType = "application/json; charset=utf-8",
                                 StatusCode = jsonResult.StatusCode
                             };
        }

        var memoryCache = context.HttpContext.RequestServices.GetService<IMemoryCache>();

        if (this.Duration > 0)
        {
            memoryCache.Set(cacheKey, context.Result, TimeSpan.FromSeconds(this.Duration));
        }
        else
        {
            memoryCache.Set(cacheKey, context.Result);
        }
    }
}

補完 OnActionExecutionAsync()

最後,我們補完實作 ActionFilter 的邏輯並加上鎖的機制就完成了。

public class ResultCacheAttribute : Attribute, IAsyncActionFilter, IOrderedFilter
{
    private static readonly ConcurrentDictionary<string, SemaphoreSlim> Lockers = new ConcurrentDictionary<string, SemaphoreSlim>();

    //...

    public async Task OnActionExecutionAsync(ActionExecutingContext context, ActionExecutionDelegate next)
    {
        var memoryCache = context.HttpContext.RequestServices.GetService<IMemoryCache>();

        var cacheKey = this.GenerateCacheKey(context);

        if (memoryCache.TryGetValue(cacheKey, out IActionResult result))
        {
            context.Result = result;
        }
        else
        {
            var locker = Lockers.GetOrAdd(cacheKey, key => new SemaphoreSlim(1, 1));

            await locker.WaitAsync();

            if (memoryCache.TryGetValue(cacheKey, out result))
            {
                locker.Release();

                context.Result = result;
            }
            else
            {
                var executedContext = await next();

                if (executedContext.Exception == null && executedContext.Result != null && !executedContext.Canceled)
                {
                    try
                    {
                        this.OutputAndCacheResult(executedContext, cacheKey);
                    }
                    catch
                    {
                        // ignored
                    }
                }

                locker.Release();
            }
        }
    }

    //...
}

測試一下

使用方式就在 Action 加上 [ResultCache],指定或不指定 Duration 都可以。

以 ASP.NET Core MVC 範本專案為例,首頁第一次載入大概需要 21 ms,之後由 ResultCache 輸出只需要大概 4ms,節省了大約 80% 的時間。

參考資料

 < Source Code >

相關資源

C# 指南
ASP.NET 教學
ASP.NET MVC 指引
Azure SQL Database 教學
SQL Server 教學
Xamarin.Forms 教學