Реализация управления настройками ПО это, вероятно, одна из тех вещей, которую практически в каждом приложении реализуют по своему. Большинство фреймворков и прочих надстроек обычно предоставляют свои средства для сохранения/загрузки значений из какого-либо key-value хранилища параметров.
Тем не менее, в большинстве случаев реализация, конкретного окна настроек и связанных с ним множества вещей оставлена на усмотрение пользователя. В данной заметке хочу поделиться подходом, к которому удалось придти. В моем случае нужно реализовать работу с настройками в MVVM-friendly стиле и с использованием специфики используемого в данном случае фреймворка Catel.
Disclaimer: в данной заметке не будет каких-либо технических тонкостей сложнее базовой рефлексии. Это просто описание подхода к решению небольшой проблемы, получившегося у меня за выходные. Захотелось подумать, как можно избавиться от стандартного boilerplate кода и копипасты, связанной с сохранением/загрузкой настроек приложения. Само решение оказалось довольно тривиальным благодаря удобным имеющимся средствам .NET/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 для каждого раздела настроек.