Как стать автором
Обновить

MVVM реализация конфигурации WPF приложения построенного на основе фреймворка Catel

Время на прочтение11 мин
Количество просмотров7.9K

Реализация управления настройками ПО это, вероятно, одна из тех вещей, которую практически в каждом приложении реализуют по своему. Большинство фреймворков и прочих надстроек обычно предоставляют свои средства для сохранения/загрузки значений из какого-либо key-value хранилища параметров.


Тем не менее, в большинстве случаев реализация, конкретного окна настроек и связанных с ним множества вещей оставлена на усмотрение пользователя. В данной заметке хочу поделиться подходом, к которому удалось придти. В моем случае нужно реализовать работу с настройками в MVVM-friendly стиле и с использованием специфики используемого в данном случае фреймворка Catel.


Disclaimer: в данной заметке не будет каких-либо технических тонкостей сложнее базовой рефлексии. Это просто описание подхода к решению небольшой проблемы, получившегося у меня за выходные. Захотелось подумать, как можно избавиться от стандартного boilerplate кода и копипасты, связанной с сохранением/загрузкой настроек приложения. Само решение оказалось довольно тривиальным благодаря удобным имеющимся средствам .NET/Catel, но возможно кому-нибудь сэкономит пару часов времени или наведет на полезные мысли.


Краткое описание фреймворка Catel

Как и другие WPF фреймворки (Prism, MVVM Light, Caliburn.Micro и т.д.), Catel предоставляет удобные средства для построения приложений в MVVM стиле.
Главные компоненты:


  • IoC (интегрированный с MVVM компонентами)
  • ModelBase: базовый класс, предоставляющий автоматическую реализацию PropertyChanged (особенно в связке с Catel.Fody), сериализацию и BeginEdit/CancelEdit/EndEdit (классические "применить"/"отмена").
  • ViewModelBase, умеющий привязываться к модели, оборачивая ее свойства.
  • Работа с представлениями (views), которые умеют автоматически создавать и привязываться к ViewModel. Поддерживаются вложенные контролы.

Требования


Будем исходить того, что от средств конфигурации мы хотим следующее:


  • Доступ к конфигурации в простом структурированном виде. Например
    CultureInfo culture = settings.Application.PreferredCulture;
    TimeSpan updateRate = settings.Perfomance.UpdateRate;.
    • Все параметры представлены в виде обычных свойств. Способ их хранения инкапсулирован внутри. Для простых типов все должно происходить автоматически, для более сложных должна быть возможность сконфигурировать сериализацию значения в строку.
  • Простота и надежность. Не хочется использовать хрупкие инструменты вроде сериализации всей модели настроек целиком или какого-нибудь Entity Framework. На нижнем уровне конфигурация остается простым хранилищем пар "параметр — значение".
  • Возможность отменить внесенные в конфигурацию изменения, например в случае, если пользователь нажал "отмена" в окне настроек.
  • Возможность подписки на обновления конфигурации. Например, мы хотим обновлять язык приложения сразу после того, как конфигурация была изменена.
  • Миграция между версиями приложения. Должна быть возможность задать действия при переходе между версиями приложения (переименовать параметры и т.д.).
  • Минимум boilerplate кода, минимум возможностей для опечаток. В идеале мы просто хотим задать автосвойство и не думать о том, как оно сохранится, под каким строковым ключом и т.д… Мы не хотим вручную заниматься копированием каждого из свойств во view-model окна настроек, все должно работать автоматически.

Стандартные средства


Catel предоставляет сервис IConfigurationService, позволяющий сохранять и загружать значения по строковым ключам из локального хранилища (файла на диске в стандартной реализации).


Если мы захотим использовать этот сервис в чистом виде, то придется эти ключи объявлять самостоятельно, например задав такие константы:


public static class Application
{
    public const String PreferredCulture = "Application.PreferredCulture";
    public static readonly String PreferredCultureDefaultValue = Thread.CurrentThread.CurrentUICulture.ToString();
}

Затем мы можем получать эти параметры примерно следующим образом:


var preferredCulture = new CultureInfo(configurationService.GetRoamingValue(
            Application.PreferredCulture,
            Application.PreferredCultureDefaultValue));

Много и нудно писать, легко сделать опечатки, когда настроек много. Кроме того, сервис поддерживает только простые типы, например CultureInfo без дополнительных преобразований сохранить не получится.


