ASP.NET Ajax 库和 WCF 数据服务
AJAX 的出现开创了繁荣的编程新时代,受到 Web 行业的赞誉,自那时起,已过去了好些年,现在,终于又向 Web 开发人员推出了一组功能强大的编程工具:ASP.NET Ajax 库和 WCF 数据服务。开发人员可以不再依赖浏览器作为不同的运行时环境,可以从 Web 执行以前只能通过智能客户端实现的一些技巧。
现在,对远程 HTTP 端点进行调用是许多应用程序都可利用的常见功能。这类应用程序使用 Windows Communication Foundation (WCF) 服务下载 JavaScript Object Notation (JSON) 数据流,并将该内容解析为 JavaScript 对象,这些对象随后呈现到当前 HTML 文档对象模型 (DOM) 中。然而,服务器端的 WCF 服务以及客户端的 JavaScript 代码处理不同的数据类型,因此您必须创建两种不同的对象模型。
通常,服务器端需要域模型,该模型是中间层处理和表示实体的方式。实体框架和 LINQ to SQL 是两个出色的工具,用于设计服务器端对象模型(从头开始或是通过从现有数据库推断)。不过,有些时候,需要将这些数据作为 WCF 服务调用的响应传输到客户端。
面向服务的体系结构 (SOA) 在几个方面最让人头疼,其中一个是要求在分离表示层和业务层时,应始终传输数据协定(而不是类)。因此,需要另一个完全不同的对象模型:表示层视图模型。
一个常见问题是检测客户端上进行的任何数据更改,并将这些更改传递给服务器。只传递更改可确保在网络上尽量少传输数据,并且可对数据库执行优化的数据访问操作。
因此需要端到端解决方案进行数据访问和操作。WCF 数据服务(以前称为 ADO.NET 数据服务)和 ASP.NET Ajax 库相结合实现一种全面的框架,使您可以下载数据,对数据进行处理并将更新返回服务器。在本文中,我将介绍如何使用 ASP.NET Ajax JavaScript 组件实现高效的客户端数据访问。
WCF 数据服务简述
WCF 数据服务中领先的端到端数据访问解决方案的关键理念是,生成服务器端数据源,然后通过 WCF 服务的特殊方式公开数据源:WCF 数据服务。(位于 msdn.microsoft.com/magazine/cc748663 上的 2008 年 8 月期《MSDN 杂志》载有 WCF 数据服务的精彩介绍。)
通常是从使用实体框架创建业务域模型开始,然后基于该模型创建 WCF 服务。我将使用大家熟知的 Northwind 数据库,处理其中的两个表:Customers 和 Orders。
首先,创建一个新类库项目,添加一个类型为 ADO.NET 实体数据模型的新项。接下来,编译该项目,从新 ASP.NET 应用程序中引用程序集。在示例代码中,我使用的是 ASP.NET MVC 项目。将所有连接字符串设置与宿主 Web 应用程序的 web.config 合并,并向 Web 项目添加新的“ADO.NET 数据服务”项。(在 Visual Studio 2008 和 Visual Studio 2010 Beta 2 中仍然使用旧名称。)现在,就有了一个包含数据源的类库,以及一个承载于 ASP.NET 应用程序中、向客户端公开内容的 WCF 数据服务。
在最简单的情况下,该 WCF 数据服务所需的所有代码如下:
public class NorthwindService : DataService<NorthwindEntities>
{
public static void InitializeService(IDataServiceConfiguration config)
{
config.SetEntitySetAccessRule("Customers", EntitySetRights.All);
}
}
在开发项目时,可能需要对实体集添加更多访问规则,可能还需要添加(为什么不?)对其他服务操作的支持。WCF 数据服务是采用 REST 方式使用的纯 WCF 服务。因此,可以很方便地添加一个或多个新服务操作,每个操作都表示对数据的粗粒度操作,如复杂查询或复杂更新。除此之外,如上面的代码所示,服务将提供对 Customers 实体集的客户端访问(对操作没有任何限制)。这意味着,即使嵌入式实体模型中确实存在 Orders 实体集,也不能查询客户和订单。需要新的访问规则才能实现对订单的客户端访问。
在向 WCF 数据服务添加新的 REST 方法之前,只允许执行几个操作,它们是使用特定 URI 格式表示的通用创建、读取、更新和删除 (CRUD) 操作。(有关语法的详细信息,请参阅 msdn.microsoft.com/data/cc668792。)通过这种 URI 格式,应用程序可以查询实体、遍历实体之间的关系以及应用任何更改。每个 CRUD 操作都映射到另一个 HTTP 谓词:GET 用于查询,POST 用于插入,PUT 用于更新,而 DELETE 用于删除。
获取对 WCF 数据服务的引用的客户端会接收到一个代理类,该类通过 datasvcutil.exe 实用工具创建,或通过 Visual Studio 中的添加服务引用向导以透明方式创建。
从智能客户端平台(无论是 Silverlight、Windows 还是 Windows Presentation Foundation (WPF))调用 WCF 数据服务并不简单。对 ASP.NET 的服务器端绑定同样如此。基于 JavaScript 和 AJAX 的 Web 客户端又怎么样呢?
通过 ASP.NET Ajax 库使用数据服务
在 ASP.NET Ajax 库中,有两个与 WCF 数据服务相关的 JavaScript 组件:OpenDataServiceProxy 和 OpenDataContext。
OpenDataContext 实际上用于在 Web 客户端中管理 CRUD 操作。可以将其视为 DataServiceContext 类的 JavaScript 对应项。DataServiceContext 在 System.Data.Services.Client 命名空间中定义,表示指定的 WCF 数据服务的运行时上下文。OpenDataContext 跟踪对所使用的实体的更改,可以智能地对后端服务生成命令。
OpenDataServiceProxy 类用作 WCF 数据服务的轻型附加代理。它实际管理只读方案,不过也可用来调用服务中公开的其他服务操作。初始化 OpenDataServiceProxy 类,如下所示:
var proxy = new Sys.Data.OpenDataServiceProxy(url);
此时类已完成并已在运行。通常,还需要花一些时间来配置 OpenDataContext 对象。不过,就服务的连接而言,可采用类似方式进行:
var dataContext = new Sys.Data.OpenDataContext();
dataContext.set_serviceUri(url);
两个类都可以用作 DataView 的数据提供程序。使用哪一个类取决于要进行的操作。如有任何 CRUD 活动必须通过服务进行,最好使用代理。如果客户端上有逻辑,并打算在应用更改之前执行一组 CRUD 操作,则应使用数据上下文。现在,重点讨论一下 OpenDataContext。
使用 OpenDataContext 类
下面演示如何创建和初始化 OpenDataContext 类的实例:
<script type="text/javascript">
var dataContext;
Sys.require([Sys.components.dataView, Sys.components.openDataContext]);
Sys.onReady(function() {
dataContext = Sys.create.openDataContext(
{
serviceUri: "/NorthwindService.svc",
mergeOption: Sys.Data.MergeOption.appendOnly
});
});
</script>
请注意,使用 Sys.require 函数是为了仅动态链接为所用组件服务的脚本文件。如果选择 Sys.require 方法,则采用传统方法进行链接所需的唯一脚本文件为 start.js:
(see code <script src="../../Scripts/MicrosoftAjax/Start.js"
type="text/javascript">
</script>
不过,所用的所有文件都必须在服务器上可用,或可通过 Microsoft 内容传送网络 (CDN) 进行引用。
在后面的图 2 中,可以看到,在文档的准备事件中会创建 OpenDataContext 类的新实例。还请注意,使用最新的简化语法是为了为公用事件定义代码并实例化公用对象。OpenDataContext 类的工厂接收服务的 URL 和一些其他设置。现在,已准备就绪,可将数据上下文用作页面中某些 DataView UI 组件的数据提供程序,如图 1 所示。
图 1 将数据上下文用作数据提供程序
<table>
<tr class="tableHeader">
<td>ID</td>
<td>Name</td>
<td>Contact</td>
</tr>
<tbody sys:attach="dataview"
class="sys-template"
dataview:dataprovider="{{ dataContext }}"
dataview:fetchoperation="Customers"
dataview:autofetch="true">
<tr>
<td>{{ CustomerID }}</td>
<td>{{ CompanyName }}</td>
<td>{{ ContactName }}</td>
</tr>
</tbody>
</table>
将创建 DataView 组件的一个实例,然后使用该实例填充其附加到的模板。DataView 提供必需的粘接代码,以便通过 WCF 数据服务下载数据并将这些数据绑定到 HTML 模板。在何处确定要下载的数据?换句话说,如何为要返回的数据指定查询字符串?
DataView 组件的提取操作属性指示要调用的服务操作的名称。如果数据提供程序是服务的纯代理,则 fetchoperation 属性接受服务上的公共方法的名称。如果改为使用 OpenDataContext 类,则 fetchoperation 的值应为 WCF 数据服务运行时可以识别的字符串。该值可以是下面任意一种形式的表达式:
Customers
Customers('ALFKI')
Customers('ALFKI')?$expand=Orders
Customers('ALFKI')?$expand=Orders&$orderBy=City
如果仅指定有效实体集的名称,则将获取完整的实体列表。使用其他关键字(如 $expand、$orderBy 和 $filter)可以包括相关实体集(内部联接的排序)、按属性排序并基于布尔条件筛选返回的实体。
可以按照基本 URI 格式,以字符串的形式手动编写查询。也可以使用内置 OpenDataQueryBuilder JavaScript 对象,如图 2 所示。
图 2 使用 AdoNetQueryBuilder 对象
<script type="text/javascript">
var dataContext;
var queryObject;
Sys.require([Sys.components.dataView,
Sys.components.openDataContext]);
Sys.onReady(function() {
dataContext = Sys.create.openDataContext(
{
serviceUri: "/NorthwindService.svc",
mergeOption: Sys.Data.MergeOption.appendOnly
});
queryObject = new Sys.Data.OpenDataQueryBuilder("Customers");
queryObject.set_orderby("ContactName");
queryObject.set_filter("City eq " + "’London’");
queryObject.set_expand("Orders");
});
</script>
查询生成器可以用于生成完整的 URL,也可以只生成 URL 的查询部分。在本例中,查询生成器获取要查询的实体集的名称。它还提供一组属性,用于设置任何所需的扩展、筛选器和排序。随后,在设置 fetchoperation 时,通过查询生成器对象设置的条件必须序列化为有效的查询字符串,如下所示:
<tbody sys:attach="dataview"
class="sys-template"
dataview:dataprovider="{{ dataContext }}"
dataview:fetchoperation="{{ queryObject.toString() }}"
dataview:autofetch="true">
使用 toString 方法可从查询生成器提取查询字符串。在示例代码中,生成的查询字符串为
Customers?$expand=Orders&$filter="City eq 'London'"&$orderby=ContactName
服务会返回一个复合对象的集合,这些对象嵌入客户人口统计信息以及一些订单信息。图 3 显示了输出。
图 3 使用 WCF 数据服务查询数据
最后一列中的数字指示客户已下的订单的数目。因为查询中使用了 $expand 特性,所以 JSON 数据流包含一个订单数组。HTML 模板引用该数组的长度并填充该列,如下所示:
<td>{{ Orders.length }}</td>
请注意,若要成功检索订单信息,应首先回到 WCF 数据服务的源代码,实现对 Orders 实体集的访问:
public static void InitializeService(
IDataServiceConfiguration config)
{
config.SetEntitySetAccessRule(
"Customers", EntitySetRights.All);
config.SetEntitySetAccessRule(
"Orders", EntitySetRights.All);
}
现在,如果还希望实现更多功能,希望在将数据返回到服务之前在客户端更新数据,可以看看该如何处理。
处理更新
图 4 显示用于查询客户的页面段的 HTML 模板。用户输入 ID,单击按钮,从而获取最新的可编辑数据。
图 4 查询数据服务
<div id="Demo2">
<table>
<tr class="tableHeader"><td>Customer ID</td></tr>
<tr><td>
<%= Html.TextBox("CustomerID", "ALFKI") %>
<input type="button" value="Load" onclick="doLoad()" />
</td></tr>
</table>
<br /><br />
<table sys:attach="dataview" id="Editor"
class="sys-template"
dataview:dataprovider="{{ dataContext }}"
dataview:autofetch="false">
<tr>
<td class="caption">ID</td>
<td>{{ CustomerID }}</td>
</tr>
<tr>
<td class="caption">Company</td>
<td>{{ CompanyName }}</td>
</tr>
<tr>
<td class="caption">Address</td>
<td>
<%=Html.SysTextBox("Address", "{binding Address}")%></td>
</tr>
<tr>
<td class="caption">City</td>
<td>{{ City }}</td>
</tr>
</table>
</div>
按需进行数据下载。负责调用数据服务的代码如下:
function doLoad() {
var id = Sys.get("#CustomerID").value;
// Prepare the query
var queryObject = new Sys.Data.OpenDataQueryBuilder("Customers");
queryObject.set_filter("CustomerID eq '" + id + "’");
var command = queryObject.toString();
// Set up the DataView
var dataView = Sys.get("$Editor").component();
dataView.set_fetchOperation(command);
dataView.fetchData();
首先获取所需的输入数据,具体而言,是用户在输入字段中键入的文本。接下来,使用新 OpenDataQueryBuilder 对象准备查询。最后,指示 DataView(进而配置为使用 WCF 数据服务)为查询下载数据。
检索到的任何数据都使用 ASP.NET Ajax 库实时绑定进行显示,这可保证对所涉及的所有 JavaScript 对象进行实时更新(请参阅图 5)。
图 5 本地编辑对象
用于编辑客户地址的文本框定义为:
<td class="caption">Address</td>
<td><%= Html.SysTextBox("Address", "{binding Address}") %></td>
除了 {binding} 表达式的使用,还应注意一下 ASP.NET MVC 中使用的自定义 HTML 帮助程序。如果尝试在 Web 窗体应用程序的环境中使用实时绑定和 AJAX 模板,可能会遇到类似的情况。那么,有什么问题呢?
若要使数据绑定正常工作,所涉及的特性必须在前面加上 sys:命名空间。因此,若要将某些文本绑定到文本框,需要确保发出下面的 HTML 标记:
<input type="text" ... sys:value="{binding Address}" />
在 ASP.NET MVC 和 Web 窗体中,都可以通过输入 HTML 文本很好地解决问题。否则,需要所选择的 ASP.NET 框架为提取标记片段而提供的改编版工具:HTML 帮助程序或服务器控件。具体而言,在 ASP.NET MVC 中,可以使用发出 sys:value 特性的自定义 HTML 帮助程序,如图 6 所示。
图 6 自定义 HTML 帮助程序
public static string SysTextBox(this HtmlHelper htmlHelper,
string name,
string value,
IDictionary<string, object> htmlAttributes)
{
var builder = new TagBuilder("input");
builder.MergeAttributes(htmlAttributes);
builder.MergeAttribute("type", "text");
builder.MergeAttribute("name", name, true);
builder.MergeAttribute("id", name, true);
builder.MergeAttribute("sys:value", value, true);
return builder.ToString(TagRenderMode.SelfClosing);
}
对所显示的客户地址的更改会在发生更改时进行记录,并通过数据上下文对象进行跟踪。请注意,仅当将数据上下文对象用作用于呈现的 DataView 的数据提供程序时,才能实现此功能。对于上述 OpenDataServiceProxy 对象,这是 OpenDataContext 对象额外可以完成的工作。
如何保存更改?若要确保将所下载数据的已修改变化量返回到数据源,只需对数据上下文实例调用 saveChanges 方法。不过,根据所绑定的应用程序类型,可能需要添加一些额外的控件层。例如,可能需要添加一个“提交”按钮,该按钮汇总所进行的操作,然后请用户确认是否要保存未决更改。图 7 显示了这样一个提交按钮的 JavaScript 代码。
图 7 用于确认更改的提交按钮
function doCommit() {
var pendingChanges = dataContext.get_hasChanges();
if (pendingChanges !== true) {
alert("No pending changes to save.");
return;
}
var changes = dataContext.get_changes();
var buffer = "";
for (var i = 0; i < changes.length; i++) {
ch = changes[i];
// Function makeReadable just converts the action to readable text
buffer += makeReadable(ch.action) +
" --> " +
ch.item["Address"];
buffer += "\n";
}
if (confirm(buffer))
dataContext.saveChanges();
}
该函数检查当前数据上下文,以确定是否存在未决更改。如果存在,则生成检测到的更改的汇总。数据上下文的 get_changes 方法返回包含有关操作类型(插入、移除或更新)的信息的对象数组,以及更改所涉及的本地对象。图 8 显示在尝试提交未决更改时,由前面的代码生成的对话框。
图 8 检测到的未决更改
可以看到,每次选择新客户时,都会丢失前一个客户的更改。这是因为数据上下文会清空,然后用其他数据重新填充。在其他某个对象中保存更改没什么意义,您将自己重新编写数据上下文的克隆。
通过单对象用户界面无法真正体现出 WCF 数据服务的客户端代理的功能。在 ASP.NET Ajax 库 Beta 工具包中,可以找到测试此功能的好方法:ImageOrganizer 示例。不过,通过只对现有示例进行一点扩展,可以说明要点。假设您有一个模板-详细信息视图,可以从一个客户的视图切换到下一个客户的视图,而不保留页面且 无需保存更改。下载仅进行一次(或定期进行),在其保留在内存期间,会正确跟踪您的用户界面允许进行的所有更改(请参阅图 9)。
图 9 跟踪客户端更改
插入和删除
到现在为止,我只是重点讨论了更新。那么,插入和删除又该如何进行?这些操作稍有差别,需要多进行一点工作。首先,不能依赖于数据绑定对所显示的基本对象进行更改。您需要负责对从用户界面中使用的数据上下文接收的内存中集合(或对象)进行更新。对于插入,只需创建适用于进行显示的对象的新本地实例,并将其添加到绑定的集合。此时,如果用户界面完全是数据绑定的,则应能够反映出更改。接下来,需要通知数据上下文,新对象已添加到实体集并需要进行跟踪以保持持久性。下面是需要附加到插入对象的 JavaScript 按钮的典型代码:
// Create a new local object
var newCustomer = { ID: "DINOE", CompanyName: "...", ... };
// Add it to the collection used for data binding
dataView.get_data().add(newCustomer);
// Inform the data context object
dataContext.insertEntity(newCustomer, "Customers");
移除对象甚至更加简单。可从内存中集合移除对象,并对数据上下文调用 removeEntity 方法。
var index = customerList.get_selectedIndex();
var customer = dataView.get_data()[index];
dataContext.removeEntity(customer);
imageData.remove(customer);
避免混淆
OpenDataContext 和 DataView 对象可以很好地一起使用,但不应相互混淆。OpenDataContext 对象表示远程 WCF 数据服务的客户端代理。但它是非常特殊的代理类型。它在客户端实现“工作单元”模式,因为它可跟踪对其帮助检索的任何实体所进行的更改。数据上下文是非常适用于 DataView 组件的数据提供程序。DataView 组件仅与呈现有关。它为模板提供插件,以方便地调用远程操作,但这只是为开发人员提供方便。没有任何这类 CRUD 和数据管理逻辑属于 DataView。
本文未深入探讨 WCF 数据服务的复杂问题,未涉及诸如并发、延迟加载和安全等方面。也未讨论数据传输。在如何使用 ASP.NET Ajax 库和 WCF 数据服务完成某些重要工作方面,希望本文能为您提供最新的汇总信息。在以后的文章中,将讨论其他相关内容。请继续关注!
Dino Esposito* 是 Microsoft Press 即将出版的《Programming ASP.NET MVC》的作者,也是《Microsoft .NET:Architecting Applications for the Enterprise》(Microsoft Press,2008 年)的合著者。Esposito 定居于意大利,经常在世界各地的业内活动中发表演讲。加入他的博客 weblogs.asp.net/despos。*
*衷心感谢以下技术专家对本文的审阅:*Boris Rivers-Moore 和 Stephen Walther