L'implémentation de la gestion des paramètres logiciels est probablement l'une de ces choses qui sont implémentées différemment dans presque toutes les applications. La plupart des frameworks et autres modules complémentaires fournissent généralement leurs propres moyens pour enregistrer / charger des valeurs à partir d'une valeur-clé de stockage de paramètres.
Cependant, dans la plupart des cas, la mise en œuvre d'une fenêtre de paramètres spécifique et de nombreuses choses associées est laissée à la discrétion de l'utilisateur. Dans cet article, je veux partager l'approche à laquelle j'ai réussi. Dans mon cas, je dois implémenter un travail avec des paramètres dans le style compatible MVVM et en utilisant les spécificités du framework Catel utilisé dans ce cas.
Avertissement : dans cette note, il n'y aura pas de subtilités techniques plus difficiles que la réflexion de base. Ceci est juste une description de l'approche pour résoudre un petit problème que j'ai eu au cours du week-end. Je voulais réfléchir à la façon de se débarrasser du code standard et de copier-coller lié à l'enregistrement / au chargement des paramètres de l'application. La solution elle-même s'est avérée plutôt triviale grâce aux outils pratiques .NET / Catel disponibles, mais peut-être que quelqu'un économisera quelques heures ou suggérera des idées utiles.
Présentation du cadre CatelComme d'autres frameworks WPF (Prism, MVVM Light, Caliburn.Micro, etc.), Catel fournit des outils pratiques pour créer des applications dans le style MVVM.
Les principaux composants:
- IoC (intégré aux composants MVVM)
- ModelBase: une classe de base qui fournit une implémentation automatique de PropertyChanged (en particulier en conjonction avec Catel.Fody), la sérialisation et BeginEdit / CancelEdit / EndEdit (classique "appliquer" / "annuler").
- ViewModelBase, capable de se lier au modèle, encapsulant ses propriétés.
- Travaillez avec des vues, qui peuvent automatiquement créer et se lier au ViewModel. Les contrôles imbriqués sont pris en charge.
Prérequis
Nous partirons du fait que nous voulons ce qui suit des outils de configuration:
- Accès à la configuration d'une manière structurée simple. Par exemple
CultureInfo culture = settings.Application.PreferredCulture;
TimeSpan updateRate = settings.Perfomance.UpdateRate;
.
- Tous les paramètres sont présentés comme des propriétés normales. La méthode de stockage est encapsulée à l'intérieur. Pour les types simples, tout doit se produire automatiquement; pour les types plus complexes, il doit être possible de configurer la sérialisation de la valeur dans une chaîne.
- Simplicité et fiabilité. Je ne veux pas utiliser des outils fragiles comme la sérialisation du modèle de configuration complet ou un Entity Framework. Au niveau inférieur, la configuration reste un simple référentiel de paires paramètre-valeur.
- La possibilité d'annuler les modifications apportées à la configuration, par exemple, si l'utilisateur a cliqué sur "Annuler" dans la fenêtre des paramètres.
- Possibilité de s'abonner aux mises à jour de configuration. Par exemple, nous voulons mettre à jour la langue de l'application immédiatement après que la configuration a été modifiée.
- Migration entre les versions d'application. Il devrait être possible de définir des actions lors du basculement entre les versions d'application (renommer les paramètres, etc.).
- Code passe-partout minimum, fautes de frappe minimum. Idéalement, nous voulons simplement définir la propriété automatique et ne pas penser à la façon dont elle sera enregistrée, sous quelle clé de chaîne, etc. Nous ne voulons pas copier manuellement chacune des propriétés dans le modèle d'affichage de la fenêtre des paramètres, tout devrait fonctionner automatiquement.
Outils standard
Catel fournit le service IConfigurationService, qui permet de stocker et de charger les valeurs des clés de chaîne à partir du stockage local (un fichier sur disque dans l'implémentation standard).
Si nous voulons utiliser ce service dans sa forme pure, nous devrons déclarer ces clés nous-mêmes, par exemple, en définissant de telles constantes:
public static class Application { public const String PreferredCulture = "Application.PreferredCulture"; public static readonly String PreferredCultureDefaultValue = Thread.CurrentThread.CurrentUICulture.ToString(); }
Ensuite, nous pouvons obtenir ces paramètres comme ceci:
var preferredCulture = new CultureInfo(configurationService.GetRoamingValue( Application.PreferredCulture, Application.PreferredCultureDefaultValue));
C’est beaucoup et fastidieux à écrire, il est facile de faire des fautes de frappe quand il y a beaucoup de paramètres. De plus, le service ne prend en charge que les types simples, par exemple CultureInfo
ne peut pas être enregistré sans transformations supplémentaires.
Pour simplifier le travail avec ce service, un wrapper composé de plusieurs composants a été obtenu.
L'exemple de code complet est disponible dans le référentiel GitHub . Il contient l'application la plus simple avec la possibilité de modifier quelques paramètres dans les paramètres et de s'assurer que tout fonctionne. Je n'ai pas pris la peine de la localisation, le paramètre "Langue" dans les paramètres est utilisé uniquement pour démontrer la configuration. Si vous êtes intéressé, Catel dispose de mécanismes de localisation pratiques, y compris au niveau WPF. Si vous n'aimez pas les fichiers de ressources, vous pouvez par exemple faire fonctionner votre propre implémentation avec GNU gettext.
Pour plus de lisibilité, dans les exemples de code du texte de cette publication, tous les commentaires xml-doc ont été supprimés.
Service de configuration
Un service qui peut être intégré via IoC et avoir accès à travailler avec des paramètres de n'importe où dans l'application.
L'objectif principal du service est de fournir un modèle de paramètres, qui à son tour fournit un moyen simple et structuré pour y accéder.
En plus du modèle de paramètres, le service offre également la possibilité d'annuler ou d'enregistrer les modifications apportées aux paramètres.
Interface:
public interface IApplicationConfigurationProviderService { event TypedEventHandler<IApplicationConfigurationProviderService> ConfigurationSaved; ConfigurationModel Configuration { get; } void LoadSettingsFromStorage(); void SaveChanges(); }
Réalisation:
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;
L'implémentation est triviale, le contenu du ConfigurationModel
décrit dans les sections suivantes. La seule chose susceptible d'attirer l'attention est la méthode ApplyMigrations
.
Dans la nouvelle version du programme, quelque chose peut changer, par exemple, la méthode de stockage d'un paramètre complexe ou son nom. Si nous ne voulons pas perdre nos paramètres après chaque mise à jour qui modifie les paramètres existants, nous avons besoin d'un mécanisme de migration. La méthode ApplyMigrations
un support très simple pour effectuer des actions lors de la transition entre les versions.
Si quelque chose a changé dans la nouvelle version de l'application, nous ajoutons simplement les actions nécessaires (par exemple, enregistrer le paramètre avec un nouveau nom) dans la nouvelle version à la liste des migrations contenues dans le fichier voisin:
private readonly IReadOnlyCollection<Migration> _migrations = new Migration[] { new Migration(new Version(1,1,0), () => {
Modèle de paramètres
L'automatisation des opérations de routine est la suivante. La configuration est décrite comme un modèle régulier (objet de données). Catel fournit une ModelBase
classe de base pratique, qui est au cœur de tous ses outils MVVM, comme les liaisons automatiques entre les trois composants MVVM. En particulier, il vous permet d'accéder facilement aux propriétés du modèle que nous voulons enregistrer.
En déclarant un tel modèle, nous pouvons obtenir ses propriétés, y mapper les clés de chaîne, les créer à partir des noms de propriété, puis charger et enregistrer automatiquement les valeurs de la configuration. En d'autres termes, liez des propriétés et des valeurs dans une configuration.
Déclaration des options de configuration
Voici le modèle racine:
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
et PerfomanceConfiguration
sont des sous-classes qui décrivent leurs groupes de paramètres:
public partial class ConfigurationModel { public class PerformanceConfiguration : ConfigurationGroupBase { [DefaultValue(10)] public Int32 MaxUpdatesPerSecond { get; set; } } }
Sous le capot, cette propriété sera liée au paramètre "Performance.MaxUpdatesPerSecond"
, dont le nom est généré à partir du nom du type PerformanceConfiguration
.
Il convient de noter que la possibilité de déclarer ces propriétés est si concise grâce à l'utilisation de Catel.Fody , un plugin pour le célèbre générateur de code .NET Fody . Si pour une raison quelconque vous ne souhaitez pas l'utiliser, les propriétés doivent être déclarées comme d'habitude, selon la documentation (visuellement similaire à DependencyProperty de WPF).
Si vous le souhaitez, le niveau d'imbrication peut être augmenté.
Implémenter la liaison de propriété avec IConfigurationService
La liaison se produit dans la classe de base ConfigurationGroupBase
, qui à son tour est héritée de ModelBase. Examinez son contenu plus en détail.
Tout d'abord, nous faisons une liste des propriétés que nous voulons enregistrer:
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 => {
Ici, nous nous tournons simplement vers l'analogue de la réflexion pour les modèles Catel, obtenons les propriétés (en filtrant l'utilitaire ou celles que nous avons explicitement marquées avec l'attribut [ExcludeFromBackup]
) et générons des clés de chaîne pour elles. Les propriétés elles-mêmes sont de type ConfigurationGroupBase
répertoriées dans une liste distincte.
La méthode LoadFromStorage()
écrit des valeurs de la configuration dans les propriétés obtenues précédemment, ou des valeurs standard, si elles n'ont pas été précédemment enregistrées. Pour les sous-groupes, leur LoadFromStorage()
appelé:
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); }
La méthode LoadPropertyFromStorage
détermine comment la valeur est transférée de la configuration à la propriété. Il est virtuel et peut être redéfini pour des propriétés non triviales.
Une petite caractéristique du fonctionnement interne du service IConfigurationService
: vous pouvez remarquer l'utilisation de IObjectConverterService
. Il est nécessaire car IConfigurationService.GetValue
dans ce cas avec un paramètre générique de type Object
et dans ce cas, il ne convertira pas les chaînes chargées en nombres, par exemple, vous devez donc le faire vous-même.
De même avec l'enregistrement des paramètres:
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); }
Il convient de noter qu'à l'intérieur du modèle de configuration, vous devez suivre des conventions de dénomination simples pour obtenir des clés de chaîne de paramètres uniformes:
- Les types de groupes de paramètres (à l'exception de la racine) sont des sous-classes du groupe "parent" et leurs noms se terminent dans Configuration.
- Pour chaque type, il existe une propriété qui lui correspond. Par exemple, le groupe
ApplicationSettings
et la propriété Application
. Le nom de la propriété n'affecte rien, mais c'est l'option la plus logique et attendue.
Définition des propriétés individuelles d'enregistrement
L'auto- IConfigurationService
Catel.Fody et IConfigurationService
(sauvegarde directe de la valeur dans IConfigurationService
et l'attribut [DefaultValue]
) ne fonctionnera que pour les types simples et les valeurs par défaut constantes. Pour les propriétés complexes, il faut peindre un peu plus authentique:
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; } } } }
Maintenant, nous pouvons, par exemple, dans la fenêtre des paramètres se lier à l'une des propriétés du modèle:
<TextBox Text="{Binding Configuration.Application.Username}" />
Il reste à se rappeler de remplacer les opérations lors de la fermeture de la fenêtre des paramètres de ViewModel:
protected override Task<Boolean> SaveAsync() { _applicationConfigurationProviderService.SaveChanges(); return base.SaveAsync(); } protected override Task<Boolean> CancelAsync() { _applicationConfigurationProviderService.LoadSettingsFromStorage(); return base.CancelAsync(); }
Avec l'augmentation du nombre de paramètres et, par conséquent, la complexité de l'interface, vous pouvez facilement créer View et ViewModel séparés pour chaque section de paramètres.