数据点

为具有 10 年历史的 ASP.NET Web 窗体应用程序注入新的活力

Julie Lerman

下载代码示例

Julie Lerman旧代码:有它不行,没有它也不行。应用程序越好用,保留得就越长久。我的第一个 ASP.NET Web 窗体应用程序已使用了 10 年多一点的时间。它最后被别人编写的一个平板电脑应用程序所取代。不过,客户同时要求我向该程序添加一个新功能,以便该公司可以立即开始收集新版本将要收集的某些数据。

这可不像使用一两个简单字段这么容易。现有应用程序是一个用于跟踪员工工作小时数的复杂时间表,其中包含由一个任务列表定义的一组动态复选框。客户端在一个单独应用程序中维护该列表。在该 Web 应用程序中,用户可以选中该列表上的任意多个项以指定他所执行的任务。该列表的项数略多于 100,并且随着时间的推移,项数会缓慢增多。

现在,客户端需要跟踪每个选中任务所花费的小时数。该应用程序仅会再使用几个月时间,为其投入太多没有什么意义,但对于这种改变,我有两个重要目标:

  1. 让用户真正方便地输入小时数,即不必单击任何其他按钮或引起回发。
  2. 添加功能时将对代码的影响减少到最低。虽然使用更现代的工具来彻底改造具有 10 年历史的应用程序很有吸引力,但我想在添加新逻辑时,不会影响现有的(有效)代码,包括数据访问和数据库。

我花费了一定时间来考虑各种方法。第 2 个目标就是要保持 CheckBoxList 不变。我决定将小时数放置在一个单独网格内,但第 1 个目标是不使用 ASP.NET GridView 控件(谢天谢地)。我决定使用一个表和 JavaScript 来检索并持久保存任务小时数据,并为此而探索了几种方法。不能使用用于调用代码隐藏文件的 AJAX PageMethods,因为我的页面是使用另一个页面的 Server.Transfer 来检索的。在我必须执行某种需要将客户端和服务器端对象进行混合的复杂数据验证之前(这种验证太困难,以致于在 JavaScript 中无法完成),一直使用内联调用(如 <%MyCodeBehindMethod()%>)。由于需要将该内联调用触及的每个方面都变为静态,情况同样开始变得糟糕。这样,我会无法取得“最低侵入性”。

最后我意识到,我的目标实际上是应该将新逻辑完全隔离出来,并将其放到一个易于从 JavaScript 访问的 WebAPI 中。这会有助于划出新逻辑与旧逻辑之间清晰的分界。

我仍有一些难题要解决。我以前在 Web API 方面的经验是创建一个新 MVC 项目。我开始也是这样做的,但是,从现有应用程序调用 Web API 中的调用方法会导致跨来源资源共享 (CORS) 问题,我找不到可以避免 CORS 的模式。最后,我发现了一篇由 Mike Wasson 撰写的有关在 Web 窗体项目中直接添加 Web API 的文章 (bit.ly/1jNZKzI),我着手尝试,不过还有要跨越很多障碍。我不会让您再去体验我在通往成功的路上进行艰难跋涉时遭受的痛苦。相反,我会为您逐步介绍我最终取得的解决方案。

我不打算通过客户端的实际应用程序来演示如何在旧应用程序中添加新功能,而是使用一个例子来跟踪用户首选项,并提供了有关这些用户首选项的作用的注释:非常有趣的材料。这里,我并没有使用含有 100 多项的列表;该窗体仅显示了一个短 CheckBoxList 以及额外的数据验证工作。我会跟踪用户注释,而不是跟踪小时数。

一旦提交到 Web API 之后,添加验证方法就没什么困难了。因为我要创建一个新例子,我使用了 Microsoft .NET Framework 4.5 和 Entity Framework 6 (EF),而没有使用 .NET Framework 2.0 和原始 ADO.NET。图 1 显示了该示例应用程序的起点:一个 ASP.NET Web 窗体,其中包含用户名和可能活动的一个可编辑 CheckBoxList。在该页面上,我将添加用于跟踪每个选中项的注释的功能,如以下网格所示。

Starting Point: A Simple ASP.NET Web Form with the Planned Addition
图 1 起点:一个含有计划添加内容的简单 ASP.NET Web 窗体