Для упрощения работы с этим сервисом получилась обертка, состоящая из нескольких компонент.


Полный код примера доступен в GitHub репозитории. Он содержит простейшее приложение с возможностью отредактировать пару параметров в настройках и убедиться, что все работает. С локализацией не стал заморачиваться, параметр "Language" в настройках используется исключительно для демонстрации работы конфигурации. Если интересует, в Catel есть удобные механизмы локализации, в том числе и на уровне WPF. Если не нравятся ресурсные файлы, можно сделать свою реализацию, работающую с GNU gettext, например.


Для удобства чтения, в примерах кода в тексте этой публикации удалены все xml-doc комментарии.



Сервис конфигурации


Сервис, который можно встроить через IoC и иметь доступ к работе с настройками из любой точки приложения.


Основная задача сервиса — предоставлять модель настроек, которая в свою очередь предоставляет простой и структурированный способ доступа к ним.


Кроме модели настроек, сервис также предоставляет возможность отменить или сохранить внесенные в настройки изменения.


Интерфейс:


public interface IApplicationConfigurationProviderService
{
    event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved;
    ConfigurationModel Configuration { get; }
    void LoadSettingsFromStorage();
    void SaveChanges();
}

Реализация:


public partial class ApplicationConfigurationProviderService : IApplicationConfigurationProviderService
{
    private readonly IConfigurationService _configurationService;

    public ApplicationConfigurationProviderService(IConfigurationService configurationService)
    {
        _configurationService = configurationService;
        Configuration         = new ConfigurationModel();

        LoadSettingsFromStorage();
        ApplyMigrations();
    }

    public event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved;

    public ConfigurationModel Configuration { get; }

    public void LoadSettingsFromStorage()
    {
        Configuration.LoadFromStorage(_configurationService);
    }

    public void SaveChanges()
    {
        Configuration.SaveToStorage(_configurationService);
        ConfigurationSaved?.Invoke(this);
    }

    private void ApplyMigrations()
    {
        var    currentVersion       = typeof(ApplicationConfigurationProviderService).Assembly.GetName().Version;
        String currentVersionString = currentVersion.ToString();
        String storedVersionString  = _configurationService.GetRoamingValue("SolutionVersion", currentVersionString);

        if (storedVersionString == currentVersionString)
            return; //Either migrations were already applied or we are on fresh install

        var storedVersion = new Version(storedVersionString);
        foreach (var migration in _migrations)
        {
            Int32 comparison = migration.Version.CompareTo(storedVersion);
            if (comparison <= 0)
                continue;

            migration.Action.Invoke();
        }

        _configurationService.SetRoamingValue("SolutionVersion", currentVersionString);
    }
}

Реализация тривиальна, содержимое ConfigurationModel описано в следующих разделах. Единственное, что вероятно привлекает внимание — метод ApplyMigrations.


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


Если в новой версии приложения что-то поменялось, мы просто добавляем необходимые действия (например сохранение параметра под новым именем) в для новой версии в список миграций, содержащийся в соседнем файле:


    private readonly IReadOnlyCollection<Migration> _migrations = new Migration[]
        {
            new Migration(new Version(1,1,0),
                () =>
                {
                    //...
                })
        }
        .OrderBy(migration => migration.Version)
        .ToArray();

    private class Migration
    {
        public readonly Version Version;
        public readonly Action  Action;

        public Migration(Version version, Action action)
        {
            Version = version;
            Action  = action;
        }
    }

Модель настроек


Автоматизация рутинных операций состоит в следующем. Конфигурация описывается как обычная модель (data-object). Catel предоставляет удобный базовый класс ModelBase, являющийся ядром всех его MVVM средств, например автоматических binding'ов между всеми тремя компонентами MVVM. В частности, он позволяет легко обращаться к свойствам модели, которые мы хотим сохранять.


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


Объявление параметров конфигурации


Так выглядит корневая модель:


public partial class ConfigurationModel : ConfigurationGroupBase
{
    public ConfigurationModel()
    {
        Application = new ApplicationConfiguration();
        Performance = new PerformanceConfiguration();
    }

    public ApplicationConfiguration Application { get; private set; }
    public PerformanceConfiguration Performance { get; private set; }
}

ApplicationConfiguration и PerfomanceConfiguration — подклассы, описывающие свои группы настроек:


