Для начала хочу определить, что в данной статье понимается под модульным приложением. Так вот, модульным приложением будем считать такое приложение, которое состоит из т.н. шелла и набора подключаемых модулей. Между ними нет прямой зависимости, только через контракты. Это позволяет независимо вносить изменения в каждый из компонентов, менять их состав и т.д. Думаю, всем и без меня прекрасно известны преимущества модульной архитектуры.
Пожалуй, самым известным фреймворком для создания WPF приложений с такой архитектурой является Prism. В данной статье я не буду проводить сравнительный анализ, т.к. не имею опыта использования Prism. После прочтения туториала, Prism со всеми его регионами, мефом и прочими артефактами, показался мне сильно усложнённым. Если читатель, знающий Prism, обоснованно укажет мне на мою неправоту и преимущества данного фреймворка — буду признателен.
В этой статье будет рассмотрена разработка простейшего модульного приложения с применением указанных инструментов.
Caliburn.Micro
Caliburn.Micro — это фреймворк, сильно упрощающий описание View и ViewModel. По сути, он сам создаёт байндинги на основании соглашений об именах, тем самым избавляя разработчика от написания их вручную и делая код меньше и чище. Вот пара примеров с их сайта:
<ListBox x:Name="Products" />
public BindableCollection<ProductViewModel> Products { get; private set; } public ProductViewModel SelectedProduct { get { return _selectedProduct; } set { _selectedProduct = value; NotifyOfPropertyChange(() => SelectedProduct); } }
Здесь в XAML мы не указываем ни ItemSource, ни SelectedItem.
<StackPanel> <TextBox x:Name="Username" /> <PasswordBox x:Name="Password" /> <Button x:Name="Login" Content="Log in" /> </StackPanel>
public bool CanLogin(string username, string password) { return !String.IsNullOrEmpty(username) && !String.IsNullOrEmpty(password); } public string Login(string username, string password) { ... }
Никаких Command и CommandParameter.
Соглашения, при острой необходимости, можно переопределить.
Конечно же, Caliburn.Micro ещё много чего умеет. Что-то мы рассмотрим далее, об остальном можно прочесть в документации.
Castle.Windsor
Castle.Windsor — это один из самых известных и самых функциональных DI-контейнеров для .net (предполагается, что читателю известно о DI и IoC). Да, в Caliburn.Micro, как и во многих других фреймворках, имеется собственный DI-контейнер — SimpleContainer, и для дальнейшего примера его возможностей вполне хватило бы. Но для более сложных задач он может не подойти, поэтому я покажу, как использовать произвольный контейнер на примере Castle.Windsor.
Задача
В качестве примера предлагаю рассмотреть процесс создания простого модульного приложения. Главная его часть — шелл — будет представлять из себя окно, в левой части которого будет ListBox-меню. При выборе пункта меню в правой части будет отображаться соответствующая ему форма. Меню будет заполняться модулями при их загрузке либо в процессе работы. Модули могут загружаться как при старте шелла, так и в процессе работы (например, какой-то модуль может подгрузить другие модули при необходимости).
Контракты
Все контракты будут находится в сборке Contracts, на которую должны ссылаться шелл и модули. Исходя из поставленной задачи, напишем контракт нашего шелла.
public interface IShell { IList<ShellMenuItem> MenuItems { get; } IModule LoadModule(Assembly assembly); }
public class ShellMenuItem { public string Caption { get; set; } public object ScreenViewModel { get; set; } }
Думаю, тут всё понятно. Шелл позволяет модулям управлять меню, а так же загружать модули в процессе работы. Элемент меню содержит отображаемое имя и ViewModel, тип которой может быть абсолютно любой. При выборе пункта меню в правой части окна будет отображаться View, соответствующая данной ViewModel. Как определить, какая же View соответствующая? Об этом позаботится Caliburn.Micro. Такой подход называется ViewModel-first, потому что в коде мы оперируем вью-моделями, а создание вью отходит на второй план и отдаётся на откуп фреймворку. Подробности — далее.
Контракт модуля выглядит совсем просто.
public interface IModule { void Init(); }
Метод Init() вызывает сторона, инициировавшая загрузку модуля.
Важно отметить, что если в проекте сборки подписаны, а в крупных проектах обычно так и есть, то необходимо быть уверенным, что шелл и модули используют сборки с контрактами одной версии.
Начинаем реализовывать Shell
Создадим проект типа WPF Application. Далее нам нужно подключить к проекту Caliburn.Micro и Castle.WIndsor. Проще всего сделать сделать это через NuGet.
PM> Install-Package Caliburn.Micro -Version 2.0.2 PM> Install-Package Castle.Windsor
Но можно и скачать сборки, либо собрать самим. Теперь создадим в проекте две папки: Views и ViewModels. В папке ViewModels создадим класс ShellViewModel; унаследуем его от PropertyChangedBase из Caliburn.Micro, чтобы не реализовывать INotifyPropertyChanged. Это будет вью-модель главного окна шелла.
class ShellViewModel: PropertyChangedBase { public ShellViewModel() { MenuItems = new ObservableCollection<ShellMenuItem>(); } public ObservableCollection<ShellMenuItem> MenuItems { get; private set; } private ShellMenuItem _selectedMenuItem; public ShellMenuItem SelectedMenuItem { get { return _selectedMenuItem; } set { if(_selectedMenuItem==value) return; _selectedMenuItem = value; NotifyOfPropertyChange(() => SelectedMenuItem); NotifyOfPropertyChange(() => CurrentView); } } public object CurrentView { get { return _selectedMenuItem == null ? null : _selectedMenuItem.ScreenViewModel; } } }
Само главное окно MainWindow скопируем во View и переименуем в ShellView. Надл не забыть переименовать не только файл, но и класс вместе неймспейсом. Т.е. вместо класса Shell.MainWindows должен быть Shell.Views.ShellView. Это важно. Иначе Caliburn.Micro не сможет определить, что именно это вью соответствует созданной ранее вью-модели. Как было сказано ранее, Caliburn.Micro опирается на соглашения об именах. В данном случае из имени класса вью-модели убирается слово «Model» и получается имя класса соответствующего вью ( Shell.ViewModels.ShellViewModel — Shell.Views.ShellView ). В роли View может выступать Windows, UserControl, Page. В модулях мы будем использовать UserControl.
XAMl-разметка главного окна будет выглядеть так:
<Window x:Class="Shell.Views.ShellView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" Title="MainWindow" Height="350" Width="525"> <Grid> <Grid.ColumnDefinitions> <ColumnDefinition Width="200"/> <ColumnDefinition Width="*"/> </Grid.ColumnDefinitions> <ListBox x:Name="MenuItems" DisplayMemberPath="Caption" Grid.Column="0"/> <ContentControl x:Name="CurrentView" Grid.Column="1"/> </Grid> </Window>
Запускаем Caliburn.Micro
Для этого сначала создадим класс Bootstraper с минимальным содержимым:
public class ShellBootstrapper : BootstrapperBase { public ShellBootstrapper() { Initialize(); } protected override void OnStartup(object sender, StartupEventArgs e) { DisplayRootViewFor<ShellViewModel>(); } }
Он должен наследоваться от BootstrapperBase. Метод OnStartup вызывается при запуске программы. DisplayRootViewFor() по-умолчанию создаёт экземпляр класса вью-модели дефолтным конструктором, ищет соответствующее вью по алгоритму, описанному выше, и отображает его.
Чтобы это заработало, надо отредактировать точку входа в приложение — App.xaml.
<Application x:Class="Shell.App" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:shell="clr-namespace:Shell"> <Application.Resources> <ResourceDictionary> <ResourceDictionary.MergedDictionaries> <ResourceDictionary> <shell:ShellBootstrapper x:Key="bootstrapper" /> </ResourceDictionary> </ResourceDictionary.MergedDictionaries> </ResourceDictionary> </Application.Resources> </Application>
Мы убрали StartupUri (отдано на откуп бутстрапперу) и добавили в ресурсы наш бутстраппер. Такая вложенность — не просто так, иначе проект не соберётся.
Теперь при запуске приложения будет создаваться бутстраппер, вызываться OnStartup и отображаться главное окно приложения, привязанное ко вью-модели.
Обратите внимание на создание вью-модели. Она создаётся конструктором по-умолчанию. А если такого у неё нет? Если она имеет зависимости от других сущностей, или другие сущности зависят от неё? Я подвожу к тому, что пришло время пустить в дело DI-контейнер Castle.Windsor.
Запускаем Castle.Windsor
Создадим класс ShellInstaller.
class ShellInstaller : IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container .Register(Component.For<IWindsorContainer>().Instance(container)) .Register(Component.For<ShellViewModel>() /*.LifeStyle.Singleton*/); } }
В нём мы будем регистрировать все наши компоненты в коде с помощью fluent-синтаксиса. Есть возможность делать это через xml, см. документацию на сайте. Пока у нас есть один компонент — вью-модель главного окна. Регистрируем его как синглтон (можно явно не указывать, т.к. это LifeStyle по-умолчанию). Также зарегистрируем сам контейнер, чтобы иметь возможно обращаться к нему. Забегая вперёд — нам это понадобится при загрузке модулей.
Далее вносим изменения в наш бутсраппер:
public class ShellBootstrapper : BootstrapperBase { private readonly IWindsorContainer _container = new WindsorContain-er(); public ShellBootstrapper() { Initialize(); } protected override void OnStartup(object sender, StartupEventArgs e) { DisplayRootViewFor<ShellViewModel>(); } protected override void Configure() { _container.Install(new ShellInstaller()); } protected override object GetInstance(Type service, string key) { return string.IsNullOrWhiteSpace(key) ? _container.Kernel.HasComponent(service) ? _container.Resolve(service) : base.GetInstance(service, key) : _container.Kernel.HasComponent(key) ? _container.Resolve(key, service) : base.GetInstance(service, key); } }
Создаём контейнер. В переопределённом методе Configure применяем наш инсталлер. Переопределяем метод GetInstance. Его базовая реализация использует конструктор по-умолчанию для создания объекта. Мы же будем пытаться получить объект из контейнера.
Взаимодействие с модулями
Первым делом нам надо научиться загружать модули. А для этого давайте определимся, что же из себя представляет модуль?
Модуль (в нашем случае) — это сборка, содержащая набор классов, реализующих требуемый функционал. Один из этих классов должен реализовывать контракт IModule. Кроме того, так же как и шелл, модуль должен иметь инсталлер, регистрирующий компоненты (классы) модуля в DI-контейнере.
Теперь приступим к реализации загрузчика. Загрузка будет вызываться при старте шелла, а также может быть вызвана в процессе работы, поэтому создадим отдельный класс.
class ModuleLoader { private readonly IWindsorContainer _mainContainer; public ModuleLoader(IWindsorContainer mainContainer) { _mainContainer = mainContainer; } public IModule LoadModule(Assembly assembly) { try { var moduleInstaller = FromAssembly.Instance(assembly); var modulecontainer = new WindsorContainer(); _mainContainer.AddChildContainer(modulecontainer); modulecontainer.Install(moduleInstaller); var module = modulecontainer.Resolve<IModule>(); if (!AssemblySource.Instance.Contains(assembly)) AssemblySource.Instance.Add(assembly); return module; } catch (Exception ex) { //TODO: good exception handling return null; } } }
Через конструктор ижектится контейнер шелла (помните, мы его специально для этого регистрировали ?). В методе LoadModule получаем инсталлер из сборки модуля. Создаём отдельный контейнер для компонент загружаемого модуля. Регистрируем его как дочерний по отношению к контейнеру шелла. Применяем инсталлер модуля. Пытаемся вернуть экземпляр IModule. Сообщаем Caliburn.Micro о сборке, чтобы он применил соглашения о наименованиях для компонентов в ней.
И не забываем зарегистрировать наш загрузчик модулей в ShellInstaller.
.Register(Component.For<ModuleLoader>()
Немного о «дочернем контейнере». Суть в том, что все его компоненты «видят» компоненты из родительского контейнера, помимо своего, но не наоборот. Компоненты разных дочерних контейнеров так же ничего не знают друг о друге. Получаем изоляцию шелла от модулей и модулей друг от друга, но не модулей от шелла — его они видят.
Далее реализуем контракт IShell, через который модули будут обращаться к шеллу.
class ShellImpl: IShell { private readonly ModuleLoader _loader; private readonly ShellViewModel _shellViewModel; public ShellImpl(ModuleLoader loader, ShellViewModel shellViewModel) { _loader = loader; _shellViewModel = shellViewModel; } public IList<ShellMenuItem> MenuItems { get { return _shellViewModel.MenuItems; } } public IModule LoadModule(Assembly assembly) { return _loader.LoadModule(assembly); } }
Регистрируем.
.Register(Component.For<IShell>().ImplementedBy<ShellImpl>())
Теперь нам нужно сделать так, чтобы модули загружались при запуске шелла. А откуда они возьмутся? В нашем примере шелл будет искать сборки с модулями рядом с Shell.exe.
Данный функционал следует реализовать в методе OnStartup:
protected override void OnStartup(object sender, StartupEventArgs e) { var loader = _container.Resolve<ModuleLoader>(); var exeDir = Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location); var pattern = "*.dll"; Directory .GetFiles(exeDir, pattern) .Select(Assembly.LoadFrom) .Select(loader.LoadModule) .Where(module => module != null) .ForEach(module => module.Init()); DisplayRootViewFor<ShellViewModel>(); }
Всё, шелл готов!
Пишем модуль
Наш тестовый модуль при загрузке будет добавлять в меню шелла два пункта. Первый пункт отобразит в правой части совсем простую форму с надписью. Второй — форму с кнопкой, с помощью которой можно будет догрузить модуль, выбрав его сборку в открывшемся диалоге выбора файла. Следуя соглашению о именах, создадим 2 папки Views и ViewModels. Затем наполним их.
Первые вью и вью-модель — тривиальны:
<UserControl x:Class="Module.Views.FirstView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300"> <Grid> <TextBlock HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="60">Hello, I'm first !</TextBlock> </Grid> </UserControl>
class FirstViewModel { }
Вторая вью тоже не отличается сложностью.
<UserControl x:Class="Module.Views.SecondView" xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:d="http://schemas.microsoft.com/expression/blend/2008" mc:Ignorable="d" d:DesignHeight="300" d:DesignWidth="300"> <Grid> <Button x:Name="Load" HorizontalAlignment="Center" VerticalAlignment="Center" FontSize="50">Load Module</Button> </Grid> </UserControl>
Во второй вью-модели реализуем загрузку выбранного модуля.
class SecondViewModel { private readonly IShell _shell; public SecondViewModel(IShell shell) { _shell = shell; } public void Load() { var dlg = new OpenFileDialog (); if (dlg.ShowDialog().GetValueOrDefault()) { var asm = Assembly.LoadFrom(dlg.FileName); var module = _shell.LoadModule(asm); if(module!=null) module.Init(); } } }
Реализуем контракт IModule. В методе Init добавляем пункты в меню шелла.
class ModuleImpl : IModule { private readonly IShell _shell; private readonly FirstViewModel _firstViewModel; private readonly SecondViewModel _secondViewModel; public ModuleImpl(IShell shell, FirstViewModel firstViewModel, SecondViewModel secondViewModel) { _shell = shell; _firstViewModel = firstViewModel; _secondViewModel = secondViewModel; } public void Init() { _shell.MenuItems.Add(new ShellMenuItem() { Caption = "First", ScreenViewModel = _firstViewModel }); _shell.MenuItems.Add(new ShellMenuItem() { Caption = "Second", ScreenViewModel = _secondViewModel }); } }
И последний штрих — инсталлер.
public class ModuleInstaller:IWindsorInstaller { public void Install(IWindsorContainer container, IConfigurationStore store) { container .Register(Component.For<FirstViewModel>()) .Register(Component.For<SecondViewModel>()) .Register(Component.For<IModule>().ImplementedBy<ModuleImpl>()); } }
Готово!
Исходники — на гит-хабе.
Заключение
В данной статье мы рассмотрели создание простейшего модульного WPF-приложения с помощью фреймворков Castle.Windwsor и Caliburn.Micro. Конечно, многие аспекты освещены не были, некоторые детали опущены и т.д., иначе получилась бы книга, а не статься. А более подробную информацию можно найти на официальных ресурсах, да и не только.
На все возникшие вопросы с удовольствием постараюсь ответить.
Спасибо за внимание!
ссылка на оригинал статьи http://habrahabr.ru/post/271655/
Добавить комментарий