Статьи

Модульная архитектура

2 октября 2016 г.

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

Идея

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

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

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

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

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

При этом, система должна автоматизировать рутинные задачи, которые повторяются от приложения к приложению. А именно:

  • Сохранение/загрузка пользовательских настроек или общее хранилище настроек),
  • Сохранение состояния или других параметров, в зависимости от применения,
  • Перенос ранее написанных компонентов,
  • Ограничение в использовании программного обеспечения без достаточного уровня прав (Загружать компоненты от уровня доступа, а не скрывать элементы интерфейса),
  • Взаимодействие с облачной инфраструктурой без необходимости дорабатывать логику (Message Queue, REST, SOAP сервисы, Web sockets, Caching, OAuth/OpenId/OpenId Connect...)

Решение

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

  1. Минимальные требования к основным интерфейсам,
  2. Модульная инфраструктура с независимым источником загрузки модулей,
  3. Общее хранилище настроек,
  4. Независимость решения от реализации приложений (UI, Services):
    1. Какие хосты есть на момент написания:

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

  • SAL Interfaces — Сборки с базовыми интерфейсами и интерфейсами расширений
  • Host — Приложение. (в случае использования в Visual Studio - EnvDTE Add-In), который зависит от версии запускающего приложения,
  • Plugin — В основе своей это независимый модуль (плагин) для хоста, но может зависеть от других модулей или реализовать в себе основу для группы других модулей. Кроме обычных плагинов, которые выполняют свои собственные задачи, присутствует 3 типа плагина, которые активно используются самим хостом:
    1. LoaderProvider — Плагин, позволяющий подгружать другие плагины из разных источников. Я для тестов написал загрузчик из файловой системы в память (Не работает с Managed C++), загрузкой по сети иаходя из роли пользователя (Сервер написан под конкретную задачу). Но это не передел, текущая архитектура позволяет использовать в качестве источника как, к примеру, nuget.org, так и удалённое общение с хостом развёрнутым на другой машине.
    2. SettingsProvider — Плагин, отвечающий за сохранение и загрузку настроек плагинов. Как я писал выше, по умолчанию написанные хосты используют XML для сохранения и загрузки данных, но это не ограничивает дальнейшее развитие. В готовых модулях я привёл в ккчестве примера провайдер использующий MSSQL.
    3. 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.

Немного о CLR

Стандартные LoaderProvider через рефлексию ищут все public классы, которые реализуют IPlugin и это не правильный подход. Дело в том, что если в коде идёт вызов конкретного класса или через рефлексию идёт обращение к конкретному классу, и этот класс не ссылается ни на какие сторонние сборки, то события AssemblyResolve не произойдёт. Т.е., сборку можно изъять из модульной инфраструктуры и использовать как обычную сборку добавив на неё ссылку и необходимость в SAL.dll отпадёт. Но текущие провайдеры модулей, реализованы по принципу сканирования всех объектов сборки, поэтому событие AssemblyResolve на все ссылающиеся сборки произойдёт на момент загрузки модуля.

В довесок, при написании сборок на Managed C++, не будет работать подгрузка без физического наличия сборки на файловой системе (из памяти подгрузить не получится). Это связано с невозможностью работы WinAPI функций LoadLibrary / FreeLibrary.

SAL.Core

Базовые интерфейсы и небольшие куски кода, реализуемые в абстрактных классах для упрощения разработки. В качестве самой минимальой версии фреймворка для основы, была выбрана версия .NET Framework v2.0. Выбор минимальной необходимой версии позволяет использовать базу на любых платформах поддерживающих эту версию фреймворка, а обратная совместимость (выбор рантайма при запуске) позволяет использовать основу до .NET Core (пока исключая).

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

На момент написания статьи единственным хостом, наследующим базовые интерфейсы, является хост для WinService приложений.

SAL.Wndows

Этот набор базовых классов, который предоставляет основу для написания приложений на основе WinForms и WPF. В составе идут интерфейсы для работы с абстрактным меню, тулбаром и окнами.

SAL.EnvDTE

С точки зрения расширения, хост как Add-In для Visual Studio расширяет интерфейсы SAL.Windows и дополняет специфичным для VS функционалом. Если зависимый плагин не находит ядра, взаимодействующего со студией, то он может продолжать работать с ограниченным функционалом.

