Меня всегда интересовала разработка многоразового и целостного кода. Но проблема многоразового кода останавливается на этапе переноса в другую инфраструктуру. Если приложение расширяется плагинами, то плагины пишутся под конкретное приложение. А что если вынести логику приложения в плагин (далее — модуль), а интерфейс приложения из управляющего звена превратить в управляемый модулем компонент. На мой взгляд, самая главная задача в подобном сценарии, упростить базовые интерфейсы до минимума и дать возможность переписать или расширить любой фрагмент всей инфраструктуры в отдельности. Если интересно, что вышло из идеи модульного кода, то добро пожаловать под кат.
Идея
Первое условие к предстоящей системе — возможность динамически расширять систему без необходимости рекомпиляции отдельных модулей. Это относится как к хосту, так и к модулям.
Любое звено решения (кроме базовых интерфейсов) может быть переписано и динамически интегрировано. В довесок к возможности расширения модулей интерфейсами, хотелось иметь возможность получать доступ в динамике к публичным методам, свойствам и событиям, которые доступны в любом модуле. Соответственно, все элементы класса реализующего базовый интерфейс IPlugin, которые помечены доступностью как public, должны быть видимы извне другими модулями.
Любой модуль, может изыматься и добавляться в инфраструктуру, но при этом, при решении заменить один модуль другим модулем, придётся реализовывать весь функционал удаляемого модуля. Т.е. Модули идентифицируются через атрибут AssemblyGuidAttribute, добавляемый автоматом при создании проекта. Поэтому 2 модуля с одним идентификатором не загрузятся
Каждый модуль должен быть легковесным, чтобы базовые интерфейсы не нуждались в постоянном обновлении, а при необходимости, модуль можно изъять из системы и встроить как обычную сборку в приложение через ссылку (Reference). Благо, CLR загружает зависящие сборки через ленивую загрузку (LazyLoad), так что нужда в сборках модульной инфраструктуры отпадает.
И последнее условие, система должна предоставлять поэтапное расширение функционала для разработчика, чтобы уровень вхождения был на достаточно низком уровне.
При этом, система должна автоматизировать рутинные задачи, которые повторяются от приложения к приложению. А именно:
Сохранение/загрузка пользовательских настроек или общее хранилище настроек,
Сохранение состояния или других параметров, в зависимости от применения,
Перенос ранее написанных компонентов,
Ограничение в использовании программного обеспечения без достаточного уровня прав (Загружать компоненты от уровня доступа, а не скрывать элементы интерфейса),
Взаимодействие с облачной инфраструктурой без необходимости дорабатывать логику (Message Queue, REST, SOAP сервисы, Web sockets, Caching, OAuth/OpenId/OpenId Connect...)
Решение
В результате накопившихся решений и отдельных компонентов, работающих по единому принципу, было составлено общее видение всей инфраструктуры:
Минимальные требования к основным интерфейсам,
Модульная инфраструктура с независимым источником загрузки модулей,
Общее хранилище настроек,
Независимость решения от реализации приложений (UI, Services):
Для предоставления независимости разработки как от конкретного приложения, так и самими программами, появились следующие ключевые компоненты:
SAL Interfaces — Сборки с базовыми интерфейсами и интерфейсами расширений
Host — Приложение. (в случае использования в Visual Studio — EnvDTE Add-In), который зависит от версии запускающего приложения,
Plugin — В основе своей это независимый модуль (плагин) для хоста, но может зависеть от других модулей или реализовать в себе основу для группы других модулей. Кроме обычных плагинов, которые выполняют свои собственные задачи, присутствует 3 типа плагина, которые активно используются самим хостом:
LoaderProvider — Провайдер, который позволяет подгружать другие модули из разных источников. Я для тестов написал загрузчик из файловой системы в память (Не работает с Managed C++), загрузкой по сети исходя из роли пользователя (Сервер написан под конкретную задачу). Но это не передел, текущая архитектура позволяет использовать в качестве источника как, к примеру, nuget.org, так и удалённое общение с хостом развёрнутым на другой машине.
SettingsProvider — Провайдер, который отвечает за сохранение и загрузку настроек плагинов. Как я писал выше, по умолчанию написанные хосты используют XML для сохранения и загрузки данных, но это не ограничивает дальнейшее развитие. В готовых модулях я привёл в ккчестве примера провайдер использующий MSSQL.
Kernel — Ядро бизнес-логики и массива зависимых модулей. По своей сути, является не только основой для зависимых модулей, но и идентификацией приложения для хоста (В минимуме, для идентификации в SettingsProvider, ибо в одном хосте могут запускаться разные массивы модулей, объединённые разными Kernel модулями).
В результате этих требований сформировались следующие базовые сборки:
SAL.Core — Набор минимальных необходимых интерфейсов для хостов и модулей,
SAL.Windows — Зависит от SAL.Core. Набор интерфейсов для хостов и модулей, поддерживающих стандартный функционал WinForms, WPF (Form, MenuBar, StatusBar, ToolBar...) приложений,
SAL.Web — Зависит от SAL.Core. Набор интерфейсов для хоста и модулей, поддерживающих приложения, написанные с использованием ASP.NET (Нуждается в кардинальной доработке).
SAL.EnvDTE — Зависит от SAL.Windows. Предоставляет расширения для плагинов, которые могут взаимодействовать с оболочкой, на которой написана Visual Studio.
Для минимального функционирования системы, достаточно добавить ссылку на SAL.Core, а при необходимости реализовать или использовать расширения, добавить ссылку на соответствующий набор расширений интерфейсов. Либо самостоятельно расширить минимальный набор интерфейсов нужной абстракцией.
Во время запуска хоста, первым делом инициализируются встроенные в хост базовые модули, для загрузки настроек и внешних плагинов (LoaderProvider и SettingsProvider).
Сначала инициализируется провайдер плагинов, а затем провайдер настроек. Встроенный в хост загрузчик ищет все плагины в папке приложения и подписывается на событие поиска зависимых сборок. Затем, встроенный в хост провайдер настроек, подгружает настройки из XML файла, находящегося в профиле пользователя. Оба провайдера поддерживают иерархическую инфраструктуру наследования и при обнаружении очередного провайдера становятся родителями нового провайдера. Если провайдер не находит требуемые ресурсы, то запрос ресурсов адресуется родительскому провайдеру.
После завершения процесса инициализации всех провайдеров, происходит инициализация всех Kernel, а затем и оставшихся плагинов. В отличие от остальных модулей, Kernel плагины инициализируются в первую очередь, получая возможность подписаться на события загрузки остальных плагинов с возможностью отмены загрузки лишних плагинов.
Данное поведение может быть переписано в хостах, если необходимо соблюсти иерархию загрузки других типов плагинов. Сейчас думаю о выносе последовательности загрузки модулей в Kernel.
Загрузка сборок
Стандартные LoaderProvider через рефлексию ищут все public классы, которые реализуют IPlugin и это не правильный подход. Дело в том, что если в коде идёт вызов конкретного класса или через рефлексию идёт обращение к конкретному классу, и этот класс не ссылается ни на какие сторонние сборки, то события AssemblyResolve не произойдёт. Т.е., сборку можно изъять из модульной инфраструктуры и использовать как обычную сборку добавив на неё ссылку и необходимость в SAL.dll отпадёт. Но базовые провайдеры модулей, реализованы по принципу сканирования текущей папки и всех объектов сборки, поэтому событие AssemblyResolve на все ссылающиеся сборки произойдёт на момент загрузки модуля.
Для решения этой проблемы, я написал несколько вариантов простых загрузчиков, но с разным поведением. В некоторых требуется указать список сборок заранее, некоторые сканируют папки самостоятельно.
В дальнейшем, как один из вариантов решения данной задачи, можно использовать сборку PEReader, которая описана ниже.
SAL.Core
Базовые интерфейсы и небольшие куски кода, реализуемые в абстрактных классах для упрощения разработки. В качестве самой минимальной версии фреймворка для основы, была выбрана версия .NET Framework v2.0. Выбор минимальной необходимой версии позволяет использовать базу на любых платформах поддерживающих эту версию фреймворка, а обратная совместимость (выбор рантайма при запуске) позволяет использовать основу до .NET Core (пока исключая).
В теории, базовые классы должны представлять из себя фундаментальную основу, позволяющие использовать их в любой ситуации. На практике же наверняка найдутся условия, для которых придётся их расширить. В этом случае весь код абстрактных классов можно переписать, а интерфейсы расширить собственной реализацией. Поэтому в этой сборке и находится самый минимум возможного кода.
На момент написания статьи единственным хостом, наследующим базовые интерфейсы, является хост для WinService приложений.
SAL.Wndows
Этот набор базовых классов, который предоставляет основу для написания приложений на основе WinForms и WPF. В составе идут интерфейсы для работы с абстрактным меню, тулбаром и окнами.
SAL.EnvDTE
С точки зрения расширения, хост как Add-In для Visual Studio расширяет интерфейсы SAL.Windows и дополняет специфичным для VS функционалом. Если зависимый плагин не находит ядра, взаимодействующего с Visual Studio, то он может продолжать работать с ограниченным функционалом.
Все написанные хосты, поддерживающие интерфейсы SAL.Core, автоматизируют следующий функционал:
Загрузка плагинов из текущей папки,
Сохранение и загрузка настроек плагинов из XML файлов в профиле пользователя,
Восстановление позиций и размера всех ранее закрытых окон при открытии приложения (SAL.Windows).
На этих интерфейсах реализованы следующие хосты:
Host MDI — Multiple Document Interface, написанный с использованием компонента DockPanel Suite,
Host Dialog — Диалоговых интерфейс с контрольным управлением через Windows ToolBar,
Host EnvDTE — Add-In для Visual Studio, проверенный на версиях EnvDTE: 8,9,10,12.
Host Windows Service — Хост в качестве виндового сервиса, с возможностью установки, удаления и запуска через параметры командной строки (PowerShell не поддерживается).
Логирование событий реализовано через стандартный System.Diagnostics.Trace. В хостах MDI, Dialog и WinService, listener прописанный в app.config'е, пытается отдать полученные события обратно в само приложение через Singleton, которые затем отображаются в окнах логов (Output или EventList) в зависимости от события. Для devenv.exe тоже присутствует возможность прописать trace listener в app.config'е, но в данном случае мы получим загрузку сборки хоста до загрузки его в качестве Add-In'а. Поэтому trace listener добавляется программно в коде (Отображает в VS Output ToolBar или модальным окном).
Написанная инфраструктура позволяет развиваться в направлении HTTP приложений, но для этого необходимо реализовать часть модулей, обеспечивающих как минимум аутентификацию, авторизацию и кеширования. Для приложения TTManager, которое описано ниже, был реализован свой собственный хост для WEB сервисов, который реализовал в себе весь необходимый функционал, но, увы, он сделан под конкретную задачу, а не как универсальное приложение.
Такой подход логирования и разбивания на отдельные модули, позволяет с лёгкостью выявить узкие моменты при запуске в новом окружении. Для примера, при разворачивании массива модулей на Windows 10, обнаружил, что загрузка, занимает времени намного больше, чем на других версиях ОС. Даже на моей старенькой машине с WinXP, загрузка 35 модулей выполняется максимум за 5 сек. Но на Win10 процесс загрузки одного единственного модуля занимал куда больше времени.
Благодаря независимой архитектуре, локализовать проблемный модуль удалось мгновенно. (В данном случае проблема была в использовании рантайма v2.0 под Windows 10).
Готовые модули
Первая версия инфраструктуры появилась в 2009 году. Как для тестирования, так и для ускорения выполнения тривиальных задач по работе, накопилось большое количество разнообразных и независимых модулей, автоматизирующих разные задачи (Все картинки кликабельны, модули можно скачать на страницах проекта).
В основе этого приложения лежит приложение, идущее совместно с Visual Studio — WCF test client. На мой взгляд, в первоисточнике масса неудобных моментов. К моменту перехода на WCF у меня уже было написано много приложений на обычных WebService'ах. Изучив принципы работы самой программы через ILSpy, я решил расширить функциональность не только WCF, но и WS клиентов. В итоге, разобрав основную программу, я написал плагин со следующим расширенным функционалом:
Поддержка WebService приложений (кроме Soap Header),
Возможность тестирования сервиса со старыми binding'ами (при открытии не обновляет прокси-класс автоматом, а только по запросу из UI),
Независимость от Visual Studio (объединил зависимые сборки через ILMerge),
Вид всех добавленных сервисов в виде дерева, а не работа только с одним сервисом,
Функция поиска по всем узлам дерева,
На форму запроса сервиса добавлен таймер, чтобы отслеживать затраченное время на полное выполнение запроса,
Добавлено восстановление отправленных параметров при закрытии и открытии формы теста или всего приложения,
Добавлена возможность сохранения и загрузки параметров в файл по кнопке на форме теста метода.
Добавлена возможность автосохранения и загрузки параметров метода (Понадобится модуль Plugin.Configuration → Auto save input values [False])
Сломана возможность редактирования .config файла через программу SvcConfigEditor.exe
Опять же, первоисточником программы стали программисты из M$. В основе программы лежит программа RDCMan, но, в отличие от основной программы, я решил встроить окно подключённого сервера в диалоговый интерфейс. А удалённое хранилище настроек, помогло держать список серверов у всех причастных коллег в актуальном состоянии.
В первоисточнике этого приложения лежит новая идея по автоматизации, которую я не смог найти в других приложениях. Цели написания такого приложения было 3:
Предоставить интерфейс для просмотра содержимого PE файла, включая большинство директорий и таблиц метаданных (Хотя вывод ресурсов RT_DIALOG существенно отличается от оригинала).
Поиск по структуре PE/CLI файлов
Дать возможность загрузки PE файла не только из файловой системы, но и через WinAPI функцию LoadLibrary. В случае загрузки через LoadLibrary, есть шанс прочитать распакованный PE файл и не надо высчитывать RVA.
Несколько раз получалось, что исполняемые файлы реализовывали некий функционал, но этот функционал либо устаревал, либо никем не использовался. Чтобы не искать по исходным кодам приложений на разных языках использование тех или иных объектов и написано это приложение. Для примера, у меня есть сборка в общем репозитории и я решил удалить из этой сборки один метод. Как узнать, используется ли этот метод в текущих зависимых сборка других проектов написанными коллегами? Можно попросить проверить всех исходный код, можно посмотреть поискать в Source Control, а можно просто поискать одноимённый метод внутри скомпилированных сборок. Оно состоит из 2х компонентов:
Сборка PEReader (написана без unsafe маркера), исходники которой доступны на GitHub'е,
Клиентской части, которая представляет собой плагин для SAL инфраструктуры, используя уровень абстракции SAL.Windows.
Для поиска по иерархии PE, DEX, ELF и ByteCode файлов, был написан отдельный модуль, который замечательно вписался в инфраструктуру: ReflectionSearch. В данный модуль была вынесена вся логика поиска по объектам через рефлексию и благодаря нескольким публичным методам в модулях чтения исполняемых программ, удалось добиться многоразовости кода.
Остальные
Чтобы не описывать каждым отдельным пунктом весь список готовых модулей, я опишу оставшиеся модули одним списком:
Reflection Search — Сборка для поиска по объектам через рефлексию. Раньше была в составе модуля PE Info, но с появлением других модулей, была перенесена в отдельный модуль, используя публичные методы PE, ELF, DEX и ByteCode модулей.
.NET Compiler — Компилятор .NET кода в реальном времени в текущем AppDomain. Предоставляет возможность написания кода (TextBox), хостинга скомпилированного приложения, кеширования скомпилированного кода и хранения скомпилированного кода как в виде отдельной сборки (Используется во второй итерации автоматизации приложения HTTP Harvester [Описан ниже]).
Browser — Хостинг для Trident'а с расширенным функционалом получения XPath (самописный, на подобии HtmlAgilityPack) к DOM элементам. (Используется на третьей итерации автоматизации приложения HTTP Harvester [Описан ниже]).
Configuration — Пользовательский интерфейс для редактирования настроек плагинов, ибо не все настройки доступны через UI при использовании SAL.Windows.
Members — Отображение в UI public элементов плагинов, которые доступны для вызова извне.
DeviceInfo — Сборка, способная прочитать S.M.A.R.T. атрибуты с совместимых устройств и работает без unsafe маркера. Для получения всех данных используется WinAPI функция DeviceIOControl, исходный код самой сборки доступен на GitHub'е.
Single Instance — Ограничение приложения единственным экземпляром (Обмен ключами осуществляется через .NET Remoting),
SQL Settings Provider — Провайдер сохранения и загрузки настроек из MSSQL. (код писался на ADO.NET и хранимых процедурах с размахом на унификацию, поэтому для отдельных СУБД придётся писать свои реализации хранимок),
SQL Assembly scripter — Создание Microsoft SQL Server скрипта из .NET сборки для установки управляемого кода в MSSQL (не проверен на unsafe сборках),
Winlogon — Модуль предоставляет публичные события для SENS интерфейсов. Первая версия использовала Winlogon, но он больше не поддерживается.
EnvDTE.PublishSql — Перед или после ручной публикации выполненяет произвольный SQL запрос через ADO.NET с указанием шаблонных значений.
Остальные тут (Всего выложено около 30 модулей). Изображения всех модулей тут.
Готовые решения
Для наглядного демонстрирования удобств построения всего комплекса на модульной архитектуре, я приведу пару готовых решений построенных на разных принципах:
Полная независимость модулей между собой
Частичная зависимость от Kernel модуля
TTManager
Приложение для системы задач, которое в основе использовало систему динамического расширения с возможностью использования разных источников задач. В итоге получился унифицированный интерфейс, который способен создавать, экспортировать/импортировать, просматривать задачи из разных источников. На текущий момент поддерживает в качестве источника MSSQL, WebService и частично REST API задач Мегаплана (не реклама). WebService написан по аналогичному принципу, с использованием базовых классов SAL.Web. Так что сам WebService также могут использовать в качестве источника MSSQL, Мегаплан или опять WebService.
Как работает
Kernel плагин приложения, ленивой загрузкой ищет все плагины источников задач (DAL). Если найдено несколько плагинов доступа к данным, то клиенту предлагается выбрать тот плагин, который он хочет использовать (Только в SAL.Windows, в хостах без пользовательского интерфейса — вылетит с ошибкой). Зависимые плагины получают доступ к выбранному DAL плагину через Kernel модуль.
Интересные моменты
В данном примере Kernel плагин абстрагирован интерфейсами от остальных зависимых плагинов. В таком случае, можно написать ещё один Kernel модуль (или переписать текущий). Или переписать вообще любой плагин) для возможности работать с несколькими источниками задач одновременно.
Для решения проблемы со статусами задач, внутри некоторых DAL плагинов зашита матрица статусов (Или берутся из источника задач, если есть). В таком случае не возникает проблем с переносом данных из одного источника в другой.
HTTP Harvester
Приложение позволяет, используя готовые плагины, парсить сайты через Trident или WebRequest. Для парсинга доступно несколько уровней абстракции. Самый низкий уровень позволяет написать дополнительный плагин, который будет заниматься открытием и парсингом ответа, используя DOM или ответ от сервера. Уровень выше предлагает написать .NET код в рантайме, который через плагин “.NET Compiler” будет скомпилирован и применён к результату страницы, отображаемой в Trident'е в рантайме. Самый высокий уровень предполагает указание, через UI, элементов на странице сайта отображаемой в Trident’e. И после применения xpath (самописный вариант) шаблона, передать на обработку в универсальный плагин или выполнить .NET код из плагина ".NET Compiler".
Как работает
Модулю, зависимому от Kernel плагина, предлагается выбрать один из готовых интерфейсов вывода и базовый пользовательский интерфейс скачивания данных. Либо Trident, либо WebRequest с возможностью логирования. Kernel предлагает не только интерфейс, но и таймер опрашивания каждого отдельного модуля.
Интерфейс вывода предлагает стандартный GridView с контейнером вывода данных, с возможностью сохранения последней открытой позиции в таблице. По умолчанию контейнер поддерживает отображение изображения или текстовых данных.
Интересные моменты
В данном случае я не стал абстрагироваться от Kernel плагина интерфейсами и все зависимые плагины ожидают найти в массиве подгруженных плагинов конкретный Kernel плагин.
Приложение писалось в 3 итерации (Только под SAL.Windows):
Сделана возможность написать плагин используя базовые элементы управления и массив методов работы с Trident описанные в Kernel плагине
Появилась возможность заменять код в плагина используя рантайм код генерируемый и редактируемый в Plugin.Compiler
Появилась возможность указывать в Trient путь к узлам HTML через UI. В результате для рантайм или онлайн кода отдаётся массив Ключ/Значение, где значением является путь к HTML элементу(ам) на подобии реализации в HtmlAgilityPack)
Что уже устарело и удалено
Удалён Host для Office 2010. Он был написан исключительно для возможности создавать из контекстного меню задачу для TTManager, но из-за обилия костылей и ограниченности возможностей, дальнейшая поддержка оказалась нецелесообразной.
Удалена возможность создания окон в EnvDTE через ATL. До VS 2007 возможность создания окон в студии была реализована только через ATL и COM. Затем появилась возможность всё делать через .NET.
Устарел хост для EnvDTE реализованный как Add-In
Известные ошибки
Хост EnvDTE проверен только на английских студиях. Могут возникнуть проблемы на локализованных версиях (Один раз испытал на VS11 с русской локализацией).
Хост EnvDTE закрывает студию, если подгружен плагин Winlogon (SENS) и пользователь решил выгрузить хост через Add-in Manager. (Встретил на Windows 10).
Т.к. Хост написан как Add-In, а не как полноценное расширение, то совместимости с другими продуктами на основе EnvDTE — нет.
Какие прогнозы дальнейшего развития
При желании использовать функции кеширования, в довесок к встроенным классам System.Web.Caching.Cache и System.Runtime.Caching.MemoryCache, доступны удалённые кеши. Для примера, AppFabric. Написав базовый интерфейс клиента для кеширования, можно разработать массив модулей для каждого вида кеша и выбирать нужный модуль по необходимости (На момент публикации уже написаны, но не выложены).
Модули на момент написания могут подгружаться с файловой системы, с файловой системы в память и обновляться по сети, используя в качестве TOC XML файл. Дальнейшее развитие позволяет использовать в качестве хранилища не только с файловой системы, но и использовать nuget как хранилище или реализовать хост, который позволяет запускать модули удалённо.
Персонализация пользователя возможна как Roles, так и Claims. Но при использовании OpenId, OAuth, OpenId Connect, провайдеров существует огромное множество, при этом от каждого провайдера требуется получить System.Security.Principal.IIdentity (При использовании Roles based auth) или System.Security.Claims.ClaimsIdentity (При использовании Claims аутентификации). Соответственно, один раз написав клиента для LinedIn'а, можно его использовать в любом приложении без перекомпиляции.
При использовании очередей сообщений можно написать модуль и набор интерфейсов, который будет выполнять функции ServiceBus, а модули реализации конкретной очереди уже будут отвечать за получение и отправку сообщений.
Можно написать UI интерфейс динамического связывания публичных методов модулей, по аналогии с SSIS или BizTalk сервисами.