Comment nous avons traduit la configuration de nos services de XML en YAML

Contexte


Notre entreprise, entre autres, a développé plusieurs services (plus précisément - 12) qui fonctionnent comme le backend de nos systèmes. Chacun des services est un service Windows et effectue ses tâches spécifiques.

Je souhaite transférer tous ces services vers * nix-OS. Pour ce faire, abandonnez l'encapsuleur sous forme de services Windows et basculez du .NET Framework vers le .NET Standard.

La dernière exigence conduit à la nécessité de se débarrasser de certains codes hérités, qui ne sont pas pris en charge dans .NET Standard, y compris du support pour la configuration de nos serveurs via XML, implémenté à l'aide des classes de System.Configuration. En même temps, cela résout le problème de longue date lié au fait que dans les configurations XML, nous avons fait des erreurs de temps en temps lors de la modification des paramètres (par exemple, nous mettons parfois la balise de fermeture au mauvais endroit ou l'oublions du tout), mais un merveilleux lecteur de config XML System.Xml. XmlDocument avale silencieusement de telles configurations, donnant un résultat complètement imprévisible.

Il a été décidé de passer à la configuration via la tendance YAML. Quels problèmes avons-nous rencontrés et comment les avons-nous résolus? Dans cet article.

Qu'avons-nous


Comment lisons-nous la configuration à partir de XML


Nous lisons XML de manière standard pour la plupart des autres projets.

Chaque service possède un fichier de paramètres pour les projets .NET, appelé AppSettings.cs, qui contient tous les paramètres requis par le service. Quelque chose comme ça:

[System.Configuration.SettingsProvider(typeof(PortableSettingsProvider))] internal sealed partial class AppSettings : IServerManagerConfigStorage, IWebSettingsStorage, IServerSettingsStorage, IGraphiteAddressStorage, IDatabaseConfigStorage, IBlackListStorage, IKeyCloackConfigFilePathProvider, IPrometheusSettingsStorage, IMetricsConfig { } 


Une technique similaire pour séparer les paramètres en interfaces facilite leur utilisation ultérieure via un conteneur DI.

Toute la magie principale du stockage des paramètres est réellement cachée dans PortableSettingsProvider (voir l'attribut class), ainsi que dans le fichier de concepteur AppSettings.Designer.cs:

 [global::System.Runtime.CompilerServices.CompilerGeneratedAttribute()] [global::System.CodeDom.Compiler.GeneratedCodeAttribute("Microsoft.VisualStudio.Editors.SettingsDesigner.SettingsSingleFileGenerator", "14.0.0.0")] internal sealed partial class AppSettings : global::System.Configuration.ApplicationSettingsBase { private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings()))); public static AppSettings Default { get { return defaultInstance; } } [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("35016")] public int ListenPort { get { return ((int)(this["ListenPort"])); } set { this["ListenPort"] = value; } } ... 

Comme vous pouvez le voir, «en arrière-plan» sont cachées toutes les propriétés que nous ajoutons à la configuration du serveur lorsque nous la modifions via le concepteur de paramètres dans Visual Studio.

Notre classe PortableSettingsProvider, mentionnée ci-dessus, lit directement le fichier XML et le résultat de la lecture est déjà utilisé dans SettingsProvider pour écrire des paramètres dans les propriétés AppSettings.

Un exemple de la configuration XML que nous lisons (la plupart des paramètres sont masqués pour des raisons de sécurité):

 <?xml version="1.0" encoding="utf-8"?> <configuration> <configSections> <sectionGroup name="userSettings" type="System.Configuration.UserSettingsGroup"> <section name="MetricServer.Properties.Settings" type="System.Configuration.ClientSettingsSection" /> </sectionGroup> </configSections> <userSettings> <MetricServer.Properties.Settings> <setting name="MCXSettings" serializeAs="String"> <value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value> </setting> <setting name="KickUnknownAfter" serializeAs="String"> <value>00:00:10</value> </setting> ... </MetricServer.Properties.Settings> </userSettings> </configuration> 

Quels fichiers YAML j'aimerais lire


Quelque chose comme ça:

 VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True 

Problèmes de transition


Premièrement, les configurations en XML sont «plates», mais en YAML elles ne le sont pas (les sections et sous-sections sont prises en charge). Ceci est clairement visible dans les exemples ci-dessus. En utilisant XML, nous avons résolu le problème des paramètres plats en introduisant nos propres analyseurs qui peuvent convertir des chaînes d'un certain type dans nos classes plus complexes. Un exemple d'une chaîne aussi complexe:

 <setting name="MCXSettings" serializeAs="String"> <value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value> </setting> 

Je n'ai pas envie de faire de telles transformations lorsque je travaille avec YAML. Mais en même temps, nous sommes limités par la structure «plate» existante de la classe AppSettings: toutes les propriétés des paramètres qu'elle contient sont empilées en un seul tas.

