领先技术

ASP.NET MVC 中的操作筛选器

Dino Esposito

目前许多软件架构师所面对的最大难题是如何设计并实现既能满足所有初始版本需求又能满足之后出现的所有其他需求的应用程序。自 1991 年 ISO/IEC 9126 文件初稿问世以来,可维护性已成为软件设计的基本属性之一。(该文件对软件质量进行了正式说明,将其细分为一组特征和子特征,其中之一就是可维护性。访问 iso.org 可以获得该文件的 PDF 版本。)

对于每个软件,能够满足客户当前和未来的需要无疑并不是一种新的需求。然而如今许多 Web 应用程序需要的是可维护性的细微的短期表现形式。很多时候客户并不需要增加新功能或采用不同的方法实现现有功能。他们只希望您对较小的功能项进行添加、替换、配置或删除。一个典型的例子是拥有大量用户的网站开展的特定广告活动。网站的总体行为不需要改变,但必须在现有操作基础上执行一些附加操作。此外,这些更改通常不是持久性的。这些更改必须在几周后删除再在几个月后重新加入进来,并进行不同的配置等。您需要具备通过组合较小的功能项编程实现任何所需功能的能力;您需要在不对源代码造成较大影响的情况下跟踪各种依赖关系;而且您需要向面向方面的软件设计迈进。这些都是控制反转 (IoC) 框架之所以被许多企业项目迅速采用的主要原因。

那本文的内容是什么呢?我们并不打算进行有关当今软件变化方式的无聊演讲,而是要深入探究 ASP.NET MVC 控制器的一项对于构建面向方面的 Web 解决方案有极大帮助的强大功能:ASP.NET MVC 操作筛选器。

那么,操作筛选器究竟是什么呢?

操作筛选器是这样一个属性,将该属性附加到控制器类或控制器方法时,可以提供将某种行为附加到所请求操作的声明性方法。通过编写操作筛选器,可以挂接某个操作方法的执行管道,并使其满足您的需要。采用这种方法,您还可以从控制器类中取出严格来讲并不属于该控制器的任何逻辑。这样做,可以使该特定行为成为可重用行为,更重要的是,使其成为可选行为。操作筛选器非常适用于实现影响控制器寿命的横切关注点。

ASP.NET MVC 附带了几个预定义的操作筛选器,如 HandleError、Authorize 和 OutputCache。HandleError 用于捕获对目标控制器类执行方法的过程中引发的异常。使用 HandleError 属性的编程接口,您可以指定要与某个给定异常类型关联的错误视图。

Authorize 属性用于阻止未经授权的用户执行某个方法。但它并不区分这些用户是尚未登录,还是已登录但缺少执行给定操作的足够权限。在此属性的配置中,您可以指定执行给定操作所需的任何角色。

OutputCache 属性用于按指定的持续时间和请求的参数列表对控制器方法的响应进行缓存。

一个操作筛选器类可以实现若干接口。图 1 显示了接口的完整列表。

图 1 操作筛选器的接口

筛选器接口 说明
IActionFilter 在执行控制器方法之前和之后调用此接口中的方法。
IAuthorizationFilter 在执行控制器方法之前调用此接口中的方法。
IExceptionFilter 在执行控制器方法的过程中引发异常时调用此接口中的方法。
IResultFilter 在处理操作结果之前和之后调用此接口中的方法。

通常情况下,您最关注的是 IActionFilter 和 IResultFilter。让我们进一步了解一下这两个接口。以下是 IActionFilter 的定义:

public interface IActionFilter
{
  void OnActionExecuted(ActionExecutedContext filterContext);
  void OnActionExecuting(ActionExecutingContext filterContext);
}

实现 OnActionExecuting 方法可以在执行控制器操作之前执行代码;实现 OnActionExecuted 可以对方法已确定的控制器状态进行后处理。 上下文对象可提供大量运行时信息。 以下是 ActionExecutingContext 的签名:

public class ActionExecutingContext : ControllerContext
{
  public ActionDescriptor ActionDescriptor { get; set; }
  public ActionResult Result { get; set; }
  public IDictionary<string, object> ActionParameters { get; set; }
}

尤其是操作描述符,它可以提供有关操作方法的信息,如方法的名称、控制器、参数、属性和其他筛选器。 ActionExecutedContext 的签名与前者只有一点区别,如下所示:

public class ActionExecutedContext : ControllerContext
{
  public ActionDescriptor ActionDescriptor { get; set; }
  public ActionResult Result { get; set; }
  public bool Canceled { get; set; }
  public Exception Exception { get; set; }
  public bool ExceptionHandled { get; set; }
}

