Современные тенденции в разработке информационных систем требуют от проектировщиков закладывать в архитектуру систем возможность динамического расширения их функционала. И не смотря на существование огромного количества наработок в этом направлении, единого решения структуры модульного приложения нет. Использование же готового решения не всегда возможно в силу специфики языка программирования или разрабатываемой системы. Так же, готовые решения модульных систем не всегда доступны для изучения, а иногда излишне сложны.
Модули в разных системах, зачастую, имеют разные границы функциональности. В системе могут быть выделены некоторые, строго определенные точки расширения – некоторый функционал, дополняемый сторонними разработчиками. Или система может представлять собой лишь механизм управления модулями, а весь ее функционал реализуется отдельными модулями.
Для разработки модульного приложения, прежде всего, необходимо выделить тот функционал, который должен расширяться с помощью модулей.
Далее разрабатываются интерфейсы, с помощью которых система будет обращаться к сторонним реализациям за этим функционалом.
Самым тонким моментом становится вопрос о том, как динамически добавлять реализации интерфейсов.
В .Net Framework присутствует мощная технология reflection. Reflection позволяет программе отслеживать и модифицировать собственную структуру и поведение во время выполнения.
Применительно к нашей задаче, reflection позволяет загружать библиотеки в память и проверять все реализованные в ней классы на предмет реализации необходимых интерфейсов.
Т.е., можно добавлять библиотеки (с реализацией выделенных интерфейсов), представляющие собой модули, в специальную директорию и с помощью технологии reflection находить и инстанцировать необходимые классы.Это наиболее популярное решение, которое часто встречается на просторах интернета. Но у подобного подхода есть существенные недостатки, связанные с высокой ресурсоемкостью.
Загрузка сборки в память и перебор всех доступных в ней классов, в поисках реализаций интерфейсов, требует большого количества оперативной памяти, и ощутимого времени на выполнение перебора. Все усложняется в том случае, если реализация метода выходит за рамки одной библиотеки и имеет зависимости от сторонних библиотек. Тогда под перебор попадают сборки, вообще не содержащие реализаций необходимых интерфейсов и процессорное время, затрачиваемое на их исследование, тратится впустую.
Очевидно, чтобы исключить лишние библиотеки из перебора, необходима дополнительная информация о модуле. Подобным источником информации может выступить текстовый файл, сопровождающий библиотеки модуля и предоставляющий информацию о них. Таким образом, перед нами встает задача разработки требований к структуре модуля, одним из пунктов которого можно предложить требование наличия файла, указывающего на главную библиотеку с реализацией интерфейсов и содержащего перечень всех необходимых зависимостей.
Простейшим решением устройства модуля может быть следующее:
1. Модуль представляет собой архив всех необходимых библиотек. В качестве алгоритма сжатия может выступать zip. Причем, как такового сжатия не требуется (бинарные библиотеки плохо поддаются архивированию), необходимо просто объединить все составляющие модуля в один файл.
2. Кроме библиотек, модуль должен содержать текстовый файл (называемый дескриптором), содержащий информацию о главной библиотеке, зависимостях, и, для повышения быстродействия путем исключения перебора, название реализуемого интерфейса вместе с полным именем класса его реализующего.
Для реализации дескриптора плагина представляется удобным воспользоваться XML.
Простейшая структура такого документа может иметь следующий вид:
Добавление нового модуля в систему может происходить в следующей последовательности:
1. Системе передается полный путь файла с добавляемым модулем.
2. Добавляемый модуль проверяется на соответствие своему дескриптору: проверяется наличие всех указанных библиотек, наличие главного класса и реализация им указанного интерфейса.
3. В директории системы, отведенной под хранение модулей, создается новая поддиректория для добавляемого модуля. Все библиотеки модуля копируются в эту директорию.
4. Вычисляется уникальный идентификатор* модуля (как вариант, можно взять хеш от имени и версии модуля).
5. Вся информация из дескриптора модуля и вычисленный идентификатор записываются в системный реестр модулей (xml-файл, хранящий информацию об установленных в системе модулях).
________________________________________
*этот идентификатор вводится на случай, если возникнет необходимость сохранения информации об использовании модуля в прошлой сессии работы в системе
В простейшем случае реестр модулей может представлять собой xml-файл со схожей с приведенной выше структурой, с той лишь разницей, что записей о модулях в ней будет много:
Последовательность действий для удаления модуля:
1. Удаление директории модуля**.
2. Удаление информации о модуле из реестра.
________________________________________
**здесь следует заметить следующий нюанс: инициирование удаления м��дуля пользователем может быть выполнено при активном использовании этого модуля системой, следствием чего может быть блокировка библиотек модуля. К этой проблеме я еще вернусь ниже.

