Pull to refresh

Объектно-ориентированная разработка инсталлятора Gin

Designing and refactoring *
Ссылка на первую часть

Транзакции.


Напомню, что я собирался реализовать механизм транзакций, позволяющий откатывать блоки операций при возникновении ошибки внутри блока, защищенного транзакцией. Сначала надо решить вопрос с ответственностью за сохранение состояния и за откат операции. Скажу сразу, что архитектура, которую я приведу ниже вырисовалась у меня не сразу, а только после нескольких попыток проектирования и реализации макета, пока у меня не получилось то, что получилось.
Для того, чтобы архитектура транзакций была легко наращиваемой, воспользуемся как и ранее наследованием. При этом возложим ответственность за сохранение состояние и откат к сохраненному состоянию на саму команду. Учтем при этом, что не все команды являются по сути транзакционными. Например, чтение из реестра не может быть частью транзакции, потому что оно ничего не изменяет в системе. А вот запись в реестр – это уже часть транзакции. И создание файла – это часть транзакции.
А поэтому объявим еще один абстрактный класс TransactionalCommand, унаследуем его от класса Command.
Выглядеть он будет так:
public abstract class TransactionalCommand : Command
{
    public abstract TransactionStep Do(ExecutionContext context, Transaction transaction);
    public abstract void Rollback(TransactionStep step);
}

Что произошло? Мы добавили перегрузку метода Do() приписав ему еще один аргумент типа Transaction. Класс Transaction я опишу немного позже, но скажу, что по сути он является транзакцией в рамках которой выполняется команда. Основное назначение нового метода Do(ExecutionContext, Transaction)следующее:
  1. создать новый шаг транзакции TransactionStep,
  2. сохранить физически (на диске, в БД и тд) состояние системы, а точнее те его аспекты, которые изменятся после выполнения команды,
  3. вызвать метод Do(ExecutionContext) класса Command (то есть совершить исходную нетранзакционную операцию),
  4. вернуть созданный в начале TransactionStep.

Мы добавили также метод Rollback() который производит откат шага транзакции.
Таким образом, мы возложили ответственность за сохранение состояния и откат команды на саму команду, что дает пользователям возможность легко наращивать массив транзакционных команд, выполняемых инсталлятором.
Инфраструктура инсталлятора должна предоставлять пользователям структурированное хранилище для сохранения состояния системы до выполнения транзакции и интерфейс доступа к нему. Чтобы как можно меньше зависеть от установки сторонних компонентов (таких как базы данных, например), я собираюсь представить состояние системы в виде произвольного набора файлов, сохраняемых в определенной подпапке рабочей папки инсталлятора. Пусть структура файлов выглядит так:
%GIN_DIRECTORY%\packages\[PackageId]\transactions\[TransactionName]\steps\[StepNumber]\
Или в виде дерева:
image
Инфраструктура предоставляет пользователю пути к папкам [StepNumber] для сохранения состояния системы перед выполнением команды. Инфраструктура сама сохраняет все остальные данные в соответствующих папках дерева, такие как данные о самой транзакции и ее шагах. Разумеется, инфраструктура сама создает все это дерево, избавляя пользователя от рутинной работы. Под пользователем я имею в виду разработчика расширений для инсталлятора.
Вот описание класса TransactionStep:
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;
    }
}

Как видно из описания класса, основных предназначений класса – два:
  • создает папку и возвращает путь к папке для сохранения состояние системы до выполнения шага
  • хранит экземпляр соответствующей команды для возможности отката шага транзакции

От этого класса мы будем наследовать подклассы конкретных шагов. Например:
public class SingleFileStep: TransactionStep
{
    public string OldFilePath { get; set; }
}

Этот подкласс годится для любых операций с одиночным файлом: создание файла, удаление файла, изменение файла. Этот подкласс предоставляет полный путь к сохраненной копии исходного файла до его изменения.
А теперь класс Transaction:
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);
        });
    }
}

Как видно, основные функции класса следующие:
  • добавление в транзакцию нового шага и хранение последовательности шагов транзакции
  • сохранение транзакции на диске в соответствующем XML-файле
  • откат транзакции посредством выполнения отката всех шагов транзакции в обратном порядке

