Backend Created for Tilman Börner ([email protected]) - 2533 on 30.04.2014 15:04:08 Please do not make illegal copies! Eine SQL-Server-Änderungshistorie implementieren Der einfachere Weg Es muss kein kompliziertes Event Sourcing sein, um in einer relationalen Datenbank Änderungen an den Datensätzen zu protokollieren. Es geht auch einfacher mit zwei eigens dafür geschaffenen Tabellen. Auf einen Blick Andy Grothe ist freiberuflicher Softwarearchitekt und spezialisiert auf die Entwicklung von WCF-, WPF-, Silverlight-, Windows-8- und WindowsPhone-Anwendungen. Sie erreichen ihn über seine Website www.P-Serv.de oder direkt unter [email protected]. Inhalt ▸ Mit zusätzlichen Datenbank­ tabellen Veränderungen protokollieren. ▸ Implementierung über Trigger und die SQL Server Data Tools. ▸ Laufzeitvergleich verschiedener Techniken. dnpCode A1405History W er ärgert sich nicht, wenn die EMail eines Kunden aus der Firmendatenbank gelöscht wurde; egal ob das mit Absicht oder aus Versehen geschah. Wie kommen Sie jetzt so schnell wie möglich wieder an die Daten? Eine Möglichkeit wäre, das Datenbank-Backup des Vortags zurückzuspielen und dort nachzuschauen. Das geht meistens nicht ohne Zutun der Administratoren. Einfacher wäre es, wenn der Benutzer ohne Weiteres und ohne Zutun eines Dritten die Möglichkeit hätte, in einer Änderungshistorie nachzusehen. Um den Anwendern ein Protokoll der Datenänderungen zur Verfügung stellen, gibt es mehrere Ansätze. Einer wäre der Einsatz einer Datenbank mit Event Sourcing; die dotnetproAusgabe 12/2013 widmete dem Thema Event Sourcing einen ganzen Schwerpunkt. Eine weitere Möglichkeit bietet Microsoft seit dem SQL Server 2008 mit dem Merkmal „Tracking Data Changes“ [1]. Es gibt aber noch eine andere Möglichkeit, die sich mit geringem Aufwand in eine bestehende SQL-Server-Datenbank einbauen lässt und zudem den relationalen Charakter der Daten beibehält. Die Werkzeuge dafür sind Visual Studio 2012 und die SQL Server Data Tools (SSDT) [2]. Welche Rolle diese Tools spielen, wird sich am Schluss erweisen. [Abb. 1] Die Live- und History-Tabellen des Beispielprojekts und ihre Beziehungen. 76 Live- und History-Tabellen Das hier vorgestellte Konzept basiert auf zusätzlichen Tabellen in der Datenbank, deren Aufgabe es ist, die Datenänderungen zu erfassen. Sie werden als „Live-Tabellen“ und „History-Tabellen“ bezeichnet. Live-Tabellen stellen die jeweils letzten (und damit aktuellen) Datensätze bereit; die History-Tabellen dagegen erfassen die Änderungen in den Live-Tabellen. Die Protokollierung – auch temporale Datenhaltung oder Historisierung genannt – wird für jede Live-Tabelle separat konfiguriert. Dazu ist für jede Live-Tabelle eine History-Tabelle anzulegen. In der Live-Tabelle sorgt dann ein Trigger dafür, dass bei jedem Insert-, Update- oder Delete-Vorgang die History-Tabelle entsprechend aktualisiert wird. Um dies an einem Beispiel zu veranschaulichen, öffnen Sie Visual Studio und legen ein neues SQL-Server-Datenbankprojekt an. Die Projekteigenschaften stellen auf der Registerkarte Projekteinstellungen die Auswahl der Zielplattform zur Verfügung, im vorliegenden Fall ist dies SQL Server 2012. Auf der Registerkarte Debuggen kann die Verbindungszeichenfolge zum SQL Server konfiguriert werden, dabei hilft ein Assistent. Er startet nach einem Mausklick auf die Schaltfläche Bearbeiten … Nun erstellen Sie einen Ordner namens Ta­b les und legen in ihm die Live-Tabellen t_Address und t_ContactDetails sowie die History-Tabellen th_Address und th_ContactDetails an; ihre Schemas sind in Abbildung 1 zu sehen. Die History-Tabellen folgen immer dem gleichen Schema: Sie enthalten die gleichen Felder wie die jeweils zugrunde liegende Live-Tabelle und dazu noch die folgenden vier Felder: ◼ h ID ◼ h Command ◼ h User ◼ h DateTime hID ist der neue Primärschlüssel der Tabelle, der mit Autoinkrement ausgestattet wird; hCommand informiert darüber, ob es sich um einen Insert-, Update- oder Delete-Befehl handelt; hUser speichert den Namen des an der Session angemeldeten Benutzers; hDateTime enthält das Datum und die Zeit des Vorgangs. Die restlichen Felder entsprechen genau der 5.2014 www.dotnetpro.de Backend Created for Tilman Börner ([email protected]) - 2533 on 30.04.2014 15:04:08 Please do not make illegal copies! Definition der jeweiligen Live-Tabelle. Ausnahme ist der Primärschlüssel mit seinem Autoinkrement, der vom Feld ID auf das Feld hID verschoben wird. Die Fremdschlüsselbeziehungen zwischen den History-Tabellen können natürlich nicht mehr gesetzt werden, da in ihnen die Felder, die ehemals den Primärschlüssel enthielten, keine eindeutigen Felder mehr sind. Jetzt können die Tabellen bereits auf dem SQL Server eingerichtet werden. Dazu drücken Sie die Taste [F5] oder klicken im Kontextmenü des Projekts auf den Menüeintrag Veröffentlichen … Das Visual-Studio-Fenster Datentoolvorgänge zeigt an, ob der Vorgang erfolgreich war (Abbildung 2). [Abb. 2] Im Fenster „Datentoolvorgänge“ ist zu sehen, ob das Anlegen der Tabellen erfolgreich war. Trigger startet Historisierung Nachdem die Live- und History-Tabellen angelegt sind, bedarf es eines Mechanismus, der Datensatzänderungen feststellt und diese in den History-Tabellen speichert. Für jede Live-Tabelle wird also ein Trigger eingerichtet. Dazu erhält das Projekt einen neuen Ordner namens Triggers. Diesem fügen Sie einen SQL CLR VB-Trigger hinzu; bei einem C#-Projekt ist es ein SQL CLR C#-Trigger. Hier heißt es aufgepasst, denn an dieser Stelle besteht Verwechslungsgefahr mit dem normalen T-SQL-Trigger. Um ein möglichst gutes Laufzeitverhalten zu erzielen, kommt hier ein expliziter CLR-Trigger zum Zug. Abbildung 3 zeigt den Aufbau der Trigger-Klasse, die hier HistoryTrigger heißt. Die Konstante sqlToTemplate enthält einen mit Platzhaltern versehenen T-SQL-Befehl. Es folgen zwei Trigger, für jede Live-Tabelle einer. Am Ende enthält die Klasse noch die Hilfsfunktion ConvertToSql(), die in jedem Trigger mehrmals verwendet wird und die in Listing 1 zu sehen ist. Je nach Typ bereitet sie den Wert für den T-SQL Befehl auf [3]. Datumswerte formatiert sie nach ISO-Norm, damit der SQL Server, bedingt durch unterschiedliche Ländereinstellungen, nicht ins Stolpern gerät. Textwerte setzt die Funktion in einfache Hochkommas und natürlich werden die Werte auch auf DBNull geprüft. Eine kleine Einschränkung gibt es bei Feldern, die intern dem DbType-Enumerationswert Binary entsprechen. Dazu zählen die SQL-Server-Datenbankmodultypen Binary, Image, Rowversion, Timestamp und VarBinary [3]. Enthält eine Tabelle die Felder Timestamp oder Rowversion, wird das Speichern des Ände- [Abb. 3] Die Gliederung der HistoryTrigger-Klasse. [Abb. 4] Ein Ausschnitt aus der History-Tabelle t_Address nach dem Testlauf. rungsdatensatzes fehlschlagen, da diese beiden Felder nicht manuell gesetzt oder geändert werden dürfen. In der Hilfsfunktion muss die Codezeile, die für die Behandlung von DbType-Feldern des Typs Binary zuständig ist, auskommentiert werden; das heißt aber auch, dass Typen wie Binary oder VarBinary nicht in die Änderungshistorie übernommen werden. Der Quellcode der Trigger ist bis auf die ersten drei Codezeilen immer identisch, siehe Listing 2. Die Information über die Änderungen wird aus den beiden temporären Tabellen Inserted oder Deleted gewonnen. Diese stehen nur im Kontext eines Triggers zur Verfügung. Das ist auch der Grund, weshalb sich der Quellcode nicht weiter modularisieren lässt. Nun wird jeder Datensatz verarbeitet. Dabei werden die Spaltennamen und die Feldinhalte zusammengetragen, der TSQL-Befehl erzeugt und die Änderungen in die History-Tabelle gespeichert. Das Ergebnis lässt sich sehen Nachdem die HistoryTrigger-Klasse fertiggestellt ist, kann sie auf dem SQL Server eingerichtet und getestet werden. Um die Funktionsfähigkeit der Klasse zu prüfen, tragen Sie manuell oder per T-SQL-Skript einen Datensatz in die Tabelle t_Address ein, ändern ihn einige Male und löschen ihn zuletzt wieder. Die History-Tabelle sollte dann in etwa aussehen, wie es in Abbildung 4 dargestellt ist, während der Datensatz in der Live-Tabelle bereits nicht mehr vorhanden ist – denn er wurde ja wieder gelöscht. Der Praxistest Nun stellt sich lediglich noch die Frage, wie viel Zeit das zusätzliche Ausführen des Triggers eigentlich benötigt. Eine kleine Testreihe auf dem Entwicklungsrechner des Autors (Windows 8.1, Intel i5, 8 GB RAM, SQL Server 2012 Developer Edition) beantwortet die Frage. Dazu wurde der Trigger der Live-Tabelle t_Address deaktiviert und für das Feld Birthday ein Standardwert gesetzt; die Funktion getdate() setzte beim Einfügen eines Datensatzes einen aktuellen Zeitstempel. Ein TSQL-Skript führte dann hundert Mal den folgenden Befehl aus : INSERT INTO t_Address ( LastName, FirstName, Street, PostalCode, City, Country) VALUES ( 'Donald', 'Duck', 'Am Ententeich 3b', '99770', 'Entenhausen', 'Germany') www.dotnetpro.de 5.201477 Backend Eine SQL-Server-Änderungshistorie implementieren Created for Tilman Börner ([email protected]) - 2533 on 30.04.2014 15:04:08 Please do not make illegal copies! Listing 1 Die Hilfsfunktion ConvertToSql(). Public Shared Function ConvertToTSql(ByVal value As Object) As String If TypeOf value Is System.DBNull Then Return "NULL" ElseIf TypeOf value Is System.DateTime Then Return "'" & CDate(value).ToString("yyyy-MM-ddTHH:mm:ss.fffffff") & "'" ElseIf TypeOf value Is System.TimeSpan Then Return "'" & value.ToString & "'" ElseIf TypeOf value Is System.DateTimeOffset Then Return "'" & CType(value, DateTimeOffset).ToString("o") & "'" ElseIf TypeOf value Is SqlTypes.SqlBinary Then Return If(CType(value, SqlBinary).IsNull, "NULL", "0x" & BitConverter.ToString(CType(value, SqlBinary).Value).Replace("-", "")) ElseIf TypeOf value Is SqlTypes.SqlBoolean Then Return If(CType(value, SqlBoolean).IsNull, "NULL", CType(value, SqlBoolean).ByteValue.ToString) ElseIf TypeOf value Is SqlTypes.SqlDateTime Then Return If(CType(value, SqlDateTime).IsNull, "NULL", "'" & CType(value, SqlDateTime).ToString & "'") ElseIf TypeOf value Is SqlTypes.SqlDecimal Then Return If(CType(value, SqlDecimal).IsNull, "NULL", CType(value, SqlDecimal).Value.ToString(CultureInfo.CreateSpecificCulture("en-US"))) ElseIf TypeOf value Is SqlTypes.SqlDouble Then Return If(CType(value, SqlDouble).IsNull, "NULL", CType(value, SqlDouble).Value.ToString(CultureInfo.CreateSpecificCulture("en-US"))) ElseIf TypeOf value Is SqlTypes.SqlGuid Then Return If(CType(value, SqlGuid).IsNull, "NULL", "'" & CType(value, SqlGuid).Value.ToString & "'") ElseIf TypeOf value Is SqlTypes.SqlInt16 Then Return If(CType(value, SqlInt16).IsNull, "NULL", CType(value, SqlInt16).Value.ToString) ElseIf TypeOf value Is SqlTypes.SqlInt32 Then Return If(CType(value, SqlInt32).IsNull, "NULL", CType(value, SqlInt32).Value.ToString) ElseIf TypeOf value Is SqlTypes.SqlInt64 Then Return If(CType(value, SqlInt64).IsNull, "NULL", CType(value, SqlInt64).Value.ToString) ElseIf TypeOf value Is SqlTypes.SqlMoney Then Return If(CType(value, SqlMoney).IsNull, "NULL", CType(value, SqlMoney).Value.ToString(CultureInfo.CreateSpecificCulture("en-US"))) ElseIf TypeOf value Is SqlTypes.SqlSingle Then Return If(CType(value, SqlSingle).IsNull, "NULL", CType(value, SqlSingle).Value.ToString(CultureInfo.CreateSpecificCulture("en-US"))) ElseIf TypeOf value Is SqlTypes.SqlString Then Return If(CType(value, SqlString).IsNull, "NULL", "'" & CType(value, SqlString).Value.ToString & "'") ElseIf TypeOf value Is SqlTypes.SqlXml Then Return If(CType(value, SqlXml).IsNull, "NULL", "'" & CType(value, SqlXml).Value.ToString & "'") Else Return "NULL" End If End Function 78 5.2014 www.dotnetpro.de Backend Created for Tilman Börner ([email protected]) - 2533 on 30.04.2014 15:04:08 Please do not make illegal copies! Nach einem Lauf des Skripts ließ sich anhand des Feldes Birthday schnell ausrechnen, wie viel Zeit vom Einfügen des ersten Datensatzes bis zum letzten verstrichen ist. Anschließend wurde das Ganze mit aktiviertem Trigger wiederholt. Nun zeigt sich auch, welche Rolle die eingangs erwähnten SQL Server Data Tools spielen. Bei den Vorbereitungen zu diesem Artikel hat der Autor das Ganze zunächst mit Triggern und einer gespeicherten Prozedur auf herkömmliche Art und Weise per T-SQL entwickelt. Dies ließ sich allerdings zum einen nicht debuggen und zum anderen war der Unterschied im Laufzeitverhalten so dramatisch, dass der Artikel zunächst „gestorben“ schien – zum Historisieren von Datensätzen kam dieser Weg nicht ernsthaft infrage. Erst der Einsatz der SQL Server Data Tools und der mit ihnen ausgelieferten .NETAssemblies gaben dieser Lösung den nötigen Schub und ließen den Einsatz für professionelle Zwecke zu. Die Zeiten für das Einfügen von 100 Datensätzen betrug ◼ 50 Millisekunden ohne Trigger ◼ 200 Millisekunden mit CLR-Trigger ◼ 2800 Millisekunden mit T-SQL-Trigger Listing 2 Daten vom Typ SqlBinary erfordern einen speziellen Umgang. <Microsoft.SqlServer.Server.SqlTrigger(Name:="HistoryTrigger_t_Address", _ Target:="t_Address", Event:="FOR INSERT, UPDATE, DELETE")> _ Public Shared Sub HistoryTrigger_t_Address() Const historyTableName As String = "th_Address" Dim Dim Dim Dim Dim Dim Dim Dim Dim action As String = SqlContext.TriggerContext.TriggerAction.ToString sqlFrom As String sqlTo() As String = Nothing dynFields As String dynValues As String sqlConnection As SqlConnection sqlCommand As SqlCommand sqlDataReader As SqlDataReader index As Integer Try 'Zugriff auf die Tabellen 'Deleted' oder 'Inserted' If SqlContext.TriggerContext.TriggerAction = TriggerAction.Delete Then sqlFrom = "SELECT * FROM Deleted" Else sqlFrom = "SELECT * FROM Inserted" End If sqlConnection = New SqlConnection("context connection=true") sqlConnection.Open() sqlCommand = sqlConnection.CreateCommand() sqlCommand.CommandText = sqlFrom sqlDataReader = sqlCommand.ExecuteReader Do While sqlDataReader.Read() dynFields = Nothing dynValues = Nothing Ein T-SQL-Trigger erfordert also 56mal so viel Zeit wie das Ausführen ohne Trigger, ein CLR-Trigger nur noch viermal so viel. Dies ist ein durchaus akzeptabler Wert. 'jede Tabellen-Spalte verarbeiten For index = 0 To SqlContext.TriggerContext.ColumnCount - 1 If dynFields Is Nothing Then dynFields = "[" & sqlDataReader.GetName(index) & "]" dynValues = ConvertToTSql(sqlDataReader.GetSqlValue(index)) Else dynFields &= ", [" & sqlDataReader.GetName(index) & "]" dynValues &= ", " & ConvertToTSql(sqlDataReader.GetSqlValue(index)) End If Next Fazit Das Protokollieren von Änderungen von Datensätzen in Datenbanken ist ein effizienter Weg, um beispielsweise bei unabsichtlichen Änderungen an Daten den vorherigen Zustand wiederherzustellen – ganz ohne Wühlen in einem Backup. Ein Weg hierfür wäre die Implementierung von Event Sourcing, aber so umständlich muss es gar nicht sein. Das Einrichten von zusätzlichen Tabellen, in denen sich Datenänderungen über die Zeit hinweg festhalten lassen, sogenannten HistoryTabellen, erfordert nur geringen Aufwand und bleibt dem relationalen Charakter der Daten treu. [jp] [1] M SDN: Nachverfolgen von Datenänderungen (SQL Server), www.dotnetpro.de/SL1405History1 [2] M SDN: SQL Server Data Tools, www.dotnetpro.de/SL1405History2 [3] M SDN: SQL Server-Datentypmappings, www.dotnetpro.de/SL1405History3 'T-Sql zwischenspeichern index = If(sqlTo Is Nothing, 0, sqlTo.Length) ReDim Preserve sqlTo(index) sqlTo(index) = String.Format(sqlToTemplate, historyTableName, dynFields, action, Date.Now.ToString("yyyy-MM-ddTHH:mm:ss.fffffff"), dynValues) Loop sqlDataReader.Close() 'T-Sql ausführen (Änderung wird in die History-Tabelle geschrieben) For index = 0 To sqlTo.Length - 1 sqlCommand = sqlConnection.CreateCommand() sqlCommand.CommandText = sqlTo(index) sqlCommand.ExecuteNonQuery() Next Catch ex As Exception End Try End Sub www.dotnetpro.de 5.201479