除了对操作说明和操作结果的引用,此类还提供可能已发生的异常的相关信息,并提供两个需要引起注意的布尔型标志。 ExceptionHandled 标志表示您的操作筛选器获得了一次将某个已发生的异常标记为已处理的机会。 Canceled 标志与 ActionExecutingContext 类的 Result 属性有关。

请注意,ActionExecutingContext 类的 Result 属性的唯一用途是将生成任何操作响应的负担从控制器方法转移到一系列已注册的操作筛选器。 如果任何操作筛选器为 Result 属性指定了值,则永远不会调用控制器类上的目标方法。 这样可以绕过目标方法,将生成响应的负担完全转移到操作筛选器。 但是,如果某个控制器方法注册了多个操作筛选器,它们将共享操作结果。 如果一个筛选器设置了操作结果,链中的所有后续筛选器都将收到属性 Canceled 设置为 true 的 ActionExecuteContext 对象。 无论是在操作已执行步骤中以编程方式设置 Canceled,还是在操作执行中步骤中设置 Result 属性,都永远不会运行目标方法。

编写操作筛选器

如前所述,在编写自定义筛选器方面,大多数情况下您会对以下两种筛选器感兴趣,即对操作结果进行预处理和后处理的筛选器,以及在执行常规控制器方法之前和之后运行的筛选器。 操作筛选器类通常并不自行实现接口,而是由 ActionFilterAttribute 派生而来:

public abstract class ActionFilterAttribute : FilterAttribute, IActionFilter, IResultFilter
{
  public virtual void OnActionExecuted(ActionExecutedContext filterContext);
  public virtual void OnActionExecuting(ActionExecutingContext filterContext);
  public virtual void OnResultExecuted(ResultExecutedContext filterContext);
  public virtual void OnResultExecuting(ResultExecutingContext filterContext);
}

覆盖 OnActionExecuted 可以在方法的执行中添加一些自定义代码。 覆盖 OnActionExecuting 是执行目标方法的前提条件。 最后,覆盖 OnResultExecuting 和 OnResultExecuted 可以在控制方法响应生成的内部步骤周围放置代码。

图 2 显示了一个操作筛选器示例,以编程方式为应用该筛选器的方法的响应添加压缩。

图 2 用于压缩方法响应的操作筛选器示例

public class CompressAttribute : ActionFilterAttribute
{
  public override void OnActionExecuting(
    ActionExecutingContext filterContext)
  {
    // Analyze the list of acceptable encodings
    var preferred = GetPreferredEncoding(
      filterContext.HttpContext.Request);

    // Compress the response accordingly
    var response = filterContext.HttpContext.Response;
    response.AppendHeader("Content-encoding", preferred.ToString());

    if (preferredEncoding == CompressionScheme.Gzip)
    {
      response.Filter = new GZipStream(
        response.Filter, CompressionMode.Compress);
    }

    if (preferredEncoding == CompressionScheme.Deflate)
    {
      response.Filter = new DeflateStream(response.Filter, CompressionMode.Compress);
    }
    return;
  }

  static CompressionScheme GetPreferredEncoding(HttpRequestBase request)
  {
    var acceptableEncoding = request.Headers["Accept-Encoding"];
    acceptableEncoding = SortEncodings(acceptableEncoding);

    // Get the preferred encoding format 
    if (acceptableEncoding.Contains("gzip"))
      return CompressionScheme.Gzip;
    if (acceptableEncoding.Contains("deflate"))
      return CompressionScheme.Deflate;

    return CompressionScheme.Identity;
  }

  static String SortEncodings(string header)
  {
    // Omitted for brevity
  }
}

在 ASP.NET 中,压缩通常是通过注册一个 HTTP 模块实现的,该模块用于截取所有请求并压缩其响应。另外,也可以在 IIS 级别启用压缩。ASP.NET MVC 对上述两种方式提供很好的支持,而且还提供第三种选择:基于每个方法控制压缩。采用这种方法,您可以控制特定 URL 的压缩级别,而无需对 HTTP 模块进行编写、注册和维护。

图 2 所示,操作筛选器覆盖了 OnActionExecuting 方法。起初这听起来可能有点奇怪,因为您可能预计将压缩作为在返回某些响应之前您需要考虑的横切关注点。压缩通过固有的 HttpResponse 的 Filter 属性实现。由运行时环境产生的所有响应都通过 HttpResponse 对象返回客户端浏览器。随后,通过 Filter 属性安装在默认输出流上的任何自定义流都可以更改所发送的输出。因此,在 OnActionExecuting 方法执行过程中,您只需要在默认输出流的基础上设置附加流即可。