步骤 1:添加新类

我需要一个用于存储新注释的类。就我的数据而言,我发现效果最好的方式是,使用由 UserId 和 FunStuffId 组成的键来确定该注释所对应的用户和行为:

namespace DomainTypes{
  public class FunStuffComment{
    [Key, Column(Order = 0)]
    public int UserId { get; set; }
    [Key, Column(Order = 1)]
    public int FunStuffId { get; set; }
    public string FunStuffName { get; set; }
    public string Comment { get; set; }
  }
}

由于我计划使用 EF 来持久保留数据,我需要指定构成组合键的属性。在 EF 中,映射组合键的秘诀是与 Key 属性一起添加 Column Order 属性。我要说说 FunStuffName 属性。尽管我可以交叉引用 FunStuff 表来获取特定条目的名称,但我发现更简便的方法是直接在该类中呈现 FunStuffName。这可能看起来有些多余,但别忘了,我的目标是避免与现有逻辑混淆。

步骤 2:向基于 Web 窗体的项目添加 Web API

幸好阅读了 Wasson 的文章,我了解到可以直接在现有项目中添加 Web API 控制器。只需在解决方案资源管理器中右键单击该项目,就会看到“添加”上下文菜单中的“Web API 控制器类”选项。所创建的控制器旨在与 MVC 结合使用,因此,首要任务是删除所有方法,并添加用于检索特定用户的现有注释的 Comment 方法。因为我要使用 Breeze JavaScript 库并已使用 NuGet 将其安装到我的项目中,我会将 Breeze 命名约定用于我的 Web API 控制器类,如图 2 所示。我尚未将 Comments 连接到我的数据访问中,因此,我会从返回一些内存中数据开始。

图 2 BreezeController Web API

namespace April2014SampleWebForms{
[BreezeController] 
public class BreezeController: ApiController  {
  [HttpGet]
  public IQueryable<FunStuffComment> Comments(int userId = 0)
    if (userId == 0){ // New user
      return new List<FunStuffComment>().AsQueryable();
    }
      return new List<FunStuffComment>{
        new FunStuffComment{FunStuffName = "Bike Ride",
          Comment = "Can't wait for spring!",FunStuffId = 1,UserId = 1},
        new FunStuffComment{FunStuffName = "Play in Snow",
          Comment = "Will we ever get snow?",FunStuffId = 2,UserId = 1},
        new FunStuffComment{FunStuffName = "Ski",
          Comment = "Also depends on that snow",FunStuffId = 3,UserId = 1}
      }.AsQueryable();    }
  }
}

Wasson 的文章可指导我们向 global.asax 文件添加路由。但是,通过 NuGet 添加 Breeze 会创建含有已定义的适当路由的 .config 文件。因此,我将在图 2 中所示的控制器中使用 Breeze 推荐命名。

现在,我可以从 FunStuffForm 的客户端,方便地调用 Comments 方法。我想在浏览器中测试我的 Web API 以确保一切正常运行,为此,可以运行该应用程序,然后浏览至 http://localhost:1378/breeze/Breeze/Comments?UserId=1。请务必使用该应用程序正在使用的正确主机端口。

步骤 3:添加客户端数据绑定

不过这些还不够。我需要用该数据执行一些操作,为此,我回顾了我以前撰写的有关 Knockout.js (msdn.microsoft.com/magazine/jj133816,关于 JavaScript 数据绑定)和 Breeze(msdn.microsoft.com/magazine/jj863129,它使数据绑定变得更加简单)的专栏文章。Breeze 会自动将我的 Web API 的结果转换为 Knockout(以及其他 API)可直接使用的可绑定对象,从而无需创建其他视图模型和映射逻辑。添加数据绑定是最密集的转换部分,会因我仍十分有限的 JavaScript 和 jQuery 技能而变得更糟糕。但我坚持了下来,并在此过程中,还成为擅长在 Chrome 中进行 JavaScript 调试的半个专家。大部分新代码位于一个单独的 JavaScript 文件中,此文件绑定到我的原始 Web 窗体页面 FunStuffForm.aspx。

