Ausgangssituation
Das betroffene Altsystem wurde vor vielen Jahren von einem Kunden entwickelt und inzwischen für die Wartung und Weiterentwicklung an uns übergeben. Es handelt sich um eine in C# programmierte .NET–Applikation mit einem Winforms-GUI, die mit ADO.NET auf zwei Datenbank-Server zugreift, wie in folgender Abbildung grob skizziert.
Als Datenbankmanagementsystem (DBMS) kommt Microsoft SQL Server zum Einsatz. Das Logging in dieser Applikation erfolgt grundsätzlich mit dem Framework log4net.
Die Applikation wird von mehreren hundert Anwendern weltweit benutzt.
Die Aktualisierung von Datensätzen bzw. das Einfügen neuer Datensätze in die Datenbank erfolgt in der Regel über Methoden, die folgendermaßen strukturiert sind:
Methode_Typ_A()
{
try
{
// Verbindungsaufbau zur Datenbank
// Datenbankzugriff
// Folgeaktionen
}
catch ( Exception )
{
// Fehlerbehandlung
}
finally
{
// Verbindungsabbau
}
}
Methode_Typ_B( geöffnete_Datenbankverbindung )
{
// Datenbankzugriff (Datenbankverbindung)
// Folgeaktionen
}
Derartige Methoden werden im Folgenden als „Datenbankmethoden“ bezeichnet.
„Datenbankzugriff“ steht hier stellvertretend für eine oder mehrere Schreib- und Leseoperationen auf der Datenbank.
Typische „Folgeaktionen“ sind z.B. das Auslösen eines Ereignisses oder das Fortschreiben von Logs.
Die „Fehlerbehandlung“ kann darin bestehen, einen Fehlereintrag ins Log zu schreiben und einen entsprechenden Rückgabewert zu setzen, der dann vom Aufrufer ausgewertet werden muss. Manchmal wird aber auch ein modaler Dialog geöffnet, der dem Anwender eine entsprechende Fehlermeldung präsentiert und im günstigsten Fall wird eine Exception geworfen. Methoden vom Typ B fangen keine Exceptions.
Die Datenbankmethoden werden in der Regel aus dem Business-Layer heraus aufgerufen und auf dieser Ebene sind im vorliegenden System auch die Transaktionen anzusiedeln. Es kommt aber auch vor, dass Datenbankmethoden von anderen Datenbankmethoden aufgerufen werden. Insbesondere Methoden vom Typ B werden häufig von Typ A–Methoden benutzt.
Anforderungen
Die nachträgliche Einführung einer Transaktionssicherheit in ein gewachsenes und über Jahre genutztes System ist ein komplexes Vorhaben. Dies wirklich umfassend und wasserdicht umzusetzen, erfordert in der Regel ein grundlegendes Refactoring und betrifft dann potentiell neben der Persistenzschicht auch den Business-Layer, da hier ggf. Prozesse aufgrund ihrer Einbettung in Transaktionen anders geschnitten werden müssen.
Im Projekt-Beispiel konnte dieser Schritt aus Budgetgründen nicht vollständig umgesetzt werden. Stattdessen hat der Kunde entschieden, dass das Ziel der geplanten Modernisierung eine Transaktionssicherheit in den wesentlichen Teilen der Applikation ist.
Bei der Implementierung waren daher folgende wichtige Anforderungen zu beachten:
- Transaktionen dürfen andere Anwender nicht maßgeblich beeinträchtigen.
Das bedeutet in erster Linie, dass Transaktionen nicht lange dauern dürfen; in einer Transaktion bleiben sämtliche Locks, die das DBMS setzt, bis zum Abschluss der Transaktion erhalten. - Die Schnittstelle zwischen dem Business- und dem Database-Layer soll nicht verändert werden. Damit soll der Aufwand für die Anpassung der Business-Methoden eingespart werden.
Sowohl die Signaturen der Datenbankmethoden als auch deren Verhalten sollen also erhalten bleiben. - Wie oben erwähnt sollen nur die wichtigsten und am häufigsten verwendeten Szenarien des Business-Layers durch Transaktionen gesichert werden.
Das bedeutet, dass die Datenbankmethoden sowohl in Transaktionen als auch außerhalb von Transaktionen verwendbar sein und sich ggf. unterschiedlich verhalten müssen.
Umsetzung
Mit herkömmlichen Transaktionen, die an Datenbank-Verbindungen bzw. Datenbank-Sessions gekoppelt sind, ließen sich die Anforderungen nicht erfüllen.
Bei der Suche nach einer Lösung bin ich auf den .NET Namespace System.Transactions gestoßen, mit dem sich relativ einfach Transaktionsanwendungen erstellen lassen. Das Konzept erlaubt es, Codeblöcke durch das Setzen von Transaktionsklammern in eine sogenannte „ambient transaction“ aufzunehmen. Ein Resource Manager (im Sinne von System.Transactions ) wie z.B. der MS SqlServer erkennt die Existenz einer solchen Transaktion und klinkt sich selbstständig ein. Commits und Rollbacks auf der Datenbank werden automatisch durchgeführt, ohne dass Code dafür geschrieben werden müsste.
Der große Vorteil dieser Lösung besteht darin, dass alle Datenbankverbindungen, die in der Transaktion geöffnet werden, automatisch in die Transaktion einbezogen werden. Das bedeutet, dass die Datenbankmethoden vom Typ A weiterhin Datenbankverbindungen öffnen können, in dieser Hinsicht also nicht verändert werden müssen.
Eine gute Quelle, die das Thema vertieft, ist diese Microsoft-Seite: https://learn.microsoft.com/en-us/dotnet/framework/data/transactions/.
Wie werden Beginn und Ende einer Transaktion codiert?
Ein Codeblock in einer Transaktionsklammer sieht in meiner Implementierung prinzipiell folgendermaßen aus:
// Namespace: ClientCode
try
{
using (TransactionScope scope = TransactionScopeHelper.CreateTransactionScope(out transactionId))
{
// Datenbankmethoden aufrufen
scope.Complete();
}
}
catch (Exception ex)
{
// Fehlerbehandlung
}
Der Aufruf von Complete() auf dem TransactionScope stößt den Commit der Datenbankmodifikationen an und sollte die letzte Aktion vor dem ( impliziten ) Verwerfen der TransactionScope-Instanz sein. Wird die Instanz verworfen, ohne dass vorher Complete() aufgerufen wurde, dann wird stattdessen ein Rollback ausgelöst. Das Verwerfen der Instanz schließt die Transaktion.
TransactionScopeHelper ist eine kleine Klasse mit Methoden rund um den TransactionScope, die ich implementiert habe.
CreateTransactionScope(…) dient dazu, auf geordnete Art und Weise eine neue TransactionScope-Instanz anzulegen, um damit die Transaktion zu eröffnen:
// Namespace: TransactionScopeHelper
///
/// Liefert einen Standard-TransactionScope: IsolationLevel = UncomittedRead, Timeout = Default
///
public static TransactionScope CreateTransactionScope(
out string transactionId, TimeSpan? transactionTimeout = null)
{
if (transactionTimeout == null)
transactionTimeout = TransactionTimeout;
var scopeOptions = new TransactionOptions();
scopeOptions.IsolationLevel = IsolationLevel.ReadUncommitted;
scopeOptions.Timeout = (TimeSpan)transactionTimeout;
var scope = new TransactionScope(TransactionScopeOption.Required, scopeOptions);
transactionId = Transaction.Current.TransactionInformation.LocalIdentifier;
if ( ! Instances.ContainsKey(transactionId))
{
var instance = new TransactionScopeHelper();
Instances.Add(transactionId,instance);
}
return scope;
}
private static Dictionary Instances =
new Dictionary();
Auf die Bedeutung der Optionen und Parameter möchte ich an der Stelle nicht näher eingehen, Informationen darüber lassen sich der Microsoft-Klassendokumentation entnehmen.
Die transactionId liefert einen eindeutigen Schlüssel der gerade aktiven Transaktion und damit auch der aktuellen TransactionScope–Instanz, die im statischen Dictionary Instances zwischengespeichert wird. Sinn und Zweck dieses Schlüssels und des Dictionaries werden im weiteren Verlauf noch erläutert.
Um Informationen über die Transaktionsdauer zu erhalten, habe ich zusätzlich Code integriert, der Laufzeiten misst und protokolliert. Die erweiterte Transaktionsklammer sieht so aus:
// Namespace: ClientCode
string transactionName = " Irgendeine Transaktionsbezeichnung";
string transactionId = null;
try
{
Log.InfoFormat("### Start Transaktion {0}. ###", transactionName);
var stopwatch = new Stopwatch();
stopwatch.Start();
using (var scope = TransactionScopeHelper.CreateTransactionScope(out transactionId))
{
// Datenbankmethoden aufrufen
scope.Complete();
}
stopwatch.Stop();
Log.InfoFormat("### Transaktion {0} erfolgreich. ###", transactionName);
Log.DebugFormat("### Transaktionsdauer: {0} ms ###", stopwatch.ElapsedMilliseconds);
}
catch (Exception ex)
{
// Fehlerbehandlung
}
Was ist bei der Fehlerbehandlung zu beachten?
Es ist natürlich grundlegend wichtig, dass alle Fehler, die zum Scheitern einer Transaktion führen sollen, bis ins Business-Layer vordringen und dort korrekt verarbeitet werden.
Meine Implementierung setzt darauf, dass im Fehlerfall Exceptions geworfen werden, die auch tatsächlich erst in der Transaktionsklammer gefangen werden dürfen. Eine solche Exception verhindert, das Complete() aufgerufen wird und führt damit automatisch zum Rollback der Transaktion.
Das bedeutet, dass die Fehlerbehandlung in allen verwendeten Datenbankmethoden vereinheitlicht werden muss, wenn eine solche Methode innerhalb einer Transaktion aufgerufen wird. Um zu erkennen, ob das der Fall ist, wird folgende kleine Hilfsmethode aus dem TransactionScopeHelper verwendet:
// Namespace: TransactionScopeHelper
public static bool RunsInTransaction()
{
return ( Transaction.Current != null );
}
Es hat sich bewährt, für diesen Anwendungsfall eine spezielle Exception-Klasse einzuführen. Man kann dann eine Methode als „bereit zum Aufruf in einer Transaktion“ markieren, indem man die Exception in den XML-Header der Methode aufnimmt. Das ist vor allem dann interessant, wenn viele Methoden angepasst werden müssen und mehr Anpassungen erforderlich sind als nur das Werfen einer Exception; das erleichtert es, den Überblick zu behalten.
Beispiel:
// Namespace: ClientCode
///
/// Beschreibung der Methode
///
/// Wird im Fehlerfall geworfen.
public void methode()
{
// do something
// Fehlerfall
If ( TransactionScopeHelper.RunsInTransaction() )
{
throw new MyTransactionException(„Fehlerbeschreibung“);
}
}
Was gehört nicht in eine Transaktion?
Nicht in eine Transaktion gehören Funktionen, die erwartungsgemäß lange laufen oder die u.U. sogar die Transaktion pausieren. Dazu gehören u.a.:
- Das Öffnen eines modalen, interaktiven Dialogs, und sei es auch nur einer MessageBox.
Ich denke, das ist offensichtlich: die Dauer der Transaktion ist gekoppelt an die Reaktionszeit des Anwenders. Das kann man nicht wirklich wollen. - Logging
Das ist insbesondere gefährlich, wenn ein Logging-Framework verwendet wird, was eine Umstellung des Loggings ja relativ einfach macht. Selbst wenn im Moment Logdateien nur im lokalen Dateisystem erzeugt werden, ist nicht gesagt, dass das für immer so bleibt. In meinem Fall erzeugt die Applikation u.U. sogar Einträge in einer zentralen Fehlerdatenbank, was ggf. lange dauern und sogar scheitern kann. - Das Auslösen von Ereignissen.
Ähnlich wie beim Logging gilt auch hier: es ist nicht abzusehen, was die Ereignis-Handler in Zukunft tun werden
Was kann man also tun, wenn solche Funktionen in den Datenbank-Methoden enthalten sind?
Meine Lösung dieses Problems sieht folgendermaßen aus:
Jede TransactionScopeHelper-Instanz enthält eine Liste von (C#)Actions, die zunächst leer ist, der aber mit einer öffentlichen Methode (Store) Actions hinzugefügt werden können. Außerdem ist eine weitere öffentliche Methode (ExecutePendingActions) implementiert, die diese Liste abarbeitet, die enthaltenen Actions also der Reihe nach aufruft.
// Namespace: TransactionScopeHelper
private List _pendingActions;
private TransactionScopeHelper()
{
_pendingActions = new List();
}
private void AddAction(Action action)
{
if ( ! _pendingActions.Contains(action))
{
_pendingActions.Add(action);
}
}
private List GetPendingActions()
{
return _pendingActions;
///
/// Speichert die gegebene Aktion, damit sie zu einem späteren Zeitpunkt ausgeführt werden kann
///
///
public static void Store(Action action)
{
var transactionId = Transaction.Current.TransactionInformation.LocalIdentifier
if ( Instances.ContainsKey(transactionId))
{
Instances[transactionId].AddAction(action) ;
}
else
{
throw new InvalidOperationException("Store(Action) kann nicht ausgeführt werden.");
}
}
///
/// Führt die gespeicherten Actionen für die gegebene Transaktion aus ///
///
///
public static void ExecutePendingActions(string transactionId)
{
if (Instances.ContainsKey(transactionId))
{
var actions = Instances[transactionId].GetPendingActions() ;
foreach ( var action in actions)
{
action.Invoke();
}
Instances.Remove(transactionId);
}
else
{
throw new InvalidOperationException("Ausführungsfehler");
}
}
In einer Datenbankmethode kann nun ein Codeblock, der fehlerträchtige Funktionen enthält, als anonyme Methode in einer Action gekapselt und mit der Store-Methode im Transaktionskontext zwischengespeichert werden.
Beispiel:
// Namespace: ClientCode
if (TransactionScopeHelper.RunsInTransaction())
{
// Präsentation der Fehlermeldung erst nach Abschluss der Transaktion
TransactionScopeHelper.Store(new Action(() =>
{
ShowMessageBox.Fehler(„Fehlermeldung“);
}));
}
else
{
ShowMessageBox.Fehler(„Fehlermeldung“);
}
Nach Abschluss der Transaktion muss dann im Business-Layer noch ExecutePendingTransactions(…) aufgerufen werden. Eine Transaktion im Business-Layer hat damit diesen finalen Rahmen:
// Namespace: ClientCode
string transactionName = " Irgendeine Transaktionsbezeichnung";
string transactionId = null;
try
{
Log.InfoFormat("### Start Transaktion {0}. ###", transactionName);
var stopwatch = new Stopwatch();
stopwatch.Start();
using (var scope = TransactionScopeHelper.CreateTransactionScope(out transactionId))
{
// Datenbankmethoden aufrufen
scope.Complete();
}
stopwatch.Stop();
Log.InfoFormat("### Transaktion {0} erfolgreich. ###", transactionName);
Log.DebugFormat("### Transaktionsdauer: {0} ms ###", stopwatch.ElapsedMilliseconds);
}
catch (MyTransactionException ex)
{
// Fehlerbehandlung
}
finally
{
if (! string.IsNullOrEmpty(transactionId))
{
TransactionScopeHelper.ExecutePendingActions(transactionId);
}
}
Welche Probleme können auftreten?
Wenn man in einer Transaktion eine Datenbankverbindung aufbaut, während eine weitere noch geöffnet ist, dann tritt u.U. folgende Exception auf:
System.Transactions.TransactionManagerCommunicationException: Der Netzwerkzugriff für den Manager für verteilte Transaktionen (MSDTC) wurde deaktiviert. Aktivieren Sie DTC für den Netzwerkzugriff in der Sicherheitskonfiguration für MSDTC, indem Sie das Verwaltungstool Komponentendienste verwenden. —> System.Runtime.InteropServices.COMException: Der Transaktions-Manager hat die Unterstützung für Remote-/Netzwerktransaktionen deaktiviert. (Ausnahme von HRESULT: 0x8004D024)
In so einem Fall kommt der Distributed Transaction Coordinator (DTC) ins Spiel, auch wenn beide Datenbankverbindungen zum gleichen Server aufgebaut werden. Das weitere Vorgehen hängt dann davon ab, ob man auf die Server-, Netzwerk- oder Firewall-Konfiguration Einfluss nehmen kann oder nicht. Wenn das nicht der Fall ist, dann müssen solche geschachtelten Verbindungen vermieden werden.
Es lohnt sich, dies vor Beginn der Umstellung zu prüfen und ggf. zu klären. Zur Prüfung sind keine Lese- oder Schreiboperationen auf der Datenbank erforderlich, das Öffnen von Verbindungen allein zeigt schon das Problem.
using (var scope = TransactionScopeHelper.CreateTransactionScope(out transactionId))
{
var externalConnection = new SqlConnection(connectionKey);
externalConnection.Open();
var internalConnection = new SqlConnection(connectionKey);
internalConnection.Open();
scope.Complete();
}
Fazit
Die Berücksichtigung von Transaktionen sollte bei Neuentwicklungen eigentlich selbstverständlich sein, und der Artikel skizziert, wie schmerzhaft ein nachträglicher Einbau sein kann. Er ist dennoch möglich, und folgendes kann als Fazit festgehalten werden:
Die Verwendung des Transaction-Frameworks kann tatsächlich Entwicklungsaufwand einsparen. Im Idealfall ist die Umstellung mit der Codierung der Transaktionsklammern erledigt. In der Realität wird man aber wohl doch die eine oder andere der im Artikel skizzierten Anpassungen vornehmen müssen, wobei diese eigentlich unabhängig vom verwendeten Transaktions-Modell sind.
Darüber hinaus kann es erforderlich sein, im Business-Layer Rollback-Szenarien explizit zu berücksichtigen; das hängt davon ab, ob und wie die Applikation bisher auf Fehler beim Datenbankzugriff reagiert.
Es kann ziemlich herausfordernd und zeitaufwändig sein, die anzupassenden Methoden in den Aufrufhierarchien überhaupt zu identifizieren, das hängt von der Anzahl der Verzweigungen und der Anzahl der Ebenen in der Hierarchie ab. Manchmal verbergen sich Datenbankzugriffe auch in an sich harmlos aussehenden Property-Settern.
Der Aufwand, insbesondere auch der Testaufwand, sollte daher keinesfalls unterschätzt werden. Wohl dem, der über ein großes Repertoire an automatisierten Tests verfügt.
Begriffserklärungen
Eine Transaktion fasst mehrere Datenbank-Zugriffe in einer Gruppe zusammen. Wenn bei einem dieser Zugriffe ein Fehler auftritt – das kann z.B. ein Timeout sein -, dann werden alle seit Beginn der Transaktion ausgeführten Operationen auf der Datenbank zurückgerollt. D.h., es wird in der Datenbank der Zustand vor Beginn der Transaktion wieder hergestellt. Damit lässt sich die Konsistenz der Daten auch im Fehlerfall sicherstellen.