领先技术

ASP.NET MVC 的上下文相关进度条

Dino Esposito

下载代码示例

Dino Esposito
大多数计算机应用程序用户都希望在应用程序开始可能耗时很长的操作时,能够收到来自该应用程序的适当反馈。尽管在 Windows 应用程序中实现此模式没有您想象的那么简单,但也并非难事。通过 Web 实现相同模式会存在一些其他困难,这些困难主要与 Web 固有的无状态性质有关。

尤其对于 Web 开发人员来说,在操作开始时显示某些静态反馈是非常简单的。因此,本专栏重点讨论的内容不是如何显示“请等待”字样的简单文本字符串或动态图像。相反,本专栏解决了报告远程操作状态的问题,提供了真实反映指定会话操作状态的上下文相关反馈。在 Web 应用程序中,耗时较长的任务发生在服务器上,而且没有任何内置工具可以将状态信息推入客户端浏览器。要实现此目的,您需要结合使用客户端和服务器代码来构建自己的框架。jQuery 之类的库会很有帮助,但它们未提供内置解决方案 - 即使为大家广泛采用的 jQuery 插件也是如此。

本专栏是一个精简系列中的第一篇。在本专栏中,我将讨论最常见的 AJAX 模式(您将发现所有上下文相关进度框架都使用该模式作为基础),并构建为 ASP.NET MVC 应用程序定制的解决方案。如果您是 Web 窗体开发人员,您可能想看一下我在 2007 年夏天撰写的面向 Web 窗体和 AJAX 的几个专栏(请参见我的专栏列表,网址是 bit.ly/psNZAc)。

“进度指示器”模式

基于 AJAX 的进度报告存在的基本问题是,在用户等待服务器响应时需要提供有关操作状态的反馈。换句话说: 用户启动一个需要一段时间才能完成的 AJAX 操作;在这段时间内用户将收到有关进度的更新。在体系结构方面存在的问题是,用户将收不到部分响应;操作仅在所有服务器端工作都完成后才返回其响应。要将部分响应返回客户端,必须在 AJAX 客户端和服务器应用程序之间建立某种同步。“进度指示器”模式解决了这个问题。

该模式建议您构建专门的服务器操作,这些操作将有关其状态的信息写入已知位置。每次操作取得重大进展时,都将覆盖状态。同时,客户端打开另一个通道,并定期从相同的已知位置读取内容。这样,将立刻检测到所有更改并将其报告给客户端。更重要的是,反馈是上下文相关的,并且是实时的。反馈反应了服务器上的实际进度,而不是根据猜测或估计。

实现该模式需要您编写服务器操作,以便这些操作知道其角色。例如,假设您实现一个基于工作流的服务器操作。开始工作流的每个重要步骤之前,该操作都将调用一些代码,这些代码使用一些与任务相关的信息更新持久存储。持久存储可以是数据库表或一段共享内存。与任务相关的信息可以是指示工作完成百分比的数字,也可以是描述正在执行的任务的消息。

同时,您需要一个同时从持久存储读取文本并将文本返回客户端的基于 JavaScript 的服务。最后,客户端将使用某些 JavaScript 代码将文本合并到现有 UI。这可能会导致在 DIV 标记中显示某些简单文本,或者某些更复杂的内容(例如,基于 HTML 的进度条)。

ASP.NET MVC 中的进度指示器

让我们了解一下如何在 ASP.NET MVC 中实现进度指示器模式。服务器操作实际上是一种控制器操作方法。控制器可以是同步的,也可以是异步的。控制器中的异步功能仅对服务器应用程序的运行状况和响应有益;不会对用户必须等待获得响应的时间产生任何影响。进度指示器模式适用于任何类型的控制器。

若要调用然后监视服务器操作,需要使用一些 AJAX 代码。但是,AJAX 库不应限于发出请求并等待响应。库还应能够设置这样的计时器:该计时器定期触发对用于返回操作当前状态的某个端点的调用。由于上面的原因,jQuery 或本机 XMLHttpRequest 对象是必需的,但还不够。因此,我创建了一个简单 JavaScript 对象,用来隐藏受监视 AJAX 调用所需的大多数额外步骤。在 ASP.NET MVC 视图中,通过 JavaScript 对象调用操作。

