![image](https://habrastorage.org/getpro/habr/post_images/94b/1a1/5cb/94b1a15cbb76b3b6eb117f9fb9c961e3.png)
Вместо предисловия
Данный материал является исключительно результатом работы по сбору информации в сети и создания сайта, работающего на основе плагинов. Здесь я постараюсь описать идею работы такой системы и основные компоненты, необходимые для её работы.
Данная статья не претендует на оригинальность, а описанная система не является единственно правильной и красивой. Но если тебе, уважаемый $habrauser$, интересно, как создать такую систему, милости прошу под кат
Проблематика и постановка задачи
В данный момент я веду достаточно большой проект, разделенный на четыре большие части. Каждая из этих частей разбита минимум на 2 маленьких задачи. Пока в системе было 2 части, поддержка была не напряжной и выполнение изменений не вызывало никаких проблем. Но со временем система разрослась до гигантских размеров и такая простая задача, как сопровождение кода стала достаточно затруднительной. Поэтому я пришел к выводу, что весь этот зоопарк надо дробить на части. Проект представляет собой ASP.NET MVC сайт в интрасети на котором работают сотрудники фирмы. Со временем пять-семь представлений и два-три контроллера переросли в огромную кучу, которую стало трудно обслуживать.
Поиск решения
Для начала я начал искать стандартные пути решения проблемы разделения проекта на части. Сразу в голову пришла мысль о областях(Areas). Но данный вариант был отброшен сразу, так как по сути просто дробил проект на еще более маленькие элементы, что не решало проблему. Также были рассмотрены все «стандартные» методы решения, но и там я не нашел ничего, что меня удовлетворило.
Основная идея была проста. Каждый компонент системы должен быть плагином с простым способом подключения к работающей системе, состоящий из как можно меньшего количества файлов. Создание и подключение нового плагина не должно никак затрагивать корневое приложение. И само подключение плагина не должно быть труднее чем пара кликов мышки.
Подход первый
В ходе долгого гугления и поисков на просторах интернета было найдено описание плагинной системы. Автор предоставил исходники проекта, которые были скачаны незамедлительно. Проект красивый, и даже выполняет все, что я хочу видеть в готовом решении. Запустил, посмотрел. Действительно, плагины, представленные в виде отдельных проектов компилируются и «автоматически подключаются» (здесь я написал в кавычках не просто так. Далее я напишу почему) к сайту. Я уже был готов прыгать от радости, но… Вот тут возникло НО, которого никак не ждал. Посмотрев параметры проектов плагинов я обнаружил параметры построения. В них были написаны параметры пост-построения, которыми выполнялось копирование библиотеки классов и областей(Areas) в папку с сайтом. Это было большим расстройством. Совсем не похоже на «удобную плагинную систему». Поэтому я продолжил поиски. И, как ни странно, этот поиск завершился успехом.
Подход номер два
Итак, на одном из форумов я нашел описание очень интересной системы. Не буду долго расписывать весь путь поэтому я сразу скажу, что нашел именно то, что искал. Система работает на основе прекомплированных плагинов с встроенными представлениями. Подключение нового плагина осуществляется копированием dll'ки в папку плагинов и перезапуском приложения. Именно эту систему я взял за основу для работы.
Начало работы
Для начала откроем Visual Studio и создадим новый проект Web Application MVC 4(на самом деле версия MVC не играет огромной роли). Но кидаться в написание кода мы не будем. Мы создадим необходимые базовые компоненты. Поэтому добавим в решение проект типа Class Library и назовем его Infrastructure.
Сначала необходимо создать интерфейс плагина, который должны реализовывать все плагины.
Код простой и писать о нем я не буду.
IModule
namespace EkzoPlugin.Infrastructure
{
public interface IModule
{
/// <summary>
/// Имя, которое будет отображаться на сайте
/// </summary>
string Title { get; }
/// <summary>
/// Уникальное имя плагина
/// </summary>
string Name { get; }
/// <summary>
/// Версия плагина
/// </summary>
Version Version { get; }
/// <summary>
/// Имя контроллера, который будет обрабатывать запросы
/// </summary>
string EntryControllerName { get; }
}
}
Теперь мы создадим еще один проект типа Class Library, назовем его PluginManager. В этом проекте будут все необходимые классы, отвечающие за подключение к базовому проекту.
Создадим файл класса и напишем следующий код:
PluginManager
namespace EkzoPlugin.PluginManager
{
public class PluginManager
{
public PluginManager()
{
Modules = new Dictionary<IModule, Assembly>();
}
private static PluginManager _current;
public static PluginManager Current
{
get { return _current ?? (_current = new PluginManager()); }
}
internal Dictionary<IModule, Assembly> Modules { get; set; }
//Возвращаем все загруженные модули
public IEnumerable<IModule> GetModules()
{
return Modules.Select(m => m.Key).ToList();
}
//Получаем плагин по имени
public IModule GetModule(string name)
{
return GetModules().Where(m => m.Name == name).FirstOrDefault();
}
}
}
Этот класс реализует менеджер плагинов, который будет хранить список загруженных плагинов и манипулировать ими.
Теперь создадим новый файл класса и назовем его PreApplicationInit. Именно здесь будет твориться магия, которая позволит автоматически подключать плагины при запуске приложений. За «магию» отвечает атрибут PreApplicationStartMethod (прочитать о нем можно тут). Если кратко, метод, указанный при его объявлении, будет выполнен перед стартом веб-приложения. Даже раньше чем Application_Start. Это позволит нам загрузить наши плагины до начала работы приложения.
PreApplicationInit
[assembly: PreApplicationStartMethod(typeof(EkzoPlugin.PluginManager.PreApplicationInit), "InitializePlugins")]
namespace EkzoPlugin.PluginManager
{
public class PreApplicationInit
{
static PreApplicationInit()
{
//Указываем путь к папке с плагинами
string pluginsPath = HostingEnvironment.MapPath("~/plugins");
//Указываем путь к временной папке, куда будут выгружать плагины
string pluginsTempPath = HostingEnvironment.MapPath("~/plugins/temp");
//Если папка плагинов не найдена, выбрасываем исключение
if (pluginsPath == null || pluginsTempPath == null)
throw new DirectoryNotFoundException("plugins");
PluginFolder = new DirectoryInfo(pluginsPath);
TempPluginFolder = new DirectoryInfo(pluginsTempPath);
}
/// <summary>
/// Папка из которой будут копироваться файлы плагинов
/// </summary>
/// <remarks>
/// Папка может содержать подпапки для разделения плагинов по типам
/// </remarks>
private static readonly DirectoryInfo PluginFolder;
/// <summary>
/// Папка в которую будут скопированы плагины
/// Если не скопировать плагин, его будет невозможно заменить при запущенном приложении
/// </summary>
private static readonly DirectoryInfo TempPluginFolder;
/// <summary>
/// Initialize method that registers all plugins
/// </summary>
public static void InitializePlugins()
{
Directory.CreateDirectory(TempPluginFolder.FullName);
//Удаляем плагины во временной папке
foreach (var f in TempPluginFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
try
{
f.Delete();
}
catch (Exception)
{
}
}
//Копируем плагины
foreach (var plug in PluginFolder.GetFiles("*.dll", SearchOption.AllDirectories))
{
try
{
var di = Directory.CreateDirectory(TempPluginFolder.FullName);
File.Copy(plug.FullName, Path.Combine(di.FullName, plug.Name), true);
}
catch (Exception)
{
}
}
// * Положит плагины в контекст 'Load'
// Для работы метода необходимо указать 'probing' папку в web.config
// так: <probing privatePath="plugins/temp" />
var assemblies = TempPluginFolder.GetFiles("*.dll", SearchOption.AllDirectories)
.Select(x => AssemblyName.GetAssemblyName(x.FullName))
.Select(x => Assembly.Load(x.FullName));
foreach (var assembly in assemblies)
{
Type type = assembly.GetTypes().Where(t => t.GetInterface(typeof(IModule).Name) != null).FirstOrDefault();
if (type != null)
{
//Добавляем плагин как ссылку к проекту
BuildManager.AddReferencedAssembly(assembly);
//Кладем плагин в менеджер для дальнейших манипуляций
var module = (IModule)Activator.CreateInstance(type);
PluginManager.Current.Modules.Add(module, assembly);
}
}
}
}
}
Это практически все, что нужно для организации работы плагинной системы. Теперь скачаем библиотеку, которая обеспечит работу с встроенными представлениями, и добавим ссылку на неё к проекту.
Осталось создать код, необходимый для регистрации плагинов.
Скрытый текст
namespace EkzoPlugin.PluginManager
{
public static class PluginBootstrapper
{
public static void Initialize()
{
foreach (var asmbl in PluginManager.Current.Modules.Values)
{
BoC.Web.Mvc.PrecompiledViews.ApplicationPartRegistry.Register(asmbl);
}
}
}
}
Данный модуль выполняет загрузку и регистрацию прекомпилированных ресурсов. Это необходимо сделать, чтобы запросы, пришедшие на сервер были правильно направлены на прекомпилированные представления.
Теперь можно перейти к нашей базовой системе и настроить её для работы с плагинами. Для начала мы откроем web.config файл и добавим следующую строку в раздел runtime
<runtime>
<assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
<probing privatePath="plugins/temp" />
Без этой настройки плагины работать не будут.
Теперь добавим к проекту ссылки на созданные ранее проект EkzoPlugin.PluginManager.
Теперь открываем Global.asax и добавляем всего две строки. Первой мы подключим пространство имен EkzoPlugin.PluginManager
using EkzoPlugin.PluginManager;
А вторую добавим первой строкой в Applicaton_Start()
protected void ApplicationStart()
{
PluginBootstrapper.Initialize();
Тут немного поясню. Помните PreApplicationInit атрибут? Так вот, перед тем, как ApplicationStart получил управление, была выполнена инициализация модулей и их загрузка в менеджер плагинов. А когда управление получила процедура ApplicationStart мы выполняем регистрацию загруженным модулей, чтобы программа знала как обрабатывать маршруты к плагинам.
Вот и все. Наше базовое приложение готово. Оно умеет работать с плагинами, расположенными в папке plugins.
Пишем плагин
Давайте теперь напишем простой плагин для демонстрации работы. Сразу хочу оговориться, что все плагины должны иметь общее пространство имен с базовым проектом и располагаться в подпространстве имен Plugin(на самом деле это не жесткое ограничение, но советую его придерживаться, чтобы избежать неприятностей). Эта та цена, которую приходится платить.
За основу возьмем проект Web Application MVC. Создадим пустой проект.
Добавим новую папку Controllers и добавим новый контроллер. Назовем его SampleMvcController.
По умолчанию Visual Studio создает контроллер с единственным экшеном Index(). Так как мы делаем простой плагин для примера, не будем его менять, а просто добавим для него представление.
После добавления представления, откроем его и напишем что-то, что будет идентифицировать наш плагин.
Например так:
<h3>Sample Mvc Plugin</h3>
Теперь откроем менеджер дополнений Visual Studio и установим RazorGenerator. Данное расширение позволяет добавлять представления в скомпилированный dll файл.
После установки, выделим представление index.cshtml в solution explorer'е и в окне свойств выставим следующие значения:
Build Action: EmbeddedResource
Custom Tool: RazorGenerator
Эти настройки дают указание на включение представления в ресурсы компилируемой библиотеки.
Мы почти закончили. Нам надо сделать все два простых шага для того, чтобы наш плагин заработал.
Первым делом мы должны добавить ссылку на созданный ранее проект EkzoPlugin.Infrastructure, содержащий интерфейс плагина, который мы и реализуем.
Добавим в проект плагина класс и назовем его SampleMVCModule.cs
SampleMVCModule
using EkzoPlugin.Infrastructure;
namespace EkzoPlugin.Plugins.SampleMVC
{
public class SampleMVCModule : IModule
{
public string Title
{
get { return "SampleMVCPlugin"; }
}
public string Name
{
get { return Assembly.GetAssembly(GetType()).GetName().Name; }
}
public Version Version
{
get { return new Version(1, 0, 0, 0); }
}
public string EntryControllerName
{
get { return "SampleMVC"; }
}
}
}
Вот и все. Плагин готов. Просто не правда ли?
Теперь скомпилируем решение и скопируем библиотеку, полученную в результате сборки плагина, в папку plugins базового сайта.
Добавим в файл _Layout.cshtml базового сайта следующие строки
@using EkzoPlugin.Infrastructure
@using EkzoPlugin.PluginManager
@{
IEnumerable<IModule> modules = PluginManager.Current.GetModules();
Func<string, IModule> getModule = name => PluginManager.Current.GetModule(name);
}
<html ......
<head>....</head>
<body>
<ul id="pluginsNavigation">
<li class="MenuItem">@Html.ActionLink("Home","Index","Home",null,null)</li>
@foreach (IModule module in modules)
{
<li class="MenuItem">@Html.ActionLink(module.Title, "Index", module.EntryControllerName)</li>
}
</ul>
...
</body>
</html>
Таким образом мы добавим ссылки на загруженные модули.
Вместо заключения
Вот и готов плагинный сайт на ASP.NET MVC. Не все идеально, не все красиво, согласен. Но свою основную задачу он выполняет. Хочу заметить, что при работе с реальным проектом очень удобным будет настройка команд посткомпиляции проектов плагинов, которые будут сами выкладывать результат в папку плагинов базового сайта.
Также хочу отметить, что каждый модуль может быть разработан и протестирован как отдельный сайт, а после компиляции в готовый модуль достаточно просто подключен к проекту.
Есть небольшая тонкость работы с скриптами и ссылками на сторонние библиотеки. Во-первых, плагин будет использовать скрипты, расположенные в папках базового сайта. То есть, если вы добавили на этапе разработки плагина какой-то скрипт, не забудьте выложить его в соответствующий каталог базового класса (это также актуально для стилей, изображений и т.д.).
Во-вторых, подключенная сторонняя библиотека должна быть размещена в папке bin базового сайта. Иначе вы получите ошибку о том, что не удалось найти необходимую сборку.
В-третьих, ваш плагин будет работать с web.config файлом базового сайта. Таким образом, если в плагине используется строка подключение или другая секция, читаемая из файла конфигурации, вам необходимо вручную перенести её.
Надеюсь, что данная статья будет кому-то интересна.
Проект на GitHub — ссылка
Проект с MetroUI шаблоном — ссылка