Метод BackForEach – это метод-расширение, вынесенный в отдельный класс, чтобы не загромождать код остальных классов. Ну и вообще, я люблю использовать методы расширения и лямбда-выражения, прошу простить мне эту слабость.
Рассмотрим теперь один из классов наследником TransactionalCommand, чтобы понять, как именно пользователи должны реализовывать команды, поддерживающие транзакции.
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);
        }
    }
 }

Метод Do() с двумя аргументами, в рамках переданной ему транзакции создает новый шаг транзакции, используя метод CreateStep<>, затем проверяет, существует ли уже файл по тому пути, где инсталлятор должен создать новый файл на текущем шаге, и если файл там уже есть, то копирует его под другим именем(хотя имя можно было и не менять) в папку, предоставленную ему инфраструктурой инсталлятора(TransactionStep.GetPath), и только потом вызывает метод Do() унаследованный от класса нетранзакционных команд Command, возвращая в качестве результата созданный в начале шаг транзакции step.
Метод Rollback получает в качестве аргумента шаг транзакции, и проверяя его свойство OldFilePath либо копирует в папку назначения старую версию файла, либо удаляет файл из папки назначения.
В коде класса Transaction вы могли заметить свойство ExecutionContext.ExecutedPackage, который представляет ссылку на исполняемый в рамках контекста пакет. Инициализируется эта ссылка внутри конструктора класса Package. Обратите внимание, что у класса Package появилось свойство TransactionsPath, указывающее путь к папке с транзакциями пакета.

Итак, мы рассмотрели сохранение и откат одного шага транзакции. Но чтобы откатить всю транзакцию, нужно выполнить шаги транзакции в обратном порядке. И по той причине, что мы хотим уметь откатывать транзакции, предварительно сохраненные на диске, нам нужно уметь загружать транзакции с диска, так как сохранять их на диск мы уже умеем (см. метод Transaction.Save())
Для этого реализуем в классе Package метод LoadTransactions(). Почему именно в классе Package? Потому что основное предназначение класса Package –исполнение пакета. И транзакции возникают именно в процессе выполнения пакета, и являются по сути характеристикой уже выполненного пакета. Таким образом, будет логичным разместить список транзакций именно в классе Package. Вот код загрузки транзакций:
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);
    }
}

Здесь все просто: мы получаем список всех подпапок в папке TransactionsPath(помните, это свойство не так давно появилось в классе Package), список имен подпапок и будет списком имен транзакций (вспомните структуру файлового дерева пакета), а затем для каждого имени формируем полный путь к файлу data.xml транзакции и десериализуем его в очередную транзакцию. При этом сериализатор создаст и список вложенных шагов транзакции, и вложенные в эти шаги команды, вместе с их методами Rollback(). Метод LoadTransactions() будем вызывать в конструкторе класса Package. Чтобы не раскрывать клиентам класса Package внутреннюю структуру списка транзакций, добавим в интерфейс класса Package несколько методов для работы с транзакциями.
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);
}


Разберемся теперь, как именно создавать транзакции внутри пакета. Хотелось бы делать это при помощи вот такого псевдокода:
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();

Как видим, структура классов существенно поменялась.
  1. В класс PackageBody теперь можно передать одну единственную команду, в подавляющем числе случаев это будет экземпляр команды CommandSequence.
  2. Для объединения последовательности команд в транзакцию используется класс TransactionContainer, наследуемый от Command. Он похож на CommandSequence, но имеет еще дополнительный параметр TransactionName.

Внутри TransactionContainer я вложил несколько команд создания файлов, и между ними вклинил выброс исключения. Поведение данного пакета будет таким:

  1. Пакет выполнит две первые команды создания файлов CreateFile
  2. На третьей команде ThrowException произойдет выброс исключения, и выполнение пакета остановится, а значит последняя команда CreateFile не выполнится вообще
  3. Пакет перейдет к процедуре отката транзакции с именем myTransaction, откатив две входящие в нее команды CreateFile.

