Пишем модульное приложение на .Net Framework



Современные тенденции в разработке информационных систем требуют от проектировщиков закладывать в архитектуру систем возможность динамического расширения их функционала. И не смотря на существование огромного количества наработок в этом направлении, единого решения структуры модульного приложения нет. Использование же готового решения не всегда возможно в силу специфики языка программирования или разрабатываемой системы. Так же, готовые решения модульных систем не всегда доступны для изучения, а иногда излишне сложны.

Модули в разных системах, зачастую, имеют разные границы функциональности. В системе могут быть выделены некоторые, строго определенные точки расширения – некоторый функционал, дополняемый сторонними разработчиками. Или система может представлять собой лишь механизм управления модулями, а весь ее функционал реализуется отдельными модулями.

Вперые опубликовано  на habrahabr.ru

Этапы проектирования модульных приложений


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

Далее разрабатываются интерфейсы, с помощью которых система будет обращаться к сторонним реализациям за этим функционалом.

Самым тонким моментом становится вопрос о том, как динамически добавлять реализации интерфейсов.

Reflection


В .Net Framework присутствует мощная технология reflection. Reflection позволяет программе отслеживать и модифицировать собственную структуру и поведение во время выполнения.

Применительно к нашей задаче, reflection позволяет загружать библиотеки в память и проверять все реализованные в ней классы на предмет реализации необходимых интерфейсов.

Т.е., можно добавлять библиотеки (с реализацией выделенных интерфейсов), представляющие собой модули, в специальную директорию и с помощью технологии reflection находить и инстанцировать необходимые классы.Это наиболее популярное решение, которое часто встречается на просторах интернета. Но у подобного подхода есть существенные недостатки, связанные с высокой ресурсоемкостью.

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

Структура модуля


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

Простейшим решением устройства модуля может быть следующее:

1. Модуль представляет собой архив всех необходимых библиотек. В качестве алгоритма сжатия может выступать zip. Причем, как такового сжатия не требуется (бинарные библиотеки плохо поддаются архивированию), необходимо просто объединить все составляющие модуля в один файл.

2. Кроме библиотек, модуль должен содержать текстовый файл (называемый дескриптором), содержащий информацию о главной библиотеке, зависимостях, и, для повышения быстродействия путем исключения перебора, название реализуемого интерфейса вместе с полным именем класса его реализующего.

Для реализации дескриптора плагина представляется удобным воспользоваться XML.

Простейшая структура такого документа может иметь следующий вид:


<?xml version="1.0"?>
<plugin>
      <type>Имя интерфейса</type>
      <name>Имя модуля</name>
      <description>Описание модуля.</description>
      <version>Номер версии модуля</version>
      <class>
        Полное имя класса, реализующего указанный интерфейс
      </class>
      <assembly>Главная библиотека</assembly>
      <dependences>
            <dependence>Дополнительная библиотека</dependence>
      </dependences>
</plugin>


Добавление и удаление модулей


Добавление нового модуля в систему может происходить в следующей последовательности:

1. Системе передается полный путь файла с добавляемым модулем.
2. Добавляемый модуль проверяется на соответствие своему дескриптору: проверяется наличие всех указанных библиотек, наличие главного класса и реализация им указанного интерфейса.
3. В директории системы, отведенной под хранение модулей, создается новая поддиректория для добавляемого модуля. Все библиотеки модуля копируются в эту директорию.
4. Вычисляется уникальный идентификатор* модуля (как вариант, можно взять хеш от имени и версии модуля).
5. Вся информация из дескриптора модуля и вычисленный идентификатор записываются в системный реестр модулей (xml-файл, хранящий информацию об установленных в системе модулях).
________________________________________
*этот идентификатор вводится на случай, если возникнет необходимость сохранения информации об использовании модуля в прошлой сессии работы в системе

В простейшем случае реестр модулей может представлять собой xml-файл со схожей с приведенной выше структурой, с той лишь разницей, что записей о модулях в ней будет много:

<?xml version="1.0"?>
<plugins>
<plugin>
<id>534523</id>
      <type>Имя интерфейса</type>
      <name>Имя модуля 1</name>
      <description>Описание модуля 1</description>
      <version>Номер версии модуля 1</version>
      <class>
          Полное имя класса, реализующего указанный интерфейс
      </class>
      <assembly>Главная библиотека</assembly>
      <dependences>
            <dependence>Дополнительная библиотека</dependence>
      </dependences>
</plugin>
<plugin>> 
<id>79568</id>
      <type>Имя интерфейса</type>
      <name>Имя модуля 2</name>
      <description>Описание модуля 2</description>
      <version>Номер версии модуля 2</version>
      <class>
          Полное имя класса, реализующего указанный интерфейс
      </class>
      <assembly>Главная библиотека</assembly>
      <dependences>
            <dependence>Дополнительная библиотека</dependence>
      </dependences>
</plugin>
. . . .
</plugins>


Последовательность действий для удаления модуля:
1. Удаление директории модуля**.
2. Удаление информации о модуле из реестра.

________________________________________
**здесь следует заметить следующий нюанс: инициирование удаления модуля пользователем может быть выполнено при активном использовании этого модуля системой, следствием чего может быть блокировка библиотек модуля. К этой проблеме я еще вернусь ниже.

Структура классов



PluginDescriptor — предоставляет информацию о модуле, достаточную для его инстанцирования.

PluginRegister — выполняет операции чтения и записи в реестр модулей. Возвращает дескриптор модуля, соответствующего указанному идентификатору. Может вернуть полный список дескрипторов всех доступных модулей.

