しばやん雑記

Azure とメイドさんが大好きなフリーランスのプログラマーのブログ

ASP.NET MVC 向けに NoOutputCache 属性を作ってみた

ASP.NET MVC のデフォルトで生成される FilterConfig.cs で OutputCache 属性を使うと全てのコントローラで有効になります。

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    filters.Add(new OutputCacheAttribute { Location = OutputCacheLocation.None });
}

一括で指定できて便利なのですが、このままだと RenderAction などを使って子アクションをレンダリングするときにエラーとなってしまいます。

@* 子アクションでは OutputCache の設定によってエラーになる *@
@Html.Action("ChildAction")

エラーが出る理由ですが、子アクションの OutputCache ではキャッシュなしという設定が許されていないことにあります。しかし、OutputCache 属性を付けていない場合はキャッシュが無効になるはずなので、この挙動は若干イレギュラーだと考えます。*1

しかし、OutputCache 属性ではキャッシュしないという設定も可能なので、キャッシュしない設定の場合であればエラーになって欲しくないわけです。解決策としては Authorize 属性に対する AllowAnonymous 属性のように OutputCache 属性に対して NoOutputCache 属性を追加することが考えられますね。

// GlobalFilters の設定を打ち消したい
[NoOutputCache]
[ChildActionOnly]
public ActionResult ChildAction()
{
    return PartialView();
}

ここだけはキャッシュしたくないというケースは割とあると思うので、何でグローバルフィルタが実装された時点で追加されなかったのかが謎です。

もしくは OutputCache 属性内部で行っている検証を変えてしまうことですが、残念ながら ValidateChildActionConfiguration メソッドはオーバーライドが出来ないので、別名で属性を作ることにしました。

public class OutputCacheExAttribute : OutputCacheAttribute
{
    public override void OnActionExecuting(ActionExecutingContext filterContext)
    {
        var skipCaching = filterContext.ActionDescriptor.IsDefined(typeof(NoOutputCacheAttribute), inherit: true)
                                    || filterContext.ActionDescriptor.ControllerDescriptor.IsDefined(typeof(NoOutputCacheAttribute), inherit: true);

        if (skipCaching)
        {
            return;
        }

        base.OnActionExecuting(filterContext);
    }
}

本体の OutputCacheEx 属性です。やってることは Authorize と AllowAnonymous と同じで、属性が付いていたらキャッシュ処理をスキップするようにしています。

[AttributeUsage(AttributeTargets.Class | AttributeTargets.Method, AllowMultiple = false, Inherited = true)]
public sealed class NoOutputCacheAttribute : Attribute
{
}

NoOutputCache 属性は単なる空っぽの属性です。単なるマークとして動作すれば問題ありません。以下は実際にテストしたコードです。単純に子アクションを使うだけですが、グローバルフィルタで OutputCacheEx 属性を使うようにしています。

public static void RegisterGlobalFilters(GlobalFilterCollection filters)
{
    filters.Add(new HandleErrorAttribute());
    filters.Add(new OutputCacheExAttribute
    {
        Location = OutputCacheLocation.None
    });
}

グローバルフィルタでは OutputCacheLocation.None を指定しています。

public class HomeController : Controller
{
    public ActionResult Index()
    {
        return View();
    }

    [NoOutputCache]
    [ChildActionOnly]
    public ActionResult ChildAction()
    {
        return PartialView();
    }
}

コントローラも単純に子アクションを作っただけですが、作成した NoOutputCache 属性を追加しています。

@{
    ViewBag.Title = "Index";
}

<h2>Index</h2>

@Html.Action("ChildAction")

まずは NoOutputCache 属性をコメントアウトして実行してみました。当然ながら Duration が 0 になっているので以下のようなエラーになります。

f:id:shiba-yan:20121203221234p:plain

次に NoOutputCache 属性のコメントを外して実行してみます。

f:id:shiba-yan:20121203221347p:plain

今回はエラーも表示されずに正しく表示されました。本家に取り込まれることを祈りながら、裏で暗躍することにします。

*1:実際は親ページのキャッシュ設定との兼ね合いなのかもしれないけど