PluginDescriptor — предоставляет информацию о модуле, достаточную для его инстанцирования.
PluginRegister — выполняет операции чтения и записи в реестр модулей. Возвращает дескриптор модуля, соответствующего указанному идентификатору. Может вернуть полный список дескрипторов всех доступных модулей.
Plugin — предоставляет информацию о модуле, основываясь на соответствующей информации в дескрипторе.Содержит ссылку на объект, представляющий собой реализацию модуля. Возможно, существование этого класса может показаться избыточным. Но его ценность проявляется в том случае, если решение о целесообразности использования модуля пользователь принимает на основании его (модуля) описании. В таком случае, мы можем предоставить всю необходимую пользователю информацию не загружая библиотек модуля.
PluginManager — выполняет добавление и удаление модулей в системе. Осуществляет контроль целостности модуля при его добавлении. Реализуется в соответствии с шаблоном «одиночка», для унификации способов получения модулей.
PluginLoader — выполняет инстанцирование модуля. Необходимость введения этого класса вызвана спецификой самого процесса инстанцирования, речь о которой пойдет ниже (так же можно прочесть эту статью).
У динамической загрузки сборок в .Net есть особенность — сборки загружаются в так называемые AppDomain — домены приложения, являющиеся изолированной средой, в которой выполняются приложения. Нюанс в том, что выгрузка сборки отдельно невозможна. Ее возможно произвести только выгрузив весь домен целиком. Таким образом, после инициализации модуля все используемые им библиотеки блокируются для удаления из файловой системы, и их выгрузка из памяти становится невозможной.
Для разрешения проблемы с блокированием файлов библиотек существует решение, называемое теневое копирование. Его суть заключается в копировании файла библиотеки и загрузки в память уже ее копии, оригинал при этом остается доступен для удаления.
Решение проблемы, связанной с выгрузкой ранее загруженного модуля из памяти, заключается в создании для модуля отдельного домена. Помимо решения проблемы с выгрузкой модуля из памяти, это решение открывает возможность ограничения прав исполняемого кода. Например, модулю можно запретить чтение и запись в любой директории отличной от той, в которой он располагается. Но у подобного решения есть и обратная сторона, требующая сериализации классов, реализующих интерфейсы модулей.
Для наглядности приведу код класса PluginLoader и часть кода класса Plugin (в приведенном коде не учитывается наличие зависимостей загружаемого модуля):
Чтобы не бросать статью на голом коде, подведу итог всего выше изложенного. Все приведенные соображения были испытаны мной всего в одном проекте, поэтому не исключено, что в Вашем опыте следования этой статье возникнут незамеченные мной трудности. Поэтому, я буду рад любым замечаниям и комментариям.
Из заведомо нерешенных проблем, могу привести следующее: возможны избыточные расходы ресурсов в том случае, если модули имеют в зависимостях одинаковые библиотеки. Если есть интересные соображения по этому вопросу — поделитесь.
Модули в разных системах, зачастую, имеют разные границы функциональности. В системе могут быть выделены некоторые, строго определенные точки расширения – некоторый функционал, дополняемый сторонними разработчиками. Или система может представлять собой лишь механизм управления модулями, а весь ее функционал реализуется отдельными модулями.
Этапы проектирования модульных приложений
Для разработки модульного приложения, прежде всего, необходимо выделить тот функционал, который должен расширяться с помощью модулей.
Далее разрабатываются интерфейсы, с помощью которых система будет обращаться к сторонним реализациям за этим функционалом.
Самым тонким моментом становится вопрос о том, как динамически добавлять реализации интерфейсов.
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 = LoadInstance(); } return instance; } } /// <summary> /// Создает домен для загружаемой реализации модуля /// и загружает ее в него. /// </summary> /// <returns> /// В случае успеха загруженное расширение, иначе null. /// </returns> private Object LoadInstance() { 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; } }
Заключение
Чтобы не бросать статью на голом коде, подведу итог всего выше изложенного. Все приведенные соображения были испытаны мной всего в одном проекте, поэтому не исключено, что в Вашем опыте следования этой статье возникнут незамеченные мной трудности. Поэтому, я буду рад любым замечаниям и комментариям.
Из заведомо нерешенных проблем, могу привести следующее: возможны избыточные расходы ресурсов в том случае, если модули имеют в зависимостях одинаковые библиотеки. Если есть интересные соображения по этому вопросу — поделитесь.
