背景知识
除其他事项外,我们公司已开发了几种服务(更确切地说是12种),可作为我们系统的后端。 每个服务都是Windows服务,并执行其特定任务。
我想将所有这些服务转移到* nix-OS。 为此,请放弃Windows服务形式的包装,然后从.NET Framework切换到.NET Standard。
最后一个要求导致需要摆脱一些旧版代码,.NET Standard中不支持该旧版代码,包括 支持通过XML配置我们的服务器,使用System.Configuration中的类实现。 同时,这解决了一个长期存在的问题,该问题涉及以下事实:在XML-configs中,更改设置时有时会犯错误(例如,有时我们将结束标记放在错误的位置或完全忘记了它),但却是System.Xml XML-configs的绝佳阅读者。 XmlDocument默默地吞下了此类配置,从而产生了完全无法预测的结果。
决定通过流行的YAML切换到配置。 在本文中,我们面临哪些问题以及如何解决这些问题。
我们有什么
我们如何从XML读取配置
对于大多数其他项目,我们以标准方式读取XML。
每个服务都有一个.NET项目的设置文件,称为AppSettings.cs,其中包含该服务所需的所有设置。 像这样:
[System.Configuration.SettingsProvider(typeof(PortableSettingsProvider))] internal sealed partial class AppSettings : IServerManagerConfigStorage, IWebSettingsStorage, IServerSettingsStorage, IGraphiteAddressStorage, IDatabaseConfigStorage, IBlackListStorage, IKeyCloackConfigFilePathProvider, IPrometheusSettingsStorage, IMetricsConfig { }
将设置分为界面的类似技术可以方便以后通过DI容器使用它们。
实际上,存储设置的所有主要魔术都隐藏在PortableSettingsProvider中(请参阅class属性)以及设计器文件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; } } ...
如您所见,“幕后”隐藏了所有通过Visual Studio中的设置设计器编辑服务器配置时添加到服务器配置的属性。
上面提到的我们的PortableSettingsProvider类直接读取XML文件,并且读取结果已在SettingsProvider中用于将设置写入AppSettings属性。
我们正在阅读的XML配置示例(出于安全原因,大多数设置都被隐藏了):
<?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>
我想阅读哪些YAML文件
像这样:
VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True
过渡问题
首先, XML中
的配置是“扁平的”,但在YAML中则不是(支持节和小节)。 在上面的示例中可以清楚地看到这一点。 使用XML,我们通过引入自己的解析器解决了平面设置的问题,该解析器可以将某种类型的字符串转换为更复杂的类。 这样的复杂字符串的示例:
<setting name="MCXSettings" serializeAs="String"> <value>Inactive, ChartLen: 1000, PrintLen: 50, UseProxy: False</value> </setting>
使用YAML时,我不希望进行此类转换。 但是同时,我们受到AppSettings类现有的“扁平”结构的限制:该类中设置的所有属性都堆积在一个堆中。
其次,我们的服务器配置不是静态的整体配置,我们会在服务器工作期间(即, 这些更改需要能够在运行时动态捕捉。 为此,在XML实现中,我们从INotifyPropertyChanged继承了AppSettings(实际上,实现AppSettings的每个接口都继承自它),并订阅设置属性事件的更新。 这种方法之所以有效,是因为现成的基类System.Configuration.ApplicationSettingsBase实现了INotifyPropertyChanged。 过渡到YAML后,必须保持类似的行为。
第三,实际上每个服务器没有一个配置文件,但是有两个配置文件:一个具有默认设置,另一个具有被覆盖的设置。 这是必需的,以便在相同类型的服务器的多个实例中的每个实例中,侦听不同的端口并且设置稍有不同,您不必完全复制整个设置。
还有一个问题 -不仅可以通过界面访问设置,还可以通过直接访问AppSettings.Default来访问设置。 让我提醒您如何在后台AppSettings.Designer.cs中声明它:
private static AppSettings defaultInstance = ((AppSettings)(global::System.Configuration.ApplicationSettingsBase.Synchronized(new AppSettings()))); public static AppSettings Default { get { return defaultInstance; } }
基于上述内容,有必要提出一种在AppSettings中存储设置的新方法。
解决方案
工具包
为了直接阅读,YAML决定使用通过NuGet提供的现成库:
在此处阅读有关如何使用这些库的更多信息。
过渡到YAML
过渡本身分两个阶段进行:首先,我们简单地从XML切换到YAML,但是保留了配置文件的统一层次结构,然后在YAML文件中输入了部分。 从原则上讲,这些阶段可以合并为一个阶段,为了简化演示,我将这样做。 下文所述的所有操作均依次应用于每个服务。
准备一个YML文件
首先,您需要准备YAML文件本身。 我们称其为项目的名称(用于将来的集成测试,该名称应该能够与不同的服务器一起使用,并区分它们之间的配置),将文件直接放在项目的根目录中,位于AppSettings旁边:

在YML文件中,对于初学者来说,我们保存一个“扁平”结构:
VirtualFeed: "MaxChartHistoryLength: 10, UseThrottling: True, ThrottlingIntervalMs: 50000, UseHistoryBroadcast: True, CalendarName: EmptyCalendar" VirtualFeedPort: 35016 UsMarketFeedUseImbalances: True
使用设置属性填充AppSettings
我们将所有属性从AppSettings.Designer.cs转移到AppSettings.cs,同时摆脱了设计器的多余属性以及get / set-parts中的代码本身。
那是:
[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; } }
它变成了:
public int VirtualFeedPort { get; set; }
如有必要,我们将完全删除AppSettings
.Designer .cs。 现在,顺便说一下,如果项目中存在app.config文件,则可以完全摆脱它的userSettings部分-相同的默认设置存储在该位置,我们通过设置设计器指定该默认设置。
来吧
即时控制设定
由于我们需要能够在运行时捕获设置的更新,因此我们需要在AppSettings中实现INotifyPropertyChanged。 基本的System.Configuration.ApplicationSettingsBase不再存在,您不能指望任何魔术。
您可以“在额头上”实现它:通过添加抛出所需事件的方法的实现,并在每个属性的设置器中对其进行调用。 但是这些是多余的代码行,此外,还需要在所有服务之间复制这些代码行。
让我们做得更漂亮-引入辅助基类AutoNotifier,该类实际上做同样的事情,但是在后台,就像System.Configuration.ApplicationSettingsBase之前所做的那样:
在这里,[CallerMemberName]属性使您可以自动获取调用对象的属性名称,即 AppSettings
现在,我们可以从该基类AutoNotifier继承我们的AppSettings,然后对每个属性稍加修改:
public int VirtualFeedPort { get { return Get<int>(); } set { Set(value); } }
通过这种方法,我们的AppSettings类(甚至包含很多设置)看起来很紧凑,同时完全实现了INotifyPropertyChanged。
是的,我知道可以使用例如Castle.DynamicProxy.IInterceptor引入更多的魔术,拦截必要属性中的更改并在那里引发事件。 但是在我看来,这样的决定太过分了。
从YAML文件读取设置
下一步是添加YAML配置本身的读取器。 这发生在更接近服务开始的地方。 隐藏与正在讨论的主题无关的不必要的细节,我们得到类似的结果:
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))
在给出的代码中,ConfigurationBuilder可能不是特别重要-使用它的所有工作都类似于使用ASP.NET Core中的配置。 但是以下几点很有趣。 首先,“开箱即用”,我们也有机会组合来自多个文件的设置。 如上所述,这要求每个服务器至少有两个配置文件。 其次,我们将所有读取的配置传递给某个ServerConfigurationProvider。 怎么了
YAML文件中的部分
我们稍后将回答这个问题,现在回到将分层结构的设置存储在YML文件中的要求。
原则上,执行此操作非常简单。 首先,在YML文件中,我们介绍所需的结构:
VirtualFeed: MaxChartHistoryLength: 10 Port: 35016 UseThrottling: True ThrottlingIntervalMs: 50000 UseHistoryBroadcast: True CalendarName: "EmptyCalendar" UsMarketFeed: UseImbalances: True
现在,我们去AppSettings并教他如何将我们的属性划分为多个部分。 像这样:
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"} }; ...
如您所见,我们直接在AppSettings中添加了一个字典,其中的键是AppSettings类实现的接口的类型,而值是相应部分的标题。 现在,我们可以将YML文件中的层次结构与AppSettings中的属性层次结构进行比较(尽管不深于一层嵌套,但是在我们的例子中,这就足够了)。
为什么我们要在AppSettings中执行此操作? 因为以这种方式我们不会散布有关不同实体设置的信息,而且这是最自然的地方,因为 在每个服务中,并因此在每个AppSettings中,都有其自己的设置部分。
如果在设置中不需要层次结构?
原则上,这是一个奇怪的情况,但是我们恰好在第一阶段就拥有了它,当时我们只是简单地从XML切换到YAML,而没有利用YAML的优势。
在这种情况下,部分的整个列表无法存储,ServerConfigurationProvider将更加简单(稍后讨论)。
但是重要的一点是,如果我们决定保留平坦的层次结构,那么我们就可以满足保持通过AppSettings.Default访问设置的能力的要求。 为此,请在AppSettings中添加这样一个简单的公共构造函数:
public static AppSettings Default { get; } public AppSettings() { Default = this; }
现在我们可以继续通过AppSettings来访问带有设置的类。在任何地方都可以使用Default(前提是已经通过ServerConfigurationProvider中的IConfigurationRoot读取了设置,并且已经实例化了AppSettings)。
如果不能接受一个单一的层次结构,那么无论如何,您都必须摆脱AppSettings。通过代码在每个地方都设置默认值,并且只能通过接口使用设置(这在原则上是很好的)。 为什么会这样-这将变得更加清晰。
ServerConfigurationProvider
前面提到的特殊ServerConfigurationProvider类处理非常神奇的事情,它使您可以仅使用平坦的AppSettings来完全使用新的分层YAML配置。
如果您迫不及待,就在这里。
完整的ServerConfigurationProvider代码 ServerConfigurationProvider由AppSettings设置类参数化:
public class ServerConfigurationProvider<TAppSettings> : IServerConfigurationProvider where TAppSettings : new()
您可能会猜到,这使您可以在所有服务中立即使用它。
读取的配置本身(IConfigurationRoot)以及上面提到的部分字典(AppSettings.Sections)被传递给构造函数。 订阅了文件更新(如果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); } } }
如您所见,在这里,如果要更新YML文件,我们将遍历所有已知的部分并逐一阅读。 然后,如果该部分早已在缓存中被读取(即某个类已经在代码中的某处请求了该部分),则我们用新的值重写缓存中的旧值。
似乎-为什么要读取每个部分,为什么不只读取高速缓存中的那些部分(即要求的部分)? 因为在阅读本节中,我们已经实现了对正确配置的检查。 如果设置不正确,则会抛出相应的警报,并记录问题。 最好尽快了解配置更改中的问题,然后从中立即阅读所有部分。
用新值更新缓存中的旧值就很简单了:
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)); } }
但是阅读章节并不是那么简单:
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) {
在这里,我们首先使用标准IConfigurationRoot.GetSection阅读本节本身。
然后,只需检查读取部分的正确性即可。接下来,我们将bindim节读为设置类型: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。以任何方式默认:通过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)) {
在这里,首先,提取我们感兴趣的接口的所有公共属性(读取-设置部分)。对于这些属性中的每一个,在读取设置中都会找到一个匹配项:如果找不到匹配项,则会记录相应的问题,因为这意味着config文件中缺少某些配置。最后,还要检查是否有任何读取设置与接口不匹配。如果有,则再次记录该问题,因为这意味着在配置文件中找到了接口中未描述的属性,这在正常情况下也不应该出现。出现了问题-要求从哪里来的,读文件中的所有设置都应该一对一地对应于界面中可用的设置?事实是,实际上,如上所述,此时没有读取一个文件,而是一次读取了两个文件-一个具有默认设置,另一个具有被覆盖的文件,并且两个文件都是连续的。因此,实际上,我们不是从一个文件中查看设置,而是从完整文件中查看。当然,在这种情况下,它们的设置应一对一对应于预期的设置。在上述资源中还应注意GetPublicProperties方法,该方法似乎只返回接口的所有公共属性。但这并不是那么简单,因为有时我们有一个接口描述从另一个接口继承的服务器设置,因此,有必要查看接口的整个层次结构以查找所有公共属性。获取服务器设置
基于上述内容,为了通过代码获取各地的服务器设置,我们转到以下界面:
该界面的第一种方法-FindSection-使您可以访问感兴趣的设置部分。 像这样:
IThreadPoolProperties threadPoolProperties = ConfigurationProvider.FindSection<IThreadPoolProperties>();
为什么需要第二种和第三种方法-我将进一步解释。设置界面注册
在我们的项目中,温莎城堡被用作IoC容器。是由他提供的,包括服务器设置界面。因此,这些接口必须在其中注册。为此,编写了一个简单的Extension-class,它简化了此过程,以免在每个服务器中写入整个接口集的注册: 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); } }
第一种方法允许您注册设置的所有部分(为此,需要IServerConfigurationProvider界面中的AllSections属性)。第一种方法中使用了第二种方法,它使用我们的ServerConfigurationProvider自动读取指定的设置部分,从而将其立即写入ServerConfigurationProvider缓存并在Windsor中注册。在这里,使用了IServerConfigurationProvider中的第二个非参数化FindSection方法。剩下的就是在Windsor容器注册代码中调用我们的Extension方法: container.RegisterAllConfigurationSections(configProvider);
结论
发生什么事了
以这种方式,可以很轻松地将我们服务器的所有设置从XML转移到YAML,同时对现有服务器代码进行最少的更改。与XML不同,YAML配置不仅具有更高的简洁性,而且还支持分区,因此更具可读性。我们并不是发明自己的自行车来解析YAML,而是使用了现成的解决方案。但是,要将它们集成到我们项目的现实中,需要本文中介绍的一些技巧。我希望它们对读者有用。可以保留即时捕获我们服务器的网络枪口中的设置更改的能力。此外,作为奖励,还可以实时捕获YAML文件本身中的更改(以前,必须重新启动服务器以获取配置文件中的任何更改)。我们保留了合并两个配置文件(默认设置和覆盖设置)的功能,并且我们使用了第三方解决方案来实现。效果不佳
我不得不放弃以前可用的功能,将从服务器Web界面应用的更改保存到配置文件,因为 对此类功能的支持需要做出很大的姿态,而摆在我们面前的业务任务通常并非如此。嗯,我还不得不拒绝通过AppSettings.Default来访问设置,但这是一个加号,而不是减号。