Pull to refresh

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

Designing and refactoring *
Sandbox

Введение


У предлагаемого вашему вниманию цикла статей есть несколько основных целей:
  1. Создать полезное программное обеспечение – инсталлятор программ и обновлений.
  2. Показать преимущества объектно-ориентированного подхода к разработке ПО и научить создавать легко расширяемые программные архитектуры.

В данном цикле статей я хочу поделиться историей создания программного обеспечения, позволяющего производить установку и обновление программных продуктов компании при помощи пакетов. Необходимость создания собственного инсталлятора(с отказом от использования готовых решений) вызвана специфичностью требований к инсталлятору. Я не буду углубляться в обоснование необходимости разработки, так как тема цикла статей другая.
Основными требованиями к разрабатываемой архитектуре будут:
  1. Реализация механизма транзакций, причем транзакции могут включать в себя не только SQL-транзакции, но и файловые, а также транзакции, связанные с изменением любых других ресурсов ОС, таких как записи в реестре, изменения конфигурационных файлов и т.д.
  2. Расширяемость операционной базы инсталлятора, то есть, добавление новых типов команд(операций), как с поддержкой транзакций, так и без нее.


Итак, каждый пакет в первом приближении должен содержать в себе:
  1. Описание последовательности выполняемых команд.
  2. Произвольный набор файлов с данными.

Описание последовательности команд я намерен сохранять в XML-файле по той причине, что платформа .NET содержит в себе удобные и простые классы для сериализации/десериализации объектов в XML-файлы, что даст нам возможность сосредоточиться на основной логике приложения, не углубляясь в операции со строками.
Сам пакет будет по формату TAR-архивом, возможно даже подвергнутым GZIP-сжатию, однако я постараюсь спроектировать ПО таким образом, чтобы при принятии решения об изменении способа хранения пакета(его структуры и способа сжатия) нам потребовалось бы минимальное вмешательство в уже существующий код.
Прикинем набор команд будущего ПО.
  1. Файловые операции
  2. SQL-операции
  3. Управление сервером IIS
  4. Возможно, что-то еще…

Как я уже упоминал, набор команд можно будет расширять. Причем, сделаем так, чтобы основной функционал – ядро ПО — хранилось в отдельной сборке, а возможные выполняемые команды конфигурировались отдельными сборками, подключаемыми к основному модулю с использованием концепции плагинов.
У меня уже захватывает дух от того как много интересных задач необходимо будет решить в процессе разработки ПО. Оговорюсь сразу, что на момент когда я пишу эти строки уже есть работающий прототип приложения, который правда пока не имеет всей заявленной функциональности, да и вносить в него изменения немного сложнее, чем мне хотелось бы. А значит, я еще подвергну его рефакторингу. Он уже имеет возможности создания транзакций для файловых операций, однако не умеет совмещать файловые и SQL-операции в пределах одной транзакции. В нем еще не реализована поддержка плагинов. Добавление новой команды в набор команд вызывает за собой необходимость править 4 файла, а я хочу, чтобы для этого достаточно было лишь просто объявить новый класс. В общем, работы – непаханое поле.
В первом приближении, диаграмма классов системы выглядит так:
image
На ней есть родительский класс всех операций (команд), которые умеет выполнять приложение, и несколько унаследованных от него команд (CreateFileCommand и DeleteFolderCommand), которых в конце концов будет очень много. Есть еще класс PackageBuilder, который содержит в себе последовательность команд. PackageBuilder умеет формировать пакеты и отдавать их пользователю. Пакеты (Package) могут сохранять себя и выполнять.
В результате псевдокод, использующий эти классы будет выглядеть так:
void CreatePackage()
{
  // Создаем и сохраняем пакет
  PackageBuilder builder = new PackageBuilder();

  builder.AddCommand(new CreateFolderCommand()
  {
    FolderPath = @"%APPROOT%\files"
  });
  builder.AddCommand(new CreateFileCommand()
  {
    SourcePath = @"d:\package\config.xml", //локальный путь на машине разработчика пакета
    DestPath = @"%APPROOT%\files\config.xml" //относительный путь на целевой машине
  });

  Package package = builder.GetResult();
  package.SaveAs(@"d:\package\output\package.pkg");
}

