A implementação do gerenciamento de configurações de software é provavelmente uma daquelas coisas implementadas de maneira diferente em quase todos os aplicativos. A maioria das estruturas e outros complementos geralmente fornece suas próprias ferramentas para salvar / carregar valores de algum valor-chave do armazenamento de parâmetros.
No entanto, na maioria dos casos, a implementação de uma janela de configurações específica e muitas outras coisas relacionadas fica a critério do usuário. Neste artigo, quero compartilhar a abordagem que consegui adotar. No meu caso, preciso implementar o trabalho com configurações no estilo amigável ao MVVM e usando as especificidades da estrutura Catel usada neste caso.
Isenção de responsabilidade : nesta nota, não haverá sutilezas técnicas mais difíceis que a reflexão básica. Esta é apenas uma descrição da abordagem para resolver um pequeno problema que recebi no fim de semana. Eu queria pensar em como se livrar do código padrão e copiar e colar relacionado a salvar / carregar as configurações do aplicativo. A solução em si acabou sendo bastante trivial, graças às convenientes ferramentas .NET / Catel disponíveis, mas talvez alguém economize algumas horas ou sugira pensamentos úteis.
Resumo da Estrutura CatelComo outras estruturas WPF (Prism, MVVM Light, Caliburn.Micro, etc.), a Catel fornece ferramentas convenientes para criar aplicativos no estilo MVVM.
Os principais componentes:
- IoC (integrado aos componentes MVVM)
- ModelBase: uma classe base que fornece implementação automática de PropertyChanged (especialmente em conjunto com Catel.Fody), serialização e BeginEdit / CancelEdit / EndEdit (clássico "aplicar" / "cancelar").
- ViewModelBase, capaz de se ligar ao modelo, agrupando suas propriedades.
- Trabalhe com visualizações, que podem criar e vincular automaticamente ao ViewModel. Controles aninhados são suportados.
Exigências
Continuaremos com o fato de que desejamos o seguinte nas ferramentas de configuração:
- Acesso à configuração de forma estruturada simples. Por exemplo
CultureInfo culture = settings.Application.PreferredCulture;
TimeSpan updateRate = settings.Perfomance.UpdateRate;
.
- Todos os parâmetros são apresentados como propriedades normais. O método de armazenamento é encapsulado dentro. Para tipos simples, tudo deve acontecer automaticamente; para tipos mais complexos, deve ser possível configurar a serialização do valor em uma string.
- Simplicidade e confiabilidade. Não quero usar ferramentas frágeis, como serializar todo o modelo de configuração completamente ou alguma Entity Framework. No nível inferior, a configuração permanece um repositório simples de pares parâmetro-valor.
- A capacidade de descartar as alterações feitas na configuração, por exemplo, se o usuário clicar em "cancelar" na janela de configurações.
- Capacidade de assinar atualizações de configuração. Por exemplo, queremos atualizar o idioma do aplicativo imediatamente após a alteração da configuração.
- Migração entre versões de aplicativos. Deve ser possível definir ações ao alternar entre versões de aplicativos (renomear parâmetros, etc.).
- Código mínimo padrão, erros mínimos de digitação. Idealmente, queremos apenas definir a propriedade automática e não pensar em como ela será salva, sob qual chave de string, etc ... Não queremos copiar manualmente cada uma das propriedades no modelo de exibição da janela de configurações, tudo deve funcionar automaticamente.
Ferramentas padrão
A Catel fornece o serviço IConfigurationService, que permite armazenar e carregar valores de chaves de seqüência de caracteres do armazenamento local (um arquivo em disco na implementação padrão).
Se quisermos usar este serviço em sua forma pura, teremos que declarar essas chaves, por exemplo, definindo essas constantes:
public static class Application { public const String PreferredCulture = "Application.PreferredCulture"; public static readonly String PreferredCultureDefaultValue = Thread.CurrentThread.CurrentUICulture.ToString(); }
Então, podemos obter esses parâmetros assim:
var preferredCulture = new CultureInfo(configurationService.GetRoamingValue( Application.PreferredCulture, Application.PreferredCultureDefaultValue));
É muito tedioso escrever, é fácil digitar erros quando há muitas configurações. Além disso, o serviço suporta apenas tipos simples, por exemplo, CultureInfo
não pode ser salvo sem transformações adicionais.
Para simplificar o trabalho com este serviço, foi obtido um invólucro composto por vários componentes.
O código de amostra completo está disponível no repositório GitHub . Ele contém o aplicativo mais simples, com a capacidade de editar alguns parâmetros nas configurações e garantir que tudo funcione. Não me incomodei com a localização, o parâmetro "Idioma" nas configurações é usado apenas para demonstrar a configuração. Se estiver interessado, a Catel possui mecanismos de localização convenientes, inclusive no nível do WPF. Se você não gosta de arquivos de recursos, pode fazer sua própria implementação trabalhando com o GNU gettext, por exemplo.
Para facilitar a leitura, nos exemplos de código no texto desta publicação, todos os comentários xml-doc foram removidos.
Serviço de configuração
Um serviço que pode ser incorporado por meio da IoC e ter acesso para trabalhar com configurações de qualquer lugar do aplicativo.
O principal objetivo do serviço é fornecer um modelo de configurações, que por sua vez fornece uma maneira simples e estruturada de acessá-los.
Além do modelo de configurações, o serviço também oferece a capacidade de desfazer ou salvar as alterações feitas nas configurações.
Interface:
public interface IApplicationConfigurationProviderService { event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved; ConfigurationModel Configuration { get; } void LoadSettingsFromStorage(); void SaveChanges(); }
Implementação:
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;
A implementação é trivial, o conteúdo do ConfigurationModel
descrito nas seções a seguir. A única coisa que provavelmente atrairá a atenção é o método ApplyMigrations
.
Na nova versão do programa, algo pode mudar, por exemplo, o método de armazenar algum parâmetro complexo ou seu nome. Se não queremos perder nossas configurações após cada atualização que altera os parâmetros existentes, precisamos de um mecanismo de migração. O método ApplyMigrations
suporte muito simples para executar qualquer ação durante a transição entre versões.
Se algo mudou na nova versão do aplicativo, simplesmente adicionamos as ações necessárias (por exemplo, salvando o parâmetro com um novo nome) na nova versão à lista de migrações contidas no arquivo vizinho:
private readonly IReadOnlyCollection<Migration> _migrations = new Migration[] { new Migration(new Version(1,1,0), () => {
Modelo de configurações
A automação de operações de rotina é a seguinte. A configuração é descrita como um modelo regular (objeto de dados). A Catel fornece uma conveniente classe base ModelBase
, que é o núcleo de todas as suas ferramentas MVVM, como ligações automáticas entre os três componentes MVVM. Em particular, permite acessar facilmente as propriedades do modelo que queremos salvar.
Ao declarar esse modelo, podemos obter suas propriedades, mapear chaves de cadeia de caracteres para elas, criá-las a partir de nomes de propriedades e, em seguida, carregar e salvar valores automaticamente da configuração. Em outras palavras, vincule propriedades e valores em uma configuração.
Declarando opções de configuração
Este é o modelo raiz:
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
e PerfomanceConfiguration
são subclasses que descrevem seus grupos de configurações:
public partial class ConfigurationModel { public class PerformanceConfiguration : ConfigurationGroupBase { [DefaultValue(10)] public Int32 MaxUpdatesPerSecond { get; set; } } }
Sob o capô, essa propriedade será vinculada ao parâmetro "Performance.MaxUpdatesPerSecond"
, cujo nome é gerado a partir do nome do tipo PerformanceConfiguration
.
Deve-se observar que a capacidade de declarar essas propriedades foi tão concisa graças ao uso do Catel.Fody , um plug-in do conhecido gerador de código .NET Fody . Se, por algum motivo, você não quiser usá-lo, as propriedades deverão ser declaradas como de costume, de acordo com a documentação (visualmente semelhante a DependencyProperty do WPF).
Se desejado, o nível de aninhamento pode ser aumentado.
Implementar ligação de propriedade com IConfigurationService
A ligação ocorre na classe base ConfigurationGroupBase
, que por sua vez é herdada do ModelBase. Considere seu conteúdo com mais detalhes.
Primeiro, fazemos uma lista de propriedades que queremos salvar:
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 => {
Aqui, simplesmente recorremos ao análogo de reflexão dos modelos Catel, obtemos as propriedades (pelo utilitário de filtragem ou aquelas que marcamos explicitamente com o atributo [ExcludeFromBackup]
) e geramos chaves de string para eles. As propriedades que são do tipo ConfigurationGroupBase
listadas em uma lista separada.
O método LoadFromStorage()
grava valores da configuração nas propriedades obtidas anteriormente, ou valores padrão, se eles não foram salvos anteriormente. Para subgrupos, o LoadFromStorage()
chamado:
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); }
O método LoadPropertyFromStorage
determina como o valor é transferido da configuração para a propriedade É virtual e pode ser redefinido para propriedades não triviais.
Um pequeno recurso da operação interna do serviço IConfigurationService
: você pode observar o uso do IObjectConverterService
. É necessário porque IConfigurationService.GetValue
nesse caso com um parâmetro genérico do tipo Object
e, nesse caso, não converterá as seqüências carregadas em números, por exemplo, portanto, você deve fazer isso sozinho.
Da mesma forma com os parâmetros de salvamento:
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); }
Deve-se observar que, dentro do modelo de configuração, você precisa seguir convenções de nomenclatura simples para obter chaves uniformes de cadeia de parâmetros:
- Os tipos de grupos de configurações (exceto a raiz) são subclasses do grupo "pai" e seus nomes terminam em Configuração.
- Para cada tipo existe uma propriedade correspondente a ele. Por exemplo, o grupo
ApplicationSettings
e a propriedade Application
. O nome da propriedade não afeta nada, mas esta é a opção mais lógica e esperada.
Configurando Salvar Propriedades Individuais
A IConfigurationService
automática IConfigurationService
Catel.Fody e IConfigurationService
(salvamento direto do valor em IConfigurationService
e o atributo [DefaultValue]
) funcionará apenas para tipos simples e valores padrão constantes. Para propriedades complexas, você precisa pintar um pouco mais de fé:
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; } } } }
Agora podemos, por exemplo, na janela de configurações vincular-se a qualquer uma das propriedades do modelo:
<TextBox Text="{Binding Configuration.Application.Username}" />
Resta lembrar de substituir as operações ao fechar a janela de configurações do ViewModel:
protected override Task<Boolean> SaveAsync() { _applicationConfigurationProviderService.SaveChanges(); return base.SaveAsync(); } protected override Task<Boolean> CancelAsync() { _applicationConfigurationProviderService.LoadSettingsFromStorage(); return base.CancelAsync(); }
Com o aumento do número de parâmetros e, consequentemente, a complexidade da interface, você pode criar facilmente View e ViewModel separados para cada seção de configurações.