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

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. Конечно, многие аспекты освещены не были, некоторые детали опущены и т.д., иначе получилась бы книга, а не статься. А более подробную информацию можно найти на официальных ресурсах, да и не только.

На все возникшие вопросы с удовольствием постараюсь ответить.

Спасибо за внимание!

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


  1. astudent
    25.11.2015 15:31

    Почему не Prism? Активно развивается, является частью .NET Foundation и есть версия даже для Xamarin.Forms.


    1. IL_Agent
      25.11.2015 16:02

      Я написал в начале статьи, почему. Как бы выглядело подобное приложение с Prism?
      Caliburn.Micro тоже развивается и в версии 3.0 будет уметь Xamarin.Forms.


      1. Holms
        25.11.2015 20:34

        А как насчет MVVM Light?


        1. IL_Agent
          25.11.2015 21:03

          Никак :). Я с ним не сильно знаком, но, как я понимаю, это лишь набор хелперов вроде PropertyChangedBase. Все привязки все равно самому надо писать. Или нет?


    1. Bonart
      30.11.2015 10:12

      Caliburn Micro намного меньше и выразительнее.


  1. dymanoid
    25.11.2015 22:23

    Одно из преимуществ Prism, например, в том, что описанный вами функционал загрузки модулей и регистрации их в контейнере доступен из коробки.


    1. IL_Agent
      26.11.2015 11:26

      Это очень простой функционал, несколько коротеньких строк :)

      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);
      


      А в Призме как? «Из коробки» ведь его тоже надо достать и приготовить. Проще ли? Гибче? Судя по примерам, что попадаются в выдаче — не очень.

      Вообще, для сравнения правильней было бы реализовать весь приведённый в статье пример на Призме. Быть может, сделаю и напишу, как появится время.