void ExecutePackage ()
{
  //Загружаем и выполняем пакет
  Package package = new Package(@"d:\package\output\package.pkg");
  package.Execute();
}

Конечно, структура классов еще успеет претерпеть множество метаморфоз, но с чего-то надо начинать.

Базовая система команд


Попробуем прикинуть базовую систему команд инсталлятора. Здесь я не собираюсь перечислять весь функционал инсталлятора, потому что собираюсь дать пользователям легкую возможность расширения функционала.
Здесь я собираюсь описать базовые каркасные команды, позволяющие объединять в единое целое остальные команды. Абстрактный класс, от которого будут наследоваться все команды, содержит один единственный метод Do(), который собственно и будет выполнять действие, запрограммированное командой. Например, команда DeleteFile будет создавать файл именно внутри этого метода Do(), а в качестве аргумента(пути удаляемого файла) будет использовать открытое свойство string FilePath. Вот как это будет выглядеть:
public abstract class Command
{
  public abstract void Do();
}

public class DeleteFile: Command
{
  public string FilePath { get; set; }

  public override void Do()
  {
    File.Delete(FilePath);
  }
}

Все остальные команды будут реализованы точно таким же образом – команда наследует от абстрактного класса Command, и замещает его метод Do(), используя в качестве аргументов открытые свойства наследуемого класса.
Итак, в первом приближении я предполагал, что команды будут добавляться одна за другой в PackageBuilder, однако быстро осознал всю негибкость такого подхода. Предположим, что последовательность команд инсталлятора должна зависеть от номера версии установленного на целевой машине стороннего ПО. Например, алгоритм установка сайта очень сильно зависит от версии установленного на целевой машине IIS. Или, например, инсталлятор должен проверить наличие на целевой машине требуемых COM-объектов, и установить их при необходимости. Во всех этих случаях требуется проверка некоторых значений реестра и выбор дальнейших действий в зависимости от прочитанных оттуда значений. Отсюда мы пришли к необходимости иметь в базовом наборе команду условного выполнения, назовем ее ExecuteIf. Вот описание ее интерфейса:
public class ExecuteIf : Command
{
  public string ArgumentName { get; set; }
  public Command Then { get; set; }
  public Command Else { get; set; }

  public void Do();
}

Я подразумеваю, что метод Do() читает из некоторого контекста выполнения булеву переменную с именем ArgumentName, и если она равна True, то выполняет команду Then, в ином случае выполняет команду Else. Здесь мы столкнулись с новым понятием – контекст выполнения. Пусть контекст выполнения представляет собой экземпляр класса ExecutionContext, который(экземпляр) будет аргументом вызова метода Do() каждой команды. Для того, чтобы не раздувать интерфейс абстрактного класса перегруженным методом Do(ExecutionContext), условимся, что метод Do() всегда вызывается с аргументом ExecutionContext, но те команды, которые не изменяют контекста исполнения, будут просто игнорировать аргумент метода Do().

Таким образом, теперь наши классы теперь выглядят так:
public abstract class Command
{
  public abstract void Do(ExecutionContext context);
}

public class DeleteFile: Command
{
  public string FilePath { get; set; }

  public override void Do(ExecutionContext context)
  {
    File.Delete(FilePath);
  }
}

public class ExecuteIf : Command
{
  public string ArgumentName { get; set; }
  public Command Then { get; set; }
  public Command Else { get; set; }

  public void Do(ExecutionContext context);
}


Отвлечемся от описания базового набора команд, и спроектируем класс ExecutionContext.

Контекст выполнения


