Link to the first partTransactions.
Let me remind you that I was going to implement a transaction mechanism that allows you to roll back blocks of operations when an error occurs inside a block protected by a transaction. First, we must resolve the issue of responsibility for maintaining the state and for rolling back the operation. I will say right away that the architecture that I will cite below loomed not immediately, but only after several attempts to design and implement the layout, until I succeeded in what I did.
In order for the transaction architecture to be easily scalable, we will use inheritance as before. At the same time, we will be responsible for maintaining the state and rolling back to the saved state on the command itself. We take into account at the same time that not all commands are essentially transactional. For example, reading from the registry cannot be part of a transaction, because it changes nothing in the system. But writing to the registry is already part of the transaction. And creating a file is part of a transaction.
And therefore we will declare another abstract class TransactionalCommand, inherit it from the class Command.
It will look like this:
public abstract class TransactionalCommand : Command { public abstract TransactionStep Do(ExecutionContext context, Transaction transaction); public abstract void Rollback(TransactionStep step); }
What happened? We added an overload of the Do () method, assigning to it another argument of the Transaction type. I will describe the Transaction class a little later, but I will say that in essence it is a transaction within which the command is executed. The main purpose of the new Do (ExecutionContext, Transaction) method is as follows:
- create a new transaction step TransactionStep,
- physically save (on disk, in the database, etc.) the state of the system, or rather those aspects of it that will change after the command is executed,
- call the Command class Do (ExecutionContext) method (that is, perform the initial non-transactional operation),
- return the TransactionStep created at the beginning.
We also added a Rollback () method that rolls back a transaction step.
Thus, we assigned the responsibility for maintaining the state and rollback of the command to the command itself, which allows users to easily increase the array of transactional commands executed by the installer.
The installer infrastructure should provide users with a structured repository for maintaining the state of the system before the transaction and the interface to access it. In order to depend as little as possible on the installation of third-party components (such as databases, for example), I am going to present the state of the system as an arbitrary set of files stored in a specific subfolder of the installer's working folder. Let the file structure look like this:
%GIN_DIRECTORY%\packages\[PackageId]\transactions\[TransactionName]\steps\[StepNumber]\
Or in the form of a tree:

