DataTable 事务记录器






4.58/5 (31投票s)
通过记录行更改(插入/删除)和字段更改来撤销/重做 DataTable 事务。
引言
本文描述了一个算法的实现,该算法通过挂钩 `DataTable` 提供的一些事件来记录 `DataTable` 的行和列更改,包括 .NET 2.0 Framework 中新的 `TableNewRow` 事件。这项工作的目的是为 UI 生成的事务提供本地的撤销/重做功能,以及在应用程序本身操作 `DataTable` 时记录事务的能力,适用于序列化和应用到远程表的镜像。在此实现中,每个表都需要自己的事务日志。在表是数据集(相互关联的表集合)一部分的情况下,这会立即导致问题。对于这种情况,此实现是不够的,因为数据集中表之间没有事务同步。坦率地说,这个问题应该在充分理解处理单个 `DataTable` 的相关问题后才能解决。
关于术语的一些说明 - 我倾向于交替使用“字段”和“列”。另外,我谈到“未提交的行”。在这种情况下,我不是指尚未保存到数据库的行。我特指尚未添加到 `DataTable` 的行集合中的行。
设计决策
记录 `DataTable` 事务的想法说起来容易做起来难。有几个问题需要考虑,并驱动了架构。在某些情况下,必须做出看似不可避免的妥协。问题包括:
- 删除 `DataRow`:一旦删除,`DataRow` 字段就无法被操作(撤销/重做问题)或添加回其所属的表。
- 新行可以在添加到表的行集合之前进行初始化(通常也是如此),导致在“添加”事务之前出现“更改”事务。
- 无法确定应用程序(或像 `DataGrid` 这样的控件)是否决定放弃新行。
接下来是对每个问题及其如何影响架构的更详细讨论。
删除 DataRow
关于管理 `DataTable` 事务列表的一个想法是保存 `DataRow` 引用和更改类型。对于字段更改,这很简单——还需要保存被更改的列以及旧值和新值,以便撤销(应用旧值)和重做(应用新值)都可以使用。问题出现在 `DataRow` 被删除时。您无法通过将已删除的 `DataRow` 添加回表集合来“撤销”它,而是必须创建一个新的 `DataRow` 实例。现在假设您有以下事务日志:
- 添加行
- 更改字段
- 更改字段
然后您撤销所有事务。第一个事务,“添加行”,在撤销时,会将该行从 `DataTable` 中删除。如果我们现在尝试重做所有事务,被删除的行必须再次作为新实例添加到 `DataTable` 中。现在,“更改字段”事务中的 `DataRow` 实例需要进行修复。日志中添加到行事务之前的所有事务都进行了调整吗?是的。最初,我选择通过保留与每行关联的主键来将 `DataRow` 实例与事务分离。这在记录事务的时间和需要主键的时间方面施加了许多限制。我最终决定修复事务日志中的 `DataRow` 引用是一个更好的实现,因为它解耦了对主键的依赖!主键仅在序列化事务时有用,这实际上是一个独立于撤销/重做功能的功能。同时也要记住,如果您撤销已删除的行,日志中较早引用已删除行的所有事务都需要进行修复。
新行字段初始化
做出上述设计决定后,.NET 2.0 的 `TableNewRow` 事件对于记录“新行”事务将非常有帮助,这一点变得更加明显。请注意,我没有称之为“添加行”,因为这在技术上是不正确的——该行尚未添加到 `DataTable` 的行集合中。这解决了在将行添加到集合之前初始化和更改列的问题,因为事务日志将始终在字段更改事务之前有一个“新行”事务。
新行初始化
但故事并没有就此结束。应用程序可能会简单地放弃新行。例如,使用 `DataGrid`,单击带“*”的行。这会触发 `TableNewRow` 事件。按 Escape 键。新行被放弃,但我仍然在日志中有一个“新行”事务!同时考虑一个应用程序创建了新行,但由于某种原因在初始化过程中出错。同样,事务日志中仍然有一个新行,但它不知道该行已被放弃。
解决这个问题的变通方法有一些影响。首先,我实现了一个新的行垃圾回收方法,该方法删除从未最终添加到 `DataTable` 行集合中的行的所有日志条目。在接受表更改(稍后讨论)和执行撤销操作之前,应始终调用此方法。本质上,在接受和撤销操作之前,`DataTable` 应该是稳定的,因为这些操作中没有一个对实际上未添加到 `DataTable` 行集合中的行有太大意义。同样,在序列化事务日志之前也应调用此方法。
这个设计决策使实现更加复杂,因为它需要管理该行是否实际上已添加到 `DataTable`。这需要一个“未提交的行列表”。我们还可以使用 `RowChanged` 事件并监视“添加”操作,将行从未提交的行列表中移除。最后,此行列表中的每个条目都应引用事务日志中引用该行的每个索引。然后,垃圾回收器可以遍历未提交的行列表,将事务日志中的索引添加到排序缓冲区,然后通过从最高索引到最低索引进行迭代来从日志中删除事务(这样,删除较低索引时,较高索引不会移动)。所有这些都是为了避免迭代事务日志以查找引用正在被收集的行的事务!
其他注意事项
我有点惊讶地注意到,即使在 `ColumnChanging` 事件处理程序中设置列错误,也无法阻止 `ColumnChanged` 事件触发。事实上,即使将 ProposedValue 设置回列的原始值,也不会阻止 `ColumnChanged` 事件触发。在 `DataGrid` 中,如果您编辑一列,输入完全相同的值,`ColumnChanging` 事件仍然会触发。值得注意。
我的原始设计
如前所述,我最初的设计使用了主键值,并且在处理新行和字段初始化方面非常“智能”(哈哈哈)。基本上,它要求在处理字段更改之前所有主键都有效。如果应用程序首先设置所有必需的主键,它将自动添加“添加行”事务。它甚至有一个应用程序可以挂钩以提供主键的事件。这看起来还可以,并且在我测试时实际上效果很好。当我写到这个设计时,我写下了这句话:“为了正确排序事务,即使该行尚未添加到集合中,也会记录‘新行’事务。”那时我停了下来,意识到那非常、非常错误!
然而,这指出了两种不同设计之间的权衡——一种必须处理从未最终添加到 `DataTable` 的新行,另一种则预期添加行但最终可能得到实际上从未添加的行。真的,这归结为同一件事——未提交的行需要从事务日志中删除。然而,要求“立即获取主键”的额外副作用使我选择了不同的架构。事实上,最终架构根本不关心您的 `DataTable` 是否有主键——主键唯一有用的时候是序列化事务日志,而该实现超出了事务日志记录器的范围。
实现
事件处理程序
TableCleared 事件
此事件不支持撤销。它清除内部集合并重置日志记录状态。
TableNewRow 事件
在此事件处理程序中,我们记录新行并将其添加到我们的未提交行集合中,并用事务索引初始化事务列表。
protected void OnTableNewRow(object sender, DataTableNewRowEventArgs e)
{
if (doLogging)
{
int idx;
DataTableTransactionRecord record;
idx = transactions.Count;
record = new DataTableTransactionRecord(idx, e.Row,
DataTableTransactionRecord.TransactionType.NewRow);
OnTransactionAdding(new TransactionEventArgs(record));
transactions.Add(record);
OnTransactionAdded(new TransactionEventArgs(record));
List<int> rowIndices = new List<int>();
rowIndices.Add(idx);
uncomittedRows.Add(e.Row, rowIndices);
}
}
RowChanged 事件
在此事件处理程序中,当 `Action==DataRowAction.Add` 时,正在添加的行将从未提交的行集合中移除。
protected void OnRowChanged(object sender, DataRowChangeEventArgs e)
{
if (doLogging)
{
if (e.Action == DataRowAction.Add)
{
if (!uncomittedRows.ContainsKey(e.Row))
{
throw new DataTableTransactionException("Attempting " +
"to commit a row that doesn't exist in the " +
"uncommitted row collection.");
}
uncomittedRows.Remove(e.Row);
}
}
}
ColumnChanging 和 ColumnChanged 事件
挂钩此事件是为了让我们能够获取当前字段值并将其作为旧值保存在事务中。`ColumnChanged` 事件在此时已经将字段值更改为提议的值。但是,我们仍然需要 `ColumnChanged` 事件来获取新行值,以防应用程序更改了提议的值!
if (doLogging)
{
object oldVal = e.Row[e.Column];
int trnIdx;
DataTableTransactionRecord record;
trnIdx = transactions.Count;
record = new DataTableTransactionRecord(
trnIdx, e.Row, e.Column.ColumnName,
oldVal, e.ProposedValue);
OnTransactionAdding(new TransactionEventArgs(record));
transactions.Add(record);
OnTransactionAdded(new TransactionEventArgs(record));
waitingForChangedEventList.Add(record);
if (uncomittedRows.ContainsKey(e.Row))
{
uncomittedRows[e.Row].Add(trnIdx);
}
}
在以下代码中,`waitForChangedEventList` 被反向搜索以查找正在更改的列。我决定缓冲那些等待通过 `ColumnChanged` 事件完成的“更改”事件,因为应用程序有可能(尽管我绝对不推荐这样做)在 `ColumnChanging` 事件(或首先触发的 `ColumnChanged` 事件处理程序)中更改其他字段(可能在其他行中)。
void OnColumnChanged(object sender, DataColumnChangeEventArgs e)
{
if (doLogging)
{
for (int i = 0; i < waitingForChangedEventList.Count; i++)
{
DataTableTransactionRecord r =
waitingForChangedEventList[i];
if ( (r.ColumnName == e.Column.ColumnName) &&
(r.Row == e.Row) )
{
r.NewValue = e.ProposedValue;
waitingForChangedEventList.RemoveAt(i);
break;
}
}
}
}
RowDeleting 事件
此事件处理程序创建一个用于删除行的事务。您无法删除与表无关的行,因此未提交集合中永远不会包含此行。
protected void OnRowDeleting(object sender,
DataRowChangeEventArgs e)
{
if (doLogging)
{
DataTableTransactionRecord record;
record = new DataTableTransactionRecord(transactions.Count, e.Row,
DataTableTransactionRecord.TransactionType.DeleteRow);
transactions.Add(record);
SaveRowFields(record, e.Row);
OnTransactionAdded(new TransactionEventArgs(record));
}
}
撤销 / 重做
事务记录器实现了 Revert(单个事务回滚)和 Apply(每个事务)功能,可用于实现撤销/重做功能。事务记录器本身不提供完整的撤销/重做实现——这留给了应用程序,下面是一个示例。
Revert(撤销)
根据事务类型,`Revert` 方法执行以下操作:
- 对于字段更改,将恢复旧值。
- 对于已添加的行,将删除该行。
- 对于已删除的行,将其重新添加,并且所有先前引用该行的事务都将进行修复以引用已恢复的行。
public void Revert(int idx)
{
// Validate idx.
if ((idx < 0) || (idx >= transactions.Count))
{
throw new ArgumentOutOfRangeException("Idx cannot be " +
"negative or greater than the number of transactions.");
}
// Turn off logging, as we don't want to record
// the undo transactions.
SuspendLogging();
DataTableTransactionRecord r = transactions[idx];
DataRow row = r.Row;
switch (r.TransType)
{
// Delete the row we added.
case DataTableTransactionRecord.TransactionType.NewRow:
// Only save row fields if this row is first time deleted.
if (!r.WasDeleted)
{
// Save all the field values for the row being deleted.
SaveRowFields(r, row);
}
// Delete the row.
row.Delete();
break;
// Add the row we deleted.
case DataTableTransactionRecord.TransactionType.DeleteRow:
DataRow newRow = sourceTable.NewRow();
// Restore all the field values into the new row.
RestoreRowFields(r, newRow);
sourceTable.Rows.Add(newRow);
// Fixup transactions referencing the deleted data row,
// going backwards
for (int n = idx; n >= 0; --n)
{
if (transactions[n].Row == row)
{
transactions[n].Row = newRow;
}
}
break;
// Undo the change to field.
case DataTableTransactionRecord.TransactionType.ChangeField:
row[r.ColumnName] = r.OldValue;
break;
}
ResumeLogging();
}
Apply
根据事务类型,`Apply`(重做)执行以下操作:
- 对于字段更改,将恢复新值。
- 对于新行,将添加一个新行。所有对该行实例的后续引用都将进行修复以引用新添加的行。
- 对于已删除的行,将删除该行。
代码与上面的 Revert `方法` 非常相似。
撤销/重做示例
在演示程序中,撤销/重做功能通过挂钩日志记录器的 TransactionAdding 事件和 UI 上撤销/重做按钮关联的 Click 事件来处理。
void OnTransactionAdding(object sender, TransactionEventArgs e)
{
btnRedo.Enabled = false;
if (undoRow < tlog.Log.Count - 1)
{
tlog.Log.RemoveRange(undoRow + 1,
tlog.Log.Count - (undoRow + 1));
tlog.AcceptChanges();
}
}
void OnUndo(object sender, EventArgs e)
{
tlog.Revert(undoRow);
--undoRow;
}
void OnRedo(object sender, EventArgs e)
{
++undoRow;
tlog.Apply(undoRow);
}
为清晰起见,上述代码已删除 dgTransactions 管理和按钮状态管理。有趣的是,当发生事务时,当前撤销事务索引之前的任何重做事务都会被删除。此外,还会调用 `AcceptChanges` 来同步 `DataTable` 和撤销更改。如果我们此时不同步,调用 `RejectChanges` 可能会重做已被撤销的更改。
接受/拒绝更改
`DataTable` 提供了 `AcceptChanges` 和 `RejectChanges` 方法。这些方法没有关联的事件,因此您应该使用事务记录器提供的这些方法,而不是直接使用它们。记录器的 `AcceptChanges` 调用中的方法会管理一个内部索引。如果调用 `RejectChanges`,则此点之后的所有事务都将从事务列表中删除。这可以使事务记录器与 `DataTable` 中已接受和未接受内容的视图保持同步。重要:在撤销索引不在撤销堆栈顶部时调用 `RejectChanges` 可能会导致一些奇怪的效果。
实现说明了如何管理事务日志和内部索引。
public void AcceptChanges()
{
lastAcceptedChangeIndex = transactions.Count;
sourceTable.AcceptChanges();
}
public void RejectChanges()
{
int numTran = transactions.Count - lastAcceptedChangeIndex;
transactions.RemoveRange(lastAcceptedChangeIndex, numTran);
sourceTable.RejectChanges();
}
演示程序
演示程序是一个小型应用程序,具有 MyXaml 前端(您还期望什么,代码???)和事件处理程序后端,允许您进行撤销/重做功能、接受/拒绝表更改、设置列和行错误(这是我进行调查的一部分,以查看 `DataTable` 事件是如何处理的)以及清除表。
声明式用户界面
UI 直观,声明控件对象图和复选框数据绑定。
<?xml version="1.0" encoding="utf-8"?>
<!-- (c) 2006 Marc Clifton All Rights Reserved -->
<MyXaml xmlns="System.Windows.Forms, System.Windows.Forms,
Version=2.0.0000.0, Culture=neutral,
PublicKeyToken=b77a5c561934e089"
xmlns:ctd="Clifton.Tools.Data, Clifton.Tools.Data"
xmlns:def="Definition"
xmlns:ref="Reference">
<Form Name="DataTableUndoRedo"
Text="DataTable Undo/Redo Demo"
ClientSize="570, 675"
MinimizeBox="false"
MaximizeBox="false"
StartPosition="CenterScreen">
<Controls>
<Label Text="Edit This Table:" Location="10, 10"
Size="100, 15"/>
<DataGrid def:Name="dgTable" Location="10, 25"
Size="280, 300" DataSource="{view}">
<TableStyles>
<DataGridTableStyle>
<GridColumnStyles>
<DataGridTextBoxColumn MappingName="LastName"
HeaderText="Last Name" Width="120"/>
<DataGridTextBoxColumn MappingName="FirstName"
HeaderText="First Name" Width="120"/>
</GridColumnStyles>
</DataGridTableStyle>
</TableStyles>
</DataGrid>
<Label Text="Transactions:" Location="10, 340" Size="100, 15"/>
<DataGrid def:Name="dgTransactions" Location="10, 355" Size="550, 300"
DataSource="{transactions}" ReadOnly="true"/>
<Button def:Name="btnUndo" Location="300, 25" Size="80, 25" Text="Undo"
Click="{app.OnUndo}"/>
<Button def:Name="btnRedo" Location="300, 55" Size="80, 25" Text="Redo"
Click="{app.OnRedo}"/>
<Button def:Name="btnAccept" Location="400, 25" Size="100, 25"
Text="Accept Changes" Click="{app.OnAcceptChanges}"/>
<Button def:Name="btnReject" Location="400, 55" Size="100, 25"
Text="Reject Changes"
Click="{app.On<CODE>RejectChanges</CODE>}"/>
<CheckBox def:Name="ckColErr" Location="300, 100" Size="200, 20"
Text="Set column error on change"/>
<CheckBox def:Name="ckRevertCol" Location="325, 120" Size="200, 20"
Text="Revert field value" Enabled="{ckColErr.Checked}"/>
<CheckBox def:Name="ckRowErr" Location="300, 140" Size="200, 20"
Text="Set row error on change"/>
<Button Location="300, 170" Size="80, 25" Text="Clear"
Click="{app.OnClear}"/>
<Button Location="300, 200" Size="80, 25" Text="Collect"
Click="{app.OnCollect}"/>
</Controls>
<ctd:BindHelper Source="{ckColErr}" SourceProperty="Checked"
Destination="{app}" DestinationProperty="ColumnErrorOnChange"/>
<ctd:BindHelper Source="{ckRowErr}" SourceProperty="Checked"
Destination="{app}" DestinationProperty="RowErrorOnChange"/>
<ctd:BindHelper Source="{ckRevertCol}" SourceProperty="Enabled"
Destination="{ckColErr}" DestinationProperty="Checked"/>
<ctd:BindHelper Source="{ckRevertCol}" SourceProperty="Checked"
Destination="{app}" DestinationProperty="RevertFieldValue"/>
</Form>
</MyXaml>
使用演示程序,您可以看到随着您操作 `DataGrid`,事务被添加到日志中。例如:
当您单击“撤销”时,事务日志中的行索引会向上移动,以便始终显示上一个应用的事务。例如,单击一次撤销:
为了说明未添加到 `DataTable` 行集合的新行问题,我们可以向下滚动到带“*”的行。
如果我们光标移回,行会恢复为“*”,但 NewRow 事务仍然存在。我们可以来回执行此操作任意次数。
如果我们单击“收集”按钮,所有这些未提交的新行事务都会被删除。
尝试撤销未提交的空行时,会引发一个有趣的异常(这是由框架引发的):
显然,解决方案是在撤销之前执行一次收集。但是我特意在演示代码中省略了任何“智能”处理,以便您可以玩演示并查看 .NET Framework 如何与事务记录器交互,而不是隐藏像这样的“陷阱”。
结论
最初记录 `DataTable` 事务的概念似乎是一项简单的任务。然而,它充满了错误的架构决策和棘手的实现。在这一点上,我认为我已经实现了原始目标与 UI 和应用程序驱动环境中 `DataTable` 的复杂性之间的适当平衡。我已决定将事务记录器的另一个方面,即序列化事务并将其应用到单独的(镜像和/或远程)`DataTable` 的能力,与此实现分开。而**那个**实现将需要主键字段,因为 `DataRow` 实例将不同!