Каждая выполняемая в рамках пакета команда помимо выполнения полезной работы может также возвращать результаты своего выполнения. Например, SQL-команда может вернуть результат выполнения хранимой процедуры или количество измененных строк, чтение из реестра возвращает собственно прочитанное значение. При этом, в общем случае, команда может возвращать не один ValueType-результат, а несколько, может она также возвращать и комплексный результат, например, DataSet. Чтобы другие команды, опирающиеся при своем выполнении на результаты предыдущих операций, могли корректно работать, нужно предоставить им способ доступа к результатам выполнения предыдущих команд. При этом каждая конкретная команда может опираться не только на результат выполнения строго предыдущей команды, а вообще любой ранее выполненной команды, в том числе может она опираться на результаты выполнения сразу нескольких предыдущих команд. Например, я могу создать команду FindIndexCommand, которая определяет индекс некоторой строки (введенной пользователем) в массиве строк, который был возвращен ранее командой SqlQueryCommand. Как видно, результат выполнения команды FindIndexCommand опирается в данном случае на два аргумента — DataSet, возвращенный командой SqlQueryCommand, и строка, введенная пользователем при помощи команды UserInputCommand. Я оперирую именами команд, которые пока еще даже не спроектировал, подразумевая, что их предназначение понятно из названия команды.
Чтобы дать пользователю такие гибкие возможности, я планирую ввести некий контекст выполнения ExecutionContext. Он даст возможность сохранять в него значение под некоторым ключом, и читать из него значение, если оно там есть. Каждая выполняемая команда будет получать его в качестве первого аргумента метода Do(). Таким образом, каждая команда будет иметь доступ к результатам всех выполненных ранее команд.
Класс ExecutionContext выглядит так:
public class ExecutionContext
{
  private Dictionary<string, object> _results = new Dictionary<string, object>();

  public void SaveResult(string key, object value)
  {
    _results[key] = value;
  }

  public object GetResult(string key)
  {
    return _results[key];
  }
}

Возможно, в дальнейшем в него добавятся другие методы, но пока он будет иметь вот такой простейший вид, как в листинге выше.
Поначалу я собирался использовать ExecutionContext вот так:
public class DeleteFile: Command
{
  public string FilePath { get; set; }

  private ExecutionContext _context;

  public DeleteFile(ExecutionContext context)
  {
    _context = context;
  }

  public DeleteFile()
  {
  }

  public override void Do()
  {
  }
}


То есть подавать его экземпляр в качестве аргумента конструктора команды, сохранять его в закрытой переменной, а уж затем использовать по необходимости в методе Do(). Однако я вспомнил, что пакет команд обычно сначала сериализуется и сохраняется, а уж затем полученный файл десериализуется и выполняется, и вот на этом втором шаге десериализатор автоматически восстановит пакет в памяти, однако он не инициализирует закрытую переменную _context (ведь она закрытая), а значит и пакет будет выполняться в неинициализированном контексте. Я подумал, что контекст ведь нужен только на этапе выполнения загруженного пакета, и решил подавать контекст в качестве аргумента метода Do(). Это сразу же принесло еще одну выгоду – нам теперь не нужно в каждой новой команде писать вот этот повторяющийся код:
private ExecutionContext _context;

public DeleteFile(ExecutionContext context)
{
  _context = context;
}

public DeleteFile ()
{
}

Ведь теперь нам не нужны конструкторы с аргументами, а значит и конструктор по умолчанию описывать не нужно, ведь компилятор создаст его за нас. Теперь каркас команды выглядит так:
public class DeleteFile: Command
{
  public string FilePath { get; set; }

  public override void Do(ExecutionContext context)
  {
  }
}

То есть в нем нет ничего лишнего, только суть. А значит программисту, который будет поддерживать и развивать приложение, станет проще понимать его суть.
Теперь мы можем написать реализацию команды ExecuteIf:
public class ExecuteIf : Command
{
  public string ArgumentName { get; set; }
  public Command Then { get; set; }
  public Command Else { get; set; }

  public override void Do(ExecutionContext context)
  {
    if ((bool)context.GetResult(ArgumentName))
    {
      Then.Do(context);
    }
    else if (Else != null)
    {
      Else.Do(context);
    }
  }
}


Базовая система команд.


