使用 SqlDataSource 实现乐观并发 (C#)

作者 :斯科特·米切尔

下载 PDF

在本教程中,我们回顾了乐观并发控制的基本原理,然后探索如何使用 SqlDataSource 控件实现它。

介绍

在前面的教程中,我们介绍了如何将插入、更新和删除功能添加到 SqlDataSource 控件。 简言之,为了提供这些功能,我们需要在控件的INSERTUPDATEDELETE属性中指定相应的InsertCommandUpdateCommandDeleteCommand SQL 语句,并在InsertParametersUpdateParametersDeleteParameters集合中提供合适的参数。 虽然可以手动指定这些属性和集合,但“配置数据源”向导中的“高级”按钮提供了一个“生成 INSERTUPDATEDELETE 语句”的复选框,该复选框将基于 SELECT 语句自动创建这些语句。

除了“生成INSERT”和UPDATEDELETE“语句”复选框外,“高级 SQL 生成选项”对话框还包括“使用乐观并发”选项(请参阅图 1)。 选中后,自动生成的 WHEREUPDATE 语句中的 DELETE 子句将被修改,以便仅在用户上次将数据加载到网格后基础数据库数据尚未被修改时执行更新或删除。

可以从“高级 SQL 生成选项”对话框添加乐观并发支持

图 1:你可以从“高级 SQL 生成选项”对话框中添加乐观并发支持

回到 “实现乐观并发 ”教程,我们研究了乐观并发控制的基本原理以及如何将其添加到 ObjectDataSource。 在本教程中,我们将修改乐观并发控制的基本要素,然后探讨如何使用 SqlDataSource 实现它。

乐观并发总结

对于允许多个用户同时编辑或删除相同数据的 Web 应用程序,一个用户可能会意外覆盖其他用户的更改。 在 “实现乐观并发 ”教程中,我提供了以下示例:

假设两个用户 Jisun 和 Sam 都访问了应用程序中的页面,允许访问者通过 GridView 控件更新和删除产品。 他们几乎同时单击 Chai 的“编辑”按钮。 Jisun 将产品名称更改为柴茶,然后单击“更新”按钮。 净结果是生成一条UPDATE语句发送到数据库,这条语句设置了所有产品的可更新字段(即使 Jisun 仅更新了一个字段ProductName)。 此时,数据库中此特定产品的值包括柴茶、饮料类别、供应商异国液体等。 但是,Sam 屏幕上的 GridView 仍以 Chai 的形式在可编辑的 GridView 行中显示产品名称。 几秒钟后,Jisun 的更改被提交,Sam 将类别更新为调味品并点击“更新”。 这会导致 UPDATE 发送到数据库的语句,该语句将产品名称设置为 Chai、 CategoryID 对应的 Condiments 类别 ID 等。 对产品名称的更改已覆盖了 Jisun 的更动。

图 2 说明了此交互。

当两个用户同时更新记录时,一个用户的更改可能会覆盖另一个用户

图 2:当两个用户同时更新记录时,一个用户的更改可能会覆盖另一个用户(单击可查看全尺寸图像

若要防止此方案展开,必须实现某种形式的 并发控制乐观并发 是本教程的重点假设,即虽然偶尔会有并发冲突,但绝大多数时候此类冲突都不会发生。 因此,如果发生冲突,乐观并发控制只会通知用户无法保存其更改,因为其他用户修改了相同的数据。

注释

对于假定存在许多并发冲突的应用程序,或者如果此类冲突不可容忍,则可以改用悲观并发控制。 有关悲观并发控制的更深入讨论,请参阅实现乐观并发教程。

乐观并发控制的工作原理是确保更新或删除的记录与更新或删除进程启动时的值相同。 例如,单击可编辑 GridView 中的“编辑”按钮时,记录的值将从数据库读取,并显示在 TextBoxes 和其他 Web 控件中。 这些原始值由 GridView 保存。 稍后,在用户进行更改并单击“更新”按钮后, UPDATE 使用的语句必须考虑到原始值加上新值,并且仅当用户开始编辑的原始值与数据库中的值相同时,才更新基础数据库记录。 图 3 描述了此事件序列。

若要使更新或删除成功,原始值必须等于当前数据库值

图 3:若要使更新或删除成功,原始值必须等于当前数据库值(单击以查看全尺寸图像

实施乐观并发有多种方法(请参阅 Peter A. Bromberg乐观并发更新逻辑 ,大致了解多个选项)。 SqlDataSource(以及数据访问层中使用的 ADO.NET 类型化数据集)使用的技术增强了 WHERE 子句以包含所有原始值的比较。 例如,以下 UPDATE 语句仅当当前数据库值等于更新 GridView 中的记录时最初检索的值时,才会更新产品的名称和价格。 参数@ProductName@UnitPrice包含用户输入的新值,而@original_ProductName包含@original_UnitPrice最初在单击“编辑”按钮时加载到 GridView 中的值:

UPDATE Products SET
    ProductName = @ProductName,
    UnitPrice = @UnitPrice
WHERE
    ProductID = @original_ProductID AND
    ProductName = @original_ProductName AND
    UnitPrice = @original_UnitPrice

如本教程所示,使用 SqlDataSource 启用乐观并发控制与选中复选框一样简单。

步骤 1:创建支持乐观并发的 SQL数据源

首先从OptimisticConcurrency.aspx文件夹中打开SqlDataSource页面。 将 SqlDataSource 控件从工具箱拖到设计器上,将其 ID 属性 ProductsDataSourceWithOptimisticConcurrency设置为 。 接下来,单击控件智能标记中的“配置数据源”链接。 在向导的第一个屏幕中,选择使用 NORTHWINDConnectionString 并单击“下一步”。

选择使用 NORTHWINDConnectionString

图 4:选择“使用 NORTHWINDConnectionString ”(单击以查看全尺寸图像

在本示例中,我们将添加一个 GridView,使用户能够编辑 Products 表。 因此,在“配置 Select 语句”屏幕中,从下拉列表中选择 Products 表,然后选择 ProductIDProductNameUnitPriceDiscontinued 列,如图 5 所示。

从“产品”表中,返回 ProductID、ProductName、UnitPrice 和已停用列

图 5:从Products表格中返回ProductIDProductNameUnitPriceDiscontinued列(单击以查看全尺寸图像

选取列后,单击“高级”按钮以显示“高级 SQL 生成选项”对话框。 选中“生成 INSERT”、“UPDATE”、DELETE,以及“使用乐观并发”复选框,然后单击“确定”(请参阅图 1 以获取屏幕截图)。 完成向导的步骤是先单击“下一步”,然后单击“完成”。

完成“配置数据源”向导后,花点时间检查生成的DeleteCommand属性和UpdateCommand属性以及DeleteParametersUpdateParameters集合。 执行此作的最简单方法是单击左下角的“源”选项卡以查看页面的声明性语法。 你将在此处找到一个UpdateCommand的值:

UPDATE [Products] SET
     [ProductName] = @ProductName,
     [UnitPrice] = @UnitPrice,
     [Discontinued] = @Discontinued
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     [UnitPrice] = @original_UnitPrice AND
     [Discontinued] = @original_Discontinued

集合中具有七个 UpdateParameters 参数:

<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
    runat="server" ...>
    <DeleteParameters>
      ...
    </DeleteParameters>
    <UpdateParameters>
        <asp:Parameter Name="ProductName" Type="String" />
        <asp:Parameter Name="UnitPrice" Type="Decimal" />
        <asp:Parameter Name="Discontinued" Type="Boolean" />
        <asp:Parameter Name="original_ProductID" Type="Int32" />
        <asp:Parameter Name="original_ProductName" Type="String" />
        <asp:Parameter Name="original_UnitPrice" Type="Decimal" />
        <asp:Parameter Name="original_Discontinued" Type="Boolean" />
    </UpdateParameters>
    ...
</asp:SqlDataSource>

同样, DeleteCommand 属性和 DeleteParameters 集合应如下所示:

DELETE FROM [Products]
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     [UnitPrice] = @original_UnitPrice AND
     [Discontinued] = @original_Discontinued
<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
    runat="server" ...>
    <DeleteParameters>
        <asp:Parameter Name="original_ProductID" Type="Int32" />
        <asp:Parameter Name="original_ProductName" Type="String" />
        <asp:Parameter Name="original_UnitPrice" Type="Decimal" />
        <asp:Parameter Name="original_Discontinued" Type="Boolean" />
    </DeleteParameters>
    <UpdateParameters>
        ...
    </UpdateParameters>
    ...
</asp:SqlDataSource>

除了扩充WHEREUpdateCommand子句和DeleteCommand属性(并将其他参数添加到相应的参数集合)之外,选择“使用乐观并发”选项还会调整另外两个属性。

当数据 Web 控件调用 SqlDataSource 或Update()Delete()方法时,它会传入原始值。 如果 SqlDataSource 属性 ConflictDetection 设置为 CompareAllValues,这些原始值将添加到命令中。 该 OldValuesParameterFormatString 属性提供用于这些原始值参数的命名模式。 “配置数据源”向导使用original_{0},并在UpdateCommandDeleteCommand属性以及UpdateParametersDeleteParameters集合中分别命名每个原始参数。

注释

由于我们不使用 SqlDataSource 控件的插入功能,因此可以随意删除 InsertCommand 属性及其 InsertParameters 集合。

正确处理NULL

遗憾的是,通过“配置数据源”向导使用乐观并发时自动生成的UPDATEDELETE扩充语句不适用于包含NULL值的记录。 要了解原因,请考虑我们的 SqlDataSource s UpdateCommand

UPDATE [Products] SET
     [ProductName] = @ProductName,
     [UnitPrice] = @UnitPrice,
     [Discontinued] = @Discontinued
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     [UnitPrice] = @original_UnitPrice AND
     [Discontinued] = @original_Discontinued

UnitPrice表中的Products列可以包含NULL值。 如果特定记录的NULLUnitPrice,则WHERE子句中的[UnitPrice] = @original_UnitPrice将始终被判定为 False,因为NULL = NULL始终返回 False。 因此,不能编辑或删除包含 NULL 值的记录,因为 UPDATE and DELETE 语句 WHERE 子句不会返回任何行来更新或删除。

注释

此 bug 于 2004 年 6 月在 SqlDataSource 中首次报告给 Microsoft ,生成不正确的 SQL 语句 ,据报道,该 bug 计划在下一版本的 ASP.NET 中修复。

若要解决此问题,我们必须手动更新WHEREUpdateCommand属性中DeleteCommand子句,以便适用于所有可能具有值的列。 一般情况下,更改为 [ColumnName] = @original_ColumnName

(
   ([ColumnName] IS NULL AND @original_ColumnName IS NULL)
     OR
   ([ColumnName] = @original_ColumnName)
)

可以通过声明性标记、“属性”窗口中的 UpdateQuery 或 DeleteQuery 选项,或通过“配置数据源”向导中的“指定自定义 SQL 语句或存储过程”选项中的 UPDATE 和 DELETE 选项卡直接进行此修改。 同样,必须对在 UpdateCommand s DeleteCommand 子句中可能包含 WHERE 值的每个列进行此修改。

将此应用到我们的示例会导致以下修改 UpdateCommand 值和 DeleteCommand 值:

UPDATE [Products] SET
     [ProductName] = @ProductName,
     [UnitPrice] = @UnitPrice,
     [Discontinued] = @Discontinued
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
        OR ([UnitPrice] = @original_UnitPrice)) AND
     [Discontinued] = @original_Discontinued
DELETE FROM [Products]
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
        OR ([UnitPrice] = @original_UnitPrice)) AND
     [Discontinued] = @original_Discontinued

步骤 2:使用“编辑和删除选项”添加 GridView

配置 SqlDataSource 以支持乐观并发后,只需在页面中添加一个利用这种并发控制的数据 Web 控件。 在本教程中,让我们添加一个 GridView,该网格视图提供编辑和删除功能。 为此,请将 GridView 从工具箱拖到设计器上,并将其设置为 IDProducts。 从 GridView 智能标记中,将其绑定到步骤 1 中添加的 ProductsDataSourceWithOptimisticConcurrency SqlDataSource 控件。 最后,检查智能标记中的“启用编辑”和“启用删除”选项。

将 GridView 绑定到 SqlDataSource 并启用编辑和删除

图 6:将 GridView 绑定到 SqlDataSource 并启用编辑和删除(单击可查看全尺寸图像

添加 GridView 后,配置其外观:通过删除 ProductID BoundField、将 ProductName BoundField 的 HeaderText 属性更改为 Product,并更新 UnitPrice BoundField,使得其 HeaderText 属性仅为 Price。 理想情况下,我们应该增强编辑界面,增加ProductName值的RequiredFieldValidator和UnitPrice值的CompareValidator(确保它是格式正确的数值)。 若要更深入地了解如何自定义 GridView 编辑界面,请参阅 自定义数据修改接口 教程。

注释

必须启用 GridView 视图的视图状态,因为从 GridView 传递到 SqlDataSource 的原始值存储在视图状态中。

对 GridView 进行这些修改后,GridView 和 SqlDataSource 声明性标记应如下所示:

<asp:SqlDataSource ID="ProductsDataSourceWithOptimisticConcurrency"
    runat="server" ConflictDetection="CompareAllValues"
    ConnectionString="<%$ ConnectionStrings:NORTHWNDConnectionString %>"
    DeleteCommand=
        "DELETE FROM [Products]
         WHERE [ProductID] = @original_ProductID
         AND [ProductName] = @original_ProductName
         AND (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
              OR ([UnitPrice] = @original_UnitPrice))
         AND [Discontinued] = @original_Discontinued"
    OldValuesParameterFormatString=
        "original_{0}"
    SelectCommand=
        "SELECT [ProductID], [ProductName], [UnitPrice], [Discontinued]
         FROM [Products]"
    UpdateCommand=
        "UPDATE [Products]
         SET [ProductName] = @ProductName, [UnitPrice] = @UnitPrice,
            [Discontinued] = @Discontinued
         WHERE [ProductID] = @original_ProductID
         AND [ProductName] = @original_ProductName
         AND (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL)
            OR ([UnitPrice] = @original_UnitPrice))
        AND [Discontinued] = @original_Discontinued">
    <DeleteParameters>
        <asp:Parameter Name="original_ProductID" Type="Int32" />
        <asp:Parameter Name="original_ProductName" Type="String" />
        <asp:Parameter Name="original_UnitPrice" Type="Decimal" />
        <asp:Parameter Name="original_Discontinued" Type="Boolean" />
    </DeleteParameters>
    <UpdateParameters>
        <asp:Parameter Name="ProductName" Type="String" />
        <asp:Parameter Name="UnitPrice" Type="Decimal" />
        <asp:Parameter Name="Discontinued" Type="Boolean" />
        <asp:Parameter Name="original_ProductID" Type="Int32" />
        <asp:Parameter Name="original_ProductName" Type="String" />
        <asp:Parameter Name="original_UnitPrice" Type="Decimal" />
        <asp:Parameter Name="original_Discontinued" Type="Boolean" />
    </UpdateParameters>
</asp:SqlDataSource>
<asp:GridView ID="Products" runat="server"
    AutoGenerateColumns="False" DataKeyNames="ProductID"
    DataSourceID="ProductsDataSourceWithOptimisticConcurrency">
    <Columns>
        <asp:CommandField ShowDeleteButton="True" ShowEditButton="True" />
        <asp:BoundField DataField="ProductName" HeaderText="Product"
            SortExpression="ProductName" />
        <asp:BoundField DataField="UnitPrice" HeaderText="Price"
            SortExpression="UnitPrice" />
        <asp:CheckBoxField DataField="Discontinued" HeaderText="Discontinued"
            SortExpression="Discontinued" />
    </Columns>
</asp:GridView>

若要查看乐观并发控制,请打开两个浏览器窗口并在两者中加载 OptimisticConcurrency.aspx 页面。 单击两个浏览器中第一个产品的“编辑”按钮。 在一个浏览器中,更改产品名称,然后单击“更新”。 浏览器将执行回发操作,GridView 将返回到其预编辑模式,显示刚刚编辑的记录中的新产品名称。

在第二个浏览器窗口中,更改价格(但将产品名称保留为其原始值),然后单击“更新”。 在回发时,表格会恢复到编辑前的模式,然而价格的更改不会被记录。 第二个浏览器显示的值与第一个浏览器相同,都是新产品名称与旧价格。 第二个浏览器窗口中所做的更改已丢失。 此外,更改会悄然丢失,因为没有异常或消息指示并发冲突刚刚发生。

第二个浏览器窗口中的更改以无提示方式丢失

图 7:第二个浏览器窗口中的更改无提示丢失(单击以查看全尺寸图像

未提交第二个浏览器的更改是因为UPDATE语句中的WHERE子句筛选掉了所有记录,因此未影响任何行。 让我们再次查看 UPDATE 语句:

UPDATE [Products] SET
     [ProductName] = @ProductName,
     [UnitPrice] = @UnitPrice,
     [Discontinued] = @Discontinued
WHERE
     [ProductID] = @original_ProductID AND
     [ProductName] = @original_ProductName AND
     (([UnitPrice] IS NULL AND @original_UnitPrice IS NULL) OR
        ([UnitPrice] = @original_UnitPrice)) AND
     [Discontinued] = @original_Discontinued

当第二个浏览器窗口更新记录时,子句中指定的 WHERE 原始产品名称与现有产品名称不匹配(因为第一个浏览器已更改)。 因此,该语句 [ProductName] = @original_ProductName 返回 False,并且 UPDATE 不会影响任何记录。

注释

删除操作的方式相同。 打开两个浏览器窗口后,首先使用一个浏览器编辑给定产品,然后保存其更改。 在一个浏览器中保存更改后,单击另一个浏览器中同一产品的“删除”按钮。 由于原始值不匹配DELETE语句中的WHERE子句,因此删除会以无提示方式失败。

从第二个浏览器窗口中的最终用户的角度来看,单击“更新”按钮后,网格返回到预编辑模式,但更改已丢失。 但是,没有视觉反馈,他们的更改没有坚持。 理想情况下,如果用户的更改由于并发冲突而丢失,我们会通知他们,并且可能让网格保持在编辑模式。 让我们看看如何实现此目的。

步骤 3:确定发生并发冲突的时间

由于并发冲突会拒绝已做出的更改,因此在发生并发冲突时提醒用户会很好。 若要提醒用户,请将标签 Web 控件添加到其ConcurrencyViolationMessageText属性显示以下消息的页面顶部:您尝试更新或删除其他用户同时更新的记录。 请查看其他用户的更改,然后重做更新或删除。 将 Label 控件的属性 CssClass 设置为 Warning,这是一个 CSS 类,该类以 Styles.css 红色、斜体、粗体和大字体显示文本。 最后,将标签 Label 的 VisibleEnableViewState 属性设置为 false。 这将隐藏标签控件,除非在回发中我们显式将其属性Visible设置为true

向页面添加标签控件以显示警告

图 8:向页面添加标签控件以显示警告(单击以查看全尺寸图像

执行更新或删除时,GridView s RowUpdatedRowDeleted 事件处理程序会在其数据源控件执行请求的更新或删除后触发。 我们可以从这些事件处理程序中确定操作影响了多少行。 如果零行受到影响,我们希望显示 ConcurrencyViolationMessage 标签。

RowUpdated 事件和 RowDeleted 事件创建事件处理程序并添加以下代码:

protected void Products_RowUpdated(object sender, GridViewUpdatedEventArgs e)
{
    if (e.AffectedRows == 0)
    {
        ConcurrencyViolationMessage.Visible = true;
        e.KeepInEditMode = true;
        // Rebind the data to the GridView to show the latest changes
        Products.DataBind();
    }
}
protected void Products_RowDeleted(object sender, GridViewDeletedEventArgs e)
{
    if (e.AffectedRows == 0)
        ConcurrencyViolationMessage.Visible = true;
}

在这两个事件处理程序中,我们检查属性 e.AffectedRows ,如果它等于 0,请将 ConcurrencyViolationMessage Label 属性 Visible 设置为 true。 在事件处理程序中 RowUpdated ,我们还指示 GridView 通过将属性设置为 KeepInEditMode true 来保持编辑模式。 为此,我们需要将数据重新绑定到网格,以便其他用户的数据加载到编辑界面中。 这是通过调用 GridView 的 DataBind() 方法实现的。

如图 9 所示,使用这两个事件处理程序时,每当发生并发冲突时,会显示一条非常明显的消息。

消息显示在面对并发冲突时

图 9:出现并发冲突时显示一条消息(单击以查看全尺寸图像

概要

创建多个并发用户可能编辑相同数据的 Web 应用程序时,请务必考虑并发控制选项。 默认情况下,ASP.NET 数据 Web 控件和数据源控件不使用任何并发控制。 如本教程中所示,使用 SqlDataSource 实现乐观并发控制相对快速且简单。 SqlDataSource 为你处理将扩充 WHERE 子句添加到自动生成的 UPDATEDELETE 语句中的大部分繁重工作,但在处理 NULL 值列时需要注意一些细节,详见“正确处理 NULL 值”部分。

本教程总结了对 SqlDataSource 的检查。 剩余教程将返回使用 ObjectDataSource 和分层体系结构处理数据。

快乐编程!

关于作者

斯科特·米切尔,七本 ASP/ASP.NET 书籍的作者和 4GuysFromRolla.com 的创始人,自1998年以来一直在与Microsoft Web 技术合作。 斯科特担任独立顾问、教练和作家。 他的最新书是 《Sams Teach Yourself ASP.NET 2.0 in 24 Hours》。 可以通过 mitchell@4GuysFromRolla.com 联系到他。