Все написанные хосты, поддерживающие интерфейсы 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 добавляется программно в коде (Отображает в Output VS или диалоговым окном).

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

Такой подход логирования и разбивания на отдельные модули, позволяет с лёгкостью выявить узкие моменты при запуске в новом окружении. Для примера, при разворачивании массива модулей на Windows 10, обнаружил, что загрузка, занимает времени намного больше, чем на другой ОС. Даже на моей старенькой машине с WinXP, загрузка 35 модулей выполняется максимум за 5 сек. Но на Win10 процесс загрузки одного единственного модуля куда больше времени.

Благодаря распределённой архитектуре, локализовать проблемный модуль удалось мгновенно. Данная проблема решена сменой рантайма с 2.0 на 4.0 в .config файле при запуске по Windows 10:


<?xml version="1.0" encoding="utf-8" ?>
<configuration>
<!--...-->
	<startup>
		<supportedRuntime version="v4.0" />
	</startup>
<!--...-->
</configuration>

Готовые модули

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

Web Service/Windows Communication Foundation Test Client

В основе этого приложения лежит приложение, идущее совместно с Visual Studio - WCF test client. Как видно из интерфейса первоисточника, целью этой программы было не массовое использование, а тестирование внутреннего функционала WCF. К моменту перехода на WCF у меня уже было написано много приложений на обычных WebService'ах. Изучив принципы работы самой программы через ILSpy, я решил добавить поддержку не только WCF, но и WS клиентов. В итоге, разобрав основную программу, я написал плагин со следующим расширенным функционалом:

  1. Поддержка WebService приложений (кроме Soap Header),
  2. Возможность тестирования сервиса со старыми binding'ами (при открытии не обновляет прокси-класс автоматом, а только по запросу из UI),
  3. Независимось от Visual Studio (объединил зависимые сборки через ILMerge),
  4. Вид всех добавленных узлов в виде дерева, а не работа только с одним сервисом,
  5. Функция поиска по всем узлам дерева,
  6. На форму запроса сервиса добавлен таймер, чтобы отслеживать затраченное время на полное выполнение запроса,
  7. Добавлено восстановление отправленных параметров при закртии и открытии формы теста,
  8. Добавлена возможность сохранения и загрузки параметров по кнопке на форме.
  9. Добавлена возможность автосохранения и загрузки параметров метода (Понадобится модуль Plugin.Configuration → Auto save input values [False])
  10. Сломана возможность редактирования .config файла на лету

RDP Client

Опять же, первоисточником программы стали программисты из M$. В основе программы лежит программа RDCMan, но, в отличие от основной программы, я решил встроить окно подключённого сервера в диалоговый интерфейс. А удалёное хранилище настроек, помогло держать список серверов у всех заинтересованных коллег в актуальном состоянии.

PE Info

В первоисточнике этого приложения лежит новая идея по автоматизации, которую я не смог найти в других приложениях. Цели написания написания такого приложения было 2:

  1. Предоставить интерфейс для просмотра содержимого PE файла, включая большинство директорий и таблиц метаданных (Хотя вывод ресурсов RT_DIALOG существенно отличается от оригинала).
  2. Поиска по структуре PE/CLI файлов
  3. Дать возможность загрузки PE файла не только из файловой системы, но и через WinAPI функцию LoadLibrary. В случае загрузки через LoadLibrary, есть шанс прочитать распакованный PE файл и не надо высчитывать RVA.

Несколько раз получалось, что исполняемые файлы реализовывали некий функционал, но этот функционал либо устаревал, либо никем не использовался. Чтобы не искать по исходным кодам приложений на разных языках использование тех или иных объектов и написано это приложение. Для примера, у меня есть сборка в общем репозитории и я решил удалить из этой сборки один метод. Как узнать, используется ли этот метод в текущих зависимых сборка других проектов написанными коллегами? Можно попросить проверить всех исходный код, можно посмотреть поискать в Source Control, а можно просто поискать одноимённый метод внутри скомпилированных сборок. Оно состоит из 2х компонентов:

  1. Сборка PEReader (написана без unsafe маркера), исходники которой доступны на GitHub'е,
  2. Клиентской части, которая представляет из себя плагин для SAL инфраструктуры, используя уровень абстракции SAL.Windows.