Вернемся к созданию каркаса команд инсталлятора. Мы уже описали базовый абстрактный класс Command, а также команду условного выполнения ExecuteIf. В качестве параметров Then и Else команды ExecuteIf может выступать не только одна команда, а целый блок последовательных команд. А это значит, что нужно спроектировать команду – аналог операторных скобок. Назовем такую команду CommandSequence. Ее интерфейс и реализация — просты:
public class CommandSequence : Command
{
  public List Commands;

  public override void Do(ExecutionContext context)
  {
    foreach (Command command in Commands)
    {
      command.Do(context);
    }
  }
}

Ее единственным параметром является список команд, а метод Do() просто выполняет их по очереди.

Вспомним теперь, что команда ExecuteIf в качестве входного аргумента принимает имя булевой переменной из контекста выполнения. Эта булева переменная должна еще откуда-то в контексте появиться. Появляться там она должна в результате проверки некоего условия. А поэтому настал черед спроектировать команды сравнения величин. Спроектируем для начала команды сравнения строк. Интерфейс команды и ее частичная реализация должны выглядеть так:
public class CompareStringsCommand: Command
{
  public string FirstOperandName { get; set; }
  public string SecondOperandName { get; set; }
  public string ResultName { get; set; }

  public void Do(ExecutionContext context)
  {
    string operand1 = (string)(context.GetResult(FirstOperandName));
    string operand2 = (string)(context.GetResult(SecondOperandName));

    bool result = Compare(operand1, operand2);
    context.SaveResult(ResultName, result);
  }
}

Команда считывает из контекста две переменные по их именам FirstOperandName и SecondOperandName, сравнивает их(само сравнение я обозначил методом Compare()), а затем сохраняет булев результат сравнения в контекст под именем ResultName.
Видно, что данная команда является хорошим претендентом на то, чтобы стать базовым абстрактным классом для всех остальных команд сравнения строк, а метод Compare(string,string) – отличным кандидатом на то, чтобы стать абстрактным методом класса и быть реализованным в наследниках. Далее я перепишу только интерфейс абстрактного класса (реализация метода Do() остается прежней), а также приведу код одного из наследников:
public abstract class CompareStringsCommand: Command
{
  public string FirstOperandName { get; set; }
  public string SecondOperandName { get; set; }
  public string ResultName { get; set; }

  protected abstract bool Compare(string operand1, string operand2);

  public override void Do(ExecutionContext context)
  {
// читаем строки из контекста, сравниваем и сохраняем в контексте результат
  }
}

public class StringStartsWith : CompareStringsCommand
{
  protected override bool Compare(string operand1, string operand2)
  {
    return operand1.StartsWith(operand2);
  }
}

Как видно, набор команд для бинарных(с двумя аргументами) операторов сравнения легко расширить любыми другими операциями сравнения(EndsWith, Contains, Equals, NotEquals) всего лишь создав наследника класса CompareStringsCommand и определив в нем метод Compare(string,string).
Унарные операции(такие как IsNullOrEmpty, например) на данном этапе предлагаю реализовывать при помощи этого же базового класса, просто игнорируя второй операнд.
Обратим внимание вот на что. Команда CompareStringsCommand сравнивает два аргумента, хранящихся в контексте выполнения, которые будут там только как результаты выполнения других команд. А что делать, если я хочу узнать, например, начинается ли строка, прочитанная из реестра, с некоторой заданной константной подстроки? Мне нужно обеспечить наличие этой константы в контексте выполнения. Спроектируем команду, которая просто сохраняет величину в контексте выполнения под заданным именем. Здесь все просто:
public class SaveConstantCommand: Command
{
  public string ResultName { get; set; }
  public object Value { get; set; }

  public override void Do(ExecutionContext context)
  {
    context.SaveResult(ResultName, Value);
  }
}

В следующей главе я рассмотрю реализацию механизма транзакций.

Ссылка на вторую часть
Tags:
Hubs:
Total votes 5: ↑5 and ↓0 +5
Views 722
Comments Comments 5