Deuxièmement, les configurations de nos serveurs ne sont pas un monolithe statique, nous les modifions de temps en temps au cours du travail du serveur, c'est-à-dire ces changements doivent pouvoir être détectés à la volée, lors de l'exécution. Pour ce faire, dans l'implémentation XML, nous héritons de nos AppSettings d'INotifyPropertyChanged (en fait, chaque interface qui implémente AppSettings en est héritée) et souscrivons à la mise à jour des événements de propriétés des paramètres. Cette approche fonctionne car la classe de base System.Configuration.ApplicationSettingsBase prête à l'emploi implémente INotifyPropertyChanged. Un comportement similaire doit être conservé après la transition vers YAML.

Troisièmement, nous n'avons pas réellement un fichier de configuration pour chaque serveur, mais deux autant: un avec les paramètres par défaut, l'autre avec les paramètres remplacés. Cela est nécessaire pour que, dans chacune des instances de serveurs du même type, écoutant des ports différents et ayant des paramètres légèrement différents, vous n'ayez pas à copier complètement l'ensemble des paramètres.

Et un autre problème - l'accès aux paramètres passe non seulement par des interfaces, mais également par un accès direct à AppSettings.Default. Permettez-moi de vous rappeler comment il est déclaré dans les coulisses AppSettings.Designer.cs:

 private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings()))); public static AppSettings Default { get { return defaultInstance; } } 

Sur la base de ce qui précède, il était nécessaire de trouver une nouvelle approche pour stocker les paramètres dans AppSettings.

Solution


Boîte à outils


Pour la lecture directe, YAML a décidé d'utiliser des bibliothèques prêtes à l'emploi disponibles via NuGet:

  • YamlDotNet - github.com/aaubry/YamlDotNet . D'après la description de la bibliothèque (traduction):
    YamlDotNet est la bibliothèque .NET pour YAML. YamlDotNet fournit un analyseur de bas niveau et un générateur YAML, ainsi qu'un modèle d'objet de haut niveau similaire à XmlDocument. Une bibliothèque de sérialisation vous permet également de lire et d'écrire des objets depuis / vers des flux YAML.

  • NetEscapades.Configuration - github.com/andrewlock/NetEscapades.Configuration . Il s'agit du fournisseur de configuration lui-même (au sens de Microsoft.Extensions.Configuration.IConfigurationSource, activement utilisé dans les applications ASP.NET Core), qui lit les fichiers YAML en utilisant uniquement celui mentionné ci-dessus YamlDotNet.

En savoir plus sur l'utilisation de ces bibliothèques ici .

Transition vers YAML


La transition elle-même a été effectuée en deux étapes: au début, nous sommes simplement passés de XML à YAML, mais en conservant une hiérarchie plate de fichiers de configuration, puis nous avons entré des sections dans des fichiers YAML. Ces étapes pourraient, en principe, être combinées en une seule, et pour des raisons de simplicité de présentation, je le ferai. Toutes les actions décrites ci-dessous ont été appliquées séquentiellement à chaque service.

Préparation d'un fichier YML