The infrastructure provides the user with paths to the [StepNumber] folders to save the state of the system before executing the command. The infrastructure itself stores all other data in the appropriate folders of the tree, such as data about the transaction itself and its steps. Of course, the infrastructure itself creates all this tree, saving the user from the routine work. By user, I mean the developer of extensions for the installer.
Here is the description of the TransactionStep class:
public class TransactionStep { public const string STEPS_SUBFOLDER_NAME = @"steps"; public int StepNumber { get; set; } public string TransactionName { get; set; } public TransactionalCommand Command { get; set; } public string GetPath(ExecutionContext context) { string transactionsPath = context.ExecutedPackage.TransactionsPath; string transactionPath = Path.Combine(transactionsPath, TransactionName); string stepsPath = Path.Combine(transactionPath, STEPS_SUBFOLDER_NAME); string stepPath = Path.Combine(stepsPath, StepNumber.ToString()); if (!Directory.Exists(stepPath)) { Directory.CreateDirectory(stepPath); } return stepPath; } }
As can be seen from the description of the class, the main purposes of the class are two:
- creates a folder and returns the path to the folder to save the state of the system before performing the step
- Stores an instance of the corresponding command to enable rollback of a transaction step
From this class we will inherit subclasses of specific steps. For example:
public class SingleFileStep: TransactionStep { public string OldFilePath { get; set; } }
This subclass is suitable for any operations with a single file: file creation, file deletion, file modification. This subclass provides the full path to the saved copy of the source file before it is modified.
And now the Transaction class:
public class Transaction { public string TransactionName { get; set; } public TransactionState TransactionState { get; set; } public List<TransactionStep> Steps { get; set; } public Transaction() { Steps = new List<TransactionStep>(); } public T CreateStep<T>(TransactionalCommand command) where T: TransactionStep, new() { T step = new T(); int stepNumber = Steps.Count; step.StepNumber = stepNumber; step.Command = command; step.TransactionName = TransactionName; Steps.Add(step); return step; } public void Save(ExecutionContext context) { string transactionsPath = context.ExecutedPackage.TransactionsPath; string transactionPath = Path.Combine(transactionsPath, TransactionName + @"\"); Directory.CreateDirectory(transactionPath); string dataFilePath = Path.Combine(transactionPath, @"data.xml"); GinSerializer.Serialize(this, dataFilePath); } public void Rollback() { Steps.BackForEach(s => { s.Command.Rollback(s); }); } }
As you can see, the main functions of the class are as follows:
- adding a new step to the transaction and storing the sequence of the transaction steps
- save transaction to disk in the corresponding XML file
- roll back a transaction by performing a rollback on all steps of a transaction in the reverse order
The BackForEach method is an extension method that is rendered into a separate class in order not to clutter up the code of other classes. Well, in general, I like to use extension methods and lambda expressions, please forgive me for this weakness.
Consider now one of the classes by the successor of TransactionalCommand, in order to understand exactly how users should implement commands that support transactions.
public class CreateFile: TransactionalCommand { public string SourcePath { get; set; } public string DestPath { get; set; } public override void Do(ExecutionContext context) { File.Copy(SourcePath, DestPath, true); } public override TransactionStep Do(ExecutionContext context, Transaction transaction) { SingleFileStep step = null; if (transaction != null) { step = transaction.CreateStep<SingleFileStep>(this); } if (File.Exists(DestPath)) { string rollbackFileName = Guid.NewGuid().ToString("N") + ".rlb"; string dataPath = step.GetPath(context); string rollbackFilePath = Path.Combine(dataPath, rollbackFileName); step.OldFilePath = rollbackFilePath; File.Copy(DestPath, rollbackFilePath); } Do(context); return step; } public override void Rollback(TransactionStep step) { SingleFileStep currentStep = (SingleFileStep)step; if (File.Exists(currentStep.OldFilePath)) { File.Copy(currentStep.OldFilePath, DestPath, true); } else { File.Delete(DestPath); } } }
The Do () method with two arguments, in the framework of the transaction passed to it, creates a new transaction step using the CreateStep <> method, then checks whether the file already exists along the path where the installer should create a new file at the current step, and if the file is already there is, it copies it under a different name (although the name could not be changed) to the folder provided to it by the installer infrastructure (TransactionStep.GetPath), and only then it calls the Do () method inherited from the class of non-transactional Command commands, returning the result created at the beginning of the trance step tion step.
The Rollback method takes a transaction step as an argument, and checking its OldFilePath property either copies the old version of the file to the target folder, or deletes the file from the target folder.
In the Transaction class code, you might notice the ExecutionContext.ExecutedPackage property, which represents a link to the executable within the context of the package. This reference is initialized inside the Package class constructor. Notice that the Package class has a property TransactionsPath, which indicates the path to the folder with the package transactions.
So, we considered saving and rollback of one transaction step. But in order to roll back the entire transaction, you need to perform the steps of the transaction in the reverse order. And for the reason that we want to be able to roll back transactions previously saved to disk, we need to be able to load transactions from disk, since we already know how to save them to disk (see the Transaction.Save () method)
To do this, we implement the LoadTransactions () method in the Package class. Why in the class Package? Because the main purpose of the Package class is to execute a package. And transactions occur during the execution of a package, and are essentially a characteristic of an already executed package. Thus, it will be logical to place the list of transactions in the Package class. Here is the transaction loading code:
private void LoadTransactions() { _transactions = new List<Transaction>(); if (String.IsNullOrEmpty(TransactionsPath) || !Directory.Exists(TransactionsPath)) { return; } string[] transactionNames = Directory.GetDirectories(TransactionsPath); foreach (string name in transactionNames) { string transactionPath = Path.Combine(TransactionsPath, name); string dataFilePath = Path.Combine(transactionPath, TRANSACTIONS_DATA_FILENAME); Transaction transaction = GinSerializers.TransactionSerializer.Deserialize(dataFilePath); _transactions.Add(transaction); } }
Everything is simple here: we get a list of all subfolders in the TransactionsPath folder (remember, this property appeared in the Package class not so long ago), the list of subfolder names will be a list of transaction names (remember the structure of the package file tree), and then for each name we form the full path to the transaction data.xml file and deserialize it into the next transaction. In this case, the serializer will create a list of transaction nested steps, and commands nested in these steps, along with their Rollback () methods. The LoadTransactions () method will be called in the Package class constructor. In order not to disclose the internal structure of the transaction list to the clients of the Package class, we will add several methods to work with transactions to the interface of the Package class.
public void Rollback(string transactionName) { Transaction transaction = GetTransactionByName(transactionName); transaction.Rollback(); } public void Rollback() { foreach (Transaction transaction in _transactions) { transaction.Rollback(); } } public void AddTransaction(Transaction transaction) { _transactions.Add(transaction); }
We will understand now how to create transactions inside the package. I would like to do this with the help of this pseudocode:
PackageBody body = new PackageBody() { Command = new CommandSequence() { Commands = new Command[] { new TransactionContainer() { TransactionName = "myTransaction", Commands = new List<Command>() { new CreateFile() { SourcePath = @"D:\test\newfile.txt", DestPath = @"C:\test\folder1\file1.txt" }, new CreateFile() { SourcePath = @"D:\test\newfile.txt", DestPath = @"C:\test\folder1\file2.txt" }, new ThrowException() { Message = "Test exception" }, new CreateFile() { SourcePath = @"D:\test\newfile.txt", DestPath = @"C:\test\folder1\file3.txt" } } } } } }; PackageBuilder builder = new PackageBuilder(body); string fileName = builder.GetResult(); Package pkg = new Package(fileName); ExecutionContext ctx = pkg.Execute();
As you can see, the structure of classes has changed significantly.
- In the PackageBody class you can now pass a single command, in most cases it will be an instance of the CommandSequence command.
- To combine a sequence of commands into a transaction, use the TransactionContainer class, which is derived from Command. It is similar to the CommandSequence, but it still has the optional TransactionName parameter.
Inside the TransactionContainer, I put in a few commands to create files, and in between them I threw in an exception throw. The behavior of this package will be as follows:
')
- The package will execute the first two CreateFile file creation commands.
- At the third ThrowException command, an exception will be thrown, and the package execution will stop, which means that the last CreateFile command will not be executed at all
- The batch will go to the transaction rollback procedure with the name myTransaction, rolling out the two CreateFile commands included in it.
Thus, the rollback of a transaction is not a rollback at all of all the package commands included in a transaction, but only a rollback of the executed transaction commands.
Note also that the Package.Execute () method returns the context of the package to the user, for example, for informational purposes, so that you can see what the results of the executed commands are, the list of transactions and paths.
The Execute () method of the Package class does a rather trivial job:
- It creates on the disk all the folder structure necessary for the package to work (see the file tree structure of the package)
- Calls the Do () method of the Command of the PackageBody class.
Then the teams themselves begin their execution. If it is a single command, then it is simply executed; if it is a CommandSequence, then it sequentially executes all the commands nested in it. If this is a TransactionContainer, then it first creates a new transaction, and then sequentially executes the commands nested in it, passing them also the created transaction as an argument. In this case, it is necessary to take into account the fact that both ordinary commands and commands supporting transactional execution can be nested in a transaction. Accordingly, the TransactionContainer should monitor the parent type of the next executed command, calling their corresponding Do () overloaded method. TransactionContainer executes a sequence of commands in a try catch finally container, and when an exception occurs in any of the nested commands, it must proceed to the rollback of the transaction by calling the Rollback () method of the current transaction. At the end of the execution of a block of commands, it saves the transaction to disk for possible subsequent download and analysis of the transaction.
The code of the Do () method of the TransactionContainer class in the first approximation looks like this:
Transaction transaction = new Transaction() { TransactionName = TransactionName, TransactionState = TransactionState.Undefined }; try { context.Transactions.Add(transaction); foreach (Command command in Commands) { if (command is TransactionalCommand) { ((TransactionalCommand)command).Do(context, transaction); } else { command.Do(context); } } transaction.TransactionState = TransactionState.Active; } catch { transaction.Rollback(); } finally { transaction.Save(context); }
In this design step, the TransactionContainer installer is an ordered list of commands that are executed one after another as part of a single transaction. But what if one of these teams is a container team, that is, a team in which one or several other teams are nested? What if, for example, a ExecuteIf or CommandSequence command is executed within a transaction? By themselves, ExecuteIf and CommandSequence do not support transactions, which means that if a transactional command is nested within them, then it will be executed outside the scope of the transaction, because Do (ExecutionContext) Do (ExecutionContext) methods will be executed with ExecuteIf and they are non-transactional . This means that the teams nested in them will work outside the transaction, which is not a very good solution.
Therefore, all container commands (ExecuteIf, ExecuteNotIf and CommandSequence, and any other container commands that the user adds to the installer) should be made transactional. However, the container team itself will not add a step to a transaction, because it has nothing to report to the parent transaction — the container command itself does not change anything in the system. She will delegate responsibility for creating steps to her nested transactional teams. That is, now any container team will be like this:
- Empty Rollback Method (TransactionStep)
- The Do (ExecutionContext) method remains the same as before, that is, it simply executes a nested command or commands in accordance with the internal logic of the container command itself, calling it or them with the Do (ExecutionContext) method with one argument.
- The Do (ExecutionContext, Transaction) method with two arguments duplicates the internal logic of the Do (ExecutionContext) method, but at the same time checks from whom the nested command or commands are inherited, and if it is inherited from the TransactionalCommand, then it calls the Do (ExecutionContext, Transaction) method, and otherwise, the Do (ExecutionContext) method. That is, the transactional check of the nested command has been transferred from the TransactionContainer class to the container class.
- Since the container command does not create its own step in the parent transaction, the Do (ExecutionContext, Transaction) method returns null.
The CommandSecquence and ExecuteIf classes, as described above, now look like this:
public class CommandSequence: TransactionalCommand { public List<Command> Commands; public override void Do(ExecutionContext context) { foreach (Command command in Commands) { command.Do(context); } } public override TransactionStep Do(ExecutionContext context, Transaction transaction) { foreach (Command command in Commands) { if (command is TransactionalCommand) { ((TransactionalCommand)command).Do(context, transaction); } else { command.Do(context); } } return null; } public override void Rollback(TransactionStep step) { } } public class ExecuteIf : TransactionalCommand { public string ArgumentName { get; set; } public Command Command { get; set; } public override void Do(ExecutionContext context) { if ((bool)context.GetResult(ArgumentName)) { Command.Do(context); } } public override TransactionStep Do(ExecutionContext context, Transactions.Transaction transaction) { if ((bool)context.GetResult(ArgumentName)) { if (Command is TransactionalCommand) { ((TransactionalCommand)Command).Do(context, transaction); } else { Command.Do(context); } } return null; } public override void Rollback(TransactionStep step) { } }
Now we can create transactions containing commands of any level of nesting.
Link to the third part