public partial class ConfigurationModel
{
    public class PerformanceConfiguration : ConfigurationGroupBase
    {
        [DefaultValue(10)]
        public Int32 MaxUpdatesPerSecond { get; set; }
    }
}

Под капотом это свойство свяжется с параметром "Performance.MaxUpdatesPerSecond", название которого сгенерировано из названия типа PerformanceConfiguration.


Нужно заметить, что возможность объявить эти свойства настолько лаконично появилась благодаря использованию Catel.Fody, плагина к известному .NET кодогенератору Fody. Если по каким-то причинам вы не хотите его использовать, свойства нужно объявлять как обычно, согласно документации (визуально похоже на DependencyProperty из WPF).


При желании, уровень вложенности можно увеличить.


Реализация связывания свойств с IConfigurationService


Связывание происходит в базовом классе ConfigurationGroupBase, который в свою очередь унаследован от ModelBase. Рассмотрим его содержимое подробнее.


В первую очередь, составляем список свойств, которые мы хотим сохранять:


public abstract class ConfigurationGroupBase : ModelBase
{
    private readonly IReadOnlyCollection<ConfigurationProperty> _configurationProperties;
    private readonly IReadOnlyCollection<PropertyData>          _nestedConfigurationGroups;

    protected ConfigurationGroupBase()
    {
        var properties = this.GetDependencyResolver()
            .Resolve<PropertyDataManager>()
            .GetCatelTypeInfo(GetType())
            .GetCatelProperties()
            .Select(property => property.Value)
            .Where(property => property.IncludeInBackup && !property.IsModelBaseProperty)
            .ToArray();

        _configurationProperties = properties
            .Where(property => !property.Type.IsSubclassOf(typeof(ConfigurationGroupBase)))
            .Select(property =>
            {
                // ReSharper disable once PossibleNullReferenceException
                String configurationKeyBase = GetType()
                    .FullName
                    .Replace("+",                                       ".")
                    .Replace(typeof(ConfigurationModel).FullName + ".", string.Empty);

                configurationKeyBase = configurationKeyBase.Remove(configurationKeyBase.Length - "Configuration".Length);

                String configurationKey = $"{configurationKeyBase}.{property.Name}";
                return new ConfigurationProperty(property, configurationKey);
            })
            .ToArray();

        _nestedConfigurationGroups = properties
            .Where(property => property.Type.IsSubclassOf(typeof(ConfigurationGroupBase)))
            .ToArray();
    }
...
    private class ConfigurationProperty
    {
        public readonly PropertyData PropertyData;
        public readonly String       ConfigurationKey;

        public ConfigurationProperty(PropertyData propertyData, String configurationKey)
        {
            PropertyData     = propertyData;
            ConfigurationKey = configurationKey;
        }
    }
}

Здесь мы просто обращаемся к аналогу рефлексии для моделей Catel, получаем свойства (отфильтровав служебные или те, которые мы явно пометили атрибутом [ExcludeFromBackup]) и генерируем для них строковые ключи. Свойства, которые сами имеют тип ConfigurationGroupBase заносим в отдельный список.


Метод LoadFromStorage() записывает в полученные ранее свойства значения из конфигурации или стандартные, если ранее они не сохранялись. Для подгрупп вызываются их LoadFromStorage():


public void LoadFromStorage(IConfigurationService configurationService)
{
    foreach (var property in _configurationProperties)
    {
        try
        {
            LoadPropertyFromStorage(configurationService, property.ConfigurationKey, property.PropertyData);
        }
        catch (Exception ex)
        {
            Log.Error(ex, "Can't load from storage nested configuration group {Name}", property.PropertyData.Name);
        }
    }

    foreach (var property in _nestedConfigurationGroups)
    {
        var configurationGroup = GetValue(property) as ConfigurationGroupBase;
        if (configurationGroup == null)
        {
            Log.Error("Can't load from storage configuration property {Name}", property.Name);
            continue;
        }

        configurationGroup.LoadFromStorage(configurationService);
    }
}

protected virtual void LoadPropertyFromStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData)
{
    var objectConverterService = this.GetDependencyResolver().Resolve<IObjectConverterService>();

    Object value = configurationService.GetRoamingValue(configurationKey, propertyData.GetDefaultValue());
    if (value is String stringValue)
        value = objectConverterService.ConvertFromStringToObject(stringValue, propertyData.Type, CultureInfo.InvariantCulture);

    SetValue(propertyData, value);
}

