使用 C# 和 MVC 实现审计跟踪和数据版本控制






4.81/5 (51投票s)
实现审计跟踪的方法
引言
在某些领域,经常需要实现数据的审计日志。一个很好的例子是医疗记录应用程序,其中数据至关重要,任何对其的更改不仅可能对企业产生法律影响,还可能对患者的健康产生影响。本文描述了一种使用 C# 反射和存储在 SQL 数据库中的数据来实现审计日志和数据版本控制系统的简单但有效的方法。
显示最终结果的截图
设置
SQL 设置
为了进行设置和测试,我们创建了两个数据库表。一个存储简单的“Person
”信息,另一个存储“审计日志/版本”信息。
Person “SampleData”
ID int
FirstName nvarchar(10)
LastName nvarchar(10)
DateOfBirth date
Deleted bit
在示例数据表中,我们使用“deleted
”字段来指示核心记录是活动的还是“已删除的”。从数据管理的角度来看,仅将关键记录标记为已删除可能更清晰——我们将在本文后面看到这种实现的示例。
“审计日志”数据
ID int
KeyFieldID int
AuditActionTypeENUM int
DateTimeStamp datetime
DataModel nvarchar(100)
Changes nvarchar(MAX)
ValueBefore nvarchar(MAX)
ValueAfter nvarchar(MAX)
在我们的审计日志表中,我们按如下方式使用字段:
- “
KeyFieldID
”存储与Person
-SampleData.ID
字段的链接。 - “
AuditActionTypeENUM
”告诉我们这是哪种类型的审计记录(创建、编辑、删除)。 - “
DateTimeStamp
”给出事件发生的时间点。 - “
DataModel
”是发生更改并正在记录的数据模型/视图模型名称。 - “
Changes
”是前一个数据状态与更改之间的差异/增量的 XML/JSON 表示。 - “
ValueBefore
/ValueAfter
”存储更改事件发生前/后的DataModel
数据的 XML/JSON 快照。
ValueBefore
/After
是可选的——根据系统的复杂性,拥有前后快照可能很有用,以便能够以细粒度级别重建数据。
基本脚手架
为了测试设计的系统,我创建了一个使用 Entity Framework 的简单 MVC 应用程序。我设置了非常基础的控制器和数据模型方法来提供索引数据,并允许 CRUD 操作。还有支持的 ViewModel。
ViewModel
public class SampleDataModel
{
public int ID { get; set; }
public string FirstName { get; set; }
public string lastname { get; set; }
public DateTime DateOfBirth { get; set; }
public bool Deleted { get; set; }
...
}
控制器
public ActionResult Edit(int id)
{
SampleDataModel SD = new SampleDataModel();
return View(SD.GetData(id));
}
public ActionResult Create()
{
SampleDataModel SD = new SampleDataModel();
SD.ID = -1; // indicates record not yet saved
SD.DateOfBirth = DateTime.Now.AddYears(-25);
return View("Edit", SD);
}
public void Delete(int id)
{
SampleDataModel SD = new SampleDataModel();
SD.DeleteRecord(id);
}
public ActionResult Save(SampleDataModel Rec)
{
SampleDataModel SD = new SampleDataModel();
if (Rec.ID == -1)
{
SD.CreateRecord(Rec);
}
else
{
SD.UpdateRecord(Rec);
}
return Redirect("/");
}
CRUD 方法
public void CreateRecord(SampleDataModel Rec)
{
AuditTestEntities ent = new AuditTestEntities();
SampleData dbRec = new SampleData();
dbRec.FirstName = Rec.FirstName;
dbRec.LastName = Rec.lastname;
dbRec.DateOfBirth = Rec.DateOfBirth;
ent.SampleData.Add(dbRec);
ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking
}
public bool UpdateRecord(SampleDataModel Rec)
{
bool rslt = false;
AuditTestEntities ent = new AuditTestEntities();
var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);
if (dbRec != null) {
dbRec.FirstName = Rec.FirstName;
dbRec.LastName = Rec.lastname;
dbRec.DateOfBirth = Rec.DateOfBirth;
ent.SaveChanges();
rslt = true;
}
return rslt;
}
public void DeleteRecord(int ID)
{
AuditTestEntities ent = new AuditTestEntities();
SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);
if (rec != null)
{
rec.Deleted = true;
ent.SaveChanges();
}
}
对于 UI 示例,我调整了 MVC 默认的 Bootstrap,提供了一个非常基础的 EDIT 和 Index 视图。
索引视图是使用 MVC Razor 语法在表格上构建的,该表格使用 Bootstrap 进行样式设置。还有三个操作按钮,分别显示“**活动记录**”(即未删除的记录)、所有记录以及创建新记录。
您应该还记得 SampleData
表的“Deleted
”字段。当我们调用控制器和随后的模型来加载数据时,我们会返回一个包含“deleted
”标志为 true
或 false
的记录列表。
public List<SampleDataModel> GetAllData(bool ShowDeleted)
{
List<SampleDataModel> rslt = new List<SampleDataModel>();
AuditTestEntities ent = new AuditTestEntities();
List<SampleData> SearchResults = new List<SampleData>();
if (ShowDeleted)
SearchResults = ent.SampleData.ToList();
else SearchResults = ent.SampleData.Where(s => s.Deleted == false).ToList();
foreach (var record in SearchResults)
{
SampleDataModel rec = new SampleDataModel();
rec.ID = record.ID;
rec.FirstName = record.FirstName;
rec.lastname = record.LastName;
rec.DateOfBirth = record.DateOfBirth;
rec.Deleted = record.Deleted;
rslt.Add(rec);
}
return rslt;
}
使用 Razor 语法,在创建索引视图时,我们可以设置表格行的颜色来突出显示已删除的记录。
<table class='table table-condensed' >
<thead></thead>
@foreach (var rec in Model)
{
<tr id="@rec.ID" @(rec.Deleted == false ?
String.Empty : "class=alert-danger" )>
<td><a href="/home/edit/@rec.ID">Edit</a>
<a href="#"
onClick="DeleteRecord(@rec.ID)">Delete</a> </td>
<td>
@rec.FirstName
</td>
<td>
@rec.lastname
</td>
<td>
@rec.DateOfBirth.ToShortDateString()
</td>
<td><a href="#" onClick="GetAuditHistory(@rec.ID)">Audit</a></td>
</tr>
}
</table>
这将输出,用红色突出显示记录。
审计
一旦我们实现了脚手架,我们就可以实现审计。概念很简单——在我们向数据库发布更改之前,我们对数据状态有“之前”和“之后”的了解。由于我们在 C# 中,我们可以使用反射来检查数据库中的数据对象,并将其与我们要发布的那个进行比较,从而查看两者之间的差异。
我曾尝试编写自己的反射代码来检查前后对象状态,并在 Stack Overflow 上找到了许多很好的起点。在尝试了几种方法以及我自己的版本后,我决定使用一个现有的 NuGet 包 Compare net objects。它递归地比较对象,因此可以处理相当复杂Object结构。这个包非常有用,并且提供了我们需要的一切,它是开源的,为我节省了时间 #JobDone。
使用 CompareObjects
,这是生成审计信息并将其插入数据库的核心代码。
在“CreateAuditTrail
”方法中,我们发送以下参数:
AuditActionType
=Create
/Delete
/Update
...KeyFieldID
= 链接到此审计所属的表记录。OldObject
/NewObject
= 在将更新保存到数据库之前,数据的现有(数据库)和新(ViewModel
)状态。
public void CreateAuditTrail
(AuditActionType Action, int KeyFieldID, Object OldObject, Object NewObject)
我们在方法中所做的第一件事就是比较对象并获得它们之间的差异。第一次使用该类时,我认为它不起作用,因为只返回了一个差异,但我发送了许多。事实证明,默认情况下,该类只返回一个差异(用于测试),因此我们需要明确定义要查找的最大差异数。我将其设置为 99
,但该值取决于您自己的需求。
// get the differance
CompareLogic compObjects = new CompareLogic();
compObjects.Config.MaxDifferences = 99;
下一步是比较对象,并遍历已识别的差异。
ComparisonResult compResult = compObjects.Compare(OldObject, NewObject);
List<AuditDelta> DeltaList = new List<AuditDelta>();
为了存储更改(增量),我创建了两个辅助类。“AuditDelta
”表示两个字段级值状态(之前和之后)之间的单个差异,而“AuditChange
”是更改的整体序列。例如,假设我们有一个记录,其中包含以下更改:
字段名 | 更改前的值 | 更改后的值 |
名 | Fred | Frederick |
姓 | Flintstone | Forsyth |
在这种情况下,我们将有一个 AuditChange
(主要的更改事件),DateTimeStamp
为当前时间,以及两个更改增量,一个用于 firstname
从 Fred
更改为 Frederick
,另一个用于 Last name 从 Flintstone
更改为 Forsyth
。
以下类代表 Change
和 Delta
s:
public class AuditChange {
public string DateTimeStamp { get; set; }
public AuditActionType AuditActionType { get; set; }
public string AuditActionTypeName { get; set; }
public List<AuditDelta> Changes { get; set; }
public AuditChange()
{
Changes = new List<AuditDelta>();
}
}
public class AuditDelta {
public string FieldName { get; set; }
public string ValueBefore { get; set; }
public string ValueAfter { get; set; }
}
一旦 CompareObject
s 使用其内部反射代码比较前后对象,我们就可以检查结果并提取所需的详细信息。*(**注意**:CompareObjects
在字段/属性名称前面放置一个字段分隔符“.
”.. 我不想要这个,所以我将其删除)。
foreach (var change in compResult.Differences)
{
AuditDelta delta = new AuditDelta();
if (change.PropertyName.Substring(0, 1) == ".")
delta.FieldName = change.PropertyName.Substring(1, change.PropertyName.Length - 1);
delta.ValueBefore = change.Object1Value;
delta.ValueAfter = change.Object2Value;
DeltaList.Add(delta);
}
一旦我们有了增量列表,我们就可以将其保存到数据库中,将更改增量的列表序列化到“changes
”字段。在此示例中,我们使用 JSON.net 进行序列化。
AuditTable audit = new AuditTable();
audit.AuditActionTypeENUM = (int)Action;
audit.DataModel = this.GetType().Name;
audit.DateTimeStamp = DateTime.Now;
audit.KeyFieldID = KeyFieldID;
audit.ValueBefore = JsonConvert.SerializeObject(OldObject);
audit.ValueAfter = JsonConvert.SerializeObject(NewObject);
audit.Changes = JsonConvert.SerializeObject(DeltaList);
AuditTestEntities ent = new AuditTestEntities();
ent.AuditTable.Add(audit);
ent.SaveChanges();
每次更改数据时,我们只需调用 CreateAuditTrail
方法,发送操作类型(Create
/Delete
/Update
)以及前后值。
在 UpdateRecord
中,我们将*新*记录(Rec
)作为参数发送,并从数据库检索旧记录,然后将两者都作为通用对象发送到我们的 CreateAuditTrail
方法。
public bool UpdateRecord(SampleDataModel Rec)
{
bool rslt = false;
AuditTestEntities ent = new AuditTestEntities();
var dbRec = ent.SampleData.FirstOrDefault(s => s.ID == Rec.ID);
if (dbRec != null) {
// audit process 1 - gather old values
SampleDataModel OldRecord = new SampleDataModel();
OldRecord.ID = dbRec.ID; // copy data from DB to "OldRecord" ViewModel
OldRecord.FirstName = dbRec.FirstName;
OldRecord.lastname = dbRec.LastName;
OldRecord.DateOfBirth = dbRec.DateOfBirth;
// update the live record
dbRec.FirstName = Rec.FirstName;
dbRec.LastName = Rec.lastname;
dbRec.DateOfBirth = Rec.DateOfBirth;
ent.SaveChanges();
CreateAuditTrail(AuditActionType.Update, Rec.ID, OldRecord, Rec);
rslt = true;
}
return rslt;
}
在我们没有前一个值或后一个值的情况下(例如,在 create
中,我们没有先前的状态;在 delete
中,我们没有后一个状态),我们发送一个空对象。
public void CreateRecord(SampleDataModel Rec)
{
AuditTestEntities ent = new AuditTestEntities();
SampleData dbRec = new SampleData();
dbRec.FirstName = Rec.FirstName;
dbRec.LastName = Rec.lastname;
dbRec.DateOfBirth = Rec.DateOfBirth;
ent.SampleData.Add(dbRec);
ent.SaveChanges(); // save first so we get back the dbRec.ID for audit tracking
SampleData DummyObject = new SampleData();
CreateAuditTrail(AuditActionType.Create, dbRec.ID, DummyObject, dbRec);
}
public void DeleteRecord(int ID)
{
AuditTestEntities ent = new AuditTestEntities();
SampleData rec = ent.SampleData.FirstOrDefault(s => s.ID == ID);
if (rec != null)
{
SampleData DummyObject = new SampleData();
rec.Deleted = true;
ent.SaveChanges();
CreateAuditTrail(AuditActionType.Delete, ID, rec, DummyObject);
}
}
Hansel and Gretel
因此,我们的审计日志已进入数据库——现在,就像童话故事一样,我们需要将这些面包屑取出来展示给用户(但希望我们的面包屑会留下来!)。
在服务器端,我们创建一个方法,为给定的记录 ID 提取审计历史,并按最新的更改优先的顺序对数据进行排序。
public List<AuditChange> GetAudit(int ID)
{
List<AuditChange> rslt = new List<AuditChange>();
AuditTestEntities ent = new AuditTestEntities();
var AuditTrail = ent.AuditTable.Where(s => s.KeyFieldID == ID).
OrderByDescending(s => s.DateTimeStamp);
var serializer = new XmlSerializer(typeof(AuditDelta));
foreach (var record in AuditTrail)
{
AuditChange Change = new AuditChange();
Change.DateTimeStamp = record.DateTimeStamp.ToString();
Change.AuditActionType = (AuditActionType)record.AuditActionTypeENUM;
Change.AuditActionTypeName = Enum.GetName(typeof(AuditActionType),
record.AuditActionTypeENUM);
List<AuditDelta> delta = new List<AuditDelta>();
delta = JsonConvert.DeserializeObject<List<AuditDelta>>(record.Changes);
Change.Changes.AddRange(delta);
rslt.Add(Change);
}
return rslt;
}
我们还实现了一个 controller
方法,将此数据作为 JSON 结果发送回来。
public JsonResult Audit(int id)
{
SampleDataModel SD = new SampleDataModel();
var AuditTrail = SD.GetAudit(id);
return Json(AuditTrail, JsonRequestBehavior.AllowGet);
}
在客户端,我们使用 Bootstrap 创建一个模态弹出窗口,其中包含一个名为“audit
”的 DIV
,我们将在此处注入审计日志数据。
<div id="myModal" class="modal fade">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<button type="button" class="close"
data-dismiss="modal" aria-hidden="true">
×</button>
<h4 class="modal-title">Audit history</h4>
</div>
<div class="modal-body">
<div id="audit"></div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-primary"
data-dismiss="modal">Close</button>
</div>
</div>
</div>
</div>
附加到每个数据行,我们都有一个 JS 函数,它使用 AJAX 调用服务器端代码。
<a href="#" onClick="GetAuditHistory(@rec.ID)">Audit</a>
JavaScript 代码调用服务器端控制器,传递所选表格行的记录 ID,并接收 JSON 数组。它迭代数组,构建一个格式精美的 HTML 表格,该表格显示在模态窗口中。
function GetAuditHistory(recordID) {
$("#audit").html("");
var AuditDisplay = "<table class='table table-condensed' cellpadding='5'>";
$.getJSON( "/home/audit/"+ recordID, function( AuditTrail ) {
for(var i = 0; i < AuditTrail.length; i++ )
{
AuditDisplay = AuditDisplay + "<tr class='active'>
<td colspan='2'>Event date: " + AuditTrail[i].DateTimeStamp + "</td>";
AuditDisplay = AuditDisplay + "<td>Action type: " +
AuditTrail[i].AuditActionTypeName + "</td></tr>";
AuditDisplay = AuditDisplay + "<tr class='text-warning'>
<td>Field name</td><td>Before change</td><td>After change</td></tr>";
for(var j = 0; j < AuditTrail[i].Changes.length; j++ )
{
AuditDisplay = AuditDisplay + "<tr>";
AuditDisplay = AuditDisplay + "<td>" +
AuditTrail[i].Changes[j].FieldName + "</td>";
AuditDisplay = AuditDisplay + "<td>" +
AuditTrail[i].Changes[j].ValueBefore + "</td>";
AuditDisplay = AuditDisplay + "<td>" +
AuditTrail[i].Changes[j].ValueAfter + "</td>";
AuditDisplay = AuditDisplay + "</tr>";
}
}
AuditDisplay = AuditDisplay + "</table>">
$("#audit").html(AuditDisplay);
$("#myModal").modal('show');
});
}
这是最终结果,显示了记录的创建、更新和最终删除过程。
摘要
本文介绍了在 C# 系统中实现审计日志系统的重要功能。它基于其主要用途是用户/安全审计的假设,并包含足够的快照信息,以便您(根据所需的详细程度)能够实时重建数据记录的快照。通过下载 SQL 脚本和代码,您可以自己尝试。
如果您觉得本文有用,请花几秒钟时间给页面顶部的文章投一票!
值得关注的方面/注意事项
- 我在此示例中使用了 JSON——如果您改用 XML,您可以通过 XML 属性装饰来更精确地控制数据的存储方式以及字段的命名方式(用于向用户显示),这将是对此实现的一个很好的改进。
- SQL 中的示例是在一个名为“
Changes
”的字段中实现所有更改的——可以改用AuditChanges
和Deltas
之间的另一个关系表来实现,如果审计历史是您解决方案中经常使用的部分,这将提供更大的灵活性来进行审计历史搜索。 - 在示例显示数据库记录和
ViewModel
记录之间的手动映射的地方,使用 AutoMapper 等工具来实现相同的结果会更有效率,代码量也会更少。 - 在我有一个字段“
AuditActionTypeName
”的地方——它会自动映射到传递给创建审计方法的模型/对象名称。这用于跟踪正在存储的数据的用户视图。但是,您可以选择以其他方式实现,存储表名、类名等。 - 此实现仅处理创建/更新/删除操作——为了安全起见,您可能还需要实现审计用户查看了特定记录的情况。在这种情况下,您还需要记录
UserID
,可能还有其他信息,如 IP 地址、机器名等。
历史
- 2015 年 8 月 17 日 - 发布版本 1