Plugin — предоставляет информацию о модуле, основываясь на соответствующей информации в дескрипторе.Содержит ссылку на объект, представляющий собой реализацию модуля. Возможно, существование этого класса может показаться избыточным. Но его ценность проявляется в том случае, если решение о целесообразности использования модуля пользователь принимает на основании его (модуля) описании. В таком случае, мы можем предоставить всю необходимую пользователю информацию не загружая библиотек модуля.

PluginManager — выполняет добавление и удаление модулей в системе. Осуществляет контроль целостности модуля при его добавлении. Реализуется в соответствии с шаблоном «одиночка», для унификации способов получения модулей.

PluginLoader — выполняет инстанцирование модуля. Необходимость введения этого класса вызвана спецификой самого процесса инстанцирования, речь о которой пойдет ниже (так же можно прочесть эту статью).

Нюансы динамического подключения модулей, специфичные для .Net Framework


У динамической загрузки сборок в .Net есть особенность — сборки загружаются в так называемые AppDomain — домены приложения, являющиеся изолированной средой, в которой выполняются приложения. Нюанс в том, что выгрузка сборки отдельно невозможна. Ее возможно произвести только выгрузив весь домен целиком. Таким образом, после инициализации модуля все используемые им библиотеки блокируются для удаления из файловой системы, и их выгрузка из памяти становится невозможной.

Для разрешения проблемы с блокированием файлов библиотек существует решение, называемое теневое копирование. Его суть заключается в копировании файла библиотеки и загрузки в память уже ее копии, оригинал при этом остается доступен для удаления.

Решение проблемы, связанной с выгрузкой ранее загруженного модуля из памяти, заключается в создании для модуля отдельного домена. Помимо решения проблемы с выгрузкой модуля из памяти, это решение открывает возможность ограничения прав исполняемого кода. Например, модулю можно запретить чтение и запись в любой директории отличной от той, в которой он располагается. Но у подобного решения есть и обратная сторона, требующая сериализации классов, реализующих интерфейсы модулей.

Для наглядности приведу код класса PluginLoader и часть кода класса Plugin (в приведенном коде не учитывается наличие зависимостей загружаемого модуля):


public class BasePlugin
{     
      ...
      
      /// <summary>
      /// Возвращает реализацию инкапсулируемого расширения.
      /// </summary>
      /// <remarks>
      /// Инстанцирование расширения происходит при первом обращении.
      /// </remarks>
      public Object Instance {
            get {
                  if (instance == null) {
                        instance = CreateInstance();
                  }
                  return instance;
            }
      }

      /// <summary>
      /// Создает домен для загружаемой реализации модуля 
      /// и загружает ее в него.
      /// </summary>
      /// <returns>
      /// В случае успеха загруженное расширение, иначе null.
      /// </returns>
      private Object CreateInstance()
      {
            Descriptor d = this.Descriptor;
                  
            /* Настраиваем домен */
            AppDomainSetup setup = new AppDomainSetup();
            setup.ShadowCopyFiles = "true"; // включаем теневое копирование
            
            //TODO: Задать настройки безопасности для плагина
            
            /* Создаем домен для плагина */
            AppDomain domain = AppDomain.CreateDomain(d.ToString(), null, setup);
            
            /* Создаем загрузчик плагина */
            PluginLoader loader = 
                  (PluginLoader)domain.CreateInstanceFromAndUnwrap(
                        typeof(PluginLoader).Assembly.Location, 
                        typeof(PluginLoader).FullName);
            
            /* Создаем экземпляр плагина */
            Object obj = null;
            try {
                  obj = loader.CreateInstance(d, PluginManager
                                              .GetPluginDirectory(this));
            } catch (Exception e) {
                  AppDomain.Unload(domain);
                  throw new PluginLoadException(d, "", e);
            }
            return obj;
      }           
}      

[Serializable]
public class PluginLoader
{
      /// <summary>
      /// Загружает сборку модуля и возвращает реализацию модуля.
      /// </summary>
      /// <param name="d">Дескриптор загружаемого модуля.</param>
      /// <param name="pluginDirectory">
      /// Полный путь к директории загружаемого модуля.
      /// </param>
      /// <returns>
      /// Реализацию модуля, соответствующего переданному дескриптору.
      /// </returns>
      public Object CreateInstance(Descriptor d, String pluginDirectory)
      {
            String assemblyFile = Path.Combine(pluginDirectory, d.AssemblyName);
            
            /* Пытаемся загрузить сборку. */
            Assembly assembly = null;
            try {
                  AssemblyName asname = 
                      AssemblyName.GetAssemblyName(assemblyFile);
                  assembly = AppDomain.CurrentDomain.Load(asname);           
            } catch (Exception e) {
                  throw new AssemblyLoadException(assemblyFile,
                                   "Ошибка загрузки сборки.", e);
            }
            
            /* Пробуем получить объект класса, реализующего модуль. */
            Object obj = null;
            try {
                  obj = assembly.CreateInstance(d.ClassName);
            } catch (Exception e) {
                  throw new ClassLoadException(d.ClassName, assembly.FullName,
                                   "Ошибка при получении экземпляра класса.", e);
            }
            return obj;
      }
}




Заключение


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

Из заведомо нерешенных проблем, могу привести следующее: возможны избыточные расходы ресурсов в том случае, если модули имеют в зависимостях одинаковые библиотеки.

1 комментарий:

Alex комментирует...

Теперь нашел нужную информацию.Буду разбираться и использовать.