La implementación de la administración de configuraciones de software es probablemente una de esas cosas que se implementan de manera diferente en casi todas las aplicaciones. La mayoría de los frameworks y otros complementos generalmente proporcionan sus propios medios para guardar / cargar valores de algún valor clave del almacenamiento de parámetros.
Sin embargo, en la mayoría de los casos, la implementación de una ventana de configuración específica y muchas cosas relacionadas queda a discreción del usuario. En este artículo quiero compartir el enfoque al que logré llegar. En mi caso, necesito implementar el trabajo con configuraciones en el estilo amigable MVVM y usando los detalles del marco Catel utilizado en este caso.
Descargo de responsabilidad : en esta nota no habrá sutilezas técnicas más difíciles que la reflexión básica. Esta es solo una descripción del enfoque para resolver un pequeño problema que obtuve durante el fin de semana. Quería pensar en cómo deshacerme del código estándar y copiar y pegar en relación con la configuración de guardar / cargar aplicaciones. La solución en sí resultó ser bastante trivial gracias a las prácticas herramientas .NET / Catel disponibles, pero quizás alguien ahorre un par de horas o sugiera ideas útiles.
Resumen del marco de CatelAl igual que otros marcos WPF (Prism, MVVM Light, Caliburn.Micro, etc.), Catel proporciona herramientas convenientes para crear aplicaciones en el estilo MVVM.
Los componentes principales:
- IoC (integrado con componentes MVVM)
- ModelBase: una clase base que proporciona una implementación automática de PropertyChanged (especialmente en combinación con Catel.Fody), serialización y BeginEdit / CancelEdit / EndEdit (clásico "aplicar" / "cancelar").
- ViewModelBase, capaz de vincularse al modelo, ajustando sus propiedades.
- Trabaje con vistas, que pueden crear y vincularse automáticamente al ViewModel. Los controles anidados son compatibles.
Requisitos
Procederemos del hecho de que queremos lo siguiente de las herramientas de configuración:
- Acceso a la configuración de forma simple y estructurada. Por ejemplo
CultureInfo culture = settings.Application.PreferredCulture;
TimeSpan updateRate = settings.Perfomance.UpdateRate;
.
- Todos los parámetros se presentan como propiedades normales. El método de almacenamiento está encapsulado en su interior. Para los tipos simples, todo debería suceder automáticamente; para los tipos más complejos, debería ser posible configurar la serialización del valor en una cadena.
- Simplicidad y fiabilidad. No quiero usar herramientas frágiles como serializar el modelo de configuración completo por completo o algún Entity Framework. En el nivel inferior, la configuración sigue siendo un depósito simple de pares de parámetros y valores.
- La capacidad de descartar los cambios realizados en la configuración, por ejemplo, si el usuario hizo clic en "cancelar" en la ventana de configuración.
- Posibilidad de suscribirse a las actualizaciones de configuración. Por ejemplo, queremos actualizar el idioma de la aplicación inmediatamente después de que se haya cambiado la configuración.
- Migración entre versiones de la aplicación. Debería ser posible establecer acciones al cambiar entre las versiones de la aplicación (cambiar el nombre de los parámetros, etc.).
- Código mínimo repetitivo, errores tipográficos mínimos. Idealmente, solo queremos establecer la propiedad automática y no pensar en cómo se guardará, en qué tecla de cadena, etc. No queremos copiar manualmente cada una de las propiedades en el modelo de vista de la ventana de configuración, todo debería funcionar automáticamente.
Herramientas estándar
Catel proporciona el servicio IConfigurationService, que permite almacenar y cargar valores de claves de cadena desde el almacenamiento local (un archivo en el disco en la implementación estándar).
Si queremos utilizar este servicio en su forma pura, tendremos que declarar estas claves nosotros mismos, por ejemplo, estableciendo tales constantes:
public static class Application { public const String PreferredCulture = "Application.PreferredCulture"; public static readonly String PreferredCultureDefaultValue = Thread.CurrentThread.CurrentUICulture.ToString(); }
Entonces podemos obtener estos parámetros así:
var preferredCulture = new CultureInfo(configurationService.GetRoamingValue( Application.PreferredCulture, Application.PreferredCultureDefaultValue));
Es mucho y tedioso escribir, es fácil hacer errores tipográficos cuando hay muchas configuraciones. Además, el servicio solo admite tipos simples, por ejemplo CultureInfo
no se puede guardar sin transformaciones adicionales.
Para simplificar el trabajo con este servicio, se obtuvo un contenedor que consta de varios componentes.
El código de muestra completo está disponible en el repositorio de GitHub . Contiene la aplicación más simple con la capacidad de editar un par de parámetros en la configuración y asegurarse de que todo funcione. No me molesté con la localización, el parámetro "Idioma" en la configuración se utiliza únicamente para demostrar la configuración. Si está interesado, Catel tiene mecanismos de localización convenientes, incluso a nivel de WPF. Si no le gustan los archivos de recursos, puede hacer que su propia implementación funcione con GNU gettext, por ejemplo.
Para facilitar la lectura, en los ejemplos de código en el texto de esta publicación, se han eliminado todos los comentarios de xml-doc.
Servicio de configuración
Un servicio que puede integrarse a través de IoC y tener acceso para trabajar con configuraciones desde cualquier lugar de la aplicación.
El objetivo principal del servicio es proporcionar un modelo de configuración, que a su vez proporciona una forma simple y estructurada de acceder a ellos.
Además del modelo de configuración, el servicio también ofrece la posibilidad de deshacer o guardar los cambios realizados en la configuración.
Interfaz:
public interface IApplicationConfigurationProviderService { event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved; ConfigurationModel Configuration { get; } void LoadSettingsFromStorage(); void SaveChanges(); }
Implementación
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;
La implementación es trivial, el contenido de ConfigurationModel
describe en las siguientes secciones. Lo único que probablemente ApplyMigrations
la atención es el método ApplyMigrations
.
En la nueva versión del programa, algo puede cambiar, por ejemplo, el método de almacenamiento de algún parámetro complejo o su nombre. Si no queremos perder nuestra configuración después de cada actualización que cambia los parámetros existentes, necesitamos un mecanismo de migración. El método ApplyMigrations
soporte muy simple para realizar cualquier acción durante la transición entre versiones.
Si algo ha cambiado en la nueva versión de la aplicación, simplemente agregamos las acciones necesarias (por ejemplo, guardar el parámetro con un nuevo nombre) en la nueva versión a la lista de migraciones contenida en el archivo vecino:
private readonly IReadOnlyCollection<Migration> _migrations = new Migration[] { new Migration(new Version(1,1,0), () => {
Modelo de configuración
La automatización de las operaciones de rutina es la siguiente. La configuración se describe como un modelo normal (objeto de datos). Catel proporciona una conveniente clase base ModelBase
, que es el núcleo de todas sus herramientas MVVM, como los enlaces automáticos entre los tres componentes MVVM. En particular, le permite acceder fácilmente a las propiedades del modelo que queremos guardar.
Al declarar dicho modelo, podemos obtener sus propiedades, asignarles claves de cadena, crearlas a partir de nombres de propiedad y luego cargar y guardar automáticamente valores de la configuración. En otras palabras, enlazar propiedades y valores en una configuración.
Declarando opciones de configuración
Este es el modelo raíz:
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
y PerfomanceConfiguration
son subclases que describen sus grupos de configuraciones:
public partial class ConfigurationModel { public class PerformanceConfiguration : ConfigurationGroupBase { [DefaultValue(10)] public Int32 MaxUpdatesPerSecond { get; set; } } }
Bajo el capó, esta propiedad se unirá al parámetro "Performance.MaxUpdatesPerSecond"
, cuyo nombre se genera a partir del nombre del tipo PerformanceConfiguration
.
Cabe señalar que la capacidad de declarar estas propiedades fue muy concisa gracias al uso de Catel.Fody , un complemento del conocido generador de código .NET Fody . Si por alguna razón no desea usarlo, las propiedades deben declararse como de costumbre, de acuerdo con la documentación (visualmente similar a DependencyProperty de WPF).
Si lo desea, se puede aumentar el nivel de anidación.
Implemente el enlace de propiedades con IConfigurationService
El enlace se produce en la clase base ConfigurationGroupBase
, que a su vez se hereda de ModelBase. Considere su contenido con más detalle.
En primer lugar, hacemos una lista de propiedades que queremos guardar:
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 => {
Aquí simplemente pasamos al análogo de reflexión para los modelos Catel, obtenemos las propiedades (mediante la utilidad de filtrado o las que marcamos explícitamente con el atributo [ExcludeFromBackup]
) y generamos claves de cadena para ellos. Las propiedades que son del tipo ConfigurationGroupBase
enumeran en una lista separada.
El método LoadFromStorage()
escribe valores de la configuración en las propiedades obtenidas anteriormente, o valores estándar, si no se guardaron previamente. Para subgrupos, su LoadFromStorage()
llama:
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); }
El método LoadPropertyFromStorage
determina cómo se transfiere el valor de la configuración a la propiedad. Es virtual y se puede redefinir para propiedades no triviales.
Una pequeña característica del funcionamiento interno del servicio IConfigurationService
: puede observar el uso de IObjectConverterService
. Es necesario porque IConfigurationService.GetValue
en este caso con un parámetro genérico de tipo Object
y en este caso no convertirá las cadenas cargadas a números, por ejemplo, por lo que debe hacerlo usted mismo.
De manera similar con los parámetros de guardado:
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); }
Cabe señalar que dentro del modelo de configuración, debe seguir convenciones de nomenclatura simples para obtener claves de cadena de parámetros uniformes:
- Los tipos de grupos de configuración (excepto la raíz) son subclases del grupo "padre" y sus nombres terminan en Configuración.
- Para cada tipo de este tipo hay una propiedad que le corresponde. Por ejemplo, el grupo
ApplicationSettings
y la propiedad Application
. El nombre de la propiedad no afecta nada, pero esta es la opción más lógica y esperada.
Configuración de guardar propiedades individuales
La IConfigurationService
automática IConfigurationService
Catel.Fody e IConfigurationService
(guardado directo del valor en IConfigurationService
y el atributo [DefaultValue]
) funcionará solo para tipos simples y valores predeterminados constantes. Para propiedades complejas, debes pintar un poco más auténtico:
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; } } } }
Ahora podemos, por ejemplo, en la ventana de configuración enlazar a cualquiera de las propiedades del modelo:
<TextBox Text="{Binding Configuration.Application.Username}" />
Queda por recordar anular las operaciones al cerrar la ventana de configuración de ViewModel:
protected override Task<Boolean> SaveAsync() { _applicationConfigurationProviderService.SaveChanges(); return base.SaveAsync(); } protected override Task<Boolean> CancelAsync() { _applicationConfigurationProviderService.LoadSettingsFromStorage(); return base.CancelAsync(); }
Con el aumento en el número de parámetros y, en consecuencia, la complejidad de la interfaz, puede crear fácilmente View y ViewModel por separado para cada sección de configuración.