Метод LoadPropertyFromStorage определяет, как происходит перенос значения из конфигурации в свойство. Он виртуален и может быть переопределен для нетривиальных свойств.


Небольшая особенность внутренней работы сервиса IConfigurationService: можно заметить использование IObjectConverterService. Он нужен из-за того, что IConfigurationService.GetValue в данном случае вызывается с generic параметром типа Object и в таком случае он не будет сам преобразовывать загруженные строки в числа, например, поэтому нужно сделать это самим.


Аналогично с сохранением параметров:


public void SaveToStorage(IConfigurationService configurationService)
{
    foreach (var property in _configurationProperties)
    {
        try
        {
            SavePropertyToStorage(configurationService, property.ConfigurationKey, property.PropertyData);
        }
        catch (Exception ex)
        {
            Log.Error(ex, "Can't save to storage configuration property {Name}", property.PropertyData.Name);
        }
    }

    foreach (var property in _nestedConfigurationGroups)
    {
        var configurationGroup = GetValue(property) as ConfigurationGroupBase;
        if (configurationGroup == null)
        {
            Log.Error("Can't save to storage nested configuration group {Name}", property.Name);
            continue;
        }

        configurationGroup.SaveToStorage(configurationService);
    }
}

protected virtual void SavePropertyToStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData)
{
    Object value = GetValue(propertyData);
    configurationService.SetRoamingValue(configurationKey, value);
}

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


  • Типы групп настроек (кроме корневой) являются подклассами "родительской" группы и их имена оканчиваются на Configuration.
  • Для каждого такого типа есть соответствующее ему свойство. Например группа ApplicationSettings и свойство Application. Название свойства ни на что не влияет, но это наиболее логичный и ожидаемый вариант.

Настройка сохранения отдельных свойств


Автомагия Catel.Fody и IConfigurationService (прямое сохранение значения в IConfigurationService и атрибут [DefaultValue]) будет работать только для простых типов и константных значений по умолчанию. Для сложных свойств придется расписать немного подлиннее:


public partial class ConfigurationModel
{
    public class ApplicationConfiguration : ConfigurationGroupBase
    {
        public CultureInfo PreferredCulture { get; set; }

        [DefaultValue("User")]
        public String Username { get; set; }

        protected override void LoadPropertyFromStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData)
        {
            switch (propertyData.Name)
            {
                case nameof(PreferredCulture):
                    String preferredCultureDefaultValue = CultureInfo.CurrentUICulture.ToString();
                    if (preferredCultureDefaultValue != "en-US" || preferredCultureDefaultValue != "ru-RU")
                        preferredCultureDefaultValue = "en-US";

                    String value = configurationService.GetRoamingValue(configurationKey, preferredCultureDefaultValue);
                    SetValue(propertyData, new CultureInfo(value));
                    break;
                default:
                    base.LoadPropertyFromStorage(configurationService, configurationKey, propertyData);
                    break;
            }
        }

        protected override void SavePropertyToStorage(IConfigurationService configurationService, String configurationKey, PropertyData propertyData)
        {
            switch (propertyData.Name)
            {
                case nameof(PreferredCulture):
                    Object value = GetValue(propertyData);
                    configurationService.SetRoamingValue(configurationKey, value.ToString());
                    break;
                default:
                    base.SavePropertyToStorage(configurationService, configurationKey, propertyData);
                    break;
            }
        }
    }
}

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


<TextBox Text="{Binding Configuration.Application.Username}" />

Осталось не забыть переопределить операции при закрытии ViewModel окна настроек:


protected override Task<Boolean> SaveAsync()
{
    _applicationConfigurationProviderService.SaveChanges();

    return base.SaveAsync();
}

protected override Task<Boolean> CancelAsync()
{
    _applicationConfigurationProviderService.LoadSettingsFromStorage();

    return base.CancelAsync();
}

С ростом количества параметров и соответственно сложности интерфейса, вы сможете без проблем создать отдельные View и ViewModel для каждого раздела настроек.

Теги:
Хабы:
Всего голосов 9: ↑9 и ↓0+9
Комментарии0

Публикации

Работа

Ближайшие события