负责操作的控制器方法将使用框架的服务器端部分,将当前状态存储在已知(并且是共享的)位置,例如 ASP.NET 缓存。最后,控制器必须公开常见端点以调用定时请求,从而实时读取状态。首先,我们来看一下操作中的整个框架,然后继续研究内部结构。

SimpleProgress Framework 简介

SimpleProgress Framework (SPF) 是专门针对这篇文章编写的,其中包括 JavaScript 文件和类库。类库定义一个关键类,即 ProgressManager 类,该类控制任务以及任何监视活动的执行。图 1 显示了一个使用该框架的控制器操作方法示例。请注意,此代码可能运行时间较长,实际应在异步控制器中运行,以避免阻塞 ASP.NET 线程太长时间。

图 1 使用 SimpleProgress Framework 的控制器操作方法

public String BookFlight(String from, String to)
{
  var taskId = GetTaskId();
  // Book first flight
  ProgressManager.SetCompleted(taskId,
    String.Format("Booking flight: {0}-{1} ...", from, to));
  Thread.Sleep(2000);
  // Book return flight
  ProgressManager.SetCompleted(taskId,
    String.Format("Booking flight: {0}-{1} ...", to, from));
  Thread.Sleep(1000);
  // Book return
  ProgressManager.SetCompleted(taskId,
    String.Format("Paying for the flight ..."));
  Thread.Sleep(2000);
  // Some return value
  return String.Format("Flight booked successfully");
}

正如您所看到的,操作基于三个主要步骤。 为简便起见,忽略了实际操作,而使用 Thread.Sleep 调用替代。 更重要的是,您可以看到三个对 SetCompleted 的调用,这些调用实际上将方法的当前状态写入共享位置。 位置的详细信息嵌入在 ProgressManager 类中。 图 2 显示了调用和监视控制器方法需要使用哪些项。

图 2 通过 JavaScript 调用和监视方法

<script type="text/javascript">
  $(document).ready(function () {
    $("#buttonStart").bind("click", buttonStartHandler);
  });
  function buttonStartHandler() {
    new SimpleProgress()
    .setInterval(600)
    .callback(
      function (status) { $("#progressbar2").text(status); },
      function (response) { $("#progressbar2").text(response); })
    .start("/task/bookflight?from=Rome&to=NewYork", "/task/progress");
  }
</script>

请注意,为了便于阅读,我将图 2 的 buttonStartHandler 放置在文档的就绪处理程序外部。 但是,这样做的话,我将定义一个新的全局可见函数,从而会增加一点 JavaScript 全局作用域的混乱程度。

首先设置一些要回调的参数(例如 URL)以获得当前状态,然后设置要调用的回调以更新进度条并在耗时较长的任务完成后刷新 UI。 最后,您将启动任务。

控制器类必须包含某些额外功能。 尤其是,必须公开要回调的方法。 此代码是相对标准的代码,我已将其硬编码到一个基类中,您可以从该基类继承控制器,如下所示:

public class TaskController : SimpleProgressController
{
  ...
public String BookFlight(String from, String to)
  {
    ...
}
}

图 3 显示了完整的 SimpleProgressController 类。

图 3 包含受监视操作的控制器的基类

public class SimpleProgressController : Controller
{
  protected readonly ProgressManager ProgressManager;
  public SimpleProgressController()
  {
    ProgressManager = new ProgressManager();
  }
  public String GetTaskId()
  {
    // Get the header with the task ID
    var id = Request.Headers[ProgressManager.HeaderNameTaskId];
    return id ??
String.Empty;
  }
  public String Progress()
  {
    var taskId = GetTaskId();
    return ProgressManager.GetStatus(taskId);
  }
}

类有两种方法。 GetTaskId 检索表示用于检索特定调用状态的关键字的唯一任务 ID。 正如您将在后面进一步看到的,任务 ID 由 JavaScript 框架生成,并使用自定义 HTTP 标头随每个请求一起发送。 您在 SimpleProgressController 类上发现的其他方法表示公共(常见)端点,JavaScript 框架将回调该端点以获得特定任务实例的状态。

在讨论一些实现细节之前,通过图 4 您可以对 SPF 能够得到的结果有个具体了解。

The Sample Application in Action
图 4 操作中的应用程序示例

ProgressManager 类

ProgressManager 类提供用于读取当前状态并将其写入共享存储区的接口。 该类基于以下接口:

public interface IProgressManager
{
  void SetCompleted(String taskId, Int32 percentage);
  void SetCompleted(String taskId, String step);
  String GetStatus(String taskId);
}