Vous devez d'abord préparer le fichier YAML lui-même. Nous l'appelons le nom du projet (utile pour les futurs tests d'intégration, qui devraient pouvoir travailler avec différents serveurs et distinguer leurs configurations entre eux), mettre le fichier directement à la racine du projet, à côté d'AppSettings:



Dans le fichier YML, pour commencer, enregistrons une structure «plate»:

 VirtualFeed: "MaxChartHistoryLength: 10, UseThrottling: True, ThrottlingIntervalMs: 50000, UseHistoryBroadcast: True, CalendarName: EmptyCalendar" VirtualFeedPort: 35016 UsMarketFeedUseImbalances: True 

Remplir AppSettings avec les propriétés des paramètres


Nous transférons toutes les propriétés d'AppSettings.Designer.cs à AppSettings.cs, en nous débarrassant simultanément des attributs superflus du concepteur et du code lui-même dans get / set-parts.

C'était:

 [global::System.Configuration.UserScopedSettingAttribute()] [global::System.Diagnostics.DebuggerNonUserCodeAttribute()] [global::System.Configuration.DefaultSettingValueAttribute("35016")] public int VirtualFeedPort{ get { return ((int)(this["VirtualFeedPort"])); } set { this["VirtualFeedPort"] = value; } } 

C'est devenu:

 public int VirtualFeedPort { get; set; } 

Nous supprimerons complètement AppSettings .Designer .cs comme inutile. Maintenant, en passant, vous pouvez complètement vous débarrasser de la section userSettings du fichier app.config, si elle se trouve dans le projet - les mêmes paramètres par défaut y sont stockés, que nous spécifions via le concepteur de paramètres.
Allez-y.

Contrôlez les paramètres à la volée


Étant donné que nous devons être en mesure de détecter les mises à jour de nos paramètres lors de l'exécution, nous devons implémenter INotifyPropertyChanged dans nos AppSettings. La base System.Configuration.ApplicationSettingsBase de base n'est plus là, respectivement, vous ne pouvez pas compter sur la magie.

Vous pouvez l'implémenter «sur le front»: en ajoutant une implémentation d'une méthode qui lance l'événement souhaité et en l'appelant dans le setter de chaque propriété. Mais ce sont des lignes de code supplémentaires, qui devront en outre être copiées sur tous les services.

Faisons plus beau - introduisez la classe de base auxiliaire AutoNotifier, qui fait en fait la même chose, mais en arrière-plan, tout comme System.Configuration.ApplicationSettingsBase l'a fait auparavant:

 /// <summary> /// Implements <see cref="INotifyPropertyChanged"/> for classes with a lot of public properties (ie AppSettings). /// This implementation is: /// - fairly slow, so don't use it for classes where getting/setting of properties is often operation; /// - not for properties described in inherited classes of 2nd level (bad idea: Inherit2 -> Inherit1 -> AutoNotifier; good idea: sealed Inherit -> AutoNotifier) /// </summary> public abstract class AutoNotifier : INotifyPropertyChanged { public event PropertyChangedEventHandler PropertyChanged; private readonly ConcurrentDictionary<string, object> _wrappedValues = new ConcurrentDictionary<string, object>(); //just to avoid manual writing a lot of fields protected T Get<T>([CallerMemberName] string propertyName = null) { return (T)_wrappedValues.GetValueOrDefault(propertyName, () => default(T)); } protected void Set<T>(T value, [CallerMemberName] string propertyName = null) { // ReSharper disable once AssignNullToNotNullAttribute _wrappedValues.AddOrUpdate(propertyName, value, (s, o) => value); OnPropertyChanged(propertyName); } public object this[string propertyName] { get { return Get<object>(propertyName); } set { Set(value, propertyName); } } protected void OnPropertyChanged([CallerMemberName] string propertyName = null) { PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)); } } 

Ici, l'attribut [CallerMemberName] vous permet d'obtenir automatiquement le nom de propriété de l'objet appelant, c'est-à-dire AppSettings

Maintenant, nous pouvons hériter nos AppSettings de cette classe de base AutoNotifier, puis chaque propriété est légèrement modifiée:

 public int VirtualFeedPort { get { return Get<int>(); } set { Set(value); } } 

Avec cette approche, nos classes AppSettings, contenant même pas mal de paramètres, ont l'air compactes et implémentent en même temps complètement INotifyPropertyChanged.

Oui, je sais qu'il serait possible d'introduire un peu plus de magie, en utilisant, par exemple, Castle.DynamicProxy.IInterceptor, en interceptant les modifications des propriétés nécessaires et en y provoquant des événements. Mais une telle décision me semblait trop surchargée.

Lecture des paramètres d'un fichier YAML


L'étape suivante consiste à ajouter le lecteur de la configuration YAML elle-même. Cela se produit quelque part plus près du début du service. En cachant des détails inutiles qui ne sont pas liés au sujet en discussion, nous obtenons quelque chose de similaire:

 public static IServerConfigurationProvider LoadServerConfiguration(IReadOnlyDictionary<Type, string> allSections) { IConfigurationBuilder builder = new ConfigurationBuilder().SetBasePath(ConfigFiles.BasePath); foreach (string configFile in configFiles) { string directory = Path.GetDirectoryName(configFile); if (!string.IsNullOrEmpty(directory)) //can be empty if relative path is used { Directory.CreateDirectory(directory); } builder = builder.AddYamlFile(configFile, optional: true, reloadOnChange: true); } IConfigurationRoot config = builder.Build(); // load prepared files and merge them return new ServerConfigurationProvider<TAppSettings>(config, allSections); } 

Dans le code présenté, ConfigurationBuilder n'est probablement pas d'un intérêt particulier - tout le travail avec celui-ci est similaire à l'utilisation des configurations dans ASP.NET Core. Mais les points suivants sont intéressants. Tout d'abord, «prêt à l'emploi», nous avons également eu la possibilité de combiner les paramètres de plusieurs fichiers. Cela fournit l'exigence d'avoir au moins deux fichiers de configuration par serveur, comme je l'ai mentionné ci-dessus. Deuxièmement, nous passons toute la configuration de lecture à un certain ServerConfigurationProvider. Pourquoi?

Sections du fichier YAML


Nous répondrons à cette question plus tard, et revenons maintenant à l'exigence de stocker les paramètres structurés hiérarchiquement dans un fichier YML.

En principe, sa mise en œuvre est assez simple. Tout d'abord, dans le fichier YML, nous introduisons la structure dont nous avons besoin:

 VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True 

Passons maintenant à AppSettings et apprenons-lui à diviser nos propriétés en sections. Quelque chose comme ça:

 public sealed class AppSettings : AutoNotifier, IWebSettingsStorage, IServerSettingsStorage, IServerManagerAddressStorage, IGlobalCredentialsStorage, IGraphiteAddressStorage, IDatabaseConfigStorage, IBlackListStorage, IKeyCloackConfigFilePathProvider, IPrometheusSettingsStorage, IHeartBeatConfig, IConcurrentAcceptorProperties, IMetricsConfig { public static IReadOnlyDictionary<Type, string> Sections { get; } = new Dictionary<Type, string> { {typeof(IDatabaseConfigStorage), "Database"}, {typeof(IWebSettingsStorage), "Web"}, {typeof(IServerSettingsStorage), "Server"}, {typeof(IConcurrentAcceptorProperties), "ConcurrentAcceptor"}, {typeof(IGraphiteAddressStorage), "Graphite"}, {typeof(IKeyCloackConfigFilePathProvider), "Keycloak"}, {typeof(IPrometheusSettingsStorage), "Prometheus"}, {typeof(IHeartBeatConfig), "Heartbeat"}, {typeof(IServerManagerAddressStorage), "ServerManager"}, {typeof(IGlobalCredentialsStorage), "GlobalCredentials"}, {typeof(IBlackListStorage), "Blacklist"}, {typeof(IMetricsConfig), "Metrics"} }; ... 

Comme vous pouvez le voir, nous avons ajouté un dictionnaire directement à AppSettings, où les clés sont les types d'interfaces que la classe AppSettings implémente, et les valeurs sont les en-têtes des sections correspondantes. Maintenant, nous pouvons comparer la hiérarchie dans le fichier YML avec la hiérarchie des propriétés dans AppSettings (bien que pas plus profond qu'un niveau d'imbrication, mais dans notre cas, cela suffisait).

Pourquoi faisons-nous cela ici - dans AppSettings? Parce que de cette façon, nous ne diffusons pas les informations sur les paramètres des différentes entités, et en plus, c'est l'endroit le plus naturel, car dans chaque service et, par conséquent, dans chaque AppSettings, sa propre section de paramètres.

Si vous n'avez pas besoin d'une hiérarchie dans les paramètres?


En principe, c'est un cas étrange, mais nous l'avons eu exactement au premier stade, lorsque nous sommes simplement passés de XML à YAML, sans utiliser les avantages de YAML.

Dans ce cas, cette liste entière de sections ne peut pas être stockée et ServerConfigurationProvider sera beaucoup plus simple (discuté plus loin).

Mais le point important est que si nous décidons de laisser une hiérarchie plate, nous pouvons simplement remplir l'exigence de maintenir la capacité d'accéder aux paramètres via AppSettings.Default. Pour ce faire, ajoutez ici un constructeur public aussi simple dans AppSettings:

 public static AppSettings Default { get; } public AppSettings() { Default = this; } 

Maintenant, nous pouvons continuer à accéder à la classe de paramètres partout via AppSettings.Default (à condition que les paramètres aient déjà été lus via IConfigurationRoot dans ServerConfigurationProvider et, en conséquence, AppSettings a été instancié).

Si une hiérarchie plate est inacceptable, alors, de toute façon, vous devez vous débarrasser d'AppSettings.Default partout par code et travailler avec des paramètres uniquement via des interfaces (ce qui est bon en principe). Pourquoi - cela deviendra clair plus loin.

ServerConfigurationProvider


La classe spéciale ServerConfigurationProvider mentionnée précédemment traite de la magie même qui vous permet de travailler pleinement avec la nouvelle configuration hiérarchique YAML avec uniquement un AppSettings plat.

Si vous ne pouvez pas attendre, le voici.

Code serveur complet de configuration du serveur
 /// <summary> /// Provides different configurations for current server /// </summary> public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider where TAppSettings : new() { private static readonly Logger Logger = LogManager.GetCurrentClassLogger(); private readonly IConfigurationRoot _configuration; private readonly IReadOnlyDictionary<Type, string> _sectionsByInterface; private readonly IReadOnlyDictionary<string, Type> _interfacesBySections; /// <summary> /// Section name -> config /// </summary> private readonly ConcurrentDictionary<string, TAppSettings> _cachedSections; public ServerConfigurationProvider(IConfigurationRoot configuration, IReadOnlyDictionary<Type, string> allSections) { _configuration = configuration; _cachedSections = new ConcurrentDictionary<string, TAppSettings>(); _sectionsByInterface = allSections; var interfacesBySections = new Dictionary<string, Type>(); foreach (KeyValuePair<Type, string> interfaceAndSection in _sectionsByInterface) { //section names must be unique interfacesBySections.Add(interfaceAndSection.Value, interfaceAndSection.Key); } _interfacesBySections = interfacesBySections; _configuration.GetReloadToken()?.RegisterChangeCallback(OnConfigurationFileChanged, null); } private void OnConfigurationFileChanged(object _) { UpdateCache(); } private void UpdateCache() { foreach (string sectionName in _cachedSections.Keys) { Type sectionInterface = _interfacesBySections[sectionName]; TAppSettings newSection = ReadSection(sectionName, sectionInterface); TAppSettings oldSection; if (_cachedSections.TryGetValue(sectionName, out oldSection)) { UpdateSection(oldSection, newSection); } } } private void UpdateSection(TAppSettings oldConfig, TAppSettings newConfig) { foreach (PropertyInfo propertyInfo in typeof(TAppSettings).GetProperties().Where(p => p.GetMethod != null && p.SetMethod != null)) { propertyInfo.SetValue(newConfig, propertyInfo.GetValue(oldConfig)); } } public IEnumerable<Type> AllSections => _sectionsByInterface.Keys; public TSettingsSectionInterface FindSection<TSettingsSectionInterface>() where TSettingsSectionInterface : class { return (TSettingsSectionInterface)FindSection(typeof(TSettingsSectionInterface)); } [CanBeNull] public object FindSection(Type sectionInterface) { string sectionName = FindSectionName(sectionInterface); if (sectionName == null) { return null; } //we must return same instance of settings for same requested section (otherwise changing of settings will lead to inconsistent state) return _cachedSections.GetOrAdd(sectionName, typeName => ReadSection(sectionName, sectionInterface)); } private string FindSectionName(Type sectionInterface) { string sectionName; if (!_sectionsByInterface.TryGetValue(sectionInterface, out sectionName)) { Logger.Debug("This server doesn't contain settings for {0}", sectionInterface.FullName); return null; } return sectionName; } private TAppSettings ReadSection(string sectionName, Type sectionInterface) { TAppSettings parsed; try { IConfigurationSection section = _configuration.GetSection(sectionName); CheckSection(section, sectionName, sectionInterface); parsed = section.Get<TAppSettings>(); if (parsed == null) { //means that this section is empty or all its properties are empty return new TAppSettings(); } ReadArrays(parsed, section); } catch (Exception ex) { Logger.Fatal(ex, "Something wrong during reading section {0} in config", sectionName.SafeSurround()); throw; } return parsed; } /// <summary> /// Manual reading of array properties in config /// </summary> private void ReadArrays(TAppSettings settings, IConfigurationSection section) { foreach (PropertyInfo propertyInfo in GetPublicProperties(typeof(TAppSettings), needSetters: true).Where(p => typeof(IEnumerable<string>).IsAssignableFrom(p.PropertyType))) { ClearDefaultArrayIfOverridenExists(section.Key, propertyInfo.Name); IConfigurationSection enumerableProperty = section.GetSection(propertyInfo.Name); propertyInfo.SetValue(settings, enumerableProperty.Get<IEnumerable<string>>()); } } /// <summary> /// Clears array property from default config to use overriden one. /// Standard implementation merges default and overriden array by indexes - this is not what we need /// </summary> private void ClearDefaultArrayIfOverridenExists(string sectionName, string propertyName) { List<IConfigurationProvider> providers = _configuration.Providers.ToList(); if (providers.Count == 0) { return; } string propertyTemplate = $"{sectionName}:{propertyName}:"; if (!providers[providers.Count - 1].TryGet($"{propertyTemplate}{0}", out _)) { //we should use array from default config, because overriden config has no overriden array return; } foreach (IConfigurationProvider provider in providers.Take(providers.Count - 1)) { for (int i = 0; ; i++) { string propertyInnerName = $"{propertyTemplate}{i}"; if (!provider.TryGet(propertyInnerName, out _)) { break; } provider.Set(propertyInnerName, null); } } } private void CheckSection(IConfigurationSection section, string sectionName, Type sectionInterface) { ICollection<PropertyInfo> properties = GetPublicProperties(sectionInterface, needSetters: false); var configProperties = new HashSet<string>(section.GetChildren().Select(c => c.Key)); foreach (PropertyInfo propertyInfo in properties) { if (!configProperties.Remove(propertyInfo.Name)) { if (propertyInfo.PropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(propertyInfo.PropertyType)) { //no way to distinguish absent array and empty array :( Logger.Debug("Property {0} has no valuable items in configs section {1}", propertyInfo.Name, sectionName.SafeSurround()); } else { Logger.Fatal("Property {0} not found in configs section {1}", propertyInfo.Name, sectionName.SafeSurround()); } } } if (configProperties.Any()) { Logger.Fatal("Unexpected config properties {0} in configs section {1}", configProperties.SafeSurroundAndJoin(), sectionName.SafeSurround()); } } private static ICollection<PropertyInfo> GetPublicProperties(Type type, bool needSetters) { if (!type.IsInterface) { return type.GetProperties().Where(x => x.GetMethod != null && (!needSetters || x.SetMethod != null)).ToArray(); } var propertyInfos = new List<PropertyInfo>(); var considered = new List<Type>(); var queue = new Queue<Type>(); considered.Add(type); queue.Enqueue(type); while (queue.Count > 0) { Type subType = queue.Dequeue(); foreach (Type subInterface in subType.GetInterfaces()) { if (considered.Contains(subInterface)) { continue; } considered.Add(subInterface); queue.Enqueue(subInterface); } PropertyInfo[] typeProperties = subType.GetProperties(BindingFlags.FlattenHierarchy | BindingFlags.Public | BindingFlags.Instance); IEnumerable<PropertyInfo> newPropertyInfos = typeProperties.Where(x => x.GetMethod != null && (!needSetters || x.SetMethod != null) && !propertyInfos.Contains(x)); propertyInfos.InsertRange(0, newPropertyInfos); } return propertyInfos; } } 


ServerConfigurationProvider est paramétré par la classe de paramètres AppSettings:
 public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider where TAppSettings : new() 

Comme vous pouvez le deviner, cela vous permet de l'utiliser immédiatement dans tous les services.

La configuration de lecture elle-même (IConfigurationRoot), ainsi que le dictionnaire de sections mentionné ci-dessus (AppSettings.Sections) sont passés au constructeur. Il y a un abonnement aux mises à jour de fichiers (voulons-nous nous retirer immédiatement ces modifications en cas de modification du fichier YML?):

 _configuration.GetReloadToken()?.RegisterChangeCallback(OnConfigurationFileChanged, null); ... private void OnConfigurationFileChanged(object _) { foreach (string sectionName in _cachedSections.Keys) { Type sectionInterface = _interfacesBySections[sectionName]; TAppSettings newSection = ReadSection(sectionName, sectionInterface); TAppSettings oldSection; if (_cachedSections.TryGetValue(sectionName, out oldSection)) { UpdateSection(oldSection, newSection); } } } 

Comme vous pouvez le voir, ici, en cas de mise à jour du fichier YML, nous parcourons toutes les sections que nous connaissons et lisons chacune. Ensuite, si la section a déjà été lue plus tôt dans le cache (c'est-à-dire qu'elle a déjà été demandée quelque part dans le code par une classe), alors nous réécrivons les anciennes valeurs dans le cache avec de nouvelles.

Il semblerait - pourquoi lire chaque section, pourquoi ne pas lire uniquement celles qui sont dans le cache (c'est-à-dire demandées)? Parce qu'en lisant la section, nous avons implémenté une vérification de la configuration correcte. Et en cas de paramètres incorrects, les alertes correspondantes sont lancées, les problèmes sont enregistrés. Il est préférable de se renseigner dès que possible sur les problèmes de modifications de configuration, à partir desquels nous lisons immédiatement toutes les sections.

La mise à jour des anciennes valeurs dans le cache avec de nouvelles valeurs est assez triviale:

 private void UpdateSection(TAppSettings oldConfig, TAppSettings newConfig) { foreach (PropertyInfo propertyInfo in typeof(TAppSettings).GetProperties().Where(p => p.GetMethod != null && p.SetMethod != null)) { propertyInfo.SetValue(newConfig, propertyInfo.GetValue(oldConfig)); } } 

Mais la lecture des sections n'est pas si simple:

 private TAppSettings ReadSection(string sectionName, Type sectionInterface) { TAppSettings parsed; try { IConfigurationSection section = _configuration.GetSection(sectionName); CheckSection(section, sectionName, sectionInterface); parsed = section.Get<TAppSettings>(); if (parsed == null) { //means that this section is empty or all its properties are empty return new TAppSettings(); } ReadArrays(parsed, section); } catch (Exception ex) { Logger.Fatal(ex, "Something wrong during reading section {0} in config", sectionName.SafeSurround()); throw; } return parsed; } 

Ici, nous lisons tout d'abord la section elle-même en utilisant le standard IConfigurationRoot.GetSection.Vérifiez ensuite l'exactitude de la section de lecture.

Ensuite, nous lisons la section bindim pour le type de nos paramètres: section.Get Ici, nous rencontrons une fonctionnalité de l'analyseur YAML - il ne fait pas de distinction entre une section vide (sans paramètres, c'est-à-dire absente) d'une section dans laquelle tous les paramètres sont vides.

Voici un cas similaire:

 VirtualFeed: Names: [] 

Ici, dans la section VirtualFeed, il y a un paramètre Noms avec une liste de valeurs vide, mais l'analyseur YAML, malheureusement, dira que la section VirtualFeed est généralement complètement vide. C'est triste.

Et enfin, dans cette méthode, un peu de magie de rue est implémentée pour prendre en charge les propriétés IEnumerable dans les paramètres. Nous n'avons pas réussi à obtenir une lecture normale des listes «prêtes à l'emploi».

 ReadArrays(parsed, section); ... /// <summary> /// Manual reading of array properties in config /// </summary> private void ReadArrays(TAppSettings settings, IConfigurationSection section) { foreach (PropertyInfo propertyInfo in GetPublicProperties(typeof(TAppSettings), needSetters: true).Where(p => typeof(IEnumerable<string>).IsAssignableFrom(p.PropertyType))) { ClearDefaultArrayIfOverridenExists(section.Key, propertyInfo.Name); IConfigurationSection enumerableProperty = section.GetSection(propertyInfo.Name); propertyInfo.SetValue(settings, enumerableProperty.Get<IEnumerable<string>>()); } } /// <summary> /// Clears array property from default config to use overriden one. /// Standard implementation merges default and overriden array by indexes - this is not what we need /// </summary> private void ClearDefaultArrayIfOverridenExists(string sectionName, string propertyName) { List<IConfigurationProvider> providers = _configuration.Providers.ToList(); if (providers.Count == 0) { return; } string propertyTemplate = $"{sectionName}:{propertyName}:"; if (!providers[providers.Count - 1].TryGet($"{propertyTemplate}{0}", out _)) { //we should use array from default config, because overriden config has no overriden array return; } foreach (IConfigurationProvider provider in providers.Take(providers.Count - 1)) { for (int i = 0; ; i++) { string propertyInnerName = $"{propertyTemplate}{i}"; if (!provider.TryGet(propertyInnerName, out _)) { break; } provider.Set(propertyInnerName, null); } } } 

Comme vous pouvez le voir, nous trouvons toutes les propriétés dont le type est hérité de IEnumerable et leur attribuons des valeurs à partir de la "section" fictive, également nommée comme paramètre qui nous intéresse. Mais avant cela, n'oubliez pas de vérifier: existe-t-il une valeur redéfinie de cette propriété énumérée dans le deuxième fichier de configuration? Si tel est le cas, nous le prenons uniquement et nous effaçons les paramètres lus dans le fichier de configuration de base. Si cela n'est pas fait, les deux propriétés (du fichier de base et du fichier remplacé) seront automatiquement fusionnées en un seul tableau au niveau de IConfigurationSection, et les indices du tableau serviront de clés pour la combinaison. Cela entraînera une sorte de hachage au lieu de la valeur remplacée normale.

La méthode ReadSection présentée est finalement utilisée dans la méthode principale de la classe: FindSection.

 [CanBeNull] public object FindSection(Type sectionInterface) { string sectionName = FindSectionName(sectionInterface); if (sectionName == null) { return null; } //we must return same instance of settings for same requested section (otherwise changing of settings will lead to inconsistent state) return _cachedSections.GetOrAdd(sectionName, typeName => ReadSection(sectionName, sectionInterface)); } 

En principe, il devient clair pourquoi, avec le soutien des sections, nous ne pouvons pas prendre en charge AppSettings.Default de quelque manière que ce soit: chaque accès à une nouvelle section de paramètres (précédemment non lue) via FindSection nous donnera en fait une nouvelle instance de la classe AppSettings, bien qu'elle soit attachée à l'interface souhaitée et, par conséquent, si nous utilisions AppSettings.Default, il serait redéfini chaque fois qu'une nouvelle section était lue et ne contiendrait que les paramètres qui appartiennent à la dernière section lue (le reste aurait des valeurs par défaut - NULL et 0).

La validation des paramètres dans la section est implémentée comme suit:

 private void CheckSection(IConfigurationSection section, string sectionName, Type sectionInterface) { ICollection<PropertyInfo> properties = GetPublicProperties(sectionInterface, needSetters: false); var configProperties = new HashSet<string>(section.GetChildren().Select(c => c.Key)); foreach (PropertyInfo propertyInfo in properties) { if (!configProperties.Remove(propertyInfo.Name)) { if (propertyInfo.PropertyType != typeof(string) && typeof(IEnumerable).IsAssignableFrom(propertyInfo.PropertyType)) { //no way to distinguish absent array and empty array :( Logger.Debug("Property {0} has no valuable items in configs section {1}", propertyInfo.Name, sectionName.SafeSurround()); } else { Logger.Fatal("Property {0} not found in configs section {1}", propertyInfo.Name, sectionName.SafeSurround()); } } } if (configProperties.Any()) { Logger.Fatal("Unexpected config properties {0} in configs section {1}", configProperties.SafeSurroundAndJoin(), sectionName.SafeSurround()); } } 

Ici, tout d'abord, toutes les propriétés publiques de l'interface qui nous intéressent sont extraites (lecture - sections paramètres). Et pour chacune de ces propriétés, une correspondance est trouvée dans les paramètres de lecture: si aucune correspondance n'est trouvée, le problème correspondant est enregistré, car cela signifie qu'une configuration est manquante dans le fichier de configuration. À la fin, il est en outre vérifié si l'un des paramètres de lecture est resté sans correspondance avec l'interface. S'il y en a, le problème est à nouveau enregistré, car cela signifie que les propriétés qui ne sont pas décrites dans l'interface ont été trouvées dans le fichier de configuration, qui ne devraient pas non plus se trouver dans une situation normale.

La question se pose - d'où vient l'exigence que, dans le fichier lu, tous les paramètres doivent correspondre à ceux disponibles dans l'interface sur une base individuelle? Le fait est qu'en fait, comme mentionné ci-dessus, à ce moment-là, aucun fichier n'a été lu, mais deux à la fois - l'un avec les paramètres par défaut et l'autre avec les paramètres remplacés, et les deux sont contigus. En conséquence, en fait, nous ne regardons pas les paramètres d'un fichier, mais ceux complets. Et dans ce cas, bien sûr, leur ensemble doit correspondre aux paramètres attendus un à un.

Faites également attention dans les sources ci-dessus à la méthode GetPublicProperties, qui, semble-t-il, ne renvoie que toutes les propriétés publiques de l'interface. Mais ce n'est pas aussi simple que cela pourrait l'être, car parfois nous avons une interface qui décrit les paramètres du serveur hérité d'une autre interface, et, en conséquence, il est nécessaire de regarder toute la hiérarchie des interfaces afin de trouver toutes les propriétés publiques.

Obtention des paramètres du serveur


Sur la base de ce qui précède, pour obtenir les paramètres du serveur partout par code, nous nous tournons vers l'interface suivante:

 /// <summary> /// Provides different configurations for current server /// </summary> public interface IServerConfigurationProvider { TSettingsSectionInterface FindSection<TSettingsSectionInterface>() where TSettingsSectionInterface : class; object FindSection(Type sectionInterface); IEnumerable<Type> AllSections { get; } } 

La première méthode de cette interface - FindSection - vous permet d'accéder à la section des paramètres qui vous intéresse. Quelque chose comme ça:

 IThreadPoolProperties threadPoolProperties = ConfigurationProvider.FindSection<IThreadPoolProperties>(); 

Pourquoi les deuxième et troisième méthodes sont nécessaires - je vais expliquer plus en détail.

Enregistrement des interfaces de paramétrage


Dans notre projet, Castle Windsor est utilisé comme conteneur IoC. C'est lui qui fournit, y compris les interfaces des paramètres du serveur. En conséquence, ces interfaces doivent y être enregistrées.

Dans ce but, une simple classe Extension a été écrite qui simplifie cette procédure afin de ne pas écrire l'enregistrement de l'ensemble des interfaces dans chaque serveur:

 public static class ServerConfigurationProviderExtensions { public static void RegisterAllConfigurationSections(this IWindsorContainer container, IServerConfigurationProvider configurationProvider) { Register(container, configurationProvider, configurationProvider.AllSections.ToArray()); } public static void Register(this IWindsorContainer container, IServerConfigurationProvider configurationProvider, params Type[] configSections) { var registrations = new IRegistration[configSections.Length]; for (int i = 0; i < registrations.Length; i++) { Type configSection = configSections[i]; object section = configurationProvider.FindSection(configSection); registrations[i] = Component.For(configSection).Instance(section).Named(configSection.FullName); } container.Register(registrations); } } 

La première méthode vous permet d'enregistrer toutes les sections de paramètres (pour cela, vous avez besoin de la propriété AllSections dans l'interface IServerConfigurationProvider).

Et la deuxième méthode est utilisée dans la première, et elle lit automatiquement la section des paramètres spécifiés à l'aide de notre ServerConfigurationProvider, l'écrivant ainsi immédiatement dans le cache ServerConfigurationProvider et l'enregistrant à Windsor.
C'est ici que la deuxième méthode FindSection, non paramétrée, de IServerConfigurationProvider est utilisée.

Il ne reste plus qu'à appeler notre méthode d'extension dans le code d'enregistrement des conteneurs de Windsor:

 container.RegisterAllConfigurationSections(configProvider); 

Conclusion


Qu'est-il arrivé?


De la manière présentée, il a été possible de transférer assez facilement tous les paramètres de nos serveurs de XML vers YAML, tout en apportant un minimum de modifications au code de serveur existant.

Contrairement aux XML, les configurations YAML se sont avérées plus lisibles en raison non seulement d'une plus grande concision, mais également de la prise en charge du partitionnement.

Nous n'avons pas inventé nos propres vélos pour analyser YAML, mais avons utilisé des solutions toutes faites. Cependant, pour les intégrer dans les réalités de notre projet, certaines des astuces décrites dans cet article étaient nécessaires. J'espère qu'ils seront utiles aux lecteurs.

Il a été possible de conserver la possibilité de détecter les changements de paramètres dans les museaux Web de nos serveurs à la volée. De plus, le bonus a également permis de capturer les changements dans le fichier YAML lui-même à la volée (auparavant, il était nécessaire de redémarrer le serveur pour tout changement dans les fichiers de configuration).

Nous avons conservé la possibilité de fusionner deux fichiers de configuration - les paramètres par défaut et remplacés, et nous l'avons fait en utilisant des solutions tierces prêtes à l'emploi.

Ce qui n'a pas très bien fonctionné


J'ai dû abandonner la possibilité précédemment disponible d'enregistrer les modifications appliquées à partir des faces Web de nos serveurs dans les fichiers de configuration, car la prise en charge d'une telle fonctionnalité nécessiterait de grands gestes, et la tâche commerciale qui nous attendait en général n'était pas telle.

Eh bien, j'ai également dû refuser l'accès aux paramètres via AppSettings.Default, mais c'est plus un avantage qu'un inconvénient.

Source: https://habr.com/ru/post/fr438362/


All Articles