6.1 读取配置信息
提及配置,大部分.NET开发人员会想起 app.config和 web.config,多年以来我们已经习惯将结构化的配置定义在这两个XML格式的文件之中。到了.NET Core时代,很多我们习以为常的东西都发生了改变,其中就包括定义配置的方式。总的来说,新的配置系统具有更好的扩展性,其最大的特点就是支持多样化的数据源。既可以采用内存的变量作为配置的数据源,也可以将配置定义在持久化的文件甚至数据库中。在对配置系统进行系统介绍之前,下面先从编程的角度阐述全新的配置读取方式。
6.1.1 配置编程模型三要素
就编程层面来讲,.NET Core的配置系统由图6-1中的3个核心对象构成。读取的配置信息最终会转换成一个 IConfiguration 对象供应用程序使用。IConfigurationBuilder 对象是IConfiguration对象的构建者,IConfigurationSource对象则代表配置数据最原始的来源。
图6-1 配置系统的3个核心对象
在读取配置时,可以根据配置的定义方式(数据源)创建相应的 IConfigurationSource 对象,并将其注册到 IConfigurationBuilder对象上。提供配置的最初来源可能不止一个,我们可以在一个 IConfigurationBuilder 对象上注册多个相同或者不同类型的 IConfigurationSource 对象。IConfigurationBuilder对象正是利用注册的这些 IConfigurationSource对象提供的数据构建程序中使用的IConfiguration对象的。
这里介绍的 IConfiguration 接口、IConfigurationSource 接口和 IConfigurationBuilder 接口以及其他一些基础类型均定义在 NuGet 包“Microsoft.Extensions.Configuration.Abstractions”中。对这些接口的默认实现,则大多定义在NuGet包“Microsoft.Extensions.Configuration”中。
6.1.2 以键值对的形式读取配置
虽然大部分情况下的配置从整体上来说都具有结构化层次关系,但是“原子”配置项都体现为最简单的键值对形式,并且键和值通常都是字符串。下面通过一个简单的实例演示如何以键值对的形式来读取配置。
假设应用程序需要通过配置来设定日期/时间的显示格式,所以可以将相关的配置信息定义在如下所示的 DateTimeFormatOptions类中,它的 4个属性体现了针对 DateTime对象的 4种显示格式(分别为长日期/时间和短日期/时间)。
如果通过配置的形式控制由DateTimeFormatOptions的4个属性所体现的显示格式,就需要定义一个构造函数。如下面的代码片段所示,该构造函数具有一个 IConfiguration接口类型的参数。键值对是配置的基本表现形式,所以 IConfiguration对象提供的索引使我们可以根据配置项的Key得到配置项的值,下面的代码正是以索引的方式得到对应配置信息的。
如果要创建一个体现当前配置的 DateTimeFormatOptions对象,就必须提供这个承载相关配置信息的IConfiguration对象。正如前面提及的,IConfiguration对象是由IConfigurationBuilder对象创建的,而原始的配置信息则是通过相应的 IConfigurationSource 对象提供的,所以创建一个IConfiguration对象的正确编程方式如下:创建一个ConfigurationBuilder(IConfigurationBuilder接口的默认实现类型)对象,并为之注册一个或者多个 IConfigurationSource 对象,最后利用它来创建我们需要的IConfiguration对象。
可以通过如下程序来读取配置并将其转换成一个 DateTimeFormatOptions对象。简单起见,我们采用的 IConfigurationSource实现类型为 MemoryConfigurationSource,它直接利用一个保存在内存中的字典对象作为最初的配置来源。下面的代码片段在为 MemoryConfigurationSource 提供的字典对象中设置了4种类型的日期/时间显示格式。
上面的代码片段创建了一个 ConfigurationBuilder 对象,并在它上面注册了一个根据内存字典创建的 MemoryConfigurationSource对象。下面调用 ConfigurationBuilder对象的 Build方法创建IConfiguration对象,并利用IConfiguration对象创建了DateTimeFormatOptions对象。为了验证该Options对象是否与原始配置一致,我们将它的4个属性打印在控制台上。程序运行之后,控制台上的输出结果如图6-2所示。(S601)
图6-2 以键值对的形式读取配置
6.1.3 读取结构化的配置
真实项目中涉及的配置大都具有结构化的层次结构,所以 IConfiguration对象同样具有这样的结构。由于配置具有一个树形层次结构,所以可以将其称为“配置树”,一个 IConfiguration对象对应这棵配置树的某个节点,而整棵配置树自然可以由根节点对应的 IConfiguration对象来表示。以键值对体现的“原子配置项”对应配置树中不具有子节点的“叶子节点”。
下面以实例来演示如何定义并读取具有层次结构的配置数据。我们依然沿用 6.1.2节的应用场景,但现在不仅需要设置日期/时间的格式,还需要设置其他数据类型的格式,如表示货币的Decimal 类型。因此,我们定义了一个 CurrencyDecimalFormatOptions 类,它的 Digits 属性和Symbol 属性分别表示小数位数与货币符号,CurrencyDecimalFormatOptions 对象依然是利用IConfiguration对象创建的。
我们定义了另一个名为FormatOptions的类型来表示针对不同数据类型的格式设置。如下面的代码片段所示,它的 DateTime属性和 CurrencyDecimal属性分别表示针对日期/时间与货币数字的格式设置。FormatOptions依然具有一个参数类型为IConfiguration的构造函数,它的两个属性均在此构造函数中被初始化。值得注意的是,初始化这两个属性采用的是当前 IConfiguration的“子配置节”,我们可以通过调用 GetSection 方法根据指定的名称(DateTime 和 CurrencyDecimal)获得这两个子配置节。
FormatOptions 类型体现的配置具有图 6-3 所示的树形层次结构。在前面演示的实例中,我们使用 MemoryConfigurationSource 对象来提供原始的配置信息。承载原始配置信息的是一个元素类型为 KeyValuePair<string,string>的集合,但是它在物理存储上并不具有树形层次结构,那么它如何提供一个结构化的IConfiguration对象承载的数据?
图6-3 树形层次结构的配置
解决方案其实很简单,对于一棵完整的配置树,具体的配置信息最终是通过叶子节点来承载的,所以 MemoryConfigurationSource 对象只需要在配置字典中保存叶子节点的数据即可。除此之外,为了描述配置树的结构,配置字典还需要将对应叶子节点在配置树中的路径作为 Key。所以,MemoryConfigurationSource 对象可以采用表 6-1 列举的配置字典对配置树进行扁平化处理,作为Key的路径将冒号(:)作为分隔符。
表6-1 配置的物理结构
下面的代码片段按照表6-1列举的结构创建了一个Dictionary<string,string>对象,并利用它创建了MemoryConfigurationSource对象。在利用ConfigurationBuilder对象得到IConfiguration对象之后,我们调用其 GetSection 方法得到名称为 Format 的配置节,并利用后者创建了一个FormatOptions。
在得到利用读取的配置创建的 FormatOptions对象之后,为了验证该对象与原始配置数据是否一致,我们依然将它的相关属性打印在控制台上。这个程序运行之后在控制台上呈现的输出结果如图6-4所示。(S602)
图6-4 读取结构化的配置
6.1.4 将结构化配置直接绑定为对象
在真正的项目开发过程中,我们倾向于像前面演示的实例一样将一组相关的配置转换成一个 POCO对象,如演示实例中的 DateTimeFormatOptions对象、CurrencyDecimalOptions对象和FormatOptions 对象。在前面的演示实例中,为了创建这些封装配置的对象,我们通常采用手动读取配置的形式。如果定义的配置项太多,逐条读取配置项其实是一项非常烦琐的工作。
如果承载配置数据的IConfiguration对象与对应的POCO类型具有兼容的结构,那么利用配置的自动绑定机制可以将IConfiguration对象直接转换成对应的POCO对象。对于我们演示的实例来说,如果采用自动化配置绑定来创建对应的Options对象,那么这些类型中就不再需要实现手动绑定的构造函数。
在删除所有 Options类型的构造函数之后,再修改 Options对象的创建方式。如下面的代码片段所示,在调用IConfigurationBuilder对象的Build方法创建出对应IConfiguration对象之后,调用GetSection方法可以得到其format配置节,而FormatOptions对象不用再通过调用构造函数来创建,而是直接调用该配置节的 Get<T>方法,该方法完成了从 IConfiguration对象到 POCO对象之间的自动化绑定。修改后的程序运行之后,同样会得到图6-4所示的输出结果。(S603)
6.1.5 将配置定义在文件中
前面演示的 3个实例都是采用 MemoryConfigurationSource将一个字典对象作为配置源,下面演示一种更加常见的配置定义方法,那就是将原始配置的内容定义在一个 JSON 文件中。我们将原本通过一个内存字典对象承载的配置定义在一个 JSON 文件中,为此在项目的根目录下创建一个名为appsettings.json的配置文件,并将该文件的“Copy to Output Directory”属性设置为“Copy always”[1],其目的是促使项目在编译的时候能够将此文件复制到输出目录下。可以采用如下形式定义关于日期/时间和货币的格式配置。
由于配置源发生了改变,原来的 MemoryConfigurationSource 需要替换成 JsonConfiguration Source,但不需要手动创建这个 JsonConfigurationSource 对象,只需要调用 IConfiguration Builder接口的AddJsonFile扩展方法添加指定的JSON文件即可。执行修改后的程序,依然可以得到图6-4所示的输出结果。(S604)
根据环境动态加载配置文件
真实项目开发过程中使用的配置往往取决于应用当前执行的环境,也就是说,不同的执行环境(开发、测试、预发和产品等)会采用不同的配置。如果采用基于物理文件的配置,则可以为不同的环境提供对应的配置文件,具体的做法如下:除了提供一个基础配置文件(如appsettings.json),我们还需要为相应的环境提供对应的差异化配置文件,后者通常采用环境名称作为文件扩展名(如appsettings.production.json)。
以目前演示的程序为例,现有的配置文件 appsettings.json 可以作为基础配置文件,如果某个环境需要采用不同的配置,也可以将差异化的配置定义在对应的文件中。如图 6-5 所示,我们额外添加了两个配置文件(appsettings.staging.json和 appsettings.production.json),从文件命名可以看出这两个配置文件分别对应预发环境和产品环境。
图6-5 针对执行环境的配置文件
我们在 JSON 文件中定义了针对日期/时间和货币格式的配置,假设预发环境和产品环境需要采用不同的货币格式,那么就需要将差异化的配置定义在针对环境的两个配置文件中。简单起见,我们仅仅将货币的小数位数定义在配置文件中。如下面的代码片段所示,货币小数位数(默认值为2)在预发环境和产品环境中分别被设置为3与4。
一般来说,可以采用环境变量来决定应用的执行环境,但为了在演示过程中能够灵活地进行环境切换,可以采用命令行参数(如/env staging)的形式来设置环境。到目前为止,针对某一环境的配置被分布到两个配置文件中,所以在启动文件时就应该根据当前执行环境动态地加载对应的配置文件。如果两个文件涉及同一段配置,就应该首选当前环境对应的那个配置文件。由于配置默认采用“后来居上”的原则,所以应该先加载基础配置文件,再加载针对环境的配置文件。针对执行环境的判断以及针对环境的配置加载体现在如下所示的代码片段中。
如上面的代码片段所示,在利用传入的命令行参数确定了当前执行环境之后,我们先后两次调用 IConfigurationBuilder 对象的 AddJsonFile 方法将两个配置文件加载进来,所以两个文件合并后的内容将用于构建 Build方法创建的 IConfiguration对象。然后以命令行的形式启动这个控制台程序,并通过命令行参数指定相应的环境名称。从图 6-6 所示的输出结果可以看出,打印出的配置数据(货币的小数位数)确实来源于环境对应的配置文件。(S605)
图6-6 输出与当前环境匹配的配置
配置文件的同步
在很多情况下,应用程序的配置只会在启动的时候从相应的配置源中读取,并在整个应用的生命周期中保持不变,一旦需要更新配置,就需要重新启动应用程序。.NET Core 的配置模型提供了针对配置源的监控功能,它能保证一旦原始配置改变之后应用程序能够及时接收到通知,此时我们可以利用预先注册的回调进行配置的同步。
前面演示的应用程序采用 JSON 文件作为配置源,所以我们希望应用程序能够感知该文件的改变,并在文件发生改变的时候自动加载新的配置,然后将其重新应用到程序之中。为了演示配置的同步,我们对程序做了如下改变。
表示JSON文件配置源的JsonConfigurationSource在默认情况下并不会监控源文件的变化,所以在调用 IConfigurationBuilder的 AddJsonFile扩展方法时,需要通过传入的 reloadOnChange参数开启这个功能。通过IConfigurationBuilder的Build方法创建的IConfiguration对象具有一个返回类型为IChangeToken的GetReloadToken方法,我们正是利用它返回的IChangeToken对象来感知配置源的变化的。一旦配置源发生变化,IConfiguration 对象将自动加载新的内容,所以只需要通过注册的回调将同一个IConfiguration对象应用到程序之中即可。
上述程序会在感知到配置源发生变化后自动将新的配置内容打印出来,所以当该程序被启动之后,我们对appsettings.json文件[2]所做的任何修改都会触发应用对该文件的重新加载。图6-7中的输出结果是两次修改货币小数位数导致的。(S606)
图6-7 配置文件更新触发配置的重新加载