当我快写完本文时,有人指出 Knockout 现在有点过时了(“那是 2012 年的情况了,”他说到),并且,许多 JavaScript 开发者已改用更简单和内容更丰富的框架,如 AngularJS 或 DurandalJS。这是一个我要改日再研究的一个课题。我确信,我的具有 10 年历史的应用程序不会逊于一个才有两年历史的工具。不过,我肯定会在将来的专栏文章中来讨论一下这些工具。

在我的 Web 窗体中,我定义了一个名为 comments 的表,该表的各列由我将用 Knockout 绑定到该表的数据字段进行填充(请参见图 3)。我还将绑定后面要用到的 UserId 和 FunStuffId 字段,但会将它们隐藏。

图 3 是为了使用 Knockout 进行绑定而设置的 HTML 表

<table id="comments">
  <thead>
    <tr>
      <th></th>
      <th></th>
      <th>Fun Stuff</th>
      <th>Comment</th>
    </tr>
  </thead>
  <tbody data-bind="foreach: comments">
    <tr>
      <td style="visibility: hidden" data-bind="text: UserId"></td>
      <td style="visibility: hidden" data-bind="text: FunStuffId"></td>
      <td data-bind="text: FunStuffName"></td>
      <td><input data-bind="value: Comment" /></td>
    </tr>
  </tbody>
</table>

名为 FunStuff.js 的 JavaScript 文件中的第一段逻辑是一个 ready 函数,一旦呈现的文档就绪,此函数就会运行。在该函数中,我定义了 viewModel 类型(如图 4 所示),我将使用其 comments 属性来绑定到我的 Web 窗体中的 comments 表。

图 4 FunStuff.js 的开始部分

var viewModel;
$(function() {
  viewModel = {
    comments: ko.observableArray(),
    addRange: addRange,
    add: add,
    remove: remove,
    exists: exists,
    errorMessage: ko.observable(""),
  };
  var serviceName = 'breeze/Comments';
  var vm = viewModel;
  var manager = new breeze.EntityManager(serviceName);
  getComments();
  ko.applyBindings(viewModel, 
    document.getElementById('comments'));
 // Other functions follow
});

ready 函数还指定特定的启动代码:

  • serviceName 定义 Web API uri
  • vm 是 viewModel 的简短别名
  • manager 为 Web API 设置 Breeze EntityManager
  • getComments 是一个用于调用 API 并返回数据的方法
  • ko.applyBinding 是一个用于将 viewModel 绑定到 comments 表的方法

请注意,我在该函数的外部声明了 viewModel。我随后需要从 .aspx 页面中的脚本对其进行访问,因此,必须确定其作用范围以获得外部可见性。

viewModel 中的最重要属性是名为 comments 的 observableArray。Knockout 将跟踪该数组中的内容,并在数组发生变化时更新绑定表。其他属性不过是公开我在该启动代码下面通过 viewModel 定义的其他函数。

让我们首先从图 5 中所示的 getComments 函数开始。

图 5 使用 Breeze 通过 Web API 来查询数据

function getComments () {
  var query = breeze.EntityQuery.from("Comments")
    .withParameters({ UserId: document.getElementById('hiddenId').value });
  return manager.executeQuery(query)
    .then(saveSucceeded).fail(failed);
}
function saveSucceeded (data) {
  var count = data.results.length;
  log("Retrieved Comments: " + count);
  if (!count) {
    log("No Comments");
    return;
  }
  vm.comments(data.results);
}
function failed(error) {
  vm.errorMessage(error);
}

在 getComments 函数中,我使用 Breeze 来执行我的 Web API 方法 Comments,从网页上的一个隐藏字段传入当前 UserId。请记住,我已在 manager 变量中定义 Breeze 的 uri 和 Comments。如果查询成功,则 saveSucceeded 函数会运行,在屏幕上记录某些信息并将查询结果推送到 viewModel 的 comments 属性中。在我的笔记本计算机上,我可在异步任务完成之前看到的是一个空表,随后,该表会瞬间填充结果(请参见图 6)。请记住,这都是在客户端发生的。不会发生回发,因此,用户的体验会十分流畅。

Comments Retrieved from Web API and Bound with the Help of Knockout.js
图 6 从 Web API 检索、并借助于 Knockout.js 进行绑定的注释

