6.2 配置模型
6.1 节通过实例演示了几种典型的配置读取方式,下面从设计的角度重写认识配置模型。配置的编程模型涉及 3 个核心对象,分别通过 3 个对应的接口(IConfiguration、IConfiguration Source 和 IConfigurationBuilder)来表示。如果从设计层面审视背后的配置模型,还缺少另一个通过 IConfigurationProvider 接口表示的核心对象。总的来说,配置模型由这 4 个核心对象组成,但是要彻底了解这4个核心对象之间的关系,需要先了解配置的几种数据结构。
6.2.1 数据结构及其转换
相同的数据具有不同的表现形式和承载方式,同时体现出不同的数据结构。对于配置来说,它在被应用程序消费的过程中是以 IConfiguration对象的形式来体现的,该对象在逻辑上具有一个树形层次结构,所以将其称为配置树,并将这棵树视为配置的逻辑结构。
配置具有多种原始来源,可以是内存对象、物理文件、数据库或者其他自定义的存储介质。如果采用物理文件来存储配置数据,还可以选择不同的文件格式,常见的文件类型包括 XML、JSON和INI这3种,所以配置的原始数据结构是多种多样的。配置模型的最终目的在于提取原始的配置数据并将其转换成一个 IConfiguration对象。换句话说,配置模型就是为了按照图 6-8所示的方式将配置数据从原始结构转变成逻辑结构。
图6-8 配置由原始结构向逻辑结构的转变
配置从原始结构向逻辑结构的转变不是一蹴而就的,在它们之间有一种中间结构。原始的配置数据被读取出来之后先统一转换成这种中间结构的数据,这种中间结构究竟是一种什么样的数据结构?
6.1 节提及,一棵配置树通过其叶子节点承载所有的原子配置项,这棵树的结构和承载的数据完全可以利用一个简单的数据字典来表达。具体来说,我们只需要将所有叶子节点在配置树中的路径作为Key,将叶子节点承载的配置数据作为Value即可。所谓的中间结构指的就是这样的数据字典,可以将其称为配置字典。所以,配置模型会按照图 6-9 所示的方式将具有不同原始结构的配置数据统一转换成配置字典,然后完成针对逻辑结构的转换。
图6-9 配置“三态”转换
对于配置模型的 4个核心对象来说,IConfiguration对象是对配置树的描述,其他 3个核心对象(IConfigurationSource、IConfigurationBuilder和 IConfigurationProvider)在配置的结构转换过程中扮演不同的角色,至于它们究竟具有什么样的作用,下面将分别进行介绍。
6.2.2 IConfiguration
配置在应用程序中总是以一个 IConfiguration 对象的形式供我们使用。一个 IConfiguration对象具有树形层次结构并不是说对应的类型具有对应的数据成员定义,而是说它提供的 API 在逻辑上体现出树形层次结构,所以说配置树是一种逻辑结构。如下所示的代码片段是IConfiguration 接口的完整定义,所谓的层次化逻辑结构就体现在它的 GetChildren 方法和GetSection方法上。
一个 IConfiguration对象表示配置树的某个配置节点。对于组成整棵树的所有配置节点来说,表示根节点的 IConfiguration 对象与表示其他配置节点的 IConfiguration 对象是不同的,所以配置模型采用不同的接口来表示它们。根节点所在的 IConfiguration 对象体现为一个IConfigurationRoot 对象,其他节点对象则用一个 IConfigurationSection 对象来表示,IConfigurationRoot接口和IConfigurationSection接口继承自IConfiguration接口。图6-10展示了由一个IConfigurationRoot对象和一组IConfigurationSection对象组成的配置树。
图6-10 由一个IConfigurationRoot对象和一组IConfigurationSection对象组成的配置树
如下所示的代码片段是IConfigurationRoot接口的定义,它具有的唯一的方法Reload实现对配置数据的重新加载。IConfigurationRoot 对象表示配置树的根,所以也代表了整棵配置树,如果它被重新加载,则意味着整棵配置树承载的所有配置数据均被重新加载。
表示非根配置节点的IConfigurationSection接口具有如下3个属性:只读属性Key用来唯一标识多个具有相同父节点的 ConfigurationSection对象;而 Path则表示当前配置节点在配置树中的路径,由组成当前路径的所有IConfigurationSection对象的Key构成,Key之间采用冒号(:)作为分隔符。Path和Key的组合体现了对应配置节在整棵配置树中的位置。
IConfigurationSection 接口的 Value 属性表示配置节点承载的数据。在大部分情况下,只有配置树的叶子节点对应的 IConfigurationSection 对象才包含具体的值,非叶子节点对应的IConfigurationSection 对象实际上仅仅表示存放所有子配置节的逻辑容器,它们的 Value 属性一般返回 Null。值得注意的是,这个 Value 属性并不是只读的,而是可读可写的,但写入的值一般不会被持久化,一旦配置树被重新加载,该值将丢失。
在对 IConfigurationRoot 接口和 IConfigurationSection 接口具有基本了解之后,下面介绍定义在 IConfiguration接口中的成员。它的 GetChildren方法返回的 IConfigurationSection集合表示它的所有子配置节,而 GetSection 方法则根据指定的 Key 得到一个具体的子配置节。当执行GetSection方法时,指定的参数会与当前 IConfigurationSection的 Path进行组合,从而确定目标配置节点所在的路径,如果在调用该方法的时候指定一个当前配置节的相对路径,就可以得到子节点以下的任何一个配置节。
如上面的代码片段所示,我们以不同的方式调用 GetSection方法得到的都是路径为“A:B:C”的 IConfigurationSection 对象。上面这段代码还体现了另一个有趣的现象:虽然这 3 个IConfigurationSection 对象均指向配置树的同一个节点,但是它们却并非同一个对象。换句话说,调用 GetSection 方法时,不论配置树中是否存在一个与指定路径相匹配的配置节,它总是会创建新的IConfigurationSection对象。
IConfiguration还具有一个索引,我们可以指定子配置节的 Key或者相对当前配置节点的路径得到对应 IConfigurationSection的值。当执行这个索引的时候,它会按照与 GetSection方法完全一致的逻辑得到一个 IConfigurationSection对象,并返回其 Value属性。如果配置树中不具有匹配的配置节,该索引会返回Null而不会抛出异常。
6.2.3 IConfigurationProvider
在 6.1 节介绍 IConfigurationSource 对象的时候,我们说它是对原始配置源的体现。虽然每种不同类型的配置源都有一个对应的 IConfigurationSource 实现,但是针对原始数据的读取并不是由它完成的,而是委托一个与之对应的 IConfigurationProvider 对象来实现。在前面介绍的配置结构转换过程中,针对不同配置源类型的 IConfigurationProvider对象可以按照图 6-11所示的方式实现配置数据从原始结构向物理结构的转换。
图6-11 原始配置数据通过IConfigurationProvider转换成配置字典
由于 IConfigurationProvider 对象的目的在于将配置数据从原始结构转换成配置字典,所以定义在IConfigurationProvider接口中的方法大都体现为针对字典对象的操作。
配置数据的加载通过调用IConfigurationProvider的Load方法来完成。我们可以调用TryGet方法获取由指定的 Key 所标识的配置项的值。从数据持久化的角度来讲,IConfiguration Provider基本上是一个只读的对象,也就是说,它只负责从持久化资源中读取配置数据,而不负责持久化更新后的配置数据,所以它提供的 Set 方法设置的配置数据一般只是保存在内存中,但是在实现该方法时对提供的值进行持久化也是可以的。
IConfigurationProvider 的 GetChildKeys 方法用于获取某个指定配置节点(对应参数parentPath)的所有子节点的 Key。当 IConfiguration的 GetChildren方法被调用时,注册的所有IConfigurationSource对应的 IConfigurationProvider的 GetChildKeys方法会被调用。这个方法的第一个参数 earlierKeys 代表的 Key 来源于其他 IConfigurationProvider 对象,当解析出当前IConfigurationProvider 提供的 Key 后,该方法需要将它们合并到 earlierKeys 集合中,合并后的结果将作为方法的返回值。值得注意的是,返回的Key的集合是经过排序的。
每种类型的配置源都对应一个 IConfigurationProvider 接口的实现类型,但它们一般不会直接实现 IConfigurationProvider接口,而是选择继承另一个名为 ConfigurationProvider的抽象类。这个抽象类的定义其实很简单,从如下代码片段可以看出,ConfigurationProvider 仅仅是对一个IDictionary<string,string>对象(Key不区分大小写)的封装,其 Set方法和 TryGetValue方法最终操作的都是这个字典对象。
抽象类 ConfigurationProvider实现了 Load方法并将其定义成虚方法,这个方法并没有提供具体的实现,所以它的派生类可以通过重写这个方法从相应的数据源中读取配置数据,并通过对Data属性的赋值完成对配置数据的加载。
6.2.4 IConfigurationSource
IConfigurationSource对象在配置模型中代表配置源,被注册到 IConfigurationBuilder对象上,为由它创建的 IConfiguration对象提供原始的配置数据。由于针对原始配置数据的读取在相应的IConfigurationProvider 对象中实现,所以 IConfigurationSource 对象的作用就在于提供相应的IConfigurationProvider对象。如下面的代码片段所示,IConfigurationSource接口具有一个唯一的Build方法,根据指定的IConfigurationBuilder对象提供对应的IConfigurationProvider对象。
6.2.5 IConfigurationBuilder
IConfigurationBuilder 对象在整个配置模型中处于核心地位,代表原始配置源的IConfigurationSource 对象就注册在它上面。IConfigurationBuilder 对象会利用注册的IConfigurationSource对象提供的原始数据创建供应用程序使用的 IConfiguration对象。如下面的代码片段所示,IConfigurationBuilder 接口定义了两个方法:Add 方法用于注册 IConfiguration Source对象;最终的 IConfiguration对象则由 Build方法创建,该方法返回一个代表整棵配置树的 IConfigurationRoot对象。注册的 IConfigurationSource对象被保存在通过 Sources属性表示的集合中,而Properties属性则以字典的形式存放任意的自定义属性。
配置系统提供了一个名为 ConfigurationBuilder 的类作为 IConfigurationBuilder 接口的默认实现。定义在它上面的 Build 方法体现了配置系统读取原始配置数据并生成配置树的默认机制,这是下面要重点讲述的内容。ConfigurationBuilder 类型的 Build 方法返回一个类型为 Configuration Root的对象,由它表示的配置树的每个非根配置节点均是一个类型为ConfigurationSection的对象。
本节主要从设计和实现原理的角度对配置模型进行详细介绍。总的来说,配置模型涉及 4个核心对象,包括承载配置逻辑结构的 IConfiguration 对象和它的创建者 IConfiguration Builder对象,以及与配置源相关的 IConfigurationSource对象和 IConfigurationProvider对象。这4 个核心对象之间的关系简单而清晰,完全可以通过一句话进行概括:IConfigurationBuilder 对象利用注册在它上面的所有 IConfigurationSource对象提供的 IConfigurationProvider对象来读取原始配置数据并创建出相应的IConfiguration对象。图6-12中的UML展示了配置模型涉及的主要接口/类型以及它们之间的关系。
图6-12 配置模型涉及的主要接口/类型以及它们之间的关系