Таким образом, откат транзакции, это вовсе не откат всех команд пакета, входящих в транзакции, а только откат выполненных команд транзакции.
Заметим также, что метод Package.Execute() возвращает пользователю контекст выполнения пакета, скажем так, в ознакомительных целях, чтобы можно было посмотреть, какие результаты у выполненных команд, список транзакций и путей.
Метод Execute() класса Package делает довольно тривиальную работу:
  1. Создает на диске всю необходимую для работы пакета структуру папок (см. структуру файлового дерева пакета)
  2. Вызывает метод Do() команды Command класса PackageBody.

Далее уже сами команды начинают свое выполнение. Если это одиночная команда, то она просто выполняется, если это CommandSequence, то она последовательно выполняет все вложенные в нее команды. Если это TransactionContainer, то она сначала создает новую транзакцию, а затем последовательно выполняет вложенные в нее команды, передавая им в качестве аргумента еще и эту созданную транзакцию. При этом надо учесть то факт, что в транзакцию могут быть вложены как обычные команды, так и команды, поддерживающие транзакционное выполнение. Соответственно, TransactionContainer должен следить за родительским типом очередной выполняемой команды, вызывая у них соответствующий перегруженный метод Do(). TransactionContainer выполняет последовательность команд в контейнере try catch finally, и при возникновении исключения в любой из вложенных команд, должен перейти к выполнению отката транзакции, вызывая метод Rollback() текущей транзакции. В конце выполнения блока команд он сохраняет транзакцию на диск для возможной последующей загрузки и анализа транзакции.
Код метода Do() класса TransactionContainer в первом приближении выглядит так:
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);
}

На данном шаге проектирования инсталлятора TransactionContainer представляет собой упорядоченный список команд, которые выполняются одна за другой в рамках единой транзакции. Но что если одна из этих команд будет представлять собой контейнерную команду, то есть команду, в которую вложены одна или несколько других команд? Что если в рамках транзакции выполняется например команда ExecuteIf или CommandSequence? Сами по себе ExecuteIf и CommandSequence не поддерживают транзакции, а значит, если внутри них вложена транзакционная команда, то она будет выполнена вне рамок транзакции, потому что у ExecuteIf и CommandSequence будут выполнены методы Do(ExecutionContext) с одним аргументом, так как они – нетранзакционные. А значит и вложенные в них команды будут работать вне транзакции, что не является очень хорошим решением.
Поэтому следует все контейнерные команды(ExecuteIf, ExecuteNotIf и CommandSequence, да и любые другие контейнерные команды, которые пользователь добавит в инсталлятор) сделать транзакционными. Однако сама по себе контейнерная команда не будет добавлять шаг в транзакцию, потому что ей нечего сообщить родительской транзакции – контейнерная команда сама по себе ничего не меняет в системе. Она будет делегировать ответственность по создания шагов своим вложенным транзакционным командам. То есть теперь любая контейнерная команда будет такой:
  • Пустой метод Rollback(TransactionStep)
  • Метод Do(ExecutionContext) остается таким же как и раньше, то есть он просто выполняет вложенную команду или команды в соответствии с внутренней логикой самой контейнерной команды, вызывая у нее или них метод Do(ExecutionContext) с одним аргументом.
  • Метод Do(ExecutionContext, Transaction) с двумя аргументами дублирует внутреннюю логику метода Do(ExecutionContext), но при этом проверяет от кого унаследована вложенная команда или команды, и если она унаследована от TransactionalCommand, то вызывает ее метод Do(ExecutionContext, Transaction), а иначе – метод Do(ExecutionContext). То есть проверка транзакционности вложенной команды перешла из класса TransactionContainer в контейнерный класс.
  • Так как контейнерная команда не создает собственного шага в родительской транзакции, то метод Do(ExecutionContext, Transaction) возвращает null.

Классы CommandSecquence и ExecuteIf в соответствии с вышеизложенным теперь выглядят так:
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)
    {    }
}

Теперь мы можем создавать транзакции, содержащие в себе команды любого уровня вложенности.

Ссылка на третью часть
Tags:
Hubs:
Total votes 3: ↑3 and ↓0 +3
Views 545
Comments Comments 1