步骤 4:响应选中和未选中的框

下一个难题是让该列表响应用户从 Fun Stuff 列表进行的选择。选中一项后,需要将其添加到 viewModel.comments 数组或从该数组删除,绑定表依赖于用户是添加还是删除选中标记。用于更新数组的逻辑位于 JavaScript 文件中,但用于提醒模型有关操作的逻辑位于 .aspx 中的一个脚本中。可以将函数(例如,复选框 onclick)绑定至 Knockout,但我没有这么做。

在 .aspx 窗体的标记中,我向页标题部分添加了以下方法:

$("#checkBoxes").click(function(event) {
  var id = $(event.target)[0].value;
  if (event.target.nodeName == "INPUT") {
    var name = $(event.target)[0].parentElement.textContent;
    // alert('check!' + 'id:' + id + ' text:' + name);
    viewModel.updateCommentsList(id, name);  }
});

这是可能的,因为我有一个名为 checkBoxes 的 div,它包围所有动态生成的 CheckBox 控件。我使用 jQuery 来获取将会触发事件的 CheckBox 的值以及相关标签中的名称。然后,我将这些内容传递给我的 viewModel 的 updateCommentsList 方法。此警报仅用于测试我是否正确连接了该函数。

现在,让我们看一看我的 JavaScript 文件中的 updateCommentsList 和相关函数。用户可能会选中或取消选中某一项,这样就需要添加或删除该项。在我的 exists 方法中,我只是让 Knockout utils 函数帮助我检查该项是否已在注释数组中,而不用关心复选框的状态。如果该项已在数组中,我需要将其删除。由于 Breeze 会跟踪更改,我会将其从 observableArray 删除,但会通知 Breeze 更改跟踪器视为已被删除。这样做有两个作用。首先,当我保存时,Breeze 会向数据库发送 DELETE 命令(在本例中,通过 EF)。但是,如果将该项再次选中并需要将其添加回 observableArray 中,则 Breeze 只是在更改跟踪器中将其恢复。否则,由于我使用组合键标识注释,让新的项和已删除的项具有相同的标识会产生冲突。请注意,虽然 Knockout 会响应用于添加项的 push 方法,但我必须通知它数组已发生改变,以使其能响应删除项的操作。同样,由于数据绑定,表会随着复选框的选中和取消选中而动态发生变化。

请注意,当我创建新项时,我会从窗体的标记中的隐藏字段获取用户的 userId。在窗体 Page_Load 的原始版本中,我在获取该用户后设置此值。通过将 UserId 和 FunStuffId 绑定到注释中的每一项,我可以将所有必要数据连同注释一起进行存储,以将它们与正确的用户和项进行关联。

在连接了 oncheck 并在响应中修改了注释 observableArray 后,我可以看到,切换“Watch Doctor Who”复选框会使“Watch Doctor Who”行根据复选框的状态显示或消失。

步骤 5:保存注释

我的页面已有一个用于保存已选中的复选框的 Save 功能,但现在,我想使用另一个 Web API 同时保存注释。现有的 save 方法会在该页面为响应 SaveThatStuff 按钮单击操作而回发时执行。其逻辑位于页面代码隐藏文件中。我实际上可以在服务器端调用之前进行一次客户端调用,以使用相同的按钮单击操作保存注释。我知道,可以使用一个旧的 onClientClick 属性并通过 Web 窗体进行这种保存,但在我正在修改的时间表应用程序中,我还必须执行一次验证以确定任务小时和时间表是否就绪可供保存。如果验证失败,我不但要放弃 Web API 保存,而且还要防止执行回发和服务器端 save 方法。我使用 onClientClick 完成这一工作时感到很困难,这促使我使用 jQuery 再次进行改进。用同样的方式,我可以响应客户端中的 CheckBox 单击操作,我可以获得对所单击的 btnSave 的客户端响应。该响应将发生在回发和服务器端响应之前。因此,我可以从一次按钮单击操作获得两个事件,如下所示: 

$("#btnSave").click(function(event) {
  validationResult = viewModel.validate();
  if (validationResult == false) {
    alert("validation failed");
    event.preventDefault();
  } else {
    viewModel.save();
  }
});

