Hintergrund
Unser Unternehmen hat unter anderem mehrere Services (genauer gesagt - 12) entwickelt, die als Backend unserer Systeme dienen. Jeder der Dienste ist ein Windows-Dienst und führt seine spezifischen Aufgaben aus.
Ich möchte alle diese Dienste auf * nix-OS übertragen. Verlassen Sie dazu den Wrapper in Form von Windows-Diensten und wechseln Sie von .NET Framework zu .NET Standard.
Die letzte Anforderung führt dazu, dass Legacy-Code entfernt werden muss, der in .NET Standard nicht unterstützt wird, einschließlich von der Unterstützung für die Konfiguration unserer Server über XML, implementiert mit Klassen von System.Configuration. Gleichzeitig wird das seit langem bestehende Problem behoben, das darin besteht, dass wir in XML-Konfigurationen von Zeit zu Zeit Fehler beim Ändern von Einstellungen gemacht haben (z. B. manchmal haben wir das schließende Tag an der falschen Stelle platziert oder es überhaupt vergessen), aber ein wunderbarer Leser von System.Xml-XML-Konfigurationen. XmlDocument verschluckt solche Konfigurationen stillschweigend und führt zu einem völlig unvorhersehbaren Ergebnis.
Es wurde beschlossen, über die trendige YAML auf Konfiguration umzusteigen. Welche Probleme hatten wir und wie haben wir sie gelöst? In diesem Artikel.
Was haben wir?
Wie lesen wir die Konfiguration aus XML?
Bei den meisten anderen Projekten lesen wir XML auf standardmäßige Weise.
Jeder Dienst verfügt über eine Einstellungsdatei für .NET-Projekte mit dem Namen AppSettings.cs, die alle für den Dienst erforderlichen Einstellungen enthält. Ungefähr so:
[System.Configuration.SettingsProvider(typeof(PortableSettingsProvider))] internal sealed partial class AppSettings : IServerManagerConfigStorage, IWebSettingsStorage, IServerSettingsStorage, IGraphiteAddressStorage, IDatabaseConfigStorage, IBlackListStorage, IKeyCloackConfigFilePathProvider, IPrometheusSettingsStorage, IMetricsConfig { }
Eine ähnliche Technik zum Trennen von Einstellungen in Schnittstellen macht es bequem, sie später über einen DI-Container zu verwenden.
Die ganze Magie des Speicherns von Einstellungen ist in PortableSettingsProvider (siehe Klassenattribut) sowie in der Designer-Datei AppSettings.Designer.cs verborgen:
[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; } } ...
Wie Sie sehen können, sind hinter den Kulissen alle Eigenschaften verborgen, die wir der Serverkonfiguration hinzufügen, wenn wir sie über den Einstellungsdesigner in Visual Studio bearbeiten.
Unsere oben erwähnte PortableSettingsProvider-Klasse liest die XML-Datei direkt, und das Leseergebnis wird bereits in SettingsProvider verwendet, um Einstellungen in die AppSettings-Eigenschaften zu schreiben.
Ein Beispiel für die XML-Konfiguration, die wir lesen (die meisten Einstellungen sind aus Sicherheitsgründen ausgeblendet):
<?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>
Welche YAML-Dateien möchte ich lesen
So etwas wie das:
VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True
Übergangsprobleme
Erstens sind die Konfigurationen in XML "flach", in YAML jedoch nicht (Abschnitte und Unterabschnitte werden unterstützt). Dies ist in den obigen Beispielen deutlich zu sehen. Mit XML haben wir das Problem der flachen Einstellungen gelöst, indem wir eigene Parser eingeführt haben, die Zeichenfolgen eines bestimmten Typs in unsere komplexeren Klassen konvertieren können. Ein Beispiel für eine solch komplexe Zeichenfolge:
<setting name="MCXSettings" serializeAs="String"> <value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value> </setting>
Ich habe keine Lust, solche Transformationen durchzuführen, wenn ich mit YAML arbeite. Gleichzeitig sind wir jedoch durch die vorhandene „flache“ Struktur der AppSettings-Klasse eingeschränkt: Alle Eigenschaften der darin enthaltenen Einstellungen sind in einem Heap zusammengefasst.
Zweitens sind die Konfigurationen unserer Server kein statischer Monolith, wir ändern sie von Zeit zu Zeit direkt im Verlauf der Serverarbeit, d. H. Diese Änderungen müssen in der Lage sein, zur Laufzeit im laufenden Betrieb zu erfassen. Zu diesem Zweck erben wir in der XML-Implementierung unsere AppSettings von INotifyPropertyChanged (tatsächlich wird jede Schnittstelle, die AppSettings implementiert, davon geerbt) und abonnieren die Aktualisierung der Ereignisse der Einstellungseigenschaften. Dieser Ansatz funktioniert, weil die Standardklasse System.Configuration.ApplicationSettingsBase standardmäßig INotifyPropertyChanged implementiert. Ein ähnliches Verhalten muss nach dem Übergang zu YAML beibehalten werden.
Drittens haben wir nicht eine Konfigurationsdatei für jeden Server, sondern zwei so viele: eine mit Standardeinstellungen, die andere mit überschriebenen. Dies ist erforderlich, damit Sie in mehreren Instanzen von Servern desselben Typs, die unterschiedliche Ports abhören und leicht unterschiedliche Einstellungen haben, nicht den gesamten Satz von Einstellungen vollständig kopieren müssen.
Und noch ein Problem : Der Zugriff auf die Einstellungen erfolgt nicht nur über Schnittstellen, sondern auch über den direkten Zugriff auf AppSettings.Default. Ich möchte Sie daran erinnern, wie es in den Backstage-AppSettings.Designer.cs deklariert ist:
private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings()))); public static AppSettings Default { get { return defaultInstance; } }
Basierend auf dem Vorstehenden war es notwendig, einen neuen Ansatz zum Speichern von Einstellungen in AppSettings zu entwickeln.
Lösung
Toolkit
Für das direkte Lesen entschied sich YAML, vorgefertigte Bibliotheken zu verwenden, die über NuGet erhältlich sind:
- YamlDotNet - github.com/aaubry/YamlDotNet . Aus der Bibliotheksbeschreibung (Übersetzung):
YamlDotNet ist die .NET-Bibliothek für YAML. YamlDotNet bietet einen Low-Level-Parser und einen YAML-Generator sowie ein High-Level-Objektmodell ähnlich XmlDocument. Ebenfalls enthalten ist eine Serialisierungsbibliothek, mit der Sie Objekte von / zu YAML-Streams lesen und schreiben können.
- NetEscapades.Configuration - github.com/andrewlock/NetEscapades.Configuration . Dies ist der Konfigurationsanbieter selbst (im Sinne von Microsoft.Extensions.Configuration.IConfigurationSource, der in ASP.NET Core-Anwendungen aktiv verwendet wird), der YAML-Dateien nur mit dem oben genannten YamlDotNet liest.
Lesen Sie hier mehr über die Verwendung dieser Bibliotheken.
Übergang zu YAML
Der Übergang selbst wurde in zwei Schritten durchgeführt: Zuerst haben wir einfach von XML zu YAML gewechselt, aber eine flache Hierarchie von Konfigurationsdateien beibehalten, und dann haben wir Abschnitte in YAML-Dateien eingegeben. Diese Phasen könnten im Prinzip zu einer zusammengefasst werden, und der Einfachheit halber werde ich genau das tun. Alle unten beschriebenen Aktionen wurden nacheinander auf jeden Dienst angewendet.
Vorbereiten einer YML-Datei
Zuerst müssen Sie die YAML-Datei selbst vorbereiten. Wir nennen es den Namen des Projekts (nützlich für zukünftige Integrationstests, die in der Lage sein sollten, mit verschiedenen Servern zu arbeiten und deren Konfigurationen voneinander zu unterscheiden). Legen Sie die Datei direkt im Stammverzeichnis des Projekts neben AppSettings ab:

