Модульное приложение на WPF + Caliburn.Micro + Castle.Windsor

от автора

Для начала хочу определить, что в данной статье понимается под модульным приложением. Так вот, модульным приложением будем считать такое приложение, которое состоит из т.н. шелла и набора подключаемых модулей. Между ними нет прямой зависимости, только через контракты. Это позволяет независимо вносить изменения в каждый из компонентов, менять их состав и т.д. Думаю, всем и без меня прекрасно известны преимущества модульной архитектуры.

image

Пожалуй, самым известным фреймворком для создания 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/


Комментарии

Добавить комментарий

Ваш адрес email не будет опубликован. Обязательные поля помечены *