Пользователь
0,0
рейтинг
19 ноября 2013 в 20:04

Разработка → Плагинная система на ASP.NET. Или сайт с плагинами, мадемуазелями и преферансом recovery mode

ASP*, .NET*
image

Вместо предисловия


Данный материал является исключительно результатом работы по сбору информации в сети и создания сайта, работающего на основе плагинов. Здесь я постараюсь описать идею работы такой системы и основные компоненты, необходимые для её работы.
Данная статья не претендует на оригинальность, а описанная система не является единственно правильной и красивой. Но если тебе, уважаемый $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 шаблоном — ссылка
Алексей @ParaPilot
карма
1,0
рейтинг 0,0
Реклама помогает поддерживать и развивать наши сервисы

Подробнее
Спецпроект

Самое читаемое Разработка

Комментарии (19)

  • 0
    Спасибо за статью. Пришел примерно к такому же решению, но пока без прекомпилированных вьюх. И информацию о плагине я бы не стал брать из сборки, так как вы будете вынуждены ее загрузить. Если брать манифест в файлике, то вы еще до загрузки сборки можете проверить, например, совместимость версий.
    Еще классно можно подцепить модули в виде Nuget пакетов ;)
    • 0
      Спасибо.
      Если брать манифест в файлике, то вы еще до загрузки сборки можете проверить, например, совместимость версий

      Идея хорошая. Попробую обязательно как будет время.
      Еще классно можно подцепить модули в виде Nuget пакетов ;)

      Согласен, идея очень хорошая. Но я сейчас занимаюсь преобразованием компонентов в шаблоны. Базовую систему в шаблон и плагин в шаблон для ускорения разработки.
  • 0
    А так же для плагинов можно использовать Managed Extensibility Framework.
    • 0
      MEF — мощный инструмент, но в контексте модульной системы для сайтов, на мой взгляд, слишком тяжеловесна.
    • 0
      Для Asp.Net приложения там возникают разные нюансы. Например в плане загрузки сборок — shadow coping сборок перед загрузкой приложения и пробинг их из приватной папки веб приложения. Плюс MEF не решает вопросов с ресурсами(js, css, cshtml).
  • 0
    А можно подробнее, чем не устроили Areas?
    С помощью того же RazorGenerator они отлично выносятся в отдельные dll'ки и получается, как мне кажется, нечто очень похожее на ваш конечный результат. Или я что-то упускаю?
    • 0
      Области не устроили только тем, что разработка плагина с их помощью не может быть выделена в написание/тестирование отдельного MVC проекта, который в конечном итоге компилируется в один единственный файл. Но я могу ошибаться, прошу поправьте.
      • 0
        Вот мне тоже так показалось, поэтому я и привел ссылку в комментарии :)
        Да, Areas можно достаточно просто выносить в отдельные dll'ки.
  • 0
    Спасибо за статью!

    Кстати, вышеописанный подход очень (!!!) похож на существующую реализацию системы плагинов в nopCommerce (http://www.nopcommerce.com) и Umbraco (http://umbraco.com/). Я бы даже сказал, что Вы переписали эту систему плагинов оттуда, просто переименовав названия классов и немного упростив код.

    Кстати, статью лучше назвать «Плагинная система на ASP.NET», а не «Плагинная система на MVC», так как все вышеописанное (кроме примера с Layout.cshtml) можно реализовать и в стандартных ASP.NET web forms
    • 0
      Честно говоря никогда не смотрел внутрь Umbraco и nopCommerce, поэтому не могу ничего сказать по поводу схожести. Как я уже написал, конечная система родилась в результате сбора информации в сети и создания конечного проекта.
      Конечно, я потратил больше времени при таком подходе, но считаю, что сделал это не зря, потому что разобрался с подходом и его функционированием.
      • 0
        Я не утверждаю, что вы «заимствовали» код из этих систем. Но если вы взгляните на их реализацию, то поймете о чем я говорю.
  • 0
    Классно! В свое время, еще на ASP.NET 1.1 сделал загрузку сборок через Reflection с дополнительным функционалом к основному ядру сайта.
    Модули конфигурировались в одну строчку в определенном хмл файле

    type id='AbsType' manager='extman.EventFestDopInfoManager;EventDopInfo.dll'

    Тем самым, базовый функционал можно было безгранично расширять, без перекомпиляции ядра.
    В модуле переопределялись методы базовых классов из ядра. Добавлялись новые классы ну и т.д. Страницы (вьюхи в нотации MVC) правда, мы туда не складывали, но ничто не мешает это делать.
    Так как технология Web Forms категорически не устраивала, только с приходом MVC появилась возможность перевести весь проект на новые фрэймвоки. Вообще конечно жалко, что не было MVC в то время не было. Web Forms вселяло беспросветную тоску. Пришлось выкручиваться и придумывать все с нуля.

    Будем переводить сразу на 4.5.
    Думаю этот пост мне поможет в этом.
    Если абстрагироваться от MVC и вообще от ASP. NET. Подобная идея отлично подходит для построения любого модульного приложения.

    Запускать модули как отдельный сайт это интересная идея. Для тестов подойдет, нужно попробовать.
  • +1
    Что-то вы слишком усложняете. Проще надо быть, проще…

    Узнать суть
    Например:
    //в целях упрощения примера пусть абсолютно все контроллеры будут плагинами. При этом в «плагине» может быть много контроллеров. Структура плагина — веб проект с стандартной asp.net mvc структурой каталогов — bin\ — бинарь, Views — вьюхи.

    1) рисуем и тестируем свои контроллеры как обычно, но роуты проставляем атрибутами (для mvc4 используем AttributeRouting, для mvc5 — встроенный). Я буду рассматривать mvc5 — различий не особо много. Например HomeController в плагине HomePlugin выглядит так
    namespace HomePlugin.Controllers
    {
        public class HomeController : Controller
        {
            [Route(""), HttpGet]
            public ActionResult Index()
            {
    
    
                return View();
            }
        }
    }
    


    2) пока забудем о Lazy загрузке контроллеров (для этого надо писать ControllerFactory, что в комменте не очень удобно, да и ничего в этом сложного). Будем регить плагины в веб конфиге хост-приложения.
    Для этого пусть у нас есть еще и хост приложение. Опять же стандартный пустой MVC5 проект. Добавим авто регистрацию роутов по атрибутам:
    [assembly: WebActivatorEx.PostApplicationStartMethod(typeof(RouteConfig), "RegisterRoutes")]
    
    namespace HomePlugin.App_Start
    {
        public class RouteConfig
        {
            public static void RegisterRoutes()
            {
                RouteCollection routes = RouteTable.Routes;
                routes.IgnoreRoute("{resource}.axd/{*pathInfo}");
    
                routes.MapMvcAttributeRoutes();
            }
        }
    }
    

    (не забудем установить nuget пакет WebActivatorEx)

    3) Наши плагины пусть будут копироваться в bin каталог хост-приложения (вместе с вьюхами). Т.е. структура хост приложения у нас будет такая: bin\HomePlugin\bin — бинари HomePlugin плагина, bin\HomePlugin\Views — вьюхи хоум плагина.

    Регим загрузку нашего плагина хост приложением вписывая его в веб конфиг:

    <configuration>
      <system.web>
        <compilation debug="true" targetFramework="4.5.1">
          <assemblies>
            <add assembly="HomePlugin"/>
          </assemblies>
        </compilation>
        <httpRuntime targetFramework="4.5.1" />
      </system.web>
    
      <runtime>
        <assemblyBinding xmlns="urn:schemas-microsoft-com:asm.v1">
          <probing privatePath="bin\HomePlugin\bin;" />
        </assemblyBinding>
      </runtime>
    </configuration>
    
    


    4) Запускаем и получаем болт в виде «не могу найти вьюху для HomeConteroller-а Index экшена.
    Окей, это потому что они лежат не там где ожидает их RazorViewEngine — напшем свой ViewEngine и попутно его зарегим (это в хост приложении естественно):
    [assembly: WebActivatorEx.PostApplicationStartMethod(typeof(RegisterViewEngine), "Start")]
    
    
    namespace WebAndPlug.App_Start
    {
        public static class RegisterViewEngine
        {
            public static void Start()
            {
                ViewEngines.Engines.Clear();
                ViewEngines.Engines.Add(new PluginViewEngine());
            }
        }
    
    
        public class PluginViewEngine : RazorViewEngine
        {
            #region Overrides of VirtualPathProviderViewEngine
    
            public override ViewEngineResult FindView(ControllerContext controllerContext, string viewName, string masterName, bool useCache)
            {
                var ctrlAssembly = controllerContext.Controller.GetType().Assembly.GetName().Name;
                string controller = controllerContext.RouteData.GetRequiredString("controller");
                string action = controllerContext.RouteData.GetRequiredString("action");
                viewName = "~/bin/" + ctrlAssembly + "/Views/" + controller + "/" + viewName + ".cshtml";
                return base.FindView(controllerContext, viewName, masterName, useCache);
            }
    
    
            public override ViewEngineResult FindPartialView(ControllerContext controllerContext, string partialViewName, bool useCache)
            {
                return base.FindPartialView(controllerContext, partialViewName, useCache);
            }
    
    
            #endregion
        }
    }
    


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

  • 0
    Мы с командой сейчас работаем с Orchard CMF.
    Очень гибкая система, также построенная на Area (но специфичные). Но он тоже DLLки закидывает не в bin, а в App_Data. Но, если поковыряться, то уверен, можно будет переделать под свои нужды. Для загрузки модулей/плагинов используются реализации IExtensionLoader.
  • 0
    Чтобы избежать копирования сборок перед загрузкой можно использовать AppDomain.CurrentDomain.SetShadowCopyPath и передать ему путь к каталогу со сборками. Этот метод помечен как Obsolete, но работает как надо.
  • 0
    Есть ли проблема с использованием Intellisense в видах?
    И как обстоит дело с передачей модели в вид?
    • 0
      Проблемы нет, так как каждый модуль может разрабатываться как отдельный сайт.
      Также нет проблем с передачей моделей.
      Единственное неудобство, которое я заметил для себя — трудности с использованием связок (bundles). Но я работаю над этим. Возможно удастся что-то сделать.
  • 0
    Хотелось бы услышать за что минусуют статью
  • 0
    Трудно живется .NET программистам без OSGi :)
    Недостатки текущей реализации которых не было бы в OSGi:
    — общие ресусры
    — общее пространство имен с базовым сайтом.
    — недостаточный контроль зависимостей между плагинами (можно ли сделать один плагин зависящим от другого?)
    — не понятно как реализовать взаимодействие плагинов между собой.
    — необходимость перезапуска приложения для установки / удаления плагина.

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