SetCompleted 方法将状态存储为百分比或简单字符串。 GetStatus 方法回读任何内容。 共享存储区通过 IProgressDataProvider 接口提取:

public interface IProgressDataProvider
{
  void Set(String taskId, String progress, Int32 durationInSeconds=300);
  String Get(String taskId);
}

SPF 的当前实现仅提供一个进度数据提供程序,该提供程序将其内容保存在 ASP.NET 缓存中。 用于标识每个请求的状态的关键字是任务 ID。 图 5 显示了一个进度数据提供程序示例。

图 5 基于 ASP.NET 缓存对象的进度数据提供程序

public class AspnetProgressProvider : IProgressDataProvider
{
  public void Set(String taskId, String progress, Int32 durationInSeconds=3)
  {
    HttpContext.Current.Cache.Insert(
      taskId,
      progress,
      null,
      DateTime.Now.AddSeconds(durationInSeconds),
      Cache.NoSlidingExpiration);
  }
  public String Get(String taskId)
  {
    var o = HttpContext.Current.Cache[taskId];
    if (o == null)
      return String.Empty;
    return (String) o;
  }
}

如前所述,受监视任务的每个请求都与唯一 ID 相关联。 该 ID 是由 JavaScript 框架生成的随机数字,通过请求 HTTP 标头从客户端传递到服务器。

JavaScript 框架

jQuery 库如此流行的一个原因是 AJAX API。 jQuery AJAX API 非常强大,而且功能丰富,其中包含一个简写函数列表,使得发出 AJAX 调用非常简单。 但是,本机 jQuery AJAX API 不支持进度监视。 因此,您需要一个使用 jQuery(或您可能喜欢的任何其他 JavaScript 框架)的包装 API,以便发出 AJAX 调用,同时确保为每个调用生成随机任务 ID 并激活监视服务。 图 6 显示了随附代码下载中的 SimpleProgress-Fx.js 文件的一段摘录,说明了启动远程调用背后的逻辑。

图 6 调用 SimpleProgress Framework 的脚本代码

var SimpleProgress = function() {
  ...
that.start = function (url, progressUrl) {
    that._taskId = that.createTaskId();
    that._progressUrl = progressUrl;
    // Place the AJAX call
    $.ajax({
      url: url,
      cache: false,
      headers: { 'X-SimpleProgress-TaskId': that._taskId },
      success: function (data) {
        if (that._taskCompletedCallback != null)
          that._taskCompletedCallback(data);
        that.end();
      }
    });
    // Start the callback (if any)
    if (that._userDefinedProgressCallback == null)
      return this;
    that._timerId = window.setTimeout(
      that._internalProgressCallback, that._interval);
  };
}

生成任务 ID 之后,函数将该 ID 作为自定义 HTTP 标头添加到 AJAX 调用方。 触发 AJAX 调用后,函数将立即设置一个定期调用回调的计时器。 回调读取当前状态并将结果传递到用户定义的函数以更新 UI。

我使用 jQuery 来执行 AJAX 调用;在这方面,为 AJAX 调用关闭浏览器缓存非常重要。 在 jQuery 中,默认情况下缓存处于打开状态,对于某些数据类型(例如脚本和 JSON With Padding (JSONP)),缓存会自动关闭。

并非一项简单的任务

监视 Web 应用程序中正在进行的操作不是一项简单的任务。 基于轮询的解决方案很常见,但并不是必然的。 在 ASP.NET 中实现持久连接的一个有趣的 GitHub 项目是 SignalR (github.com/signalr)。 使用 SignalR,您可以解决此处讨论的相同问题而不必轮询更改。

在本专栏中,我讨论了一个尝试简化上下文相关进度条的实现的示例框架(客户端和服务器)。 虽然已针对 ASP.NET MVC 优化了代码,但是基础模式完全通用,也可以在 ASP.NET Web 窗体应用程序中使用。 如果您下载并使用源代码进行试验,欢迎您随时分享您的感想和反馈。

Dino Esposito是《Programming Microsoft ASP.NET MVC3》(Microsoft Press,2011 年)的作者,同时也是《Microsoft .NET: Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。 Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。 请关注他的 Twitter:twitter.com/despos

衷心感谢以下技术专家对本专栏的审阅: Damian EdwardsPhil HaackScott Hunter