Клиент имеет 2 варианта поиска:
Через пользовательский интерфейс вручную, либо поиском через рефлексию, наложенную на сборку PEReader.
UI представляет из себя дерево директорий и дерево метаданных со всеми таблицами. В следующей версии я хочу добавить развёрнутый вид привычной иерархии метаданных, но не более, ибо для чтения тонких и толстых методов есть инструменты гораздо удобнее.
Компонент поиска по PE файлам представляет из себя рефлексию свойств и классов сборки PEReader, с возможностью выбора нужного элемента PE файла и указания требуемого для поиска ключа и значения. После выбора папки поиск будет осуществлён по всем найденным файлам с правильными PE заголовками.

Остальные

Чтобы не описывать каждым отдельным пунктом весь список готовых модулей, я опишу оставшиеся модули одним списком:

  1. .NET Compiler — Компилятор .NET кода в реальном времени в текущем AppDomain. Предоставляет возможность написания кода (TextBox), хостинга скомпилированного приложения, кеширования скомпилированного кода и хранения скомпилированного кода как в виде отдельной сборки (Используется во второй итерации автоматизации приложения HTTP Harvester [Описан ниже]).
  2. Autorun — Автозапуск хоста через реестр при старте ОС.
  3. Browser — Хостинг для Trident'а с расширенным функционалом получения XPath (самописный, на подобии HtmlAgilityPack) к DOM элементам. (Используется на третьей итерации автоматизации приложения HTTP Harvester [Описан ниже]).
  4. Configuration — Пользовательский интерфейс для редактирования настроек плагинов, ибо не все настройки доступны через UI при использовании SAL.Windows.
  5. Members — Отображение в UI public элементов плагинов, которые доступны для вызова извне.
  6. DeviceInfo — Сборка, способная прочитать S.M.A.R.T. атрибуты с совместимых устройств и работает без unsafe маркера. Для получения всех данных используется WinAPI функция DeviceIOControl, исходный код самой сборки доступен на GitHub'е.
  7. File Plugin Provider — Возможность указания папки подгрузки плагинов и запуска мониторинга папки на появление плагинов в процессе исполнения,
  8. Network Plugin Provider — Возможность подгрузки плагинов по сети в локальную папку и последующая загрузка через рефлексию (ответ от сервера ждёт в специфичном XML формате),
  9. Single Instance — Ограничение приложения единственным экземпляром (Обмен ключами осуществляется через .NET Remoting),
  10. SQL Settings Provider — Провайдер сохранения и загрузки настроек из MSSQL. (код писался на ADO.NET и хранимых процедурах с размахом на унификацию, скорее всего из-за этого, с другими реляционными СУБД работать не будет),
  11. SQL Assembly scripter — Создание Microsoft SQL Server скрипта из .NET сборки для установки управляемого кода в MSSQL (не проверен на unsafe сборках),
  12. Winlogon — Модуль предоставляет публичные события для SENS интерфейсов. Первая версия использовала Winlogon, но он больше не поддерживается.
  13. EnvDTE.PublishCmd — Этот модуль я детально описал тут.
  14. EnvDTE.PublishSql — Перед или после релиза выполненяет произвольный SQL запрос через ADO.NET с указанием шаблонных значений.

Остальные тут.
Изображения всех модулей тут.

Готовые решения

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

  • Полная независимость модулей между собой
  • Частичная зависимость от 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):

  1. Сделана возможность написать плагин используя базовые элементы управления и массив методов работы с Trident описанные в Kernel плагине
  2. Появилась возможность заменять код в плагина используя рантайм код генерируемый и редактируемый в Plugin.Compiler
  3. Появилась возможность указывать в Trient путь к узлам HTML через UI. В результате для рантайм или онлайн кода отдаётся массив Ключ/Значение, где значением является путь к HTML элементу(ам) на подобии реализации в HtmlAgilityPack)

Zet Universe

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

Что уже устарело и удалено

  1. Удалён Host для Office 2010. Он был написан исключительно для возможности создавать из контекстного меню задачу для TTManager, но из-за обилия костылей и ограничимости возможностей, дальнейшая поддержка оказалась нецелесообразной.
  2. Удалена возможность создания окон в EnvDTE через ATL. В VS 2007 возможнось создания окон в студии была реализована только через ATL и COM. Затем появилась возможность всё делать через .NET.
  3. Устарел хост для 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 сервисами.

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