我在始终返回 true 的示例中具有一个 stub 验证方法,不过,我进行了测试,确定在它返回 false 时一切也是正常的。在这种情况下,我使用 JavaScript event.preventDefault 来停止进一步处理。我不仅不会保存注释,而且也不会发生回发和服务器端保存。否则,我会调用 viewModel.save,该页面会通过该按钮的服务器端行为继续运行,保存用户的 FunStuff 选择。我的 saveComments 函数将由 viewModel.save 调用,它会要求 Breeze entityManager 执行 saveChanges:

function saveComments() {
  manager.saveChanges()
    .then(saveSucceeded)
    .fail(failed);
}

反过来,这又会查找我的控制器 SaveChanges 方法并执行:

[HttpPost]
  public SaveResult SaveChanges(JObject saveBundle)
  {
    return _contextProvider.SaveChanges(saveBundle);
  }

为了使其发挥作用,我在 EF6 数据层中添加了 Comments,然后切换 Comments 控制器方法以使用 Breeze 服务器端组件对数据库进行查询(对我的 EF6 数据层进行调用)。这样,返回到客户端的数据将是来自数据库的数据,SaveChanges 随后可将这些数据保存回数据库中。您可以在下载示例中看到这种情况,该示例使用了 EF6 和 Code First,并将创建示例数据库并作为种子。

图 7 响应用户对复选框的单击操作对注释列表进行更新的 JavaScript

function updateCommentsList(selectedValue, selectedText) {
  if (exists(selectedValue)) {
    var comment = remove(selectedValue);
    comment.entityAspect.setDeleted();
  } else {
  var deleted = manager.getChanges().filter(function (e) {
    return e.FunStuffId() == selectedValue
  })[0];  // Note: .filter won't work in IE8 or earlier
  var newSelection;
  if (deleted) {
    newSelection = deleted;
    deleted.entityAspect.rejectChanges();
  } else {
    newSelection = manager.createEntity('FunStuffComment', {
      'UserId': document.getElementById('hiddenId').value,
      'FunStuffId': selectedValue,
      'FunStuffName': selectedText,
      'Comment': ""
    });
  }
  viewModel.comments.push(newSelection);    }
  function exists(stuffId) {
    var existingItem = ko.utils.arrayFirst(vm.comments(), function (item) {
      return stuffId == item.FunStuffId();
    });
    return existingItem != null;
  };
  function remove(stuffId) {
    var selected = ko.utils.arrayFirst
    (vm.comments(), function (item) {
    return stuffId == item.FunStuffId;
    });
    ko.utils.arrayRemoveItem(vm.comments(), selected);
    vm.comments.valueHasMutated();
  };

从我的朋友获得有关 JavaScript 的一些帮助

在处理为本文生成的该项目以及示例时,我编写的 JavaScript 超出以往。这并不是我擅长的领域(正如我在本专栏中经常指出的那样),不过,我对所取得的成果感到很骄傲。但是,鉴于许多读者可能是第一次看到其中的某些技术,我请教 IdeaBlade 的 Ward Bell(Breeze 的创建人)深入复查了代码,并通过一些结对编程来帮助我整理某些 Breeze 工作以及 JavaScript 和 jQuery。或许,除了现在“过时”地使用 Knockout.js 之外,可供下载的示例应提供一些良好借鉴。不过请记住,重点是要通过这些更现代的技术来增强旧的 Web 窗体项目,让最终用户体验更加愉快。

Julie Lerman 是 Microsoft MVP、.NET 导师和顾问,住在佛蒙特州的山区。您可以在全球的用户组和会议中看到她对数据访问和其他 Microsoft .NET 主题的演示。她是《Programming Entity Framework》(2010) 以及“代码优先”版 (2011) 和 DbContext 版 (2012)(均出自 O’Reilly Media)的作者,博客网址为 thedatafarm.com/blog。通过她的 Twitter(网址为 twitter.com/julielerman)关注她,并在 juliel.me/PS-Videos 上观看其 Pluralsight 课程。

衷心感谢以下 Microsoft 技术专家对本文的审阅:Damian Edwards (dedward@microsoft.com) 和 Scott Hunter (Scott.Hunter@microsoft.com)