Speichern wir zunächst in der YML-Datei eine „flache“ Struktur:
VirtualFeed: "MaxChartHistoryLength: 10, UseThrottling: True, ThrottlingIntervalMs: 50000, UseHistoryBroadcast: True, CalendarName: EmptyCalendar" VirtualFeedPort: 35016 UsMarketFeedUseImbalances: True
AppSettings mit Einstellungen füllen
Wir übertragen alle Eigenschaften von AppSettings.Designer.cs auf AppSettings.cs und entfernen gleichzeitig die überflüssigen Attribute des Designers und den Code selbst in get / set-parts.
Es war:
[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; } }
Es wurde:
public int VirtualFeedPort { get; set; }
Wir werden AppSettings
.Designer .cs als unnötig entfernen. Übrigens können Sie jetzt den Abschnitt userSettings in der Datei app.config vollständig entfernen, wenn er sich im Projekt befindet. Dort werden dieselben Standardeinstellungen gespeichert, die wir über den Einstellungsdesigner festlegen.
Mach weiter.
Steuerungseinstellungen im laufenden Betrieb
Da wir in der Lage sein müssen, Aktualisierungen unserer Einstellungen zur Laufzeit abzufangen, müssen wir INotifyPropertyChanged in unseren AppSettings implementieren. Die Basis System.Configuration.ApplicationSettingsBase ist nicht mehr vorhanden. Sie können sich nicht auf Magie verlassen.
Sie können es "auf der Stirn" implementieren: indem Sie eine Implementierung einer Methode hinzufügen, die das gewünschte Ereignis auslöst, und es im Setter jeder Eigenschaft aufrufen. Dies sind jedoch zusätzliche Codezeilen, die zusätzlich über alle Dienste hinweg kopiert werden müssen.
Machen wir es schöner - stellen Sie die zusätzliche Basisklasse AutoNotifier vor, die tatsächlich dasselbe tut, jedoch hinter den Kulissen, genau wie zuvor System.Configuration.ApplicationSettingsBase:
Hier können Sie mit dem Attribut [CallerMemberName] automatisch den Eigenschaftsnamen des aufrufenden Objekts abrufen, d. H. AppSettings
Jetzt können wir unsere AppSettings von dieser Basisklasse AutoNotifier erben, und dann wird jede Eigenschaft leicht geändert:
public int VirtualFeedPort { get { return Get<int>(); } set { Set(value); } }
Mit diesem Ansatz sehen unsere AppSettings-Klassen, die sogar viele Einstellungen enthalten, kompakt aus und implementieren gleichzeitig INotifyPropertyChanged vollständig.
Ja, ich weiß, dass es möglich wäre, etwas mehr Magie einzuführen, indem Sie beispielsweise Castle.DynamicProxy.IInterceptor verwenden, Änderungen an den erforderlichen Eigenschaften abfangen und dort Ereignisse auslösen. Aber eine solche Entscheidung schien mir zu überladen.
Lesen von Einstellungen aus einer YAML-Datei
Der nächste Schritt besteht darin, den Leser der YAML-Konfiguration selbst hinzuzufügen. Dies geschieht irgendwo näher am Start des Dienstes. Wenn wir unnötige Details verbergen, die nicht mit dem diskutierten Thema zusammenhängen, erhalten wir etwas Ähnliches:
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))
In dem vorgestellten Code ist ConfigurationBuilder wahrscheinlich nicht von besonderem Interesse - alle Arbeiten damit ähneln der Arbeit mit Konfigurationen in ASP.NET Core. Die folgenden Punkte sind jedoch von Interesse. Erstens hatten wir "out of the box" auch die Möglichkeit, Einstellungen aus mehreren Dateien zu kombinieren. Dies setzt voraus, dass mindestens zwei Konfigurationsdateien pro Server vorhanden sind, wie oben erwähnt. Zweitens übergeben wir die gesamte Lesekonfiguration an einen bestimmten ServerConfigurationProvider. Warum?
Abschnitte in der YAML-Datei
Wir werden diese Frage später beantworten und nun zu der Anforderung zurückkehren, hierarchisch strukturierte Einstellungen in einer YML-Datei zu speichern.
Die Implementierung ist im Prinzip recht einfach. Zunächst stellen wir in der YML-Datei die Struktur vor, die wir benötigen:
VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True
Gehen wir jetzt zu AppSettings und bringen ihm bei, wie wir unsere Eigenschaften in Abschnitte unterteilen können. Irgendwie so:
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"} }; ...
Wie Sie sehen können, haben wir AppSettings direkt ein Wörterbuch hinzugefügt, wobei die Schlüssel die Arten von Schnittstellen sind, die von der AppSettings-Klasse implementiert werden, und die Werte die Überschriften der entsprechenden Abschnitte sind. Jetzt können wir die Hierarchie in der YML-Datei mit der Hierarchie der Eigenschaften in AppSettings vergleichen (obwohl nicht tiefer als eine Verschachtelungsebene, aber in unserem Fall war dies ausreichend).
Warum machen wir das hier - in AppSettings? Weil wir auf diese Weise die Informationen über die Einstellungen für verschiedene Entitäten nicht verbreiten und dies außerdem der natürlichste Ort ist, weil in jedem Dienst und dementsprechend in jedem AppSettings einen eigenen Einstellungsbereich.
Wenn Sie keine Hierarchie in den Einstellungen benötigen?
Im Prinzip ist es ein seltsamer Fall, aber wir hatten ihn genau in der ersten Phase, als wir einfach von XML zu YAML wechselten, ohne die Vorteile von YAML zu nutzen.
In diesem Fall kann nicht die gesamte Liste der Abschnitte gespeichert werden, und ServerConfigurationProvider ist viel einfacher (wird später erläutert).
Der wichtige Punkt ist jedoch, dass wir, wenn wir uns entscheiden, eine flache Hierarchie zu verlassen, nur die Anforderung erfüllen können, den Zugriff auf Einstellungen über AppSettings.Default aufrechtzuerhalten. Fügen Sie dazu hier einen so einfachen öffentlichen Konstruktor in AppSettings hinzu:
public static AppSettings Default { get; } public AppSettings() { Default = this; }
Jetzt können wir weiterhin überall über AppSettings.Default auf die Einstellungsklasse zugreifen (vorausgesetzt, die Einstellungen wurden bereits über IConfigurationRoot in ServerConfigurationProvider gelesen und AppSettings wurde entsprechend instanziiert).
Wenn eine flache Hierarchie nicht akzeptabel ist, müssen Sie AppSettings ohnehin loswerden. Standardmäßig überall per Code und mit Einstellungen nur über Schnittstellen arbeiten (was im Prinzip gut ist). Warum so - es wird weiter klar.
ServerConfigurationProvider
Die zuvor erwähnte spezielle ServerConfigurationProvider-Klasse befasst sich mit der Magie, die es Ihnen ermöglicht, mit der neuen hierarchischen YAML-Konfiguration mit nur flachen AppSettings vollständig zu arbeiten.
Wenn Sie nicht warten können, ist es hier.
Vollständiger ServerConfigurationProvider-Code ServerConfigurationProvider wird von der AppSettings-Einstellungsklasse parametrisiert:
public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider where TAppSettings : new()
Auf diese Weise können Sie es, wie Sie vielleicht vermuten, sofort in allen Diensten verwenden.
Die Lesekonfiguration selbst (IConfigurationRoot) sowie das oben erwähnte Abschnittswörterbuch (AppSettings.Sections) werden an den Konstruktor übergeben. Es gibt ein Abonnement für Dateiaktualisierungen (möchten wir diese Änderungen im Falle einer Änderung der YML-Datei sofort auf uns übertragen?):
_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); } } }
Wie Sie hier sehen können, gehen wir beim Aktualisieren der YML-Datei alle uns bekannten Abschnitte durch und lesen sie jeweils. Wenn der Abschnitt dann bereits früher im Cache gelesen wurde (d. H. Von einer Klasse bereits irgendwo im Code angefordert wurde), schreiben wir die alten Werte im Cache mit neuen um.
Es scheint - warum jeden Abschnitt lesen, warum nicht nur diejenigen lesen, die sich im Cache befinden (d. H. Gefordert werden)? Weil wir beim Lesen des Abschnitts eine Überprüfung auf die richtige Konfiguration implementiert haben. Und bei falschen Einstellungen werden die entsprechenden Warnungen ausgegeben und Probleme protokolliert. Es ist besser, sich so schnell wie möglich über Probleme bei den Konfigurationsänderungen zu informieren, aus denen wir sofort alle Abschnitte lesen.
Das Aktualisieren alter Werte im Cache mit neuen Werten ist trivial genug:
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)); } }
Das Lesen von Abschnitten ist jedoch nicht so einfach:
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) {
Hier lesen wir zunächst den Abschnitt selbst mit dem Standard IConfigurationRoot.GetSection. - .
: section.Get YAML- — ( , .. ) , .
:
VirtualFeed: Names: []
VirtualFeed Names , YAML-, , , VirtualFeed . .
IEnumerable- . « » .
ReadArrays(parsed, section); ...
, , IEnumerable «», . : -? — , , -, . , ( ) IConfigurationSection, . - .
ReadSection : FindSection.
[CanBeNull] public object FindSection(Type sectionInterface) { string sectionName = FindSectionName(sectionInterface); if (sectionName == null) { return null; }
, , AppSettings.Default: ( ) FindSection AppSettings, , , , AppSettings.Default, , ( — NULL 0).
:
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)) {
( — ). : , , , - . , - . , , .. , , , .
— , «--»? , , , , — , , . , , . , , .
GetPublicProperties, , , . , , , , , , , , , .
:
— FindSection — . Irgendwie so:
IThreadPoolProperties threadPoolProperties = ConfigurationProvider.FindSection<IThreadPoolProperties>();
— .
IoC- Castle Windsor. . , .
Extension-, , :
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); } }
( AllSections IServerConfigurationProvider).
, ServerConfigurationProvider, ServerConfigurationProvider Windsor.
, , FindSection IServerConfigurationProvider.
Windsor Extension-:
container.RegisterAllConfigurationSections(configProvider);
Fazit
XML YAML, .
YAML-, XML, , .
YAML, . , , . , .
- « ». , YAML- ( -).
— , « ».
, - , -, .. , - - .
AppSettings.Default, , .