但对于 HTTP 压缩,最困难的部分是仔细权衡浏览器的各种首选项。浏览器通过 Accept-Encoding 标头发送其压缩首选项。该标头的内容指明浏览器只能处理某些特定编码(通常是 gzip 和 deflate)。为获得良好表现,操作筛选器必须尝试准确判断浏览器可以处理哪些编码。这是比较棘手的任务。RFC 2616 中详细说明了 Accept-Encoding 标头的作用(请参见 w3.org/Protocols/rfc2616/rfc2616-sec14.html)。简而言之,Accept-Encoding 标头的内容可以包含一个 q 参数,用于为可接受的值指定优先级。例如,假设以下所有字符串都是某一编码的可接受值,尽管 gzip 显然是其中的首选编码,但其实只有在第一个字符串中它才是首选项:

gzip, deflate
gzip;q=.7,deflate
gzip;q=.5,deflate;q=.5,identity

压缩筛选器应将这一点考虑在内,就像图 2 中的筛选器那样。 以上细节应当使您加强这样的意识,即编写操作筛选器时会对请求的处理造成干扰。 因此,您所做的任何操作都应与客户端浏览器的预期一致。

应用操作筛选器

如前所述,操作筛选器就是一个属性,它既可以应用于各个方法,也可以应用于整个父类。 它的设置方法如下:

[Compress]
public ActionResult List()
{
  // Some code here
  ...
}

如果属性类包含某些公共属性,您可以使用熟悉的属性表示法以声明方式为这些属性赋值:

[Compress(Level=1)]
public ActionResult List()
{
  ...
}

图 3 显示了 Firebug 所报告的压缩响应内容。

图 3 通过压缩属性获得的压缩响应

属性只是配置方法的一种静态方式。这意味着要应用进一步的更改,还需要第二个编译步骤。不过,以属性形式表示的操作筛选器提供了一个重要优势:它们将横切关注点保持在核心操作方法以外。

广泛认识操作筛选器

为度量操作筛选器的真实作用,可以考虑随着时间推移需要大量自定义工作的应用程序,以及为不同客户安装时需要进行改写的应用程序。

例如,假设一个网站有时会开展基于奖励分数的广告活动,奖励分数是注册用户通过在某个站点内执行标准操作(购买商品、回答问题、聊天、写博客等)获得的。作为开发人员,您可能需要在执行交易、发布评论或开始聊天的常规方法运行之后运行的某种代码。遗憾的是,这种代码属于往来代码,通常不包括在核心操作方法的原始实现中。使用操作筛选器,您可以为每种必要方案创建不同的组件,还可以实现其他功能,例如,安排某个操作筛选器用于增加奖励分数。接着,将奖励筛选器附加到需要 post 操作的所有方法;然后重新编译并运行。

[Reward(Points=100)]
public ActionResult Post(String post)
{
  // Core logic for posting to a blog
  ...
}

如前所述,属性是静态的,因此需要增加一个编译步骤。尽管并非所有情况都必须增加此步骤(例如在具有高灵活性功能的站点),但总比没有好得多。至少,您可以获得在不对现有功能造成较大影响的情况下快速更新 Web 解决方案的功能,这对于保持对回归错误的严格控制很有好处。

动态加载

本文展示了控制器操作方法环境中的操作筛选器。我展示的标准方法是将筛选器编写为用于以静态方式修饰操作方法的属性。但是,有一个基本的问题:您是否可以动态加载操作筛选器?

ASP.NET MVC 框架是一(大)段编写良好的代码,因此公开了大量接口和可覆盖方法,使用它们,您几乎可以对该框架的所有方面进行自定义。幸运的是,即将推出的 Model-View-Controller (MVC) 3 将继续增强这种趋势。根据公共发展路线图,团队为 MVC 3 制定的目标之一是实现所有级别的依赖关系注入。因此,前面有关动态加载的问题的答案就在于 MVC 框架的依赖关系注入能力。一种可能的成功策略是自定义操作调用程序,以便在执行方法之前获得对筛选器列表的访问。由于筛选器列表看起来就像一个普通的读/写集合对象,因此要对其进行动态填充应该不会太困难。不过这非常适合作为新栏目的素材。

Dino Esposito* 是 2010 年 Microsoft Press 出版的《Programming ASP.NET MVC》一书的作者,也是《Microsoft .NET:Architecting Applications for the Enterprise》一书(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。您可以访问他的博客,地址为 weblogs.asp.net/despos*。

*衷心感谢以下技术专家对本文的审阅:*Scott Hanselman